use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::debug;
use typesec_core::policy::{PolicyEngine, PolicyResult};
use crate::http::{HttpClient, ReqwestHttpClient};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkOsResource {
pub resource_type_slug: String,
pub resource_external_id: String,
}
impl WorkOsResource {
pub fn parse(resource: &str) -> Option<Self> {
let (resource_type_slug, resource_external_id) = resource
.split_once('/')
.or_else(|| resource.split_once(':'))?;
if resource_type_slug.is_empty() || resource_external_id.is_empty() {
return None;
}
Some(Self {
resource_type_slug: resource_type_slug.to_string(),
resource_external_id: resource_external_id.to_string(),
})
}
}
#[derive(Debug, Clone, Serialize)]
pub struct WorkOsFgaRequest {
pub permission_slug: String,
pub resource_type_slug: String,
pub resource_external_id: String,
}
#[derive(Debug, Deserialize)]
struct WorkOsFgaResponse {
authorized: bool,
}
pub struct WorkOsFgaEngine {
api_key: String,
base_url: String,
http: Arc<dyn HttpClient>,
}
impl WorkOsFgaEngine {
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_http(
api_key,
"https://api.workos.com",
Arc::new(ReqwestHttpClient::new()),
)
}
pub fn with_http(
api_key: impl Into<String>,
base_url: impl Into<String>,
http: Arc<dyn HttpClient>,
) -> Self {
Self {
api_key: api_key.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
http,
}
}
fn request_for(&self, action: &str, resource: &str) -> Result<WorkOsFgaRequest, String> {
let resource = WorkOsResource::parse(resource)
.ok_or_else(|| format!("resource '{resource}' is not formatted as type/id"))?;
Ok(WorkOsFgaRequest {
permission_slug: permission_slug(action, &resource.resource_type_slug),
resource_type_slug: resource.resource_type_slug,
resource_external_id: resource.resource_external_id,
})
}
}
impl PolicyEngine for WorkOsFgaEngine {
fn check(&self, subject: &str, action: &str, resource: &str) -> PolicyResult {
debug!(subject, action, resource, "workos fga check");
let request = match self.request_for(action, resource) {
Ok(request) => request,
Err(reason) => return PolicyResult::Delegate(reason),
};
let url = format!(
"{}/authorization/organization_memberships/{}/check",
self.base_url, subject
);
let body = json!({
"permission_slug": request.permission_slug,
"resource_type_slug": request.resource_type_slug,
"resource_external_id": request.resource_external_id,
});
let headers = [("Authorization", format!("Bearer {}", self.api_key))];
match self.http.post_json(&url, &headers, &body) {
Ok(value) => match serde_json::from_value::<WorkOsFgaResponse>(value) {
Ok(response) if response.authorized => PolicyResult::Allow,
Ok(_) => PolicyResult::Deny(format!(
"WorkOS denied '{subject}' permission '{action}' on '{resource}'"
)),
Err(err) => PolicyResult::Deny(format!("WorkOS response parse error: {err}")),
},
Err(err) => PolicyResult::Deny(format!("WorkOS FGA check failed: {err}")),
}
}
}
fn permission_slug(action: &str, resource_type: &str) -> String {
if action.contains(':') {
action.to_string()
} else {
format!("{resource_type}:{action}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::StaticHttpClient;
use serde_json::json;
#[test]
fn parses_resource_ids_for_workos() {
let parsed = WorkOsResource::parse("project/proj_123").expect("parse");
assert_eq!(parsed.resource_type_slug, "project");
assert_eq!(parsed.resource_external_id, "proj_123");
}
#[test]
fn allows_when_workos_authorizes() {
let url = "https://api.workos.test/authorization/organization_memberships/om_1/check";
let http = StaticHttpClient::new().with_response(url, json!({ "authorized": true }));
let engine =
WorkOsFgaEngine::with_http("sk_test", "https://api.workos.test", Arc::new(http));
assert_eq!(
engine.check("om_1", "edit", "project/proj_123"),
PolicyResult::Allow
);
}
#[test]
fn denies_when_workos_denies() {
let url = "https://api.workos.test/authorization/organization_memberships/om_1/check";
let http = StaticHttpClient::new().with_response(url, json!({ "authorized": false }));
let engine =
WorkOsFgaEngine::with_http("sk_test", "https://api.workos.test", Arc::new(http));
assert!(matches!(
engine.check("om_1", "edit", "project/proj_123"),
PolicyResult::Deny(_)
));
}
}