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:
+177
-64
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user