Files
cubelinux-2/cubesys/src/grants.rs
T
hermes e77c650e9c feat(cubesys): Task 6b — permission grants (PDF flags 5-19)
Add a delegated-grant auth layer:
- cubesys/src/grants.rs: Grant/Owner/Perm model, GRANT_BUCKET at
  Czyx::new(0,1,0,1), grant/revoke/grant_allows, std-only JSON codec.
- Stored via put_record with a doc_type='grant-table' header so it
  survives checkpoint/restore (raw put_raw was dropped on dump_store).
- commands.rs: GRANT/REVOKE opcodes + admit_mutate() enforcement hook
  (owner -> grant -> deny). GRANT/REVOKE require HELLO identity under
  --require-identity.
- Enforce owner-match contract preserved (legacy/tests stay green).
- 11 new grant tests; full ./check quick = 115 pass, clippy -D clean.
2026-08-11 15:01:35 -04:00

690 lines
21 KiB
Rust

//! 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<String>,
}
impl Owner {
/// An owner with only a local user.
pub fn new(local: impl Into<String>) -> Self {
Owner {
local: local.into(),
remote: None,
}
}
/// An owner with a local and a remote user.
pub fn with_remote(local: impl Into<String>, remote: impl Into<String>) -> 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<Czyx>,
/// 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<Grant> {
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::<u64>().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<String>) -> 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<u8> {
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<Grant> {
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<Czyx>,
) -> Result<u64, String> {
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<Czyx>,
) -> usize {
let before = read_bucket(store);
// Keep every grant that does NOT match the revoke criteria; the rest are
// removed.
let kept: Vec<Grant> = 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<Grant> {
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":<value>`). 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<String> {
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<String> {
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<ConcurrentStore>, 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);
}
}