//! 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); } }