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