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}