From 43279be9d1e7bb340be43019e08979fb3dcf8046 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 7 Jan 2025 21:42:38 +0000 Subject: [PATCH 1/4] chore: add api endpoints to get idp field values --- coderd/apidoc/docs.go | 92 ++++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 84 +++++++++++++++++++++++++++ codersdk/idpsync.go | 28 +++++++++ docs/reference/api/enterprise.md | 80 ++++++++++++++++++++++++++ enterprise/coderd/coderd.go | 8 +++ enterprise/coderd/idpsync.go | 55 ++++++++++++++++++ enterprise/coderd/userauth_test.go | 8 +++ 7 files changed, 355 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0b4778a20cbce..a23488052007a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3170,6 +3170,52 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/settings/idpsync/field-values": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get the organization idp sync claim field values", + "operationId": "get-the-organization-idp-sync-claim-field-values", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "string", + "description": "Claim Field", + "name": "claimField", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, "/organizations/{organization}/settings/idpsync/groups": { "get": { "security": [ @@ -3952,6 +3998,52 @@ const docTemplate = `{ } } }, + "/settings/idpsync/field-values": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get the idp sync claim field values", + "operationId": "get-the-idp-sync-claim-field-values", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "string", + "description": "Claim Field", + "name": "claimField", + "in": "query", + "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 e9fbb8a5a9083..67d394ae67c25 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2788,6 +2788,48 @@ } } }, + "/organizations/{organization}/settings/idpsync/field-values": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get the organization idp sync claim field values", + "operationId": "get-the-organization-idp-sync-claim-field-values", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "string", + "description": "Claim Field", + "name": "claimField", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, "/organizations/{organization}/settings/idpsync/groups": { "get": { "security": [ @@ -3478,6 +3520,48 @@ } } }, + "/settings/idpsync/field-values": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get the idp sync claim field values", + "operationId": "get-the-idp-sync-claim-field-values", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "string", + "description": "Claim Field", + "name": "claimField", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, "/settings/idpsync/organization": { "get": { "security": [ diff --git a/codersdk/idpsync.go b/codersdk/idpsync.go index 3a2e707ccb623..bc54587b3795b 100644 --- a/codersdk/idpsync.go +++ b/codersdk/idpsync.go @@ -163,3 +163,31 @@ func (c *Client) GetOrganizationAvailableIDPSyncFields(ctx context.Context, orgI var resp []string return resp, json.NewDecoder(res.Body).Decode(&resp) } + +func (c *Client) GetIDPSyncFieldValues(ctx context.Context, claimField string) ([]string, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/settings/idpsync/field-values?claimField=%s", claimField), 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) GetOrganizationIDPSyncFieldValues(ctx context.Context, orgID string, claimField string) ([]string, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/settings/idpsync/field-values?claimField=%s", orgID, claimField), 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 21a46e92cba08..80515f3ee7bef 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -1830,6 +1830,46 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/setting To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get the organization idp sync claim field values + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/settings/idpsync/field-values?claimField=string \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/settings/idpsync/field-values` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|-------|----------------|----------|-----------------| +| `organization` | path | string(uuid) | true | Organization ID | +| `claimField` | query | string(string) | true | Claim Field | + +### 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 @@ -2536,6 +2576,46 @@ curl -X GET http://coder-server:8080/api/v2/settings/idpsync/available-fields \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get the idp sync claim field values + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/settings/idpsync/field-values?claimField=string \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /settings/idpsync/field-values` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|-------|----------------|----------|-----------------| +| `organization` | path | string(uuid) | true | Organization ID | +| `claimField` | query | string(string) | true | Claim Field | + +### 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 71273ff97fd75..b3974077ab0b5 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -297,6 +297,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Patch("/", api.patchOrganizationIDPSyncSettings) }) r.Get("/available-fields", api.deploymentIDPSyncClaimFields) + r.Get("/field-values", api.deploymentIDPSyncClaimFieldValues) }) }) @@ -311,6 +312,13 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Patch("/idpsync/groups", api.patchGroupIDPSyncSettings) r.Get("/idpsync/roles", api.roleIDPSyncSettings) r.Patch("/idpsync/roles", api.patchRoleIDPSyncSettings) + r.Get("/idpsync/field-values", api.organizationIDPSyncClaimFieldValues) + // r.Route("/idpsync/field-values/{claimField}", func(r chi.Router) { + // r.Use( + // httpmw.ExtractClaimFieldParam(api.Database), + // ) + // r.Get("/", api.organizationIDPSyncClaimFieldValues) + // }) }) }) diff --git a/enterprise/coderd/idpsync.go b/enterprise/coderd/idpsync.go index e7346f8406844..73146181b966f 100644 --- a/enterprise/coderd/idpsync.go +++ b/enterprise/coderd/idpsync.go @@ -363,3 +363,58 @@ func (api *API) idpSyncClaimFields(orgID uuid.UUID, rw http.ResponseWriter, r *h httpapi.Write(ctx, rw, http.StatusOK, fields) } + +// @Summary Get the organization idp sync claim field values +// @ID get-the-organization-idp-sync-claim-field-values +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param organization path string true "Organization ID" format(uuid) +// @Param claimField query string true "Claim Field" format(string) +// @Success 200 {array} string +// @Router /organizations/{organization}/settings/idpsync/field-values [get] +func (api *API) organizationIDPSyncClaimFieldValues(rw http.ResponseWriter, r *http.Request) { + org := httpmw.OrganizationParam(r) + claimField := r.URL.Query().Get("claimField") + api.idpSyncClaimFieldValues(org.ID, claimField, rw, r) +} + +// @Summary Get the idp sync claim field values +// @ID get-the-idp-sync-claim-field-values +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param organization path string true "Organization ID" format(uuid) +// @Param claimField query string true "Claim Field" format(string) +// @Success 200 {array} string +// @Router /settings/idpsync/field-values [get] +func (api *API) deploymentIDPSyncClaimFieldValues(rw http.ResponseWriter, r *http.Request) { + claimField := r.URL.Query().Get("claimField") + // nil uuid implies all organizations + api.idpSyncClaimFieldValues(uuid.Nil, claimField, rw, r) +} + +func (api *API) idpSyncClaimFieldValues(orgID uuid.UUID, claimField string, rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + fields, err := api.Database.OIDCClaimFieldValues(ctx, database.OIDCClaimFieldValuesParams{ + OrganizationID: orgID, + ClaimField: claimField, + }) + + 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 IDP claim field values", + 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 28257078ebb36..d3e997608f316 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -178,6 +178,14 @@ func TestUserOIDC(t *testing.T) { require.NoError(t, err) require.ElementsMatch(t, fields, orgFields) + fieldValues, err := runner.AdminClient.GetIDPSyncFieldValues(ctx, "organization") + require.NoError(t, err) + require.ElementsMatch(t, []string{"first", "second"}, fieldValues) + + orgFieldValues, err := runner.AdminClient.GetOrganizationIDPSyncFieldValues(ctx, orgOne.ID.String(), "organization") + require.NoError(t, err) + require.ElementsMatch(t, []string{"first", "second"}, orgFieldValues) + // When: they are manually added to the fourth organization, a new sync // should remove them. _, err = runner.AdminClient.PostOrganizationMember(ctx, orgThree.ID, "alice") From faf5150a5fd61ef2f8334d1e8cb21b54deb80df6 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 7 Jan 2025 22:36:17 +0000 Subject: [PATCH 2/4] chore: removed unused code --- enterprise/coderd/coderd.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index b3974077ab0b5..90042bf0997fc 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -313,12 +313,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Get("/idpsync/roles", api.roleIDPSyncSettings) r.Patch("/idpsync/roles", api.patchRoleIDPSyncSettings) r.Get("/idpsync/field-values", api.organizationIDPSyncClaimFieldValues) - // r.Route("/idpsync/field-values/{claimField}", func(r chi.Router) { - // r.Use( - // httpmw.ExtractClaimFieldParam(api.Database), - // ) - // r.Get("/", api.organizationIDPSyncClaimFieldValues) - // }) }) }) From a4558032c3e0ebc0096fb71b7beadbed3564dd48 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 8 Jan 2025 18:41:32 +0000 Subject: [PATCH 3/4] fix: url encode query param --- codersdk/idpsync.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/codersdk/idpsync.go b/codersdk/idpsync.go index bc54587b3795b..2cc1f51ee3011 100644 --- a/codersdk/idpsync.go +++ b/codersdk/idpsync.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "regexp" "github.com/google/uuid" @@ -165,7 +166,9 @@ func (c *Client) GetOrganizationAvailableIDPSyncFields(ctx context.Context, orgI } func (c *Client) GetIDPSyncFieldValues(ctx context.Context, claimField string) ([]string, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/settings/idpsync/field-values?claimField=%s", claimField), nil) + qv := url.Values{} + qv.Add("claimField", claimField) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/settings/idpsync/field-values?%s", qv.Encode()), nil) if err != nil { return nil, xerrors.Errorf("make request: %w", err) } @@ -179,7 +182,9 @@ func (c *Client) GetIDPSyncFieldValues(ctx context.Context, claimField string) ( } func (c *Client) GetOrganizationIDPSyncFieldValues(ctx context.Context, orgID string, claimField string) ([]string, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/settings/idpsync/field-values?claimField=%s", orgID, claimField), nil) + qv := url.Values{} + qv.Add("claimField", claimField) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/settings/idpsync/field-values?%s", orgID, qv.Encode()), nil) if err != nil { return nil, xerrors.Errorf("make request: %w", err) } From 4ff34ccd70bd8c1e099e4c815250533c79930ab9 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 8 Jan 2025 18:42:58 +0000 Subject: [PATCH 4/4] fix: check for empty string in claimField --- enterprise/coderd/idpsync.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/enterprise/coderd/idpsync.go b/enterprise/coderd/idpsync.go index 73146181b966f..192d61ea996c6 100644 --- a/enterprise/coderd/idpsync.go +++ b/enterprise/coderd/idpsync.go @@ -375,8 +375,7 @@ func (api *API) idpSyncClaimFields(orgID uuid.UUID, rw http.ResponseWriter, r *h // @Router /organizations/{organization}/settings/idpsync/field-values [get] func (api *API) organizationIDPSyncClaimFieldValues(rw http.ResponseWriter, r *http.Request) { org := httpmw.OrganizationParam(r) - claimField := r.URL.Query().Get("claimField") - api.idpSyncClaimFieldValues(org.ID, claimField, rw, r) + api.idpSyncClaimFieldValues(org.ID, rw, r) } // @Summary Get the idp sync claim field values @@ -389,15 +388,21 @@ func (api *API) organizationIDPSyncClaimFieldValues(rw http.ResponseWriter, r *h // @Success 200 {array} string // @Router /settings/idpsync/field-values [get] func (api *API) deploymentIDPSyncClaimFieldValues(rw http.ResponseWriter, r *http.Request) { - claimField := r.URL.Query().Get("claimField") // nil uuid implies all organizations - api.idpSyncClaimFieldValues(uuid.Nil, claimField, rw, r) + api.idpSyncClaimFieldValues(uuid.Nil, rw, r) } -func (api *API) idpSyncClaimFieldValues(orgID uuid.UUID, claimField string, rw http.ResponseWriter, r *http.Request) { +func (api *API) idpSyncClaimFieldValues(orgID uuid.UUID, rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - fields, err := api.Database.OIDCClaimFieldValues(ctx, database.OIDCClaimFieldValuesParams{ + claimField := r.URL.Query().Get("claimField") + if claimField == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "claimField query parameter is required", + }) + return + } + fieldValues, err := api.Database.OIDCClaimFieldValues(ctx, database.OIDCClaimFieldValuesParams{ OrganizationID: orgID, ClaimField: claimField, }) @@ -416,5 +421,5 @@ func (api *API) idpSyncClaimFieldValues(orgID uuid.UUID, claimField string, rw h return } - httpapi.Write(ctx, rw, http.StatusOK, fields) + httpapi.Write(ctx, rw, http.StatusOK, fieldValues) }