Skip to main content

reddb_server/auth/
registry.rs

1//! `red.registry` — governance metadata surface for RedDB-owned resources.
2//!
3//! Tracer slice for #648. The registry governs metadata about config
4//! resources (type/schema, mutability, sensitivity, managed status,
5//! required action/resource, evidence requirement). Values themselves
6//! live in their native stores — `red.config`, `red.vault`, the policy
7//! store; the registry only describes how those values are validated
8//! and authorized.
9//!
10//! Invariants:
11//!
12//! * The active surface returns the current version for any registered
13//!   resource.
14//! * History records every superseded version with actor, time, and a
15//!   change reason.
16//! * Entries are mutated only through this module's governance API
17//!   ([`ConfigRegistry::register`] / [`ConfigRegistry::supersede`]),
18//!   which calls into [`AuthStore::check_policy_authz`]. There is no
19//!   SQL surface — ordinary DML cannot reach these entries by
20//!   construction.
21
22use std::borrow::Cow;
23use std::collections::HashMap;
24use std::sync::RwLock;
25
26use super::policies::{EvalContext, ResourceRef};
27use super::store::AuthStore;
28use super::UserId;
29use crate::runtime::control_events::{
30    ControlEvent, ControlEventConfig, ControlEventCtx, ControlEventLedger, EventKind, Outcome,
31    Sensitivity as ControlSensitivity,
32};
33
34/// How a config resource may be changed.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Mutability {
37    /// Fixed at registration — supersede is rejected.
38    Immutable,
39    /// Mutable only via governance commands (registry API), never via DML.
40    MutableViaGovernance,
41}
42
43/// Data classification of the underlying value the entry governs.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Sensitivity {
46    Public,
47    Internal,
48    Confidential,
49    Secret,
50}
51
52/// Evidence the Control Event Ledger must capture for mutations of the
53/// underlying resource. Metadata-only is the default; `Full` includes
54/// the previous and next normalized value fingerprints.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum EvidenceRequirement {
57    None,
58    Metadata,
59    Full,
60}
61
62/// A single registry entry — the governance metadata for one config
63/// resource (a config key, a vault path, a policy id, an audit surface).
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ConfigRegistryEntry {
66    /// Canonical resource id (e.g. `"red.config.audit.enabled"`).
67    pub id: String,
68    /// Monotonically increasing version. Starts at 1 on register; each
69    /// `supersede` increments by one.
70    pub version: u64,
71    /// Logical type of the resource — e.g. `"config_key"`, `"vault_path"`,
72    /// `"policy"`, `"audit_surface"`.
73    pub resource_type: String,
74    /// Schema / value-shape description (free-form for the tracer; a
75    /// future slice can promote this to a structured schema id).
76    pub schema: String,
77    pub mutability: Mutability,
78    pub sensitivity: Sensitivity,
79    /// `true` for operator-owned guardrail entries (managed-policy /
80    /// managed-config namespace style). `false` for ordinary entries.
81    pub managed: bool,
82    /// Policy action a caller must satisfy to mutate the underlying
83    /// resource (not the registry entry itself).
84    pub required_action: String,
85    /// Policy resource the action applies to.
86    pub required_resource: String,
87    pub evidence_requirement: EvidenceRequirement,
88    /// Display form of the principal who last wrote this entry.
89    pub updated_by: String,
90    /// Unix ms when this version became active.
91    pub updated_at_ms: u128,
92}
93
94/// One row of registry history — a superseded version plus the
95/// who/when/why metadata for the change.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ConfigRegistryHistoryRecord {
98    pub entry: ConfigRegistryEntry,
99    /// Unix ms when the entry was superseded (i.e. when the *next*
100    /// version became active).
101    pub superseded_at_ms: u128,
102    /// Display form of the principal that wrote the superseding entry.
103    pub superseded_by: String,
104    pub change_reason: String,
105}
106
107/// Errors surfaced by the registry's governance API.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum RegistryError {
110    /// Caller failed the policy-first authorization check.
111    Unauthorized { action: String, resource: String },
112    /// Lookup target does not exist.
113    NotFound(String),
114    /// Tried to supersede an `Immutable` entry.
115    Immutable(String),
116    /// Tried to register an id that already has an active entry.
117    AlreadyRegistered(String),
118    /// The registry mutation succeeded locally, but compliance mode
119    /// required durable control-event evidence and the ledger rejected it.
120    ControlEvent(String),
121}
122
123impl std::fmt::Display for RegistryError {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            Self::Unauthorized { action, resource } => write!(
127                f,
128                "registry mutation denied by policy: action={action} resource={resource}"
129            ),
130            Self::NotFound(id) => write!(f, "registry entry not found: {id}"),
131            Self::Immutable(id) => write!(f, "registry entry is immutable: {id}"),
132            Self::AlreadyRegistered(id) => write!(f, "registry entry already exists: {id}"),
133            Self::ControlEvent(msg) => write!(f, "{msg}"),
134        }
135    }
136}
137
138impl std::error::Error for RegistryError {}
139
140/// Draft used when calling [`ConfigRegistry::register`] or
141/// [`ConfigRegistry::supersede`]. The registry stamps `version`,
142/// `updated_by`, and `updated_at_ms` itself so callers can't forge them.
143#[derive(Debug, Clone)]
144pub struct ConfigRegistryDraft {
145    pub id: String,
146    pub resource_type: String,
147    pub schema: String,
148    pub mutability: Mutability,
149    pub sensitivity: Sensitivity,
150    pub managed: bool,
151    pub required_action: String,
152    pub required_resource: String,
153    pub evidence_requirement: EvidenceRequirement,
154}
155
156/// Control-event dependencies for audited registry mutations.
157pub struct ConfigRegistryControl<'a> {
158    pub ctx: &'a ControlEventCtx<'a>,
159    pub ledger: &'a dyn ControlEventLedger,
160    pub config: ControlEventConfig,
161}
162
163/// In-process registry. Accessed only through governance methods; never
164/// exposed as a SQL collection or wire surface.
165#[derive(Default)]
166pub struct ConfigRegistry {
167    active: RwLock<HashMap<String, ConfigRegistryEntry>>,
168    history: RwLock<HashMap<String, Vec<ConfigRegistryHistoryRecord>>>,
169}
170
171/// Policy action for creating a new registry entry.
172pub const ACTION_REGISTER: &str = "red.registry:register";
173/// Policy action for superseding (mutating) an existing registry entry.
174pub const ACTION_SUPERSEDE: &str = "red.registry:supersede";
175/// Resource kind used when building the [`ResourceRef`] for the
176/// authorization check.
177pub const RESOURCE_KIND: &str = "registry";
178
179impl ConfigRegistry {
180    pub fn new() -> Self {
181        Self::default()
182    }
183
184    /// Register a new entry. Returns `AlreadyRegistered` if an active
185    /// version already exists; use [`Self::supersede`] in that case.
186    ///
187    /// Authorization: `auth.check_policy_authz(actor, "red.registry:register",
188    /// registry:<id>, ctx)` must return `true`.
189    pub fn register(
190        &self,
191        auth: &AuthStore,
192        actor: &UserId,
193        ctx: &EvalContext,
194        draft: ConfigRegistryDraft,
195        now_ms: u128,
196    ) -> Result<ConfigRegistryEntry, RegistryError> {
197        let resource = ResourceRef::new(RESOURCE_KIND, draft.id.clone());
198        if !auth.check_policy_authz(actor, ACTION_REGISTER, &resource, ctx) {
199            return Err(RegistryError::Unauthorized {
200                action: ACTION_REGISTER.to_string(),
201                resource: format!("{}:{}", RESOURCE_KIND, draft.id),
202            });
203        }
204
205        let mut active = self.active.write().unwrap_or_else(|e| e.into_inner());
206        if active.contains_key(&draft.id) {
207            return Err(RegistryError::AlreadyRegistered(draft.id));
208        }
209        let entry = ConfigRegistryEntry {
210            id: draft.id.clone(),
211            version: 1,
212            resource_type: draft.resource_type,
213            schema: draft.schema,
214            mutability: draft.mutability,
215            sensitivity: draft.sensitivity,
216            managed: draft.managed,
217            required_action: draft.required_action,
218            required_resource: draft.required_resource,
219            evidence_requirement: draft.evidence_requirement,
220            updated_by: actor.to_string(),
221            updated_at_ms: now_ms,
222        };
223        active.insert(draft.id, entry.clone());
224        Ok(entry)
225    }
226
227    pub fn register_with_control_events(
228        &self,
229        auth: &AuthStore,
230        actor: &UserId,
231        ctx: &EvalContext,
232        draft: ConfigRegistryDraft,
233        now_ms: u128,
234        control: &ConfigRegistryControl<'_>,
235    ) -> Result<ConfigRegistryEntry, RegistryError> {
236        let id = draft.id.clone();
237        let draft_for_event = draft.clone();
238        match self.register(auth, actor, ctx, draft, now_ms) {
239            Ok(entry) => match emit_registry_event(
240                control,
241                Outcome::Allowed,
242                ACTION_REGISTER,
243                &entry.id,
244                None,
245                registry_fields_for_entry(&entry),
246            ) {
247                Ok(()) => Ok(entry),
248                Err(err) => {
249                    self.rollback_register(&id);
250                    Err(err)
251                }
252            },
253            Err(err @ RegistryError::Unauthorized { .. }) => {
254                emit_registry_denied(control, ACTION_REGISTER, &id, &err, &draft_for_event);
255                Err(err)
256            }
257            Err(err) => {
258                emit_registry_error(control, ACTION_REGISTER, &id, &err, &draft_for_event);
259                Err(err)
260            }
261        }
262    }
263
264    /// Supersede the active entry for `id`. The previous version is
265    /// pushed into history with `superseded_at_ms == now_ms` and the
266    /// caller-supplied `change_reason`. Rejected if the active entry is
267    /// `Immutable`.
268    ///
269    /// Authorization: `auth.check_policy_authz(actor, "red.registry:supersede",
270    /// registry:<id>, ctx)` must return `true`.
271    pub fn supersede(
272        &self,
273        auth: &AuthStore,
274        actor: &UserId,
275        ctx: &EvalContext,
276        draft: ConfigRegistryDraft,
277        change_reason: impl Into<String>,
278        now_ms: u128,
279    ) -> Result<ConfigRegistryEntry, RegistryError> {
280        let resource = ResourceRef::new(RESOURCE_KIND, draft.id.clone());
281        if !auth.check_policy_authz(actor, ACTION_SUPERSEDE, &resource, ctx) {
282            return Err(RegistryError::Unauthorized {
283                action: ACTION_SUPERSEDE.to_string(),
284                resource: format!("{}:{}", RESOURCE_KIND, draft.id),
285            });
286        }
287
288        let mut active = self.active.write().unwrap_or_else(|e| e.into_inner());
289        let prev = active
290            .get(&draft.id)
291            .cloned()
292            .ok_or_else(|| RegistryError::NotFound(draft.id.clone()))?;
293        if prev.mutability == Mutability::Immutable {
294            return Err(RegistryError::Immutable(draft.id));
295        }
296
297        let next = ConfigRegistryEntry {
298            id: draft.id.clone(),
299            version: prev.version + 1,
300            resource_type: draft.resource_type,
301            schema: draft.schema,
302            mutability: draft.mutability,
303            sensitivity: draft.sensitivity,
304            managed: draft.managed,
305            required_action: draft.required_action,
306            required_resource: draft.required_resource,
307            evidence_requirement: draft.evidence_requirement,
308            updated_by: actor.to_string(),
309            updated_at_ms: now_ms,
310        };
311        active.insert(draft.id.clone(), next.clone());
312
313        let record = ConfigRegistryHistoryRecord {
314            entry: prev,
315            superseded_at_ms: now_ms,
316            superseded_by: actor.to_string(),
317            change_reason: change_reason.into(),
318        };
319        self.history
320            .write()
321            .unwrap_or_else(|e| e.into_inner())
322            .entry(draft.id)
323            .or_default()
324            .push(record);
325        Ok(next)
326    }
327
328    pub fn supersede_with_control_events(
329        &self,
330        auth: &AuthStore,
331        actor: &UserId,
332        ctx: &EvalContext,
333        draft: ConfigRegistryDraft,
334        change_reason: impl Into<String>,
335        now_ms: u128,
336        control: &ConfigRegistryControl<'_>,
337    ) -> Result<ConfigRegistryEntry, RegistryError> {
338        let id = draft.id.clone();
339        let draft_for_event = draft.clone();
340        let previous = self.get_active(&id);
341        let history_len = self.history(&id).len();
342        match self.supersede(auth, actor, ctx, draft, change_reason, now_ms) {
343            Ok(entry) => match emit_registry_event(
344                control,
345                Outcome::Allowed,
346                ACTION_SUPERSEDE,
347                &entry.id,
348                None,
349                registry_fields_for_entry(&entry),
350            ) {
351                Ok(()) => Ok(entry),
352                Err(err) => {
353                    if let Some(previous) = previous {
354                        self.rollback_supersede(previous, history_len);
355                    }
356                    Err(err)
357                }
358            },
359            Err(err @ RegistryError::Unauthorized { .. }) => {
360                emit_registry_denied(control, ACTION_SUPERSEDE, &id, &err, &draft_for_event);
361                Err(err)
362            }
363            Err(err) => {
364                emit_registry_error(control, ACTION_SUPERSEDE, &id, &err, &draft_for_event);
365                Err(err)
366            }
367        }
368    }
369
370    /// Active surface — current version for `id`, or `None`.
371    pub fn get_active(&self, id: &str) -> Option<ConfigRegistryEntry> {
372        self.active.read().ok().and_then(|m| m.get(id).cloned())
373    }
374
375    /// All currently-active entries (id-sorted for deterministic output).
376    pub fn list_active(&self) -> Vec<ConfigRegistryEntry> {
377        let map = match self.active.read() {
378            Ok(g) => g,
379            Err(_) => return Vec::new(),
380        };
381        let mut out: Vec<ConfigRegistryEntry> = map.values().cloned().collect();
382        out.sort_by(|a, b| a.id.cmp(&b.id));
383        out
384    }
385
386    /// History for `id`, oldest first. Empty when the id never had a
387    /// supersede (or never existed).
388    pub fn history(&self, id: &str) -> Vec<ConfigRegistryHistoryRecord> {
389        self.history
390            .read()
391            .ok()
392            .and_then(|m| m.get(id).cloned())
393            .unwrap_or_default()
394    }
395
396    /// Restore an active entry that was already accepted through a trusted
397    /// bootstrap path and persisted in internal config state. This is not a
398    /// governance mutation; it only rehydrates the process-local registry
399    /// after `system.bootstrap.completed` makes the manifest file optional.
400    pub(crate) fn restore_bootstrap_entry(
401        &self,
402        entry: ConfigRegistryEntry,
403    ) -> Result<(), RegistryError> {
404        let mut active = self.active.write().unwrap_or_else(|e| e.into_inner());
405        if let Some(existing) = active.get(&entry.id) {
406            if existing == &entry {
407                return Ok(());
408            }
409            return Err(RegistryError::AlreadyRegistered(entry.id));
410        }
411        active.insert(entry.id.clone(), entry);
412        Ok(())
413    }
414
415    fn rollback_register(&self, id: &str) {
416        self.active
417            .write()
418            .unwrap_or_else(|e| e.into_inner())
419            .remove(id);
420    }
421
422    fn rollback_supersede(&self, previous: ConfigRegistryEntry, history_len: usize) {
423        let id = previous.id.clone();
424        self.active
425            .write()
426            .unwrap_or_else(|e| e.into_inner())
427            .insert(id.clone(), previous);
428        if let Some(records) = self
429            .history
430            .write()
431            .unwrap_or_else(|e| e.into_inner())
432            .get_mut(&id)
433        {
434            records.truncate(history_len);
435        }
436    }
437}
438
439fn emit_registry_denied(
440    control: &ConfigRegistryControl<'_>,
441    action: &'static str,
442    id: &str,
443    err: &RegistryError,
444    draft: &ConfigRegistryDraft,
445) {
446    let _ = emit_registry_event(
447        control,
448        Outcome::Denied,
449        action,
450        id,
451        Some(err.to_string()),
452        registry_fields_for_draft(draft),
453    );
454}
455
456fn emit_registry_error(
457    control: &ConfigRegistryControl<'_>,
458    action: &'static str,
459    id: &str,
460    err: &RegistryError,
461    draft: &ConfigRegistryDraft,
462) {
463    let _ = emit_registry_event(
464        control,
465        Outcome::Error,
466        action,
467        id,
468        Some(err.to_string()),
469        registry_fields_for_draft(draft),
470    );
471}
472
473fn emit_registry_event(
474    control: &ConfigRegistryControl<'_>,
475    outcome: Outcome,
476    action: &'static str,
477    id: &str,
478    reason: Option<String>,
479    fields: HashMap<String, ControlSensitivity>,
480) -> Result<(), RegistryError> {
481    let event = ControlEvent {
482        kind: EventKind::ConfigWrite,
483        outcome,
484        action: Cow::Borrowed(action),
485        resource: Some(format!("{RESOURCE_KIND}:{id}")),
486        reason,
487        matched_policy_id: None,
488        fields,
489    };
490    match control.ledger.emit(control.ctx, event) {
491        Ok(_) => Ok(()),
492        Err(err) if control.config.require_persistence() => {
493            Err(RegistryError::ControlEvent(err.to_string()))
494        }
495        Err(_) => Ok(()),
496    }
497}
498
499fn registry_fields_for_entry(entry: &ConfigRegistryEntry) -> HashMap<String, ControlSensitivity> {
500    let mut fields = registry_common_fields(
501        &entry.id,
502        &entry.resource_type,
503        entry.managed,
504        entry.mutability,
505    );
506    fields.insert(
507        "payload".to_string(),
508        registry_payload_sensitivity(&entry.resource_type, entry.schema.as_bytes()),
509    );
510    fields.insert(
511        "version".to_string(),
512        ControlSensitivity::raw(entry.version.to_string()),
513    );
514    fields
515}
516
517fn registry_fields_for_draft(draft: &ConfigRegistryDraft) -> HashMap<String, ControlSensitivity> {
518    let mut fields = registry_common_fields(
519        &draft.id,
520        &draft.resource_type,
521        draft.managed,
522        draft.mutability,
523    );
524    fields.insert(
525        "payload".to_string(),
526        registry_payload_sensitivity(&draft.resource_type, draft.schema.as_bytes()),
527    );
528    fields
529}
530
531fn registry_common_fields(
532    id: &str,
533    resource_type: &str,
534    managed: bool,
535    mutability: Mutability,
536) -> HashMap<String, ControlSensitivity> {
537    let mut fields = HashMap::new();
538    fields.insert("id".to_string(), ControlSensitivity::raw(id));
539    fields.insert(
540        "resource_type".to_string(),
541        ControlSensitivity::raw(resource_type),
542    );
543    fields.insert(
544        "managed".to_string(),
545        ControlSensitivity::raw(managed.to_string()),
546    );
547    fields.insert(
548        "mutability".to_string(),
549        ControlSensitivity::raw(mutability_label(mutability)),
550    );
551    fields
552}
553
554fn registry_payload_sensitivity(resource_type: &str, payload: &[u8]) -> ControlSensitivity {
555    if registry_payload_raw_allowed(resource_type) {
556        ControlSensitivity::raw(String::from_utf8_lossy(payload).into_owned())
557    } else {
558        ControlSensitivity::hashed(payload)
559    }
560}
561
562fn registry_payload_raw_allowed(resource_type: &str) -> bool {
563    matches!(resource_type, "audit_surface")
564}
565
566fn mutability_label(mutability: Mutability) -> &'static str {
567    match mutability {
568        Mutability::Immutable => "immutable",
569        Mutability::MutableViaGovernance => "mutable_via_governance",
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::auth::policies::Policy;
577    use crate::auth::{AuthConfig, Role};
578
579    fn store_with_admin() -> (std::sync::Arc<AuthStore>, UserId) {
580        let store = std::sync::Arc::new(AuthStore::new(AuthConfig::default()));
581        store.create_user("ops", "p", Role::Admin).unwrap();
582        let uid = UserId::platform("ops");
583        (store, uid)
584    }
585
586    fn ctx() -> EvalContext {
587        EvalContext {
588            principal_tenant: None,
589            current_tenant: None,
590            peer_ip: None,
591            mfa_present: false,
592            now_ms: 1_700_000_000_000,
593            principal_is_admin_role: true,
594            principal_is_system_owned: false,
595            principal_is_platform_scoped: true,
596        }
597    }
598
599    fn allow_all_registry(id: &str) -> Policy {
600        Policy::from_json_str(&format!(
601            r#"{{
602                "id": "{id}",
603                "version": 1,
604                "statements": [{{
605                    "effect": "allow",
606                    "actions": ["red.registry:*"],
607                    "resources": ["registry:*"]
608                }}]
609            }}"#
610        ))
611        .unwrap()
612    }
613
614    fn deny_all_registry(id: &str) -> Policy {
615        Policy::from_json_str(&format!(
616            r#"{{
617                "id": "{id}",
618                "version": 1,
619                "statements": [{{
620                    "effect": "deny",
621                    "actions": ["red.registry:*"],
622                    "resources": ["registry:*"]
623                }}]
624            }}"#
625        ))
626        .unwrap()
627    }
628
629    fn sample_draft(id: &str) -> ConfigRegistryDraft {
630        ConfigRegistryDraft {
631            id: id.to_string(),
632            resource_type: "config_key".into(),
633            schema: "string".into(),
634            mutability: Mutability::MutableViaGovernance,
635            sensitivity: Sensitivity::Internal,
636            managed: true,
637            required_action: "config:write".into(),
638            required_resource: format!("config:{id}"),
639            evidence_requirement: EvidenceRequirement::Metadata,
640        }
641    }
642
643    #[test]
644    fn register_then_get_active_returns_v1() {
645        let (store, uid) = store_with_admin();
646        store.put_policy(allow_all_registry("p-allow")).unwrap();
647        store
648            .attach_policy(
649                super::super::store::PrincipalRef::User(uid.clone()),
650                "p-allow",
651            )
652            .unwrap();
653        let reg = ConfigRegistry::new();
654
655        let entry = reg
656            .register(
657                &store,
658                &uid,
659                &ctx(),
660                sample_draft("red.config.audit.enabled"),
661                1_000,
662            )
663            .expect("register");
664        assert_eq!(entry.version, 1);
665
666        let got = reg.get_active("red.config.audit.enabled").unwrap();
667        assert_eq!(got, entry);
668        assert!(reg.history("red.config.audit.enabled").is_empty());
669    }
670
671    #[test]
672    fn supersede_promotes_v2_and_records_history() {
673        let (store, uid) = store_with_admin();
674        store.put_policy(allow_all_registry("p-allow")).unwrap();
675        store
676            .attach_policy(
677                super::super::store::PrincipalRef::User(uid.clone()),
678                "p-allow",
679            )
680            .unwrap();
681        let reg = ConfigRegistry::new();
682
683        let v1 = reg
684            .register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
685            .unwrap();
686        let mut next = sample_draft("k");
687        next.schema = "string-v2".into();
688        let v2 = reg
689            .supersede(&store, &uid, &ctx(), next, "tightened schema", 2_000)
690            .unwrap();
691        assert_eq!(v2.version, 2);
692        assert_eq!(reg.get_active("k").unwrap(), v2);
693
694        let hist = reg.history("k");
695        assert_eq!(hist.len(), 1);
696        assert_eq!(hist[0].entry, v1);
697        assert_eq!(hist[0].superseded_at_ms, 2_000);
698        assert_eq!(hist[0].superseded_by, uid.to_string());
699        assert_eq!(hist[0].change_reason, "tightened schema");
700    }
701
702    #[test]
703    fn explicit_deny_blocks_mutation_even_for_admin() {
704        let (store, uid) = store_with_admin();
705        store.put_policy(allow_all_registry("p-allow")).unwrap();
706        store.put_policy(deny_all_registry("p-deny")).unwrap();
707        store
708            .attach_policy(
709                super::super::store::PrincipalRef::User(uid.clone()),
710                "p-allow",
711            )
712            .unwrap();
713        store
714            .attach_policy(
715                super::super::store::PrincipalRef::User(uid.clone()),
716                "p-deny",
717            )
718            .unwrap();
719        let reg = ConfigRegistry::new();
720
721        let err = reg
722            .register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
723            .unwrap_err();
724        assert!(
725            matches!(err, RegistryError::Unauthorized { .. }),
726            "got {err:?}"
727        );
728        assert!(reg.get_active("k").is_none());
729    }
730
731    #[test]
732    fn ordinary_user_without_registry_policy_is_denied() {
733        // Non-admin principal, no policy granting `red.registry:*` →
734        // policy-first DefaultDeny rejects the mutation.
735        let store = std::sync::Arc::new(AuthStore::new(AuthConfig::default()));
736        store.create_user("alice", "p", Role::Write).unwrap();
737        let uid = UserId::platform("alice");
738        // Insert any policy so IAM is the authoritative path.
739        store
740            .put_policy(
741                Policy::from_json_str(
742                    r#"{"id":"p-unrelated","version":1,"statements":[{"effect":"allow","actions":["select"],"resources":["table:public.x"]}]}"#,
743                )
744                .unwrap(),
745            )
746            .unwrap();
747        let mut c = ctx();
748        c.principal_is_admin_role = false;
749        let reg = ConfigRegistry::new();
750        let err = reg
751            .register(&store, &uid, &c, sample_draft("k"), 1_000)
752            .unwrap_err();
753        assert!(
754            matches!(err, RegistryError::Unauthorized { .. }),
755            "got {err:?}"
756        );
757    }
758
759    #[test]
760    fn immutable_entries_reject_supersede() {
761        let (store, uid) = store_with_admin();
762        store.put_policy(allow_all_registry("p-allow")).unwrap();
763        store
764            .attach_policy(
765                super::super::store::PrincipalRef::User(uid.clone()),
766                "p-allow",
767            )
768            .unwrap();
769        let reg = ConfigRegistry::new();
770
771        let mut draft = sample_draft("k");
772        draft.mutability = Mutability::Immutable;
773        reg.register(&store, &uid, &ctx(), draft, 1_000).unwrap();
774
775        let err = reg
776            .supersede(
777                &store,
778                &uid,
779                &ctx(),
780                sample_draft("k"),
781                "should fail",
782                2_000,
783            )
784            .unwrap_err();
785        assert!(matches!(err, RegistryError::Immutable(_)), "got {err:?}");
786        assert_eq!(reg.get_active("k").unwrap().version, 1);
787        assert!(reg.history("k").is_empty());
788    }
789
790    #[test]
791    fn register_twice_is_already_registered() {
792        let (store, uid) = store_with_admin();
793        store.put_policy(allow_all_registry("p-allow")).unwrap();
794        store
795            .attach_policy(
796                super::super::store::PrincipalRef::User(uid.clone()),
797                "p-allow",
798            )
799            .unwrap();
800        let reg = ConfigRegistry::new();
801        reg.register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
802            .unwrap();
803        let err = reg
804            .register(&store, &uid, &ctx(), sample_draft("k"), 1_500)
805            .unwrap_err();
806        assert!(
807            matches!(err, RegistryError::AlreadyRegistered(_)),
808            "got {err:?}"
809        );
810    }
811
812    #[test]
813    fn registry_is_not_exposed_as_sql_collection() {
814        // The registry lives outside the SQL surface — ordinary DML
815        // cannot reach these entries because there is no collection /
816        // table / virtual surface that mirrors them. This test pins
817        // that contract: a ConfigRegistry stands alone; nothing on
818        // AuthStore or the storage path exposes it as a row source.
819        let reg = ConfigRegistry::new();
820        // The public API surface is the governance methods only:
821        let _ = reg.list_active();
822        let _ = reg.history("k");
823        // (No `as_collection()` / `as_table()` / SQL accessor exists by
824        // construction — if a future change adds one, this test should
825        // be reviewed alongside the new wire surface.)
826    }
827}