aion-server 0.29.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
//! The grant vocabulary: every authorization word this deployment spells.
//!
//! A grant word is deployment-wide authorization for a surface that is not a
//! namespace operation. `deploy` was the first, and for a long time it was not
//! a WORD anywhere: it was a `bool` on [`CallerIdentity`], a JWT claim key, a
//! header literal repeated at every adapter boundary, and a hand-written field
//! on `GET /whoami`. Nothing enumerated it, so nothing could enumerate a
//! SECOND one either — a word that parsed but appeared in no list would be
//! grantable and invisible, which is the visibility-versus-availability defect
//! in its purest form: an operator cannot grant what the server will not name.
//!
//! So the vocabulary is DATA. [`GRANT_WORDS`] is the single enumeration, and
//! everything downstream walks it rather than restating it: `/whoami` builds
//! its `grants` descriptor from this table, and the refusal
//! ([`require_grant`]) reads the header and claim spellings out of the row
//! instead of re-spelling them. Adding a row is how a word comes to exist;
//! there is nowhere else to add one.

use crate::error::ServerError;
use crate::namespace::resolver::{CallerIdentity, GrantSource};

/// One authorization grant word, with every surface that carries it.
///
/// The fields are the four things an operator, an audit line, and a client all
/// have to agree on. Keeping them in one row is what makes them impossible to
/// drift apart: a header changed here changes the refusal hint, the `/whoami`
/// descriptor, and the credential-promotion pin in the same edit.
#[derive(Clone, Copy, Debug)]
pub struct GrantWord {
    /// The stable word an operator and an audit line spell.
    word: &'static str,
    /// The request header that carries it on the development paths.
    header: &'static str,
    /// The bearer-token claim that carries it when auth is enabled.
    claim: &'static str,
    /// One sentence naming what the word authorises, for the descriptor.
    description: &'static str,
    /// Reads this word's decision off a resolved caller.
    ///
    /// A function rather than a field name because the decision lives on
    /// [`CallerIdentity`] as a typed accessor per word; this is the seam that
    /// lets the descriptor be built by walking the table instead of by a
    /// second hand-written list that could disagree with it.
    granted: fn(&CallerIdentity) -> bool,
}

impl GrantWord {
    /// The stable word an operator and an audit line spell.
    #[must_use]
    pub const fn word(&self) -> &'static str {
        self.word
    }

    /// The request header that carries this word on the development paths.
    #[must_use]
    pub const fn header(&self) -> &'static str {
        self.header
    }

    /// The bearer-token claim that carries this word when auth is enabled.
    #[must_use]
    pub const fn claim(&self) -> &'static str {
        self.claim
    }

    /// One sentence naming what this word authorises.
    #[must_use]
    pub const fn description(&self) -> &'static str {
        self.description
    }

    /// Whether `caller` holds this word.
    #[must_use]
    pub fn granted_for(&self, caller: &CallerIdentity) -> bool {
        (self.granted)(caller)
    }
}

/// The deployment-wide `deploy` grant: the operator deploy surface.
///
/// The word, header, and claim are exactly what the deploy path has always
/// used ([`crate::deploy::DeployGuard`], `api/http/auth.rs`,
/// `auth/jwks.rs`); this row DESCRIBES that path rather than redefining it.
pub const DEPLOY: GrantWord = GrantWord {
    word: "deploy",
    header: "x-aion-deploy",
    claim: "deploy",
    description: "load packages, re-point routing, and unload versions on the deployment-wide \
                  operator deploy surface",
    granted: CallerIdentity::deploy_granted,
};

/// The deployment-wide `assistant.sessions` grant: server-owned assistant
/// harness sessions.
pub const ASSISTANT_SESSIONS: GrantWord = GrantWord {
    word: "assistant.sessions",
    header: "x-aion-assistant-sessions",
    claim: "assistant.sessions",
    description: "start and drive server-owned assistant harness sessions",
    granted: CallerIdentity::assistant_sessions_granted,
};

/// Every grant word this deployment defines.
///
/// The ONE enumeration. `/whoami`'s `grants` descriptor, the operator's
/// all-grants identity, and the credential-header pins are all derived from
/// this slice; a word added here reaches every one of them, and a word that
/// exists anywhere else without a row here fails the pins that walk it.
pub const GRANT_WORDS: &[GrantWord] = &[DEPLOY, ASSISTANT_SESSIONS];

