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::collections::HashMap;
23use std::sync::RwLock;
24
25use super::policies::{EvalContext, ResourceRef};
26use super::store::AuthStore;
27use super::UserId;
28
29/// How a config resource may be changed.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Mutability {
32    /// Fixed at registration — supersede is rejected.
33    Immutable,
34    /// Mutable only via governance commands (registry API), never via DML.
35    MutableViaGovernance,
36}
37
38/// Data classification of the underlying value the entry governs.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Sensitivity {
41    Public,
42    Internal,
43    Confidential,
44    Secret,
45}
46
47/// Evidence the Control Event Ledger must capture for mutations of the
48/// underlying resource. Metadata-only is the default; `Full` includes
49/// the previous and next normalized value fingerprints.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum EvidenceRequirement {
52    None,
53    Metadata,
54    Full,
55}
56
57/// A single registry entry — the governance metadata for one config
58/// resource (a config key, a vault path, a policy id, an audit surface).
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ConfigRegistryEntry {
61    /// Canonical resource id (e.g. `"red.config.audit.enabled"`).
62    pub id: String,
63    /// Monotonically increasing version. Starts at 1 on register; each
64    /// `supersede` increments by one.
65    pub version: u64,
66    /// Logical type of the resource — e.g. `"config_key"`, `"vault_path"`,
67    /// `"policy"`, `"audit_surface"`.
68    pub resource_type: String,
69    /// Schema / value-shape description (free-form for the tracer; a
70    /// future slice can promote this to a structured schema id).
71    pub schema: String,
72    pub mutability: Mutability,
73    pub sensitivity: Sensitivity,
74    /// `true` for operator-owned guardrail entries (managed-policy /
75    /// managed-config namespace style). `false` for ordinary entries.
76    pub managed: bool,
77    /// Policy action a caller must satisfy to mutate the underlying
78    /// resource (not the registry entry itself).
79    pub required_action: String,
80    /// Policy resource the action applies to.
81    pub required_resource: String,
82    pub evidence_requirement: EvidenceRequirement,
83    /// Display form of the principal who last wrote this entry.
84    pub updated_by: String,
85    /// Unix ms when this version became active.
86    pub updated_at_ms: u128,
87}
88
89/// One row of registry history — a superseded version plus the
90/// who/when/why metadata for the change.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ConfigRegistryHistoryRecord {
93    pub entry: ConfigRegistryEntry,
94    /// Unix ms when the entry was superseded (i.e. when the *next*
95    /// version became active).
96    pub superseded_at_ms: u128,
97    /// Display form of the principal that wrote the superseding entry.
98    pub superseded_by: String,
99    pub change_reason: String,
100}
101
102/// Errors surfaced by the registry's governance API.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum RegistryError {
105    /// Caller failed the policy-first authorization check.
106    Unauthorized { action: String, resource: String },
107    /// Lookup target does not exist.
108    NotFound(String),
109    /// Tried to supersede an `Immutable` entry.
110    Immutable(String),
111    /// Tried to register an id that already has an active entry.
112    AlreadyRegistered(String),
113}
114
115impl std::fmt::Display for RegistryError {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match self {
118            Self::Unauthorized { action, resource } => write!(
119                f,
120                "registry mutation denied by policy: action={action} resource={resource}"
121            ),
122            Self::NotFound(id) => write!(f, "registry entry not found: {id}"),
123            Self::Immutable(id) => write!(f, "registry entry is immutable: {id}"),
124            Self::AlreadyRegistered(id) => write!(f, "registry entry already exists: {id}"),
125        }
126    }
127}
128
129impl std::error::Error for RegistryError {}
130
131/// Draft used when calling [`ConfigRegistry::register`] or
132/// [`ConfigRegistry::supersede`]. The registry stamps `version`,
133/// `updated_by`, and `updated_at_ms` itself so callers can't forge them.
134#[derive(Debug, Clone)]
135pub struct ConfigRegistryDraft {
136    pub id: String,
137    pub resource_type: String,
138    pub schema: String,
139    pub mutability: Mutability,
140    pub sensitivity: Sensitivity,
141    pub managed: bool,
142    pub required_action: String,
143    pub required_resource: String,
144    pub evidence_requirement: EvidenceRequirement,
145}
146
147/// In-process registry. Accessed only through governance methods; never
148/// exposed as a SQL collection or wire surface.
149#[derive(Default)]
150pub struct ConfigRegistry {
151    active: RwLock<HashMap<String, ConfigRegistryEntry>>,
152    history: RwLock<HashMap<String, Vec<ConfigRegistryHistoryRecord>>>,
153}
154
155/// Policy action for creating a new registry entry.
156pub const ACTION_REGISTER: &str = "red.registry:register";
157/// Policy action for superseding (mutating) an existing registry entry.
158pub const ACTION_SUPERSEDE: &str = "red.registry:supersede";
159/// Resource kind used when building the [`ResourceRef`] for the
160/// authorization check.
161pub const RESOURCE_KIND: &str = "registry";
162
163impl ConfigRegistry {
164    pub fn new() -> Self {
165        Self::default()
166    }
167
168    /// Register a new entry. Returns `AlreadyRegistered` if an active
169    /// version already exists; use [`Self::supersede`] in that case.
170    ///
171    /// Authorization: `auth.check_policy_authz(actor, "red.registry:register",
172    /// registry:<id>, ctx)` must return `true`.
173    pub fn register(
174        &self,
175        auth: &AuthStore,
176        actor: &UserId,
177        ctx: &EvalContext,
178        draft: ConfigRegistryDraft,
179        now_ms: u128,
180    ) -> Result<ConfigRegistryEntry, RegistryError> {
181        let resource = ResourceRef::new(RESOURCE_KIND, draft.id.clone());
182        if !auth.check_policy_authz(actor, ACTION_REGISTER, &resource, ctx) {
183            return Err(RegistryError::Unauthorized {
184                action: ACTION_REGISTER.to_string(),
185                resource: format!("{}:{}", RESOURCE_KIND, draft.id),
186            });
187        }
188
189        let mut active = self.active.write().unwrap_or_else(|e| e.into_inner());
190        if active.contains_key(&draft.id) {
191            return Err(RegistryError::AlreadyRegistered(draft.id));
192        }
193        let entry = ConfigRegistryEntry {
194            id: draft.id.clone(),
195            version: 1,
196            resource_type: draft.resource_type,
197            schema: draft.schema,
198            mutability: draft.mutability,
199            sensitivity: draft.sensitivity,
200            managed: draft.managed,
201            required_action: draft.required_action,
202            required_resource: draft.required_resource,
203            evidence_requirement: draft.evidence_requirement,
204            updated_by: actor.to_string(),
205            updated_at_ms: now_ms,
206        };
207        active.insert(draft.id, entry.clone());
208        Ok(entry)
209    }
210
211    /// Supersede the active entry for `id`. The previous version is
212    /// pushed into history with `superseded_at_ms == now_ms` and the
213    /// caller-supplied `change_reason`. Rejected if the active entry is
214    /// `Immutable`.
215    ///
216    /// Authorization: `auth.check_policy_authz(actor, "red.registry:supersede",
217    /// registry:<id>, ctx)` must return `true`.
218    pub fn supersede(
219        &self,
220        auth: &AuthStore,
221        actor: &UserId,
222        ctx: &EvalContext,
223        draft: ConfigRegistryDraft,
224        change_reason: impl Into<String>,
225        now_ms: u128,
226    ) -> Result<ConfigRegistryEntry, RegistryError> {
227        let resource = ResourceRef::new(RESOURCE_KIND, draft.id.clone());
228        if !auth.check_policy_authz(actor, ACTION_SUPERSEDE, &resource, ctx) {
229            return Err(RegistryError::Unauthorized {
230                action: ACTION_SUPERSEDE.to_string(),
231                resource: format!("{}:{}", RESOURCE_KIND, draft.id),
232            });
233        }
234
235        let mut active = self.active.write().unwrap_or_else(|e| e.into_inner());
236        let prev = active
237            .get(&draft.id)
238            .cloned()
239            .ok_or_else(|| RegistryError::NotFound(draft.id.clone()))?;
240        if prev.mutability == Mutability::Immutable {
241            return Err(RegistryError::Immutable(draft.id));
242        }
243
244        let next = ConfigRegistryEntry {
245            id: draft.id.clone(),
246            version: prev.version + 1,
247            resource_type: draft.resource_type,
248            schema: draft.schema,
249            mutability: draft.mutability,
250            sensitivity: draft.sensitivity,
251            managed: draft.managed,
252            required_action: draft.required_action,
253            required_resource: draft.required_resource,
254            evidence_requirement: draft.evidence_requirement,
255            updated_by: actor.to_string(),
256            updated_at_ms: now_ms,
257        };
258        active.insert(draft.id.clone(), next.clone());
259
260        let record = ConfigRegistryHistoryRecord {
261            entry: prev,
262            superseded_at_ms: now_ms,
263            superseded_by: actor.to_string(),
264            change_reason: change_reason.into(),
265        };
266        self.history
267            .write()
268            .unwrap_or_else(|e| e.into_inner())
269            .entry(draft.id)
270            .or_default()
271            .push(record);
272        Ok(next)
273    }
274
275    /// Active surface — current version for `id`, or `None`.
276    pub fn get_active(&self, id: &str) -> Option<ConfigRegistryEntry> {
277        self.active.read().ok().and_then(|m| m.get(id).cloned())
278    }
279
280    /// All currently-active entries (id-sorted for deterministic output).
281    pub fn list_active(&self) -> Vec<ConfigRegistryEntry> {
282        let map = match self.active.read() {
283            Ok(g) => g,
284            Err(_) => return Vec::new(),
285        };
286        let mut out: Vec<ConfigRegistryEntry> = map.values().cloned().collect();
287        out.sort_by(|a, b| a.id.cmp(&b.id));
288        out
289    }
290
291    /// History for `id`, oldest first. Empty when the id never had a
292    /// supersede (or never existed).
293    pub fn history(&self, id: &str) -> Vec<ConfigRegistryHistoryRecord> {
294        self.history
295            .read()
296            .ok()
297            .and_then(|m| m.get(id).cloned())
298            .unwrap_or_default()
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::auth::policies::Policy;
306    use crate::auth::{AuthConfig, Role};
307
308    fn store_with_admin() -> (std::sync::Arc<AuthStore>, UserId) {
309        let store = std::sync::Arc::new(AuthStore::new(AuthConfig::default()));
310        store.create_user("ops", "p", Role::Admin).unwrap();
311        let uid = UserId::platform("ops");
312        (store, uid)
313    }
314
315    fn ctx() -> EvalContext {
316        EvalContext {
317            principal_tenant: None,
318            current_tenant: None,
319            peer_ip: None,
320            mfa_present: false,
321            now_ms: 1_700_000_000_000,
322            principal_is_admin_role: true,
323            principal_is_system_owned: false,
324            principal_is_platform_scoped: true,
325        }
326    }
327
328    fn allow_all_registry(id: &str) -> Policy {
329        Policy::from_json_str(&format!(
330            r#"{{
331                "id": "{id}",
332                "version": 1,
333                "statements": [{{
334                    "effect": "allow",
335                    "actions": ["red.registry:*"],
336                    "resources": ["registry:*"]
337                }}]
338            }}"#
339        ))
340        .unwrap()
341    }
342
343    fn deny_all_registry(id: &str) -> Policy {
344        Policy::from_json_str(&format!(
345            r#"{{
346                "id": "{id}",
347                "version": 1,
348                "statements": [{{
349                    "effect": "deny",
350                    "actions": ["red.registry:*"],
351                    "resources": ["registry:*"]
352                }}]
353            }}"#
354        ))
355        .unwrap()
356    }
357
358    fn sample_draft(id: &str) -> ConfigRegistryDraft {
359        ConfigRegistryDraft {
360            id: id.to_string(),
361            resource_type: "config_key".into(),
362            schema: "string".into(),
363            mutability: Mutability::MutableViaGovernance,
364            sensitivity: Sensitivity::Internal,
365            managed: true,
366            required_action: "config:write".into(),
367            required_resource: format!("config:{id}"),
368            evidence_requirement: EvidenceRequirement::Metadata,
369        }
370    }
371
372    #[test]
373    fn register_then_get_active_returns_v1() {
374        let (store, uid) = store_with_admin();
375        store.put_policy(allow_all_registry("p-allow")).unwrap();
376        store
377            .attach_policy(
378                super::super::store::PrincipalRef::User(uid.clone()),
379                "p-allow",
380            )
381            .unwrap();
382        let reg = ConfigRegistry::new();
383
384        let entry = reg
385            .register(
386                &store,
387                &uid,
388                &ctx(),
389                sample_draft("red.config.audit.enabled"),
390                1_000,
391            )
392            .expect("register");
393        assert_eq!(entry.version, 1);
394
395        let got = reg.get_active("red.config.audit.enabled").unwrap();
396        assert_eq!(got, entry);
397        assert!(reg.history("red.config.audit.enabled").is_empty());
398    }
399
400    #[test]
401    fn supersede_promotes_v2_and_records_history() {
402        let (store, uid) = store_with_admin();
403        store.put_policy(allow_all_registry("p-allow")).unwrap();
404        store
405            .attach_policy(
406                super::super::store::PrincipalRef::User(uid.clone()),
407                "p-allow",
408            )
409            .unwrap();
410        let reg = ConfigRegistry::new();
411
412        let v1 = reg
413            .register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
414            .unwrap();
415        let mut next = sample_draft("k");
416        next.schema = "string-v2".into();
417        let v2 = reg
418            .supersede(&store, &uid, &ctx(), next, "tightened schema", 2_000)
419            .unwrap();
420        assert_eq!(v2.version, 2);
421        assert_eq!(reg.get_active("k").unwrap(), v2);
422
423        let hist = reg.history("k");
424        assert_eq!(hist.len(), 1);
425        assert_eq!(hist[0].entry, v1);
426        assert_eq!(hist[0].superseded_at_ms, 2_000);
427        assert_eq!(hist[0].superseded_by, uid.to_string());
428        assert_eq!(hist[0].change_reason, "tightened schema");
429    }
430
431    #[test]
432    fn explicit_deny_blocks_mutation_even_for_admin() {
433        let (store, uid) = store_with_admin();
434        store.put_policy(allow_all_registry("p-allow")).unwrap();
435        store.put_policy(deny_all_registry("p-deny")).unwrap();
436        store
437            .attach_policy(
438                super::super::store::PrincipalRef::User(uid.clone()),
439                "p-allow",
440            )
441            .unwrap();
442        store
443            .attach_policy(
444                super::super::store::PrincipalRef::User(uid.clone()),
445                "p-deny",
446            )
447            .unwrap();
448        let reg = ConfigRegistry::new();
449
450        let err = reg
451            .register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
452            .unwrap_err();
453        assert!(
454            matches!(err, RegistryError::Unauthorized { .. }),
455            "got {err:?}"
456        );
457        assert!(reg.get_active("k").is_none());
458    }
459
460    #[test]
461    fn ordinary_user_without_registry_policy_is_denied() {
462        // Non-admin principal, no policy granting `red.registry:*` →
463        // policy-first DefaultDeny rejects the mutation.
464        let store = std::sync::Arc::new(AuthStore::new(AuthConfig::default()));
465        store.create_user("alice", "p", Role::Write).unwrap();
466        let uid = UserId::platform("alice");
467        // Insert any policy so IAM is the authoritative path.
468        store
469            .put_policy(
470                Policy::from_json_str(
471                    r#"{"id":"p-unrelated","version":1,"statements":[{"effect":"allow","actions":["select"],"resources":["table:public.x"]}]}"#,
472                )
473                .unwrap(),
474            )
475            .unwrap();
476        let mut c = ctx();
477        c.principal_is_admin_role = false;
478        let reg = ConfigRegistry::new();
479        let err = reg
480            .register(&store, &uid, &c, sample_draft("k"), 1_000)
481            .unwrap_err();
482        assert!(
483            matches!(err, RegistryError::Unauthorized { .. }),
484            "got {err:?}"
485        );
486    }
487
488    #[test]
489    fn immutable_entries_reject_supersede() {
490        let (store, uid) = store_with_admin();
491        store.put_policy(allow_all_registry("p-allow")).unwrap();
492        store
493            .attach_policy(
494                super::super::store::PrincipalRef::User(uid.clone()),
495                "p-allow",
496            )
497            .unwrap();
498        let reg = ConfigRegistry::new();
499
500        let mut draft = sample_draft("k");
501        draft.mutability = Mutability::Immutable;
502        reg.register(&store, &uid, &ctx(), draft, 1_000).unwrap();
503
504        let err = reg
505            .supersede(
506                &store,
507                &uid,
508                &ctx(),
509                sample_draft("k"),
510                "should fail",
511                2_000,
512            )
513            .unwrap_err();
514        assert!(matches!(err, RegistryError::Immutable(_)), "got {err:?}");
515        assert_eq!(reg.get_active("k").unwrap().version, 1);
516        assert!(reg.history("k").is_empty());
517    }
518
519    #[test]
520    fn register_twice_is_already_registered() {
521        let (store, uid) = store_with_admin();
522        store.put_policy(allow_all_registry("p-allow")).unwrap();
523        store
524            .attach_policy(
525                super::super::store::PrincipalRef::User(uid.clone()),
526                "p-allow",
527            )
528            .unwrap();
529        let reg = ConfigRegistry::new();
530        reg.register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
531            .unwrap();
532        let err = reg
533            .register(&store, &uid, &ctx(), sample_draft("k"), 1_500)
534            .unwrap_err();
535        assert!(
536            matches!(err, RegistryError::AlreadyRegistered(_)),
537            "got {err:?}"
538        );
539    }
540
541    #[test]
542    fn registry_is_not_exposed_as_sql_collection() {
543        // The registry lives outside the SQL surface — ordinary DML
544        // cannot reach these entries because there is no collection /
545        // table / virtual surface that mirrors them. This test pins
546        // that contract: a ConfigRegistry stands alone; nothing on
547        // AuthStore or the storage path exposes it as a row source.
548        let reg = ConfigRegistry::new();
549        // The public API surface is the governance methods only:
550        let _ = reg.list_active();
551        let _ = reg.history("k");
552        // (No `as_collection()` / `as_table()` / SQL accessor exists by
553        // construction — if a future change adds one, this test should
554        // be reviewed alongside the new wire surface.)
555    }
556}