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.
153 lines
6.9 KiB
Markdown
153 lines
6.9 KiB
Markdown
---
|
|
id: call/registry/access-control-ownership-check
|
|
name: Update AccessControl::check to consult OwnershipProvider for dynamic resource ownership (ADR-050 §2)
|
|
status: done
|
|
depends_on: [core/ownership-store-trait]
|
|
scope: moderate
|
|
risk: medium
|
|
impact: component
|
|
level: 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`):
|
|
|
|
```rust
|
|
pub fn check(&self, identity: Option<&Identity>) -> AccessResult
|
|
```
|
|
|
|
Updated:
|
|
|
|
```rust
|
|
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:
|
|
|
|
1. `registry/registration.rs:142` — `acl.check(identity.as_ref())` in `invoke()`
|
|
2. `registry/registration.rs:194` — `acl.check(identity.as_ref())` in `invoke_streaming()`
|
|
3. `protocol/connection.rs:351` — `access_control.check(caller_identity.as_ref())` in `invoke_with_policy()`
|
|
4. `registry/discovery.rs:224` — `spec.access_control.check(calling_identity)` in `services/list`
|
|
5. `registry/discovery.rs:247` — `spec.access_control.check(calling_identity)` in `services/schema`
|
|
6. `registry/discovery.rs:269` — `reg.spec.access_control.check(calling_identity)` in `services/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_id` from the input or thread the ownership
|
|
provider into the dispatch path — that's
|
|
`call/registry/dispatch-resource-id-extraction`, which depends on this
|
|
task. This task changes the signature and the check logic; all call sites
|
|
pass `None, None` and behave exactly as before.
|
|
- Does NOT add `resource_id_path` to `OperationSpec` — that's
|
|
`call/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 → Allowed`
|
|
- `resource_id Some + ownership Some + provider says not owns → Forbidden`
|
|
- `resource_id Some + ownership Some + identity None → Forbidden`
|
|
- `resource_id None + ownership Some + provider says owns_any → Allowed` (the `list` scope-gate)
|
|
- `resource_id None + ownership Some + provider says not owns_any → Forbidden`
|
|
- `ownership None + resource_type Some → static fallback (existing behavior)`
|
|
- A mock `OwnershipProvider` impl for testing
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [ ] `AccessControl::check` signature updated: `(identity, resource_id, ownership) -> AccessResult`
|
|
- [ ] Scope check path unchanged (required_scopes, required_scopes_any)
|
|
- [ ] Resource check path: when `ownership` is Some, consults the provider
|
|
- [ ] Resource check path: when `ownership` is None, falls back to static `Identity.resources` (backward compat)
|
|
- [ ] `resource_id None + ownership Some` → calls `owns_any` (the `list` scope-gate)
|
|
- [ ] `resource_id Some + ownership Some` → calls `owns`
|
|
- [ ] `identity 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 `OwnershipProvider` for the dynamic path
|
|
- [ ] `cargo test -p alknet-call` succeeds
|
|
- [ ] `cargo clippy -p alknet-call` succeeds 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 `check` signature change touches every
|
|
> call site and test. The key design property: backward compatibility when
|
|
> `ownership` is `None`. All existing call sites pass `(None, None)` in
|
|
> this task and behave exactly as before. The dispatch-path wiring
|
|
> (extracting `resource_id` from input and threading a real ownership
|
|
> provider) is the next task. This task is the signature + logic change;
|
|
> the next task is the wiring. |