Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
126b1bb
feat(scim): add SCIM Users, Groups, and admin endpoints
xlgmokha Sep 30, 2026
88a1a1b
chore(scim): use scim-go v0.8.0 remove-with-value check
xlgmokha Sep 30, 2026
49684a2
chore(scim): read the request projection from scim-go context
xlgmokha Sep 30, 2026
878794c
chore(scim): rely on scim-go canonical values for members.type
xlgmokha Sep 30, 2026
df4b38e
chore(scim): test that SCIM users cannot register passkeys or sign in…
xlgmokha Oct 1, 2026
29e6b50
chore(scim): test that removing emails keeps the user's email
xlgmokha Oct 1, 2026
644ef6b
fix(scim): validate emails and fall back to an email userName
xlgmokha Oct 1, 2026
6b46026
chore(scim): parse the SSO provider id from an identity provider in o…
xlgmokha Oct 1, 2026
554d684
fix(scim): lock every verified email that SSO account linking reads
xlgmokha Oct 1, 2026
d3c0ea9
chore(scim): keep the authenticated token in one context key
xlgmokha Oct 1, 2026
9b821f7
chore(scim): test that every authorization header shape is rate limited
xlgmokha Oct 1, 2026
4ee9420
chore(scim): mount scim-go under /scim/v2 and reuse the auth bearer t…
xlgmokha Oct 1, 2026
1200172
chore(scim): compute group member changes from the rows found and pas…
xlgmokha Oct 1, 2026
01b32c8
chore(scim): read tokens and settings instead of writing them before …
xlgmokha Oct 1, 2026
e34b7f3
chore(scim): fix scim-go lint findings and move linking locks and log…
xlgmokha Oct 1, 2026
25f7ec3
chore(scim): simplify the provider rate limit and scim error mapping
xlgmokha Oct 1, 2026
a98c4de
chore(scim): rename SCIM helpers to say what they do
xlgmokha Oct 1, 2026
56d1622
chore(scim): order scim.go and scim_admin.go by constructors, methods…
xlgmokha Oct 1, 2026
39e2f9d
chore(scim): test that /Me, /Bulk and .search return 501
xlgmokha Oct 1, 2026
4a1aee8
docs(scim): say unknown routes with a valid token count against the p…
xlgmokha Oct 1, 2026
524098e
chore(scim): share admin jwt and audit query test helpers
xlgmokha Oct 1, 2026
f940242
chore(scim): collapse SCIM deprovisioned check into a single query
xlgmokha Oct 1, 2026
41da9b6
chore(scim): pass a SCIMTarget to the SCIM row writers
xlgmokha Oct 1, 2026
a9255d7
chore(scim): pass a SCIMIdentityRename to RenameSCIMIdentity
xlgmokha Oct 1, 2026
2f8bd37
chore(scim): name scimTable fields and table references consistently
xlgmokha Oct 1, 2026
982e8e1
chore(scim): split ReplaceSCIMGroupMembers into lookup and write steps
xlgmokha Oct 1, 2026
86f088b
chore(scim): share provider and search setup between Users and Groups…
xlgmokha Oct 1, 2026
feb886d
chore(scim): split SCIM user Create, Replace and Delete into steps
xlgmokha Oct 1, 2026
3b16e99
chore(scim): flatten SCIM group save
xlgmokha Oct 1, 2026
4710b8b
chore(scim): split SCIM admin token create and revoke
xlgmokha Oct 1, 2026
f6218b2
chore(scim): use the API clock and handle errors SCIM ignored
xlgmokha Oct 1, 2026
b47e3b4
chore(scim): log SCIM user warnings through the request logger
xlgmokha Oct 1, 2026
7fb5885
chore(scim): name filter conditions, use field names and unexport tok…
xlgmokha Oct 1, 2026
9154230
chore(scim): stop asserting in test goroutines and use matching testi…
xlgmokha Oct 1, 2026
bc50dec
chore: update scim-go to v0.8.1
xlgmokha Oct 1, 2026
1d0c825
chore(scim): test that versioned writes to missing SCIM rows return n…
xlgmokha Oct 1, 2026
16baa7c
chore: bump scim-go to v0.8.2
xlgmokha Oct 1, 2026
990ebe7
chore: bump scim-go to v0.8.3
xlgmokha Oct 1, 2026
f93e6be
chore(scim): encode SCIM group attributes without rendering members
xlgmokha Oct 1, 2026
189c0c9
chore(scim): validate only added SCIM group members
xlgmokha Oct 1, 2026
58c07f9
chore(scim): stop selecting unused userName for SCIM group members
xlgmokha Oct 1, 2026
ddf83b1
chore(scim): test that SCIM group replace validates only added members
xlgmokha Oct 1, 2026
29ac50e
fix(scim): sync the linked user email when the SCIM primary email cha…
xlgmokha Oct 1, 2026
044eb48
chore(scim): test that a SCIM rename with a new email keeps the user'…
xlgmokha Oct 1, 2026
355471d
chore(deps): bump scim-go to v0.8.4
xlgmokha Oct 1, 2026
e64538c
fix(scim): refuse to link a SCIM user to a non-SSO account
xlgmokha Oct 1, 2026
5b79f40
fix(scim): refuse to relink a SCIM user to an account the provider de…
xlgmokha Oct 1, 2026
7b0dcfc
fix(scim): revoke SCIM tokens when SCIM is disabled
xlgmokha Oct 1, 2026
8e0a216
fix(scim): clear pending one-time tokens when the SCIM primary email …
xlgmokha Oct 1, 2026
09df1c0
fix(scim): check for a provider-deleted user under the SCIM user lock
xlgmokha Oct 1, 2026
58faafa
chore(scim): test that admin user delete lets SCIM create a fresh acc…
xlgmokha Oct 1, 2026
d697dc7
chore(scim): check live and deleted SCIM links in one query
xlgmokha Oct 1, 2026
1339c3b
chore(scim): drop comment from concurrent SCIM create test
xlgmokha Oct 1, 2026
9a93754
chore(scim): split SCIM rate limiting, errors, linking and cleanup in…
xlgmokha Oct 1, 2026
55f88c5
chore(scim): share the SCIM user create and replace write path
xlgmokha Oct 1, 2026
8a98cf9
chore(scim): share the SCIM optimistic version clause
xlgmokha Oct 1, 2026
6035ed2
chore(scim): share the SCIM disabled audit event
xlgmokha Oct 1, 2026
50b2f20
chore(scim): move the shared SCIM test suite and helpers into scim_te…
xlgmokha Oct 1, 2026
6fcad5e
chore(scim): move SCIM tests next to the source they cover
xlgmokha Oct 1, 2026
3ff6360
chore(scim): rename the shared SCIM test suite to SCIMTestSuite
xlgmokha Oct 1, 2026
cb776b1
chore(scim): drop duplicate scim-go import aliases in SCIM tests
xlgmokha Oct 1, 2026
bedcb82
chore(scim): share admin and SCIM request helpers in SCIM tests
xlgmokha Oct 1, 2026
0395c31
chore(scim): build PatchOp and Okta user fixtures with helpers in SCI…
xlgmokha Oct 1, 2026
51f408f
chore(scim): share audit window, row count and provider type helpers …
xlgmokha Oct 1, 2026
146161e
chore(scim): share the SCIM models test bootstrap
xlgmokha Oct 1, 2026
9104f65
chore(scim): split looped SCIM link tests instead of resetting the suite
xlgmokha Oct 1, 2026
576a01c
chore(scim): rename scimCanLink to scimRequireSSOUser
xlgmokha Oct 1, 2026
ea3d9f9
chore(scim): pass SCIM audit fields as a scimAuditEvent
xlgmokha Oct 1, 2026
77b7296
chore(scim): name the SCIM user and group write callback types
xlgmokha Oct 1, 2026
976c845
fix(scim): send Retry-After on SCIM rate limit responses
xlgmokha Oct 1, 2026
ced63bf
chore(scim): add hack/scim-demo.sh to exercise SCIM against a local s…
xlgmokha Oct 1, 2026
aab7e63
chore(scim): cover discovery, paging, groups and errors in hack/scim-…
xlgmokha Oct 1, 2026
6eb37b4
fix(scim): write SCIM group member audit entries in one insert
xlgmokha Oct 1, 2026
f0847f1
fix(scim): preallocate SCIM group members when rendering
xlgmokha Oct 1, 2026
3518767
chore(scim): rename SCIM helpers to say which table and scope they ac…
xlgmokha Oct 1, 2026
70c15b6
chore(scim): share SCIM replace, delete, lock and audit helpers
xlgmokha Oct 1, 2026
7e421af
fix(scim): render SCIM write responses inside the write transaction
xlgmokha Oct 1, 2026
f85f808
fix(scim): refuse a SCIM user write when its link changed after the read
xlgmokha Oct 1, 2026
4064f4a
fix(scim): read only member ids and build the audit actor once per gr…
xlgmokha Oct 1, 2026
19c7dc8
fix(scim): write SCIM user delete and token revoke audit entries in o…
xlgmokha Oct 1, 2026
5f79c93
fix(scim): skip the SCIM list count query when the page is not full
xlgmokha Oct 1, 2026
e02297e
fix(scim): skip group and link checks for a SCIM user created in the …
xlgmokha Oct 1, 2026
5066ed4
fix(scim): render SCIM group writes from the member diff instead of r…
xlgmokha Oct 1, 2026
6f6d867
chore(scim): fix golangci-lint findings in SCIM code and tests
xlgmokha Oct 1, 2026
374e36c
chore(scim): order SCIM helpers after exported functions
xlgmokha Oct 1, 2026
62dc31f
chore(scim): bump scim-go to v0.9.0
xlgmokha Oct 1, 2026
b5ead48
fix(scim): apply member-only group PATCH as a delta instead of reread…
xlgmokha Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ Header on which to rate limit the `/token` endpoint. This header is expected to

