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;
14use crate::provider::{ProviderHttpEngine, ProviderHttpError};
15
16/// A WorkOS resource identifier parsed from a Typesec resource id.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct WorkOsResource {
19    /// WorkOS resource type slug, for example `project`.
20    pub resource_type_slug: String,
21    /// Application-level external resource id.
22    pub resource_external_id: String,
23}
24
25impl WorkOsResource {
26    /// Parse `type/id` or `type:id` into a WorkOS resource reference.
27    pub fn parse(resource: &str) -> Option<Self> {
28        let (resource_type_slug, resource_external_id) = resource
29            .split_once('/')
30            .or_else(|| resource.split_once(':'))?;
31        if resource_type_slug.is_empty() || resource_external_id.is_empty() {
32            return None;
33        }
34        Some(Self {
35            resource_type_slug: resource_type_slug.to_string(),
36            resource_external_id: resource_external_id.to_string(),
37        })
38    }
39}
40
41/// JSON body sent to the WorkOS authorization check endpoint.
42#[derive(Debug, Clone, Serialize)]
43pub struct WorkOsFgaRequest {
44    /// Permission slug to check.
45    pub permission_slug: String,
46    /// Resource type slug.
47    pub resource_type_slug: String,
48    /// External resource id.
49    pub resource_external_id: String,
50}
51
52#[derive(Debug, Deserialize)]
53struct WorkOsFgaResponse {
54    authorized: bool,
55}
56
57/// Policy engine that delegates resource checks to WorkOS FGA.
58pub struct WorkOsFgaEngine {
59    engine: ProviderHttpEngine,
60}
61
62impl WorkOsFgaEngine {
63    /// Create an engine using `https://api.workos.com`.
64    pub fn new(api_key: impl Into<String>) -> Self {
65        Self {
66            engine: ProviderHttpEngine::new(api_key, "https://api.workos.com"),
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            engine: ProviderHttpEngine::with_http(api_key, base_url, http),
78        }
79    }
80
81    fn request_for(&self, action: &str, resource: &str) -> Result<WorkOsFgaRequest, String> {
82        let resource = WorkOsResource::parse(resource)
83            .ok_or_else(|| format!("resource '{resource}' is not formatted as type/id"))?;
84        Ok(WorkOsFgaRequest {
85            permission_slug: permission_slug(action, &resource.resource_type_slug),
86            resource_type_slug: resource.resource_type_slug,
87            resource_external_id: resource.resource_external_id,
88        })
89    }
90}
91
92impl PolicyEngine for WorkOsFgaEngine {
93    fn check(&self, subject: &SubjectId, action: &str, resource: &ResourceId) -> PolicyResult {
94        let subject = subject.as_str();
95        let resource = resource.as_str();
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("workos", reason),
101        };
102
103        let url = format!(
104            "{}/authorization/organization_memberships/{}/check",
105            self.engine.base_url(),
106            subject
107        );
108        let body = json!({
109            "permission_slug": request.permission_slug,
110            "resource_type_slug": request.resource_type_slug,
111            "resource_external_id": request.resource_external_id,
112        });
113
114        match self.engine.bearer_post::<WorkOsFgaResponse>(&url, &body) {
115            Ok(response) if response.authorized => PolicyResult::Allow,
116            Ok(_) => PolicyResult::Deny(format!(
117                "WorkOS denied '{subject}' permission '{action}' on '{resource}'"
118            )),
119            Err(ProviderHttpError::Parse(err)) => {
120                PolicyResult::Deny(format!("WorkOS response parse error: {err}"))
121            }
122            Err(ProviderHttpError::Transport(err)) => {
123                PolicyResult::Deny(format!("WorkOS FGA check failed: {err}"))
124            }
125        }
126    }
127}
128
129fn permission_slug(action: &str, resource_type: &str) -> String {
130    if action.contains(':') {
131        action.to_string()
132    } else {
133        format!("{resource_type}:{action}")
134    }
135}
136
137#[cfg(test)]
138mod tests;