feat(cubesys): Task 6 (B) — require HELLO identity, gate seal/open

Adopted recommendation (B): make owner authority a hard guarantee instead
of the non-breaking opt-in. A mutating op now requires a stamped HELLO
identity; anonymous writes are rejected. seal/open (destructive writes)
are gated the same way, and seal stamps the owner onto the encrypted record.

Design / non-breaking bridge:
- Session gains enforce_owner: bool (default false) so library/REPL/unit
  tests stay permissive — the 26 prior tests + 3 Task-6 tests are unchanged.
- owner_violation() gains require_identity: the (false) path keeps legacy
  behaviour; the (true) path rejects no-identity mutating ops.
- The daemon flips enforce_owner=true on every connection (both HELLO and
  no-HELLO branches), implementing the default --require-identity policy.
- Added --allow-anonymous escape hatch so legacy cubec/stress.sh (which
  send no HELLO) keep working; stress.sh now passes --allow-anonymous.
- Session::set_enforce_owner() accessor so the daemon (separate bin) can
  set the private field.

Verification:
- ./check quick: EXIT=0, fmt+clippy clean, 28 cubesys lib tests (added
  enforce_owner_requires_identity, seal_open_respect_owner).
- Ad-hoc daemon verifier (LE framing) against the rebuilt cube-server:
  anonymous prog/del rejected, HELLO'd owner first-claim + self-overwrite
  allowed, cross-owner overwrite rejected. ALL PASS.

