diff --git a/.hermes/plans/2026-08-11_041500-concurrent-multitenant-db.md b/.hermes/plans/2026-08-11_041500-concurrent-multitenant-db.md index 5020368..3cebe7b 100644 --- a/.hermes/plans/2026-08-11_041500-concurrent-multitenant-db.md +++ b/.hermes/plans/2026-08-11_041500-concurrent-multitenant-db.md @@ -97,11 +97,13 @@ - Implement: before applying a mutating op, read the record's `CubeHeader`; deny unless `header.owner_local_user == caller.owner_local` OR a matching grant exists (Task 6b). Reads check owner/other perms. Reuse the lifted `permits()`. - `./check stress` + a denial test. Commit. -### Task 6b: Permission grants (PDF flags 5-19) -- **Files:** `cubesys/src/tenant.rs` (GRANT_BUCKET null-space coord + `Grant` struct), `cubesys/src/commands.rs` (`GRANT`/`REVOKE` opcodes + grant lookup in enforcement order). -- **Test:** `grant_allows_delegated_write` (owner A grants owner B write on a coord-prefix; B's write succeeds, C's fails), `revoke_removes_grant`, `grant_scope_prefix` (grant covers subtree only). -- Implement: `Grant { granter, grantee, perms: rwx, scope: Option }` stored at a null-space coord; enforcement order = owner → grant → `Acl::permits` → deny. Grants durable via the tenant WAL. Only `granter` may `REVOKE`. -- `./check` green. Commit. +### Task 6b: Permission grants (PDF flags 5-19) — DONE 2026-08-11 +- **Files:** `cubesys/src/grants.rs` (NEW — `Grant`, `Owner`, `Perm`, `GRANT_BUCKET` at `Czyx::new(0,1,0,1)`, `grant`/`revoke`/`grant_allows`/`read_bucket`/`write_bucket`); `cubesys/src/commands.rs` (`GRANT`/`REVOKE` opcodes, `admit_mutate` enforcement hook); `cubesys/src/lib.rs` (`pub mod grants;`). +- **Enforcement model (as built):** `admit_mutate(coord, want)` runs on every mutating op (`prog`/`write`/`del`/`seal`/`open`). Order: (1) owner match OR unowned (first-writer-claims) → allow; (2) any grant where `grantee == caller`, `perms` covers the op, and `scope` covers the coord (wildcard axis = `0`) → allow; (3) else deny. No external KMS; grants live in the tenant WAL. +- **Durability fix (key learning):** the grant table is stored via `put_record` + a tagged `CubeHeader{doc_type:"grant-table"}`, NOT `put_raw` JSON — because `dump_store` (checkpoint) requires the `[u32 header_len][hdr][body]` framing. Storing raw bytes corrupted/ dropped the record on checkpoint (`grant_survives_reopen` initially failed). Now persists correctly. +- **Identity gating:** `GRANT`/`REVOKE` require a HELLO identity when the daemon runs `--require-identity` (i.e. `enforce_owner`); anonymous sessions get rejected. Only the `granter` may `REVOKE`. +- **Tests (all green):** `grant_allows_delegated_write`, `revoke_removes_grant` (found + fixed an inverted keep/remove filter), `grant_scope_prefix`, `grant_requires_identity`, `grant_survives_reopen`, plus `Grant` JSON roundtrip / null-scope / perms-parse / scope-wildcard unit tests. Full `./check quick` = 115 tests pass, clippy -D warnings clean. +- Commit note: Task 6b closes the "PDF flags 5-19 unbuilt" gap. The delegated-grant model is now real and durable. ### Task 7: Concurrency stress + multi-tenant benchmark - **Files:** `tools/stress.sh` (add `--tenants N` driving N `cubec` clients); `cube-bench` (add `multi_tenant_put` section). @@ -133,3 +135,31 @@ - **Token storage is local-file, not a KMS.** Fine for the "hardware you own" privacy model; note it is not multi-host. - **Open question for user:** auto-provision unknown tenants (default, convenient) vs deny-unknown (strict, must pre-create token). Plan defaults to provision; flag `--deny-unknown-tenant` flips it. - **No new external deps** unless `parking_lot` is proven necessary (writer starvation under std `RwLock`). YAGNI until measured. + +--- + +## Auth layer — recommendations (2026-08-11) + +**What is now real (grounded in the PDF's own model):** +1. Tenant isolation at the storage layer — the PDF's C-axis environment selector is the tenant boundary; cross-tenant access is impossible by construction (separate `Arc` + separate C namespace + separate on-disk WAL/checkpoint). +2. Owner-marking enforcement — every `CubeHeader` carries `owner_local_user`/`owner_remote_user`; mutating ops now require owner match (or first-writer-claims on unowned records) or a grant. +3. Delegated grants (PDF flags 5-19) — `Grant{ granter, grantee, perms:rwx, scope:Option }` stored as a durable, checkpoint-safe record; enforcement order owner → grant → deny. This is the "permissions and associations" layer the PDF reserved but never specified. +4. Identity from `HELLO` (tenant + owner pair) — no external token service; consistent with the user's "metadata is reserved coordinate space, not a side table" stance and the privacy mandate (local hardware, no datacenter). + +**Gaps / recommendations (honest ceiling — ranked):** + +- **R1 — Reuse, don't fork, the ACL logic.** The FUSE mount (`cubefs::nullspace::Acl::permits`) already implements owner/group/other permission checks, but the daemon does NOT share it — `admit_mutate` today only checks owner-match + grant, not the `mode` bits. Recommend lifting `Acl`/`permits` into a shared `cubeacl` crate (or `cubesys::acl`) so daemon + FUSE enforce identical rules. Right now a record with mode `0600` is still readable by any other owner unless a grant exists — that's a read-permission gap. + +- **R2 — Grant table is single-record-per-tenant (O(n) scan).** `read_bucket` deserializes the whole JSON array on every `grant`/`revoke`/`grant_allows` call. Fine for an agent memory store (tens of grants), but recommend a prefix-indexed bucket (one coord per `grantee` or per scope) before scaling past ~1k grants/tenant. + +- **R3 — No expiry / revocation propagation.** Grants have a `seq` but no TTL; a revoked grant does not proactively invalidate in-flight transactions. Add `expires_at: Option` to `Grant` and check it in `grant_allows` if time-bound delegation is wanted. + +- **R4 — Identity is asserted, not authenticated.** `HELLO` carries a self-asserted `(tenant, owner)` with no signature/challenge. For a single-owner box on a private network (the user's model) this is acceptable; if the box is ever exposed beyond loopback/LAN, add a mutual-auth step (e.g. a pre-shared key or the existing cubecrypt header-flag encrypted handshake) before stamping identity. The `cubecrypt` crate already has `HEADER_FLAG_ENCRYPTED` at bit 12 — wire it into the HELLO frame. + +- **R5 — Read-gating not enforced.** `admit_mutate` only fires on writes. Reads (`run`/`get`) currently ignore grants/owner. If read-privacy matters (it does for a memory store), add an `admit_read` mirror that checks owner/grant-read before returning a record body. + +- **R6 — Audit log.** Every grant/revoke/write is in the WAL but not attributable to *who* at the app layer. Add a `grant_audit` null-space record (append-only) listing `(seq, granter, grantee, perms, scope, ts)` so delegation is auditable — important the moment more than one human/agent touches a tenant. + +- **R7 — Daemon flag hygiene.** `--require-identity` (i.e. `!allow_anonymous`) is the only switch; there is no `--read-only` or per-tenant policy yet. Recommend a small `tenant policy` table (allow-anon? require-grant-for-read? max-grants?) before multi-user rollout. + +These are recommendations, not blockers — the built layer satisfies the PDF's flags 5-19 intent and the user's privacy model. R1 and R5 are the two I'd close first if this store ever holds more than one principal's data. diff --git a/cubesys/src/bin/cube-server.rs b/cubesys/src/bin/cube-server.rs index eef0d27..81e60d5 100644 --- a/cubesys/src/bin/cube-server.rs +++ b/cubesys/src/bin/cube-server.rs @@ -308,7 +308,11 @@ fn main() { } else { "legacy shared store" }, - if require_identity { "required" } else { "anonymous allowed" } + if require_identity { + "required" + } else { + "anonymous allowed" + } ); // Best-effort initial checkpoint so a crash before the first flush still diff --git a/cubesys/src/commands.rs b/cubesys/src/commands.rs index 1ba7736..8509188 100644 --- a/cubesys/src/commands.rs +++ b/cubesys/src/commands.rs @@ -11,6 +11,7 @@ //! survives restarts. Each `exec` takes and returns an `Arc` //! so the server can hand a cloned handle to each worker thread. +use crate::grants::{grant, grant_allows, perms_from_str, revoke, Owner, Perm}; use crate::store::ConcurrentStore; use crate::tenant::{TenantIdentity, TenantSession}; use cubecode::{CodeCell, Kind, Op, Vm}; @@ -137,6 +138,64 @@ impl Session { self.enforce_owner = on; } + /// Authorization gate for a mutating op (Task 6 + Task 6b — the PDF's + /// flags 5-19 delegated-grant layer). A mutating command (prog/write/del/ + /// seal/open) may proceed only when EITHER the session's identity owner + /// matches the record's `owner_local_user` (owner), OR the caller holds a + /// grant authorizing `want` over the coordinate. Returns `None` to allow, + /// or an error message to reject. + /// + /// Enforcement order (matches the plan's D5): + /// 1. owner match -> allow + /// 2. else a matching grant -> allow + /// 3. else deny + /// An unowned record is claimable by any (identified) writer (first-write + /// wins), same as the Task 6 rule. + fn admit_mutate(&self, coord: Czyx, want: Perm) -> Option { + // Policy (matches the prior Task 6 `owner_violation` contract): + // * No session identity: + // - if `enforce_owner` (daemon `--require-identity`) is ON -> reject + // (anonymous writes are not allowed); + // - otherwise (legacy / library / REPL / test) -> allow + // * A session identity IS present: owner-match OR a grant always apply + // (this is the heart of the PDF's flags 5-19 delegated model). + match &self.identity { + None => { + if self.enforce_owner { + Some( + "owner enforcement: mutating operations require a HELLO identity \ + (daemon policy --require-identity)" + .to_string(), + ) + } else { + None + } + } + Some(id) => { + // (1) owner match — or unowned record, claimable by first writer. + if let Some(record_owner) = self.store.owner(&coord) { + if record_owner == id.owner_local { + return None; + } + } else { + return None; // unowned -> first writer claims it + } + // (2) grant. + let who = Owner::from(id); + if grant_allows(&self.store, &who, &coord, want) { + return None; + } + // (3) deny. + Some(format!( + "owner violation: record at {} owned by '{}', you are '{}' (and hold no matching grant)", + coord.pack_u32(), + self.store.owner(&coord).unwrap_or_default(), + id.owner_local + )) + } + } + } + /// Shared handle to the underlying concurrent store. pub fn store(&self) -> Arc { self.store.clone() @@ -225,13 +284,14 @@ impl Session { .take() .ok_or_else(|| "commit: no transaction is open".to_string())?; let n = txn.ops.len(); - // Task 6: owner enforcement for buffered txn ops. The live path - // already checked at `prog`/`write`/`del` time, but a concurrent - // commit from another owner could have claimed the coord between - // our BEGIN and COMMIT, so re-check now (the buffer is re-read - // here, consistent with the single WAL entry T5 writes). + // Task 6/6b: re-check ownership + grants for buffered txn ops. + // The live path already checked at `prog`/`write`/`del` time, + // but a concurrent commit from another owner (or a revoked grant) + // could have changed the situation between our BEGIN and COMMIT, + // so re-check now. The grant-aware `admit_mutate` is used so a + // grant issued/revoked mid-txn is honored at commit. for op in &txn.ops { - if let Some(msg) = owner_violation(&self.identity, &store.owner(&op.coord), self.enforce_owner) { + if let Some(msg) = self.admit_mutate(op.coord, Perm::Write) { // Roll the txn back: restore it so the caller can retry // after resolving the conflict (do not silently drop). self.txn = Some(txn); @@ -298,7 +358,7 @@ impl Session { // duplicating the record codec. let coord = scratch_code_coord(path, Kind::Fn, name, &ops)?; // Task 6: reject overwriting a record owned by a different owner. - if let Some(msg) = owner_violation(&self.identity, &store.owner(&coord), self.enforce_owner) { + if let Some(msg) = self.admit_mutate(coord, Perm::Write) { return Err(msg); } let owner = self.identity.as_ref().map(|i| i.owner_local.as_str()); @@ -338,7 +398,7 @@ impl Session { .map_err(|e| format!("bytecode decode error: {e:?}"))?; let name = path.rsplit('/').next().unwrap_or(path); let coord = scratch_code_coord(path, Kind::Fn, name, &code)?; - if let Some(msg) = owner_violation(&self.identity, &store.owner(&coord), self.enforce_owner) { + if let Some(msg) = self.admit_mutate(coord, Perm::Write) { return Err(msg); } let owner = self.identity.as_ref().map(|i| i.owner_local.as_str()); @@ -369,7 +429,7 @@ impl Session { let coord = crate::path_to_czyx(path).map_err(|e| e.to_string())?; // Task 6: reject deleting a record owned by a different owner // (unless unowned, which any identity may take over). - if let Some(msg) = owner_violation(&self.identity, &store.owner(&coord), self.enforce_owner) { + if let Some(msg) = self.admit_mutate(coord, Perm::Write) { return Err(msg); } if let Some(txn) = self.txn.as_mut() { @@ -379,6 +439,98 @@ impl Session { store.delete_raw(&coord); Ok(format!("deleted {path}")) } + "grant" => { + // Issue a permission grant (Task 6b / PDF flags 5-19). Only an + // identified owner may grant (under --require-identity); in the + // legacy permissive mode the granter is treated as "root". + if self.enforce_owner && self.identity.is_none() { + return Err( + "grant requires a HELLO identity (daemon policy --require-identity)" + .to_string(), + ); + } + let identity = self.identity.clone(); + let granter = match &identity { + Some(i) => Owner::from(i), + None => Owner::new("root"), + }; + let grantee_tok = it + .next() + .ok_or_else(|| "grant needs ".to_string())?; + let (gl, gr) = match grantee_tok.split_once('#') { + Some((l, r)) => (l.to_string(), Some(r.to_string())), + None => (grantee_tok.to_string(), None), + }; + let perms_s = it + .next() + .ok_or_else(|| "grant needs ".to_string())?; + let perms = perms_from_str(perms_s) + .ok_or_else(|| format!("grant: bad perms '{perms_s}' (use r/w/x)"))?; + let scope = match it.next() { + None => None, + Some(s) => { + if s.eq_ignore_ascii_case("global") { + None + } else { + let c = parse_coord(s) + .ok_or_else(|| format!("grant: bad scope '{s}' (C.Z.Y.X)"))?; + Some(c) + } + } + }; + let grantee = Owner { + local: gl.clone(), + remote: gr, + }; + let seq = grant(&self.store, &granter, &grantee, perms, scope) + .map_err(|e| format!("grant: {e}"))?; + let scope_s = match scope { + Some(c) => c.pack_u32().to_string(), + None => "global".to_string(), + }; + return Ok(format!( + "ok: granted seq={seq} {gl}<-{perms_s} over {scope_s}" + )); + } + "revoke" => { + // Revoke a grant (only the original granter may). Task 6b. + if self.enforce_owner && self.identity.is_none() { + return Err( + "revoke requires a HELLO identity (daemon policy --require-identity)" + .to_string(), + ); + } + let identity = self.identity.clone(); + let granter = match &identity { + Some(i) => Owner::from(i), + None => Owner::new("root"), + }; + let grantee_tok = it + .next() + .ok_or_else(|| "revoke needs ".to_string())?; + let (gl, gr) = match grantee_tok.split_once('#') { + Some((l, r)) => (l.to_string(), Some(r.to_string())), + None => (grantee_tok.to_string(), None), + }; + let scope = match it.next() { + None => None, + Some(s) => { + if s.eq_ignore_ascii_case("global") { + None + } else { + let c = parse_coord(s) + .ok_or_else(|| format!("revoke: bad scope '{s}' (C.Z.Y.X)"))?; + Some(c) + } + } + }; + let grantee = Owner { + local: gl, + remote: gr, + }; + let removed = revoke(&self.store, &granter, &grantee, scope); + return Ok(format!("ok: revoked {removed} grant(s)")); + } "run" => { let path = it.next().ok_or_else(|| "run needs ".to_string())?; let _coord = crate::path_to_czyx(path).map_err(|e| e.to_string())?; @@ -441,7 +593,7 @@ impl Session { // Task 6 (B): seal/open are destructive writes to `coord`, so // they obey the same owner gate as prog/write/del. A no-identity // session (under --require-identity) or a non-owner is rejected. - if let Some(msg) = owner_violation(&self.identity, &store.owner(&coord), self.enforce_owner) { + if let Some(msg) = self.admit_mutate(coord, Perm::Write) { return Err(msg); } @@ -510,55 +662,8 @@ pub fn txn_snapshot(s: &Session) -> CubeStore { /// writing — used to buffer `prog`/`write` mutations during a transaction. fn scratch_code_coord(path: &str, kind: Kind, name: &str, code: &[Op]) -> Result { let mut scratch = CubeStore::new(HashBackend::new()); - crate::store_code_cell(&mut scratch, path, kind, name, &[], code, None).map_err(|e| e.to_string()) -} - -/// Owner-enforcement check (Task 6): a mutating command may only create or -/// overwrite a record when EITHER the record is unowned OR the session's -/// identity owner matches the record's `owner_local_user`. Returns `None` when -/// the write is allowed, or an `Err`-style message when it must be rejected. -/// -/// `require_identity` selects the policy: -/// * `false` (legacy / library / REPL / tests): a session with no stamped -/// identity writes freely — every write is permitted. This keeps pre-existing -/// `cubec`/stress.sh flows working until the operator opts in. -/// * `true` (daemon `--require-identity` policy): a mutating op from a session -/// with no `HELLO` identity is *rejected* — anonymous writes are not allowed. -/// Once a session has an identity, the normal owner-match rule applies -/// (unowned coords are claimable by the first writer; owned coords require -/// the matching owner). -fn owner_violation( - identity: &Option, - record_owner: &Option, - require_identity: bool, -) -> Option { - match (&identity.as_ref().map(|i| i.owner_local.as_str()), record_owner) { - // No session identity. - (None, _) => { - if require_identity { - Some( - "owner enforcement: mutating operations require a HELLO identity \ - (daemon policy --require-identity)" - .to_string(), - ) - } else { - None - } - } - // Session has an identity but the record is unowned -> allow (first write - // by this owner claims it). - (Some(_), None) => None, - // Both set: must match exactly. - (Some(session_owner), Some(record_owner)) => { - if session_owner == record_owner { - None - } else { - Some(format!( - "owner violation: record owned by '{record_owner}', you are '{session_owner}'" - )) - } - } - } + crate::store_code_cell(&mut scratch, path, kind, name, &[], code, None) + .map_err(|e| e.to_string()) } /// Build the `CubeHeader` a `store_code_cell` call would attach (mirrors @@ -676,12 +781,17 @@ mod tests { let mut s = session(); s.exec("begin").unwrap(); // buffered, not yet visible - assert!(s.exec("prog /c001/z001/y001/x001 const 2 const 3 add halt").is_ok()); + assert!(s + .exec("prog /c001/z001/y001/x001 const 2 const 3 add halt") + .is_ok()); assert!(s.store.get_raw(&Czyx::new(1, 1, 1, 1)).is_none()); // COMMIT makes all ops durable+visible at once s.exec("commit").unwrap(); let v = s.store.get_raw(&Czyx::new(1, 1, 1, 1)); - assert!(v.is_some(), "prog buffered during txn must appear after commit"); + assert!( + v.is_some(), + "prog buffered during txn must appear after commit" + ); } #[test] @@ -699,7 +809,7 @@ mod tests { #[test] fn owner_enforcement_blocks_cross_owner_overwrite() { - use crate::tenant::{TenantIdentity, TenantId}; + use crate::tenant::{TenantId, TenantIdentity}; let mut s = session(); // Stamp an identity (Task 3 path) for owner "alice". s.set_identity(TenantIdentity { @@ -735,7 +845,7 @@ mod tests { #[test] fn owner_enforcement_allows_first_claim_and_same_owner() { - use crate::tenant::{TenantIdentity, TenantId}; + use crate::tenant::{TenantId, TenantIdentity}; let mut s = session(); // A session with NO identity writes freely (legacy / test path). assert!(s.exec("prog /c051/z001/y001/x001 const 1 halt").is_ok()); @@ -806,7 +916,10 @@ mod tests { s.enforce_owner = true; // No identity => rejected. let r = s.exec("prog /c053/z001/y001/x001 const 1 halt"); - assert!(r.is_err(), "anonymous write must be rejected under --require-identity"); + assert!( + r.is_err(), + "anonymous write must be rejected under --require-identity" + ); assert!(r.unwrap_err().contains("require a HELLO identity")); // A no-identity del/write is likewise rejected. assert!(s.exec("del /c053/z001/y001/x001").is_err()); diff --git a/cubesys/src/grants.rs b/cubesys/src/grants.rs new file mode 100644 index 0000000..abcca69 --- /dev/null +++ b/cubesys/src/grants.rs @@ -0,0 +1,689 @@ +//! Permission grants — the PDF's flags 5-19 "permissions and associations" +//! layer (Task 6b of the concurrent / multi-tenant plan). +//! +//! The PDF reserves cube-header flags 5-19 for delegated permissions but +//! ships no concrete model. We implement a *grant* as a first-class, +//! addressable record in Null space — consistent with the PDF's stance that +//! "metadata is reserved coordinate space, not a side table". A grant +//! delegates some perms (r / w / x) over a coordinate scope from a `granter` +//! owner to a `grantee` owner. +//! +//! Enforcement order (see `admit_mutate` in `commands.rs`): +//! 1. owner match -> allow +//! 2. else any grant whose `grantee == caller`, whose `perms` cover the op, +//! and whose `scope` covers the coordinate -> allow +//! 3. else deny +//! +//! All grants for a tenant live in ONE durable record at [`GRANT_BUCKET`] +//! (a JSON array of grant objects — std-only, no `serde` dependency), so they +//! persist with the tenant's WAL exactly like any other record. Only the +//! `granter` may `REVOKE`. +//! +//! Scope semantics: a scope axis of `0` acts as a wildcard for that axis, so +//! `scope 5.0.0.0` means "the entire class-5 subtree" while `scope 5.1.1.1` +//! is exact. A `None` scope means global. + +use crate::store::ConcurrentStore; +use crate::tenant::TenantIdentity; +use cubecoords::{CubeHeader, Czyx}; + +/// Permission bits. +pub const PERM_READ: u8 = 1; +/// Permission bit: write (create / overwrite / delete / seal / open). +pub const PERM_WRITE: u8 = 2; +/// Permission bit: execute (reserved; not yet used by any command). +pub const PERM_EXEC: u8 = 4; + +/// The kind of operation a grant can authorize. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum Perm { + /// Read access (reserved for future read-gating). + Read, + /// Write access (the mutating commands: `prog` / `write` / `del` / `seal` / `open`). + Write, + /// Execute access (reserved). + Exec, +} + +impl Perm { + /// The bitmask for this permission. + pub fn bit(self) -> u8 { + match self { + Perm::Read => PERM_READ, + Perm::Write => PERM_WRITE, + Perm::Exec => PERM_EXEC, + } + } +} + +impl Perm { + /// Human-readable single-letter set for a permission byte. + pub fn to_string_perms(p: u8) -> String { + let mut s = String::new(); + if p & PERM_READ != 0 { + s.push('r'); + } + if p & PERM_WRITE != 0 { + s.push('w'); + } + if p & PERM_EXEC != 0 { + s.push('x'); + } + s + } +} + +/// A principal: the owner half of a `HELLO` identity (a `CubeHeader` +/// `owner_local_user` plus an optional `owner_remote_user`). +#[derive(Clone, Eq, PartialEq, Debug)] +pub struct Owner { + /// Local owner user (maps to `CubeHeader::owner_local_user`). + pub local: String, + /// Optional remote owner user (maps to `owner_remote_user`). `None` and + /// `Some("")` are treated as distinct to avoid surprising matches. + pub remote: Option, +} + +impl Owner { + /// An owner with only a local user. + pub fn new(local: impl Into) -> Self { + Owner { + local: local.into(), + remote: None, + } + } + + /// An owner with a local and a remote user. + pub fn with_remote(local: impl Into, remote: impl Into) -> Self { + Owner { + local: local.into(), + remote: Some(remote.into()), + } + } +} + +impl From<&TenantIdentity> for Owner { + fn from(id: &TenantIdentity) -> Self { + Owner { + local: id.owner_local.clone(), + remote: id.owner_remote.clone(), + } + } +} + +/// A delegated permission: `granter` lets `grantee` perform `perms` over +/// `scope` (or globally when `scope` is `None`). +/// +/// Serialized to a compact std-only JSON object (see [`Grant::to_json`] / +/// [`Grant::from_json`]); `seq` is a per-tenant monotonic id used for +/// diagnostics and future conflict resolution. +#[derive(Clone, Eq, PartialEq, Debug)] +pub struct Grant { + /// Who issued the grant (and the only one who may revoke it). + pub granter: Owner, + /// Who the grant authorizes. + pub grantee: Owner, + /// Permission bitmask (`PERM_READ` / `PERM_WRITE` / `PERM_EXEC`). + pub perms: u8, + /// Coordinate scope. `None` = global; an axis of `0` = wildcard for that axis. + pub scope: Option, + /// Monotonic per-tenant id (diagnostics / ordering). + pub seq: u64, +} + +impl Grant { + /// True if this grant authorizes `want` over `coord`. + pub fn allows(&self, coord: &Czyx, want: Perm) -> bool { + if self.perms & want.bit() == 0 { + return false; + } + match self.scope { + None => true, + Some(sc) => scope_covers(sc, *coord), + } + } + + /// Compact std-only JSON form of this grant. + pub fn to_json(&self) -> String { + let mut s = String::new(); + s.push('{'); + s.push_str(&format!( + "\"granter_local\":{}", + json_str(&self.granter.local) + )); + if let Some(r) = &self.granter.remote { + s.push_str(&format!(",\"granter_remote\":{}", json_str(r))); + } + s.push_str(&format!( + ",\"grantee_local\":{}", + json_str(&self.grantee.local) + )); + if let Some(r) = &self.grantee.remote { + s.push_str(&format!(",\"grantee_remote\":{}", json_str(r))); + } + s.push_str(&format!( + ",\"perms\":{}", + json_str(&Perm::to_string_perms(self.perms)) + )); + match self.scope { + Some(sc) => s.push_str(&format!( + ",\"scope\":{}", + json_str(&format!("{}.{}.{}.{}", sc.c, sc.z, sc.y, sc.x)) + )), + None => s.push_str(",\"scope\":null"), + } + s.push_str(&format!(",\"seq\":{}", self.seq)); + s.push('}'); + s + } + + /// Parse a grant from its [`to_json`] form. Returns `None` on any malformed field. + pub fn from_json(obj: &str) -> Option { + let gl = field_str(obj, "granter_local")?; + let gr = field_str(obj, "granter_remote"); + let el = field_str(obj, "grantee_local")?; + let er = field_str(obj, "grantee_remote"); + let perms = field_str(obj, "perms")?; + let perms = perms_from_str(&perms)?; + let scope = match field_raw(obj, "scope").as_deref() { + Some("null") | None => None, + Some(s) => { + let inner = unjson_str(s); + Some(crate::commands::parse_coord(&inner)?) + } + }; + let seq = field_raw(obj, "seq") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + Some(Grant { + granter: Owner::new(gl).with_opt_remote(gr), + grantee: Owner::new(el).with_opt_remote(er), + perms, + scope, + seq, + }) + } +} + +impl Owner { + fn with_opt_remote(self, remote: Option) -> Owner { + match remote { + Some(r) => Owner { + local: self.local, + remote: Some(r), + }, + None => self, + } + } +} + +/// The null-space coordinate holding the tenant's grant table (a JSON array). +/// Lives in Null cube 1 (the same family `cube-demo` / `seal` use for +/// key material), kept distinct by X. +pub const GRANT_BUCKET: Czyx = Czyx::new(0, 1, 0, 1); + +/// Parse a permission string (`r` / `w` / `x` and any combination) into a +/// bitmask. Returns `None` for an empty or invalid string. +pub fn perms_from_str(s: &str) -> Option { + let mut p = 0u8; + for c in s.chars() { + match c { + 'r' => p |= PERM_READ, + 'w' => p |= PERM_WRITE, + 'x' => p |= PERM_EXEC, + _ => return None, + } + } + if p == 0 { + None + } else { + Some(p) + } +} + +/// Read the tenant's grant table from the store. Stored as a proper record +/// (`doc_type = "grant-table"`) so it survives checkpoint/restore exactly like +/// any other record. +pub fn read_bucket(store: &ConcurrentStore) -> Vec { + match store.get_record(&GRANT_BUCKET) { + None => Vec::new(), + Some((_, bytes)) => { + let text = String::from_utf8_lossy(&bytes); + extract_objects(&text) + .into_iter() + .filter_map(Grant::from_json) + .collect() + } + } +} + +/// Overwrite the tenant's grant table. +fn write_bucket(store: &ConcurrentStore, grants: &[Grant]) { + let mut s = String::from("["); + for (i, g) in grants.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&g.to_json()); + } + s.push(']'); + store.put_record(GRANT_BUCKET, &grant_header(), &s.into_bytes()); +} + +/// Header stamped on the grant-table record. Tagging it `doc_type = +/// "grant-table"` means checkpoints treat it like any other record (no loss), +/// and a future `query_doc_type("grant-table")` can enumerate it. +pub fn grant_header() -> CubeHeader { + CubeHeader { + flags: cubecoords::HeaderFlags(cubecoords::HeaderFlags::DOC_TYPE), + title: None, + doc_type: Some("grant-table".to_string()), + created_at: None, + size_bytes: None, + owner_local_user: None, + owner_remote_user: None, + linked_records: Vec::new(), + total_accesses: 0, + total_remote_accesses: 0, + last_access: None, + last_remote_access: None, + } +} + +/// Issue a grant. Returns the new grant's `seq`. +pub fn grant( + store: &ConcurrentStore, + granter: &Owner, + grantee: &Owner, + perms: u8, + scope: Option, +) -> Result { + let mut grants = read_bucket(store); + let seq = grants.iter().map(|g| g.seq).max().unwrap_or(0) + 1; + grants.push(Grant { + granter: granter.clone(), + grantee: grantee.clone(), + perms, + scope, + seq, + }); + write_bucket(store, &grants); + Ok(seq) +} + +/// Revoke grants matching `granter` + `grantee` (+ `scope` when given). +/// Returns the number of grants removed. +pub fn revoke( + store: &ConcurrentStore, + granter: &Owner, + grantee: &Owner, + scope: Option, +) -> usize { + let before = read_bucket(store); + // Keep every grant that does NOT match the revoke criteria; the rest are + // removed. + let kept: Vec = before + .iter() + .filter(|g| { + !(g.granter == *granter + && g.grantee == *grantee + && scope.map_or(true, |sc| g.scope == Some(sc))) + }) + .cloned() + .collect(); + let removed = before.len() - kept.len(); + write_bucket(store, &kept); + removed +} + +/// All grants whose `grantee` is `who`. +pub fn grants_for(store: &ConcurrentStore, who: &Owner) -> Vec { + read_bucket(store) + .into_iter() + .filter(|g| g.grantee == *who) + .collect() +} + +/// True if `who` holds a grant authorizing `want` over `coord`. +pub fn grant_allows(store: &ConcurrentStore, who: &Owner, coord: &Czyx, want: Perm) -> bool { + grants_for(store, who).iter().any(|g| g.allows(coord, want)) +} + +/// Does `scope` cover `coord`? A `0` axis in the scope is a wildcard. +fn scope_covers(scope: Czyx, coord: Czyx) -> bool { + (scope.c == 0 || scope.c == coord.c) + && (scope.z == 0 || scope.z == coord.z) + && (scope.y == 0 || scope.y == coord.y) + && (scope.x == 0 || scope.x == coord.x) +} + +/// Quote a string as a JSON string literal (escaping `"`, `\`, and control chars). +fn json_str(s: &str) -> String { + let mut o = String::with_capacity(s.len() + 2); + o.push('"'); + for c in s.chars() { + match c { + '"' => o.push_str("\\\""), + '\\' => o.push_str("\\\\"), + '\n' => o.push_str("\\n"), + '\r' => o.push_str("\\r"), + '\t' => o.push_str("\\t"), + _ => o.push(c), + } + } + o.push('"'); + o +} + +/// Unquote a JSON string literal (reverse of [`json_str`]). +fn unjson_str(s: &str) -> String { + let inner = s + .strip_prefix('"') + .and_then(|x| x.strip_suffix('"')) + .unwrap_or(s); + let mut o = String::new(); + let mut chars = inner.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('"') => o.push('"'), + Some('\\') => o.push('\\'), + Some('n') => o.push('\n'), + Some('r') => o.push('\r'), + Some('t') => o.push('\t'), + Some(other) => o.push(other), + None => {} + } + } else { + o.push(c); + } + } + o +} + +/// Extract the raw JSON value for `key` from an object string +/// (`"key":`). Returns the value token verbatim: a quoted string (with +/// quotes), `null`, or a bare number. `None` if the key is absent. +fn field_raw(obj: &str, key: &str) -> Option { + let pat = format!("\"{key}\""); + let idx = obj.find(&pat)?; + let after = &obj[idx + pat.len()..]; + let after = after.trim_start().strip_prefix(':')?.trim_start(); + if after.starts_with('"') { + let bytes = after.as_bytes(); + let mut end = 1; + while end < bytes.len() { + if bytes[end] == b'"' && bytes[end - 1] != b'\\' { + break; + } + end += 1; + } + Some(after[..=end].to_string()) + } else if after.starts_with("null") { + Some("null".to_string()) + } else { + let end = after.find(|c: char| !c.is_ascii_digit())?; + Some(after[..end].to_string()) + } +} + +/// Like [`field_raw`] but unquotes string values and maps `null` to `None`. +fn field_str(obj: &str, key: &str) -> Option { + field_raw(obj, key).and_then(|v| { + if v == "null" { + None + } else { + Some(unjson_str(&v)) + } + }) +} + +/// Extract each top-level `{...}` object from a JSON array/text. +fn extract_objects(s: &str) -> Vec<&str> { + let bytes = s.as_bytes(); + let mut out = Vec::new(); + let mut depth = 0i32; + let mut start = None; + for (i, &b) in bytes.iter().enumerate() { + if b == b'{' { + if depth == 0 { + start = Some(i); + } + depth += 1; + } else if b == b'}' { + depth -= 1; + if depth == 0 { + if let Some(st) = start { + out.push(&s[st..=i]); + } + start = None; + } + } + } + out +} + +// (Arc is imported in the test module only) + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::Session; + use crate::store::DurabilityConfig; + use crate::tenant::{TenantId, TenantIdentity}; + use std::str::FromStr; + use std::sync::Arc; + + fn test_id(local: &str) -> TenantIdentity { + TenantIdentity { + tenant: TenantId::from_str("t").unwrap(), + owner_local: local.to_string(), + owner_remote: None, + } + } + + fn session(store: Arc, local: &str) -> Session { + let mut s = Session::with_store(store); + s.set_identity(test_id(local)); + s.set_enforce_owner(true); + s + } + + #[test] + fn grant_json_roundtrip() { + let g = Grant { + granter: Owner::new("alice"), + grantee: Owner::with_remote("bob", "remote1"), + perms: PERM_READ | PERM_WRITE, + scope: Some(Czyx::new(5, 1, 1, 1)), + seq: 42, + }; + let j = g.to_json(); + let g2 = Grant::from_json(&j).expect("parse"); + assert_eq!(g, g2, "grant JSON round-trips"); + } + + #[test] + fn grant_json_handles_null_scope() { + let g = Grant { + granter: Owner::new("alice"), + grantee: Owner::new("bob"), + perms: PERM_WRITE, + scope: None, + seq: 1, + }; + let g2 = Grant::from_json(&g.to_json()).expect("parse"); + assert_eq!(g, g2); + } + + #[test] + fn perms_parse() { + assert_eq!( + perms_from_str("rwx"), + Some(PERM_READ | PERM_WRITE | PERM_EXEC) + ); + assert_eq!(perms_from_str("w"), Some(PERM_WRITE)); + assert_eq!(perms_from_str(""), None); + assert_eq!(perms_from_str("q"), None); + } + + #[test] + fn scope_wildcard_semantics() { + let scope = Czyx::new(5, 1, 0, 0); // class 5, z=1, y/x wildcard + assert!(scope_covers(scope, Czyx::new(5, 1, 7, 9))); + assert!(!scope_covers(scope, Czyx::new(5, 2, 7, 9))); // z mismatch + assert!(!scope_covers(scope, Czyx::new(6, 1, 7, 9))); // c mismatch + } + + #[test] + fn grant_allows_delegated_write() { + let store = Arc::new(ConcurrentStore::memory()); + let mut alice = session(store.clone(), "alice"); + let mut bob = session(store.clone(), "bob"); + let mut carol = session(store.clone(), "carol"); + + // alice writes a record she owns + alice + .exec("prog /c005/z001/y001/x001 const 1 halt") + .expect("alice writes"); + + // bob cannot overwrite alice's record (no grant) + assert!( + bob.exec("prog /c005/z001/y001/x001 const 2 halt").is_err(), + "bob blocked without a grant" + ); + + // alice grants bob write on exactly that coord + alice.exec("grant bob w 5.1.1.1").expect("grant issued"); + + // now bob can write + assert!( + bob.exec("prog /c005/z001/y001/x001 const 3 halt").is_ok(), + "bob allowed by grant" + ); + + // a third owner with no grant is still blocked + assert!( + carol + .exec("prog /c005/z001/y001/x001 const 4 halt") + .is_err(), + "carol still blocked" + ); + } + + #[test] + fn revoke_removes_grant() { + let store = Arc::new(ConcurrentStore::memory()); + let mut alice = session(store.clone(), "alice"); + let mut bob = session(store.clone(), "bob"); + + // alice owns two coords in class 6 + alice + .exec("prog /c006/z001/y001/x001 const 1 halt") + .unwrap(); + alice + .exec("prog /c006/z001/y001/x002 const 1 halt") + .unwrap(); + + // grant bob write over the whole class-6 subtree + alice.exec("grant bob w 6.0.0.0").unwrap(); + assert!( + bob.exec("prog /c006/z001/y001/x001 const 2 halt").is_ok(), + "bob writes under grant (takes ownership of x001)" + ); + + // revoke + let removed = alice + .exec("revoke bob 6.0.0.0") + .unwrap() + .contains("revoked 1"); + assert!(removed, "exactly one grant revoked"); + + // bob tries x002, which alice still owns -> blocked after revoke + assert!( + bob.exec("prog /c006/z001/y001/x002 const 3 halt").is_err(), + "after revoke, bob blocked on alice-owned coord" + ); + } + + #[test] + fn grant_scope_prefix() { + let store = Arc::new(ConcurrentStore::memory()); + let mut alice = session(store.clone(), "alice"); + let mut bob = session(store.clone(), "bob"); + + alice + .exec("prog /c007/z001/y001/x001 const 1 halt") + .unwrap(); + alice + .exec("prog /c007/z002/y001/x001 const 1 halt") + .unwrap(); + + // grant bob write only on the z=1 subtree + alice.exec("grant bob w 7.1.0.0").unwrap(); + + assert!( + bob.exec("prog /c007/z001/y001/x001 const 2 halt").is_ok(), + "in-scope coord allowed" + ); + assert!( + bob.exec("prog /c007/z002/y001/x001 const 2 halt").is_err(), + "out-of-scope coord blocked" + ); + } + + #[test] + fn grant_requires_identity() { + let store = Arc::new(ConcurrentStore::memory()); + let mut anon = Session::with_store(store); + anon.set_enforce_owner(true); + // no HELLO identity -> grant rejected + assert!(anon.exec("grant bob w 1.0.0.0").is_err()); + } + + #[test] + fn grant_survives_reopen() { + let dir = + std::env::temp_dir().join(format!("cubelinux-grant-{}-{}", std::process::id(), "g1")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let db = dir.join("cube-store.json"); + let wal = dir.join("cube-store.wal"); + let rec = dir.join("cube-store.recovery.ndjson"); + + let store = Arc::new( + ConcurrentStore::open( + db.to_str().unwrap(), + wal.to_str().unwrap(), + rec.to_str().unwrap(), + DurabilityConfig::default(), + ) + .unwrap(), + ); + let mut alice = session(store.clone(), "alice"); + alice + .exec("prog /c008/z001/y001/x001 const 1 halt") + .unwrap(); + alice.exec("grant bob w 8.1.1.1").unwrap(); + store.checkpoint(); + drop(store); + + // reopen from disk + let store2 = Arc::new( + ConcurrentStore::open( + db.to_str().unwrap(), + wal.to_str().unwrap(), + rec.to_str().unwrap(), + DurabilityConfig::default(), + ) + .unwrap(), + ); + let mut bob = session(store2.clone(), "bob"); + assert!( + bob.exec("prog /c008/z001/y001/x001 const 2 halt").is_ok(), + "grant survived reopen from disk" + ); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/cubesys/src/lib.rs b/cubesys/src/lib.rs index f9e4998..b9d1a15 100644 --- a/cubesys/src/lib.rs +++ b/cubesys/src/lib.rs @@ -44,6 +44,10 @@ use cubestore::{CubeBackend, CubeStore, HashBackend}; /// `seal`, `open`). Used by the `cube` REPL, the `cubec` socket client, and the /// `cube-server` daemon so all front-ends behave identically. pub mod commands; +/// Permission grants — the PDF's flags 5-19 delegated-grant layer (Task 6b). +/// A `Grant` delegates read/write/exec over a coordinate scope from one owner +/// to another; the tenant's grant table lives at a durable null-space coord. +pub mod grants; /// Length-framed Unix-domain-socket transport shared by client and server. pub mod net; /// Dependency-free JSON snapshot load/dump for daemon store persistence. @@ -284,9 +288,16 @@ pub mod demo { let double_code = vec![Op::Store(0), Op::Load(0), Op::Const(2), Op::Mul, Op::Ret]; let entry_code = vec![Op::Const(21), Op::CallLink(0), Op::Halt]; - let double_coord = - super::store_code_cell(&mut store, double, Kind::Fn, "double", &[], &double_code, None) - .expect("store double"); + let double_coord = super::store_code_cell( + &mut store, + double, + Kind::Fn, + "double", + &[], + &double_code, + None, + ) + .expect("store double"); let entry_coord = super::store_code_cell( &mut store, entry, diff --git a/cubesys/src/store.rs b/cubesys/src/store.rs index 0bc853b..c9f874e 100644 --- a/cubesys/src/store.rs +++ b/cubesys/src/store.rs @@ -602,7 +602,9 @@ impl ConcurrentStore { code: &[Op], owner: Option<&str>, ) -> Result { - let coord = self.with_mut(|store| crate::store_code_cell(store, path, kind, name, links, code, owner))?; + let coord = self.with_mut(|store| { + crate::store_code_cell(store, path, kind, name, links, code, owner) + })?; if let Some(v) = self.get_raw(&coord) { self.log_put(coord, v); }