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.
199 lines
8.3 KiB
Markdown
199 lines
8.3 KiB
Markdown
---
|
|
id: core/ownership-store-trait
|
|
name: Add OwnershipProvider (sync read) + OwnershipStore (async write) traits and InMemoryOwnershipStore (ADR-050)
|
|
status: done
|
|
depends_on: []
|
|
scope: moderate
|
|
risk: low
|
|
impact: component
|
|
level: implementation
|
|
---
|
|
|
|
## Description
|
|
|
|
Add the `OwnershipProvider` (sync read trait) and `OwnershipStore` (async
|
|
write trait) for runtime-spawned resource ownership, plus an
|
|
`InMemoryOwnershipStore` default adapter. This is the fourth instance of
|
|
the repo/adapter pattern (ADR-033), alongside `IdentityProvider` (ADR-004),
|
|
`IdentityStore` (ADR-035), and `CredentialStore` (ADR-031). Per ADR-050.
|
|
|
|
### Why this exists
|
|
|
|
Runtime-spawned resources (containers, TTYs, workspace processes) have
|
|
derived ownership: whoever spawned the resource owns it. The static
|
|
`Identity.resources` model can't represent this — the resource didn't exist
|
|
when the identity was resolved. `AccessControl::check` (in alknet-call) will
|
|
consult `OwnershipProvider` at check time to answer "does this identity own
|
|
this specific resource?"
|
|
|
|
### Module placement
|
|
|
|
Create a new `ownership.rs` module in `alknet-core/src/`, re-export from
|
|
`lib.rs`. Follow the same pattern as `store.rs` (which holds
|
|
`CredentialStore` + `InMemoryCredentialStore`).
|
|
|
|
### OwnershipProvider trait (read side, sync)
|
|
|
|
```rust
|
|
/// Read side: consulted by AccessControl::check on the dispatch hot path.
|
|
/// Sync — called in the dispatch loop, no .await.
|
|
pub trait OwnershipProvider: Send + Sync + 'static {
|
|
/// Does `identity` own `resource_type/resource_id` with `action`?
|
|
/// Called when AccessControl has resource_type + resource_action set
|
|
/// and the dispatcher has extracted resource_id from the input via
|
|
/// OperationSpec.resource_id_path (ADR-050 §2a).
|
|
fn owns(
|
|
&self,
|
|
identity: &Identity,
|
|
resource_type: &str,
|
|
resource_id: &str,
|
|
action: &str,
|
|
) -> bool;
|
|
|
|
/// What resources of `resource_type` does `identity` own?
|
|
/// Called for the `list` case (resource_type set, resource_id_path
|
|
/// absent) — the result-filter path (ADR-050 §4a). Returns the set of
|
|
/// resource IDs the caller owns, for the handler to filter against.
|
|
fn owned_resources(
|
|
&self,
|
|
identity: &Identity,
|
|
resource_type: &str,
|
|
) -> Vec<String>;
|
|
|
|
/// Does `identity` own *any* resource of `resource_type`?
|
|
/// Called for the `list` case — the scope-gate path (ADR-050 §4a).
|
|
fn owns_any(
|
|
&self,
|
|
identity: &Identity,
|
|
resource_type: &str,
|
|
) -> bool;
|
|
}
|
|
```
|
|
|
|
### OwnershipStore trait (write side, async)
|
|
|
|
```rust
|
|
/// Write side: called by the handler that manages the resource lifecycle.
|
|
/// Async — not on the dispatch hot path. The handler calls `record` on
|
|
/// spawn and `revoke` on teardown (ADR-050 §4b — handler-driven, not a
|
|
/// reaper).
|
|
#[async_trait]
|
|
pub trait OwnershipStore: Send + Sync + 'static {
|
|
/// Record that `identity` spawned `resource_type/resource_id`.
|
|
async fn record(
|
|
&self,
|
|
identity: &Identity,
|
|
resource_type: &str,
|
|
resource_id: &str,
|
|
) -> Result<(), OwnershipError>;
|
|
|
|
/// Revoke ownership of `resource_type/resource_id`.
|
|
/// Called by the handler on resource teardown (ADR-050 §4b).
|
|
async fn revoke(
|
|
&self,
|
|
resource_type: &str,
|
|
resource_id: &str,
|
|
) -> Result<(), OwnershipError>;
|
|
}
|
|
```
|
|
|
|
Note: the ADR-050 sketch shows `record`/`revoke` taking `&mut self`. The
|
|
in-memory implementation should use interior mutability (`RwLock` or
|
|
`Mutex`, same as `InMemoryCredentialStore` which takes `&self` for its
|
|
async methods). The trait takes `&self` so it can be shared as
|
|
`Arc<dyn OwnershipStore>`.
|
|
|
|
### OwnershipError
|
|
|
|
```rust
|
|
#[non_exhaustive]
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum OwnershipError {
|
|
#[error("backend error: {message}")]
|
|
Backend { message: String },
|
|
#[error("not found: {entity}")]
|
|
NotFound { entity: String },
|
|
}
|
|
```
|
|
|
|
Follow the same shape as `StoreError` in `store.rs`. `#[non_exhaustive]`
|
|
so future variants (e.g., concurrency conflict) can be added without
|
|
breaking consumers.
|
|
|
|
### InMemoryOwnershipStore
|
|
|
|
```rust
|
|
pub struct InMemoryOwnershipStore {
|
|
// Map: (resource_type, resource_id) → owner Identity
|
|
inner: RwLock<HashMap<(String, String), Identity>>,
|
|
}
|
|
```
|
|
|
|
- `owns`: look up `(resource_type, resource_id)`, compare owner's `id`
|
|
to `identity.id`, check that the action is... actually, the ownership
|
|
store records *who owns* a resource, not *what actions they can perform*.
|
|
The `action` parameter in `owns` is the `resource_action` from
|
|
`AccessControl` — it's passed for future use (per-action ownership
|
|
grants), but the base model is "owner can do anything they own." For
|
|
now, `owns` returns `true` if the identity owns the resource, regardless
|
|
of action. The action parameter is accepted but not gated on — this
|
|
preserves the door for per-action grants without building them now.
|
|
- `owned_resources`: iterate the map, return IDs where the owner's `id`
|
|
matches.
|
|
- `owns_any`: iterate the map, return `true` if any entry matches the
|
|
identity + resource_type.
|
|
- `record`: insert `(resource_type, resource_id) → identity`.
|
|
- `revoke`: remove `(resource_type, resource_id)`.
|
|
|
|
### What this task does NOT do
|
|
|
|
- Does NOT change `AccessControl::check` — that's
|
|
`call/registry/access-control-ownership-check`, which depends on this
|
|
task.
|
|
- Does NOT add `resource_id_path` to `OperationSpec` — that's
|
|
`call/registry/operation-spec-resource-id-path`, which is independent.
|
|
- Does NOT build a persistence adapter (SQLite/honker-backed) — that's
|
|
additive, built when a concrete use case forces it (ADR-050 §1).
|
|
- Does NOT wire the ownership provider into the dispatch path — that's
|
|
`call/registry/dispatch-resource-id-extraction`, which depends on this
|
|
task and the two call tasks.
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [ ] `OwnershipProvider` trait with `owns`, `owned_resources`, `owns_any` (all sync)
|
|
- [ ] `OwnershipStore` trait with `record`, `revoke` (both async, `#[async_trait]`)
|
|
- [ ] `OwnershipError` enum (`#[non_exhaustive]`, `thiserror::Error`) with `Backend` + `NotFound` variants
|
|
- [ ] `InMemoryOwnershipStore` implements both `OwnershipProvider` + `OwnershipStore`
|
|
- [ ] `InMemoryOwnershipStore` uses interior mutability (`RwLock`, same as `InMemoryCredentialStore`)
|
|
- [ ] `owns` returns true if identity owns the resource (action accepted but not gated — base model is owner-can-do-anything)
|
|
- [ ] `owned_resources` returns the set of resource IDs the identity owns for a given type
|
|
- [ ] `owns_any` returns true if identity owns at least one resource of the type
|
|
- [ ] `record` inserts ownership; `revoke` removes it
|
|
- [ ] New `ownership.rs` module in `alknet-core/src/`
|
|
- [ ] Re-exported from `lib.rs`: `pub use ownership::{OwnershipProvider, OwnershipStore, InMemoryOwnershipStore, OwnershipError};`
|
|
- [ ] Unit tests: record → owns → revoke → not owns round-trip
|
|
- [ ] Unit test: owned_resources returns correct set for an owner with multiple resources
|
|
- [ ] Unit test: owns_any returns false for an owner with no resources of that type
|
|
- [ ] Unit test: revoke on non-existent resource is a no-op (or NotFound — pick one and document)
|
|
- [ ] `cargo test -p alknet-core` succeeds
|
|
- [ ] `cargo clippy -p alknet-core` succeeds with no warnings
|
|
|
|
## References
|
|
|
|
- docs/architecture/crates/core/auth.md — "Ownership Provider and Store (ADR-050)" section
|
|
- docs/architecture/decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md — ADR-050 (the full decision)
|
|
- docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md — ADR-033 (the pattern)
|
|
- crates/alknet-core/src/store.rs — `CredentialStore` + `InMemoryCredentialStore` (the pattern to follow)
|
|
|
|
## Notes
|
|
|
|
> The read/write split mirrors ADR-035: `OwnershipProvider` (read, sync) is
|
|
> the trait the dispatch path depends on; `OwnershipStore` (write, async)
|
|
> is the trait the handler lifecycle calls. The in-memory default
|
|
> implements both. The `owns` method accepts an `action` parameter but
|
|
> doesn't gate on it — the base model is "owner can do anything they own."
|
|
> Per-action grants are a future extension (additive, not needed now). The
|
|
> `action` parameter preserves the door without building the mechanism. A
|
|
> persistence adapter (SQLite/honker-backed) is not built in this sync —
|
|
> ownership is runtime state, meaningless across restarts; the in-memory
|
|
> default is sufficient for the docker/runner cases. |