Skip to content

feat(scim): Add SCIM /Users endpoints - #2747

Draft
xlgmokha wants to merge 12 commits into
scim/2-corefrom
scim/3-users
Draft

feat(scim): Add SCIM /Users endpoints#2747
xlgmokha wants to merge 12 commits into
scim/2-corefrom
scim/3-users

Conversation

@xlgmokha

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

Feature. Add SCIM endpoints for core User schema.

What is the current behavior?

These endpoints do not exist yet.

What is the new behavior?

Adds:

Method Path Description
GET /scim/v2/Users List / paginate / sort SCIM users
GET /scim/v2/Users/{id} Get a specific SCIM user
POST /scim/v2/Users Create a new SCIM user
PUT /scim/v2/Users/{id} Replace a specific SCIM user
DELETE /scim/v2/Users/{id} Delete (soft) a specific SCIM user
GET /scim/v2/ResourceTypes/{id} Resource Type endpoint for User resource
GET /scim/v2/Schemas/{id} Schema description endpoint for User schema

Additional context

  • The /Users endpoints authenticate with a bearer SCIM token. Tokens are stored hashed in the new scim_tokens table, are revocable (revoked_at) and expirable (expires_at), and resolve to an sso_provider_id that scopes every query to a single tenant.
  • The new /ResourceTypes/{id} and /Schemas/{id} routes are discovery metadata and are unauthenticated, consistent with the existing /ServiceProviderConfig, /ResourceTypes, and /Schemas endpoints.
  • All SCIM routes stay behind the existing requireScimServerEnabled gate.
  • New migrations add the scim_users and scim_tokens tables. A user's SCIM payload is stored as a JSONB resource column and deletes are soft using deleted_at.
  • PATCH and filtering are not implemented yet. List rejects any filter parameter.

Extracted from #2731

@xlgmokha
xlgmokha changed the base branch from master to scim/2-core August 26, 2026 00:59
@xlgmokha xlgmokha self-assigned this Aug 26, 2026
@xlgmokha
xlgmokha force-pushed the scim/3-users branch 2 times, most recently from 6e4416d to edff202 Compare August 26, 2026 22:33
@xlgmokha
xlgmokha force-pushed the scim/3-users branch 2 times, most recently from 3538a0b to 77782ab Compare August 26, 2026 23:31
@xlgmokha
xlgmokha force-pushed the scim/3-users branch 2 times, most recently from 4709682 to e114c8e Compare August 27, 2026 16:17
@xlgmokha
xlgmokha force-pushed the scim/3-users branch 3 times, most recently from eb969a8 to 86c6ef9 Compare August 28, 2026 15:45
@xlgmokha
xlgmokha marked this pull request as ready for review August 28, 2026 16:39
@xlgmokha
xlgmokha requested a review from a team as a code owner August 28, 2026 16:39
}

var rows []scimUser
if err := r.db.WithContext(ctx).RawQuery("INSERT INTO scim_users (id, sso_provider_id, resource) VALUES (?, ?, ?) RETURNING id, resource, active, created_at, updated_at", uuid.Must(uuid.NewV4()), r.tenant(ctx), resource).All(&rows); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Severity: MEDIUM

An authenticated SCIM client can POST a userName, but this insert never populates the user_id link to the corresponding auth.users account. Provisioning therefore creates only a shadow scim_users record, so later SCIM lifecycle operations cannot disable or revoke access for the account users actually authenticate with.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: The Create function must be updated to link the new scim_users row to the corresponding auth.users account by populating the user_id column. The fix requires three coordinated changes:

  1. In Create (line 99): Before the INSERT, query auth.users (or the identities table) to find an existing user whose email matches the SCIM userName. If found, include user_id in the INSERT: INSERT INTO scim_users (id, sso_provider_id, user_id, resource) VALUES (?, ?, ?, ?). Pass the resolved UUID (or nil/NULL if no match yet).

  2. In Delete (line 123): After soft-deleting the scim_users row, also disable or delete the linked auth.users account. Use the user_id returned from the DELETE (add it to the RETURNING clause) and issue an UPDATE auth.users SET ... WHERE id = ? or call the relevant storage layer helper to ban/disable the account.

  3. In Replace (line 112): When the SCIM active attribute is set to false via a PUT, propagate that deactivation to the linked auth.users account using the same user_id lookup.

