Skip to main content

architect_sdk/
authrs.rs

1//! Authrs permission-check client. Active only when AUTHRS_URL and SERVICE_NAME env vars are set.
2//!
3//! Before each entity operation the handler calls `check_entity_permission_opt()`, which
4//! posts to authrs `/admin/permissions/check` and returns Unauthorized if the user lacks the action.
5//!
6//! Resource format: `service:{SERVICE_NAME}/package:{package_id}/table:{table_name}`
7//! Action format:   `{httpVerb}{PascalCaseTableName}` e.g. `getMaterials`, `postMaterials`
8
9use crate::case::to_camel_case;
10use crate::config::{ResolvedEntity, ResolvedReport};
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 mut request = self
56            .client
57            .post(&url)
58            .header("X-Tenant-ID", tenant_id)
59            .json(&body);
60        // Continue the current request's trace downstream (W3C traceparent), if any.
61        if let Some(tp) = crate::middleware::outbound_traceparent() {
62            request = request.header(crate::middleware::TRACEPARENT_HEADER, tp);
63        }
64        let resp = request.send().await.map_err(|e| {
65            tracing::error!(error = %e, "authrs request failed");
66            AppError::Unauthorized(format!("permission service unavailable: {}", e))
67        })?;
68
69        if !resp.status().is_success() {
70            let status = resp.status().as_u16();
71            tracing::error!(status, "authrs returned non-success status");
72            return Err(AppError::Unauthorized(format!(
73                "permission check failed with status {}",
74                status
75            )));
76        }
77
78        let check_resp: CheckResponse = resp.json().await.map_err(|e| {
79            tracing::error!(error = %e, "authrs response parse failed");
80            AppError::Unauthorized(format!("permission check response invalid: {}", e))
81        })?;
82
83        Ok(check_resp.allowed.unwrap_or(false))
84    }
85}
86
87fn pascal_case(s: &str) -> String {
88    let camel = to_camel_case(s);
89    let mut chars = camel.chars();
90    match chars.next() {
91        None => String::new(),
92        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
93    }
94}
95
96/// Check entity permission against authrs. No-op when authrs is not configured (client_opt is None).
97///
98/// Requires `X-User-ID` header when authrs is configured; returns Unauthorized if missing.
99/// Returns Unauthorized when the user lacks the required action on the derived resource.
100pub async fn check_entity_permission_opt(
101    client_opt: &Option<Arc<AuthrsClient>>,
102    tenant_id: Option<&str>,
103    user_id: Option<&str>,
104    entity: &ResolvedEntity,
105    http_verb: &str,
106) -> Result<(), AppError> {
107    let client = match client_opt {
108        Some(c) => c,
109        None => return Ok(()),
110    };
111
112    let user_id =
113        user_id.ok_or_else(|| AppError::Unauthorized("X-User-ID header is required".into()))?;
114    let tenant_id = tenant_id.unwrap_or("");
115
116    let action = format!("{}{}", http_verb, pascal_case(&entity.table_name));
117    let resource = format!(
118        "service:{}/package:{}/table:{}",
119        client.service_name, entity.package_id, entity.table_name
120    );
121
122    tracing::debug!(
123        user_id = %user_id,
124        resource = %resource,
125        action = %action,
126        "checking authrs permission"
127    );
128
129    let allowed = client.check(tenant_id, user_id, &resource, &action).await?;
130
131    if allowed {
132        tracing::info!(
133            user_id = %user_id,
134            tenant_id = %tenant_id,
135            resource = %resource,
136            action = %action,
137            "permission granted"
138        );
139    } else {
140        tracing::warn!(
141            user_id = %user_id,
142            tenant_id = %tenant_id,
143            resource = %resource,
144            action = %action,
145            "permission denied"
146        );
147        return Err(AppError::Unauthorized(format!(
148            "action '{}' not permitted on '{}'",
149            action, resource
150        )));
151    }
152
153    Ok(())
154}
155
156/// Check report permission against authrs. No-op when authrs is not configured (client_opt is None).
157///
158/// Mirrors [`check_entity_permission_opt`] for the reports feature. Requires `X-User-ID` when
159/// authrs is configured.
160///
161/// Resource format: `service:{SERVICE_NAME}/package:{package_id}/report:{report_id}`
162/// Action format:   `{action}` (e.g. `run`).
163pub async fn check_report_permission_opt(
164    client_opt: &Option<Arc<AuthrsClient>>,
165    tenant_id: Option<&str>,
166    user_id: Option<&str>,
167    report: &ResolvedReport,
168    action: &str,
169) -> Result<(), AppError> {
170    let client = match client_opt {
171        Some(c) => c,
172        None => return Ok(()),
173    };
174
175    let user_id =
176        user_id.ok_or_else(|| AppError::Unauthorized("X-User-ID header is required".into()))?;
177    let tenant_id = tenant_id.unwrap_or("");
178
179    let resource = format!(
180        "service:{}/package:{}/report:{}",
181        client.service_name, report.package_id, report.id
182    );
183
184    tracing::debug!(
185        user_id = %user_id,
186        resource = %resource,
187        action = %action,
188        "checking authrs report permission"
189    );
190
191    let allowed = client.check(tenant_id, user_id, &resource, action).await?;
192
193    if allowed {
194        tracing::info!(
195            user_id = %user_id,
196            tenant_id = %tenant_id,
197            resource = %resource,
198            action = %action,
199            "report permission granted"
200        );
201    } else {
202        tracing::warn!(
203            user_id = %user_id,
204            tenant_id = %tenant_id,
205            resource = %resource,
206            action = %action,
207            "report permission denied"
208        );
209        return Err(AppError::Unauthorized(format!(
210            "action '{}' not permitted on '{}'",
211            action, resource
212        )));
213    }
214
215    Ok(())
216}