Skip to main content

aion_server/deploy/
guard.rs

1//! Adapter-boundary deploy authorization.
2//!
3//! Deploy is not a data operation: loading a package registers code into the
4//! shared BEAM VM and re-points routing for a workflow *type* that is
5//! startable from every namespace. Namespace grants therefore authorize the
6//! wrong thing — the deploy grant is deployment-wide, carried by the `deploy`
7//! token claim (or the `x-aion-deploy` development header), and this guard
8//! decides it before any handler logic runs.
9
10use std::sync::Arc;
11
12use aion::Engine;
13
14use crate::error::ServerError;
15use crate::namespace::resolver::{CallerIdentity, GrantSource, NamespaceResolver};
16
17/// Adapter-boundary guard for the operator deploy API, the sibling of
18/// [`crate::namespace::NamespaceGuard`] for engine-global operations.
19#[derive(Clone)]
20pub struct DeployGuard {
21    resolver: NamespaceResolver,
22}
23
24impl DeployGuard {
25    /// Build a guard from the shared namespace resolver (the engine owner).
26    #[must_use]
27    pub const fn new(resolver: NamespaceResolver) -> Self {
28        Self { resolver }
29    }
30
31    /// Authorize a caller for the deploy surface before any handler logic.
32    ///
33    /// # Errors
34    ///
35    /// Returns a `deploy_denied` wire error when the transport already denied
36    /// the caller (bad/missing credentials) or when the caller lacks the
37    /// deploy grant; the denial hint names the knob that actually carries the
38    /// grant, mirroring the namespace-denial pattern.
39    pub fn authorize(&self, caller: &CallerIdentity) -> Result<(), ServerError> {
40        if let Some(reason) = caller.denial_reason() {
41            return Err(ServerError::deploy_denied(reason));
42        }
43        if caller.deploy_granted() {
44            return Ok(());
45        }
46        Err(deploy_denied(caller))
47    }
48
49    /// Borrow the engine handle for an authorized deploy operation.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`ServerError::Config`] only for guards constructed without an
54    /// engine for unit tests.
55    pub fn engine(&self) -> Result<&Arc<Engine>, ServerError> {
56        self.resolver.engine()
57    }
58}
59
60fn deploy_denied(caller: &CallerIdentity) -> ServerError {
61    let subject = caller.subject();
62    let hint = match caller.grant_source() {
63        GrantSource::NamespacesHeader => {
64            format!("set x-aion-deploy: true for subject `{subject}`")
65        }
66        GrantSource::TokenClaim => {
67            format!("mint a token whose deploy claim is true for subject `{subject}`")
68        }
69    };
70    ServerError::deploy_denied(format!(
71        "subject `{subject}` is not authorized to deploy; {hint}"
72    ))
73}
74
75#[cfg(test)]
76mod tests {
77    use aion_proto::WireErrorCode;
78
79    use super::DeployGuard;
80    use crate::config::NamespaceMode;
81    use crate::namespace::{
82        CallerIdentity, NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces,
83    };
84
85    fn guard() -> DeployGuard {
86        DeployGuard::new(NamespaceResolver::authorization_only(
87            NamespaceMode::SharedEngine,
88            StaticWorkflowNamespaces::default(),
89            StaticScheduleNamespaces::default(),
90        ))
91    }
92
93    #[test]
94    fn granted_caller_is_authorized() -> Result<(), Box<dyn std::error::Error>> {
95        let header_caller = CallerIdentity::new("ci", [String::from("tenant-a")]).with_deploy(true);
96        let token_caller =
97            CallerIdentity::from_token_claims("ci", [String::from("tenant-a")]).with_deploy(true);
98
99        guard().authorize(&header_caller)?;
100        guard().authorize(&token_caller)?;
101        Ok(())
102    }
103
104    /// The denial hint must point at the knob that actually carries the
105    /// deploy grant: the development `x-aion-deploy` header for
106    /// header-sourced identities, the token's deploy claim for identities
107    /// produced by the JWT path (mirrors
108    /// `denial_hint_names_the_grant_source`).
109    #[test]
110    fn denial_hint_names_the_grant_source() -> Result<(), Box<dyn std::error::Error>> {
111        let header_caller = CallerIdentity::new("ci", [String::from("tenant-a")]);
112        let header_denial = guard()
113            .authorize(&header_caller)
114            .err()
115            .map(|error| error.to_wire_error())
116            .ok_or("expected header-sourced caller to be denied")?;
117        assert_eq!(header_denial.code, WireErrorCode::DeployDenied);
118        assert!(
119            header_denial
120                .message
121                .contains("subject `ci` is not authorized to deploy"),
122            "denial must name the subject: {}",
123            header_denial.message
124        );
125        assert!(
126            header_denial.message.contains("x-aion-deploy"),
127            "header-path denial must hint the dev header: {}",
128            header_denial.message
129        );
130        assert!(
131            !header_denial.message.contains("deploy claim"),
132            "header-path denial must not hint the token claim: {}",
133            header_denial.message
134        );
135
136        let token_caller = CallerIdentity::from_token_claims("ci", [String::from("tenant-a")]);
137        let token_denial = guard()
138            .authorize(&token_caller)
139            .err()
140            .map(|error| error.to_wire_error())
141            .ok_or("expected token-sourced caller to be denied")?;
142        assert_eq!(token_denial.code, WireErrorCode::DeployDenied);
143        assert!(
144            token_denial.message.contains("deploy claim"),
145            "JWT-path denial must hint the token's deploy claim: {}",
146            token_denial.message
147        );
148        assert!(
149            !token_denial.message.contains("x-aion-deploy"),
150            "JWT-path denial must not hint the dev header: {}",
151            token_denial.message
152        );
153        Ok(())
154    }
155
156    /// A transport-level credential failure stays a deploy denial carrying
157    /// the transport's specific reason.
158    #[test]
159    fn transport_denied_caller_is_deploy_denied_with_reason()
160    -> Result<(), Box<dyn std::error::Error>> {
161        let denied = CallerIdentity::denied("ci", "invalid bearer token").with_deploy(true);
162
163        let error = guard()
164            .authorize(&denied)
165            .err()
166            .map(|error| error.to_wire_error())
167            .ok_or("expected transport-denied caller to be refused")?;
168        assert_eq!(error.code, WireErrorCode::DeployDenied);
169        assert!(
170            error.message.contains("invalid bearer token"),
171            "denial must carry the transport reason: {}",
172            error.message
173        );
174        Ok(())
175    }
176
177    /// Namespace grants must not leak into the deploy decision: a caller
178    /// with every namespace but no deploy grant is denied.
179    #[test]
180    fn namespace_grants_do_not_imply_deploy() {
181        let caller = CallerIdentity::new("ci", [String::from("tenant-a"), String::from("b")]);
182
183        let result = guard().authorize(&caller);
184        assert_eq!(
185            result.err().map(|error| error.to_wire_error().code),
186            Some(WireErrorCode::DeployDenied)
187        );
188    }
189}