From 4bda39585dade551d5d9eb578bfc9d761e2ce36a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 26 Sep 2025 11:43:32 +0200 Subject: [PATCH] feat: add external API key scopes (#19916) # Add support for low-level API key scopes This PR adds support for fine-grained API key scopes based on RBAC resource:action pairs. It includes: 1. A new endpoint `/api/v2/auth/scopes` to list all public low-level API key scopes 2. Generated constants in the SDK for all public scopes 3. Tests to verify scope validation during token creation 4. Updated API documentation to reflect the expanded scope options The implementation allows users to create API keys with specific permissions like `workspace:read` or `template:use` instead of only the legacy `all` or `application_connect` scopes. Fixes #19847 --- Makefile | 7 ++ coderd/apidoc/docs.go | 105 +++++++++++++++++++++--- coderd/apidoc/swagger.json | 104 +++++++++++++++++++++-- coderd/apikey.go | 18 +++- coderd/apikey/apikey.go | 35 +++++--- coderd/apikey_scopes_validation_test.go | 47 +++++++++++ coderd/coderd.go | 2 + coderd/coderdtest/swaggerparser.go | 1 + coderd/scopes_catalog.go | 30 +++++++ coderd/scopes_catalog_api_test.go | 30 +++++++ codersdk/apikey.go | 10 +-- codersdk/apikey_scopes_gen.go | 73 ++++++++++++++++ codersdk/scopes_catalog.go | 5 ++ docs/reference/api/authorization.md | 30 +++++++ docs/reference/api/schemas.md | 61 +++++++++++--- scripts/apikeyscopesgen/main.go | 99 ++++++++++++++++++++++ site/src/api/typesGenerated.ts | 74 ++++++++++++++++- 17 files changed, 675 insertions(+), 56 deletions(-) create mode 100644 coderd/apikey_scopes_validation_test.go create mode 100644 coderd/scopes_catalog.go create mode 100644 coderd/scopes_catalog_api_test.go create mode 100644 codersdk/apikey_scopes_gen.go create mode 100644 codersdk/scopes_catalog.go create mode 100644 scripts/apikeyscopesgen/main.go diff --git a/Makefile b/Makefile index ed746b0f71..2f9a4ffd73 100644 --- a/Makefile +++ b/Makefile @@ -651,6 +651,7 @@ GEN_FILES := \ coderd/rbac/object_gen.go \ codersdk/rbacresources_gen.go \ coderd/rbac/scopes_constants_gen.go \ + codersdk/apikey_scopes_gen.go \ docs/admin/integrations/prometheus.md \ docs/reference/cli/index.md \ docs/admin/security/audit-logs.md \ @@ -870,6 +871,12 @@ codersdk/rbacresources_gen.go: scripts/typegen/codersdk.gotmpl scripts/typegen/m mv /tmp/rbacresources_gen.go codersdk/rbacresources_gen.go touch "$@" +codersdk/apikey_scopes_gen.go: scripts/apikeyscopesgen/main.go coderd/rbac/scopes_catalog.go coderd/rbac/scopes.go + # Generate SDK constants for public low-level API key scopes. + go run ./scripts/apikeyscopesgen > /tmp/apikey_scopes_gen.go + mv /tmp/apikey_scopes_gen.go codersdk/apikey_scopes_gen.go + touch "$@" + site/src/api/rbacresourcesGenerated.ts: site/node_modules/.installed scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go go run scripts/typegen/main.go rbac typescript > "$@" (cd site/ && pnpm exec biome format --write src/api/rbacresourcesGenerated.ts) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ecb2b4a7ea..0961bbd904 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -324,6 +324,26 @@ const docTemplate = `{ } } }, + "/auth/scopes": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "List API key scopes", + "operationId": "list-api-key-scopes", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ExternalAPIKeyScopes" + } + } + } + } + }, "/authcheck": { "post": { "security": [ @@ -11299,11 +11319,71 @@ const docTemplate = `{ "type": "string", "enum": [ "all", - "application_connect" + "api_key:*", + "api_key:create", + "api_key:delete", + "api_key:read", + "api_key:update", + "application_connect", + "file:*", + "file:create", + "file:read", + "template:*", + "template:create", + "template:delete", + "template:read", + "template:update", + "template:use", + "user:read_personal", + "user:update_personal", + "user_secret:*", + "user_secret:create", + "user_secret:delete", + "user_secret:read", + "user_secret:update", + "workspace:*", + "workspace:application_connect", + "workspace:create", + "workspace:delete", + "workspace:read", + "workspace:ssh", + "workspace:start", + "workspace:stop", + "workspace:update" ], "x-enum-varnames": [ "APIKeyScopeAll", - "APIKeyScopeApplicationConnect" + "APIKeyScopeApiKeyAll", + "APIKeyScopeApiKeyCreate", + "APIKeyScopeApiKeyDelete", + "APIKeyScopeApiKeyRead", + "APIKeyScopeApiKeyUpdate", + "APIKeyScopeApplicationConnect", + "APIKeyScopeFileAll", + "APIKeyScopeFileCreate", + "APIKeyScopeFileRead", + "APIKeyScopeTemplateAll", + "APIKeyScopeTemplateCreate", + "APIKeyScopeTemplateDelete", + "APIKeyScopeTemplateRead", + "APIKeyScopeTemplateUpdate", + "APIKeyScopeTemplateUse", + "APIKeyScopeUserReadPersonal", + "APIKeyScopeUserUpdatePersonal", + "APIKeyScopeUserSecretAll", + "APIKeyScopeUserSecretCreate", + "APIKeyScopeUserSecretDelete", + "APIKeyScopeUserSecretRead", + "APIKeyScopeUserSecretUpdate", + "APIKeyScopeWorkspaceAll", + "APIKeyScopeWorkspaceApplicationConnect", + "APIKeyScopeWorkspaceCreate", + "APIKeyScopeWorkspaceDelete", + "APIKeyScopeWorkspaceRead", + "APIKeyScopeWorkspaceSsh", + "APIKeyScopeWorkspaceStart", + "APIKeyScopeWorkspaceStop", + "APIKeyScopeWorkspaceUpdate" ] }, "codersdk.AddLicenseRequest": { @@ -12373,15 +12453,7 @@ const docTemplate = `{ "type": "integer" }, "scope": { - "enum": [ - "all", - "application_connect" - ], - "allOf": [ - { - "$ref": "#/definitions/codersdk.APIKeyScope" - } - ] + "$ref": "#/definitions/codersdk.APIKeyScope" }, "token_name": { "type": "string" @@ -13229,6 +13301,17 @@ const docTemplate = `{ "ExperimentAIBridge" ] }, + "codersdk.ExternalAPIKeyScopes": { + "type": "object", + "properties": { + "external": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIKeyScope" + } + } + } + }, "codersdk.ExternalAgentCredentials": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0607160868..1d5cdf296a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -274,6 +274,22 @@ } } }, + "/auth/scopes": { + "get": { + "produces": ["application/json"], + "tags": ["Authorization"], + "summary": "List API key scopes", + "operationId": "list-api-key-scopes", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ExternalAPIKeyScopes" + } + } + } + } + }, "/authcheck": { "post": { "security": [ @@ -10021,8 +10037,74 @@ }, "codersdk.APIKeyScope": { "type": "string", - "enum": ["all", "application_connect"], - "x-enum-varnames": ["APIKeyScopeAll", "APIKeyScopeApplicationConnect"] + "enum": [ + "all", + "api_key:*", + "api_key:create", + "api_key:delete", + "api_key:read", + "api_key:update", + "application_connect", + "file:*", + "file:create", + "file:read", + "template:*", + "template:create", + "template:delete", + "template:read", + "template:update", + "template:use", + "user:read_personal", + "user:update_personal", + "user_secret:*", + "user_secret:create", + "user_secret:delete", + "user_secret:read", + "user_secret:update", + "workspace:*", + "workspace:application_connect", + "workspace:create", + "workspace:delete", + "workspace:read", + "workspace:ssh", + "workspace:start", + "workspace:stop", + "workspace:update" + ], + "x-enum-varnames": [ + "APIKeyScopeAll", + "APIKeyScopeApiKeyAll", + "APIKeyScopeApiKeyCreate", + "APIKeyScopeApiKeyDelete", + "APIKeyScopeApiKeyRead", + "APIKeyScopeApiKeyUpdate", + "APIKeyScopeApplicationConnect", + "APIKeyScopeFileAll", + "APIKeyScopeFileCreate", + "APIKeyScopeFileRead", + "APIKeyScopeTemplateAll", + "APIKeyScopeTemplateCreate", + "APIKeyScopeTemplateDelete", + "APIKeyScopeTemplateRead", + "APIKeyScopeTemplateUpdate", + "APIKeyScopeTemplateUse", + "APIKeyScopeUserReadPersonal", + "APIKeyScopeUserUpdatePersonal", + "APIKeyScopeUserSecretAll", + "APIKeyScopeUserSecretCreate", + "APIKeyScopeUserSecretDelete", + "APIKeyScopeUserSecretRead", + "APIKeyScopeUserSecretUpdate", + "APIKeyScopeWorkspaceAll", + "APIKeyScopeWorkspaceApplicationConnect", + "APIKeyScopeWorkspaceCreate", + "APIKeyScopeWorkspaceDelete", + "APIKeyScopeWorkspaceRead", + "APIKeyScopeWorkspaceSsh", + "APIKeyScopeWorkspaceStart", + "APIKeyScopeWorkspaceStop", + "APIKeyScopeWorkspaceUpdate" + ] }, "codersdk.AddLicenseRequest": { "type": "object", @@ -11032,12 +11114,7 @@ "type": "integer" }, "scope": { - "enum": ["all", "application_connect"], - "allOf": [ - { - "$ref": "#/definitions/codersdk.APIKeyScope" - } - ] + "$ref": "#/definitions/codersdk.APIKeyScope" }, "token_name": { "type": "string" @@ -11863,6 +11940,17 @@ "ExperimentAIBridge" ] }, + "codersdk.ExternalAPIKeyScopes": { + "type": "object", + "properties": { + "external": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIKeyScope" + } + } + } + }, "codersdk.ExternalAgentCredentials": { "type": "object", "properties": { diff --git a/coderd/apikey.go b/coderd/apikey.go index 9063f0dac7..226c50c15b 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -66,9 +66,19 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { return } - scope := database.APIKeyScopeAll - if scope != "" { - scope = database.APIKeyScope(createToken.Scope) + // Map and validate requested scope. + // Accept special scopes (all, application_connect) and curated public low-level scopes. + scopes := database.APIKeyScopes{database.APIKeyScopeAll} + if createToken.Scope != "" { + name := string(createToken.Scope) + if !rbac.IsExternalScope(rbac.ScopeName(name)) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to create API key.", + Detail: fmt.Sprintf("invalid API key scope: %q", name), + }) + return + } + scopes = database.APIKeyScopes{database.APIKeyScope(name)} } tokenName := namesgenerator.GetRandomName(1) @@ -81,7 +91,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { UserID: user.ID, LoginType: database.LoginTypeToken, DefaultLifetime: api.DeploymentValues.Sessions.DefaultTokenDuration.Value(), - Scope: scope, + Scopes: scopes, TokenName: tokenName, } diff --git a/coderd/apikey/apikey.go b/coderd/apikey/apikey.go index 10586178e1..738b2c2126 100644 --- a/coderd/apikey/apikey.go +++ b/coderd/apikey/apikey.go @@ -25,9 +25,16 @@ type CreateParams struct { // Optional. ExpiresAt time.Time LifetimeSeconds int64 - Scope database.APIKeyScope - TokenName string - RemoteAddr string + // Scope is legacy single-scope input kept for backward compatibility. + // + // Deprecated: Prefer Scopes for new code. + Scope database.APIKeyScope + // Scopes is the full list of scopes to attach to the key. + // If empty and Scope is set, the generator will use [Scope]. + // If both are empty, the generator will default to [APIKeyScopeAll]. + Scopes database.APIKeyScopes + TokenName string + RemoteAddr string } // Generate generates an API key, returning the key as a string as well as the @@ -62,14 +69,20 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) bitlen := len(ip) * 8 - scope := database.APIKeyScopeAll - if params.Scope != "" { - scope = params.Scope - } - switch scope { - case database.APIKeyScopeAll, database.APIKeyScopeApplicationConnect: + var scopes database.APIKeyScopes + switch { + case len(params.Scopes) > 0: + scopes = params.Scopes + case params.Scope != "": + scopes = database.APIKeyScopes{params.Scope} default: - return database.InsertAPIKeyParams{}, "", xerrors.Errorf("invalid API key scope: %q", scope) + scopes = database.APIKeyScopes{database.APIKeyScopeAll} + } + + for _, s := range scopes { + if !s.Valid() { + return database.InsertAPIKeyParams{}, "", xerrors.Errorf("invalid API key scope: %q", s) + } } token := fmt.Sprintf("%s-%s", keyID, keySecret) @@ -92,7 +105,7 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) UpdatedAt: dbtime.Now(), HashedSecret: hashed[:], LoginType: params.LoginType, - Scopes: database.APIKeyScopes{scope}, + Scopes: scopes, AllowList: database.AllowList{database.AllowListWildcard()}, TokenName: params.TokenName, }, token, nil diff --git a/coderd/apikey_scopes_validation_test.go b/coderd/apikey_scopes_validation_test.go new file mode 100644 index 0000000000..187ec77792 --- /dev/null +++ b/coderd/apikey_scopes_validation_test.go @@ -0,0 +1,47 @@ +package coderd_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestTokenCreation_ScopeValidation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + scope codersdk.APIKeyScope + wantErr bool + }{ + {name: "AllowsPublicLowLevelScope", scope: "workspace:read", wantErr: false}, + {name: "RejectsInternalOnlyScope", scope: "debug_info:read", wantErr: true}, + {name: "AllowsLegacyScopes", scope: "application_connect", wantErr: false}, + {name: "AllowsCanonicalSpecialScope", scope: "all", wantErr: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitShort) + defer cancel() + + resp, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{Scope: tc.scope}) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotEmpty(t, resp.Key) + }) + } +} diff --git a/coderd/coderd.go b/coderd/coderd.go index 57dea0f85f..1724c369ec 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1048,6 +1048,8 @@ func New(options *Options) *API { // All CSP errors will be logged r.Post("/csp/reports", api.logReportCSPViolations) + r.Get("/auth/scopes", api.listExternalScopes) + r.Get("/buildinfo", buildInfoHandler(buildInfo)) // /regions is overridden in the enterprise version r.Group(func(r chi.Router) { diff --git a/coderd/coderdtest/swaggerparser.go b/coderd/coderdtest/swaggerparser.go index b94473ee83..4a0b6744a9 100644 --- a/coderd/coderdtest/swaggerparser.go +++ b/coderd/coderdtest/swaggerparser.go @@ -308,6 +308,7 @@ func assertSecurityDefined(t *testing.T, comment SwaggerComment) { if comment.router == "/updatecheck" || comment.router == "/buildinfo" || comment.router == "/" || + comment.router == "/auth/scopes" || comment.router == "/users/login" || comment.router == "/users/otp/request" || comment.router == "/users/otp/change-password" || diff --git a/coderd/scopes_catalog.go b/coderd/scopes_catalog.go new file mode 100644 index 0000000000..789cbb0af1 --- /dev/null +++ b/coderd/scopes_catalog.go @@ -0,0 +1,30 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" +) + +// listExternalScopes returns the curated list of API key scopes (resource:action) +// requestable via the API. +// +// @Summary List API key scopes +// @ID list-api-key-scopes +// @Tags Authorization +// @Produce json +// @Success 200 {object} codersdk.ExternalAPIKeyScopes +// @Router /auth/scopes [get] +func (*API) listExternalScopes(rw http.ResponseWriter, r *http.Request) { + scopes := rbac.ExternalScopeNames() + external := make([]codersdk.APIKeyScope, 0, len(scopes)) + for _, scope := range scopes { + external = append(external, codersdk.APIKeyScope(scope)) + } + + httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.ExternalAPIKeyScopes{ + External: external, + }) +} diff --git a/coderd/scopes_catalog_api_test.go b/coderd/scopes_catalog_api_test.go new file mode 100644 index 0000000000..3de74843f3 --- /dev/null +++ b/coderd/scopes_catalog_api_test.go @@ -0,0 +1,30 @@ +package coderd_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" +) + +func TestListPublicLowLevelScopes(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + + res, err := client.Request(t.Context(), http.MethodGet, "/api/v2/auth/scopes", nil) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + var got struct { + External []string `json:"external"` + } + require.NoError(t, json.NewDecoder(res.Body).Decode(&got)) + + want := rbac.ExternalScopeNames() + require.Equal(t, want, got.External) +} diff --git a/codersdk/apikey.go b/codersdk/apikey.go index 32c97cf538..fd9acf7a64 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -42,17 +42,9 @@ const ( type APIKeyScope string -const ( - // APIKeyScopeAll is a scope that allows the user to do everything. - APIKeyScopeAll APIKeyScope = "all" - // APIKeyScopeApplicationConnect is a scope that allows the user - // to connect to applications in a workspace. - APIKeyScopeApplicationConnect APIKeyScope = "application_connect" -) - type CreateTokenRequest struct { Lifetime time.Duration `json:"lifetime"` - Scope APIKeyScope `json:"scope" enums:"all,application_connect"` + Scope APIKeyScope `json:"scope"` TokenName string `json:"token_name"` } diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go new file mode 100644 index 0000000000..eee7d57e7b --- /dev/null +++ b/codersdk/apikey_scopes_gen.go @@ -0,0 +1,73 @@ +// Code generated by scripts/apikeyscopesgen. DO NOT EDIT. +package codersdk + +const ( + APIKeyScopeAll APIKeyScope = "all" + APIKeyScopeApiKeyAll APIKeyScope = "api_key:*" + APIKeyScopeApiKeyCreate APIKeyScope = "api_key:create" + APIKeyScopeApiKeyDelete APIKeyScope = "api_key:delete" + APIKeyScopeApiKeyRead APIKeyScope = "api_key:read" + APIKeyScopeApiKeyUpdate APIKeyScope = "api_key:update" + APIKeyScopeApplicationConnect APIKeyScope = "application_connect" + APIKeyScopeFileAll APIKeyScope = "file:*" + APIKeyScopeFileCreate APIKeyScope = "file:create" + APIKeyScopeFileRead APIKeyScope = "file:read" + APIKeyScopeTemplateAll APIKeyScope = "template:*" + APIKeyScopeTemplateCreate APIKeyScope = "template:create" + APIKeyScopeTemplateDelete APIKeyScope = "template:delete" + APIKeyScopeTemplateRead APIKeyScope = "template:read" + APIKeyScopeTemplateUpdate APIKeyScope = "template:update" + APIKeyScopeTemplateUse APIKeyScope = "template:use" + APIKeyScopeUserReadPersonal APIKeyScope = "user:read_personal" + APIKeyScopeUserUpdatePersonal APIKeyScope = "user:update_personal" + APIKeyScopeUserSecretAll APIKeyScope = "user_secret:*" + APIKeyScopeUserSecretCreate APIKeyScope = "user_secret:create" + APIKeyScopeUserSecretDelete APIKeyScope = "user_secret:delete" + APIKeyScopeUserSecretRead APIKeyScope = "user_secret:read" + APIKeyScopeUserSecretUpdate APIKeyScope = "user_secret:update" + APIKeyScopeWorkspaceAll APIKeyScope = "workspace:*" + APIKeyScopeWorkspaceApplicationConnect APIKeyScope = "workspace:application_connect" + APIKeyScopeWorkspaceCreate APIKeyScope = "workspace:create" + APIKeyScopeWorkspaceDelete APIKeyScope = "workspace:delete" + APIKeyScopeWorkspaceRead APIKeyScope = "workspace:read" + APIKeyScopeWorkspaceSsh APIKeyScope = "workspace:ssh" + APIKeyScopeWorkspaceStart APIKeyScope = "workspace:start" + APIKeyScopeWorkspaceStop APIKeyScope = "workspace:stop" + APIKeyScopeWorkspaceUpdate APIKeyScope = "workspace:update" +) + +// PublicAPIKeyScopes lists all public low-level API key scopes. +var PublicAPIKeyScopes = []APIKeyScope{ + APIKeyScopeAll, + APIKeyScopeApiKeyAll, + APIKeyScopeApiKeyCreate, + APIKeyScopeApiKeyDelete, + APIKeyScopeApiKeyRead, + APIKeyScopeApiKeyUpdate, + APIKeyScopeApplicationConnect, + APIKeyScopeFileAll, + APIKeyScopeFileCreate, + APIKeyScopeFileRead, + APIKeyScopeTemplateAll, + APIKeyScopeTemplateCreate, + APIKeyScopeTemplateDelete, + APIKeyScopeTemplateRead, + APIKeyScopeTemplateUpdate, + APIKeyScopeTemplateUse, + APIKeyScopeUserReadPersonal, + APIKeyScopeUserUpdatePersonal, + APIKeyScopeUserSecretAll, + APIKeyScopeUserSecretCreate, + APIKeyScopeUserSecretDelete, + APIKeyScopeUserSecretRead, + APIKeyScopeUserSecretUpdate, + APIKeyScopeWorkspaceAll, + APIKeyScopeWorkspaceApplicationConnect, + APIKeyScopeWorkspaceCreate, + APIKeyScopeWorkspaceDelete, + APIKeyScopeWorkspaceRead, + APIKeyScopeWorkspaceSsh, + APIKeyScopeWorkspaceStart, + APIKeyScopeWorkspaceStop, + APIKeyScopeWorkspaceUpdate, +} diff --git a/codersdk/scopes_catalog.go b/codersdk/scopes_catalog.go new file mode 100644 index 0000000000..220dca3fa5 --- /dev/null +++ b/codersdk/scopes_catalog.go @@ -0,0 +1,5 @@ +package codersdk + +type ExternalAPIKeyScopes struct { + External []APIKeyScope `json:"external"` +} diff --git a/docs/reference/api/authorization.md b/docs/reference/api/authorization.md index 3565a8c922..e13964b869 100644 --- a/docs/reference/api/authorization.md +++ b/docs/reference/api/authorization.md @@ -1,5 +1,35 @@ # Authorization +## List API key scopes + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/auth/scopes \ + -H 'Accept: application/json' +``` + +`GET /auth/scopes` + +### Example responses + +> 200 Response + +```json +{ + "external": [ + "all" + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ExternalAPIKeyScopes](schemas.md#codersdkexternalapikeyscopes) | + ## Check authorization ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 1f3f231f45..a121ea236f 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -468,10 +468,40 @@ #### Enumerated Values -| Value | -|-----------------------| -| `all` | -| `application_connect` | +| Value | +|---------------------------------| +| `all` | +| `api_key:*` | +| `api_key:create` | +| `api_key:delete` | +| `api_key:read` | +| `api_key:update` | +| `application_connect` | +| `file:*` | +| `file:create` | +| `file:read` | +| `template:*` | +| `template:create` | +| `template:delete` | +| `template:read` | +| `template:update` | +| `template:use` | +| `user:read_personal` | +| `user:update_personal` | +| `user_secret:*` | +| `user_secret:create` | +| `user_secret:delete` | +| `user_secret:read` | +| `user_secret:update` | +| `workspace:*` | +| `workspace:application_connect` | +| `workspace:create` | +| `workspace:delete` | +| `workspace:read` | +| `workspace:ssh` | +| `workspace:start` | +| `workspace:stop` | +| `workspace:update` | ## codersdk.AddLicenseRequest @@ -1756,13 +1786,6 @@ This is required on creation to enable a user-flow of validating a template work | `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | | `token_name` | string | false | | | -#### Enumerated Values - -| Property | Value | -|----------|-----------------------| -| `scope` | `all` | -| `scope` | `application_connect` | - ## codersdk.CreateUserRequestWithOrgs ```json @@ -3489,6 +3512,22 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `workspace-sharing` | | `aibridge` | +## codersdk.ExternalAPIKeyScopes + +```json +{ + "external": [ + "all" + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------|-------------------------------------------------------|----------|--------------|-------------| +| `external` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | + ## codersdk.ExternalAgentCredentials ```json diff --git a/scripts/apikeyscopesgen/main.go b/scripts/apikeyscopesgen/main.go new file mode 100644 index 0000000000..7a131df4a6 --- /dev/null +++ b/scripts/apikeyscopesgen/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "bytes" + "fmt" + "go/format" + "os" + "sort" + "strings" + + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" +) + +func main() { + out, err := generate() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "generate apikey scopes: %v\n", err) + os.Exit(1) + } + if _, err := fmt.Print(string(out)); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "write output: %v\n", err) + os.Exit(1) + } +} + +func generate() ([]byte, error) { + names := rbac.ExternalScopeNames() + sort.Strings(names) + + var b bytes.Buffer + if _, err := b.WriteString("// Code generated by scripts/apikeyscopesgen. DO NOT EDIT.\n"); err != nil { + return nil, err + } + if _, err := b.WriteString("package codersdk\n\n"); err != nil { + return nil, err + } + + // Constants + if _, err := b.WriteString("const (\n"); err != nil { + return nil, err + } + for _, n := range names { + res, act := splitRA(n) + if act == policy.WildcardSymbol { + act = "All" + } + constName := fmt.Sprintf("APIKeyScope%s%s", pascal(res), pascal(act)) + if _, err := fmt.Fprintf(&b, "\t%s APIKeyScope = \"%s\"\n", constName, n); err != nil { + return nil, err + } + } + if _, err := b.WriteString(")\n\n"); err != nil { + return nil, err + } + + // Slices + if _, err := b.WriteString("// PublicAPIKeyScopes lists all public low-level API key scopes.\n"); err != nil { + return nil, err + } + if _, err := b.WriteString("var PublicAPIKeyScopes = []APIKeyScope{\n"); err != nil { + return nil, err + } + for _, n := range names { + res, act := splitRA(n) + if act == policy.WildcardSymbol { + act = "All" + } + constName := fmt.Sprintf("APIKeyScope%s%s", pascal(res), pascal(act)) + if _, err := fmt.Fprintf(&b, "\t%s,\n", constName); err != nil { + return nil, err + } + } + if _, err := b.WriteString("}\n\n"); err != nil { + return nil, err + } + + return format.Source(b.Bytes()) +} + +func splitRA(name string) (resource string, action string) { + parts := strings.SplitN(name, ":", 2) + if len(parts) != 2 { + return name, "" + } + return parts[0], parts[1] +} + +func pascal(s string) string { + // Replace non-identifier separators with spaces, then Title-case and strip. + s = strings.ReplaceAll(s, "_", " ") + s = strings.ReplaceAll(s, "-", " ") + s = strings.ReplaceAll(s, ":", " ") + words := strings.Fields(s) + for i := range words { + words[i] = strings.ToUpper(words[i][:1]) + words[i][1:] + } + return strings.Join(words, "") +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d8f80586fe..bfd5532510 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -53,9 +53,74 @@ export interface APIKey { } // From codersdk/apikey.go -export type APIKeyScope = "all" | "application_connect"; +export type APIKeyScope = + | "all" + | "api_key:*" + | "api_key:create" + | "api_key:delete" + | "api_key:read" + | "api_key:update" + | "application_connect" + | "file:*" + | "file:create" + | "file:read" + | "template:*" + | "template:create" + | "template:delete" + | "template:read" + | "template:update" + | "template:use" + | "user:read_personal" + | "user_secret:*" + | "user_secret:create" + | "user_secret:delete" + | "user_secret:read" + | "user_secret:update" + | "user:update_personal" + | "workspace:*" + | "workspace:application_connect" + | "workspace:create" + | "workspace:delete" + | "workspace:read" + | "workspace:ssh" + | "workspace:start" + | "workspace:stop" + | "workspace:update"; -export const APIKeyScopes: APIKeyScope[] = ["all", "application_connect"]; +export const APIKeyScopes: APIKeyScope[] = [ + "all", + "api_key:*", + "api_key:create", + "api_key:delete", + "api_key:read", + "api_key:update", + "application_connect", + "file:*", + "file:create", + "file:read", + "template:*", + "template:create", + "template:delete", + "template:read", + "template:update", + "template:use", + "user:read_personal", + "user_secret:*", + "user_secret:create", + "user_secret:delete", + "user_secret:read", + "user_secret:update", + "user:update_personal", + "workspace:*", + "workspace:application_connect", + "workspace:create", + "workspace:delete", + "workspace:read", + "workspace:ssh", + "workspace:start", + "workspace:stop", + "workspace:update", +]; // From codersdk/apikey.go export interface APIKeyWithOwner extends APIKey { @@ -983,6 +1048,11 @@ export const Experiments: Experiment[] = [ "workspace-usage", ]; +// From codersdk/scopes_catalog.go +export interface ExternalAPIKeyScopes { + readonly external: readonly APIKeyScope[]; +} + // From codersdk/workspaces.go export interface ExternalAgentCredentials { readonly command: string;