use globset::{Glob, GlobMatcher};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use super::request::{ActionClass, DecisionScope, PermissionRequest};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RuleId(pub String);
impl RuleId {
pub fn new() -> Self {
Self(format!("rule_{}", Uuid::now_v7()))
}
}
impl Default for RuleId {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RememberRule {
pub id: RuleId,
pub tenant_id: Option<String>,
pub scope: DecisionScope,
pub scope_value: Option<String>,
pub class: ActionClass,
pub action_pattern: String,
pub target_pattern: String,
pub allow: bool,
pub reason: Option<String>,
pub created_at: OffsetDateTime,
pub created_by: String,
#[serde(default)]
pub expires_at: Option<OffsetDateTime>,
#[serde(default)]
pub revoked_at: Option<OffsetDateTime>,
#[serde(skip)]
compiled: Option<CompiledRule>,
}
#[derive(Clone)]
struct CompiledRule {
action: GlobMatcher,
target: GlobMatcher,
}
impl std::fmt::Debug for CompiledRule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompiledRule")
.field("action", &"<glob>")
.field("target", &"<glob>")
.finish()
}
}
impl RememberRule {
pub fn new(
scope: DecisionScope,
scope_value: Option<String>,
class: ActionClass,
action_pattern: impl Into<String>,
target_pattern: impl Into<String>,
allow: bool,
created_by: impl Into<String>,
) -> Result<Self, RuleCompileError> {
let mut rule = Self {
id: RuleId::new(),
tenant_id: None,
scope,
scope_value,
class,
action_pattern: action_pattern.into(),
target_pattern: target_pattern.into(),
allow,
reason: None,
created_at: OffsetDateTime::now_utc(),
created_by: created_by.into(),
expires_at: None,
revoked_at: None,
compiled: None,
};
rule.ensure_compiled()?;
Ok(rule)
}
pub fn ensure_compiled(&mut self) -> Result<(), RuleCompileError> {
if self.compiled.is_some() {
return Ok(());
}
let action = Glob::new(&self.action_pattern)
.map_err(|err| RuleCompileError::Action(err.to_string()))?
.compile_matcher();
let target = Glob::new(&self.target_pattern)
.map_err(|err| RuleCompileError::Target(err.to_string()))?
.compile_matcher();
self.compiled = Some(CompiledRule { action, target });
Ok(())
}
pub fn matches(&self, request: &PermissionRequest, now: OffsetDateTime) -> bool {
if self.revoked_at.is_some() {
return false;
}
if let Some(expires_at) = self.expires_at {
if expires_at <= now {
return false;
}
}
if !tenants_match(self.tenant_id.as_deref(), request.tenant_id.as_deref()) {
return false;
}
if !scope_matches(self.scope, self.scope_value.as_deref(), request) {
return false;
}
if self.class != request.class {
return false;
}
let Some(compiled) = self.compiled.as_ref() else {
return false;
};
compiled.action.is_match(&request.action) && compiled.target.is_match(&request.target)
}
}
fn tenants_match(rule_tenant: Option<&str>, request_tenant: Option<&str>) -> bool {
match (rule_tenant, request_tenant) {
(None, _) => true,
(Some(rule), Some(req)) => rule == req,
(Some(_), None) => false,
}
}
fn scope_matches(
scope: DecisionScope,
scope_value: Option<&str>,
request: &PermissionRequest,
) -> bool {
match scope {
DecisionScope::Always => true,
DecisionScope::Session => scope_value == Some(request.session_id.as_str()),
DecisionScope::Workspace => {
scope_value.is_some() && scope_value == request.workspace_id.as_deref()
}
DecisionScope::User => scope_value == Some(request.actor.as_str()),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuleCompileError {
Action(String),
Target(String),
}
impl std::fmt::Display for RuleCompileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RuleCompileError::Action(msg) => write!(f, "invalid action pattern: {msg}"),
RuleCompileError::Target(msg) => write!(f, "invalid target pattern: {msg}"),
}
}
}
impl std::error::Error for RuleCompileError {}
#[cfg(test)]
mod tests {
use super::*;
fn request(session: &str, actor: &str, action: &str, target: &str) -> PermissionRequest {
let mut req =
PermissionRequest::new("p1", session, actor, ActionClass::Read, action, target);
req.workspace_id = Some("ws".to_string());
req
}
#[test]
fn session_scoped_rule_matches_session_id_only() {
let mut rule = RememberRule::new(
DecisionScope::Session,
Some("s1".to_string()),
ActionClass::Read,
"fs.*",
"src/**",
true,
"alice",
)
.unwrap();
rule.ensure_compiled().unwrap();
let now = OffsetDateTime::now_utc();
assert!(rule.matches(&request("s1", "alice", "fs.read", "src/lib.rs"), now));
assert!(!rule.matches(&request("s2", "alice", "fs.read", "src/lib.rs"), now));
}
#[test]
fn always_scope_matches_any_session_or_actor() {
let rule = RememberRule::new(
DecisionScope::Always,
None,
ActionClass::Read,
"*",
"**",
true,
"system",
)
.unwrap();
let now = OffsetDateTime::now_utc();
assert!(rule.matches(&request("any", "bob", "fs.read", "src/lib.rs"), now));
}
#[test]
fn expired_rule_does_not_match() {
let mut rule = RememberRule::new(
DecisionScope::Always,
None,
ActionClass::Read,
"*",
"**",
true,
"system",
)
.unwrap();
let past = OffsetDateTime::now_utc() - time::Duration::minutes(5);
rule.expires_at = Some(past);
assert!(!rule.matches(
&request("s", "a", "fs.read", "x"),
OffsetDateTime::now_utc()
));
}
#[test]
fn soft_revoked_rule_does_not_match() {
let mut rule = RememberRule::new(
DecisionScope::Always,
None,
ActionClass::Read,
"*",
"**",
true,
"system",
)
.unwrap();
rule.revoked_at = Some(OffsetDateTime::now_utc());
assert!(!rule.matches(
&request("s", "a", "fs.read", "x"),
OffsetDateTime::now_utc()
));
}
#[test]
fn workspace_scope_requires_workspace_id_match() {
let mut rule = RememberRule::new(
DecisionScope::Workspace,
Some("ws".to_string()),
ActionClass::Read,
"*",
"**",
true,
"alice",
)
.unwrap();
rule.ensure_compiled().unwrap();
let now = OffsetDateTime::now_utc();
let mut other = request("s1", "alice", "fs.read", "src/x");
assert!(rule.matches(&other, now));
other.workspace_id = Some("ws-other".to_string());
assert!(!rule.matches(&other, now));
}
#[test]
fn ensure_compiled_is_idempotent() {
let mut rule = RememberRule::new(
DecisionScope::Always,
None,
ActionClass::Read,
"*",
"**",
true,
"system",
)
.unwrap();
rule.ensure_compiled().unwrap();
rule.ensure_compiled().unwrap();
}
}