use fraiseql_auth::scim::{ScimGroup, ScimUser};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
pub const USER_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:User";
pub const GROUP_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:Group";
pub const LIST_RESPONSE_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
pub const ERROR_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:Error";
pub const PATCH_OP_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
pub const SCIM_CONTENT_TYPE: &str = "application/scim+json";
#[must_use]
pub fn etag(version: i64) -> String {
format!("W/\"{version}\"")
}
#[must_use]
pub fn user_to_json(user: &ScimUser, base_url: &str, groups: &[String]) -> Value {
let mut value = json!({
"schemas": [USER_SCHEMA],
"id": user.id,
"userName": user.user_name,
"active": user.active,
"meta": {
"resourceType": "User",
"created": user.created_at.to_rfc3339(),
"lastModified": user.updated_at.to_rfc3339(),
"location": format!("{base_url}/Users/{}", user.id),
"version": etag(user.version),
},
});
let obj = value.as_object_mut().expect("object literal");
if let Some(external_id) = &user.external_id {
obj.insert("externalId".to_string(), json!(external_id));
}
if let Some(display_name) = &user.display_name {
obj.insert("displayName".to_string(), json!(display_name));
}
if user.given_name.is_some() || user.family_name.is_some() {
let mut name = serde_json::Map::new();
if let Some(given) = &user.given_name {
name.insert("givenName".to_string(), json!(given));
}
if let Some(family) = &user.family_name {
name.insert("familyName".to_string(), json!(family));
}
obj.insert("name".to_string(), Value::Object(name));
}
if let Some(email) = &user.email {
obj.insert("emails".to_string(), json!([{ "value": email, "primary": true }]));
}
if !groups.is_empty() {
obj.insert(
"groups".to_string(),
Value::Array(groups.iter().map(|g| json!({ "display": g })).collect()),
);
}
value
}
#[must_use]
pub fn group_to_json(group: &ScimGroup, base_url: &str) -> Value {
let mut value = json!({
"schemas": [GROUP_SCHEMA],
"id": group.id,
"displayName": group.display_name,
"meta": {
"resourceType": "Group",
"created": group.created_at.to_rfc3339(),
"lastModified": group.updated_at.to_rfc3339(),
"location": format!("{base_url}/Groups/{}", group.id),
"version": etag(group.version),
},
});
let obj = value.as_object_mut().expect("object literal");
if let Some(external_id) = &group.external_id {
obj.insert("externalId".to_string(), json!(external_id));
}
if !group.members.is_empty() {
obj.insert(
"members".to_string(),
Value::Array(group.members.iter().map(|m| json!({ "value": m })).collect()),
);
}
value
}
#[must_use]
pub fn project(mut resource: Value, attributes: Option<&str>, excluded: Option<&str>) -> Value {
const ALWAYS: [&str; 3] = ["schemas", "id", "meta"];
let Some(obj) = resource.as_object_mut() else {
return resource;
};
if let Some(list) = attributes {
let wanted: Vec<String> = list
.split(',')
.map(|a| a.trim().to_ascii_lowercase())
.filter(|a| !a.is_empty())
.collect();
if !wanted.is_empty() {
obj.retain(|key, _| {
ALWAYS.contains(&key.as_str()) || wanted.contains(&key.to_ascii_lowercase())
});
return resource;
}
}
if let Some(list) = excluded {
let unwanted: Vec<String> = list
.split(',')
.map(|a| a.trim().to_ascii_lowercase())
.filter(|a| !a.is_empty())
.collect();
obj.retain(|key, _| {
ALWAYS.contains(&key.as_str()) || !unwanted.contains(&key.to_ascii_lowercase())
});
}
resource
}
#[must_use]
pub fn list_response(resources: &[Value], total: i64, start_index: i64) -> Value {
json!({
"schemas": [LIST_RESPONSE_SCHEMA],
"totalResults": total,
"itemsPerPage": resources.len(),
"startIndex": start_index,
"Resources": resources,
})
}
#[must_use]
pub fn error_response(status: u16, detail: &str, scim_type: Option<&str>) -> Value {
let mut value = json!({
"schemas": [ERROR_SCHEMA],
"status": status.to_string(),
"detail": detail,
});
if let Some(scim_type) = scim_type {
value
.as_object_mut()
.expect("object literal")
.insert("scimType".to_string(), json!(scim_type));
}
value
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserBody {
#[serde(default)]
pub schemas: Vec<String>,
#[serde(default)]
pub user_name: Option<String>,
#[serde(default)]
pub external_id: Option<String>,
#[serde(default)]
pub name: Option<NameBody>,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub emails: Vec<EmailBody>,
#[serde(default)]
pub active: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NameBody {
#[serde(default)]
pub given_name: Option<String>,
#[serde(default)]
pub family_name: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct EmailBody {
#[serde(default)]
pub value: Option<String>,
#[serde(default)]
pub primary: Option<bool>,
}
impl UserBody {
#[must_use]
pub fn primary_email(&self) -> Option<String> {
self.emails
.iter()
.find(|e| e.primary == Some(true))
.or_else(|| self.emails.first())
.and_then(|e| e.value.clone())
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GroupBody {
#[serde(default)]
pub schemas: Vec<String>,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub external_id: Option<String>,
#[serde(default)]
pub members: Vec<MemberBody>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct MemberBody {
#[serde(default)]
pub value: Option<String>,
}
impl GroupBody {
#[must_use]
pub fn member_ids(&self) -> Vec<String> {
self.members.iter().filter_map(|m| m.value.clone()).collect()
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct PatchBody {
#[serde(default)]
pub schemas: Vec<String>,
#[serde(default, rename = "Operations")]
pub operations: Vec<PatchOperation>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct PatchOperation {
#[serde(default)]
pub op: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub value: Option<Value>,
}
#[must_use]
pub fn service_provider_config(base_url: &str) -> Value {
json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"documentationUri": "https://github.com/fraiseql/fraiseql/blob/dev/docs/auth/scim.md",
"patch": { "supported": true },
"bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
"filter": { "supported": true, "maxResults": 200 },
"changePassword":{ "supported": false },
"sort": { "supported": false },
"etag": { "supported": true },
"authenticationSchemes": [{
"type": "oauthbearertoken",
"name": "OAuth Bearer Token",
"description": "Provisioning bearer token, distinct from the admin token",
"specUri": "http://www.rfc-editor.org/info/rfc6750",
"primary": true,
}],
"meta": {
"resourceType": "ServiceProviderConfig",
"location": format!("{base_url}/ServiceProviderConfig"),
},
})
}
#[must_use]
pub fn resource_types(base_url: &str) -> Vec<Value> {
vec![
json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
"id": "User",
"name": "User",
"endpoint": "/Users",
"description": "User Account",
"schema": USER_SCHEMA,
"meta": {
"resourceType": "ResourceType",
"location": format!("{base_url}/ResourceTypes/User"),
},
}),
json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
"id": "Group",
"name": "Group",
"endpoint": "/Groups",
"description": "Group",
"schema": GROUP_SCHEMA,
"meta": {
"resourceType": "ResourceType",
"location": format!("{base_url}/ResourceTypes/Group"),
},
}),
]
}
#[must_use]
pub fn schemas(base_url: &str) -> Vec<Value> {
fn attr(name: &str, ty: &str, multi: bool, required: bool, unique: &str) -> Value {
json!({
"name": name,
"type": ty,
"multiValued": multi,
"required": required,
"caseExact": false,
"mutability": "readWrite",
"returned": "default",
"uniqueness": unique,
})
}
vec![
json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
"id": USER_SCHEMA,
"name": "User",
"description": "User Account",
"attributes": [
attr("userName", "string", false, true, "server"),
json!({
"name": "name",
"type": "complex",
"multiValued": false,
"required": false,
"mutability": "readWrite",
"returned": "default",
"subAttributes": [
attr("givenName", "string", false, false, "none"),
attr("familyName", "string", false, false, "none"),
],
}),
attr("displayName", "string", false, false, "none"),
json!({
"name": "emails",
"type": "complex",
"multiValued": true,
"required": false,
"mutability": "readWrite",
"returned": "default",
"subAttributes": [
attr("value", "string", false, false, "none"),
attr("primary", "boolean", false, false, "none"),
],
}),
attr("active", "boolean", false, false, "none"),
],
"meta": {
"resourceType": "Schema",
"location": format!("{base_url}/Schemas/{USER_SCHEMA}"),
},
}),
json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
"id": GROUP_SCHEMA,
"name": "Group",
"description": "Group",
"attributes": [
attr("displayName", "string", false, true, "none"),
json!({
"name": "members",
"type": "complex",
"multiValued": true,
"required": false,
"mutability": "readWrite",
"returned": "default",
"subAttributes": [attr("value", "string", false, false, "none")],
}),
],
"meta": {
"resourceType": "Schema",
"location": format!("{base_url}/Schemas/{GROUP_SCHEMA}"),
},
}),
]
}