diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 48b5618dec653..2c046dc8d997c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3132,6 +3132,44 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/settings/idpsync/available-fields": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get the available organization idp sync claim fields", + "operationId": "get-the-available-organization-idp-sync-claim-fields", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, "/organizations/{organization}/settings/idpsync/groups": { "get": { "security": [ @@ -3800,6 +3838,44 @@ const docTemplate = `{ } } }, + "/settings/idpsync/available-fields": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get the available idp sync claim fields", + "operationId": "get-the-available-idp-sync-claim-fields", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, "/settings/idpsync/organization": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d6aec54109316..4baae0c3568c3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2754,6 +2754,40 @@ } } }, + "/organizations/{organization}/settings/idpsync/available-fields": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get the available organization idp sync claim fields", + "operationId": "get-the-available-organization-idp-sync-claim-fields", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, "/organizations/{organization}/settings/idpsync/groups": { "get": { "security": [ @@ -3342,6 +3376,40 @@ } } }, + "/settings/idpsync/available-fields": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get the available idp sync claim fields", + "operationId": "get-the-available-idp-sync-claim-fields", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, "/settings/idpsync/organization": { "get": { "security": [ diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 2a9c29b175dae..4845ff22288fe 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3283,6 +3283,18 @@ func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID return q.db.ListWorkspaceAgentPortShares(ctx, workspaceID) } +func (q *querier) OIDCClaimFields(ctx context.Context, organizationID uuid.UUID) ([]string, error) { + resource := rbac.ResourceIdpsyncSettings + if organizationID != uuid.Nil { + resource = resource.InOrg(organizationID) + } + + if err := q.authorizeContext(ctx, policy.ActionRead, resource); err != nil { + return nil, err + } + return q.db.OIDCClaimFields(ctx, organizationID) +} + func (q *querier) OrganizationMembers(ctx context.Context, arg database.OrganizationMembersParams) ([]database.OrganizationMembersRow, error) { return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.OrganizationMembers)(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 978478e4709c5..2eb75f8b738c4 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -626,6 +626,13 @@ func (s *MethodTestSuite) TestLicense() { } func (s *MethodTestSuite) TestOrganization() { + s.Run("Deployment/OIDCClaimFields", s.Subtest(func(db database.Store, check *expects) { + check.Args(uuid.Nil).Asserts(rbac.ResourceIdpsyncSettings, policy.ActionRead).Returns([]string{}) + })) + s.Run("Organization/OIDCClaimFields", s.Subtest(func(db database.Store, check *expects) { + id := uuid.New() + check.Args(id).Asserts(rbac.ResourceIdpsyncSettings.InOrg(id), policy.ActionRead).Returns([]string{}) + })) s.Run("ByOrganization/GetGroups", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) a := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 64310cf36445a..aed57e9284b3a 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -8409,6 +8409,35 @@ func (q *FakeQuerier) ListWorkspaceAgentPortShares(_ context.Context, workspaceI return shares, nil } +func (q *FakeQuerier) OIDCClaimFields(_ context.Context, organizationID uuid.UUID) ([]string, error) { + orgMembers := q.getOrganizationMemberNoLock(organizationID) + + var fields []string + for _, link := range q.userLinks { + if organizationID != uuid.Nil { + inOrg := slices.ContainsFunc(orgMembers, func(organizationMember database.OrganizationMember) bool { + return organizationMember.UserID == link.UserID + }) + if !inOrg { + continue + } + } + + if link.LoginType != database.LoginTypeOIDC { + continue + } + + for k := range link.Claims.IDTokenClaims { + fields = append(fields, k) + } + for k := range link.Claims.UserInfoClaims { + fields = append(fields, k) + } + } + + return slice.Unique(fields), nil +} + func (q *FakeQuerier) OrganizationMembers(_ context.Context, arg database.OrganizationMembersParams) ([]database.OrganizationMembersRow, error) { if err := validateDatabaseType(arg); err != nil { return []database.OrganizationMembersRow{}, err diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index d03e481628991..32d3cce658525 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2058,6 +2058,13 @@ func (m queryMetricsStore) ListWorkspaceAgentPortShares(ctx context.Context, wor return r0, r1 } +func (m queryMetricsStore) OIDCClaimFields(ctx context.Context, organizationID uuid.UUID) ([]string, error) { + start := time.Now() + r0, r1 := m.s.OIDCClaimFields(ctx, organizationID) + m.queryLatencies.WithLabelValues("OIDCClaimFields").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) OrganizationMembers(ctx context.Context, arg database.OrganizationMembersParams) ([]database.OrganizationMembersRow, error) { start := time.Now() r0, r1 := m.s.OrganizationMembers(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 04bf967caf26b..d6c34411f8208 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4359,6 +4359,21 @@ func (mr *MockStoreMockRecorder) ListWorkspaceAgentPortShares(arg0, arg1 any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWorkspaceAgentPortShares", reflect.TypeOf((*MockStore)(nil).ListWorkspaceAgentPortShares), arg0, arg1) } +// OIDCClaimFields mocks base method. +func (m *MockStore) OIDCClaimFields(arg0 context.Context, arg1 uuid.UUID) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OIDCClaimFields", arg0, arg1) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// OIDCClaimFields indicates an expected call of OIDCClaimFields. +func (mr *MockStoreMockRecorder) OIDCClaimFields(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OIDCClaimFields", reflect.TypeOf((*MockStore)(nil).OIDCClaimFields), arg0, arg1) +} + // OrganizationMembers mocks base method. func (m *MockStore) OrganizationMembers(arg0 context.Context, arg1 database.OrganizationMembersParams) ([]database.OrganizationMembersRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index e687994778017..ff77012755fa2 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -3,6 +3,7 @@ package database import ( "context" "database/sql" + "encoding/json" "fmt" "strings" @@ -527,3 +528,9 @@ func insertAuthorizedFilter(query string, replaceWith string) (string, error) { filtered := strings.Replace(query, authorizedQueryPlaceholder, replaceWith, 1) return filtered, nil } + +// UpdateUserLinkRawJSON is a custom query for unit testing. Do not ever expose this +func (q *sqlQuerier) UpdateUserLinkRawJSON(ctx context.Context, userID uuid.UUID, data json.RawMessage) error { + _, err := q.sdb.ExecContext(ctx, "UPDATE user_links SET claims = $2 WHERE user_id = $1", userID, data) + return err +} diff --git a/coderd/database/oidcclaims_test.go b/coderd/database/oidcclaims_test.go new file mode 100644 index 0000000000000..85fd5b3df3812 --- /dev/null +++ b/coderd/database/oidcclaims_test.go @@ -0,0 +1,219 @@ +package database_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/testutil" +) + +type extraKeys struct { + database.UserLinkClaims + Foo string `json:"foo"` +} + +func TestOIDCClaims(t *testing.T) { + t.Parallel() + + toJSON := func(a any) json.RawMessage { + b, _ := json.Marshal(a) + return b + } + + db, _ := dbtestutil.NewDB(t) + g := userGenerator{t: t, db: db} + + // https://en.wikipedia.org/wiki/Alice_and_Bob#Cast_of_characters + alice := g.withLink(database.LoginTypeOIDC, toJSON(extraKeys{ + UserLinkClaims: database.UserLinkClaims{ + IDTokenClaims: map[string]interface{}{ + "sub": "alice", + "alice-id": "from-bob", + }, + UserInfoClaims: nil, + MergedClaims: map[string]interface{}{ + "sub": "alice", + "alice-id": "from-bob", + }, + }, + // Always should be a no-op + Foo: "bar", + })) + bob := g.withLink(database.LoginTypeOIDC, toJSON(database.UserLinkClaims{ + IDTokenClaims: map[string]interface{}{ + "sub": "bob", + "bob-id": "from-bob", + "array": []string{ + "a", "b", "c", + }, + "map": map[string]interface{}{ + "key": "value", + "foo": "bar", + }, + "nil": nil, + }, + UserInfoClaims: map[string]interface{}{ + "sub": "bob", + "bob-info": []string{}, + "number": 42, + }, + MergedClaims: map[string]interface{}{ + "sub": "bob", + "bob-info": []string{}, + "number": 42, + "bob-id": "from-bob", + "array": []string{ + "a", "b", "c", + }, + "map": map[string]interface{}{ + "key": "value", + "foo": "bar", + }, + "nil": nil, + }, + })) + charlie := g.withLink(database.LoginTypeOIDC, toJSON(database.UserLinkClaims{ + IDTokenClaims: map[string]interface{}{ + "sub": "charlie", + "charlie-id": "charlie", + }, + UserInfoClaims: map[string]interface{}{ + "sub": "charlie", + "charlie-info": "charlie", + }, + MergedClaims: map[string]interface{}{ + "sub": "charlie", + "charlie-id": "charlie", + "charlie-info": "charlie", + }, + })) + + // users that just try to cause problems, but should not affect the output of + // queries. + problematics := []database.User{ + g.withLink(database.LoginTypeOIDC, toJSON(database.UserLinkClaims{})), // null claims + g.withLink(database.LoginTypeOIDC, []byte(`{}`)), // empty claims + g.withLink(database.LoginTypeOIDC, []byte(`{"foo": "bar"}`)), // random keys + g.noLink(database.LoginTypeOIDC), // no link + + g.withLink(database.LoginTypeGithub, toJSON(database.UserLinkClaims{ + IDTokenClaims: map[string]interface{}{ + "not": "allowed", + }, + UserInfoClaims: map[string]interface{}{ + "do-not": "look", + }, + MergedClaims: map[string]interface{}{ + "not": "allowed", + "do-not": "look", + }, + })), // github should be omitted + + // extra random users + g.noLink(database.LoginTypeGithub), + g.noLink(database.LoginTypePassword), + } + + // Insert some orgs, users, and links + orgA := dbfake.Organization(t, db).Members( + append(problematics, + alice, + bob, + )..., + ).Do() + orgB := dbfake.Organization(t, db).Members( + append(problematics, + bob, + charlie, + )..., + ).Do() + orgC := dbfake.Organization(t, db).Members().Do() + + // Verify the OIDC claim fields + always := []string{"array", "map", "nil", "number"} + expectA := append([]string{"sub", "alice-id", "bob-id", "bob-info"}, always...) + expectB := append([]string{"sub", "bob-id", "bob-info", "charlie-id", "charlie-info"}, always...) + requireClaims(t, db, orgA.Org.ID, expectA) + requireClaims(t, db, orgB.Org.ID, expectB) + requireClaims(t, db, orgC.Org.ID, []string{}) + requireClaims(t, db, uuid.Nil, slice.Unique(append(expectA, expectB...))) +} + +func requireClaims(t *testing.T, db database.Store, orgID uuid.UUID, want []string) { + t.Helper() + + ctx := testutil.Context(t, testutil.WaitMedium) + got, err := db.OIDCClaimFields(ctx, orgID) + require.NoError(t, err) + + require.ElementsMatch(t, want, got) +} + +type userGenerator struct { + t *testing.T + db database.Store +} + +func (g userGenerator) noLink(lt database.LoginType) database.User { + t := g.t + db := g.db + + t.Helper() + + u := dbgen.User(t, db, database.User{ + LoginType: lt, + }) + return u +} + +func (g userGenerator) withLink(lt database.LoginType, rawJSON json.RawMessage) database.User { + t := g.t + db := g.db + + user := g.noLink(lt) + + link := dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: lt, + }) + + if sql, ok := db.(rawUpdater); ok { + // The only way to put arbitrary json into the db for testing edge cases. + // Making this a public API would be a mistake. + err := sql.UpdateUserLinkRawJSON(context.Background(), user.ID, rawJSON) + require.NoError(t, err) + } else { + // no need to test the json key logic in dbmem. Everything is type safe. + var claims database.UserLinkClaims + err := json.Unmarshal(rawJSON, &claims) + require.NoError(t, err) + + _, err = db.UpdateUserLink(context.Background(), database.UpdateUserLinkParams{ + OAuthAccessToken: link.OAuthAccessToken, + OAuthAccessTokenKeyID: link.OAuthAccessTokenKeyID, + OAuthRefreshToken: link.OAuthRefreshToken, + OAuthRefreshTokenKeyID: link.OAuthRefreshTokenKeyID, + OAuthExpiry: link.OAuthExpiry, + UserID: link.UserID, + LoginType: link.LoginType, + // The new claims + Claims: claims, + }) + require.NoError(t, err) + } + + return user +} + +type rawUpdater interface { + UpdateUserLinkRawJSON(ctx context.Context, userID uuid.UUID, data json.RawMessage) error +} diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b7652865447ad..49ba6fbf8496a 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -413,6 +413,9 @@ type sqlcQuerier interface { ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error) ListProvisionerKeysByOrganizationExcludeReserved(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error) + // OIDCClaimFields returns a list of distinct keys in the the merged_claims fields. + // This query is used to generate the list of available sync fields for idp sync settings. + OIDCClaimFields(ctx context.Context, organizationID uuid.UUID) ([]string, error) // Arguments are optional with uuid.Nil to ignore. // - Use just 'organization_id' to get all members of an org // - Use just 'user_id' to get all orgs a user is a member of diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 94f108886ea60..09dd4c1fbc488 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9846,6 +9846,49 @@ func (q *sqlQuerier) InsertUserLink(ctx context.Context, arg InsertUserLinkParam return i, err } +const oIDCClaimFields = `-- name: OIDCClaimFields :many +SELECT + DISTINCT jsonb_object_keys(claims->'merged_claims') +FROM + user_links +WHERE + -- Only return rows where the top level key exists + claims ? 'merged_claims' AND + -- 'null' is the default value for the id_token_claims field + -- jsonb 'null' is not the same as SQL NULL. Strip these out. + jsonb_typeof(claims->'merged_claims') != 'null' AND + login_type = 'oidc' + AND CASE WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + user_links.user_id = ANY(SELECT organization_members.user_id FROM organization_members WHERE organization_id = $1) + ELSE true + END +` + +// OIDCClaimFields returns a list of distinct keys in the the merged_claims fields. +// This query is used to generate the list of available sync fields for idp sync settings. +func (q *sqlQuerier) OIDCClaimFields(ctx context.Context, organizationID uuid.UUID) ([]string, error) { + rows, err := q.db.QueryContext(ctx, oIDCClaimFields, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var jsonb_object_keys string + if err := rows.Scan(&jsonb_object_keys); err != nil { + return nil, err + } + items = append(items, jsonb_object_keys) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateUserLink = `-- name: UpdateUserLink :one UPDATE user_links diff --git a/coderd/database/queries/user_links.sql b/coderd/database/queries/user_links.sql index d0d52c3eac054..274193b0c8bf6 100644 --- a/coderd/database/queries/user_links.sql +++ b/coderd/database/queries/user_links.sql @@ -57,3 +57,24 @@ SET claims = $6 WHERE user_id = $7 AND login_type = $8 RETURNING *; + + +-- name: OIDCClaimFields :many +-- OIDCClaimFields returns a list of distinct keys in the the merged_claims fields. +-- This query is used to generate the list of available sync fields for idp sync settings. +SELECT + DISTINCT jsonb_object_keys(claims->'merged_claims') +FROM + user_links +WHERE + -- Only return rows where the top level key exists + claims ? 'merged_claims' AND + -- 'null' is the default value for the id_token_claims field + -- jsonb 'null' is not the same as SQL NULL. Strip these out. + jsonb_typeof(claims->'merged_claims') != 'null' AND + login_type = 'oidc' + AND CASE WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + user_links.user_id = ANY(SELECT organization_members.user_id FROM organization_members WHERE organization_id = @organization_id) + ELSE true + END +; diff --git a/coderd/userauth.go b/coderd/userauth.go index 44b8c15a5dba5..c5e95e44998b2 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1395,13 +1395,6 @@ func mergeClaims(a, b map[string]interface{}) map[string]interface{} { return c } -// OauthDebugContext provides helpful information for admins to debug -// OAuth login issues. -type OauthDebugContext struct { - IDTokenClaims map[string]interface{} `json:"id_token_claims"` - UserInfoClaims map[string]interface{} `json:"user_info_claims"` -} - type oauthLoginParams struct { User database.User Link database.UserLink diff --git a/codersdk/idpsync.go b/codersdk/idpsync.go index 0226dc7f9eb5f..6d34714bc5833 100644 --- a/codersdk/idpsync.go +++ b/codersdk/idpsync.go @@ -137,3 +137,31 @@ func (c *Client) PatchOrganizationIDPSyncSettings(ctx context.Context, req Organ var resp OrganizationSyncSettings return resp, json.NewDecoder(res.Body).Decode(&resp) } + +func (c *Client) GetAvailableIDPSyncFields(ctx context.Context) ([]string, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/settings/idpsync/available-fields", nil) + if err != nil { + return nil, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var resp []string + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +func (c *Client) GetOrganizationAvailableIDPSyncFields(ctx context.Context, orgID string) ([]string, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/settings/idpsync/available-fields", orgID), nil) + if err != nil { + return nil, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var resp []string + return resp, json.NewDecoder(res.Body).Decode(&resp) +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 53b50a460f875..d5bf44192fc00 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -1778,6 +1778,43 @@ curl -X DELETE http://coder-server:8080/api/v2/organizations/{organization}/prov To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get the available organization idp sync claim fields + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/settings/idpsync/available-fields \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/settings/idpsync/available-fields` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------------ | -------- | --------------- | +| `organization` | path | string(uuid) | true | Organization ID | + +### Example responses + +> 200 Response + +```json +["string"] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | --------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of string | + +

Response Schema

+ +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get group IdP Sync settings by organization ### Code samples @@ -2274,6 +2311,43 @@ curl -X PATCH http://coder-server:8080/api/v2/scim/v2/Users/{id} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get the available idp sync claim fields + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/settings/idpsync/available-fields \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /settings/idpsync/available-fields` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------------ | -------- | --------------- | +| `organization` | path | string(uuid) | true | Organization ID | + +### Example responses + +> 200 Response + +```json +["string"] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | --------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of string | + +

Response Schema

+ +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get organization IdP Sync settings ### Code samples diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index b7642f4835c3b..b9356bc5b8f92 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -291,9 +291,12 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Use( apiKeyMiddleware, ) - r.Route("/settings/idpsync/organization", func(r chi.Router) { - r.Get("/", api.organizationIDPSyncSettings) - r.Patch("/", api.patchOrganizationIDPSyncSettings) + r.Route("/settings/idpsync", func(r chi.Router) { + r.Route("/organization", func(r chi.Router) { + r.Get("/", api.organizationIDPSyncSettings) + r.Patch("/", api.patchOrganizationIDPSyncSettings) + }) + r.Get("/available-fields", api.deploymentIDPSyncClaimFields) }) }) @@ -303,6 +306,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { httpmw.ExtractOrganizationParam(api.Database), ) r.Route("/organizations/{organization}/settings", func(r chi.Router) { + r.Get("/idpsync/available-fields", api.organizationIDPSyncClaimFields) r.Get("/idpsync/groups", api.groupIDPSyncSettings) r.Patch("/idpsync/groups", api.patchGroupIDPSyncSettings) r.Get("/idpsync/roles", api.roleIDPSyncSettings) diff --git a/enterprise/coderd/idpsync.go b/enterprise/coderd/idpsync.go index 0df64ffb86b4b..087266462df26 100644 --- a/enterprise/coderd/idpsync.go +++ b/enterprise/coderd/idpsync.go @@ -1,8 +1,11 @@ package coderd import ( + "fmt" "net/http" + "github.com/google/uuid" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" @@ -259,3 +262,50 @@ func (api *API) patchOrganizationIDPSyncSettings(rw http.ResponseWriter, r *http AssignDefault: settings.AssignDefault, }) } + +// @Summary Get the available organization idp sync claim fields +// @ID get-the-available-organization-idp-sync-claim-fields +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param organization path string true "Organization ID" format(uuid) +// @Success 200 {array} string +// @Router /organizations/{organization}/settings/idpsync/available-fields [get] +func (api *API) organizationIDPSyncClaimFields(rw http.ResponseWriter, r *http.Request) { + org := httpmw.OrganizationParam(r) + api.idpSyncClaimFields(org.ID, rw, r) +} + +// @Summary Get the available idp sync claim fields +// @ID get-the-available-idp-sync-claim-fields +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param organization path string true "Organization ID" format(uuid) +// @Success 200 {array} string +// @Router /settings/idpsync/available-fields [get] +func (api *API) deploymentIDPSyncClaimFields(rw http.ResponseWriter, r *http.Request) { + // nil uuid implies all organizations + api.idpSyncClaimFields(uuid.Nil, rw, r) +} + +func (api *API) idpSyncClaimFields(orgID uuid.UUID, rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + fields, err := api.Database.OIDCClaimFields(ctx, orgID) + if httpapi.IsUnauthorizedError(err) { + // Give a helpful error. The user could read the org, so this does not + // leak anything. + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "You do not have permission to view the available IDP fields", + Detail: fmt.Sprintf("%s.read permission is required", rbac.ResourceIdpsyncSettings.Type), + }) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, fields) +} diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go index 47ed424055ece..28257078ebb36 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -165,6 +165,19 @@ func TestUserOIDC(t *testing.T) { user, err := userClient.User(ctx, codersdk.Me) require.NoError(t, err) + // Then: the available sync fields should be "email" and "organization" + fields, err := runner.AdminClient.GetAvailableIDPSyncFields(ctx) + require.NoError(t, err) + require.ElementsMatch(t, []string{ + "aud", "exp", "iss", // Always included from jwt + "email", "organization", + }, fields) + + // This should be the same as above + orgFields, err := runner.AdminClient.GetOrganizationAvailableIDPSyncFields(ctx, orgOne.ID.String()) + require.NoError(t, err) + require.ElementsMatch(t, fields, orgFields) + // When: they are manually added to the fourth organization, a new sync // should remove them. _, err = runner.AdminClient.PostOrganizationMember(ctx, orgThree.ID, "alice")