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.
8.7 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | ||
|---|---|---|---|---|---|---|---|---|---|
| call/registry/dispatch-resource-id-extraction | Wire dispatch path to extract resource_id from input and thread OwnershipProvider to AccessControl::check (ADR-050 §2a, §4a) | done |
|
moderate | medium | component | implementation |
Description
Wire the dispatch path to: (a) extract resource_id from the operation
input using spec.resource_id_path, and (b) thread an OwnershipProvider
to AccessControl::check. This is the task that makes the dynamic
ownership model actually work end-to-end. Per ADR-050 §2a and §4a.
Where the wiring happens
The dispatch flow is:
dispatch() // protocol/dispatch.rs:220
→ extract input from payload // line 238
→ build_root_context(...) // line 246
→ registry.invoke(name, input, context) // line 261
→ look up registration // registration.rs:123
→ check visibility // registration.rs:128
→ check ACL: acl.check(...) // registration.rs:142 ← THIS IS WHERE resource_id + ownership go
→ call handler
registry.invoke() (in registration.rs:116) has access to both input
and registration.spec (which now has resource_id_path). The resource ID
extraction and ownership provider threading happen here.
resource_id extraction (ADR-050 §2a)
In invoke() and invoke_streaming(), before the check() call:
// Extract resource_id from input using spec.resource_id_path
let resource_id = registration.spec.resource_id_path
.as_ref()
.and_then(|path| extract_json_pointer(&input, path));
extract_json_pointer parses a JSON pointer like "$.containerId" and
extracts the value at that path from the input Value. If the pointer is
None, or the path doesn't exist in the input, resource_id is None.
JSON pointer parsing: the resource_id_path uses the $.field syntax
(JSON Pointer with a leading $). Use the serde_json pointer API or a
small helper. serde_json::Value::pointer() takes a /field path — strip
the leading $. and replace with /, or use a simple helper that handles
the $. prefix. If the path is malformed or the field is missing,
resource_id is None (graceful — the check will deny if the spec
requires a resource_type but no ID is available).
OwnershipProvider threading
The OwnershipProvider needs to be available in invoke(). Two options:
Option A: thread it through OperationContext. Add an
ownership: Option<Arc<dyn OwnershipProvider>> field to
OperationContext. build_root_context populates it from the
CallAdapter (which holds Arc<dyn OwnershipProvider>). invoke() reads
context.ownership.as_deref() and passes it to check().
Option B: hold it on the OperationRegistry. The registry holds
Option<Arc<dyn OwnershipProvider>> and invoke() reads it directly.
Recommendation: Option A (OperationContext). It's consistent with how
identity, capabilities, and env are threaded — all on
OperationContext. The registry stays stateless (no new field); the
context carries the ownership provider the same way it carries the
identity provider's output. The assembly layer wires the ownership
provider into the CallAdapter at construction, and
build_root_context threads it onto the context.
What changes
-
OperationContextgainsownership: Option<Arc<dyn OwnershipProvider>>(inoperation-registry.md/operation-context.md/ the struct inregistry/operation_context.rsor whereverOperationContextis defined).Nonewhen no ownership provider is wired (backward compat —checkfalls back to static path). -
build_root_context(inprotocol/dispatch.rs:148) populatesownershipfromself.ownership_provider(a new field onDispatcher/CallAdapter, set at construction by the assembly layer). -
invoke()andinvoke_streaming()(inregistration.rs) extractresource_idfrominputviaspec.resource_id_path, then callacl.check(identity.as_ref(), resource_id.as_deref(), context.ownership.as_deref()). -
invoke_with_policy()inconnection.rs:311— the composition path (internal calls viaOperationEnv::invoke). Same pattern: extractresource_idfrominputusing the child op'sresource_id_path, thread the ownership provider from the parent context. -
Dispatcher/CallAdaptergains anownership_provider: Option<Arc<dyn OwnershipProvider>>field, set at construction.Noneby default (backward compat — deployments without runtime-spawned resources don't wire one).
What this task does NOT do
- Does NOT build a persistence adapter — the in-memory
InMemoryOwnershipStorefromcore/ownership-store-traitis the default. - Does NOT build any handler that calls
record/revoke— that's downstream crate work (alknet-docker, etc.). - Does NOT change
services/list/services/schema/services/list-peers— those stay on the(None, None)path (scope-gating only, no per-resource ownership check at discovery time).
Tests
- Integration test: an operation with
resource_type+resource_id_path- an
OwnershipProviderwired →checkconsults the provider → Allowed when the provider says owns.
- an
- Integration test: same setup but provider says not owns → Forbidden.
- Integration test:
resource_id_pathpoints to a field missing from input →resource_idis None →listpath (owns_any) or Forbidden (if resource_type requires a specific ID). - Integration test: no ownership provider wired (
ownership: Noneon context) → staticIdentity.resourcesfallback (backward compat). - Unit test:
extract_json_pointerextracts$.containerIdfrom{"containerId": "abc123"}→Some("abc123"). - Unit test:
extract_json_pointeron missing field →None. - Unit test:
resource_id_path: None→resource_id: None.
Acceptance Criteria
OperationContexthasownership: Option<Arc<dyn OwnershipProvider>>fieldDispatcher/CallAdapterholdsownership_provider: Option<Arc<dyn OwnershipProvider>>, set at constructionbuild_root_contextpopulatescontext.ownershipfrom the dispatcher's providerinvoke()extractsresource_idfrominputviaspec.resource_id_path, passes(resource_id, context.ownership.as_deref())tocheck()invoke_streaming()does the sameinvoke_with_policy()inconnection.rsdoes the same for the composition path- JSON pointer extraction helper handles
$.fieldsyntax - Missing field in input →
resource_id: None(graceful, not a panic) resource_id_path: None→resource_id: None- No ownership provider wired →
checkfalls back to static path (backward compat) - Integration test: provider says owns → Allowed
- Integration test: provider says not owns → Forbidden
- Integration test: missing field →
listpath or Forbidden cargo test -p alknet-callsucceedscargo clippy -p alknet-callsucceeds with no warnings
References
- docs/architecture/crates/call/operation-registry.md — AccessControl (dispatch flow with resource_id extraction)
- docs/architecture/decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md — ADR-050 §2a, §4a, §4d
- crates/alknet-call/src/protocol/dispatch.rs — dispatch() and build_root_context()
- crates/alknet-call/src/registry/registration.rs — invoke() and invoke_streaming() (the check() call sites)
- crates/alknet-call/src/protocol/connection.rs — invoke_with_policy() (composition path)
- tasks/call/registry/operation-spec-resource-id-path.md — the resource_id_path field (dependency)
- tasks/call/registry/access-control-ownership-check.md — the check() signature change (dependency)
- tasks/core/ownership-store-trait.md — the OwnershipProvider trait (dependency)
Notes
This is the wiring task — it connects the pieces from the three dependency tasks into a working end-to-end flow. The key design choice is threading the ownership provider via
OperationContext(consistent with how identity, capabilities, and env are threaded), not via the registry (which stays stateless). The assembly layer wiresOption<Arc<dyn OwnershipProvider>>into theCallAdapterat construction;Noneis the default for deployments without runtime-spawned resources (backward compat). The JSON pointer extraction is a small helper —$.containerId→ look upcontainerIdin the input Value. Missing fields are graceful (None), not errors — the check denies if the spec requires a resource_type but no ID is available.