From 75ad1122fd0ca312a45afe00a48d72622469ca9b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 21 Jun 2026 02:58:39 -0400 Subject: [PATCH 1/5] fix: return role assignments from admin api --- adminapi/handler_test.go | 22 ++++++++++++++++++++-- adminapi/types.go | 2 +- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/adminapi/handler_test.go b/adminapi/handler_test.go index ddd8ec5..d8a9390 100644 --- a/adminapi/handler_test.go +++ b/adminapi/handler_test.go @@ -112,6 +112,24 @@ func TestHandlerServesAuthzUIReadRoutes(t *testing.T) { } } +func TestRolesReadReturnsRoleAssignmentsForAuthzUI(t *testing.T) { + h := newTestHandler(t) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/authz/roles", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 body=%s", rec.Code, rec.Body.String()) + } + var assignments []RoleAssignment + if err := json.Unmarshal(rec.Body.Bytes(), &assignments); err != nil { + t.Fatalf("decode role assignments: %v", err) + } + if len(assignments) != 1 || assignments[0].User != "admin-1" || assignments[0].Role != "tenant_admin" || assignments[0].Context != "admin" { + t.Fatalf("assignments = %#v, want admin-1 tenant_admin in admin context", assignments) + } +} + func TestHandlerReturnsJSONErrorsForUnknownOrWrongMethodAdminAPIRequests(t *testing.T) { h := newTestHandler(t) for _, tc := range []struct { @@ -264,8 +282,8 @@ func (a actionDenyAuthorizer) Authorize(_ context.Context, _ Principal, _ string type testProvider struct{} -func (testProvider) Roles(context.Context, Principal) ([]Role, error) { - return []Role{{Name: "tenant_admin", Scopes: []string{"cms.page.read"}}}, nil +func (testProvider) Roles(context.Context, Principal) ([]RoleAssignment, error) { + return []RoleAssignment{{User: "admin-1", Role: "tenant_admin", Context: "admin", Scopes: []string{"cms.page.read"}}}, nil } func (testProvider) UpsertRole(context.Context, Principal, RoleAssignment) error { return nil } diff --git a/adminapi/types.go b/adminapi/types.go index 05e33cb..5e9547b 100644 --- a/adminapi/types.go +++ b/adminapi/types.go @@ -117,7 +117,7 @@ type Authorizer interface { } type Provider interface { - Roles(context.Context, Principal) ([]Role, error) + Roles(context.Context, Principal) ([]RoleAssignment, error) UpsertRole(context.Context, Principal, RoleAssignment) error DeleteRole(context.Context, Principal, RoleAssignment) error Scopes(context.Context, Principal) ([]Scope, error) From ee19e2c900603fef334f13f6f7d685839b6be719 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 21 Jun 2026 03:06:50 -0400 Subject: [PATCH 2/5] fix: keep role provider compatibility --- adminapi/handler.go | 18 ++++++- adminapi/handler_test.go | 112 ++++++++++++++++++++++++++++++++++++++- adminapi/types.go | 6 ++- 3 files changed, 133 insertions(+), 3 deletions(-) diff --git a/adminapi/handler.go b/adminapi/handler.go index 286e1bb..a18429f 100644 --- a/adminapi/handler.go +++ b/adminapi/handler.go @@ -1,6 +1,7 @@ package adminapi import ( + "context" "encoding/json" "fmt" "net/http" @@ -172,7 +173,7 @@ func (h *handler) dispatch(w http.ResponseWriter, r *http.Request, route Route) func (h *handler) serveRoute(w http.ResponseWriter, r *http.Request, principal Principal, route Route) { switch route.Name { case "roles": - items, err := h.options.Provider.Roles(r.Context(), principal) + items, err := h.roleAssignments(r.Context(), principal) writeProviderResult(w, items, err) case "roles-upsert": var input RoleAssignment @@ -267,6 +268,21 @@ func (h *handler) serveRoute(w http.ResponseWriter, r *http.Request, principal P } } +func (h *handler) roleAssignments(ctx context.Context, principal Principal) ([]RoleAssignment, error) { + if provider, ok := h.options.Provider.(RoleAssignmentProvider); ok { + return provider.RoleAssignments(ctx, principal) + } + roles, err := h.options.Provider.Roles(ctx, principal) + if err != nil { + return nil, err + } + assignments := make([]RoleAssignment, 0, len(roles)) + for _, role := range roles { + assignments = append(assignments, RoleAssignment{Role: role.Name, Scopes: role.Scopes}) + } + return assignments, nil +} + func decodeRouteJSON(w http.ResponseWriter, r *http.Request, out any) bool { if err := decodeJSON(r, out); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON") diff --git a/adminapi/handler_test.go b/adminapi/handler_test.go index d8a9390..61285ff 100644 --- a/adminapi/handler_test.go +++ b/adminapi/handler_test.go @@ -128,6 +128,34 @@ func TestRolesReadReturnsRoleAssignmentsForAuthzUI(t *testing.T) { if len(assignments) != 1 || assignments[0].User != "admin-1" || assignments[0].Role != "tenant_admin" || assignments[0].Context != "admin" { t.Fatalf("assignments = %#v, want admin-1 tenant_admin in admin context", assignments) } + if len(assignments[0].Scopes) != 1 || assignments[0].Scopes[0] != "cms.page.read" { + t.Fatalf("assignment scopes = %#v, want cms.page.read", assignments[0].Scopes) + } +} + +func TestRolesReadFallsBackToLegacyRoleDefinitions(t *testing.T) { + h, err := NewHandler(Options{ + PrincipalResolver: fixedPrincipal{Principal{Subject: "admin-1"}}, + Authorizer: allowAuthorizer{}, + Provider: legacyRoleProvider{}, + }) + if err != nil { + t.Fatalf("NewHandler: %v", err) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/authz/roles", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 body=%s", rec.Code, rec.Body.String()) + } + var assignments []RoleAssignment + if err := json.Unmarshal(rec.Body.Bytes(), &assignments); err != nil { + t.Fatal(err) + } + if len(assignments) != 1 || assignments[0].Role != "tenant_admin" || len(assignments[0].Scopes) != 1 { + t.Fatalf("assignments = %#v, want role-definition fallback", assignments) + } } func TestHandlerReturnsJSONErrorsForUnknownOrWrongMethodAdminAPIRequests(t *testing.T) { @@ -282,7 +310,11 @@ func (a actionDenyAuthorizer) Authorize(_ context.Context, _ Principal, _ string type testProvider struct{} -func (testProvider) Roles(context.Context, Principal) ([]RoleAssignment, error) { +func (testProvider) Roles(context.Context, Principal) ([]Role, error) { + return []Role{{Name: "tenant_admin", Scopes: []string{"cms.page.read"}}}, nil +} + +func (testProvider) RoleAssignments(context.Context, Principal) ([]RoleAssignment, error) { return []RoleAssignment{{User: "admin-1", Role: "tenant_admin", Context: "admin", Scopes: []string{"cms.page.read"}}}, nil } @@ -349,3 +381,81 @@ func (testProvider) CheckRelation(context.Context, Principal, RelationCheck) (De func (testProvider) Enforce(context.Context, Principal, DecisionRequest) (Decision, error) { return Decision{Allowed: true, Reason: "matched test rule"}, nil } + +type legacyRoleProvider struct{} + +func (legacyRoleProvider) Roles(context.Context, Principal) ([]Role, error) { + return []Role{{Name: "tenant_admin", Scopes: []string{"cms.page.read"}}}, nil +} + +func (legacyRoleProvider) UpsertRole(ctx context.Context, p Principal, r RoleAssignment) error { + return testProvider{}.UpsertRole(ctx, p, r) +} + +func (legacyRoleProvider) DeleteRole(ctx context.Context, p Principal, r RoleAssignment) error { + return testProvider{}.DeleteRole(ctx, p, r) +} + +func (legacyRoleProvider) Scopes(ctx context.Context, p Principal) ([]Scope, error) { + return testProvider{}.Scopes(ctx, p) +} + +func (legacyRoleProvider) Capabilities(ctx context.Context, p Principal) ([]Capability, error) { + return testProvider{}.Capabilities(ctx, p) +} + +func (legacyRoleProvider) Declarations(ctx context.Context, p Principal) (Declarations, error) { + return testProvider{}.Declarations(ctx, p) +} + +func (legacyRoleProvider) ProjectionInputs(ctx context.Context, p Principal) (ProjectionInputs, error) { + return testProvider{}.ProjectionInputs(ctx, p) +} + +func (legacyRoleProvider) Model(ctx context.Context, p Principal) (Model, error) { + return testProvider{}.Model(ctx, p) +} + +func (legacyRoleProvider) Policies(ctx context.Context, p Principal) ([]Policy, error) { + return testProvider{}.Policies(ctx, p) +} + +func (legacyRoleProvider) UpsertPolicy(ctx context.Context, p Principal, r PolicyRule) error { + return testProvider{}.UpsertPolicy(ctx, p, r) +} + +func (legacyRoleProvider) DeletePolicy(ctx context.Context, p Principal, r PolicyRule) error { + return testProvider{}.DeletePolicy(ctx, p, r) +} + +func (legacyRoleProvider) AttributePolicies(ctx context.Context, p Principal) ([]AttributePolicy, error) { + return testProvider{}.AttributePolicies(ctx, p) +} + +func (legacyRoleProvider) UpsertAttributePolicy(ctx context.Context, p Principal, policy AttributePolicy) error { + return testProvider{}.UpsertAttributePolicy(ctx, p, policy) +} + +func (legacyRoleProvider) DeleteAttributePolicy(ctx context.Context, p Principal, policy AttributePolicy) error { + return testProvider{}.DeleteAttributePolicy(ctx, p, policy) +} + +func (legacyRoleProvider) RelationTuples(ctx context.Context, p Principal) ([]RelationTuple, error) { + return testProvider{}.RelationTuples(ctx, p) +} + +func (legacyRoleProvider) UpsertRelationTuple(ctx context.Context, p Principal, tuple RelationTuple) error { + return testProvider{}.UpsertRelationTuple(ctx, p, tuple) +} + +func (legacyRoleProvider) DeleteRelationTuple(ctx context.Context, p Principal, tuple RelationTuple) error { + return testProvider{}.DeleteRelationTuple(ctx, p, tuple) +} + +func (legacyRoleProvider) CheckRelation(ctx context.Context, p Principal, check RelationCheck) (Decision, error) { + return testProvider{}.CheckRelation(ctx, p, check) +} + +func (legacyRoleProvider) Enforce(ctx context.Context, p Principal, req DecisionRequest) (Decision, error) { + return testProvider{}.Enforce(ctx, p, req) +} diff --git a/adminapi/types.go b/adminapi/types.go index 5e9547b..01b6be3 100644 --- a/adminapi/types.go +++ b/adminapi/types.go @@ -117,7 +117,7 @@ type Authorizer interface { } type Provider interface { - Roles(context.Context, Principal) ([]RoleAssignment, error) + Roles(context.Context, Principal) ([]Role, error) UpsertRole(context.Context, Principal, RoleAssignment) error DeleteRole(context.Context, Principal, RoleAssignment) error Scopes(context.Context, Principal) ([]Scope, error) @@ -138,6 +138,10 @@ type Provider interface { Enforce(context.Context, Principal, DecisionRequest) (Decision, error) } +type RoleAssignmentProvider interface { + RoleAssignments(context.Context, Principal) ([]RoleAssignment, error) +} + type RouteCatalog struct { ByPath map[string]Route } From 02662f0823869f6da106e511bb0796cb2757df21 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 21 Jun 2026 03:07:44 -0400 Subject: [PATCH 3/5] fix: expose scope action metadata --- adminapi/handler_test.go | 2 +- adminapi/types.go | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/adminapi/handler_test.go b/adminapi/handler_test.go index 61285ff..50da0f4 100644 --- a/adminapi/handler_test.go +++ b/adminapi/handler_test.go @@ -323,7 +323,7 @@ func (testProvider) UpsertRole(context.Context, Principal, RoleAssignment) error func (testProvider) DeleteRole(context.Context, Principal, RoleAssignment) error { return nil } func (testProvider) Scopes(context.Context, Principal) ([]Scope, error) { - return []Scope{{Name: "cms.page.read", Resource: "cms.page", Action: "read"}}, nil + return []Scope{{Name: "cms.page.read", Resource: "cms.page", Action: "read", Actions: []string{"read"}, Description: "Read pages", Category: "content"}}, nil } func (testProvider) Capabilities(context.Context, Principal) ([]Capability, error) { diff --git a/adminapi/types.go b/adminapi/types.go index 01b6be3..8a2e5bd 100644 --- a/adminapi/types.go +++ b/adminapi/types.go @@ -32,10 +32,15 @@ type RoleAssignment struct { } type Scope struct { - Name string `json:"name"` - Context string `json:"context,omitempty"` - Resource string `json:"resource,omitempty"` - Action string `json:"action,omitempty"` + Name string `json:"name"` + Context string `json:"context,omitempty"` + Resource string `json:"resource,omitempty"` + Action string `json:"action,omitempty"` + Actions []string `json:"actions,omitempty"` + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` + OwnerPlugin string `json:"owner_plugin,omitempty"` + OwnerModule string `json:"owner_module,omitempty"` } type Capability struct { From 16a88ca0574ba02da720989d632ce41ca811ccd4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 21 Jun 2026 03:09:59 -0400 Subject: [PATCH 4/5] fix: classify invalid authz requests --- adminapi/handler.go | 5 +++++ adminapi/handler_test.go | 24 ++++++++++++++++++++++++ adminapi/types.go | 3 +++ 3 files changed, 32 insertions(+) diff --git a/adminapi/handler.go b/adminapi/handler.go index a18429f..22ef3a2 100644 --- a/adminapi/handler.go +++ b/adminapi/handler.go @@ -3,6 +3,7 @@ package adminapi import ( "context" "encoding/json" + "errors" "fmt" "net/http" "path" @@ -293,6 +294,10 @@ func decodeRouteJSON(w http.ResponseWriter, r *http.Request, out any) bool { func writeProviderResult(w http.ResponseWriter, payload any, err error) { if err != nil { + if errors.Is(err, ErrInvalidRequest) { + writeError(w, http.StatusBadRequest, "invalid authz request") + return + } writeError(w, http.StatusInternalServerError, "authz provider unavailable") return } diff --git a/adminapi/handler_test.go b/adminapi/handler_test.go index 50da0f4..8f4980d 100644 --- a/adminapi/handler_test.go +++ b/adminapi/handler_test.go @@ -196,6 +196,24 @@ func TestHandlerReturnsJSONErrorsForUnknownOrWrongMethodAdminAPIRequests(t *test } } +func TestProviderInvalidRequestReturnsBadRequest(t *testing.T) { + h, err := NewHandler(Options{ + PrincipalResolver: fixedPrincipal{Principal{Subject: "admin-1"}}, + Authorizer: allowAuthorizer{}, + Provider: invalidRoleProvider{}, + }) + if err != nil { + t.Fatalf("NewHandler: %v", err) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/authz/roles", strings.NewReader(`{"user":"admin-1","role":"tenant_editor"}`))) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 body=%s", rec.Code, rec.Body.String()) + } +} + func TestHandlerSupportsAuthzUIMutationRoutes(t *testing.T) { h := newTestHandler(t) for _, tc := range []struct { @@ -459,3 +477,9 @@ func (legacyRoleProvider) CheckRelation(ctx context.Context, p Principal, check func (legacyRoleProvider) Enforce(ctx context.Context, p Principal, req DecisionRequest) (Decision, error) { return testProvider{}.Enforce(ctx, p, req) } + +type invalidRoleProvider struct{ testProvider } + +func (invalidRoleProvider) UpsertRole(context.Context, Principal, RoleAssignment) error { + return ErrInvalidRequest +} diff --git a/adminapi/types.go b/adminapi/types.go index 8a2e5bd..e149163 100644 --- a/adminapi/types.go +++ b/adminapi/types.go @@ -3,9 +3,12 @@ package adminapi import ( "context" + "errors" "net/http" ) +var ErrInvalidRequest = errors.New("invalid authz request") + type Options struct { BasePath string PrincipalResolver PrincipalResolver From 70b2385b3d860c8482754da84ce7279df6c1f40e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 21 Jun 2026 03:11:15 -0400 Subject: [PATCH 5/5] fix: complete legacy role fallback --- adminapi/handler.go | 7 ++++++- adminapi/handler_test.go | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/adminapi/handler.go b/adminapi/handler.go index 22ef3a2..6d22970 100644 --- a/adminapi/handler.go +++ b/adminapi/handler.go @@ -279,7 +279,12 @@ func (h *handler) roleAssignments(ctx context.Context, principal Principal) ([]R } assignments := make([]RoleAssignment, 0, len(roles)) for _, role := range roles { - assignments = append(assignments, RoleAssignment{Role: role.Name, Scopes: role.Scopes}) + assignments = append(assignments, RoleAssignment{ + User: principal.Subject, + Role: role.Name, + Context: "admin", + Scopes: role.Scopes, + }) } return assignments, nil } diff --git a/adminapi/handler_test.go b/adminapi/handler_test.go index 8f4980d..2ac99f5 100644 --- a/adminapi/handler_test.go +++ b/adminapi/handler_test.go @@ -153,7 +153,7 @@ func TestRolesReadFallsBackToLegacyRoleDefinitions(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &assignments); err != nil { t.Fatal(err) } - if len(assignments) != 1 || assignments[0].Role != "tenant_admin" || len(assignments[0].Scopes) != 1 { + if len(assignments) != 1 || assignments[0].User != "admin-1" || assignments[0].Role != "tenant_admin" || assignments[0].Context != "admin" || len(assignments[0].Scopes) != 1 { t.Fatalf("assignments = %#v, want role-definition fallback", assignments) } } @@ -400,6 +400,8 @@ func (testProvider) Enforce(context.Context, Principal, DecisionRequest) (Decisi return Decision{Allowed: true, Reason: "matched test rule"}, nil } +// legacyRoleProvider intentionally does not embed testProvider because +// testProvider implements RoleAssignmentProvider and would bypass this fallback. type legacyRoleProvider struct{} func (legacyRoleProvider) Roles(context.Context, Principal) ([]Role, error) {