blindplane-access 0.1.0

Signed enterprise access grants, capability policies, revocation and encrypted audit events for Blindplane
Documentation
//! Canonical audit events sealed for a user role and tenant-administrator role.

use blindplane_core::{Author, open, seal};
use blindplane_crypto::aead::Suite;
use blindplane_wire::{RecordContext, SealedRecord};

use crate::AccessError;
use crate::codec::{
    AccessValidationPolicy, Cursor, push_bytes, push_header, push_string, validate_identifier,
};
use crate::grant::RoleKeypair;

const AUDIT_EVENT_TAG: u8 = 5;
const AUDIT_SCHEMA_VERSION: u32 = 1;

/// Kind of encrypted enterprise audit event.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AuditEventKind {
    /// A user or agent request.
    Request,
    /// A service or agent response.
    Response,
    /// An MCP, tool, skill, or CLI usage observation.
    CapabilityUsage,
    /// A policy allow or deny result.
    PolicyDecision,
    /// An application-defined event.
    Custom,
}

impl AuditEventKind {
    const fn code(self) -> u8 {
        match self {
            Self::Request => 1,
            Self::Response => 2,
            Self::CapabilityUsage => 3,
            Self::PolicyDecision => 4,
            Self::Custom => 5,
        }
    }

    fn from_code(code: u8) -> Result<Self, AccessError> {
        match code {
            1 => Ok(Self::Request),
            2 => Ok(Self::Response),
            3 => Ok(Self::CapabilityUsage),
            4 => Ok(Self::PolicyDecision),
            5 => Ok(Self::Custom),
            _ => Err(AccessError::WrongObjectType),
        }
    }
}

/// Full encrypted event content visible to the owning user and tenant administrators.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuditEvent {
    /// Tenant isolation boundary.
    pub tenant_id: String,
    /// User or service that owns the event.
    pub subject_id: String,
    /// Exact session or audit stream.
    pub session_id: String,
    /// Monotonic event number inside the stream.
    pub sequence: u64,
    /// Event time in Unix seconds.
    pub timestamp: u64,
    /// Event category.
    pub kind: AuditEventKind,
    /// Content media type such as `application/json`.
    pub media_type: String,
    /// Opaque request, response, or usage payload.
    pub body: Vec<u8>,
}

impl AuditEvent {
    /// Canonical plaintext encoding, intended to be passed immediately to [`seal_audit_event`].
    pub fn encode(&self) -> Vec<u8> {
        let mut out = Vec::with_capacity(
            96 + self.tenant_id.len()
                + self.subject_id.len()
                + self.session_id.len()
                + self.media_type.len()
                + self.body.len(),
        );
        push_header(&mut out, AUDIT_EVENT_TAG);
        push_string(&mut out, &self.tenant_id);
        push_string(&mut out, &self.subject_id);
        push_string(&mut out, &self.session_id);
        out.extend_from_slice(&self.sequence.to_be_bytes());
        out.extend_from_slice(&self.timestamp.to_be_bytes());
        out.push(self.kind.code());
        push_string(&mut out, &self.media_type);
        push_bytes(&mut out, &self.body);
        out
    }

    /// Decode and validate a canonical event under default bounds.
    pub fn decode(bytes: &[u8]) -> Result<Self, AccessError> {
        Self::decode_with(bytes, &AccessValidationPolicy::default())
    }

    /// Decode and validate a canonical event under caller-supplied bounds.
    pub fn decode_with(bytes: &[u8], limits: &AccessValidationPolicy) -> Result<Self, AccessError> {
        let mut cursor = Cursor::new(bytes);
        cursor.take_header(AUDIT_EVENT_TAG)?;
        let event = Self {
            tenant_id: cursor.take_string(limits.max_identifier_bytes)?,
            subject_id: cursor.take_string(limits.max_identifier_bytes)?,
            session_id: cursor.take_string(limits.max_identifier_bytes)?,
            sequence: cursor.take_u64()?,
            timestamp: cursor.take_u64()?,
            kind: AuditEventKind::from_code(cursor.take_u8()?)?,
            media_type: cursor.take_string(limits.max_identifier_bytes)?,
            body: cursor.take_bytes(limits.max_event_body_bytes)?.to_vec(),
        };
        if !cursor.is_empty() {
            return Err(AccessError::TrailingBytes);
        }
        event.validate(limits)?;
        if event.encode() != bytes {
            return Err(AccessError::NonCanonicalEncoding);
        }
        Ok(event)
    }

