Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/ogar-rbac/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", bra
# Consumed, never cloned: the algorithm is consumer-agnostic and stays where it
# is. This crate supplies the impl and the grant data, not the mechanics.
lance-graph-rbac = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main" }

# Directory object identity (`Guid128`): a group-to-role grant names the group
# by its objectGUID, never by a name or address that can be reassigned.
ogar-dir-core = { path = "../ogar-dir-core" }
372 changes: 372 additions & 0 deletions crates/ogar-rbac/src/group_policy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,372 @@
//! Which roles a directory group carries: the authority's versioned policy.
//!
//! A group's membership is directory state, observed and versioned by the
//! directory. Which roles the group confers is policy, decided here: a grant
//! is made and revoked by someone, for a reason, at a time, independent of
//! what the directory looks like. Keeping the two apart means re-observing
//! the directory never changes a grant, and changing a grant never needs a
//! directory version.
//!
//! The policy is an append-only log. Each change is a [`PolicyVersion`] of
//! its own, numbered by this log and by nothing else, and every change keeps
//! the [`GrantProvenance`] it was made with. [`GroupRolePolicy::as_of`] reads
//! the state at any version.
//!
//! The state keeps "no grant data" and "no roles" apart. A group the policy
//! never named answers `None`: nothing was decided for it, which is not the
//! same as a decision that it carries no role. A group whose every grant was
//! revoked answers `Some([])`. A consumer such as dir-sim's `GroupRoles`
//! reads the two differently, and must, so roles are never inferred from
//! missing evidence.

use lance_graph_contract::rbac::RoleId;
use ogar_dir_core::Guid128;
use std::collections::BTreeMap;

/// A version of the group-role policy: the number of changes applied.
/// `PolicyVersion(0)` is the empty policy.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PolicyVersion(pub u64);

/// Who made a change, on what evidence, and when.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GrantProvenance {
/// The principal that made the change.
pub by: String,
/// The request, ticket or record it was made for.
pub evidence: String,
/// When it was made (unix ms).
pub at_ms: i64,
}

/// One change to the policy.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PolicyChange {
/// `group` now confers `role`.
Grant {
/// The group, by objectGUID.
group: Guid128,
/// The role.
role: RoleId,
},
/// `group` no longer confers `role`.
Revoke {
/// The group, by objectGUID.
group: Guid128,
/// The role.
role: RoleId,
},
}

/// A change as recorded: its version, the change and its provenance.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PolicyEntry {
/// The version this change produced.
pub version: PolicyVersion,
/// The change.
pub change: PolicyChange,
/// Who made it, why and when.
pub provenance: GrantProvenance,
}

/// Why a change was refused. Nothing is recorded for a refused change.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PolicyError {
/// The change names the all-zero objectGUID, which means "absent".
AbsentGroup,
/// The group already confers the role.
AlreadyGranted(Guid128, RoleId),
/// The group does not confer the role.
NotGranted(Guid128, RoleId),
/// The policy has no such version.
UnknownVersion(PolicyVersion),
}

/// The group-role policy: an append-only log of grants and revocations.
#[derive(Clone, Debug, Default)]
pub struct GroupRolePolicy {
entries: Vec<PolicyEntry>,
/// The state at [`Self::head`], kept current by [`Self::apply`] so a
/// change is checked without replaying the log.
head: GroupRoleState,
}

