Skip to main content

alibi_plugins/scim/
managed.rs

1use super::{AuthResult, ScimPlugin, Value};
2use alibi_core::endpoint::{EndpointCall, EndpointInput, EndpointResponse, ServerEndpoint};
3use alibi_core::utils::json::JsValue;
4use alibi_core::{AuthContext, AuthError, AuthSchema};
5use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
6use chrono::{DateTime, SubsecRound, Utc};
7use hmac::{Hmac, KeyInit, Mac};
8use rand::seq::IndexedRandom;
9use serde::Serialize;
10use sha2::Sha256;
11/// Trusted creation arguments; the expiry is a Date rather than JSON text.
12#[derive(Clone, Debug, Serialize)]
13#[serde(rename_all = "camelCase")]
14pub struct ScimManagedConnectionInput {
15    pub creation_request_id: String,
16    pub provisioning_domain_id: String,
17    pub actor_id: String,
18    pub scopes: Vec<String>,
19    pub expires_at: DateTime<Utc>,
20}
21fn body_string(body: &JsValue, name: &str, min: usize) -> AuthResult<String> {
22    let value = body.get(name);
23    let Some(text) = value.and_then(JsValue::as_str) else {
24        return Err(crate::endpoint::validation(format!(
25            "[body.{name}] Invalid input: expected string, received {}",
26            crate::authentication_helpers::json_type(value)
27        )));
28    };
29    let text = alibi_core::utils::javascript::trim(text);
30    let length = text.encode_utf16().count();
31    if length < min {
32        return Err(crate::endpoint::validation(format!(
33            "[body.{name}] Too small: expected string to have >={min} characters"
34        )));
35    }
36    if length > 255 {
37        return Err(crate::endpoint::validation(format!(
38            "[body.{name}] Too big: expected string to have <=255 characters"
39        )));
40    }
41    Ok(text.into())
42}
43pub(super) fn validate(call: &EndpointCall) -> AuthResult<EndpointInput> {
44    let body = call.body().ok_or_else(|| {
45        crate::endpoint::validation("[body] Invalid input: expected object, received undefined")
46    })?;
47    if call.operation_id() == "decommissionSCIMConnection" {
48        let mut output = indexmap::IndexMap::new();
49        _ = output.insert(
50            "connectionId".into(),
51            JsValue::String(body_string(body, "connectionId", 1)?),
52        );
53        if body.get("provisioningDomainId").is_some() {
54            _ = output.insert(
55                "provisioningDomainId".into(),
56                JsValue::String(body_string(body, "provisioningDomainId", 1)?),
57            );
58        }
59        return Ok(EndpointInput {
60            body: Some(JsValue::Object(output)),
61            query: call.query().cloned(),
62        });
63    }
64    if matches!(
65        call.operation_id(),
66        "decommissionSCIMManagedConnection"
67            | "getSCIMManagedConnection"
68            | "revokeSCIMManagedCredential"
69            | "listSCIMManagedConnectionEvents"
70    ) {
71        let id = body.get("connectionId");
72        let Some(id) = id.and_then(JsValue::as_str) else {
73            return Err(crate::endpoint::validation(format!(
74                "[body.connectionId] Invalid input: expected string, received {}",
75                crate::authentication_helpers::json_type(id)
76            )));
77        };
78        if !id.starts_with("ba_scim_connection_") {
79            return Err(crate::endpoint::validation(
80                r#"[body.connectionId] Invalid string: must start with "ba_scim_connection_""#,
81            ));
82        }
83        if id.encode_utf16().count() > 255 {
84            return Err(crate::endpoint::validation(
85                "[body.connectionId] Too big: expected string to have <=255 characters",
86            ));
87        }
88        let mut extra = indexmap::IndexMap::new();
89        if call.operation_id() == "revokeSCIMManagedCredential" {
90            let value = body.get("credentialId");
91            let credential = value.and_then(JsValue::as_str).ok_or_else(|| {
92                crate::endpoint::validation(format!(
93                    "[body.credentialId] Invalid input: expected string, received {}",
94                    crate::authentication_helpers::json_type(value)
95                ))
96            })?;
97            if !credential.starts_with("ba_scim_credential_") {
98                return Err(crate::endpoint::validation(
99                    r#"[body.credentialId] Invalid string: must start with "ba_scim_credential_""#,
100                ));
101            }
102            if credential.encode_utf16().count() > 255 {
103                return Err(crate::endpoint::validation(
104                    "[body.credentialId] Too big: expected string to have <=255 characters",
105                ));
106            }
107            _ = extra.insert("credentialId".into(), JsValue::String(credential.into()));
108            _ = extra.insert(
109                "actorId".into(),
110                JsValue::String(body_string(body, "actorId", 1)?),
111            );
112        }
113        if call.operation_id() == "decommissionSCIMManagedConnection" {
114            _ = extra.insert(
115                "actorId".into(),
116                JsValue::String(body_string(body, "actorId", 1)?),
117            );
118        }
119        extra.extend([
120            ("connectionId".into(), JsValue::String(id.into())),
121            (
122                "provisioningDomainId".into(),
123                JsValue::String(body_string(body, "provisioningDomainId", 1)?),
124            ),
125        ]);
126        return Ok(EndpointInput {
127            body: Some(JsValue::Object(extra)),
128            query: call.query().cloned(),
129        });
130    }
131    if matches!(
132        call.operation_id(),
133        "listSCIMManagedConnections" | "reconcileSCIMProjection"
134    ) {
135        return Ok(EndpointInput {
136            body: Some(JsValue::Object(
137                [(
138                    "provisioningDomainId".into(),
139                    JsValue::String(body_string(body, "provisioningDomainId", 1)?),
140                )]
141                .into_iter()
142                .collect(),
143            )),
144            query: call.query().cloned(),
145        });
146    }
147    let rotating = call.operation_id() == "rotateSCIMManagedCredential";
148    let mut output = indexmap::IndexMap::new();
149    if rotating {
150        let id = body.get("connectionId");
151        let Some(id) = id.and_then(JsValue::as_str) else {
152            return Err(crate::endpoint::validation(format!(
153                "[body.connectionId] Invalid input: expected string, received {}",
154                crate::authentication_helpers::json_type(id)
155            )));
156        };
157        if !id.starts_with("ba_scim_connection_") {
158            return Err(crate::endpoint::validation(
159                r#"[body.connectionId] Invalid string: must start with "ba_scim_connection_""#,
160            ));
161        }
162        if id.encode_utf16().count() > 255 {
163            return Err(crate::endpoint::validation(
164                "[body.connectionId] Too big: expected string to have <=255 characters",
165            ));
166        }
167        _ = output.insert("connectionId".into(), JsValue::String(id.into()));
168    }
169
170    for (name, min) in [
171        ("creationRequestId", 16),
172        ("provisioningDomainId", 1),
173        ("actorId", 1),
174    ] {
175        if rotating && name == "creationRequestId" {
176            continue;
177        }
178        _ = output.insert(name.into(), JsValue::String(body_string(body, name, min)?));
179    }
180    let scopes = body.get("scopes");
181    let Some(values) = scopes.and_then(JsValue::as_array) else {
182        return Err(crate::endpoint::validation(format!(
183            "[body.scopes] Invalid input: expected array, received {}",
184            crate::authentication_helpers::json_type(scopes)
185        )));
186    };
187    if values.is_empty() {
188        return Err(crate::endpoint::validation(
189            "[body.scopes] Too small: expected array to have >=1 items",
190        ));
191    }
192    for (index, value) in values.iter().enumerate() {
193        if !value.as_str().is_some_and(|scope| {
194            matches!(
195                scope,
196                "scim.users.read" | "scim.users.write" | "scim.groups.read" | "scim.groups.write"
197            )
198        }) {
199            return Err(crate::endpoint::validation(format!(
200                r#"[body.scopes.{index}] Invalid option: expected one of "scim.users.read"|"scim.users.write"|"scim.groups.read"|"scim.groups.write""#
201            )));
202        }
203    }
204    _ = output.insert("scopes".into(), JsValue::Array(values.to_vec()));
205    let expiry = body.get("expiresAt");
206    let Some(date) = expiry.and_then(JsValue::as_date) else {
207        return Err(crate::endpoint::validation(format!(
208            "[body.expiresAt] Invalid input: expected date, received {}",
209            crate::authentication_helpers::json_type(expiry)
210        )));
211    };
212    _ = output.insert("expiresAt".into(), JsValue::Date(date.trunc_subsecs(3)));
213    Ok(EndpointInput {
214        body: Some(JsValue::Object(output)),
215        query: call.query().cloned(),
216    })
217}
218fn random_string(length: usize) -> String {
219    let alphabet = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
220    let mut rng = rand::rng();
221    (0..length)
222        .map(|_| char::from(*alphabet.choose(&mut rng).unwrap_or(&b'a')))
223        .collect()
224}
225impl ScimPlugin {
226    /// Request complete-state reconciliation of one provisioning domain.
227    /// # Errors
228    /// Returns an error if arguments cannot be serialized.
229    pub fn reconcile_projection_endpoint(domain: &str) -> AuthResult<ServerEndpoint<Value>> {
230        ServerEndpoint::new("scim", "reconcileSCIMProjection")
231            .with_body(&serde_json::json!({"provisioningDomainId":domain}))
232    }
233
234    /// Retire a managed connection and its active credentials through the trusted API.
235    /// # Errors
236    /// Returns an error if arguments cannot be serialized.
237    pub fn decommission_managed_connection_endpoint(
238        id: &str,
239        domain: &str,
240        actor: &str,
241    ) -> AuthResult<ServerEndpoint<Value>> {
242        ServerEndpoint::new("scim", "decommissionSCIMManagedConnection").with_body(
243            &serde_json::json!({"connectionId":id,"provisioningDomainId":domain,"actorId":actor}),
244        )
245    }
246    pub(super) async fn decommission_managed_connection<S: AuthSchema>(
247        &self,
248        call: &EndpointCall,
249        ctx: &AuthContext<S>,
250    ) -> AuthResult<EndpointResponse> {
251        _ = self
252            .managed_connections
253            .as_ref()
254            .ok_or_else(|| AuthError::Api {
255                status: 400,
256                code: None,
257                message: "SCIM managed connections are not configured".into(),
258            })?;
259        let body = call
260            .body()
261            .ok_or_else(|| AuthError::internal("Validated body missing"))?;
262        let data = alibi_core::store::DecommissionScimManagedConnection {
263            connection_id: body
264                .get("connectionId")
265                .and_then(JsValue::as_str)
266                .unwrap_or_default()
267                .into(),
268            provisioning_domain_id: body_string(body, "provisioningDomainId", 1)?,
269            actor_id: body_string(body, "actorId", 1)?,
270        };
271        let mut started = None;
272        for _ in 0..5 {
273            if let Some(connection) = ctx
274                .database
275                .decommission_scim_managed_connection(data.clone(), false)
276                .await?
277            {
278                started = Some(connection);
279                break;
280            }
281        }
282        let started = started.ok_or_else(|| {
283            alibi_core::store::scim::scim_managed_conflict(
284                "Managed SCIM connection changed repeatedly while decommissioning began",
285            )
286        })?;
287        let core = if started.status == "decommissioned" {
288            JsValue::from(serde_json::json!({"status":"complete","retryAfter":null}))
289        } else {
290            let response = self.decommission_connection(call, ctx).await?;
291            if response.result().is_err() {
292                return Ok(response);
293            }
294            response
295                .result()
296                .as_ref()
297                .map_err(|error| AuthError::internal(error.to_string()))?
298                .clone()
299        };
300        if started.status != "decommissioned"
301            && core.get("status").and_then(JsValue::as_str) == Some("complete")
302        {
303            let mut completed = false;
304            for _ in 0..5 {
305                if ctx
306                    .database
307                    .decommission_scim_managed_connection(data.clone(), true)
308                    .await?
309                    .is_some()
310                {
311                    completed = true;
312                    break;
313                }
314            }
315            if !completed {
316                return Err(alibi_core::store::scim::scim_managed_conflict(
317                    "Managed SCIM connection changed repeatedly while decommissioning completed",
318                ));
319            }
320        }
321        let mut response = self.get_managed_connection(call, ctx).await?;
322        let mut value = response
323            .result()
324            .as_ref()
325            .map_err(|error| AuthError::internal(error.to_string()))?
326            .clone();
327        if let JsValue::Object(values) = &mut value {
328            _ = values.insert("decommission".into(), core);
329        }
330        response.replace(value);
331        Ok(response)
332    }
333
334    /// Permanently bind and retire a connection before its first provisioning request.
335    /// # Errors
336    /// Returns an error if arguments cannot be serialized.
337    pub fn decommission_connection_endpoint(
338        id: &str,
339        domain: Option<&str>,
340    ) -> AuthResult<ServerEndpoint<Value>> {
341        let mut body = serde_json::json!({"connectionId":id});
342        if let Some(domain) = domain
343            && let Value::Object(values) = &mut body
344        {
345            _ = values.insert("provisioningDomainId".into(), Value::String(domain.into()));
346        }
347        ServerEndpoint::new("scim", "decommissionSCIMConnection").with_body(&body)
348    }
349    pub(super) async fn decommission_connection<S: AuthSchema>(
350        &self,
351        call: &EndpointCall,
352        ctx: &AuthContext<S>,
353    ) -> AuthResult<EndpointResponse> {
354        use sha2::Digest;
355        let body = call
356            .body()
357            .ok_or_else(|| AuthError::internal("Validated body missing"))?;
358        let id = body
359            .get("connectionId")
360            .and_then(JsValue::as_str)
361            .ok_or_else(|| AuthError::internal("Validated ID missing"))?;
362        let domain = body.get("provisioningDomainId").and_then(JsValue::as_str);
363        let key = URL_SAFE_NO_PAD.encode(Sha256::digest(serde_json::to_vec(&[
364            "scim-connection",
365            id,
366        ])?));
367        let binding = ctx
368            .database
369            .terminal_scim_connection_binding(id, domain, &key)
370            .await?;
371        let binding = match self.decommission_binding(ctx, binding).await {
372            Ok(binding) => binding,
373            Err(AuthError::CallbackFailure(cause)) => {
374                return Ok(Self::scim_callback_response(cause.as_ref()));
375            }
376            Err(error) => return Err(error),
377        };
378        let leased = binding.decommission_status == "reconciling";
379        EndpointResponse::json(
380            &serde_json::json!({"connectionId":binding.connection_id,"provisioningDomainId":binding.provisioning_domain_id,"status":binding.decommission_status,"decommissionedAt":binding.decommissioned_at,"completedAt":binding.decommission_completed_at,"retryAfter":if leased {binding.decommission_lease_expires_at}else{None},"reconciledUsers":binding.decommission_reconciled_user_count,"batches":binding.decommission_batch_count}),
381        )
382    }
383
384    /// List the newest 100 connection events in ascending sequence order.
385    /// # Errors
386    /// Returns an error if the arguments cannot be serialized.
387    pub fn list_managed_connection_events_endpoint(
388        id: &str,
389        domain: &str,
390    ) -> AuthResult<ServerEndpoint<Value>> {
391        ServerEndpoint::new("scim", "listSCIMManagedConnectionEvents")
392            .with_body(&serde_json::json!({"connectionId":id,"provisioningDomainId":domain}))
393    }
394    pub(super) async fn list_managed_connection_events<S: AuthSchema>(
395        &self,
396        call: &EndpointCall,
397        ctx: &AuthContext<S>,
398    ) -> AuthResult<EndpointResponse> {
399        _ = self
400            .managed_connections
401            .as_ref()
402            .ok_or_else(|| AuthError::Api {
403                status: 400,
404                code: None,
405                message: "SCIM managed connections are not configured".into(),
406            })?;
407        let body = call
408            .body()
409            .ok_or_else(|| AuthError::internal("Validated body missing"))?;
410        let id = body
411            .get("connectionId")
412            .and_then(JsValue::as_str)
413            .ok_or_else(|| AuthError::internal("Validated ID missing"))?;
414        let events = ctx
415            .database
416            .list_scim_managed_connection_events(id, &body_string(body, "provisioningDomainId", 1)?)
417            .await?
418            .ok_or_else(|| AuthError::Api {
419                status: 404,
420                code: None,
421                message: "Managed SCIM connection not found".into(),
422            })?;
423        EndpointResponse::json(&serde_json::json!({"events":events}))
424    }
425
426    /// Revoke one domain-owned credential through the trusted server API.
427    /// # Errors
428    /// Returns an error if the arguments cannot be serialized.
429    pub fn revoke_managed_credential_endpoint(
430        id: &str,
431        domain: &str,
432        credential: &str,
433        actor: &str,
434    ) -> AuthResult<ServerEndpoint<Value>> {
435        ServerEndpoint::new("scim","revokeSCIMManagedCredential").with_body(&serde_json::json!({"connectionId":id,"provisioningDomainId":domain,"credentialId":credential,"actorId":actor}))
436    }
437    pub(super) async fn revoke_managed_credential<S: AuthSchema>(
438        &self,
439        call: &EndpointCall,
440        ctx: &AuthContext<S>,
441    ) -> AuthResult<EndpointResponse> {
442        _ = self
443            .managed_connections
444            .as_ref()
445            .ok_or_else(|| AuthError::Api {
446                status: 400,
447                code: None,
448                message: "SCIM managed connections are not configured".into(),
449            })?;
450        let body = call
451            .body()
452            .ok_or_else(|| AuthError::internal("Validated body missing"))?;
453        let data = alibi_core::store::RevokeScimManagedCredential {
454            connection_id: body
455                .get("connectionId")
456                .and_then(JsValue::as_str)
457                .unwrap_or_default()
458                .into(),
459            credential_id: body
460                .get("credentialId")
461                .and_then(JsValue::as_str)
462                .unwrap_or_default()
463                .into(),
464            provisioning_domain_id: body_string(body, "provisioningDomainId", 1)?,
465            actor_id: body_string(body, "actorId", 1)?,
466        };
467        for _ in 0..5 {
468            if ctx
469                .database
470                .revoke_scim_managed_credential(data.clone())
471                .await?
472                .is_some()
473            {
474                return self.get_managed_connection(call, ctx).await;
475            }
476        }
477        Err(alibi_core::store::scim::scim_managed_conflict(
478            "Managed SCIM connection changed repeatedly during credential revocation",
479        ))
480    }
481
482    /// Trusted rotation arguments with an actual Date expiry.
483    /// # Errors
484    /// Returns an error if the arguments cannot be serialized.
485    pub fn rotate_managed_credential_endpoint(
486        input: &ScimManagedCredentialRotationInput,
487    ) -> AuthResult<ServerEndpoint<Value>> {
488        let mut body = JsValue::from(serde_json::to_value(input)?);
489        if let JsValue::Object(values) = &mut body {
490            _ = values.insert("expiresAt".into(), JsValue::Date(input.expires_at));
491        }
492        Ok(ServerEndpoint::new("scim", "rotateSCIMManagedCredential").with_body_value(body))
493    }
494    pub(super) async fn rotate_managed_credential<S: AuthSchema>(
495        &self,
496        call: &EndpointCall,
497        ctx: &AuthContext<S>,
498    ) -> AuthResult<EndpointResponse> {
499        call.queue_response_header("cache-control", "no-store");
500        call.queue_response_header("pragma", "no-cache");
501        let options = self
502            .managed_connections
503            .as_ref()
504            .ok_or_else(|| AuthError::Api {
505                status: 400,
506                code: None,
507                message: "SCIM managed connections are not configured".into(),
508            })?;
509        let body = call
510            .body()
511            .ok_or_else(|| AuthError::internal("Validated body missing"))?;
512        let expires = *body
513            .get("expiresAt")
514            .and_then(JsValue::as_date)
515            .ok_or_else(|| AuthError::internal("Validated expiry missing"))?;
516        if expires <= Utc::now().trunc_subsecs(3) {
517            return Err(AuthError::Api {
518                status: 400,
519                code: None,
520                message: "Managed SCIM credential expiry must be in the future".into(),
521            });
522        }
523        let scopes = body
524            .get("scopes")
525            .and_then(JsValue::as_array)
526            .ok_or_else(|| AuthError::internal("Validated scopes missing"))?
527            .iter()
528            .filter_map(JsValue::as_str)
529            .map(str::to_owned)
530            .collect::<Vec<_>>();
531        if scopes
532            .iter()
533            .collect::<std::collections::HashSet<_>>()
534            .len()
535            != scopes.len()
536        {
537            return Err(AuthError::Api {
538                status: 400,
539                code: None,
540                message: "Managed SCIM credential scopes must be unique".into(),
541            });
542        }
543        let credential_id = format!("ba_scim_credential_{}", random_string(32));
544        let token = format!("{credential_id}.{}", random_string(48));
545        let mut mac = Hmac::<Sha256>::new_from_slice(options.credential_hash_secret.as_bytes())
546            .map_err(|error| AuthError::internal(error.to_string()))?;
547        mac.update(token.as_bytes());
548        let digest = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
549        let data = alibi_core::store::RotateScimManagedCredential {
550            connection_id: body
551                .get("connectionId")
552                .and_then(JsValue::as_str)
553                .ok_or_else(|| AuthError::internal("Validated ID missing"))?
554                .into(),
555            provisioning_domain_id: body_string(body, "provisioningDomainId", 1)?,
556            actor_id: body_string(body, "actorId", 1)?,
557            credential_id,
558            token_digest: digest,
559            serialized_scopes: serde_json::to_string(&scopes)?,
560            expires_at: expires,
561            created_at: Utc::now().trunc_subsecs(3),
562            max_active_credentials: options.max_active_credentials,
563        };
564        for _ in 0..5 {
565            if let Some(created) = ctx
566                .database
567                .rotate_scim_managed_credential(data.clone())
568                .await?
569            {
570                let mut connection = serde_json::to_value(created.connection)?;
571                if let Value::Object(values) = &mut connection {
572                    _ = values.remove("id");
573                    _ = values.remove("revision");
574                }
575                let credential = created.credential;
576                return EndpointResponse::json(
577                    &serde_json::json!({"connection":connection,"credential":{"credentialId":credential.credential_id,"status":credential.status,"scopes":scopes,"expiresAt":credential.expires_at,"createdAt":credential.created_at,"createdBy":credential.created_by,"lastUsedAt":credential.last_used_at,"revokedAt":credential.revoked_at,"revokedBy":credential.revoked_by},"token":token}),
578                );
579            }
580        }
581        Err(alibi_core::store::scim::scim_managed_conflict(
582            "Managed SCIM connection changed repeatedly during credential rotation",
583        ))
584    }
585
586    /// Read domain-owned connection metadata and its current credential policy.
587    /// # Errors
588    /// Returns an error if the arguments cannot be serialized.
589    pub fn get_managed_connection_endpoint(
590        id: &str,
591        domain: &str,
592    ) -> AuthResult<ServerEndpoint<Value>> {
593        ServerEndpoint::new("scim", "getSCIMManagedConnection")
594            .with_body(&serde_json::json!({"connectionId":id,"provisioningDomainId":domain}))
595    }
596    pub(super) async fn get_managed_connection<S: AuthSchema>(
597        &self,
598        call: &EndpointCall,
599        ctx: &AuthContext<S>,
600    ) -> AuthResult<EndpointResponse> {
601        _ = self
602            .managed_connections
603            .as_ref()
604            .ok_or_else(|| AuthError::Api {
605                status: 400,
606                code: None,
607                message: "SCIM managed connections are not configured".into(),
608            })?;
609        let body = call
610            .body()
611            .ok_or_else(|| AuthError::internal("Validated body missing"))?;
612        let id = body
613            .get("connectionId")
614            .and_then(JsValue::as_str)
615            .ok_or_else(|| AuthError::internal("Validated ID missing"))?;
616        let state = ctx
617            .database
618            .get_scim_managed_connection(id, &body_string(body, "provisioningDomainId", 1)?)
619            .await?
620            .ok_or_else(|| AuthError::Api {
621                status: 404,
622                code: None,
623                message: "Managed SCIM connection not found".into(),
624            })?;
625        let mut connection = serde_json::to_value(state.connection)?;
626        if let Value::Object(values) = &mut connection {
627            _ = values.remove("id");
628            _ = values.remove("revision");
629        }
630        let mut credentials = Vec::new();
631        for credential in state.credentials {
632            let scopes: Vec<String> = serde_json::from_str(&credential.serialized_scopes)
633                .map_err(|_| invalid_scope_policy())?;
634            if scopes.is_empty()
635                || scopes.iter().any(|scope| {
636                    !matches!(
637                        scope.as_str(),
638                        "scim.users.read"
639                            | "scim.users.write"
640                            | "scim.groups.read"
641                            | "scim.groups.write"
642                    )
643                })
644                || scopes
645                    .iter()
646                    .collect::<std::collections::HashSet<_>>()
647                    .len()
648                    != scopes.len()
649            {
650                return Err(invalid_scope_policy());
651            }
652            let status = if credential.status == "active"
653                && credential.expires_at <= Utc::now().trunc_subsecs(3)
654            {
655                "expired"
656            } else {
657                &credential.status
658            };
659            credentials.push(serde_json::json!({"credentialId":credential.credential_id,"status":status,"scopes":scopes,"expiresAt":credential.expires_at,"createdAt":credential.created_at,"createdBy":credential.created_by,"lastUsedAt":credential.last_used_at,"revokedAt":credential.revoked_at,"revokedBy":credential.revoked_by}));
660        }
661        EndpointResponse::json(
662            &serde_json::json!({"connection":connection,"credentials":credentials}),
663        )
664    }
665
666    /// List durable connection metadata belonging to one provisioning domain.
667    /// # Errors
668    /// Returns an error if the input cannot be serialized.
669    pub fn list_managed_connections_endpoint(domain: &str) -> AuthResult<ServerEndpoint<Value>> {
670        ServerEndpoint::new("scim", "listSCIMManagedConnections")
671            .with_body(&serde_json::json!({"provisioningDomainId":domain}))
672    }
673    pub(super) async fn list_managed_connections<S: AuthSchema>(
674        &self,
675        call: &EndpointCall,
676        ctx: &AuthContext<S>,
677    ) -> AuthResult<EndpointResponse> {
678        _ = self
679            .managed_connections
680            .as_ref()
681            .ok_or_else(|| AuthError::Api {
682                status: 400,
683                code: None,
684                message: "SCIM managed connections are not configured".into(),
685            })?;
686        let body = call
687            .body()
688            .ok_or_else(|| AuthError::internal("Validated body missing"))?;
689        let rows = ctx
690            .database
691            .list_scim_managed_connections(&body_string(body, "provisioningDomainId", 1)?)
692            .await?;
693        let connections = rows
694            .into_iter()
695            .map(|row| {
696                let mut row = serde_json::to_value(row)?;
697                if let Value::Object(values) = &mut row {
698                    _ = values.remove("id");
699                    _ = values.remove("revision");
700                }
701                Ok(row)
702            })
703            .collect::<AuthResult<Vec<_>>>()?;
704        EndpointResponse::json(&serde_json::json!({"connections":connections}))
705    }
706
707    /// Create a managed connection and one independently hashed bearer credential.
708    /// # Errors
709    /// Returns an error if the arguments cannot be represented as logical input.
710    pub fn create_managed_connection_endpoint(
711        input: &ScimManagedConnectionInput,
712    ) -> AuthResult<ServerEndpoint<Value>> {
713        let mut body = JsValue::from(serde_json::to_value(input)?);
714        if let JsValue::Object(values) = &mut body {
715            _ = values.insert("expiresAt".into(), JsValue::Date(input.expires_at));
716        }
717        Ok(ServerEndpoint::new("scim", "createSCIMManagedConnection").with_body_value(body))
718    }
719    pub(super) async fn create_managed_connection<S: AuthSchema>(
720        &self,
721        call: &EndpointCall,
722        ctx: &AuthContext<S>,
723    ) -> AuthResult<EndpointResponse> {
724        call.queue_response_header("cache-control", "no-store");
725        call.queue_response_header("pragma", "no-cache");
726        let options = self
727            .managed_connections
728            .as_ref()
729            .ok_or_else(|| AuthError::Api {
730                status: 400,
731                code: None,
732                message: "SCIM managed connections are not configured".into(),
733            })?;
734        let body = call
735            .body()
736            .ok_or_else(|| AuthError::internal("Validated body missing"))?;
737        let expires = *body
738            .get("expiresAt")
739            .and_then(JsValue::as_date)
740            .ok_or_else(|| AuthError::internal("Validated expiry missing"))?;
741        if expires <= Utc::now().trunc_subsecs(3) {
742            return Err(AuthError::Api {
743                status: 400,
744                code: None,
745                message: "Managed SCIM credential expiry must be in the future".into(),
746            });
747        }
748        let scopes = body
749            .get("scopes")
750            .and_then(JsValue::as_array)
751            .ok_or_else(|| AuthError::internal("Validated scopes missing"))?
752            .iter()
753            .filter_map(JsValue::as_str)
754            .map(str::to_owned)
755            .collect::<Vec<_>>();
756        if scopes
757            .iter()
758            .collect::<std::collections::HashSet<_>>()
759            .len()
760            != scopes.len()
761        {
762            return Err(AuthError::Api {
763                status: 400,
764                code: None,
765                message: "Managed SCIM credential scopes must be unique".into(),
766            });
767        }
768        let connection_id = format!("ba_scim_connection_{}", random_string(32));
769        let credential_id = format!("ba_scim_credential_{}", random_string(32));
770        let token = format!("{credential_id}.{}", random_string(48));
771        let mut mac = Hmac::<Sha256>::new_from_slice(options.credential_hash_secret.as_bytes())
772            .map_err(|error| AuthError::internal(error.to_string()))?;
773        mac.update(token.as_bytes());
774        let digest = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
775        let created = ctx
776            .database
777            .create_scim_managed_connection(alibi_core::store::CreateScimManagedConnection {
778                creation_request_id: body_string(body, "creationRequestId", 16)?,
779                connection_id,
780                provisioning_domain_id: body_string(body, "provisioningDomainId", 1)?,
781                actor_id: body_string(body, "actorId", 1)?,
782                credential_id,
783                token_digest: digest,
784                serialized_scopes: serde_json::to_string(&scopes)?,
785                expires_at: expires,
786                created_at: Utc::now().trunc_subsecs(3),
787            })
788            .await?;
789        let mut connection = serde_json::to_value(created.connection)?;
790        if let Value::Object(fields) = &mut connection {
791            _ = fields.remove("id");
792            _ = fields.remove("revision");
793        }
794        let credential = created.credential;
795        EndpointResponse::json(
796            &serde_json::json!({"connection":connection,"credential":{"credentialId":credential.credential_id,"status":credential.status,"scopes":scopes,"expiresAt":credential.expires_at,"createdAt":credential.created_at,"createdBy":credential.created_by,"lastUsedAt":credential.last_used_at,"revokedAt":credential.revoked_at,"revokedBy":credential.revoked_by},"token":token}),
797        )
798    }
799}
800
801fn invalid_scope_policy() -> AuthError {
802    AuthError::Api {
803        status: 500,
804        code: None,
805        message: "Managed SCIM credential scope policy is invalid".into(),
806    }
807}
808
809#[derive(Clone, Debug, Serialize)]
810#[serde(rename_all = "camelCase")]
811pub struct ScimManagedCredentialRotationInput {
812    pub connection_id: String,
813    pub provisioning_domain_id: String,
814    pub actor_id: String,
815    pub scopes: Vec<String>,
816    pub expires_at: DateTime<Utc>,
817}