Skip to main content

cratestack_core/context/
system.rs

1//! The trusted/service principal that model policies **name** via
2//! `auth().isSystem()`, rather than a blanket "skip policy" bypass
3//! flag (issue #486 / webank-context ADR 0038 blocker B1).
4//!
5//! The design constraint this file exists to satisfy: *obtaining a
6//! system context must not be possible from a request-derived
7//! context*. That is enforced structurally, not by convention:
8//!
9//! - [`SystemContext`] is the only public way to produce a
10//!   [`CoolContext`] whose private `system` flag is set.
11//! - It has no `From<CoolContext>`, no `TryFrom<CoolContext>`, and no
12//!   constructor that accepts a caller-supplied `CoolContext`. There is
13//!   therefore no function anywhere — in this crate or any downstream
14//!   one — that turns an inbound request's context into a system one.
15//!   An `AuthProvider::authenticate` implementation, which is the only
16//!   place a `CoolContext` is ever built from a request, has no way to
17//!   reach this type at all.
18//! - It is not `Deserialize`, and `CoolContext::system` is
19//!   `#[serde(skip)]`, so the marker cannot arrive over a wire (RPC
20//!   envelope, cached principal, client-state-store round trip, ...).
21//!
22//! Fail-closed follows from the *policy* side, not from this type:
23//! `is_system()` only ever *satisfies a predicate a schema wrote down*
24//! (`ReadPredicate::AuthIsSystem`, matched in
25//! `cratestack_sqlx::query::support::create::evaluate_input_predicate`,
26//! `query::support::policy_predicate::push_policy_predicate`, and
27//! `render::policy_predicate::render_policy_predicate`). A model that
28//! never names `auth().isSystem()` in an `@@allow` clause never emits
29//! that predicate at all — see
30//! `cratestack_macros::policy::model::tests_system_principal` — so a
31//! system caller gains nothing on it: the model's existing default-deny
32//! / owner-scoped rules apply exactly as they would to any other caller
33//! lacking the claims those rules check for.
34
35use std::collections::BTreeMap;
36
37use crate::value::Value;
38
39use super::{CoolAuthIdentity, CoolContext, PrincipalContext};
40
41/// A context representing trusted in-process/server code (a procedure,
42/// a worker, a reconciliation job) rather than an end user.
43///
44/// Deliberately a distinct type from [`CoolContext`] so that "this call
45/// runs as the system" is visible in a function signature and
46/// greppable, instead of being a boolean threaded through call sites
47/// the way `db.model().unchecked().update(...)` would have been. Borrow
48/// the inner context with [`SystemContext::context`] to hand it to the
49/// ORM (`cool.model().update(id).run(system.context())`), or consume it
50/// with [`SystemContext::into_context`] where an owned `CoolContext` is
51/// required.
52#[derive(Debug, Clone, PartialEq)]
53pub struct SystemContext {
54    inner: CoolContext,
55}
56
57impl SystemContext {
58    /// A system context attributed to a named service. The name is
59    /// recorded as both the `service` claim and (prefixed) the `id`
60    /// claim, so it flows through unchanged into
61    /// `cratestack_sqlx::audit::actor_from_context` — which reads
62    /// `principal.claims.id` for `AuditActor::id` and the full claims
63    /// map for `AuditActor::claims` — without any audit-path code
64    /// needing to know a system caller is a distinct kind of caller.
65    /// An audit row produced by a system write reads
66    /// `actor.id = "system:<service>"`, which is how design constraint
67    /// #3 (auditability) is met: no new audit machinery, just a
68    /// deliberately-shaped actor identity flowing through the existing
69    /// one.
70    pub fn for_service(service: impl Into<String>) -> Self {
71        let service = service.into();
72        let mut fields = BTreeMap::new();
73        fields.insert("service".to_owned(), Value::String(service.clone()));
74        fields.insert("id".to_owned(), Value::String(format!("system:{service}")));
75
76        Self {
77            inner: CoolContext {
78                auth: Some(CoolAuthIdentity {
79                    fields: fields.clone(),
80                }),
81                principal: Some(PrincipalContext::from_claims(fields)),
82                extensions: BTreeMap::new(),
83                system: true,
84            },
85        }
86    }
87
88    /// Borrow the underlying context to pass to the query layer.
89    pub fn context(&self) -> &CoolContext {
90        &self.inner
91    }
92
93    /// Consume into the underlying context.
94    ///
95    /// Note this is the *only* direction that exists: `CoolContext ->
96    /// SystemContext` has no constructor anywhere. That asymmetry is
97    /// the whole security property this module provides.
98    pub fn into_context(self) -> CoolContext {
99        self.inner
100    }
101}
102
103impl AsRef<CoolContext> for SystemContext {
104    fn as_ref(&self) -> &CoolContext {
105        &self.inner
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn system_context_is_system_and_authenticated() {
115        let ctx = SystemContext::for_service("ledger-worker");
116        assert!(ctx.context().is_system());
117        assert!(ctx.context().is_authenticated());
118        assert_eq!(
119            ctx.context().auth_field("service"),
120            Some(&Value::String("ledger-worker".to_owned()))
121        );
122    }
123
124    #[test]
125    fn request_derived_contexts_are_never_system() {
126        assert!(!CoolContext::anonymous().is_system());
127        assert!(
128            !CoolContext::authenticated([(
129                "subjectId".to_owned(),
130                Value::String("u-1".to_owned())
131            )])
132            .is_system()
133        );
134    }
135
136    /// The wire is the interesting attack surface: if `system` were
137    /// serialized, anything that round-trips a `CoolContext` (RPC
138    /// envelopes, cached principals) would let a client assert it.
139    #[test]
140    fn system_flag_does_not_survive_serde_round_trip() {
141        let system = SystemContext::for_service("ledger-worker").into_context();
142        assert!(system.is_system());
143
144        let json = serde_json::to_string(&system).expect("context should serialize");
145        // Check for the *key*, not the substring — the service name
146        // this fixture uses legitimately puts "system:" inside a claim
147        // value, so a substring check would pass for the wrong reason.
148        let encoded: serde_json::Value =
149            serde_json::from_str(&json).expect("context should serialize to an object");
150        assert!(
151            encoded
152                .as_object()
153                .expect("context serializes as an object")
154                .get("system")
155                .is_none(),
156            "system marker must not appear on the wire: {json}"
157        );
158
159        let decoded: CoolContext = serde_json::from_str(&json).expect("context should deserialize");
160        assert!(
161            !decoded.is_system(),
162            "a deserialized context must never be a system context"
163        );
164    }
165
166    /// A hand-forged payload that explicitly sets `system` must not be
167    /// honoured either — this is the forgery test design constraint #4
168    /// asks for: nothing an HTTP caller controls can produce a system
169    /// context, and a wire payload is the most direct thing an HTTP
170    /// caller controls.
171    #[test]
172    fn forged_system_field_in_payload_is_ignored() {
173        let decoded: CoolContext =
174            serde_json::from_str(r#"{"auth":null,"principal":null,"extensions":{},"system":true}"#)
175                .expect("unknown/skipped field should be ignored");
176        assert!(!decoded.is_system());
177    }
178}