typesec_integrations/
workos.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct WorkOsResource {
15 pub resource_type_slug: String,
17 pub resource_external_id: String,
19}
20
21impl WorkOsResource {
22 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#[derive(Debug, Clone, Serialize)]
39pub struct WorkOsFgaRequest {
40 pub permission_slug: String,
42 pub resource_type_slug: String,
44 pub resource_external_id: String,
46}
47
48#[derive(Debug, Deserialize)]
49struct WorkOsFgaResponse {
50 authorized: bool,
51}
52
53pub struct WorkOsFgaEngine {
55 api_key: String,
56 base_url: String,
57 http: Arc<dyn HttpClient>,
58}
59
60impl WorkOsFgaEngine {
61 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 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}