/// Look a grant word up by the word an operator spells.
#[must_use]
pub fn lookup(word: &str) -> Option<&'static GrantWord> {
    GRANT_WORDS.iter().find(|grant| grant.word == word)
}

/// Authorize a caller for a grant word before any handler logic runs.
///
/// This is the refusal every granted route calls. It is deliberately NOT
/// [`crate::deploy::DeployGuard`]: deploy keeps its own code path and its own
/// `deploy_denied` wire code, while every other word refuses on the dedicated
/// `grant_denied` code, which names the word instead of naming the deploy
/// surface a caller may never have touched.
///
/// # Errors
///
/// Returns a `grant_denied` [`ServerError`] when the transport already denied
/// the caller (bad or missing credentials, whose specific reason is carried
/// through) or when the caller does not hold `grant`. The denial names the
/// subject, the word, and the knob that actually carries it for this caller's
/// grant source — a hint that pointed at the wrong knob would send an operator
/// to edit a header on a token-authenticated deployment.
pub fn require_grant(caller: &CallerIdentity, grant: &GrantWord) -> Result<(), ServerError> {
    if let Some(reason) = caller.denial_reason() {
        return Err(ServerError::grant_denied(reason));
    }
    if grant.granted_for(caller) {
        return Ok(());
    }
    Err(grant_denied(caller, grant))
}

/// Build the refusal, with the hint switched on where this caller's grants
/// came from. Mirrors the deploy guard's denial shape so the two refusals read
/// the same to an operator.
fn grant_denied(caller: &CallerIdentity, grant: &GrantWord) -> ServerError {
    let subject = caller.subject();
    let word = grant.word();
    let hint = match caller.grant_source() {
        GrantSource::NamespacesHeader => {
            format!("set `{}`: true for subject `{subject}`", grant.header())
        }
        GrantSource::TokenClaim => {
            format!(
                "mint a token whose `{}` claim is true for subject `{subject}`",
                grant.claim()
            )
        }
        // An operator holds every grant word (`CallerIdentity::operator` sets
        // each one), so this arm is never reached; keep the match exhaustive.
        GrantSource::Operator => {
            format!("subject `{subject}` is the operator and already holds `{word}`")
        }
    };
    ServerError::grant_denied(format!(
        "subject `{subject}` is not authorized for `{word}`; {hint}"
    ))
}

#[cfg(test)]
mod tests {
    use aion_proto::WireErrorCode;

    use super::{ASSISTANT_SESSIONS, DEPLOY, GRANT_WORDS, GrantWord, lookup, require_grant};
    use crate::namespace::CallerIdentity;

    /// Vacuity control for every loop below: an emptied vocabulary would
    /// satisfy them all.
    #[test]
    fn the_vocabulary_is_not_empty() {
        assert!(
            !GRANT_WORDS.is_empty(),
            "the grant vocabulary is empty, so every test that walks it measures nothing"
        );
    }

    /// The `deploy` row DESCRIBES the path deploy already takes. These three
    /// literals are what `DeployGuard`, `api/http/auth.rs`, and `auth/jwks.rs`
    /// have used since the deploy grant existed; a row that spelled any of
    /// them differently would describe a knob that grants nothing.
    #[test]
    fn the_deploy_row_matches_the_path_deploy_actually_takes() {
        assert_eq!(DEPLOY.word(), "deploy");
        assert_eq!(DEPLOY.header(), "x-aion-deploy");
        assert_eq!(DEPLOY.claim(), "deploy");
    }

    /// The second word, pinned by the same rule.
    #[test]
    fn the_assistant_sessions_row_carries_its_declared_spellings() {
        assert_eq!(ASSISTANT_SESSIONS.word(), "assistant.sessions");
        assert_eq!(ASSISTANT_SESSIONS.header(), "x-aion-assistant-sessions");
        assert_eq!(ASSISTANT_SESSIONS.claim(), "assistant.sessions");
    }

    /// Two rows sharing a word, header, or claim would make one of them
    /// ungrantable: the credential would silently authorize the other.
    #[test]
    fn every_row_is_distinct_in_word_header_and_claim() {
        /// One named accessor over a grant word, so the walk asserts every
        /// field by NAME rather than by three near-identical loops.
        type Field = (&'static str, fn(&GrantWord) -> &'static str);
        let fields: [Field; 3] = [
            ("word", GrantWord::word),
            ("header", GrantWord::header),
            ("claim", GrantWord::claim),
        ];
        for (field, read) in fields {
            let mut values: Vec<&str> = GRANT_WORDS.iter().map(read).collect();
            let total = values.len();
            values.sort_unstable();
            values.dedup();
            assert_eq!(values.len(), total, "two grant words share one {field}");
        }
    }

