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_platform_scoped: true,
595        }
596    }
597
598    fn allow_all_registry(id: &str) -> Policy {
599        Policy::from_json_str(&format!(
600            r#"{{
601                "id": "{id}",
602                "version": 1,
603                "statements": [{{
604                    "effect": "allow",
605                    "actions": ["red.registry:*"],
606                    "resources": ["registry:*"]
607                }}]
608            }}"#
609        ))
610        .unwrap()
611    }
612
613    fn deny_all_registry(id: &str) -> Policy {
614        Policy::from_json_str(&format!(
615            r#"{{
616                "id": "{id}",
617                "version": 1,
618                "statements": [{{
619                    "effect": "deny",
620                    "actions": ["red.registry:*"],
621                    "resources": ["registry:*"]
622                }}]
623            }}"#
624        ))
625        .unwrap()
626    }
627
628    fn sample_draft(id: &str) -> ConfigRegistryDraft {
629        ConfigRegistryDraft {
630            id: id.to_string(),
631            resource_type: "config_key".into(),
632            schema: "string".into(),
633            mutability: Mutability::MutableViaGovernance,
634            sensitivity: Sensitivity::Internal,
635            managed: true,
636            required_action: "config:write".into(),
637            required_resource: format!("config:{id}"),
638            evidence_requirement: EvidenceRequirement::Metadata,
639        }
640    }
641
642    #[test]
643    fn register_then_get_active_returns_v1() {
644        let (store, uid) = store_with_admin();
645        store.put_policy(allow_all_registry("p-allow")).unwrap();
646        store
647            .attach_policy(
648                super::super::store::PrincipalRef::User(uid.clone()),
649                "p-allow",
650            )
651            .unwrap();
652        let reg = ConfigRegistry::new();
653
654        let entry = reg
655            .register(
656                &store,
657                &uid,
658                &ctx(),
659                sample_draft("red.config.audit.enabled"),
660                1_000,
661            )
662            .expect("register");
663        assert_eq!(entry.version, 1);
664
665        let got = reg.get_active("red.config.audit.enabled").unwrap();
666        assert_eq!(got, entry);
667        assert!(reg.history("red.config.audit.enabled").is_empty());
668    }
669
670    #[test]
671    fn supersede_promotes_v2_and_records_history() {
672        let (store, uid) = store_with_admin();
673        store.put_policy(allow_all_registry("p-allow")).unwrap();
674        store
675            .attach_policy(
676                super::super::store::PrincipalRef::User(uid.clone()),
677                "p-allow",
678            )
679            .unwrap();
680        let reg = ConfigRegistry::new();
681
682        let v1 = reg
683            .register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
684            .unwrap();
685        let mut next = sample_draft("k");
686        next.schema = "string-v2".into();
687        let v2 = reg
688            .supersede(&store, &uid, &ctx(), next, "tightened schema", 2_000)
689            .unwrap();
690        assert_eq!(v2.version, 2);
691        assert_eq!(reg.get_active("k").unwrap(), v2);
692
693        let hist = reg.history("k");
694        assert_eq!(hist.len(), 1);
695        assert_eq!(hist[0].entry, v1);
696        assert_eq!(hist[0].superseded_at_ms, 2_000);
697        assert_eq!(hist[0].superseded_by, uid.to_string());
698        assert_eq!(hist[0].change_reason, "tightened schema");
699    }
700
701    #[test]
702    fn explicit_deny_blocks_mutation_even_for_admin() {
703        let (store, uid) = store_with_admin();
704        store.put_policy(allow_all_registry("p-allow")).unwrap();
705        store.put_policy(deny_all_registry("p-deny")).unwrap();
706        store
707            .attach_policy(
708                super::super::store::PrincipalRef::User(uid.clone()),
709                "p-allow",
710            )
711            .unwrap();
712        store
713            .attach_policy(
714                super::super::store::PrincipalRef::User(uid.clone()),
715                "p-deny",
716            )
717            .unwrap();
718        let reg = ConfigRegistry::new();
719
720        let err = reg
721            .register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
722            .unwrap_err();
723        assert!(
724            matches!(err, RegistryError::Unauthorized { .. }),
725            "got {err:?}"
726        );
727        assert!(reg.get_active("k").is_none());
728    }
729
730    #[test]
731    fn ordinary_user_without_registry_policy_is_denied() {
732        // Non-admin principal, no policy granting `red.registry:*` →
733        // policy-first DefaultDeny rejects the mutation.
734        let store = std::sync::Arc::new(AuthStore::new(AuthConfig::default()));
735        store.create_user("alice", "p", Role::Write).unwrap();
736        let uid = UserId::platform("alice");
737        // Insert any policy so IAM is the authoritative path.
738        store
739            .put_policy(
740                Policy::from_json_str(
741                    r#"{"id":"p-unrelated","version":1,"statements":[{"effect":"allow","actions":["select"],"resources":["table:public.x"]}]}"#,
742                )
743                .unwrap(),
744            )
745            .unwrap();
746        let mut c = ctx();
747        c.principal_is_admin_role = false;
748        let reg = ConfigRegistry::new();
749        let err = reg
750            .register(&store, &uid, &c, sample_draft("k"), 1_000)
751            .unwrap_err();
752        assert!(
753            matches!(err, RegistryError::Unauthorized { .. }),
754            "got {err:?}"
755        );
756    }
757
758    #[test]
759    fn immutable_entries_reject_supersede() {
760        let (store, uid) = store_with_admin();
761        store.put_policy(allow_all_registry("p-allow")).unwrap();
762        store
763            .attach_policy(
764                super::super::store::PrincipalRef::User(uid.clone()),
765                "p-allow",
766            )
767            .unwrap();
768        let reg = ConfigRegistry::new();
769
770        let mut draft = sample_draft("k");
771        draft.mutability = Mutability::Immutable;
772        reg.register(&store, &uid, &ctx(), draft, 1_000).unwrap();
773
774        let err = reg
775            .supersede(
776                &store,
777                &uid,
778                &ctx(),
779                sample_draft("k"),
780                "should fail",
781                2_000,
782            )
783            .unwrap_err();
784        assert!(matches!(err, RegistryError::Immutable(_)), "got {err:?}");
785        assert_eq!(reg.get_active("k").unwrap().version, 1);
786        assert!(reg.history("k").is_empty());
787    }
788
789    #[test]
790    fn register_twice_is_already_registered() {
791        let (store, uid) = store_with_admin();
792        store.put_policy(allow_all_registry("p-allow")).unwrap();
793        store
794            .attach_policy(
795                super::super::store::PrincipalRef::User(uid.clone()),
796                "p-allow",
797            )
798            .unwrap();
799        let reg = ConfigRegistry::new();
800        reg.register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
801            .unwrap();
802        let err = reg
803            .register(&store, &uid, &ctx(), sample_draft("k"), 1_500)
804            .unwrap_err();
805        assert!(
806            matches!(err, RegistryError::AlreadyRegistered(_)),
807            "got {err:?}"
808        );
809    }
810
811    #[test]
812    fn registry_is_not_exposed_as_sql_collection() {
813        // The registry lives outside the SQL surface — ordinary DML
814        // cannot reach these entries because there is no collection /
815        // table / virtual surface that mirrors them. This test pins
816        // that contract: a ConfigRegistry stands alone; nothing on
817        // AuthStore or the storage path exposes it as a row source.
818        let reg = ConfigRegistry::new();
819        // The public API surface is the governance methods only:
820        let _ = reg.list_active();
821        let _ = reg.history("k");
822        // (No `as_collection()` / `as_table()` / SQL accessor exists by
823        // construction — if a future change adds one, this test should
824        // be reviewed alongside the new wire surface.)
825    }
826}