Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

feat: improve RBAC resource ID matching and structured allowlist targets#20035

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Draft
ThomasK33 wants to merge1 commit intothomask33/09-29-feat_typed_rbac_allow_list
base:thomask33/09-29-feat_typed_rbac_allow_list
Choose a base branch
Loading
fromthomask33/09-30-api_allowlist_structured_json
Draft
Show file tree
Hide file tree
Changes fromall commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletionscoderd/apidoc/docs.go
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

3 changes: 3 additions & 0 deletionscoderd/apidoc/swagger.json
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

10 changes: 8 additions & 2 deletionscoderd/apikey.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -215,7 +215,10 @@ func (api *API) apiKeyByID(rw http.ResponseWriter, r *http.Request) {
return
}

httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(key))
sdkKey := convertAPIKey(key)
api.populateAllowListDisplayNames(ctx, sdkKey.AllowList)

httpapi.Write(ctx, rw, http.StatusOK, sdkKey)
}

// @Summary Get API key by token name
Expand DownExpand Up@@ -250,7 +253,10 @@ func (api *API) apiKeyByName(rw http.ResponseWriter, r *http.Request) {
return
}

httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(token))
sdkKey := convertAPIKey(token)
api.populateAllowListDisplayNames(ctx, sdkKey.AllowList)

httpapi.Write(ctx, rw, http.StatusOK, sdkKey)
}

// @Summary Update token API key
Expand Down
78 changes: 78 additions & 0 deletionscoderd/apikey_resolve_internal_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
package coderd

import (
"testing"
"time"

"github.com/google/uuid"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
)

func TestConvertAPIKeyAllowListDisplayName(t *testing.T) {
t.Parallel()

ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)

db := dbmock.NewMockStore(ctrl)
templateID := uuid.New()

db.EXPECT().
GetTemplateByID(gomock.Any(), templateID).
Return(database.Template{
ID: templateID,
Name: "infra-template",
DisplayName: "Infra Template",
}, nil).
Times(1)

key := database.APIKey{
ID: "key-1",
UserID: uuid.New(),
LastUsed: time.Now(),
ExpiresAt: time.Now().Add(time.Hour),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
LoginType: database.LoginTypeToken,
LifetimeSeconds: int64(time.Hour.Seconds()),
TokenName: "cli",
Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderAll},
AllowList: database.AllowList{
{Type: string(codersdk.ResourceTemplate), ID: templateID.String()},
},
}

result := convertAPIKey(key)

require.Len(t, result.AllowList, 1)
require.Equal(t, codersdk.ResourceTemplate, result.AllowList[0].Type)
require.Equal(t, templateID.String(), result.AllowList[0].ID)
require.Equal(t, "Infra Template", result.AllowList[0].DisplayName)
}

func TestConvertAPIKeyAllowListDisplayNameWildcard(t *testing.T) {
t.Parallel()

ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)

key := database.APIKey{
ID: "key-2",
AllowList: database.AllowList{
{Type: string(codersdk.ResourceWildcard), ID: policy.WildcardSymbol},
},
}

result := convertAPIKey(key)

require.Len(t, result.AllowList, 1)
require.Equal(t, codersdk.ResourceWildcard, result.AllowList[0].Type)
require.Equal(t, "*", result.AllowList[0].ID)
require.Empty(t, result.AllowList[0].DisplayName)
}
7 changes: 4 additions & 3 deletionscoderd/apikey_scopes_validation_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,9 +93,10 @@ func TestTokenCreation_AllowListValidation(t *testing.T) {
// Invalid resource type should be rejected.
_,err:=client.CreateToken(ctx,codersdk.Me, codersdk.CreateTokenRequest{
Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead},
AllowList: []codersdk.APIAllowListTarget{
{Type:codersdk.RBACResource("unknown"),ID:uuid.New().String()},
},
AllowList: []codersdk.APIAllowListTarget{{
Type:codersdk.RBACResource("unknown"),
ID:uuid.New().String(),
}},
})
require.Error(t,err)

