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 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}