systemprompt-security 0.44.0

Security infrastructure for systemprompt.io AI governance: JWT, OAuth2 token extraction, scope enforcement, ChaCha20-Poly1305 secret encryption, the four-layer tool-call governance pipeline, and the unified authz decision plane (deny-overrides resolver + AuthzDecisionHook) shared by gateway and MCP enforcement.
Documentation
//! The authoritative entity catalog a deployment hands to ingestion.
//!
//! Ingestion self-materialises a catalog row for every id it resolves, which is
//! what satisfies the `access_control_rules` FK. The cost is that a literal
//! `entity_id` naming something that does not exist mints a row rather than
//! failing: the boot succeeds and the grant points at nothing. For kinds whose
//! ids are *generated* rather than authored — gateway routes, whose ids are
//! `synthesize_route_id(pattern, provider)` — that turns a typo into a silent
//! dead grant.
//!
//! [`RegisteredEntities`] is how a deployment says "for this kind, I know the
//! full set of real ids; reject anything else". Core owns the enforcement; the
//! caller owns what is authoritative, because only the caller knows where its
//! truth lives (a profile file, a service registry, a vendor API). Passing the
//! set in as data is deliberate: core must not reach for a host's convention.
//!
//! A kind absent from the map is not enforced and keeps the self-materialising
//! behaviour, so adding this to an existing call site is opt-in per kind.
//! [`RegisteredEntities::require`] is the same check as a `Result`, for the
//! write paths that mint a catalog row directly rather than through ingestion.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use std::collections::{HashMap, HashSet};

use super::super::error::{AuthzError, AuthzResult};
use super::super::types::EntityKind;

#[derive(Debug, Clone, Default)]
pub struct RegisteredEntities {
    by_kind: HashMap<EntityKind, HashSet<String>>,
}

impl RegisteredEntities {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    // Why: declaring a kind with an empty set is meaningful, not a no-op — it
    // says "this deployment has none of these", so every literal id of that
    // kind is rejected rather than waved through as an undeclared kind.
    #[must_use]
    pub fn with_kind<I, S>(mut self, kind: EntityKind, ids: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.by_kind
            .insert(kind, ids.into_iter().map(Into::into).collect());
        self
    }

    #[must_use]
    pub fn permits(&self, kind: EntityKind, id: &str) -> bool {
        self.by_kind
            .get(&kind)
            .is_none_or(|known| known.contains(id))
    }

    pub fn require(&self, kind: EntityKind, id: &str) -> AuthzResult<()> {
        if self.permits(kind, id) {
            Ok(())
        } else {
            Err(AuthzError::Validation(unregistered_id_message(
                kind, id, self,
            )))
        }
    }

    #[must_use]
    pub fn known_ids(&self, kind: EntityKind) -> Vec<&str> {
        let mut ids: Vec<&str> = self
            .by_kind
            .get(&kind)
            .map(|known| known.iter().map(String::as_str).collect())
            .unwrap_or_default();
        ids.sort_unstable();
        ids
    }
}

fn unregistered_id_message(kind: EntityKind, id: &str, registered: &RegisteredEntities) -> String {
    let known = registered.known_ids(kind);
    let catalog = if known.is_empty() {
        format!("no {} is registered on this deployment", kind.as_str())
    } else {
        format!("registered {}s: {}", kind.as_str(), known.join(", "))
    };
    let hint = match kind {
        EntityKind::GatewayRoute => {
            " — gateway route ids are generated by synthesize_route_id(model_pattern, provider), \
             not authored, so a hand-written one can never match; grant routes with `entity_match` \
             instead of a literal `entity_id`"
        },
        _ => "",
    };
    format!(
        "access-control rule references {kind} '{id}', which no catalog row registers ({catalog}){hint}",
        kind = kind.as_str(),
    )
}