Skip to main content

qail_core/rls/
mod.rs

1//! Row-Level Security (RLS) Context for Multi-Tenant SaaS
2//!
3//! Provides a shared tenant context that all Qail drivers can use
4//! for data isolation. Each driver implements isolation differently:
5//!
6//! - **qail-pg**: `set_config('app.current_tenant_id', ...)` session variables
7//! - **qail-qdrant**: metadata filter `{ tenant_id: "..." }` on vector search
8//!
9//! # Example
10//!
11//! ```
12//! use qail_core::rls::{RlsContext, SuperAdminToken};
13//!
14//! // Tenant context — scopes data to a single tenant
15//! let ctx = RlsContext::tenant("550e8400-e29b-41d4-a716-446655440000");
16//! assert_eq!(ctx.tenant_id, "550e8400-e29b-41d4-a716-446655440000");
17//!
18//! // Super admin — bypasses tenant isolation (requires named constructor)
19//! let token = SuperAdminToken::for_system_process("example");
20//! let admin = RlsContext::super_admin(token);
21//! assert!(admin.bypasses_rls());
22//!
23//! // Global context — scopes to platform rows (tenant_id IS NULL)
24//! let global = RlsContext::global();
25//! assert!(global.is_global());
26//! ```
27
28/// Scoped LISTEN/NOTIFY channel derivation.
29pub mod channel;
30/// Owner (user) scope registry.
31pub mod owner;
32/// Tenant scope registry.
33pub mod tenant;
34
35/// Counts of tables registered by [`init_scope_registries`].
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct ScopeRegistryCounts {
38    /// Tables registered for tenant scope (`tenant_id` column).
39    pub tenant: usize,
40    /// Tables registered for owner scope (`owner <column>` attribute).
41    pub owner: usize,
42}
43
44/// How the process declared its AST isolation registries.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ScopeRegistryState {
47    /// Nothing was declared. `Qail::with_rls` REFUSES to run in this state —
48    /// silently scoping nothing was the original false-green.
49    Uninitialized,
50    /// Registries were populated (from a schema or by explicit registration).
51    Initialized,
52    /// The application declared that isolation is enforced by DB policies
53    /// only; `with_rls` performs no AST injection on any table.
54    PolicyOnly,
55}
56
57/// A declaration conflicted with the mode this process already sealed.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct ScopeModeConflict {
60    /// The mode already in force.
61    pub current: ScopeRegistryState,
62    /// The mode that was requested.
63    pub requested: ScopeRegistryState,
64}
65
66impl std::fmt::Display for ScopeModeConflict {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        write!(
69            f,
70            "RLS isolation mode is sealed as {:?}; refusing transition to {:?}",
71            self.current, self.requested
72        )
73    }
74}
75
76impl std::error::Error for ScopeModeConflict {}
77
78/// One-way isolation-mode coordinator.
79///
80/// `Uninitialized` may move to exactly one of `Initialized` or `PolicyOnly`;
81/// once sealed, the conflicting transition is refused. The application's
82/// security decision is therefore not subject to last-writer-wins from a
83/// library, a test, or a late registration.
84///
85/// The process-wide instance is private to this module: the ONLY ways to
86/// reach `Initialized` are [`init_scope_registries`] /
87/// [`init_scope_registries_from_tables`] (which populate the registries and
88/// refuse to seal if nothing was registered) and
89/// [`declare_no_scoped_tables`] (an explicit, reasoned declaration that the
90/// schema has none). Crate-internal registration helpers are mode-neutral.
91/// Exposing `declare_initialized` on the global would let a caller publish
92/// `Initialized` over empty registries — the original silent no-op, one
93/// line away. The type is crate-private; tests drive a local instance.
94#[derive(Debug)]
95pub(crate) struct ScopeModeCoordinator {
96    state: std::sync::atomic::AtomicU8,
97    policy_only_reason: std::sync::OnceLock<&'static str>,
98}
99
100const MODE_UNINITIALIZED: u8 = 0;
101const MODE_INITIALIZED: u8 = 1;
102const MODE_POLICY_ONLY: u8 = 2;
103
104impl ScopeModeCoordinator {
105    /// A fresh, unsealed coordinator.
106    pub(crate) const fn new() -> Self {
107        Self {
108            state: std::sync::atomic::AtomicU8::new(MODE_UNINITIALIZED),
109            policy_only_reason: std::sync::OnceLock::new(),
110        }
111    }
112
113    fn decode(raw: u8) -> ScopeRegistryState {
114        match raw {
115            MODE_INITIALIZED => ScopeRegistryState::Initialized,
116            MODE_POLICY_ONLY => ScopeRegistryState::PolicyOnly,
117            _ => ScopeRegistryState::Uninitialized,
118        }
119    }
120
121    /// Current mode.
122    pub(crate) fn state(&self) -> ScopeRegistryState {
123        Self::decode(self.state.load(std::sync::atomic::Ordering::Acquire))
124    }
125
126    /// Seal as `target`. Idempotent for the same target; refuses the other.
127    fn seal(&self, target: u8) -> Result<(), ScopeModeConflict> {
128        match self.state.compare_exchange(
129            MODE_UNINITIALIZED,
130            target,
131            std::sync::atomic::Ordering::AcqRel,
132            std::sync::atomic::Ordering::Acquire,
133        ) {
134            Ok(_) => Ok(()),
135            Err(current) if current == target => Ok(()),
136            Err(current) => Err(ScopeModeConflict {
137                current: Self::decode(current),
138                requested: Self::decode(target),
139            }),
140        }
141    }
142
143    /// Publish `Initialized`. Call AFTER the registries are populated so a
144    /// reader never observes `Initialized` with an empty registry.
145    ///
146    /// Crate-private on purpose: see the type-level docs.
147    pub(crate) fn declare_initialized(&self) -> Result<(), ScopeModeConflict> {
148        self.seal(MODE_INITIALIZED)
149    }
150
151    /// Publish `PolicyOnly` with an auditable reason. The reason is recorded
152    /// only when the transition succeeds.
153    pub(crate) fn declare_policy_only(
154        &self,
155        reason: &'static str,
156    ) -> Result<(), ScopeModeConflict> {
157        self.seal(MODE_POLICY_ONLY)?;
158        self.policy_only_reason.get_or_init(|| reason);
159        Ok(())
160    }
161
162    /// The reason recorded by a successful [`Self::declare_policy_only`].
163    pub(crate) fn policy_only_reason(&self) -> Option<&'static str> {
164        if self.state() == ScopeRegistryState::PolicyOnly {
165            self.policy_only_reason.get().copied()
166        } else {
167            None
168        }
169    }
170}
171
172impl Default for ScopeModeCoordinator {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178/// The process-wide isolation mode. Private: reachable only through the
179/// boundary APIs below, never declared `Initialized` without a registration.
180static SCOPE_MODE: ScopeModeCoordinator = ScopeModeCoordinator::new();
181
182/// Current registry state for this process.
183pub fn scope_registry_state() -> ScopeRegistryState {
184    SCOPE_MODE.state()
185}
186
187/// Why a boundary initialization refused to seal `Initialized`.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum ScopeInitError {
190    /// The process already sealed a conflicting mode.
191    ModeConflict(ScopeModeConflict),
192    /// Nothing was registered. Sealing `Initialized` over empty registries
193    /// would make every `.with_rls()` a silent no-op — the original
194    /// false-green. If the schema genuinely has no scoped tables, say so
195    /// with [`declare_no_scoped_tables`].
196    NoScopedTables,
197    /// A registry could not be populated (poisoned lock). The mode is left
198    /// untouched so a half-filled registry is never published.
199    RegistryUnavailable(String),
200}
201
202impl std::fmt::Display for ScopeInitError {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        match self {
205            Self::ModeConflict(conflict) => write!(f, "{conflict}"),
206            Self::NoScopedTables => write!(
207                f,
208                "refusing to seal RLS scope registries: no tenant- or owner-scoped tables were registered (declare_no_scoped_tables(reason) if that is intentional)"
209            ),
210            Self::RegistryUnavailable(why) => {
211                write!(f, "RLS scope registry unavailable: {why}")
212            }
213        }
214    }
215}
216
217impl std::error::Error for ScopeInitError {}
218
219impl From<ScopeModeConflict> for ScopeInitError {
220    fn from(conflict: ScopeModeConflict) -> Self {
221        Self::ModeConflict(conflict)
222    }
223}
224
225/// Seal `Initialized` only if the registries hold at least one table.
226///
227/// The counts passed in are what THIS call registered; the invariant is
228/// checked against the live registries so a second call that registers
229/// nothing new still seals fine when an earlier call populated them.
230fn seal_initialized_if_populated(
231    counts: ScopeRegistryCounts,
232) -> Result<ScopeRegistryCounts, ScopeInitError> {
233    let live = live_registered_total()?;
234    seal_initialized_if_populated_with(live, counts)
235}
236
237/// Live total across both registries. A poisoned registry is an error, never
238/// zero — "unavailable" and "empty" lead to opposite security decisions.
239fn live_registered_total() -> Result<usize, ScopeInitError> {
240    let tenant = tenant::try_tenant_table_count().map_err(ScopeInitError::RegistryUnavailable)?;
241    let owner = owner::try_owner_table_count().map_err(ScopeInitError::RegistryUnavailable)?;
242    Ok(tenant + owner)
243}
244
245/// The invariant, separated from the globals so it can be tested directly:
246/// seal only when the live registries hold at least one table.
247fn seal_initialized_if_populated_with(
248    live_registered: usize,
249    counts: ScopeRegistryCounts,
250) -> Result<ScopeRegistryCounts, ScopeInitError> {
251    if live_registered == 0 {
252        return Err(ScopeInitError::NoScopedTables);
253    }
254    SCOPE_MODE.declare_initialized()?;
255    Ok(counts)
256}
257
258/// Populate BOTH runtime scope registries from a parsed `schema.qail`, then
259/// publish `Initialized`.
260///
261/// This is the **application boundary** call: the binary that owns the
262/// process decides what the AST layer scopes. Library code (the gateway's
263/// schema loader, drivers) never calls it on your behalf, because activating
264/// AST injection changes what every `.with_rls()` in the process returns.
265/// Until this, [`init_scope_registries_from_tables`],
266/// [`declare_no_scoped_tables`] or [`declare_policy_only_isolation`] runs,
267/// `Qail::with_rls` fails with `RlsRegistryUninitialized`.
268///
269/// Refuses to seal if nothing was registered
270/// ([`ScopeInitError::NoScopedTables`]) or a registry could not be filled
271/// ([`ScopeInitError::RegistryUnavailable`]); refused once the process
272/// sealed `PolicyOnly`. Idempotent otherwise — more tables may be added by
273/// calling again.
274pub fn init_scope_registries(
275    schema: &crate::migrate::Schema,
276) -> Result<ScopeRegistryCounts, ScopeInitError> {
277    if SCOPE_MODE.state() == ScopeRegistryState::PolicyOnly {
278        return Err(ScopeModeConflict {
279            current: ScopeRegistryState::PolicyOnly,
280            requested: ScopeRegistryState::Initialized,
281        }
282        .into());
283    }
284    let tenant = tenant::register_from_migrate_schema(schema)
285        .map_err(ScopeInitError::RegistryUnavailable)?;
286    let owner =
287        owner::register_from_migrate_schema(schema).map_err(ScopeInitError::RegistryUnavailable)?;
288    seal_initialized_if_populated(ScopeRegistryCounts { tenant, owner })
289}
290
291/// Programmatic form of [`init_scope_registries`] for processes without a
292/// `schema.qail` (embedded tools, tests): register the given tenant and
293/// owner tables, then seal `Initialized`. Same refusals as the schema form.
294pub fn init_scope_registries_from_tables(
295    tenant_tables: &[(&str, &str)],
296    owner_tables: &[(&str, &str)],
297) -> Result<ScopeRegistryCounts, ScopeInitError> {
298    if SCOPE_MODE.state() == ScopeRegistryState::PolicyOnly {
299        return Err(ScopeModeConflict {
300            current: ScopeRegistryState::PolicyOnly,
301            requested: ScopeRegistryState::Initialized,
302        }
303        .into());
304    }
305    // Fallible registration: a poisoned registry aborts BEFORE sealing, so a
306    // live total made nonzero by the other registry can never publish
307    // `Initialized` over partial metadata.
308    let tenant = tenant::try_register_tenant_tables(tenant_tables)
309        .map_err(ScopeInitError::RegistryUnavailable)?;
310    let owner = owner::try_register_owner_tables(owner_tables)
311        .map_err(ScopeInitError::RegistryUnavailable)?;
312    seal_initialized_if_populated(ScopeRegistryCounts { tenant, owner })
313}
314
315/// Declare that this schema intentionally has NO tenant- or owner-scoped
316/// tables, and seal `Initialized` with empty registries. Every
317/// `.with_rls()` is then a checked no-op on every table. `reason` is
318/// recorded so the choice is auditable. Refused if a registry already holds
319/// tables (use [`init_scope_registries`]) or the process sealed `PolicyOnly`.
320pub fn declare_no_scoped_tables(reason: &'static str) -> Result<(), ScopeInitError> {
321    // An unavailable registry must not read as "empty": that would seal
322    // `Initialized` over metadata we cannot see, and every later lookup
323    // would return None — an unscoped query.
324    if live_registered_total()? != 0 {
325        return Err(ScopeInitError::RegistryUnavailable(
326            "registries are not empty; use init_scope_registries instead".to_string(),
327        ));
328    }
329    SCOPE_MODE.declare_initialized()?;
330    NO_SCOPED_TABLES_REASON.get_or_init(|| reason);
331    Ok(())
332}
333
334static NO_SCOPED_TABLES_REASON: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
335
336/// The reason given to a successful [`declare_no_scoped_tables`].
337pub fn no_scoped_tables_reason() -> Option<&'static str> {
338    NO_SCOPED_TABLES_REASON.get().copied()
339}
340
341/// Declare that this process relies on PostgreSQL RLS policies alone and
342/// wants NO AST injection: `with_rls` becomes a checked no-op everywhere.
343///
344/// This is the explicit form of what an un-initialized process used to get
345/// by accident. `reason` is recorded so the choice is auditable. Refused if
346/// the process already sealed `Initialized`.
347pub fn declare_policy_only_isolation(reason: &'static str) -> Result<(), ScopeModeConflict> {
348    SCOPE_MODE.declare_policy_only(reason)
349}
350
351/// The reason given to a successful [`declare_policy_only_isolation`].
352pub fn policy_only_reason() -> Option<&'static str> {
353    SCOPE_MODE.policy_only_reason()
354}
355
356/// An opaque token that authorizes RLS bypass.
357///
358/// Create via one of the named constructors:
359/// - [`SuperAdminToken::for_system_process`] — cron, startup, cross-tenant internals
360/// - [`SuperAdminToken::for_webhook`] — inbound callbacks
361/// - [`SuperAdminToken::for_auth`] — login, register, token refresh
362///
363/// External code cannot fabricate this token — it has a private field
364/// and no public field constructor.
365///
366/// # Usage
367/// ```ignore
368/// let token = SuperAdminToken::for_system_process("cron::cleanup");
369/// let ctx = RlsContext::super_admin(token);
370/// assert!(ctx.bypasses_rls());
371/// ```
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct SuperAdminToken {
374    _private: (),
375}
376
377impl SuperAdminToken {
378    /// Issue a token for a system/background process.
379    ///
380    /// Use for cron jobs, startup introspection, and internal cross-tenant
381    /// maintenance paths. For shared/public reference data, prefer
382    /// [`RlsContext::global()`] instead of bypass.
383    ///
384    /// The `_reason` parameter documents intent at the call site
385    /// (e.g. `"cron::check_expired_holds"`). Drivers like `qail-pg`
386    /// may log it via tracing.
387    pub fn for_system_process(_reason: &str) -> Self {
388        Self { _private: () }
389    }
390
391    /// Issue a token for an inbound webhook or gateway trigger.
392    ///
393    /// Use for Meta WhatsApp callbacks, Xendit payment callbacks,
394    /// and gateway event triggers that are authenticated via shared
395    /// secret (`X-Trigger-Secret`) rather than JWT.
396    pub fn for_webhook(_source: &str) -> Self {
397        Self { _private: () }
398    }
399
400    /// Issue a token for an authentication operation.
401    ///
402    /// Use for login, register, token refresh, and admin-claims
403    /// resolution — operations that necessarily run before (or
404    /// outside) a tenant scope is known.
405    pub fn for_auth(_operation: &str) -> Self {
406        Self { _private: () }
407    }
408}
409
410/// RLS context carrying tenant identity for data isolation.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct RlsContext {
413    /// The unified tenant ID — the primary identity for data isolation.
414    /// Empty string means no tenant scope.
415    pub tenant_id: String,
416
417    /// When true, the current user is a platform super admin
418    /// and should bypass tenant isolation.
419    ///
420    /// This field is private — external code must use `bypasses_rls()`.
421    /// Only `super_admin(token)` can set this to true, and that requires
422    /// a `SuperAdminToken` which emits an audit log on creation.
423    is_super_admin: bool,
424
425    /// When true, the context is explicitly scoped to global/platform rows
426    /// (`tenant_id IS NULL`) rather than tenant-specific rows.
427    is_global: bool,
428
429    /// The authenticated user's UUID for user-scoped DB policies.
430    /// Empty string means no user scope. Set via `RlsContext::user()`.
431    user_id: String,
432}
433
434impl RlsContext {
435    /// Create a context scoped to a specific tenant (the unified identity).
436    pub fn tenant(tenant_id: &str) -> Self {
437        Self {
438            tenant_id: tenant_id.to_string(),
439            is_super_admin: false,
440            is_global: false,
441            user_id: String::new(),
442        }
443    }
444
445    /// Create a global context scoped to platform rows (`tenant_id IS NULL`).
446    ///
447    /// This is not a bypass: it applies explicit global scoping in AST injection
448    /// and exposes `app.is_global=true` for policy usage at the database layer.
449    pub fn global() -> Self {
450        Self {
451            tenant_id: String::new(),
452            is_super_admin: false,
453            is_global: true,
454            user_id: String::new(),
455        }
456    }
457
458    /// Create a super admin context that bypasses tenant isolation.
459    ///
460    /// Requires a `SuperAdminToken` — which can only be created via
461    /// named constructors (`for_system_process`, `for_webhook`, `for_auth`).
462    ///
463    /// Uses nil UUID for all IDs to avoid `''::uuid` cast errors
464    /// in PostgreSQL RLS policies (PostgreSQL doesn't short-circuit OR).
465    pub fn super_admin(_token: SuperAdminToken) -> Self {
466        let nil = "00000000-0000-0000-0000-000000000000".to_string();
467        Self {
468            tenant_id: nil,
469            is_super_admin: true,
470            is_global: false,
471            user_id: String::new(),
472        }
473    }
474
475    /// Create an empty context (no tenant, no super admin).
476    ///
477    /// Used for system-level operations that must not operate within
478    /// any tenant scope (startup introspection, migrations, health checks).
479    pub fn empty() -> Self {
480        Self {
481            tenant_id: String::new(),
482            is_super_admin: false,
483            is_global: false,
484            user_id: String::new(),
485        }
486    }
487
488    /// Create a user-scoped context for authenticated end-user operations.
489    ///
490    /// Sets `app.current_user_id` so that DB policies can enforce
491    /// row-level isolation by user (e.g. `user_id = get_current_user_id()`).
492    /// Does NOT bypass tenant isolation or grant super-admin.
493    pub fn user(user_id: &str) -> Self {
494        Self {
495            tenant_id: String::new(),
496            is_super_admin: false,
497            is_global: false,
498            user_id: user_id.to_string(),
499        }
500    }
501
502    /// Attach an authenticated user ID to an existing tenant/global context.
503    ///
504    /// User scope is orthogonal to tenant scope: PostgreSQL policies can
505    /// use both `app.current_tenant_id` and `app.current_user_id`.
506    pub fn with_user(mut self, user_id: &str) -> Self {
507        self.user_id = user_id.to_string();
508        self
509    }
510
511    /// Returns true if this context has a tenant scope.
512    pub fn has_tenant(&self) -> bool {
513        !self.tenant_id.is_empty()
514    }
515
516    /// Returns true if this context has a user scope.
517    pub fn has_user(&self) -> bool {
518        !self.user_id.is_empty()
519    }
520
521    /// Returns the user ID for this context (empty if none).
522    pub fn user_id(&self) -> &str {
523        &self.user_id
524    }
525
526    /// Returns true if this context bypasses tenant isolation.
527    pub fn bypasses_rls(&self) -> bool {
528        self.is_super_admin
529    }
530
531    /// Returns true if this context is explicitly scoped to global rows.
532    pub fn is_global(&self) -> bool {
533        self.is_global
534    }
535}
536
537impl std::fmt::Display for RlsContext {
538    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
539        if self.is_super_admin {
540            write!(f, "RlsContext(super_admin)")
541        } else if self.is_global {
542            write!(f, "RlsContext(global)")
543        } else if !self.tenant_id.is_empty() {
544            write!(f, "RlsContext(tenant={})", self.tenant_id)
545        } else {
546            write!(f, "RlsContext(none)")
547        }
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn scope_mode_seals_one_way_policy_only_first() {
557        // Local coordinator: never touches the process-global SCOPE_MODE.
558        let mode = ScopeModeCoordinator::new();
559        assert_eq!(mode.state(), ScopeRegistryState::Uninitialized);
560        assert_eq!(mode.policy_only_reason(), None);
561
562        mode.declare_policy_only("db policies only").unwrap();
563        assert_eq!(mode.state(), ScopeRegistryState::PolicyOnly);
564        assert_eq!(mode.policy_only_reason(), Some("db policies only"));
565
566        // Same declaration again is idempotent; the first reason stands.
567        mode.declare_policy_only("second reason").unwrap();
568        assert_eq!(mode.policy_only_reason(), Some("db policies only"));
569
570        // The conflicting transition is refused and the mode is unchanged.
571        let err = mode.declare_initialized().unwrap_err();
572        assert_eq!(err.current, ScopeRegistryState::PolicyOnly);
573        assert_eq!(err.requested, ScopeRegistryState::Initialized);
574        assert_eq!(mode.state(), ScopeRegistryState::PolicyOnly);
575    }
576
577    #[test]
578    fn scope_mode_seals_one_way_initialized_first() {
579        let mode = ScopeModeCoordinator::new();
580        mode.declare_initialized().unwrap();
581        mode.declare_initialized().unwrap();
582        assert_eq!(mode.state(), ScopeRegistryState::Initialized);
583
584        let err = mode.declare_policy_only("too late").unwrap_err();
585        assert_eq!(err.current, ScopeRegistryState::Initialized);
586        assert_eq!(mode.state(), ScopeRegistryState::Initialized);
587        assert_eq!(
588            mode.policy_only_reason(),
589            None,
590            "a refused declaration must not record a reason"
591        );
592    }
593
594    #[test]
595    fn low_level_registration_is_mode_neutral_and_empty_init_refuses_to_seal() {
596        // These run against the process globals, but every assertion here is
597        // order-independent: a sibling test may already have sealed
598        // `Initialized` via a real registration, which is exactly the only
599        // sealed state this test must ever observe.
600        let before = scope_registry_state();
601        tenant::try_register_tenant_tables(&[]).unwrap();
602        owner::try_register_owner_tables(&[]).unwrap();
603        assert_eq!(
604            scope_registry_state(),
605            before,
606            "empty low-level registration must not change the mode"
607        );
608        tenant::try_register_tenant_tables(&[("_mode_neutral_probe", "tenant_id")]).unwrap();
609        assert_eq!(
610            scope_registry_state(),
611            before,
612            "non-empty low-level registration must not change the mode either"
613        );
614        assert!(
615            tenant::try_tenant_table_count().unwrap() > 0,
616            "…but the table IS recorded"
617        );
618    }
619
620    #[test]
621    fn init_from_tables_refuses_empty_then_seals_on_real_registration() {
622        // Cannot assert `NoScopedTables` against the live process once any
623        // sibling registered a table, so exercise the refusal on the pure
624        // helper and the success path on the real boundary.
625        assert_eq!(
626            seal_initialized_if_populated_with(
627                0,
628                ScopeRegistryCounts {
629                    tenant: 0,
630                    owner: 0
631                }
632            ),
633            Err(ScopeInitError::NoScopedTables)
634        );
635        let counts =
636            init_scope_registries_from_tables(&[("_init_from_tables_t", "tenant_id")], &[])
637                .expect("one real table seals Initialized");
638        assert_eq!(
639            counts,
640            ScopeRegistryCounts {
641                tenant: 1,
642                owner: 0
643            }
644        );
645        assert_eq!(scope_registry_state(), ScopeRegistryState::Initialized);
646    }
647
648    #[test]
649    fn init_from_migrate_schema_reports_registry_failure_instead_of_zero() {
650        let schema = crate::migrate::parse_qail(
651            "table _init_schema_orders {\n  id UUID primary_key\n  tenant_id UUID\n}\n",
652        )
653        .unwrap();
654        assert_eq!(tenant::register_from_migrate_schema(&schema), Ok(1));
655        assert_eq!(owner::register_from_migrate_schema(&schema), Ok(0));
656    }
657
658    /// Poison a lock the way production would: a writer panics while
659    /// holding the guard.
660    fn poison<T: Send + Sync + 'static>(lock: &'static std::sync::RwLock<T>) {
661        let result = std::thread::spawn(move || {
662            let _guard = lock.write().unwrap();
663            panic!("poison the registry lock");
664        })
665        .join();
666        assert!(result.is_err(), "writer thread must have panicked");
667        assert!(lock.is_poisoned());
668    }
669
670    #[test]
671    fn poisoned_tenant_registry_is_an_error_for_count_and_registration() {
672        // A leaked local lock: poisoning the process registry would take every
673        // sibling test down with it.
674        let lock: &'static std::sync::RwLock<tenant::TenantRegistry> = Box::leak(Box::new(
675            std::sync::RwLock::new(tenant::TenantRegistry::new()),
676        ));
677        // Pre-populate so a fail-open implementation would see a nonzero count.
678        tenant::register_into(lock, &[("_poison_orders", "tenant_id")]).unwrap();
679        poison(lock);
680
681        let count = tenant::count_in(lock).expect_err("poisoned count must not read as 0");
682        assert!(count.contains("poisoned"), "{count}");
683        let reg = tenant::register_into(lock, &[("_poison_more", "tenant_id")])
684            .expect_err("poisoned registration must not be silently discarded");
685        assert!(reg.contains("poisoned"), "{reg}");
686    }
687
688    #[test]
689    fn poisoned_owner_registry_is_an_error_for_count_and_registration() {
690        let lock: &'static std::sync::RwLock<owner::OwnerRegistry> = Box::leak(Box::new(
691            std::sync::RwLock::new(owner::OwnerRegistry::new()),
692        ));
693        owner::register_into(lock, &[("_poison_listings", "seller_id")]).unwrap();
694        poison(lock);
695
696        assert!(
697            owner::count_in(lock)
698                .expect_err("poisoned count must not read as 0")
699                .contains("poisoned")
700        );
701        assert!(
702            owner::register_into(lock, &[("_poison_more", "seller_id")])
703                .expect_err("poisoned registration must not be silently discarded")
704                .contains("poisoned")
705        );
706    }
707
708    #[test]
709    fn poisoned_registry_lookup_is_an_error_not_unregistered() {
710        // The runtime fail-open: after `Initialized`, a poisoned registry
711        // read must surface as an error. `None` would mean "unregistered"
712        // and disable every predicate at once.
713        let tenant_lock: &'static std::sync::RwLock<tenant::TenantRegistry> = Box::leak(Box::new(
714            std::sync::RwLock::new(tenant::TenantRegistry::new()),
715        ));
716        tenant::register_into(tenant_lock, &[("_poison_lookup_orders", "tenant_id")]).unwrap();
717        assert_eq!(
718            tenant::lookup_in(tenant_lock, "_poison_lookup_orders"),
719            Ok(Some("tenant_id".to_string()))
720        );
721        poison(tenant_lock);
722        let err = tenant::lookup_in(tenant_lock, "_poison_lookup_orders")
723            .expect_err("a registered table behind a poisoned lock must NOT read as None");
724        assert!(err.contains("poisoned"), "{err}");
725
726        let owner_lock: &'static std::sync::RwLock<owner::OwnerRegistry> = Box::leak(Box::new(
727            std::sync::RwLock::new(owner::OwnerRegistry::new()),
728        ));
729        owner::register_into(owner_lock, &[("_poison_lookup_listings", "seller_id")]).unwrap();
730        poison(owner_lock);
731        assert!(
732            owner::lookup_in(owner_lock, "_poison_lookup_listings")
733                .expect_err("poisoned owner lookup must error")
734                .contains("poisoned")
735        );
736    }
737
738    #[test]
739    fn compatibility_lookup_collapses_error_but_scoping_does_not_use_it() {
740        // The public Option form is a convenience only; scoping goes through
741        // the Result form. Document the contract at the source.
742        let lock: &'static std::sync::RwLock<tenant::TenantRegistry> = Box::leak(Box::new(
743            std::sync::RwLock::new(tenant::TenantRegistry::new()),
744        ));
745        tenant::register_into(lock, &[("_compat_orders", "tenant_id")]).unwrap();
746        poison(lock);
747        // Same data, two answers: the Option form hides the failure…
748        assert_eq!(
749            tenant::lookup_in(lock, "_compat_orders").ok().flatten(),
750            None
751        );
752        // …the Result form reports it. `Qail::with_rls` maps the latter to
753        // `QailBuildError::RlsRegistryUnavailable` (see ast::cmd::rls).
754        assert!(tenant::lookup_in(lock, "_compat_orders").is_err());
755    }
756
757    #[test]
758    fn registry_unavailable_never_seals_initialized() {
759        // The boundary invariant with an unavailable registry: the error must
760        // surface and the mode must be untouched — exercised on a local
761        // coordinator with the same logic the globals use.
762        let mode = ScopeModeCoordinator::new();
763        let live: Result<usize, ScopeInitError> = Err(ScopeInitError::RegistryUnavailable(
764            "owner registry lock poisoned".into(),
765        ));
766        let outcome = live.and_then(|n| {
767            if n == 0 {
768                Err(ScopeInitError::NoScopedTables)
769            } else {
770                mode.declare_initialized().map_err(Into::into)
771            }
772        });
773        assert!(matches!(
774            outcome,
775            Err(ScopeInitError::RegistryUnavailable(_))
776        ));
777        assert_eq!(mode.state(), ScopeRegistryState::Uninitialized);
778    }
779
780    #[test]
781    fn test_tenant_context() {
782        let ctx = RlsContext::tenant("t-123");
783        assert_eq!(ctx.tenant_id, "t-123");
784        assert!(!ctx.bypasses_rls());
785        assert!(ctx.has_tenant());
786    }
787
788    #[test]
789    fn test_super_admin_via_named_constructors() {
790        let token = SuperAdminToken::for_system_process("test");
791        let ctx = RlsContext::super_admin(token);
792        assert!(ctx.bypasses_rls());
793
794        let token = SuperAdminToken::for_webhook("test");
795        let ctx = RlsContext::super_admin(token);
796        assert!(ctx.bypasses_rls());
797
798        let token = SuperAdminToken::for_auth("test");
799        let ctx = RlsContext::super_admin(token);
800        assert!(ctx.bypasses_rls());
801    }
802
803    #[test]
804    fn test_display() {
805        let token = SuperAdminToken::for_system_process("test_display");
806        assert_eq!(
807            RlsContext::super_admin(token).to_string(),
808            "RlsContext(super_admin)"
809        );
810        assert_eq!(RlsContext::tenant("x").to_string(), "RlsContext(tenant=x)");
811    }
812
813    #[test]
814    fn test_equality() {
815        let a = RlsContext::tenant("t-1");
816        let b = RlsContext::tenant("t-1");
817        let c = RlsContext::tenant("t-2");
818        assert_eq!(a, b);
819        assert_ne!(a, c);
820    }
821
822    #[test]
823    fn test_empty_context() {
824        let ctx = RlsContext::empty();
825        assert!(!ctx.has_tenant());
826        assert!(!ctx.bypasses_rls());
827        assert!(!ctx.is_global());
828    }
829
830    #[test]
831    fn test_global_context() {
832        let ctx = RlsContext::global();
833        assert!(!ctx.has_tenant());
834        assert!(!ctx.bypasses_rls());
835        assert!(ctx.is_global());
836        assert_eq!(ctx.to_string(), "RlsContext(global)");
837    }
838
839    #[test]
840    fn test_for_system_process() {
841        let token = SuperAdminToken::for_system_process("cron::check_expired_holds");
842        let ctx = RlsContext::super_admin(token);
843        assert!(ctx.bypasses_rls());
844    }
845
846    #[test]
847    fn test_for_webhook() {
848        let token = SuperAdminToken::for_webhook("xendit_callback");
849        let ctx = RlsContext::super_admin(token);
850        assert!(ctx.bypasses_rls());
851    }
852
853    #[test]
854    fn test_for_auth() {
855        let token = SuperAdminToken::for_auth("login");
856        let ctx = RlsContext::super_admin(token);
857        assert!(ctx.bypasses_rls());
858    }
859
860    #[test]
861    fn test_all_constructors_produce_equal_tokens() {
862        let a = SuperAdminToken::for_system_process("a");
863        let b = SuperAdminToken::for_webhook("b");
864        let c = SuperAdminToken::for_auth("c");
865        // All tokens are structurally identical
866        assert_eq!(a, b);
867        assert_eq!(b, c);
868    }
869
870    #[test]
871    fn test_user_context() {
872        let ctx = RlsContext::user("550e8400-e29b-41d4-a716-446655440000");
873        assert!(!ctx.has_tenant());
874        assert!(!ctx.bypasses_rls());
875        assert!(!ctx.is_global());
876        assert!(ctx.has_user());
877        assert_eq!(ctx.user_id(), "550e8400-e29b-41d4-a716-446655440000");
878    }
879
880    #[test]
881    fn test_with_user_preserves_tenant_scope() {
882        let ctx = RlsContext::tenant("tenant-1").with_user("user-1");
883
884        assert_eq!(ctx.tenant_id, "tenant-1");
885        assert_eq!(ctx.user_id(), "user-1");
886        assert!(ctx.has_tenant());
887        assert!(ctx.has_user());
888        assert!(!ctx.bypasses_rls());
889    }
890
891    #[test]
892    fn test_user_context_display() {
893        let ctx = RlsContext::user("u-123");
894        assert_eq!(ctx.to_string(), "RlsContext(none)");
895        // user context doesn't have tenant, so Display falls through to "none"
896        // (user_id is an orthogonal axis, not a tenant scope)
897    }
898
899    #[test]
900    fn test_other_constructors_have_no_user() {
901        assert!(!RlsContext::tenant("t-1").has_user());
902        assert!(!RlsContext::global().has_user());
903        assert!(!RlsContext::empty().has_user());
904        let token = SuperAdminToken::for_auth("test");
905        assert!(!RlsContext::super_admin(token).has_user());
906    }
907}