Skip to main content

reddb_server/auth/
store.rs

1//! AuthStore -- manages users, sessions, and API keys in memory.
2//!
3//! Password hashing delegates to the existing Argon2id implementation in
4//! `crate::storage::encryption::argon2id`.  Token generation uses the
5//! OS CSPRNG (`crate::crypto::os_random`) plus SHA-256.
6#![deny(clippy::disallowed_methods)]
7
8use std::borrow::Cow;
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, RwLock};
12
13use crate::crypto::os_random;
14use crate::crypto::sha256::sha256;
15use crate::storage::encryption::argon2id::{derive_key, Argon2Params};
16use crate::storage::engine::pager::Pager;
17
18use super::column_policy_gate::{ColumnAccessRequest, ColumnPolicyGate, ColumnPolicyOutcome};
19use super::enforcement_mode::{legacy_rbac_decision, PolicyEnforcementMode};
20use super::managed_policy::{ManagedPolicyDecision, ManagedPolicyGate, PolicyOp};
21use super::policies::{self as iam_policies, EvalContext, Policy, ResourceRef, SimulationOutcome};
22use super::privileges::{
23    check_grant, Action, AuthzContext, AuthzError, Grant, GrantPrincipal, GrantsView,
24    PermissionCache, Resource, UserAttributes,
25};
26use super::registry::ConfigRegistry;
27use super::vault::{KeyPair, Vault, VaultState};
28use super::{now_ms, ApiKey, AuthConfig, AuthError, Role, Session, User, UserId};
29use crate::runtime::control_events::{
30    ControlEvent, ControlEventConfig, ControlEventCtx, ControlEventLedger, EventKind, Outcome,
31    Sensitivity,
32};
33
34// ---------------------------------------------------------------------------
35// PrincipalRef + SimCtx — IAM policy attachments
36// ---------------------------------------------------------------------------
37
38/// Principal targeted by `attach_policy` / `detach_policy`.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub enum PrincipalRef {
41    User(UserId),
42    Group(String),
43}
44
45/// Reserved IAM group that every principal belongs to. Used by the
46/// GRANT-to-PUBLIC compatibility layer.
47pub const PUBLIC_IAM_GROUP: &str = "__public__";
48
49/// Optional context overrides for `simulate` — anything not set falls back
50/// to a default value when the kernel evaluates the request.
51#[derive(Debug, Clone, Default)]
52pub struct SimCtx {
53    pub current_tenant: Option<String>,
54    pub peer_ip: Option<std::net::IpAddr>,
55    pub mfa_present: bool,
56    pub now_ms: Option<u128>,
57}
58
59/// Control-event dependencies for audited IAM policy mutations.
60///
61/// The existing `put_policy` / `delete_policy` / attach / detach
62/// methods remain unaudited for bootstrap and synthetic GRANT internals.
63/// Public mutation surfaces that need evidence pass this context to the
64/// `*_with_control_events` siblings.
65pub struct PolicyMutationControl<'a> {
66    pub ctx: &'a ControlEventCtx<'a>,
67    pub ledger: &'a dyn ControlEventLedger,
68    pub config: ControlEventConfig,
69    pub registry: Option<&'a ConfigRegistry>,
70    pub actor: &'a UserId,
71    pub eval_ctx: &'a EvalContext,
72}
73
74/// Control-event dependencies for audited user lifecycle mutations.
75pub struct UserLifecycleControl<'a> {
76    pub ctx: &'a ControlEventCtx<'a>,
77    pub ledger: &'a dyn ControlEventLedger,
78    pub config: ControlEventConfig,
79}
80
81#[derive(Clone)]
82struct AuthStoreControlEvents {
83    ledger: Arc<dyn ControlEventLedger>,
84    config: ControlEventConfig,
85}
86
87// ---------------------------------------------------------------------------
88// BootstrapResult
89// ---------------------------------------------------------------------------
90
91/// Result of a successful bootstrap operation.
92///
93/// The `certificate` is the hex-encoded string the admin must save --
94/// it is the ONLY way to unseal the vault after a restart.
95pub struct BootstrapResult {
96    pub user: User,
97    pub api_key: ApiKey,
98    /// Certificate hex string.  `None` when vault is not configured.
99    pub certificate: Option<String>,
100}
101
102impl std::fmt::Debug for BootstrapResult {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("BootstrapResult")
105            .field("user", &self.user)
106            .field("api_key", &"<redacted>")
107            .field(
108                "certificate",
109                &self.certificate.as_ref().map(|_| "<redacted>"),
110            )
111            .finish()
112    }
113}
114
115// ---------------------------------------------------------------------------
116// AuthStore
117// ---------------------------------------------------------------------------
118
119/// Central in-process authority for auth state.
120///
121/// All mutations are guarded by `RwLock`s so the store is `Send + Sync`.
122pub struct AuthStore {
123    /// `(tenant_id, username) -> User`. Tenant scoping is built into the
124    /// key so `alice@acme` and `alice@globex` are distinct identities.
125    users: RwLock<HashMap<UserId, User>>,
126    sessions: RwLock<HashMap<String, Session>>,
127    /// key-string -> (owner UserId, role)
128    api_key_index: RwLock<HashMap<String, (UserId, Role)>>,
129    /// Once true, bootstrap() is permanently sealed.
130    bootstrapped: AtomicBool,
131    config: AuthConfig,
132    /// Optional encrypted vault for persisting auth state to pager pages.
133    vault: RwLock<Option<Vault>>,
134    /// Reference to the pager for vault page I/O.
135    pager: Option<Arc<Pager>>,
136    /// Existing operator credential; bootstrap must preserve this seal.
137    provided_certificate: Option<String>,
138    /// Certificate-based keypair for token signing and vault seal.
139    /// Populated after bootstrap or after restoring from a sealed vault.
140    keypair: RwLock<Option<KeyPair>>,
141    /// Encrypted key-value store for arbitrary secrets.
142    /// Persisted to vault alongside users/api_keys.
143    vault_kv: RwLock<HashMap<String, String>>,
144    secret_key_init: std::sync::Mutex<()>,
145    /// Plain (non-encrypted) user key-value store for `$kv.*` / `SET KV`
146    /// (#1602). Deliberately isolated from `vault_kv` (encrypted secrets)
147    /// and from the runtime config store — three independent flat-maps,
148    /// no mixing. Backs the `kv:read` / `kv:write` IAM-gated resolver.
149    plain_kv: RwLock<HashMap<String, String>>,
150    /// Per-user GRANT rows. Persisted via `vault_kv` under the
151    /// `red.acl.grants.<tenant>/<user>` key prefix so existing snapshot
152    /// logic keeps working without modification. See `privileges` module
153    /// for the resolution algorithm.
154    grants: RwLock<HashMap<UserId, Vec<Grant>>>,
155    /// PUBLIC grants — apply to every authenticated principal.
156    public_grants: RwLock<Vec<Grant>>,
157    /// PG-style account attributes (`VALID UNTIL`, `CONNECTION LIMIT`,
158    /// `search_path`). Keyed by `UserId`. Persisted under
159    /// `red.acl.attrs.<tenant>/<user>`.
160    user_attributes: RwLock<HashMap<UserId, UserAttributes>>,
161    /// Live session count per user — used by `CONNECTION LIMIT`
162    /// enforcement on login. Bumped at authenticate, decremented at
163    /// session revoke / expiry.
164    session_count_by_user: RwLock<HashMap<UserId, u32>>,
165    /// Pre-resolved (resource, action) cache built per-user so the
166    /// hot path skips a linear scan of the user's grants on every
167    /// statement. Invalidated on GRANT / REVOKE / ALTER USER.
168    permission_cache: RwLock<HashMap<UserId, PermissionCache>>,
169    /// IAM-style policies, keyed by id. Persisted under
170    /// `red.iam.policies`. The kernel in `super::policies` owns the
171    /// Policy type — this map just deduplicates and shares.
172    policies: RwLock<HashMap<String, Arc<Policy>>>,
173    /// Per-user policy attachments — ordered list of policy ids.
174    /// Persisted under `red.iam.attachments.users`.
175    user_attachments: RwLock<HashMap<UserId, Vec<String>>>,
176    /// Per-group policy attachments. Users join groups through
177    /// `UserAttributes::groups`; effective policies resolve group
178    /// attachments before user-direct attachments.
179    group_attachments: RwLock<HashMap<String, Vec<String>>>,
180    /// Cached effective `Vec<Arc<Policy>>` per user. Invalidated on
181    /// any policy mutation that affects the user's attachments.
182    iam_effective_cache: RwLock<HashMap<UserId, Vec<Arc<Policy>>>>,
183    /// Once any IAM policy is installed, authorization switches to the
184    /// IAM evaluator and stays deny-by-default even if policies are
185    /// later deleted. Persisted under `red.iam.enabled`.
186    iam_authorization_enabled: AtomicBool,
187    /// Selects the fallback behaviour when the IAM evaluator returns
188    /// `DefaultDeny`. See [`PolicyEnforcementMode`] and #712 / S5A.
189    /// Default is [`PolicyEnforcementMode::default_existing_install`]
190    /// (`LegacyRbac`) so an existing install upgrading past #712
191    /// keeps its current authorization posture until the operator
192    /// flips it explicitly. The bootstrap path sets this to
193    /// `PolicyOnly` for fresh installs; the boot-time config loader
194    /// supersedes both when a `red.config.policy.enforcement_mode`
195    /// value is present in the configured value store.
196    enforcement_mode: RwLock<PolicyEnforcementMode>,
197    /// Once-per-boot flag used by [`AuthStore::take_legacy_rbac_warn_once`]
198    /// to deliver exactly one `warn`-level log line when the store is
199    /// running under [`PolicyEnforcementMode::LegacyRbac`]. The boot
200    /// path checks the flag and, if it can claim it, emits the line.
201    legacy_rbac_boot_warn_emitted: AtomicBool,
202    /// `(tenant, role) → HashSet<CollectionId>` cache used by the AI
203    /// pipeline (issue #119). Distinct from `permission_cache`, which
204    /// is keyed by `UserId` and answers `(resource, action)` lookups —
205    /// this cache answers the inverse "what collections is this scope
206    /// allowed to read?" query that `AuthorizedSearch` uses to
207    /// pre-filter SEARCH SIMILAR / SEARCH CONTEXT candidates before any
208    /// similarity score is computed. Entries TTL out at 60s and are
209    /// invalidated explicitly on GRANT/REVOKE/CREATE POLICY/DROP
210    /// POLICY/DROP COLLECTION.
211    visible_collections_cache: super::scope_cache::AuthCache,
212    control_events: RwLock<Option<AuthStoreControlEvents>>,
213}
214
215// Use fast-but-safe Argon2id params for auth hashing (smaller than the
216// default 64 MB so that user-management RPCs respond quickly).
217fn auth_argon2_params() -> Argon2Params {
218    Argon2Params {
219        m_cost: 4 * 1024, // 4 MB
220        t_cost: 3,
221        p: 1,
222        tag_len: 32,
223    }
224}
225
226fn policy_control_fields(
227    policy_id: &str,
228    policy: Option<&Policy>,
229    principal: Option<&PrincipalRef>,
230) -> HashMap<String, Sensitivity> {
231    let mut fields = HashMap::new();
232    fields.insert("policy_id".to_string(), Sensitivity::raw(policy_id));
233    if let Some(policy) = policy {
234        let effects = policy
235            .statements
236            .iter()
237            .map(|s| match s.effect {
238                iam_policies::Effect::Allow => "allow",
239                iam_policies::Effect::Deny => "deny",
240            })
241            .collect::<Vec<_>>()
242            .join(",");
243        let actions = policy
244            .statements
245            .iter()
246            .flat_map(|s| s.actions.iter())
247            .map(policy_action_pattern)
248            .collect::<Vec<_>>()
249            .join(",");
250        let resources = policy
251            .statements
252            .iter()
253            .flat_map(|s| s.resources.iter())
254            .map(policy_resource_pattern)
255            .collect::<Vec<_>>()
256            .join(",");
257        fields.insert("effect".to_string(), Sensitivity::raw(effects));
258        fields.insert("action".to_string(), Sensitivity::raw(actions));
259        fields.insert("resource".to_string(), Sensitivity::raw(resources));
260        if let Some(attrs) = policy_principal_attrs(policy) {
261            fields.insert("principal_attrs".to_string(), Sensitivity::raw(attrs));
262        }
263    }
264    if let Some(principal) = principal {
265        fields.insert(
266            "principal".to_string(),
267            Sensitivity::raw(policy_principal_label(principal)),
268        );
269    }
270    fields
271}
272
273fn policy_action_pattern(pattern: &iam_policies::ActionPattern) -> String {
274    match pattern {
275        iam_policies::ActionPattern::Exact(s) => s.clone(),
276        iam_policies::ActionPattern::Wildcard => "*".to_string(),
277        iam_policies::ActionPattern::Prefix(s) => format!("{s}:*"),
278    }
279}
280
281fn policy_resource_pattern(pattern: &iam_policies::ResourcePattern) -> String {
282    match pattern {
283        iam_policies::ResourcePattern::Exact { kind, name } => format!("{kind}:{name}"),
284        iam_policies::ResourcePattern::Glob(s) => s.clone(),
285        iam_policies::ResourcePattern::Wildcard => "*".to_string(),
286    }
287}
288
289fn policy_principal_attrs(policy: &Policy) -> Option<String> {
290    let mut attrs = Vec::new();
291    for statement in &policy.statements {
292        let Some(condition) = &statement.condition else {
293            continue;
294        };
295        if let Some(value) = condition.platform_scoped {
296            attrs.push(format!("platform_scoped={value}"));
297        }
298        if let Some(value) = condition.mfa {
299            attrs.push(format!("mfa={value}"));
300        }
301        if let Some(value) = condition.tenant_match {
302            attrs.push(format!("tenant_match={value}"));
303        }
304    }
305    if attrs.is_empty() {
306        None
307    } else {
308        Some(attrs.join(","))
309    }
310}
311
312fn policy_principal_label(principal: &PrincipalRef) -> String {
313    match principal {
314        PrincipalRef::User(uid) => format!("user:{uid}"),
315        PrincipalRef::Group(group) => format!("group:{group}"),
316    }
317}
318
319fn default_user_lifecycle_ctx<'a>() -> ControlEventCtx<'a> {
320    ControlEventCtx {
321        actor: crate::runtime::control_events::ActorRef::Anonymous,
322        scope: None,
323        request_id: None,
324        trace_id: None,
325    }
326}
327
328fn bootstrap_user_lifecycle_ctx<'a>() -> ControlEventCtx<'a> {
329    ControlEventCtx {
330        actor: crate::runtime::control_events::ActorRef::System("bootstrap"),
331        scope: None,
332        request_id: None,
333        trace_id: None,
334    }
335}
336
337fn user_resource(id: &UserId) -> String {
338    format!("user:{id}")
339}
340
341fn api_key_resource(api_key_id: &str) -> String {
342    format!("apikey:{api_key_id}")
343}
344
345/// Hex digest that identifies an API key in control events and, with the
346/// `rkh_` prefix, at rest. Accepts either the secret or the stored id, so
347/// every entry point can take whichever the caller holds.
348fn api_key_id(key: &str) -> String {
349    match key.strip_prefix("rkh_") {
350        Some(hex) => hex.to_string(),
351        None => hex::encode(sha256(key.as_bytes())),
352    }
353}
354
355/// Stored form of an API key: `rkh_<sha256(secret)>`. The secret is only
356/// ever compared by hashing it and looking the result up.
357pub(crate) fn api_key_credential_id(token: &str) -> String {
358    format!("rkh_{}", api_key_id(token))
359}
360
361fn password_evidence() -> Sensitivity {
362    Sensitivity::redacted()
363}
364
365fn user_control_fields(
366    id: &UserId,
367    role: Option<Role>,
368    enabled: Option<bool>,
369    include_password: bool,
370) -> HashMap<String, Sensitivity> {
371    let mut fields = HashMap::new();
372    fields.insert(
373        "username".to_string(),
374        Sensitivity::raw(id.username.clone()),
375    );
376    fields.insert(
377        "tenant_id".to_string(),
378        Sensitivity::raw(id.tenant.clone().unwrap_or_default()),
379    );
380    if let Some(role) = role {
381        fields.insert("role".to_string(), Sensitivity::raw(role.as_str()));
382    }
383    if let Some(enabled) = enabled {
384        fields.insert("enabled".to_string(), Sensitivity::raw(enabled.to_string()));
385    }
386    if include_password {
387        fields.insert("password".to_string(), password_evidence());
388    }
389    fields
390}
391
392fn api_key_control_fields(
393    id: &UserId,
394    role: Role,
395    api_key_id: &str,
396) -> HashMap<String, Sensitivity> {
397    let mut fields = user_control_fields(id, Some(role), None, false);
398    fields.insert("api_key_id".to_string(), Sensitivity::raw(api_key_id));
399    fields.insert("api_key".to_string(), Sensitivity::redacted());
400    fields
401}
402
403fn user_error_is_denied(err: &AuthError) -> bool {
404    !matches!(err, AuthError::Internal(_))
405}
406
407impl AuthStore {
408    // -----------------------------------------------------------------
409    // Construction
410    // -----------------------------------------------------------------
411
412    pub fn new(config: AuthConfig) -> Self {
413        Self {
414            users: RwLock::new(HashMap::new()),
415            sessions: RwLock::new(HashMap::new()),
416            api_key_index: RwLock::new(HashMap::new()),
417            bootstrapped: AtomicBool::new(false),
418            config,
419            vault: RwLock::new(None),
420            pager: None,
421            provided_certificate: None,
422            keypair: RwLock::new(None),
423            vault_kv: RwLock::new(HashMap::new()),
424            secret_key_init: std::sync::Mutex::new(()),
425            plain_kv: RwLock::new(HashMap::new()),
426            grants: RwLock::new(HashMap::new()),
427            public_grants: RwLock::new(Vec::new()),
428            user_attributes: RwLock::new(HashMap::new()),
429            session_count_by_user: RwLock::new(HashMap::new()),
430            permission_cache: RwLock::new(HashMap::new()),
431            policies: RwLock::new(HashMap::new()),
432            user_attachments: RwLock::new(HashMap::new()),
433            group_attachments: RwLock::new(HashMap::new()),
434            iam_effective_cache: RwLock::new(HashMap::new()),
435            iam_authorization_enabled: AtomicBool::new(false),
436            enforcement_mode: RwLock::new(PolicyEnforcementMode::default_existing_install()),
437            legacy_rbac_boot_warn_emitted: AtomicBool::new(false),
438            visible_collections_cache: super::scope_cache::AuthCache::new(
439                super::scope_cache::DEFAULT_TTL,
440            ),
441            control_events: RwLock::new(None),
442        }
443    }
444
445    pub fn configure_control_events(
446        &self,
447        ledger: Arc<dyn ControlEventLedger>,
448        config: ControlEventConfig,
449    ) {
450        *self
451            .control_events
452            .write()
453            .unwrap_or_else(|e| e.into_inner()) = Some(AuthStoreControlEvents { ledger, config });
454    }
455
456    fn configured_control_events(&self) -> Option<AuthStoreControlEvents> {
457        self.control_events
458            .read()
459            .unwrap_or_else(|e| e.into_inner())
460            .clone()
461    }
462
463    /// Create an `AuthStore` backed by encrypted vault pages inside the
464    /// main `.rdb` database file.
465    ///
466    /// If vault pages already exist, their contents are loaded and
467    /// restored into the in-memory store.  All subsequent mutations are
468    /// automatically persisted to the vault pages via the pager.
469    pub fn with_vault(config: AuthConfig, pager: Arc<Pager>) -> Result<Self, AuthError> {
470        let certificate = crate::utils::env_with_file_fallback("REDDB_CERTIFICATE");
471        let passphrase = crate::utils::env_with_file_fallback("REDDB_VAULT_PASSPHRASE");
472        match (certificate, passphrase) {
473            (Some(_), Some(_)) => Err(AuthError::Internal(
474                "set either REDDB_CERTIFICATE or REDDB_VAULT_PASSPHRASE, not both".into(),
475            )),
476            (None, Some(passphrase)) => Self::with_vault_passphrase(config, pager, &passphrase),
477            (certificate, None) => {
478                Self::with_vault_optional_certificate(config, pager, certificate.as_deref())
479            }
480        }
481    }
482
483    pub fn with_vault_certificate(
484        config: AuthConfig,
485        pager: Arc<Pager>,
486        certificate_hex: &str,
487    ) -> Result<Self, AuthError> {
488        Self::with_vault_optional_certificate(config, pager, Some(certificate_hex))
489    }
490
491    pub fn with_vault_passphrase(
492        config: AuthConfig,
493        pager: Arc<Pager>,
494        passphrase: &str,
495    ) -> Result<Self, AuthError> {
496        let mut store = Self::new(config);
497        let vault = Vault::with_passphrase(&pager, passphrase)?;
498        if let Some(state) = vault.load(&pager)? {
499            store.restore_from_vault(state);
500        }
501        *store.vault.write().map_err(lock_err)? = Some(vault);
502        store.pager = Some(pager);
503        Ok(store)
504    }
505
506    fn with_vault_optional_certificate(
507        config: AuthConfig,
508        pager: Arc<Pager>,
509        certificate_hex: Option<&str>,
510    ) -> Result<Self, AuthError> {
511        let mut store = Self::new(config);
512
513        // Restore persisted state if vault pages exist. A fresh database does
514        // not need a temporary seal: bootstrap generates the certificate and
515        // installs the first real vault atomically.
516        if Vault::has_saved_state(&pager) {
517            let vault = match certificate_hex {
518                Some(certificate) => Vault::with_certificate(&pager, certificate)?,
519                None => Vault::open(&pager)?,
520            };
521            if let Some(state) = vault.load(&pager)? {
522                store.restore_from_vault(state);
523            }
524            *store.vault.write().unwrap_or_else(|e| e.into_inner()) = Some(vault);
525        } else if let Some(certificate) = certificate_hex {
526            let vault = Vault::with_certificate(&pager, certificate)?;
527            *store.vault.write().unwrap_or_else(|e| e.into_inner()) = Some(vault);
528        }
529
530        store.pager = Some(pager);
531        store.provided_certificate =
532            certificate_hex.map(|certificate| certificate.trim().to_ascii_lowercase());
533        Ok(store)
534    }
535
536    pub fn config(&self) -> &AuthConfig {
537        &self.config
538    }
539
540    pub fn is_enabled(&self) -> bool {
541        self.config.enabled
542    }
543
544    /// Returns true when no users exist yet and bootstrap hasn't been sealed.
545    pub fn needs_bootstrap(&self) -> bool {
546        !self.bootstrapped.load(Ordering::Acquire)
547            && self.users.read().map(|u| u.is_empty()).unwrap_or(true)
548    }
549
550    /// Internal: read-locked lookup by `UserId`.
551    fn get_user_cloned(&self, id: &UserId) -> Option<User> {
552        self.users.read().ok().and_then(|m| m.get(id).cloned())
553    }
554
555    /// Whether bootstrap has already been performed (sealed).
556    pub fn is_bootstrapped(&self) -> bool {
557        self.bootstrapped.load(Ordering::Acquire)
558    }
559
560    /// Bootstrap the first admin user. One-shot, irreversible.
561    ///
562    /// Uses an atomic compare-exchange to guarantee that even under
563    /// concurrent calls, only the first one succeeds. Once sealed,
564    /// all subsequent calls fail immediately -- there is no undo.
565    ///
566    /// When a vault/pager is configured, a certificate-based keypair is
567    /// generated and the vault is re-encrypted with the certificate-derived
568    /// key.  The certificate hex string is returned in `BootstrapResult`
569    /// so the admin can save it.
570    pub fn bootstrap(&self, username: &str, password: &str) -> Result<BootstrapResult, AuthError> {
571        if let Some(configured) = self.configured_control_events() {
572            let ctx = bootstrap_user_lifecycle_ctx();
573            let control = UserLifecycleControl {
574                ctx: &ctx,
575                ledger: configured.ledger.as_ref(),
576                config: configured.config,
577            };
578            self.bootstrap_with_control_events_inner(username, password, &control)
579        } else {
580            self.bootstrap_unaudited(username, password)
581        }
582    }
583
584    pub fn bootstrap_with_control_events(
585        &self,
586        username: &str,
587        password: &str,
588        ctx: &ControlEventCtx<'_>,
589        ledger: &dyn ControlEventLedger,
590        config: ControlEventConfig,
591    ) -> Result<BootstrapResult, AuthError> {
592        let system_ctx = ControlEventCtx {
593            actor: crate::runtime::control_events::ActorRef::System("bootstrap"),
594            scope: ctx.scope.clone(),
595            request_id: ctx.request_id.clone(),
596            trace_id: ctx.trace_id.clone(),
597        };
598        let control = UserLifecycleControl {
599            ctx: &system_ctx,
600            ledger,
601            config,
602        };
603        self.bootstrap_with_control_events_inner(username, password, &control)
604    }
605
606    /// Backwards-compatible bootstrap entry point for older callers
607    /// that used to ask for an operator-owned first admin. Authorization
608    /// is now policy-first, so this creates the same ordinary platform
609    /// admin as [`Self::bootstrap`].
610    pub fn bootstrap_system_admin(
611        &self,
612        username: &str,
613        password: &str,
614    ) -> Result<BootstrapResult, AuthError> {
615        self.bootstrap(username, password)
616    }
617
618    fn bootstrap_unaudited(
619        &self,
620        username: &str,
621        password: &str,
622    ) -> Result<BootstrapResult, AuthError> {
623        // Atomic seal: only the first caller wins.
624        if self
625            .bootstrapped
626            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
627            .is_err()
628        {
629            return Err(AuthError::Forbidden(
630                "bootstrap already completed — sealed permanently".to_string(),
631            ));
632        }
633
634        // Double-check users are actually empty (belt and suspenders).
635        {
636            let users = self.users.read().map_err(lock_err)?;
637            if !users.is_empty() {
638                return Err(AuthError::Forbidden(
639                    "bootstrap already completed — users exist".to_string(),
640                ));
641            }
642        }
643
644        let user = self.create_user_in_tenant_unaudited(None, username, password, Role::Admin)?;
645        let key = self.create_api_key_in_tenant_unaudited(
646            None,
647            username,
648            "bootstrap",
649            Role::Admin,
650            None,
651        )?;
652
653        // Token signing gets a fresh master secret. Preserve an existing
654        // certificate/passphrase seal instead of replacing the operator's key.
655        let credential_result = (|| -> Result<Option<String>, AuthError> {
656            let certificate = if let Some(ref pager) = self.pager {
657                let keypair = KeyPair::generate();
658                let mut vault = self.vault.write().map_err(lock_err)?;
659                let certificate = if vault.is_none() {
660                    let certificate = keypair.certificate_hex();
661                    *vault = Some(Vault::with_certificate_bytes(pager, &keypair.certificate)?);
662                    Some(certificate)
663                } else {
664                    self.provided_certificate.clone()
665                };
666                *self.keypair.write().map_err(lock_err)? = Some(keypair);
667                drop(vault);
668                self.ensure_vault_secret_key_result()?;
669                certificate
670            } else {
671                None
672            };
673            *self.enforcement_mode.write().map_err(lock_err)? =
674                PolicyEnforcementMode::default_fresh_bootstrap();
675            self.vault_kv.write().map_err(lock_err)?.insert(
676                "red.config.policy.enforcement_mode".into(),
677                PolicyEnforcementMode::default_fresh_bootstrap()
678                    .as_str()
679                    .into(),
680            );
681            self.persist_to_vault_result()?;
682            Ok(certificate)
683        })();
684        let certificate = match credential_result {
685            Ok(certificate) => certificate,
686            Err(err) => {
687                self.rollback_bootstrap(&UserId::from_parts(None, username));
688                return Err(err);
689            }
690        };
691
692        Ok(BootstrapResult {
693            user,
694            api_key: key,
695            certificate,
696        })
697    }
698
699    fn bootstrap_with_control_events_inner(
700        &self,
701        username: &str,
702        password: &str,
703        control: &UserLifecycleControl<'_>,
704    ) -> Result<BootstrapResult, AuthError> {
705        let id = UserId::from_parts(None, username);
706        match self.bootstrap_unaudited(username, password) {
707            Ok(result) => {
708                let event_result = self.emit_user_lifecycle_allowed(
709                    control,
710                    EventKind::UserCreate,
711                    "user.create",
712                    &id,
713                    user_control_fields(&id, Some(Role::Admin), Some(true), true),
714                );
715                if let Err(err) = event_result {
716                    self.rollback_bootstrap(&id);
717                    return Err(err);
718                }
719                Ok(result)
720            }
721            Err(err) => {
722                self.emit_user_lifecycle_outcome(
723                    control,
724                    if user_error_is_denied(&err) {
725                        Outcome::Denied
726                    } else {
727                        Outcome::Error
728                    },
729                    EventKind::UserCreate,
730                    "user.create",
731                    &id,
732                    Some(err.to_string()),
733                    user_control_fields(&id, Some(Role::Admin), Some(true), true),
734                );
735                Err(err)
736            }
737        }
738    }
739
740    /// Auto-bootstrap from environment variables if no users exist.
741    ///
742    /// Checks `REDDB_USERNAME` and `REDDB_PASSWORD`. If both are set and
743    /// the user store is empty, creates the first admin user automatically.
744    /// This mirrors the Docker pattern (`MYSQL_ROOT_PASSWORD`, etc.).
745    ///
746    /// Returns `Some(BootstrapResult)` if bootstrapped, `None` if skipped.
747    pub fn bootstrap_from_env(&self) -> Option<BootstrapResult> {
748        if !self.needs_bootstrap() {
749            return None;
750        }
751
752        let username = crate::utils::env_with_file_fallback("REDDB_USERNAME")?;
753        let password = crate::utils::env_with_file_fallback("REDDB_PASSWORD")?;
754
755        if username.is_empty() || password.is_empty() {
756            return None;
757        }
758
759        match self.bootstrap(&username, &password) {
760            Ok(result) => {
761                // F-04: `username` is REDDB_USERNAME — operator-supplied
762                // (env), but still routed through the LogField escaper
763                // because env strings cross trust boundaries in some
764                // deployment models (k8s downward API, Vault sidecar,
765                // external secret operator). See ADR 0010.
766                tracing::info!(
767                    username = %reddb_wire::audit_safe_log_field(&username),
768                    "bootstrapped admin user from REDDB_USERNAME/REDDB_PASSWORD"
769                );
770                if let Some(ref cert) = result.certificate {
771                    deliver_bootstrap_certificate(cert);
772                }
773                Some(result)
774            }
775            Err(e) => {
776                tracing::error!(err = %e, "env bootstrap failed");
777                None
778            }
779        }
780    }
781
782    // -----------------------------------------------------------------
783    // Vault persistence
784    // -----------------------------------------------------------------
785
786    /// Persist the current auth state to the vault pages (if configured).
787    pub(crate) fn persist_to_vault_result(&self) -> Result<(), AuthError> {
788        let vault_guard = self.vault.read().unwrap_or_else(|e| e.into_inner());
789        if let (Some(ref vault), Some(ref pager)) = (&*vault_guard, &self.pager) {
790            let state = self.snapshot();
791            vault.save(pager, &state)?;
792        }
793        Ok(())
794    }
795
796    /// Persist the current auth state to the vault pages (if configured).
797    ///
798    /// Legacy auth mutations still treat in-memory state as authoritative.
799    /// New secret-management paths use the `try_` methods below so callers
800    /// get a hard error if the vault write fails.
801    fn persist_to_vault(&self) {
802        if let Err(e) = self.persist_to_vault_result() {
803            tracing::error!(err = %e, "vault persist failed");
804            // Issue #205 — vault persist is the secret-rotation
805            // serialization point: when this fails, freshly rotated
806            // credentials live only in memory and a process restart
807            // loses them. Operator-grade event so the operator can
808            // intervene before the next restart.
809            crate::telemetry::operator_event::OperatorEvent::SecretRotationFailed {
810                secret_ref: "auth_vault".to_string(),
811                error: e.to_string(),
812            }
813            .emit_global();
814        }
815    }
816
817    /// True when this store has an encrypted vault and pager wired in.
818    pub fn is_vault_backed(&self) -> bool {
819        self.pager.is_some()
820            && self
821                .vault
822                .read()
823                .map(|guard| guard.is_some())
824                .unwrap_or(false)
825    }
826
827    // -----------------------------------------------------------------
828    // Vault KV — encrypted key-value store for arbitrary secrets
829    // -----------------------------------------------------------------
830
831    /// Read a value from the vault KV store. Returns `None` if not set.
832    pub fn vault_kv_get(&self, key: &str) -> Option<String> {
833        self.vault_kv
834            .read()
835            .ok()
836            .and_then(|kv| kv.get(key).cloned())
837    }
838
839    /// Snapshot vault KV values for statement-local secret resolution.
840    pub fn vault_kv_snapshot(&self) -> HashMap<String, String> {
841        self.vault_kv
842            .read()
843            .map(|kv| kv.clone())
844            .unwrap_or_default()
845    }
846
847    /// Export vault KV as an encrypted logical blob for JSONL dump/restore.
848    /// Returns `None` when the vault has no KV entries.
849    pub fn vault_kv_export_encrypted(&self) -> Result<Option<String>, AuthError> {
850        if !self.is_vault_backed() {
851            return Err(AuthError::Forbidden(
852                "vault KV export requires an enabled, unsealed vault".to_string(),
853            ));
854        }
855        let kv = self.vault_kv_snapshot();
856        if kv.is_empty() {
857            return Ok(None);
858        }
859
860        let vault_guard = self.vault.read().map_err(lock_err)?;
861        let vault = vault_guard.as_ref().ok_or_else(|| {
862            AuthError::Forbidden("vault KV export requires an enabled, unsealed vault".to_string())
863        })?;
864        let state = VaultState {
865            users: Vec::new(),
866            api_keys: Vec::new(),
867            bootstrapped: false,
868            master_secret: None,
869            kv,
870        };
871        Ok(Some(vault.seal_logical_export(&state)?))
872    }
873
874    /// Merge imported vault KV entries and fail if the encrypted vault
875    /// write cannot be made durable.
876    pub fn vault_kv_try_import(
877        &self,
878        entries: HashMap<String, String>,
879    ) -> Result<usize, AuthError> {
880        if !self.is_vault_backed() {
881            return Err(AuthError::Forbidden(
882                "vault KV import requires an enabled, unsealed vault".to_string(),
883            ));
884        }
885        if entries.is_empty() {
886            return Ok(0);
887        }
888
889        let mut previous = HashMap::new();
890        {
891            let mut kv = self.vault_kv.write().map_err(lock_err)?;
892            for (key, value) in &entries {
893                previous.insert(key.clone(), kv.insert(key.clone(), value.clone()));
894            }
895        }
896
897        if let Err(err) = self.persist_to_vault_result() {
898            if let Ok(mut kv) = self.vault_kv.write() {
899                for (key, old) in previous {
900                    match old {
901                        Some(value) => {
902                            kv.insert(key, value);
903                        }
904                        None => {
905                            kv.remove(&key);
906                        }
907                    }
908                }
909            }
910            return Err(err);
911        }
912
913        Ok(entries.len())
914    }
915
916    /// Import false placeholders for secrets whose encrypted payload
917    /// could not be decrypted during logical restore.
918    pub fn vault_kv_try_import_placeholders(&self, keys: &[String]) -> Result<usize, AuthError> {
919        let entries = keys
920            .iter()
921            .map(|key| (key.clone(), "false".to_string()))
922            .collect();
923        self.vault_kv_try_import(entries)
924    }
925
926    /// Write a value to the vault KV store, persisting to disk.
927    pub fn vault_kv_set(&self, key: String, value: String) {
928        if let Ok(mut kv) = self.vault_kv.write() {
929            kv.insert(key, value);
930        }
931        self.persist_to_vault();
932    }
933
934    /// Write a value to the vault KV store and fail if the vault write
935    /// cannot be made durable.
936    pub fn vault_kv_try_set(&self, key: String, value: String) -> Result<(), AuthError> {
937        if !self.is_vault_backed() {
938            return Err(AuthError::Forbidden(
939                "SET SECRET requires an enabled, unsealed vault".to_string(),
940            ));
941        }
942
943        let previous = {
944            let mut kv = self.vault_kv.write().map_err(lock_err)?;
945            kv.insert(key.clone(), value)
946        };
947
948        if let Err(err) = self.persist_to_vault_result() {
949            if let Ok(mut kv) = self.vault_kv.write() {
950                match previous {
951                    Some(value) => {
952                        kv.insert(key, value);
953                    }
954                    None => {
955                        kv.remove(&key);
956                    }
957                }
958            }
959            return Err(err);
960        }
961
962        Ok(())
963    }
964
965    /// Delete a value from the vault KV store. Returns true if it existed.
966    pub fn vault_kv_delete(&self, key: &str) -> bool {
967        let existed = self
968            .vault_kv
969            .write()
970            .map(|mut kv| kv.remove(key).is_some())
971            .unwrap_or(false);
972        if existed {
973            self.persist_to_vault();
974        }
975        existed
976    }
977
978    /// Delete a value from the vault KV store and fail if the vault write
979    /// cannot be made durable.
980    pub fn vault_kv_try_delete(&self, key: &str) -> Result<bool, AuthError> {
981        if !self.is_vault_backed() {
982            return Err(AuthError::Forbidden(
983                "DELETE SECRET requires an enabled, unsealed vault".to_string(),
984            ));
985        }
986
987        let removed = {
988            let mut kv = self.vault_kv.write().map_err(lock_err)?;
989            kv.remove(key)
990        };
991
992        if removed.is_none() {
993            return Ok(false);
994        }
995
996        if let Err(err) = self.persist_to_vault_result() {
997            if let Ok(mut kv) = self.vault_kv.write() {
998                if let Some(value) = removed {
999                    kv.insert(key.to_string(), value);
1000                }
1001            }
1002            return Err(err);
1003        }
1004
1005        Ok(true)
1006    }
1007
1008    /// List all keys in the vault KV store.
1009    pub fn vault_kv_keys(&self) -> Vec<String> {
1010        self.vault_kv
1011            .read()
1012            .map(|kv| kv.keys().cloned().collect())
1013            .unwrap_or_default()
1014    }
1015
1016    // -----------------------------------------------------------------
1017    // Plain KV — non-encrypted user key-value store (#1602)
1018    //
1019    // A sibling to the vault KV above, but stored in its own flat-map
1020    // with no encryption. Backs `$kv.*` / `SET KV` / `DELETE KV` and is
1021    // gated by `kv:read` / `kv:write` at the runtime layer. Kept fully
1022    // separate from `vault_kv` (secrets) and the config store.
1023    // -----------------------------------------------------------------
1024
1025    /// Read a value from the plain KV store. Returns `None` if not set.
1026    pub fn plain_kv_get(&self, key: &str) -> Option<String> {
1027        self.plain_kv
1028            .read()
1029            .ok()
1030            .and_then(|kv| kv.get(key).cloned())
1031    }
1032
1033    /// Snapshot plain KV values for statement-local resolution.
1034    pub fn plain_kv_snapshot(&self) -> HashMap<String, String> {
1035        self.plain_kv
1036            .read()
1037            .map(|kv| kv.clone())
1038            .unwrap_or_default()
1039    }
1040
1041    /// Write a value to the plain KV store.
1042    pub fn plain_kv_set(&self, key: String, value: String) {
1043        if let Ok(mut kv) = self.plain_kv.write() {
1044            kv.insert(key, value);
1045        }
1046    }
1047
1048    /// Delete a value from the plain KV store. Returns true if it existed.
1049    pub fn plain_kv_delete(&self, key: &str) -> bool {
1050        self.plain_kv
1051            .write()
1052            .map(|mut kv| kv.remove(key).is_some())
1053            .unwrap_or(false)
1054    }
1055
1056    /// List all keys in the plain KV store.
1057    pub fn plain_kv_keys(&self) -> Vec<String> {
1058        self.plain_kv
1059            .read()
1060            .map(|kv| kv.keys().cloned().collect())
1061            .unwrap_or_default()
1062    }
1063
1064    /// Convenience: get the 32-byte secret key for Value::Secret encryption.
1065    /// Generated on first boot and stored at `red.secret.aes_key`.
1066    pub fn vault_secret_key(&self) -> Option<[u8; 32]> {
1067        let hex_str = self.vault_kv_get("red.secret.aes_key")?;
1068        let bytes = hex::decode(hex_str).ok()?;
1069        if bytes.len() == 32 {
1070            let mut key = [0u8; 32];
1071            key.copy_from_slice(&bytes);
1072            Some(key)
1073        } else {
1074            None
1075        }
1076    }
1077
1078    /// Generate and store the AES-256 secret key on first boot if not present.
1079    pub fn ensure_vault_secret_key(&self) {
1080        if let Err(err) = self.ensure_vault_secret_key_result() {
1081            tracing::error!(error = %err, "vault secret key initialization failed");
1082        }
1083    }
1084
1085    pub(crate) fn ensure_vault_secret_key_result(&self) -> Result<(), AuthError> {
1086        let _guard = self.secret_key_init.lock().map_err(lock_err)?;
1087        if self.vault_secret_key().is_some() {
1088            return Ok(());
1089        }
1090        if self.vault_kv_get("red.secret.aes_key").is_some() {
1091            return Err(AuthError::Internal(
1092                "stored vault secret key is invalid".into(),
1093            ));
1094        }
1095        let value = hex::encode(random_bytes(32));
1096        if self.is_vault_backed() {
1097            self.vault_kv_try_set("red.secret.aes_key".into(), value)
1098        } else {
1099            self.vault_kv
1100                .write()
1101                .map_err(lock_err)?
1102                .insert("red.secret.aes_key".into(), value);
1103            Ok(())
1104        }
1105    }
1106
1107    /// Take a snapshot of the current auth state for vault serialization.
1108    fn snapshot(&self) -> VaultState {
1109        let users_guard = self.users.read().unwrap_or_else(|e| e.into_inner());
1110        let users: Vec<User> = users_guard.values().cloned().collect();
1111
1112        // Collect (owner UserId, api_key) pairs from all users so a
1113        // tenant-scoped owner can be reattached on restore.
1114        let mut api_keys = Vec::new();
1115        for user in &users {
1116            let owner = UserId::from_parts(user.tenant_id.as_deref(), &user.username);
1117            for key in &user.api_keys {
1118                api_keys.push((owner.clone(), key.clone()));
1119            }
1120        }
1121
1122        // Include the master secret if a keypair is loaded.
1123        let master_secret = self
1124            .keypair
1125            .read()
1126            .ok()
1127            .and_then(|guard| guard.as_ref().map(|kp| kp.master_secret.clone()));
1128
1129        let kv = self.vault_kv.read().map(|m| m.clone()).unwrap_or_default();
1130
1131        VaultState {
1132            users,
1133            api_keys,
1134            bootstrapped: self.bootstrapped.load(Ordering::Acquire),
1135            master_secret,
1136            kv,
1137        }
1138    }
1139
1140    /// Restore the in-memory auth state from a vault snapshot.
1141    fn restore_from_vault(&mut self, state: VaultState) {
1142        // Restore bootstrap seal.
1143        if state.bootstrapped {
1144            self.bootstrapped.store(true, Ordering::Release);
1145        }
1146
1147        // Restore keypair from master secret (if present).
1148        if let Some(secret) = state.master_secret {
1149            let kp = KeyPair::from_master_secret(secret);
1150            if let Ok(mut guard) = self.keypair.write() {
1151                *guard = Some(kp);
1152            }
1153        }
1154
1155        // Restore KV store.
1156        if let Ok(mut kv) = self.vault_kv.write() {
1157            *kv = state.kv;
1158        }
1159
1160        // Restore users.
1161        let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
1162        let mut idx = self
1163            .api_key_index
1164            .write()
1165            .unwrap_or_else(|e| e.into_inner());
1166
1167        for mut user in state.users {
1168            let id = UserId::from_parts(user.tenant_id.as_deref(), &user.username);
1169            // A vault written before keys were hashed at rest holds the
1170            // secret itself. Hash it on the way in; the next persist
1171            // rewrites the line and the secret is gone from disk.
1172            for key in &mut user.api_keys {
1173                if !key.key.starts_with("rkh_") {
1174                    key.key = api_key_credential_id(&key.key);
1175                }
1176            }
1177            // Register API keys in the index.
1178            for key in &user.api_keys {
1179                idx.insert(key.key.clone(), (id.clone(), key.role));
1180            }
1181            users.insert(id, user);
1182        }
1183        drop(idx);
1184        drop(users);
1185
1186        self.rehydrate_acl();
1187        self.rehydrate_iam();
1188    }
1189
1190    // -----------------------------------------------------------------
1191    // User management
1192    // -----------------------------------------------------------------
1193
1194    /// Create a new platform-scoped user (`tenant_id = None`).
1195    ///
1196    /// For tenant-scoped creation, use [`Self::create_user_in_tenant`].
1197    pub fn create_user(
1198        &self,
1199        username: &str,
1200        password: &str,
1201        role: Role,
1202    ) -> Result<User, AuthError> {
1203        self.create_user_in_tenant(None, username, password, role)
1204    }
1205
1206    pub fn create_user_with_control_events(
1207        &self,
1208        username: &str,
1209        password: &str,
1210        role: Role,
1211        ctx: &ControlEventCtx<'_>,
1212        ledger: &dyn ControlEventLedger,
1213        config: ControlEventConfig,
1214    ) -> Result<User, AuthError> {
1215        self.create_user_in_tenant_with_control_events(
1216            None, username, password, role, ctx, ledger, config,
1217        )
1218    }
1219
1220    /// Create a user under the given tenant scope. `tenant_id == None`
1221    /// produces a platform-wide user. `(tenant, username)` is the
1222    /// uniqueness key — the same `username` may exist independently
1223    /// under multiple tenants.
1224    pub fn create_user_in_tenant(
1225        &self,
1226        tenant_id: Option<&str>,
1227        username: &str,
1228        password: &str,
1229        role: Role,
1230    ) -> Result<User, AuthError> {
1231        if let Some(configured) = self.configured_control_events() {
1232            let ctx = default_user_lifecycle_ctx();
1233            let control = UserLifecycleControl {
1234                ctx: &ctx,
1235                ledger: configured.ledger.as_ref(),
1236                config: configured.config,
1237            };
1238            self.create_user_in_tenant_controlled(tenant_id, username, password, role, &control)
1239        } else {
1240            self.create_user_in_tenant_unaudited(tenant_id, username, password, role)
1241        }
1242    }
1243
1244    #[allow(clippy::too_many_arguments)]
1245    pub fn create_user_in_tenant_with_control_events(
1246        &self,
1247        tenant_id: Option<&str>,
1248        username: &str,
1249        password: &str,
1250        role: Role,
1251        ctx: &ControlEventCtx<'_>,
1252        ledger: &dyn ControlEventLedger,
1253        config: ControlEventConfig,
1254    ) -> Result<User, AuthError> {
1255        let control = UserLifecycleControl {
1256            ctx,
1257            ledger,
1258            config,
1259        };
1260        self.create_user_in_tenant_controlled(tenant_id, username, password, role, &control)
1261    }
1262
1263    pub fn create_admin_user(
1264        &self,
1265        username: &str,
1266        password: &str,
1267        role: Role,
1268        tenant_id: Option<&str>,
1269    ) -> Result<User, AuthError> {
1270        if let Some(configured) = self.configured_control_events() {
1271            let ctx = default_user_lifecycle_ctx();
1272            let control = UserLifecycleControl {
1273                ctx: &ctx,
1274                ledger: configured.ledger.as_ref(),
1275                config: configured.config,
1276            };
1277            self.create_user_in_tenant_controlled(tenant_id, username, password, role, &control)
1278        } else {
1279            self.create_user_in_tenant_unaudited(tenant_id, username, password, role)
1280        }
1281    }
1282
1283    fn create_user_in_tenant_unaudited(
1284        &self,
1285        tenant_id: Option<&str>,
1286        username: &str,
1287        password: &str,
1288        role: Role,
1289    ) -> Result<User, AuthError> {
1290        let id = UserId::from_parts(tenant_id, username);
1291        let mut users = self.users.write().map_err(lock_err)?;
1292        if users.contains_key(&id) {
1293            return Err(AuthError::UserExists(id.to_string()));
1294        }
1295
1296        let now = now_ms();
1297        let user = User {
1298            username: username.to_string(),
1299            tenant_id: tenant_id.map(|s| s.to_string()),
1300            password_hash: hash_password(password),
1301            scram_verifier: Some(make_scram_verifier(password)),
1302            role,
1303            api_keys: Vec::new(),
1304            created_at: now,
1305            updated_at: now,
1306            enabled: true,
1307        };
1308        users.insert(id, user.clone());
1309        drop(users); // release lock before vault I/O
1310        self.persist_to_vault();
1311        Ok(user)
1312    }
1313
1314    fn create_user_in_tenant_controlled(
1315        &self,
1316        tenant_id: Option<&str>,
1317        username: &str,
1318        password: &str,
1319        role: Role,
1320        control: &UserLifecycleControl<'_>,
1321    ) -> Result<User, AuthError> {
1322        let id = UserId::from_parts(tenant_id, username);
1323        match self.create_user_in_tenant_unaudited(tenant_id, username, password, role) {
1324            Ok(user) => {
1325                if let Err(err) = self.emit_user_lifecycle_allowed(
1326                    control,
1327                    EventKind::UserCreate,
1328                    "user.create",
1329                    &id,
1330                    user_control_fields(&id, Some(role), Some(true), true),
1331                ) {
1332                    self.rollback_create_user(&id);
1333                    return Err(err);
1334                }
1335                Ok(user)
1336            }
1337            Err(err) => {
1338                self.emit_user_lifecycle_outcome(
1339                    control,
1340                    if user_error_is_denied(&err) {
1341                        Outcome::Denied
1342                    } else {
1343                        Outcome::Error
1344                    },
1345                    EventKind::UserCreate,
1346                    "user.create",
1347                    &id,
1348                    Some(err.to_string()),
1349                    user_control_fields(&id, Some(role), Some(true), true),
1350                );
1351                Err(err)
1352            }
1353        }
1354    }
1355
1356    /// Look up a user's SCRAM verifier by full `UserId`.
1357    ///
1358    /// The wire handshake passes the tenant resolved from the session
1359    /// (or `None` for the bootstrap admin) so cross-tenant collisions
1360    /// never authenticate the wrong identity.
1361    pub fn lookup_scram_verifier(&self, id: &UserId) -> Option<crate::auth::scram::ScramVerifier> {
1362        // Synthetic platform-owner principal must never authenticate.
1363        if crate::auth::self_lock_guard::is_synthetic_principal(&id.username) {
1364            return None;
1365        }
1366        let users = self.users.read().ok()?;
1367        // A disabled account keeps its verifier (re-enable restores it)
1368        // but must not authenticate; returning `None` here makes the
1369        // handshake use the dummy verifier, so timing stays uniform.
1370        users
1371            .get(id)
1372            .filter(|u| u.enabled)
1373            .and_then(|u| u.scram_verifier.clone())
1374    }
1375
1376    /// Backwards-compatible shim for the v2 wire bootstrap path: looks
1377    /// up a user by username assuming the platform (`tenant=None`)
1378    /// scope. Use this only where the handshake hasn't yet learned the
1379    /// caller's tenant.
1380    pub fn lookup_scram_verifier_global(
1381        &self,
1382        username: &str,
1383    ) -> Option<crate::auth::scram::ScramVerifier> {
1384        self.lookup_scram_verifier(&UserId::platform(username))
1385    }
1386
1387    /// Return all users (password hashes redacted).
1388    ///
1389    /// The synthetic [`crate::auth::self_lock_guard::PLATFORM_OWNER_USERNAME`]
1390    /// principal is filtered out — it is a policy-graph anchor, not an
1391    /// operator-visible account.
1392    pub fn list_users(&self) -> Vec<User> {
1393        let users = match self.users.read() {
1394            Ok(g) => g,
1395            Err(_) => return Vec::new(),
1396        };
1397        users
1398            .values()
1399            .filter(|u| !crate::auth::self_lock_guard::is_synthetic_principal(&u.username))
1400            .map(|u| User {
1401                password_hash: String::new(), // redacted
1402                ..u.clone()
1403            })
1404            .collect()
1405    }
1406
1407    /// Return users restricted to a tenant scope.
1408    ///
1409    /// `tenant_filter`:
1410    ///   - `None` listing in `Some(None)` — only platform users
1411    ///   - `Some(Some("acme"))` — only users in tenant `acme`
1412    ///   - `None` argument — all users (admin-only callers)
1413    pub fn list_users_scoped(&self, tenant_filter: Option<Option<&str>>) -> Vec<User> {
1414        let users = match self.users.read() {
1415            Ok(g) => g,
1416            Err(_) => return Vec::new(),
1417        };
1418        users
1419            .values()
1420            .filter(|u| match tenant_filter {
1421                None => true,
1422                Some(t) => u.tenant_id.as_deref() == t,
1423            })
1424            .filter(|u| !crate::auth::self_lock_guard::is_synthetic_principal(&u.username))
1425            .map(|u| User {
1426                password_hash: String::new(), // redacted
1427                ..u.clone()
1428            })
1429            .collect()
1430    }
1431
1432    /// Resource shape used by IAM policies that govern user lifecycle
1433    /// mutations. Platform users are addressed as `user:<username>`;
1434    /// tenant users are addressed as `user:tenant/<tenant>/<username>`
1435    /// by the existing policy resource qualifier.
1436    pub fn user_lifecycle_resource(uid: &UserId) -> ResourceRef {
1437        let resource = ResourceRef::new("user", uid.username.clone());
1438        match &uid.tenant {
1439            Some(tenant) => resource.with_tenant(tenant.clone()),
1440            None => resource,
1441        }
1442    }
1443
1444    pub fn eval_context_for_principal(
1445        &self,
1446        principal: &UserId,
1447        role: Role,
1448        current_tenant: Option<String>,
1449    ) -> EvalContext {
1450        EvalContext {
1451            principal_tenant: principal.tenant.clone(),
1452            current_tenant,
1453            peer_ip: None,
1454            mfa_present: false,
1455            now_ms: now_ms(),
1456            principal_is_admin_role: role == Role::Admin,
1457            principal_is_platform_scoped: principal.tenant.is_none(),
1458        }
1459    }
1460
1461    /// Policy gate for user lifecycle mutations.
1462    ///
1463    /// This is intentionally separate from the store mutation methods:
1464    /// bootstrap and manifest loading can still seed users directly,
1465    /// while public surfaces must authorize `user:*` before calling the
1466    /// mutator. Explicit Deny wins even in `LegacyRbac` mode.
1467    pub fn check_user_lifecycle_authz(
1468        &self,
1469        actor: &UserId,
1470        actor_role: Role,
1471        action: &str,
1472        target: &UserId,
1473    ) -> bool {
1474        let resource = Self::user_lifecycle_resource(target);
1475        let ctx = self.eval_context_for_principal(actor, actor_role, target.tenant.clone());
1476        self.check_policy_authz_with_role(actor, action, &resource, &ctx, actor_role)
1477    }
1478
1479    /// Returns `true` only when an IAM policy explicitly denies the
1480    /// action on the target. `DefaultDeny` (no matching policy) is
1481    /// treated as permitted, so this is safe to call after the caller
1482    /// has already verified the actor's role — it only blocks protected
1483    /// principals (e.g. the cloud-preset `red_admin` guardrail).
1484    pub fn has_explicit_user_lifecycle_deny(
1485        &self,
1486        actor: &UserId,
1487        actor_role: Role,
1488        action: &str,
1489        target: &UserId,
1490    ) -> bool {
1491        let resource = Self::user_lifecycle_resource(target);
1492        let ctx = self.eval_context_for_principal(actor, actor_role, target.tenant.clone());
1493        let pols = self.effective_policies(actor);
1494        let refs: Vec<&Policy> = pols.iter().map(|p| p.as_ref()).collect();
1495        matches!(
1496            iam_policies::evaluate(&refs, action, &resource, &ctx),
1497            iam_policies::Decision::Deny { .. }
1498        )
1499    }
1500
1501    pub fn get_user(&self, tenant_id: Option<&str>, username: &str) -> Option<User> {
1502        let id = UserId::from_parts(tenant_id, username);
1503        self.get_user_cloned(&id).map(|u| User {
1504            password_hash: String::new(),
1505            ..u
1506        })
1507    }
1508
1509    /// Delete a platform-scoped user (`tenant_id = None`) and revoke
1510    /// all of their API keys + sessions.
1511    ///
1512    /// For tenant-scoped deletes, use [`Self::delete_user_in_tenant`].
1513    pub fn delete_user(&self, username: &str) -> Result<(), AuthError> {
1514        self.delete_user_in_tenant(None, username)
1515    }
1516
1517    pub fn delete_user_with_control_events(
1518        &self,
1519        username: &str,
1520        ctx: &ControlEventCtx<'_>,
1521        ledger: &dyn ControlEventLedger,
1522        config: ControlEventConfig,
1523    ) -> Result<(), AuthError> {
1524        self.delete_user_in_tenant_with_control_events(None, username, ctx, ledger, config)
1525    }
1526
1527    /// Delete a user identified by `(tenant_id, username)` and revoke
1528    /// all of their API keys + sessions.
1529    pub fn delete_user_in_tenant(
1530        &self,
1531        tenant_id: Option<&str>,
1532        username: &str,
1533    ) -> Result<(), AuthError> {
1534        if let Some(configured) = self.configured_control_events() {
1535            let ctx = default_user_lifecycle_ctx();
1536            let control = UserLifecycleControl {
1537                ctx: &ctx,
1538                ledger: configured.ledger.as_ref(),
1539                config: configured.config,
1540            };
1541            self.delete_user_in_tenant_controlled(tenant_id, username, &control)
1542        } else {
1543            self.delete_user_in_tenant_unaudited(tenant_id, username)
1544        }
1545    }
1546
1547    pub fn delete_user_in_tenant_with_control_events(
1548        &self,
1549        tenant_id: Option<&str>,
1550        username: &str,
1551        ctx: &ControlEventCtx<'_>,
1552        ledger: &dyn ControlEventLedger,
1553        config: ControlEventConfig,
1554    ) -> Result<(), AuthError> {
1555        let control = UserLifecycleControl {
1556            ctx,
1557            ledger,
1558            config,
1559        };
1560        self.delete_user_in_tenant_controlled(tenant_id, username, &control)
1561    }
1562
1563    fn delete_user_in_tenant_unaudited(
1564        &self,
1565        tenant_id: Option<&str>,
1566        username: &str,
1567    ) -> Result<(), AuthError> {
1568        let id = UserId::from_parts(tenant_id, username);
1569        let mut users = self.users.write().map_err(lock_err)?;
1570        let user = users
1571            .remove(&id)
1572            .ok_or_else(|| AuthError::UserNotFound(id.to_string()))?;
1573
1574        // Remove API key index entries.
1575        if let Ok(mut idx) = self.api_key_index.write() {
1576            for api_key in &user.api_keys {
1577                idx.remove(&api_key.key);
1578            }
1579        }
1580
1581        // Remove sessions belonging to this user (match on tenant+username
1582        // so we don't tear down a same-named user in another tenant).
1583        if let Ok(mut sessions) = self.sessions.write() {
1584            sessions
1585                .retain(|_, s| !(s.username == username && s.tenant_id.as_deref() == tenant_id));
1586        }
1587
1588        self.persist_to_vault();
1589        Ok(())
1590    }
1591
1592    fn delete_user_in_tenant_controlled(
1593        &self,
1594        tenant_id: Option<&str>,
1595        username: &str,
1596        control: &UserLifecycleControl<'_>,
1597    ) -> Result<(), AuthError> {
1598        let id = UserId::from_parts(tenant_id, username);
1599        let rollback = self.user_delete_rollback_snapshot(&id, tenant_id, username);
1600        match self.delete_user_in_tenant_unaudited(tenant_id, username) {
1601            Ok(()) => {
1602                if let Err(err) = self.emit_user_lifecycle_allowed(
1603                    control,
1604                    EventKind::UserDelete,
1605                    "user.delete",
1606                    &id,
1607                    user_control_fields(&id, rollback.as_ref().map(|r| r.0.role), None, false),
1608                ) {
1609                    if let Some((user, sessions)) = rollback {
1610                        self.restore_deleted_user(&id, user, sessions);
1611                    }
1612                    return Err(err);
1613                }
1614                Ok(())
1615            }
1616            Err(err) => {
1617                self.emit_user_lifecycle_outcome(
1618                    control,
1619                    if user_error_is_denied(&err) {
1620                        Outcome::Denied
1621                    } else {
1622                        Outcome::Error
1623                    },
1624                    EventKind::UserDelete,
1625                    "user.delete",
1626                    &id,
1627                    Some(err.to_string()),
1628                    user_control_fields(&id, rollback.as_ref().map(|r| r.0.role), None, false),
1629                );
1630                Err(err)
1631            }
1632        }
1633    }
1634
1635    /// Change password (requires the old password). Defaults to
1636    /// platform tenant; use [`Self::change_password_in_tenant`] for
1637    /// scoped users.
1638    pub fn change_password(
1639        &self,
1640        username: &str,
1641        old_password: &str,
1642        new_password: &str,
1643    ) -> Result<(), AuthError> {
1644        self.change_password_in_tenant(None, username, old_password, new_password)
1645    }
1646
1647    pub fn change_password_with_control_events(
1648        &self,
1649        username: &str,
1650        old_password: &str,
1651        new_password: &str,
1652        ctx: &ControlEventCtx<'_>,
1653        ledger: &dyn ControlEventLedger,
1654        config: ControlEventConfig,
1655    ) -> Result<(), AuthError> {
1656        self.change_password_in_tenant_with_control_events(
1657            None,
1658            username,
1659            old_password,
1660            new_password,
1661            ctx,
1662            ledger,
1663            config,
1664        )
1665    }
1666
1667    #[allow(clippy::too_many_arguments)]
1668    pub fn change_password_in_tenant(
1669        &self,
1670        tenant_id: Option<&str>,
1671        username: &str,
1672        old_password: &str,
1673        new_password: &str,
1674    ) -> Result<(), AuthError> {
1675        if let Some(configured) = self.configured_control_events() {
1676            let ctx = default_user_lifecycle_ctx();
1677            let control = UserLifecycleControl {
1678                ctx: &ctx,
1679                ledger: configured.ledger.as_ref(),
1680                config: configured.config,
1681            };
1682            self.change_password_in_tenant_controlled(
1683                tenant_id,
1684                username,
1685                old_password,
1686                new_password,
1687                &control,
1688            )
1689        } else {
1690            self.change_password_in_tenant_unaudited(
1691                tenant_id,
1692                username,
1693                old_password,
1694                new_password,
1695            )
1696        }
1697    }
1698
1699    #[allow(clippy::too_many_arguments)]
1700    pub fn change_password_in_tenant_with_control_events(
1701        &self,
1702        tenant_id: Option<&str>,
1703        username: &str,
1704        old_password: &str,
1705        new_password: &str,
1706        ctx: &ControlEventCtx<'_>,
1707        ledger: &dyn ControlEventLedger,
1708        config: ControlEventConfig,
1709    ) -> Result<(), AuthError> {
1710        let control = UserLifecycleControl {
1711            ctx,
1712            ledger,
1713            config,
1714        };
1715        self.change_password_in_tenant_controlled(
1716            tenant_id,
1717            username,
1718            old_password,
1719            new_password,
1720            &control,
1721        )
1722    }
1723
1724    fn change_password_in_tenant_unaudited(
1725        &self,
1726        tenant_id: Option<&str>,
1727        username: &str,
1728        old_password: &str,
1729        new_password: &str,
1730    ) -> Result<(), AuthError> {
1731        let id = UserId::from_parts(tenant_id, username);
1732        let mut users = self.users.write().map_err(lock_err)?;
1733        let user = users
1734            .get_mut(&id)
1735            .ok_or_else(|| AuthError::UserNotFound(id.to_string()))?;
1736
1737        if !verify_password(old_password, &user.password_hash) {
1738            return Err(AuthError::InvalidCredentials);
1739        }
1740
1741        user.password_hash = hash_password(new_password);
1742        user.scram_verifier = Some(make_scram_verifier(new_password));
1743        user.updated_at = now_ms();
1744        drop(users); // release lock before vault I/O
1745        self.persist_to_vault();
1746        Ok(())
1747    }
1748
1749    /// Administrative password reset (`ALTER USER … PASSWORD '…'`): no
1750    /// old-password check. Rehashes, regenerates the SCRAM verifier and
1751    /// revokes every live session of the account so a rotated credential
1752    /// cannot keep an existing session alive. Callers gate authorization.
1753    pub fn set_password_by_admin(&self, id: &UserId, new_password: &str) -> Result<(), AuthError> {
1754        if new_password.is_empty() {
1755            return Err(AuthError::Forbidden("password cannot be empty".to_string()));
1756        }
1757        {
1758            let mut users = self.users.write().map_err(lock_err)?;
1759            let user = users
1760                .get_mut(id)
1761                .ok_or_else(|| AuthError::UserNotFound(id.to_string()))?;
1762            user.password_hash = hash_password(new_password);
1763            user.scram_verifier = Some(make_scram_verifier(new_password));
1764            user.updated_at = now_ms();
1765        }
1766        self.purge_sessions_for(id);
1767        self.persist_to_vault();
1768        Ok(())
1769    }
1770
1771    fn change_password_in_tenant_controlled(
1772        &self,
1773        tenant_id: Option<&str>,
1774        username: &str,
1775        old_password: &str,
1776        new_password: &str,
1777        control: &UserLifecycleControl<'_>,
1778    ) -> Result<(), AuthError> {
1779        let id = UserId::from_parts(tenant_id, username);
1780        let previous = self.get_user_cloned(&id);
1781        match self.change_password_in_tenant_unaudited(
1782            tenant_id,
1783            username,
1784            old_password,
1785            new_password,
1786        ) {
1787            Ok(()) => {
1788                if let Err(err) = self.emit_user_lifecycle_allowed(
1789                    control,
1790                    EventKind::UserUpdate,
1791                    "user.update",
1792                    &id,
1793                    user_control_fields(
1794                        &id,
1795                        previous.as_ref().map(|u| u.role),
1796                        previous.as_ref().map(|u| u.enabled),
1797                        true,
1798                    ),
1799                ) {
1800                    self.restore_user_snapshot(&id, previous);
1801                    return Err(err);
1802                }
1803                Ok(())
1804            }
1805            Err(err) => {
1806                self.emit_user_lifecycle_outcome(
1807                    control,
1808                    if user_error_is_denied(&err) {
1809                        Outcome::Denied
1810                    } else {
1811                        Outcome::Error
1812                    },
1813                    EventKind::UserUpdate,
1814                    "user.update",
1815                    &id,
1816                    Some(err.to_string()),
1817                    user_control_fields(
1818                        &id,
1819                        previous.as_ref().map(|u| u.role),
1820                        previous.as_ref().map(|u| u.enabled),
1821                        true,
1822                    ),
1823                );
1824                Err(err)
1825            }
1826        }
1827    }
1828
1829    /// Change a user's role (admin-only operation). Defaults to platform
1830    /// tenant; use [`Self::change_role_in_tenant`] for scoped users.
1831    pub fn change_role(&self, username: &str, new_role: Role) -> Result<(), AuthError> {
1832        self.change_role_in_tenant(None, username, new_role)
1833    }
1834
1835    pub fn change_role_in_tenant(
1836        &self,
1837        tenant_id: Option<&str>,
1838        username: &str,
1839        new_role: Role,
1840    ) -> Result<(), AuthError> {
1841        if let Some(configured) = self.configured_control_events() {
1842            let ctx = default_user_lifecycle_ctx();
1843            let control = UserLifecycleControl {
1844                ctx: &ctx,
1845                ledger: configured.ledger.as_ref(),
1846                config: configured.config,
1847            };
1848            self.change_role_in_tenant_controlled(tenant_id, username, new_role, &control)
1849        } else {
1850            self.change_role_in_tenant_unaudited(tenant_id, username, new_role)
1851        }
1852    }
1853
1854    pub fn change_role_in_tenant_with_control_events(
1855        &self,
1856        tenant_id: Option<&str>,
1857        username: &str,
1858        new_role: Role,
1859        ctx: &ControlEventCtx<'_>,
1860        ledger: &dyn ControlEventLedger,
1861        config: ControlEventConfig,
1862    ) -> Result<(), AuthError> {
1863        let control = UserLifecycleControl {
1864            ctx,
1865            ledger,
1866            config,
1867        };
1868        self.change_role_in_tenant_controlled(tenant_id, username, new_role, &control)
1869    }
1870
1871    fn change_role_in_tenant_unaudited(
1872        &self,
1873        tenant_id: Option<&str>,
1874        username: &str,
1875        new_role: Role,
1876    ) -> Result<(), AuthError> {
1877        let id = UserId::from_parts(tenant_id, username);
1878        let mut users = self.users.write().map_err(lock_err)?;
1879        let user = users
1880            .get_mut(&id)
1881            .ok_or_else(|| AuthError::UserNotFound(id.to_string()))?;
1882
1883        let prior_role = user.role;
1884        user.role = new_role;
1885        user.updated_at = now_ms();
1886
1887        // Issue #205 — promotion to Admin is an operator-grade event:
1888        // the new role grants destructive capabilities (DROP, ALTER,
1889        // GRANT) that an operator must observe out-of-band even when
1890        // the auth path itself is healthy.
1891        if new_role == Role::Admin && prior_role != Role::Admin {
1892            crate::telemetry::operator_event::OperatorEvent::AdminCapabilityGranted {
1893                granted_to: id.to_string(),
1894                capability: "Role::Admin".to_string(),
1895                granted_by: "auth_store::change_role".to_string(),
1896            }
1897            .emit_global();
1898        }
1899
1900        // Downgrade any API keys that now exceed the user's role.
1901        for key in &mut user.api_keys {
1902            if key.role > new_role {
1903                key.role = new_role;
1904            }
1905        }
1906
1907        // Update the api_key_index as well.
1908        if let Ok(mut idx) = self.api_key_index.write() {
1909            for key in &user.api_keys {
1910                if let Some(entry) = idx.get_mut(&key.key) {
1911                    entry.1 = key.role;
1912                }
1913            }
1914        }
1915
1916        // Sessions snapshot the role at login. A change in either direction
1917        // must not keep riding on a token minted under the old one — a
1918        // demotion would otherwise stay ineffective for the session TTL.
1919        if prior_role != new_role {
1920            self.purge_sessions_for(&id);
1921        }
1922
1923        self.persist_to_vault();
1924        Ok(())
1925    }
1926
1927    fn change_role_in_tenant_controlled(
1928        &self,
1929        tenant_id: Option<&str>,
1930        username: &str,
1931        new_role: Role,
1932        control: &UserLifecycleControl<'_>,
1933    ) -> Result<(), AuthError> {
1934        let id = UserId::from_parts(tenant_id, username);
1935        let previous = self.get_user_cloned(&id);
1936        match self.change_role_in_tenant_unaudited(tenant_id, username, new_role) {
1937            Ok(()) => {
1938                if let Err(err) = self.emit_user_lifecycle_allowed(
1939                    control,
1940                    EventKind::UserUpdate,
1941                    "user.update",
1942                    &id,
1943                    user_control_fields(
1944                        &id,
1945                        Some(new_role),
1946                        previous.as_ref().map(|u| u.enabled),
1947                        false,
1948                    ),
1949                ) {
1950                    self.restore_user_snapshot(&id, previous);
1951                    return Err(err);
1952                }
1953                Ok(())
1954            }
1955            Err(err) => {
1956                self.emit_user_lifecycle_outcome(
1957                    control,
1958                    if user_error_is_denied(&err) {
1959                        Outcome::Denied
1960                    } else {
1961                        Outcome::Error
1962                    },
1963                    EventKind::UserUpdate,
1964                    "user.update",
1965                    &id,
1966                    Some(err.to_string()),
1967                    user_control_fields(
1968                        &id,
1969                        Some(new_role),
1970                        previous.as_ref().map(|u| u.enabled),
1971                        false,
1972                    ),
1973                );
1974                Err(err)
1975            }
1976        }
1977    }
1978
1979    // -----------------------------------------------------------------
1980    // Authentication (login)
1981    // -----------------------------------------------------------------
1982
1983    /// Verify credentials for a platform-tenant user (`tenant_id = None`)
1984    /// and create a session. For tenant-scoped login use
1985    /// [`Self::authenticate_in_tenant`].
1986    ///
1987    /// When a keypair is available (certificate-based seal), session tokens
1988    /// are signed with the master secret so the server can verify they were
1989    /// genuinely issued by this vault instance.
1990    pub fn authenticate(&self, username: &str, password: &str) -> Result<Session, AuthError> {
1991        self.authenticate_in_tenant(None, username, password)
1992    }
1993
1994    /// Verify credentials for `(tenant_id, username, password)` and
1995    /// create a session. Tenant-aware: `alice@acme` and `alice@globex`
1996    /// authenticate independently.
1997    ///
1998    /// Every outcome is recorded in the control-event ledger when one is
1999    /// configured: login was the one credential decision with no chained
2000    /// trace, so a brute-force run or a login from a freshly re-enabled
2001    /// account was invisible to an audit of the store. Under a
2002    /// compliance-mode ledger a login that cannot be recorded is refused.
2003    pub fn authenticate_in_tenant(
2004        &self,
2005        tenant_id: Option<&str>,
2006        username: &str,
2007        password: &str,
2008    ) -> Result<Session, AuthError> {
2009        let id = UserId::from_parts(tenant_id, username);
2010        match self.authenticate_in_tenant_unaudited(tenant_id, username, password) {
2011            Ok(session) => {
2012                if let Err(err) = self.emit_auth_event(
2013                    EventKind::AuthLogin,
2014                    Outcome::Allowed,
2015                    &id,
2016                    Some(session.role),
2017                    None,
2018                ) {
2019                    if let Ok(mut sessions) = self.sessions.write() {
2020                        sessions.remove(&session.token);
2021                    }
2022                    return Err(err);
2023                }
2024                Ok(session)
2025            }
2026            Err(err) => {
2027                let outcome = if matches!(err, AuthError::InvalidCredentials) {
2028                    Outcome::Denied
2029                } else {
2030                    Outcome::Error
2031                };
2032                // Already refusing; a ledger failure cannot make it worse.
2033                let _ = self.emit_auth_event(
2034                    EventKind::AuthLoginFailed,
2035                    outcome,
2036                    &id,
2037                    None,
2038                    Some(&err.to_string()),
2039                );
2040                Err(err)
2041            }
2042        }
2043    }
2044
2045    /// Record one authentication decision. `Err` only when the ledger is
2046    /// configured to require persistence and refused the event.
2047    fn emit_auth_event(
2048        &self,
2049        kind: EventKind,
2050        outcome: Outcome,
2051        id: &UserId,
2052        role: Option<Role>,
2053        reason: Option<&str>,
2054    ) -> Result<(), AuthError> {
2055        let Some(configured) = self.configured_control_events() else {
2056            return Ok(());
2057        };
2058        let ctx = ControlEventCtx {
2059            actor: crate::runtime::control_events::ActorRef::User(id),
2060            scope: id.tenant.as_deref().map(Cow::Borrowed),
2061            request_id: None,
2062            trace_id: None,
2063        };
2064        let mut fields: HashMap<String, Sensitivity> = HashMap::new();
2065        fields.insert("principal".to_string(), Sensitivity::raw(id.to_string()));
2066        if let Some(role) = role {
2067            fields.insert("role".to_string(), Sensitivity::raw(role.as_str()));
2068        }
2069        let event = ControlEvent {
2070            kind,
2071            outcome,
2072            action: Cow::Borrowed(kind.as_str()),
2073            resource: Some(format!("principal:{id}")),
2074            reason: reason.map(str::to_string),
2075            matched_policy_id: None,
2076            fields,
2077        };
2078        match configured.ledger.emit(&ctx, event) {
2079            Ok(_) => Ok(()),
2080            Err(err) if configured.config.require_persistence() => Err(AuthError::Internal(
2081                format!("control-event ledger refused {}: {err}", kind.as_str()),
2082            )),
2083            Err(err) => {
2084                tracing::warn!(
2085                    target: "reddb::security",
2086                    kind = kind.as_str(),
2087                    error = %err,
2088                    "control-event ledger refused an auth event"
2089                );
2090                Ok(())
2091            }
2092        }
2093    }
2094
2095    fn authenticate_in_tenant_unaudited(
2096        &self,
2097        tenant_id: Option<&str>,
2098        username: &str,
2099        password: &str,
2100    ) -> Result<Session, AuthError> {
2101        // The synthetic platform-owner principal exists only as a
2102        // policy-graph anchor (see `crate::auth::self_lock_guard`); it
2103        // must never authenticate via any transport.
2104        if crate::auth::self_lock_guard::is_synthetic_principal(username) {
2105            return Err(AuthError::InvalidCredentials);
2106        }
2107        let id = UserId::from_parts(tenant_id, username);
2108        let users = self.users.read().map_err(lock_err)?;
2109        let user = users.get(&id).ok_or(AuthError::InvalidCredentials)?;
2110
2111        if !user.enabled {
2112            return Err(AuthError::InvalidCredentials);
2113        }
2114
2115        let stored_hash = user.password_hash.clone();
2116        if !verify_password(password, &stored_hash) {
2117            return Err(AuthError::InvalidCredentials);
2118        }
2119
2120        // Generate token: signed if keypair is available, random otherwise.
2121        let token = match self.keypair.read().ok().and_then(|g| {
2122            g.as_ref().map(|kp| {
2123                let token_id = random_hex(16);
2124                let sig = kp.sign(format!("session:{}", token_id).as_bytes());
2125                // Take first 16 bytes of signature for compact token.
2126                format!("rs_{}{}", token_id, hex::encode(&sig[..16]))
2127            })
2128        }) {
2129            Some(signed_token) => signed_token,
2130            None => generate_session_token(),
2131        };
2132
2133        let now = now_ms();
2134        let session = Session {
2135            token,
2136            username: username.to_string(),
2137            tenant_id: user.tenant_id.clone(),
2138            role: user.role,
2139            created_at: now,
2140            expires_at: now + (self.config.session_ttl_secs as u128 * 1000),
2141        };
2142
2143        drop(users); // release read lock before acquiring write
2144
2145        let mut sessions = self.sessions.write().map_err(lock_err)?;
2146        sessions.insert(session.token.clone(), session.clone());
2147        drop(sessions);
2148
2149        // Transparently upgrade a hash produced by the retired in-tree KDF.
2150        // This is the only point where the verified plaintext is in hand.
2151        if password_hash_needs_upgrade(&stored_hash) {
2152            let upgraded = hash_password(password);
2153            let mut rehashed = false;
2154            if let Ok(mut users) = self.users.write() {
2155                if let Some(user) = users.get_mut(&id) {
2156                    // Skip if a concurrent password change already moved it.
2157                    if user.password_hash == stored_hash {
2158                        user.password_hash = upgraded;
2159                        rehashed = true;
2160                    }
2161                }
2162            }
2163            if rehashed {
2164                self.persist_to_vault();
2165            }
2166        }
2167
2168        Ok(session)
2169    }
2170
2171    // -----------------------------------------------------------------
2172    // Token validation
2173    // -----------------------------------------------------------------
2174
2175    /// Validate a token (session or API key).
2176    ///
2177    /// Returns `(username, role)` if valid, `None` otherwise. Tenant
2178    /// scope is dropped here for compatibility with the bulk of the
2179    /// existing caller surface (routing, gRPC control, redwire). Use
2180    /// [`Self::validate_token_full`] when the caller needs the
2181    /// resolved `UserId` (e.g. to pin `CURRENT_TENANT()`).
2182    pub fn validate_token(&self, token: &str) -> Option<(String, Role)> {
2183        self.validate_token_full(token)
2184            .map(|(id, role)| (id.username, role))
2185    }
2186
2187    /// Tenant-aware token validation. Returns the resolved `UserId`
2188    /// (which carries the tenant) and the granted `Role`.
2189    pub fn validate_token_full(&self, token: &str) -> Option<(UserId, Role)> {
2190        let resolved = self.resolve_token_unchecked(token)?;
2191        // Every credential is re-checked against the live user record so
2192        // `ALTER USER … DISABLE` / `VALID UNTIL` take effect immediately for
2193        // sessions and API keys, not only at password login.
2194        if !self.principal_is_active(&resolved.0) {
2195            return None;
2196        }
2197        Some(resolved)
2198    }
2199
2200    /// Token → `(UserId, Role)` without consulting the owning user record.
2201    fn resolve_token_unchecked(&self, token: &str) -> Option<(UserId, Role)> {
2202        // Try session tokens first.
2203        if token.starts_with("rs_") {
2204            if let Ok(sessions) = self.sessions.read() {
2205                if let Some(session) = sessions.get(token) {
2206                    let now = now_ms();
2207                    if now < session.expires_at {
2208                        return Some((
2209                            UserId::from_parts(session.tenant_id.as_deref(), &session.username),
2210                            session.role,
2211                        ));
2212                    }
2213                }
2214            }
2215            return None;
2216        }
2217
2218        // Try API keys: the index is keyed by the credential id, so the
2219        // presented secret is hashed and never compared as text.
2220        if token.starts_with("rk_") {
2221            let id = api_key_credential_id(token);
2222            let (owner, role) = self
2223                .api_key_index
2224                .read()
2225                .ok()
2226                .and_then(|idx| idx.get(&id).cloned())?;
2227            if self.api_key_expired(&owner, &id) {
2228                return None;
2229            }
2230            return Some((owner, role));
2231        }
2232
2233        None
2234    }
2235
2236    /// Whether the key `id` owned by `owner` has passed its `expires_at`.
2237    /// A key the owner no longer lists counts as expired.
2238    fn api_key_expired(&self, owner: &UserId, id: &str) -> bool {
2239        let Ok(users) = self.users.read() else {
2240            return true;
2241        };
2242        users
2243            .get(owner)
2244            .and_then(|user| user.api_keys.iter().find(|key| key.key == id))
2245            .map(|key| key.expires_at.is_some_and(|deadline| now_ms() >= deadline))
2246            .unwrap_or(true)
2247    }
2248
2249    /// The current role of `id` when the account is active, else `None`.
2250    ///
2251    /// Callers holding a long-lived credential (the browser refresh cookie)
2252    /// use this to re-derive the principal's role at use time rather than
2253    /// trusting a role captured when the credential was minted.
2254    pub fn active_user_role(&self, id: &UserId) -> Option<Role> {
2255        if !self.principal_is_active(id) {
2256            return None;
2257        }
2258        self.users.read().ok()?.get(id).map(|user| user.role)
2259    }
2260
2261    /// Whether `id` names an enabled account whose `VALID UNTIL` (if any)
2262    /// has not passed. Unknown accounts are inactive.
2263    fn principal_is_active(&self, id: &UserId) -> bool {
2264        let enabled = match self.users.read() {
2265            Ok(users) => users.get(id).map(|u| u.enabled).unwrap_or(false),
2266            Err(_) => false,
2267        };
2268        if !enabled {
2269            return false;
2270        }
2271        match self.user_attributes(id).valid_until {
2272            Some(deadline) => now_ms() < deadline,
2273            None => true,
2274        }
2275    }
2276
2277    // -----------------------------------------------------------------
2278    // API Key management
2279    // -----------------------------------------------------------------
2280
2281    /// Create a persistent API key for a platform-tenant user.
2282    ///
2283    /// For tenant-scoped users use [`Self::create_api_key_in_tenant`].
2284    pub fn create_api_key(
2285        &self,
2286        username: &str,
2287        name: &str,
2288        role: Role,
2289    ) -> Result<ApiKey, AuthError> {
2290        self.create_api_key_in_tenant(None, username, name, role)
2291    }
2292
2293    pub fn create_api_key_with_control_events(
2294        &self,
2295        username: &str,
2296        name: &str,
2297        role: Role,
2298        ctx: &ControlEventCtx<'_>,
2299        ledger: &dyn ControlEventLedger,
2300        config: ControlEventConfig,
2301    ) -> Result<ApiKey, AuthError> {
2302        self.create_api_key_in_tenant_with_control_events(
2303            None, username, name, role, ctx, ledger, config,
2304        )
2305    }
2306
2307    pub fn create_api_key_in_tenant(
2308        &self,
2309        tenant_id: Option<&str>,
2310        username: &str,
2311        name: &str,
2312        role: Role,
2313    ) -> Result<ApiKey, AuthError> {
2314        self.create_api_key_in_tenant_expiring(tenant_id, username, name, role, None)
2315    }
2316
2317    /// Create an API key that stops validating at `expires_at` (ms since
2318    /// epoch). The returned value carries the secret; nothing else does.
2319    pub fn create_api_key_in_tenant_expiring(
2320        &self,
2321        tenant_id: Option<&str>,
2322        username: &str,
2323        name: &str,
2324        role: Role,
2325        expires_at: Option<u128>,
2326    ) -> Result<ApiKey, AuthError> {
2327        if let Some(configured) = self.configured_control_events() {
2328            let ctx = default_user_lifecycle_ctx();
2329            let control = UserLifecycleControl {
2330                ctx: &ctx,
2331                ledger: configured.ledger.as_ref(),
2332                config: configured.config,
2333            };
2334            self.create_api_key_in_tenant_controlled(
2335                tenant_id, username, name, role, expires_at, &control,
2336            )
2337        } else {
2338            self.create_api_key_in_tenant_unaudited(tenant_id, username, name, role, expires_at)
2339        }
2340    }
2341
2342    #[allow(clippy::too_many_arguments)]
2343    pub fn create_api_key_in_tenant_with_control_events(
2344        &self,
2345        tenant_id: Option<&str>,
2346        username: &str,
2347        name: &str,
2348        role: Role,
2349        ctx: &ControlEventCtx<'_>,
2350        ledger: &dyn ControlEventLedger,
2351        config: ControlEventConfig,
2352    ) -> Result<ApiKey, AuthError> {
2353        let control = UserLifecycleControl {
2354            ctx,
2355            ledger,
2356            config,
2357        };
2358        self.create_api_key_in_tenant_controlled(tenant_id, username, name, role, None, &control)
2359    }
2360
2361    fn create_api_key_in_tenant_unaudited(
2362        &self,
2363        tenant_id: Option<&str>,
2364        username: &str,
2365        name: &str,
2366        role: Role,
2367        expires_at: Option<u128>,
2368    ) -> Result<ApiKey, AuthError> {
2369        let id = UserId::from_parts(tenant_id, username);
2370        let mut users = self.users.write().map_err(lock_err)?;
2371        let user = users
2372            .get_mut(&id)
2373            .ok_or_else(|| AuthError::UserNotFound(id.to_string()))?;
2374
2375        // The key's role cannot exceed the user's role.
2376        if role > user.role {
2377            return Err(AuthError::RoleExceeded {
2378                requested: role,
2379                ceiling: user.role,
2380            });
2381        }
2382
2383        let secret = generate_api_key();
2384        let stored = ApiKey {
2385            key: api_key_credential_id(&secret),
2386            name: name.to_string(),
2387            role,
2388            created_at: now_ms(),
2389            expires_at,
2390        };
2391
2392        user.api_keys.push(stored.clone());
2393        user.updated_at = now_ms();
2394
2395        // Update the index.
2396        if let Ok(mut idx) = self.api_key_index.write() {
2397            idx.insert(stored.key.clone(), (id.clone(), stored.role));
2398        }
2399
2400        drop(users); // release lock before vault I/O
2401        self.persist_to_vault();
2402        // The secret leaves the store exactly once, here.
2403        Ok(ApiKey {
2404            key: secret,
2405            ..stored
2406        })
2407    }
2408
2409    fn create_api_key_in_tenant_controlled(
2410        &self,
2411        tenant_id: Option<&str>,
2412        username: &str,
2413        name: &str,
2414        role: Role,
2415        expires_at: Option<u128>,
2416        control: &UserLifecycleControl<'_>,
2417    ) -> Result<ApiKey, AuthError> {
2418        let id = UserId::from_parts(tenant_id, username);
2419        match self.create_api_key_in_tenant_unaudited(tenant_id, username, name, role, expires_at) {
2420            Ok(api_key) => {
2421                let key_id = api_key_id(&api_key.key);
2422                if let Err(err) = self.emit_api_key_allowed(
2423                    control,
2424                    EventKind::ApiKeyCreate,
2425                    "apikey.create",
2426                    &key_id,
2427                    api_key_control_fields(&id, role, &key_id),
2428                ) {
2429                    self.rollback_create_api_key(&id, &api_key.key);
2430                    return Err(err);
2431                }
2432                Ok(api_key)
2433            }
2434            Err(err) => {
2435                self.emit_user_lifecycle_outcome(
2436                    control,
2437                    if user_error_is_denied(&err) {
2438                        Outcome::Denied
2439                    } else {
2440                        Outcome::Error
2441                    },
2442                    EventKind::ApiKeyCreate,
2443                    "apikey.create",
2444                    &id,
2445                    Some(err.to_string()),
2446                    user_control_fields(&id, Some(role), None, false),
2447                );
2448                Err(err)
2449            }
2450        }
2451    }
2452
2453    /// Revoke (delete) an API key.
2454    pub fn revoke_api_key(&self, key: &str) -> Result<(), AuthError> {
2455        if let Some(configured) = self.configured_control_events() {
2456            let ctx = default_user_lifecycle_ctx();
2457            let control = UserLifecycleControl {
2458                ctx: &ctx,
2459                ledger: configured.ledger.as_ref(),
2460                config: configured.config,
2461            };
2462            self.revoke_api_key_controlled(key, &control)
2463        } else {
2464            self.revoke_api_key_unaudited(key)
2465        }
2466    }
2467
2468    pub fn revoke_api_key_with_control_events(
2469        &self,
2470        key: &str,
2471        ctx: &ControlEventCtx<'_>,
2472        ledger: &dyn ControlEventLedger,
2473        config: ControlEventConfig,
2474    ) -> Result<(), AuthError> {
2475        let control = UserLifecycleControl {
2476            ctx,
2477            ledger,
2478            config,
2479        };
2480        self.revoke_api_key_controlled(key, &control)
2481    }
2482
2483    fn revoke_api_key_unaudited(&self, key: &str) -> Result<(), AuthError> {
2484        let key_id = api_key_credential_id(key);
2485        let key = key_id.as_str();
2486        let mut users = self.users.write().map_err(lock_err)?;
2487
2488        // Find which user owns this key (look up by the api_key_index
2489        // first; fall back to a scan for legacy state restored before
2490        // the index was reseeded).
2491        let owner_id: UserId = {
2492            if let Ok(idx) = self.api_key_index.read() {
2493                if let Some((id, _)) = idx.get(key) {
2494                    id.clone()
2495                } else {
2496                    return Err(AuthError::KeyNotFound(key.to_string()));
2497                }
2498            } else {
2499                let owner = users
2500                    .iter()
2501                    .find(|(_, u)| u.api_keys.iter().any(|k| k.key == key));
2502                match owner {
2503                    Some((id, _)) => id.clone(),
2504                    None => return Err(AuthError::KeyNotFound(key.to_string())),
2505                }
2506            }
2507        };
2508
2509        let user = users
2510            .get_mut(&owner_id)
2511            .ok_or_else(|| AuthError::KeyNotFound(key.to_string()))?;
2512        user.api_keys.retain(|k| k.key != key);
2513        user.updated_at = now_ms();
2514
2515        // Remove from index.
2516        if let Ok(mut idx) = self.api_key_index.write() {
2517            idx.remove(key);
2518        }
2519
2520        self.persist_to_vault();
2521        Ok(())
2522    }
2523
2524    fn revoke_api_key_controlled(
2525        &self,
2526        key: &str,
2527        control: &UserLifecycleControl<'_>,
2528    ) -> Result<(), AuthError> {
2529        let key_id = api_key_id(key);
2530        let rollback = self.api_key_rollback_snapshot(key);
2531        let id = rollback
2532            .as_ref()
2533            .map(|r| r.0.clone())
2534            .unwrap_or_else(|| UserId::from_parts(None, ""));
2535        let role = rollback.as_ref().map(|r| r.1.role).unwrap_or(Role::Read);
2536        match self.revoke_api_key_unaudited(key) {
2537            Ok(()) => {
2538                if let Err(err) = self.emit_api_key_allowed(
2539                    control,
2540                    EventKind::ApiKeyRevoke,
2541                    "apikey.revoke",
2542                    &key_id,
2543                    api_key_control_fields(&id, role, &key_id),
2544                ) {
2545                    if let Some((owner, api_key)) = rollback {
2546                        self.restore_api_key(&owner, api_key);
2547                    }
2548                    return Err(err);
2549                }
2550                Ok(())
2551            }
2552            Err(err) => {
2553                self.emit_user_lifecycle_outcome(
2554                    control,
2555                    if user_error_is_denied(&err) {
2556                        Outcome::Denied
2557                    } else {
2558                        Outcome::Error
2559                    },
2560                    EventKind::ApiKeyRevoke,
2561                    "apikey.revoke",
2562                    &id,
2563                    Some(err.to_string()),
2564                    api_key_control_fields(&id, role, &key_id),
2565                );
2566                Err(err)
2567            }
2568        }
2569    }
2570
2571    // -----------------------------------------------------------------
2572    // Session management
2573    // -----------------------------------------------------------------
2574
2575    /// Revoke a session token.
2576    pub fn revoke_session(&self, token: &str) {
2577        if let Ok(mut sessions) = self.sessions.write() {
2578            sessions.remove(token);
2579        }
2580    }
2581
2582    /// Purge expired sessions (housekeeping).
2583    pub fn purge_expired_sessions(&self) -> usize {
2584        let now = now_ms();
2585        if let Ok(mut sessions) = self.sessions.write() {
2586            let before = sessions.len();
2587            sessions.retain(|_, s| s.expires_at > now);
2588            return before - sessions.len();
2589        }
2590        0
2591    }
2592
2593    // -----------------------------------------------------------------
2594    // Granular RBAC — GRANT / REVOKE
2595    //
2596    // The privilege engine lives in `super::privileges`. These helpers
2597    // are the AuthStore facade: they keep an in-memory map of grants per
2598    // user (plus a `public_grants` list), persist additions/removals to
2599    // the existing `vault_kv` store, and rebuild the per-user
2600    // `PermissionCache` so the hot path stays O(1).
2601    //
2602    // Persistence design: rather than extend the snapshot/restore
2603    // pipeline (Agent #2's territory) we serialise grants and account
2604    // attributes to the vault KV store. That gives us atomic write +
2605    // encrypted-at-rest semantics for free without touching the
2606    // existing USER/KEY/KV serializer paths. On restart `rehydrate_acl`
2607    // reads these KV entries back into the in-memory maps.
2608    // -----------------------------------------------------------------
2609
2610    /// Persist a grant. Returns `Forbidden` when the granting user is
2611    /// not Admin or attempts a cross-tenant grant.
2612    pub fn grant(
2613        &self,
2614        granter: &UserId,
2615        granter_role: Role,
2616        principal: GrantPrincipal,
2617        resource: Resource,
2618        actions: Vec<Action>,
2619        with_grant_option: bool,
2620        tenant: Option<String>,
2621    ) -> Result<(), AuthError> {
2622        if granter_role != Role::Admin {
2623            return Err(AuthError::Forbidden(format!(
2624                "GRANT requires Admin role; granter `{}` has `{:?}`",
2625                granter, granter_role
2626            )));
2627        }
2628
2629        // Cross-tenant guard: a tenant-scoped admin cannot mint grants
2630        // outside their tenant. Platform admin (tenant=None) may grant
2631        // anywhere.
2632        if granter.tenant.is_some() && granter.tenant != tenant {
2633            return Err(AuthError::Forbidden(format!(
2634                "cross-tenant GRANT denied: granter tenant `{:?}` != grant tenant `{:?}`",
2635                granter.tenant, tenant
2636            )));
2637        }
2638
2639        let mut actions_set = std::collections::BTreeSet::new();
2640        for a in actions {
2641            actions_set.insert(a);
2642        }
2643        let g = Grant {
2644            principal: principal.clone(),
2645            resource,
2646            actions: actions_set,
2647            with_grant_option,
2648            granted_by: granter.to_string(),
2649            granted_at: now_ms(),
2650            tenant,
2651            columns: None,
2652        };
2653
2654        match &principal {
2655            GrantPrincipal::User(uid) => {
2656                self.grants
2657                    .write()
2658                    .unwrap_or_else(|e| e.into_inner())
2659                    .entry(uid.clone())
2660                    .or_default()
2661                    .push(g.clone());
2662                self.invalidate_permission_cache(Some(uid));
2663            }
2664            GrantPrincipal::Public => {
2665                self.public_grants
2666                    .write()
2667                    .unwrap_or_else(|e| e.into_inner())
2668                    .push(g.clone());
2669                self.invalidate_permission_cache(None);
2670            }
2671            GrantPrincipal::Group(_) => {
2672                return Err(AuthError::Forbidden(
2673                    "GROUP principals are not yet supported; use a USER or PUBLIC".to_string(),
2674                ));
2675            }
2676        }
2677
2678        // Issue #119: a fresh grant changes the visible-collections set
2679        // for `(tenant, role)` callers under the same tenant. Drop those
2680        // cache entries so the next AI command sees the new SELECT
2681        // privilege immediately.
2682        self.invalidate_visible_collections_for_tenant(g.tenant.as_deref());
2683
2684        self.persist_acl_to_kv();
2685        Ok(())
2686    }
2687
2688    /// Drop matching grants from a principal. Returns the number of
2689    /// grants removed.
2690    pub fn revoke(
2691        &self,
2692        granter: &UserId,
2693        granter_role: Role,
2694        principal: &GrantPrincipal,
2695        resource: &Resource,
2696        actions: &[Action],
2697    ) -> Result<usize, AuthError> {
2698        if granter_role != Role::Admin {
2699            return Err(AuthError::Forbidden(format!(
2700                "REVOKE requires Admin role; granter `{}` has `{:?}`",
2701                granter, granter_role
2702            )));
2703        }
2704
2705        // Cross-tenant guard, mirroring GRANT: a tenant-scoped admin can only
2706        // touch grants of principals in its own tenant. The target tenant
2707        // comes from the statement text, so without this a tenant admin could
2708        // strip another tenant's grants. Platform admin (tenant=None) may
2709        // revoke anywhere.
2710        if let GrantPrincipal::User(uid) = principal {
2711            if granter.tenant.is_some() && granter.tenant != uid.tenant {
2712                return Err(AuthError::Forbidden(format!(
2713                    "cross-tenant REVOKE denied: granter tenant `{:?}` != principal tenant `{:?}`",
2714                    granter.tenant, uid.tenant
2715                )));
2716            }
2717        }
2718
2719        let removed = match principal {
2720            GrantPrincipal::User(uid) => {
2721                let mut g = self.grants.write().unwrap_or_else(|e| e.into_inner());
2722                let before = g.get(uid).map(|v| v.len()).unwrap_or(0);
2723                if let Some(list) = g.get_mut(uid) {
2724                    list.retain(|gr| {
2725                        !(gr.resource == *resource
2726                            && (actions.iter().any(|a| gr.actions.contains(a))
2727                                || (gr.actions.contains(&Action::All) && !actions.is_empty())))
2728                    });
2729                }
2730                let after = g.get(uid).map(|v| v.len()).unwrap_or(0);
2731                drop(g);
2732                self.invalidate_permission_cache(Some(uid));
2733                before - after
2734            }
2735            GrantPrincipal::Public => {
2736                let mut p = self
2737                    .public_grants
2738                    .write()
2739                    .unwrap_or_else(|e| e.into_inner());
2740                let before = p.len();
2741                p.retain(|gr| {
2742                    !(gr.resource == *resource
2743                        && (actions.iter().any(|a| gr.actions.contains(a))
2744                            || (gr.actions.contains(&Action::All) && !actions.is_empty())))
2745                });
2746                let after = p.len();
2747                drop(p);
2748                self.invalidate_permission_cache(None);
2749                before - after
2750            }
2751            GrantPrincipal::Group(_) => 0,
2752        };
2753
2754        if removed > 0 {
2755            // Issue #119: REVOKE may shrink the visible-collections set
2756            // for any `(tenant, role)` slot. We don't know the exact
2757            // tenant when the principal is `Public`, so a `Public`
2758            // revoke clears the whole cache; user revokes scope to the
2759            // user's tenant.
2760            match principal {
2761                GrantPrincipal::User(uid) => {
2762                    self.invalidate_visible_collections_for_tenant(uid.tenant.as_deref());
2763                }
2764                GrantPrincipal::Public | GrantPrincipal::Group(_) => {
2765                    self.invalidate_visible_collections_cache();
2766                }
2767            }
2768            self.persist_acl_to_kv();
2769        }
2770        Ok(removed)
2771    }
2772
2773    /// Compute the set of collection ids a given `(tenant, role)`
2774    /// scope can read, consulting the explicit grant table. The result
2775    /// is cached for `super::scope_cache::DEFAULT_TTL` (60s) and
2776    /// invalidated on every GRANT/REVOKE/policy/collection mutation
2777    /// that could change the answer.
2778    ///
2779    /// `all_collections` is the full list of collection ids known to
2780    /// the storage layer. The runtime hands it in so this module stays
2781    /// decoupled from the storage crate. Each collection passes through
2782    /// `check_grant(SELECT)` under a synthetic `(principal, role,
2783    /// tenant)` view. The cache key includes principal because direct
2784    /// grants can differ between users that share the same tenant and
2785    /// role.
2786    pub fn visible_collections_for_scope(
2787        &self,
2788        tenant: Option<&str>,
2789        role: Role,
2790        principal: &str,
2791        all_collections: &[String],
2792    ) -> std::collections::HashSet<String> {
2793        let key = super::scope_cache::ScopeKey::new(tenant, principal, role);
2794        if let Some(hit) = self.visible_collections_cache.get(&key) {
2795            return hit;
2796        }
2797        // Slow path: walk every collection through `check_grant`. We
2798        // build the AuthzContext once, then reuse it per resource.
2799        let ctx = AuthzContext {
2800            principal,
2801            effective_role: role,
2802            tenant,
2803        };
2804        let mut visible = std::collections::HashSet::new();
2805        for collection in all_collections {
2806            let resource = Resource::table_from_name(collection);
2807            if self.check_grant(&ctx, Action::Select, &resource).is_ok() {
2808                visible.insert(collection.clone());
2809            }
2810        }
2811        self.visible_collections_cache.insert(key, visible.clone());
2812        visible
2813    }
2814
2815    /// Stats probe required by issue #119 — exposes hit/miss counts and
2816    /// invalidations for the visible-collections cache so metrics
2817    /// pipelines can compute a hit rate.
2818    pub fn auth_cache_stats(&self) -> super::scope_cache::AuthCacheStats {
2819        self.visible_collections_cache.stats()
2820    }
2821
2822    /// Drop every cached `(tenant, role)` entry. Called from CREATE
2823    /// POLICY / DROP POLICY / DROP COLLECTION paths where the affected
2824    /// tenant set is unknown.
2825    pub fn invalidate_visible_collections_cache(&self) {
2826        self.visible_collections_cache.invalidate_all();
2827    }
2828
2829    /// Drop cached entries for one tenant. Called from GRANT / REVOKE
2830    /// where the principal's tenant is known.
2831    pub fn invalidate_visible_collections_for_tenant(&self, tenant: Option<&str>) {
2832        self.visible_collections_cache.invalidate_tenant(tenant);
2833    }
2834
2835    /// Snapshot of every grant the principal effectively has, including
2836    /// `Public` grants. Audit / introspection helper.
2837    pub fn effective_grants(&self, uid: &UserId) -> Vec<Grant> {
2838        let mut out = Vec::new();
2839        if let Ok(g) = self.grants.read() {
2840            if let Some(list) = g.get(uid) {
2841                out.extend(list.iter().cloned());
2842            }
2843        }
2844        if let Ok(p) = self.public_grants.read() {
2845            out.extend(p.iter().cloned());
2846        }
2847        out
2848    }
2849
2850    /// Run a privilege check using the in-memory grant tables. Returns
2851    /// `Ok(())` on allow, `Err(AuthzError)` on deny.
2852    pub fn check_grant(
2853        &self,
2854        ctx: &AuthzContext<'_>,
2855        action: Action,
2856        resource: &Resource,
2857    ) -> Result<(), AuthzError> {
2858        if ctx.effective_role == Role::Admin {
2859            return Ok(());
2860        }
2861
2862        let uid = UserId::from_parts(ctx.tenant, ctx.principal);
2863
2864        // Fast path: per-user pre-resolved cache.
2865        if let Ok(cache) = self.permission_cache.read() {
2866            if let Some(pc) = cache.get(&uid) {
2867                if pc.allows(resource, action) {
2868                    return Ok(());
2869                }
2870            }
2871        }
2872
2873        // Slow path: linear scan + rebuild cache as a side-effect.
2874        let user_grants = self
2875            .grants
2876            .read()
2877            .ok()
2878            .and_then(|g| g.get(&uid).cloned())
2879            .unwrap_or_default();
2880        let any_user_grants = self
2881            .grants
2882            .read()
2883            .ok()
2884            .map(|g| g.values().any(|list| !list.is_empty()))
2885            .unwrap_or(false);
2886        let public_grants = self
2887            .public_grants
2888            .read()
2889            .ok()
2890            .map(|p| p.clone())
2891            .unwrap_or_default();
2892        if user_grants.is_empty() && public_grants.is_empty() && any_user_grants {
2893            return Err(AuthzError::PermissionDenied {
2894                action,
2895                resource: resource.clone(),
2896                principal: ctx.principal.to_string(),
2897            });
2898        }
2899        let view = GrantsView {
2900            user_grants: &user_grants,
2901            public_grants: &public_grants,
2902        };
2903        let result = check_grant(ctx, action, resource, &view);
2904
2905        if result.is_ok() {
2906            let pc = PermissionCache::build(&user_grants, &public_grants);
2907            if let Ok(mut cache) = self.permission_cache.write() {
2908                cache.insert(uid, pc);
2909            }
2910        }
2911        result
2912    }
2913
2914    // -----------------------------------------------------------------
2915    // ALTER USER attributes (VALID UNTIL, CONNECTION LIMIT, etc.)
2916    // -----------------------------------------------------------------
2917
2918    /// Replace the attribute record for `uid`.
2919    pub fn set_user_attributes(
2920        &self,
2921        uid: &UserId,
2922        attrs: UserAttributes,
2923    ) -> Result<(), AuthError> {
2924        let users = self.users.read().map_err(lock_err)?;
2925        if !users.contains_key(uid) {
2926            return Err(AuthError::UserNotFound(uid.to_string()));
2927        }
2928        drop(users);
2929
2930        self.user_attributes
2931            .write()
2932            .unwrap_or_else(|e| e.into_inner())
2933            .insert(uid.clone(), attrs);
2934        self.invalidate_iam_cache(Some(uid));
2935        self.persist_acl_to_kv();
2936        Ok(())
2937    }
2938
2939    /// Read the attributes for `uid`. Returns `Default::default()` for
2940    /// users that have never been altered.
2941    pub fn user_attributes(&self, uid: &UserId) -> UserAttributes {
2942        self.user_attributes
2943            .read()
2944            .ok()
2945            .and_then(|m| m.get(uid).cloned())
2946            .unwrap_or_default()
2947    }
2948
2949    pub fn add_user_to_group(&self, uid: &UserId, group: &str) -> Result<(), AuthError> {
2950        if group.trim().is_empty() {
2951            return Err(AuthError::Forbidden("group name cannot be empty".into()));
2952        }
2953        let mut attrs = self.user_attributes(uid);
2954        if !attrs.groups.iter().any(|g| g == group) {
2955            attrs.groups.push(group.to_string());
2956            attrs.groups.sort();
2957        }
2958        self.set_user_attributes(uid, attrs)
2959    }
2960
2961    pub fn remove_user_from_group(&self, uid: &UserId, group: &str) -> Result<(), AuthError> {
2962        let mut attrs = self.user_attributes(uid);
2963        attrs.groups.retain(|g| g != group);
2964        self.set_user_attributes(uid, attrs)
2965    }
2966
2967    /// Toggle `User.enabled` without rotating credentials.
2968    pub fn set_user_enabled(&self, uid: &UserId, enabled: bool) -> Result<(), AuthError> {
2969        if let Some(configured) = self.configured_control_events() {
2970            let ctx = default_user_lifecycle_ctx();
2971            let control = UserLifecycleControl {
2972                ctx: &ctx,
2973                ledger: configured.ledger.as_ref(),
2974                config: configured.config,
2975            };
2976            self.set_user_enabled_controlled(uid, enabled, &control)
2977        } else {
2978            self.set_user_enabled_unaudited(uid, enabled)
2979        }
2980    }
2981
2982    pub fn disable_user(
2983        &self,
2984        username: &str,
2985        ctx: &ControlEventCtx<'_>,
2986        ledger: &dyn ControlEventLedger,
2987        config: ControlEventConfig,
2988    ) -> Result<(), AuthError> {
2989        self.disable_user_in_tenant(None, username, ctx, ledger, config)
2990    }
2991
2992    pub fn disable_user_in_tenant(
2993        &self,
2994        tenant_id: Option<&str>,
2995        username: &str,
2996        ctx: &ControlEventCtx<'_>,
2997        ledger: &dyn ControlEventLedger,
2998        config: ControlEventConfig,
2999    ) -> Result<(), AuthError> {
3000        let uid = UserId::from_parts(tenant_id, username);
3001        let control = UserLifecycleControl {
3002            ctx,
3003            ledger,
3004            config,
3005        };
3006        self.set_user_enabled_controlled(&uid, false, &control)
3007    }
3008
3009    fn set_user_enabled_unaudited(&self, uid: &UserId, enabled: bool) -> Result<(), AuthError> {
3010        let mut users = self.users.write().map_err(lock_err)?;
3011        let user = users
3012            .get_mut(uid)
3013            .ok_or_else(|| AuthError::UserNotFound(uid.to_string()))?;
3014        user.enabled = enabled;
3015        user.updated_at = now_ms();
3016        drop(users);
3017        if !enabled {
3018            self.purge_sessions_for(uid);
3019        }
3020        self.persist_to_vault();
3021        Ok(())
3022    }
3023
3024    /// Drop every live session belonging to `uid` (tenant + username match
3025    /// so a same-named user in another tenant is untouched).
3026    fn purge_sessions_for(&self, uid: &UserId) {
3027        if let Ok(mut sessions) = self.sessions.write() {
3028            sessions.retain(|_, s| !(s.username == uid.username && s.tenant_id == uid.tenant));
3029        }
3030    }
3031
3032    fn set_user_enabled_controlled(
3033        &self,
3034        uid: &UserId,
3035        enabled: bool,
3036        control: &UserLifecycleControl<'_>,
3037    ) -> Result<(), AuthError> {
3038        let previous = self.get_user_cloned(uid);
3039        let kind = if enabled {
3040            EventKind::UserUpdate
3041        } else {
3042            EventKind::UserDisable
3043        };
3044        let action = if enabled {
3045            "user.update"
3046        } else {
3047            "user.disable"
3048        };
3049        match self.set_user_enabled_unaudited(uid, enabled) {
3050            Ok(()) => {
3051                if let Err(err) = self.emit_user_lifecycle_allowed(
3052                    control,
3053                    kind,
3054                    action,
3055                    uid,
3056                    user_control_fields(
3057                        uid,
3058                        previous.as_ref().map(|u| u.role),
3059                        Some(enabled),
3060                        false,
3061                    ),
3062                ) {
3063                    self.restore_user_snapshot(uid, previous);
3064                    return Err(err);
3065                }
3066                Ok(())
3067            }
3068            Err(err) => {
3069                self.emit_user_lifecycle_outcome(
3070                    control,
3071                    if user_error_is_denied(&err) {
3072                        Outcome::Denied
3073                    } else {
3074                        Outcome::Error
3075                    },
3076                    kind,
3077                    action,
3078                    uid,
3079                    Some(err.to_string()),
3080                    user_control_fields(
3081                        uid,
3082                        previous.as_ref().map(|u| u.role),
3083                        Some(enabled),
3084                        false,
3085                    ),
3086                );
3087                Err(err)
3088            }
3089        }
3090    }
3091
3092    fn emit_user_lifecycle_allowed(
3093        &self,
3094        control: &UserLifecycleControl<'_>,
3095        kind: EventKind,
3096        action: &'static str,
3097        id: &UserId,
3098        fields: HashMap<String, Sensitivity>,
3099    ) -> Result<(), AuthError> {
3100        self.emit_user_lifecycle_event(control, Outcome::Allowed, kind, action, id, None, fields)
3101    }
3102
3103    fn emit_api_key_allowed(
3104        &self,
3105        control: &UserLifecycleControl<'_>,
3106        kind: EventKind,
3107        action: &'static str,
3108        api_key_id: &str,
3109        fields: HashMap<String, Sensitivity>,
3110    ) -> Result<(), AuthError> {
3111        self.emit_control_event(
3112            control,
3113            Outcome::Allowed,
3114            kind,
3115            action,
3116            Some(api_key_resource(api_key_id)),
3117            None,
3118            fields,
3119        )
3120    }
3121
3122    #[allow(clippy::too_many_arguments)]
3123    fn emit_user_lifecycle_outcome(
3124        &self,
3125        control: &UserLifecycleControl<'_>,
3126        outcome: Outcome,
3127        kind: EventKind,
3128        action: &'static str,
3129        id: &UserId,
3130        reason: Option<String>,
3131        fields: HashMap<String, Sensitivity>,
3132    ) {
3133        let _ = self.emit_user_lifecycle_event(control, outcome, kind, action, id, reason, fields);
3134    }
3135
3136    #[allow(clippy::too_many_arguments)]
3137    fn emit_user_lifecycle_event(
3138        &self,
3139        control: &UserLifecycleControl<'_>,
3140        outcome: Outcome,
3141        kind: EventKind,
3142        action: &'static str,
3143        id: &UserId,
3144        reason: Option<String>,
3145        fields: HashMap<String, Sensitivity>,
3146    ) -> Result<(), AuthError> {
3147        self.emit_control_event(
3148            control,
3149            outcome,
3150            kind,
3151            action,
3152            Some(user_resource(id)),
3153            reason,
3154            fields,
3155        )
3156    }
3157
3158    #[allow(clippy::too_many_arguments)]
3159    fn emit_control_event(
3160        &self,
3161        control: &UserLifecycleControl<'_>,
3162        outcome: Outcome,
3163        kind: EventKind,
3164        action: &'static str,
3165        resource: Option<String>,
3166        reason: Option<String>,
3167        fields: HashMap<String, Sensitivity>,
3168    ) -> Result<(), AuthError> {
3169        let event = ControlEvent {
3170            kind,
3171            outcome,
3172            action: Cow::Borrowed(action),
3173            resource,
3174            reason,
3175            matched_policy_id: None,
3176            fields,
3177        };
3178        match control.ledger.emit(control.ctx, event) {
3179            Ok(_) => Ok(()),
3180            Err(err) if control.config.require_persistence() => {
3181                Err(AuthError::Internal(err.to_string()))
3182            }
3183            Err(_) => Ok(()),
3184        }
3185    }
3186
3187    fn rollback_create_user(&self, id: &UserId) {
3188        let removed = self
3189            .users
3190            .write()
3191            .unwrap_or_else(|e| e.into_inner())
3192            .remove(id);
3193        if let Some(user) = removed {
3194            self.remove_api_key_index_entries(&user);
3195        }
3196        self.persist_to_vault();
3197    }
3198
3199    fn rollback_bootstrap(&self, id: &UserId) {
3200        self.bootstrapped.store(false, Ordering::Release);
3201        self.rollback_create_user(id);
3202    }
3203
3204    fn restore_user_snapshot(&self, id: &UserId, previous: Option<User>) {
3205        match previous {
3206            Some(user) => {
3207                self.users
3208                    .write()
3209                    .unwrap_or_else(|e| e.into_inner())
3210                    .insert(id.clone(), user.clone());
3211                self.index_user_api_keys(id, &user);
3212            }
3213            None => {
3214                self.rollback_create_user(id);
3215                return;
3216            }
3217        }
3218        self.persist_to_vault();
3219    }
3220
3221    fn user_delete_rollback_snapshot(
3222        &self,
3223        id: &UserId,
3224        tenant_id: Option<&str>,
3225        username: &str,
3226    ) -> Option<(User, Vec<(String, Session)>)> {
3227        let user = self.get_user_cloned(id)?;
3228        let sessions = self
3229            .sessions
3230            .read()
3231            .map(|sessions| {
3232                sessions
3233                    .iter()
3234                    .filter(|(_, session)| {
3235                        session.username == username && session.tenant_id.as_deref() == tenant_id
3236                    })
3237                    .map(|(token, session)| (token.clone(), session.clone()))
3238                    .collect()
3239            })
3240            .unwrap_or_default();
3241        Some((user, sessions))
3242    }
3243
3244    fn restore_deleted_user(&self, id: &UserId, user: User, sessions: Vec<(String, Session)>) {
3245        self.users
3246            .write()
3247            .unwrap_or_else(|e| e.into_inner())
3248            .insert(id.clone(), user.clone());
3249        self.index_user_api_keys(id, &user);
3250        if !sessions.is_empty() {
3251            let mut guard = self.sessions.write().unwrap_or_else(|e| e.into_inner());
3252            for (token, session) in sessions {
3253                guard.insert(token, session);
3254            }
3255        }
3256        self.persist_to_vault();
3257    }
3258
3259    fn rollback_create_api_key(&self, id: &UserId, key: &str) {
3260        let key_id = api_key_credential_id(key);
3261        let key = key_id.as_str();
3262        if let Ok(mut users) = self.users.write() {
3263            if let Some(user) = users.get_mut(id) {
3264                user.api_keys.retain(|api_key| api_key.key != key);
3265                user.updated_at = now_ms();
3266            }
3267        }
3268        if let Ok(mut idx) = self.api_key_index.write() {
3269            idx.remove(key);
3270        }
3271        self.persist_to_vault();
3272    }
3273
3274    fn api_key_rollback_snapshot(&self, key: &str) -> Option<(UserId, ApiKey)> {
3275        let key = api_key_credential_id(key);
3276        let users = self.users.read().ok()?;
3277        users.iter().find_map(|(id, user)| {
3278            user.api_keys
3279                .iter()
3280                .find(|api_key| api_key.key == key)
3281                .cloned()
3282                .map(|api_key| (id.clone(), api_key))
3283        })
3284    }
3285
3286    fn restore_api_key(&self, id: &UserId, api_key: ApiKey) {
3287        if let Ok(mut users) = self.users.write() {
3288            if let Some(user) = users.get_mut(id) {
3289                if !user
3290                    .api_keys
3291                    .iter()
3292                    .any(|candidate| candidate.key == api_key.key)
3293                {
3294                    user.api_keys.push(api_key.clone());
3295                    user.updated_at = now_ms();
3296                }
3297            }
3298        }
3299        if let Ok(mut idx) = self.api_key_index.write() {
3300            idx.insert(api_key.key.clone(), (id.clone(), api_key.role));
3301        }
3302        self.persist_to_vault();
3303    }
3304
3305    fn remove_api_key_index_entries(&self, user: &User) {
3306        if let Ok(mut idx) = self.api_key_index.write() {
3307            for api_key in &user.api_keys {
3308                idx.remove(&api_key.key);
3309            }
3310        }
3311    }
3312
3313    fn index_user_api_keys(&self, id: &UserId, user: &User) {
3314        if let Ok(mut idx) = self.api_key_index.write() {
3315            for api_key in &user.api_keys {
3316                idx.insert(api_key.key.clone(), (id.clone(), api_key.role));
3317            }
3318        }
3319    }
3320
3321    // -----------------------------------------------------------------
3322    // Login-side enforcement (HTTP path)
3323    // -----------------------------------------------------------------
3324
3325    /// Authenticate with VALID UNTIL / CONNECTION LIMIT enforcement.
3326    /// Wraps `authenticate_in_tenant` and additionally:
3327    ///   * rejects logins after `valid_until`,
3328    ///   * rejects logins when the live session count would exceed the
3329    ///     `connection_limit` attribute.
3330    pub fn authenticate_with_attrs(
3331        &self,
3332        tenant_id: Option<&str>,
3333        username: &str,
3334        password: &str,
3335    ) -> Result<Session, AuthError> {
3336        let uid = UserId::from_parts(tenant_id, username);
3337        let attrs = self.user_attributes(&uid);
3338
3339        if let Some(deadline) = attrs.valid_until {
3340            if now_ms() >= deadline {
3341                return Err(AuthError::Forbidden(format!(
3342                    "account `{}` expired (VALID UNTIL exceeded)",
3343                    uid
3344                )));
3345            }
3346        }
3347
3348        if let Some(limit) = attrs.connection_limit {
3349            let current = self
3350                .session_count_by_user
3351                .read()
3352                .ok()
3353                .and_then(|m| m.get(&uid).copied())
3354                .unwrap_or(0);
3355            if current >= limit {
3356                return Err(AuthError::Forbidden(format!(
3357                    "account `{}` exceeded CONNECTION LIMIT ({})",
3358                    uid, limit
3359                )));
3360            }
3361        }
3362
3363        let session = self.authenticate_in_tenant(tenant_id, username, password)?;
3364
3365        if let Ok(mut counts) = self.session_count_by_user.write() {
3366            *counts.entry(uid).or_insert(0) += 1;
3367        }
3368        Ok(session)
3369    }
3370
3371    /// Decrement the live-session count for `uid`. Call from session
3372    /// revoke / expiry paths so CONNECTION LIMIT stays accurate.
3373    pub fn decrement_session_count(&self, uid: &UserId) {
3374        if let Ok(mut counts) = self.session_count_by_user.write() {
3375            if let Some(c) = counts.get_mut(uid) {
3376                *c = c.saturating_sub(1);
3377            }
3378        }
3379    }
3380
3381    // -----------------------------------------------------------------
3382    // ACL persistence — vault_kv backed
3383    // -----------------------------------------------------------------
3384
3385    /// Re-read the ACL state from `vault_kv`. Call after vault load /
3386    /// restore so the in-memory maps reflect the persisted data.
3387    pub fn rehydrate_acl(&self) {
3388        let kv_snapshot: Vec<(String, String)> = self
3389            .vault_kv
3390            .read()
3391            .map(|kv| {
3392                kv.iter()
3393                    .filter(|(k, _)| {
3394                        k.starts_with("red.acl.grants.")
3395                            || k.starts_with("red.acl.attrs.")
3396                            || k == &"red.acl.public_grants"
3397                    })
3398                    .map(|(k, v)| (k.clone(), v.clone()))
3399                    .collect()
3400            })
3401            .unwrap_or_default();
3402
3403        for (k, v) in kv_snapshot {
3404            if k == "red.acl.public_grants" {
3405                if let Some(parsed) = decode_grants_blob(&v) {
3406                    *self
3407                        .public_grants
3408                        .write()
3409                        .unwrap_or_else(|e| e.into_inner()) = parsed;
3410                }
3411            } else if let Some(suffix) = k.strip_prefix("red.acl.grants.") {
3412                if let Some(uid) = decode_uid(suffix) {
3413                    if let Some(mut parsed) = decode_grants_blob(&v) {
3414                        // Restore the principal field — the on-disk
3415                        // line stores only resource+action shape.
3416                        for g in parsed.iter_mut() {
3417                            g.principal = GrantPrincipal::User(uid.clone());
3418                        }
3419                        self.grants
3420                            .write()
3421                            .unwrap_or_else(|e| e.into_inner())
3422                            .insert(uid, parsed);
3423                    }
3424                }
3425            } else if let Some(suffix) = k.strip_prefix("red.acl.attrs.") {
3426                if let Some(uid) = decode_uid(suffix) {
3427                    if let Some(parsed) = decode_attrs_blob(&v) {
3428                        self.user_attributes
3429                            .write()
3430                            .unwrap_or_else(|e| e.into_inner())
3431                            .insert(uid, parsed);
3432                    }
3433                }
3434            }
3435        }
3436
3437        self.permission_cache
3438            .write()
3439            .unwrap_or_else(|e| e.into_inner())
3440            .clear();
3441    }
3442
3443    /// Snapshot every ACL change back into the vault KV store.
3444    fn persist_acl_to_kv(&self) {
3445        let public = self
3446            .public_grants
3447            .read()
3448            .ok()
3449            .map(|p| encode_grants_blob(&p))
3450            .unwrap_or_default();
3451        self.vault_kv_set("red.acl.public_grants".to_string(), public);
3452
3453        let snapshot: Vec<(UserId, Vec<Grant>)> = self
3454            .grants
3455            .read()
3456            .ok()
3457            .map(|g| g.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
3458            .unwrap_or_default();
3459        for (uid, list) in snapshot {
3460            let key = format!("red.acl.grants.{}", encode_uid(&uid));
3461            let val = encode_grants_blob(&list);
3462            self.vault_kv_set(key, val);
3463        }
3464
3465        let attrs_snapshot: Vec<(UserId, UserAttributes)> = self
3466            .user_attributes
3467            .read()
3468            .ok()
3469            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
3470            .unwrap_or_default();
3471        for (uid, attrs) in attrs_snapshot {
3472            let key = format!("red.acl.attrs.{}", encode_uid(&uid));
3473            let val = encode_attrs_blob(&attrs);
3474            self.vault_kv_set(key, val);
3475        }
3476    }
3477
3478    fn invalidate_permission_cache(&self, uid: Option<&UserId>) {
3479        if let Ok(mut cache) = self.permission_cache.write() {
3480            match uid {
3481                Some(u) => {
3482                    cache.remove(u);
3483                }
3484                None => cache.clear(),
3485            }
3486        }
3487    }
3488
3489    // -----------------------------------------------------------------
3490    // IAM policies — put / delete / attach / detach / simulate
3491    //
3492    // The kernel in `super::policies` owns the Policy type and the
3493    // evaluator. AuthStore handles persistence + per-user cache + the
3494    // GRANT translation layer (synthetic `_grant_*` policies).
3495    // -----------------------------------------------------------------
3496
3497    pub fn put_policy_with_control_events(
3498        &self,
3499        p: Policy,
3500        control: &PolicyMutationControl<'_>,
3501    ) -> Result<(), AuthError> {
3502        let policy_id = p.id.clone();
3503        let kind = if self.get_policy(&policy_id).is_some() {
3504            EventKind::PolicyUpdate
3505        } else {
3506            EventKind::PolicyCreate
3507        };
3508
3509        if p.id.starts_with("_grant_") || p.id.starts_with("_default_") {
3510            let err = AuthError::Forbidden(format!("policy id `{}` is reserved", p.id));
3511            self.emit_policy_error(
3512                control,
3513                kind,
3514                "policy:put",
3515                &policy_id,
3516                Some(&p),
3517                None,
3518                &err,
3519            );
3520            return Err(err);
3521        }
3522
3523        if let Some(ManagedPolicyDecision::Deny {
3524            entry_id,
3525            matched_action,
3526            matched_resource,
3527            reason,
3528            ..
3529        }) = self.managed_policy_decision(&policy_id, PolicyOp::Put, control)
3530        {
3531            self.emit_policy_denied(
3532                control,
3533                kind,
3534                "policy:put",
3535                &policy_id,
3536                Some(&p),
3537                None,
3538                &reason.to_string(),
3539                Some(entry_id),
3540                Some((matched_action, matched_resource)),
3541            );
3542            return Err(Self::managed_policy_error(&policy_id, &reason.to_string()));
3543        }
3544
3545        let previous = self.get_policy(&policy_id);
3546        let was_enabled = self.iam_authorization_enabled();
3547        match self.put_policy_internal(p.clone()) {
3548            Ok(()) => match self.emit_policy_allowed(
3549                control,
3550                kind,
3551                "policy:put",
3552                &policy_id,
3553                Some(&p),
3554                None,
3555                None,
3556            ) {
3557                Ok(()) => Ok(()),
3558                Err(err) => {
3559                    self.restore_policy_put(&policy_id, previous, was_enabled);
3560                    Err(err)
3561                }
3562            },
3563            Err(err) => {
3564                self.emit_policy_error(
3565                    control,
3566                    kind,
3567                    "policy:put",
3568                    &policy_id,
3569                    Some(&p),
3570                    None,
3571                    &err,
3572                );
3573                Err(err)
3574            }
3575        }
3576    }
3577
3578    pub fn delete_policy_with_control_events(
3579        &self,
3580        id: &str,
3581        control: &PolicyMutationControl<'_>,
3582    ) -> Result<(), AuthError> {
3583        let existing = self.get_policy(id);
3584        if let Some(ManagedPolicyDecision::Deny {
3585            entry_id,
3586            matched_action,
3587            matched_resource,
3588            reason,
3589            ..
3590        }) = self.managed_policy_decision(id, PolicyOp::Drop, control)
3591        {
3592            self.emit_policy_denied(
3593                control,
3594                EventKind::PolicyDelete,
3595                "policy:drop",
3596                id,
3597                existing.as_deref(),
3598                None,
3599                &reason.to_string(),
3600                Some(entry_id),
3601                Some((matched_action, matched_resource)),
3602            );
3603            return Err(Self::managed_policy_error(id, &reason.to_string()));
3604        }
3605
3606        let user_attachments = self
3607            .user_attachments
3608            .read()
3609            .map(|m| m.clone())
3610            .unwrap_or_default();
3611        let group_attachments = self
3612            .group_attachments
3613            .read()
3614            .map(|m| m.clone())
3615            .unwrap_or_default();
3616        let was_enabled = self.iam_authorization_enabled();
3617        match self.delete_policy(id) {
3618            Ok(()) => match self.emit_policy_allowed(
3619                control,
3620                EventKind::PolicyDelete,
3621                "policy:drop",
3622                id,
3623                existing.as_deref(),
3624                None,
3625                None,
3626            ) {
3627                Ok(()) => Ok(()),
3628                Err(err) => {
3629                    if let Some(policy) = existing {
3630                        self.restore_policy_delete(
3631                            id,
3632                            policy,
3633                            user_attachments,
3634                            group_attachments,
3635                            was_enabled,
3636                        );
3637                    }
3638                    Err(err)
3639                }
3640            },
3641            Err(err) => {
3642                self.emit_policy_error(
3643                    control,
3644                    EventKind::PolicyDelete,
3645                    "policy:drop",
3646                    id,
3647                    existing.as_deref(),
3648                    None,
3649                    &err,
3650                );
3651                Err(err)
3652            }
3653        }
3654    }
3655
3656    pub fn attach_policy_with_control_events(
3657        &self,
3658        principal: PrincipalRef,
3659        policy_id: &str,
3660        control: &PolicyMutationControl<'_>,
3661    ) -> Result<(), AuthError> {
3662        let existing = self.get_policy(policy_id);
3663        if let Some(ManagedPolicyDecision::Deny {
3664            entry_id,
3665            matched_action,
3666            matched_resource,
3667            reason,
3668            ..
3669        }) = self.managed_policy_decision(policy_id, PolicyOp::Attach, control)
3670        {
3671            self.emit_policy_denied(
3672                control,
3673                EventKind::PolicyAttach,
3674                "policy:attach",
3675                policy_id,
3676                existing.as_deref(),
3677                Some(&principal),
3678                &reason.to_string(),
3679                Some(entry_id),
3680                Some((matched_action, matched_resource)),
3681            );
3682            return Err(Self::managed_policy_error(policy_id, &reason.to_string()));
3683        }
3684
3685        if let Some(err_msg) = self.check_self_lock_invariant_for_attach(policy_id) {
3686            self.emit_policy_denied(
3687                control,
3688                EventKind::PolicyAttach,
3689                "policy:attach",
3690                policy_id,
3691                existing.as_deref(),
3692                Some(&principal),
3693                &err_msg,
3694                None,
3695                None,
3696            );
3697            return Err(AuthError::Forbidden(err_msg));
3698        }
3699
3700        let user_attachments = self
3701            .user_attachments
3702            .read()
3703            .map(|m| m.clone())
3704            .unwrap_or_default();
3705        let group_attachments = self
3706            .group_attachments
3707            .read()
3708            .map(|m| m.clone())
3709            .unwrap_or_default();
3710        match self.attach_policy(principal.clone(), policy_id) {
3711            Ok(()) => match self.emit_policy_allowed(
3712                control,
3713                EventKind::PolicyAttach,
3714                "policy:attach",
3715                policy_id,
3716                existing.as_deref(),
3717                Some(&principal),
3718                None,
3719            ) {
3720                Ok(()) => Ok(()),
3721                Err(err) => {
3722                    self.restore_policy_attachments(user_attachments, group_attachments);
3723                    Err(err)
3724                }
3725            },
3726            Err(err) => {
3727                self.emit_policy_error(
3728                    control,
3729                    EventKind::PolicyAttach,
3730                    "policy:attach",
3731                    policy_id,
3732                    existing.as_deref(),
3733                    Some(&principal),
3734                    &err,
3735                );
3736                Err(err)
3737            }
3738        }
3739    }
3740
3741    pub fn detach_policy_with_control_events(
3742        &self,
3743        principal: PrincipalRef,
3744        policy_id: &str,
3745        control: &PolicyMutationControl<'_>,
3746    ) -> Result<(), AuthError> {
3747        let existing = self.get_policy(policy_id);
3748        if let Some(ManagedPolicyDecision::Deny {
3749            entry_id,
3750            matched_action,
3751            matched_resource,
3752            reason,
3753            ..
3754        }) = self.managed_policy_decision(policy_id, PolicyOp::Detach, control)
3755        {
3756            self.emit_policy_denied(
3757                control,
3758                EventKind::PolicyDetach,
3759                "policy:detach",
3760                policy_id,
3761                existing.as_deref(),
3762                Some(&principal),
3763                &reason.to_string(),
3764                Some(entry_id),
3765                Some((matched_action, matched_resource)),
3766            );
3767            return Err(Self::managed_policy_error(policy_id, &reason.to_string()));
3768        }
3769        let Some(existing_policy) = existing else {
3770            let err = AuthError::Forbidden(format!("policy `{policy_id}` not found"));
3771            self.emit_policy_error(
3772                control,
3773                EventKind::PolicyDetach,
3774                "policy:detach",
3775                policy_id,
3776                None,
3777                Some(&principal),
3778                &err,
3779            );
3780            return Err(err);
3781        };
3782
3783        let user_attachments = self
3784            .user_attachments
3785            .read()
3786            .map(|m| m.clone())
3787            .unwrap_or_default();
3788        let group_attachments = self
3789            .group_attachments
3790            .read()
3791            .map(|m| m.clone())
3792            .unwrap_or_default();
3793        match self.detach_policy(principal.clone(), policy_id) {
3794            Ok(()) => match self.emit_policy_allowed(
3795                control,
3796                EventKind::PolicyDetach,
3797                "policy:detach",
3798                policy_id,
3799                Some(existing_policy.as_ref()),
3800                Some(&principal),
3801                None,
3802            ) {
3803                Ok(()) => Ok(()),
3804                Err(err) => {
3805                    self.restore_policy_attachments(user_attachments, group_attachments);
3806                    Err(err)
3807                }
3808            },
3809            Err(err) => {
3810                self.emit_policy_error(
3811                    control,
3812                    EventKind::PolicyDetach,
3813                    "policy:detach",
3814                    policy_id,
3815                    Some(existing_policy.as_ref()),
3816                    Some(&principal),
3817                    &err,
3818                );
3819                Err(err)
3820            }
3821        }
3822    }
3823
3824    fn managed_policy_decision(
3825        &self,
3826        policy_id: &str,
3827        op: PolicyOp,
3828        control: &PolicyMutationControl<'_>,
3829    ) -> Option<ManagedPolicyDecision> {
3830        control.registry.map(|registry| {
3831            ManagedPolicyGate::new(registry).check_mutation(
3832                self,
3833                control.actor,
3834                control.eval_ctx,
3835                policy_id,
3836                op,
3837            )
3838        })
3839    }
3840
3841    /// Run the [`crate::auth::self_lock_guard`] invariant against the
3842    /// current policy set. Returns `Some(error_message)` when an attach
3843    /// must be refused, `None` when the system would remain unlockable.
3844    ///
3845    /// `_policy_id` is informational — the invariant is on the full
3846    /// graph and doesn't currently special-case the new entry.
3847    fn check_self_lock_invariant_for_attach(&self, _policy_id: &str) -> Option<String> {
3848        use crate::auth::self_lock_guard::{
3849            check_self_lock_invariant, format_block_error, InvariantOutcome,
3850        };
3851        let policies: Vec<Arc<Policy>> = match self.policies.read() {
3852            Ok(map) => map.values().cloned().collect(),
3853            Err(_) => return None,
3854        };
3855        let outcome = check_self_lock_invariant(&policies);
3856        match &outcome {
3857            InvariantOutcome::Ok => None,
3858            InvariantOutcome::Blocked { .. } => format_block_error(&outcome),
3859        }
3860    }
3861
3862    fn managed_policy_error(policy_id: &str, reason: &str) -> AuthError {
3863        AuthError::Forbidden(format!(
3864            "managed policy mutation blocked for `{policy_id}`: {reason}"
3865        ))
3866    }
3867
3868    fn emit_policy_allowed(
3869        &self,
3870        control: &PolicyMutationControl<'_>,
3871        kind: EventKind,
3872        action: &'static str,
3873        policy_id: &str,
3874        policy: Option<&Policy>,
3875        principal: Option<&PrincipalRef>,
3876        matched_policy_id: Option<String>,
3877    ) -> Result<(), AuthError> {
3878        let event = ControlEvent {
3879            kind,
3880            outcome: Outcome::Allowed,
3881            action: std::borrow::Cow::Borrowed(action),
3882            resource: Some(format!("policy:{policy_id}")),
3883            reason: None,
3884            matched_policy_id,
3885            fields: policy_control_fields(policy_id, policy, principal),
3886        };
3887        match control.ledger.emit(control.ctx, event) {
3888            Ok(_) => Ok(()),
3889            Err(err) if control.config.require_persistence() => {
3890                Err(AuthError::Internal(err.to_string()))
3891            }
3892            Err(_) => Ok(()),
3893        }
3894    }
3895
3896    #[allow(clippy::too_many_arguments)]
3897    fn emit_policy_denied(
3898        &self,
3899        control: &PolicyMutationControl<'_>,
3900        kind: EventKind,
3901        action: &'static str,
3902        policy_id: &str,
3903        policy: Option<&Policy>,
3904        principal: Option<&PrincipalRef>,
3905        reason: &str,
3906        matched_policy_id: Option<String>,
3907        matched: Option<(String, String)>,
3908    ) {
3909        let mut fields = policy_control_fields(policy_id, policy, principal);
3910        if let Some((matched_action, matched_resource)) = matched {
3911            fields.insert(
3912                "matched_action".to_string(),
3913                Sensitivity::raw(matched_action),
3914            );
3915            fields.insert(
3916                "matched_resource".to_string(),
3917                Sensitivity::raw(matched_resource),
3918            );
3919        }
3920        let event = ControlEvent {
3921            kind,
3922            outcome: Outcome::Denied,
3923            action: std::borrow::Cow::Borrowed(action),
3924            resource: Some(format!("policy:{policy_id}")),
3925            reason: Some(reason.to_string()),
3926            matched_policy_id,
3927            fields,
3928        };
3929        let _ = control.ledger.emit(control.ctx, event);
3930    }
3931
3932    fn emit_policy_error(
3933        &self,
3934        control: &PolicyMutationControl<'_>,
3935        kind: EventKind,
3936        action: &'static str,
3937        policy_id: &str,
3938        policy: Option<&Policy>,
3939        principal: Option<&PrincipalRef>,
3940        err: &AuthError,
3941    ) {
3942        let event = ControlEvent {
3943            kind,
3944            outcome: Outcome::Error,
3945            action: std::borrow::Cow::Borrowed(action),
3946            resource: Some(format!("policy:{policy_id}")),
3947            reason: Some(err.to_string()),
3948            matched_policy_id: None,
3949            fields: policy_control_fields(policy_id, policy, principal),
3950        };
3951        let _ = control.ledger.emit(control.ctx, event);
3952    }
3953
3954    fn restore_policy_put(
3955        &self,
3956        policy_id: &str,
3957        previous: Option<Arc<Policy>>,
3958        was_enabled: bool,
3959    ) {
3960        let mut policies = self.policies.write().unwrap_or_else(|e| e.into_inner());
3961        match previous {
3962            Some(policy) => {
3963                policies.insert(policy_id.to_string(), policy);
3964            }
3965            None => {
3966                policies.remove(policy_id);
3967            }
3968        }
3969        drop(policies);
3970        self.iam_authorization_enabled
3971            .store(was_enabled, Ordering::Release);
3972        self.iam_effective_cache
3973            .write()
3974            .unwrap_or_else(|e| e.into_inner())
3975            .clear();
3976        self.invalidate_visible_collections_cache();
3977        self.persist_iam_to_kv();
3978    }
3979
3980    fn restore_policy_delete(
3981        &self,
3982        policy_id: &str,
3983        policy: Arc<Policy>,
3984        user_attachments: HashMap<UserId, Vec<String>>,
3985        group_attachments: HashMap<String, Vec<String>>,
3986        was_enabled: bool,
3987    ) {
3988        self.policies
3989            .write()
3990            .unwrap_or_else(|e| e.into_inner())
3991            .insert(policy_id.to_string(), policy);
3992        self.restore_policy_attachments(user_attachments, group_attachments);
3993        self.iam_authorization_enabled
3994            .store(was_enabled, Ordering::Release);
3995        self.persist_iam_to_kv();
3996    }
3997
3998    fn restore_policy_attachments(
3999        &self,
4000        user_attachments: HashMap<UserId, Vec<String>>,
4001        group_attachments: HashMap<String, Vec<String>>,
4002    ) {
4003        *self
4004            .user_attachments
4005            .write()
4006            .unwrap_or_else(|e| e.into_inner()) = user_attachments;
4007        *self
4008            .group_attachments
4009            .write()
4010            .unwrap_or_else(|e| e.into_inner()) = group_attachments;
4011        self.iam_effective_cache
4012            .write()
4013            .unwrap_or_else(|e| e.into_inner())
4014            .clear();
4015        self.invalidate_visible_collections_cache();
4016        self.persist_iam_to_kv();
4017    }
4018
4019    /// Insert or replace a policy by id. Rejects synthetic ids
4020    /// (`_grant_*` / `_default_*`) so callers can't hand-write them
4021    /// from the public API. Use `put_policy_internal` for synthetic
4022    /// inserts.
4023    pub fn put_policy(&self, p: Policy) -> Result<(), AuthError> {
4024        if p.id.starts_with("_grant_") || p.id.starts_with("_default_") {
4025            return Err(AuthError::Forbidden(format!(
4026                "policy id `{}` is reserved",
4027                p.id
4028            )));
4029        }
4030        self.put_policy_internal(p)
4031    }
4032
4033    /// Internal put bypassing the synthetic-namespace guard. Used by
4034    /// the GRANT translation layer; exposed publicly so integration
4035    /// tests can register synthetic `_grant_*` policies without going
4036    /// through the SQL frontend.
4037    pub fn put_policy_internal(&self, p: Policy) -> Result<(), AuthError> {
4038        p.validate()
4039            .map_err(|e| AuthError::Forbidden(format!("invalid policy `{}`: {e}", p.id)))?;
4040        let id = p.id.clone();
4041        self.policies
4042            .write()
4043            .unwrap_or_else(|e| e.into_inner())
4044            .insert(id, Arc::new(p));
4045        self.iam_authorization_enabled
4046            .store(true, Ordering::Release);
4047        self.iam_effective_cache
4048            .write()
4049            .unwrap_or_else(|e| e.into_inner())
4050            .clear();
4051        // Issue #119: a policy mutation can change the visible-
4052        // collections answer for any (tenant, role); we don't know
4053        // which up-front, so blow the whole cache.
4054        self.invalidate_visible_collections_cache();
4055        self.persist_iam_to_kv();
4056        Ok(())
4057    }
4058
4059    /// Whether the IAM evaluator should be authoritative for runtime
4060    /// authorization. This flips on the first policy write and remains
4061    /// on after deletes so dropping all policies leaves the instance in
4062    /// default-deny rather than silently returning to role fallback.
4063    pub fn iam_authorization_enabled(&self) -> bool {
4064        self.iam_authorization_enabled.load(Ordering::Acquire)
4065    }
4066
4067    /// Remove a policy and any attachments referencing it.
4068    pub fn delete_policy(&self, id: &str) -> Result<(), AuthError> {
4069        let removed = self
4070            .policies
4071            .write()
4072            .unwrap_or_else(|e| e.into_inner())
4073            .remove(id)
4074            .is_some();
4075        if !removed {
4076            return Err(AuthError::Forbidden(format!("policy `{id}` not found")));
4077        }
4078        // Detach from every user / group.
4079        if let Ok(mut ua) = self.user_attachments.write() {
4080            for ids in ua.values_mut() {
4081                ids.retain(|p| p != id);
4082            }
4083            ua.retain(|_, v| !v.is_empty());
4084        }
4085        if let Ok(mut ga) = self.group_attachments.write() {
4086            for ids in ga.values_mut() {
4087                ids.retain(|p| p != id);
4088            }
4089            ga.retain(|_, v| !v.is_empty());
4090        }
4091        self.iam_effective_cache
4092            .write()
4093            .unwrap_or_else(|e| e.into_inner())
4094            .clear();
4095        // Issue #119: dropping a policy can shrink any caller's visible
4096        // set; clear the (tenant, role) cache so AI commands re-resolve.
4097        self.invalidate_visible_collections_cache();
4098        self.persist_iam_to_kv();
4099        Ok(())
4100    }
4101
4102    /// List all policies (id-sorted for deterministic output).
4103    pub fn list_policies(&self) -> Vec<Arc<Policy>> {
4104        let map = match self.policies.read() {
4105            Ok(g) => g,
4106            Err(_) => return Vec::new(),
4107        };
4108        let mut out: Vec<Arc<Policy>> = map.values().cloned().collect();
4109        out.sort_by(|a, b| a.id.cmp(&b.id));
4110        out
4111    }
4112
4113    /// Fetch a single policy by id.
4114    pub fn get_policy(&self, id: &str) -> Option<Arc<Policy>> {
4115        self.policies.read().ok().and_then(|m| m.get(id).cloned())
4116    }
4117
4118    /// List policies directly attached to a group.
4119    pub fn group_policies(&self, group: &str) -> Vec<Arc<Policy>> {
4120        let policies = self.policies.read();
4121        let attachments = self.group_attachments.read();
4122        let mut out = Vec::new();
4123        if let (Ok(p_map), Ok(ga_map)) = (policies, attachments) {
4124            if let Some(ids) = ga_map.get(group) {
4125                for id in ids {
4126                    if let Some(p) = p_map.get(id) {
4127                        out.push(p.clone());
4128                    }
4129                }
4130            }
4131        }
4132        out.sort_by(|a, b| a.id.cmp(&b.id));
4133        out
4134    }
4135
4136    /// Delete synthetic policies produced by SQL GRANT translation.
4137    /// REVOKE uses this to keep the IAM lane and the legacy grant table
4138    /// in lock-step.
4139    pub fn delete_synthetic_grant_policies(
4140        &self,
4141        principal: &GrantPrincipal,
4142        resource: &Resource,
4143        actions: &[Action],
4144    ) -> usize {
4145        let attached = match principal {
4146            GrantPrincipal::User(uid) => self
4147                .user_attachments
4148                .read()
4149                .ok()
4150                .and_then(|m| m.get(uid).cloned())
4151                .unwrap_or_default(),
4152            GrantPrincipal::Group(group) => self
4153                .group_attachments
4154                .read()
4155                .ok()
4156                .and_then(|m| m.get(group).cloned())
4157                .unwrap_or_default(),
4158            GrantPrincipal::Public => self
4159                .group_attachments
4160                .read()
4161                .ok()
4162                .and_then(|m| m.get(PUBLIC_IAM_GROUP).cloned())
4163                .unwrap_or_default(),
4164        };
4165        if attached.is_empty() {
4166            return 0;
4167        }
4168
4169        let mut delete_ids = Vec::new();
4170        if let Ok(policies) = self.policies.read() {
4171            for id in attached {
4172                let Some(policy) = policies.get(&id) else {
4173                    continue;
4174                };
4175                if !policy.id.starts_with("_grant_") {
4176                    continue;
4177                }
4178                if synthetic_grant_matches(policy, resource, actions) {
4179                    delete_ids.push(policy.id.clone());
4180                }
4181            }
4182        }
4183
4184        let mut deleted = 0usize;
4185        for id in delete_ids {
4186            if self.delete_policy(&id).is_ok() {
4187                deleted += 1;
4188            }
4189        }
4190        deleted
4191    }
4192
4193    /// Apply the `REDDB_POLICY_BREAK_GLASS` recovery path: install or
4194    /// refresh the unlock policy, ensure the synthetic platform-owner
4195    /// user record exists, rebind the unlock policy to it, and emit a
4196    /// `policy.break_glass` audit event. Idempotent across reboots.
4197    ///
4198    /// `boot_ts_ms` is recorded in the audit event so operators can
4199    /// correlate the recovery with the boot transcript.
4200    pub fn apply_policy_break_glass(&self, boot_ts_ms: u128) -> Result<(), AuthError> {
4201        use crate::auth::self_lock_guard::{
4202            break_glass_audit_fields, unlock_policy, PLATFORM_OWNER_UNLOCK_POLICY_ID,
4203            PLATFORM_OWNER_USERNAME,
4204        };
4205
4206        // (1) Install or refresh the unlock policy. `put_policy_internal`
4207        // bypasses the synthetic-namespace guard, lets us replace any
4208        // tampered persisted copy, and persists to vault on success.
4209        let policy = unlock_policy();
4210        self.put_policy_internal(policy)?;
4211
4212        // (2) Ensure the synthetic platform-owner user record exists.
4213        // We bypass the public user-creation API to keep this user
4214        // immutable, disabled, and absent from operator listings even
4215        // if our filter ever regresses.
4216        let owner_id = UserId::platform(PLATFORM_OWNER_USERNAME);
4217        {
4218            let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
4219            users.entry(owner_id.clone()).or_insert_with(|| User {
4220                username: PLATFORM_OWNER_USERNAME.to_string(),
4221                tenant_id: None,
4222                // No usable password; even if `authenticate` were
4223                // wired to accept the principal it would fail
4224                // verification. Defence in depth: `enabled = false`
4225                // and the username filter both block it.
4226                password_hash: String::new(),
4227                scram_verifier: None,
4228                role: Role::Admin,
4229                api_keys: Vec::new(),
4230                created_at: boot_ts_ms,
4231                updated_at: boot_ts_ms,
4232                enabled: false,
4233            });
4234        }
4235
4236        // (3) Rebind the unlock policy to the synthetic principal.
4237        // `attach_policy` is idempotent (the helper checks for an
4238        // existing binding before appending).
4239        self.attach_policy(
4240            PrincipalRef::User(owner_id.clone()),
4241            PLATFORM_OWNER_UNLOCK_POLICY_ID,
4242        )?;
4243
4244        // (4) Emit a loud audit event. If the control-event ledger
4245        // isn't configured (e.g. unit tests without runtime wiring),
4246        // log a warn and proceed — recovery must not depend on audit
4247        // wiring being live.
4248        let fields_raw = break_glass_audit_fields(boot_ts_ms);
4249        let mut fields: HashMap<String, Sensitivity> = HashMap::new();
4250        for (k, v) in fields_raw {
4251            fields.insert(k, Sensitivity::raw(v));
4252        }
4253        if let Some(configured) = self.configured_control_events() {
4254            let ctx = ControlEventCtx {
4255                actor: crate::runtime::control_events::ActorRef::System("break_glass"),
4256                scope: None,
4257                request_id: None,
4258                trace_id: None,
4259            };
4260            let event = ControlEvent {
4261                kind: EventKind::PolicyBreakGlass,
4262                outcome: Outcome::Allowed,
4263                action: std::borrow::Cow::Borrowed("policy:break_glass"),
4264                resource: Some(format!("policy:{PLATFORM_OWNER_UNLOCK_POLICY_ID}")),
4265                reason: Some("REDDB_POLICY_BREAK_GLASS=1 at boot".into()),
4266                matched_policy_id: None,
4267                fields,
4268            };
4269            let _ = configured.ledger.emit(&ctx, event);
4270        }
4271        tracing::warn!(
4272            policy_id = PLATFORM_OWNER_UNLOCK_POLICY_ID,
4273            principal = PLATFORM_OWNER_USERNAME,
4274            boot_ts_ms = %boot_ts_ms,
4275            "REDDB_POLICY_BREAK_GLASS=1 — installed/refreshed platform-owner unlock policy and rebound to synthetic principal"
4276        );
4277        Ok(())
4278    }
4279
4280    /// Attach a policy to a user or group. Returns an error if the
4281    /// policy id doesn't exist.
4282    pub fn attach_policy(&self, principal: PrincipalRef, policy_id: &str) -> Result<(), AuthError> {
4283        if !self
4284            .policies
4285            .read()
4286            .map(|m| m.contains_key(policy_id))
4287            .unwrap_or(false)
4288        {
4289            return Err(AuthError::Forbidden(format!(
4290                "policy `{policy_id}` not found"
4291            )));
4292        }
4293        match &principal {
4294            PrincipalRef::User(uid) => {
4295                let mut ua = self
4296                    .user_attachments
4297                    .write()
4298                    .unwrap_or_else(|e| e.into_inner());
4299                let list = ua.entry(uid.clone()).or_default();
4300                if !list.iter().any(|p| p == policy_id) {
4301                    list.push(policy_id.to_string());
4302                }
4303                drop(ua);
4304                self.invalidate_iam_cache(Some(uid));
4305            }
4306            PrincipalRef::Group(g) => {
4307                let mut ga = self
4308                    .group_attachments
4309                    .write()
4310                    .unwrap_or_else(|e| e.into_inner());
4311                let list = ga.entry(g.clone()).or_default();
4312                if !list.iter().any(|p| p == policy_id) {
4313                    list.push(policy_id.to_string());
4314                }
4315                drop(ga);
4316                self.invalidate_iam_cache(None);
4317            }
4318        }
4319        self.persist_iam_to_kv();
4320        Ok(())
4321    }
4322
4323    /// Remove a policy attachment from a user or group.
4324    pub fn detach_policy(&self, principal: PrincipalRef, policy_id: &str) -> Result<(), AuthError> {
4325        match &principal {
4326            PrincipalRef::User(uid) => {
4327                if let Ok(mut ua) = self.user_attachments.write() {
4328                    if let Some(list) = ua.get_mut(uid) {
4329                        list.retain(|p| p != policy_id);
4330                        if list.is_empty() {
4331                            ua.remove(uid);
4332                        }
4333                    }
4334                }
4335                self.invalidate_iam_cache(Some(uid));
4336            }
4337            PrincipalRef::Group(g) => {
4338                if let Ok(mut ga) = self.group_attachments.write() {
4339                    if let Some(list) = ga.get_mut(g) {
4340                        list.retain(|p| p != policy_id);
4341                        if list.is_empty() {
4342                            ga.remove(g);
4343                        }
4344                    }
4345                }
4346                self.invalidate_iam_cache(None);
4347            }
4348        }
4349        self.persist_iam_to_kv();
4350        Ok(())
4351    }
4352
4353    /// Resolve the ordered list of effective policies for a user:
4354    /// group attachments first (least specific), then user
4355    /// attachments (most specific). Cached per user.
4356    pub fn effective_policies(&self, user: &UserId) -> Vec<Arc<Policy>> {
4357        if let Ok(cache) = self.iam_effective_cache.read() {
4358            if let Some(hit) = cache.get(user) {
4359                return hit.clone();
4360            }
4361        }
4362        let policies = self.policies.read();
4363        let user_attachments = self.user_attachments.read();
4364        let group_attachments = self.group_attachments.read();
4365        let mut groups = self
4366            .user_attributes
4367            .read()
4368            .ok()
4369            .and_then(|m| m.get(user).map(|attrs| attrs.groups.clone()))
4370            .unwrap_or_default();
4371        groups.insert(0, PUBLIC_IAM_GROUP.to_string());
4372        let mut out: Vec<Arc<Policy>> = Vec::new();
4373        if let (Ok(p_map), Ok(ua_map), Ok(ga_map)) = (policies, user_attachments, group_attachments)
4374        {
4375            for group in groups {
4376                if let Some(ids) = ga_map.get(&group) {
4377                    for id in ids {
4378                        if let Some(p) = p_map.get(id) {
4379                            out.push(p.clone());
4380                        }
4381                    }
4382                }
4383            }
4384            if let Some(ids) = ua_map.get(user) {
4385                for id in ids {
4386                    if let Some(p) = p_map.get(id) {
4387                        out.push(p.clone());
4388                    }
4389                }
4390            }
4391        }
4392        if let Ok(mut cache) = self.iam_effective_cache.write() {
4393            cache.insert(user.clone(), out.clone());
4394        }
4395        out
4396    }
4397
4398    /// Run the policy simulator for a principal. Synthesises an
4399    /// `EvalContext` from the user record + caller-supplied extras.
4400    pub fn simulate(
4401        &self,
4402        principal: &UserId,
4403        action: &str,
4404        resource: &ResourceRef,
4405        ctx_extras: SimCtx,
4406    ) -> SimulationOutcome {
4407        let user_role = self
4408            .users
4409            .read()
4410            .ok()
4411            .and_then(|u| u.get(principal).map(|u| u.role));
4412        let principal_is_admin_role = user_role == Some(Role::Admin);
4413        let now = ctx_extras.now_ms.unwrap_or_else(now_ms);
4414        let ctx = EvalContext {
4415            principal_tenant: principal.tenant.clone(),
4416            current_tenant: ctx_extras.current_tenant,
4417            peer_ip: ctx_extras.peer_ip,
4418            mfa_present: ctx_extras.mfa_present,
4419            now_ms: now,
4420            principal_is_admin_role,
4421            principal_is_platform_scoped: principal.tenant.is_none(),
4422        };
4423        let pols = self.effective_policies(principal);
4424        let refs: Vec<&Policy> = pols.iter().map(|p| p.as_ref()).collect();
4425        iam_policies::simulate(&refs, action, resource, &ctx)
4426    }
4427
4428    /// Production hot-path policy evaluation. Returns `true` on Allow
4429    /// / AdminBypass, `false` on Deny / DefaultDeny.
4430    ///
4431    /// This entry point is **strict**: it never consults the
4432    /// [`PolicyEnforcementMode`] fallback. Governance APIs that gate
4433    /// admin-tier mutations (managed-config writes, registry
4434    /// supersedes, managed-policy lifecycle) call this so they cannot
4435    /// accidentally pick up the lenient `LegacyRbac` posture. Runtime
4436    /// hot-path callers that should respect the mode call
4437    /// [`AuthStore::check_policy_authz_with_role`] instead.
4438    pub fn check_policy_authz(
4439        &self,
4440        principal: &UserId,
4441        action: &str,
4442        resource: &ResourceRef,
4443        ctx: &EvalContext,
4444    ) -> bool {
4445        let pols = self.effective_policies(principal);
4446        let refs: Vec<&Policy> = pols.iter().map(|p| p.as_ref()).collect();
4447        let decision = iam_policies::evaluate(&refs, action, resource, ctx);
4448        matches!(
4449            decision,
4450            iam_policies::Decision::Allow { .. } | iam_policies::Decision::AdminBypass
4451        )
4452    }
4453
4454    /// Mode-aware policy evaluation for runtime SQL/HTTP/wire surfaces.
4455    ///
4456    /// Returns `true` on `Allow`/`AdminBypass`, `false` on an explicit
4457    /// `Deny`. On `DefaultDeny` (no statement matched) the result
4458    /// depends on the active [`PolicyEnforcementMode`]:
4459    ///
4460    /// * `LegacyRbac` — defer to
4461    ///   [`legacy_rbac_decision`][super::enforcement_mode::legacy_rbac_decision]
4462    ///   using the caller's `role`. This preserves the pre-#712
4463    ///   behaviour where a principal with no attached policy is gated
4464    ///   only by their role.
4465    /// * `PolicyOnly` — return `false`. A principal needs an explicit
4466    ///   matching `Allow` to be authorized.
4467    ///
4468    /// An explicit `Deny` always wins, irrespective of mode and role.
4469    pub fn check_policy_authz_with_role(
4470        &self,
4471        principal: &UserId,
4472        action: &str,
4473        resource: &ResourceRef,
4474        ctx: &EvalContext,
4475        role: Role,
4476    ) -> bool {
4477        let pols = self.effective_policies(principal);
4478        let refs: Vec<&Policy> = pols.iter().map(|p| p.as_ref()).collect();
4479        let decision = iam_policies::evaluate(&refs, action, resource, ctx);
4480        match decision {
4481            iam_policies::Decision::Allow { .. } | iam_policies::Decision::AdminBypass => true,
4482            iam_policies::Decision::Deny { .. } => false,
4483            iam_policies::Decision::DefaultDeny => match self.enforcement_mode() {
4484                PolicyEnforcementMode::LegacyRbac => legacy_rbac_decision(role, action),
4485                PolicyEnforcementMode::PolicyOnly => false,
4486            },
4487        }
4488    }
4489
4490    // -----------------------------------------------------------------
4491    // PolicyEnforcementMode (#712)
4492    // -----------------------------------------------------------------
4493
4494    /// Read the active enforcement mode. Cheap (a single `RwLock` read);
4495    /// safe to call on the hot path.
4496    pub fn enforcement_mode(&self) -> PolicyEnforcementMode {
4497        *self
4498            .enforcement_mode
4499            .read()
4500            .unwrap_or_else(|e| e.into_inner())
4501    }
4502
4503    /// Overwrite the active enforcement mode and persist it to the
4504    /// vault KV so the new value survives a restart. Returns the
4505    /// previous value so callers logging a transition (e.g. the
4506    /// boot-time loader, the S5B migration command) can record the
4507    /// before/after.
4508    pub fn set_enforcement_mode(&self, mode: PolicyEnforcementMode) -> PolicyEnforcementMode {
4509        let prev = {
4510            let mut guard = self
4511                .enforcement_mode
4512                .write()
4513                .unwrap_or_else(|e| e.into_inner());
4514            let prev = *guard;
4515            *guard = mode;
4516            prev
4517        };
4518        self.vault_kv_set(
4519            "red.config.policy.enforcement_mode".to_string(),
4520            mode.as_str().to_string(),
4521        );
4522        prev
4523    }
4524
4525    /// Claim the "emit the one-time legacy-RBAC boot warning" token.
4526    /// Returns `true` on the first call after construction (or after a
4527    /// process restart) **iff** the active mode is `LegacyRbac`;
4528    /// returns `false` on every subsequent call so the boot path can
4529    /// guarantee the warning is logged at most once per boot.
4530    pub fn take_legacy_rbac_warn_once(&self) -> bool {
4531        if self.enforcement_mode() != PolicyEnforcementMode::LegacyRbac {
4532            return false;
4533        }
4534        !self
4535            .legacy_rbac_boot_warn_emitted
4536            .swap(true, Ordering::AcqRel)
4537    }
4538
4539    /// Evaluate a resolved table projection through the column policy
4540    /// gate. Query paths should pass already-resolved column names; this
4541    /// helper intentionally does not parse SQL projection syntax.
4542    pub fn check_column_projection_authz(
4543        &self,
4544        principal: &UserId,
4545        request: &ColumnAccessRequest,
4546        ctx: &EvalContext,
4547    ) -> ColumnPolicyOutcome {
4548        let pols = self.effective_policies(principal);
4549        let refs: Vec<&Policy> = pols.iter().map(|p| p.as_ref()).collect();
4550        ColumnPolicyGate::new(&refs).evaluate(request, ctx)
4551    }
4552
4553    fn invalidate_iam_cache(&self, uid: Option<&UserId>) {
4554        if let Ok(mut cache) = self.iam_effective_cache.write() {
4555            match uid {
4556                Some(u) => {
4557                    cache.remove(u);
4558                }
4559                None => cache.clear(),
4560            }
4561        }
4562    }
4563
4564    /// Drop every effective-policy cache entry. Called from execution
4565    /// paths that mutate policies/attachments without knowing which
4566    /// users will be affected.
4567    pub fn invalidate_all_iam_cache(&self) {
4568        self.invalidate_iam_cache(None);
4569    }
4570
4571    // -----------------------------------------------------------------
4572    // IAM persistence — vault_kv backed under `red.iam.*` keys
4573    // -----------------------------------------------------------------
4574
4575    /// Reload IAM state (policies + attachments) from the vault KV.
4576    /// Replaces the legacy `rehydrate_acl` reader — pre-1.0 we drop
4577    /// the old `red.acl.*` blob format entirely.
4578    pub fn rehydrate_iam(&self) {
4579        let mut enabled = self
4580            .vault_kv_get("red.iam.enabled")
4581            .map(|v| v == "true")
4582            .unwrap_or(false);
4583        // Policies — single JSON object keyed by id.
4584        if let Some(blob) = self.vault_kv_get("red.iam.policies") {
4585            if let Ok(val) = crate::serde_json::from_str::<crate::serde_json::Value>(&blob) {
4586                if let Some(obj) = val.as_object() {
4587                    let mut map = HashMap::new();
4588                    for (id, body) in obj.iter() {
4589                        let s = body.to_string_compact();
4590                        if let Ok(p) = Policy::from_json_str(&s) {
4591                            map.insert(id.clone(), Arc::new(p));
4592                        }
4593                    }
4594                    if !map.is_empty() {
4595                        enabled = true;
4596                    }
4597                    *self.policies.write().unwrap_or_else(|e| e.into_inner()) = map;
4598                }
4599            }
4600        }
4601        // User attachments.
4602        if let Some(blob) = self.vault_kv_get("red.iam.attachments.users") {
4603            if let Ok(val) = crate::serde_json::from_str::<crate::serde_json::Value>(&blob) {
4604                if let Some(obj) = val.as_object() {
4605                    let mut map: HashMap<UserId, Vec<String>> = HashMap::new();
4606                    for (encoded_uid, ids_v) in obj.iter() {
4607                        let Some(uid) = decode_uid(encoded_uid) else {
4608                            continue;
4609                        };
4610                        if let Some(arr) = ids_v.as_array() {
4611                            let ids: Vec<String> = arr
4612                                .iter()
4613                                .filter_map(|v| v.as_str().map(|s| s.to_string()))
4614                                .collect();
4615                            map.insert(uid, ids);
4616                        }
4617                    }
4618                    *self
4619                        .user_attachments
4620                        .write()
4621                        .unwrap_or_else(|e| e.into_inner()) = map;
4622                }
4623            }
4624        }
4625        // Group attachments.
4626        if let Some(blob) = self.vault_kv_get("red.iam.attachments.groups") {
4627            if let Ok(val) = crate::serde_json::from_str::<crate::serde_json::Value>(&blob) {
4628                if let Some(obj) = val.as_object() {
4629                    let mut map: HashMap<String, Vec<String>> = HashMap::new();
4630                    for (g, ids_v) in obj.iter() {
4631                        if let Some(arr) = ids_v.as_array() {
4632                            let ids: Vec<String> = arr
4633                                .iter()
4634                                .filter_map(|v| v.as_str().map(|s| s.to_string()))
4635                                .collect();
4636                            map.insert(g.clone(), ids);
4637                        }
4638                    }
4639                    *self
4640                        .group_attachments
4641                        .write()
4642                        .unwrap_or_else(|e| e.into_inner()) = map;
4643                }
4644            }
4645        }
4646        self.iam_authorization_enabled
4647            .store(enabled, Ordering::Release);
4648        self.invalidate_iam_cache(None);
4649
4650        // #712 / S5A: load persisted enforcement mode, if any. Absence
4651        // of the key means an existing install upgrading past #712 —
4652        // stay on the lenient default written at construction time.
4653        // Fresh installs receive `PolicyOnly` via the bootstrap path
4654        // (`mark_bootstrap_complete`) before the first restart, so this
4655        // hydrate path picks it up on every subsequent boot.
4656        if let Some(stored) = self.vault_kv_get("red.config.policy.enforcement_mode") {
4657            if let Some(mode) = PolicyEnforcementMode::parse(&stored) {
4658                *self
4659                    .enforcement_mode
4660                    .write()
4661                    .unwrap_or_else(|e| e.into_inner()) = mode;
4662            }
4663        }
4664
4665        // #712 / S5A: legacy_rbac is a transitional posture; emit one
4666        // boot-time warning so operators see the migration nudge in
4667        // their logs. The flag inside take_legacy_rbac_warn_once
4668        // guarantees we don't spam reload cycles.
4669        if self.take_legacy_rbac_warn_once() {
4670            tracing::warn!(
4671                target: "reddb::auth::enforcement_mode",
4672                mode = "legacy_rbac",
4673                hard_version = crate::auth::enforcement_mode::POLICY_ONLY_HARD_VERSION,
4674                "policy enforcement_mode=legacy_rbac (transitional). Run \
4675                 `MIGRATE POLICY MODE TO 'policy_only'` (next slice S5B) \
4676                 before {} to flip this install over to the strict posture.",
4677                crate::auth::enforcement_mode::POLICY_ONLY_HARD_VERSION,
4678            );
4679        }
4680    }
4681
4682    /// Snapshot policies + attachments into the vault KV. Called
4683    /// after every mutation.
4684    fn persist_iam_to_kv(&self) {
4685        let enabled = if self.iam_authorization_enabled() {
4686            "true"
4687        } else {
4688            "false"
4689        };
4690        self.vault_kv_set("red.iam.enabled".to_string(), enabled.to_string());
4691
4692        // Policies: `{ "<id>": <policy_json>, ... }`
4693        let policies_obj = {
4694            let map = self.policies.read().unwrap_or_else(|e| e.into_inner());
4695            let mut obj = crate::serde_json::Map::new();
4696            for (id, p) in map.iter() {
4697                let s = p.to_json_string();
4698                if let Ok(v) = crate::serde_json::from_str::<crate::serde_json::Value>(&s) {
4699                    obj.insert(id.clone(), v);
4700                }
4701            }
4702            crate::serde_json::Value::Object(obj).to_string_compact()
4703        };
4704        self.vault_kv_set("red.iam.policies".to_string(), policies_obj);
4705
4706        // User attachments: `{ "<encoded_uid>": [ "<policy_id>", ... ], ... }`
4707        let users_obj = {
4708            let map = self
4709                .user_attachments
4710                .read()
4711                .unwrap_or_else(|e| e.into_inner());
4712            let mut obj = crate::serde_json::Map::new();
4713            for (uid, ids) in map.iter() {
4714                let arr = crate::serde_json::Value::Array(
4715                    ids.iter()
4716                        .map(|s| crate::serde_json::Value::String(s.clone()))
4717                        .collect(),
4718                );
4719                obj.insert(encode_uid(uid), arr);
4720            }
4721            crate::serde_json::Value::Object(obj).to_string_compact()
4722        };
4723        self.vault_kv_set("red.iam.attachments.users".to_string(), users_obj);
4724
4725        // Group attachments.
4726        let groups_obj = {
4727            let map = self
4728                .group_attachments
4729                .read()
4730                .unwrap_or_else(|e| e.into_inner());
4731            let mut obj = crate::serde_json::Map::new();
4732            for (g, ids) in map.iter() {
4733                let arr = crate::serde_json::Value::Array(
4734                    ids.iter()
4735                        .map(|s| crate::serde_json::Value::String(s.clone()))
4736                        .collect(),
4737                );
4738                obj.insert(g.clone(), arr);
4739            }
4740            crate::serde_json::Value::Object(obj).to_string_compact()
4741        };
4742        self.vault_kv_set("red.iam.attachments.groups".to_string(), groups_obj);
4743    }
4744}
4745
4746fn synthetic_grant_matches(policy: &Policy, resource: &Resource, actions: &[Action]) -> bool {
4747    policy.statements.iter().any(|st| {
4748        st.effect == crate::auth::policies::Effect::Allow
4749            && st.condition.is_none()
4750            && grant_actions_overlap(&st.actions, actions)
4751            && grant_resource_matches(&st.resources, resource)
4752    })
4753}
4754
4755fn grant_actions_overlap(
4756    patterns: &[crate::auth::policies::ActionPattern],
4757    actions: &[Action],
4758) -> bool {
4759    if actions.contains(&Action::All) {
4760        return true;
4761    }
4762    patterns.iter().any(|pat| match pat {
4763        crate::auth::policies::ActionPattern::Wildcard => true,
4764        crate::auth::policies::ActionPattern::Exact(s) => {
4765            actions.iter().any(|a| s.eq_ignore_ascii_case(a.as_str()))
4766        }
4767        crate::auth::policies::ActionPattern::Prefix(_) => false,
4768    })
4769}
4770
4771fn grant_resource_matches(
4772    patterns: &[crate::auth::policies::ResourcePattern],
4773    resource: &Resource,
4774) -> bool {
4775    let expected = grant_resource_pattern(resource);
4776    patterns.iter().any(|pat| pat == &expected)
4777}
4778
4779fn grant_resource_pattern(resource: &Resource) -> crate::auth::policies::ResourcePattern {
4780    use crate::auth::policies::ResourcePattern;
4781
4782    match resource {
4783        Resource::Database => ResourcePattern::Glob("table:*".to_string()),
4784        Resource::Schema(s) => ResourcePattern::Glob(format!("table:{s}.*")),
4785        Resource::Table { schema, table } => ResourcePattern::Exact {
4786            kind: "table".to_string(),
4787            name: match schema {
4788                Some(s) => format!("{s}.{table}"),
4789                None => table.clone(),
4790            },
4791        },
4792        Resource::Function { schema, name } => ResourcePattern::Exact {
4793            kind: "function".to_string(),
4794            name: match schema {
4795                Some(s) => format!("{s}.{name}"),
4796                None => name.clone(),
4797            },
4798        },
4799    }
4800}
4801
4802// ===========================================================================
4803// ACL serialization helpers — line-oriented, human-readable so an
4804// operator inspecting the vault dump can spot misconfigurations.
4805//
4806// Format (one record per line):
4807//   GRANT|<resource>|<actions_csv>|<with_grant_option>|<tenant_or_*>|<granted_by>|<granted_at>
4808//   ATTR|<valid_until>|<connection_limit>|<search_path>
4809//
4810// Resources are encoded as:
4811//   db                          → Database
4812//   schema:<name>               → Schema(name)
4813//   table:<schema_or_*>:<name>  → Table { schema, table }
4814//   func:<schema_or_*>:<name>   → Function { schema, name }
4815// ===========================================================================
4816
4817fn encode_uid(uid: &UserId) -> String {
4818    match &uid.tenant {
4819        Some(t) => format!("{}/{}", t, uid.username),
4820        None => format!("*/{}", uid.username),
4821    }
4822}
4823
4824fn decode_uid(s: &str) -> Option<UserId> {
4825    let (tenant, username) = s.split_once('/')?;
4826    Some(if tenant == "*" {
4827        UserId::platform(username)
4828    } else {
4829        UserId::scoped(tenant, username)
4830    })
4831}
4832
4833fn encode_resource(r: &Resource) -> String {
4834    match r {
4835        Resource::Database => "db".into(),
4836        Resource::Schema(s) => format!("schema:{}", s),
4837        Resource::Table { schema, table } => {
4838            format!("table:{}:{}", schema.as_deref().unwrap_or("*"), table)
4839        }
4840        Resource::Function { schema, name } => {
4841            format!("func:{}:{}", schema.as_deref().unwrap_or("*"), name)
4842        }
4843    }
4844}
4845
4846fn decode_resource(s: &str) -> Option<Resource> {
4847    if s == "db" {
4848        return Some(Resource::Database);
4849    }
4850    if let Some(rest) = s.strip_prefix("schema:") {
4851        return Some(Resource::Schema(rest.to_string()));
4852    }
4853    if let Some(rest) = s.strip_prefix("table:") {
4854        let (schema, table) = rest.split_once(':')?;
4855        return Some(Resource::Table {
4856            schema: if schema == "*" {
4857                None
4858            } else {
4859                Some(schema.to_string())
4860            },
4861            table: table.to_string(),
4862        });
4863    }
4864    if let Some(rest) = s.strip_prefix("func:") {
4865        let (schema, name) = rest.split_once(':')?;
4866        return Some(Resource::Function {
4867            schema: if schema == "*" {
4868                None
4869            } else {
4870                Some(schema.to_string())
4871            },
4872            name: name.to_string(),
4873        });
4874    }
4875    None
4876}
4877
4878fn encode_grants_blob(grants: &[Grant]) -> String {
4879    let mut out = String::new();
4880    for g in grants {
4881        let actions: Vec<&str> = g.actions.iter().map(|a| a.as_str()).collect();
4882        out.push_str(&format!(
4883            "GRANT|{}|{}|{}|{}|{}|{}\n",
4884            encode_resource(&g.resource),
4885            actions.join(","),
4886            g.with_grant_option,
4887            g.tenant.as_deref().unwrap_or("*"),
4888            g.granted_by,
4889            g.granted_at,
4890        ));
4891    }
4892    out
4893}
4894
4895fn decode_grants_blob(s: &str) -> Option<Vec<Grant>> {
4896    let mut out = Vec::new();
4897    for line in s.lines() {
4898        if line.is_empty() {
4899            continue;
4900        }
4901        let parts: Vec<&str> = line.split('|').collect();
4902        if parts.len() != 7 || parts[0] != "GRANT" {
4903            return None;
4904        }
4905        let resource = decode_resource(parts[1])?;
4906        let mut actions = std::collections::BTreeSet::new();
4907        for token in parts[2].split(',') {
4908            if let Some(a) = Action::from_keyword(token) {
4909                actions.insert(a);
4910            }
4911        }
4912        let with_grant_option = parts[3] == "true";
4913        let tenant = if parts[4] == "*" {
4914            None
4915        } else {
4916            Some(parts[4].to_string())
4917        };
4918        let granted_by = parts[5].to_string();
4919        let granted_at: u128 = parts[6].parse().unwrap_or(0);
4920        out.push(Grant {
4921            // Principal field is reconstructed by the loader from the
4922            // storage-key prefix; default to `Public` here.
4923            principal: GrantPrincipal::Public,
4924            resource,
4925            actions,
4926            with_grant_option,
4927            granted_by,
4928            granted_at,
4929            tenant,
4930            columns: None,
4931        });
4932    }
4933    Some(out)
4934}
4935
4936fn encode_attrs_blob(a: &UserAttributes) -> String {
4937    let valid = a
4938        .valid_until
4939        .map(|t| t.to_string())
4940        .unwrap_or_else(|| "*".into());
4941    let limit = a
4942        .connection_limit
4943        .map(|l| l.to_string())
4944        .unwrap_or_else(|| "*".into());
4945    let path = a.search_path.clone().unwrap_or_else(|| "*".into());
4946    let groups = if a.groups.is_empty() {
4947        "*".to_string()
4948    } else {
4949        a.groups.join(",")
4950    };
4951    format!("ATTR|{}|{}|{}|{}\n", valid, limit, path, groups)
4952}
4953
4954fn decode_attrs_blob(s: &str) -> Option<UserAttributes> {
4955    let line = s.lines().next()?;
4956    let parts: Vec<&str> = line.split('|').collect();
4957    if !(parts.len() == 4 || parts.len() == 5) || parts[0] != "ATTR" {
4958        return None;
4959    }
4960    let groups = if parts.get(4).copied().unwrap_or("*") == "*" {
4961        Vec::new()
4962    } else {
4963        parts[4]
4964            .split(',')
4965            .filter(|g| !g.is_empty())
4966            .map(|g| g.to_string())
4967            .collect()
4968    };
4969    Some(UserAttributes {
4970        valid_until: if parts[1] == "*" {
4971            None
4972        } else {
4973            parts[1].parse().ok()
4974        },
4975        connection_limit: if parts[2] == "*" {
4976            None
4977        } else {
4978            parts[2].parse().ok()
4979        },
4980        search_path: if parts[3] == "*" {
4981            None
4982        } else {
4983            Some(parts[3].to_string())
4984        },
4985        groups,
4986    })
4987}
4988
4989// ===========================================================================
4990// Password hashing
4991// ===========================================================================
4992
4993/// Derive a SCRAM-SHA-256 verifier for a fresh user / password
4994/// rotation. Salt is 16 random bytes; iter is the engine default
4995/// (`scram::DEFAULT_ITER`). Stored alongside the Argon2 password
4996/// hash so HTTP login + v2 SCRAM can both authenticate the same
4997/// user.
4998fn make_scram_verifier(password: &str) -> crate::auth::scram::ScramVerifier {
4999    let salt = random_bytes(16);
5000    crate::auth::scram::ScramVerifier::from_password(
5001        password,
5002        salt,
5003        crate::auth::scram::DEFAULT_ITER,
5004    )
5005}
5006
5007/// Argon2id cost parameters for password hashing, at the OWASP floor
5008/// (19 MiB, t=2, p=1). The previous in-tree KDF ran at 4 MiB / t=3, which
5009/// is below that floor; the parameters are encoded in the PHC string so
5010/// they can be raised again without another format change.
5011const PASSWORD_M_COST_KIB: u32 = 19 * 1024;
5012const PASSWORD_T_COST: u32 = 2;
5013const PASSWORD_P_COST: u32 = 1;
5014const PASSWORD_TAG_LEN: usize = 32;
5015
5016fn password_hasher() -> argon2::Argon2<'static> {
5017    let params = argon2::Params::new(
5018        PASSWORD_M_COST_KIB,
5019        PASSWORD_T_COST,
5020        PASSWORD_P_COST,
5021        Some(PASSWORD_TAG_LEN),
5022    )
5023    .expect("Argon2id parameters are constant and valid");
5024    argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
5025}
5026
5027/// Hash a password with Argon2id (RFC 9106) via the RustCrypto `argon2`
5028/// crate, encoded as a PHC string:
5029/// `$argon2id$v=19$m=19456,t=2,p=1$<salt_b64>$<hash_b64>`.
5030///
5031/// The previous in-tree implementation was not RFC 9106 — its `G` mixing
5032/// function added the block indices where the specification calls for the
5033/// `2·lo32(a)·lo32(b)` term, and its compression function was ad hoc — so
5034/// its memory-hardness was unknown. Hashes in the old
5035/// `argon2id$<salt_hex>$<hash_hex>` form still verify (see
5036/// [`verify_password`]) and are transparently upgraded on the owner's next
5037/// successful login.
5038pub(crate) fn hash_password(password: &str) -> String {
5039    use argon2::PasswordHasher;
5040    // The salt comes from the OS CSPRNG inside the hasher and, like
5041    // `random_bytes`, fails closed rather than degrading.
5042    password_hasher()
5043        .hash_password(password.as_bytes())
5044        .expect("Argon2id hashing cannot fail for valid parameters and a working CSPRNG")
5045        .to_string()
5046}
5047
5048/// Whether `stored_hash` was produced by the retired in-tree KDF and should
5049/// be re-hashed the next time its owner authenticates.
5050pub(crate) fn password_hash_needs_upgrade(stored_hash: &str) -> bool {
5051    stored_hash.starts_with("argon2id$")
5052}
5053
5054/// Verify a password against either encoding: the current PHC string, or a
5055/// legacy `argon2id$<salt_hex>$<hash_hex>` produced by the retired in-tree
5056/// KDF. Legacy hashes stay verifiable so an upgrade does not lock every
5057/// account out; [`password_hash_needs_upgrade`] drives the rehash.
5058pub(crate) fn verify_password(password: &str, stored_hash: &str) -> bool {
5059    if let Some(rest) = stored_hash.strip_prefix("argon2id$") {
5060        return verify_legacy_password(password, rest);
5061    }
5062    use argon2::{PasswordHash, PasswordVerifier};
5063    let Ok(parsed) = stored_hash.parse::<PasswordHash>() else {
5064        return false;
5065    };
5066    password_hasher()
5067        .verify_password(password.as_bytes(), &parsed)
5068        .is_ok()
5069}
5070
5071/// Verify against the retired in-tree KDF, whose stored form is
5072/// `<salt_hex>$<hash_hex>` (the `argon2id$` prefix is already stripped).
5073fn verify_legacy_password(password: &str, salt_and_hash: &str) -> bool {
5074    let Some((salt_hex, hash_hex)) = salt_and_hash.split_once('$') else {
5075        return false;
5076    };
5077    let (Ok(salt), Ok(expected)) = (hex::decode(salt_hex), hex::decode(hash_hex)) else {
5078        return false;
5079    };
5080    let computed = derive_key(password.as_bytes(), &salt, &auth_argon2_params());
5081    constant_time_eq(&computed, &expected)
5082}
5083
5084/// Constant-time byte comparison — one implementation for the crate.
5085fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5086    crate::crypto::constant_time_eq(a, b)
5087}
5088
5089// ===========================================================================
5090// Token generation
5091// ===========================================================================
5092
5093fn generate_session_token() -> String {
5094    format!("rs_{}", hex::encode(random_bytes(32)))
5095}
5096
5097fn generate_api_key() -> String {
5098    format!("rk_{}", hex::encode(random_bytes(32)))
5099}
5100
5101/// Generate `n` random bytes and return as a hex string.
5102fn random_hex(n: usize) -> String {
5103    hex::encode(random_bytes(n))
5104}
5105
5106/// Generate `n` cryptographically random bytes using the OS CSPRNG,
5107/// then mix with SHA-256 for domain separation.
5108pub(crate) fn random_bytes(n: usize) -> Vec<u8> {
5109    let mut buf = vec![0u8; n.max(32)];
5110    // Fail closed: these bytes become session tokens, API keys, salts and
5111    // AES-GCM nonces. A predictable fallback (the old time-derived seed)
5112    // is strictly worse than refusing the operation.
5113    if let Err(err) = os_random::fill_bytes(&mut buf) {
5114        panic!("OS CSPRNG unavailable; refusing to generate credential material: {err}");
5115    }
5116    // SHA-256 mix to ensure uniform distribution.
5117    let digest = sha256(&buf);
5118    if n <= 32 {
5119        digest[..n].to_vec()
5120    } else {
5121        // Chain SHA-256 for longer outputs (unusual but supported).
5122        let mut out = Vec::with_capacity(n);
5123        let mut prev = digest;
5124        while out.len() < n {
5125            out.extend_from_slice(&prev[..std::cmp::min(32, n - out.len())]);
5126            prev = sha256(&prev);
5127        }
5128        out
5129    }
5130}
5131
5132// ===========================================================================
5133// Helpers
5134// ===========================================================================
5135
5136fn lock_err<T>(_: T) -> AuthError {
5137    AuthError::Internal("lock poisoned".to_string())
5138}
5139
5140// ===========================================================================
5141// Tests
5142// ===========================================================================
5143
5144#[cfg(test)]
5145mod tests {
5146    use super::*;
5147
5148    fn test_config() -> AuthConfig {
5149        AuthConfig {
5150            enabled: true,
5151            session_ttl_secs: 60,
5152            require_auth: true,
5153            auto_encrypt_storage: false,
5154            vault_enabled: false,
5155            cert: Default::default(),
5156            oauth: Default::default(),
5157        }
5158    }
5159
5160    #[test]
5161    fn test_create_and_list_users() {
5162        let store = AuthStore::new(test_config());
5163        store.create_user("alice", "pass1", Role::Admin).unwrap();
5164        store.create_user("bob", "pass2", Role::Read).unwrap();
5165
5166        let users = store.list_users();
5167        assert_eq!(users.len(), 2);
5168        // Password hashes should be redacted.
5169        for u in &users {
5170            assert!(u.password_hash.is_empty());
5171        }
5172    }
5173
5174    #[test]
5175    fn test_create_duplicate_user() {
5176        let store = AuthStore::new(test_config());
5177        store.create_user("alice", "pass", Role::Admin).unwrap();
5178        let err = store.create_user("alice", "pass2", Role::Read).unwrap_err();
5179        assert!(matches!(err, AuthError::UserExists(_)));
5180    }
5181
5182    struct RecordingLedger(std::sync::Mutex<Vec<(String, String)>>);
5183
5184    impl crate::runtime::control_events::ControlEventLedger for RecordingLedger {
5185        fn emit(
5186            &self,
5187            _ctx: &ControlEventCtx<'_>,
5188            event: ControlEvent,
5189        ) -> Result<
5190            crate::runtime::control_events::EventId,
5191            crate::runtime::control_events::ControlEventError,
5192        > {
5193            self.0.lock().unwrap().push((
5194                event.kind.as_str().to_string(),
5195                event.outcome.as_str().to_string(),
5196            ));
5197            Ok(crate::runtime::control_events::EventId("evt".to_string()))
5198        }
5199    }
5200
5201    #[test]
5202    fn logins_land_in_the_control_event_ledger() {
5203        let store = AuthStore::new(test_config());
5204        store.create_user("dave", "pw", Role::Read).unwrap();
5205        let ledger = std::sync::Arc::new(RecordingLedger(std::sync::Mutex::new(Vec::new())));
5206        store.configure_control_events(
5207            ledger.clone(),
5208            crate::runtime::control_events::ControlEventConfig::default(),
5209        );
5210        store.authenticate("dave", "pw").expect("login");
5211        store
5212            .authenticate("dave", "nope")
5213            .expect_err("bad password");
5214        store
5215            .authenticate("nobody", "pw")
5216            .expect_err("unknown user");
5217        let seen = ledger.0.lock().unwrap().clone();
5218        let allowed = Outcome::Allowed.as_str().to_string();
5219        let denied = Outcome::Denied.as_str().to_string();
5220        assert!(
5221            seen.contains(&("auth.login".to_string(), allowed)),
5222            "success not recorded: {seen:?}"
5223        );
5224        assert_eq!(
5225            seen.iter()
5226                .filter(|(k, o)| k == "auth.login_failed" && *o == denied)
5227                .count(),
5228            2,
5229            "both refusals must be recorded: {seen:?}"
5230        );
5231    }
5232
5233    #[test]
5234    fn api_keys_are_hashed_at_rest_and_expire() {
5235        let store = AuthStore::new(test_config());
5236        store.create_user("erin", "pw", Role::Write).unwrap();
5237        let key = store
5238            .create_api_key_in_tenant_expiring(None, "erin", "ci", Role::Read, Some(now_ms() + 60))
5239            .unwrap();
5240        assert!(key.key.starts_with("rk_"));
5241
5242        let stored = store
5243            .users
5244            .read()
5245            .unwrap()
5246            .get(&UserId::from_parts(None, "erin"))
5247            .unwrap()
5248            .api_keys[0]
5249            .clone();
5250        assert!(stored.key.starts_with("rkh_"), "{}", stored.key);
5251        assert_ne!(stored.key, key.key, "the secret must not be stored");
5252        assert_eq!(stored.key, api_key_credential_id(&key.key));
5253
5254        assert!(store.validate_token(&key.key).is_some());
5255        std::thread::sleep(std::time::Duration::from_millis(80));
5256        assert!(
5257            store.validate_token(&key.key).is_none(),
5258            "an expired key must not validate"
5259        );
5260
5261        // Revocation accepts the stored id as well as the secret.
5262        store.revoke_api_key(&stored.key).unwrap();
5263        assert!(store.revoke_api_key(&key.key).is_err());
5264    }
5265
5266    #[test]
5267    fn revoke_is_clamped_to_the_granter_tenant() {
5268        use crate::auth::privileges::{Action, GrantPrincipal, Resource};
5269        let store = AuthStore::new(test_config());
5270        let acme_admin = UserId::from_parts(Some("acme"), "adm");
5271        let globex_user = UserId::from_parts(Some("globex"), "u");
5272        let err = store
5273            .revoke(
5274                &acme_admin,
5275                Role::Admin,
5276                &GrantPrincipal::User(globex_user),
5277                &Resource::table_from_name("orders"),
5278                &[Action::Select],
5279            )
5280            .expect_err("a tenant admin must not touch another tenant's grants");
5281        assert!(err.to_string().contains("cross-tenant"), "{err}");
5282        // Platform admin may.
5283        store
5284            .revoke(
5285                &UserId::from_parts(None, "platform"),
5286                Role::Admin,
5287                &GrantPrincipal::User(UserId::from_parts(Some("globex"), "u")),
5288                &Resource::table_from_name("orders"),
5289                &[Action::Select],
5290            )
5291            .expect("platform admin revokes anywhere");
5292    }
5293
5294    #[test]
5295    fn role_change_revokes_sessions_minted_under_the_old_role() {
5296        let store = AuthStore::new(test_config());
5297        store.create_user("carol", "pw", Role::Write).unwrap();
5298        let session = store.authenticate("carol", "pw").unwrap();
5299        assert!(store.validate_token(&session.token).is_some());
5300        store.change_role("carol", Role::Read).unwrap();
5301        assert!(
5302            store.validate_token(&session.token).is_none(),
5303            "a demotion must not keep riding on the old session"
5304        );
5305    }
5306
5307    #[test]
5308    fn test_authenticate_and_validate() {
5309        let store = AuthStore::new(test_config());
5310        store.create_user("alice", "secret", Role::Write).unwrap();
5311
5312        let session = store.authenticate("alice", "secret").unwrap();
5313        assert!(session.token.starts_with("rs_"));
5314
5315        let (username, role) = store.validate_token(&session.token).unwrap();
5316        assert_eq!(username, "alice");
5317        assert_eq!(role, Role::Write);
5318    }
5319
5320    #[test]
5321    fn test_authenticate_wrong_password() {
5322        let store = AuthStore::new(test_config());
5323        store.create_user("alice", "secret", Role::Read).unwrap();
5324
5325        let err = store.authenticate("alice", "wrong").unwrap_err();
5326        assert!(matches!(err, AuthError::InvalidCredentials));
5327    }
5328
5329    #[test]
5330    fn test_api_key_lifecycle() {
5331        let store = AuthStore::new(test_config());
5332        store.create_user("alice", "pass", Role::Admin).unwrap();
5333
5334        let key = store
5335            .create_api_key("alice", "ci-token", Role::Write)
5336            .unwrap();
5337        assert!(key.key.starts_with("rk_"));
5338
5339        let (username, role) = store.validate_token(&key.key).unwrap();
5340        assert_eq!(username, "alice");
5341        assert_eq!(role, Role::Write);
5342
5343        store.revoke_api_key(&key.key).unwrap();
5344        assert!(store.validate_token(&key.key).is_none());
5345    }
5346
5347    #[test]
5348    fn test_api_key_role_exceeded() {
5349        let store = AuthStore::new(test_config());
5350        store.create_user("bob", "pass", Role::Read).unwrap();
5351
5352        let err = store
5353            .create_api_key("bob", "escalate", Role::Admin)
5354            .unwrap_err();
5355        assert!(matches!(err, AuthError::RoleExceeded { .. }));
5356    }
5357
5358    #[test]
5359    fn test_change_password() {
5360        let store = AuthStore::new(test_config());
5361        store.create_user("alice", "old", Role::Write).unwrap();
5362
5363        store.change_password("alice", "old", "new").unwrap();
5364
5365        // Old password should fail.
5366        assert!(store.authenticate("alice", "old").is_err());
5367        // New password should succeed.
5368        assert!(store.authenticate("alice", "new").is_ok());
5369    }
5370
5371    #[test]
5372    fn test_change_role() {
5373        let store = AuthStore::new(test_config());
5374        store.create_user("alice", "pass", Role::Admin).unwrap();
5375        store.create_api_key("alice", "key1", Role::Admin).unwrap();
5376
5377        store.change_role("alice", Role::Read).unwrap();
5378
5379        // User's role should be Read now.
5380        let users = store.list_users();
5381        let alice = users.iter().find(|u| u.username == "alice").unwrap();
5382        assert_eq!(alice.role, Role::Read);
5383
5384        // API keys should have been downgraded.
5385        assert_eq!(alice.api_keys[0].role, Role::Read);
5386    }
5387
5388    #[test]
5389    fn test_admin_user_mutations_are_policy_controlled_not_flag_guarded() {
5390        let store = AuthStore::new(test_config());
5391        store
5392            .create_admin_user("system", "pass", Role::Admin, None)
5393            .unwrap();
5394
5395        let uid = UserId::platform("system");
5396        store.change_password("system", "pass", "new").unwrap();
5397        store.change_role("system", Role::Read).unwrap();
5398        store.set_user_enabled(&uid, false).unwrap();
5399
5400        let key = store
5401            .create_api_key("system", "rotation", Role::Read)
5402            .unwrap();
5403        // The account is disabled at this point: every credential it owns
5404        // must stop validating, API keys included (they used to be resolved
5405        // straight from the index without consulting the user record).
5406        assert!(store.validate_token(&key.key).is_none());
5407        store.set_user_enabled(&uid, true).unwrap();
5408        assert!(store.validate_token(&key.key).is_some());
5409        store.revoke_api_key(&key.key).unwrap();
5410        assert!(store.validate_token(&key.key).is_none());
5411        store.delete_user("system").unwrap();
5412    }
5413
5414    #[test]
5415    fn test_user_lifecycle_deny_policy_blocks_cloud_admin_deletion() {
5416        use crate::auth::policies::Policy;
5417
5418        let store = AuthStore::new(test_config());
5419        store
5420            .create_user("cloud-admin", "pass", Role::Admin)
5421            .unwrap();
5422        store
5423            .create_user("customer-admin", "pass", Role::Admin)
5424            .unwrap();
5425
5426        store
5427            .put_policy(
5428                Policy::from_json_str(
5429                    r#"{
5430                        "id": "customer-admin-allow-all",
5431                        "version": 1,
5432                        "statements": [{
5433                            "effect": "allow",
5434                            "actions": ["*"],
5435                            "resources": ["*"]
5436                        }]
5437                    }"#,
5438                )
5439                .unwrap(),
5440            )
5441            .unwrap();
5442        store
5443            .put_policy(
5444                Policy::from_json_str(
5445                    r#"{
5446                        "id": "cloud-admin-protection",
5447                        "version": 1,
5448                        "statements": [{
5449                            "effect": "deny",
5450                            "actions": [
5451                                "user:delete",
5452                                "user:disable",
5453                                "user:password:change",
5454                                "user:role:update"
5455                            ],
5456                            "resources": ["user:cloud-admin"]
5457                        }]
5458                    }"#,
5459                )
5460                .unwrap(),
5461            )
5462            .unwrap();
5463        let customer = UserId::platform("customer-admin");
5464        store
5465            .attach_policy(
5466                PrincipalRef::User(customer.clone()),
5467                "customer-admin-allow-all",
5468            )
5469            .unwrap();
5470        store
5471            .attach_policy(
5472                PrincipalRef::User(customer.clone()),
5473                "cloud-admin-protection",
5474            )
5475            .unwrap();
5476
5477        let cloud_admin = UserId::platform("cloud-admin");
5478        assert!(!store.check_user_lifecycle_authz(
5479            &customer,
5480            Role::Admin,
5481            "user:delete",
5482            &cloud_admin,
5483        ));
5484        assert!(!store.check_user_lifecycle_authz(
5485            &customer,
5486            Role::Admin,
5487            "user:disable",
5488            &cloud_admin,
5489        ));
5490        assert!(!store.check_user_lifecycle_authz(
5491            &customer,
5492            Role::Admin,
5493            "user:password:change",
5494            &cloud_admin,
5495        ));
5496        assert!(!store.check_user_lifecycle_authz(
5497            &customer,
5498            Role::Admin,
5499            "user:role:update",
5500            &cloud_admin,
5501        ));
5502
5503        let another_user = UserId::platform("someone-else");
5504        assert!(store.check_user_lifecycle_authz(
5505            &customer,
5506            Role::Admin,
5507            "user:delete",
5508            &another_user,
5509        ));
5510    }
5511
5512    #[test]
5513    fn test_regular_user_mutations_still_work() {
5514        let store = AuthStore::new(test_config());
5515        store.create_user("alice", "old", Role::Admin).unwrap();
5516
5517        let uid = UserId::platform("alice");
5518        store.set_user_enabled(&uid, false).unwrap();
5519        assert!(matches!(
5520            store.authenticate("alice", "old"),
5521            Err(AuthError::InvalidCredentials)
5522        ));
5523
5524        store.set_user_enabled(&uid, true).unwrap();
5525        store.change_password("alice", "old", "new").unwrap();
5526        store.change_role("alice", Role::Read).unwrap();
5527        store.delete_user("alice").unwrap();
5528        assert!(matches!(
5529            store.authenticate("alice", "new"),
5530            Err(AuthError::InvalidCredentials)
5531        ));
5532    }
5533
5534    #[test]
5535    fn test_delete_user() {
5536        let store = AuthStore::new(test_config());
5537        store.create_user("alice", "pass", Role::Admin).unwrap();
5538        let key = store.create_api_key("alice", "key1", Role::Read).unwrap();
5539        let session = store.authenticate("alice", "pass").unwrap();
5540
5541        store.delete_user("alice").unwrap();
5542
5543        assert!(store.validate_token(&key.key).is_none());
5544        assert!(store.validate_token(&session.token).is_none());
5545        assert!(store.list_users().is_empty());
5546    }
5547
5548    #[test]
5549    fn test_revoke_session() {
5550        let store = AuthStore::new(test_config());
5551        store.create_user("alice", "pass", Role::Read).unwrap();
5552        let session = store.authenticate("alice", "pass").unwrap();
5553
5554        store.revoke_session(&session.token);
5555        assert!(store.validate_token(&session.token).is_none());
5556    }
5557
5558    #[test]
5559    fn test_password_hash_format() {
5560        // PHC string: `$argon2id$v=19$m=19456,t=2,p=1$<salt_b64>$<hash_b64>`.
5561        // Encoding the parameters is what lets the cost be raised later
5562        // without another format change — the retired in-tree form stored
5563        // only salt and tag, so its 4 MiB / t=3 cost was frozen.
5564        let hash = hash_password("test");
5565        assert!(
5566            hash.starts_with("$argon2id$v=19$"),
5567            "expected a PHC-encoded Argon2id hash, got: {hash}"
5568        );
5569        assert!(
5570            hash.contains(&format!(
5571                "m={PASSWORD_M_COST_KIB},t={PASSWORD_T_COST},p={PASSWORD_P_COST}"
5572            )),
5573            "the PHC string must carry the cost parameters: {hash}"
5574        );
5575        assert!(verify_password("test", &hash));
5576        assert!(!verify_password("wrong", &hash));
5577        assert!(
5578            !password_hash_needs_upgrade(&hash),
5579            "a freshly minted hash must not be flagged for upgrade"
5580        );
5581    }
5582
5583    #[test]
5584    fn legacy_password_hashes_still_verify_and_upgrade_on_login() {
5585        // Records written by the retired in-tree KDF must keep verifying —
5586        // otherwise the upgrade locks every existing account out — and must
5587        // be re-hashed with the real KDF once the plaintext is in hand.
5588        let legacy = {
5589            let salt = random_bytes(16);
5590            let hash = derive_key(b"hunter2", &salt, &auth_argon2_params());
5591            format!("argon2id${}${}", hex::encode(&salt), hex::encode(&hash))
5592        };
5593        assert!(verify_password("hunter2", &legacy));
5594        assert!(!verify_password("wrong", &legacy));
5595        assert!(password_hash_needs_upgrade(&legacy));
5596
5597        let store = AuthStore::new(test_config());
5598        store
5599            .create_user("erin", "placeholder", Role::Write)
5600            .unwrap();
5601        {
5602            let mut users = store.users.write().unwrap();
5603            users
5604                .get_mut(&UserId::platform("erin"))
5605                .unwrap()
5606                .password_hash = legacy.clone();
5607        }
5608
5609        store
5610            .authenticate("erin", "hunter2")
5611            .expect("a legacy hash must still authenticate");
5612
5613        let stored = store
5614            .users
5615            .read()
5616            .unwrap()
5617            .get(&UserId::platform("erin"))
5618            .unwrap()
5619            .password_hash
5620            .clone();
5621        assert!(
5622            stored.starts_with("$argon2id$v=19$"),
5623            "a successful login must upgrade the stored hash, got: {stored}"
5624        );
5625        assert!(store.authenticate("erin", "hunter2").is_ok());
5626    }
5627
5628    #[test]
5629    fn test_constant_time_eq() {
5630        assert!(constant_time_eq(b"hello", b"hello"));
5631        assert!(!constant_time_eq(b"hello", b"world"));
5632        assert!(!constant_time_eq(b"short", b"longer"));
5633    }
5634
5635    #[test]
5636    fn test_bootstrap_seals_permanently() {
5637        let store = AuthStore::new(test_config());
5638
5639        assert!(store.needs_bootstrap());
5640        assert!(!store.is_bootstrapped());
5641
5642        // First bootstrap succeeds
5643        let result = store.bootstrap("admin", "secret");
5644        assert!(result.is_ok());
5645        let br = result.unwrap();
5646        assert_eq!(br.user.username, "admin");
5647        assert_eq!(br.user.role, Role::Admin);
5648        assert!(br.api_key.key.starts_with("rk_"));
5649        // No vault configured, so no certificate.
5650        assert!(br.certificate.is_none());
5651
5652        // Sealed now
5653        assert!(!store.needs_bootstrap());
5654        assert!(store.is_bootstrapped());
5655
5656        // Second bootstrap fails -- sealed permanently
5657        let result = store.bootstrap("admin2", "secret2");
5658        assert!(result.is_err());
5659        let err = result.unwrap_err();
5660        assert!(err.to_string().contains("sealed permanently"));
5661
5662        // Only 1 user exists (the first one)
5663        assert_eq!(store.list_users().len(), 1);
5664        assert_eq!(store.list_users()[0].username, "admin");
5665    }
5666
5667    #[test]
5668    fn test_bootstrap_after_manual_user_creation() {
5669        let store = AuthStore::new(test_config());
5670
5671        // Create a user manually first
5672        store.create_user("existing", "pass", Role::Read).unwrap();
5673
5674        // Bootstrap sees the seal hasn't been set but users exist
5675        // The atomic seal fires first, then the users check catches it
5676        assert!(!store.needs_bootstrap()); // users exist → false
5677    }
5678
5679    // ---------------------------------------------------------------
5680    // Tenant scoping
5681    // ---------------------------------------------------------------
5682
5683    #[test]
5684    fn test_same_username_two_tenants_distinct() {
5685        let store = AuthStore::new(test_config());
5686        store
5687            .create_user_in_tenant(Some("acme"), "alice", "pw-acme", Role::Write)
5688            .unwrap();
5689        store
5690            .create_user_in_tenant(Some("globex"), "alice", "pw-globex", Role::Read)
5691            .unwrap();
5692
5693        // Two distinct users.
5694        let users = store.list_users();
5695        assert_eq!(users.len(), 2);
5696
5697        // Each verifies its own password under its own tenant.
5698        assert!(store
5699            .authenticate_in_tenant(Some("acme"), "alice", "pw-acme")
5700            .is_ok());
5701        assert!(store
5702            .authenticate_in_tenant(Some("globex"), "alice", "pw-globex")
5703            .is_ok());
5704
5705        // Cross-tenant credentials are rejected.
5706        assert!(store
5707            .authenticate_in_tenant(Some("acme"), "alice", "pw-globex")
5708            .is_err());
5709        assert!(store
5710            .authenticate_in_tenant(Some("globex"), "alice", "pw-acme")
5711            .is_err());
5712    }
5713
5714    #[test]
5715    fn test_session_carries_tenant() {
5716        let store = AuthStore::new(test_config());
5717        store
5718            .create_user_in_tenant(Some("acme"), "alice", "pw", Role::Admin)
5719            .unwrap();
5720        let session = store
5721            .authenticate_in_tenant(Some("acme"), "alice", "pw")
5722            .unwrap();
5723        assert_eq!(session.tenant_id.as_deref(), Some("acme"));
5724
5725        let (id, role) = store.validate_token_full(&session.token).unwrap();
5726        assert_eq!(id.tenant.as_deref(), Some("acme"));
5727        assert_eq!(id.username, "alice");
5728        assert_eq!(role, Role::Admin);
5729    }
5730
5731    #[test]
5732    fn test_platform_user_has_no_tenant() {
5733        let store = AuthStore::new(test_config());
5734        store.create_user("admin", "pw", Role::Admin).unwrap();
5735        let session = store.authenticate("admin", "pw").unwrap();
5736        assert!(session.tenant_id.is_none());
5737
5738        let (id, _) = store.validate_token_full(&session.token).unwrap();
5739        assert!(id.tenant.is_none());
5740    }
5741
5742    #[test]
5743    fn test_lookup_scram_verifier_global_resolves_platform() {
5744        let store = AuthStore::new(test_config());
5745        store.create_user("admin", "pw", Role::Admin).unwrap();
5746        store
5747            .create_user_in_tenant(Some("acme"), "admin", "pw", Role::Admin)
5748            .unwrap();
5749
5750        // The global helper picks the platform-tenant user only.
5751        let v = store.lookup_scram_verifier_global("admin");
5752        assert!(v.is_some());
5753
5754        // The tenant-scoped user has its own verifier.
5755        let v_acme = store.lookup_scram_verifier(&UserId::scoped("acme", "admin"));
5756        assert!(v_acme.is_some());
5757
5758        // The two verifiers carry independent salts.
5759        assert_ne!(v.unwrap().salt, v_acme.unwrap().salt);
5760    }
5761
5762    #[test]
5763    fn test_delete_in_tenant_does_not_touch_other_tenant() {
5764        let store = AuthStore::new(test_config());
5765        store
5766            .create_user_in_tenant(Some("acme"), "alice", "pw", Role::Admin)
5767            .unwrap();
5768        store
5769            .create_user_in_tenant(Some("globex"), "alice", "pw", Role::Admin)
5770            .unwrap();
5771
5772        store.delete_user_in_tenant(Some("acme"), "alice").unwrap();
5773
5774        // Globex still alive.
5775        assert!(store
5776            .authenticate_in_tenant(Some("globex"), "alice", "pw")
5777            .is_ok());
5778        // Acme gone.
5779        assert!(store
5780            .authenticate_in_tenant(Some("acme"), "alice", "pw")
5781            .is_err());
5782    }
5783
5784    #[test]
5785    fn test_user_id_display() {
5786        assert_eq!(UserId::platform("admin").to_string(), "admin");
5787        assert_eq!(UserId::scoped("acme", "alice").to_string(), "acme/alice");
5788    }
5789
5790    // -----------------------------------------------------------------
5791    // PolicyEnforcementMode wiring (#712 / S5A)
5792    // -----------------------------------------------------------------
5793
5794    fn enforcement_eval_ctx(role: Role) -> EvalContext {
5795        EvalContext {
5796            principal_tenant: None,
5797            current_tenant: None,
5798            peer_ip: None,
5799            mfa_present: false,
5800            now_ms: 1_700_000_000_000,
5801            principal_is_admin_role: role == Role::Admin,
5802            principal_is_platform_scoped: true,
5803        }
5804    }
5805
5806    /// Helper: install a single unrelated allow policy so the IAM
5807    /// path is the authoritative one — without any installed policy
5808    /// the auth flow short-circuits before we ever consult the mode.
5809    fn install_unrelated_policy(store: &AuthStore) {
5810        store
5811            .put_policy(
5812                Policy::from_json_str(
5813                    r#"{"id":"p-unrelated","version":1,"statements":[{"effect":"allow","actions":["select"],"resources":["table:public.other"]}]}"#,
5814                )
5815                .unwrap(),
5816            )
5817            .unwrap();
5818    }
5819
5820    #[test]
5821    fn enforcement_mode_default_for_new_store_is_legacy_rbac() {
5822        // Pre-bootstrap construction = "existing install" path.
5823        // Defaulting to LegacyRbac preserves pre-#712 behaviour on
5824        // upgrade so an operator that has not yet attached IAM
5825        // policies does not lose access after the upgrade.
5826        let store = AuthStore::new(test_config());
5827        assert_eq!(store.enforcement_mode(), PolicyEnforcementMode::LegacyRbac);
5828    }
5829
5830    #[test]
5831    fn enforcement_mode_set_round_trips_and_returns_previous() {
5832        let store = AuthStore::new(test_config());
5833        let prev = store.set_enforcement_mode(PolicyEnforcementMode::PolicyOnly);
5834        assert_eq!(prev, PolicyEnforcementMode::LegacyRbac);
5835        assert_eq!(store.enforcement_mode(), PolicyEnforcementMode::PolicyOnly);
5836        let prev = store.set_enforcement_mode(PolicyEnforcementMode::LegacyRbac);
5837        assert_eq!(prev, PolicyEnforcementMode::PolicyOnly);
5838    }
5839
5840    #[test]
5841    fn policy_only_mode_treats_no_matching_policy_as_deny() {
5842        // Acceptance #2: in `policy_only` mode a principal with no
5843        // matching policy is denied even when the action's role
5844        // floor would otherwise have allowed it.
5845        let store = AuthStore::new(test_config());
5846        store.create_user("alice", "p", Role::Admin).unwrap();
5847        let uid = UserId::platform("alice");
5848        install_unrelated_policy(&store);
5849        store.set_enforcement_mode(PolicyEnforcementMode::PolicyOnly);
5850
5851        let resource = ResourceRef::new("table", "public.t");
5852        // Even Role::Admin is denied — DefaultDeny is the deciding
5853        // signal in `policy_only`, regardless of role.
5854        assert!(!store.check_policy_authz_with_role(
5855            &uid,
5856            "select",
5857            &resource,
5858            &enforcement_eval_ctx(Role::Admin),
5859            Role::Admin,
5860        ));
5861    }
5862
5863    #[test]
5864    fn legacy_rbac_mode_falls_back_to_role_decision() {
5865        // Acceptance #3: in `legacy_rbac` mode the same principal
5866        // falls through to the role-based decision. `select` requires
5867        // only `Read`, so all three roles satisfy it; a write verb
5868        // requires `Write`, so `Read` does not.
5869        let store = AuthStore::new(test_config());
5870        store.create_user("alice", "p", Role::Read).unwrap();
5871        let uid = UserId::platform("alice");
5872        install_unrelated_policy(&store);
5873        store.set_enforcement_mode(PolicyEnforcementMode::LegacyRbac);
5874
5875        let table = ResourceRef::new("table", "public.t");
5876        // Read role + select action: allowed via the legacy fallback.
5877        assert!(store.check_policy_authz_with_role(
5878            &uid,
5879            "select",
5880            &table,
5881            &enforcement_eval_ctx(Role::Read),
5882            Role::Read,
5883        ));
5884        // Read role + insert action: legacy decision says no.
5885        assert!(!store.check_policy_authz_with_role(
5886            &uid,
5887            "insert",
5888            &table,
5889            &enforcement_eval_ctx(Role::Read),
5890            Role::Read,
5891        ));
5892        // Admin role + insert action: legacy decision says yes.
5893        assert!(store.check_policy_authz_with_role(
5894            &uid,
5895            "insert",
5896            &table,
5897            &enforcement_eval_ctx(Role::Admin),
5898            Role::Admin,
5899        ));
5900    }
5901
5902    #[test]
5903    fn explicit_deny_wins_irrespective_of_mode_or_role() {
5904        // Even in `legacy_rbac` with an Admin role, an explicit
5905        // matching Deny statement always denies. The mode only
5906        // influences the `DefaultDeny` arm, never an explicit one.
5907        let store = AuthStore::new(test_config());
5908        store.create_user("alice", "p", Role::Admin).unwrap();
5909        let uid = UserId::platform("alice");
5910        store
5911            .put_policy(
5912                Policy::from_json_str(
5913                    r#"{"id":"p-deny-select","version":1,"statements":[{"effect":"deny","actions":["select"],"resources":["table:public.t"]}]}"#,
5914                )
5915                .unwrap(),
5916            )
5917            .unwrap();
5918        store
5919            .attach_policy(PrincipalRef::User(uid.clone()), "p-deny-select")
5920            .unwrap();
5921
5922        let resource = ResourceRef::new("table", "public.t");
5923        for mode in [
5924            PolicyEnforcementMode::LegacyRbac,
5925            PolicyEnforcementMode::PolicyOnly,
5926        ] {
5927            store.set_enforcement_mode(mode);
5928            assert!(
5929                !store.check_policy_authz_with_role(
5930                    &uid,
5931                    "select",
5932                    &resource,
5933                    &enforcement_eval_ctx(Role::Admin),
5934                    Role::Admin,
5935                ),
5936                "explicit deny must win under mode {mode:?}"
5937            );
5938        }
5939    }
5940
5941    #[test]
5942    fn explicit_allow_wins_irrespective_of_mode_or_role() {
5943        // Symmetric guarantee: an explicit Allow is honoured even in
5944        // `policy_only` mode for a role that the legacy fallback
5945        // would have rejected (Read role + admin-tier action).
5946        let store = AuthStore::new(test_config());
5947        store.create_user("alice", "p", Role::Read).unwrap();
5948        let uid = UserId::platform("alice");
5949        store
5950            .put_policy(
5951                Policy::from_json_str(
5952                    r#"{"id":"p-allow-create","version":1,"statements":[{"effect":"allow","actions":["create"],"resources":["table:public.t"]}]}"#,
5953                )
5954                .unwrap(),
5955            )
5956            .unwrap();
5957        store
5958            .attach_policy(PrincipalRef::User(uid.clone()), "p-allow-create")
5959            .unwrap();
5960        store.set_enforcement_mode(PolicyEnforcementMode::PolicyOnly);
5961
5962        let resource = ResourceRef::new("table", "public.t");
5963        assert!(store.check_policy_authz_with_role(
5964            &uid,
5965            "create",
5966            &resource,
5967            &enforcement_eval_ctx(Role::Read),
5968            Role::Read,
5969        ));
5970    }
5971
5972    #[test]
5973    fn take_legacy_rbac_warn_once_is_at_most_once_per_boot() {
5974        let store = AuthStore::new(test_config());
5975        // Default mode = LegacyRbac, so the first call wins the token.
5976        assert!(store.take_legacy_rbac_warn_once());
5977        // Every subsequent call is a no-op for the lifetime of this
5978        // process / AuthStore — that is the "once per boot" guarantee.
5979        for _ in 0..3 {
5980            assert!(!store.take_legacy_rbac_warn_once());
5981        }
5982    }
5983
5984    #[test]
5985    fn take_legacy_rbac_warn_once_is_silent_under_policy_only() {
5986        // Acceptance #5 phrases the warning as "Boot in legacy_rbac
5987        // mode emits exactly one warn entry per boot" — implicitly,
5988        // `policy_only` boots emit zero. The token must not be
5989        // claimable when the mode is strict so the boot path stays
5990        // quiet.
5991        let store = AuthStore::new(test_config());
5992        store.set_enforcement_mode(PolicyEnforcementMode::PolicyOnly);
5993        assert!(!store.take_legacy_rbac_warn_once());
5994        // And, importantly, switching back to LegacyRbac later does
5995        // not "owe" us a warning — the token was never claimed under
5996        // policy_only, so it is still available the first time the
5997        // mode is legacy_rbac.
5998        store.set_enforcement_mode(PolicyEnforcementMode::LegacyRbac);
5999        assert!(store.take_legacy_rbac_warn_once());
6000        assert!(!store.take_legacy_rbac_warn_once());
6001    }
6002
6003    #[test]
6004    fn strict_check_policy_authz_ignores_enforcement_mode() {
6005        // Governance APIs (managed_config, registry, managed_policy)
6006        // call the strict `check_policy_authz` — it must return
6007        // `false` on DefaultDeny regardless of mode so the lenient
6008        // posture cannot accidentally elevate an admin-tier mutation.
6009        let store = AuthStore::new(test_config());
6010        store.create_user("alice", "p", Role::Admin).unwrap();
6011        let uid = UserId::platform("alice");
6012        install_unrelated_policy(&store);
6013        store.set_enforcement_mode(PolicyEnforcementMode::LegacyRbac);
6014
6015        let resource = ResourceRef::new("table", "public.t");
6016        assert!(!store.check_policy_authz(
6017            &uid,
6018            "select",
6019            &resource,
6020            &enforcement_eval_ctx(Role::Admin),
6021        ));
6022    }
6023
6024    #[test]
6025    fn disabling_a_user_invalidates_its_api_keys_and_sessions() {
6026        let store = AuthStore::new(test_config());
6027        store.create_user("carol", "pw", Role::Write).unwrap();
6028        let key = store
6029            .create_api_key("carol", "ci", Role::Write)
6030            .unwrap()
6031            .key;
6032        let session = store.authenticate("carol", "pw").unwrap();
6033        assert!(store.validate_token(&key).is_some());
6034        assert!(store.validate_token(&session.token).is_some());
6035
6036        let uid = UserId::platform("carol");
6037        store.set_user_enabled(&uid, false).unwrap();
6038
6039        // API keys resolved straight from the index and never consulted the
6040        // user record, so a disabled (or offboarded) account kept a working
6041        // credential forever.
6042        assert!(
6043            store.validate_token(&key).is_none(),
6044            "an API key must stop working the moment its owner is disabled"
6045        );
6046        assert!(
6047            store.validate_token(&session.token).is_none(),
6048            "live sessions must not survive disabling the account"
6049        );
6050        assert!(
6051            store.lookup_scram_verifier(&uid).is_none(),
6052            "SCRAM must not authenticate a disabled account"
6053        );
6054    }
6055
6056    #[test]
6057    fn admin_password_reset_applies_and_revokes_sessions() {
6058        let store = AuthStore::new(test_config());
6059        store.create_user("dave", "old", Role::Write).unwrap();
6060        let session = store.authenticate("dave", "old").unwrap();
6061
6062        store
6063            .set_password_by_admin(&UserId::platform("dave"), "rotated")
6064            .expect("admin reset");
6065
6066        assert!(
6067            store.authenticate("dave", "old").is_err(),
6068            "the old password must stop working after a reset"
6069        );
6070        assert!(
6071            store.authenticate("dave", "rotated").is_ok(),
6072            "the new password must work after a reset"
6073        );
6074        assert!(
6075            store.validate_token(&session.token).is_none(),
6076            "a password rotation must revoke live sessions"
6077        );
6078    }
6079}
6080
6081/// Hand the operator the vault certificate minted by an env bootstrap.
6082///
6083/// It used to be printed to stderr unconditionally, which in a container
6084/// means the log aggregator now holds the one secret that unseals the
6085/// vault. With `REDDB_BOOTSTRAP_CERT_OUT` set it is written there
6086/// (owner-only, thanks to the boot umask) and only the path is logged;
6087/// without it, stderr remains the delivery channel, with a warning that
6088/// says so.
6089fn deliver_bootstrap_certificate(cert: &str) {
6090    if let Some(path) = crate::utils::env_with_file_fallback("REDDB_BOOTSTRAP_CERT_OUT") {
6091        match std::fs::write(&path, format!("{cert}\n")) {
6092            Ok(()) => {
6093                tracing::warn!(
6094                    target: "reddb::security",
6095                    path = %path,
6096                    "vault certificate written — save it: the ONLY way to unseal after a restart"
6097                );
6098                return;
6099            }
6100            Err(err) => {
6101                tracing::error!(
6102                    target: "reddb::security",
6103                    path = %path,
6104                    error = %err,
6105                    "could not write the vault certificate; falling back to stderr"
6106                );
6107            }
6108        }
6109    }
6110    eprintln!("[reddb] CERTIFICATE: {cert}");
6111    tracing::warn!(
6112        target: "reddb::security",
6113        "vault certificate issued on stderr — save it (ONLY way to unseal after restart); \
6114         set REDDB_BOOTSTRAP_CERT_OUT to receive it as a file instead of a log line"
6115    );
6116}