Skip to main content

systemprompt_security/authz/
subject.rs

1//! Extension-declared subject dimensions for the RBAC resolver.
2//!
3//! Core's resolver knows two subject dimensions, `user` and `role`. Anything
4//! else an operator wants to write rules against — department, cost centre,
5//! clearance, jurisdiction — is a tenant concept, and core deliberately does
6//! not learn it. What core provides instead is the mechanism:
7//!
8//! 1. An extension mints a [`RuleType`] with [`RuleType::extension`] and
9//!    describes it as a [`SubjectDimension`], choosing where it slots in the
10//!    precedence ladder.
11//! 2. It implements [`SubjectAttributeProvider`] to look up that dimension's
12//!    values for a user, and registers the provider with
13//!    [`register_subject_attribute_provider!
14//!    `][crate::register_subject_attribute_provider].
15//! 3. The enforcement path calls [`gather_subject_attributes`] once per request
16//!    and hands the resulting [`SubjectAttributes`] to
17//!    [`resolve`][super::resolver::resolve] alongside the dimension list.
18//!
19//! Values are resolved by lookup rather than read from JWT claims, so a
20//! department change or a revocation takes effect on the next request instead
21//! of lingering until the token refreshes.
22//!
23//! The split between metadata ([`SubjectDimension`], no behaviour) and
24//! behaviour ([`SubjectAttributeProvider`], async I/O) is what keeps
25//! [`resolve`][super::resolver::resolve] pure and synchronous: gathering is
26//! the only async step, and it happens before the resolver runs.
27//!
28//! Copyright (c) systemprompt.io — Business Source License 1.1.
29//! See <https://systemprompt.io> for licensing details.
30
31use std::collections::BTreeMap;
32use std::fmt::Debug;
33use std::sync::Arc;
34
35use async_trait::async_trait;
36use systemprompt_identifiers::UserId;
37
38use super::registry::AuthzHookContext;
39use super::types::RuleType;
40
41pub const USER_PRECEDENCE: u16 = 0;
42pub const ROLE_PRECEDENCE: u16 = 200;
43
44/// Describes one subject dimension to the resolver.
45///
46/// Metadata only: no behaviour and no I/O, so it is cheap to clone and safe to
47/// hold in a `const`.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct SubjectDimension {
50    pub rule_type: RuleType,
51    pub label: &'static str,
52    pub precedence: u16,
53}
54
55/// Subject values per dimension, gathered before the pure resolver runs.
56///
57/// A dimension with no values for the user is simply absent, which makes its
58/// rules unmatchable for that request.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct SubjectAttributes(BTreeMap<RuleType, Vec<String>>);
61
62impl SubjectAttributes {
63    pub const EMPTY: Self = Self(BTreeMap::new());
64
65    #[must_use]
66    pub const fn new() -> Self {
67        Self::EMPTY
68    }
69
70    pub fn insert(&mut self, rule_type: RuleType, values: Vec<String>) {
71        self.0.insert(rule_type, values);
72    }
73
74    #[must_use]
75    pub fn values(&self, rule_type: &RuleType) -> &[String] {
76        self.0.get(rule_type).map_or(&[], Vec::as_slice)
77    }
78
79    #[must_use]
80    pub fn is_empty(&self) -> bool {
81        self.0.is_empty()
82    }
83}
84
85impl FromIterator<(RuleType, Vec<String>)> for SubjectAttributes {
86    fn from_iter<I: IntoIterator<Item = (RuleType, Vec<String>)>>(iter: I) -> Self {
87        Self(iter.into_iter().collect())
88    }
89}
90
91pub static NO_SUBJECT_ATTRIBUTES: SubjectAttributes = SubjectAttributes::EMPTY;
92
93/// Looks up the values a user holds for one extension-owned dimension.
94///
95/// `default_attribution` says whether a request with no header and no key
96/// binding for this dimension is attributed to the first value the user holds.
97/// `false` makes the dimension header-only: such a request carries no
98/// attribution for it, while a header or key binding is still verified against
99/// `values_for`.
100///
101/// `#[async_trait]` because providers are held as
102/// [`SharedSubjectAttributeProvider`], an `Arc<dyn …>`, so the trait must stay
103/// `dyn`-compatible.
104#[async_trait]
105pub trait SubjectAttributeProvider: Send + Sync + Debug {
106    fn dimension(&self) -> SubjectDimension;
107
108    async fn values_for(&self, user_id: &UserId) -> Result<Vec<String>, super::AuthzError>;
109
110    fn default_attribution(&self) -> bool {
111        true
112    }
113}
114
115/// Shared handle to a registered provider.
116pub type SharedSubjectAttributeProvider = Arc<dyn SubjectAttributeProvider>;
117
118/// One inventory submission per
119/// [`register_subject_attribute_provider!
120/// `][crate::register_subject_attribute_provider] call. The factory runs once
121/// at `AppContext` build time and must not block.
122#[derive(Debug, Clone, Copy)]
123pub struct SubjectProviderRegistration {
124    pub factory: fn(&AuthzHookContext) -> SharedSubjectAttributeProvider,
125}
126
127inventory::collect!(SubjectProviderRegistration);
128
129#[must_use]
130pub fn discover_subject_providers(ctx: &AuthzHookContext) -> Vec<SharedSubjectAttributeProvider> {
131    inventory::iter::<SubjectProviderRegistration>()
132        .map(|reg| (reg.factory)(ctx))
133        .collect()
134}
135
136#[must_use]
137pub fn dimensions_of(providers: &[SharedSubjectAttributeProvider]) -> Vec<SubjectDimension> {
138    providers.iter().map(|p| p.dimension()).collect()
139}
140
141pub async fn gather_subject_attributes(
142    providers: &[SharedSubjectAttributeProvider],
143    user_id: &UserId,
144) -> Result<SubjectAttributes, super::AuthzError> {
145    let mut attributes = SubjectAttributes::new();
146    for provider in providers {
147        attributes.insert(
148            provider.dimension().rule_type,
149            provider.values_for(user_id).await?,
150        );
151    }
152    Ok(attributes)
153}
154
155#[macro_export]
156macro_rules! register_subject_attribute_provider {
157    ($factory:expr) => {
158        ::inventory::submit! {
159            $crate::authz::SubjectProviderRegistration {
160                factory: $factory,
161            }
162        }
163    };
164}
165
166/// The registered subject-attribute providers, discovered once at a
167/// composition root and shared by every consumer that needs to look a
168/// dimension up by name (gateway scope attribution, quota subjects).
169#[derive(Debug, Clone, Default)]
170pub struct SubjectProviderSet(Arc<[SharedSubjectAttributeProvider]>);
171
172impl SubjectProviderSet {
173    #[must_use]
174    pub fn discover(ctx: &AuthzHookContext) -> Self {
175        Self(discover_subject_providers(ctx).into())
176    }
177
178    #[must_use]
179    pub fn from_providers(providers: Vec<SharedSubjectAttributeProvider>) -> Self {
180        Self(providers.into())
181    }
182
183    #[must_use]
184    pub fn find(&self, dimension: &str) -> Option<&SharedSubjectAttributeProvider> {
185        self.0
186            .iter()
187            .find(|provider| provider.dimension().rule_type.as_str() == dimension)
188    }
189
190    #[must_use]
191    pub fn registered_dimensions(&self) -> Vec<RuleType> {
192        self.0.iter().map(|p| p.dimension().rule_type).collect()
193    }
194
195    pub fn iter(&self) -> impl Iterator<Item = &SharedSubjectAttributeProvider> {
196        self.0.iter()
197    }
198}