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        // An operator always holds the deploy grant (`deploy_granted` is true),
70        // so this arm is never reached; keep the match exhaustive.
71        GrantSource::Operator => {
72            format!("subject `{subject}` is the operator and already holds the deploy grant")
73        }
74    };
75    ServerError::deploy_denied(format!(
76        "subject `{subject}` is not authorized to deploy; {hint}"
77    ))
78}
79
80#[cfg(test)]
81mod tests {
82    use aion_proto::WireErrorCode;
83
84    use super::DeployGuard;
85    use crate::config::NamespaceMode;
86    use crate::namespace::{
87        CallerIdentity, NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces,
88    };
89
90    fn guard() -> DeployGuard {
91        DeployGuard::new(NamespaceResolver::authorization_only(
92            NamespaceMode::SharedEngine,
93            StaticWorkflowNamespaces::default(),
94            StaticScheduleNamespaces::default(),
95        ))
96    }
97
98    #[test]
99    fn granted_caller_is_authorized() -> Result<(), Box<dyn std::error::Error>> {
100        let header_caller = CallerIdentity::new("ci", [String::from("tenant-a")]).with_deploy(true);
101        let token_caller =
102            CallerIdentity::from_token_claims("ci", [String::from("tenant-a")]).with_deploy(true);
103
104        guard().authorize(&header_caller)?;
105        guard().authorize(&token_caller)?;
106        Ok(())
107    }
108
109    /// The denial hint must point at the knob that actually carries the
110    /// deploy grant: the development `x-aion-deploy` header for
111    /// header-sourced identities, the token's deploy claim for identities
112    /// produced by the JWT path (mirrors
113    /// `denial_hint_names_the_grant_source`).
114    #[test]
115    fn denial_hint_names_the_grant_source() -> Result<(), Box<dyn std::error::Error>> {
116        let header_caller = CallerIdentity::new("ci", [String::from("tenant-a")]);
117        let header_denial = guard()
118            .authorize(&header_caller)
119            .err()
120            .map(|error| error.to_wire_error())
121            .ok_or("expected header-sourced caller to be denied")?;
122        assert_eq!(header_denial.code, WireErrorCode::DeployDenied);
123        assert!(
124            header_denial
125                .message
126                .contains("subject `ci` is not authorized to deploy"),
127            "denial must name the subject: {}",
128            header_denial.message
129        );
130        assert!(
131            header_denial.message.contains("x-aion-deploy"),
132            "header-path denial must hint the dev header: {}",
133            header_denial.message
134        );
135        assert!(
136            !header_denial.message.contains("deploy claim"),
137            "header-path denial must not hint the token claim: {}",
138            header_denial.message
139        );
140
141        let token_caller = CallerIdentity::from_token_claims("ci", [String::from("tenant-a")]);
142        let token_denial = guard()
143            .authorize(&token_caller)
144            .err()
145            .map(|error| error.to_wire_error())
146            .ok_or("expected token-sourced caller to be denied")?;
147        assert_eq!(token_denial.code, WireErrorCode::DeployDenied);
148        assert!(
149            token_denial.message.contains("deploy claim"),
150            "JWT-path denial must hint the token's deploy claim: {}",
151            token_denial.message
152        );
153        assert!(
154            !token_denial.message.contains("x-aion-deploy"),
155            "JWT-path denial must not hint the dev header: {}",
156            token_denial.message
157        );
158        Ok(())
159    }
160
161    /// A transport-level credential failure stays a deploy denial carrying
162    /// the transport's specific reason.
163    #[test]
164    fn transport_denied_caller_is_deploy_denied_with_reason()
165    -> Result<(), Box<dyn std::error::Error>> {
166        let denied = CallerIdentity::denied("ci", "invalid bearer token").with_deploy(true);
167
168        let error = guard()
169            .authorize(&denied)
170            .err()
171            .map(|error| error.to_wire_error())
172            .ok_or("expected transport-denied caller to be refused")?;
173        assert_eq!(error.code, WireErrorCode::DeployDenied);
174        assert!(
175            error.message.contains("invalid bearer token"),
176            "denial must carry the transport reason: {}",
177            error.message
178        );
179        Ok(())
180    }
181
182    /// Namespace grants must not leak into the deploy decision: a caller
183    /// with every namespace but no deploy grant is denied.
184    #[test]
185    fn namespace_grants_do_not_imply_deploy() {
186        let caller = CallerIdentity::new("ci", [String::from("tenant-a"), String::from("b")]);
187
188        let result = guard().authorize(&caller);
189        assert_eq!(
190            result.err().map(|error| error.to_wire_error().code),
191            Some(WireErrorCode::DeployDenied)
192        );
193    }
194}