Skip to main content

beep_authz/
spicedb.rs

1use std::sync::Arc;
2
3use tokio::sync::RwLock;
4use tonic::{service::interceptor::InterceptedService, transport::Channel};
5
6use crate::{
7    authzed::api::v1::{
8        check_permission_response::Permissionship, CheckPermissionRequest, CheckPermissionResponse,
9        ObjectReference, SubjectReference,
10    },
11    config::SpiceDbConfig,
12    grpc_auth::AuthInterceptor,
13    object::SpiceDbObject,
14    permission::{AuthorizationResult, Permissions},
15    AuthorizationError, PermissionsServiceClient,
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(Debug, 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    #[tracing::instrument(skip(config), fields(endpoint = %config.endpoint))]
100    pub async fn new(config: SpiceDbConfig) -> Result<Self, AuthorizationError> {
101        tracing::info!("Creating SpiceDB repository");
102        let channel = Self::create_channel(&config).await?;
103
104        // Always use an interceptor, even if token is empty
105        let token = config.token.unwrap_or_default();
106
107        let interceptor = AuthInterceptor::new(token);
108        let permissions = Arc::new(RwLock::new(PermissionsServiceClient::with_interceptor(
109            channel.clone(),
110            interceptor,
111        )));
112
113        tracing::info!("SpiceDB repository created successfully");
114        Ok(Self { permissions })
115    }
116
117    #[tracing::instrument(skip(config), fields(endpoint = %config.endpoint))]
118    async fn create_channel(config: &SpiceDbConfig) -> Result<Channel, AuthorizationError> {
119        // Add http:// scheme if not present
120        let endpoint_url =
121            if config.endpoint.starts_with("http://") || config.endpoint.starts_with("https://") {
122                config.endpoint.clone()
123            } else {
124                format!("http://{}", config.endpoint)
125            };
126
127        tracing::debug!("Connecting to SpiceDB at {}", endpoint_url);
128        let endpoint = Channel::from_shared(endpoint_url.clone()).map_err(|e| {
129            tracing::error!(
130                error = %e,
131                endpoint = %config.endpoint,
132                "Invalid endpoint URL format"
133            );
134            AuthorizationError::ConnectionError { msg: e.to_string() }
135        })?;
136
137        let channel = endpoint.connect().await.map_err(|e| {
138            let error_msg = e.to_string();
139            
140            // Check for common connection error patterns
141            if error_msg.contains("dns") || error_msg.contains("DNS") {
142                tracing::error!(
143                    error = %error_msg,
144                    endpoint = %config.endpoint,
145                    "Failed to resolve SpiceDB hostname - check endpoint configuration"
146                );
147            } else if error_msg.contains("Connection refused") || error_msg.contains("refused") {
148                tracing::error!(
149                    error = %error_msg,
150                    endpoint = %config.endpoint,
151                    "Connection refused - SpiceDB may not be running or endpoint is incorrect"
152                );
153            } else if error_msg.contains("timeout") || error_msg.contains("timed out") {
154                tracing::error!(
155                    error = %error_msg,
156                    endpoint = %config.endpoint,
157                    "Connection timeout - check network connectivity and firewall rules"
158                );
159            } else {
160                tracing::error!(
161                    error = %error_msg,
162                    endpoint = %config.endpoint,
163                    "Failed to connect to SpiceDB - check network connectivity"
164                );
165            }
166            
167            AuthorizationError::ConnectionError { msg: error_msg }
168        })?;
169
170        tracing::info!("Successfully connected to SpiceDB");
171        Ok(channel)
172    }
173
174    async fn permissions(
175        &self,
176    ) -> tokio::sync::RwLockWriteGuard<
177        '_,
178        PermissionsServiceClient<InterceptedService<Channel, AuthInterceptor>>,
179    > {
180        self.permissions.write().await
181    }
182
183    /// Checks if a subject has a specific permission on a resource.
184    ///
185    /// This is the primary method for performing authorization checks in your application.
186    /// It evaluates whether the given subject (e.g., a user) has the specified permission
187    /// on the target resource (e.g., a channel, server, or other object).
188    ///
189    /// # How It Works
190    ///
191    /// The method performs the following steps:
192    /// 1. Converts the resource and subject into SpiceDB object references
193    /// 2. Sends a gRPC `CheckPermission` request to SpiceDB
194    /// 3. SpiceDB evaluates the permission based on defined relationships and rules
195    /// 4. Returns an `AuthorizationResult` indicating whether access is granted
196    ///
197    /// # Arguments
198    ///
199    /// * `resource` - The resource being accessed (e.g., `SpiceDbObject::Channel("123")`)
200    /// * `permission` - The permission being checked (e.g., `Permissions::ViewChannels`)
201    /// * `subject` - The entity requesting access (e.g., `SpiceDbObject::User("456")`)
202    ///
203    /// # Returns
204    ///
205    /// Returns an `AuthorizationResult` which can be queried with:
206    /// - `has_permissions()` - Returns `true` if access is granted
207    /// - `result()` - Returns `Ok(())` if granted, or `Err(AuthorizationError)` if denied
208    ///
209    /// # Examples
210    ///
211    /// ## Basic permission check
212    ///
213    /// ```no_run
214    /// use authz::{SpiceDbRepository, SpiceDbObject, Permissions};
215    ///
216    /// # async fn example(repo: SpiceDbRepository) {
217    /// let result = repo.check_permissions(
218    ///     SpiceDbObject::Channel("general".to_string()),
219    ///     Permissions::SendMessages,
220    ///     SpiceDbObject::User("alice".to_string()),
221    /// ).await;
222    ///
223    /// if result.has_permissions() {
224    ///     // Allow user to send message
225    ///     println!("User can send messages");
226    /// } else {
227    ///     // Deny access
228    ///     println!("User cannot send messages");
229    /// }
230    /// # }
231    /// ```
232    ///
233    /// ## Using result() for error handling
234    ///
235    /// ```no_run
236    /// use authz::{SpiceDbRepository, SpiceDbObject, Permissions, AuthorizationError};
237    ///
238    /// # async fn example(repo: SpiceDbRepository) -> Result<(), AuthorizationError> {
239    /// let result = repo.check_permissions(
240    ///     SpiceDbObject::Server("server-1".to_string()),
241    ///     Permissions::ManageRoles,
242    ///     SpiceDbObject::User("bob".to_string()),
243    /// ).await;
244    ///
245    /// // Returns error if permission denied
246    /// result.result()?;
247    ///
248    /// // Continue with authorized action
249    /// println!("User is authorized to manage roles");
250    /// # Ok(())
251    /// # }
252    /// ```
253    ///
254    /// ## Checking administrative access
255    ///
256    /// ```no_run
257    /// use authz::{SpiceDbRepository, SpiceDbObject, Permissions};
258    ///
259    /// # async fn example(repo: SpiceDbRepository) {
260    /// let is_admin = repo.check_permissions(
261    ///     SpiceDbObject::Server("my-server".to_string()),
262    ///     Permissions::Administrator,
263    ///     SpiceDbObject::User("charlie".to_string()),
264    /// ).await.has_permissions();
265    ///
266    /// if is_admin {
267    ///     // Grant full access
268    /// }
269    /// # }
270    /// ```
271    ///
272    /// # Performance Considerations
273    ///
274    /// Each call to this method makes a network request to SpiceDB. For high-performance
275    /// scenarios, consider:
276    /// - Caching authorization results when appropriate
277    /// - Batching multiple checks when possible
278    /// - Using SpiceDB's consistency guarantees to balance freshness vs. performance
279    ///
280    /// # See Also
281    ///
282    /// - [`check_permissions_raw`](Self::check_permissions_raw) - Lower-level API with more control
283    /// - [`Permissions`] - Available permission types
284    /// - [`SpiceDbObject`] - Resource and subject types
285    #[tracing::instrument(skip(self, resource, subject), fields(permission = %permission))]
286    pub async fn check_permissions(
287        &self,
288        resource: impl Into<SpiceDbObject>,
289        permission: Permissions,
290        subject: impl Into<SpiceDbObject>,
291    ) -> AuthorizationResult {
292        let resource: SpiceDbObject = resource.into();
293        let subject: SpiceDbObject = subject.into();
294        tracing::debug!(
295            resource_type = resource.get_object_type(),
296            resource_id = resource.get_object_id(),
297            subject_type = subject.get_object_type(),
298            subject_id = subject.get_object_id(),
299            "Checking permissions"
300        );
301        let permission: String = permission.to_string();
302        let result: AuthorizationResult = self
303            .check_permissions_raw(resource, permission, subject)
304            .await
305            .into();
306        tracing::info!(
307            has_permission = result.has_permissions(),
308            "Permission check completed"
309        );
310        result
311    }
312
313    /// Performs a raw permission check using SpiceDB object references and string permissions.
314    ///
315    /// This is a lower-level API that provides more flexibility than [`check_permissions`](Self::check_permissions).
316    /// It allows you to specify permissions as strings and use custom object references directly,
317    /// which can be useful for:
318    /// - Custom permission types not defined in the `Permissions` enum
319    /// - Dynamic permission names determined at runtime
320    /// - Direct integration with SpiceDB's native types
321    ///
322    /// # Arguments
323    ///
324    /// * `resource` - The resource object reference (must implement `Into<ObjectReference>`)
325    /// * `permission` - The permission name as a string (e.g., "view", "edit", "admin")
326    /// * `subject` - The subject object reference (must implement `Into<ObjectReference>`)
327    ///
328    /// # Returns
329    ///
330    /// Returns a `Result` containing:
331    /// - `Ok(Permissionship)` - The permission status from SpiceDB:
332    ///   - `Permissionship::HasPermission` - Access is granted
333    ///   - `Permissionship::NoPermission` - Access is denied
334    ///   - `Permissionship::ConditionalPermission` - Access depends on additional context
335    /// - `Err(AuthorizationError::Unauthorized)` - The check failed or was denied
336    ///
337    /// # Examples
338    ///
339    /// ## Custom permission check
340    ///
341    /// ```no_run
342    /// use authz::{SpiceDbRepository, authzed::api::v1::ObjectReference};
343    ///
344    /// # async fn example(repo: SpiceDbRepository) -> Result<(), Box<dyn std::error::Error>> {
345    /// let resource = ObjectReference {
346    ///     object_type: "document".to_string(),
347    ///     object_id: "doc-123".to_string(),
348    /// };
349    ///
350    /// let subject = ObjectReference {
351    ///     object_type: "user".to_string(),
352    ///     object_id: "user-456".to_string(),
353    /// };
354    ///
355    /// let permissionship = repo.check_permissions_raw(
356    ///     resource,
357    ///     "edit",
358    ///     subject,
359    /// ).await?;
360    ///
361    /// match permissionship {
362    ///     authz::authzed::api::v1::check_permission_response::Permissionship::HasPermission => {
363    ///         println!("Permission granted");
364    ///     }
365    ///     _ => {
366    ///         println!("Permission denied");
367    ///     }
368    /// }
369    /// # Ok(())
370    /// # }
371    /// ```
372    ///
373    /// ## Dynamic permission names
374    ///
375    /// ```no_run
376    /// use authz::{SpiceDbRepository, SpiceDbObject};
377    ///
378    /// # async fn check_dynamic_permission(
379    /// #     repo: SpiceDbRepository,
380    /// #     action: &str,
381    /// #     resource_id: &str,
382    /// #     user_id: &str,
383    /// # ) -> Result<bool, Box<dyn std::error::Error>> {
384    /// let permission_name = format!("can_{}", action);
385    ///
386    /// let result = repo.check_permissions_raw(
387    ///     SpiceDbObject::Channel(resource_id.to_string()),
388    ///     permission_name,
389    ///     SpiceDbObject::User(user_id.to_string()),
390    /// ).await?;
391    ///
392    /// Ok(result.has_permissions())
393    /// # }
394    /// ```
395    ///
396    /// # Errors
397    ///
398    /// This function will return an error if:
399    /// - The gRPC connection to SpiceDB fails
400    /// - The request times out
401    /// - The permission check is denied (returns `AuthorizationError::Unauthorized`)
402    ///
403    /// # See Also
404    ///
405    /// - [`check_permissions`](Self::check_permissions) - Higher-level, type-safe API
406    /// - [SpiceDB CheckPermission API](https://buf.build/authzed/api/docs/main:authzed.api.v1#authzed.api.v1.PermissionsService.CheckPermission)
407    #[tracing::instrument(skip(self, resource, permission, subject))]
408    pub async fn check_permissions_raw(
409        &self,
410        resource: impl Into<ObjectReference>,
411        permission: impl Into<String>,
412        subject: impl Into<ObjectReference>,
413    ) -> Result<Permissionship, AuthorizationError> {
414        let resource: ObjectReference = resource.into();
415        let sub_object_reference: ObjectReference = subject.into();
416        let permission_str = permission.into();
417
418        tracing::debug!(
419            resource_type = %resource.object_type,
420            resource_id = %resource.object_id,
421            subject_type = %sub_object_reference.object_type,
422            subject_id = %sub_object_reference.object_id,
423            permission = %permission_str,
424            "Performing raw permission check"
425        );
426
427        let subject = SubjectReference {
428            object: Some(sub_object_reference),
429            ..Default::default()
430        };
431        let check_request = CheckPermissionRequest {
432            resource: Some(resource),
433            permission: permission_str,
434            subject: Some(subject),
435            ..Default::default()
436        };
437
438        let check_response: CheckPermissionResponse = self
439            .permissions()
440            .await
441            .check_permission(check_request)
442            .await
443            .map_err(|e| {
444                let error_msg = e.to_string();
445                let error_code = e.code();
446                
447                match error_code {
448                    tonic::Code::Unauthenticated => {
449                        tracing::error!(
450                            error = %error_msg,
451                            error_code = ?error_code,
452                            "SpiceDB authentication failed - check your token"
453                        );
454                    }
455                    tonic::Code::PermissionDenied => {
456                        tracing::warn!(
457                            error = %error_msg,
458                            error_code = ?error_code,
459                            "Permission denied by SpiceDB"
460                        );
461                    }
462                    tonic::Code::Unavailable => {
463                        tracing::error!(
464                            error = %error_msg,
465                            error_code = ?error_code,
466                            "SpiceDB service unavailable - check connection"
467                        );
468                    }
469                    _ => {
470                        tracing::warn!(
471                            error = %error_msg,
472                            error_code = ?error_code,
473                            "Permission check failed"
474                        );
475                    }
476                }
477                
478                AuthorizationError::Unauthorized
479            })?
480            .into_inner();
481
482        let permissionship = check_response.permissionship();
483        tracing::debug!("Permission check result: {:?}", permissionship);
484        Ok(permissionship)
485    }
486}