Skip to main content

blindplane_access/
event.rs

1//! Canonical audit events sealed for a user role and tenant-administrator role.
2
3use blindplane_core::{Author, open, seal};
4use blindplane_crypto::aead::Suite;
5use blindplane_wire::{RecordContext, SealedRecord};
6
7use crate::AccessError;
8use crate::codec::{
9    AccessValidationPolicy, Cursor, push_bytes, push_header, push_string, validate_identifier,
10};
11use crate::grant::RoleKeypair;
12
13const AUDIT_EVENT_TAG: u8 = 5;
14const AUDIT_SCHEMA_VERSION: u32 = 1;
15
16/// Kind of encrypted enterprise audit event.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum AuditEventKind {
19    /// A user or agent request.
20    Request,
21    /// A service or agent response.
22    Response,
23    /// An MCP, tool, skill, or CLI usage observation.
24    CapabilityUsage,
25    /// A policy allow or deny result.
26    PolicyDecision,
27    /// An application-defined event.
28    Custom,
29}
30
31impl AuditEventKind {
32    const fn code(self) -> u8 {
33        match self {
34            Self::Request => 1,
35            Self::Response => 2,
36            Self::CapabilityUsage => 3,
37            Self::PolicyDecision => 4,
38            Self::Custom => 5,
39        }
40    }
41
42    fn from_code(code: u8) -> Result<Self, AccessError> {
43        match code {
44            1 => Ok(Self::Request),
45            2 => Ok(Self::Response),
46            3 => Ok(Self::CapabilityUsage),
47            4 => Ok(Self::PolicyDecision),
48            5 => Ok(Self::Custom),
49            _ => Err(AccessError::WrongObjectType),
50        }
51    }
52}
53
54/// Full encrypted event content visible to the owning user and tenant administrators.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct AuditEvent {
57    /// Tenant isolation boundary.
58    pub tenant_id: String,
59    /// User or service that owns the event.
60    pub subject_id: String,
61    /// Exact session or audit stream.
62    pub session_id: String,
63    /// Monotonic event number inside the stream.
64    pub sequence: u64,
65    /// Event time in Unix seconds.
66    pub timestamp: u64,
67    /// Event category.
68    pub kind: AuditEventKind,
69    /// Content media type such as `application/json`.
70    pub media_type: String,
71    /// Opaque request, response, or usage payload.
72    pub body: Vec<u8>,
73}
74
75impl AuditEvent {
76    /// Canonical plaintext encoding, intended to be passed immediately to [`seal_audit_event`].
77    pub fn encode(&self) -> Vec<u8> {
78        let mut out = Vec::with_capacity(
79            96 + self.tenant_id.len()
80                + self.subject_id.len()
81                + self.session_id.len()
82                + self.media_type.len()
83                + self.body.len(),
84        );
85        push_header(&mut out, AUDIT_EVENT_TAG);
86        push_string(&mut out, &self.tenant_id);
87        push_string(&mut out, &self.subject_id);
88        push_string(&mut out, &self.session_id);
89        out.extend_from_slice(&self.sequence.to_be_bytes());
90        out.extend_from_slice(&self.timestamp.to_be_bytes());
91        out.push(self.kind.code());
92        push_string(&mut out, &self.media_type);
93        push_bytes(&mut out, &self.body);
94        out
95    }
96
97    /// Decode and validate a canonical event under default bounds.
98    pub fn decode(bytes: &[u8]) -> Result<Self, AccessError> {
99        Self::decode_with(bytes, &AccessValidationPolicy::default())
100    }
101
102    /// Decode and validate a canonical event under caller-supplied bounds.
103    pub fn decode_with(bytes: &[u8], limits: &AccessValidationPolicy) -> Result<Self, AccessError> {
104        let mut cursor = Cursor::new(bytes);
105        cursor.take_header(AUDIT_EVENT_TAG)?;
106        let event = Self {
107            tenant_id: cursor.take_string(limits.max_identifier_bytes)?,
108            subject_id: cursor.take_string(limits.max_identifier_bytes)?,
109            session_id: cursor.take_string(limits.max_identifier_bytes)?,
110            sequence: cursor.take_u64()?,
111            timestamp: cursor.take_u64()?,
112            kind: AuditEventKind::from_code(cursor.take_u8()?)?,
113            media_type: cursor.take_string(limits.max_identifier_bytes)?,
114            body: cursor.take_bytes(limits.max_event_body_bytes)?.to_vec(),
115        };
116        if !cursor.is_empty() {
117            return Err(AccessError::TrailingBytes);
118        }
119        event.validate(limits)?;
120        if event.encode() != bytes {
121            return Err(AccessError::NonCanonicalEncoding);
122        }
123        Ok(event)
124    }
125
126    fn validate(&self, limits: &AccessValidationPolicy) -> Result<(), AccessError> {
127        validate_identifier(&self.tenant_id, limits.max_identifier_bytes)?;
128        validate_identifier(&self.subject_id, limits.max_identifier_bytes)?;
129        validate_identifier(&self.session_id, limits.max_identifier_bytes)?;
130        validate_identifier(&self.media_type, limits.max_identifier_bytes)?;
131        if self.sequence == 0 {
132            return Err(AccessError::InvalidEpoch);
133        }
134        if self.body.len() > limits.max_event_body_bytes {
135            return Err(AccessError::LengthLimit(self.body.len()));
136        }
137        Ok(())
138    }
139}
140
141/// Clear routing fields authenticated by both record signature and payload AEAD.
142#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct AuditContext {
144    /// Tenant isolation boundary.
145    pub tenant_id: String,
146    /// User or service that owns the event.
147    pub subject_id: String,
148    /// Stable session or stream identifier.
149    pub stream_id: String,
150    /// Access epoch, incremented when either role membership shrinks.
151    pub epoch: u64,
152    /// Monotonic event number inside the stream.
153    pub sequence: u64,
154}
155
156/// Seal an event for exactly its subject role and the tenant administrator role.
157pub fn seal_audit_event(
158    author: &Author,
159    context: AuditContext,
160    event: &AuditEvent,
161    subject_role: &RoleKeypair,
162    administrator_role: &RoleKeypair,
163    suite: Suite,
164) -> Result<SealedRecord, AccessError> {
165    let limits = AccessValidationPolicy::default();
166    validate_context(&context, &limits)?;
167    event.validate(&limits)?;
168    if event.tenant_id != context.tenant_id
169        || event.subject_id != context.subject_id
170        || event.session_id != context.stream_id
171        || event.sequence != context.sequence
172        || subject_role.tenant_id() != context.tenant_id
173        || administrator_role.tenant_id() != context.tenant_id
174        || subject_role.scope() != administrator_role.scope()
175    {
176        return Err(AccessError::SubjectMismatch);
177    }
178    if subject_role.role_id() == administrator_role.role_id()
179        && subject_role.key_epoch() == administrator_role.key_epoch()
180    {
181        return Err(AccessError::InvalidKeyIdentity);
182    }
183    let recipients = [subject_role.recipient()?, administrator_role.recipient()?];
184    seal(
185        author,
186        RecordContext {
187            tenant: context.tenant_id,
188            object_id: context.subject_id,
189            field: context.stream_id,
190            epoch: context.epoch,
191            version: context.sequence,
192            schema_version: AUDIT_SCHEMA_VERSION,
193        },
194        &event.encode(),
195        &recipients,
196        vec![],
197        suite,
198    )
199    .map_err(|_| AccessError::CryptographicFailure)
200}
201
202/// Open and context-check an event using either its subject role or tenant admin role.
203pub fn open_audit_event(
204    record: &SealedRecord,
205    role: &RoleKeypair,
206    expected_author: [u8; 32],
207) -> Result<AuditEvent, AccessError> {
208    let recipient = role.recipient_keypair()?;
209    let plaintext =
210        open(record, &recipient, expected_author).map_err(|_| AccessError::CryptographicFailure)?;
211    let event = AuditEvent::decode(plaintext.as_bytes())?;
212    if record.context.schema_version != AUDIT_SCHEMA_VERSION
213        || record.context.tenant != event.tenant_id
214        || record.context.object_id != event.subject_id
215        || record.context.field != event.session_id
216        || record.context.version != event.sequence
217    {
218        return Err(AccessError::SubjectMismatch);
219    }
220    Ok(event)
221}
222
223fn validate_context(
224    context: &AuditContext,
225    limits: &AccessValidationPolicy,
226) -> Result<(), AccessError> {
227    validate_identifier(&context.tenant_id, limits.max_identifier_bytes)?;
228    validate_identifier(&context.subject_id, limits.max_identifier_bytes)?;
229    validate_identifier(&context.stream_id, limits.max_identifier_bytes)?;
230    if context.epoch == 0 || context.sequence == 0 {
231        return Err(AccessError::InvalidEpoch);
232    }
233    Ok(())
234}