mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add allow_list to resource-scoped API tokens (#19964)
# Add API key allow_list for resource-scoped tokens This PR adds support for API key allow lists, enabling tokens to be scoped to specific resources. The implementation: 1. Adds a new `allow_list` field to the `CreateTokenRequest` struct, allowing clients to specify resource-specific scopes when creating API tokens 2. Implements `APIAllowListTarget` type to represent resource targets in the format `<type>:<id>` with support for wildcards 3. Adds validation and normalization logic for allow lists to handle wildcards and deduplication 4. Integrates with RBAC by creating an `APIKeyEffectiveScope` that merges API key scopes with allow list restrictions 5. Updates API documentation and TypeScript types to reflect the new functionality This feature enables creating tokens that are limited to specific resources (like workspaces or templates) by ID, making it possible to create more granular API tokens with limited access.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package codersdk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
)
|
||||
|
||||
// APIAllowListTarget represents a single allow-list entry using the canonical
|
||||
// string form "<resource_type>:<id>". The wildcard symbol "*" is treated as a
|
||||
// permissive match for either side.
|
||||
type APIAllowListTarget struct {
|
||||
Type RBACResource `json:"type"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func AllowAllTarget() APIAllowListTarget {
|
||||
return APIAllowListTarget{Type: ResourceWildcard, ID: policy.WildcardSymbol}
|
||||
}
|
||||
|
||||
func AllowTypeTarget(r RBACResource) APIAllowListTarget {
|
||||
return APIAllowListTarget{Type: r, ID: policy.WildcardSymbol}
|
||||
}
|
||||
|
||||
func AllowResourceTarget(r RBACResource, id uuid.UUID) APIAllowListTarget {
|
||||
return APIAllowListTarget{Type: r, ID: id.String()}
|
||||
}
|
||||
|
||||
// String returns the canonical string representation "<type>:<id>" with "*" wildcards.
|
||||
func (t APIAllowListTarget) String() string {
|
||||
return string(t.Type) + ":" + t.ID
|
||||
}
|
||||
|
||||
// MarshalJSON encodes as a JSON string: "<type>:<id>".
|
||||
func (t APIAllowListTarget) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(t.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes from a JSON string: "<type>:<id>".
|
||||
func (t *APIAllowListTarget) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimSpace(s), ":", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return xerrors.Errorf("invalid allow_list entry %q: want <type>:<id>", s)
|
||||
}
|
||||
|
||||
resource, id := RBACResource(parts[0]), parts[1]
|
||||
|
||||
// Type
|
||||
if resource != ResourceWildcard {
|
||||
if _, ok := policy.RBACPermissions[string(resource)]; !ok {
|
||||
return xerrors.Errorf("unknown resource type %q", resource)
|
||||
}
|
||||
}
|
||||
t.Type = resource
|
||||
|
||||
// ID
|
||||
if id != policy.WildcardSymbol {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return xerrors.Errorf("invalid %s ID (must be UUID): %q", resource, id)
|
||||
}
|
||||
}
|
||||
t.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// Implement encoding.TextMarshaler/Unmarshaler for broader compatibility
|
||||
|
||||
func (t APIAllowListTarget) MarshalText() ([]byte, error) { return []byte(t.String()), nil }
|
||||
|
||||
func (t *APIAllowListTarget) UnmarshalText(b []byte) error {
|
||||
return t.UnmarshalJSON([]byte("\"" + string(b) + "\""))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package codersdk_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestAPIAllowListTarget_JSONRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
all := codersdk.AllowAllTarget()
|
||||
b, err := json.Marshal(all)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `"*:*"`, string(b))
|
||||
var rt codersdk.APIAllowListTarget
|
||||
require.NoError(t, json.Unmarshal(b, &rt))
|
||||
require.Equal(t, codersdk.ResourceWildcard, rt.Type)
|
||||
require.Equal(t, policy.WildcardSymbol, rt.ID)
|
||||
|
||||
ty := codersdk.AllowTypeTarget(codersdk.ResourceWorkspace)
|
||||
b, err = json.Marshal(ty)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `"workspace:*"`, string(b))
|
||||
require.NoError(t, json.Unmarshal(b, &rt))
|
||||
require.Equal(t, codersdk.ResourceWorkspace, rt.Type)
|
||||
require.Equal(t, policy.WildcardSymbol, rt.ID)
|
||||
|
||||
id := uuid.New()
|
||||
res := codersdk.AllowResourceTarget(codersdk.ResourceTemplate, id)
|
||||
b, err = json.Marshal(res)
|
||||
require.NoError(t, err)
|
||||
exp := `"template:` + id.String() + `"`
|
||||
require.JSONEq(t, exp, string(b))
|
||||
}
|
||||
+5
-4
@@ -44,10 +44,11 @@ const (
|
||||
type APIKeyScope string
|
||||
|
||||
type CreateTokenRequest struct {
|
||||
Lifetime time.Duration `json:"lifetime"`
|
||||
Scope APIKeyScope `json:"scope,omitempty"` // Deprecated: use Scopes instead.
|
||||
Scopes []APIKeyScope `json:"scopes,omitempty"`
|
||||
TokenName string `json:"token_name"`
|
||||
Lifetime time.Duration `json:"lifetime"`
|
||||
Scope APIKeyScope `json:"scope,omitempty"` // Deprecated: use Scopes instead.
|
||||
Scopes []APIKeyScope `json:"scopes,omitempty"`
|
||||
TokenName string `json:"token_name"`
|
||||
AllowList []APIAllowListTarget `json:"allow_list,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateAPIKeyResponse contains an API key for a user.
|
||||
|
||||
Reference in New Issue
Block a user