impl GroupRolePolicy {
/// The empty policy.
#[must_use]
pub fn new() -> Self {
Self::default()
}

/// The latest version.
#[must_use]
pub fn head(&self) -> PolicyVersion {
PolicyVersion(self.entries.len() as u64)
}

/// Every change, in version order.
#[must_use]
pub fn history(&self) -> &[PolicyEntry] {
&self.entries
}

/// Record `change` as the next version.
///
/// # Errors
///
/// [`PolicyError::AbsentGroup`] for the all-zero objectGUID, which
/// means "absent": a group whose GUID was not read never picks up a
/// grant. [`PolicyError::AlreadyGranted`] for a grant the group already
/// confers, [`PolicyError::NotGranted`] for a revocation of a role it
/// does not: a change that would not change the state is not a change.
pub fn apply(
&mut self,
change: PolicyChange,
provenance: GrantProvenance,
) -> Result<PolicyVersion, PolicyError> {
let (PolicyChange::Grant { group, .. } | PolicyChange::Revoke { group, .. }) = change;
if group == Guid128::default() {
return Err(PolicyError::AbsentGroup);
}
match change {
PolicyChange::Grant { group, role } if self.head.confers(group, role) => {
return Err(PolicyError::AlreadyGranted(group, role));
}
PolicyChange::Revoke { group, role } if !self.head.confers(group, role) => {
return Err(PolicyError::NotGranted(group, role));
}
_ => {}
}
Comment thread
AdaWorldAPI marked this conversation as resolved.
let version = PolicyVersion(self.entries.len() as u64 + 1);
let entry = PolicyEntry {
version,
change,
provenance,
};
self.head.step(&entry);
self.entries.push(entry);
Ok(version)
}

/// The policy as of `version`.
///
/// # Errors
///
/// [`PolicyError::UnknownVersion`] past [`Self::head`].
pub fn as_of(&self, version: PolicyVersion) -> Result<GroupRoleState, PolicyError> {
let n = usize::try_from(version.0)
.ok()
.filter(|&n| n <= self.entries.len())
.ok_or(PolicyError::UnknownVersion(version))?;
if n == self.entries.len() {
return Ok(self.head.clone());
}
let mut state = GroupRoleState::default();
for e in &self.entries[..n] {
state.step(e);
}
Ok(state)
}
}

/// The policy at one version.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GroupRoleState {
/// Every group the policy has named, with the roles it confers and the
/// provenance of each standing grant. A group whose grants were all
/// revoked stays, with no roles.
groups: BTreeMap<Guid128, BTreeMap<RoleId, GrantProvenance>>,
}

impl GroupRoleState {
/// Apply one recorded change. The only place a change alters a state,
/// so the cached head and a replay in [`GroupRolePolicy::as_of`] agree.
fn step(&mut self, e: &PolicyEntry) {
match e.change {
PolicyChange::Grant { group, role } => {
self.groups
.entry(group)
.or_default()
.insert(role, e.provenance.clone());
}
PolicyChange::Revoke { group, role } => {
self.groups.entry(group).or_default().remove(role);
}
}
}

/// The roles `group` confers: `None` when the policy never named it,
/// `Some` (possibly empty) when it did.
#[must_use]
pub fn roles_of(&self, group: Guid128) -> Option<Vec<RoleId>> {
self.groups.get(&group).map(|r| r.keys().copied().collect())
}

/// Whether `group` confers `role`.
#[must_use]
pub fn confers(&self, group: Guid128, role: RoleId) -> bool {
self.groups
.get(&group)
.is_some_and(|r| r.contains_key(role))
}

/// The provenance of the standing grant of `role` to `group`.
#[must_use]
pub fn provenance(&self, group: Guid128, role: RoleId) -> Option<&GrantProvenance> {
self.groups.get(&group).and_then(|r| r.get(role))
}

/// Every group the policy has named, with its roles, in GUID order.
pub fn groups(&self) -> impl Iterator<Item = (Guid128, Vec<RoleId>)> + '_ {
self.groups
.iter()
.map(|(g, r)| (*g, r.keys().copied().collect()))
}
}

