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,
};
#[derive(Debug, Clone)]
pub struct SpiceDbRepository {
permissions:
Arc<RwLock<PermissionsServiceClient<InterceptedService<Channel, AuthInterceptor>>>>,
}
impl SpiceDbRepository {
#[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?;
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> {
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();
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
}
#[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
}
#[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)
}
}