Skip to main content

systemprompt_security/authz/
scope_binding.rs

1//! Owner-membership verification for API-key scope bindings.
2//!
3//! A key may only be bound to a dimension value its owner actually holds, as
4//! reported by the registered subject-attribute provider for that dimension.
5//! The admin HTTP route and the CLI both issue keys through this one check.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use systemprompt_identifiers::{ScopeDimension, UserId};
11use systemprompt_models::attribution::ScopeBinding;
12use thiserror::Error;
13
14use super::{AuthzError, SubjectProviderSet};
15
16/// Why a requested scope binding was refused.
17#[derive(Debug, Error)]
18pub enum ScopeBindingError {
19    #[error("unknown scope dimension '{0}': no subject attribute provider registers it")]
20    UnknownDimension(ScopeDimension),
21
22    #[error("the key owner is not a member of {dimension} '{value}'")]
23    NotAMember {
24        dimension: ScopeDimension,
25        value: String,
26    },
27
28    #[error("subject attribute lookup failed: {0}")]
29    Lookup(#[from] AuthzError),
30}
31
32impl SubjectProviderSet {
33    pub async fn verify_scope_bindings(
34        &self,
35        owner: &UserId,
36        scopes: &[ScopeBinding],
37    ) -> Result<(), ScopeBindingError> {
38        for scope in scopes {
39            let Some(provider) = self.find(scope.dimension.as_str()) else {
40                return Err(ScopeBindingError::UnknownDimension(scope.dimension.clone()));
41            };
42            let held = provider.values_for(owner).await?;
43            if !held.iter().any(|value| value == &scope.value) {
44                return Err(ScopeBindingError::NotAMember {
45                    dimension: scope.dimension.clone(),
46                    value: scope.value.clone(),
47                });
48            }
49        }
50        Ok(())
51    }
52}