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
| 49 | 49 | mod why; | |
| 50 | 50 | mod token_policy; | |
| 51 | 51 | mod sso; | |
| 52 | + | mod scim; | |
| 52 | 53 | mod onboarding; | |
| 53 | 54 | mod toolkit; | |
| 54 | 55 | ||
| ⋯ | |||
| 527 | 528 | let token = token.to_owned(); | |
| 528 | 529 | return toolkit::blob(request, env, &services, method, &token).await; | |
| 529 | 530 | } | |
| 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 | + | } | |
| 530 | 537 | // g1t as an OIDC issuer for workflow jobs (oidc.rs). | |
| 531 | 538 | if !on_mcp && method == "GET" && path.starts_with("/actions/oidc/") { | |
| 532 | 539 | return oidc::handle(&request, env, &services, &path).await; | |
| 89 | 89 | ), | |
| 90 | 90 | ( | |
| 91 | 91 | "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.", | |
| 93 | 93 | &[ | |
| 94 | 94 | Op::Sso(SsoOp::GetSso), | |
| 95 | 95 | Op::Sso(SsoOp::ConfigureSso), | |
| ⋯ | |||
| 97 | 97 | Op::Sso(SsoOp::DeleteSso), | |
| 98 | 98 | Op::Sso(SsoOp::ListSsoIdentities), | |
| 99 | 99 | Op::Sso(SsoOp::UnlinkSsoIdentity), | |
| 100 | + | Op::Sso(SsoOp::EnableScim), | |
| 101 | + | Op::Sso(SsoOp::DisableScim), | |
| 100 | 102 | ], | |
| 101 | 103 | ), | |
| 102 | 104 | ( | |
| 831 | 831 | } | |
| 832 | 832 | ||
| 833 | 833 | impl Op { | |
| 834 | − | pub const ALL: [Op; 415] = [ | |
| 834 | + | pub const ALL: [Op; 417] = [ | |
| 835 | 835 | Op::Whoami, | |
| 836 | 836 | Op::GetWorkspace, | |
| 837 | 837 | Op::CreateWorkspace, | |
| ⋯ | |||
| 1199 | 1199 | Op::Sso(SsoOp::DeleteSso), | |
| 1200 | 1200 | Op::Sso(SsoOp::ListSsoIdentities), | |
| 1201 | 1201 | Op::Sso(SsoOp::UnlinkSsoIdentity), | |
| 1202 | + | Op::Sso(SsoOp::EnableScim), | |
| 1203 | + | Op::Sso(SsoOp::DisableScim), | |
| 1202 | 1204 | Op::Onboarding(OnboardingOp::GetOnboarding), | |
| 1203 | 1205 | Op::Onboarding(OnboardingOp::UpdateOnboarding), | |
| 1204 | 1206 | Op::Packages(PackagesOp::ListPackages), | |
| 15213 | 15213 | "entity_id": "https://g1t.sh/auth/sso/flagon-io/metadata", | |
| 15214 | 15214 | "acs_url": "https://g1t.sh/auth/sso/flagon-io/saml", | |
| 15215 | 15215 | "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" | |
| 15217 | 15218 | } | |
| 15218 | 15219 | } | |
| 15219 | 15220 | }, | |
| ⋯ | |||
| 15266 | 15267 | "entity_id": "https://g1t.sh/auth/sso/flagon-io/metadata", | |
| 15267 | 15268 | "acs_url": "https://g1t.sh/auth/sso/flagon-io/saml", | |
| 15268 | 15269 | "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" | |
| 15270 | 15272 | } | |
| 15271 | 15273 | } | |
| 15272 | 15274 | }, | |
| ⋯ | |||
| 15309 | 15311 | "response": { | |
| 15310 | 15312 | "unlinked": true | |
| 15311 | 15313 | } | |
| 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 | + | } | |
| 15312 | 15332 | } | |
| 15313 | 15333 | } | |
| 80 | 80 | route("PUT", "/workspaces/:workspace/sso/required", Op::Sso(SsoOp::SetSsoRequired), &[]), | |
| 81 | 81 | route("GET", "/workspaces/:workspace/sso/identities", Op::Sso(SsoOp::ListSsoIdentities), &[]), | |
| 82 | 82 | 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), &[]), | |
| 83 | 85 | route("POST", "/workspaces/:workspace/invitations", Op::InviteMember, &[]), | |
| 84 | 86 | route("DELETE", "/workspaces/:workspace/invitations/:id", Op::RevokeWorkspaceInvite, &[]), | |
| 85 | 87 | // Who has access. GitHub's addresses, but for adding someone, which |
| 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 | + | } |
| 5 | 5 | //! owners only, as people, and setting it up needs a paid plan. Signing in | |
| 6 | 6 | //! through it is a browser's trip to the provider, so it is not here. | |
| 7 | 7 | ||
| 8 | − | use g1t_contracts::sso::{SsoConfig, SsoProtocol, SsoSettings, sso_addresses}; | |
| 8 | + | use g1t_contracts::sso::{ScimToken, SsoConfig, SsoProtocol, SsoSettings, sso_addresses}; | |
| 9 | 9 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 10 | 10 | use serde_json::{Value, json}; | |
| 11 | 11 | use worker::Result; | |
| ⋯ | |||
| 21 | 21 | DeleteSso, | |
| 22 | 22 | ListSsoIdentities, | |
| 23 | 23 | UnlinkSsoIdentity, | |
| 24 | + | EnableScim, | |
| 25 | + | DisableScim, | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 26 | 28 | /// The config fields an owner sends, as `SsoConfig` names them. | |
| ⋯ | |||
| 41 | 43 | /// Every one: `Op::ALL` lists each as `Op::Sso(…)`, which a test checks | |
| 42 | 44 | /// against this. | |
| 43 | 45 | #[cfg(test)] | |
| 44 | − | pub const ALL: [SsoOp; 6] = [ | |
| 46 | + | pub const ALL: [SsoOp; 8] = [ | |
| 45 | 47 | SsoOp::GetSso, | |
| 46 | 48 | SsoOp::ConfigureSso, | |
| 47 | 49 | SsoOp::SetSsoRequired, | |
| 48 | 50 | SsoOp::DeleteSso, | |
| 49 | 51 | SsoOp::ListSsoIdentities, | |
| 50 | 52 | SsoOp::UnlinkSsoIdentity, | |
| 53 | + | SsoOp::EnableScim, | |
| 54 | + | SsoOp::DisableScim, | |
| 51 | 55 | ]; | |
| 52 | 56 | ||
| 53 | 57 | pub fn name(self) -> &'static str { | |
| ⋯ | |||
| 58 | 62 | SsoOp::DeleteSso => "delete_sso", | |
| 59 | 63 | SsoOp::ListSsoIdentities => "list_sso_identities", | |
| 60 | 64 | SsoOp::UnlinkSsoIdentity => "unlink_sso_identity", | |
| 65 | + | SsoOp::EnableScim => "enable_scim", | |
| 66 | + | SsoOp::DisableScim => "disable_scim", | |
| 61 | 67 | } | |
| 62 | 68 | } | |
| 63 | 69 | ||
| ⋯ | |||
| 69 | 75 | SsoOp::DeleteSso => "Remove a workspace's single sign-on", | |
| 70 | 76 | SsoOp::ListSsoIdentities => "List the people linked through single sign-on", | |
| 71 | 77 | 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", | |
| 72 | 80 | } | |
| 73 | 81 | } | |
| 74 | 82 | ||
| ⋯ | |||
| 79 | 87 | 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.", | |
| 80 | 88 | 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.", | |
| 81 | 89 | 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.", | |
| 82 | 92 | 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.", | |
| 83 | 93 | } | |
| 84 | 94 | } | |
| ⋯ | |||
| 92 | 102 | pub fn input(self) -> Value { | |
| 93 | 103 | let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." }); | |
| 94 | 104 | 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"]), | |
| 96 | 106 | SsoOp::ConfigureSso => ( | |
| 97 | 107 | json!({ | |
| 98 | 108 | "workspace": workspace, | |
| ⋯ | |||
| 163 | 173 | } | |
| 164 | 174 | ||
| 165 | 175 | /// 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 { | |
| 167 | 177 | let addresses = sso_addresses(site, &settings.slug); | |
| 168 | 178 | let mut out = serde_json::to_value(&settings).unwrap_or_default(); | |
| 169 | 179 | out["service_provider"] = serde_json::to_value(addresses).unwrap_or_default(); | |
| 180 | + | out["service_provider"]["scim_url"] = json!(scim_url(api, &settings.slug)); | |
| 170 | 181 | out | |
| 171 | 182 | } | |
| 172 | 183 | ||
| 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 | + | ||
| 173 | 189 | pub async fn run(op: SsoOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 174 | 190 | let Some(actor) = viewer.clone() else { | |
| 175 | 191 | return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token.")); | |
| ⋯ | |||
| 180 | 196 | let identity = &services.identity; | |
| 181 | 197 | let surface = services.audit.surface; | |
| 182 | 198 | let site = services.addresses.site.as_str(); | |
| 199 | + | let api = services.addresses.api.as_str(); | |
| 183 | 200 | 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)), | |
| 185 | 202 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 186 | 203 | }; | |
| 187 | 204 | Ok(match op { | |
| ⋯ | |||
| 240 | 257 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 241 | 258 | } | |
| 242 | 259 | } | |
| 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 | + | } | |
| 243 | 276 | SsoOp::UnlinkSsoIdentity => { | |
| 244 | 277 | let Some(username) = text(input, "username") else { | |
| 245 | 278 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the person to unlink.")); | |
| ⋯ | |||
| 285 | 318 | ||
| 286 | 319 | #[test] | |
| 287 | 320 | 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"); | |
| 290 | 324 | assert_eq!(out["service_provider"]["acs_url"], "https://g1t.sh/auth/sso/acme/saml"); | |
| 291 | 325 | assert_eq!(out["service_provider"]["redirect_uri"], "https://g1t.sh/auth/sso/acme/callback"); | |
| 292 | 326 | assert_eq!(out["connection"], Value::Null); | |
| 466 | 466 | a("delete_sso", Op::Sso(SsoOp::DeleteSso), "Remove its single sign-on"), | |
| 467 | 467 | a("list_sso_identities", Op::Sso(SsoOp::ListSsoIdentities), "The people linked through single sign-on"), | |
| 468 | 468 | 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"), | |
| 469 | 471 | ], | |
| 470 | 472 | }, | |
| 471 | 473 | Tool { | |
| ⋯ | |||
| 645 | 647 | | Op::DeleteWorkspace | |
| 646 | 648 | | Op::UpdateWorkspace | |
| 647 | 649 | | 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) | |
| 649 | 651 | | Op::RemoveMember | |
| 650 | 652 | | Op::TransferOwnership | |
| 651 | 653 | | Op::LeaveWorkspace | |
| 166 | 166 | Signing in through the provider is a trip in the browser, so it has no | |
| 167 | 167 | API: send people to `sign_in_url`. | |
| 168 | 168 | ||
| 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` | | |
| 170 | 193 | ||
| 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. |
Binary or large file; its contents are not shown.
| 90 | 90 | </div> | |
| 91 | 91 | )} | |
| 92 | 92 | </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> | |
| 93 | 107 | {sso!.identities.length === 0 ? ( | |
| 94 | 108 | <p className="text-sm text-muted">No one has signed in through it yet.</p> | |
| 95 | 109 | ) : ( |
| 266 | 266 | what: "Single sign-on", | |
| 267 | 267 | free: "Not available", | |
| 268 | 268 | 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.", | |
| 270 | 270 | }, | |
| 271 | 271 | { | |
| 272 | 272 | what: "Secret scanning, push protection, vulnerability alerts, security updates", |
| 2 | 2 | import { Form, data } from "react-router"; | |
| 3 | 3 | import { KeyRound, Sparkles } from "lucide-react"; | |
| 4 | 4 | ||
| 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"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/sso"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| ⋯ | |||
| 27 | 27 | import { Unavailable } from "../../components/boundary"; | |
| 28 | 28 | import { isFailed, part } from "../../lib/partial"; | |
| 29 | 29 | import { billing } from "../../lib/services.server"; | |
| 30 | + | import { useAddresses } from "../../lib/addresses"; | |
| 30 | 31 | import { PROTOCOL_NAMES } from "../../lib/sso"; | |
| 31 | 32 | import { sso } from "../../lib/sso.server"; | |
| 32 | 33 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| ⋯ | |||
| 52 | 53 | }; | |
| 53 | 54 | } | |
| 54 | 55 | ||
| 55 | − | type Answer = { saved?: string; error?: string; intent?: string }; | |
| 56 | + | type Answer = { saved?: string; error?: string; intent?: string; token?: string }; | |
| 56 | 57 | ||
| 57 | 58 | export async function action({ request, params, context }: Route.ActionArgs): Promise<Answer> { | |
| 58 | 59 | assertSameOrigin(request); | |
| ⋯ | |||
| 97 | 98 | } | |
| 98 | 99 | case "delete": | |
| 99 | 100 | 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."); | |
| 100 | 107 | default: | |
| 101 | 108 | return { error: "Unknown change.", intent }; | |
| 102 | 109 | } | |
| ⋯ | |||
| 144 | 151 | {settings.connection && <PeopleSection settings={settings} answer={said("unlink")} />} | |
| 145 | 152 | ||
| 146 | 153 | {settings.connection && ( | |
| 154 | + | <ScimSection settings={settings} slug={slug} disabled={free} answer={said("scim-on") ?? said("scim-off")} /> | |
| 155 | + | )} | |
| 156 | + | ||
| 157 | + | {settings.connection && ( | |
| 147 | 158 | <DangerZone> | |
| 148 | 159 | <DangerAction | |
| 149 | 160 | title="Remove single sign-on" | |
| ⋯ | |||
| 158 | 169 | ); | |
| 159 | 170 | } | |
| 160 | 171 | ||
| 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'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 | + | ||
| 161 | 260 | /** Removing it, once the owner says so in a dialog. */ | |
| 162 | 261 | function RemoveDialog({ slug }: { slug: string }) { | |
| 163 | 262 | return ( | |
| 1315 | 1315 | ("delete_sso", Scope::WorkspaceAdmin), | |
| 1316 | 1316 | ("list_sso_identities", Scope::AccessRead), | |
| 1317 | 1317 | ("unlink_sso_identity", Scope::AccessAdmin), | |
| 1318 | + | ("enable_scim", Scope::WorkspaceAdmin), | |
| 1319 | + | ("disable_scim", Scope::WorkspaceAdmin), | |
| 1318 | 1320 | // Who has access. | |
| 1319 | 1321 | ("list_collaborators", Scope::AccessRead), | |
| 1320 | 1322 | ("get_collaborator_permission", Scope::AccessRead), |
| 23 | 23 | //! - `sso_saml` takes `SsoSamlArgs`, returns `Outcome<SsoFinished>`. | |
| 24 | 24 | //! - `sso_summary` takes `SlugArgs`, returns `SsoSummary`. | |
| 25 | 25 | //! - `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. | |
| 26 | 35 | ||
| 27 | 36 | use serde::{Deserialize, Serialize}; | |
| 28 | 37 | ||
| ⋯ | |||
| 151 | 160 | /// before requiring it. | |
| 152 | 161 | pub you_linked: bool, | |
| 153 | 162 | 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>, | |
| 154 | 224 | } | |
| 155 | 225 | ||
| 156 | 226 | /// Whether a workspace has single sign-on, for anyone: the sign-in page | |
| 6437 | 6437 | body: &[], | |
| 6438 | 6438 | }, | |
| 6439 | 6439 | 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 { | |
| 6440 | 6464 | id: "get_sso", | |
| 6441 | 6465 | method: "GET", | |
| 6442 | 6466 | path: "/workspaces/{workspace}/sso", |
Comments
Sign in to comment.