Skip to main content

systemprompt_traits/
auth.rs

1//! Authentication and role-management provider traits.
2//!
3//! `UserProvider` is dispatched as a trait object (`dyn UserProvider`), so it
4//! uses `#[async_trait]`; native `async fn` in traits is not yet
5//! `dyn`-compatible. `RoleProvider` is only used through concrete types and
6//! declares native `async` methods.
7//!
8//! `is_service_account` marks a non-human service account: it never receives an
9//! interactive credential (session login, passkey, bridge code, authorization
10//! code), and `ensure_interactive` refuses it on every interactive sign-in
11//! path.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16use async_trait::async_trait;
17use std::future::Future;
18use systemprompt_identifiers::UserId;
19
20use crate::BoxedSource;
21
22pub type AuthResult<T> = Result<T, AuthProviderError>;
23
24#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum AuthProviderError {
27    #[error("Invalid credentials")]
28    InvalidCredentials,
29
30    #[error("User not found")]
31    UserNotFound,
32
33    #[error("Invalid token")]
34    InvalidToken,
35
36    #[error("Token expired")]
37    TokenExpired,
38
39    #[error("Insufficient permissions")]
40    InsufficientPermissions,
41
42    #[error("Service accounts cannot sign in interactively")]
43    ServiceAccountInteractiveLogin,
44
45    #[error("Internal error: {0}")]
46    Internal(#[source] BoxedSource),
47}
48
49#[derive(Debug, Clone)]
50pub struct AuthUser {
51    pub id: UserId,
52    pub name: String,
53    pub email: String,
54    pub roles: Vec<String>,
55    pub is_active: bool,
56    pub is_service_account: bool,
57}
58
59impl AuthUser {
60    pub const fn ensure_interactive(&self) -> AuthResult<()> {
61        if self.is_service_account {
62            return Err(AuthProviderError::ServiceAccountInteractiveLogin);
63        }
64        Ok(())
65    }
66}
67
68/// Federated-identity claim payload passed to
69/// [`UserProvider::find_or_create_federated`].
70///
71/// Carries only the OIDC fields needed to seed a freshly federated user — the
72/// trait stays free of any concrete JWT type so it can live in
73/// `systemprompt-traits` without taking a dependency on `systemprompt-models`.
74#[derive(Debug, Clone, Default)]
75pub struct FederatedIdentityClaims {
76    pub email: Option<String>,
77    pub email_verified: bool,
78    pub name: Option<String>,
79    pub preferred_username: Option<String>,
80    pub roles: Vec<String>,
81}
82
83/// Whether an inbound chat-platform sender resolves to a linkable identity.
84///
85/// This is the identity-linking rule, stated once: a sender is `Linked` only
86/// when the platform verified the claims (for Slack, a workspace profile with
87/// a confirmed email — an unconfirmed address would let anyone who can set it
88/// claim the account that owns it). Anything less is `Unlinked`, whose empty
89/// claims land the sender on a fresh, role-less first-touch user that no rule
90/// grants anything to — never on an existing account.
91#[derive(Debug, Clone, Default)]
92pub enum SenderIdentity {
93    Linked(FederatedIdentityClaims),
94    #[default]
95    Unlinked,
96}
97
98impl SenderIdentity {
99    #[must_use]
100    pub fn claims(&self) -> FederatedIdentityClaims {
101        match self {
102            Self::Linked(claims) => claims.clone(),
103            Self::Unlinked => FederatedIdentityClaims::default(),
104        }
105    }
106}
107
108#[async_trait]
109pub trait UserProvider: Send + Sync {
110    async fn find_by_id(&self, id: &UserId) -> AuthResult<Option<AuthUser>>;
111    async fn find_by_email(&self, email: &str) -> AuthResult<Option<AuthUser>>;
112    async fn find_by_name(&self, name: &str) -> AuthResult<Option<AuthUser>>;
113    async fn create_user(
114        &self,
115        name: &str,
116        email: &str,
117        full_name: Option<&str>,
118    ) -> AuthResult<AuthUser>;
119    async fn create_anonymous(&self, fingerprint: &str) -> AuthResult<AuthUser>;
120    async fn assign_roles(&self, user_id: &UserId, roles: &[String]) -> AuthResult<()>;
121
122    async fn find_or_create_federated(
123        &self,
124        issuer: &str,
125        external_sub: &str,
126        claims: &FederatedIdentityClaims,
127    ) -> AuthResult<UserId>;
128
129    async fn promote_anonymous(&self, source: &UserId, target: &UserId) -> AuthResult<u64>;
130}
131
132pub trait RoleProvider: Send + Sync {
133    fn get_roles(&self, user_id: &UserId) -> impl Future<Output = AuthResult<Vec<String>>> + Send;
134    fn assign_role(
135        &self,
136        user_id: &UserId,
137        role: &str,
138    ) -> impl Future<Output = AuthResult<()>> + Send;
139    fn revoke_role(
140        &self,
141        user_id: &UserId,
142        role: &str,
143    ) -> impl Future<Output = AuthResult<()>> + Send;
144    fn list_users_by_role(
145        &self,
146        role: &str,
147    ) -> impl Future<Output = AuthResult<Vec<AuthUser>>> + Send;
148}