typesec_integrations/
workos.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct WorkOsResource {
19 pub resource_type_slug: String,
21 pub resource_external_id: String,
23}
24
25impl WorkOsResource {
26 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#[derive(Debug, Clone, Serialize)]
43pub struct WorkOsFgaRequest {
44 pub permission_slug: String,
46 pub resource_type_slug: String,
48 pub resource_external_id: String,
50}
51
52#[derive(Debug, Deserialize)]
53struct WorkOsFgaResponse {
54 authorized: bool,
55}
56
57pub struct WorkOsFgaEngine {
59 engine: ProviderHttpEngine,
60}
61
62impl WorkOsFgaEngine {
63 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 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;