gwk_kernel/authority.rs
1//! Authority evaluation: which commands are gated, and what the gate answers.
2//!
3//! The contract's precedence, in order:
4//!
5//! 1. an invariant or explicit prohibition → **deny**;
6//! 2. absent or risk-gated authority → **page**;
7//! 3. a matching unexpired scoped grant → **allow**.
8//!
9//! Every result writes an immutable receipt in the same transaction. A page
10//! also raises a deduplicated unresolved attention item and does NOT mutate the
11//! target — it is a refusal that leaves a trail, not a queued write.
12
13use gwk_domain::envelope::{Actor, CommandEnvelope};
14use sqlx::PgConnection;
15
16use crate::project::Refusal;
17
18/// The commands an operator grant gates, and the class each falls in.
19///
20/// A command absent from this table is UNCLASSIFIED: it is not evaluated, gets
21/// no receipt, and behaves exactly as it did before authority existed. That is
22/// the honest default — `action_class` is an open string and no accepted
23/// artifact maps commands onto it, so inventing a class for the whole work
24/// lifecycle would gate ordinary work behind grants nobody has issued.
25///
26/// Two commands that look like obvious candidates are deliberately absent, and
27/// the reason is the same for both: a gate that cannot be opened is a deadlock,
28/// not a gate.
29///
30/// - `grant_authority` — gating it means the first grant needs a grant. No
31/// grant exists on a fresh kernel, so it would page, so no grant could ever
32/// be created, so nothing could ever be allowed. Granting is an operator act
33/// arriving over a same-EUID socket; the kernel gates what a grant PERMITS,
34/// not the act of granting.
35/// - `activate_kernel` — genesis runs before any grant can exist, for the same
36/// reason. Activation is protected structurally instead (exactly one genesis
37/// append against a sealed allowlist), which is a stronger guarantee than a
38/// grant lookup and does not depend on prior state.
39///
40/// Adding a class later is one line here plus its test. Removing the bootstrap
41/// exclusions is not — read the two paragraphs above first.
42const RISK_CLASS: &[(&str, &str)] = &[
43 // The stop/kill spine. Killing running work is the destructive act in a
44 // system whose whole job is to keep work running.
45 ("issue_command", "stop"),
46];
47
48/// What the gate decided.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub(crate) enum Decision {
51 /// Not gated at all — no class, so no evaluation and no receipt.
52 Unclassified,
53 /// A matching unexpired grant covers this.
54 Allow { action_class: &'static str },
55 /// Gated, and nothing covers it. Raises attention; does not mutate.
56 Page { action_class: &'static str },
57}
58
59impl Decision {
60 pub(crate) fn action_class(&self) -> Option<&'static str> {
61 match self {
62 Self::Unclassified => None,
63 Self::Allow { action_class } | Self::Page { action_class } => Some(action_class),
64 }
65 }
66}
67
68/// The class this command falls in, if any.
69pub(crate) fn class_of(command_type: &str) -> Option<&'static str> {
70 RISK_CLASS
71 .iter()
72 .find(|(name, _)| *name == command_type)
73 .map(|(_, class)| *class)
74}
75
76/// Evaluate one command against the grants on record.
77///
78/// Read under the writer lock the caller already holds, so a grant revoked in a
79/// concurrent transaction cannot be the one that allows this.
80pub(crate) async fn evaluate(
81 conn: &mut PgConnection,
82 envelope: &CommandEnvelope,
83 command_type: &str,
84 subject: &str,
85) -> Result<Decision, Refusal> {
86 let Some(action_class) = class_of(command_type) else {
87 return Ok(Decision::Unclassified);
88 };
89 let allowed = matching_grant(conn, &envelope.actor, action_class, subject, envelope).await?;
90 Ok(if allowed {
91 Decision::Allow { action_class }
92 } else {
93 Decision::Page { action_class }
94 })
95}
96
97/// Is there a live grant for this actor, class, and subject?
98///
99/// Scope matches EXACTLY, and a grant with no scope covers its whole class.
100/// No prefix and no pattern: the failure mode of a too-narrow grant is a
101/// refusal a human then widens, while the failure mode of a too-broad one is a
102/// command that should have paged and did not.
103///
104/// "Unexpired" is measured against the command's own `issued_at` rather than
105/// the database clock, so replaying the log reaches the same verdict it did
106/// live — a grant that had expired by wall-clock replay time must not turn a
107/// historical allow into a page.
108async fn matching_grant(
109 conn: &mut PgConnection,
110 actor: &Actor,
111 action_class: &str,
112 subject: &str,
113 envelope: &CommandEnvelope,
114) -> Result<bool, Refusal> {
115 // The grant's id, not `SELECT 1`: a bare literal comes back INT4 while the
116 // obvious Rust type for it is i64, and the driver refuses that decode
117 // outright. Selecting the id sidesteps the width question and names the
118 // grant that allowed this if anyone ever needs to log it.
119 let found: Option<String> = sqlx::query_scalar(
120 "SELECT id FROM gwk.authority_grant \
121 WHERE action_class = $1 \
122 AND grantee = $2::jsonb \
123 AND (scope IS NULL OR scope = $3) \
124 AND revoked_at IS NULL \
125 AND (expires_at IS NULL OR expires_at > $4::timestamptz) \
126 LIMIT 1",
127 )
128 .bind(action_class)
129 .bind(
130 serde_json::to_value(actor)
131 .map_err(|e| Refusal::storage(format!("serialize actor: {e}")))?,
132 )
133 .bind(subject)
134 .bind(envelope.issued_at.as_str())
135 .fetch_optional(conn)
136 .await
137 .map_err(|e| Refusal::storage(format!("read authority grants: {e}")))?;
138 Ok(found.is_some())
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn only_the_named_commands_are_gated() {
147 assert_eq!(class_of("issue_command"), Some("stop"));
148 assert_eq!(class_of("create_task"), None);
149 assert_eq!(class_of("transition_attempt"), None);
150 }
151
152 #[test]
153 fn the_bootstrap_commands_are_not_gated() {
154 // Both would deadlock: granting would need a grant, and genesis runs
155 // before any grant can exist. If either of these ever returns Some,
156 // a fresh kernel can no longer be brought up.
157 assert_eq!(class_of("grant_authority"), None);
158 assert_eq!(class_of("revoke_authority"), None);
159 assert_eq!(class_of("activate_kernel"), None);
160 }
161
162 #[test]
163 fn an_unclassified_decision_carries_no_class() {
164 assert_eq!(Decision::Unclassified.action_class(), None);
165 assert_eq!(
166 Decision::Page {
167 action_class: "stop"
168 }
169 .action_class(),
170 Some("stop")
171 );
172 }
173}