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
41/// Precedence of core's `user` dimension. Nothing may bind tighter.
42pub const USER_PRECEDENCE: u16 = 0;
43/// Precedence of core's `role` dimension. Extensions slot below this value to
44/// outrank roles, above it to yield to them.
45pub const ROLE_PRECEDENCE: u16 = 200;
46
47/// Describes one subject dimension to the resolver.
48///
49/// Metadata only: no behaviour and no I/O, so it is cheap to clone and safe to
50/// hold in a `const`.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SubjectDimension {
53 /// The `access_control_rules.rule_type` slug this dimension owns.
54 pub rule_type: RuleType,
55 /// Operator-facing label, e.g. for the access matrix column header.
56 pub label: &'static str,
57 /// Lower binds tighter. Core uses [`USER_PRECEDENCE`] and
58 /// [`ROLE_PRECEDENCE`]; an extension dimension at 100 outranks a role rule
59 /// and yields to a user rule.
60 pub precedence: u16,
61}
62
63/// Subject values per dimension, gathered before the pure resolver runs.
64///
65/// A dimension with no values for the user is simply absent, which makes its
66/// rules unmatchable for that request.
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct SubjectAttributes(BTreeMap<RuleType, Vec<String>>);
69
70impl SubjectAttributes {
71 /// No values for any dimension. Const so callers with no extension
72 /// dimensions registered can pass `&SubjectAttributes::EMPTY` without a
73 /// binding.
74 pub const EMPTY: Self = Self(BTreeMap::new());
75
76 #[must_use]
77 pub const fn new() -> Self {
78 Self::EMPTY
79 }
80
81 /// Records the values for one dimension, replacing any previous entry.
82 pub fn insert(&mut self, rule_type: RuleType, values: Vec<String>) {
83 self.0.insert(rule_type, values);
84 }
85
86 /// Values the user holds for `rule_type`, empty when the dimension is
87 /// unregistered or the user has no value for it.
88 #[must_use]
89 pub fn values(&self, rule_type: &RuleType) -> &[String] {
90 self.0.get(rule_type).map_or(&[], Vec::as_slice)
91 }
92
93 #[must_use]
94 pub fn is_empty(&self) -> bool {
95 self.0.is_empty()
96 }
97}
98
99impl FromIterator<(RuleType, Vec<String>)> for SubjectAttributes {
100 fn from_iter<I: IntoIterator<Item = (RuleType, Vec<String>)>>(iter: I) -> Self {
101 Self(iter.into_iter().collect())
102 }
103}
104
105/// Shared `'static` empty attribute set.
106///
107/// [`SubjectAttributes::EMPTY`] cannot be const-promoted behind a reference
108/// (its map owns a heap allocation in the general case), so call sites that
109/// need a borrow outliving the expression — a helper returning a
110/// [`ResolveInput`][super::resolver::ResolveInput], a resolver call with no
111/// dimensions registered — borrow this instead.
112pub static NO_SUBJECT_ATTRIBUTES: SubjectAttributes = SubjectAttributes::EMPTY;
113
114/// Looks up the values a user holds for one extension-owned dimension.
115///
116/// `#[async_trait]` because providers are held as
117/// [`SharedSubjectAttributeProvider`], an `Arc<dyn …>`, so the trait must stay
118/// `dyn`-compatible.
119#[async_trait]
120pub trait SubjectAttributeProvider: Send + Sync + Debug {
121 /// The dimension this provider supplies. Must be stable for the process
122 /// lifetime; the resolver builds its precedence ladder from it.
123 fn dimension(&self) -> SubjectDimension;
124
125 /// Values for `user_id`, or empty when the user has none. Implementations
126 /// should fail soft: a lookup error is an absent attribute, not a deny,
127 /// because the resolver's own deny paths already close the default.
128 async fn values_for(&self, user_id: &UserId) -> Vec<String>;
129}
130
131/// Shared handle to a registered provider.
132pub type SharedSubjectAttributeProvider = Arc<dyn SubjectAttributeProvider>;
133
134/// One inventory submission per
135/// [`register_subject_attribute_provider!
136/// `][crate::register_subject_attribute_provider] call. The factory runs once
137/// at `AppContext` build time and must not block.
138#[derive(Debug, Clone, Copy)]
139pub struct SubjectProviderRegistration {
140 pub factory: fn(&AuthzHookContext) -> SharedSubjectAttributeProvider,
141}
142
143inventory::collect!(SubjectProviderRegistration);
144
145#[must_use]
146pub fn discover_subject_providers(ctx: &AuthzHookContext) -> Vec<SharedSubjectAttributeProvider> {
147 inventory::iter::<SubjectProviderRegistration>()
148 .map(|reg| (reg.factory)(ctx))
149 .collect()
150}
151
152/// The dimension list to hand [`resolve`][super::resolver::resolve], derived
153/// from the providers gathered for the same request.
154#[must_use]
155pub fn dimensions_of(providers: &[SharedSubjectAttributeProvider]) -> Vec<SubjectDimension> {
156 providers.iter().map(|p| p.dimension()).collect()
157}
158
159/// Gathers every provider's values for `user_id`.
160///
161/// The only async step in the authorization path and the only place a provider
162/// is called; everything downstream operates on the returned snapshot.
163pub async fn gather_subject_attributes(
164 providers: &[SharedSubjectAttributeProvider],
165 user_id: &UserId,
166) -> SubjectAttributes {
167 let mut attributes = SubjectAttributes::new();
168 for provider in providers {
169 let dimension = provider.dimension();
170 let values = provider.values_for(user_id).await;
171 attributes.insert(dimension.rule_type, values);
172 }
173 attributes
174}
175
176/// Register an extension subject-attribute provider at static-init time.
177///
178/// The factory receives a borrowed [`AuthzHookContext`] (pool + audit sink)
179/// and returns the constructed provider. Wire alongside `register_extension!`
180/// in the extension's `extension.rs`:
181///
182/// ```ignore
183/// systemprompt_security::register_subject_attribute_provider!(|ctx| {
184/// std::sync::Arc::new(DepartmentAttributeProvider::new(ctx.pool.clone()))
185/// as systemprompt_security::authz::SharedSubjectAttributeProvider
186/// });
187/// ```
188#[macro_export]
189macro_rules! register_subject_attribute_provider {
190 ($factory:expr) => {
191 ::inventory::submit! {
192 $crate::authz::SubjectProviderRegistration {
193 factory: $factory,
194 }
195 }
196 };
197}