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