#[cfg(test)]
mod tests {
use super::*;

fn g(n: u8) -> Guid128 {
Guid128([n; 16])
}

fn by(who: &str, evidence: &str, at_ms: i64) -> GrantProvenance {
GrantProvenance {
by: who.into(),
evidence: evidence.into(),
at_ms,
}
}

const FINANCE: u8 = 0x62;

// A group never named answers None; a group whose every grant was
// revoked answers Some(empty). The two are not the same answer.
#[test]
fn a_revoked_group_is_empty_not_unread() {
let mut p = GroupRolePolicy::new();
let granted = p
.apply(
PolicyChange::Grant {
group: g(FINANCE),
role: "accountant",
},
by("admin", "REQ-1", 1),
)
.unwrap();
let revoked = p
.apply(
PolicyChange::Revoke {
group: g(FINANCE),
role: "accountant",
},
by("admin", "REQ-2", 2),
)
.unwrap();
let now = p.as_of(revoked).unwrap();
assert_eq!(now.roles_of(g(FINANCE)), Some(vec![]));
assert_eq!(now.roles_of(g(0x99)), None);
assert_eq!(
p.as_of(granted).unwrap().roles_of(g(FINANCE)),
Some(vec!["accountant"])
);
assert_eq!(
p.as_of(PolicyVersion(0)).unwrap().roles_of(g(FINANCE)),
None
);
}

// A standing grant keeps the provenance it was made with; a re-grant
// after a revocation carries its own, and the log keeps all three.
#[test]
fn each_grant_keeps_its_own_provenance() {
let mut p = GroupRolePolicy::new();
let grant = PolicyChange::Grant {
group: g(FINANCE),
role: "accountant",
};
let v1 = p.apply(grant, by("alice", "REQ-1", 10)).unwrap();
p.apply(
PolicyChange::Revoke {
group: g(FINANCE),
role: "accountant",
},
by("bob", "REQ-2", 20),
)
.unwrap();
let v3 = p.apply(grant, by("carol", "REQ-3", 30)).unwrap();
assert_eq!(
p.as_of(v1).unwrap().provenance(g(FINANCE), "accountant"),
Some(&by("alice", "REQ-1", 10))
);
assert_eq!(
p.as_of(v3).unwrap().provenance(g(FINANCE), "accountant"),
Some(&by("carol", "REQ-3", 30))
);
assert_eq!(p.history().len(), 3);
// The cached head is the replayed state, not a second answer.
let mut replay = GroupRoleState::default();
for e in p.history() {
replay.step(e);
}
assert_eq!(p.as_of(p.head()).unwrap(), replay);
assert_eq!(p.history()[1].provenance.by, "bob");
}

// The all-zero objectGUID means "absent": a group whose GUID was not
// read must not pick up a grant, so no change may name it.
#[test]
fn the_absent_group_is_refused() {
let mut p = GroupRolePolicy::new();
for change in [
PolicyChange::Grant {
group: Guid128::default(),
role: "accountant",
},
PolicyChange::Revoke {
group: Guid128::default(),
role: "accountant",
},
] {
assert_eq!(
p.apply(change, by("a", "x", 1)),
Err(PolicyError::AbsentGroup)
);
}
assert_eq!(p.head(), PolicyVersion(0));
assert_eq!(
p.as_of(p.head()).unwrap().roles_of(Guid128::default()),
None
);
}

// A change that would not change the state is refused and not recorded.
#[test]
fn a_no_op_change_is_refused() {
let mut p = GroupRolePolicy::new();
let grant = PolicyChange::Grant {
group: g(FINANCE),
role: "accountant",
};
let revoke = PolicyChange::Revoke {
group: g(FINANCE),
role: "accountant",
};
assert_eq!(
p.apply(revoke, by("a", "x", 1)),
Err(PolicyError::NotGranted(g(FINANCE), "accountant"))
);
p.apply(grant, by("a", "x", 1)).unwrap();
assert_eq!(
p.apply(grant, by("a", "x", 2)),
Err(PolicyError::AlreadyGranted(g(FINANCE), "accountant"))
);
assert_eq!(p.head(), PolicyVersion(1));
assert_eq!(
p.as_of(PolicyVersion(2)),
Err(PolicyError::UnknownVersion(PolicyVersion(2)))
);
}
}
2 changes: 2 additions & 0 deletions crates/ogar-rbac/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
#![forbid(unsafe_code)]
#![warn(missing_docs)]

pub mod group_policy;

use lance_graph_contract::class_view::WideFieldMask;
use lance_graph_contract::rbac::Membership;
use lance_graph_contract::rbac::{
Expand Down
Loading