    /// Every row must say what it authorises: an empty description reaches the
    /// console's `/whoami` descriptor as a grant nobody can explain.
    #[test]
    fn every_row_describes_what_it_authorises() {
        for grant in GRANT_WORDS {
            assert!(
                !grant.description().trim().is_empty(),
                "`{}` carries no description",
                grant.word()
            );
        }
    }

    /// `lookup` reads the table, and answers `None` for a word that is not in
    /// it rather than inventing one.
    #[test]
    fn lookup_finds_every_word_and_invents_none() {
        for grant in GRANT_WORDS {
            let found = lookup(grant.word());
            assert_eq!(
                found.map(GrantWord::word),
                Some(grant.word()),
                "`{}` is in the vocabulary but not findable in it",
                grant.word()
            );
        }
        assert!(lookup("assistant").is_none());
        assert!(lookup("").is_none());
    }

    /// The auth-off operator holds EVERY word — walked from the vocabulary, so
    /// a word added without an operator-side grant fails here. An operator
    /// silently missing a grant is a single-tenant deployment where a surface
    /// is unreachable with no credential that could reach it.
    #[test]
    fn the_operator_holds_every_word() {
        let operator = CallerIdentity::operator("operator");
        for grant in GRANT_WORDS {
            assert!(
                grant.granted_for(&operator),
                "the operator does not hold `{}`",
                grant.word()
            );
        }
    }

    /// A header- or token-sourced caller holds NO word until one is attached:
    /// the negative control for the test above, which would pass on an
    /// identity that granted everything to everyone.
    #[test]
    fn an_enumerated_caller_holds_no_word_by_default() {
        for caller in [
            CallerIdentity::new("ci", [String::from("tenant-a")]),
            CallerIdentity::from_token_claims("ci", [String::from("tenant-a")]),
            CallerIdentity::denied("ci", "invalid bearer token"),
        ] {
            for grant in GRANT_WORDS {
                assert!(
                    !grant.granted_for(&caller),
                    "`{}` was granted to a caller that asserted nothing",
                    grant.word()
                );
            }
        }
    }

    /// `granted_for` reads the caller's own decision per word, not a shared
    /// flag: holding `deploy` must not confer `assistant.sessions`, and the
    /// reverse.
    #[test]
    fn one_word_does_not_confer_another() {
        let deployer = CallerIdentity::new("ci", [String::from("tenant-a")]).with_deploy(true);
        assert!(DEPLOY.granted_for(&deployer));
        assert!(!ASSISTANT_SESSIONS.granted_for(&deployer));

        let assistant =
            CallerIdentity::new("ci", [String::from("tenant-a")]).with_assistant_sessions(true);
        assert!(ASSISTANT_SESSIONS.granted_for(&assistant));
        assert!(!DEPLOY.granted_for(&assistant));
    }

    #[test]
    fn a_granted_caller_is_authorized() -> Result<(), Box<dyn std::error::Error>> {
        let header_caller =
            CallerIdentity::new("ci", [String::from("tenant-a")]).with_assistant_sessions(true);
        let token_caller = CallerIdentity::from_token_claims("ci", [String::from("tenant-a")])
            .with_assistant_sessions(true);

        require_grant(&header_caller, &ASSISTANT_SESSIONS)?;
        require_grant(&token_caller, &ASSISTANT_SESSIONS)?;
        require_grant(&CallerIdentity::operator("operator"), &ASSISTANT_SESSIONS)?;
        Ok(())
    }

    /// A grant refusal carries `grant_denied`, never `deploy_denied`: the
    /// deploy code names a surface this caller never touched, and an SDK
    /// branching on it would route the failure to deploy handling.
    #[test]
    fn a_refusal_never_borrows_the_deploy_code() -> Result<(), Box<dyn std::error::Error>> {
        let caller = CallerIdentity::new("ci", [String::from("tenant-a")]);
        let error = require_grant(&caller, &ASSISTANT_SESSIONS)
            .err()
            .map(|error| error.to_wire_error())
            .ok_or("expected an ungranted caller to be refused")?;

        assert_eq!(error.code, WireErrorCode::GrantDenied);
        assert!(
            error.message.contains("assistant.sessions"),
            "the refusal must name the word: {}",
            error.message
        );
        assert!(
            error.message.contains("subject `ci`"),
            "the refusal must name the subject: {}",
            error.message
        );
        Ok(())
    }

