feat: add multi-scope support to API keys (#19917)

# Canonicalize API Key Scopes

This PR introduces canonical API key scopes with a `coder:` namespace prefix to avoid collisions with low-level resource:action names. It:

1. Renames special API key scopes in the database:
   - `all` → `coder:all`
   - `application_connect` → `coder:application_connect`

2. Adds support for a new `scopes` field in the API key creation request, allowing multiple scopes to be specified while maintaining backward compatibility with the singular `scope` field.

3. Updates the API documentation to reflect these changes, including the new endpoint for listing public API key scopes.

4. Ensures backward compatibility by mapping between legacy and canonical scope names in relevant code paths.
This commit is contained in:
Thomas Kosiewski
2025-09-26 11:56:34 +02:00
committed by GitHub
parent 4bda39585d
commit d0db9ec88f
35 changed files with 332 additions and 113 deletions
+25 -4
View File
@@ -11254,7 +11254,6 @@ const docTemplate = `{
"last_used",
"lifetime_seconds",
"login_type",
"scope",
"token_name",
"updated_at",
"user_id"
@@ -11292,6 +11291,7 @@ const docTemplate = `{
]
},
"scope": {
"description": "Deprecated: use Scopes instead.",
"enum": [
"all",
"application_connect"
@@ -11302,6 +11302,12 @@ const docTemplate = `{
}
]
},
"scopes": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.APIKeyScope"
}
},
"token_name": {
"type": "string"
},
@@ -11319,12 +11325,14 @@ const docTemplate = `{
"type": "string",
"enum": [
"all",
"application_connect",
"api_key:*",
"api_key:create",
"api_key:delete",
"api_key:read",
"api_key:update",
"application_connect",
"coder:all",
"coder:application_connect",
"file:*",
"file:create",
"file:read",
@@ -11353,12 +11361,14 @@ const docTemplate = `{
],
"x-enum-varnames": [
"APIKeyScopeAll",
"APIKeyScopeApplicationConnect",
"APIKeyScopeApiKeyAll",
"APIKeyScopeApiKeyCreate",
"APIKeyScopeApiKeyDelete",
"APIKeyScopeApiKeyRead",
"APIKeyScopeApiKeyUpdate",
"APIKeyScopeApplicationConnect",
"APIKeyScopeCoderAll",
"APIKeyScopeCoderApplicationConnect",
"APIKeyScopeFileAll",
"APIKeyScopeFileCreate",
"APIKeyScopeFileRead",
@@ -12453,7 +12463,18 @@ const docTemplate = `{
"type": "integer"
},
"scope": {
"$ref": "#/definitions/codersdk.APIKeyScope"
"description": "Deprecated: use Scopes instead.",
"allOf": [
{
"$ref": "#/definitions/codersdk.APIKeyScope"
}
]
},
"scopes": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.APIKeyScope"
}
},
"token_name": {
"type": "string"
+25 -4
View File
@@ -9982,7 +9982,6 @@
"last_used",
"lifetime_seconds",
"login_type",
"scope",
"token_name",
"updated_at",
"user_id"
@@ -10015,6 +10014,7 @@
]
},
"scope": {
"description": "Deprecated: use Scopes instead.",
"enum": ["all", "application_connect"],
"allOf": [
{
@@ -10022,6 +10022,12 @@
}
]
},
"scopes": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.APIKeyScope"
}
},
"token_name": {
"type": "string"
},
@@ -10039,12 +10045,14 @@
"type": "string",
"enum": [
"all",
"application_connect",
"api_key:*",
"api_key:create",
"api_key:delete",
"api_key:read",
"api_key:update",
"application_connect",
"coder:all",
"coder:application_connect",
"file:*",
"file:create",
"file:read",
@@ -10073,12 +10081,14 @@
],
"x-enum-varnames": [
"APIKeyScopeAll",
"APIKeyScopeApplicationConnect",
"APIKeyScopeApiKeyAll",
"APIKeyScopeApiKeyCreate",
"APIKeyScopeApiKeyDelete",
"APIKeyScopeApiKeyRead",
"APIKeyScopeApiKeyUpdate",
"APIKeyScopeApplicationConnect",
"APIKeyScopeCoderAll",
"APIKeyScopeCoderApplicationConnect",
"APIKeyScopeFileAll",
"APIKeyScopeFileCreate",
"APIKeyScopeFileRead",
@@ -11114,7 +11124,18 @@
"type": "integer"
},
"scope": {
"$ref": "#/definitions/codersdk.APIKeyScope"
"description": "Deprecated: use Scopes instead.",
"allOf": [
{
"$ref": "#/definitions/codersdk.APIKeyScope"
}
]
},
"scopes": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.APIKeyScope"
}
},
"token_name": {
"type": "string"
+26 -5
View File
@@ -67,18 +67,39 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
}
// 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 != "" {
// Accept legacy special scopes (all, application_connect) and external scopes.
// Default to coder:all scopes for backward compatibility.
scopes := database.APIKeyScopes{database.ApiKeyScopeCoderAll}
if len(createToken.Scopes) > 0 {
scopes = make(database.APIKeyScopes, 0, len(createToken.Scopes))
for _, s := range createToken.Scopes {
name := string(s)
if !rbac.IsExternalScope(rbac.ScopeName(name)) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to create API key.",
Detail: fmt.Sprintf("invalid or unsupported API key scope: %q", name),
})
return
}
scopes = append(scopes, database.APIKeyScope(name))
}
} else if string(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),
Detail: fmt.Sprintf("invalid or unsupported API key scope: %q", name),
})
return
}
scopes = database.APIKeyScopes{database.APIKeyScope(name)}
switch name {
case "all":
scopes = database.APIKeyScopes{database.ApiKeyScopeCoderAll}
case "application_connect":
scopes = database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect}
default:
scopes = database.APIKeyScopes{database.APIKeyScope(name)}
}
}
tokenName := namesgenerator.GetRandomName(1)
+14 -5
View File
@@ -25,13 +25,12 @@ type CreateParams struct {
// Optional.
ExpiresAt time.Time
LifetimeSeconds int64
// Scope is legacy single-scope input kept for backward compatibility.
//
// Deprecated: Prefer Scopes for new code.
// Deprecated: use Scopes instead.
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
@@ -74,9 +73,19 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error)
case len(params.Scopes) > 0:
scopes = params.Scopes
case params.Scope != "":
scopes = database.APIKeyScopes{params.Scope}
var scope database.APIKeyScope
switch params.Scope {
case "all":
scope = database.ApiKeyScopeCoderAll
case "application_connect":
scope = database.ApiKeyScopeCoderApplicationConnect
default:
scope = params.Scope
}
scopes = database.APIKeyScopes{scope}
default:
scopes = database.APIKeyScopes{database.APIKeyScopeAll}
// Default to coder:all scope for backward compatibility.
scopes = database.APIKeyScopes{database.ApiKeyScopeCoderAll}
}
for _, s := range scopes {
+5 -5
View File
@@ -35,7 +35,7 @@ func TestGenerate(t *testing.T) {
LifetimeSeconds: int64(time.Hour.Seconds()),
TokenName: "hello",
RemoteAddr: "1.2.3.4",
Scope: database.APIKeyScopeApplicationConnect,
Scope: database.ApiKeyScopeCoderApplicationConnect,
},
},
{
@@ -62,7 +62,7 @@ func TestGenerate(t *testing.T) {
ExpiresAt: time.Time{},
TokenName: "hello",
RemoteAddr: "1.2.3.4",
Scope: database.APIKeyScopeApplicationConnect,
Scope: database.ApiKeyScopeCoderApplicationConnect,
},
},
{
@@ -75,7 +75,7 @@ func TestGenerate(t *testing.T) {
ExpiresAt: time.Time{},
TokenName: "hello",
RemoteAddr: "1.2.3.4",
Scope: database.APIKeyScopeApplicationConnect,
Scope: database.ApiKeyScopeCoderApplicationConnect,
},
},
{
@@ -88,7 +88,7 @@ func TestGenerate(t *testing.T) {
LifetimeSeconds: int64(time.Hour.Seconds()),
TokenName: "hello",
RemoteAddr: "",
Scope: database.APIKeyScopeApplicationConnect,
Scope: database.ApiKeyScopeCoderApplicationConnect,
},
},
{
@@ -161,7 +161,7 @@ func TestGenerate(t *testing.T) {
if tc.params.Scope != "" {
assert.True(t, key.Scopes.Has(tc.params.Scope))
} else {
assert.True(t, key.Scopes.Has(database.APIKeyScopeAll))
assert.True(t, key.Scopes.Has(database.ApiKeyScopeCoderAll))
}
if tc.params.TokenName != "" {
+18 -1
View File
@@ -22,7 +22,8 @@ func TestTokenCreation_ScopeValidation(t *testing.T) {
{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},
{name: "AllowsLegacyScopes2", scope: "all", wantErr: false},
{name: "AllowsCanonicalSpecialScope", scope: "coder:all", wantErr: false},
}
for _, tc := range cases {
@@ -42,6 +43,22 @@ func TestTokenCreation_ScopeValidation(t *testing.T) {
}
require.NoError(t, err)
require.NotEmpty(t, resp.Key)
// Fetch and verify the stored scopes match expectation.
keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
require.NoError(t, err)
require.Len(t, keys, 1)
// Normalize legacy singular scopes to canonical coder:* values.
expected := tc.scope
switch tc.scope {
case codersdk.APIKeyScopeAll:
expected = codersdk.APIKeyScopeCoderAll
case codersdk.APIKeyScopeApplicationConnect:
expected = codersdk.APIKeyScopeCoderApplicationConnect
}
require.Contains(t, keys[0].Scopes, expected)
})
}
}
+48
View File
@@ -88,6 +88,54 @@ func TestTokenScoped(t *testing.T) {
require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect)
}
// Ensure backward-compat: when a token is created using the legacy singular
// scope names ("all" or "application_connect"), the API returns the same
// legacy value in the deprecated singular Scope field while also supporting
// the new multi-scope field.
func TestTokenLegacySingularScopeCompat(t *testing.T) {
t.Parallel()
cases := []struct {
name string
scope codersdk.APIKeyScope
scopes []codersdk.APIKeyScope
}{
{
name: "all",
scope: codersdk.APIKeyScopeAll,
scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll},
},
{
name: "application_connect",
scope: codersdk.APIKeyScopeApplicationConnect,
scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderApplicationConnect},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
defer cancel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
// Create with legacy singular scope.
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Scope: tc.scope,
})
require.NoError(t, err)
// Read back and ensure the deprecated singular field matches exactly.
keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
require.NoError(t, err)
require.Len(t, keys, 1)
require.Equal(t, tc.scope, keys[0].Scope)
require.ElementsMatch(t, keys[0].Scopes, tc.scopes)
})
}
}
func TestUserSetTokenDuration(t *testing.T) {
t.Parallel()
+2 -2
View File
@@ -251,7 +251,7 @@ func (s *MethodTestSuite) TestAPIKey() {
}))
s.Run("InsertAPIKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.InsertAPIKeyParams{UserID: u.ID, LoginType: database.LoginTypePassword, Scopes: database.APIKeyScopes{database.APIKeyScopeAll}, IPAddress: defaultIPAddress()}
arg := database.InsertAPIKeyParams{UserID: u.ID, LoginType: database.LoginTypePassword, Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderAll}, IPAddress: defaultIPAddress()}
ret := testutil.Fake(s.T(), faker, database.APIKey{UserID: u.ID, LoginType: database.LoginTypePassword})
dbm.EXPECT().InsertAPIKey(gomock.Any(), arg).Return(ret, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceApiKey.WithOwner(u.ID.String()), policy.ActionCreate)
@@ -265,7 +265,7 @@ func (s *MethodTestSuite) TestAPIKey() {
check.Args(arg).Asserts(a, policy.ActionUpdate).Returns()
}))
s.Run("DeleteApplicationConnectAPIKeysByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
a := testutil.Fake(s.T(), faker, database.APIKey{Scopes: database.APIKeyScopes{database.APIKeyScopeApplicationConnect}})
a := testutil.Fake(s.T(), faker, database.APIKey{Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect}})
dbm.EXPECT().DeleteApplicationConnectAPIKeysByUserID(gomock.Any(), a.UserID).Return(nil).AnyTimes()
check.Args(a.UserID).Asserts(rbac.ResourceApiKey.WithOwner(a.UserID.String()), policy.ActionDelete).Returns()
}))
+1 -1
View File
@@ -185,7 +185,7 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey, munge ...func
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()),
LoginType: takeFirst(seed.LoginType, database.LoginTypePassword),
Scopes: takeFirstSlice([]database.APIKeyScope(seed.Scopes), []database.APIKeyScope{database.APIKeyScopeAll}),
Scopes: takeFirstSlice([]database.APIKeyScope(seed.Scopes), []database.APIKeyScope{database.ApiKeyScopeCoderAll}),
AllowList: takeFirstSlice(seed.AllowList, database.AllowList{database.AllowListWildcard()}),
TokenName: takeFirst(seed.TokenName),
}
+2 -2
View File
@@ -11,8 +11,8 @@ CREATE TYPE agent_key_scope_enum AS ENUM (
);
CREATE TYPE api_key_scope AS ENUM (
'all',
'application_connect',
'coder:all',
'coder:application_connect',
'aibridge_interception:create',
'aibridge_interception:read',
'aibridge_interception:update',
@@ -0,0 +1,5 @@
-- Revert canonicalization of special API key scopes
-- Rename enum values back: 'coder:all' -> 'all', 'coder:application_connect' -> 'application_connect'
ALTER TYPE api_key_scope RENAME VALUE 'coder:all' TO 'all';
ALTER TYPE api_key_scope RENAME VALUE 'coder:application_connect' TO 'application_connect';
@@ -0,0 +1,5 @@
-- Canonicalize special API key scopes to coder:* namespace
-- Rename enum values: 'all' -> 'coder:all', 'application_connect' -> 'coder:application_connect'
ALTER TYPE api_key_scope RENAME VALUE 'all' TO 'coder:all';
ALTER TYPE api_key_scope RENAME VALUE 'application_connect' TO 'coder:application_connect';
+4 -3
View File
@@ -134,9 +134,9 @@ func (w ConnectionLog) RBACObject() rbac.Object {
func (s APIKeyScope) ToRBAC() rbac.ScopeName {
switch s {
case APIKeyScopeAll:
case ApiKeyScopeCoderAll:
return rbac.ScopeAll
case APIKeyScopeApplicationConnect:
case ApiKeyScopeCoderApplicationConnect:
return rbac.ScopeApplicationConnect
default:
// Allow low-level resource:action scopes to flow through to RBAC for
@@ -218,7 +218,8 @@ func (s APIKeyScopes) Expand() (rbac.Scope, error) {
// Name returns a human-friendly identifier for tracing/logging.
func (s APIKeyScopes) Name() rbac.RoleIdentifier {
if len(s) == 0 {
return rbac.RoleIdentifier{Name: string(APIKeyScopeAll)}
// Return all for backward compatibility.
return rbac.RoleIdentifier{Name: string(ApiKeyScopeCoderAll)}
}
names := make([]string, 0, len(s))
for _, s := range s {
@@ -20,7 +20,7 @@ func TestAPIKeyScopesExpand(t *testing.T) {
}{
{
name: "all",
scopes: APIKeyScopes{APIKeyScopeAll},
scopes: APIKeyScopes{ApiKeyScopeCoderAll},
want: func(t *testing.T, s rbac.Scope) {
requirePermission(t, s, rbac.ResourceWildcard.Type, policy.Action(policy.WildcardSymbol))
requireAllowAll(t, s)
@@ -28,7 +28,7 @@ func TestAPIKeyScopesExpand(t *testing.T) {
},
{
name: "application_connect",
scopes: APIKeyScopes{APIKeyScopeApplicationConnect},
scopes: APIKeyScopes{ApiKeyScopeCoderApplicationConnect},
want: func(t *testing.T, s rbac.Scope) {
requirePermission(t, s, rbac.ResourceWorkspace.Type, policy.ActionApplicationConnect)
requireAllowAll(t, s)
@@ -69,7 +69,7 @@ func TestAPIKeyScopesExpand(t *testing.T) {
t.Run("merge", func(t *testing.T) {
t.Parallel()
scopes := APIKeyScopes{APIKeyScopeApplicationConnect, APIKeyScopeAll, ApiKeyScopeWorkspaceRead}
scopes := APIKeyScopes{ApiKeyScopeCoderApplicationConnect, ApiKeyScopeCoderAll, ApiKeyScopeWorkspaceRead}
s, err := scopes.Expand()
require.NoError(t, err)
requirePermission(t, s, rbac.ResourceWildcard.Type, policy.Action(policy.WildcardSymbol))
+6 -6
View File
@@ -19,8 +19,8 @@ import (
type APIKeyScope string
const (
APIKeyScopeAll APIKeyScope = "all"
APIKeyScopeApplicationConnect APIKeyScope = "application_connect"
ApiKeyScopeCoderAll APIKeyScope = "coder:all"
ApiKeyScopeCoderApplicationConnect APIKeyScope = "coder:application_connect"
ApiKeyScopeAibridgeInterceptionCreate APIKeyScope = "aibridge_interception:create"
ApiKeyScopeAibridgeInterceptionRead APIKeyScope = "aibridge_interception:read"
ApiKeyScopeAibridgeInterceptionUpdate APIKeyScope = "aibridge_interception:update"
@@ -198,8 +198,8 @@ func (ns NullAPIKeyScope) Value() (driver.Value, error) {
func (e APIKeyScope) Valid() bool {
switch e {
case APIKeyScopeAll,
APIKeyScopeApplicationConnect,
case ApiKeyScopeCoderAll,
ApiKeyScopeCoderApplicationConnect,
ApiKeyScopeAibridgeInterceptionCreate,
ApiKeyScopeAibridgeInterceptionRead,
ApiKeyScopeAibridgeInterceptionUpdate,
@@ -345,8 +345,8 @@ func (e APIKeyScope) Valid() bool {
func AllAPIKeyScopeValues() []APIKeyScope {
return []APIKeyScope{
APIKeyScopeAll,
APIKeyScopeApplicationConnect,
ApiKeyScopeCoderAll,
ApiKeyScopeCoderApplicationConnect,
ApiKeyScopeAibridgeInterceptionCreate,
ApiKeyScopeAibridgeInterceptionRead,
ApiKeyScopeAibridgeInterceptionUpdate,
+1 -1
View File
@@ -432,7 +432,7 @@ DELETE FROM
api_keys
WHERE
user_id = $1 AND
'application_connect'::api_key_scope = ANY(scopes)
'coder:application_connect'::api_key_scope = ANY(scopes)
`
func (q *sqlQuerier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error {
+1 -1
View File
@@ -77,7 +77,7 @@ DELETE FROM
api_keys
WHERE
user_id = $1 AND
'application_connect'::api_key_scope = ANY(scopes);
'coder:application_connect'::api_key_scope = ANY(scopes);
-- name: DeleteAPIKeysByUserID :exec
DELETE FROM
+2 -2
View File
@@ -313,7 +313,7 @@ func TestAPIKey(t *testing.T) {
_, token = dbgen.APIKey(t, db, database.APIKey{
UserID: user.ID,
ExpiresAt: dbtime.Now().AddDate(0, 0, 1),
Scopes: database.APIKeyScopes{database.APIKeyScopeApplicationConnect},
Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect},
})
r = httptest.NewRequest("GET", "/", nil)
@@ -330,7 +330,7 @@ func TestAPIKey(t *testing.T) {
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Checks that it exists on the context!
apiKey := httpmw.APIKey(r)
assert.Equal(t, database.APIKeyScopeApplicationConnect, apiKey.Scopes[0])
assert.Equal(t, database.ApiKeyScopeCoderApplicationConnect, apiKey.Scopes[0])
assertActorOk(t, r)
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
+1 -1
View File
@@ -172,7 +172,7 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s
LastUsed: dbtime.Now(),
ExpiresAt: dbtime.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scopes: database.APIKeyScopes{database.APIKeyScopeAll},
Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderAll},
AllowList: database.AllowList{database.AllowListWildcard()},
IPAddress: pqtype.Inet{
IPNet: net.IPNet{
+1 -1
View File
@@ -66,7 +66,7 @@ func TestWorkspaceParam(t *testing.T) {
LastUsed: dbtime.Now(),
ExpiresAt: dbtime.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scopes: database.APIKeyScopes{database.APIKeyScopeAll},
Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderAll},
AllowList: database.AllowList{database.AllowListWildcard()},
IPAddress: pqtype.Inet{
IPNet: net.IPNet{
+2 -2
View File
@@ -61,8 +61,8 @@ func WorkspaceAgentScope(params WorkspaceAgentScopeParams) Scope {
}
const (
ScopeAll ScopeName = "all"
ScopeApplicationConnect ScopeName = "application_connect"
ScopeAll ScopeName = "coder:all"
ScopeApplicationConnect ScopeName = "coder:application_connect"
ScopeNoUserData ScopeName = "no_user_data"
)
+2 -1
View File
@@ -57,7 +57,8 @@ var externalLowLevel = map[ScopeName]struct{}{
// low-level resource:action scopes.
func IsExternalScope(name ScopeName) bool {
switch name {
case ScopeAll, ScopeApplicationConnect:
// Include `all` and `application_connect` for backward compatibility.
case "all", ScopeAll, "application_connect", ScopeApplicationConnect:
return true
}
if _, ok := externalLowLevel[name]; ok {
+4 -4
View File
@@ -153,8 +153,8 @@ const (
// ensure changes in rbac/scopes.go remain in sync here.
func (e ScopeName) Valid() bool {
switch e {
case ScopeName("all"),
ScopeName("application_connect"),
case ScopeName("coder:all"),
ScopeName("coder:application_connect"),
ScopeName("no_user_data"),
ScopeAibridgeInterceptionCreate,
ScopeAibridgeInterceptionRead,
@@ -303,8 +303,8 @@ func (e ScopeName) Valid() bool {
// including builtin and generated low-level scopes.
func AllScopeNameValues() []ScopeName {
return []ScopeName{
ScopeName("all"),
ScopeName("application_connect"),
ScopeName("coder:all"),
ScopeName("coder:application_connect"),
ScopeName("no_user_data"),
ScopeAibridgeInterceptionCreate,
ScopeAibridgeInterceptionRead,
+2 -2
View File
@@ -1958,7 +1958,7 @@ func TestUserLogout(t *testing.T) {
for i := range 3 {
key, _ := dbgen.APIKey(t, db, database.APIKey{
UserID: newUser.ID,
Scopes: database.APIKeyScopes{database.APIKeyScopeApplicationConnect},
Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect},
})
shouldBeDeleted[fmt.Sprintf("application_connect key owned by logout user %d", i)] = key.ID
}
@@ -1968,7 +1968,7 @@ func TestUserLogout(t *testing.T) {
for i := range 3 {
key, _ := dbgen.APIKey(t, db, database.APIKey{
UserID: firstUser.UserID,
Scopes: database.APIKeyScopes{database.APIKeyScopeApplicationConnect},
Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect},
})
shouldNotBeDeleted[fmt.Sprintf("application_connect key owned by admin user %d", i)] = key.ID
}
+17 -5
View File
@@ -1570,10 +1570,21 @@ func userOrganizationIDs(ctx context.Context, api *API, user database.User) ([]u
}
func convertAPIKey(k database.APIKey) codersdk.APIKey {
// Derive a single scope from arrays for response compatibility.
scope := database.APIKeyScopeAll
if k.Scopes.Has(database.APIKeyScopeApplicationConnect) {
scope = database.APIKeyScopeApplicationConnect
// Derive a single legacy scope name for response compatibility.
// Historically, the API exposed only two scope strings: "all" and
// "application_connect". Continue to return those for clients even
// though the database stores canonical values (e.g. "coder:all")
// and may include low-level scopes.
var legacyScope codersdk.APIKeyScope
if k.Scopes.Has(database.ApiKeyScopeCoderApplicationConnect) {
legacyScope = codersdk.APIKeyScopeApplicationConnect
} else if k.Scopes.Has(database.ApiKeyScopeCoderAll) {
legacyScope = codersdk.APIKeyScopeAll
}
scopes := make([]codersdk.APIKeyScope, 0, len(k.Scopes))
for _, s := range k.Scopes {
scopes = append(scopes, codersdk.APIKeyScope(s))
}
return codersdk.APIKey{
@@ -1584,7 +1595,8 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey {
CreatedAt: k.CreatedAt,
UpdatedAt: k.UpdatedAt,
LoginType: codersdk.LoginType(k.LoginType),
Scope: codersdk.APIKeyScope(scope),
Scope: legacyScope,
Scopes: scopes,
LifetimeSeconds: k.LifetimeSeconds,
TokenName: k.TokenName,
}
+1 -1
View File
@@ -113,7 +113,7 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
DefaultLifetime: api.DeploymentValues.Sessions.DefaultDuration.Value(),
ExpiresAt: exp,
LifetimeSeconds: lifetimeSeconds,
Scope: database.APIKeyScopeApplicationConnect,
Scope: database.ApiKeyScopeCoderApplicationConnect,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{