beep-authz 0.4.0

Authorization library for Beep services
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
use std::sync::Arc;

use tokio::sync::RwLock;
use tonic::{service::interceptor::InterceptedService, transport::Channel};

use crate::{
    authzed::api::v1::{
        check_permission_response::Permissionship, CheckPermissionRequest, CheckPermissionResponse,
        ObjectReference, SubjectReference,
    },
    config::SpiceDbConfig,
    grpc_auth::AuthInterceptor,
    object::SpiceDbObject,
    permission::{AuthorizationResult, Permissions},
    AuthorizationError, PermissionsServiceClient,
};

/// Main SpiceDB client for performing authorization checks.
///
/// `SpiceDbRepository` provides a high-level interface to interact with SpiceDB,
/// a Google Zanzibar-inspired authorization system. It handles connection management,
/// authentication, and permission checking operations.
///
/// # Architecture
///
/// The repository maintains a gRPC connection to a SpiceDB server and provides
/// methods to check whether a subject (e.g., a user) has a specific permission
/// on a resource (e.g., a channel or server).
///
/// # Examples
///
/// ```no_run
/// use authz::{SpiceDbRepository, SpiceDbConfig, SpiceDbObject, Permissions};
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let config = SpiceDbConfig {
///     endpoint: "localhost:50051".to_string(),
///     token: Some("your-token".to_string()),
/// };
///
/// let repo = SpiceDbRepository::new(config).await?;
///
/// // Check if user can view a channel
/// let result = repo.check_permissions(
///     SpiceDbObject::Channel("channel-123".to_string()),
///     Permissions::ViewChannels,
///     SpiceDbObject::User("user-456".to_string()),
/// ).await;
///
/// if result.has_permissions() {
///     println!("Access granted!");
/// }
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone)]
pub struct SpiceDbRepository {
    permissions:
        Arc<RwLock<PermissionsServiceClient<InterceptedService<Channel, AuthInterceptor>>>>,
}

