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;
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
94/// Check entity permission against authrs. No-op when authrs is not configured (client_opt is None).
95///
96/// Requires `X-User-ID` header when authrs is configured; returns Unauthorized if missing.
97/// Returns Unauthorized when the user lacks the required action on the derived resource.
98pub 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}