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:
2026-07-05 12:57:33 +00:00
parent de536b82e1
commit 73724b8a1d
28 changed files with 1000 additions and 29 deletions

View File

@@ -180,6 +180,7 @@ pub(crate) fn build_spec(tool: &Tool, namespace: &str) -> OperationSpec {
output_schema,
error_schemas,
AccessControl::default(),
None,
)
}

View File

@@ -427,6 +427,7 @@ impl FromOpenAPI {
output_schema,
error_schemas,
AccessControl::default(),
None,
);
let path_template = path.to_string();
@@ -967,6 +968,7 @@ mod tests {
abort_policy: alknet_call::registry::context::AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
internal: true,
ownership: None,
}
}

View File

@@ -488,6 +488,7 @@ mod tests {
serde_json::json!({}),
vec![],
acl,
None,
)
}

View File

@@ -560,6 +560,7 @@ mod tests {
json!({}),
errors,
AccessControl::default(),
None,
)
}
@@ -1015,6 +1016,7 @@ mod tests {
json!({}),
vec![error("INTERNAL_ERROR", Some(418))],
AccessControl::default(),
None,
),
HandlerKind::Once(noop_handler()),
OperationProvenance::Local,

View File

@@ -135,6 +135,7 @@ impl GatewayDispatch {
env,
abort_policy: AbortPolicy::default(),
internal: false,
ownership: None,
}
}
}
@@ -204,6 +205,7 @@ mod tests {
serde_json::json!({}),
vec![],
acl,
None,
)
}
@@ -216,6 +218,7 @@ mod tests {
serde_json::json!({}),
vec![],
acl,
None,
)
}
@@ -231,6 +234,7 @@ mod tests {
serde_json::json!({}),
vec![],
acl,
None,
),
HandlerKind::Once(make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
@@ -278,6 +282,7 @@ mod tests {
serde_json::json!({}),
vec![],
acl,
None,
)
}

View File

@@ -261,7 +261,7 @@ fn access_check_for_op(
) -> Option<String> {
let name = operation.strip_prefix('/').unwrap_or(operation);
let reg = registry.registration(name)?;
if let AccessResult::Forbidden(message) = reg.spec.access_control.check(identity) {
if let AccessResult::Forbidden(message) = reg.spec.access_control.check(identity, None, None) {
return Some(message);
}
None
@@ -349,6 +349,7 @@ mod tests {
json!({}),
vec![],
acl,
None,
)
}
@@ -361,6 +362,7 @@ mod tests {
json!({}),
vec![],
AccessControl::default(),
None,
)
}
@@ -428,6 +430,7 @@ mod tests {
json!({}),
vec![],
acl,
None,
)
}

View File

@@ -72,6 +72,7 @@ mod tests {
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
)
}

View File

@@ -91,6 +91,7 @@ mod tests {
serde_json::json!({}),
vec![],
acl,
None,
)
}
@@ -103,6 +104,7 @@ mod tests {
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
)
}
@@ -170,6 +172,7 @@ mod tests {
abort_policy: AbortPolicy::default(),
deadline: Some(Instant::now() + Duration::from_secs(30)),
internal: true,
ownership: None,
}
}

View File

@@ -313,6 +313,7 @@ mod tests {
serde_json::json!({}),
vec![],
acl,
None,
)
}
@@ -325,6 +326,7 @@ mod tests {
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
)
}
@@ -568,6 +570,7 @@ mod tests {
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
@@ -928,6 +931,7 @@ mod tests {
abort_policy: AbortPolicy::default(),
deadline: Some(Instant::now() + Duration::from_secs(30)),
internal: false,
ownership: None,
}
}
@@ -948,6 +952,7 @@ mod tests {
abort_policy: AbortPolicy::default(),
deadline: Some(Instant::now() + Duration::from_secs(30)),
internal: true,
ownership: None,
}
}

View File

@@ -62,6 +62,7 @@ fn test_context(request_id: &str, caps: Capabilities) -> OperationContext {
abort_policy: AbortPolicy::default(),
deadline: Some(Instant::now() + Duration::from_secs(30)),
internal: true,
ownership: None,
}
}