Expand Down
16 changes: 16 additions & 0 deletionscoderd/rbac/regosql/compile_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -217,6 +217,14 @@ func TestRegoQueries(t *testing.T) {
" OR (workspaces.group_acl#>array['96c55a0e-73b4-44fc-abac-70d53c35c04c', 'permissions'] ? '*'))",
VariableConverter: regosql.WorkspaceConverter(),
},
{
Name: "WorkspaceIDMatcher",
Queries: []string{
`input.object.id = "a8d0f8ce-6a01-4d0d-ab1d-1d546958feae"`,
},
ExpectedSQL: p("workspaces.id :: text = 'a8d0f8ce-6a01-4d0d-ab1d-1d546958feae'"),
VariableConverter: regosql.WorkspaceConverter(),
},
{
Name: "NoACLConfig",
Queries: []string{
Expand DownExpand Up@@ -262,6 +270,14 @@ neq(input.object.owner, "");
p("false")),
VariableConverter: regosql.TemplateConverter(),
},
{
Name: "TemplateIDMatcher",
Queries: []string{
`input.object.id = "a829cb9d-7c5b-4c3b-bf78-053827a56e58"`,
},
ExpectedSQL: p("t.id :: text = 'a829cb9d-7c5b-4c3b-bf78-053827a56e58'"),
VariableConverter: regosql.TemplateConverter(),
},
{
Name: "UserNoOrgOwner",
Queries: []string{
Expand Down
4 changes: 2 additions & 2 deletionscoderd/rbac/regosql/configs.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ func userACLMatcher(m sqltypes.VariableMatcher) ACLMappingVar {

func TemplateConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
sqltypes.StringVarMatcher("t.id :: text", []string{"input", "object", "id"}),
sqltypes.StringVarMatcher("t.organization_id :: text", []string{"input", "object", "org_owner"}),
// Templates have no user owner, only owner by an organization.
sqltypes.AlwaysFalse(userOwnerMatcher()),
Expand All@@ -38,7 +38,7 @@ func TemplateConverter() *sqltypes.VariableConverter {

func WorkspaceConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
sqltypes.StringVarMatcher("workspaces.id :: text", []string{"input", "object", "id"}),
sqltypes.StringVarMatcher("workspaces.organization_id :: text", []string{"input", "object", "org_owner"}),
userOwnerMatcher(),
)
Expand Down
62 changes: 62 additions & 0 deletionscoderd/users.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -1616,3 +1616,65 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey {
AllowList: allowList,
}
}

func (api *API) populateAllowListDisplayNames(ctx context.Context, allowList []codersdk.APIAllowListTarget) {
if len(allowList) == 0 {
return
}

cache := make(map[string]string, len(allowList))
for i := range allowList {
target := allowList[i]
if target.Type == codersdk.ResourceWildcard || target.ID == policy.WildcardSymbol {
continue
}

key := target.String()
name, ok := cache[key]
if !ok {
name, ok = api.allowListDisplayName(ctx, target.Type, target.ID)
if !ok {
cache[key] = ""
continue
}
cache[key] = name
}
if name != "" {
allowList[i].DisplayName = name
}
}
}

func (api *API) allowListDisplayName(ctx context.Context, resource codersdk.RBACResource, rawID string) (string, bool) {
if api == nil || api.Options == nil || api.Database == nil {
return "", false
}
if rawID == "" || rawID == policy.WildcardSymbol {
return "", false
}

id, err := uuid.Parse(rawID)
if err != nil {
return "", false
}

switch resource {
case codersdk.ResourceWorkspace:
workspace, err := api.Database.GetWorkspaceByID(ctx, id)
if err != nil {
return "", false
}
return workspace.Name, true
case codersdk.ResourceTemplate:
template, err := api.Database.GetTemplateByID(ctx, id)
if err != nil {
return "", false
}
if template.DisplayName != "" {
return template.DisplayName, true
}
return template.Name, true
default:
return "", false
}
}
107 changes: 76 additions & 31 deletionscodersdk/allowlist.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,12 +10,16 @@ import (
"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.
// APIAllowListTarget represents a single allow-list entry. The canonical string
// form is "<resource_type>:<id>" with "*" acting as a wildcard for either
// component. Structured JSON callers should use the object form
// `{ "type": "workspace", "id": "<uuid>" }`. Optionally, servers may attach a
// DisplayName to provide a human-friendly label; clients must ignore the field
// when submitting data.
type APIAllowListTarget struct {
Type RBACResource `json:"type"`
ID string `json:"id"`
Type RBACResource `json:"type"`
ID string `json:"id"`
DisplayName string `json:"display_name,omitempty"`
}

func AllowAllTarget() APIAllowListTarget {
Expand All@@ -30,51 +34,92 @@ 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.
// String returns the canonical string representation "<type>:<id>" with wildcards preserved.
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>".
// UnmarshalJSON accepts either the structured object representation
// `{ "type": "workspace", "id": "<uuid>" }` or the legacy string form "workspace:<uuid>".
func (t *APIAllowListTarget) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
if len(b) == 0 {
return xerrors.New("empty allow_list entry")
}

// Attempt to decode the structured object form first.
var obj struct {
Type string `json:"type"`
ID string `json:"id"`
DisplayName string `json:"display_name"`
}
parts := strings.SplitN(strings.TrimSpace(s), ":", 2)
if err := json.Unmarshal(b, &obj); err == nil {
if obj.Type != "" || obj.ID != "" {
if obj.Type == "" || obj.ID == "" {
return xerrors.New("allow_list entry must include both type and id")
}
if err := t.setValues(obj.Type, obj.ID); err != nil {
return err
}
// Ignore object.DisplayName on input to keep backend validation strict.
return nil
}
}

var legacy string
if err := json.Unmarshal(b, &legacy); err != nil {
return xerrors.New("invalid allow_list entry: expected object with type/id or string")
}
parts := strings.SplitN(strings.TrimSpace(legacy), ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return xerrors.Errorf("invalid allow_list entry %q: want <type>:<id>",s)
return xerrors.Errorf("invalid allow_list entry %q: want <type>:<id>",legacy)
}
return t.setValues(parts[0], parts[1])
}

resource, id := RBACResource(parts[0]), parts[1]
func (t *APIAllowListTarget) setValues(rawType, rawID string) error {
rawType = strings.TrimSpace(rawType)
rawID = strings.TrimSpace(rawID)

// Type
if resource != ResourceWildcard {
if _, ok := policy.RBACPermissions[string(resource)]; !ok {
return xerrors.Errorf("unknown resource type %q", resource)
}
if rawType == "" || rawID == "" {
return xerrors.New("allow_list entry must include non-empty type and id")
}
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)
if rawType == policy.WildcardSymbol {
t.Type = ResourceWildcard
} else {
if _, ok := policy.RBACPermissions[rawType]; !ok {
return xerrors.Errorf("unknown resource type %q", rawType)
}
t.Type = RBACResource(rawType)
}

if rawID == policy.WildcardSymbol {
t.ID = policy.WildcardSymbol
return nil
}

if _, err := uuid.Parse(rawID); err != nil {
return xerrors.Errorf("invalid %s ID (must be UUID): %q", rawType, rawID)
}
t.ID =id
t.ID =rawID
return nil
}

// Implement encoding.TextMarshaler/Unmarshaler for broader compatibility
// MarshalJSON ensures encoding/json uses the structured representation instead
// of the legacy colon-delimited string form.
func (t APIAllowListTarget) MarshalJSON() ([]byte, error) {
type alias APIAllowListTarget
return json.Marshal(alias(t))
}

// 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) + "\""))
strTarget := strings.TrimSpace(string(b))
parts := strings.SplitN(strTarget, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return xerrors.Errorf("invalid allow_list entry %q: want <type>:<id>", strTarget)
}
return t.setValues(parts[0], parts[1])
}
Loading
Loading

[8]ページ先頭

©2009-2025 Movatter.jp