Rate limit the number of emails sent per hour on the following endpoints: `/signup`, `/invite`, `/magiclink`, `/recover`, `/otp`, & `/user`.

`GOTRUE_SSO_SCIM_ENABLED` - `bool`

Mounts the SCIM 2.0 routes at `/scim/v2` and the SCIM admin routes at `/admin/sso/providers/{id}/scim`. Defaults to `false`.

`GOTRUE_RATE_LIMIT_SCIM` - `number`

Requests per 5 minutes to `/scim/v2`, with a burst of 30. Requests with a valid SCIM token are limited per SSO provider. Requests without a valid token, and requests to `/ServiceProviderConfig`, are limited per IP. Defaults to 3000.

`GOTRUE_PASSWORD_MIN_LENGTH` - `int`

Minimum password length, defaults to 6.
Expand Down
9 changes: 4 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ require (
github.com/consensys/gnark-crypto v0.18.1 // indirect
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
github.com/crewjam/httperr v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
Expand Down Expand Up @@ -85,7 +84,6 @@ require (
github.com/onsi/gomega v1.27.6 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
Expand All @@ -99,7 +97,7 @@ require (
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
github.com/speakeasy-api/openapi v1.24.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
Expand All @@ -108,7 +106,7 @@ require (
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/mod v0.40.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/tools v0.49.0 // indirect
Expand Down Expand Up @@ -164,7 +162,8 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.1
github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20240303152453-e0e82adf1721
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/supabase-community/scim-go v0.9.0
github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869
github.com/xeipuuv/gojsonschema v1.2.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -419,8 +419,6 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
Expand Down Expand Up @@ -487,8 +485,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
Expand All @@ -498,8 +496,10 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/supabase-community/scim-go v0.9.0 h1:7flgOmRbi67NYEYBILxwio1XzqMFL0pCwRKffMHBqBg=
github.com/supabase-community/scim-go v0.9.0/go.mod h1:oEMij9JuKtAl0wl0jeyIHDKqHvPpUmTXegKeCBKyxXw=
github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869 h1:VDuRtwen5Z7QQ5ctuHUse4wAv/JozkKZkdic5vUV4Lg=
github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869/go.mod h1:eHX5nlSMSnyPjUrbYzeqrA8snCe2SKyfizKjU3dkfOw=
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw=
Expand Down Expand Up @@ -572,8 +572,8 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
Expand Down
135 changes: 135 additions & 0 deletions hack/scim-demo.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env bash

set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BASE_URL="${GOTRUE_URL:-http://localhost:9999}"
SECRET="${GOTRUE_JWT_SECRET:-$(sed -n 's/^GOTRUE_JWT_SECRET=//p' "$ROOT/.env" 2>/dev/null | tr -d '"')}"
RUN="$(date +%s)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT

[ -n "$SECRET" ] || { echo "set GOTRUE_JWT_SECRET or add it to $ROOT/.env" >&2; exit 1; }

b64url() {
openssl base64 -e -A | tr '+/' '-_' | tr -d '='
}

admin_jwt() {
local now header payload sig
now="$(date +%s)"
header="$(printf '%s' '{"alg":"HS256","typ":"JWT"}' | b64url)"
payload="$(printf '{"role":"service_role","iat":%s,"exp":%s}' "$now" "$((now + 600))" | b64url)"
sig="$(printf '%s.%s' "$header" "$payload" | openssl dgst -sha256 -hmac "$SECRET" -binary | b64url)"
printf '%s.%s.%s' "$header" "$payload" "$sig"
}

ADMIN_TOKEN="$(admin_jwt)"

call() {
local expected="$1" method="$2" url="$3" token="$4" body="${5:-}" show="${6:-.}" status
local args=(-s -X "$method" -o "$TMP/body" -w '%{http_code}' -H "Authorization: Bearer $token")
[ -n "$body" ] && args+=(-H 'Content-Type: application/scim+json' --data "$body")
printf '\n\033[1m%s %s\033[0m\n' "$method" "${url#"$BASE_URL"}"
status="$(curl "${args[@]}" "$url")"
[ -s "$TMP/body" ] && jq -C "$show" < "$TMP/body"
if [ "$status" != "$expected" ]; then
printf '\033[1;31mHTTP %s, expected %s\033[0m\n' "$status" "$expected"
exit 1
fi
printf '\033[1;32mHTTP %s\033[0m\n' "$status"
}

field() {
jq -r "$1" < "$TMP/body"
}

uri() {
jq -rn --arg v "$1" '$v | @uri'
}

section() {
printf '\n\033[1;34m== %s ==\033[0m\n' "$1"
}

user() {
jq -n --arg u "$1" --arg g "$2" --arg f "$3" --argjson active "${4:-true}" '{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
userName: $u,
name: {givenName: $g, familyName: $f},
emails: [{value: $u, primary: true}],
active: $active
}'
}

patch() {
jq -n --argjson ops "[$1]" '{schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: $ops}'
}

openssl req -x509 -newkey rsa:2048 -nodes -keyout "$TMP/key.pem" -subj "/CN=scim-demo.example" -days 1 -outform DER -out "$TMP/cert.der" 2>/dev/null
CERT="$(openssl base64 -e -A < "$TMP/cert.der")"
METADATA="<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\" entityID=\"https://scim-demo-$RUN.example/entityid\"><md:IDPSSODescriptor protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\"><md:KeyDescriptor use=\"signing\"><ds:KeyInfo xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"><ds:X509Data><ds:X509Certificate>$CERT</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:SingleSignOnService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" Location=\"https://scim-demo-$RUN.example/sso\"/></md:IDPSSODescriptor></md:EntityDescriptor>"

section "Admin: provider, SCIM and tokens"
call 201 POST "$BASE_URL/admin/sso/providers" "$ADMIN_TOKEN" "$(jq -n --arg xml "$METADATA" '{type: "saml", metadata_xml: $xml}')"
PROVIDER="$(field .id)"
ADMIN="$BASE_URL/admin/sso/providers/$PROVIDER"

call 200 POST "$ADMIN/scim" "$ADMIN_TOKEN"
call 201 POST "$ADMIN/scim/tokens" "$ADMIN_TOKEN" '{}'
SCIM_TOKEN="$(field .token)"
call 201 POST "$ADMIN/scim/tokens" "$ADMIN_TOKEN" '{}'
SPARE_TOKEN="$(field .token)"
SPARE_PREFIX="$(field .prefix)"
call 200 GET "$ADMIN/scim/tokens" "$ADMIN_TOKEN"
call 200 DELETE "$ADMIN/scim/tokens/$SPARE_PREFIX" "$ADMIN_TOKEN"
call 200 GET "$ADMIN/scim" "$ADMIN_TOKEN"
SCIM="$BASE_URL/scim/v2"
call 401 GET "$SCIM/Users" "$SPARE_TOKEN"

section "Discovery"
call 200 GET "$SCIM/ServiceProviderConfig" "$SCIM_TOKEN"
call 200 GET "$SCIM/ResourceTypes" "$SCIM_TOKEN" "" '[.Resources[] | {name, endpoint, schema}]'
call 200 GET "$SCIM/Schemas" "$SCIM_TOKEN" "" '[.Resources[].id]'

section "Users"
BJENSEN="bjensen+$RUN@example.com"
JSMITH="jsmith+$RUN@example.com"
call 201 POST "$SCIM/Users" "$SCIM_TOKEN" "$(user "$BJENSEN" Barbara Jensen)"
USER="$(field .id)"
call 201 POST "$SCIM/Users" "$SCIM_TOKEN" "$(user "$JSMITH" John Smith)"
OTHER="$(field .id)"
call 200 GET "$SCIM/Users?filter=$(uri "userName eq \"$BJENSEN\"")" "$SCIM_TOKEN"
call 200 GET "$SCIM/Users?sortBy=userName&sortOrder=descending" "$SCIM_TOKEN"
call 200 GET "$SCIM/Users?sortBy=userName&startIndex=2&count=1" "$SCIM_TOKEN"
call 200 PATCH "$SCIM/Users/$USER" "$SCIM_TOKEN" "$(patch '{"op": "replace", "path": "name.familyName", "value": "Jensen-Smith"}')"
call 200 PUT "$SCIM/Users/$USER" "$SCIM_TOKEN" "$(user "$BJENSEN" Babs Jensen)"
call 200 PATCH "$SCIM/Users/$USER" "$SCIM_TOKEN" "$(patch '{"op": "replace", "path": "active", "value": false}')"
call 200 PATCH "$SCIM/Users/$USER" "$SCIM_TOKEN" "$(patch '{"op": "replace", "path": "active", "value": true}')"

section "Groups"
call 201 POST "$SCIM/Groups" "$SCIM_TOKEN" "$(jq -n --arg n "Tour Guides $RUN" --arg m "$USER" '{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
displayName: $n,
members: [{value: $m}]
}')"
GROUP="$(field .id)"
call 204 PATCH "$SCIM/Groups/$GROUP" "$SCIM_TOKEN" "$(patch "{\"op\": \"add\", \"path\": \"members\", \"value\": [{\"value\": \"$OTHER\"}]}")"
call 204 PATCH "$SCIM/Groups/$GROUP" "$SCIM_TOKEN" "$(patch "{\"op\": \"remove\", \"path\": \"members[value eq \\\"$USER\\\"]\"}")"
call 200 GET "$SCIM/Groups/$GROUP" "$SCIM_TOKEN"

section "Errors"
call 409 POST "$SCIM/Users" "$SCIM_TOKEN" "$(user "$BJENSEN" Barbara Jensen)"
call 400 GET "$SCIM/Users?filter=$(uri 'userName sw "bjensen"')" "$SCIM_TOKEN"
call 400 GET "$SCIM/Users?sortBy=title" "$SCIM_TOKEN"
call 404 GET "$SCIM/Users/00000000-0000-0000-0000-000000000000" "$SCIM_TOKEN"
call 401 GET "$SCIM/Users" "not-a-token"

section "Cleanup"
call 204 DELETE "$SCIM/Groups/$GROUP" "$SCIM_TOKEN"
call 204 DELETE "$SCIM/Users/$USER" "$SCIM_TOKEN"
call 204 DELETE "$SCIM/Users/$OTHER" "$SCIM_TOKEN"
call 404 GET "$SCIM/Users/$USER" "$SCIM_TOKEN"
call 200 DELETE "$ADMIN/scim" "$ADMIN_TOKEN"
call 401 GET "$SCIM/Users" "$SCIM_TOKEN"
call 200 DELETE "$ADMIN" "$ADMIN_TOKEN"
10 changes: 10 additions & 0 deletions internal/api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,10 @@ func (a *API) adminUserDelete(w http.ResponseWriter, r *http.Request) error {
return apierrors.NewInternalServerError("Error soft deleting user").WithInternalError(terr)
}

if terr := a.deleteSCIMUsers(tx, r, adminUser, user.ID); terr != nil {
return apierrors.NewInternalServerError("Error deleting user's SCIM users").WithInternalError(terr)
}

if terr := user.SoftDeleteUserIdentities(tx); terr != nil {
return apierrors.NewInternalServerError("Error soft deleting user identities").WithInternalError(terr)
}
Expand All @@ -644,6 +648,12 @@ func (a *API) adminUserDelete(w http.ResponseWriter, r *http.Request) error {
return apierrors.NewInternalServerError("Error deleting user's sessions").WithInternalError(terr)
}
} else {
if terr := models.LockUserForSCIM(tx, user.ID); terr != nil {
return apierrors.NewInternalServerError("Error locking user").WithInternalError(terr)
}
if terr := a.deleteSCIMUsers(tx, r, adminUser, user.ID); terr != nil {
return apierrors.NewInternalServerError("Error deleting user's SCIM users").WithInternalError(terr)
}
if terr := tx.Destroy(user); terr != nil {
return apierrors.NewInternalServerError("Database error deleting user").WithInternalError(terr)
}
Expand Down
84 changes: 84 additions & 0 deletions internal/api/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,68 @@ func (ts *AdminTestSuite) TestAdminUserDelete() {
}
}