Without these three changes, the scim_users shadow record is fully decoupled from the real authentication account, so SCIM lifecycle operations (deprovision, deactivate) have no effect on actual user access.


func (r *userRepository) Delete(ctx context.Context, id string) error {
var ids []string
if err := r.db.WithContext(ctx).RawQuery("UPDATE scim_users SET deleted_at = now() WHERE sso_provider_id = ? AND deleted_at IS NULL AND id = ? RETURNING id", r.tenant(ctx), id).All(&ids); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Severity: MEDIUM

An authenticated SCIM client can DELETE a User, but this operation only timestamps scim_users.deleted_at. It does not disable the associated Auth account or revoke its sessions, allowing a deprovisioned user to continue logging in or using already-issued credentials despite a successful SCIM deletion.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: The Delete method must be extended to also deprovision the linked auth account when a SCIM user is deleted. The scim_users table already has a user_id foreign key column referencing auth.users. The fix requires several coordinated steps:

  1. Add user_id (nullable uuid.UUID) to the scimUser struct in models.go with db:"user_id".
  2. Update the Delete SQL query to RETURNING id, user_id so the linked auth user ID is retrieved.
  3. Wrap the entire delete operation in a database transaction (r.db.Transaction(...)).
  4. After soft-deleting the scim_users row, if user_id is non-null, call models.Logout(tx, userID) to revoke all active sessions for that auth user.
  5. Also ban the auth user by setting banned_until to a permanent/far-future timestamp (e.g., time.Date(9999, 12, 31, ...)) and persisting that via models.UpdateUserBannedUntil(tx, user) to prevent new logins even if sessions are somehow reused.

All database mutations (soft-delete + session revocation + ban) must be atomic within a single transaction to avoid a partial-deprovision state.

}
users = append(users, user)
}
return users, total, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: LOW

An authenticated SCIM client can request excludedAttributes=emails (or a restrictive attributes list), but the parsed selectors are ignored before this method returns every stored core.User, including emails and names. Integrations relying on SCIM attribute filtering therefore receive PII they explicitly excluded.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: The List method ignores query.Attributes and query.ExcludedAttributes, causing full core.User objects (including PII such as emails and names) to be returned even when the SCIM client explicitly excludes them.

There are two approaches to fix this:

Option 1 – Reject unsupported attribute selection (short-term, consistent with existing behaviour for filter):
At the top of the List function (after the existing query.Filter check around line 61), add a guard that rejects requests that use attributes or excludedAttributes:

if len(query.Attributes) > 0 || len(query.ExcludedAttributes) > 0 {
    return nil, 0, protocol.ErrNotImplemented("attribute selection is not supported")
}

This mirrors the existing treatment of filter and signals clearly to clients that the feature is not yet available, rather than silently leaking data.

Option 2 – Implement proper attribute projection (complete fix per RFC 7644):
Add a helper function (e.g., applyAttributeSelection(user *core.User, include, exclude []string) *core.User) that, given the Attributes inclusion list and ExcludedAttributes exclusion list, returns a copy of the user with non-requested fields set to their zero values. The function must respect RFC 7644 §3.9 rules: id, schemas, meta, and userName must always be included. Call this helper inside the loop in List after r.mapFrom, and also in UserByID in server.go (which has the same gap). This approach requires careful case-insensitive string matching of SCIM path names to struct fields and comprehensive tests covering edge cases.

@xlgmokha xlgmokha closed this Aug 28, 2026
@xlgmokha
xlgmokha deleted the scim/3-users branch August 28, 2026 23:05
@xlgmokha xlgmokha reopened this Aug 28, 2026
@xlgmokha
xlgmokha marked this pull request as draft August 28, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant