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    // Why: shared rather than owned. The sources are fixed for the process
71    // lifetime while the index is rebuilt whenever the cache sees the rule or
72    // entity tables change, so an owned copy would deep-clone every plugin
73    // id, skill id and member set on each rebuild for no benefit.
74    pub(super) sources: Arc<ChainSources>,
75}
76
77impl ParentChainIndex {
78    #[must_use]
79    pub const fn from_parts(
80        marketplaces: BTreeMap<MarketplaceId, LoadedParent>,
81        plugins: BTreeMap<PluginId, LoadedParent>,
82        sources: Arc<ChainSources>,
83    ) -> Self {
84        Self {
85            marketplaces,
86            plugins,
87            sources,
88        }
89    }
90
91    pub async fn load(
92        repo: &AccessControlRepository,
93        sources: Arc<ChainSources>,
94    ) -> AuthzResult<Self> {
95        let marketplaces = load_marketplaces(repo, &sources).await?;
96
97        let plugin_ids = sources.plugin_ids_to_load();
98        let mut rules = repo
99            .list_rules_bulk(EntityKind::Plugin, &plugin_ids)
100            .await?;
101        let entities = repo
102            .list_entities_bulk(EntityKind::Plugin, &plugin_ids)
103            .await?;
104        let plugins = plugin_ids
105            .into_iter()
106            .map(|id| {
107                let parent = LoadedParent {
108                    entity: EntityRef::from_kind_and_id(EntityKind::Plugin, &id),
109                    rules: rules.remove(&id).unwrap_or_default(),
110                    default_included: entities.get(&id).map(|row| row.default_included),
111                };
112                (PluginId::new(id), parent)
113            })
114            .collect();
115
116        Ok(Self {
117            marketplaces,
118            plugins,
119            sources,
120        })
121    }
122
123    #[must_use]
124    pub fn resolve(&self, kind: EntityKind, id: &str, base: ResolveBase<'_>) -> Decision {
125        let entity = EntityRef::from_kind_and_id(kind, id);
126        let resolve_with = |parents: &[ResolveParent<'_>]| {
127            resolve(ResolveInput {
128                entity: &entity,
129                rules: base.rules,
130                user_id: base.user_id,
131                user_roles: base.user_roles,
132                default_included: base.default_included,
133                parents,
134                attributes: base.attributes,
135                dimensions: base.dimensions,
136            })
137        };
138
139        let mut first_deny = None;
140        for chain in self.chains_for(kind, id) {
141            let decision = resolve_with(&chain);
142            if decision.permits() {
143                return decision;
144            }
145            first_deny.get_or_insert(decision);
146        }
147        first_deny.unwrap_or_else(|| resolve_with(&[]))
148    }
149}
150
151async fn load_marketplaces(
152    repo: &AccessControlRepository,
153    sources: &ChainSources,
154) -> AuthzResult<BTreeMap<MarketplaceId, LoadedParent>> {
155    let ids = sources.marketplace_ids_to_load();
156    if ids.is_empty() {
157        return Ok(BTreeMap::new());
158    }
159    let raw: Vec<String> = ids.iter().map(|id| id.as_str().to_owned()).collect();
160    let mut rules = repo.list_rules_bulk(EntityKind::Marketplace, &raw).await?;
161    let entities = repo
162        .list_entities_bulk(EntityKind::Marketplace, &raw)
163        .await?;
164    Ok(ids
165        .into_iter()
166        .map(|id| {
167            let fallback = sources
168                .marketplaces
169                .get(&id)
170                .and_then(|source| source.fallback_default_included);
171            let parent = LoadedParent {
172                entity: EntityRef::Marketplace(id.clone()),
173                rules: rules.remove(id.as_str()).unwrap_or_default(),
174                default_included: entities
175                    .get(id.as_str())
176                    .map(|row| row.default_included)
177                    .or(fallback),
178            };
179            (id, parent)
180        })
181        .collect())
182}