mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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()
|
||||
}))
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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';
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user