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 (
|
||||
|
||||
@@ -638,6 +638,7 @@ type DeploymentValues struct {
|
||||
AgentFallbackTroubleshootingURL serpent.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"`
|
||||
BrowserOnly serpent.Bool `json:"browser_only,omitempty" typescript:",notnull"`
|
||||
SCIMAPIKey serpent.String `json:"scim_api_key,omitempty" typescript:",notnull"`
|
||||
UseLegacySCIM serpent.Bool `json:"scim_use_legacy,omitempty" typescript:",notnull"`
|
||||
ExternalTokenEncryptionKeys serpent.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"`
|
||||
Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"`
|
||||
RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"`
|
||||
@@ -3447,6 +3448,18 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
Annotations: serpent.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"),
|
||||
Value: &c.SCIMAPIKey,
|
||||
},
|
||||
{
|
||||
Name: "SCIM Use Legacy",
|
||||
// The legacy SCIM is a weird mix of SCIM 1.0 and SCIM 2.0
|
||||
Description: "Use the legacy SCIM implementation instead of the SCIM 2.0 handler. This is provided for backward compatibility for existing users.",
|
||||
Flag: "scim-use-legacy",
|
||||
Env: "CODER_SCIM_USE_LEGACY",
|
||||
Hidden: true,
|
||||
// TODO: When SCIM 2.0 has been tested more, flip this to false to default to the new scim
|
||||
Default: "true",
|
||||
Annotations: serpent.Annotations{}.Mark(annotationEnterpriseKey, "true"),
|
||||
Value: &c.UseLegacySCIM,
|
||||
},
|
||||
{
|
||||
Name: "External Token Encryption Keys",
|
||||
Description: "Encrypt OIDC and Git authentication tokens with AES-256-GCM in the database. The value must be a comma-separated list of base64-encoded keys. Each key, when base64-decoded, must be exactly 32 bytes in length. The first key will be used to encrypt new values. Subsequent keys will be used as a fallback when decrypting. During normal operation it is recommended to only set one key unless you are in the process of rotating keys with the `coder server dbcrypt rotate` command.",
|
||||
|
||||
Generated
+14
-14
@@ -4520,9 +4520,9 @@ curl -X POST http://coder-server:8080/scim/v2/Users \
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|----------------------------------------------|----------|-------------|
|
||||
| `body` | body | [coderd.SCIMUser](schemas.md#coderdscimuser) | true | New user |
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|------------------------------------------------------|----------|-------------|
|
||||
| `body` | body | [legacyscim.SCIMUser](schemas.md#legacyscimscimuser) | true | New user |
|
||||
|
||||
### Example responses
|
||||
|
||||
@@ -4559,9 +4559,9 @@ curl -X POST http://coder-server:8080/scim/v2/Users \
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|----------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [coderd.SCIMUser](schemas.md#coderdscimuser) |
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [legacyscim.SCIMUser](schemas.md#legacyscimscimuser) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -4638,10 +4638,10 @@ curl -X PUT http://coder-server:8080/scim/v2/Users/{id} \
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|----------------------------------------------|----------|----------------------|
|
||||
| `id` | path | string(uuid) | true | User ID |
|
||||
| `body` | body | [coderd.SCIMUser](schemas.md#coderdscimuser) | true | Replace user request |
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|------------------------------------------------------|----------|----------------------|
|
||||
| `id` | path | string(uuid) | true | User ID |
|
||||
| `body` | body | [legacyscim.SCIMUser](schemas.md#legacyscimscimuser) | true | Replace user request |
|
||||
|
||||
### Example responses
|
||||
|
||||
@@ -4730,10 +4730,10 @@ curl -X PATCH http://coder-server:8080/scim/v2/Users/{id} \
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|----------------------------------------------|----------|---------------------|
|
||||
| `id` | path | string(uuid) | true | User ID |
|
||||
| `body` | body | [coderd.SCIMUser](schemas.md#coderdscimuser) | true | Update user request |
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|------------------------------------------------------|----------|---------------------|
|
||||
| `id` | path | string(uuid) | true | User ID |
|
||||
| `body` | body | [legacyscim.SCIMUser](schemas.md#legacyscimscimuser) | true | Update user request |
|
||||
|
||||
### Example responses
|
||||
|
||||
|
||||
Generated
+1
@@ -538,6 +538,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
|
||||
"workspace_agent_logs": 0
|
||||
},
|
||||
"scim_api_key": "string",
|
||||
"scim_use_legacy": true,
|
||||
"session_lifetime": {
|
||||
"default_duration": 0,
|
||||
"default_token_lifetime": 0,
|
||||
|
||||
Generated
+54
-51
@@ -220,57 +220,6 @@
|
||||
|--------------------|
|
||||
| `prebuild_claimed` |
|
||||
|
||||
## coderd.SCIMUser
|
||||
|
||||
```json
|
||||
{
|
||||
"active": true,
|
||||
"emails": [
|
||||
{
|
||||
"display": "string",
|
||||
"primary": true,
|
||||
"type": "string",
|
||||
"value": "user@example.com"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
null
|
||||
],
|
||||
"id": "string",
|
||||
"meta": {
|
||||
"resourceType": "string"
|
||||
},
|
||||
"name": {
|
||||
"familyName": "string",
|
||||
"givenName": "string"
|
||||
},
|
||||
"schemas": [
|
||||
"string"
|
||||
],
|
||||
"userName": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|------------------|--------------------|----------|--------------|-----------------------------------------------------------------------------|
|
||||
| `active` | boolean | false | | Active is a ptr to prevent the empty value from being interpreted as false. |
|
||||
| `emails` | array of object | false | | |
|
||||
| `» display` | string | false | | |
|
||||
| `» primary` | boolean | false | | |
|
||||
| `» type` | string | false | | |
|
||||
| `» value` | string | false | | |
|
||||
| `groups` | array of undefined | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `meta` | object | false | | |
|
||||
| `» resourceType` | string | false | | |
|
||||
| `name` | object | false | | |
|
||||
| `» familyName` | string | false | | |
|
||||
| `» givenName` | string | false | | |
|
||||
| `schemas` | array of string | false | | |
|
||||
| `userName` | string | false | | |
|
||||
|
||||
## coderd.cspViolation
|
||||
|
||||
```json
|
||||
@@ -6058,6 +6007,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"workspace_agent_logs": 0
|
||||
},
|
||||
"scim_api_key": "string",
|
||||
"scim_use_legacy": true,
|
||||
"session_lifetime": {
|
||||
"default_duration": 0,
|
||||
"default_token_lifetime": 0,
|
||||
@@ -6657,6 +6607,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"workspace_agent_logs": 0
|
||||
},
|
||||
"scim_api_key": "string",
|
||||
"scim_use_legacy": true,
|
||||
"session_lifetime": {
|
||||
"default_duration": 0,
|
||||
"default_token_lifetime": 0,
|
||||
@@ -6817,6 +6768,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
| `redirect_to_access_url` | boolean | false | | |
|
||||
| `retention` | [codersdk.RetentionConfig](#codersdkretentionconfig) | false | | |
|
||||
| `scim_api_key` | string | false | | |
|
||||
| `scim_use_legacy` | boolean | false | | |
|
||||
| `session_lifetime` | [codersdk.SessionLifetime](#codersdksessionlifetime) | false | | |
|
||||
| `ssh_keygen_algorithm` | string | false | | |
|
||||
| `stats_collection` | [codersdk.StatsCollectionConfig](#codersdkstatscollectionconfig) | false | | |
|
||||
@@ -17915,6 +17867,57 @@ Zero means unspecified. There might be a limit, but the client need not try to r
|
||||
|
||||
None
|
||||
|
||||
## legacyscim.SCIMUser
|
||||
|
||||
```json
|
||||
{
|
||||
"active": true,
|
||||
"emails": [
|
||||
{
|
||||
"display": "string",
|
||||
"primary": true,
|
||||
"type": "string",
|
||||
"value": "user@example.com"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
null
|
||||
],
|
||||
"id": "string",
|
||||
"meta": {
|
||||
"resourceType": "string"
|
||||
},
|
||||
"name": {
|
||||
"familyName": "string",
|
||||
"givenName": "string"
|
||||
},
|
||||
"schemas": [
|
||||
"string"
|
||||
],
|
||||
"userName": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|------------------|--------------------|----------|--------------|-----------------------------------------------------------------------------|
|
||||
| `active` | boolean | false | | Active is a ptr to prevent the empty value from being interpreted as false. |
|
||||
| `emails` | array of object | false | | |
|
||||
| `» display` | string | false | | |
|
||||
| `» primary` | boolean | false | | |
|
||||
| `» type` | string | false | | |
|
||||
| `» value` | string | false | | |
|
||||
| `groups` | array of undefined | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `meta` | object | false | | |
|
||||
| `» resourceType` | string | false | | |
|
||||
| `name` | object | false | | |
|
||||
| `» familyName` | string | false | | |
|
||||
| `» givenName` | string | false | | |
|
||||
| `schemas` | array of string | false | | |
|
||||
| `userName` | string | false | | |
|
||||
|
||||
## netcheck.Report
|
||||
|
||||
```json
|
||||
|
||||
@@ -95,6 +95,7 @@ func (r *RootCmd) Server(_ func()) *serpent.Command {
|
||||
ConnectionLogging: true,
|
||||
BrowserOnly: options.DeploymentValues.BrowserOnly.Value(),
|
||||
SCIMAPIKey: []byte(options.DeploymentValues.SCIMAPIKey.Value()),
|
||||
UseLegacySCIM: options.DeploymentValues.UseLegacySCIM.Value(),
|
||||
RBAC: true,
|
||||
DERPServerRelayAddress: options.DeploymentValues.DERP.Server.RelayURL.String(),
|
||||
DERPServerRegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()),
|
||||
|
||||
+11
-34
@@ -622,40 +622,12 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
})
|
||||
})
|
||||
|
||||
if len(options.SCIMAPIKey) != 0 {
|
||||
api.AGPL.RootHandler.Route("/scim/v2", func(r chi.Router) {
|
||||
r.Use(
|
||||
api.RequireFeatureMW(codersdk.FeatureSCIM),
|
||||
)
|
||||
r.Get("/ServiceProviderConfig", api.scimServiceProviderConfig)
|
||||
r.Post("/Users", api.scimPostUser)
|
||||
r.Route("/Users", func(r chi.Router) {
|
||||
r.Get("/", api.scimGetUsers)
|
||||
r.Post("/", api.scimPostUser)
|
||||
r.Get("/{id}", api.scimGetUser)
|
||||
r.Patch("/{id}", api.scimPatchUser)
|
||||
r.Put("/{id}", api.scimPutUser)
|
||||
})
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := r.URL.String()
|
||||
httpapi.Write(r.Context(), w, http.StatusNotFound, codersdk.Response{
|
||||
Message: fmt.Sprintf("SCIM endpoint %s not found", u),
|
||||
Detail: "This endpoint is not implemented. If it is correct and required, please contact support.",
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Show a helpful 404 error. Because this is not under the /api/v2 routes,
|
||||
// the frontend is the fallback. A html page is not a helpful error for
|
||||
// a SCIM provider. This JSON has a call to action that __may__ resolve
|
||||
// the issue.
|
||||
// Using Mount to cover all subroute possibilities.
|
||||
api.AGPL.RootHandler.Mount("/scim/v2", http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), w, http.StatusNotFound, codersdk.Response{
|
||||
Message: "SCIM is disabled, please contact your administrator if you believe this is an error",
|
||||
Detail: "SCIM endpoints are disabled if no SCIM is configured. Configure 'CODER_SCIM_AUTH_HEADER' to enable.",
|
||||
})
|
||||
})))
|
||||
var mountScimError error
|
||||
api.AGPL.RootHandler.Route("/scim", func(r chi.Router) {
|
||||
mountScimError = api.mountScimRoute(options, r)
|
||||
})
|
||||
if mountScimError != nil {
|
||||
return nil, xerrors.Errorf("mount scim routes: %w", mountScimError)
|
||||
}
|
||||
|
||||
// We always want to run the replica manager even if we don't have DERP
|
||||
@@ -754,6 +726,11 @@ type Options struct {
|
||||
// Whether to block non-browser connections.
|
||||
BrowserOnly bool
|
||||
SCIMAPIKey []byte
|
||||
// UseLegacySCIM opts into the legacy SCIM handler implementation
|
||||
// (imulab/go-scim based). This is provided for backward compatibility
|
||||
// during the transition to the new elimity-com/scim implementation.
|
||||
// It will be removed in a future release.
|
||||
UseLegacySCIM bool
|
||||
|
||||
ExternalTokenEncryption []dbcrypt.Cipher
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ type Options struct {
|
||||
BrowserOnly bool
|
||||
EntitlementsUpdateInterval time.Duration
|
||||
SCIMAPIKey []byte
|
||||
UseLegacySCIM bool
|
||||
UserWorkspaceQuota int
|
||||
ProxyHealthInterval time.Duration
|
||||
LicenseOptions *LicenseOptions
|
||||
@@ -108,6 +109,7 @@ func NewWithAPI(t *testing.T, options *Options) (
|
||||
AuditLogging: options.AuditLogging,
|
||||
BrowserOnly: options.BrowserOnly,
|
||||
SCIMAPIKey: options.SCIMAPIKey,
|
||||
UseLegacySCIM: options.UseLegacySCIM,
|
||||
DERPServerRelayAddress: serverURL.String(),
|
||||
DERPServerRegionID: int(oop.DeploymentValues.DERP.Server.RegionID.Value()),
|
||||
ReplicaSyncUpdateInterval: options.ReplicaSyncUpdateInterval,
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
package coderd
|
||||
// Package legacyscim preserves the old imulab/go-scim based SCIM handler.
|
||||
// It was added in May 2026 to keep an opt-out path available during the
|
||||
// rollout of the new SCIM 2.0 implementation in
|
||||
// enterprise/coderd/scim. Once that implementation has run in production
|
||||
// for a while and the CODER_SCIM_USE_LEGACY default is flipped, remove
|
||||
// this package in its entirety.
|
||||
//
|
||||
// Enabled via the UseLegacySCIM option.
|
||||
//
|
||||
// Deprecated: Use the enterprise/coderd/scim package instead.
|
||||
package legacyscim
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -6,6 +16,8 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -16,17 +28,64 @@ import (
|
||||
"github.com/imulab/go-scim/pkg/v2/spec"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
agpl "github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/idpsync"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/scim"
|
||||
)
|
||||
|
||||
func (api *API) scimVerifyAuthHeader(r *http.Request) bool {
|
||||
// LegacyServer is the old SCIM handler implementation, kept for backward
|
||||
// compatibility. It uses the imulab/go-scim library and custom JSON handling.
|
||||
type LegacyServer struct {
|
||||
Logger slog.Logger
|
||||
Database database.Store
|
||||
IDPSync idpsync.IDPSync
|
||||
AGPL *agpl.API
|
||||
AccessURL *url.URL
|
||||
SCIMAPIKey []byte
|
||||
Auditor *atomic.Pointer[audit.Auditor]
|
||||
}
|
||||
|
||||
// Handler returns an http.Handler that serves the legacy SCIM endpoints.
|
||||
// It should be mounted at /scim/v2.
|
||||
func (s *LegacyServer) Handler() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/ServiceProviderConfig", s.scimServiceProviderConfig)
|
||||
r.Post("/Users", s.scimPostUser)
|
||||
r.Route("/Users", func(r chi.Router) {
|
||||
r.Get("/", s.scimGetUsers)
|
||||
r.Post("/", s.scimPostUser)
|
||||
r.Get("/{id}", s.scimGetUser)
|
||||
r.Patch("/{id}", s.scimPatchUser)
|
||||
r.Put("/{id}", s.scimPutUser)
|
||||
})
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := r.URL.String()
|
||||
httpapi.Write(r.Context(), w, http.StatusNotFound, codersdk.Response{
|
||||
Message: "SCIM endpoint not found: " + u,
|
||||
Detail: "This endpoint is not implemented. If it is correct and required, please contact support.",
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// AuthMiddleware verifies the SCIM Bearer token.
|
||||
func (s *LegacyServer) AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if !s.scimVerifyAuthHeader(r) {
|
||||
scimUnauthorized(rw)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(rw, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LegacyServer) scimVerifyAuthHeader(r *http.Request) bool {
|
||||
bearer := []byte("bearer ")
|
||||
hdr := []byte(r.Header.Get("Authorization"))
|
||||
|
||||
@@ -35,11 +94,11 @@ func (api *API) scimVerifyAuthHeader(r *http.Request) bool {
|
||||
hdr = hdr[len(bearer):]
|
||||
}
|
||||
|
||||
return len(api.SCIMAPIKey) != 0 && subtle.ConstantTimeCompare(hdr, api.SCIMAPIKey) == 1
|
||||
return len(s.SCIMAPIKey) != 0 && subtle.ConstantTimeCompare(hdr, s.SCIMAPIKey) == 1
|
||||
}
|
||||
|
||||
func scimUnauthorized(rw http.ResponseWriter) {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusUnauthorized, "invalidAuthorization", xerrors.New("invalid authorization")))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusUnauthorized, "invalidAuthorization", xerrors.New("invalid authorization")))
|
||||
}
|
||||
|
||||
// scimServiceProviderConfig returns a static SCIM service provider configuration.
|
||||
@@ -50,7 +109,7 @@ func scimUnauthorized(rw http.ResponseWriter) {
|
||||
// @Tags Enterprise
|
||||
// @Success 200
|
||||
// @Router /scim/v2/ServiceProviderConfig [get]
|
||||
func (api *API) scimServiceProviderConfig(rw http.ResponseWriter, _ *http.Request) {
|
||||
func (s *LegacyServer) scimServiceProviderConfig(rw http.ResponseWriter, _ *http.Request) {
|
||||
// No auth needed to query this endpoint.
|
||||
|
||||
rw.Header().Set("Content-Type", spec.ApplicationScimJson)
|
||||
@@ -60,35 +119,35 @@ func (api *API) scimServiceProviderConfig(rw http.ResponseWriter, _ *http.Reques
|
||||
// Increment this time if you make any changes to the provider config.
|
||||
providerUpdated := time.Date(2024, 10, 25, 17, 0, 0, 0, time.UTC)
|
||||
var location string
|
||||
locURL, err := api.AccessURL.Parse("/scim/v2/ServiceProviderConfig")
|
||||
locURL, err := s.AccessURL.Parse("/scim/v2/ServiceProviderConfig")
|
||||
if err == nil {
|
||||
location = locURL.String()
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(rw)
|
||||
enc.SetEscapeHTML(true)
|
||||
_ = enc.Encode(scim.ServiceProviderConfig{
|
||||
_ = enc.Encode(ServiceProviderConfig{
|
||||
Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"},
|
||||
DocURI: "https://coder.com/docs/admin/users/oidc-auth#scim",
|
||||
Patch: scim.Supported{
|
||||
Patch: Supported{
|
||||
Supported: true,
|
||||
},
|
||||
Bulk: scim.BulkSupported{
|
||||
Bulk: BulkSupported{
|
||||
Supported: false,
|
||||
},
|
||||
Filter: scim.FilterSupported{
|
||||
Filter: FilterSupported{
|
||||
Supported: false,
|
||||
},
|
||||
ChangePassword: scim.Supported{
|
||||
ChangePassword: Supported{
|
||||
Supported: false,
|
||||
},
|
||||
Sort: scim.Supported{
|
||||
Sort: Supported{
|
||||
Supported: false,
|
||||
},
|
||||
ETag: scim.Supported{
|
||||
ETag: Supported{
|
||||
Supported: false,
|
||||
},
|
||||
AuthSchemes: []scim.AuthenticationScheme{
|
||||
AuthSchemes: []AuthenticationScheme{
|
||||
{
|
||||
Type: "oauthbearertoken",
|
||||
Name: "HTTP Header Authentication",
|
||||
@@ -96,7 +155,7 @@ func (api *API) scimServiceProviderConfig(rw http.ResponseWriter, _ *http.Reques
|
||||
DocURI: "https://coder.com/docs/admin/users/oidc-auth#scim",
|
||||
},
|
||||
},
|
||||
Meta: scim.ServiceProviderMeta{
|
||||
Meta: ServiceProviderMeta{
|
||||
Created: providerUpdated,
|
||||
LastModified: providerUpdated,
|
||||
Location: location,
|
||||
@@ -118,8 +177,8 @@ func (api *API) scimServiceProviderConfig(rw http.ResponseWriter, _ *http.Reques
|
||||
// @Router /scim/v2/Users [get]
|
||||
//
|
||||
//nolint:revive
|
||||
func (api *API) scimGetUsers(rw http.ResponseWriter, r *http.Request) {
|
||||
if !api.scimVerifyAuthHeader(r) {
|
||||
func (s *LegacyServer) scimGetUsers(rw http.ResponseWriter, r *http.Request) {
|
||||
if !s.scimVerifyAuthHeader(r) {
|
||||
scimUnauthorized(rw)
|
||||
return
|
||||
}
|
||||
@@ -146,13 +205,13 @@ func (api *API) scimGetUsers(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Router /scim/v2/Users/{id} [get]
|
||||
//
|
||||
//nolint:revive
|
||||
func (api *API) scimGetUser(rw http.ResponseWriter, r *http.Request) {
|
||||
if !api.scimVerifyAuthHeader(r) {
|
||||
func (s *LegacyServer) scimGetUser(rw http.ResponseWriter, r *http.Request) {
|
||||
if !s.scimVerifyAuthHeader(r) {
|
||||
scimUnauthorized(rw)
|
||||
return
|
||||
}
|
||||
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusNotFound, spec.ErrNotFound.Type, xerrors.New("endpoint will always return 404")))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusNotFound, spec.ErrNotFound.Type, xerrors.New("endpoint will always return 404")))
|
||||
}
|
||||
|
||||
// We currently use our own struct instead of using the SCIM package. This was
|
||||
@@ -193,20 +252,20 @@ var SCIMAuditAdditionalFields = map[string]string{
|
||||
// @Security Authorization
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param request body coderd.SCIMUser true "New user"
|
||||
// @Success 200 {object} coderd.SCIMUser
|
||||
// @Param request body legacyscim.SCIMUser true "New user"
|
||||
// @Success 200 {object} legacyscim.SCIMUser
|
||||
// @Router /scim/v2/Users [post]
|
||||
func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
|
||||
func (s *LegacyServer) scimPostUser(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.scimVerifyAuthHeader(r) {
|
||||
if !s.scimVerifyAuthHeader(r) {
|
||||
scimUnauthorized(rw)
|
||||
return
|
||||
}
|
||||
|
||||
auditor := *api.AGPL.Auditor.Load()
|
||||
auditor := *s.Auditor.Load()
|
||||
aReq, commitAudit := audit.InitRequest[database.User](rw, &audit.RequestParams{
|
||||
Audit: auditor,
|
||||
Log: api.Logger,
|
||||
Log: s.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionCreate,
|
||||
AdditionalFields: SCIMAuditAdditionalFields,
|
||||
@@ -216,12 +275,12 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
|
||||
var sUser SCIMUser
|
||||
err := json.NewDecoder(r.Body).Decode(&sUser)
|
||||
if err != nil {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
|
||||
return
|
||||
}
|
||||
|
||||
if sUser.Active == nil {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -234,12 +293,12 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidEmail", xerrors.New("no primary email provided")))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidEmail", xerrors.New("no primary email provided")))
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:gocritic
|
||||
dbUser, err := api.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
|
||||
dbUser, err := s.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
|
||||
Email: email,
|
||||
Username: sUser.UserName,
|
||||
})
|
||||
@@ -253,7 +312,7 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if *sUser.Active && dbUser.Status == database.UserStatusSuspended {
|
||||
//nolint:gocritic
|
||||
newUser, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
|
||||
newUser, err := s.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
|
||||
ID: dbUser.ID,
|
||||
// The user will get transitioned to Active after logging in.
|
||||
Status: database.UserStatusDormant,
|
||||
@@ -295,23 +354,23 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
|
||||
// This is to preserve single org deployment behavior.
|
||||
organizations := []uuid.UUID{}
|
||||
//nolint:gocritic // SCIM operations are a system user
|
||||
orgSync, err := api.IDPSync.OrganizationSyncSettings(dbauthz.AsSystemRestricted(ctx), api.Database)
|
||||
orgSync, err := s.IDPSync.OrganizationSyncSettings(dbauthz.AsSystemRestricted(ctx), s.Database)
|
||||
if err != nil {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusInternalServerError, "internalError", xerrors.Errorf("failed to get organization sync settings: %w", err)))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusInternalServerError, "internalError", xerrors.Errorf("failed to get organization sync settings: %w", err)))
|
||||
return
|
||||
}
|
||||
if orgSync.AssignDefault {
|
||||
//nolint:gocritic // SCIM operations are a system user
|
||||
defaultOrganization, err := api.Database.GetDefaultOrganization(dbauthz.AsSystemRestricted(ctx))
|
||||
defaultOrganization, err := s.Database.GetDefaultOrganization(dbauthz.AsSystemRestricted(ctx))
|
||||
if err != nil {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusInternalServerError, "internalError", xerrors.Errorf("failed to get default organization: %w", err)))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusInternalServerError, "internalError", xerrors.Errorf("failed to get default organization: %w", err)))
|
||||
return
|
||||
}
|
||||
organizations = append(organizations, defaultOrganization.ID)
|
||||
}
|
||||
|
||||
//nolint:gocritic // needed for SCIM
|
||||
dbUser, err = api.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, agpl.CreateUserRequest{
|
||||
dbUser, err = s.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), s.Database, agpl.CreateUserRequest{
|
||||
CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{
|
||||
Username: sUser.UserName,
|
||||
Email: email,
|
||||
@@ -322,7 +381,7 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
|
||||
SkipNotifications: true,
|
||||
})
|
||||
if err != nil {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusInternalServerError, "internalError", xerrors.Errorf("failed to create user: %w", err)))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusInternalServerError, "internalError", xerrors.Errorf("failed to create user: %w", err)))
|
||||
return
|
||||
}
|
||||
aReq.New = dbUser
|
||||
@@ -342,20 +401,20 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Produce application/scim+json
|
||||
// @Tags Enterprise
|
||||
// @Param id path string true "User ID" format(uuid)
|
||||
// @Param request body coderd.SCIMUser true "Update user request"
|
||||
// @Param request body legacyscim.SCIMUser true "Update user request"
|
||||
// @Success 200 {object} codersdk.User
|
||||
// @Router /scim/v2/Users/{id} [patch]
|
||||
func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
|
||||
func (s *LegacyServer) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.scimVerifyAuthHeader(r) {
|
||||
if !s.scimVerifyAuthHeader(r) {
|
||||
scimUnauthorized(rw)
|
||||
return
|
||||
}
|
||||
|
||||
auditor := *api.AGPL.Auditor.Load()
|
||||
auditor := *s.Auditor.Load()
|
||||
aReq, commitAudit := audit.InitRequestWithCancel[database.User](rw, &audit.RequestParams{
|
||||
Audit: auditor,
|
||||
Log: api.Logger,
|
||||
Log: s.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionWrite,
|
||||
})
|
||||
@@ -367,19 +426,19 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
|
||||
var sUser SCIMUser
|
||||
err := json.NewDecoder(r.Body).Decode(&sUser)
|
||||
if err != nil {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
|
||||
return
|
||||
}
|
||||
sUser.ID = id
|
||||
|
||||
uid, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidId", xerrors.Errorf("id must be a uuid: %w", err)))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidId", xerrors.Errorf("id must be a uuid: %w", err)))
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:gocritic // needed for SCIM
|
||||
dbUser, err := api.Database.GetUserByID(dbauthz.AsSystemRestricted(ctx), uid)
|
||||
dbUser, err := s.Database.GetUserByID(dbauthz.AsSystemRestricted(ctx), uid)
|
||||
if err != nil {
|
||||
_ = handlerutil.WriteError(rw, err) // internal error
|
||||
return
|
||||
@@ -388,14 +447,14 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
|
||||
aReq.UserID = dbUser.ID
|
||||
|
||||
if sUser.Active == nil {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
|
||||
return
|
||||
}
|
||||
|
||||
newStatus := scimUserStatus(dbUser, *sUser.Active)
|
||||
if dbUser.Status != newStatus {
|
||||
//nolint:gocritic // needed for SCIM
|
||||
userNew, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
|
||||
userNew, err := s.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
|
||||
ID: dbUser.ID,
|
||||
Status: newStatus,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
@@ -426,20 +485,20 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Produce application/scim+json
|
||||
// @Tags Enterprise
|
||||
// @Param id path string true "User ID" format(uuid)
|
||||
// @Param request body coderd.SCIMUser true "Replace user request"
|
||||
// @Param request body legacyscim.SCIMUser true "Replace user request"
|
||||
// @Success 200 {object} codersdk.User
|
||||
// @Router /scim/v2/Users/{id} [put]
|
||||
func (api *API) scimPutUser(rw http.ResponseWriter, r *http.Request) {
|
||||
func (s *LegacyServer) scimPutUser(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.scimVerifyAuthHeader(r) {
|
||||
if !s.scimVerifyAuthHeader(r) {
|
||||
scimUnauthorized(rw)
|
||||
return
|
||||
}
|
||||
|
||||
auditor := *api.AGPL.Auditor.Load()
|
||||
auditor := *s.Auditor.Load()
|
||||
aReq, commitAudit := audit.InitRequestWithCancel[database.User](rw, &audit.RequestParams{
|
||||
Audit: auditor,
|
||||
Log: api.Logger,
|
||||
Log: s.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionWrite,
|
||||
})
|
||||
@@ -451,23 +510,23 @@ func (api *API) scimPutUser(rw http.ResponseWriter, r *http.Request) {
|
||||
var sUser SCIMUser
|
||||
err := json.NewDecoder(r.Body).Decode(&sUser)
|
||||
if err != nil {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
|
||||
return
|
||||
}
|
||||
sUser.ID = id
|
||||
if sUser.Active == nil {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
|
||||
return
|
||||
}
|
||||
|
||||
uid, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidId", xerrors.Errorf("id must be a uuid: %w", err)))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidId", xerrors.Errorf("id must be a uuid: %w", err)))
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:gocritic // needed for SCIM
|
||||
dbUser, err := api.Database.GetUserByID(dbauthz.AsSystemRestricted(ctx), uid)
|
||||
dbUser, err := s.Database.GetUserByID(dbauthz.AsSystemRestricted(ctx), uid)
|
||||
if err != nil {
|
||||
_ = handlerutil.WriteError(rw, err) // internal error
|
||||
return
|
||||
@@ -484,14 +543,14 @@ func (api *API) scimPutUser(rw http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Currently ignoring a lot of the SCIM fields. Coder's SCIM implementation
|
||||
// is very basic and only supports active status changes.
|
||||
if immutabilityViolation(dbUser.Username, sUser.UserName) {
|
||||
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "mutability", xerrors.Errorf("username is currently an immutable field, and cannot be changed. Current: %s, New: %s", dbUser.Username, sUser.UserName)))
|
||||
_ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "mutability", xerrors.Errorf("username is currently an immutable field, and cannot be changed. Current: %s, New: %s", dbUser.Username, sUser.UserName)))
|
||||
return
|
||||
}
|
||||
|
||||
newStatus := scimUserStatus(dbUser, *sUser.Active)
|
||||
if dbUser.Status != newStatus {
|
||||
//nolint:gocritic // needed for SCIM
|
||||
userNew, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
|
||||
userNew, err := s.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
|
||||
ID: dbUser.ID,
|
||||
Status: newStatus,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
@@ -1,4 +1,4 @@
|
||||
package scim
|
||||
package legacyscim
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -0,0 +1,39 @@
|
||||
package scim
|
||||
|
||||
import (
|
||||
"github.com/scim2/filter-parser/v2"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
// userQuery only supports queries of a singular attribute expression.
|
||||
// Everything else is rejected. Okta just uses username.
|
||||
// Eg: username eq "alice"
|
||||
func userQuery(expr filter.Expression) (database.GetUsersParams, error) {
|
||||
if expr == nil {
|
||||
return database.GetUsersParams{}, nil
|
||||
}
|
||||
|
||||
attrExpr, ok := expr.(*filter.AttributeExpression)
|
||||
if !ok {
|
||||
return database.GetUsersParams{}, xerrors.Errorf("expected attribute expression")
|
||||
}
|
||||
|
||||
attrValue, ok := attrExpr.CompareValue.(string)
|
||||
if !ok {
|
||||
return database.GetUsersParams{}, xerrors.Errorf("expected string compare value")
|
||||
}
|
||||
|
||||
var getUsers database.GetUsersParams
|
||||
switch attrExpr.AttributePath.AttributeName {
|
||||
case "userName":
|
||||
getUsers.ExactUsername = attrValue
|
||||
case "email":
|
||||
getUsers.ExactEmail = attrValue
|
||||
default:
|
||||
return database.GetUsersParams{}, xerrors.Errorf("unsupported filter attribute: %s", attrExpr.AttributePath.AttributeName)
|
||||
}
|
||||
|
||||
return getUsers, nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package scim
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/elimity-com/scim"
|
||||
scimErrors "github.com/elimity-com/scim/errors"
|
||||
"github.com/elimity-com/scim/optional"
|
||||
"github.com/elimity-com/scim/schema"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
agpl "github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/idpsync"
|
||||
)
|
||||
|
||||
// Handler wraps the elimity-com/scim library's Server to implement
|
||||
// SCIM 2.0 endpoints. The library auto-serves /Schemas, /ResourceTypes,
|
||||
// and /ServiceProviderConfig from schema definitions.
|
||||
type Handler struct {
|
||||
opts *Options
|
||||
srv *scim.Server
|
||||
}
|
||||
|
||||
// Options holds all the dependencies needed by SCIM resource handlers.
|
||||
type Options struct {
|
||||
DB database.Store
|
||||
Auditor *atomic.Pointer[audit.Auditor]
|
||||
IDPSync idpsync.IDPSync
|
||||
Logger slog.Logger
|
||||
|
||||
// AGPL is needed for CreateUser.
|
||||
AGPL *agpl.API
|
||||
|
||||
// SCIMAPIKey is the bearer token used to authenticate SCIM requests.
|
||||
SCIMAPIKey []byte
|
||||
}
|
||||
|
||||
func New(opts *Options) (*Handler, error) {
|
||||
userHandler := &ResourceUser{
|
||||
store: opts.DB,
|
||||
opts: opts,
|
||||
}
|
||||
|
||||
args := &scim.ServerArgs{
|
||||
ServiceProviderConfig: &scim.ServiceProviderConfig{
|
||||
DocumentationURI: optional.NewString("https://coder.com/docs/admin/users/oidc-auth#scim"),
|
||||
AuthenticationSchemes: []scim.AuthenticationScheme{
|
||||
{
|
||||
Type: scim.AuthenticationTypeOauthBearerToken,
|
||||
Name: "HTTP Header Authentication",
|
||||
Description: "Authentication scheme using the Authorization header with the shared token",
|
||||
// TODO: Add documentation links for these specific docs once they exist.
|
||||
SpecURI: optional.String{},
|
||||
DocumentationURI: optional.String{},
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
MaxResults: 0,
|
||||
// SupportFiltering is set to false, as all filtering operations are not
|
||||
// supported. A minimal filtering syntax is supported because Okta seems to
|
||||
// ignore this field and attempt to filter anyway.
|
||||
SupportFiltering: false,
|
||||
SupportPatch: true,
|
||||
},
|
||||
ResourceTypes: []scim.ResourceType{
|
||||
{
|
||||
ID: optional.NewString("User"),
|
||||
Name: "User",
|
||||
Description: optional.NewString("User Account"),
|
||||
Endpoint: "/Users",
|
||||
Schema: schema.CoreUserSchema(),
|
||||
Handler: userHandler,
|
||||
SchemaExtensions: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
srv, err := scim.NewServer(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
opts: opts,
|
||||
srv: &srv,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Handler) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if !s.verifyAuthHeader(r) {
|
||||
scimUnauthorized(rw)
|
||||
return
|
||||
}
|
||||
|
||||
// All authenticated requests are treated as coming from the SCIM provisioner
|
||||
//nolint:gocritic // auth header authenticates as this identity
|
||||
ctx := dbauthz.AsSCIMProvisioner(r.Context())
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Handler) Handler() http.Handler {
|
||||
return s.authMiddleware(s.srv)
|
||||
}
|
||||
|
||||
func (s *Handler) verifyAuthHeader(r *http.Request) bool {
|
||||
bearer := []byte("bearer ")
|
||||
hdr := []byte(r.Header.Get("Authorization"))
|
||||
|
||||
// Case-insensitive comparison of the "Bearer " prefix.
|
||||
if len(hdr) >= len(bearer) && subtle.ConstantTimeCompare(bytes.ToLower(hdr[:len(bearer)]), bearer) == 1 {
|
||||
hdr = hdr[len(bearer):]
|
||||
}
|
||||
|
||||
return len(s.opts.SCIMAPIKey) != 0 && subtle.ConstantTimeCompare(hdr, s.opts.SCIMAPIKey) == 1
|
||||
}
|
||||
|
||||
func scimUnauthorized(rw http.ResponseWriter) {
|
||||
rw.Header().Set("Content-Type", "application/scim+json")
|
||||
rw.WriteHeader(http.StatusUnauthorized)
|
||||
// scim error spec:
|
||||
// https://datatracker.ietf.org/doc/html/rfc7644#section-3.12
|
||||
_ = json.NewEncoder(rw).Encode(scimErrors.ScimError{
|
||||
ScimType: "", // No scimType exists for unauthorized errors.
|
||||
Detail: "invalid authorization",
|
||||
Status: http.StatusUnauthorized,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package scim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/elimity-com/scim"
|
||||
scimErrors "github.com/elimity-com/scim/errors"
|
||||
"github.com/elimity-com/scim/optional"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
agpl "github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
var _ scim.ResourceHandler = (*ResourceUser)(nil)
|
||||
|
||||
// auditUser emits an audit log for a SCIM operation. This uses
|
||||
// BackgroundAudit instead of InitRequest because the elimity-com/scim
|
||||
// library owns the http.ResponseWriter and does not expose it to
|
||||
// resource handlers.
|
||||
func (ru *ResourceUser) auditUser(ctx context.Context, r *http.Request, action database.AuditAction, old, changed database.User) {
|
||||
raw, _ := json.Marshal(map[string]string{
|
||||
"automatic_actor": "coder",
|
||||
"automatic_subsystem": "scim",
|
||||
})
|
||||
auditor := *ru.opts.Auditor.Load()
|
||||
|
||||
// This is a best effort
|
||||
// TODO: Check X-Forwarded-For and others for proxied requests
|
||||
ip := r.RemoteAddr
|
||||
|
||||
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.User]{
|
||||
Audit: auditor,
|
||||
Log: ru.opts.Logger,
|
||||
UserID: uuid.Nil, // SCIM provisioner, not a real user
|
||||
Action: action,
|
||||
Old: old,
|
||||
New: changed,
|
||||
IP: ip,
|
||||
UserAgent: r.UserAgent(),
|
||||
AdditionalFields: raw,
|
||||
Status: http.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
type ResourceUser struct {
|
||||
store database.Store
|
||||
opts *Options
|
||||
}
|
||||
|
||||
// Create implements scim.ResourceHandler. Creates a new Coder user from
|
||||
// SCIM attributes, or returns the existing user if a duplicate is found.
|
||||
func (ru *ResourceUser) Create(r *http.Request, attributes scim.ResourceAttributes) (scim.Resource, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Extract fields from the SCIM attributes.
|
||||
// Do our best to match what the OIDC signup flow also does.
|
||||
username, _ := attributeAsString(attributes, "userName")
|
||||
email := primaryEmail(attributes)
|
||||
if email == "" {
|
||||
// email is required
|
||||
return scim.Resource{}, scimErrors.ScimErrorBadRequest("no primary email provided")
|
||||
}
|
||||
|
||||
// This comes from userOIDC
|
||||
// TODO: Ideally this code would be shared between the two places.
|
||||
usernameValidErr := codersdk.NameValid(username)
|
||||
if usernameValidErr != nil {
|
||||
if username == "" {
|
||||
username = email
|
||||
}
|
||||
username = codersdk.UsernameFrom(username)
|
||||
}
|
||||
|
||||
// TODO: OIDC has optional configuration like `EmailDomain` to reject emails outside a specific domain.
|
||||
// We should consider whether we want to support that for SCIM as well, and if so, apply that validation here.
|
||||
|
||||
active := true
|
||||
if a, ok := attribute(attributes, "active"); ok {
|
||||
v, err := booleanValue(a)
|
||||
if err != nil {
|
||||
return scim.Resource{}, scimErrors.ScimErrorBadRequest(
|
||||
fmt.Sprintf("invalid boolean value for 'active' field: %v", a))
|
||||
}
|
||||
active = v
|
||||
}
|
||||
|
||||
// Check for existing user by email or username.
|
||||
dbUser, err := ru.store.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
||||
Email: email,
|
||||
Username: username,
|
||||
})
|
||||
if err == nil {
|
||||
// SCIM spec says to return a StatusConflict if the user already exists.
|
||||
// However, Coder never deletes a user. So suspended **is** deleted.
|
||||
// If the user is not suspended, we return a conflict.
|
||||
if dbUser.Status != database.UserStatusSuspended {
|
||||
return scim.Resource{}, scimErrors.ScimError{
|
||||
ScimType: scimErrors.ScimTypeUniqueness,
|
||||
Detail: fmt.Sprintf("user already exists with email %q or username %q", email, username),
|
||||
Status: http.StatusConflict,
|
||||
}
|
||||
}
|
||||
|
||||
// If the user is suspended, then they might be deleted on the SCIM side.
|
||||
// We can just update their status and return the user as they exist.
|
||||
status := scimUserStatus(dbUser, &active)
|
||||
dbUser, err = ru.updateUserStatus(ctx, r, dbUser, status)
|
||||
if err != nil {
|
||||
return scim.Resource{}, err
|
||||
}
|
||||
return userResource(dbUser), nil
|
||||
}
|
||||
|
||||
if !xerrors.Is(err, sql.ErrNoRows) {
|
||||
// Internal DB errors should be returned.
|
||||
// ErrNoRows is expected if the user does not exist.
|
||||
return scim.Resource{}, err
|
||||
}
|
||||
|
||||
// OIDC login runs org, group, and role sync. SCIM does not have (or not yet) these
|
||||
// claims. We only need to sync the default organization if that is enabled.
|
||||
//
|
||||
// When the user eventually logs in via OIDC, the regular sync will run.
|
||||
// However, since org sync can be disabled. We need to assign the default org if
|
||||
// that is how we are configured.
|
||||
organizations := []uuid.UUID{}
|
||||
orgSync, err := ru.opts.IDPSync.OrganizationSyncSettings(ctx, ru.store)
|
||||
if err != nil {
|
||||
return scim.Resource{}, xerrors.Errorf("get organization sync settings: %w", err)
|
||||
}
|
||||
if orgSync.AssignDefault {
|
||||
// Technically, we could just always assign this. When they eventually log in,
|
||||
// the org would be removed if necessary. But to avoid confusion of the user
|
||||
// being in the org before they log in, we apply some intelligence to this guess
|
||||
// of "Do they belong in the default org".
|
||||
defaultOrganization, err := ru.store.GetDefaultOrganization(ctx)
|
||||
if err != nil {
|
||||
return scim.Resource{}, xerrors.Errorf("get default organization: %w", err)
|
||||
}
|
||||
organizations = append(organizations, defaultOrganization.ID)
|
||||
}
|
||||
|
||||
// CreateUser does InsertOrganizationMember internally, and InsertUser
|
||||
// implicitly assigns the member role at site scope. The SCIM provisioner
|
||||
// role cannot assign either, so escalate to a system context for this
|
||||
// specific call, matching the legacy SCIM handler.
|
||||
//nolint:gocritic // SCIM bearer token authenticates as the SCIM provisioner; user creation needs broader rights to assign default roles.
|
||||
dbUser, err = ru.opts.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), ru.store, agpl.CreateUserRequest{
|
||||
CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{
|
||||
Username: username,
|
||||
Email: email,
|
||||
OrganizationIDs: organizations,
|
||||
},
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
// Do not send notifications to user admins; SCIM may call this
|
||||
// sequentially for many users.
|
||||
// TODO: Maybe we should spam them anyway?
|
||||
SkipNotifications: true,
|
||||
})
|
||||
if err != nil {
|
||||
return scim.Resource{}, xerrors.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
ru.auditUser(ctx, r, database.AuditActionCreate, database.User{}, dbUser)
|
||||
return userResource(dbUser), nil
|
||||
}
|
||||
|
||||
// Get implements scim.ResourceHandler. Returns a single user by ID.
|
||||
func (ru *ResourceUser) Get(r *http.Request, idStr string) (scim.Resource, error) {
|
||||
ctx := r.Context()
|
||||
usr, err := ru.user(ctx, idStr)
|
||||
if err != nil {
|
||||
return scim.Resource{}, err
|
||||
}
|
||||
|
||||
return userResource(usr), nil
|
||||
}
|
||||
|
||||
// GetAll implements scim.ResourceHandler. Returns a paginated list of users.
|
||||
func (ru *ResourceUser) GetAll(r *http.Request, params scim.ListRequestParams) (scim.Page, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
var qry database.GetUsersParams
|
||||
if params.FilterValidator != nil {
|
||||
var err error
|
||||
qry, err = userQuery(params.FilterValidator.GetFilter())
|
||||
if err != nil {
|
||||
return scim.Page{}, scimErrors.ScimErrorBadRequest(fmt.Sprintf("invalid filter: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
qry.LimitOpt = int32(params.Count) //nolint:gosec
|
||||
qry.OffsetOpt = int32(params.StartIndex - 1) //nolint:gosec
|
||||
|
||||
if qry.LimitOpt < 0 {
|
||||
qry.LimitOpt = 100
|
||||
}
|
||||
|
||||
users, err := ru.store.GetUsers(ctx, qry)
|
||||
if err != nil {
|
||||
return scim.Page{}, err
|
||||
}
|
||||
|
||||
totalCount := int64(len(users))
|
||||
if len(users) == int(qry.LimitOpt) {
|
||||
// If the limit is not reached, that is the count
|
||||
// TODO: If there is a query and the limit is reached, this is inaccurate.
|
||||
totalCount, err = ru.store.GetUserCount(ctx, false)
|
||||
if err != nil {
|
||||
return scim.Page{}, err
|
||||
}
|
||||
}
|
||||
|
||||
resources := make([]scim.Resource, 0, len(users))
|
||||
for _, u := range users {
|
||||
resources = append(resources, userResourceFromGetUsersRow(u))
|
||||
}
|
||||
|
||||
return scim.Page{
|
||||
TotalResults: int(totalCount),
|
||||
Resources: resources,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Replace implements scim.ResourceHandler (PUT). Replaces user attributes.
|
||||
// Currently only supports changing the active status per existing behavior.
|
||||
func (ru *ResourceUser) Replace(r *http.Request, idStr string, attributes scim.ResourceAttributes) (scim.Resource, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
dbUser, err := ru.user(ctx, idStr)
|
||||
if err != nil {
|
||||
return scim.Resource{}, err
|
||||
}
|
||||
|
||||
// All of our fields except for active are immutable.
|
||||
if !attributeEqual(dbUser.Username, attributes, "userName") {
|
||||
return scim.Resource{}, scimErrors.ScimErrorBadRequest(fmt.Sprintf("changing the 'userName' field is not supported (current value: %q)", dbUser.Username))
|
||||
}
|
||||
|
||||
// TODO: Check if the primary email has changed. If it has, should we do something?
|
||||
|
||||
activeInterface, ok := attribute(attributes, "active")
|
||||
if !ok {
|
||||
return scim.Resource{}, scimErrors.ScimErrorBadRequest("missing required 'active' field")
|
||||
}
|
||||
|
||||
active, err := booleanValue(activeInterface)
|
||||
if err != nil {
|
||||
return scim.Resource{}, scimErrors.ScimErrorBadRequest(fmt.Sprintf("invalid boolean value for 'active' field: %v", activeInterface))
|
||||
}
|
||||
|
||||
newStatus := scimUserStatus(dbUser, &active)
|
||||
dbUser, err = ru.updateUserStatus(ctx, r, dbUser, newStatus)
|
||||
if err != nil {
|
||||
return scim.Resource{}, err
|
||||
}
|
||||
|
||||
return userResource(dbUser), nil
|
||||
}
|
||||
|
||||
// Delete implements scim.ResourceHandler. Suspends the user (Coder does
|
||||
// not hard-delete users).
|
||||
func (ru *ResourceUser) Delete(r *http.Request, idStr string) error {
|
||||
ctx := r.Context()
|
||||
|
||||
dbUser, err := ru.user(ctx, idStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = ru.updateUserStatus(ctx, r, dbUser, database.UserStatusSuspended)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Patch implements scim.ResourceHandler. Updates user attributes based on
|
||||
// SCIM PatchOp operations. Currently, supports changing the active status.
|
||||
func (ru *ResourceUser) Patch(r *http.Request, idStr string, operations []scim.PatchOperation) (scim.Resource, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
uid, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
return scim.Resource{}, badUUID(idStr, err)
|
||||
}
|
||||
|
||||
dbUser, err := ru.store.GetUserByID(ctx, uid)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
return scim.Resource{}, scimErrors.ScimErrorResourceNotFound(idStr)
|
||||
}
|
||||
return scim.Resource{}, err
|
||||
}
|
||||
|
||||
// Process operations. Currently, we only handle the "active" attribute.
|
||||
var activeSet *bool
|
||||
for _, op := range operations {
|
||||
switch op.Op {
|
||||
case "add":
|
||||
// TODO: Currently we do not support the adding of attributes.
|
||||
case "remove":
|
||||
// TODO: If the path is unspecified, we should fail with the status code 400.
|
||||
// Today, we only accept the 'active' field and silently drop the rest.
|
||||
if op.Path != nil && strings.EqualFold(op.Path.String(), "active") {
|
||||
activeSet = ptr.Ref(false)
|
||||
}
|
||||
case "replace":
|
||||
// TODO: Honor mutability rules of fields like `userName` and `email`.
|
||||
// Should scim be able to change those fields?
|
||||
|
||||
// SCIM PATCH replace can come in two forms:
|
||||
// 1. Path set: {"op":"replace","path":"active","value":false}
|
||||
// 2. No path, value is a map: {"op":"replace","value":{"active":false}}
|
||||
if op.Path != nil && strings.EqualFold(op.Path.String(), "active") {
|
||||
v, err := booleanValue(op.Value)
|
||||
if err != nil {
|
||||
return scim.Resource{}, scimErrors.ScimErrorBadRequest(fmt.Sprintf("invalid boolean value for 'active' field: %v", op.Value))
|
||||
}
|
||||
activeSet = &v
|
||||
} else if m, ok := op.Value.(map[string]interface{}); ok {
|
||||
if actV, ok := attribute(m, "active"); ok {
|
||||
v, err := booleanValue(actV)
|
||||
if err != nil {
|
||||
return scim.Resource{}, scimErrors.ScimErrorBadRequest(fmt.Sprintf("invalid boolean value for 'active' field: %v", actV))
|
||||
}
|
||||
activeSet = &v
|
||||
}
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
newStatus := scimUserStatus(dbUser, activeSet)
|
||||
dbUser, err = ru.updateUserStatus(ctx, r, dbUser, newStatus)
|
||||
if err != nil {
|
||||
return scim.Resource{}, err
|
||||
}
|
||||
|
||||
return userResource(dbUser), nil
|
||||
}
|
||||
|
||||
func (ru *ResourceUser) user(ctx context.Context, idStr string) (database.User, error) {
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
return database.User{}, badUUID(idStr, err)
|
||||
}
|
||||
|
||||
usr, err := ru.store.GetUserByID(ctx, id)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
return database.User{}, scimErrors.ScimErrorResourceNotFound(idStr)
|
||||
}
|
||||
return database.User{}, err
|
||||
}
|
||||
|
||||
return usr, nil
|
||||
}
|
||||
|
||||
// updateUserStatus is a no-op if the status did not change.
|
||||
func (ru *ResourceUser) updateUserStatus(ctx context.Context, r *http.Request, u database.User, status database.UserStatus) (database.User, error) {
|
||||
if u.Status == status {
|
||||
return u, nil
|
||||
}
|
||||
newUser, err := ru.store.UpdateUserStatus(ctx, database.UpdateUserStatusParams{
|
||||
ID: u.ID, Status: status, UpdatedAt: dbtime.Now(), UserIsSeen: false,
|
||||
})
|
||||
if err != nil {
|
||||
return database.User{}, err
|
||||
}
|
||||
ru.auditUser(ctx, r, database.AuditActionWrite, u, newUser)
|
||||
return newUser, nil
|
||||
}
|
||||
|
||||
// scimUserStatus maps the SCIM "active" boolean to Coder's internal user status.
|
||||
// It preserves the active/dormant distinction: active users stay active,
|
||||
// dormant or suspended users become dormant when re-activated (they become
|
||||
// active after their next login).
|
||||
//
|
||||
//nolint:revive // active is not a control flag
|
||||
func scimUserStatus(user database.User, active *bool) database.UserStatus {
|
||||
if active == nil {
|
||||
return user.Status
|
||||
}
|
||||
|
||||
if !(*active) {
|
||||
// SCIM "active: false" means the user should be suspended
|
||||
return database.UserStatusSuspended
|
||||
}
|
||||
|
||||
switch user.Status {
|
||||
case database.UserStatusActive:
|
||||
// Active users stay active
|
||||
return database.UserStatusActive
|
||||
case database.UserStatusDormant, database.UserStatusSuspended:
|
||||
// Dormant or suspended users become dormant when re-activated
|
||||
// The user can then become active by doing something in the product.
|
||||
return database.UserStatusDormant
|
||||
default:
|
||||
return database.UserStatusDormant
|
||||
}
|
||||
}
|
||||
|
||||
// userResource converts a database.User into a SCIM Resource.
|
||||
func userResource(u database.User) scim.Resource {
|
||||
return scim.Resource{
|
||||
ID: u.ID.String(),
|
||||
ExternalID: optional.String{},
|
||||
Attributes: scim.ResourceAttributes{
|
||||
"userName": u.Username,
|
||||
"name": map[string]interface{}{
|
||||
"formatted": u.Name,
|
||||
},
|
||||
"emails": []map[string]interface{}{
|
||||
{
|
||||
"primary": true,
|
||||
"value": u.Email,
|
||||
},
|
||||
},
|
||||
"active": u.Status == database.UserStatusActive ||
|
||||
u.Status == database.UserStatusDormant,
|
||||
},
|
||||
Meta: scim.Meta{
|
||||
Created: &u.CreatedAt,
|
||||
LastModified: &u.UpdatedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// userResourceFromGetUsersRow converts a database.GetUsersRow into a SCIM Resource.
|
||||
func userResourceFromGetUsersRow(u database.GetUsersRow) scim.Resource {
|
||||
return scim.Resource{
|
||||
ID: u.ID.String(),
|
||||
ExternalID: optional.String{},
|
||||
Attributes: scim.ResourceAttributes{
|
||||
"userName": u.Username,
|
||||
"name": map[string]interface{}{
|
||||
"formatted": u.Name,
|
||||
},
|
||||
"emails": []map[string]interface{}{
|
||||
{
|
||||
"primary": true,
|
||||
"value": u.Email,
|
||||
},
|
||||
},
|
||||
"active": u.Status == database.UserStatusActive ||
|
||||
u.Status == database.UserStatusDormant,
|
||||
},
|
||||
Meta: scim.Meta{
|
||||
Created: &u.CreatedAt,
|
||||
LastModified: &u.UpdatedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func attributeAsBool(attrs scim.ResourceAttributes, key string) (value bool, exists bool) {
|
||||
val, ok := attribute(attrs, key)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
pv, err := strconv.ParseBool(v)
|
||||
return pv, err == nil
|
||||
case bool:
|
||||
return v, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func attributeAsString(attrs scim.ResourceAttributes, key string) (string, bool) {
|
||||
val, ok := attribute(attrs, key)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
return v, true
|
||||
case bool:
|
||||
return strconv.FormatBool(v), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func attribute(attrs scim.ResourceAttributes, key string) (interface{}, bool) {
|
||||
// attribute names are case-insensitive per SCIM spec
|
||||
val, ok := attrs[key]
|
||||
if ok {
|
||||
return val, true
|
||||
}
|
||||
|
||||
// This is terrible, but we need to iterate the map to find the key in a case-insensitive way.
|
||||
// The scim Spec says attribute names are case-insensitive.
|
||||
for k, v := range attrs {
|
||||
if k == key {
|
||||
return v, true
|
||||
}
|
||||
if len(k) == len(key) && strings.EqualFold(k, key) {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// badUUID returns a 404 not-found error for non-UUID identifiers.
|
||||
// SCIM clients may send arbitrary strings as IDs; returning 404
|
||||
// (rather than 400) signals that no resource matches.
|
||||
func badUUID(idStr string, _ error) scimErrors.ScimError {
|
||||
return scimErrors.ScimError{
|
||||
Detail: fmt.Sprintf("%q is not a valid uuid; resource not found", idStr),
|
||||
Status: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
|
||||
func booleanValue(v interface{}) (bool, error) {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b, nil
|
||||
case string:
|
||||
return strconv.ParseBool(b)
|
||||
default:
|
||||
return false, xerrors.Errorf("expected boolean or string value, got %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
func attributeEqual[T comparable](existing T, attrs scim.ResourceAttributes, key string) bool {
|
||||
found, ok := attribute(attrs, key)
|
||||
if !ok {
|
||||
return true // No change if the attribute is not present in the request
|
||||
}
|
||||
|
||||
sameType, ok := found.(T)
|
||||
if !ok {
|
||||
return false // Type mismatch, consider it a change
|
||||
}
|
||||
|
||||
return existing == sameType
|
||||
}
|
||||
|
||||
// primaryEmail extracts the primary email from SCIM resource attributes.
|
||||
func primaryEmail(attributes scim.ResourceAttributes) string {
|
||||
emailsRaw, ok := attribute(attributes, "emails")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
emails, ok := emailsRaw.([]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var fallback string
|
||||
for _, e := range emails {
|
||||
emailMap, ok := e.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
val, ok := attributeAsString(emailMap, "value")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if primary, _ := attributeAsBool(emailMap, "primary"); primary {
|
||||
return val
|
||||
}
|
||||
fallback = val
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
package scim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/elimity-com/scim"
|
||||
scimErrors "github.com/elimity-com/scim/errors"
|
||||
"github.com/google/uuid"
|
||||
filter "github.com/scim2/filter-parser/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
)
|
||||
|
||||
// setupSCIM creates a ResourceUser backed by a real database for testing.
|
||||
// The returned mock auditor can be inspected for emitted audit logs.
|
||||
func setupSCIM(t *testing.T) (*ResourceUser, database.Store, *audit.MockAuditor) {
|
||||
t.Helper()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
mockAudit := audit.NewMock()
|
||||
auditorPtr := atomic.Pointer[audit.Auditor]{}
|
||||
var a audit.Auditor = mockAudit
|
||||
auditorPtr.Store(&a)
|
||||
|
||||
ru := &ResourceUser{
|
||||
store: db,
|
||||
opts: &Options{
|
||||
DB: db,
|
||||
Auditor: &auditorPtr,
|
||||
Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug),
|
||||
},
|
||||
}
|
||||
return ru, db, mockAudit
|
||||
}
|
||||
|
||||
// scimRequest builds an *http.Request with scim provisioner context,
|
||||
// simulating the auth context that the SCIM middleware normally sets.
|
||||
func scimRequest(t *testing.T) *http.Request {
|
||||
t.Helper()
|
||||
ctx := dbauthz.AsSCIMProvisioner(context.Background())
|
||||
return httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
||||
}
|
||||
|
||||
// seedUser creates a user in the database for testing.
|
||||
func seedUser(t *testing.T, db database.Store, opts database.User) database.User {
|
||||
t.Helper()
|
||||
return dbgen.User(t, db, opts)
|
||||
}
|
||||
|
||||
// setupSCIMMock creates a ResourceUser backed by a gomock store for tests
|
||||
// that only need to verify call patterns (e.g. audit emission) without
|
||||
// real SQL.
|
||||
func setupSCIMMock(t *testing.T) (*ResourceUser, *dbmock.MockStore, *audit.MockAuditor) {
|
||||
t.Helper()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mockStore := dbmock.NewMockStore(ctrl)
|
||||
mockAudit := audit.NewMock()
|
||||
auditorPtr := atomic.Pointer[audit.Auditor]{}
|
||||
var a audit.Auditor = mockAudit
|
||||
auditorPtr.Store(&a)
|
||||
|
||||
ru := &ResourceUser{
|
||||
store: mockStore,
|
||||
opts: &Options{
|
||||
DB: mockStore,
|
||||
Auditor: &auditorPtr,
|
||||
Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug),
|
||||
},
|
||||
}
|
||||
return ru, mockStore, mockAudit
|
||||
}
|
||||
|
||||
// --- Pure function tests (no DB) ---
|
||||
|
||||
func TestScimUserStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
boolPtr := func(b bool) *bool { return &b }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status database.UserStatus
|
||||
active *bool
|
||||
expected database.UserStatus
|
||||
}{
|
||||
{"active+true=active", database.UserStatusActive, boolPtr(true), database.UserStatusActive},
|
||||
{"active+false=suspended", database.UserStatusActive, boolPtr(false), database.UserStatusSuspended},
|
||||
{"suspended+true=dormant", database.UserStatusSuspended, boolPtr(true), database.UserStatusDormant},
|
||||
{"suspended+false=suspended", database.UserStatusSuspended, boolPtr(false), database.UserStatusSuspended},
|
||||
{"dormant+true=dormant", database.UserStatusDormant, boolPtr(true), database.UserStatusDormant},
|
||||
{"dormant+false=suspended", database.UserStatusDormant, boolPtr(false), database.UserStatusSuspended},
|
||||
{"active+nil=active", database.UserStatusActive, nil, database.UserStatusActive},
|
||||
{"suspended+nil=suspended", database.UserStatusSuspended, nil, database.UserStatusSuspended},
|
||||
{"dormant+nil=dormant", database.UserStatusDormant, nil, database.UserStatusDormant},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
user := database.User{Status: tt.status}
|
||||
got := scimUserStatus(user, tt.active)
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
attrs scim.ResourceAttributes
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "primary email",
|
||||
attrs: scim.ResourceAttributes{
|
||||
"emails": []interface{}{
|
||||
map[string]interface{}{"value": "a@b.com", "primary": true},
|
||||
},
|
||||
},
|
||||
expected: "a@b.com",
|
||||
},
|
||||
{
|
||||
name: "fallback to first when no primary",
|
||||
attrs: scim.ResourceAttributes{
|
||||
"emails": []interface{}{
|
||||
map[string]interface{}{"value": "first@b.com"},
|
||||
},
|
||||
},
|
||||
expected: "first@b.com",
|
||||
},
|
||||
{
|
||||
name: "picks primary over first",
|
||||
attrs: scim.ResourceAttributes{
|
||||
"emails": []interface{}{
|
||||
map[string]interface{}{"value": "first@b.com"},
|
||||
map[string]interface{}{"value": "primary@b.com", "primary": true},
|
||||
},
|
||||
},
|
||||
expected: "primary@b.com",
|
||||
},
|
||||
{
|
||||
name: "polluted",
|
||||
attrs: scim.ResourceAttributes{
|
||||
"emails": []interface{}{
|
||||
// Try and cause a panic
|
||||
"not-a-map",
|
||||
true,
|
||||
7,
|
||||
map[int]interface{}{
|
||||
1: "bad",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"value": 123, // value is not a string
|
||||
},
|
||||
map[string]interface{}{},
|
||||
map[string]interface{}{"value": "first@b.com"},
|
||||
map[string]interface{}{"value": "primary@b.com", "primary": true},
|
||||
},
|
||||
},
|
||||
expected: "primary@b.com",
|
||||
},
|
||||
{
|
||||
name: "no emails key",
|
||||
attrs: scim.ResourceAttributes{},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty emails",
|
||||
attrs: scim.ResourceAttributes{"emails": []interface{}{}},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "wrong type",
|
||||
attrs: scim.ResourceAttributes{"emails": "not-a-list"},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive top-level key",
|
||||
attrs: scim.ResourceAttributes{
|
||||
"Emails": []interface{}{
|
||||
map[string]interface{}{"value": "a@b.com", "primary": true},
|
||||
},
|
||||
},
|
||||
expected: "a@b.com",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive inner keys",
|
||||
attrs: scim.ResourceAttributes{
|
||||
"emails": []interface{}{
|
||||
map[string]interface{}{"Value": "a@b.com", "Primary": true},
|
||||
},
|
||||
},
|
||||
expected: "a@b.com",
|
||||
},
|
||||
{
|
||||
name: "all caps keys",
|
||||
attrs: scim.ResourceAttributes{
|
||||
"EMAILS": []interface{}{
|
||||
map[string]interface{}{"VALUE": "a@b.com", "PRIMARY": true},
|
||||
},
|
||||
},
|
||||
expected: "a@b.com",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := primaryEmail(tt.attrs)
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBooleanValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"bool true", true, true, false},
|
||||
{"bool false", false, false, false},
|
||||
{"string true", "true", true, false},
|
||||
{"string false", "false", false, false},
|
||||
{"string True", "True", true, false},
|
||||
{"int", 42, false, true},
|
||||
{"nil", nil, false, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := booleanValue(tt.input)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttribute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
attrs scim.ResourceAttributes
|
||||
key string
|
||||
wantVal interface{}
|
||||
wantOK bool
|
||||
}{
|
||||
{"exact match", scim.ResourceAttributes{"active": true}, "active", true, true},
|
||||
{"capital first", scim.ResourceAttributes{"active": true}, "Active", true, true},
|
||||
{"all caps", scim.ResourceAttributes{"active": true}, "ACTIVE", true, true},
|
||||
{"camelCase key", scim.ResourceAttributes{"userName": "alice"}, "username", "alice", true},
|
||||
{"camelCase swapped", scim.ResourceAttributes{"username": "alice"}, "userName", "alice", true},
|
||||
{"missing key", scim.ResourceAttributes{"active": true}, "missing", nil, false},
|
||||
{"empty map", scim.ResourceAttributes{}, "active", nil, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
val, ok := attribute(tt.attrs, tt.key)
|
||||
assert.Equal(t, tt.wantOK, ok)
|
||||
assert.Equal(t, tt.wantVal, val)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttributeAsBool(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
attrs scim.ResourceAttributes
|
||||
key string
|
||||
want bool
|
||||
wantOK bool
|
||||
}{
|
||||
{"exact key bool", scim.ResourceAttributes{"active": true}, "active", true, true},
|
||||
{"mixed case bool", scim.ResourceAttributes{"active": false}, "Active", false, true},
|
||||
{"all caps bool", scim.ResourceAttributes{"active": true}, "ACTIVE", true, true},
|
||||
{"mixed case string true", scim.ResourceAttributes{"active": "true"}, "Active", true, true},
|
||||
{"mixed case string false", scim.ResourceAttributes{"active": "false"}, "ACTIVE", false, true},
|
||||
{"missing key", scim.ResourceAttributes{}, "active", false, false},
|
||||
{"non-convertible", scim.ResourceAttributes{"active": 42}, "active", false, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := attributeAsBool(tt.attrs, tt.key)
|
||||
assert.Equal(t, tt.wantOK, ok)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttributeAsString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
attrs scim.ResourceAttributes
|
||||
key string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"exact key string", scim.ResourceAttributes{"userName": "alice"}, "userName", "alice", true},
|
||||
{"mixed case string", scim.ResourceAttributes{"userName": "alice"}, "UserName", "alice", true},
|
||||
{"lower case lookup", scim.ResourceAttributes{"userName": "alice"}, "username", "alice", true},
|
||||
{"bool to string", scim.ResourceAttributes{"active": true}, "active", "true", true},
|
||||
{"mixed case bool to string", scim.ResourceAttributes{"active": false}, "Active", "false", true},
|
||||
{"missing key", scim.ResourceAttributes{}, "userName", "", false},
|
||||
{"non-convertible", scim.ResourceAttributes{"count": 42}, "count", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := attributeAsString(tt.attrs, tt.key)
|
||||
assert.Equal(t, tt.wantOK, ok)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttributeEqual(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("exact match same value", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
attrs := scim.ResourceAttributes{"userName": "alice"}
|
||||
assert.True(t, attributeEqual("alice", attrs, "userName"))
|
||||
})
|
||||
|
||||
t.Run("mixed case same value", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
attrs := scim.ResourceAttributes{"userName": "alice"}
|
||||
assert.True(t, attributeEqual("alice", attrs, "UserName"))
|
||||
})
|
||||
|
||||
t.Run("mixed case different value", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
attrs := scim.ResourceAttributes{"userName": "bob"}
|
||||
assert.False(t, attributeEqual("alice", attrs, "USERNAME"))
|
||||
})
|
||||
|
||||
t.Run("missing key means no change", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
attrs := scim.ResourceAttributes{}
|
||||
assert.True(t, attributeEqual("alice", attrs, "userName"))
|
||||
})
|
||||
|
||||
t.Run("type mismatch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
attrs := scim.ResourceAttributes{"userName": 42}
|
||||
assert.False(t, attributeEqual("alice", attrs, "userName"))
|
||||
})
|
||||
}
|
||||
|
||||
// --- Handler tests (with DB) ---
|
||||
|
||||
func TestResourceUser_CaseInsensitive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ru, db, _ := setupSCIM(t)
|
||||
|
||||
// Seed an active user.
|
||||
user := seedUser(t, db, database.User{
|
||||
Status: database.UserStatusActive,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
|
||||
r := scimRequest(t)
|
||||
|
||||
// Replace with "Active" (capital A) instead of "active".
|
||||
res, err := ru.Replace(r, user.ID.String(), scim.ResourceAttributes{
|
||||
"userName": user.Username,
|
||||
"Active": false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, false, res.Attributes["active"])
|
||||
|
||||
// Confirm suspended via Get.
|
||||
res, err = ru.Get(r, user.ID.String())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, false, res.Attributes["active"])
|
||||
|
||||
// Patch back with map-style replace using "Active" key.
|
||||
res, err = ru.Patch(r, user.ID.String(), []scim.PatchOperation{
|
||||
{Op: "replace", Value: map[string]interface{}{"Active": true}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, res.Attributes["active"])
|
||||
|
||||
// Confirm reactivated via Get.
|
||||
res, err = ru.Get(r, user.ID.String())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, res.Attributes["active"])
|
||||
}
|
||||
|
||||
func TestResourceUser_Create(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Coder does not hard-delete users. A SCIM Delete suspends the user, so
|
||||
// when an IdP later re-creates the same user, the handler should match
|
||||
// them by email/username and reactivate the existing row instead of
|
||||
// returning 409 Conflict. See commit b3e6e0aa06.
|
||||
|
||||
t.Run("duplicate-active-conflict", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ru, db, _ := setupSCIM(t)
|
||||
|
||||
existing := seedUser(t, db, database.User{
|
||||
Status: database.UserStatusActive,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
|
||||
_, err := ru.Create(scimRequest(t), scim.ResourceAttributes{
|
||||
"userName": existing.Username,
|
||||
"emails": []interface{}{
|
||||
map[string]interface{}{"value": existing.Email, "primary": true},
|
||||
},
|
||||
"active": true,
|
||||
})
|
||||
require.Error(t, err)
|
||||
var scimErr scimErrors.ScimError
|
||||
require.ErrorAs(t, err, &scimErr)
|
||||
assert.Equal(t, http.StatusConflict, scimErr.Status)
|
||||
})
|
||||
|
||||
t.Run("suspended-user-reactivates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ru, db, mockAudit := setupSCIM(t)
|
||||
|
||||
existing := seedUser(t, db, database.User{
|
||||
Status: database.UserStatusSuspended,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
|
||||
res, err := ru.Create(scimRequest(t), scim.ResourceAttributes{
|
||||
"userName": existing.Username,
|
||||
"emails": []interface{}{
|
||||
map[string]interface{}{"value": existing.Email, "primary": true},
|
||||
},
|
||||
"active": true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, existing.ID.String(), res.ID, "response should reference the existing user, not a new one")
|
||||
|
||||
// The SCIM response must reflect the post-update state so the IdP
|
||||
// sees active=true after the recreate.
|
||||
assert.Equal(t, true, res.Attributes["active"], "response should report the reactivated state")
|
||||
|
||||
// Suspended + active=true reactivates to Dormant (not Active) per scimUserStatus.
|
||||
got, err := db.GetUserByID(dbauthz.AsSCIMProvisioner(context.Background()), existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, database.UserStatusDormant, got.Status, "suspended user should be marked dormant on recreate")
|
||||
|
||||
// Reactivation should emit one audit log for the status change.
|
||||
assert.Len(t, mockAudit.AuditLogs(), 1)
|
||||
})
|
||||
|
||||
t.Run("suspended-user-stays-suspended-when-active-false", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ru, db, mockAudit := setupSCIM(t)
|
||||
|
||||
existing := seedUser(t, db, database.User{
|
||||
Status: database.UserStatusSuspended,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
|
||||
res, err := ru.Create(scimRequest(t), scim.ResourceAttributes{
|
||||
"userName": existing.Username,
|
||||
"emails": []interface{}{
|
||||
map[string]interface{}{"value": existing.Email, "primary": true},
|
||||
},
|
||||
"active": false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, existing.ID.String(), res.ID)
|
||||
assert.Equal(t, false, res.Attributes["active"])
|
||||
|
||||
got, err := db.GetUserByID(dbauthz.AsSCIMProvisioner(context.Background()), existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, database.UserStatusSuspended, got.Status)
|
||||
|
||||
// No status change → no audit log.
|
||||
assert.Empty(t, mockAudit.AuditLogs())
|
||||
})
|
||||
}
|
||||
|
||||
func TestResourceUser_Lifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ru, db, _ := setupSCIM(t)
|
||||
|
||||
// Seed an active user.
|
||||
user := seedUser(t, db, database.User{
|
||||
Status: database.UserStatusActive,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
|
||||
r := scimRequest(t)
|
||||
|
||||
// Step 1: Get the user. Verify fields match.
|
||||
res, err := ru.Get(r, user.ID.String())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID.String(), res.ID)
|
||||
assert.Equal(t, user.Username, res.Attributes["userName"])
|
||||
assert.Equal(t, true, res.Attributes["active"])
|
||||
|
||||
// Step 2: Replace with active=false → suspended.
|
||||
res, err = ru.Replace(r, user.ID.String(), scim.ResourceAttributes{
|
||||
"userName": user.Username,
|
||||
"active": false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, false, res.Attributes["active"])
|
||||
|
||||
// Step 3: Get → confirm inactive.
|
||||
res, err = ru.Get(r, user.ID.String())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, false, res.Attributes["active"])
|
||||
|
||||
// Step 4: Patch active=true → dormant (shown as active in SCIM).
|
||||
res, err = ru.Patch(r, user.ID.String(), []scim.PatchOperation{
|
||||
{Op: "replace", Path: mustPath("active"), Value: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, res.Attributes["active"])
|
||||
|
||||
// Step 5: Get → confirm active again.
|
||||
res, err = ru.Get(r, user.ID.String())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, res.Attributes["active"])
|
||||
|
||||
// Step 6: Delete → suspended.
|
||||
err = ru.Delete(r, user.ID.String())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 7: Get → confirm inactive after delete.
|
||||
res, err = ru.Get(r, user.ID.String())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, false, res.Attributes["active"])
|
||||
}
|
||||
|
||||
func TestResourceUser_GetAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ru, db, _ := setupSCIM(t)
|
||||
|
||||
// Seed 3 users.
|
||||
for i := 0; i < 3; i++ {
|
||||
seedUser(t, db, database.User{
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
}
|
||||
|
||||
r := scimRequest(t)
|
||||
|
||||
// Get all with large count.
|
||||
page, err := ru.GetAll(r, scim.ListRequestParams{Count: 100, StartIndex: 1})
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, page.TotalResults, 3)
|
||||
assert.GreaterOrEqual(t, len(page.Resources), 3)
|
||||
|
||||
// Paginate: startIndex=2, count=1.
|
||||
page, err = ru.GetAll(r, scim.ListRequestParams{Count: 1, StartIndex: 2})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, page.Resources, 1)
|
||||
assert.GreaterOrEqual(t, page.TotalResults, 3)
|
||||
}
|
||||
|
||||
func TestResourceUser_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ru, _, _ := setupSCIM(t)
|
||||
r := scimRequest(t)
|
||||
missingUUID := uuid.New().String()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() error
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "Get/non-UUID",
|
||||
run: func() error { _, err := ru.Get(r, "not-a-uuid"); return err },
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "Get/missing",
|
||||
run: func() error { _, err := ru.Get(r, missingUUID); return err },
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "Replace/non-UUID",
|
||||
run: func() error { _, err := ru.Replace(r, "bad", scim.ResourceAttributes{}); return err },
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "Replace/missing",
|
||||
run: func() error { _, err := ru.Replace(r, missingUUID, scim.ResourceAttributes{}); return err },
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "Replace/immutable-userName",
|
||||
run: func() error {
|
||||
// Need a real user for this test.
|
||||
user := seedUser(t, ru.store, database.User{LoginType: database.LoginTypeOIDC})
|
||||
_, err := ru.Replace(r, user.ID.String(), scim.ResourceAttributes{
|
||||
"userName": "different-name",
|
||||
})
|
||||
return err
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "Patch/non-UUID",
|
||||
run: func() error { _, err := ru.Patch(r, "bad", nil); return err },
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "Patch/missing",
|
||||
run: func() error { _, err := ru.Patch(r, missingUUID, nil); return err },
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "Delete/non-UUID",
|
||||
run: func() error { return ru.Delete(r, "bad") },
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "Delete/missing",
|
||||
run: func() error { return ru.Delete(r, missingUUID) },
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.run()
|
||||
require.Error(t, err)
|
||||
var scimErr scimErrors.ScimError
|
||||
require.ErrorAs(t, err, &scimErr)
|
||||
assert.Equal(t, tt.wantStatus, scimErr.Status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceUser_AuditLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// These tests use dbmock instead of a real database because they only
|
||||
// verify audit emission logic (does an audit log fire when status
|
||||
// changes?), not SQL correctness. The handlers call just GetUserByID
|
||||
// and UpdateUserStatus, both trivially mockable.
|
||||
|
||||
makeUser := func(status database.UserStatus) (database.User, database.User) {
|
||||
id := uuid.New()
|
||||
user := database.User{
|
||||
ID: id,
|
||||
Username: "testuser",
|
||||
Status: status,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
}
|
||||
suspended := user
|
||||
suspended.Status = database.UserStatusSuspended
|
||||
return user, suspended
|
||||
}
|
||||
|
||||
t.Run("Replace/status-change-emits-audit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ru, mockStore, mockAudit := setupSCIMMock(t)
|
||||
activeUser, suspendedUser := makeUser(database.UserStatusActive)
|
||||
|
||||
mockStore.EXPECT().GetUserByID(gomock.Any(), activeUser.ID).Return(activeUser, nil)
|
||||
mockStore.EXPECT().UpdateUserStatus(gomock.Any(), gomock.Any()).Return(suspendedUser, nil)
|
||||
|
||||
_, err := ru.Replace(scimRequest(t), activeUser.ID.String(), scim.ResourceAttributes{
|
||||
"userName": activeUser.Username,
|
||||
"active": false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, mockAudit.AuditLogs(), 1)
|
||||
})
|
||||
|
||||
t.Run("Replace/no-change-skips-audit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ru, mockStore, mockAudit := setupSCIMMock(t)
|
||||
activeUser, _ := makeUser(database.UserStatusActive)
|
||||
|
||||
mockStore.EXPECT().GetUserByID(gomock.Any(), activeUser.ID).Return(activeUser, nil)
|
||||
// No UpdateUserStatus expected: active=true on an already active user is a no-op.
|
||||
|
||||
_, err := ru.Replace(scimRequest(t), activeUser.ID.String(), scim.ResourceAttributes{
|
||||
"userName": activeUser.Username,
|
||||
"active": true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, mockAudit.AuditLogs())
|
||||
})
|
||||
|
||||
t.Run("Delete/active-user-emits-audit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ru, mockStore, mockAudit := setupSCIMMock(t)
|
||||
activeUser, suspendedUser := makeUser(database.UserStatusActive)
|
||||
|
||||
mockStore.EXPECT().GetUserByID(gomock.Any(), activeUser.ID).Return(activeUser, nil)
|
||||
mockStore.EXPECT().UpdateUserStatus(gomock.Any(), gomock.Any()).Return(suspendedUser, nil)
|
||||
|
||||
err := ru.Delete(scimRequest(t), activeUser.ID.String())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, mockAudit.AuditLogs(), 1)
|
||||
})
|
||||
|
||||
t.Run("Delete/suspended-user-skips-audit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ru, mockStore, mockAudit := setupSCIMMock(t)
|
||||
_, suspendedUser := makeUser(database.UserStatusSuspended)
|
||||
|
||||
mockStore.EXPECT().GetUserByID(gomock.Any(), suspendedUser.ID).Return(suspendedUser, nil)
|
||||
// No UpdateUserStatus expected: already suspended.
|
||||
|
||||
err := ru.Delete(scimRequest(t), suspendedUser.ID.String())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, mockAudit.AuditLogs())
|
||||
})
|
||||
}
|
||||
|
||||
// mustPath parses a SCIM attribute path string into a *filter.Path
|
||||
// for use in PatchOperation test data.
|
||||
func mustPath(attr string) *filter.Path {
|
||||
p, err := filter.ParsePath([]byte(attr))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("mustPath(%q): %v", attr, err))
|
||||
}
|
||||
return &p
|
||||
}
|
||||
+604
-766
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/legacyscim"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/scim"
|
||||
)
|
||||
|
||||
func (api *API) mountScimRoute(opt *Options, r chi.Router) error {
|
||||
if len(opt.SCIMAPIKey) == 0 {
|
||||
// Show a helpful 404 error. Because this is not under the /api/v2 routes,
|
||||
// the frontend is the fallback. A html page is not a helpful error for
|
||||
// a SCIM provider. This JSON has a call to action that __may__ resolve
|
||||
// the issue.
|
||||
//
|
||||
// Using mount to cover all subroute possibilities
|
||||
r.Mount("/", http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), w, http.StatusNotFound, codersdk.Response{
|
||||
Message: "SCIM is disabled, please contact your administrator if you believe this is an error",
|
||||
Detail: "SCIM endpoints are disabled if no SCIM is configured. Configure 'CODER_SCIM_AUTH_HEADER' to enable.",
|
||||
})
|
||||
})))
|
||||
return nil
|
||||
}
|
||||
|
||||
if opt.UseLegacySCIM {
|
||||
// Legacy SCIM handler (imulab/go-scim based). Opt-in for
|
||||
// backward compatibility during the transition period.
|
||||
legacySrv := &legacyscim.LegacyServer{
|
||||
Logger: opt.Logger,
|
||||
Database: opt.Database,
|
||||
IDPSync: opt.IDPSync,
|
||||
AGPL: api.AGPL,
|
||||
AccessURL: api.AccessURL,
|
||||
SCIMAPIKey: opt.SCIMAPIKey,
|
||||
Auditor: &api.AGPL.Auditor,
|
||||
}
|
||||
r.Mount("/v2", chi.Chain(
|
||||
api.RequireFeatureMW(codersdk.FeatureSCIM),
|
||||
legacySrv.AuthMiddleware,
|
||||
).Handler(legacySrv.Handler()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SCIM 2.0 handler (elimity-com/scim based).
|
||||
scimSrv, err := scim.New(&scim.Options{
|
||||
DB: opt.Database,
|
||||
Auditor: &api.AGPL.Auditor,
|
||||
IDPSync: opt.IDPSync,
|
||||
Logger: opt.Logger,
|
||||
AGPL: api.AGPL,
|
||||
SCIMAPIKey: opt.SCIMAPIKey,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create scim server: %w", err)
|
||||
}
|
||||
|
||||
// The elimity-com/scim library reads r.URL.Path and strips "/v2"
|
||||
// internally. Chi's Route/Mount modifies its own routing context
|
||||
// but not r.URL.Path, so we use http.StripPrefix to ensure the
|
||||
// library sees paths like "/v2/Users" instead of "/scim/v2/Users".
|
||||
r.Mount("/", chi.Chain(
|
||||
api.RequireFeatureMW(codersdk.FeatureSCIM),
|
||||
middleware.StripPrefix("/scim"),
|
||||
).Handler(scimSrv.Handler()))
|
||||
return nil
|
||||
}
|
||||
@@ -512,6 +512,7 @@ require (
|
||||
github.com/danieljoos/wincred v1.2.3
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.0
|
||||
github.com/elazarl/goproxy v1.8.0
|
||||
github.com/elimity-com/scim v0.0.0-20260506142751-830e1caafcc3
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/go-git/go-git/v5 v5.19.1
|
||||
github.com/invopop/jsonschema v0.14.0
|
||||
@@ -519,6 +520,7 @@ require (
|
||||
github.com/nats-io/nats-server/v2 v2.12.8
|
||||
github.com/nats-io/nats.go v1.51.0
|
||||
github.com/openai/openai-go/v3 v3.28.0
|
||||
github.com/scim2/filter-parser/v2 v2.2.0
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/smallstep/pkcs7 v0.2.1
|
||||
github.com/sony/gobreaker/v2 v2.4.0
|
||||
@@ -576,6 +578,8 @@ require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/daixiang0/gci v0.13.7 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/di-wu/parser v0.2.2 // indirect
|
||||
github.com/di-wu/xsd-datetime v1.0.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
|
||||
@@ -420,6 +420,10 @@ github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7c
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/di-wu/parser v0.2.2 h1:I9oHJ8spBXOeL7Wps0ffkFFFiXJf/pk7NX9lcAMqRMU=
|
||||
github.com/di-wu/parser v0.2.2/go.mod h1:SLp58pW6WamdmznrVRrw2NTyn4wAvT9rrEFynKX7nYo=
|
||||
github.com/di-wu/xsd-datetime v1.0.0 h1:vZoGNkbzpBNoc+JyfVLEbutNDNydYV8XwHeV7eUJoxI=
|
||||
github.com/di-wu/xsd-datetime v1.0.0/go.mod h1:i3iEhrP3WchwseOBeIdW/zxeoleXTOzx1WyDXgdmOww=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
@@ -450,6 +454,8 @@ github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7
|
||||
github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU=
|
||||
github.com/elazarl/goproxy v1.8.0 h1:dt561rX7UAYMeFRLtzFx6uQGl2TpL1dr6uCG23nFQSY=
|
||||
github.com/elazarl/goproxy v1.8.0/go.mod h1:b5xm6W48AUHNpRTCvlnd0YVh+JafCCtsLsJZvvNTz+E=
|
||||
github.com/elimity-com/scim v0.0.0-20260506142751-830e1caafcc3 h1:P+JJLBS2QNe5aWBpNoDWqmGwNv/DKP+WZpU/mPIS+28=
|
||||
github.com/elimity-com/scim v0.0.0-20260506142751-830e1caafcc3/go.mod h1:JkjcmqbLW+khwt2fmBPJFBhx2zGZ8XobRZ+O0VhlwWo=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.21.2 h1:OLDgvZKuofk4em9fT5tFG5j4jE1/hXnX75UMvcrL4AA=
|
||||
@@ -1083,6 +1089,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM=
|
||||
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/scim2/filter-parser/v2 v2.2.0 h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM=
|
||||
github.com/scim2/filter-parser/v2 v2.2.0/go.mod h1:jWnkDToqX/Y0ugz0P5VvpVEUKcWcyHHj+X+je9ce5JA=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
|
||||
Generated
+1
@@ -4090,6 +4090,7 @@ export interface DeploymentValues {
|
||||
readonly agent_fallback_troubleshooting_url?: string;
|
||||
readonly browser_only?: boolean;
|
||||
readonly scim_api_key?: string;
|
||||
readonly scim_use_legacy?: boolean;
|
||||
readonly external_token_encryption_keys?: string;
|
||||
readonly provisioner?: ProvisionerConfig;
|
||||
readonly rate_limit?: RateLimitConfig;
|
||||
|
||||
Reference in New Issue
Block a user