    /// The hint must point at the knob that actually carries the grant for
    /// THIS caller: the development header for a header-sourced identity, the
    /// token claim for one produced by the JWT path. Mirrors
    /// `deploy::guard::tests::denial_hint_names_the_grant_source`.
    #[test]
    fn the_hint_names_this_callers_grant_source() -> Result<(), Box<dyn std::error::Error>> {
        let header_denial = require_grant(
            &CallerIdentity::new("ci", [String::from("tenant-a")]),
            &ASSISTANT_SESSIONS,
        )
        .err()
        .map(|error| error.to_wire_error())
        .ok_or("expected the header-sourced caller to be denied")?;
        assert!(
            header_denial.message.contains("x-aion-assistant-sessions"),
            "header-path denial must hint the dev header: {}",
            header_denial.message
        );
        assert!(
            !header_denial.message.contains("claim"),
            "header-path denial must not hint the token claim: {}",
            header_denial.message
        );

        let token_denial = require_grant(
            &CallerIdentity::from_token_claims("ci", [String::from("tenant-a")]),
            &ASSISTANT_SESSIONS,
        )
        .err()
        .map(|error| error.to_wire_error())
        .ok_or("expected the token-sourced caller to be denied")?;
        assert!(
            token_denial
                .message
                .contains("`assistant.sessions` claim is true"),
            "JWT-path denial must hint the token claim: {}",
            token_denial.message
        );
        assert!(
            !token_denial.message.contains("x-aion-assistant-sessions"),
            "JWT-path denial must not hint the dev header: {}",
            token_denial.message
        );
        Ok(())
    }

    /// Every word's refusal quotes its OWN header and claim — walked from the
    /// vocabulary, so a row whose hint spellings drifted from its declared
    /// ones fails here instead of sending an operator to a knob that grants
    /// nothing.
    #[test]
    fn every_words_refusal_quotes_that_words_own_knobs() -> Result<(), Box<dyn std::error::Error>> {
        for grant in GRANT_WORDS {
            let header_message = require_grant(&CallerIdentity::new("ci", []), grant)
                .err()
                .map(|error| error.to_wire_error().message)
                .ok_or("expected the header-sourced caller to be denied")?;
            assert!(
                header_message.contains(grant.header()),
                "`{}` refusal must name its header `{}`: {header_message}",
                grant.word(),
                grant.header()
            );

            let claim_message = require_grant(&CallerIdentity::from_token_claims("ci", []), grant)
                .err()
                .map(|error| error.to_wire_error().message)
                .ok_or("expected the token-sourced caller to be denied")?;
            assert!(
                claim_message.contains(grant.claim()),
                "`{}` refusal must name its claim `{}`: {claim_message}",
                grant.word(),
                grant.claim()
            );
        }
        Ok(())
    }

    /// A transport-level credential failure stays a grant denial carrying the
    /// transport's specific reason — the caller has to be told the credential
    /// was rejected, not that a grant is missing from a credential the server
    /// never accepted.
    #[test]
    fn a_transport_denied_caller_keeps_its_reason() -> Result<(), Box<dyn std::error::Error>> {
        let denied =
            CallerIdentity::denied("ci", "invalid bearer token").with_assistant_sessions(true);

        let error = require_grant(&denied, &ASSISTANT_SESSIONS)
            .err()
            .map(|error| error.to_wire_error())
            .ok_or("expected the transport-denied caller to be refused")?;
        assert_eq!(error.code, WireErrorCode::GrantDenied);
        assert!(
            error.message.contains("invalid bearer token"),
            "the denial must carry the transport reason: {}",
            error.message
        );
        Ok(())
    }

    /// Namespace grants must not leak into a grant decision: a caller with
    /// every namespace but no grant word is refused for each of them.
    #[test]
    fn namespace_grants_do_not_imply_any_word() {
        let caller = CallerIdentity::new("ci", [String::from("tenant-a"), String::from("b")]);
        for grant in GRANT_WORDS {
            assert_eq!(
                require_grant(&caller, grant)
                    .err()
                    .map(|error| error.to_wire_error().code),
                Some(WireErrorCode::GrantDenied),
                "namespaces implied `{}`",
                grant.word()
            );
        }
    }
}