1use std::collections::HashMap;
34use std::sync::Arc;
35
36use super::policies::{self as iam_policies, Decision, EvalContext, Policy, ResourceRef};
37
38pub const PLATFORM_OWNER_USERNAME: &str = "__platform_owner__";
42
43pub const PLATFORM_OWNER_UNLOCK_POLICY_ID: &str = "__platform_owner_unlock__";
46
47pub const BREAK_GLASS_ENV: &str = "REDDB_POLICY_BREAK_GLASS";
49
50pub 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
72pub fn is_synthetic_principal(username: &str) -> bool {
74 username == PLATFORM_OWNER_USERNAME
75}
76
77pub fn is_unlock_policy(policy_id: &str) -> bool {
79 policy_id == PLATFORM_OWNER_UNLOCK_POLICY_ID
80}
81
82pub 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#[derive(Debug, Clone)]
102pub enum InvariantOutcome {
103 Ok,
104 Blocked {
105 policy_id: String,
106 sid: Option<String>,
107 reason: String,
108 },
109}
110
111pub 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 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
156pub 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
177pub 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 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 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 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 let store = fresh_store();
470 store.apply_policy_break_glass(100).expect("install");
471 let owner = UserId::platform(PLATFORM_OWNER_USERNAME);
472 store
474 .detach_policy(
475 PrincipalRef::User(owner.clone()),
476 PLATFORM_OWNER_UNLOCK_POLICY_ID,
477 )
478 .expect("detach");
479
480 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 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 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 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 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}