From dc402edd1a8b75a818e7dbbd9a1f948bccaa91ec Mon Sep 17 00:00:00 2001 From: mo khan Date: Tue, 25 Aug 2026 18:44:02 -0600 Subject: [PATCH 1/4] feat(scim): add scim_users and scim_tokens tables --- .../20260821000000_add_scim_users.up.sql | 70 +++++++++++++++++++ .../20260821010000_add_scim_tokens.up.sql | 43 ++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 migrations/20260821000000_add_scim_users.up.sql create mode 100644 migrations/20260821010000_add_scim_tokens.up.sql diff --git a/migrations/20260821000000_add_scim_users.up.sql b/migrations/20260821000000_add_scim_users.up.sql new file mode 100644 index 0000000000..ba6d3edfcb --- /dev/null +++ b/migrations/20260821000000_add_scim_users.up.sql @@ -0,0 +1,70 @@ +/* auth_migration: 20260821000000 */ +-- SCIM Users provisioned into one SSO provider. The resource is stored as a +-- document; queryable columns are generated from it so the two cannot drift. +create table if not exists {{ index .Options "Namespace" }}.scim_users ( + id uuid not null default gen_random_uuid(), + sso_provider_id uuid not null references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade, + user_id uuid references {{ index .Options "Namespace" }}.users (id) on delete set null, + resource jsonb not null, + user_name text not null generated always as (resource->>'userName') stored, + external_id text generated always as (resource->>'externalId') stored, + active boolean not null generated always as (coalesce((resource->>'active')::boolean, true)) stored, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz, + constraint scim_users_pkey primary key (id) +); + +/* auth_migration: 20260821000000 */ +-- userName is unique within a provider, case-folded, excluding soft-deleted rows. +create unique index if not exists scim_users_user_name_key + on {{ index .Options "Namespace" }}.scim_users (sso_provider_id, lower(user_name collate "C")) + where deleted_at is null; + +/* auth_migration: 20260821000000 */ +-- externalId is unique within a provider when set; nulls are unconstrained. +create unique index if not exists scim_users_external_id_key + on {{ index .Options "Namespace" }}.scim_users (sso_provider_id, external_id) + where external_id is not null and deleted_at is null; + +/* auth_migration: 20260821000000 */ +-- Links a SCIM user to its auth.users row; not partial, so an ON DELETE SET +-- NULL from auth.users can find soft-deleted rows too. +create index if not exists scim_users_user_id_idx + on {{ index .Options "Namespace" }}.scim_users (user_id); + +/* auth_migration: 20260821000000 */ +-- Sort indexes break ties on id for a total order; user_name uses collate "C" +-- so ordering does not depend on the database's collation. +create index if not exists scim_users_id_idx + on {{ index .Options "Namespace" }}.scim_users (sso_provider_id, id) + where deleted_at is null; + +/* auth_migration: 20260821000000 */ +create index if not exists scim_users_user_name_idx + on {{ index .Options "Namespace" }}.scim_users (sso_provider_id, lower(user_name collate "C"), id) + where deleted_at is null; + +/* auth_migration: 20260821000000 */ +create index if not exists scim_users_created_at_idx + on {{ index .Options "Namespace" }}.scim_users (sso_provider_id, created_at, id) + where deleted_at is null; + +/* auth_migration: 20260821000000 */ +create index if not exists scim_users_updated_at_idx + on {{ index .Options "Namespace" }}.scim_users (sso_provider_id, updated_at, id) + where deleted_at is null; + +/* auth_migration: 20260821000000 */ +create index if not exists scim_users_sso_provider_id_idx + on {{ index .Options "Namespace" }}.scim_users (sso_provider_id); + +/* auth_migration: 20260821000000 */ +-- Supports purging soft-deleted rows. +create index if not exists scim_users_deleted_at_idx + on {{ index .Options "Namespace" }}.scim_users (deleted_at); + +/* auth_migration: 20260821000000 */ +alter table {{ index .Options "Namespace" }}.scim_users enable row level security; +/* auth_migration: 20260821000000 */ +grant select on {{ index .Options "Namespace" }}.scim_users to postgres with grant option; diff --git a/migrations/20260821010000_add_scim_tokens.up.sql b/migrations/20260821010000_add_scim_tokens.up.sql new file mode 100644 index 0000000000..e0e6f0e2ba --- /dev/null +++ b/migrations/20260821010000_add_scim_tokens.up.sql @@ -0,0 +1,43 @@ +/* auth_migration: 20260821010000 */ +-- Bearer tokens authorising SCIM requests for one SSO provider. Only the +-- SHA-256 digest is stored; a token carries 160 bits, so the digest needs no salt. +create table if not exists {{ index .Options "Namespace" }}.scim_tokens ( + id uuid not null default gen_random_uuid(), + sso_provider_id uuid not null references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade, + token_hash text not null, + prefix text not null, + created_at timestamptz not null default now(), + expires_at timestamptz, + revoked_at timestamptz, + last_used_at timestamptz, + constraint scim_tokens_pkey primary key (id), + constraint scim_tokens_token_hash_check check (token_hash ~ '^[0-9a-f]{64}$'), + constraint scim_tokens_expires_at_future check (expires_at is null or expires_at > created_at), + constraint scim_tokens_revoked_after_created check (revoked_at is null or revoked_at >= created_at) +); + +/* auth_migration: 20260821010000 */ +-- The digest resolves a request to a provider, so it is unique across all providers. +create unique index if not exists scim_tokens_token_hash_key + on {{ index .Options "Namespace" }}.scim_tokens (token_hash); + +/* auth_migration: 20260821010000 */ +-- Not partial, so an ON DELETE CASCADE from sso_providers can find revoked +-- tokens too. +create index if not exists scim_tokens_sso_provider_id_idx + on {{ index .Options "Namespace" }}.scim_tokens (sso_provider_id); + +/* auth_migration: 20260821010000 */ +-- Supports purging expired tokens. +create index if not exists scim_tokens_expires_at_idx + on {{ index .Options "Namespace" }}.scim_tokens (expires_at); + +/* auth_migration: 20260821010000 */ +-- Supports purging revoked tokens. +create index if not exists scim_tokens_revoked_at_idx + on {{ index .Options "Namespace" }}.scim_tokens (revoked_at); + +/* auth_migration: 20260821010000 */ +alter table {{ index .Options "Namespace" }}.scim_tokens enable row level security; +/* auth_migration: 20260821010000 */ +grant select on {{ index .Options "Namespace" }}.scim_tokens to postgres with grant option; From 9a4b64622a5607df81128e228839b773aaedaa8f Mon Sep 17 00:00:00 2001 From: mo khan Date: Thu, 27 Aug 2026 09:53:40 -0600 Subject: [PATCH 2/4] feat(scim): noramlize scim_users.user_name to lowercase --- migrations/20260821000000_add_scim_users.up.sql | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/migrations/20260821000000_add_scim_users.up.sql b/migrations/20260821000000_add_scim_users.up.sql index ba6d3edfcb..742009fb11 100644 --- a/migrations/20260821000000_add_scim_users.up.sql +++ b/migrations/20260821000000_add_scim_users.up.sql @@ -6,7 +6,7 @@ create table if not exists {{ index .Options "Namespace" }}.scim_users ( sso_provider_id uuid not null references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade, user_id uuid references {{ index .Options "Namespace" }}.users (id) on delete set null, resource jsonb not null, - user_name text not null generated always as (resource->>'userName') stored, + user_name text not null generated always as (lower(resource->>'userName')) stored, external_id text generated always as (resource->>'externalId') stored, active boolean not null generated always as (coalesce((resource->>'active')::boolean, true)) stored, created_at timestamptz not null default now(), @@ -18,7 +18,7 @@ create table if not exists {{ index .Options "Namespace" }}.scim_users ( /* auth_migration: 20260821000000 */ -- userName is unique within a provider, case-folded, excluding soft-deleted rows. create unique index if not exists scim_users_user_name_key - on {{ index .Options "Namespace" }}.scim_users (sso_provider_id, lower(user_name collate "C")) + on {{ index .Options "Namespace" }}.scim_users (sso_provider_id, user_name) where deleted_at is null; /* auth_migration: 20260821000000 */ @@ -34,15 +34,13 @@ create index if not exists scim_users_user_id_idx on {{ index .Options "Namespace" }}.scim_users (user_id); /* auth_migration: 20260821000000 */ --- Sort indexes break ties on id for a total order; user_name uses collate "C" --- so ordering does not depend on the database's collation. create index if not exists scim_users_id_idx on {{ index .Options "Namespace" }}.scim_users (sso_provider_id, id) where deleted_at is null; /* auth_migration: 20260821000000 */ create index if not exists scim_users_user_name_idx - on {{ index .Options "Namespace" }}.scim_users (sso_provider_id, lower(user_name collate "C"), id) + on {{ index .Options "Namespace" }}.scim_users (sso_provider_id, user_name collate "C", id) where deleted_at is null; /* auth_migration: 20260821000000 */ From 6037ef2427bcfaa58a95cdfbfc646d24026f39d9 Mon Sep 17 00:00:00 2001 From: mo khan Date: Thu, 27 Aug 2026 10:16:52 -0600 Subject: [PATCH 3/4] fix(scim): remove RLS --- migrations/20260821000000_add_scim_users.up.sql | 5 ----- migrations/20260821010000_add_scim_tokens.up.sql | 5 ----- 2 files changed, 10 deletions(-) diff --git a/migrations/20260821000000_add_scim_users.up.sql b/migrations/20260821000000_add_scim_users.up.sql index 742009fb11..2fcb14e713 100644 --- a/migrations/20260821000000_add_scim_users.up.sql +++ b/migrations/20260821000000_add_scim_users.up.sql @@ -61,8 +61,3 @@ create index if not exists scim_users_sso_provider_id_idx -- Supports purging soft-deleted rows. create index if not exists scim_users_deleted_at_idx on {{ index .Options "Namespace" }}.scim_users (deleted_at); - -/* auth_migration: 20260821000000 */ -alter table {{ index .Options "Namespace" }}.scim_users enable row level security; -/* auth_migration: 20260821000000 */ -grant select on {{ index .Options "Namespace" }}.scim_users to postgres with grant option; diff --git a/migrations/20260821010000_add_scim_tokens.up.sql b/migrations/20260821010000_add_scim_tokens.up.sql index e0e6f0e2ba..3a39e09a20 100644 --- a/migrations/20260821010000_add_scim_tokens.up.sql +++ b/migrations/20260821010000_add_scim_tokens.up.sql @@ -36,8 +36,3 @@ create index if not exists scim_tokens_expires_at_idx -- Supports purging revoked tokens. create index if not exists scim_tokens_revoked_at_idx on {{ index .Options "Namespace" }}.scim_tokens (revoked_at); - -/* auth_migration: 20260821010000 */ -alter table {{ index .Options "Namespace" }}.scim_tokens enable row level security; -/* auth_migration: 20260821010000 */ -grant select on {{ index .Options "Namespace" }}.scim_tokens to postgres with grant option; From 635900001e07e3d29c2518ec2cba91fad33b0f82 Mon Sep 17 00:00:00 2001 From: mo khan Date: Thu, 27 Aug 2026 10:29:29 -0600 Subject: [PATCH 4/4] feat(scim): remove default uuid generator on scim_users, scim_tokens --- migrations/20260821000000_add_scim_users.up.sql | 2 +- migrations/20260821010000_add_scim_tokens.up.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/20260821000000_add_scim_users.up.sql b/migrations/20260821000000_add_scim_users.up.sql index 2fcb14e713..03178f3acc 100644 --- a/migrations/20260821000000_add_scim_users.up.sql +++ b/migrations/20260821000000_add_scim_users.up.sql @@ -2,7 +2,7 @@ -- SCIM Users provisioned into one SSO provider. The resource is stored as a -- document; queryable columns are generated from it so the two cannot drift. create table if not exists {{ index .Options "Namespace" }}.scim_users ( - id uuid not null default gen_random_uuid(), + id uuid not null, sso_provider_id uuid not null references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade, user_id uuid references {{ index .Options "Namespace" }}.users (id) on delete set null, resource jsonb not null, diff --git a/migrations/20260821010000_add_scim_tokens.up.sql b/migrations/20260821010000_add_scim_tokens.up.sql index 3a39e09a20..6b45799c2b 100644 --- a/migrations/20260821010000_add_scim_tokens.up.sql +++ b/migrations/20260821010000_add_scim_tokens.up.sql @@ -2,7 +2,7 @@ -- Bearer tokens authorising SCIM requests for one SSO provider. Only the -- SHA-256 digest is stored; a token carries 160 bits, so the digest needs no salt. create table if not exists {{ index .Options "Namespace" }}.scim_tokens ( - id uuid not null default gen_random_uuid(), + id uuid not null, sso_provider_id uuid not null references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade, token_hash text not null, prefix text not null,