Skip to main content

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}