func (ts *AdminTestSuite) TestAdminUserDeleteSoftDeletesSCIMUser() {
cases := []struct {
desc string
body map[string]any
wantEmail string
}{
{
desc: "hard delete",
body: map[string]any{"should_soft_delete": false},
wantEmail: "scim-hard-delete@example.com",
},
{
desc: "soft delete",
body: map[string]any{"should_soft_delete": true},
wantEmail: "scim-soft-delete@example.com",
},
}

for _, c := range cases {
ts.Run(c.desc, func() {
scimUser, u := ts.createLinkedSCIMUser(c.wantEmail)
group, err := models.CreateSCIMGroup(ts.API.db, scimUser.SSOProviderID, []byte(`{"displayName":"Engineering"}`))
require.NoError(ts.T(), err)
_, _, _, err = models.ReplaceSCIMGroupMembers(ts.API.db, group, []uuid.UUID{scimUser.ID})
require.NoError(ts.T(), err)

var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/admin/users/%s", u.ID), &buffer)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))

ts.API.handler.ServeHTTP(w, req)
require.Equal(ts.T(), http.StatusOK, w.Code)

row := ts.findSCIMUserByID(scimUser.ID)
require.NotNil(ts.T(), row.DeletedAt)

members, err := ts.API.db.Q().Where("scim_user_id = ?", scimUser.ID).Count(&models.SCIMGroupMember{})
require.NoError(ts.T(), err)
require.Zero(ts.T(), members)
updated, err := models.FindSCIMGroup(ts.API.db, scimUser.SSOProviderID, group.ID)
require.NoError(ts.T(), err)
require.True(ts.T(), updated.UpdatedAt.After(group.UpdatedAt))

entry := models.AuditLogEntry{}
require.NoError(ts.T(), ts.API.db.Q().Where("payload->>'action' = ? AND payload->'traits'->>'scim_user_id' = ?", models.SCIMGroupMemberRemovedAction, scimUser.ID.String()).First(&entry))
require.Equal(ts.T(), group.ID.String(), entry.Payload["traits"].(map[string]any)["scim_group_id"])
require.Equal(ts.T(), "supabase_admin", entry.Payload["actor_username"])

deleted := []models.AuditLogEntry{}
require.NoError(ts.T(), ts.API.db.Q().Where("payload->>'action' = ? AND payload->'traits'->>'scim_user_id' = ?", models.SCIMUserDeletedAction, scimUser.ID.String()).All(&deleted))
require.Len(ts.T(), deleted, 1)
traits := deleted[0].Payload["traits"].(map[string]any)
require.Equal(ts.T(), "supabase_admin", deleted[0].Payload["actor_username"])
require.Equal(ts.T(), scimUser.SSOProviderID.String(), traits["sso_provider_id"])
require.Equal(ts.T(), u.ID.String(), traits["user_id"])
require.Equal(ts.T(), "success", traits["outcome"])
})
}
}

