Skip to main content

corium_authz/
authorizer.rs

1//! [`SystemDbAuthorizer`]: the [`Authorizer`] that answers from Corium's own
2//! authorization database.
3//!
4//! The request path is deliberately local and lock-light: authenticate in the
5//! interceptor (already done by the time this runs), read the current compiled
6//! policy snapshot, walk it, decide. Compilation happens when the authz basis
7//! `t` advances — on a background refresh task where one is spawned, or lazily
8//! on the first check otherwise — never per request.
9
10use std::collections::{BTreeSet, HashMap};
11use std::sync::{Arc, Mutex, RwLock};
12use std::time::Duration;
13
14use corium_protocol::authz::{Access, ActionClass, Authorizer, Decision, Principal, ViewFilter};
15
16use crate::audit::{AuditEvent, AuditSink, TracingAudit};
17use crate::eval::{self, Denial, Limits, Outcome};
18use crate::model::{ObjectRef, action_name};
19use crate::policy::{Policy, PolicyError};
20use crate::source::{PolicySource, SourceError};
21use crate::subject::{self, SubjectMapping};
22
23/// How fresh the policy behind a decision must be.
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25pub enum Consistency {
26    /// Decide from the latest compiled snapshot this process holds.
27    ///
28    /// The fast path: no I/O, and policy changes reach the process through the
29    /// refresh task. Propagation across a fleet is eventually consistent.
30    #[default]
31    Pinned,
32    /// Re-read the source before deciding, so the decision reflects every
33    /// policy change committed before the request arrived.
34    Fresh,
35}
36
37/// Who may still act when the authorization database cannot be read.
38///
39/// This exists for operator recovery — restoring a corrupted authz database,
40/// reaching a cluster whose policy store is down — and nothing else. Every
41/// break-glass grant is audited as a denial-turned-grant at `warn`.
42#[derive(Clone, Debug, Default)]
43pub struct BreakGlass {
44    /// Principal subjects admitted while the policy is unavailable.
45    pub subjects: BTreeSet<String>,
46    /// Roles admitted while the policy is unavailable.
47    pub roles: BTreeSet<String>,
48}
49
50impl BreakGlass {
51    /// Whether `principal` is admitted by this configuration.
52    #[must_use]
53    pub fn admits(&self, principal: &Principal) -> bool {
54        self.subjects.contains(&principal.subject)
55            || principal.roles.iter().any(|role| self.roles.contains(role))
56    }
57}
58
59/// Tuning for [`SystemDbAuthorizer`].
60#[derive(Clone, Debug)]
61pub struct AuthzConfig {
62    /// Search bounds for one check.
63    pub limits: Limits,
64    /// How a principal's claims become subjects.
65    pub mapping: SubjectMapping,
66    /// Default consistency for a decision.
67    pub consistency: Consistency,
68    /// Action classes that always re-read the source, whatever `consistency`
69    /// says. Empty by default; setting `[Admin]` makes control-plane changes
70    /// wait for the newest policy while reads stay on the pinned snapshot.
71    pub fresh_for: BTreeSet<ActionClass>,
72    /// Relations a plain (non-userset) subject object is expanded through, so
73    /// `group:eng writer database:music` grants everyone with `member` on
74    /// `group:eng`.
75    pub expand_relations: Vec<String>,
76    /// The object catalog-wide actions target.
77    pub catalog_object: ObjectRef,
78    /// Entries kept in the check-result cache; 0 disables it.
79    pub check_cache_capacity: usize,
80    /// Operator recovery access when policy cannot be read.
81    pub break_glass: Option<BreakGlass>,
82    /// How often the refresh task polls when the source has no change signal.
83    pub refresh_interval: Duration,
84}
85
86impl Default for AuthzConfig {
87    fn default() -> Self {
88        Self {
89            limits: Limits::default(),
90            mapping: SubjectMapping::default(),
91            consistency: Consistency::default(),
92            fresh_for: BTreeSet::new(),
93            expand_relations: vec!["member".to_owned()],
94            catalog_object: ObjectRef::new("catalog", "*"),
95            check_cache_capacity: 4_096,
96            break_glass: None,
97            refresh_interval: crate::source::DEFAULT_POLL_INTERVAL,
98        }
99    }
100}
101
102/// A decision, with everything an audit line or a `corium authz check` needs.
103#[derive(Clone)]
104pub struct AuthzDecision {
105    /// The decision itself.
106    pub decision: Decision,
107    /// Authz database basis the decision was made against.
108    pub authz_t: u64,
109    /// Target object the check ran against.
110    pub object: String,
111    /// Relationship path that granted the access.
112    pub path: Option<String>,
113    /// Views the decision was narrowed through.
114    pub views: Vec<String>,
115    /// Why it was denied, when it was.
116    pub reason: Option<String>,
117}
118
119impl AuthzDecision {
120    /// Whether the access was permitted.
121    #[must_use]
122    pub fn is_allowed(&self) -> bool {
123        !matches!(self.decision, Decision::Deny(_))
124    }
125
126    /// The view filter, when the decision narrows visibility.
127    #[must_use]
128    pub fn filter(&self) -> Option<Arc<dyn ViewFilter>> {
129        match &self.decision {
130            Decision::AllowFiltered(filter) => Some(Arc::clone(filter)),
131            _ => None,
132        }
133    }
134}
135
136impl std::fmt::Debug for AuthzDecision {
137    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        formatter
139            .debug_struct("AuthzDecision")
140            .field("decision", &self.decision)
141            .field("authz_t", &self.authz_t)
142            .field("object", &self.object)
143            .field("path", &self.path)
144            .field("views", &self.views)
145            .field("reason", &self.reason)
146            .finish()
147    }
148}
149
150/// Answers authorization from a Corium database holding `ReBAC` policy.
151pub struct SystemDbAuthorizer {
152    source: Arc<dyn PolicySource>,
153    config: AuthzConfig,
154    policy: RwLock<Option<Arc<Policy>>>,
155    checks: Mutex<CheckCache>,
156    audit: Arc<dyn AuditSink>,
157}
158
159impl SystemDbAuthorizer {
160    /// Builds an authorizer over `source` with default tuning.
161    #[must_use]
162    pub fn new(source: Arc<dyn PolicySource>) -> Self {
163        Self::with_config(source, AuthzConfig::default())
164    }
165
166    /// Builds an authorizer over `source`.
167    #[must_use]
168    pub fn with_config(source: Arc<dyn PolicySource>, config: AuthzConfig) -> Self {
169        let capacity = config.check_cache_capacity;
170        Self {
171            source,
172            config,
173            policy: RwLock::new(None),
174            checks: Mutex::new(CheckCache::new(capacity)),
175            audit: Arc::new(TracingAudit),
176        }
177    }
178
179    /// Routes audit events to `audit` instead of `tracing` (builder style).
180    #[must_use]
181    pub fn with_audit(mut self, audit: Arc<dyn AuditSink>) -> Self {
182        self.audit = audit;
183        self
184    }
185
186    /// The tuning in force.
187    #[must_use]
188    pub fn config(&self) -> &AuthzConfig {
189        &self.config
190    }
191
192    /// The compiled policy this process is deciding from, if it has one.
193    #[must_use]
194    pub fn current_policy(&self) -> Option<Arc<Policy>> {
195        self.policy
196            .read()
197            .unwrap_or_else(std::sync::PoisonError::into_inner)
198            .clone()
199    }
200
201    /// Reads the source and compiles a new snapshot when its basis has moved.
202    ///
203    /// Safe to call from anywhere: it is what the refresh task loops on, what
204    /// a `Fresh` check calls, and what a caller uses to fail startup early on a
205    /// misconfigured authz database.
206    ///
207    /// # Errors
208    /// Returns [`RefreshError`] when the snapshot cannot be read or compiled.
209    /// The last good policy is kept: a policy that stops compiling does not
210    /// silently become "deny everything" while the previous one still applies.
211    pub async fn refresh(&self) -> Result<Arc<Policy>, RefreshError> {
212        let snapshot = self.source.snapshot().await?;
213        if let Some(current) = self.current_policy()
214            && current.basis_t() == snapshot.basis_t()
215        {
216            return Ok(current);
217        }
218        let policy = Arc::new(Policy::compile(&snapshot)?);
219        tracing::debug!(
220            target: "corium_authz",
221            source = self.source.name(),
222            authz_t = policy.basis_t(),
223            stats = ?policy.stats(),
224            "compiled authorization policy"
225        );
226        *self
227            .policy
228            .write()
229            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::clone(&policy));
230        self.checks
231            .lock()
232            .unwrap_or_else(std::sync::PoisonError::into_inner)
233            .retarget(policy.basis_t());
234        Ok(policy)
235    }
236
237    /// Spawns the background task that keeps the compiled snapshot current.
238    ///
239    /// The task waits on the source's change signal (a basis watch, a
240    /// tx-report broadcast, or a poll), recompiles off the request path, and
241    /// swaps the result in. It logs and retries on failure rather than
242    /// exiting, so a transient store outage does not permanently freeze policy.
243    pub fn spawn_refresh(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
244        let authorizer = Arc::clone(self);
245        tokio::spawn(async move {
246            loop {
247                let basis_t = authorizer
248                    .current_policy()
249                    .map_or(0, |policy| policy.basis_t());
250                if let Err(error) = authorizer.refresh().await {
251                    tracing::warn!(
252                        target: "corium_authz",
253                        source = authorizer.source.name(),
254                        %error,
255                        "cannot refresh authorization policy; keeping the last good snapshot"
256                    );
257                    tokio::time::sleep(authorizer.config.refresh_interval).await;
258                    continue;
259                }
260                authorizer.source.changed(basis_t).await;
261            }
262        })
263    }
264
265    /// Runs one check, returning the decision with its audit detail.
266    pub async fn check(&self, principal: &Principal, access: &Access) -> AuthzDecision {
267        let decision = self.decide(principal, access).await;
268        self.audit.record(&AuditEvent::new(
269            principal,
270            access,
271            &decision,
272            self.source.name(),
273        ));
274        decision
275    }
276
277    async fn decide(&self, principal: &Principal, access: &Access) -> AuthzDecision {
278        let fresh = self.config.consistency == Consistency::Fresh
279            || self
280                .config
281                .fresh_for
282                .contains(&ActionClass::of(access.action));
283        let policy = if fresh {
284            match self.refresh().await {
285                Ok(policy) => policy,
286                // A `Fresh` check that cannot reach the source must not fall
287                // back to a pinned snapshot: the caller asked for the newest
288                // policy precisely because a stale answer is not good enough.
289                Err(error) => return self.unavailable(principal, access, &error.to_string()),
290            }
291        } else {
292            match self.current_policy() {
293                Some(policy) => policy,
294                None => match self.refresh().await {
295                    Ok(policy) => policy,
296                    Err(error) => {
297                        return self.unavailable(principal, access, &error.to_string());
298                    }
299                },
300            }
301        };
302
303        let objects = self.target_objects(&policy, access);
304        let object_label = objects
305            .iter()
306            .map(ToString::to_string)
307            .collect::<Vec<_>>()
308            .join(",");
309        let key = CacheKey {
310            principal: subject::fingerprint(principal, &self.config.mapping),
311            action: action_name(access.action),
312            object: object_label.clone(),
313        };
314        if self.config.check_cache_capacity > 0
315            && let Some(cached) = self
316                .checks
317                .lock()
318                .unwrap_or_else(std::sync::PoisonError::into_inner)
319                .get(policy.basis_t(), &key)
320        {
321            return cached;
322        }
323
324        let subjects = subject::subjects_of(principal, &policy, &self.config.mapping);
325        let object_type = objects
326            .first()
327            .map_or_else(|| "*".to_owned(), |object| object.kind.clone());
328        let mut relations = BTreeSet::new();
329        for object in &objects {
330            relations.extend(policy.relations_for(&object.kind, access.action));
331        }
332        let query = eval::Query {
333            subjects,
334            relations,
335            action: action_name(access.action).to_owned(),
336            objects,
337            expand_relations: self.config.expand_relations.clone(),
338            limits: self.config.limits,
339        };
340        let outcome = eval::check(&policy, &query);
341        let decision = Self::render(&outcome, policy.basis_t(), object_label, &object_type);
342        if self.config.check_cache_capacity > 0 {
343            self.checks
344                .lock()
345                .unwrap_or_else(std::sync::PoisonError::into_inner)
346                .put(policy.basis_t(), key, decision.clone());
347        }
348        decision
349    }
350
351    /// The objects an access targets.
352    ///
353    /// A database-scoped action targets `database:<name>` plus any object
354    /// registered against that database (a tenant, say). An admin action also
355    /// targets the catalog object, so catalog-level ownership covers creating
356    /// and deleting databases that have no object of their own yet.
357    fn target_objects(&self, policy: &Policy, access: &Access) -> Vec<ObjectRef> {
358        let mut objects = Vec::new();
359        match &access.database {
360            Some(database) => {
361                objects.push(ObjectRef::new("database", database.clone()));
362                objects.extend(policy.objects_for_database(database).iter().cloned());
363                if ActionClass::of(access.action) == ActionClass::Admin {
364                    objects.push(self.config.catalog_object.clone());
365                }
366            }
367            None => objects.push(self.config.catalog_object.clone()),
368        }
369        objects
370    }
371
372    fn render(outcome: &Outcome, authz_t: u64, object: String, object_type: &str) -> AuthzDecision {
373        match outcome {
374            Outcome::Allow { matches } => AuthzDecision {
375                decision: Decision::Allow,
376                authz_t,
377                object,
378                path: matches.first().map(eval::Match::render_path),
379                views: Vec::new(),
380                reason: None,
381            },
382            Outcome::AllowFiltered {
383                matches,
384                filter,
385                views,
386            } => AuthzDecision {
387                decision: Decision::AllowFiltered(Arc::clone(filter)),
388                authz_t,
389                object,
390                path: matches.first().map(eval::Match::render_path),
391                views: views.clone(),
392                reason: None,
393            },
394            Outcome::Deny(denial) => {
395                let reason = match denial {
396                    Denial::NoPermission { action, .. } => format!(
397                        "no permission maps action {action:?} on object type {object_type:?}"
398                    ),
399                    other => other.to_string(),
400                };
401                AuthzDecision {
402                    decision: Decision::Deny(reason.clone()),
403                    authz_t,
404                    object,
405                    path: None,
406                    views: Vec::new(),
407                    reason: Some(reason),
408                }
409            }
410        }
411    }
412
413    /// The fail-closed answer when policy cannot be read, with the break-glass
414    /// escape for operator recovery.
415    fn unavailable(&self, principal: &Principal, access: &Access, error: &str) -> AuthzDecision {
416        let authz_t = self.current_policy().map_or(0, |policy| policy.basis_t());
417        if let Some(break_glass) = &self.config.break_glass
418            && break_glass.admits(principal)
419        {
420            tracing::warn!(
421                target: "corium_authz::audit",
422                subject = %principal.subject,
423                provider = %principal.provider,
424                action = action_name(access.action),
425                source = self.source.name(),
426                %error,
427                "break-glass: allowing access while the authorization database is unavailable"
428            );
429            return AuthzDecision {
430                decision: Decision::Allow,
431                authz_t,
432                object: access
433                    .database
434                    .clone()
435                    .unwrap_or_else(|| "catalog".to_owned()),
436                path: Some("break-glass".to_owned()),
437                views: Vec::new(),
438                reason: None,
439            };
440        }
441        let reason = format!("authorization policy is unavailable: {error}");
442        AuthzDecision {
443            decision: Decision::Deny(reason.clone()),
444            authz_t,
445            object: access
446                .database
447                .clone()
448                .unwrap_or_else(|| "catalog".to_owned()),
449            path: None,
450            views: Vec::new(),
451            reason: Some(reason),
452        }
453    }
454}
455
456#[tonic::async_trait]
457impl Authorizer for SystemDbAuthorizer {
458    async fn authorize(&self, principal: &Principal, access: &Access) -> Decision {
459        self.check(principal, access).await.decision
460    }
461}
462
463/// Failure to load or compile a policy snapshot.
464#[derive(Clone, Debug, thiserror::Error)]
465pub enum RefreshError {
466    /// The snapshot could not be read.
467    #[error(transparent)]
468    Source(#[from] SourceError),
469    /// The snapshot is not a well-formed policy.
470    #[error(transparent)]
471    Policy(#[from] PolicyError),
472}
473
474#[derive(Clone, PartialEq, Eq, Hash)]
475struct CacheKey {
476    principal: String,
477    action: &'static str,
478    object: String,
479}
480
481/// Positive and negative check results for one authz basis.
482///
483/// Keying by basis `t` is what makes invalidation free: a policy change moves
484/// the basis, and every entry under the old one is dropped wholesale.
485struct CheckCache {
486    basis_t: u64,
487    capacity: usize,
488    entries: HashMap<CacheKey, AuthzDecision>,
489}
490
491impl CheckCache {
492    fn new(capacity: usize) -> Self {
493        Self {
494            basis_t: 0,
495            capacity,
496            entries: HashMap::new(),
497        }
498    }
499
500    fn retarget(&mut self, basis_t: u64) {
501        if self.basis_t != basis_t {
502            self.basis_t = basis_t;
503            self.entries.clear();
504        }
505    }
506
507    fn get(&mut self, basis_t: u64, key: &CacheKey) -> Option<AuthzDecision> {
508        self.retarget(basis_t);
509        self.entries.get(key).cloned()
510    }
511
512    fn put(&mut self, basis_t: u64, key: CacheKey, decision: AuthzDecision) {
513        self.retarget(basis_t);
514        // Bulk eviction rather than an LRU: entries are cheap, uniform, and
515        // die at the next policy change anyway, so the extra bookkeeping of a
516        // recency order would cost more than the occasional refill.
517        if self.entries.len() >= self.capacity {
518            self.entries.clear();
519        }
520        self.entries.insert(key, decision);
521    }
522}