1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25pub enum Consistency {
26 #[default]
31 Pinned,
32 Fresh,
35}
36
37#[derive(Clone, Debug, Default)]
43pub struct BreakGlass {
44 pub subjects: BTreeSet<String>,
46 pub roles: BTreeSet<String>,
48}
49
50impl BreakGlass {
51 #[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#[derive(Clone, Debug)]
61pub struct AuthzConfig {
62 pub limits: Limits,
64 pub mapping: SubjectMapping,
66 pub consistency: Consistency,
68 pub fresh_for: BTreeSet<ActionClass>,
72 pub expand_relations: Vec<String>,
76 pub catalog_object: ObjectRef,
78 pub check_cache_capacity: usize,
80 pub break_glass: Option<BreakGlass>,
82 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#[derive(Clone)]
104pub struct AuthzDecision {
105 pub decision: Decision,
107 pub authz_t: u64,
109 pub object: String,
111 pub path: Option<String>,
113 pub views: Vec<String>,
115 pub reason: Option<String>,
117}
118
119impl AuthzDecision {
120 #[must_use]
122 pub fn is_allowed(&self) -> bool {
123 !matches!(self.decision, Decision::Deny(_))
124 }
125
126 #[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
150pub 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 #[must_use]
162 pub fn new(source: Arc<dyn PolicySource>) -> Self {
163 Self::with_config(source, AuthzConfig::default())
164 }
165
166 #[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 #[must_use]
181 pub fn with_audit(mut self, audit: Arc<dyn AuditSink>) -> Self {
182 self.audit = audit;
183 self
184 }
185
186 #[must_use]
188 pub fn config(&self) -> &AuthzConfig {
189 &self.config
190 }
191
192 #[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 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 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 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 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 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 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#[derive(Clone, Debug, thiserror::Error)]
465pub enum RefreshError {
466 #[error(transparent)]
468 Source(#[from] SourceError),
469 #[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
481struct 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 if self.entries.len() >= self.capacity {
518 self.entries.clear();
519 }
520 self.entries.insert(key, decision);
521 }
522}