mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
Generated
+72
-69
@@ -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": {
|
||||
|
||||
Generated
+72
-69
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+42
-26
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,6 +85,7 @@ const (
|
||||
SubjectTypeWorkspaceBuilder SubjectType = "workspace_builder"
|
||||
SubjectTypeChatd SubjectType = "chatd"
|
||||
SubjectTypeAIProviderMetadataReader SubjectType = "ai_provider_metadata_reader"
|
||||
SubjectTypeSCIMProvisioner SubjectType = "scim_provisioner"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
Reference in New Issue
Block a user