Skip to main content

systemprompt_security/authz/parent_chain/
mod.rs

1//! The `entity → plugin → marketplace` parent chain, loaded through
2//! [`ChainIndexCache`] and shared by every enforcement site.
3//!
4//! [`ChainSources`] says who parents whom; [`ParentChainIndex::load`] fetches
5//! the rules and `default_included` sentinels for every enabled marketplace
6//! and every named plugin in four bulk queries, independent of catalogue size.
7//! [`ParentChainIndex::resolve`] then runs the pure [`resolve`] resolver once
8//! per owner chain: an entity that belongs to several marketplaces, or a skill
9//! selected by several plugins, is admitted when any one of them admits the
10//! subject, mirroring how an artifact survives while any plugin shipping it
11//! survives.
12//!
13//! The entity's own ruleset is evaluated ahead of its parents, and a plugin
14//! rule closes the cascade before any marketplace is consulted, so a deny at
15//! the entity or plugin level still wins over every admitting marketplace.
16//!
17//! Copyright (c) systemprompt.io — Business Source License 1.1.
18//! See <https://systemprompt.io> for licensing details.
19
20mod cache;
21mod chains;
22mod sources;
23
24use std::collections::BTreeMap;
25use std::sync::Arc;
26
27use systemprompt_identifiers::{MarketplaceId, PluginId, UserId};
28
29use super::error::AuthzResult;
30use super::repository::AccessControlRepository;
31use super::resolver::{ResolveInput, ResolveParent, resolve};
32use super::subject::{SubjectAttributes, SubjectDimension};
33use super::types::{AccessRule, Decision, EntityKind, EntityRef};
34
35pub use cache::ChainIndexCache;
36pub use sources::{ChainSources, MarketplaceSource};
37
38#[derive(Debug, Clone)]
39pub struct LoadedParent {
40    pub entity: EntityRef,
41    pub rules: Vec<AccessRule>,
42    pub default_included: Option<bool>,
43}
44
45impl LoadedParent {
46    #[must_use]
47    pub fn as_resolve_parent(&self) -> ResolveParent<'_> {
48        ResolveParent {
49            entity: &self.entity,
50            rules: &self.rules,
51            default_included: self.default_included,
52        }
53    }
54}
55
56#[derive(Debug, Clone, Copy)]
57pub struct ResolveBase<'a> {
58    pub rules: &'a [AccessRule],
59    pub user_id: &'a UserId,
60    pub user_roles: &'a [String],
61    pub default_included: Option<bool>,
62    pub attributes: &'a SubjectAttributes,
63    pub dimensions: &'a [SubjectDimension],
64}
65
66#[derive(Debug, Clone, Default)]
67pub struct ParentChainIndex {
68    pub(super) marketplaces: BTreeMap<MarketplaceId, LoadedParent>,
69    pub(super) plugins: BTreeMap<PluginId, LoadedParent>,
70    pub(super) sources: Arc<ChainSources>,
71}
72
73impl ParentChainIndex {
74    #[must_use]
75    pub const fn from_parts(
76        marketplaces: BTreeMap<MarketplaceId, LoadedParent>,
77        plugins: BTreeMap<PluginId, LoadedParent>,
78        sources: Arc<ChainSources>,
79    ) -> Self {
80        Self {
81            marketplaces,
82            plugins,
83            sources,
84        }
85    }
86
87    pub async fn load(
88        repo: &AccessControlRepository,
89        sources: Arc<ChainSources>,
90    ) -> AuthzResult<Self> {
91        let marketplaces = load_marketplaces(repo, &sources).await?;
92
93        let plugin_ids = sources.plugin_ids_to_load();
94        let mut rules = repo
95            .list_rules_bulk(EntityKind::Plugin, &plugin_ids)
96            .await?;
97        let entities = repo
98            .list_entities_bulk(EntityKind::Plugin, &plugin_ids)
99            .await?;
100        let plugins = plugin_ids
101            .into_iter()
102            .map(|id| {
103                let parent = LoadedParent {
104                    entity: EntityRef::from_kind_and_id(EntityKind::Plugin, &id),
105                    rules: rules.remove(&id).unwrap_or_default(),
106                    default_included: entities.get(&id).map(|row| row.default_included),
107                };
108                (PluginId::new(id), parent)
109            })
110            .collect();
111
112        Ok(Self {
113            marketplaces,
114            plugins,
115            sources,
116        })
117    }
118
119    #[must_use]
120    pub fn resolve(&self, kind: EntityKind, id: &str, base: ResolveBase<'_>) -> Decision {
121        let entity = EntityRef::from_kind_and_id(kind, id);
122        let resolve_with = |parents: &[ResolveParent<'_>]| {
123            resolve(ResolveInput {
124                entity: &entity,
125                rules: base.rules,
126                user_id: base.user_id,
127                user_roles: base.user_roles,
128                default_included: base.default_included,
129                parents,
130                attributes: base.attributes,
131                dimensions: base.dimensions,
132            })
133        };
134
135        let mut first_deny = None;
136        for chain in self.chains_for(kind, id) {
137            let decision = resolve_with(&chain);
138            if decision.permits() {
139                return decision;
140            }
141            first_deny.get_or_insert(decision);
142        }
143        first_deny.unwrap_or_else(|| resolve_with(&[]))
144    }
145}
146
147async fn load_marketplaces(
148    repo: &AccessControlRepository,
149    sources: &ChainSources,
150) -> AuthzResult<BTreeMap<MarketplaceId, LoadedParent>> {
151    let ids = sources.marketplace_ids_to_load();
152    if ids.is_empty() {
153        return Ok(BTreeMap::new());
154    }
155    let raw: Vec<String> = ids.iter().map(|id| id.as_str().to_owned()).collect();
156    let mut rules = repo.list_rules_bulk(EntityKind::Marketplace, &raw).await?;
157    let entities = repo
158        .list_entities_bulk(EntityKind::Marketplace, &raw)
159        .await?;
160    Ok(ids
161        .into_iter()
162        .map(|id| {
163            let fallback = sources
164                .marketplaces
165                .get(&id)
166                .and_then(|source| source.fallback_default_included);
167            let parent = LoadedParent {
168                entity: EntityRef::Marketplace(id.clone()),
169                rules: rules.remove(id.as_str()).unwrap_or_default(),
170                default_included: entities
171                    .get(id.as_str())
172                    .map(|row| row.default_included)
173                    .or(fallback),
174            };
175            (id, parent)
176        })
177        .collect())
178}