mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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
This commit is contained in:
Generated
+94
-11
@@ -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": {
|
||||
|
||||
Generated
+96
-8
@@ -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": {
|
||||
|
||||
+14
-4
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+24
-11
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user