use super::{ClaimsMapper, TokenClaims, TokenError, TokenValidator};
use crate::{AccessControl, AuditEvent, AuditOutcome, AuditSink, Permission};
use std::sync::Arc;
pub struct SsoAccessControl {
validator: Arc<dyn TokenValidator>,
mapper: ClaimsMapper,
access_control: AccessControl,
audit_sink: Option<Arc<dyn AuditSink>>,
}
impl SsoAccessControl {
pub fn builder() -> SsoAccessControlBuilder {
SsoAccessControlBuilder::default()
}
pub async fn check_token(
&self,
token: &str,
permission: &Permission,
) -> Result<TokenClaims, SsoError> {
let claims = self.validator.validate(token).await?;
let user_id = self.mapper.get_user_id(&claims);
let roles = self.mapper.map_to_roles(&claims);
let result = self.check_with_roles(&user_id, &roles, permission);
if let Some(sink) = &self.audit_sink {
let outcome = if result.is_ok() { AuditOutcome::Allowed } else { AuditOutcome::Denied };
let event = AuditEvent::tool_access(&user_id, &permission.to_string(), outcome);
let _ = sink.log(event).await;
}
result?;
Ok(claims)
}
fn check_with_roles(
&self,
user_id: &str,
roles: &[String],
permission: &Permission,
) -> Result<(), SsoError> {
if self.access_control.check_roles(roles, permission) {
Ok(())
} else {
Err(SsoError::AccessDenied {
user: user_id.to_string(),
permission: permission.to_string(),
})
}
}
pub fn access_control(&self) -> &AccessControl {
&self.access_control
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Role;
use async_trait::async_trait;
struct DummyValidator;
#[async_trait]
impl TokenValidator for DummyValidator {
async fn validate(&self, _token: &str) -> Result<TokenClaims, TokenError> {
Err(TokenError::ValidationError("not used in unit test".into()))
}
fn issuer(&self) -> &str {
"test-issuer"
}
}
#[test]
fn test_check_with_roles_honors_deny_precedence() {
let access_control = AccessControl::builder()
.role(Role::new("editor").allow(Permission::AllTools))
.role(Role::new("restricted").deny(Permission::Tool("code_exec".into())))
.build()
.unwrap();
let sso = SsoAccessControl {
validator: Arc::new(DummyValidator),
mapper: ClaimsMapper::builder().build(),
access_control,
audit_sink: None,
};
assert!(
sso.check_with_roles(
"bob",
&["editor".to_string(), "restricted".to_string()],
&Permission::Tool("code_exec".into())
)
.is_err()
);
assert!(
sso.check_with_roles(
"bob",
&["restricted".to_string(), "editor".to_string()],
&Permission::Tool("code_exec".into())
)
.is_err()
);
assert!(
sso.check_with_roles(
"bob",
&["editor".to_string(), "restricted".to_string()],
&Permission::Tool("search".into())
)
.is_ok()
);
}
}
#[derive(Debug, thiserror::Error)]
pub enum SsoError {
#[error("Token error: {0}")]
TokenError(#[from] TokenError),
#[error("Access denied for user '{user}' to '{permission}'")]
AccessDenied { user: String, permission: String },
}
#[derive(Default)]
pub struct SsoAccessControlBuilder {
validator: Option<Arc<dyn TokenValidator>>,
mapper: Option<ClaimsMapper>,
access_control: Option<AccessControl>,
audit_sink: Option<Arc<dyn AuditSink>>,
}
impl SsoAccessControlBuilder {
pub fn validator(mut self, v: impl TokenValidator + 'static) -> Self {
self.validator = Some(Arc::new(v));
self
}
pub fn mapper(mut self, m: ClaimsMapper) -> Self {
self.mapper = Some(m);
self
}
pub fn access_control(mut self, ac: AccessControl) -> Self {
self.access_control = Some(ac);
self
}
pub fn audit_sink(mut self, sink: impl AuditSink + 'static) -> Self {
self.audit_sink = Some(Arc::new(sink));
self
}
pub fn build(self) -> Result<SsoAccessControl, &'static str> {
Ok(SsoAccessControl {
validator: self.validator.ok_or("validator is required")?,
mapper: self.mapper.unwrap_or_else(|| ClaimsMapper::builder().build()),
access_control: self.access_control.ok_or("access_control is required")?,
audit_sink: self.audit_sink,
})
}
}