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}