1use 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#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct WorkOsResource {
18 pub resource_type_slug: String,
20 pub resource_external_id: String,
22}
23
24impl WorkOsResource {
25 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#[derive(Debug, Clone, Serialize)]
42pub struct WorkOsFgaRequest {
43 pub permission_slug: String,
45 pub resource_type_slug: String,
47 pub resource_external_id: String,
49}
50
51#[derive(Debug, Deserialize)]
52struct WorkOsFgaResponse {
53 authorized: bool,
54}
55
56pub struct WorkOsFgaEngine {
58 api_key: String,
59 base_url: String,
60 http: Arc<dyn HttpClient>,
61}
62
63impl WorkOsFgaEngine {
64 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 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}