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.
This commit is contained in:
hermes
2026-08-11 15:01:35 -04:00
parent 16cbf6c3cb
commit e77c650e9c
6 changed files with 923 additions and 74 deletions
+177 -64
View File
@@ -11,6 +11,7 @@
//! survives restarts. Each `exec` takes and returns an `Arc<ConcurrentStore>`
//! 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<String> {
// 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<ConcurrentStore> {
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 <grantee_local[#remote]>".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 <perms: r|w|x>".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 <grantee_local[#remote]>".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 <path>".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<HashBackend> {
/// writing — used to buffer `prog`/`write` mutations during a transaction.
fn scratch_code_coord(path: &str, kind: Kind, name: &str, code: &[Op]) -> Result<Czyx, String> {
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<TenantIdentity>,
record_owner: &Option<String>,
require_identity: bool,
) -> Option<String> {
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());