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

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

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