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.
690 lines
21 KiB
Rust
690 lines
21 KiB
Rust
//! 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);
|
|
}
|
|
}
|