Skip to main content

netget/server/scim/
schema.rs

1//! The SCIM schemas NetGet serves: RFC 7643 ยง8.7's own representations of User, Group, the
2//! Enterprise User extension, ServiceProviderConfig, ResourceType and Schema, embedded verbatim
3//! (`rfc7643_schemas.json`), and the attribute characteristics the query engine needs.
4use serde_json::{json, Value};
5use std::sync::LazyLock;
6
7pub const USER: &str = "urn:ietf:params:scim:schemas:core:2.0:User";
8pub const GROUP: &str = "urn:ietf:params:scim:schemas:core:2.0:Group";
9pub const ENTERPRISE: &str = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
10pub const LIST_RESPONSE: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
11pub const SEARCH_REQUEST: &str = "urn:ietf:params:scim:api:messages:2.0:SearchRequest";
12pub const PATCH_OP: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
13pub const ERROR: &str = "urn:ietf:params:scim:api:messages:2.0:Error";
14
15pub static SCHEMAS: LazyLock<Vec<Value>> = LazyLock::new(|| {
16    serde_json::from_str(include_str!("rfc7643_schemas.json")).expect("embedded RFC 7643 schemas")
17});
18
19/// A resource type NetGet serves: name, endpoint, core schema, extension schemas.
20pub struct ResourceType {
21    pub name: &'static str,
22    pub endpoint: &'static str,
23    pub schema: &'static str,
24    pub extensions: &'static [&'static str],
25}
26
27pub const RESOURCE_TYPES: &[ResourceType] = &[
28    ResourceType {
29        name: "User",
30        endpoint: "/Users",
31        schema: USER,
32        extensions: &[ENTERPRISE],
33    },
34    ResourceType {
35        name: "Group",
36        endpoint: "/Groups",
37        schema: GROUP,
38        extensions: &[],
39    },
40];
41
42pub fn by_endpoint(segment: &str) -> Option<&'static ResourceType> {
43    RESOURCE_TYPES.iter().find(|r| &r.endpoint[1..] == segment)
44}
45
46pub fn by_name(name: &str) -> Option<&'static ResourceType> {
47    RESOURCE_TYPES
48        .iter()
49        .find(|r| r.name.eq_ignore_ascii_case(name))
50}
51
52pub fn schema(urn: &str) -> Option<&'static Value> {
53    SCHEMAS.iter().find(|s| {
54        s["id"]
55            .as_str()
56            .is_some_and(|id| id.eq_ignore_ascii_case(urn))
57    })
58}
59
60/// Case-insensitive object member lookup (SCIM attribute names are case-insensitive).
61pub fn member<'a>(obj: &'a Value, name: &str) -> Option<&'a Value> {
62    obj.as_object()?
63        .iter()
64        .find(|(k, _)| k.eq_ignore_ascii_case(name))
65        .map(|(_, v)| v)
66}
67
68pub fn member_key(obj: &Value, name: &str) -> Option<String> {
69    obj.as_object()?
70        .keys()
71        .find(|k| k.eq_ignore_ascii_case(name))
72        .cloned()
73}
74
75fn find_attr<'a>(attrs: &'a Value, name: &str) -> Option<&'a Value> {
76    attrs.as_array()?.iter().find(|a| {
77        a["name"]
78            .as_str()
79            .is_some_and(|n| n.eq_ignore_ascii_case(name))
80    })
81}
82
83static COMMON: LazyLock<Value> = LazyLock::new(|| {
84    json!([
85        {"name": "id", "type": "string", "caseExact": true, "returned": "always", "mutability": "readOnly", "multiValued": false},
86        {"name": "externalId", "type": "string", "caseExact": true, "returned": "default", "mutability": "readWrite", "multiValued": false},
87        {"name": "schemas", "type": "reference", "caseExact": true, "returned": "always", "multiValued": true},
88        {"name": "meta", "type": "complex", "returned": "default", "mutability": "readOnly", "multiValued": false, "subAttributes": [
89            {"name": "resourceType", "type": "string", "caseExact": true},
90            {"name": "created", "type": "dateTime"},
91            {"name": "lastModified", "type": "dateTime"},
92            {"name": "location", "type": "reference", "caseExact": true},
93            {"name": "version", "type": "string", "caseExact": true}
94        ]}
95    ])
96});
97
98/// The definition of `attr` (and `sub`), looked up in the extension `urn` when given, else in
99/// the common attributes and the core schema.
100pub fn attribute(
101    rt: &ResourceType,
102    urn: Option<&str>,
103    attr: &str,
104    sub: Option<&str>,
105) -> Option<&'static Value> {
106    let base = match urn {
107        Some(u) if !u.eq_ignore_ascii_case(rt.schema) => {
108            if !rt.extensions.iter().any(|e| e.eq_ignore_ascii_case(u)) {
109                return None;
110            }
111            find_attr(&schema(u)?["attributes"], attr)?
112        }
113        _ => find_attr(&COMMON, attr)
114            .or_else(|| find_attr(&schema(rt.schema)?["attributes"], attr))?,
115    };
116    match sub {
117        None => Some(base),
118        Some(s) => find_attr(&base["subAttributes"], s),
119    }
120}
121
122pub fn case_exact(def: Option<&Value>) -> bool {
123    def.and_then(|d| d["caseExact"].as_bool()).unwrap_or(false)
124}
125
126pub fn returned(def: Option<&Value>) -> &'static str {
127    match def.and_then(|d| d["returned"].as_str()) {
128        Some("always") => "always",
129        Some("never") => "never",
130        Some("request") => "request",
131        _ => "default",
132    }
133}
134
135/// Every top-level attribute name with `returned` = `wanted`, core and extension.
136pub fn names_returned(rt: &ResourceType, wanted: &str) -> Vec<(Option<&'static str>, String)> {
137    let mut out = Vec::new();
138    for a in COMMON.as_array().into_iter().flatten() {
139        if a["returned"] == wanted {
140            out.push((None, a["name"].as_str().unwrap_or_default().to_owned()));
141        }
142    }
143    let mut add = |urn: Option<&'static str>, s: &str| {
144        for a in schema(s)
145            .and_then(|s| s["attributes"].as_array())
146            .into_iter()
147            .flatten()
148        {
149            if a["returned"] == wanted {
150                out.push((urn, a["name"].as_str().unwrap_or_default().to_owned()));
151            }
152        }
153    };
154    add(None, rt.schema);
155    for e in rt.extensions {
156        add(Some(e), e);
157    }
158    out
159}
160
161/// A served schema with its `meta.location` pointing at this service.
162pub fn published(s: &Value, base: &str) -> Value {
163    let mut s = s.clone();
164    let id = s["id"].as_str().unwrap_or_default().to_owned();
165    s["schemas"] = json!(["urn:ietf:params:scim:schemas:core:2.0:Schema"]);
166    s["meta"] = json!({"resourceType": "Schema", "location": format!("{base}/Schemas/{id}")});
167    s
168}
169
170pub fn resource_type_resource(rt: &ResourceType, base: &str) -> Value {
171    json!({
172        "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
173        "id": rt.name,
174        "name": rt.name,
175        "endpoint": rt.endpoint,
176        "description": format!("{} accounts", rt.name),
177        "schema": rt.schema,
178        "schemaExtensions": rt.extensions.iter().map(|e| json!({"schema": e, "required": false})).collect::<Vec<_>>(),
179        "meta": {"resourceType": "ResourceType", "location": format!("{base}/ResourceTypes/{}", rt.name)}
180    })
181}