use super::scopes::{Scope, ScopeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
pub id: String,
pub tenant_id: Option<String>,
pub scopes: ScopeSet,
pub label: Option<String>,
}
impl Principal {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
tenant_id: None,
scopes: ScopeSet::empty(),
label: None,
}
}
pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
self.tenant_id = Some(tenant_id.into());
self
}
pub fn with_scopes(mut self, scopes: ScopeSet) -> Self {
self.scopes = scopes;
self
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn has_scopes(&self, required: ScopeSet) -> bool {
self.scopes.covers(required)
}
pub fn has_scope(&self, scope: Scope) -> bool {
self.scopes.contains(scope)
}
pub fn is_cross_tenant(&self, target_tenant: &str) -> bool {
match &self.tenant_id {
Some(t) => t != target_tenant,
None => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthOutcome {
Authenticated(Principal),
Unauthenticated,
Revoked { id: String },
Expired { id: String },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_principal_has_empty_scopes() {
let p = Principal::new("p1");
assert!(p.scopes.is_empty());
assert!(p.tenant_id.is_none());
assert!(p.label.is_none());
}
#[test]
fn builder_chain() {
let p = Principal::new("p1")
.with_tenant("acme")
.with_scopes(ScopeSet::from_iter([Scope::Read, Scope::Recall]))
.with_label("alice@example.com");
assert_eq!(p.id, "p1");
assert_eq!(p.tenant_id.as_deref(), Some("acme"));
assert!(p.has_scope(Scope::Read));
assert!(p.has_scope(Scope::Recall));
assert!(!p.has_scope(Scope::Write));
assert_eq!(p.label.as_deref(), Some("alice@example.com"));
}
#[test]
fn has_scopes_checks_all() {
let p = Principal::new("p1").with_scopes(ScopeSet::from_iter([Scope::Read, Scope::Recall]));
assert!(p.has_scopes(ScopeSet::from_iter([Scope::Read])));
assert!(p.has_scopes(ScopeSet::from_iter([Scope::Read, Scope::Recall])));
assert!(!p.has_scopes(ScopeSet::from_iter([Scope::Read, Scope::Forget])));
}
#[test]
fn is_cross_tenant_for_scoped_token() {
let p = Principal::new("p1").with_tenant("acme");
assert!(p.is_cross_tenant("widget-co"));
assert!(!p.is_cross_tenant("acme"));
}
#[test]
fn is_cross_tenant_false_for_cluster_wide_token() {
let p = Principal::new("admin").with_scopes(ScopeSet::from_iter([Scope::Admin]));
assert!(!p.is_cross_tenant("acme"));
assert!(!p.is_cross_tenant("widget-co"));
}
#[test]
fn auth_outcomes_distinguish_revoked_from_expired() {
let revoked = AuthOutcome::Revoked { id: "p1".into() };
let expired = AuthOutcome::Expired { id: "p1".into() };
assert_ne!(revoked, expired);
}
}