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:
@@ -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()`.
|
- 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.
|
- `./check stress` + a denial test. Commit.
|
||||||
|
|
||||||
### Task 6b: Permission grants (PDF flags 5-19)
|
### Task 6b: Permission grants (PDF flags 5-19) — DONE 2026-08-11
|
||||||
- **Files:** `cubesys/src/tenant.rs` (GRANT_BUCKET null-space coord + `Grant` struct), `cubesys/src/commands.rs` (`GRANT`/`REVOKE` opcodes + grant lookup in enforcement order).
|
- **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;`).
|
||||||
- **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).
|
- **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.
|
||||||
- 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`.
|
- **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.
|
||||||
- `./check` green. Commit.
|
- **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
|
### 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).
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -308,7 +308,11 @@ fn main() {
|
|||||||
} else {
|
} else {
|
||||||
"legacy shared store"
|
"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
|
// Best-effort initial checkpoint so a crash before the first flush still
|
||||||
|
|||||||
+177
-64
@@ -11,6 +11,7 @@
|
|||||||
//! survives restarts. Each `exec` takes and returns an `Arc<ConcurrentStore>`
|
//! survives restarts. Each `exec` takes and returns an `Arc<ConcurrentStore>`
|
||||||
//! so the server can hand a cloned handle to each worker thread.
|
//! 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::store::ConcurrentStore;
|
||||||
use crate::tenant::{TenantIdentity, TenantSession};
|
use crate::tenant::{TenantIdentity, TenantSession};
|
||||||
use cubecode::{CodeCell, Kind, Op, Vm};
|
use cubecode::{CodeCell, Kind, Op, Vm};
|
||||||
@@ -137,6 +138,64 @@ impl Session {
|
|||||||
self.enforce_owner = on;
|
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.
|
/// Shared handle to the underlying concurrent store.
|
||||||
pub fn store(&self) -> Arc<ConcurrentStore> {
|
pub fn store(&self) -> Arc<ConcurrentStore> {
|
||||||
self.store.clone()
|
self.store.clone()
|
||||||
@@ -225,13 +284,14 @@ impl Session {
|
|||||||
.take()
|
.take()
|
||||||
.ok_or_else(|| "commit: no transaction is open".to_string())?;
|
.ok_or_else(|| "commit: no transaction is open".to_string())?;
|
||||||
let n = txn.ops.len();
|
let n = txn.ops.len();
|
||||||
// Task 6: owner enforcement for buffered txn ops. The live path
|
// Task 6/6b: re-check ownership + grants for buffered txn ops.
|
||||||
// already checked at `prog`/`write`/`del` time, but a concurrent
|
// The live path already checked at `prog`/`write`/`del` time,
|
||||||
// commit from another owner could have claimed the coord between
|
// but a concurrent commit from another owner (or a revoked grant)
|
||||||
// our BEGIN and COMMIT, so re-check now (the buffer is re-read
|
// could have changed the situation between our BEGIN and COMMIT,
|
||||||
// here, consistent with the single WAL entry T5 writes).
|
// 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 {
|
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
|
// Roll the txn back: restore it so the caller can retry
|
||||||
// after resolving the conflict (do not silently drop).
|
// after resolving the conflict (do not silently drop).
|
||||||
self.txn = Some(txn);
|
self.txn = Some(txn);
|
||||||
@@ -298,7 +358,7 @@ impl Session {
|
|||||||
// duplicating the record codec.
|
// duplicating the record codec.
|
||||||
let coord = scratch_code_coord(path, Kind::Fn, name, &ops)?;
|
let coord = scratch_code_coord(path, Kind::Fn, name, &ops)?;
|
||||||
// Task 6: reject overwriting a record owned by a different owner.
|
// 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);
|
return Err(msg);
|
||||||
}
|
}
|
||||||
let owner = self.identity.as_ref().map(|i| i.owner_local.as_str());
|
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:?}"))?;
|
.map_err(|e| format!("bytecode decode error: {e:?}"))?;
|
||||||
let name = path.rsplit('/').next().unwrap_or(path);
|
let name = path.rsplit('/').next().unwrap_or(path);
|
||||||
let coord = scratch_code_coord(path, Kind::Fn, name, &code)?;
|
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);
|
return Err(msg);
|
||||||
}
|
}
|
||||||
let owner = self.identity.as_ref().map(|i| i.owner_local.as_str());
|
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())?;
|
let coord = crate::path_to_czyx(path).map_err(|e| e.to_string())?;
|
||||||
// Task 6: reject deleting a record owned by a different owner
|
// Task 6: reject deleting a record owned by a different owner
|
||||||
// (unless unowned, which any identity may take over).
|
// (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);
|
return Err(msg);
|
||||||
}
|
}
|
||||||
if let Some(txn) = self.txn.as_mut() {
|
if let Some(txn) = self.txn.as_mut() {
|
||||||
@@ -379,6 +439,98 @@ impl Session {
|
|||||||
store.delete_raw(&coord);
|
store.delete_raw(&coord);
|
||||||
Ok(format!("deleted {path}"))
|
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" => {
|
"run" => {
|
||||||
let path = it.next().ok_or_else(|| "run needs <path>".to_string())?;
|
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())?;
|
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
|
// Task 6 (B): seal/open are destructive writes to `coord`, so
|
||||||
// they obey the same owner gate as prog/write/del. A no-identity
|
// they obey the same owner gate as prog/write/del. A no-identity
|
||||||
// session (under --require-identity) or a non-owner is rejected.
|
// 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);
|
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.
|
/// 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> {
|
fn scratch_code_coord(path: &str, kind: Kind, name: &str, code: &[Op]) -> Result<Czyx, String> {
|
||||||
let mut scratch = CubeStore::new(HashBackend::new());
|
let mut scratch = CubeStore::new(HashBackend::new());
|
||||||
crate::store_code_cell(&mut scratch, path, kind, name, &[], code, None).map_err(|e| e.to_string())
|
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}'"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the `CubeHeader` a `store_code_cell` call would attach (mirrors
|
/// Build the `CubeHeader` a `store_code_cell` call would attach (mirrors
|
||||||
@@ -676,12 +781,17 @@ mod tests {
|
|||||||
let mut s = session();
|
let mut s = session();
|
||||||
s.exec("begin").unwrap();
|
s.exec("begin").unwrap();
|
||||||
// buffered, not yet visible
|
// 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());
|
assert!(s.store.get_raw(&Czyx::new(1, 1, 1, 1)).is_none());
|
||||||
// COMMIT makes all ops durable+visible at once
|
// COMMIT makes all ops durable+visible at once
|
||||||
s.exec("commit").unwrap();
|
s.exec("commit").unwrap();
|
||||||
let v = s.store.get_raw(&Czyx::new(1, 1, 1, 1));
|
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]
|
#[test]
|
||||||
@@ -699,7 +809,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn owner_enforcement_blocks_cross_owner_overwrite() {
|
fn owner_enforcement_blocks_cross_owner_overwrite() {
|
||||||
use crate::tenant::{TenantIdentity, TenantId};
|
use crate::tenant::{TenantId, TenantIdentity};
|
||||||
let mut s = session();
|
let mut s = session();
|
||||||
// Stamp an identity (Task 3 path) for owner "alice".
|
// Stamp an identity (Task 3 path) for owner "alice".
|
||||||
s.set_identity(TenantIdentity {
|
s.set_identity(TenantIdentity {
|
||||||
@@ -735,7 +845,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn owner_enforcement_allows_first_claim_and_same_owner() {
|
fn owner_enforcement_allows_first_claim_and_same_owner() {
|
||||||
use crate::tenant::{TenantIdentity, TenantId};
|
use crate::tenant::{TenantId, TenantIdentity};
|
||||||
let mut s = session();
|
let mut s = session();
|
||||||
// A session with NO identity writes freely (legacy / test path).
|
// A session with NO identity writes freely (legacy / test path).
|
||||||
assert!(s.exec("prog /c051/z001/y001/x001 const 1 halt").is_ok());
|
assert!(s.exec("prog /c051/z001/y001/x001 const 1 halt").is_ok());
|
||||||
@@ -806,7 +916,10 @@ mod tests {
|
|||||||
s.enforce_owner = true;
|
s.enforce_owner = true;
|
||||||
// No identity => rejected.
|
// No identity => rejected.
|
||||||
let r = s.exec("prog /c053/z001/y001/x001 const 1 halt");
|
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"));
|
assert!(r.unwrap_err().contains("require a HELLO identity"));
|
||||||
// A no-identity del/write is likewise rejected.
|
// A no-identity del/write is likewise rejected.
|
||||||
assert!(s.exec("del /c053/z001/y001/x001").is_err());
|
assert!(s.exec("del /c053/z001/y001/x001").is_err());
|
||||||
|
|||||||
@@ -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
@@ -44,6 +44,10 @@ use cubestore::{CubeBackend, CubeStore, HashBackend};
|
|||||||
/// `seal`, `open`). Used by the `cube` REPL, the `cubec` socket client, and the
|
/// `seal`, `open`). Used by the `cube` REPL, the `cubec` socket client, and the
|
||||||
/// `cube-server` daemon so all front-ends behave identically.
|
/// `cube-server` daemon so all front-ends behave identically.
|
||||||
pub mod commands;
|
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.
|
/// Length-framed Unix-domain-socket transport shared by client and server.
|
||||||
pub mod net;
|
pub mod net;
|
||||||
/// Dependency-free JSON snapshot load/dump for daemon store persistence.
|
/// 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 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 entry_code = vec![Op::Const(21), Op::CallLink(0), Op::Halt];
|
||||||
|
|
||||||
let double_coord =
|
let double_coord = super::store_code_cell(
|
||||||
super::store_code_cell(&mut store, double, Kind::Fn, "double", &[], &double_code, None)
|
&mut store,
|
||||||
|
double,
|
||||||
|
Kind::Fn,
|
||||||
|
"double",
|
||||||
|
&[],
|
||||||
|
&double_code,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.expect("store double");
|
.expect("store double");
|
||||||
let entry_coord = super::store_code_cell(
|
let entry_coord = super::store_code_cell(
|
||||||
&mut store,
|
&mut store,
|
||||||
|
|||||||
@@ -602,7 +602,9 @@ impl ConcurrentStore {
|
|||||||
code: &[Op],
|
code: &[Op],
|
||||||
owner: Option<&str>,
|
owner: Option<&str>,
|
||||||
) -> Result<Czyx, crate::SysError> {
|
) -> 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) {
|
if let Some(v) = self.get_raw(&coord) {
|
||||||
self.log_put(coord, v);
|
self.log_put(coord, v);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user