func (ts *AdminTestSuite) TestAdminUserSoftDeletion() {
// create user
u, err := models.NewUser("123456789", "test@example.com", "secret", ts.Config.JWT.Aud, map[string]interface{}{"name": "test"})
Expand Down Expand Up @@ -1184,3 +1246,25 @@ func (ts *AdminTestSuite) TestAdminUserCreateValidationErrors() {

}
}

func (ts *AdminTestSuite) createLinkedSCIMUser(email string) (*models.SCIMUser, *models.User) {
provider := &models.SSOProvider{}
require.NoError(ts.T(), ts.API.db.Create(provider))

u, err := models.NewUser("", email, "", ts.Config.JWT.Aud, nil)
require.NoError(ts.T(), err)
u.IsSSOUser = true
require.NoError(ts.T(), ts.API.db.Create(u))

scimUser, err := models.CreateSCIMUser(ts.API.db, provider.ID, []byte(`{"userName":"`+email+`"}`))
require.NoError(ts.T(), err)
require.NoError(ts.T(), models.LinkSCIMUser(ts.API.db, scimUser, u.ID))

return scimUser, u
}

func (ts *AdminTestSuite) findSCIMUserByID(id uuid.UUID) *models.SCIMUser {
var row models.SCIMUser
require.NoError(ts.T(), ts.API.db.Q().Where("id = ?", id).First(&row))
return &row
}
Loading
Loading