Skip to main content

typesec_integrations/
workos.rs

1//! WorkOS Fine-Grained Authorization integration.
2
3use std::sync::Arc;
4
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7use tracing::debug;
8use typesec_core::policy::{PolicyEngine, PolicyResult};
9
10use crate::http::{HttpClient, ReqwestHttpClient};
11
12/// A WorkOS resource identifier parsed from a Typesec resource id.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct WorkOsResource {
15    /// WorkOS resource type slug, for example `project`.
16    pub resource_type_slug: String,
17    /// Application-level external resource id.
18    pub resource_external_id: String,
19}
20
21impl WorkOsResource {
22    /// Parse `type/id` or `type:id` into a WorkOS resource reference.
23    pub fn parse(resource: &str) -> Option<Self> {
24        let (resource_type_slug, resource_external_id) = resource
25            .split_once('/')
26            .or_else(|| resource.split_once(':'))?;
27        if resource_type_slug.is_empty() || resource_external_id.is_empty() {
28            return None;
29        }
30        Some(Self {
31            resource_type_slug: resource_type_slug.to_string(),
32            resource_external_id: resource_external_id.to_string(),
33        })
34    }
35}
36
37/// JSON body sent to the WorkOS authorization check endpoint.
38#[derive(Debug, Clone, Serialize)]
39pub struct WorkOsFgaRequest {
40    /// Permission slug to check.
41    pub permission_slug: String,
42    /// Resource type slug.
43    pub resource_type_slug: String,
44    /// External resource id.
45    pub resource_external_id: String,
46}
47
48#[derive(Debug, Deserialize)]
49struct WorkOsFgaResponse {
50    authorized: bool,
51}
52
53/// Policy engine that delegates resource checks to WorkOS FGA.
54pub struct WorkOsFgaEngine {
55    api_key: String,
56    base_url: String,
57    http: Arc<dyn HttpClient>,
58}
59
60impl WorkOsFgaEngine {
61    /// Create an engine using `https://api.workos.com`.
62    pub fn new(api_key: impl Into<String>) -> Self {
63        Self::with_http(
64            api_key,
65            "https://api.workos.com",
66            Arc::new(ReqwestHttpClient::new()),
67        )
68    }
69
70    /// Create an engine with custom base URL and HTTP client.
71    pub fn with_http(
72        api_key: impl Into<String>,
73        base_url: impl Into<String>,
74        http: Arc<dyn HttpClient>,
75    ) -> Self {
76        Self {
77            api_key: api_key.into(),
78            base_url: base_url.into().trim_end_matches('/').to_string(),
79            http,
80        }
81    }
82
83    fn request_for(&self, action: &str, resource: &str) -> Result<WorkOsFgaRequest, String> {
84        let resource = WorkOsResource::parse(resource)
85            .ok_or_else(|| format!("resource '{resource}' is not formatted as type/id"))?;
86        Ok(WorkOsFgaRequest {
87            permission_slug: permission_slug(action, &resource.resource_type_slug),
88            resource_type_slug: resource.resource_type_slug,
89            resource_external_id: resource.resource_external_id,
90        })
91    }
92}
93
94impl PolicyEngine for WorkOsFgaEngine {
95    fn check(&self, subject: &str, action: &str, resource: &str) -> PolicyResult {
96        debug!(subject, action, resource, "workos fga check");
97
98        let request = match self.request_for(action, resource) {
99            Ok(request) => request,
100            Err(reason) => return PolicyResult::Delegate(reason),
101        };
102
103        let url = format!(
104            "{}/authorization/organization_memberships/{}/check",
105            self.base_url, subject
106        );
107        let body = json!({
108            "permission_slug": request.permission_slug,
109            "resource_type_slug": request.resource_type_slug,
110            "resource_external_id": request.resource_external_id,
111        });
112        let headers = [("Authorization", format!("Bearer {}", self.api_key))];
113
114        match self.http.post_json(&url, &headers, &body) {
115            Ok(value) => match serde_json::from_value::<WorkOsFgaResponse>(value) {
116                Ok(response) if response.authorized => PolicyResult::Allow,
117                Ok(_) => PolicyResult::Deny(format!(
118                    "WorkOS denied '{subject}' permission '{action}' on '{resource}'"
119                )),
120                Err(err) => PolicyResult::Deny(format!("WorkOS response parse error: {err}")),
121            },
122            Err(err) => PolicyResult::Deny(format!("WorkOS FGA check failed: {err}")),
123        }
124    }
125}
126
127fn permission_slug(action: &str, resource_type: &str) -> String {
128    if action.contains(':') {
129        action.to_string()
130    } else {
131        format!("{resource_type}:{action}")
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::http::StaticHttpClient;
139    use serde_json::json;
140
141    #[test]
142    fn parses_resource_ids_for_workos() {
143        let parsed = WorkOsResource::parse("project/proj_123").expect("parse");
144        assert_eq!(parsed.resource_type_slug, "project");
145        assert_eq!(parsed.resource_external_id, "proj_123");
146    }
147
148    #[test]
149    fn allows_when_workos_authorizes() {
150        let url = "https://api.workos.test/authorization/organization_memberships/om_1/check";
151        let http = StaticHttpClient::new().with_response(url, json!({ "authorized": true }));
152        let engine =
153            WorkOsFgaEngine::with_http("sk_test", "https://api.workos.test", Arc::new(http));
154
155        assert_eq!(
156            engine.check("om_1", "edit", "project/proj_123"),
157            PolicyResult::Allow
158        );
159    }
160
161    #[test]
162    fn denies_when_workos_denies() {
163        let url = "https://api.workos.test/authorization/organization_memberships/om_1/check";
164        let http = StaticHttpClient::new().with_response(url, json!({ "authorized": false }));
165        let engine =
166            WorkOsFgaEngine::with_http("sk_test", "https://api.workos.test", Arc::new(http));
167
168        assert!(matches!(
169            engine.check("om_1", "edit", "project/proj_123"),
170            PolicyResult::Deny(_)
171        ));
172    }
173}