--- 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; /// 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`. ### 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>, } ``` - `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.