1use crate::case::to_camel_case;
10use crate::config::ResolvedEntity;
11use crate::error::AppError;
12use serde::Deserialize;
13use std::sync::Arc;
14
15pub struct AuthrsClient {
16 base_url: String,
17 service_name: String,
18 client: reqwest::Client,
19}
20
21#[derive(Deserialize)]
22struct CheckResponse {
23 allowed: Option<bool>,
24}
25
26impl AuthrsClient {
27 pub fn from_env() -> Option<Arc<Self>> {
28 let base_url = std::env::var("AUTHRS_URL").ok()?;
29 let service_name = std::env::var("SERVICE_NAME").ok()?;
30 let client = reqwest::Client::builder()
31 .timeout(std::time::Duration::from_secs(5))
32 .build()
33 .ok()?;
34 tracing::info!(url = %base_url, service = %service_name, "authrs permission checks enabled");
35 Some(Arc::new(Self {
36 base_url,
37 service_name,
38 client,
39 }))
40 }
41
42 async fn check(
43 &self,
44 tenant_id: &str,
45 user_id: &str,
46 resource: &str,
47 action: &str,
48 ) -> Result<bool, AppError> {
49 let url = format!("{}/admin/permissions/check", self.base_url);
50 let body = serde_json::json!({
51 "userId": user_id,
52 "resource": resource,
53 "action": action,
54 });
55 let resp = self
56 .client
57 .post(&url)
58 .header("X-Tenant-ID", tenant_id)
59 .json(&body)
60 .send()
61 .await
62 .map_err(|e| {
63 tracing::error!(error = %e, "authrs request failed");
64 AppError::Unauthorized(format!("permission service unavailable: {}", e))
65 })?;
66
67 if !resp.status().is_success() {
68 let status = resp.status().as_u16();
69 tracing::error!(status, "authrs returned non-success status");
70 return Err(AppError::Unauthorized(format!(
71 "permission check failed with status {}",
72 status
73 )));
74 }
75
76 let check_resp: CheckResponse = resp.json().await.map_err(|e| {
77 tracing::error!(error = %e, "authrs response parse failed");
78 AppError::Unauthorized(format!("permission check response invalid: {}", e))
79 })?;
80
81 Ok(check_resp.allowed.unwrap_or(false))
82 }
83}
84
85fn pascal_case(s: &str) -> String {
86 let camel = to_camel_case(s);
87 let mut chars = camel.chars();
88 match chars.next() {
89 None => String::new(),
90 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
91 }
92}
93
94pub async fn check_entity_permission_opt(
99 client_opt: &Option<Arc<AuthrsClient>>,
100 tenant_id: Option<&str>,
101 user_id: Option<&str>,
102 entity: &ResolvedEntity,
103 http_verb: &str,
104) -> Result<(), AppError> {
105 let client = match client_opt {
106 Some(c) => c,
107 None => return Ok(()),
108 };
109
110 let user_id =
111 user_id.ok_or_else(|| AppError::Unauthorized("X-User-ID header is required".into()))?;
112 let tenant_id = tenant_id.unwrap_or("");
113
114 let action = format!("{}{}", http_verb, pascal_case(&entity.table_name));
115 let resource = format!(
116 "service:{}/package:{}/table:{}",
117 client.service_name, entity.package_id, entity.table_name
118 );
119
120 tracing::debug!(
121 user_id = %user_id,
122 resource = %resource,
123 action = %action,
124 "checking authrs permission"
125 );
126
127 let allowed = client.check(tenant_id, user_id, &resource, &action).await?;
128
129 if allowed {
130 tracing::info!(
131 user_id = %user_id,
132 tenant_id = %tenant_id,
133 resource = %resource,
134 action = %action,
135 "permission granted"
136 );
137 } else {
138 tracing::warn!(
139 user_id = %user_id,
140 tenant_id = %tenant_id,
141 resource = %resource,
142 action = %action,
143 "permission denied"
144 );
145 return Err(AppError::Unauthorized(format!(
146 "action '{}' not permitted on '{}'",
147 action, resource
148 )));
149 }
150
151 Ok(())
152}