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
//! Runtime capability discovery (`GET /whoami`).
//!
//! The console asserts no authorization at build time. It DISCOVERS its
//! capabilities at runtime by asking the server who the caller is and what it
//! is allowed to do, then renders affordances from that. This endpoint runs
//! through the same [`HttpCaller`] extractor as every data route, so it
//! reflects exactly the identity the server resolved for the request — there is
//! no second auth path.
//!
//! It is safe to expose without additional gating: it only reflects the
//! caller's OWN grants. It never enumerates other subjects, never lists the
//! deployment's namespaces, and reveals nothing an authorized request to the
//! data API would not already reveal to the same caller.
use axum::{Json, extract::State};
use serde::Serialize;
use super::auth::HttpCaller;
use crate::ServerState;
use crate::namespace::grants::GRANT_WORDS;
/// Capability snapshot for the resolved caller, consumed by the ops console to
/// gate affordances at runtime.
#[derive(Debug, Serialize)]
pub(crate) struct WhoAmI {
/// Caller subject as resolved by the transport (the audit label).
subject: String,
/// Whether the server has auth configured. When `false` the server is in
/// single-tenant operator mode and the caller is the operator.
auth_enabled: bool,
/// Every grant word this deployment defines, and whether this caller
/// holds it.
///
/// Built by walking [`GRANT_WORDS`], never by a second hand-written list:
/// a word that existed in the grammar and not here would be grantable and
/// undiscoverable, which is exactly the defect this endpoint exists to
/// prevent for namespaces.
grants: Vec<GrantDescriptor>,
/// Whether the caller holds access to every namespace (operator mode),
/// rather than the explicit `namespaces` set.
all_namespaces: bool,
/// The caller's explicitly granted namespaces, sorted. Empty for an
/// operator (whose all-access is signaled by `all_namespaces`).
namespaces: Vec<String>,
}
/// One grant word as the console discovers it: what it is called, how it is
/// carried, what it authorises, and whether this caller holds it.
#[derive(Debug, Serialize)]
pub(crate) struct GrantDescriptor {
/// 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.
description: &'static str,
/// Whether this caller holds it.
granted: bool,
}
/// Reflect the resolved caller's identity and grants for runtime capability
/// discovery.
pub(crate) async fn whoami(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
) -> Json<WhoAmI> {
Json(WhoAmI {
subject: caller.subject().to_owned(),
auth_enabled: state.runtime_config().auth.enabled,
grants: GRANT_WORDS
.iter()
.map(|grant| GrantDescriptor {
word: grant.word(),
header: grant.header(),
claim: grant.claim(),
description: grant.description(),
granted: grant.granted_for(&caller),
})
.collect(),
all_namespaces: caller.all_namespaces(),
namespaces: caller.namespaces(),
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use aion::EngineBuilder;
use aion_store::{EventStore, InMemoryStore};
use axum::{body, http::Request, http::StatusCode};
use serde_json::Value;
use tower::ServiceExt;
use super::super::router::workflow_router;
use super::super::test_support::{read_json, runtime_config, server_state};
use super::GRANT_WORDS;
use crate::test_support::{EngineUnderTest, StateUnderTest};
use crate::{
NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces,
config::NamespaceMode,
};
/// Auth-off state over a fresh engine, held by the caller so the engine is
/// shut down when the test ends; `workflow_router(state.clone())` is the
/// router.
async fn auth_off_state() -> Result<StateUnderTest, Box<dyn std::error::Error>> {
let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
let engine = EngineUnderTest::new(Arc::new(
EngineBuilder::new()
.stop_drain_timeout(std::time::Duration::from_secs(5))
.store_arc(store)
.in_memory_visibility()
.scheduler_threads(1)
.build()
.await?,
));
let resolver = NamespaceResolver::from_parts(
NamespaceMode::SharedEngine,
Some(engine.handle()),
Arc::new(StaticWorkflowNamespaces::default()),
Arc::new(StaticScheduleNamespaces::default()),
);
let mut config = runtime_config();
config.auth.enabled = false;
server_state(engine, resolver, config).await
}
/// Auth-off operator mode: `/whoami` reports the operator's full access with
/// no development headers on the request. This is the runtime signal the
/// ops console reads to enable deploy/namespace affordances.
#[tokio::test]
async fn whoami_reports_operator_in_auth_off_mode() -> Result<(), Box<dyn std::error::Error>> {
let state = auth_off_state().await?;
let response = workflow_router(state.clone())
.oneshot(
Request::builder()
.uri("/whoami")
.body(body::Body::empty())?,
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body: Value = read_json(response).await?;
assert_eq!(body["auth_enabled"], serde_json::json!(false));
assert_eq!(body["all_namespaces"], serde_json::json!(true));
assert_eq!(body["subject"], serde_json::json!("operator"));
assert_eq!(body["namespaces"], serde_json::json!([]));
// The operator's grants live ONLY in the vocabulary walk now — the
// per-word bools were duplicate truth and are gone. In auth-off mode
// every word is held.
let listed = body["grants"]
.as_array()
.ok_or("`/whoami` must carry a `grants` array")?;
assert!(
listed
.iter()
.all(|row| row["granted"] == serde_json::json!(true)),
"auth-off mode grants the operator every word: {listed:?}"
);
Ok(())
}
/// THE VOCABULARY PIN. Every word in the grammar appears in `/whoami`'s
/// `grants` list with its header, claim, and description.
///
/// It walks [`GRANT_WORDS`] itself, not a copy of it. That is the whole
/// point: `deploy` spent its life as a `bool`, a claim key, and a repeated
/// header literal with no list anywhere, so a SECOND word could have been
/// parsed, carried, and enforced while appearing in nothing an operator
/// could read. A word added to the grammar and not to the descriptor is a
/// grant that is enforceable and undiscoverable — it fails HERE.
#[tokio::test]
async fn whoami_lists_every_word_in_the_grant_vocabulary()
-> Result<(), Box<dyn std::error::Error>> {
// Vacuity control: an emptied vocabulary would satisfy the loop below.
assert!(
!GRANT_WORDS.is_empty(),
"the grant vocabulary is empty, so this pin measures nothing"
);
let state = auth_off_state().await?;
let response = workflow_router(state.clone())
.oneshot(
Request::builder()
.uri("/whoami")
.body(body::Body::empty())?,
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body: Value = read_json(response).await?;
let listed = body["grants"]
.as_array()
.ok_or("`/whoami` must carry a `grants` array")?;
assert_eq!(
listed.len(),
GRANT_WORDS.len(),
"`grants` lists {} words for a vocabulary of {}: {listed:?}",
listed.len(),
GRANT_WORDS.len()
);
for grant in GRANT_WORDS {
let described = listed
.iter()
.find(|row| row["word"] == serde_json::json!(grant.word()))
.ok_or_else(|| {
format!(
"`{}` is in the grant vocabulary but not in `/whoami`'s grants list",
grant.word()
)
})?;
assert_eq!(
described["header"],
serde_json::json!(grant.header()),
"`{}` is described with the wrong header",
grant.word()
);
assert_eq!(
described["claim"],
serde_json::json!(grant.claim()),
"`{}` is described with the wrong claim",
grant.word()
);
assert_eq!(
described["description"],
serde_json::json!(grant.description()),
"`{}` is described with the wrong description",
grant.word()
);
assert_eq!(
described["granted"],
serde_json::json!(true),
"the auth-off operator must hold `{}`",
grant.word()
);
}
Ok(())
}
}