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.
6.9 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| call/registry/access-control-ownership-check | Update AccessControl::check to consult OwnershipProvider for dynamic resource ownership (ADR-050 §2) | done |
|
moderate | medium | component | implementation |
Description
Update AccessControl::check to accept resource_id and an optional
OwnershipProvider reference, consulting the provider for runtime-spawned
resource ownership checks. Per ADR-050 §2.
The signature change
Current (in crates/alknet-call/src/registry/spec.rs):
pub fn check(&self, identity: Option<&Identity>) -> AccessResult
Updated:
pub fn check(
&self,
identity: Option<&Identity>,
resource_id: Option<&str>,
ownership: Option<&dyn OwnershipProvider>,
) -> AccessResult
Check logic
1. Scope check (unchanged): identity.scopes ⊇ required_scopes.
If identity is None and scopes are required, deny here.
(Same as current — no change to the scope-check path.)
2. Resource check (only if self.resource_type is Some):
a. resource_id Some + ownership Some:
→ identity must be Some (owns takes &Identity, not Option);
if identity is None, deny.
→ p.owns(identity, resource_type, resource_id, resource_action)
→ if false, Forbidden("not owner of resource")
b. resource_id None + ownership Some (the `list` case, ADR-050 §4a):
→ if identity is None, deny.
→ p.owns_any(identity, resource_type) [scope-gate]
→ if false, Forbidden("no owned resources of type")
c. ownership None → fall back to static
→ identity.resources[resource_type] ∋ resource_action
(backward compat for non-runtime resources — the current logic)
Backward compatibility
When ownership is None, check falls back to the static
Identity.resources path — the existing resource-check logic (lines 83-105
of spec.rs) runs unchanged. This means operations with static resource
sets work without wiring an ownership provider. The ownership provider is
an additional check, not a replacement.
When resource_id is None and ownership is None, the existing logic
also runs unchanged (the resource_action without a specific resource ID
path, lines 97-105). This covers operations that have resource_action but
no resource_type — those stay on the static path.
Call sites to update
There are 6 call sites for check() that need the signature update:
registry/registration.rs:142—acl.check(identity.as_ref())ininvoke()registry/registration.rs:194—acl.check(identity.as_ref())ininvoke_streaming()protocol/connection.rs:351—access_control.check(caller_identity.as_ref())ininvoke_with_policy()registry/discovery.rs:224—spec.access_control.check(calling_identity)inservices/listregistry/discovery.rs:247—spec.access_control.check(calling_identity)inservices/schemaregistry/discovery.rs:269—reg.spec.access_control.check(calling_identity)inservices/list-peers
For sites 1-3 (dispatch path): pass resource_id: None and
ownership: None for now — the dispatch-path wiring (extracting
resource_id from input via spec.resource_id_path and threading the
ownership provider) is call/registry/dispatch-resource-id-extraction,
which depends on this task. This task changes the signature and the check
logic; the dispatch task wires the new parameters.
For sites 4-6 (service discovery): these are services/list /
services/schema / services/list-peers filtering — they check whether a
calling peer can see an operation at all. Pass resource_id: None and
ownership: None — service discovery doesn't do per-resource ownership
checks (it's scope-gating only). The list result-filter (ADR-050 §4a) is
a handler-level concern, not a service-discovery concern.
What this task does NOT do
- Does NOT extract
resource_idfrom the input or thread the ownership provider into the dispatch path — that'scall/registry/dispatch-resource-id-extraction, which depends on this task. This task changes the signature and the check logic; all call sites passNone, Noneand behave exactly as before. - Does NOT add
resource_id_pathtoOperationSpec— that'scall/registry/operation-spec-resource-id-path, which is independent (no dependency).
Tests
The existing tests in spec.rs (lines 157-327) all call check(identity)
with one argument. They need to be updated to check(identity, None, None)
— the behavior should be identical (backward compat). Add new tests for
the ownership-provider path:
resource_id Some + ownership Some + provider says owns → Allowedresource_id Some + ownership Some + provider says not owns → Forbiddenresource_id Some + ownership Some + identity None → Forbiddenresource_id None + ownership Some + provider says owns_any → Allowed(thelistscope-gate)resource_id None + ownership Some + provider says not owns_any → Forbiddenownership None + resource_type Some → static fallback (existing behavior)- A mock
OwnershipProviderimpl for testing
Acceptance Criteria
AccessControl::checksignature updated:(identity, resource_id, ownership) -> AccessResult- Scope check path unchanged (required_scopes, required_scopes_any)
- Resource check path: when
ownershipis Some, consults the provider - Resource check path: when
ownershipis None, falls back to staticIdentity.resources(backward compat) resource_id None + ownership Some→ callsowns_any(thelistscope-gate)resource_id Some + ownership Some→ callsownsidentity None + ownership Some + resource_type Some→ Forbidden (owns needs &Identity)- All 6 existing call sites updated to pass
(None, None)— behavior unchanged - All existing tests updated to
check(identity, None, None)— behavior unchanged - New tests with a mock
OwnershipProviderfor the dynamic path cargo test -p alknet-callsucceedscargo clippy -p alknet-callsucceeds with no warnings
References
- docs/architecture/crates/call/operation-registry.md — AccessControl (updated with the new check signature)
- docs/architecture/decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md — ADR-050 §2, §4a
- crates/alknet-call/src/registry/spec.rs — current AccessControl::check implementation
- tasks/core/ownership-store-trait.md — the OwnershipProvider trait (dependency)
Notes
This is the one-way door — the
checksignature change touches every call site and test. The key design property: backward compatibility whenownershipisNone. All existing call sites pass(None, None)in this task and behave exactly as before. The dispatch-path wiring (extractingresource_idfrom input and threading a real ownership provider) is the next task. This task is the signature + logic change; the next task is the wiring.