impl SpiceDbRepository {
    /// Creates a new SpiceDB client with the given configuration.
    ///
    /// This establishes a gRPC connection to the SpiceDB server and sets up
    /// authentication using the provided token.
    ///
    /// # Arguments
    ///
    /// * `config` - Configuration containing the endpoint URL and optional authentication token
    ///
    /// # Returns
    ///
    /// Returns `Ok(SpiceDbRepository)` on successful connection, or an
    /// `AuthorizationError::ConnectionError` if the connection fails.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use authz::{SpiceDbRepository, SpiceDbConfig};
    ///
    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let config = SpiceDbConfig {
    ///     endpoint: "localhost:50051".to_string(),
    ///     token: Some("somerandomkey".to_string()),
    /// };
    ///
    /// let repo = SpiceDbRepository::new(config).await?;
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// # Errors
    ///
    /// This function will return an error if:
    /// - The endpoint URL is invalid
    /// - The connection to SpiceDB cannot be established
    /// - Network issues prevent communication with the server
    #[tracing::instrument(skip(config), fields(endpoint = %config.endpoint))]
    pub async fn new(config: SpiceDbConfig) -> Result<Self, AuthorizationError> {
        tracing::info!("Creating SpiceDB repository");
        let channel = Self::create_channel(&config).await?;

        // Always use an interceptor, even if token is empty
        let token = config.token.unwrap_or_default();

        let interceptor = AuthInterceptor::new(token);
        let permissions = Arc::new(RwLock::new(PermissionsServiceClient::with_interceptor(
            channel.clone(),
            interceptor,
        )));

        tracing::info!("SpiceDB repository created successfully");
        Ok(Self { permissions })
    }

    #[tracing::instrument(skip(config), fields(endpoint = %config.endpoint))]
    async fn create_channel(config: &SpiceDbConfig) -> Result<Channel, AuthorizationError> {
        // Add http:// scheme if not present
        let endpoint_url =
            if config.endpoint.starts_with("http://") || config.endpoint.starts_with("https://") {
                config.endpoint.clone()
            } else {
                format!("http://{}", config.endpoint)
            };

        tracing::debug!("Connecting to SpiceDB at {}", endpoint_url);
        let endpoint = Channel::from_shared(endpoint_url.clone()).map_err(|e| {
            tracing::error!(
                error = %e,
                endpoint = %config.endpoint,
                "Invalid endpoint URL format"
            );
            AuthorizationError::ConnectionError { msg: e.to_string() }
        })?;

        let channel = endpoint.connect().await.map_err(|e| {
            let error_msg = e.to_string();
            
            // Check for common connection error patterns
            if error_msg.contains("dns") || error_msg.contains("DNS") {
                tracing::error!(
                    error = %error_msg,
                    endpoint = %config.endpoint,
                    "Failed to resolve SpiceDB hostname - check endpoint configuration"
                );
            } else if error_msg.contains("Connection refused") || error_msg.contains("refused") {
                tracing::error!(
                    error = %error_msg,
                    endpoint = %config.endpoint,
                    "Connection refused - SpiceDB may not be running or endpoint is incorrect"
                );
            } else if error_msg.contains("timeout") || error_msg.contains("timed out") {
                tracing::error!(
                    error = %error_msg,
                    endpoint = %config.endpoint,
                    "Connection timeout - check network connectivity and firewall rules"
                );
            } else {
                tracing::error!(
                    error = %error_msg,
                    endpoint = %config.endpoint,
                    "Failed to connect to SpiceDB - check network connectivity"
                );
            }
            
            AuthorizationError::ConnectionError { msg: error_msg }
        })?;

        tracing::info!("Successfully connected to SpiceDB");
        Ok(channel)
    }

    async fn permissions(
        &self,
    ) -> tokio::sync::RwLockWriteGuard<
        '_,
        PermissionsServiceClient<InterceptedService<Channel, AuthInterceptor>>,
    > {
        self.permissions.write().await
    }

    /// Checks if a subject has a specific permission on a resource.
    ///
    /// This is the primary method for performing authorization checks in your application.
    /// It evaluates whether the given subject (e.g., a user) has the specified permission
    /// on the target resource (e.g., a channel, server, or other object).
    ///
    /// # How It Works
    ///
    /// The method performs the following steps:
    /// 1. Converts the resource and subject into SpiceDB object references
    /// 2. Sends a gRPC `CheckPermission` request to SpiceDB
    /// 3. SpiceDB evaluates the permission based on defined relationships and rules
    /// 4. Returns an `AuthorizationResult` indicating whether access is granted
    ///
    /// # Arguments
    ///
    /// * `resource` - The resource being accessed (e.g., `SpiceDbObject::Channel("123")`)
    /// * `permission` - The permission being checked (e.g., `Permissions::ViewChannels`)
    /// * `subject` - The entity requesting access (e.g., `SpiceDbObject::User("456")`)
    ///
    /// # Returns
    ///
    /// Returns an `AuthorizationResult` which can be queried with:
    /// - `has_permissions()` - Returns `true` if access is granted
    /// - `result()` - Returns `Ok(())` if granted, or `Err(AuthorizationError)` if denied
    ///
    /// # Examples
    ///
    /// ## Basic permission check
    ///
    /// ```no_run
    /// use authz::{SpiceDbRepository, SpiceDbObject, Permissions};
    ///
    /// # async fn example(repo: SpiceDbRepository) {
    /// let result = repo.check_permissions(
    ///     SpiceDbObject::Channel("general".to_string()),
    ///     Permissions::SendMessages,
    ///     SpiceDbObject::User("alice".to_string()),
    /// ).await;
    ///
    /// if result.has_permissions() {
    ///     // Allow user to send message
    ///     println!("User can send messages");
    /// } else {
    ///     // Deny access
    ///     println!("User cannot send messages");
    /// }
    /// # }
    /// ```
    ///
    /// ## Using result() for error handling
    ///
    /// ```no_run
    /// use authz::{SpiceDbRepository, SpiceDbObject, Permissions, AuthorizationError};
    ///
    /// # async fn example(repo: SpiceDbRepository) -> Result<(), AuthorizationError> {
    /// let result = repo.check_permissions(
    ///     SpiceDbObject::Server("server-1".to_string()),
    ///     Permissions::ManageRoles,
    ///     SpiceDbObject::User("bob".to_string()),
    /// ).await;
    ///
    /// // Returns error if permission denied
    /// result.result()?;
    ///
    /// // Continue with authorized action
    /// println!("User is authorized to manage roles");
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// ## Checking administrative access
    ///
    /// ```no_run
    /// use authz::{SpiceDbRepository, SpiceDbObject, Permissions};
    ///
    /// # async fn example(repo: SpiceDbRepository) {
    /// let is_admin = repo.check_permissions(
    ///     SpiceDbObject::Server("my-server".to_string()),
    ///     Permissions::Administrator,
    ///     SpiceDbObject::User("charlie".to_string()),
    /// ).await.has_permissions();
    ///
    /// if is_admin {
    ///     // Grant full access
    /// }
    /// # }
    /// ```
    ///
    /// # Performance Considerations
    ///
    /// Each call to this method makes a network request to SpiceDB. For high-performance
    /// scenarios, consider:
    /// - Caching authorization results when appropriate
    /// - Batching multiple checks when possible
    /// - Using SpiceDB's consistency guarantees to balance freshness vs. performance
    ///
    /// # See Also
    ///
    /// - [`check_permissions_raw`](Self::check_permissions_raw) - Lower-level API with more control
    /// - [`Permissions`] - Available permission types
    /// - [`SpiceDbObject`] - Resource and subject types
    #[tracing::instrument(skip(self, resource, subject), fields(permission = %permission))]
    pub async fn check_permissions(
        &self,
        resource: impl Into<SpiceDbObject>,
        permission: Permissions,
        subject: impl Into<SpiceDbObject>,
    ) -> AuthorizationResult {
        let resource: SpiceDbObject = resource.into();
        let subject: SpiceDbObject = subject.into();
        tracing::debug!(
            resource_type = resource.get_object_type(),
            resource_id = resource.get_object_id(),
            subject_type = subject.get_object_type(),
            subject_id = subject.get_object_id(),
            "Checking permissions"
        );
        let permission: String = permission.to_string();
        let result: AuthorizationResult = self
            .check_permissions_raw(resource, permission, subject)
            .await
            .into();
        tracing::info!(
            has_permission = result.has_permissions(),
            "Permission check completed"
        );
        result
    }

    /// Performs a raw permission check using SpiceDB object references and string permissions.
    ///
    /// This is a lower-level API that provides more flexibility than [`check_permissions`](Self::check_permissions).
    /// It allows you to specify permissions as strings and use custom object references directly,
    /// which can be useful for:
    /// - Custom permission types not defined in the `Permissions` enum
    /// - Dynamic permission names determined at runtime
    /// - Direct integration with SpiceDB's native types
    ///
    /// # Arguments
    ///
    /// * `resource` - The resource object reference (must implement `Into<ObjectReference>`)
    /// * `permission` - The permission name as a string (e.g., "view", "edit", "admin")
    /// * `subject` - The subject object reference (must implement `Into<ObjectReference>`)
    ///
    /// # Returns
    ///
    /// Returns a `Result` containing:
    /// - `Ok(Permissionship)` - The permission status from SpiceDB:
    ///   - `Permissionship::HasPermission` - Access is granted
    ///   - `Permissionship::NoPermission` - Access is denied
    ///   - `Permissionship::ConditionalPermission` - Access depends on additional context
    /// - `Err(AuthorizationError::Unauthorized)` - The check failed or was denied
    ///
    /// # Examples
    ///
    /// ## Custom permission check
    ///
    /// ```no_run
    /// use authz::{SpiceDbRepository, authzed::api::v1::ObjectReference};
    ///
    /// # async fn example(repo: SpiceDbRepository) -> Result<(), Box<dyn std::error::Error>> {
    /// let resource = ObjectReference {
    ///     object_type: "document".to_string(),
    ///     object_id: "doc-123".to_string(),
    /// };
    ///
    /// let subject = ObjectReference {
    ///     object_type: "user".to_string(),
    ///     object_id: "user-456".to_string(),
    /// };
    ///
    /// let permissionship = repo.check_permissions_raw(
    ///     resource,
    ///     "edit",
    ///     subject,
    /// ).await?;
    ///
    /// match permissionship {
    ///     authz::authzed::api::v1::check_permission_response::Permissionship::HasPermission => {
    ///         println!("Permission granted");
    ///     }
    ///     _ => {
    ///         println!("Permission denied");
    ///     }
    /// }
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// ## Dynamic permission names
    ///
    /// ```no_run
    /// use authz::{SpiceDbRepository, SpiceDbObject};
    ///
    /// # async fn check_dynamic_permission(
    /// #     repo: SpiceDbRepository,
    /// #     action: &str,
    /// #     resource_id: &str,
    /// #     user_id: &str,
    /// # ) -> Result<bool, Box<dyn std::error::Error>> {
    /// let permission_name = format!("can_{}", action);
    ///
    /// let result = repo.check_permissions_raw(
    ///     SpiceDbObject::Channel(resource_id.to_string()),
    ///     permission_name,
    ///     SpiceDbObject::User(user_id.to_string()),
    /// ).await?;
    ///
    /// Ok(result.has_permissions())
    /// # }
    /// ```
    ///
    /// # Errors
    ///
    /// This function will return an error if:
    /// - The gRPC connection to SpiceDB fails
    /// - The request times out
    /// - The permission check is denied (returns `AuthorizationError::Unauthorized`)
    ///
    /// # See Also
    ///
    /// - [`check_permissions`](Self::check_permissions) - Higher-level, type-safe API
    /// - [SpiceDB CheckPermission API](https://buf.build/authzed/api/docs/main:authzed.api.v1#authzed.api.v1.PermissionsService.CheckPermission)
    #[tracing::instrument(skip(self, resource, permission, subject))]
    pub async fn check_permissions_raw(
        &self,
        resource: impl Into<ObjectReference>,
        permission: impl Into<String>,
        subject: impl Into<ObjectReference>,
    ) -> Result<Permissionship, AuthorizationError> {
        let resource: ObjectReference = resource.into();
        let sub_object_reference: ObjectReference = subject.into();
        let permission_str = permission.into();

        tracing::debug!(
            resource_type = %resource.object_type,
            resource_id = %resource.object_id,
            subject_type = %sub_object_reference.object_type,
            subject_id = %sub_object_reference.object_id,
            permission = %permission_str,
            "Performing raw permission check"
        );

        let subject = SubjectReference {
            object: Some(sub_object_reference),
            ..Default::default()
        };
        let check_request = CheckPermissionRequest {
            resource: Some(resource),
            permission: permission_str,
            subject: Some(subject),
            ..Default::default()
        };

        let check_response: CheckPermissionResponse = self
            .permissions()
            .await
            .check_permission(check_request)
            .await
            .map_err(|e| {
                let error_msg = e.to_string();
                let error_code = e.code();
                
                match error_code {
                    tonic::Code::Unauthenticated => {
                        tracing::error!(
                            error = %error_msg,
                            error_code = ?error_code,
                            "SpiceDB authentication failed - check your token"
                        );
                    }
                    tonic::Code::PermissionDenied => {
                        tracing::warn!(
                            error = %error_msg,
                            error_code = ?error_code,
                            "Permission denied by SpiceDB"
                        );
                    }
                    tonic::Code::Unavailable => {
                        tracing::error!(
                            error = %error_msg,
                            error_code = ?error_code,
                            "SpiceDB service unavailable - check connection"
                        );
                    }
                    _ => {
                        tracing::warn!(
                            error = %error_msg,
                            error_code = ?error_code,
                            "Permission check failed"
                        );
                    }
                }
                
                AuthorizationError::Unauthorized
            })?
            .into_inner();

        let permissionship = check_response.permissionship();
        tracing::debug!("Permission check result: {:?}", permissionship);
        Ok(permissionship)
    }
}