Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Add SCIM provisioning to workspace single sign-on

A workspace's identity provider can now assign and unassign people over SCIM 2.0 at /scim/v2/{workspace} on the API, with a per-workspace bearer token owners make (shown once) at Single sign-on -> SCIM provisioning or with enable_scim / disable_scim (REST and MCP). With SCIM on, only active provisioned people can sign in through single sign-on; someone the provider deactivates or deletes leaves the workspace at once: membership, repository and team roles, SSO link and SSO sessions. Owners are never removed this way, and the audit log says so. A provisioned user is matched to whoever signs in by their id at the provider, userName or address. Users only: Groups answers empty, and pushing one is refused with how to assign members instead. Users support create, read, PUT, PATCH (as Okta and Entra send it, including string booleans and schema-qualified paths), delete, and eq filters on userName, externalId, id and emails.value, with the ServiceProviderConfig and ResourceTypes discovery endpoints. Sudo shows whether SCIM is on and how many are assigned. Refs #56

G1t-Session:
ses_01m54fhewne5tbgqqh4n8f5fvz
syntaqxcommitted Parent97b1964Browse files
23 files+400−207 files not counted0/23 viewed
+7−0
4949 mod why;
5050 mod token_policy;
5151 mod sso;
52+mod scim;
5253 mod onboarding;
5354 mod toolkit;
5455
527528 let token = token.to_owned();
528529 return toolkit::blob(request, env, &services, method, &token).await;
529530 }
531+ // A workspace's identity provider assigning people (SCIM), with the
532+ // workspace's SCIM token, which is not a g1t token (scim.rs).
533+ if !on_mcp && let Some(rest) = path.strip_prefix("/scim/v2/") {
534+ let rest = rest.to_owned();
535+ return scim::handle(request, &services, method, &rest).await;
536+ }
530537 // g1t as an OIDC issuer for workflow jobs (oidc.rs).
531538 if !on_mcp && method == "GET" && path.starts_with("/actions/oidc/") {
532539 return oidc::handle(&request, env, &services, &path).await;
+3−1
8989 ),
9090 (
9191 "Single sign-on",
92− "A workspace's own identity provider over OpenID Connect, OAuth 2.0 or SAML 2.0, on every paid plan: setting it up, requiring it of members, the people linked to it, and unlinking one. People sign in to g1t as themselves and then through the provider to use the workspace; owners are never held out. Owners only, as people.",
92+ "A workspace's own identity provider over OpenID Connect, OAuth 2.0 or SAML 2.0, on every paid plan: setting it up, requiring it of members, the people linked to it, unlinking one, and SCIM, through which the provider assigns and unassigns people at /scim/v2/{workspace}. People sign in to g1t as themselves and then through the provider to use the workspace; owners are never held out. Owners only, as people.",
9393 &[
9494 Op::Sso(SsoOp::GetSso),
9595 Op::Sso(SsoOp::ConfigureSso),
9797 Op::Sso(SsoOp::DeleteSso),
9898 Op::Sso(SsoOp::ListSsoIdentities),
9999 Op::Sso(SsoOp::UnlinkSsoIdentity),
100+ Op::Sso(SsoOp::EnableScim),
101+ Op::Sso(SsoOp::DisableScim),
100102 ],
101103 ),
102104 (
+3−1
831831 }
832832
833833 impl Op {
834− pub const ALL: [Op; 415] = [
834+ pub const ALL: [Op; 417] = [
835835 Op::Whoami,
836836 Op::GetWorkspace,
837837 Op::CreateWorkspace,
11991199 Op::Sso(SsoOp::DeleteSso),
12001200 Op::Sso(SsoOp::ListSsoIdentities),
12011201 Op::Sso(SsoOp::UnlinkSsoIdentity),
1202+ Op::Sso(SsoOp::EnableScim),
1203+ Op::Sso(SsoOp::DisableScim),
12021204 Op::Onboarding(OnboardingOp::GetOnboarding),
12031205 Op::Onboarding(OnboardingOp::UpdateOnboarding),
12041206 Op::Packages(PackagesOp::ListPackages),
+22−2
1521315213 "entity_id": "https://g1t.sh/auth/sso/flagon-io/metadata",
1521415214 "acs_url": "https://g1t.sh/auth/sso/flagon-io/saml",
1521515215 "metadata_url": "https://g1t.sh/auth/sso/flagon-io/metadata",
15216− "sign_in_url": "https://g1t.sh/auth/sso/flagon-io"
15216+ "sign_in_url": "https://g1t.sh/auth/sso/flagon-io",
15217+ "scim_url": "https://api.g1t.sh/scim/v2/flagon-io"
1521715218 }
1521815219 }
1521915220 },
1526615267 "entity_id": "https://g1t.sh/auth/sso/flagon-io/metadata",
1526715268 "acs_url": "https://g1t.sh/auth/sso/flagon-io/saml",
1526815269 "metadata_url": "https://g1t.sh/auth/sso/flagon-io/metadata",
15269− "sign_in_url": "https://g1t.sh/auth/sso/flagon-io"
15270+ "sign_in_url": "https://g1t.sh/auth/sso/flagon-io",
15271+ "scim_url": "https://api.g1t.sh/scim/v2/flagon-io"
1527015272 }
1527115273 }
1527215274 },
1530915311 "response": {
1531015312 "unlinked": true
1531115313 }
15314+ },
15315+ "enable_scim": {
15316+ "params": {
15317+ "workspace": "flagon-io"
15318+ },
15319+ "response": {
15320+ "token": "g1t_scim_4f1c…",
15321+ "hint": "9a2e",
15322+ "scim_url": "https://api.g1t.sh/scim/v2/flagon-io"
15323+ }
15324+ },
15325+ "disable_scim": {
15326+ "params": {
15327+ "workspace": "flagon-io"
15328+ },
15329+ "response": {
15330+ "disabled": true
15331+ }
1531215332 }
1531315333 }
+2−0
8080 route("PUT", "/workspaces/:workspace/sso/required", Op::Sso(SsoOp::SetSsoRequired), &[]),
8181 route("GET", "/workspaces/:workspace/sso/identities", Op::Sso(SsoOp::ListSsoIdentities), &[]),
8282 route("DELETE", "/workspaces/:workspace/sso/identities/:username", Op::Sso(SsoOp::UnlinkSsoIdentity), &[]),
83+ route("POST", "/workspaces/:workspace/sso/scim", Op::Sso(SsoOp::EnableScim), &[]),
84+ route("DELETE", "/workspaces/:workspace/sso/scim", Op::Sso(SsoOp::DisableScim), &[]),
8385 route("POST", "/workspaces/:workspace/invitations", Op::InviteMember, &[]),
8486 route("DELETE", "/workspaces/:workspace/invitations/:id", Op::RevokeWorkspaceInvite, &[]),
8587 // Who has access. GitHub's addresses, but for adding someone, which
+70−0
1+//! SCIM 2.0 for a workspace's identity provider, at
2+//! `/scim/v2/{workspace}/…`: the provider assigns and unassigns people,
3+//! with the workspace's SCIM token as its bearer token, which is not a g1t
4+//! access token. Identity checks the token and answers everything
5+//! (services/identity/src/sso/provisioning.rs); this passes the request on
6+//! and writes the answer as `application/scim+json`.
7+
8+use g1t_contracts::sso::{ScimAnswer, ScimRequestArgs};
9+use serde_json::json;
10+use worker::{Request, Response, Result};
11+
12+use crate::operations::Services;
13+
14+/// The workspace and the rest of a path after `/scim/v2/`.
15+pub fn split(rest: &str) -> Option<(String, String)> {
16+ let (workspace, path) = match rest.split_once('/') {
17+ Some((workspace, path)) => (workspace, format!("/{path}")),
18+ None => (rest, "/".to_owned()),
19+ };
20+ let workspace = workspace.trim().to_lowercase();
21+ let valid = !workspace.is_empty() && workspace.len() <= 39 && workspace.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
22+ valid.then_some((workspace, path))
23+}
24+
25+fn reply(status: u16, body: Option<serde_json::Value>) -> Result<Response> {
26+ let mut response = match body {
27+ Some(body) => Response::from_json(&body)?,
28+ None => Response::empty()?,
29+ }
30+ .with_status(status);
31+ if status != 204 {
32+ response.headers_mut().set("content-type", "application/scim+json")?;
33+ }
34+ response.headers_mut().set("cache-control", "no-store")?;
35+ Ok(response)
36+}
37+
38+pub async fn handle(mut request: Request, services: &Services, method: &str, rest: &str) -> Result<Response> {
39+ let Some((workspace, path)) = split(rest) else {
40+ return reply(404, Some(json!({ "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "404", "detail": "Name the workspace: /scim/v2/{workspace}/Users." })));
41+ };
42+ let query = request.url()?.query().map(str::to_owned);
43+ let body = if matches!(method, "POST" | "PUT" | "PATCH") { Some(request.text().await?) } else { None };
44+ let base = format!("{}/scim/v2/{workspace}", services.addresses.api);
45+ let args = ScimRequestArgs {
46+ workspace,
47+ token: crate::toolkit::bearer(&request),
48+ method: method.to_owned(),
49+ path,
50+ query,
51+ body,
52+ base,
53+ };
54+ let answer: ScimAnswer = g1t_kit::call(&services.identity, "scim_request", &args).await?;
55+ reply(answer.status, answer.body)
56+}
57+
58+#[cfg(test)]
59+mod tests {
60+ use super::*;
61+
62+ #[test]
63+ fn the_workspace_comes_first() {
64+ assert_eq!(split("acme/Users/scu_1"), Some(("acme".into(), "/Users/scu_1".into())));
65+ assert_eq!(split("Acme/Users"), Some(("acme".into(), "/Users".into())));
66+ assert_eq!(split("acme"), Some(("acme".into(), "/".into())));
67+ assert_eq!(split("/Users"), None);
68+ assert_eq!(split("ac me/Users"), None);
69+ }
70+}
+41−7
55 //! owners only, as people, and setting it up needs a paid plan. Signing in
66 //! through it is a browser's trip to the provider, so it is not here.
77
8−use g1t_contracts::sso::{SsoConfig, SsoProtocol, SsoSettings, sso_addresses};
8+use g1t_contracts::sso::{ScimToken, SsoConfig, SsoProtocol, SsoSettings, sso_addresses};
99 use g1t_contracts::{FailureCode, Outcome, Viewer};
1010 use serde_json::{Value, json};
1111 use worker::Result;
2121 DeleteSso,
2222 ListSsoIdentities,
2323 UnlinkSsoIdentity,
24+ EnableScim,
25+ DisableScim,
2426 }
2527
2628 /// The config fields an owner sends, as `SsoConfig` names them.
4143 /// Every one: `Op::ALL` lists each as `Op::Sso(…)`, which a test checks
4244 /// against this.
4345 #[cfg(test)]
44− pub const ALL: [SsoOp; 6] = [
46+ pub const ALL: [SsoOp; 8] = [
4547 SsoOp::GetSso,
4648 SsoOp::ConfigureSso,
4749 SsoOp::SetSsoRequired,
4850 SsoOp::DeleteSso,
4951 SsoOp::ListSsoIdentities,
5052 SsoOp::UnlinkSsoIdentity,
53+ SsoOp::EnableScim,
54+ SsoOp::DisableScim,
5155 ];
5256
5357 pub fn name(self) -> &'static str {
5862 SsoOp::DeleteSso => "delete_sso",
5963 SsoOp::ListSsoIdentities => "list_sso_identities",
6064 SsoOp::UnlinkSsoIdentity => "unlink_sso_identity",
65+ SsoOp::EnableScim => "enable_scim",
66+ SsoOp::DisableScim => "disable_scim",
6167 }
6268 }
6369
6975 SsoOp::DeleteSso => "Remove a workspace's single sign-on",
7076 SsoOp::ListSsoIdentities => "List the people linked through single sign-on",
7177 SsoOp::UnlinkSsoIdentity => "Unlink someone from single sign-on",
78+ SsoOp::EnableScim => "Turn on SCIM, or make a new SCIM token",
79+ SsoOp::DisableScim => "Turn off SCIM",
7280 }
7381 }
7482
7987 SsoOp::SetSsoRequired => "Whether members must sign in through the provider: then a member uses the workspace only from a session that did in the last day, and with tokens and keys once linked; held out otherwise, keeping their place. Owners are never held, and outside collaborators are not asked. Requiring it needs a paid plan and you to have signed in through it yourself (you_linked); stopping never does. Recorded as sso.required or sso.not_required. Owners only, as people.",
8088 SsoOp::DeleteSso => "Remove the workspace's single sign-on: forgets the provider and who everyone is there, and stops requiring it. Members keep their place. Never needs a plan. Recorded as sso.deleted. Owners only, as people.",
8189 SsoOp::ListSsoIdentities => "The people linked to the workspace's provider: username, subject (their id at the provider), email (as the provider gave it, for showing), linked_at and last_used_at. Owners only, as people.",
90+ SsoOp::EnableScim => "Turn on SCIM for the workspace's single sign-on, or replace its token: returns token (shown this once; the old one stops working), hint (its last four characters) and scim_url, the SCIM 2.0 base URL to give the identity provider with the token as a bearer token. With SCIM on, only people the provider has assigned (active users) can sign in through single sign-on, and one it deactivates or deletes leaves the workspace at once: membership, roles, link and sessions. Owners are never removed that way. Users only; groups are not kept. Needs single sign-on set up and a paid plan. Recorded as sso.scim_enabled or sso.scim_token_rotated. Owners only, as people.",
91+ SsoOp::DisableScim => "Turn off SCIM: the token stops working and the provisioned users are forgotten. Nobody is removed from the workspace, and single sign-on lets people in as before. Recorded as sso.scim_disabled. Owners only, as people.",
8292 SsoOp::UnlinkSsoIdentity => "Forget who someone is at the workspace's provider, so they (or another account there) link again on their next sign-in through it; their sessions go through it again. Recorded as sso.identity_unlinked. Owners only, as people.",
8393 }
8494 }
92102 pub fn input(self) -> Value {
93103 let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." });
94104 let (properties, required): (Value, &[&str]) = match self {
95− SsoOp::GetSso | SsoOp::DeleteSso | SsoOp::ListSsoIdentities => (json!({ "workspace": workspace }), &["workspace"]),
105+ SsoOp::GetSso | SsoOp::DeleteSso | SsoOp::ListSsoIdentities | SsoOp::EnableScim | SsoOp::DisableScim => (json!({ "workspace": workspace }), &["workspace"]),
96106 SsoOp::ConfigureSso => (
97107 json!({
98108 "workspace": workspace,
163173 }
164174
165175 /// The settings, with where to point the provider.
166−fn with_addresses(settings: SsoSettings, site: &str) -> Value {
176+fn with_addresses(settings: SsoSettings, site: &str, api: &str) -> Value {
167177 let addresses = sso_addresses(site, &settings.slug);
168178 let mut out = serde_json::to_value(&settings).unwrap_or_default();
169179 out["service_provider"] = serde_json::to_value(addresses).unwrap_or_default();
180+ out["service_provider"]["scim_url"] = json!(scim_url(api, &settings.slug));
170181 out
171182 }
172183
184+/// The SCIM 2.0 base URL for a workspace (scim.rs).
185+fn scim_url(api: &str, slug: &str) -> String {
186+ format!("{}/scim/v2/{}", api.trim_end_matches('/'), slug.to_lowercase())
187+}
188+
173189 pub async fn run(op: SsoOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
174190 let Some(actor) = viewer.clone() else {
175191 return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token."));
180196 let identity = &services.identity;
181197 let surface = services.audit.surface;
182198 let site = services.addresses.site.as_str();
199+ let api = services.addresses.api.as_str();
183200 let settings = |outcome: Outcome<SsoSettings>| match outcome {
184− Outcome::Ok(settings) => Outcome::Ok(with_addresses(settings, site)),
201+ Outcome::Ok(settings) => Outcome::Ok(with_addresses(settings, site, api)),
185202 Outcome::Fail(failure) => Outcome::Fail(failure),
186203 };
187204 Ok(match op {
240257 Outcome::Fail(failure) => Outcome::Fail(failure),
241258 }
242259 }
260+ SsoOp::EnableScim => {
261+ let made: Outcome<ScimToken> =
262+ g1t_kit::call(identity, "enable_scim", &json!({ "actor": actor, "slug": workspace, "surface": surface })).await?;
263+ match made {
264+ Outcome::Ok(made) => Outcome::Ok(json!({ "token": made.token, "hint": made.hint, "scim_url": scim_url(api, &workspace) })),
265+ Outcome::Fail(failure) => Outcome::Fail(failure),
266+ }
267+ }
268+ SsoOp::DisableScim => {
269+ let off: Outcome<bool> =
270+ g1t_kit::call(identity, "disable_scim", &json!({ "actor": actor, "slug": workspace, "surface": surface })).await?;
271+ match off {
272+ Outcome::Ok(off) => Outcome::Ok(json!({ "disabled": off })),
273+ Outcome::Fail(failure) => Outcome::Fail(failure),
274+ }
275+ }
243276 SsoOp::UnlinkSsoIdentity => {
244277 let Some(username) = text(input, "username") else {
245278 return Ok(Outcome::fail(FailureCode::Invalid, "Name the person to unlink."));
285318
286319 #[test]
287320 fn the_answer_says_where_to_point_the_provider() {
288− let settings = SsoSettings { slug: "acme".into(), connection: None, required: false, you_linked: false, identities: vec![] };
289− let out = with_addresses(settings, "https://g1t.sh");
321+ let settings = SsoSettings { slug: "acme".into(), connection: None, required: false, you_linked: false, identities: vec![], scim: None };
322+ let out = with_addresses(settings, "https://g1t.sh", "https://api.g1t.sh");
323+ assert_eq!(out["service_provider"]["scim_url"], "https://api.g1t.sh/scim/v2/acme");
290324 assert_eq!(out["service_provider"]["acs_url"], "https://g1t.sh/auth/sso/acme/saml");
291325 assert_eq!(out["service_provider"]["redirect_uri"], "https://g1t.sh/auth/sso/acme/callback");
292326 assert_eq!(out["connection"], Value::Null);
+3−1
466466 a("delete_sso", Op::Sso(SsoOp::DeleteSso), "Remove its single sign-on"),
467467 a("list_sso_identities", Op::Sso(SsoOp::ListSsoIdentities), "The people linked through single sign-on"),
468468 a("unlink_sso_identity", Op::Sso(SsoOp::UnlinkSsoIdentity), "Unlink someone from single sign-on"),
469+ a("enable_scim", Op::Sso(SsoOp::EnableScim), "Turn on SCIM, or make a new SCIM token"),
470+ a("disable_scim", Op::Sso(SsoOp::DisableScim), "Turn off SCIM"),
469471 ],
470472 },
471473 Tool {
645647 | Op::DeleteWorkspace
646648 | Op::UpdateWorkspace
647649 | Op::Tokens(TokenOp::RevokeMemberToken | TokenOp::SetTokenPolicy)
648− | Op::Sso(SsoOp::ConfigureSso | SsoOp::DeleteSso | SsoOp::UnlinkSsoIdentity)
650+ | Op::Sso(SsoOp::ConfigureSso | SsoOp::DeleteSso | SsoOp::UnlinkSsoIdentity | SsoOp::EnableScim | SsoOp::DisableScim)
649651 | Op::RemoveMember
650652 | Op::TransferOwnership
651653 | Op::LeaveWorkspace
+37−5
166166 Signing in through the provider is a trip in the browser, so it has no
167167 API: send people to `sign_in_url`.
168168
169−## Not yet
169+## SCIM
170+
171+With SCIM, your provider tells g1t who is assigned to the workspace, and
172+who is not any more:
173+
174+- Only people your provider has assigned, and not deactivated, can sign in
175+ through it. Someone else is told to ask whoever runs your provider.
176+- Someone it unassigns, deactivates or deletes leaves the workspace at
177+ once: their membership, their roles on its repositories and teams, their
178+ link, and their sessions through it. Their g1t account is theirs and
179+ stays. **Owners are never removed this way**; the audit log says so, and
180+ you remove one by hand if they should go.
181+- People, not groups. Assign a group to the app in your provider and its
182+ members are provisioned one by one; turn off pushing groups.
183+
184+To turn it on, open **Single sign-on → SCIM provisioning** and choose
185+**Turn on SCIM**. Give your provider:
186+
187+| | |
188+| --- | --- |
189+| SCIM base URL (tenant URL) | `https://api.g1t.sh/scim/v2/<workspace>` |
190+| Authentication | Bearer token: the SCIM token, shown once |
191+| Unique identifier | `userName` |
192+| Supported | Create, update (PUT and PATCH), deactivate and delete users; filtering by `userName`, `externalId`, `id` or `emails.value` |
170193
171−Provisioning and deprovisioning with SCIM is not available yet. Until it
172−is, someone you remove at your provider keeps their g1t session for up to
173−a day and their tokens until an owner removes them from the workspace or
174−unlinks them.
194+Whoever signs in through your provider is matched to the person it
195+assigned by their id there, their userName, or their address. **Make a new
196+token** replaces the old one at once; **Turn off SCIM** forgets who was
197+assigned and removes nobody.
198+
199+Through the API: `POST /workspaces/{workspace}/sso/scim` (`enable_scim`)
200+answers with the token and `scim_url`; `DELETE` turns it off
201+(`disable_scim`). `get_sso` lists who is assigned under `scim`. Recorded as
202+`sso.scim_enabled`, `sso.scim_token_rotated`, `sso.scim_disabled`,
203+`sso.scim_user_provisioned`, `sso.scim_user_activated`,
204+`sso.scim_user_deactivated`, `sso.scim_user_deleted`,
205+`member.removed_by_scim` and `sso.scim_owner_kept`, with `scim` as the
206+actor.
+0−0

Binary or large file; its contents are not shown.

+14−0
9090 </div>
9191 )}
9292 </dl>
93+ <p className="text-sm">
94+ <span className="text-xs text-faint">SCIM </span>
95+ {sso?.scim ? (
96+ <>
97+ <Badge tone="info">On</Badge>{" "}
98+ <span className="text-muted">
99+ {sso.scim.users.filter((user) => user.active).length} active of {sso.scim.users.length} assigned, token ending{" "}
100+ <span className="font-mono">{sso.scim.hint}</span>, last used <When at={sso.scim.last_used_at} />
101+ </span>
102+ </>
103+ ) : (
104+ <Badge>Off</Badge>
105+ )}
106+ </p>
93107 {sso!.identities.length === 0 ? (
94108 <p className="text-sm text-muted">No one has signed in through it yet.</p>
95109 ) : (
+1−1
266266 what: "Single sign-on",
267267 free: "Not available",
268268 plan: "Included, at no extra price",
269− note: "Your identity provider over SAML 2.0, OpenID Connect or OAuth 2.0, and requiring it of members.",
269+ note: "Your identity provider over SAML 2.0, OpenID Connect or OAuth 2.0, requiring it of members, and SCIM provisioning.",
270270 },
271271 {
272272 what: "Secret scanning, push protection, vulnerability alerts, security updates",
+101−2
22 import { Form, data } from "react-router";
33 import { KeyRound, Sparkles } from "lucide-react";
44
5−import { type SsoConfig, type SsoProtocol, type SsoSettings, ssoAddresses } from "@g1t/contracts";
5+import { type SsoConfig, type SsoProtocol, type SsoSettings, scimUrl, ssoAddresses } from "@g1t/contracts";
66
77 import type { Route } from "./+types/sso";
88 import { page } from "../../lib/meta";
2727 import { Unavailable } from "../../components/boundary";
2828 import { isFailed, part } from "../../lib/partial";
2929 import { billing } from "../../lib/services.server";
30+import { useAddresses } from "../../lib/addresses";
3031 import { PROTOCOL_NAMES } from "../../lib/sso";
3132 import { sso } from "../../lib/sso.server";
3233 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
5253 };
5354 }
5455
55−type Answer = { saved?: string; error?: string; intent?: string };
56+type Answer = { saved?: string; error?: string; intent?: string; token?: string };
5657
5758 export async function action({ request, params, context }: Route.ActionArgs): Promise<Answer> {
5859 assertSameOrigin(request);
9798 }
9899 case "delete":
99100 return answer(await sso.remove(user, slug), "Single sign-on is removed.");
101+ case "scim-on": {
102+ const made = await sso.enableScim(user, slug);
103+ return made.ok ? { saved: "Copy the token now: it is not shown again.", intent, token: made.value.token } : { error: made.error.message, intent };
104+ }
105+ case "scim-off":
106+ return answer(await sso.disableScim(user, slug), "SCIM is off.");
100107 default:
101108 return { error: "Unknown change.", intent };
102109 }
144151 {settings.connection && <PeopleSection settings={settings} answer={said("unlink")} />}
145152
146153 {settings.connection && (
154+ <ScimSection settings={settings} slug={slug} disabled={free} answer={said("scim-on") ?? said("scim-off")} />
155+ )}
156+
157+ {settings.connection && (
147158 <DangerZone>
148159 <DangerAction
149160 title="Remove single sign-on"
158169 );
159170 }
160171
172+/** SCIM: the provider assigns and unassigns people. */
173+function ScimSection({ settings, slug, disabled, answer }: { settings: SsoSettings; slug: string; disabled: boolean; answer?: Answer }) {
174+ const scim = settings.scim ?? null;
175+ const url = scimUrl(useAddresses().api, slug);
176+ return (
177+ <section aria-labelledby="sso-scim">
178+ <h2 id="sso-scim" className="font-medium">
179+ SCIM provisioning
180+ </h2>
181+ <p className="mt-1.5 text-sm text-muted">
182+ Let your provider tell g1t who is assigned to {slug}. Then only people it has assigned sign in through it, and
183+ someone it unassigns or deactivates leaves {slug} at once, with their roles and sessions. Owners are never removed
184+ that way. People, not groups: assign a group&apos;s members to g1t.
185+ </p>
186+ <div className="mt-4 space-y-3">
187+ <Field label="SCIM base URL">
188+ <CopyLine text={url} breakAtSlashes />
189+ </Field>
190+ {answer?.token && (
191+ <Field label="SCIM token, shown this once" hint="Give it to your provider as the bearer token (API token or secret token).">
192+ <CopyLine text={answer.token} />
193+ </Field>
194+ )}
195+ {scim && !answer?.token && (
196+ <p className="text-sm text-muted">
197+ Token ending {scim.hint}, made {scim.created_by ? `by ${scim.created_by} ` : ""}
198+ <TimeAgo at={scim.created_at} />
199+ {scim.last_used_at ? (
200+ <>
201+ , last used <TimeAgo at={scim.last_used_at} />
202+ </>
203+ ) : (
204+ ", not used yet"
205+ )}
206+ .
207+ </p>
208+ )}
209+ <div className="flex flex-wrap items-center gap-3">
210+ <Form method="post">
211+ <input type="hidden" name="intent" value="scim-on" />
212+ <SubmitButton variant={scim ? "outline" : "default"} size={scim ? "sm" : "default"} match={{ intent: "scim-on" }} pending="Making a token…" disabled={disabled}>
213+ {scim ? "Make a new token" : "Turn on SCIM"}
214+ </SubmitButton>
215+ </Form>
216+ {scim && (
217+ <Form method="post">
218+ <input type="hidden" name="intent" value="scim-off" />
219+ <SubmitButton variant="ghost" size="sm" match={{ intent: "scim-off" }} pending="Turning off…">
220+ Turn off SCIM
221+ </SubmitButton>
222+ </Form>
223+ )}
224+ <Saved answer={answer} />
225+ </div>
226+ </div>
227+ {scim && (
228+ <div className="mt-5">
229+ {scim.users.length === 0 ? (
230+ <p className="text-sm text-faint">Your provider has not assigned anyone yet.</p>
231+ ) : (
232+ <Table>
233+ <TableHeader>
234+ <TableRow>
235+ <TableHead>Assigned</TableHead>
236+ <TableHead>On g1t</TableHead>
237+ <TableHead>State</TableHead>
238+ </TableRow>
239+ </TableHeader>
240+ <TableBody>
241+ {scim.users.map((user) => (
242+ <TableRow key={user.id}>
243+ <TableCell className="max-w-64 truncate">
244+ <span className="font-medium">{user.display_name ?? user.user_name}</span>
245+ {user.display_name && <span className="block truncate text-xs text-faint">{user.user_name}</span>}
246+ </TableCell>
247+ <TableCell className="text-muted">{user.username ?? "Not signed in yet"}</TableCell>
248+ <TableCell>{user.active ? <Badge tone="success">Active</Badge> : <Badge tone="neutral">Deactivated</Badge>}</TableCell>
249+ </TableRow>
250+ ))}
251+ </TableBody>
252+ </Table>
253+ )}
254+ </div>
255+ )}
256+ </section>
257+ );
258+}
259+
161260 /** Removing it, once the owner says so in a dialog. */
162261 function RemoveDialog({ slug }: { slug: string }) {
163262 return (
+2−0
13151315 ("delete_sso", Scope::WorkspaceAdmin),
13161316 ("list_sso_identities", Scope::AccessRead),
13171317 ("unlink_sso_identity", Scope::AccessAdmin),
1318+ ("enable_scim", Scope::WorkspaceAdmin),
1319+ ("disable_scim", Scope::WorkspaceAdmin),
13181320 // Who has access.
13191321 ("list_collaborators", Scope::AccessRead),
13201322 ("get_collaborator_permission", Scope::AccessRead),
+70−0
2323 //! - `sso_saml` takes `SsoSamlArgs`, returns `Outcome<SsoFinished>`.
2424 //! - `sso_summary` takes `SlugArgs`, returns `SsoSummary`.
2525 //! - `sso_metadata` takes `SsoMetadataArgs`, returns the XML as a string.
26+//! - `enable_scim` takes `SsoSettingsArgs`, returns `Outcome<ScimToken>`.
27+//! - `disable_scim` takes `SsoSettingsArgs`, returns `Outcome<bool>`.
28+//! - `scim_request` takes `ScimRequestArgs`, returns `ScimAnswer`.
29+//!
30+//! **SCIM.** With a SCIM token, the provider tells g1t who is assigned to
31+//! the workspace (`/scim/v2/{workspace}/Users` on the API). Then only an
32+//! active provisioned user may sign in through single sign-on, and one
33+//! deactivated or deleted leaves the workspace at once: their membership,
34+//! roles, link and sessions. Owners are never removed this way.
2635
2736 use serde::{Deserialize, Serialize};
2837
151160 /// before requiring it.
152161 pub you_linked: bool,
153162 pub identities: Vec<SsoIdentity>,
163+ /// SCIM, when it is on: the provider says who is assigned.
164+ #[serde(default)]
165+ pub scim: Option<ScimStatus>,
166+}
167+
168+/// A workspace's SCIM, as its owners see it.
169+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
170+pub struct ScimStatus {
171+ /// The token's last four characters.
172+ pub hint: String,
173+ pub created_at: String,
174+ /// The username of whoever made the token.
175+ pub created_by: Option<String>,
176+ pub last_used_at: Option<String>,
177+ /// Who the provider has told g1t of, at most 500.
178+ pub users: Vec<ProvisionedUser>,
179+}
180+
181+/// Someone the provider assigned to the workspace.
182+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
183+pub struct ProvisionedUser {
184+ pub id: String,
185+ pub user_name: String,
186+ pub display_name: Option<String>,
187+ pub active: bool,
188+ /// The g1t account they signed in as, once they have.
189+ pub username: Option<String>,
190+ pub updated_at: String,
191+}
192+
193+/// `enable_scim`: a new SCIM token for the workspace, replacing any other.
194+/// Shown once.
195+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
196+pub struct ScimToken {
197+ pub token: String,
198+ pub hint: String,
199+}
200+
201+/// `scim_request`: one SCIM request, as the API received it at
202+/// `/scim/v2/{workspace}/…`. Returns `ScimAnswer`.
203+#[derive(Debug, Serialize, Deserialize)]
204+pub struct ScimRequestArgs {
205+ pub workspace: String,
206+ /// The bearer token sent.
207+ pub token: String,
208+ pub method: String,
209+ /// After the workspace, such as `/Users/scu_…`.
210+ pub path: String,
211+ #[serde(default)]
212+ pub query: Option<String>,
213+ #[serde(default)]
214+ pub body: Option<String>,
215+ /// `https://api.g1t.sh/scim/v2/{workspace}`, for resources' locations.
216+ pub base: String,
217+}
218+
219+/// What to answer a SCIM request with.
220+#[derive(Debug, Serialize, Deserialize)]
221+pub struct ScimAnswer {
222+ pub status: u16,
223+ pub body: Option<serde_json::Value>,
154224 }
155225
156226 /// Whether a workspace has single sign-on, for anyone: the sign-in page
+24−0
64376437 body: &[],
64386438 },
64396439 Operation {
6440+ id: "disable_scim",
6441+ method: "DELETE",
6442+ path: "/workspaces/{workspace}/sso/scim",
6443+ tag: "Single sign-on",
6444+ summary: "Turn off SCIM",
6445+ scope: Some("workspace:admin"),
6446+ params: &[
6447+ Param { name: "workspace", location: In::Path, kind: "string", required: true, values: &[] },
6448+ ],
6449+ body: &[],
6450+ },
6451+ Operation {
6452+ id: "enable_scim",
6453+ method: "POST",
6454+ path: "/workspaces/{workspace}/sso/scim",
6455+ tag: "Single sign-on",
6456+ summary: "Turn on SCIM, or make a new SCIM token",
6457+ scope: Some("workspace:admin"),
6458+ params: &[
6459+ Param { name: "workspace", location: In::Path, kind: "string", required: true, values: &[] },
6460+ ],
6461+ body: &[],
6462+ },
6463+ Operation {
64406464 id: "get_sso",
64416465 method: "GET",
64426466 path: "/workspaces/{workspace}/sso",
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.