use cratefield_core::{Database, DbError, Row, Statement};
use time::OffsetDateTime;
use crate::clock::{iso, parse};
pub const TABLE: &str = "org_grants";
pub const ANY_KIND: &str = "*";
const MAX_PART: usize = 256;
const MAX_SEGMENTS: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Permission {
Read,
Write,
Admin,
}
impl Permission {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Admin => "admin",
}
}
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value {
"read" => Some(Self::Read),
"write" => Some(Self::Write),
"admin" => Some(Self::Admin),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Effect {
Allow,
Deny,
}
impl Effect {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Deny => "deny",
}
}
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value {
"allow" => Some(Self::Allow),
"deny" => Some(Self::Deny),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidSelector {
#[error("the kind must be 1–256 printable characters without `/`")]
Kind,
#[error("the space must be 1–256 printable characters without `/`")]
Space,
#[error("a path is at most 32 segments of 1–256 printable characters, none `.` or `..`")]
Path,
#[error("a resource has a concrete kind; `*` is for selectors")]
WildcardResource,
#[error("the principal must be 1–256 printable characters")]
Principal,
}
fn plain(value: &str, allow_slash: bool) -> bool {
!value.is_empty()
&& value.len() <= MAX_PART
&& value
.chars()
.all(|ch| !ch.is_control() && (allow_slash || ch != '/'))
}
fn check_path(path: &[String]) -> Result<(), InvalidSelector> {
if path.len() > MAX_SEGMENTS
|| path
.iter()
.any(|segment| !plain(segment, false) || segment == "." || segment == "..")
{
return Err(InvalidSelector::Path);
}
Ok(())
}
#[must_use]
pub fn split_path(path: &str) -> Vec<String> {
path.split('/')
.filter(|segment| !segment.is_empty())
.map(str::to_owned)
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Selector {
kind: String,
space: String,
path: Vec<String>,
}
impl Selector {
pub fn new(
kind: impl Into<String>,
space: impl Into<String>,
path: Vec<String>,
) -> Result<Self, InvalidSelector> {
let (kind, space) = (kind.into(), space.into());
if !plain(&kind, false) {
return Err(InvalidSelector::Kind);
}
if !plain(&space, false) {
return Err(InvalidSelector::Space);
}
check_path(&path)?;
Ok(Self { kind, space, path })
}
#[must_use]
pub fn kind(&self) -> &str {
&self.kind
}
#[must_use]
pub fn space(&self) -> &str {
&self.space
}
#[must_use]
pub fn path(&self) -> &[String] {
&self.path
}
#[must_use]
pub fn path_string(&self) -> String {
self.path.join("/")
}
#[must_use]
pub fn covers(&self, resource: &Resource) -> bool {
(self.kind == ANY_KIND || self.kind == resource.kind)
&& self.space == resource.space
&& resource.path.starts_with(&self.path)
}
#[must_use]
pub fn contains(&self, other: &Selector) -> bool {
(self.kind == ANY_KIND || self.kind == other.kind)
&& self.space == other.space
&& other.path.starts_with(&self.path)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Resource {
kind: String,
space: String,
path: Vec<String>,
}
impl Resource {
pub fn new(
kind: impl Into<String>,
space: impl Into<String>,
path: Vec<String>,
) -> Result<Self, InvalidSelector> {
let selector = Selector::new(kind, space, path)?;
if selector.kind == ANY_KIND {
return Err(InvalidSelector::WildcardResource);
}
Ok(Self {
kind: selector.kind,
space: selector.space,
path: selector.path,
})
}
#[must_use]
pub fn kind(&self) -> &str {
&self.kind
}
#[must_use]
pub fn space(&self) -> &str {
&self.space
}
#[must_use]
pub fn path(&self) -> &[String] {
&self.path
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Grant {
pub id: String,
pub principal: String,
pub selector: Selector,
pub permission: Permission,
pub effect: Effect,
pub expires_at: Option<String>,
pub created_by: String,
pub created_at: String,
}
impl Grant {
#[must_use]
pub fn implicit(
principal: impl Into<String>,
selector: Selector,
permission: Permission,
) -> Self {
Self {
id: String::new(),
principal: principal.into(),
selector,
permission,
effect: Effect::Allow,
expires_at: None,
created_by: String::new(),
created_at: String::new(),
}
}
#[must_use]
pub fn is_live(&self, now: OffsetDateTime) -> bool {
match &self.expires_at {
None => true,
Some(at) => parse(at) > now,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialScope {
pub selector: Selector,
pub permission: Permission,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allowed,
Denied(DenyReason),
}
impl Decision {
#[must_use]
pub const fn is_allowed(self) -> bool {
matches!(self, Self::Allowed)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DenyReason {
NoGrant,
ExplicitDeny,
CredentialScope,
}
#[must_use]
pub fn decide(
grants: &[Grant],
principals: &[&str],
resource: &Resource,
wanted: Permission,
now: OffsetDateTime,
) -> Decision {
let mut allowed = false;
for grant in grants {
if !principals.contains(&grant.principal.as_str())
|| !grant.is_live(now)
|| !grant.selector.covers(resource)
{
continue;
}
match grant.effect {
Effect::Deny => return Decision::Denied(DenyReason::ExplicitDeny),
Effect::Allow => allowed |= grant.permission >= wanted,
}
}
if allowed {
Decision::Allowed
} else {
Decision::Denied(DenyReason::NoGrant)
}
}
#[must_use]
pub fn decide_with_credential(
grants: &[Grant],
principals: &[&str],
credential: Option<&[CredentialScope]>,
resource: &Resource,
wanted: Permission,
now: OffsetDateTime,
) -> Decision {
let decision = decide(grants, principals, resource, wanted, now);
if !decision.is_allowed() {
return decision;
}
match credential {
None => decision,
Some(scopes)
if scopes
.iter()
.any(|scope| scope.permission >= wanted && scope.selector.covers(resource)) =>
{
decision
}
Some(_) => Decision::Denied(DenyReason::CredentialScope),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Coverage {
pub allow: Vec<Vec<String>>,
pub deny: Vec<Vec<String>>,
}
impl Coverage {
#[must_use]
pub fn covers(&self, path: &[String]) -> bool {
!self.deny.iter().any(|prefix| path.starts_with(prefix))
&& self.allow.iter().any(|prefix| path.starts_with(prefix))
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.allow.is_empty()
}
#[must_use]
pub fn is_whole(&self) -> bool {
self.deny.is_empty() && self.allow.iter().any(Vec::is_empty)
}
}
#[must_use]
pub fn coverage(
grants: &[Grant],
principals: &[&str],
credential: Option<&[CredentialScope]>,
kind: &str,
space: &str,
wanted: Permission,
now: OffsetDateTime,
) -> Coverage {
let applies = |selector: &Selector| {
(selector.kind == ANY_KIND || selector.kind == kind) && selector.space == space
};
let mut out = Coverage::default();
for grant in grants {
if !principals.contains(&grant.principal.as_str())
|| !grant.is_live(now)
|| !applies(&grant.selector)
{
continue;
}
match grant.effect {
Effect::Deny => out.deny.push(grant.selector.path.clone()),
Effect::Allow if grant.permission >= wanted => {
out.allow.push(grant.selector.path.clone());
}
Effect::Allow => {}
}
}
if let Some(scopes) = credential {
let limits: Vec<&Vec<String>> = scopes
.iter()
.filter(|scope| scope.permission >= wanted && applies(&scope.selector))
.map(|scope| &scope.selector.path)
.collect();
let mut narrowed = Vec::new();
for allow in &out.allow {
for limit in &limits {
if allow.starts_with(limit) {
narrowed.push(allow.clone());
} else if limit.starts_with(allow) {
narrowed.push((*limit).clone());
}
}
}
out.allow = narrowed;
}
out.allow.sort();
out.allow.dedup();
out.deny.sort();
out.deny.dedup();
out
}
#[must_use]
pub fn may_grant(
grants: &[Grant],
principals: &[&str],
proposed: &Selector,
now: OffsetDateTime,
) -> bool {
let mut holds = false;
for grant in grants {
if !principals.contains(&grant.principal.as_str()) || !grant.is_live(now) {
continue;
}
let selector = &grant.selector;
match grant.effect {
Effect::Deny => {
if selector.contains(proposed) || proposed.contains(selector) {
return false;
}
}
Effect::Allow => {
holds |= grant.permission == Permission::Admin && selector.contains(proposed);
}
}
}
holds
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewGrant {
pub principal: String,
pub selector: Selector,
pub permission: Permission,
pub effect: Effect,
pub expires_at: Option<OffsetDateTime>,
pub created_by: String,
}
fn check_principal(principal: &str) -> Result<(), InvalidSelector> {
if plain(principal, true) {
Ok(())
} else {
Err(InvalidSelector::Principal)
}
}
#[derive(Debug, thiserror::Error)]
pub enum GrantError {
#[error(transparent)]
Invalid(#[from] InvalidSelector),
#[error(transparent)]
Db(#[from] DbError),
}
pub async fn put_grant(
db: &dyn Database,
org_id: &str,
id: &str,
grant: &NewGrant,
now: OffsetDateTime,
) -> Result<String, GrantError> {
check_principal(&grant.principal)?;
let path = grant.selector.path_string();
let statement = Statement::with_values(
"INSERT INTO org_grants \
(id, org_id, principal, kind, space, path, permission, effect, expires_at, \
created_by, created_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT (org_id, principal, kind, space, path, effect) DO UPDATE \
SET permission = excluded.permission, expires_at = excluded.expires_at",
vec![
id.into(),
org_id.into(),
grant.principal.as_str().into(),
grant.selector.kind.as_str().into(),
grant.selector.space.as_str().into(),
path.as_str().into(),
grant.permission.as_str().into(),
grant.effect.as_str().into(),
grant.expires_at.map(iso).into(),
grant.created_by.as_str().into(),
iso(now).into(),
],
);
db.execute(&statement).await?;
let read = Statement::with_values(
"SELECT id FROM org_grants WHERE org_id = ? AND principal = ? AND kind = ? \
AND space = ? AND path = ? AND effect = ?",
vec![
org_id.into(),
grant.principal.as_str().into(),
grant.selector.kind.as_str().into(),
grant.selector.space.as_str().into(),
path.into(),
grant.effect.as_str().into(),
],
);
Ok(db
.query(&read)
.await?
.first()
.and_then(|row| row.get::<String>("id"))
.unwrap_or_else(|| id.to_owned()))
}
const COLUMNS: &str = "id, principal, kind, space, path, permission, effect, expires_at, \
created_by, created_at";
fn grant_from(row: &Row) -> Option<Grant> {
let text = |name: &str| row.get::<String>(name).unwrap_or_default();
let effect = Effect::parse(&text("effect"))?;
let selector = Selector::new(text("kind"), text("space"), split_path(&text("path")));
let permission = Permission::parse(&text("permission"));
let (selector, permission) = match (effect, selector, permission) {
(_, Ok(selector), Some(permission)) => (selector, permission),
(Effect::Deny, _, _) => (
Selector::new(ANY_KIND, text("space"), Vec::new()).ok()?,
Permission::Admin,
),
(Effect::Allow, _, _) => return None,
};
Some(Grant {
id: text("id"),
principal: text("principal"),
selector,
permission,
effect,
expires_at: row.get::<String>("expires_at").filter(|at| !at.is_empty()),
created_by: text("created_by"),
created_at: text("created_at"),
})
}
pub async fn grants_for(
db: &dyn Database,
org_id: &str,
principals: &[&str],
) -> Result<Vec<Grant>, DbError> {
if principals.is_empty() {
return Ok(Vec::new());
}
let marks = vec!["?"; principals.len()].join(", ");
let mut values: Vec<sea_query::Value> = vec![org_id.into()];
values.extend(principals.iter().map(|principal| (*principal).into()));
let statement = Statement::with_values(
format!(
"SELECT {COLUMNS} FROM org_grants WHERE org_id = ? AND principal IN ({marks}) \
ORDER BY created_at, id"
),
values,
);
Ok(db
.query(&statement)
.await?
.rows
.iter()
.filter_map(grant_from)
.collect())
}
pub async fn grants_on_space(
db: &dyn Database,
org_id: &str,
space: &str,
) -> Result<Vec<Grant>, DbError> {
let statement = Statement::with_values(
format!(
"SELECT {COLUMNS} FROM org_grants WHERE org_id = ? AND space = ? \
ORDER BY created_at, id"
),
vec![org_id.into(), space.into()],
);
Ok(db
.query(&statement)
.await?
.rows
.iter()
.filter_map(grant_from)
.collect())
}
pub async fn grants_in_org(db: &dyn Database, org_id: &str) -> Result<Vec<Grant>, DbError> {
let statement = Statement::with_values(
format!("SELECT {COLUMNS} FROM org_grants WHERE org_id = ? ORDER BY created_at, id"),
vec![org_id.into()],
);
Ok(db
.query(&statement)
.await?
.rows
.iter()
.filter_map(grant_from)
.collect())
}
pub async fn grant(db: &dyn Database, org_id: &str, id: &str) -> Result<Option<Grant>, DbError> {
let statement = Statement::with_values(
format!("SELECT {COLUMNS} FROM org_grants WHERE org_id = ? AND id = ?"),
vec![org_id.into(), id.into()],
);
Ok(db.query(&statement).await?.first().and_then(grant_from))
}
pub async fn delete_grant(db: &dyn Database, org_id: &str, id: &str) -> Result<bool, DbError> {
let statement = Statement::with_values(
"DELETE FROM org_grants WHERE org_id = ? AND id = ?",
vec![org_id.into(), id.into()],
);
Ok(db.execute(&statement).await? > 0)
}
pub async fn delete_grants_for(
db: &dyn Database,
org_id: &str,
principal: &str,
) -> Result<u64, DbError> {
let statement = Statement::with_values(
"DELETE FROM org_grants WHERE org_id = ? AND principal = ?",
vec![org_id.into(), principal.into()],
);
db.execute(&statement).await
}
pub async fn delete_grants_on_space(
db: &dyn Database,
org_id: &str,
space: &str,
) -> Result<u64, DbError> {
let statement = Statement::with_values(
"DELETE FROM org_grants WHERE org_id = ? AND space = ?",
vec![org_id.into(), space.into()],
);
db.execute(&statement).await
}
#[cfg(test)]
mod tests {
use super::*;
fn now() -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("in range")
}
fn sel(kind: &str, space: &str, path: &str) -> Selector {
Selector::new(kind, space, split_path(path)).expect("a valid selector")
}
fn res(kind: &str, space: &str, path: &str) -> Resource {
Resource::new(kind, space, split_path(path)).expect("a valid resource")
}
fn allow(principal: &str, selector: Selector, permission: Permission) -> Grant {
Grant::implicit(principal, selector, permission)
}
fn deny(principal: &str, selector: Selector) -> Grant {
Grant {
effect: Effect::Deny,
..Grant::implicit(principal, selector, Permission::Read)
}
}
#[test]
fn nothing_is_open_by_default() {
let decision = decide(
&[],
&["u1"],
&res("pages", "shared", "a"),
Permission::Read,
now(),
);
assert_eq!(decision, Decision::Denied(DenyReason::NoGrant));
}
#[test]
fn a_prefix_matches_whole_segments_only() {
let grants = [allow(
"u1",
sel("pages", "s", "coding/claude"),
Permission::Read,
)];
let ok = |path: &str| {
decide(
&grants,
&["u1"],
&res("pages", "s", path),
Permission::Read,
now(),
)
.is_allowed()
};
assert!(ok("coding/claude"));
assert!(ok("coding/claude/x/y"));
assert!(!ok("coding/claudette"));
assert!(!ok("coding/codex/x"));
assert!(!ok("coding"));
}
#[test]
fn permissions_are_ordered_and_a_deny_wins() {
let grants = [
allow("u1", sel("*", "s", ""), Permission::Write),
deny("u1", sel("pages", "s", "secret")),
];
let ask = |path: &str, permission| {
decide(
&grants,
&["u1"],
&res("pages", "s", path),
permission,
now(),
)
};
assert!(ask("a", Permission::Read).is_allowed());
assert!(ask("a", Permission::Write).is_allowed());
assert!(!ask("a", Permission::Admin).is_allowed());
assert_eq!(
ask("secret/x", Permission::Read),
Decision::Denied(DenyReason::ExplicitDeny)
);
}
#[test]
fn grants_of_other_principals_spaces_and_kinds_do_not_apply() {
let grants = [
allow("u2", sel("pages", "s", ""), Permission::Admin),
allow("u1", sel("pages", "other", ""), Permission::Admin),
allow("u1", sel("files", "s", ""), Permission::Admin),
];
assert!(
!decide(
&grants,
&["u1"],
&res("pages", "s", "a"),
Permission::Read,
now()
)
.is_allowed()
);
}
#[test]
fn an_expired_grant_or_deny_stops_applying() {
let past = Some(iso(now() - time::Duration::seconds(1)));
let grants = [Grant {
expires_at: past.clone(),
..allow("u1", sel("pages", "s", ""), Permission::Read)
}];
assert!(
!decide(
&grants,
&["u1"],
&res("pages", "s", "a"),
Permission::Read,
now()
)
.is_allowed()
);
let grants = [
allow("u1", sel("pages", "s", ""), Permission::Read),
Grant {
expires_at: past,
..deny("u1", sel("pages", "s", ""))
},
];
assert!(
decide(
&grants,
&["u1"],
&res("pages", "s", "a"),
Permission::Read,
now()
)
.is_allowed()
);
let corrupt = Grant {
expires_at: Some("not a time".to_owned()),
..allow("u1", sel("pages", "s", ""), Permission::Read)
};
assert!(!corrupt.is_live(now()), "an unreadable expiry fails closed");
}
#[test]
fn a_credential_only_narrows() {
let grants = [allow("u1", sel("pages", "s", ""), Permission::Write)];
let scopes = [CredentialScope {
selector: sel("pages", "s", "public"),
permission: Permission::Read,
}];
let ask = |path: &str, permission, credential: Option<&[CredentialScope]>| {
decide_with_credential(
&grants,
&["u1"],
credential,
&res("pages", "s", path),
permission,
now(),
)
};
assert!(ask("public/a", Permission::Read, Some(&scopes)).is_allowed());
assert_eq!(
ask("public/a", Permission::Write, Some(&scopes)),
Decision::Denied(DenyReason::CredentialScope)
);
assert!(!ask("private", Permission::Read, Some(&scopes)).is_allowed());
assert!(!ask("public/a", Permission::Read, Some(&[])).is_allowed());
assert!(ask("private", Permission::Write, None).is_allowed());
let wide = [CredentialScope {
selector: sel("*", "elsewhere", ""),
permission: Permission::Admin,
}];
let elsewhere = decide_with_credential(
&grants,
&["u1"],
Some(&wide),
&res("pages", "elsewhere", "a"),
Permission::Read,
now(),
);
assert!(!elsewhere.is_allowed());
}
#[test]
fn coverage_agrees_with_decide() {
let grants = [
allow("u1", sel("pages", "s", "a"), Permission::Read),
allow("u1", sel("*", "s", "b/c"), Permission::Write),
deny("u1", sel("pages", "s", "a/x")),
];
let scopes = [CredentialScope {
selector: sel("pages", "s", "a/y"),
permission: Permission::Read,
}];
for credential in [None, Some(&scopes[..])] {
let cover = coverage(
&grants,
&["u1"],
credential,
"pages",
"s",
Permission::Read,
now(),
);
for path in [
"a", "a/x", "a/x/1", "a/y", "a/y/z", "b", "b/c", "b/c/d", "z",
] {
let decided = decide_with_credential(
&grants,
&["u1"],
credential,
&res("pages", "s", path),
Permission::Read,
now(),
);
assert_eq!(
cover.covers(&split_path(path)),
decided.is_allowed(),
"{path} with {credential:?}"
);
}
}
}
#[test]
fn only_an_admin_holder_may_grant_and_only_inside_what_they_hold() {
let grants = [allow("u1", sel("pages", "s", "team"), Permission::Admin)];
assert!(may_grant(
&grants,
&["u1"],
&sel("pages", "s", "team/a"),
now()
));
assert!(may_grant(
&grants,
&["u1"],
&sel("pages", "s", "team"),
now()
));
assert!(!may_grant(&grants, &["u1"], &sel("pages", "s", ""), now()));
assert!(!may_grant(&grants, &["u1"], &sel("*", "s", "team"), now()));
assert!(!may_grant(
&grants,
&["u2"],
&sel("pages", "s", "team/a"),
now()
));
let writer = [allow("u1", sel("pages", "s", ""), Permission::Write)];
assert!(!may_grant(&writer, &["u1"], &sel("pages", "s", "a"), now()));
let fenced = [
allow("u1", sel("pages", "s", ""), Permission::Admin),
deny("u1", sel("pages", "s", "team/secret")),
];
assert!(!may_grant(
&fenced,
&["u1"],
&sel("pages", "s", "team"),
now()
));
assert!(may_grant(
&fenced,
&["u1"],
&sel("pages", "s", "other"),
now()
));
}
#[test]
fn selectors_refuse_ambiguous_parts() {
assert!(Selector::new("pages", "s", vec!["a/b".to_owned()]).is_err());
assert!(Selector::new("pages", "s", vec![String::new()]).is_err());
assert!(Selector::new("pages", "s", vec!["..".to_owned()]).is_err());
assert!(Selector::new("pages", "a/b", Vec::new()).is_err());
assert!(Selector::new("", "s", Vec::new()).is_err());
assert!(Selector::new("pages", "s", vec!["a\nb".to_owned()]).is_err());
assert_eq!(
Resource::new("*", "s", Vec::new()),
Err(InvalidSelector::WildcardResource)
);
assert_eq!(split_path("/a//b/"), vec!["a".to_owned(), "b".to_owned()]);
}
}