use crate::{
Decision, DecisionAuditId, DecisionAuditOccurrence, DecisionAuditOccurrenceError, DenialReason,
FactId, FactResolutionError, FactResolutionEvidence, Locale, ObligationId, PolicyHash,
PolicyId, Presence, ReasonValue, RequestId, SubjectRef, SubjectSlot, TenantBinding, TenantId,
Trace, TraceClause,
};
use serde::{Deserialize, Serialize, de::Error as DeError};
use std::collections::BTreeMap;
use thiserror::Error;
pub const AUDIT_ENTRY_SCHEMA_VERSION: u16 = 2;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PolicyAnchor {
#[serde(rename = "policy_id")]
id: PolicyId,
#[serde(rename = "policy_hash")]
hash: PolicyHash,
#[serde(rename = "policy_hash_version")]
hash_version: u16,
}
#[derive(Deserialize)]
struct PolicyAnchorWire {
#[serde(rename = "policy_id")]
id: PolicyId,
#[serde(rename = "policy_hash")]
hash: PolicyHash,
#[serde(rename = "policy_hash_version")]
hash_version: u16,
}
impl<'de> Deserialize<'de> for PolicyAnchor {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = PolicyAnchorWire::deserialize(deserializer)?;
if wire.hash_version != crate::POLICY_HASH_FORMAT_VERSION {
return Err(D::Error::custom(format!(
"unsupported policy hash format version {}; expected {}",
wire.hash_version,
crate::POLICY_HASH_FORMAT_VERSION
)));
}
Ok(Self {
id: wire.id,
hash: wire.hash,
hash_version: wire.hash_version,
})
}
}
impl PolicyAnchor {
#[must_use]
pub const fn new(policy_id: PolicyId, policy_hash: PolicyHash) -> Self {
Self {
id: policy_id,
hash: policy_hash,
hash_version: crate::POLICY_HASH_FORMAT_VERSION,
}
}
#[must_use]
pub const fn policy_id(&self) -> &PolicyId {
&self.id
}
#[must_use]
pub const fn policy_hash(&self) -> &PolicyHash {
&self.hash
}
#[must_use]
pub const fn policy_hash_version(&self) -> u16 {
self.hash_version
}
const fn validate_current(&self) -> Result<(), AuditEntryError> {
if self.hash_version != crate::POLICY_HASH_FORMAT_VERSION {
return Err(AuditEntryError::UnsupportedPolicyHashVersion {
expected: crate::POLICY_HASH_FORMAT_VERSION,
actual: self.hash_version,
});
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LegacyPolicyAnchor {
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)]
pub struct AuditEntry {
schema_version: u16,
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,
}
#[derive(Deserialize)]
struct AuditEntryWire {
schema_version: u16,
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,
#[serde(default)]
subjects: BTreeMap<SubjectSlot, SubjectRef>,
locale: Locale,
}
impl<'de> Deserialize<'de> for AuditEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = AuditEntryWire::deserialize(deserializer)?;
if !matches!(wire.schema_version, 1 | AUDIT_ENTRY_SCHEMA_VERSION) {
return Err(D::Error::custom(format!(
"unsupported audit entry schema version {}; expected {}",
wire.schema_version, AUDIT_ENTRY_SCHEMA_VERSION
)));
}
let occurrence = DecisionAuditOccurrence::new(wire.decision_audit_id, wire.occurred_at)
.map_err(D::Error::custom)?;
let mut entry = Self::new(
occurrence,
wire.request_id,
wire.anchor,
wire.effect,
wire.obligations,
wire.consulted,
wire.decisive,
wire.denial_reason,
wire.trace,
wire.binding,
wire.fact_resolution,
wire.tenant,
wire.principal,
wire.subjects,
wire.locale,
)
.map_err(D::Error::custom)?;
entry.schema_version = wire.schema_version;
entry.validate_current().map_err(D::Error::custom)?;
Ok(entry)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LegacyAuditEntry {
pub decision_audit_id: String,
pub occurred_at: time::OffsetDateTime,
pub request_id: Option<RequestId>,
pub anchor: LegacyPolicyAnchor,
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,
}
impl LegacyAuditEntry {
pub fn validate_semantics(&self) -> Result<(), AuditEntryError> {
validate_audit_semantics(
self.effect,
&self.obligations,
&self.consulted,
&self.decisive,
self.denial_reason.as_ref(),
&self.trace,
)
}
pub fn into_current(
self,
occurrence: DecisionAuditOccurrence,
policy_hash_version: u16,
binding: TenantBinding,
fact_resolution: FactResolutionEvidence,
) -> Result<AuditEntry, AuditEntryError> {
self.validate_semantics()?;
if policy_hash_version != crate::POLICY_HASH_FORMAT_VERSION {
return Err(AuditEntryError::UnsupportedPolicyHashVersion {
expected: crate::POLICY_HASH_FORMAT_VERSION,
actual: policy_hash_version,
});
}
AuditEntry::new(
occurrence,
self.request_id,
PolicyAnchor::new(self.anchor.policy_id, self.anchor.policy_hash),
self.effect,
self.obligations,
self.consulted,
self.decisive,
self.denial_reason,
self.trace,
binding,
fact_resolution,
self.tenant,
self.principal,
self.subjects,
self.locale,
)
}
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum AuditEntryError {
#[error("invalid selected observation evidence")]
InvalidObservations,
#[error("unsupported audit entry schema version {actual}; expected {expected}")]
UnsupportedSchemaVersion {
expected: u16,
actual: u16,
},
#[error("unsupported policy hash format version {actual}; expected {expected}")]
UnsupportedPolicyHashVersion {
expected: u16,
actual: u16,
},
#[error("audit entry tenant does not match its binding")]
BindingTenantMismatch,
#[error("audit entry carries an invalid decision occurrence")]
InvalidDecisionOccurrence(#[source] DecisionAuditOccurrenceError),
#[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,
}
#[derive(Debug, Error)]
pub enum AuditConstructionError {
#[error(transparent)]
Trace(#[from] crate::TraceError),
#[error(transparent)]
Entry(#[from] AuditEntryError),
}
impl AuditEntry {
pub fn from_decision<O: Serialize + Clone>(
occurrence: DecisionAuditOccurrence,
anchor: PolicyAnchor,
decision: &Decision<O>,
context: &crate::Context,
evidence: FactResolutionEvidence,
) -> Result<Self, AuditConstructionError> {
let trace = decision.to_trace()?;
Ok(Self::new(
occurrence,
context.request_id().cloned(),
anchor,
EffectKind::from(decision),
decision.obligations.clone(),
trace.consulted.clone(),
trace.decisive.clone(),
decision.denial_reason()?,
trace,
context.binding().clone(),
evidence,
context.tenant().clone(),
context.principal().clone(),
context.subjects().clone(),
context.locale().clone(),
)?)
}
#[allow(
clippy::too_many_arguments,
reason = "Published Gatekeep 4 constructor; retain its signature until a justified major API migration"
)]
pub fn new(
occurrence: DecisionAuditOccurrence,
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> {
occurrence
.validate()
.map_err(AuditEntryError::InvalidDecisionOccurrence)?;
let (decision_audit_id, occurred_at) = occurrence.into_parts();
let entry = Self {
schema_version: AUDIT_ENTRY_SCHEMA_VERSION,
decision_audit_id,
occurred_at,
request_id,
anchor,
effect,
obligations,
consulted,
decisive,
denial_reason,
trace,
binding,
fact_resolution,
tenant,
principal,
subjects,
locale,
};
entry.validate_current()?;
Ok(entry)
}
#[must_use]
pub const fn schema_version(&self) -> u16 {
self.schema_version
}
#[must_use]
pub fn occurrence(&self) -> DecisionAuditOccurrence {
DecisionAuditOccurrence::from_validated_parts(
self.decision_audit_id.clone(),
self.occurred_at,
)
}
#[must_use]
pub const fn decision_audit_id(&self) -> &DecisionAuditId {
&self.decision_audit_id
}
#[must_use]
pub const fn occurred_at(&self) -> time::OffsetDateTime {
self.occurred_at
}
#[must_use]
pub const fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
#[must_use]
pub const fn anchor(&self) -> &PolicyAnchor {
&self.anchor
}
#[must_use]
pub const fn effect(&self) -> EffectKind {
self.effect
}
#[must_use]
pub fn obligations(&self) -> &[ObligationId] {
&self.obligations
}
#[must_use]
pub fn consulted(&self) -> &[(FactId, Presence)] {
&self.consulted
}
#[must_use]
pub const fn decisive(&self) -> &TraceClause {
&self.decisive
}
#[must_use]
pub const fn denial_reason(&self) -> Option<&DenialReason> {
self.denial_reason.as_ref()
}
#[must_use]
pub const fn trace(&self) -> &Trace {
&self.trace
}
#[must_use]
pub const fn binding(&self) -> &TenantBinding {
&self.binding
}
#[must_use]
pub const fn fact_resolution(&self) -> &FactResolutionEvidence {
&self.fact_resolution
}
#[must_use]
pub const fn tenant(&self) -> &TenantId {
&self.tenant
}
#[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
}
pub fn validate_current(&self) -> Result<(), AuditEntryError> {
if !matches!(self.schema_version, 1 | AUDIT_ENTRY_SCHEMA_VERSION) {
return Err(AuditEntryError::UnsupportedSchemaVersion {
expected: AUDIT_ENTRY_SCHEMA_VERSION,
actual: self.schema_version,
});
}
if self.schema_version == 1 && !self.fact_resolution.observations().is_empty() {
return Err(AuditEntryError::InvalidObservations);
}
for observation in self.fact_resolution.observations() {
let expected = if observation.value() {
Presence::Present
} else {
Presence::Absent
};
if self
.consulted
.iter()
.any(|(fact, value)| fact == observation.fact() && *value != expected)
{
return Err(AuditEntryError::InvalidObservations);
}
}
DecisionAuditOccurrence::from_validated_parts(
self.decision_audit_id.clone(),
self.occurred_at,
)
.validate()
.map_err(AuditEntryError::InvalidDecisionOccurrence)?;
self.anchor.validate_current()?;
if self.binding.tenant() != &self.tenant {
return Err(AuditEntryError::BindingTenantMismatch);
}
self.fact_resolution
.validate()
.map_err(AuditEntryError::InvalidFactResolutionEvidence)?;
self.validate_semantics()
}
pub fn validate_semantics(&self) -> Result<(), AuditEntryError> {
validate_audit_semantics(
self.effect,
&self.obligations,
&self.consulted,
&self.decisive,
self.denial_reason.as_ref(),
&self.trace,
)
}
}
fn validate_audit_semantics(
effect: EffectKind,
obligations: &[ObligationId],
consulted: &[(FactId, Presence)],
decisive: &TraceClause,
denial_reason: Option<&DenialReason>,
trace: &Trace,
) -> Result<(), AuditEntryError> {
if decisive != &trace.decisive {
return Err(AuditEntryError::DecisiveTraceMismatch);
}
if consulted != trace.consulted.as_slice() {
return Err(AuditEntryError::ConsultedTraceMismatch);
}
match (effect, decisive) {
(EffectKind::Permit, TraceClause::Permit { .. }) => {
if denial_reason.is_some() {
return Err(AuditEntryError::PermitWithDenialReason);
}
}
(EffectKind::Deny, TraceClause::Deny { .. }) => {
if !obligations.is_empty() {
return Err(AuditEntryError::DenyWithObligations);
}
if !denial_reason_matches_trace(denial_reason, 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 Some(actual.len()) != unsatisfied.len().checked_add(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()))
})
}