Skip to main content

reddb_server/auth/
self_lock_guard.rs

1//! PolicySelfLockGuard — attach-time invariant + break-glass recovery
2//! for issue #713.
3//!
4//! Operators can compose policies that, taken together, deny the system
5//! the right to ever modify policies again — `deny policy:detach on *`,
6//! for example. Such an attach permanently bricks the IAM surface. This
7//! module catches it at the boundary and refuses, then offers a single
8//! documented recovery path for the case where the invariant was
9//! somehow bypassed.
10//!
11//! # Invariant
12//!
13//! On every audited `policy:attach`, we simulate the post-attach policy
14//! graph against a synthetic principal called [`PLATFORM_OWNER_USERNAME`]
15//! who carries an inert system-reserved unlock policy granting
16//! `policy:*` and `admin:bootstrap` on `*`. If the simulation of
17//! `policy:detach` against any policy resource does not return `Allow`,
18//! we refuse the attach with an error naming the offending statement.
19//!
20//! The synthetic principal is *not* an admin role and *not* an
21//! authentication identity — it exists purely as a policy-graph anchor
22//! for the invariant check. It is filtered out of every catalog read
23//! path and rejected at every authentication entry point.
24//!
25//! # Break-glass
26//!
27//! `REDDB_POLICY_BREAK_GLASS=1` at boot: install/refresh the unlock
28//! policy, rebind it to the synthetic principal, emit a loud
29//! `policy.break_glass` audit event, and warn on the log. After the
30//! operator attaches a corrective policy by hand, they reboot without
31//! the env var and the system continues normally.
32
33use std::collections::HashMap;
34use std::sync::Arc;
35
36use super::policies::{self as iam_policies, Decision, EvalContext, Policy, ResourceRef};
37
38/// Synthetic principal username. Reserved — must never appear in
39/// `red.users`, must never authenticate. Lives only inside the policy
40/// graph as the "ultimate admin" anchor for the invariant.
41pub const PLATFORM_OWNER_USERNAME: &str = "__platform_owner__";
42
43/// Policy id for the system-reserved unlock policy attached to the
44/// synthetic principal.
45pub const PLATFORM_OWNER_UNLOCK_POLICY_ID: &str = "__platform_owner_unlock__";
46
47/// Env var that triggers the break-glass recovery path at boot.
48pub const BREAK_GLASS_ENV: &str = "REDDB_POLICY_BREAK_GLASS";
49
50/// Construct the inert unlock policy: allow `policy:*` and
51/// `admin:bootstrap` on `*`. Returns the parsed `Policy` ready to be
52/// fed to `AuthStore::put_policy`.
53pub fn unlock_policy() -> Policy {
54    let json = format!(
55        r#"{{
56            "id": "{id}",
57            "version": 1,
58            "statements": [
59                {{
60                    "sid": "unlock-policy-lifecycle",
61                    "effect": "allow",
62                    "actions": ["policy:*", "admin:bootstrap"],
63                    "resources": ["*"]
64                }}
65            ]
66        }}"#,
67        id = PLATFORM_OWNER_UNLOCK_POLICY_ID,
68    );
69    Policy::from_json_str(&json).expect("unlock policy must compile")
70}
71
72/// True when the username equals the reserved synthetic principal.
73pub fn is_synthetic_principal(username: &str) -> bool {
74    username == PLATFORM_OWNER_USERNAME
75}
76
77/// True when the policy id matches the system-reserved unlock policy.
78pub fn is_unlock_policy(policy_id: &str) -> bool {
79    policy_id == PLATFORM_OWNER_UNLOCK_POLICY_ID
80}
81
82/// Build the `EvalContext` for the synthetic principal. Marks it as
83/// admin-role and platform-scoped so policies with conditions targeting
84/// non-admin or tenant-scoped principals don't match and don't block the
85/// invariant.
86pub fn synthetic_eval_context() -> EvalContext {
87    EvalContext {
88        principal_tenant: None,
89        current_tenant: None,
90        peer_ip: None,
91        mfa_present: false,
92        now_ms: 0,
93        principal_is_admin_role: true,
94        principal_is_platform_scoped: true,
95    }
96}
97
98/// Outcome of the invariant check. Carries enough detail for the
99/// caller to produce a clear error message naming the offending
100/// statement.
101#[derive(Debug, Clone)]
102pub enum InvariantOutcome {
103    Ok,
104    Blocked {
105        policy_id: String,
106        sid: Option<String>,
107        reason: String,
108    },
109}
110
111/// Simulate `policy:detach` against `policy:*` for the synthetic
112/// principal, treating every entry in `existing_policies` as effective
113/// for the principal (whoever attached one of these deny statements
114/// implicitly proposed to make it apply to everyone, including the
115/// platform owner; the simulator gates that by condition matching).
116///
117/// `existing_policies` should be the *full* policy set after the
118/// proposed mutation has been applied (for an attach, that's just the
119/// current set — attach doesn't add a policy, only a binding).
120pub fn check_self_lock_invariant(existing_policies: &[Arc<Policy>]) -> InvariantOutcome {
121    let unlock = unlock_policy();
122    let mut refs: Vec<&Policy> = Vec::with_capacity(existing_policies.len() + 1);
123    refs.push(&unlock);
124    for p in existing_policies {
125        // Skip the persisted unlock policy if it's already in the set —
126        // we always evaluate against the freshly-built one to avoid a
127        // tampered persisted copy weakening the invariant.
128        if p.id == PLATFORM_OWNER_UNLOCK_POLICY_ID {
129            continue;
130        }
131        refs.push(p.as_ref());
132    }
133
134    let ctx = synthetic_eval_context();
135    let resource = ResourceRef::new("policy", "*");
136    let outcome = iam_policies::simulate(&refs, "policy:detach", &resource, &ctx);
137
138    match outcome.decision {
139        Decision::Allow { .. } | Decision::AdminBypass => InvariantOutcome::Ok,
140        Decision::Deny {
141            matched_policy_id,
142            matched_sid,
143        } => InvariantOutcome::Blocked {
144            policy_id: matched_policy_id,
145            sid: matched_sid,
146            reason: outcome.reason,
147        },
148        Decision::DefaultDeny => InvariantOutcome::Blocked {
149            policy_id: PLATFORM_OWNER_UNLOCK_POLICY_ID.to_string(),
150            sid: None,
151            reason: outcome.reason,
152        },
153    }
154}
155
156/// Format a human-facing error message for a blocked invariant.
157pub fn format_block_error(outcome: &InvariantOutcome) -> Option<String> {
158    match outcome {
159        InvariantOutcome::Ok => None,
160        InvariantOutcome::Blocked {
161            policy_id,
162            sid,
163            reason,
164        } => {
165            let sid_part = sid
166                .as_ref()
167                .map(|s| format!(" (sid={s})"))
168                .unwrap_or_default();
169            Some(format!(
170                "self-lock invariant: attach would block `__platform_owner__` \
171                 from detaching policies — offending statement in policy `{policy_id}`{sid_part}: {reason}"
172            ))
173        }
174    }
175}
176
177/// Marker carried in the `fields` map of the break-glass audit event.
178pub fn break_glass_audit_fields(boot_ts_ms: u128) -> HashMap<String, String> {
179    let mut map = HashMap::new();
180    map.insert("boot_ts_ms".into(), boot_ts_ms.to_string());
181    map.insert("env_var".into(), BREAK_GLASS_ENV.into());
182    map.insert("policy_id".into(), PLATFORM_OWNER_UNLOCK_POLICY_ID.into());
183    map.insert("principal".into(), PLATFORM_OWNER_USERNAME.into());
184    map
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn parse(json: &str) -> Arc<Policy> {
192        Arc::new(Policy::from_json_str(json).expect("policy parses"))
193    }
194
195    #[test]
196    fn unlock_policy_compiles() {
197        let p = unlock_policy();
198        assert_eq!(p.id, PLATFORM_OWNER_UNLOCK_POLICY_ID);
199        assert_eq!(p.statements.len(), 1);
200    }
201
202    #[test]
203    fn synthetic_principal_check() {
204        assert!(is_synthetic_principal(PLATFORM_OWNER_USERNAME));
205        assert!(!is_synthetic_principal("alice"));
206        assert!(is_unlock_policy(PLATFORM_OWNER_UNLOCK_POLICY_ID));
207        assert!(!is_unlock_policy("p-something-else"));
208    }
209
210    #[test]
211    fn invariant_ok_when_only_unlock() {
212        let outcome = check_self_lock_invariant(&[]);
213        assert!(matches!(outcome, InvariantOutcome::Ok));
214    }
215
216    #[test]
217    fn invariant_blocks_deny_detach_on_wildcard() {
218        let p = parse(
219            r#"{
220                "id": "p-self-lock",
221                "version": 1,
222                "statements": [{
223                    "sid": "lock",
224                    "effect": "deny",
225                    "actions": ["policy:detach"],
226                    "resources": ["*"]
227                }]
228            }"#,
229        );
230        let outcome = check_self_lock_invariant(&[p]);
231        match outcome {
232            InvariantOutcome::Blocked { policy_id, sid, .. } => {
233                assert_eq!(policy_id, "p-self-lock");
234                assert_eq!(sid.as_deref(), Some("lock"));
235            }
236            other => panic!("expected Blocked, got {other:?}"),
237        }
238    }
239
240    #[test]
241    fn invariant_blocks_deny_detach_on_policy_wildcard() {
242        let p = parse(
243            r#"{
244                "id": "p-policy-lock",
245                "version": 1,
246                "statements": [{
247                    "effect": "deny",
248                    "actions": ["policy:detach"],
249                    "resources": ["policy:*"]
250                }]
251            }"#,
252        );
253        let outcome = check_self_lock_invariant(&[p]);
254        assert!(matches!(outcome, InvariantOutcome::Blocked { .. }));
255    }
256
257    #[test]
258    fn invariant_allows_narrower_deny() {
259        // Deny only for tenant-scoped principals. The synthetic
260        // principal is platform-scoped, so the condition skips this
261        // statement and the unlock policy still grants Allow.
262        let p = parse(
263            r#"{
264                "id": "p-narrow",
265                "version": 1,
266                "statements": [{
267                    "effect": "deny",
268                    "actions": ["policy:detach"],
269                    "resources": ["*"],
270                    "condition": { "platform_scoped": false }
271                }]
272            }"#,
273        );
274        let outcome = check_self_lock_invariant(&[p]);
275        assert!(matches!(outcome, InvariantOutcome::Ok));
276    }
277
278    #[test]
279    fn format_block_error_for_blocked() {
280        let outcome = InvariantOutcome::Blocked {
281            policy_id: "p-x".into(),
282            sid: Some("s-y".into()),
283            reason: "deny at p-x.statement[0] (sid=s-y)".into(),
284        };
285        let msg = format_block_error(&outcome).expect("blocked carries a message");
286        assert!(msg.contains("p-x"));
287        assert!(msg.contains("s-y"));
288        assert!(msg.contains("__platform_owner__"));
289    }
290
291    #[test]
292    fn format_block_error_none_for_ok() {
293        assert!(format_block_error(&InvariantOutcome::Ok).is_none());
294    }
295
296    #[test]
297    fn break_glass_audit_fields_carries_marker() {
298        let fields = break_glass_audit_fields(123_456);
299        assert_eq!(fields.get("boot_ts_ms").unwrap(), "123456");
300        assert_eq!(fields.get("env_var").unwrap(), BREAK_GLASS_ENV);
301        assert_eq!(
302            fields.get("policy_id").unwrap(),
303            PLATFORM_OWNER_UNLOCK_POLICY_ID
304        );
305        assert_eq!(fields.get("principal").unwrap(), PLATFORM_OWNER_USERNAME);
306    }
307
308    // -----------------------------------------------------------------
309    // End-to-end against the real AuthStore — covers the attach
310    // refusal, narrower-deny pass, and break-glass recovery paths.
311    // -----------------------------------------------------------------
312
313    mod end_to_end {
314        use super::*;
315        use crate::auth::store::{PolicyMutationControl, PrincipalRef};
316        use crate::auth::{AuthConfig, AuthError, AuthStore, Role, UserId};
317        use crate::runtime::control_events::{
318            ActorRef, ControlEvent, ControlEventConfig, ControlEventCtx, ControlEventError,
319            ControlEventLedger, EventId, EventKind,
320        };
321
322        struct NoopLedger;
323        impl ControlEventLedger for NoopLedger {
324            fn emit(
325                &self,
326                _: &ControlEventCtx<'_>,
327                _: ControlEvent,
328            ) -> Result<EventId, ControlEventError> {
329                Ok(EventId("test".into()))
330            }
331        }
332
333        fn fresh_store() -> std::sync::Arc<AuthStore> {
334            std::sync::Arc::new(AuthStore::new(AuthConfig::default()))
335        }
336
337        fn attach_with_ledger(
338            store: &std::sync::Arc<AuthStore>,
339            principal: PrincipalRef,
340            policy_id: &str,
341            actor: &UserId,
342        ) -> Result<(), AuthError> {
343            let ledger = NoopLedger;
344            let ctx = ControlEventCtx {
345                actor: ActorRef::User(actor),
346                scope: None,
347                request_id: None,
348                trace_id: None,
349            };
350            let eval_ctx = EvalContext {
351                principal_tenant: None,
352                current_tenant: None,
353                peer_ip: None,
354                mfa_present: false,
355                now_ms: 0,
356                principal_is_admin_role: true,
357                principal_is_platform_scoped: true,
358            };
359            let control = PolicyMutationControl {
360                ctx: &ctx,
361                ledger: &ledger,
362                config: ControlEventConfig::default(),
363                registry: None,
364                actor,
365                eval_ctx: &eval_ctx,
366            };
367            store.attach_policy_with_control_events(principal, policy_id, &control)
368        }
369
370        #[test]
371        fn attach_refuses_self_lock_policy() {
372            let store = fresh_store();
373            store.create_user("alice", "p", Role::Admin).unwrap();
374            let alice = UserId::platform("alice");
375
376            let lock = Policy::from_json_str(
377                r#"{
378                    "id": "p-self-lock",
379                    "version": 1,
380                    "statements": [{
381                        "sid": "brick",
382                        "effect": "deny",
383                        "actions": ["policy:detach"],
384                        "resources": ["*"]
385                    }]
386                }"#,
387            )
388            .unwrap();
389            store.put_policy(lock).unwrap();
390
391            let err = attach_with_ledger(
392                &store,
393                PrincipalRef::User(alice.clone()),
394                "p-self-lock",
395                &alice,
396            )
397            .expect_err("self-lock attach must be refused");
398            let msg = err.to_string();
399            assert!(msg.contains("self-lock invariant"), "msg={msg}");
400            assert!(msg.contains("p-self-lock"), "msg={msg}");
401            assert!(msg.contains("brick"), "msg={msg}");
402        }
403
404        #[test]
405        fn attach_allows_narrower_deny() {
406            let store = fresh_store();
407            store.create_user("alice", "p", Role::Admin).unwrap();
408            let alice = UserId::platform("alice");
409
410            let narrow = Policy::from_json_str(
411                r#"{
412                    "id": "p-narrow",
413                    "version": 1,
414                    "statements": [{
415                        "effect": "deny",
416                        "actions": ["policy:detach"],
417                        "resources": ["*"],
418                        "condition": { "platform_scoped": false }
419                    }]
420                }"#,
421            )
422            .unwrap();
423            store.put_policy(narrow).unwrap();
424
425            attach_with_ledger(
426                &store,
427                PrincipalRef::User(alice.clone()),
428                "p-narrow",
429                &alice,
430            )
431            .expect("narrower deny attaches normally");
432        }
433
434        #[test]
435        fn break_glass_installs_rebinds_and_hides_synthetic() {
436            let store = fresh_store();
437            store
438                .apply_policy_break_glass(123_000)
439                .expect("break-glass");
440
441            assert!(store.get_policy(PLATFORM_OWNER_UNLOCK_POLICY_ID).is_some());
442
443            let users = store.list_users();
444            assert!(
445                !users.iter().any(|u| u.username == PLATFORM_OWNER_USERNAME),
446                "synthetic principal must not appear in list_users"
447            );
448
449            let scoped = store.list_users_scoped(Some(None));
450            assert!(
451                !scoped.iter().any(|u| u.username == PLATFORM_OWNER_USERNAME),
452                "synthetic principal must not appear in list_users_scoped"
453            );
454
455            let err = store
456                .authenticate_in_tenant(None, PLATFORM_OWNER_USERNAME, "")
457                .expect_err("synthetic principal must not authenticate");
458            assert!(matches!(err, AuthError::InvalidCredentials));
459
460            // Idempotent.
461            store.apply_policy_break_glass(124_000).expect("idempotent");
462            assert!(store.get_policy(PLATFORM_OWNER_UNLOCK_POLICY_ID).is_some());
463        }
464
465        #[test]
466        fn break_glass_recovers_from_tampered_attachment() {
467            // Simulates the brief's "rebind it in case a malicious
468            // attach detached it earlier" scenario.
469            let store = fresh_store();
470            store.apply_policy_break_glass(100).expect("install");
471            let owner = UserId::platform(PLATFORM_OWNER_USERNAME);
472            // Force the binding away.
473            store
474                .detach_policy(
475                    PrincipalRef::User(owner.clone()),
476                    PLATFORM_OWNER_UNLOCK_POLICY_ID,
477                )
478                .expect("detach");
479
480            // Boot again with the env var → reattaches.
481            store.apply_policy_break_glass(200).expect("rebind");
482            let effective = store.effective_policies(&owner);
483            assert!(
484                effective
485                    .iter()
486                    .any(|p| p.id == PLATFORM_OWNER_UNLOCK_POLICY_ID),
487                "break-glass must rebind unlock policy to synthetic principal"
488            );
489        }
490
491        #[test]
492        fn event_kind_break_glass_str() {
493            assert_eq!(EventKind::PolicyBreakGlass.as_str(), "policy.break_glass");
494        }
495
496        #[test]
497        fn linter_and_attach_surface_same_self_lock_message() {
498            // Acceptance for S2B (#715): the linter must report the
499            // SelfLockRisk diagnostic with the exact string the
500            // attach-time guard surfaces, so operators see the same
501            // explanation in both places.
502            use crate::auth::policy_linter::{lint, DiagnosticCode};
503
504            let raw = r#"{
505                "id": "p-self-lock",
506                "version": 1,
507                "statements": [{
508                    "sid": "brick",
509                    "effect": "deny",
510                    "actions": ["policy:detach"],
511                    "resources": ["*"]
512                }]
513            }"#;
514
515            // Linter side.
516            let diags = lint(raw);
517            let lint_msg = diags
518                .iter()
519                .find(|d| d.code == DiagnosticCode::SelfLockRisk)
520                .expect("linter must flag SelfLockRisk")
521                .message
522                .clone();
523
524            // Attach side, against a real AuthStore.
525            let store = fresh_store();
526            store.create_user("alice", "p", Role::Admin).unwrap();
527            let alice = UserId::platform("alice");
528            store
529                .put_policy(Policy::from_json_str(raw).unwrap())
530                .unwrap();
531            let err = attach_with_ledger(
532                &store,
533                PrincipalRef::User(alice.clone()),
534                "p-self-lock",
535                &alice,
536            )
537            .expect_err("attach must refuse");
538
539            // The attach error wraps the same string in
540            // `AuthError::Forbidden(...)` — strip the wrapper before
541            // comparing so we're matching the *explanation*, not the
542            // error-variant Display prefix.
543            let attach_msg = match err {
544                crate::auth::AuthError::Forbidden(s) => s,
545                other => panic!("expected Forbidden, got {other:?}"),
546            };
547            assert_eq!(
548                lint_msg, attach_msg,
549                "linter and attach must surface identical self-lock explanations",
550            );
551        }
552    }
553}