Note: seal's demo key-cell crypto path (KeyCellMissing on the synthetic key
material) is a pre-existing quirk unrelated to this change; the gate fires
before crypto, so the test verifies the gate, not the crypto.
This commit is contained in:
CUBELinux-2
2026-08-11 14:12:42 -04:00
parent abc1b56d24
commit 16cbf6c3cb
3 changed files with 162 additions and 22 deletions
+27 -5
View File
@@ -49,14 +49,25 @@ struct Server {
/// daemon runs in legacy SharedFile mode this is false and a single store
/// backs every tenant.
per_tenant: bool,
/// When true (default daemon policy), every mutating op requires a stamped
/// `HELLO` identity and owner-match enforcement (Task 6 B). When false
/// (`--allow-anonymous`), the legacy permissive behaviour is restored for
/// pre-existing `cubec`/stress.sh clients that never send HELLO.
require_identity: bool,
}
impl Server {
fn new(listener: UnixListener, registry: Arc<TenantRegistry>, per_tenant: bool) -> Self {
fn new(
listener: UnixListener,
registry: Arc<TenantRegistry>,
per_tenant: bool,
require_identity: bool,
) -> Self {
Server {
listener,
registry,
per_tenant,
require_identity,
}
}
@@ -66,6 +77,7 @@ impl Server {
Ok(mut stream) => {
let registry = self.registry.clone();
let per_tenant = self.per_tenant;
let require_identity = self.require_identity;
// Thread-per-connection: each client runs on its own thread
// against its tenant's store.
thread::spawn(move || {
@@ -128,6 +140,7 @@ impl Server {
match resolve_default(&registry, per_tenant) {
Ok(ts) => {
let mut sess = Session::for_tenant(&ts);
sess.set_enforce_owner(require_identity);
handle_command(&mut sess, &mut stream, line);
// Hold the session for the rest of
// the connection (so txns span lines).
@@ -153,6 +166,7 @@ impl Server {
// carries any in-flight transaction across the frames
// that follow (Task 5: BEGIN..COMMIT spanning lines).
let mut session = Session::for_tenant(&ts);
session.set_enforce_owner(require_identity);
// HELLO already consumed; serve subsequent command
// frames on this connection until the peer closes.
@@ -231,7 +245,14 @@ fn main() {
let store_path = arg(&args, "--store").unwrap_or_else(default_store);
let recovery_log = arg(&args, "--recovery-log").unwrap_or_else(default_recovery);
let tenant_dir = arg(&args, "--tenant-dir");
let _deny_unknown = args.iter().any(|a| a == "--deny-unknown-tenant");
let (_deny_unknown, allow_anonymous) = (
args.iter().any(|a| a == "--deny-unknown-tenant"),
args.iter().any(|a| a == "--allow-anonymous"),
);
// Default daemon policy (Task 6 B): require a HELLO identity on every
// mutating op. `--allow-anonymous` restores the legacy permissive mode for
// pre-existing cubec/stress.sh clients that never send HELLO.
let require_identity = !allow_anonymous;
if let Some(parent) = std::path::Path::new(&socket_path).parent() {
let _ = std::fs::create_dir_all(parent);
@@ -281,12 +302,13 @@ fn main() {
}
};
eprintln!(
"cube-server: listening on {socket_path} ({} mode)",
"cube-server: listening on {socket_path} ({} mode, identity enforcement: {})",
if per_tenant {
"per-tenant disk"
} else {
"legacy shared store"
}
},
if require_identity { "required" } else { "anonymous allowed" }
);
// Best-effort initial checkpoint so a crash before the first flush still
@@ -297,7 +319,7 @@ fn main() {
def.store.checkpoint();
}
Server::new(listener, registry, per_tenant).run();
Server::new(listener, registry, per_tenant, require_identity).run();
}
/// Fetch `--key VALUE` from argv, or None.
+131 -16
View File
@@ -64,6 +64,13 @@ pub struct Session {
/// `COMMIT`/`ROLLBACK`; mutating commands buffer into it instead of
/// touching the store live, and reads consult the `BEGIN` snapshot.
txn: Option<Txn>,
/// When true, owner enforcement (Task 6) requires a stamped identity: a
/// mutating op from a session with no `HELLO` identity is rejected, and a
/// HELLO'd owner may only write records it owns. The daemon sets this per
/// connection; the library/REPL/tests leave it false (legacy permissive
/// behaviour, so pre-existing `cubec`/stress.sh flows keep working until
/// the operator opts in via the daemon's `--require-identity` policy).
enforce_owner: bool,
}
impl Default for Session {
@@ -81,6 +88,7 @@ impl Session {
per_cmd: BTreeMap::new(),
identity: None,
txn: None,
enforce_owner: false,
}
}
@@ -92,12 +100,15 @@ impl Session {
per_cmd: BTreeMap::new(),
identity: None,
txn: None,
enforce_owner: false,
}
}
/// Build a session bound to a tenant's store + identity (used by the
/// daemon per connection, so a `BEGIN`/`COMMIT` spanning multiple command
/// frames keeps its txn state on the same `Session`).
/// frames keeps its txn state on the same `Session`). `enforce_owner`
/// starts false; the daemon sets it true on every connection that must
/// require a `HELLO` identity (the operator's `--require-identity` policy).
pub fn for_tenant(ts: &TenantSession) -> Self {
Session {
store: ts.store.clone(),
@@ -105,6 +116,7 @@ impl Session {
per_cmd: BTreeMap::new(),
identity: ts.identity(),
txn: None,
enforce_owner: false,
}
}
@@ -119,6 +131,12 @@ impl Session {
self.identity.clone()
}
/// Enable/disable owner enforcement for this session (Task 6 B). The daemon
/// calls this per connection to apply its `--require-identity` policy.
pub fn set_enforce_owner(&mut self, on: bool) {
self.enforce_owner = on;
}
/// Shared handle to the underlying concurrent store.
pub fn store(&self) -> Arc<ConcurrentStore> {
self.store.clone()
@@ -213,7 +231,7 @@ impl Session {
// our BEGIN and COMMIT, so re-check now (the buffer is re-read
// here, consistent with the single WAL entry T5 writes).
for op in &txn.ops {
if let Some(msg) = owner_violation(&self.identity, &store.owner(&op.coord)) {
if let Some(msg) = owner_violation(&self.identity, &store.owner(&op.coord), self.enforce_owner) {
// Roll the txn back: restore it so the caller can retry
// after resolving the conflict (do not silently drop).
self.txn = Some(txn);
@@ -280,7 +298,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)) {
if let Some(msg) = owner_violation(&self.identity, &store.owner(&coord), self.enforce_owner) {
return Err(msg);
}
let owner = self.identity.as_ref().map(|i| i.owner_local.as_str());
@@ -320,7 +338,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)) {
if let Some(msg) = owner_violation(&self.identity, &store.owner(&coord), self.enforce_owner) {
return Err(msg);
}
let owner = self.identity.as_ref().map(|i| i.owner_local.as_str());
@@ -351,7 +369,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)) {
if let Some(msg) = owner_violation(&self.identity, &store.owner(&coord), self.enforce_owner) {
return Err(msg);
}
if let Some(txn) = self.txn.as_mut() {
@@ -420,6 +438,13 @@ impl Session {
let transform = parse_transform(tf)
.ok_or_else(|| "unknown transform (none|gcm|chacha|xts)".to_string())?;
// 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) {
return Err(msg);
}
if store.get_record(&kc).is_none() {
store.put_raw(kc, b"demo-key-material-32-bytes-long!!".to_vec());
}
@@ -433,9 +458,12 @@ impl Session {
);
if cmd == "seal" {
let (h, body) = store
let (mut h, body) = store
.get_record(&coord)
.ok_or_else(|| format!("seal: no record at {path}"))?;
// Carry the owner onto the encrypted record so the gate keeps
// working after sealing (Task 6 B).
h.owner_local_user = self.identity.as_ref().map(|i| i.owner_local.clone());
store
.with_mut(|s| env.put_encrypted(s, coord, Selector::Slot(0), &body, h))
.map_err(|e| format!("seal: {e:?}"))?;
@@ -490,21 +518,39 @@ fn scratch_code_coord(path: &str, kind: Kind, name: &str, code: &[Op]) -> Result
/// 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.
///
/// Enforcement is opt-in / non-breaking: if the session has no stamped
/// identity (e.g. a `Session::with_store` used by tests, or a client that
/// never sent `HELLO`), every write is permitted. The daemon stamps identity
/// via `HELLO` (Task 3) before any mutating op, so live traffic is gated.
fn owner_violation(identity: &Option<TenantIdentity>, record_owner: &Option<String>) -> Option<String> {
let owner = identity.as_ref().map(|i| i.owner_local.as_str());
match (owner, record_owner) {
// No session identity -> no gate (legacy / test path).
(None, _) => None,
/// `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.as_str() {
if session_owner == record_owner {
None
} else {
Some(format!(
@@ -749,6 +795,75 @@ mod tests {
assert!(r.unwrap_err().contains("owner violation"));
}
#[test]
fn enforce_owner_requires_identity() {
// Task 6 (B): when the daemon policy requires a HELLO identity, a
// mutating op from a session with no identity is rejected.
use crate::tenant::{TenantId, TenantIdentity};
use std::str::FromStr;
let mut s = Session::with_store(Arc::new(ConcurrentStore::memory()));
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.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());
// But once an identity is stamped, the same owner may write.
s.set_identity(TenantIdentity {
tenant: TenantId::from_str("alpha").unwrap(),
owner_local: "alice".to_string(),
owner_remote: None,
});
assert!(s.exec("prog /c053/z001/y001/x001 const 2 halt").is_ok());
}
#[test]
fn seal_open_respect_owner() {
// Task 6 (B): the owner gate is wired into the seal/open branch and
// fires before any crypto. Cross-owner and anonymous access are
// rejected at the gate; we don't exercise the (pre-existing demo)
// key-cell crypto here.
use crate::tenant::{TenantConfig, TenantId, TenantIdentity, TenantRegistry};
use std::str::FromStr;
let registry = TenantRegistry::with_config(TenantConfig::Memory);
let ts = registry
.get_or_provision(TenantId::from_str("alpha").unwrap())
.unwrap();
ts.set_identity(TenantIdentity {
tenant: TenantId::from_str("alpha").unwrap(),
owner_local: "alice".to_string(),
owner_remote: None,
});
let mut s = Session::for_tenant(&ts);
s.set_enforce_owner(true);
// Alice writes her code cell (owned by alice).
assert!(s.exec("prog /c054/z001/y001/x001 const 7 halt").is_ok());
// Bob (enforce_owner on) cannot seal alice's record -> gate rejects
// before any crypto runs.
ts.set_identity(TenantIdentity {
tenant: TenantId::from_str("alpha").unwrap(),
owner_local: "bob".to_string(),
owner_remote: None,
});
let mut b = Session::for_tenant(&ts);
b.set_enforce_owner(true);
let r = b.exec("seal /c054/z001/y001/x001 5.5.5.5 none");
assert!(r.is_err(), "non-owner seal must be rejected at the gate");
assert!(r.unwrap_err().contains("owner violation"));
// An anonymous session (enforce_owner on, no identity) is also rejected
// on seal/open.
let mut anon = Session::with_store(Arc::new(ConcurrentStore::memory()));
anon.set_enforce_owner(true);
let r = anon.exec("seal /c054/z001/y001/x001 5.5.5.5 none");
assert!(r.is_err(), "anonymous seal must be rejected");
assert!(r.unwrap_err().contains("require a HELLO identity"));
}
#[test]
fn txn_isolation_begin_snapshot_hides_live_writer() {
// Stream A opens a txn and snapshots; stream B mutates the live store.