diff --git a/adminapi/handler.go b/adminapi/handler.go index 286e1bb..6d22970 100644 --- a/adminapi/handler.go +++ b/adminapi/handler.go @@ -1,7 +1,9 @@ package adminapi import ( + "context" "encoding/json" + "errors" "fmt" "net/http" "path" @@ -172,7 +174,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 +269,26 @@ 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{ + User: principal.Subject, + Role: role.Name, + Context: "admin", + 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") @@ -277,6 +299,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 ddd8ec5..2ac99f5 100644 --- a/adminapi/handler_test.go +++ b/adminapi/handler_test.go @@ -112,6 +112,52 @@ 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) + } + 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].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) + } +} + func TestHandlerReturnsJSONErrorsForUnknownOrWrongMethodAdminAPIRequests(t *testing.T) { h := newTestHandler(t) for _, tc := range []struct { @@ -150,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 { @@ -268,12 +332,16 @@ 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 +} + func (testProvider) UpsertRole(context.Context, Principal, RoleAssignment) error { return nil } 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) { @@ -331,3 +399,89 @@ 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 } + +// 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) { + 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) +} + +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 05e33cb..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 @@ -32,10 +35,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 { @@ -138,6 +146,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 }