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
@@ -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<Czyx> }` 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<Czyx> }` 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<u64>` 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.
+5 -1
View File
@@ -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
+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());
+689
View File
@@ -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<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);
}
}
+13 -2
View File
@@ -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,8 +288,15 @@ 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)
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,
+3 -1
View File
@@ -602,7 +602,9 @@ impl ConcurrentStore {
code: &[Op],
owner: Option<&str>,
) -> Result<Czyx, crate::SysError> {
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);
}