feat(core,call): implement ADR-050 dynamic resource ownership — ownership store, resource_id_path, check signature, dispatch wiring

Implements the 4-task DAG for runtime-spawned resource ownership so
AccessControl::check can answer "does this identity own this specific
container/TTY/process" instead of relying only on static
Identity.resources grants.

1. core/ownership-store-trait: OwnershipProvider (sync read) +
   OwnershipStore (async write) traits + InMemoryOwnershipStore +
   OwnershipError in alknet-core. Fourth instance of the repo/adapter
   pattern (ADR-033), mirroring CredentialStore/store.rs.

2. call/registry/operation-spec-resource-id-path: resource_id_path
   field on OperationSpec — JSON pointer into input for resource ID
   extraction. Single field addition, all ~40 construction sites across
   alknet-call + alknet-http updated to pass None (no semantic change).

3. call/registry/access-control-ownership-check: check() signature
   gains resource_id + Option<&dyn OwnershipProvider>. 3-case decision
   tree: ownership Some + resource_id Some -> owns(); ownership Some +
   resource_id None -> owns_any() (list scope-gate); ownership None ->
   static Identity.resources fallback (backward compat). 7 call sites
   updated to (None, None) — including a 7th in alknet-http/gateway_routes
   not listed in the original spec.

4. call/registry/dispatch-resource-id-extraction: wire the dispatch
   path. OperationContext gains ownership field; Dispatcher/CallAdapter
   gain with_ownership_provider builders; invoke/invoke_streaming/
   invoke_with_policy extract resource_id via spec.resource_id_path
   and thread context.ownership to check(). extract_json_pointer helper
   handles $.field syntax (graceful None on missing/non-string). 20
   OperationContext literals updated across both crates.

Backward compatibility is load-bearing throughout: ownership=None
falls back to the existing static resource-check path. Deployments
without runtime-spawned resources wire nothing and behave identically.

821 tests pass workspace-wide (was ~770); clippy clean; fmt clean.
Task specs marked done.
This commit is contained in:
glm-5.2 committed 2026-07-05 12:57:33 +00:00
1 parent de536b82e1
commit 73724b8a1d
28 files changed
+1000 -29

No files matched your search

@@ -14,6 +14,7 @@ use std::sync::Arc;
use std::time::Duration;
use alknet_core::auth::{AuthContext, IdentityProvider};
use alknet_core::ownership::OwnershipProvider;
use alknet_core::types::{Connection, HandlerError, ProtocolHandler};
use async_trait::async_trait;
@@ -63,6 +64,11 @@ impl CallAdapter {
self
}
pub fn with_ownership_provider(mut self, provider: Arc<dyn OwnershipProvider>) -> Self {
self.dispatcher = self.dispatcher.with_ownership_provider(provider);
self
}
pub fn registry(&self) -> &Arc<OperationRegistry> {
&self.dispatcher.registry
}
@@ -224,6 +230,7 @@ mod tests {
serde_json::json!({}),
vec![],
acl,
None,
)
}
@@ -237,6 +244,7 @@ mod tests {
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
)
}
@@ -257,6 +265,7 @@ mod tests {
serde_json::json!({}),
vec![],
acl,
None,
),
HandlerKind::Once(handler),
OperationProvenance::Local,
@@ -548,6 +557,7 @@ mod tests {
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
),
HandlerKind::Once(echo_handler()),
OperationProvenance::FromCall,
@@ -583,6 +593,7 @@ mod tests {
abort_policy: AbortPolicy::default(),
deadline: context.deadline,
internal: false,
ownership: None,
};
let response = context
.env
@@ -615,6 +626,7 @@ mod tests {
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
),
HandlerKind::Once(echo_handler()),
OperationProvenance::FromCall,
@@ -641,6 +653,7 @@ mod tests {
abort_policy: AbortPolicy::default(),
deadline: context.deadline,
internal: false,
ownership: None,
};
let response = context
.env
+14 -1
View File
@@ -326,6 +326,7 @@ impl OperationEnv for OverlayOperationEnv {
let composition_authority;
let scoped_env;
let access_control;
let resource_id_path;
{
let overlay = self.overlay.read();
let Some(registration) = overlay.get(&name) else {
@@ -338,6 +339,7 @@ impl OperationEnv for OverlayOperationEnv {
.clone()
.unwrap_or_else(ScopedPeerEnv::empty);
access_control = registration.spec.access_control.clone();
resource_id_path = registration.spec.resource_id_path.clone();
}
let caller_identity = if parent.internal {
@@ -348,7 +350,14 @@ impl OperationEnv for OverlayOperationEnv {
} else {
parent.identity.clone()
};
if let AccessResult::Forbidden(message) = access_control.check(caller_identity.as_ref()) {
let resource_id = resource_id_path
.as_ref()
.and_then(|path| crate::registry::registration::extract_json_pointer(&input, path));
if let AccessResult::Forbidden(message) = access_control.check(
caller_identity.as_ref(),
resource_id.as_deref(),
parent.ownership.as_deref(),
) {
return ResponseEnvelope::forbidden(parent.request_id.clone(), message);
}
@@ -368,6 +377,7 @@ impl OperationEnv for OverlayOperationEnv {
scoped_env,
env: parent.env.clone(),
internal: true,
ownership: parent.ownership.clone(),
};
match handler {
@@ -487,6 +497,7 @@ mod tests {
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
)
}
@@ -525,6 +536,7 @@ mod tests {
abort_policy: AbortPolicy::default(),
deadline: Some(Instant::now() + Duration::from_secs(30)),
internal: true,
ownership: None,
}
}
@@ -1002,6 +1014,7 @@ mod tests {
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
),
HandlerKind::Stream(streaming_handler),
OperationProvenance::FromCall,
@@ -16,6 +16,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
use alknet_core::ownership::OwnershipProvider;
use alknet_core::types::StreamError;
use futures::stream::StreamExt;
use serde_json::Value;
@@ -70,6 +71,7 @@ pub struct Dispatcher {
pub registry: Arc<OperationRegistry>,
pub identity_provider: Arc<dyn IdentityProvider>,
pub session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
pub ownership_provider: Option<Arc<dyn OwnershipProvider>>,
pub default_timeout: Duration,
}
@@ -82,6 +84,7 @@ impl Dispatcher {
registry,
identity_provider,
session_source: None,
ownership_provider: None,
default_timeout: DEFAULT_TIMEOUT,
}
}
@@ -99,6 +102,11 @@ impl Dispatcher {
self
}
pub fn with_ownership_provider(mut self, provider: Arc<dyn OwnershipProvider>) -> Self {
self.ownership_provider = Some(provider);
self
}
fn strip_leading_slash(operation_id: &str) -> &str {
operation_id.strip_prefix('/').unwrap_or(operation_id)
}
@@ -182,6 +190,7 @@ impl Dispatcher {
env: stub_env,
abort_policy: AbortPolicy::default(),
internal: false,
ownership: self.ownership_provider.clone(),
};
context.env = self.compose_root_env(connection, &context);
context
@@ -432,6 +441,7 @@ impl Clone for Dispatcher {
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
session_source: self.session_source.clone(),
ownership_provider: self.ownership_provider.clone(),
default_timeout: self.default_timeout,
}
}
@@ -524,6 +534,7 @@ mod tests {
serde_json::json!({}),
vec![],
acl,
None,
)
}
@@ -539,6 +550,7 @@ mod tests {
serde_json::json!({}),
vec![],
acl,
None,
),
HandlerKind::Once(make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
@@ -1001,6 +1013,7 @@ mod tests {
serde_json::json!({}),
vec![],
acl,
None,
)
}