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