Skip to main content

astrid_kernel/kernel_router/admin/
mod.rs

1//! Layer 6 admin dispatcher (issue #672).
2//!
3//! Subscribes to `astrid.v1.admin.*` and routes every variant of
4//! [`AdminRequestKind`] through the same capability-enforcement
5//! preamble introduced in issue #670 (Layer 5). On allow, the mutating
6//! handlers in [`handlers`] acquire
7//! [`Kernel::admin_write_lock`](crate::Kernel::admin_write_lock) before
8//! touching `profile.toml` / `groups.toml`, then atomically replace the
9//! resolved config on the [`ArcSwap`](arc_swap::ArcSwap) backing
10//! [`Kernel::groups`](crate::Kernel::groups) and/or invalidate the
11//! matching [`PrincipalProfileCache`](astrid_capsule::profile_cache::PrincipalProfileCache)
12//! entry.
13//!
14//! # Audit trail
15//!
16//! Every admin topic — allow or deny — appends an
17//! [`AuditAction::AdminRequest`] entry. `method` is the wire name
18//! (`"admin.agent.create"`, etc.); `target_principal` is `Some` for
19//! variants that operate on another principal and `None` otherwise.
20//! `params` captures the full request payload (capabilities granted,
21//! quotas set, group definition) for forensic replay without diffing
22//! `profile.toml` snapshots.
23
24#[cfg(test)]
25mod enforcement_tests;
26mod handlers;
27#[cfg(test)]
28mod state_tests;
29#[cfg(test)]
30mod state_tests_agent_modify;
31#[cfg(test)]
32mod tests;
33
34use std::sync::Arc;
35
36use astrid_audit::{AuditOutcome, AuthorizationProof};
37use astrid_core::principal::PrincipalId;
38use astrid_events::ipc::IpcPayload;
39use astrid_events::kernel_api::{
40    AdminKernelRequest, AdminKernelResponse, AdminRequestKind, AdminResponseBody,
41};
42use tracing::warn;
43
44use super::{
45    AdminAuditEntry, AuthorityScope, authorize_request, publish_response, record_admin_audit,
46    resolve_caller,
47};
48
49/// Admin IPC input topic prefix.
50const ADMIN_TOPIC_PREFIX: &str = "astrid.v1.admin.";
51/// Admin IPC response topic prefix (paired with [`ADMIN_TOPIC_PREFIX`]).
52const ADMIN_RESPONSE_PREFIX: &str = "astrid.v1.admin.response.";
53
54/// Spawn the admin dispatcher task. Mirrors [`super::spawn_kernel_router`]
55/// but listens on `astrid.v1.admin.*` and parses
56/// [`AdminKernelRequest`] payloads.
57pub(crate) fn spawn_admin_router(kernel: Arc<crate::Kernel>) -> tokio::task::JoinHandle<()> {
58    let mut receiver = kernel.event_bus.subscribe_topic("astrid.v1.admin.*");
59
60    tokio::spawn(async move {
61        while let Some(event) = receiver.recv().await {
62            let astrid_events::AstridEvent::Ipc { message, .. } = &*event else {
63                continue;
64            };
65
66            // Never loop back on our own response topic.
67            if message.topic.starts_with(ADMIN_RESPONSE_PREFIX) {
68                continue;
69            }
70
71            let IpcPayload::RawJson(val) = &message.payload else {
72                continue;
73            };
74
75            match serde_json::from_value::<AdminKernelRequest>(val.clone()) {
76                Ok(req) => {
77                    let caller = resolve_caller(message);
78                    handle_admin_request(&kernel, message.topic.clone(), caller, req).await;
79                },
80                Err(e) => {
81                    warn!(
82                        error = %e,
83                        topic = %message.topic,
84                        "Failed to parse AdminKernelRequest from IPC"
85                    );
86                },
87            }
88        }
89    })
90}
91
92/// Compute the response topic for an incoming admin request topic.
93fn admin_response_topic(input_topic: &str) -> String {
94    input_topic.strip_prefix(ADMIN_TOPIC_PREFIX).map_or_else(
95        || input_topic.to_string(),
96        |suffix| format!("{ADMIN_RESPONSE_PREFIX}{suffix}"),
97    )
98}
99
100/// Return the authority scope `req` exercises for `caller`.
101///
102/// Self-scoped when the target principal equals the caller
103/// ([`AdminRequestKind::QuotaGet`] / [`AdminRequestKind::QuotaSet`]
104/// / [`AdminRequestKind::AgentList`] — the last scoped as "self" so
105/// agents can see their own row). Everything else is cross-tenant,
106/// including creation / group operations that are intrinsically global.
107#[must_use]
108pub fn resolve_admin_scope(req: &AdminRequestKind, caller: &PrincipalId) -> AuthorityScope {
109    match req {
110        AdminRequestKind::QuotaGet { principal } | AdminRequestKind::QuotaSet { principal, .. } => {
111            if principal == caller {
112                AuthorityScope::Self_
113            } else {
114                AuthorityScope::Global
115            }
116        },
117        // `GroupList` is read-only over system config and carries no
118        // target principal; every agent legitimately needs to read it
119        // to enumerate their own group-inherited capabilities (e.g.
120        // `caps check <self>` follows AgentList with GroupList to
121        // resolve `(group: agent)` → `self:agent:list`). Self-scoping
122        // makes the request match against `self:group:list`, which
123        // the `self:*` grant on the `agent` builtin already satisfies
124        // — without handing out the admin-tier `group:list` capability.
125        // The mutating group operations (`group create / delete /
126        // modify`) keep their own dedicated caps (`group:create`,
127        // `group:delete`, `group:modify`) and remain
128        // `AuthorityScope::Global` below, so this widening is read-only.
129        AdminRequestKind::AgentList | AdminRequestKind::GroupList => AuthorityScope::Self_,
130        AdminRequestKind::AgentCreate { .. }
131        | AdminRequestKind::AgentDelete { .. }
132        | AdminRequestKind::AgentEnable { .. }
133        | AdminRequestKind::AgentDisable { .. }
134        | AdminRequestKind::AgentModify { .. }
135        | AdminRequestKind::GroupCreate { .. }
136        | AdminRequestKind::GroupDelete { .. }
137        | AdminRequestKind::GroupModify { .. }
138        | AdminRequestKind::CapsGrant { .. }
139        | AdminRequestKind::CapsRevoke { .. } => AuthorityScope::Global,
140    }
141}
142
143/// Static capability string required to satisfy `req` under `scope`.
144///
145/// Pure function — the mapping can be unit-tested in isolation.
146/// Every variant has an entry; there is no default-allow arm.
147///
148/// `self:*` forms apply when the target principal is the caller
149/// themselves; admins operating on another principal need the
150/// unscoped `quota:set` / `caps:grant` forms. Group admin is always
151/// global — there is no "self" variant of `group:create`.
152#[must_use]
153pub fn required_capability_for_admin_request(
154    req: &AdminRequestKind,
155    scope: AuthorityScope,
156) -> &'static str {
157    match (req, scope) {
158        (AdminRequestKind::AgentCreate { .. }, _) => "agent:create",
159        (AdminRequestKind::AgentDelete { .. }, _) => "agent:delete",
160        (AdminRequestKind::AgentEnable { .. }, _) => "agent:enable",
161        (AdminRequestKind::AgentDisable { .. }, _) => "agent:disable",
162        (AdminRequestKind::AgentModify { .. }, _) => "agent:modify",
163        (AdminRequestKind::AgentList, AuthorityScope::Self_) => "self:agent:list",
164        (AdminRequestKind::AgentList, AuthorityScope::Global) => "agent:list",
165        (AdminRequestKind::QuotaSet { .. }, AuthorityScope::Self_) => "self:quota:set",
166        (AdminRequestKind::QuotaSet { .. }, AuthorityScope::Global) => "quota:set",
167        (AdminRequestKind::QuotaGet { .. }, AuthorityScope::Self_) => "self:quota:get",
168        (AdminRequestKind::QuotaGet { .. }, AuthorityScope::Global) => "quota:get",
169        (AdminRequestKind::GroupCreate { .. }, _) => "group:create",
170        (AdminRequestKind::GroupDelete { .. }, _) => "group:delete",
171        (AdminRequestKind::GroupModify { .. }, _) => "group:modify",
172        (AdminRequestKind::GroupList, AuthorityScope::Self_) => "self:group:list",
173        (AdminRequestKind::GroupList, AuthorityScope::Global) => "group:list",
174        (AdminRequestKind::CapsGrant { .. }, _) => "caps:grant",
175        (AdminRequestKind::CapsRevoke { .. }, _) => "caps:revoke",
176    }
177}
178
179/// Stable wire-name identifier for an [`AdminRequestKind`] — used as
180/// the `method` field on every [`AuditAction::AdminRequest`] entry.
181#[must_use]
182pub fn admin_request_method(req: &AdminRequestKind) -> &'static str {
183    match req {
184        AdminRequestKind::AgentCreate { .. } => "admin.agent.create",
185        AdminRequestKind::AgentDelete { .. } => "admin.agent.delete",
186        AdminRequestKind::AgentEnable { .. } => "admin.agent.enable",
187        AdminRequestKind::AgentDisable { .. } => "admin.agent.disable",
188        AdminRequestKind::AgentModify { .. } => "admin.agent.modify",
189        AdminRequestKind::AgentList => "admin.agent.list",
190        AdminRequestKind::QuotaSet { .. } => "admin.quota.set",
191        AdminRequestKind::QuotaGet { .. } => "admin.quota.get",
192        AdminRequestKind::GroupCreate { .. } => "admin.group.create",
193        AdminRequestKind::GroupDelete { .. } => "admin.group.delete",
194        AdminRequestKind::GroupModify { .. } => "admin.group.modify",
195        AdminRequestKind::GroupList => "admin.group.list",
196        AdminRequestKind::CapsGrant { .. } => "admin.caps.grant",
197        AdminRequestKind::CapsRevoke { .. } => "admin.caps.revoke",
198    }
199}
200
201/// Borrow the target principal for audit purposes — `Some` only when the
202/// request operates on a principal distinct from the caller.
203#[must_use]
204pub fn admin_target_principal(req: &AdminRequestKind) -> Option<&PrincipalId> {
205    match req {
206        AdminRequestKind::AgentDelete { principal }
207        | AdminRequestKind::AgentEnable { principal }
208        | AdminRequestKind::AgentDisable { principal }
209        | AdminRequestKind::AgentModify { principal, .. }
210        | AdminRequestKind::QuotaSet { principal, .. }
211        | AdminRequestKind::QuotaGet { principal }
212        | AdminRequestKind::CapsGrant { principal, .. }
213        | AdminRequestKind::CapsRevoke { principal, .. } => Some(principal),
214        AdminRequestKind::AgentCreate { .. }
215        | AdminRequestKind::AgentList
216        | AdminRequestKind::GroupCreate { .. }
217        | AdminRequestKind::GroupDelete { .. }
218        | AdminRequestKind::GroupModify { .. }
219        | AdminRequestKind::GroupList => None,
220    }
221}
222
223async fn handle_admin_request(
224    kernel: &Arc<crate::Kernel>,
225    topic: String,
226    caller: PrincipalId,
227    req: AdminKernelRequest,
228) {
229    let response_topic = admin_response_topic(&topic);
230    let request_id = req.request_id.clone();
231    let method = admin_request_method(&req.kind);
232    let scope = resolve_admin_scope(&req.kind, &caller);
233    let required_cap = required_capability_for_admin_request(&req.kind, scope);
234    let target = admin_target_principal(&req.kind).cloned();
235    // Capture the params field for the audit entry — clients submitting
236    // malformed JSON never reach this point, so serialization is
237    // infallible for shapes we accept.
238    let audit_params = serde_json::to_value(&req.kind).ok();
239
240    match authorize_request(kernel, &caller, required_cap) {
241        Ok(()) => {
242            record_admin_audit(
243                kernel,
244                AdminAuditEntry {
245                    caller: &caller,
246                    method,
247                    required_cap,
248                    target_principal: target.clone(),
249                    params: audit_params.clone(),
250                    authorization: AuthorizationProof::System {
251                        reason: format!("policy allow: {caller} holds {required_cap}"),
252                    },
253                    outcome: AuditOutcome::success(),
254                },
255            );
256        },
257        Err(e) => {
258            warn!(
259                security_event = true,
260                method = method,
261                principal = %caller,
262                required = required_cap,
263                error = %e,
264                "Permission check denied admin request"
265            );
266            record_admin_audit(
267                kernel,
268                AdminAuditEntry {
269                    caller: &caller,
270                    method,
271                    required_cap,
272                    target_principal: target,
273                    params: audit_params,
274                    authorization: AuthorizationProof::Denied {
275                        reason: e.to_string(),
276                    },
277                    outcome: AuditOutcome::failure(e.to_string()),
278                },
279            );
280            publish_response(
281                kernel,
282                response_topic,
283                AdminKernelResponse::for_request(
284                    request_id,
285                    AdminResponseBody::Error(e.to_string()),
286                ),
287            );
288            return;
289        },
290    }
291
292    let body = handlers::dispatch(kernel, req.kind).await;
293    publish_response(
294        kernel,
295        response_topic,
296        AdminKernelResponse::for_request(request_id, body),
297    );
298}