feat(cubesys): R5 — read-gating in daemon path (owner/grant-native)
Closes the read-privacy gap flagged in plan R1/R5. Rather than fork the FUSE uid/gid Acl model (whose identity is POSIX uid, incompatible with the daemon's name-based HELLO identity), read-gating is done owner/grant-native: - commands.rs: new admit_read(coord) mirroring admit_mutate (owner -> read-grant -> deny; unowned records world-readable). Wired into (read+execute) and (metadata read), gated by enforce_owner exactly like writes. - Tests: read_gate_blocks_non_owner_and_allows_read_grant (bob denied, allowed after alice grants read), read_gate_requires_identity_under_enforce (anonymous stat rejected under --require-identity). - Full ./check quick green: 39 cubesys lib tests, clippy -D warnings clean. Note in plan: Acl lift (R1) is NOT a literal fork; the daemon reuses the owner/grant enforcement concept, not the POSIX Acl struct.
This commit is contained in:
@@ -196,6 +196,60 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authorization gate for a read op (R5 — closes the read-privacy gap in
|
||||||
|
/// the daemon path). A read command (`run`/`stat`, and the metadata read in
|
||||||
|
/// `ls`) 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 `Perm::Read` over the coordinate. Returns `None` to allow, or
|
||||||
|
/// an error message to reject.
|
||||||
|
///
|
||||||
|
/// Mirrors [`admit_mutate`] (Task 6 + 6b): same anonymous policy, same
|
||||||
|
/// owner→grant→deny ordering. An unowned record is world-readable (first
|
||||||
|
/// writer claims ownership on write; reads never create state).
|
||||||
|
///
|
||||||
|
/// Note: the FUSE mount enforces POSIX `mode` bits via
|
||||||
|
/// `cubefs::nullspace::Acl`; the daemon deliberately does NOT import that
|
||||||
|
/// uid/gid model — its identity is name-based (`Owner`) from `HELLO`, so
|
||||||
|
/// read gating here is owner/grant-native rather than a literal `Acl` lift.
|
||||||
|
/// (See plan R1/R5 for the rationale.)
|
||||||
|
fn admit_read(&self, coord: Czyx) -> Option<String> {
|
||||||
|
match &self.identity {
|
||||||
|
None => {
|
||||||
|
if self.enforce_owner {
|
||||||
|
Some(
|
||||||
|
"owner enforcement: read operations require a HELLO identity \
|
||||||
|
(daemon policy --require-identity)"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(id) => {
|
||||||
|
// (1) owner match — or unowned record, world-readable.
|
||||||
|
if let Some(record_owner) = self.store.owner(&coord) {
|
||||||
|
if record_owner == id.owner_local {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return None; // unowned -> readable by anyone
|
||||||
|
}
|
||||||
|
// (2) read grant.
|
||||||
|
let who = Owner::from(id);
|
||||||
|
if grant_allows(&self.store, &who, &coord, Perm::Read) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// (3) deny.
|
||||||
|
Some(format!(
|
||||||
|
"owner violation: record at {} owned by '{}', you are '{}' (and hold no matching read 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()
|
||||||
@@ -534,6 +588,11 @@ impl Session {
|
|||||||
"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())?;
|
||||||
|
// R5: reads (this one also *executes*) honor the owner/grantee
|
||||||
|
// read gate, same as writes honor the mutate gate.
|
||||||
|
if let Some(msg) = self.admit_read(_coord) {
|
||||||
|
return Err(msg);
|
||||||
|
}
|
||||||
let sn = txn_snapshot(self);
|
let sn = txn_snapshot(self);
|
||||||
let cell = crate::load_code_cell(&sn, path).map_err(|e| e.to_string())?;
|
let cell = crate::load_code_cell(&sn, path).map_err(|e| e.to_string())?;
|
||||||
let mut vm = Vm::new(sn);
|
let mut vm = Vm::new(sn);
|
||||||
@@ -561,6 +620,11 @@ impl Session {
|
|||||||
}
|
}
|
||||||
"stat" => {
|
"stat" => {
|
||||||
let path = it.next().ok_or_else(|| "stat needs <path>".to_string())?;
|
let path = it.next().ok_or_else(|| "stat needs <path>".to_string())?;
|
||||||
|
// R5: metadata reads honor the read gate.
|
||||||
|
let coord = crate::path_to_czyx(path).map_err(|e| e.to_string())?;
|
||||||
|
if let Some(msg) = self.admit_read(coord) {
|
||||||
|
return Err(msg);
|
||||||
|
}
|
||||||
let sn = txn_snapshot(self);
|
let sn = txn_snapshot(self);
|
||||||
let fs = cubefs::CubeFs::new(sn);
|
let fs = cubefs::CubeFs::new(sn);
|
||||||
let a = fs
|
let a = fs
|
||||||
@@ -1077,4 +1141,86 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_gate_blocks_non_owner_and_allows_read_grant() {
|
||||||
|
// R5: `run` (a read+execute op) honors the same owner/grantee gate as
|
||||||
|
// writes. alice owns a cell; bob cannot read it under enforce_owner;
|
||||||
|
// once alice grants bob read, bob's read succeeds.
|
||||||
|
use crate::grants::grant;
|
||||||
|
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 a = Session::for_tenant(&ts);
|
||||||
|
a.set_enforce_owner(true);
|
||||||
|
// Alice writes a runnable cell she owns.
|
||||||
|
assert!(a.exec("prog /c070/z001/y001/x001 const 7 halt").is_ok());
|
||||||
|
|
||||||
|
// Bob is a different owner on the same (shared) store.
|
||||||
|
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("run /c070/z001/y001/x001");
|
||||||
|
assert!(
|
||||||
|
r.is_err(),
|
||||||
|
"non-owner read must be gated under --require-identity"
|
||||||
|
);
|
||||||
|
assert!(r.unwrap_err().contains("owner violation"));
|
||||||
|
|
||||||
|
// Alice grants bob READ on the coord.
|
||||||
|
ts.set_identity(TenantIdentity {
|
||||||
|
tenant: TenantId::from_str("alpha").unwrap(),
|
||||||
|
owner_local: "alice".to_string(),
|
||||||
|
owner_remote: None,
|
||||||
|
});
|
||||||
|
let a2 = Session::for_tenant(&ts);
|
||||||
|
let _ = grant(
|
||||||
|
&a2.store,
|
||||||
|
&crate::grants::Owner::from(&a2.identity().unwrap()),
|
||||||
|
&crate::grants::Owner {
|
||||||
|
local: "bob".to_string(),
|
||||||
|
remote: None,
|
||||||
|
},
|
||||||
|
crate::grants::PERM_READ,
|
||||||
|
Some(Czyx::new(70, 1, 1, 1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now bob's read passes via grant.
|
||||||
|
let r = b.exec("run /c070/z001/y001/x001");
|
||||||
|
assert!(r.is_ok(), "read grant must allow bob's read: {r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_gate_requires_identity_under_enforce() {
|
||||||
|
// R5: a `stat` (pure metadata read) from an anonymous session is
|
||||||
|
// rejected under --require-identity, exactly like a write.
|
||||||
|
let dir = std::env::temp_dir().join(format!("cube2-rgate-{}", std::process::id()));
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let cs = ConcurrentStore::open(
|
||||||
|
dir.join("db.cubedb").to_str().unwrap(),
|
||||||
|
dir.join("wal.ndjson").to_str().unwrap(),
|
||||||
|
dir.join("recovery.jsonl").to_str().unwrap(),
|
||||||
|
DurabilityConfig::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut s = Session::with_store(Arc::new(cs));
|
||||||
|
s.set_enforce_owner(true);
|
||||||
|
let r = s.exec("stat /c071/z001/y001/x001");
|
||||||
|
assert!(r.is_err(), "anonymous stat must be gated");
|
||||||
|
assert!(r.unwrap_err().contains("require a HELLO identity"));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user