    fn validate(&self, limits: &AccessValidationPolicy) -> Result<(), AccessError> {
        validate_identifier(&self.tenant_id, limits.max_identifier_bytes)?;
        validate_identifier(&self.subject_id, limits.max_identifier_bytes)?;
        validate_identifier(&self.session_id, limits.max_identifier_bytes)?;
        validate_identifier(&self.media_type, limits.max_identifier_bytes)?;
        if self.sequence == 0 {
            return Err(AccessError::InvalidEpoch);
        }
        if self.body.len() > limits.max_event_body_bytes {
            return Err(AccessError::LengthLimit(self.body.len()));
        }
        Ok(())
    }
}

/// Clear routing fields authenticated by both record signature and payload AEAD.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuditContext {
    /// Tenant isolation boundary.
    pub tenant_id: String,
    /// User or service that owns the event.
    pub subject_id: String,
    /// Stable session or stream identifier.
    pub stream_id: String,
    /// Access epoch, incremented when either role membership shrinks.
    pub epoch: u64,
    /// Monotonic event number inside the stream.
    pub sequence: u64,
}

/// Seal an event for exactly its subject role and the tenant administrator role.
pub fn seal_audit_event(
    author: &Author,
    context: AuditContext,
    event: &AuditEvent,
    subject_role: &RoleKeypair,
    administrator_role: &RoleKeypair,
    suite: Suite,
) -> Result<SealedRecord, AccessError> {
    let limits = AccessValidationPolicy::default();
    validate_context(&context, &limits)?;
    event.validate(&limits)?;
    if event.tenant_id != context.tenant_id
        || event.subject_id != context.subject_id
        || event.session_id != context.stream_id
        || event.sequence != context.sequence
        || subject_role.tenant_id() != context.tenant_id
        || administrator_role.tenant_id() != context.tenant_id
        || subject_role.scope() != administrator_role.scope()
    {
        return Err(AccessError::SubjectMismatch);
    }
    if subject_role.role_id() == administrator_role.role_id()
        && subject_role.key_epoch() == administrator_role.key_epoch()
    {
        return Err(AccessError::InvalidKeyIdentity);
    }
    let recipients = [subject_role.recipient()?, administrator_role.recipient()?];
    seal(
        author,
        RecordContext {
            tenant: context.tenant_id,
            object_id: context.subject_id,
            field: context.stream_id,
            epoch: context.epoch,
            version: context.sequence,
            schema_version: AUDIT_SCHEMA_VERSION,
        },
        &event.encode(),
        &recipients,
        vec![],
        suite,
    )
    .map_err(|_| AccessError::CryptographicFailure)
}

/// Open and context-check an event using either its subject role or tenant admin role.
pub fn open_audit_event(
    record: &SealedRecord,
    role: &RoleKeypair,
    expected_author: [u8; 32],
) -> Result<AuditEvent, AccessError> {
    let recipient = role.recipient_keypair()?;
    let plaintext =
        open(record, &recipient, expected_author).map_err(|_| AccessError::CryptographicFailure)?;
    let event = AuditEvent::decode(plaintext.as_bytes())?;
    if record.context.schema_version != AUDIT_SCHEMA_VERSION
        || record.context.tenant != event.tenant_id
        || record.context.object_id != event.subject_id
        || record.context.field != event.session_id
        || record.context.version != event.sequence
    {
        return Err(AccessError::SubjectMismatch);
    }
    Ok(event)
}

fn validate_context(
    context: &AuditContext,
    limits: &AccessValidationPolicy,
) -> Result<(), AccessError> {
    validate_identifier(&context.tenant_id, limits.max_identifier_bytes)?;
    validate_identifier(&context.subject_id, limits.max_identifier_bytes)?;
    validate_identifier(&context.stream_id, limits.max_identifier_bytes)?;
    if context.epoch == 0 || context.sequence == 0 {
        return Err(AccessError::InvalidEpoch);
    }
    Ok(())
}