chore: add query to fetch top level idp claim fields (#15525)

Adds an api endpoint to grab all available sync field options for IDP
sync. This is for autocomplete on idp sync forms. This is required for
organization admins to have some insight into the claim fields available
when configuring group/role sync.
This commit is contained in:
Steven Masley
2024-11-18 14:31:39 -06:00
committed by GitHub
parent 48bb452829
commit c3c23ed3d9
18 changed files with 679 additions and 10 deletions
+76
View File
@@ -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": [
+68
View File
@@ -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": [
+12
View File
@@ -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)
}
+7
View File
@@ -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})
+29
View File
@@ -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
@@ -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)
+15
View File
@@ -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()
+7
View File
@@ -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
}
+219
View File
@@ -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
}
+3
View File
@@ -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
+43
View File
@@ -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
+21
View File
@@ -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
;
-7
View File
@@ -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
+28
View File
@@ -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)
}
+74
View File
@@ -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 |
<h3 id="get-the-available-organization-idp-sync-claim-fields-responseschema">Response Schema</h3>
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 |
<h3 id="get-the-available-idp-sync-claim-fields-responseschema">Response Schema</h3>
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get organization IdP Sync settings
### Code samples
+7 -3
View File
@@ -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)
+50
View File
@@ -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)
}
+13
View File
@@ -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")