-
Notifications
You must be signed in to change notification settings - Fork 366
feat(SCIM): user endpoints #12530
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat(SCIM): user endpoints #12530
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
0278c7f
feat(SCIM): Implement User endpoint
Dschoordsch afb7184
comply with MS Entra and Okta
Dschoordsch 540a85c
Add more tests
Dschoordsch f82c862
Add scimId foreign key and (scimId, scimUserName) unique constraint
Dschoordsch e5a7d71
cleanup logging and audit logs
Dschoordsch bb94b19
Fix indentation
Dschoordsch 7cb4ac1
migration order
Dschoordsch ef57130
Cleanup
Dschoordsch File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
35 changes: 35 additions & 0 deletions
35
packages/server/postgres/migrations/2026-02-04T08:30:22.386Z_addScimExternalIdToUser.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import {type Kysely} from 'kysely' | ||
|
|
||
| // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. | ||
| export async function up(db: Kysely<any>): Promise<void> { | ||
| await db.schema | ||
| .alterTable('User') | ||
| .addColumn('scimId', 'varchar(100)', (col) => col.references('SAML.id').onDelete('set null')) | ||
| .addColumn('scimExternalId', 'varchar(255)') | ||
| .addColumn('scimUserName', 'varchar(255)') | ||
|
Dschoordsch marked this conversation as resolved.
|
||
| // Okta wants these | ||
| .addColumn('scimGivenName', 'varchar(255)') | ||
| .addColumn('scimFamilyName', 'varchar(255)') | ||
| .execute() | ||
| await db.schema | ||
| .createIndex('User_scimId_scimUserName_idx') | ||
| .unique() | ||
| .on('User') | ||
| .columns(['scimId', 'scimUserName']) | ||
| .where('scimId', 'is not', null) | ||
| .where('scimUserName', 'is not', null) | ||
| .execute() | ||
| } | ||
|
|
||
| // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. | ||
| export async function down(db: Kysely<any>): Promise<void> { | ||
| await db.schema.dropIndex('User_scimId_scimUserName_idx').execute() | ||
| await db.schema | ||
| .alterTable('User') | ||
| .dropColumn('scimId') | ||
| .dropColumn('scimExternalId') | ||
| .dropColumn('scimUserName') | ||
| .dropColumn('scimGivenName') | ||
| .dropColumn('scimFamilyName') | ||
| .execute() | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # Parabol SCIM Provisioning | ||
| An enterprise organization with verified domains (i.e. SAML enabled) can use SCIM to provision and de-provision users. The users that can be managed via SCIM are limited by the verified domains associated with the organization. | ||
| The SCIM setup is tightly coupled to the organization. | ||
|
|
||
| ## User categories used here | ||
| - provisioned: users that were originally created via this SCIM provisioning | ||
| - domain-matched: users that belong to the verified domains associated with the organization | ||
| - externals: users that don't belong to the verified domains and were not provisioned via this SCIM but are members of the organization | ||
|
|
||
| SCIM provisioning **can**: | ||
| - provision users regardless of domain that will be added to the organization. | ||
| - update provisioned and domain-matched users. | ||
| - list provisioned, domain-matched, and external users in the organization. | ||
| - de-provision provisioned and domain-matched users. | ||
|
|
||
| SCIM provisioning **cannot**: | ||
| - de-provision externals. They will be removed from the organization but not deleted from Parabol. | ||
| - update externals. | ||
| - manage groups/teams. | ||
| - apply roles. | ||
|
|
||
| ## Notes | ||
| - degress will remove a user from the organization and all associated teams. Re-provisioning them will not re-add team membership again. Externals will remain a Parabol user. | ||
| - egress will list all users in the organization combined with users provisioned via this SCIM combined with users bolonging to the verified domains. | ||
| - all ingress will add the users (new and existing) to the organization regardless of their email domain. | ||
|
|
||
| ## Attributes | ||
| The following attributes are supported for SCIM provisioning: | ||
| - `userName`: unused by Parabol, can be used to query users; will fall back to SAML PersistentNameID, NameID or email if unknown | ||
| - `displayName`: the user's full name, can be changed locally in Parabol | ||
| - `emails` (required): Parabol will use the primary email provided, falling back to the first one; Parabol lowercases all email addresses | ||
| - `externalId`: only stored and echoed back | ||
| - `name.givenName`: only stored and echoed back, guessed when unknown[^1] | ||
| - `name.familyName`: only stored and echoed back, guessed when unknown[^1] | ||
| [^1]: If `name.givenName` or `name.familyName` are unknown, the missing attribute(s) are guessed by the following algorithm: | ||
| - if `displayName` consists of multiple parts (e.g. "Jane H. Doe"), then `name.givenName` will be the first, `name.familyName` the last part, (e.g. "Jane" and "Doe") | ||
| - else if `email` consists of multiple parts separated by `.` (e.g. "jane.h.doe@example.com"), then `name.givenName` will be the first, `name.familyName` the last part, (e.g. "Jane" and "Doe") | ||
| - else `name.givenName` will be set to `displayName` and `name.lastName` will be set to the local email capitalized, so for "Jane<doe@example.com>" it will be "Jane" and "Doe" | ||
|
|
||
| ## Warning | ||
| Org admins can be de-provisioned via SCIM, but currently users cannot be promoted to org admins via SCIM. Be sure to manually promote a new org admin before de-provisioning the last org admin. | ||
|
mattkrick marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import {JwtPayload} from 'jsonwebtoken' | ||
| import {DataLoaderWorker} from '../graphql/graphql' | ||
|
|
||
| export type SCIMContext = { | ||
| authToken: JwtPayload | ||
| dataLoader: DataLoaderWorker | ||
| ip: string | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| import {HttpRequest, HttpResponse, TemplatedApp} from 'uWebSockets.js' | ||
| import querystring from 'querystring' | ||
| import SCIMMY from 'scimmy' | ||
| import appOrigin from '../appOrigin' | ||
| import {getNewDataLoader} from '../dataloader/getNewDataLoader' | ||
| import uWSAsyncHandler from '../graphql/uWSAsyncHandler' | ||
| import parseBody from '../parseBody' | ||
| import getVerifiedAuthToken from '../utils/getVerifiedAuthToken' | ||
| import {SCIMContext} from './SCIMContext' | ||
| import './UserEgress' | ||
| import './UserIngress' | ||
| import './UserDegress' | ||
| import {decode} from 'jsonwebtoken' | ||
| import makeAppURL from '../../client/utils/makeAppURL' | ||
| import {Logger} from '../utils/Logger' | ||
| import uwsGetIP from '../utils/uwsGetIP' | ||
|
|
||
| SCIMMY.Config.set({ | ||
| //documentationUri: "https://example.com/docs/scim.html", | ||
| patch: true, | ||
| filter: true, | ||
| bulk: false, | ||
| authenticationSchemes: [ | ||
| { | ||
| type: 'oauth2', | ||
| name: 'OAuth 2.0 Authorization Framework', | ||
| description: 'Authentication scheme using the OAuth 2.0 Authorization Framework Standard', | ||
| specUri: 'https://datatracker.ietf.org/doc/html/rfc6749' | ||
| }, | ||
| { | ||
| type: 'oauthbearertoken', | ||
| name: 'OAuth Bearer Token', | ||
| description: 'Authentication scheme using the OAuth Bearer Token Standard', | ||
| specUri: 'https://datatracker.ietf.org/doc/html/rfc6750' | ||
| } | ||
| ] | ||
| }) | ||
|
|
||
| type Handler = ( | ||
| res: HttpResponse, | ||
| req: {query: Record<string, string | number>; id?: string; body: Json}, | ||
| ctx: SCIMContext | ||
| ) => Promise<void> | ||
| const scimHandler = (handler: Handler) => | ||
| uWSAsyncHandler(async (res: HttpResponse, req: HttpRequest) => { | ||
| const dataLoader = getNewDataLoader('SCIMHandler') | ||
| try { | ||
| const ip = uwsGetIP(res, req) | ||
| const authHeader = | ||
| req.getHeader('x-application-authorization') || req.getHeader('authorization') | ||
| const token = authHeader?.slice(7) | ||
|
|
||
| // For scimAuthenticationType === bearerToken the token is unsigned as we compare it to the stored token. | ||
| // But we want to get the scimId from it so that we know what to compare it to. For OAuth we check the signature later. | ||
| const unverifiedToken = token ? decode(token, {json: true}) : null | ||
| if (unverifiedToken?.aud !== 'action-scim' || !unverifiedToken.sub) { | ||
| throw new SCIMMY.Types.Error(401, '', 'Unauthorized') | ||
| } | ||
|
|
||
| // We haven't verified the token yet, but we need to read the request before we can await on the DB request | ||
| const query: Record<string, string | number> = {} | ||
| new URLSearchParams(req.getQuery()).forEach((v, k) => { | ||
| // workaround for https://github.com/scimmyjs/scimmy/issues/89 | ||
| if (['startIndex', 'count'].includes(k)) { | ||
| const int = parseInt(v) | ||
| if (!isNaN(int)) { | ||
| query[k] = int | ||
| return | ||
| } | ||
| } | ||
| query[k] = v | ||
| }) | ||
| const parameter = req.getParameter(0) | ||
| const id = parameter !== undefined ? querystring.unescape(parameter) : undefined | ||
|
mattkrick marked this conversation as resolved.
|
||
| const [body, saml] = await Promise.all([ | ||
| parseBody({res}), | ||
| dataLoader.get('saml').load(unverifiedToken.sub) | ||
| ]) | ||
| if (!saml?.scimAuthenticationType) { | ||
| throw new SCIMMY.Types.Error(401, '', 'SCIM not enabled for this SSO Domain') | ||
| } | ||
|
|
||
| // Verify the token | ||
| // token was refreshed, so previous token is invalid | ||
| if (saml.scimAuthenticationType === 'bearerToken' && saml.scimBearerToken !== token) { | ||
| throw new SCIMMY.Types.Error(401, '', 'Unauthorized') | ||
| } | ||
| // Verify the signature if it's not a bearer token | ||
| if (saml.scimAuthenticationType === 'oauthClientCredentials') { | ||
| const authToken = getVerifiedAuthToken(token) | ||
| if (!authToken.sub) { | ||
| throw new SCIMMY.Types.Error(401, '', 'Unauthorized') | ||
| } | ||
| } | ||
|
|
||
| await handler(res, {query, id, body}, {ip, authToken: unverifiedToken, dataLoader}) | ||
| } catch (err) { | ||
| const response = new SCIMMY.Messages.Error(err as any) | ||
| if (response.status >= 500) { | ||
| Logger.error(err) | ||
| } | ||
| res | ||
| .writeStatus(`${response.status}`) | ||
| .writeHeader('Content-Type', 'application/scim+json') | ||
| .end(JSON.stringify(response)) | ||
| } finally { | ||
| dataLoader.dispose() | ||
| } | ||
| }) | ||
|
|
||
| export const registerSCIMHandlers = (app: TemplatedApp, pathPrefix: string = '/scim') => { | ||
| const route = makeAppURL(appOrigin, pathPrefix) | ||
| SCIMMY.Resources.Schema.basepath(route) | ||
| SCIMMY.Resources.ResourceType.basepath(route) | ||
| SCIMMY.Resources.ServiceProviderConfig.basepath(route) | ||
| for (let Resource of Object.values(SCIMMY.Resources.declared())) { | ||
| Resource.basepath(route) | ||
| } | ||
|
|
||
| const addHandler = ( | ||
| path: string, | ||
| method: 'get' | 'post' | 'put' | 'patch' | 'del', | ||
| handler: Handler | ||
| ) => { | ||
| app[method](`${pathPrefix}${path}`, scimHandler(handler)) | ||
| } | ||
|
|
||
| addHandler('/ServiceProviderConfig', 'get', async (res, _, ctx) => { | ||
| res | ||
| .writeHeader('Content-Type', 'application/json') | ||
| .end(JSON.stringify(await new SCIMMY.Resources.ServiceProviderConfig().read(ctx))) | ||
| }) | ||
|
|
||
| addHandler('/ResourceTypes', 'get', async (res, _, ctx) => { | ||
| const resourceTypes = await new SCIMMY.Resources.ResourceType().read(ctx) | ||
| res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(resourceTypes)) | ||
| }) | ||
| addHandler('/ResourceTypes/:id', 'get', async (res, {query, id}, ctx) => { | ||
| const resourceTypes = await new SCIMMY.Resources.ResourceType(id, query).read(ctx) | ||
| res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(resourceTypes)) | ||
| }) | ||
|
|
||
| addHandler('/Schemas', 'get', async (res, _, ctx) => { | ||
| const resourceTypes = await new SCIMMY.Resources.Schema().read(ctx) | ||
| res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(resourceTypes)) | ||
| }) | ||
| addHandler('/Schemas/:id', 'get', async (res, {id, query}, ctx) => { | ||
| const resourceTypes = await new SCIMMY.Resources.Schema(id, query).read(ctx) | ||
| res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(resourceTypes)) | ||
| }) | ||
|
|
||
| addHandler('/Users', 'get', async (res, {query}, ctx) => { | ||
| const users = await new SCIMMY.Resources.User(undefined, query).read(ctx) | ||
| res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(users)) | ||
| }) | ||
| addHandler('/Users', 'post', async (res, {body}, ctx) => { | ||
| if (body === null) { | ||
| res.writeStatus('400 Bad Request') | ||
| return | ||
| } | ||
| const user = await new SCIMMY.Resources.User().write(body, ctx) | ||
| res | ||
| .writeStatus('201 Created') | ||
| .writeHeader('Content-Type', 'application/scim+json') | ||
| .end(JSON.stringify(user)) | ||
| }) | ||
| addHandler('/Users/:id', 'get', async (res, {id, query}, ctx) => { | ||
| const user = await new SCIMMY.Resources.User(id, query).read(ctx) | ||
| res.writeHeader('Content-Type', 'application/scim+json') | ||
| res.end(JSON.stringify(user)) | ||
| }) | ||
| addHandler('/Users/:id', 'put', async (res, {id, query, body}, ctx) => { | ||
| if (body === null) { | ||
| res.writeStatus('400 Bad Request') | ||
| return | ||
| } | ||
| const updatedUser = await new SCIMMY.Resources.User(id, query).write(body as any, ctx) | ||
| res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(updatedUser)) | ||
| }) | ||
| addHandler('/Users/:id', 'patch', async (res, {id, query, body}, ctx) => { | ||
| if (body === null) { | ||
| res.writeStatus('400 Bad Request') | ||
| return | ||
| } | ||
| const updatedUser = await new SCIMMY.Resources.User(id, query).patch(body as any, ctx) | ||
| res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(updatedUser)) | ||
| }) | ||
| addHandler('/Users/:id', 'del', async (res, {id, query}, ctx) => { | ||
| await new SCIMMY.Resources.User(id, query).dispose(ctx) | ||
| res.writeStatus('204 No Content').end() | ||
| }) | ||
|
|
||
| app.any(`${pathPrefix}/*`, (res) => { | ||
| res.writeStatus('404 Not Found').end() | ||
| }) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was taken from Okta (referenced here). I removed 1 optional group test and fixed one which passed an invalid email but was supposed to fail on something else.
It's a Runscope/Radar spec, but the test parses this file and runs it.