Skip to main content

systemprompt_security/authz/parent_chain/
mod.rs

1//! The `entity → plugin → marketplace` parent chain, loaded once per request
2//! and shared by every enforcement site.
3//!
4//! [`ChainSources`] says who parents whom; [`ParentChainIndex::load`] fetches
5//! the rules and `default_included` sentinels for the marketplace and every
6//! plugin named in four bulk queries, independent of catalogue size.
7//! [`ParentChainIndex::resolve`] then runs the pure [`resolve`] resolver once
8//! per owner chain: a skill selected by several plugins is admitted when any
9//! owner admits the subject, mirroring how an artifact survives while any
10//! plugin shipping it survives.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15mod sources;
16
17use std::collections::BTreeMap;
18use std::sync::Arc;
19
20use systemprompt_identifiers::{PluginId, UserId};
21
22use super::error::AuthzResult;
23use super::repository::AccessControlRepository;
24use super::resolver::{ResolveInput, ResolveParent, resolve};
25use super::subject::{SubjectAttributes, SubjectDimension};
26use super::types::{AccessRule, Decision, EntityKind, EntityRef};
27
28pub use sources::{ChainSources, MarketplaceSource};
29
30#[derive(Debug, Clone)]
31pub struct LoadedParent {
32    pub entity: EntityRef,
33    pub rules: Vec<AccessRule>,
34    pub default_included: Option<bool>,
35}
36
37impl LoadedParent {
38    #[must_use]
39    pub fn as_resolve_parent(&self) -> ResolveParent<'_> {
40        ResolveParent {
41            entity: &self.entity,
42            rules: &self.rules,
43            default_included: self.default_included,
44        }
45    }
46}
47
48#[derive(Debug, Clone, Copy)]
49pub struct ResolveBase<'a> {
50    pub rules: &'a [AccessRule],
51    pub user_id: &'a UserId,
52    pub user_roles: &'a [String],
53    pub default_included: Option<bool>,
54    pub attributes: &'a SubjectAttributes,
55    pub dimensions: &'a [SubjectDimension],
56}
57
58#[derive(Debug, Clone, Default)]
59pub struct ParentChainIndex {
60    marketplace: Option<LoadedParent>,
61    plugins: BTreeMap<PluginId, LoadedParent>,
62    // Why: shared rather than owned. The sources are fixed for the process
63    // lifetime but the index is rebuilt on every authz decision, so an owned
64    // copy deep-cloned every plugin id, skill id and member set per call.
65    sources: Arc<ChainSources>,
66}
67
68impl ParentChainIndex {
69    #[must_use]
70    pub const fn from_parts(
71        marketplace: Option<LoadedParent>,
72        plugins: BTreeMap<PluginId, LoadedParent>,
73        sources: Arc<ChainSources>,
74    ) -> Self {
75        Self {
76            marketplace,
77            plugins,
78            sources,
79        }
80    }
81
82    pub async fn load(
83        repo: &AccessControlRepository,
84        sources: Arc<ChainSources>,
85    ) -> AuthzResult<Self> {
86        let marketplace = match sources.marketplace.as_ref() {
87            Some(source) => Some(load_marketplace(repo, source).await?),
88            None => None,
89        };
90
91        let plugin_ids = sources.plugin_ids_to_load();
92        let mut rules = repo
93            .list_rules_bulk(EntityKind::Plugin, &plugin_ids)
94            .await?;
95        let entities = repo
96            .list_entities_bulk(EntityKind::Plugin, &plugin_ids)
97            .await?;
98        let plugins = plugin_ids
99            .into_iter()
100            .map(|id| {
101                let parent = LoadedParent {
102                    entity: EntityRef::from_kind_and_id(EntityKind::Plugin, &id),
103                    rules: rules.remove(&id).unwrap_or_default(),
104                    default_included: entities.get(&id).map(|row| row.default_included),
105                };
106                (PluginId::new(id), parent)
107            })
108            .collect();
109
110        Ok(Self {
111            marketplace,
112            plugins,
113            sources,
114        })
115    }
116
117    #[must_use]
118    pub fn chains_for(&self, kind: EntityKind, id: &str) -> Vec<Vec<ResolveParent<'_>>> {
119        let marketplace = self
120            .marketplace
121            .as_ref()
122            .map(LoadedParent::as_resolve_parent);
123        let marketplace_chain = || {
124            marketplace
125                .map(|parent| vec![vec![parent]])
126                .unwrap_or_default()
127        };
128
129        match kind {
130            EntityKind::Skill => {
131                let owned: Vec<Vec<ResolveParent<'_>>> = self
132                    .sources
133                    .skill_owners
134                    .get(id)
135                    .into_iter()
136                    .flatten()
137                    .filter_map(|owner| self.plugins.get(owner).map(|plugin| (owner, plugin)))
138                    .map(|(owner, plugin)| {
139                        let mut chain = vec![plugin.as_resolve_parent()];
140                        if self.sources.plugins.contains(owner) {
141                            chain.extend(marketplace);
142                        }
143                        chain
144                    })
145                    .collect();
146                if !owned.is_empty() {
147                    return owned;
148                }
149                if self.sources.is_marketplace_member(kind, id) {
150                    return marketplace_chain();
151                }
152                Vec::new()
153            },
154            EntityKind::Plugin if self.sources.plugins.contains(id) => marketplace_chain(),
155            EntityKind::Plugin => Vec::new(),
156            _ if self.sources.is_marketplace_member(kind, id) => marketplace_chain(),
157            _ => Vec::new(),
158        }
159    }
160
161    #[must_use]
162    pub fn resolve(&self, kind: EntityKind, id: &str, base: ResolveBase<'_>) -> Decision {
163        let entity = EntityRef::from_kind_and_id(kind, id);
164        let resolve_with = |parents: &[ResolveParent<'_>]| {
165            resolve(ResolveInput {
166                entity: &entity,
167                rules: base.rules,
168                user_id: base.user_id,
169                user_roles: base.user_roles,
170                default_included: base.default_included,
171                parents,
172                attributes: base.attributes,
173                dimensions: base.dimensions,
174            })
175        };
176
177        let mut first_deny = None;
178        for chain in self.chains_for(kind, id) {
179            let decision = resolve_with(&chain);
180            if matches!(decision, Decision::Allow { .. }) {
181                return decision;
182            }
183            first_deny.get_or_insert(decision);
184        }
185        first_deny.unwrap_or_else(|| resolve_with(&[]))
186    }
187}
188
189async fn load_marketplace(
190    repo: &AccessControlRepository,
191    source: &MarketplaceSource,
192) -> AuthzResult<LoadedParent> {
193    let id = source.id.as_str();
194    let rules = repo
195        .list_rules_for_entity(EntityKind::Marketplace, id)
196        .await?;
197    let default_included = repo
198        .get_entity(EntityKind::Marketplace, id)
199        .await?
200        .map(|row| row.default_included)
201        .or(source.fallback_default_included);
202    Ok(LoadedParent {
203        entity: EntityRef::Marketplace(source.id.clone()),
204        rules,
205        default_included,
206    })
207}