use std::sync::Arc;
use aion::Engine;
use crate::error::ServerError;
use crate::namespace::resolver::{CallerIdentity, GrantSource, NamespaceResolver};
#[derive(Clone)]
pub struct DeployGuard {
resolver: NamespaceResolver,
}
impl DeployGuard {
#[must_use]
pub const fn new(resolver: NamespaceResolver) -> Self {
Self { resolver }
}
pub fn authorize(&self, caller: &CallerIdentity) -> Result<(), ServerError> {
if let Some(reason) = caller.denial_reason() {
return Err(ServerError::deploy_denied(reason));
}
if caller.deploy_granted() {
return Ok(());
}
Err(deploy_denied(caller))
}
pub fn engine(&self) -> Result<&Arc<Engine>, ServerError> {
self.resolver.engine()
}
}
fn deploy_denied(caller: &CallerIdentity) -> ServerError {
let subject = caller.subject();
let hint = match caller.grant_source() {
GrantSource::NamespacesHeader => {
format!("set x-aion-deploy: true for subject `{subject}`")
}
GrantSource::TokenClaim => {
format!("mint a token whose deploy claim is true for subject `{subject}`")
}
};
ServerError::deploy_denied(format!(
"subject `{subject}` is not authorized to deploy; {hint}"
))
}
#[cfg(test)]
mod tests {
use aion_proto::WireErrorCode;
use super::DeployGuard;
use crate::config::NamespaceMode;
use crate::namespace::{
CallerIdentity, NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces,
};
fn guard() -> DeployGuard {
DeployGuard::new(NamespaceResolver::authorization_only(
NamespaceMode::SharedEngine,
StaticWorkflowNamespaces::default(),
StaticScheduleNamespaces::default(),
))
}
#[test]
fn granted_caller_is_authorized() -> Result<(), Box<dyn std::error::Error>> {
let header_caller = CallerIdentity::new("ci", [String::from("tenant-a")]).with_deploy(true);
let token_caller =
CallerIdentity::from_token_claims("ci", [String::from("tenant-a")]).with_deploy(true);
guard().authorize(&header_caller)?;
guard().authorize(&token_caller)?;
Ok(())
}
#[test]
fn denial_hint_names_the_grant_source() -> Result<(), Box<dyn std::error::Error>> {
let header_caller = CallerIdentity::new("ci", [String::from("tenant-a")]);
let header_denial = guard()
.authorize(&header_caller)
.err()
.map(|error| error.to_wire_error())
.ok_or("expected header-sourced caller to be denied")?;
assert_eq!(header_denial.code, WireErrorCode::DeployDenied);
assert!(
header_denial
.message
.contains("subject `ci` is not authorized to deploy"),
"denial must name the subject: {}",
header_denial.message
);
assert!(
header_denial.message.contains("x-aion-deploy"),
"header-path denial must hint the dev header: {}",
header_denial.message
);
assert!(
!header_denial.message.contains("deploy claim"),
"header-path denial must not hint the token claim: {}",
header_denial.message
);
let token_caller = CallerIdentity::from_token_claims("ci", [String::from("tenant-a")]);
let token_denial = guard()
.authorize(&token_caller)
.err()
.map(|error| error.to_wire_error())
.ok_or("expected token-sourced caller to be denied")?;
assert_eq!(token_denial.code, WireErrorCode::DeployDenied);
assert!(
token_denial.message.contains("deploy claim"),
"JWT-path denial must hint the token's deploy claim: {}",
token_denial.message
);
assert!(
!token_denial.message.contains("x-aion-deploy"),
"JWT-path denial must not hint the dev header: {}",
token_denial.message
);
Ok(())
}
#[test]
fn transport_denied_caller_is_deploy_denied_with_reason()
-> Result<(), Box<dyn std::error::Error>> {
let denied = CallerIdentity::denied("ci", "invalid bearer token").with_deploy(true);
let error = guard()
.authorize(&denied)
.err()
.map(|error| error.to_wire_error())
.ok_or("expected transport-denied caller to be refused")?;
assert_eq!(error.code, WireErrorCode::DeployDenied);
assert!(
error.message.contains("invalid bearer token"),
"denial must carry the transport reason: {}",
error.message
);
Ok(())
}
#[test]
fn namespace_grants_do_not_imply_deploy() {
let caller = CallerIdentity::new("ci", [String::from("tenant-a"), String::from("b")]);
let result = guard().authorize(&caller);
assert_eq!(
result.err().map(|error| error.to_wire_error().code),
Some(WireErrorCode::DeployDenied)
);
}
}