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:
@@ -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(®istry, 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
@@ -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.
|
||||
|
||||
+4
-1
@@ -32,7 +32,10 @@ STORE="$TMPD/store.json"
|
||||
trap 'kill $DPID 2>/dev/null; wait $DPID 2>/dev/null; rm -rf "$TMPD"' EXIT
|
||||
|
||||
echo "starting stress daemon on $SOCK ..."
|
||||
"$SRV" --socket "$SOCK" --store "$STORE" >"$TMPD/daemon.log" 2>&1 &
|
||||
# Legacy clients (cubec in this script) send no HELLO, so run the daemon in
|
||||
# anonymous-allowed mode; owner enforcement is exercised by the unit/ad-hoc
|
||||
# verifiers, not the stress harness.
|
||||
"$SRV" --socket "$SOCK" --store "$STORE" --allow-anonymous >"$TMPD/daemon.log" 2>&1 &
|
||||
DPID=$!
|
||||
# wait for socket (max ~15s)
|
||||
for i in $(seq 1 15); do [ -S "$SOCK" ] && break; sleep 1; done
|
||||
|
||||
Reference in New Issue
Block a user