use std::{collections::BTreeMap, convert::Infallible};
use async_trait::async_trait;
use serde::{Deserialize, Serialize, de::Error as DeError};
use thiserror::Error;
use crate::{
ApplicationVerifiedTenantBinding, BindingProvenance, Decision, DecisionAuditId,
DecisionAuditOccurrence, DenialReason, EvidenceDigest, FactId, KnownFacts, Locale,
ObligationId, PartialFacts, PolicyHash, PolicyId, Presence, ReasonValue, RequestId,
ResidualPolicy, SubjectRef, SubjectSlot, TenantBinding, TenantBindingError, TenantId, Trace,
TraceClause, TrustedServiceBinding,
};
pub trait Clock: Send + Sync {
fn now_utc(&self) -> time::OffsetDateTime;
}
impl<F> Clock for F
where
F: Fn() -> time::OffsetDateTime + Send + Sync,
{
fn now_utc(&self) -> time::OffsetDateTime {
self()
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SystemClock;
impl Clock for SystemClock {
fn now_utc(&self) -> time::OffsetDateTime {
time::OffsetDateTime::now_utc()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Context {
tenant: TenantId,
binding: TenantBinding,
principal: SubjectRef,
subjects: BTreeMap<SubjectSlot, SubjectRef>,
locale: Locale,
request_id: Option<crate::RequestId>,
decision_audit_occurrence: Option<DecisionAuditOccurrence>,
}
impl Context {
pub fn new(
tenant: TenantId,
binding: TenantBinding,
principal: SubjectRef,
locale: Locale,
) -> Result<Self, ContextError> {
Self::new_at(
tenant,
binding,
principal,
locale,
time::OffsetDateTime::now_utc(),
)
}
pub fn new_at(
tenant: TenantId,
binding: TenantBinding,
principal: SubjectRef,
locale: Locale,
now: time::OffsetDateTime,
) -> Result<Self, ContextError> {
if tenant != *binding.tenant() {
return Err(ContextError::TenantMismatch {
expected: tenant,
bound: binding.tenant().clone(),
});
}
binding.validate_at(now).map_err(ContextError::Binding)?;
Ok(Self {
tenant,
binding,
principal,
subjects: BTreeMap::new(),
locale,
request_id: None,
decision_audit_occurrence: None,
})
}
pub fn from_application_verified(
binding: ApplicationVerifiedTenantBinding,
principal: SubjectRef,
locale: Locale,
) -> Result<Self, ContextError> {
let tenant = binding.tenant().clone();
Self::new(
tenant,
TenantBinding::ApplicationVerified(binding),
principal,
locale,
)
}
pub fn from_trusted_service(
binding: TrustedServiceBinding,
principal: SubjectRef,
locale: Locale,
) -> Result<Self, ContextError> {
let tenant = binding.tenant().clone();
Self::new(
tenant,
TenantBinding::TrustedService(binding),
principal,
locale,
)
}
pub fn validate_at(&self, now: time::OffsetDateTime) -> Result<(), ContextError> {
if self.tenant != *self.binding.tenant() {
return Err(ContextError::TenantMismatch {
expected: self.tenant.clone(),
bound: self.binding.tenant().clone(),
});
}
self.binding.validate_at(now).map_err(ContextError::Binding)
}
#[must_use]
pub const fn tenant(&self) -> &TenantId {
&self.tenant
}
#[must_use]
pub const fn binding(&self) -> &TenantBinding {
&self.binding
}
#[must_use]
pub const fn principal(&self) -> &SubjectRef {
&self.principal
}
#[must_use]
pub const fn subjects(&self) -> &BTreeMap<SubjectSlot, SubjectRef> {
&self.subjects
}
#[must_use]
pub const fn locale(&self) -> &Locale {
&self.locale
}
#[must_use]
pub const fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
#[must_use]
pub fn with_subject(mut self, slot: SubjectSlot, subject: SubjectRef) -> Self {
self.subjects.insert(slot, subject);
self
}
#[must_use]
pub fn with_request_id(mut self, request_id: RequestId) -> Self {
self.request_id = Some(request_id);
self
}
#[must_use]
pub fn with_decision_audit_occurrence(mut self, occurrence: DecisionAuditOccurrence) -> Self {
self.decision_audit_occurrence = Some(occurrence);
self
}
#[must_use]
pub const fn decision_audit_occurrence(&self) -> Option<&DecisionAuditOccurrence> {
self.decision_audit_occurrence.as_ref()
}
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum ContextError {
#[error("tenant context does not match its binding: expected {expected}, bound {bound}")]
TenantMismatch {
expected: TenantId,
bound: TenantId,
},
#[error(transparent)]
Binding(#[from] TenantBindingError),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct FactResolution<F> {
facts: F,
metadata: Option<FactResolutionMetadata>,
observed_at: time::OffsetDateTime,
}
#[derive(Deserialize)]
struct FactResolutionWire<F> {
facts: F,
metadata: Option<FactResolutionMetadata>,
observed_at: time::OffsetDateTime,
}
impl<'de, F> Deserialize<'de> for FactResolution<F>
where
F: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = FactResolutionWire::deserialize(deserializer)?;
Self::new(wire.facts, wire.metadata, wire.observed_at).map_err(D::Error::custom)
}
}
impl<F> FactResolution<F> {
pub fn new(
facts: F,
metadata: Option<FactResolutionMetadata>,
observed_at: time::OffsetDateTime,
) -> Result<Self, FactResolutionError> {
if let Some(fresh_until) = metadata
.as_ref()
.and_then(FactResolutionMetadata::fresh_until)
&& fresh_until < observed_at
{
return Err(FactResolutionError::InvalidFreshnessWindow {
observed_at,
fresh_until,
});
}
Ok(Self {
facts,
metadata,
observed_at,
})
}
#[must_use]
pub const fn facts(&self) -> &F {
&self.facts
}
#[must_use]
pub const fn metadata(&self) -> Option<&FactResolutionMetadata> {
self.metadata.as_ref()
}
#[must_use]
pub const fn observed_at(&self) -> time::OffsetDateTime {
self.observed_at
}
pub fn validate_at(
&self,
received_at: time::OffsetDateTime,
) -> Result<(), FactResolutionError> {
if self.observed_at > received_at {
return Err(FactResolutionError::ObservedInFuture {
observed_at: self.observed_at,
received_at,
});
}
if let Some(fresh_until) = self
.metadata
.as_ref()
.and_then(FactResolutionMetadata::fresh_until)
&& received_at >= fresh_until
{
return Err(FactResolutionError::Expired {
received_at,
fresh_until,
});
}
Ok(())
}
#[must_use]
pub fn into_parts(self) -> (F, Option<FactResolutionMetadata>, time::OffsetDateTime) {
(self.facts, self.metadata, self.observed_at)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FactResolutionMetadata {
source: BindingProvenance,
revision: Option<BindingProvenance>,
fresh_until: Option<time::OffsetDateTime>,
}
impl FactResolutionMetadata {
#[must_use]
pub const fn new(
source: BindingProvenance,
revision: Option<BindingProvenance>,
fresh_until: Option<time::OffsetDateTime>,
) -> Self {
Self {
source,
revision,
fresh_until,
}
}
#[must_use]
pub const fn source(&self) -> &BindingProvenance {
&self.source
}
#[must_use]
pub const fn revision(&self) -> Option<&BindingProvenance> {
self.revision.as_ref()
}
#[must_use]
pub const fn fresh_until(&self) -> Option<time::OffsetDateTime> {
self.fresh_until
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct FactResolutionEvidence {
source: Option<BindingProvenance>,
revision: Option<BindingProvenance>,
observed_at: time::OffsetDateTime,
fresh_until: Option<time::OffsetDateTime>,
fact_set_digest: EvidenceDigest,
}
#[derive(Deserialize)]
struct FactResolutionEvidenceWire {
source: Option<BindingProvenance>,
revision: Option<BindingProvenance>,
observed_at: time::OffsetDateTime,
fresh_until: Option<time::OffsetDateTime>,
fact_set_digest: EvidenceDigest,
}
impl<'de> Deserialize<'de> for FactResolutionEvidence {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = FactResolutionEvidenceWire::deserialize(deserializer)?;
let evidence = Self {
source: wire.source,
revision: wire.revision,
observed_at: wire.observed_at,
fresh_until: wire.fresh_until,
fact_set_digest: wire.fact_set_digest,
};
evidence.validate().map_err(D::Error::custom)?;
Ok(evidence)
}
}
impl FactResolutionEvidence {
fn validate(&self) -> Result<(), FactResolutionError> {
if let Some(fresh_until) = self.fresh_until
&& fresh_until < self.observed_at
{
return Err(FactResolutionError::InvalidFreshnessWindow {
observed_at: self.observed_at,
fresh_until,
});
}
Ok(())
}
pub fn from_resolution(
resolution: &FactResolution<KnownFacts>,
) -> Result<Self, FactResolutionEvidenceError> {
let encoded = postcard::to_allocvec(resolution.facts())
.map_err(FactResolutionEvidenceError::Serialization)?;
let fact_set_digest = EvidenceDigest::new(*blake3::hash(&encoded).as_bytes());
let (source, revision, fresh_until) =
resolution
.metadata()
.map_or((None, None, None), |metadata| {
(
Some(metadata.source.clone()),
metadata.revision.clone(),
metadata.fresh_until,
)
});
Ok(Self {
source,
revision,
observed_at: resolution.observed_at(),
fresh_until,
fact_set_digest,
})
}
#[must_use]
pub const fn source(&self) -> Option<&BindingProvenance> {
self.source.as_ref()
}
#[must_use]
pub const fn revision(&self) -> Option<&BindingProvenance> {
self.revision.as_ref()
}
#[must_use]
pub const fn observed_at(&self) -> time::OffsetDateTime {
self.observed_at
}
#[must_use]
pub const fn fresh_until(&self) -> Option<time::OffsetDateTime> {
self.fresh_until
}
#[must_use]
pub const fn fact_set_digest(&self) -> &EvidenceDigest {
&self.fact_set_digest
}
}
#[derive(Debug, Error)]
pub enum FactResolutionEvidenceError {
#[error("resolved fact set could not be serialized for evidence")]
Serialization(#[source] postcard::Error),
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum FactResolutionError {
#[error("fact-resolution freshness deadline precedes its observation time")]
InvalidFreshnessWindow {
observed_at: time::OffsetDateTime,
fresh_until: time::OffsetDateTime,
},
#[error("fact-resolution result expired before the decision boundary")]
Expired {
received_at: time::OffsetDateTime,
fresh_until: time::OffsetDateTime,
},
#[error("fact-resolution observation is after the decision boundary")]
ObservedInFuture {
observed_at: time::OffsetDateTime,
received_at: time::OffsetDateTime,
},
}
#[async_trait]
pub trait FactResolver: Send + Sync {
type Error: std::error::Error + Send + Sync + 'static;
async fn resolve_for_decision(
&self,
required: &[FactId],
cx: &Context,
clock: &dyn Clock,
) -> Result<FactResolution<KnownFacts>, ResolveError<Self::Error>>;
async fn resolve_for_query(
&self,
required: &[FactId],
cx: &Context,
clock: &dyn Clock,
) -> Result<FactResolution<PartialFacts>, ResolveError<Self::Error>>;
}
#[derive(Debug, Error)]
pub enum ResolveError<E> {
#[error("fact backend failed")]
Backend(#[from] E),
#[error(transparent)]
Resolution(FactResolutionError),
#[error("required fact is missing: {0}")]
MissingFact(FactId),
#[error("required subject slot is missing for fact {fact}: {slot}")]
MissingSubject {
fact: FactId,
slot: SubjectSlot,
},
#[error("fact resolution timed out")]
Timeout,
}
pub trait PolicyObserver: Send + Sync {
fn observe(&self, decision_summary: &DecisionSummary);
}
#[derive(Default)]
pub struct NoopPolicyObserver;
impl PolicyObserver for NoopPolicyObserver {
fn observe(&self, _decision_summary: &DecisionSummary) {}
}
#[async_trait]
pub trait AuditSink: Send + Sync {
type Error: std::error::Error + Send + Sync + 'static;
async fn record(&self, entry: &AuditEntry) -> Result<(), Self::Error>;
}
#[derive(Default)]
pub struct NoopAuditSink;
#[async_trait]
impl AuditSink for NoopAuditSink {
type Error = Infallible;
async fn record(&self, _entry: &AuditEntry) -> Result<(), Self::Error> {
Ok(())
}
}
pub trait QueryLowering<O> {
type Filter;
type Projection;
fn lower(
&self,
residual: &ResidualPolicy<O>,
cx: &Context,
) -> Result<Lowered<Self::Filter, Self::Projection>, LowerError>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Lowered<F, P> {
pub filter: F,
pub grade: P,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum LowerError {
#[error("residual fact cannot be lowered: {0}")]
Unlowerable(FactId),
#[error("graded projection requires a total order")]
NonTotalGrade,
}
pub trait ReasonCatalog {
fn render(&self, reason: &crate::DenialReason, locale: &Locale) -> String;
}
#[derive(Default)]
pub struct IdentityReasonCatalog;
impl ReasonCatalog for IdentityReasonCatalog {
fn render(&self, reason: &crate::DenialReason, _locale: &Locale) -> String {
reason.code.as_str().to_owned()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyAnchor {
pub policy_id: PolicyId,
pub policy_hash: PolicyHash,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum EffectKind {
Permit,
Deny,
}
impl<O> From<&Decision<O>> for EffectKind {
fn from(decision: &Decision<O>) -> Self {
match decision.effect {
crate::Effect::Permit(_) => Self::Permit,
crate::Effect::Deny => Self::Deny,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DecisionSummary {
pub anchor: PolicyAnchor,
pub effect: EffectKind,
pub obligations: Vec<ObligationId>,
pub consulted: Vec<(FactId, Presence)>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditEntry {
pub decision_audit_id: DecisionAuditId,
pub occurred_at: time::OffsetDateTime,
pub request_id: Option<RequestId>,
pub anchor: PolicyAnchor,
pub effect: EffectKind,
pub obligations: Vec<ObligationId>,
pub consulted: Vec<(FactId, Presence)>,
pub decisive: TraceClause,
pub denial_reason: Option<DenialReason>,
pub trace: Trace,
#[serde(default)]
pub binding: Option<TenantBinding>,
#[serde(default)]
pub fact_resolution: Option<FactResolutionEvidence>,
pub tenant: TenantId,
pub principal: SubjectRef,
#[serde(default)]
pub subjects: BTreeMap<SubjectSlot, SubjectRef>,
pub locale: Locale,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum AuditEntryError {
#[error("current audit entry is missing its tenant binding")]
MissingBinding,
#[error("audit entry tenant does not match its binding")]
BindingTenantMismatch,
#[error("current audit entry is missing fact-resolution evidence")]
MissingFactResolution,
#[error("audit entry carries invalid fact-resolution evidence")]
InvalidFactResolutionEvidence(#[source] FactResolutionError),
#[error("audit entry decisive clause does not match its trace")]
DecisiveTraceMismatch,
#[error("audit entry consulted facts do not match its trace")]
ConsultedTraceMismatch,
#[error("audit entry effect does not match its decisive trace clause")]
EffectTraceMismatch,
#[error("permit audit entry carries a denial reason")]
PermitWithDenialReason,
#[error("deny audit entry carries obligations")]
DenyWithObligations,
#[error("audit entry denial reason does not match its decisive trace clause")]
DenialReasonMismatch,
}
impl AuditEntry {
#[allow(clippy::too_many_arguments)]
pub fn new(
decision_audit_id: DecisionAuditId,
occurred_at: time::OffsetDateTime,
request_id: Option<RequestId>,
anchor: PolicyAnchor,
effect: EffectKind,
obligations: Vec<ObligationId>,
consulted: Vec<(FactId, Presence)>,
decisive: TraceClause,
denial_reason: Option<DenialReason>,
trace: Trace,
binding: TenantBinding,
fact_resolution: FactResolutionEvidence,
tenant: TenantId,
principal: SubjectRef,
subjects: BTreeMap<SubjectSlot, SubjectRef>,
locale: Locale,
) -> Result<Self, AuditEntryError> {
let entry = Self {
decision_audit_id,
occurred_at,
request_id,
anchor,
effect,
obligations,
consulted,
decisive,
denial_reason,
trace,
binding: Some(binding),
fact_resolution: Some(fact_resolution),
tenant,
principal,
subjects,
locale,
};
entry.validate_current()?;
Ok(entry)
}
pub fn validate_current(&self) -> Result<(), AuditEntryError> {
let binding = self
.binding
.as_ref()
.ok_or(AuditEntryError::MissingBinding)?;
if binding.tenant() != &self.tenant {
return Err(AuditEntryError::BindingTenantMismatch);
}
self.fact_resolution
.as_ref()
.ok_or(AuditEntryError::MissingFactResolution)?
.validate()
.map_err(AuditEntryError::InvalidFactResolutionEvidence)?;
self.validate_semantics()
}
pub fn validate_semantics(&self) -> Result<(), AuditEntryError> {
if self.decisive != self.trace.decisive {
return Err(AuditEntryError::DecisiveTraceMismatch);
}
if self.consulted != self.trace.consulted {
return Err(AuditEntryError::ConsultedTraceMismatch);
}
match (&self.effect, &self.decisive) {
(EffectKind::Permit, TraceClause::Permit { .. }) => {
if self.denial_reason.is_some() {
return Err(AuditEntryError::PermitWithDenialReason);
}
}
(EffectKind::Deny, TraceClause::Deny { .. }) => {
if !self.obligations.is_empty() {
return Err(AuditEntryError::DenyWithObligations);
}
if !denial_reason_matches_trace(self.denial_reason.as_ref(), &self.decisive) {
return Err(AuditEntryError::DenialReasonMismatch);
}
}
_ => return Err(AuditEntryError::EffectTraceMismatch),
}
Ok(())
}
}
fn denial_reason_matches_trace(reason: Option<&DenialReason>, decisive: &TraceClause) -> bool {
let TraceClause::Deny {
denied,
unsatisfied,
label,
reason: reason_code,
shape,
} = decisive
else {
return reason.is_none();
};
let expected_code = reason_code
.as_ref()
.map(crate::ReasonCode::as_str)
.or_else(|| label.as_ref().map(crate::ClauseLabel::as_str));
let Some(expected_code) = expected_code else {
return reason.is_none();
};
let Some(reason) = reason else {
return false;
};
if reason.code.as_str() != expected_code || reason.shape != *shape {
return false;
}
let actual = reason
.params
.iter()
.map(|(key, value)| (key.as_str(), value))
.collect::<BTreeMap<_, _>>();
if actual.len() != unsatisfied.len() + usize::from(denied.is_some()) {
return false;
}
for (index, fact) in unsatisfied.iter().enumerate() {
let key = if index == 0 {
"missing_fact".to_owned()
} else {
format!("missing_fact_{index}")
};
if actual.get(key.as_str()) != Some(&&ReasonValue::Fact(fact.clone())) {
return false;
}
}
denied.as_ref().is_none_or(|value| {
actual.get("denied_outcome") == Some(&&ReasonValue::Outcome(value.clone()))
})
}