beep_authz/
spicedb.rs

1use std::sync::Arc;
2
3use tokio::sync::RwLock;
4use tonic::{service::interceptor::InterceptedService, transport::Channel};
5
6use crate::{
7    AuthorizationError, PermissionsServiceClient,
8    authzed::api::v1::{
9        CheckPermissionRequest, CheckPermissionResponse, ObjectReference, SubjectReference,
10        check_permission_response::Permissionship,
11    },
12    config::SpiceDbConfig,
13    grpc_auth::AuthInterceptor,
14    object::SpiceDbObject,
15    permission::{AuthorizationResult, Permissions},
16};
17
18/// Main SpiceDB client for performing authorization checks.
19///
20/// `SpiceDbRepository` provides a high-level interface to interact with SpiceDB,
21/// a Google Zanzibar-inspired authorization system. It handles connection management,
22/// authentication, and permission checking operations.
23///
24/// # Architecture
25///
26/// The repository maintains a gRPC connection to a SpiceDB server and provides
27/// methods to check whether a subject (e.g., a user) has a specific permission
28/// on a resource (e.g., a channel or server).
29///
30/// # Examples
31///
32/// ```no_run
33/// use authz::{SpiceDbRepository, SpiceDbConfig, SpiceDbObject, Permissions};
34///
35/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
36/// let config = SpiceDbConfig {
37///     endpoint: "localhost:50051".to_string(),
38///     token: Some("your-token".to_string()),
39/// };
40///
41/// let repo = SpiceDbRepository::new(config).await?;
42///
43/// // Check if user can view a channel
44/// let result = repo.check_permissions(
45///     SpiceDbObject::Channel("channel-123".to_string()),
46///     Permissions::ViewChannels,
47///     SpiceDbObject::User("user-456".to_string()),
48/// ).await;
49///
50/// if result.has_permissions() {
51///     println!("Access granted!");
52/// }
53/// # Ok(())
54/// # }
55/// ```
56#[derive(Clone)]
57pub struct SpiceDbRepository {
58    permissions:
59        Arc<RwLock<PermissionsServiceClient<InterceptedService<Channel, AuthInterceptor>>>>,
60}
61
62impl SpiceDbRepository {
63    /// Creates a new SpiceDB client with the given configuration.
64    ///
65    /// This establishes a gRPC connection to the SpiceDB server and sets up
66    /// authentication using the provided token.
67    ///
68    /// # Arguments
69    ///
70    /// * `config` - Configuration containing the endpoint URL and optional authentication token
71    ///
72    /// # Returns
73    ///
74    /// Returns `Ok(SpiceDbRepository)` on successful connection, or an
75    /// `AuthorizationError::ConnectionError` if the connection fails.
76    ///
77    /// # Examples
78    ///
79    /// ```no_run
80    /// use authz::{SpiceDbRepository, SpiceDbConfig};
81    ///
82    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
83    /// let config = SpiceDbConfig {
84    ///     endpoint: "localhost:50051".to_string(),
85    ///     token: Some("somerandomkey".to_string()),
86    /// };
87    ///
88    /// let repo = SpiceDbRepository::new(config).await?;
89    /// # Ok(())
90    /// # }
91    /// ```
92    ///
93    /// # Errors
94    ///
95    /// This function will return an error if:
96    /// - The endpoint URL is invalid
97    /// - The connection to SpiceDB cannot be established
98    /// - Network issues prevent communication with the server
99    pub async fn new(config: SpiceDbConfig) -> Result<Self, AuthorizationError> {
100        let channel = Self::create_channel(&config).await?;
101
102        // Always use an interceptor, even if token is empty
103        let token = config.token.unwrap_or_default();
104
105        let interceptor = AuthInterceptor::new(token);
106        let permissions = Arc::new(RwLock::new(PermissionsServiceClient::with_interceptor(
107            channel.clone(),
108            interceptor,
109        )));
110
111        Ok(Self { permissions })
112    }
113
114    async fn create_channel(config: &SpiceDbConfig) -> Result<Channel, AuthorizationError> {
115        // Add http:// scheme if not present
116        let endpoint_url =
117            if config.endpoint.starts_with("http://") || config.endpoint.starts_with("https://") {
118                config.endpoint.clone()
119            } else {
120                format!("http://{}", config.endpoint)
121            };
122
123        let endpoint = Channel::from_shared(endpoint_url.clone())
124            .map_err(|e| AuthorizationError::ConnectionError { msg: e.to_string() })?;
125
126        let channel = endpoint
127            .connect()
128            .await
129            .map_err(|e| AuthorizationError::ConnectionError { msg: e.to_string() })?;
130
131        Ok(channel)
132    }
133
134    async fn permissions(
135        &self,
136    ) -> tokio::sync::RwLockWriteGuard<
137        '_,
138        PermissionsServiceClient<InterceptedService<Channel, AuthInterceptor>>,
139    > {
140        self.permissions.write().await
141    }
142
143    /// Checks if a subject has a specific permission on a resource.
144    ///
145    /// This is the primary method for performing authorization checks in your application.
146    /// It evaluates whether the given subject (e.g., a user) has the specified permission
147    /// on the target resource (e.g., a channel, server, or other object).
148    ///
149    /// # How It Works
150    ///
151    /// The method performs the following steps:
152    /// 1. Converts the resource and subject into SpiceDB object references
153    /// 2. Sends a gRPC `CheckPermission` request to SpiceDB
154    /// 3. SpiceDB evaluates the permission based on defined relationships and rules
155    /// 4. Returns an `AuthorizationResult` indicating whether access is granted
156    ///
157    /// # Arguments
158    ///
159    /// * `resource` - The resource being accessed (e.g., `SpiceDbObject::Channel("123")`)
160    /// * `permission` - The permission being checked (e.g., `Permissions::ViewChannels`)
161    /// * `subject` - The entity requesting access (e.g., `SpiceDbObject::User("456")`)
162    ///
163    /// # Returns
164    ///
165    /// Returns an `AuthorizationResult` which can be queried with:
166    /// - `has_permissions()` - Returns `true` if access is granted
167    /// - `result()` - Returns `Ok(())` if granted, or `Err(AuthorizationError)` if denied
168    ///
169    /// # Examples
170    ///
171    /// ## Basic permission check
172    ///
173    /// ```no_run
174    /// use authz::{SpiceDbRepository, SpiceDbObject, Permissions};
175    ///
176    /// # async fn example(repo: SpiceDbRepository) {
177    /// let result = repo.check_permissions(
178    ///     SpiceDbObject::Channel("general".to_string()),
179    ///     Permissions::SendMessages,
180    ///     SpiceDbObject::User("alice".to_string()),
181    /// ).await;
182    ///
183    /// if result.has_permissions() {
184    ///     // Allow user to send message
185    ///     println!("User can send messages");
186    /// } else {
187    ///     // Deny access
188    ///     println!("User cannot send messages");
189    /// }
190    /// # }
191    /// ```
192    ///
193    /// ## Using result() for error handling
194    ///
195    /// ```no_run
196    /// use authz::{SpiceDbRepository, SpiceDbObject, Permissions, AuthorizationError};
197    ///
198    /// # async fn example(repo: SpiceDbRepository) -> Result<(), AuthorizationError> {
199    /// let result = repo.check_permissions(
200    ///     SpiceDbObject::Server("server-1".to_string()),
201    ///     Permissions::ManageRoles,
202    ///     SpiceDbObject::User("bob".to_string()),
203    /// ).await;
204    ///
205    /// // Returns error if permission denied
206    /// result.result()?;
207    ///
208    /// // Continue with authorized action
209    /// println!("User is authorized to manage roles");
210    /// # Ok(())
211    /// # }
212    /// ```
213    ///
214    /// ## Checking administrative access
215    ///
216    /// ```no_run
217    /// use authz::{SpiceDbRepository, SpiceDbObject, Permissions};
218    ///
219    /// # async fn example(repo: SpiceDbRepository) {
220    /// let is_admin = repo.check_permissions(
221    ///     SpiceDbObject::Server("my-server".to_string()),
222    ///     Permissions::Administrator,
223    ///     SpiceDbObject::User("charlie".to_string()),
224    /// ).await.has_permissions();
225    ///
226    /// if is_admin {
227    ///     // Grant full access
228    /// }
229    /// # }
230    /// ```
231    ///
232    /// # Performance Considerations
233    ///
234    /// Each call to this method makes a network request to SpiceDB. For high-performance
235    /// scenarios, consider:
236    /// - Caching authorization results when appropriate
237    /// - Batching multiple checks when possible
238    /// - Using SpiceDB's consistency guarantees to balance freshness vs. performance
239    ///
240    /// # See Also
241    ///
242    /// - [`check_permissions_raw`](Self::check_permissions_raw) - Lower-level API with more control
243    /// - [`Permissions`] - Available permission types
244    /// - [`SpiceDbObject`] - Resource and subject types
245    pub async fn check_permissions(
246        &self,
247        resource: impl Into<SpiceDbObject>,
248        permission: Permissions,
249        subject: impl Into<SpiceDbObject>,
250    ) -> AuthorizationResult {
251        let resource: SpiceDbObject = resource.into();
252        let subject: SpiceDbObject = subject.into();
253        let permission: String = permission.to_string();
254        self.check_permissions_raw(resource, permission, subject)
255            .await
256            .into()
257    }
258
259    /// Performs a raw permission check using SpiceDB object references and string permissions.
260    ///
261    /// This is a lower-level API that provides more flexibility than [`check_permissions`](Self::check_permissions).
262    /// It allows you to specify permissions as strings and use custom object references directly,
263    /// which can be useful for:
264    /// - Custom permission types not defined in the `Permissions` enum
265    /// - Dynamic permission names determined at runtime
266    /// - Direct integration with SpiceDB's native types
267    ///
268    /// # Arguments
269    ///
270    /// * `resource` - The resource object reference (must implement `Into<ObjectReference>`)
271    /// * `permission` - The permission name as a string (e.g., "view", "edit", "admin")
272    /// * `subject` - The subject object reference (must implement `Into<ObjectReference>`)
273    ///
274    /// # Returns
275    ///
276    /// Returns a `Result` containing:
277    /// - `Ok(Permissionship)` - The permission status from SpiceDB:
278    ///   - `Permissionship::HasPermission` - Access is granted
279    ///   - `Permissionship::NoPermission` - Access is denied
280    ///   - `Permissionship::ConditionalPermission` - Access depends on additional context
281    /// - `Err(AuthorizationError::Unauthorized)` - The check failed or was denied
282    ///
283    /// # Examples
284    ///
285    /// ## Custom permission check
286    ///
287    /// ```no_run
288    /// use authz::{SpiceDbRepository, authzed::api::v1::ObjectReference};
289    ///
290    /// # async fn example(repo: SpiceDbRepository) -> Result<(), Box<dyn std::error::Error>> {
291    /// let resource = ObjectReference {
292    ///     object_type: "document".to_string(),
293    ///     object_id: "doc-123".to_string(),
294    /// };
295    ///
296    /// let subject = ObjectReference {
297    ///     object_type: "user".to_string(),
298    ///     object_id: "user-456".to_string(),
299    /// };
300    ///
301    /// let permissionship = repo.check_permissions_raw(
302    ///     resource,
303    ///     "edit",
304    ///     subject,
305    /// ).await?;
306    ///
307    /// match permissionship {
308    ///     authz::authzed::api::v1::check_permission_response::Permissionship::HasPermission => {
309    ///         println!("Permission granted");
310    ///     }
311    ///     _ => {
312    ///         println!("Permission denied");
313    ///     }
314    /// }
315    /// # Ok(())
316    /// # }
317    /// ```
318    ///
319    /// ## Dynamic permission names
320    ///
321    /// ```no_run
322    /// use authz::{SpiceDbRepository, SpiceDbObject};
323    ///
324    /// # async fn check_dynamic_permission(
325    /// #     repo: SpiceDbRepository,
326    /// #     action: &str,
327    /// #     resource_id: &str,
328    /// #     user_id: &str,
329    /// # ) -> Result<bool, Box<dyn std::error::Error>> {
330    /// let permission_name = format!("can_{}", action);
331    ///
332    /// let result = repo.check_permissions_raw(
333    ///     SpiceDbObject::Channel(resource_id.to_string()),
334    ///     permission_name,
335    ///     SpiceDbObject::User(user_id.to_string()),
336    /// ).await?;
337    ///
338    /// Ok(result.has_permissions())
339    /// # }
340    /// ```
341    ///
342    /// # Errors
343    ///
344    /// This function will return an error if:
345    /// - The gRPC connection to SpiceDB fails
346    /// - The request times out
347    /// - The permission check is denied (returns `AuthorizationError::Unauthorized`)
348    ///
349    /// # See Also
350    ///
351    /// - [`check_permissions`](Self::check_permissions) - Higher-level, type-safe API
352    /// - [SpiceDB CheckPermission API](https://buf.build/authzed/api/docs/main:authzed.api.v1#authzed.api.v1.PermissionsService.CheckPermission)
353    pub async fn check_permissions_raw(
354        &self,
355        resource: impl Into<ObjectReference>,
356        permission: impl Into<String>,
357        subject: impl Into<ObjectReference>,
358    ) -> Result<Permissionship, AuthorizationError> {
359        let resource: ObjectReference = resource.into();
360        let sub_object_reference: ObjectReference = subject.into();
361        let subject = SubjectReference {
362            object: Some(sub_object_reference),
363            ..Default::default()
364        };
365        let check_request = CheckPermissionRequest {
366            resource: Some(resource),
367            permission: permission.into(),
368            subject: Some(subject),
369            ..Default::default()
370        };
371
372        let check_response: CheckPermissionResponse = self
373            .permissions()
374            .await
375            .check_permission(check_request)
376            .await
377            .map_err(|_| AuthorizationError::Unauthorized)?
378            .into_inner();
379
380        Ok(check_response.permissionship())
381    }
382}