feat: implement SCIM handler for SCIM 2.0 compliance (#25572)

Rewrites the SCIM 2.0 user provisioning handler to be RFC 7644
compliant. Verified against an external IdP Okta.

Behavior is OPT IN
This commit is contained in:
Steven Masley
2026-05-28 10:00:37 -05:00
committed by GitHub
parent 6df1536256
commit 4591212482
26 changed files with 2664 additions and 1091 deletions
+72 -69
View File
@@ -13961,7 +13961,7 @@ const docTemplate = `{
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/coderd.SCIMUser"
"$ref": "#/definitions/legacyscim.SCIMUser"
}
}
],
@@ -13969,7 +13969,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/coderd.SCIMUser"
"$ref": "#/definitions/legacyscim.SCIMUser"
}
}
},
@@ -14035,7 +14035,7 @@ const docTemplate = `{
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/coderd.SCIMUser"
"$ref": "#/definitions/legacyscim.SCIMUser"
}
}
],
@@ -14077,7 +14077,7 @@ const docTemplate = `{
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/coderd.SCIMUser"
"$ref": "#/definitions/legacyscim.SCIMUser"
}
}
],
@@ -14288,71 +14288,6 @@ const docTemplate = `{
"ReinitializeReasonPrebuildClaimed"
]
},
"coderd.SCIMUser": {
"type": "object",
"properties": {
"active": {
"description": "Active is a ptr to prevent the empty value from being interpreted as false.",
"type": "boolean"
},
"emails": {
"type": "array",
"items": {
"type": "object",
"properties": {
"display": {
"type": "string"
},
"primary": {
"type": "boolean"
},
"type": {
"type": "string"
},
"value": {
"type": "string",
"format": "email"
}
}
}
},
"groups": {
"type": "array",
"items": {}
},
"id": {
"type": "string"
},
"meta": {
"type": "object",
"properties": {
"resourceType": {
"type": "string"
}
}
},
"name": {
"type": "object",
"properties": {
"familyName": {
"type": "string"
},
"givenName": {
"type": "string"
}
}
},
"schemas": {
"type": "array",
"items": {
"type": "string"
}
},
"userName": {
"type": "string"
}
}
},
"coderd.cspViolation": {
"type": "object",
"properties": {
@@ -18791,6 +18726,9 @@ const docTemplate = `{
"scim_api_key": {
"type": "string"
},
"scim_use_legacy": {
"type": "boolean"
},
"session_lifetime": {
"$ref": "#/definitions/codersdk.SessionLifetime"
},
@@ -27408,6 +27346,71 @@ const docTemplate = `{
"key.NodePublic": {
"type": "object"
},
"legacyscim.SCIMUser": {
"type": "object",
"properties": {
"active": {
"description": "Active is a ptr to prevent the empty value from being interpreted as false.",
"type": "boolean"
},
"emails": {
"type": "array",
"items": {
"type": "object",
"properties": {
"display": {
"type": "string"
},
"primary": {
"type": "boolean"
},
"type": {
"type": "string"
},
"value": {
"type": "string",
"format": "email"
}
}
}
},
"groups": {
"type": "array",
"items": {}
},
"id": {
"type": "string"
},
"meta": {
"type": "object",
"properties": {
"resourceType": {
"type": "string"
}
}
},
"name": {
"type": "object",
"properties": {
"familyName": {
"type": "string"
},
"givenName": {
"type": "string"
}
}
},
"schemas": {
"type": "array",
"items": {
"type": "string"
}
},
"userName": {
"type": "string"
}
}
},
"netcheck.Report": {
"type": "object",
"properties": {
+72 -69
View File
@@ -12389,7 +12389,7 @@
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/coderd.SCIMUser"
"$ref": "#/definitions/legacyscim.SCIMUser"
}
}
],
@@ -12397,7 +12397,7 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/coderd.SCIMUser"
"$ref": "#/definitions/legacyscim.SCIMUser"
}
}
},
@@ -12455,7 +12455,7 @@
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/coderd.SCIMUser"
"$ref": "#/definitions/legacyscim.SCIMUser"
}
}
],
@@ -12493,7 +12493,7 @@
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/coderd.SCIMUser"
"$ref": "#/definitions/legacyscim.SCIMUser"
}
}
],
@@ -12692,71 +12692,6 @@
"enum": ["prebuild_claimed"],
"x-enum-varnames": ["ReinitializeReasonPrebuildClaimed"]
},
"coderd.SCIMUser": {
"type": "object",
"properties": {
"active": {
"description": "Active is a ptr to prevent the empty value from being interpreted as false.",
"type": "boolean"
},
"emails": {
"type": "array",
"items": {
"type": "object",
"properties": {
"display": {
"type": "string"
},
"primary": {
"type": "boolean"
},
"type": {
"type": "string"
},
"value": {
"type": "string",
"format": "email"
}
}
}
},
"groups": {
"type": "array",
"items": {}
},
"id": {
"type": "string"
},
"meta": {
"type": "object",
"properties": {
"resourceType": {
"type": "string"
}
}
},
"name": {
"type": "object",
"properties": {
"familyName": {
"type": "string"
},
"givenName": {
"type": "string"
}
}
},
"schemas": {
"type": "array",
"items": {
"type": "string"
}
},
"userName": {
"type": "string"
}
}
},
"coderd.cspViolation": {
"type": "object",
"properties": {
@@ -17062,6 +16997,9 @@
"scim_api_key": {
"type": "string"
},
"scim_use_legacy": {
"type": "boolean"
},
"session_lifetime": {
"$ref": "#/definitions/codersdk.SessionLifetime"
},
@@ -25273,6 +25211,71 @@
"key.NodePublic": {
"type": "object"
},
"legacyscim.SCIMUser": {
"type": "object",
"properties": {
"active": {
"description": "Active is a ptr to prevent the empty value from being interpreted as false.",
"type": "boolean"
},
"emails": {
"type": "array",
"items": {
"type": "object",
"properties": {
"display": {
"type": "string"
},
"primary": {
"type": "boolean"
},
"type": {
"type": "string"
},
"value": {
"type": "string",
"format": "email"
}
}
}
},
"groups": {
"type": "array",
"items": {}
},
"id": {
"type": "string"
},
"meta": {
"type": "object",
"properties": {
"resourceType": {
"type": "string"
}
}
},
"name": {
"type": "object",
"properties": {
"familyName": {
"type": "string"
},
"givenName": {
"type": "string"
}
}
},
"schemas": {
"type": "array",
"items": {
"type": "string"
}
},
"userName": {
"type": "string"
}
}
},
"netcheck.Report": {
"type": "object",
"properties": {
+31 -1
View File
@@ -742,6 +742,29 @@ var (
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()
subjectSCIM = rbac.Subject{
Type: rbac.SubjectTypeSCIMProvisioner,
FriendlyName: "SCIM Provisioner",
ID: uuid.Nil.String(),
Roles: rbac.Roles([]rbac.Role{
{
Identifier: rbac.RoleIdentifier{Name: "scim"},
DisplayName: "SCIM",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceSystem.Type: {policy.ActionRead}, // Required for idp config reads, this should be fixed
rbac.ResourceAssignRole.Type: rbac.ResourceAssignRole.AvailableActions(),
rbac.ResourceAssignOrgRole.Type: rbac.ResourceAssignOrgRole.AvailableActions(),
rbac.ResourceUser.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionRead, policy.ActionUpdatePersonal},
rbac.ResourceOrganization.Type: {policy.ActionRead},
rbac.ResourceOrganizationMember.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate},
}),
User: []rbac.Permission{},
ByOrgID: map[string]rbac.OrgPermissions{},
},
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()
)
// AsProvisionerd returns a context with an actor that has permissions required
@@ -872,6 +895,12 @@ func AsAIProviderMetadataReader(ctx context.Context) context.Context {
return As(ctx, subjectAIProviderMetadataReader)
}
// AsSCIMProvisioner returns a context with an actor that has permissions required for
// handling the /scim/v2 routes and provisioning users via SCIM.
func AsSCIMProvisioner(ctx context.Context) context.Context {
return As(ctx, subjectSCIM)
}
var AsRemoveActor = rbac.Subject{
ID: "remove-actor",
}
@@ -4659,7 +4688,8 @@ func (q *querier) GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UU
}
func (q *querier) GetUserCount(ctx context.Context, includeSystem bool) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
// If you can read every user, then you can read the count of users.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
return 0, err
}
return q.db.GetUserCount(ctx, includeSystem)
+1 -1
View File
@@ -4578,7 +4578,7 @@ func (s *MethodTestSuite) TestSystemFunctions() {
}))
s.Run("GetUserCount", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetUserCount(gomock.Any(), false).Return(int64(0), nil).AnyTimes()
check.Args(false).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(int64(0))
check.Args(false).Asserts(rbac.ResourceUser, policy.ActionRead).Returns(int64(0))
}))
s.Run("GetTemplates", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetTemplates(gomock.Any()).Return([]database.Template{}, nil).AnyTimes()
+2
View File
@@ -413,6 +413,8 @@ func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams,
arg.AfterID,
arg.Search,
arg.Name,
arg.ExactUsername,
arg.ExactEmail,
pq.Array(arg.Status),
pq.Array(arg.RbacRole),
arg.LastSeenBefore,
+42 -26
View File
@@ -28052,65 +28052,77 @@ WHERE
name ILIKE concat('%', $3, '%')
ELSE true
END
-- Filter by exact username
AND CASE
WHEN $4 :: text != '' THEN
lower(username) = lower($4)
ELSE true
END
-- Filter by exact email
AND CASE
WHEN $5 :: text != '' THEN
lower(email) = lower($5)
ELSE true
END
-- Filter by status
AND CASE
-- @status needs to be a text because it can be empty, If it was
-- user_status enum, it would not.
WHEN cardinality($4 :: user_status[]) > 0 THEN
status = ANY($4 :: user_status[])
WHEN cardinality($6 :: user_status[]) > 0 THEN
status = ANY($6 :: user_status[])
ELSE true
END
-- Filter by rbac_roles
AND CASE
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
-- everyone is a member.
WHEN cardinality($5 :: text[]) > 0 AND 'member' != ANY($5 :: text[]) THEN
rbac_roles && $5 :: text[]
WHEN cardinality($7 :: text[]) > 0 AND 'member' != ANY($7 :: text[]) THEN
rbac_roles && $7 :: text[]
ELSE true
END
-- Filter by last_seen
AND CASE
WHEN $6 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
last_seen_at <= $6
ELSE true
END
AND CASE
WHEN $7 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
last_seen_at >= $7
ELSE true
END
-- Filter by created_at
AND CASE
WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
created_at <= $8
last_seen_at <= $8
ELSE true
END
AND CASE
WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
created_at >= $9
last_seen_at >= $9
ELSE true
END
-- Filter by created_at
AND CASE
WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
created_at <= $10
ELSE true
END
AND CASE
WHEN $11 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
created_at >= $11
ELSE true
END
-- Filter by system type
AND CASE
WHEN $10::bool THEN TRUE
WHEN $12::bool THEN TRUE
ELSE is_system = false
END
-- Filter by github.com user ID
AND CASE
WHEN $11 :: bigint != 0 THEN
github_com_user_id = $11
WHEN $13 :: bigint != 0 THEN
github_com_user_id = $13
ELSE true
END
-- Filter by login_type
AND CASE
WHEN cardinality($12 :: login_type[]) > 0 THEN
login_type = ANY($12 :: login_type[])
WHEN cardinality($14 :: login_type[]) > 0 THEN
login_type = ANY($14 :: login_type[])
ELSE true
END
-- Filter by service account.
AND CASE
WHEN $13 :: boolean IS NOT NULL THEN
is_service_account = $13 :: boolean
WHEN $15 :: boolean IS NOT NULL THEN
is_service_account = $15 :: boolean
ELSE true
END
-- End of filters
@@ -28119,16 +28131,18 @@ WHERE
-- @authorize_filter
ORDER BY
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
LOWER(username) ASC OFFSET $14
LOWER(username) ASC OFFSET $16
LIMIT
-- A null limit means "no limit", so 0 means return all
NULLIF($15 :: int, 0)
NULLIF($17 :: int, 0)
`
type GetUsersParams struct {
AfterID uuid.UUID `db:"after_id" json:"after_id"`
Search string `db:"search" json:"search"`
Name string `db:"name" json:"name"`
ExactUsername string `db:"exact_username" json:"exact_username"`
ExactEmail string `db:"exact_email" json:"exact_email"`
Status []UserStatus `db:"status" json:"status"`
RbacRole []string `db:"rbac_role" json:"rbac_role"`
LastSeenBefore time.Time `db:"last_seen_before" json:"last_seen_before"`
@@ -28173,6 +28187,8 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse
arg.AfterID,
arg.Search,
arg.Name,
arg.ExactUsername,
arg.ExactEmail,
pq.Array(arg.Status),
pq.Array(arg.RbacRole),
arg.LastSeenBefore,
+12
View File
@@ -486,6 +486,18 @@ WHERE
name ILIKE concat('%', @name, '%')
ELSE true
END
-- Filter by exact username
AND CASE
WHEN @exact_username :: text != '' THEN
lower(username) = lower(@exact_username)
ELSE true
END
-- Filter by exact email
AND CASE
WHEN @exact_email :: text != '' THEN
lower(email) = lower(@exact_email)
ELSE true
END
-- Filter by status
AND CASE
-- @status needs to be a text because it can be empty, If it was
+1
View File
@@ -85,6 +85,7 @@ const (
SubjectTypeWorkspaceBuilder SubjectType = "workspace_builder"
SubjectTypeChatd SubjectType = "chatd"
SubjectTypeAIProviderMetadataReader SubjectType = "ai_provider_metadata_reader"
SubjectTypeSCIMProvisioner SubjectType = "scim_provisioner"
)
const (