Files
fchengyan a1913f813f fix(gateway): admit operator.provision keys on tenants.create and ten… (#1584)
* fix(gateway): admit operator.provision keys on tenants.create and tenants.users.add

The CVE #866 fail-closed hardening regressed operator.provision: the
role-only router check maps provision-only API keys to viewer and
rejects tenants.create / tenants.users.add with 'requires admin role',
even though the tenant handlers explicitly admit ScopeProvision on
exactly those two methods (issue #1524).

Restore the intended least-privilege provisioning path without any role
promotion: the router now allows credentials carrying ScopeProvision on
exactly the two tenant-provisioning RPCs (permissions.IsProvisionMethod).
Every other admin/write surface stays denied, and viewers without the
provision scope gain nothing.

Regression tests (internal/gateway/router_test.go) prove:
- provision-only succeeds on tenants.create and tenants.users.add
- provision-only stays denied on tenants.update and agents.create
- plain viewers stay denied on the provisioning methods
- unauthenticated clients stay denied

* refactor(gateway): route provisionScopeAllowed through permissions.HasProvisionScope

Address review feedback on #1584: HasProvisionScope was exported and
tested but had no production caller — the router used Client.HasScope
directly. Wire the router to the permissions helper so the provision
scope check has a single source of truth.
2026-09-28 14:04:35 +07:00

485 lines
15 KiB
Go

package permissions
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// --- Role hierarchy ---
func TestRoleLevel_Ordering(t *testing.T) {
// Owner > Admin > Operator > Viewer > unknown
levels := []struct {
role Role
level int
}{
{RoleOwner, 4},
{RoleAdmin, 3},
{RoleOperator, 2},
{RoleViewer, 1},
{Role("unknown"), 0},
{Role(""), 0},
}
for _, tt := range levels {
t.Run(string(tt.role), func(t *testing.T) {
got := roleLevel(tt.role)
if got != tt.level {
t.Fatalf("roleLevel(%q) = %d, want %d", tt.role, got, tt.level)
}
})
}
}
func TestHasMinRole(t *testing.T) {
tests := []struct {
name string
role Role
required Role
want bool
}{
{"owner_meets_admin", RoleOwner, RoleAdmin, true},
{"admin_meets_admin", RoleAdmin, RoleAdmin, true},
{"operator_fails_admin", RoleOperator, RoleAdmin, false},
{"viewer_fails_operator", RoleViewer, RoleOperator, false},
{"operator_meets_viewer", RoleOperator, RoleViewer, true},
{"admin_meets_viewer", RoleAdmin, RoleViewer, true},
{"viewer_meets_viewer", RoleViewer, RoleViewer, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := HasMinRole(tt.role, tt.required)
if got != tt.want {
t.Fatalf("HasMinRole(%q, %q) = %v, want %v", tt.role, tt.required, got, tt.want)
}
})
}
}
// --- RoleFromScopes ---
func TestRoleFromScopes(t *testing.T) {
tests := []struct {
name string
scopes []Scope
want Role
}{
{"admin_scope", []Scope{ScopeAdmin}, RoleAdmin},
{"admin_overrides_read", []Scope{ScopeRead, ScopeAdmin}, RoleAdmin},
{"write_is_operator", []Scope{ScopeWrite}, RoleOperator},
{"approvals_is_operator", []Scope{ScopeApprovals}, RoleOperator},
{"pairing_is_operator", []Scope{ScopePairing}, RoleOperator},
{"read_is_viewer", []Scope{ScopeRead}, RoleViewer},
{"empty_scopes", []Scope{}, RoleViewer},
{"nil_scopes", nil, RoleViewer},
{"provision_only_is_viewer", []Scope{ScopeProvision}, RoleViewer},
{"read_and_write", []Scope{ScopeRead, ScopeWrite}, RoleOperator},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := RoleFromScopes(tt.scopes)
if got != tt.want {
t.Fatalf("RoleFromScopes(%v) = %q, want %q", tt.scopes, got, tt.want)
}
})
}
}
// TestIsProvisionMethod covers the ScopeProvision method allowlist (issue #1524):
// exactly tenants.create and tenants.users.add, nothing else.
func TestIsProvisionMethod(t *testing.T) {
allowed := []string{protocol.MethodTenantsCreate, protocol.MethodTenantsUsersAdd}
for _, method := range allowed {
if !IsProvisionMethod(method) {
t.Fatalf("IsProvisionMethod(%q) = false, want true", method)
}
}
denied := []string{
protocol.MethodTenantsUpdate,
protocol.MethodTenantsUsersRemove,
protocol.MethodAgentsCreate,
protocol.MethodAPIKeysCreate,
protocol.MethodConfigApply,
"tenants.nonexistent",
}
for _, method := range denied {
if IsProvisionMethod(method) {
t.Fatalf("IsProvisionMethod(%q) = true, want false", method)
}
}
}
func TestHasProvisionScope(t *testing.T) {
if !HasProvisionScope([]Scope{ScopeProvision}) {
t.Fatal("HasProvisionScope([operator.provision]) = false, want true")
}
if !HasProvisionScope([]Scope{ScopeRead, ScopeProvision}) {
t.Fatal("HasProvisionScope([operator.read operator.provision]) = false, want true")
}
if HasProvisionScope(nil) {
t.Fatal("HasProvisionScope(nil) = true, want false")
}
if HasProvisionScope([]Scope{ScopeAdmin}) {
t.Fatal("HasProvisionScope([operator.admin]) = true, want false")
}
}
// --- CanAccess: role-based method access ---
func TestCanAccess_AdminMethods(t *testing.T) {
pe := NewPolicyEngine(nil)
adminMethods := []string{
protocol.MethodConfigApply,
protocol.MethodConfigPermissionsCheck,
protocol.MethodAgentsCreate,
protocol.MethodAgentsDelete,
protocol.MethodAPIKeysCreate,
protocol.MethodTeamsCreate,
}
for _, method := range adminMethods {
t.Run(method, func(t *testing.T) {
if !pe.CanAccess(RoleAdmin, method) {
t.Fatalf("admin should access %s", method)
}
if !pe.CanAccess(RoleOwner, method) {
t.Fatalf("owner should access %s", method)
}
if pe.CanAccess(RoleOperator, method) {
t.Fatalf("operator should NOT access %s", method)
}
if pe.CanAccess(RoleViewer, method) {
t.Fatalf("viewer should NOT access %s", method)
}
})
}
}
func TestCanAccess_WriteMethods(t *testing.T) {
pe := NewPolicyEngine(nil)
writeMethods := []string{
protocol.MethodChatSend,
protocol.MethodSessionsDelete,
protocol.MethodSessionsCompact,
protocol.MethodCronCreate,
protocol.MethodTeamsTaskCancel,
protocol.MethodTeamsTaskRetry,
}
for _, method := range writeMethods {
t.Run(method, func(t *testing.T) {
if !pe.CanAccess(RoleOperator, method) {
t.Fatalf("operator should access %s", method)
}
if !pe.CanAccess(RoleAdmin, method) {
t.Fatalf("admin should access %s", method)
}
if pe.CanAccess(RoleViewer, method) {
t.Fatalf("viewer should NOT access write method %s", method)
}
})
}
}
func TestCanAccess_ReadMethods_AnyRole(t *testing.T) {
pe := NewPolicyEngine(nil)
// A method not in admin or write lists → defaults to viewer
readMethod := "sessions.list" // not in admin/write lists
for _, role := range []Role{RoleViewer, RoleOperator, RoleAdmin, RoleOwner} {
if !pe.CanAccess(role, readMethod) {
t.Fatalf("%s should access read method %s", role, readMethod)
}
}
}
// TestCanAccess_UnknownMethod_DeniedForAll locks in the fail-closed behavior
// introduced by issue #866: any method not present in the public / admin /
// write / read allowlists MUST be denied for every role including owner.
// This is the inverse of the previous default-permit behavior.
func TestCanAccess_UnknownMethod_DeniedForAll(t *testing.T) {
pe := NewPolicyEngine(nil)
unknown := "totally.unknown.method"
for _, role := range []Role{RoleViewer, RoleOperator, RoleAdmin, RoleOwner} {
if pe.CanAccess(role, unknown) {
t.Fatalf("%s must NOT access unclassified method %q (fail-closed)", role, unknown)
}
}
if MethodRole(unknown) != RoleNone {
t.Fatalf("MethodRole(%q) = %q, want RoleNone", unknown, MethodRole(unknown))
}
}
// TestCanAccess_CVE866_HeartbeatAndLogs asserts that the three RPCs exploited
// in the issue-#866 chain now require admin. Viewer/operator must be denied.
func TestCanAccess_CVE866_HeartbeatAndLogs(t *testing.T) {
pe := NewPolicyEngine(nil)
adminOnly := []string{
protocol.MethodHeartbeatSet,
protocol.MethodHeartbeatChecklistSet,
protocol.MethodLogsTail,
protocol.MethodHeartbeatToggle,
protocol.MethodHeartbeatTest,
}
for _, method := range adminOnly {
t.Run(method, func(t *testing.T) {
if pe.CanAccess(RoleViewer, method) {
t.Fatalf("viewer must NOT access %s (CVE #866)", method)
}
if pe.CanAccess(RoleOperator, method) {
t.Fatalf("operator must NOT access %s (CVE #866)", method)
}
if !pe.CanAccess(RoleAdmin, method) {
t.Fatalf("admin should access %s", method)
}
if !pe.CanAccess(RoleOwner, method) {
t.Fatalf("owner should access %s", method)
}
})
}
}
// TestCanAccess_PublicMethods ensures pre-auth RPCs remain reachable by every
// role (including the zero-value RoleNone placeholder that pre-connect clients
// hold) — otherwise legitimate clients cannot even complete the handshake.
func TestCanAccess_PublicMethods(t *testing.T) {
pe := NewPolicyEngine(nil)
public := []string{
protocol.MethodConnect,
protocol.MethodHealth,
protocol.MethodStatus,
protocol.MethodBrowserPairingStatus,
}
for _, method := range public {
t.Run(method, func(t *testing.T) {
for _, role := range []Role{RoleViewer, RoleOperator, RoleAdmin, RoleOwner} {
if !pe.CanAccess(role, method) {
t.Fatalf("%s must access public method %s", role, method)
}
}
})
}
}
// --- CanAccessWithScopes: scope-based method access ---
func TestCanAccessWithScopes(t *testing.T) {
pe := NewPolicyEngine(nil)
tests := []struct {
name string
scopes []Scope
method string
want bool
}{
// Admin method requires ScopeAdmin
{"admin_scope_for_admin_method", []Scope{ScopeAdmin}, protocol.MethodAgentsCreate, true},
{"read_scope_for_admin_method", []Scope{ScopeRead}, protocol.MethodAgentsCreate, false},
{"write_scope_for_admin_method", []Scope{ScopeWrite}, protocol.MethodAgentsCreate, false},
// Approvals method requires ScopeApprovals or ScopeAdmin
{"approvals_scope_for_approvals", []Scope{ScopeApprovals}, "approvals.list", true},
{"admin_scope_for_approvals", []Scope{ScopeAdmin}, "approvals.list", true},
{"read_scope_for_approvals", []Scope{ScopeRead}, "approvals.list", false},
// Write method requires ScopeWrite or ScopeAdmin
{"write_scope_for_chat", []Scope{ScopeWrite}, protocol.MethodChatSend, true},
{"admin_scope_for_chat", []Scope{ScopeAdmin}, protocol.MethodChatSend, true},
{"read_scope_for_chat", []Scope{ScopeRead}, protocol.MethodChatSend, false},
// Read method allows ScopeRead, ScopeWrite, or ScopeAdmin
{"read_scope_for_read_method", []Scope{ScopeRead}, "sessions.list", true},
{"write_scope_for_read_method", []Scope{ScopeWrite}, "sessions.list", true},
{"admin_scope_for_read_method", []Scope{ScopeAdmin}, "sessions.list", true},
// Empty scopes
{"empty_scopes", []Scope{}, protocol.MethodAgentsCreate, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := pe.CanAccessWithScopes(tt.scopes, tt.method)
if got != tt.want {
t.Fatalf("CanAccessWithScopes(%v, %q) = %v, want %v", tt.scopes, tt.method, got, tt.want)
}
})
}
}
// --- IsOwner ---
func TestIsOwner(t *testing.T) {
pe := NewPolicyEngine([]string{"alice", "bob"})
if !pe.IsOwner("alice") {
t.Fatal("alice should be owner")
}
if !pe.IsOwner("bob") {
t.Fatal("bob should be owner")
}
if pe.IsOwner("charlie") {
t.Fatal("charlie should not be owner")
}
if pe.IsOwner("") {
t.Fatal("empty string should not be owner")
}
}
func TestIsOwner_EmptyList(t *testing.T) {
pe := NewPolicyEngine(nil)
if pe.IsOwner("anyone") {
t.Fatal("no one should be owner with empty list")
}
}
// --- ValidScope ---
func TestValidScope(t *testing.T) {
for scope := range AllScopes {
if !ValidScope(string(scope)) {
t.Fatalf("expected %q to be valid", scope)
}
}
if ValidScope("nonexistent.scope") {
t.Fatal("expected invalid scope to be rejected")
}
if ValidScope("") {
t.Fatal("expected empty scope to be rejected")
}
}
// --- MethodApprovalsList: locks in fix for writePrefixes shadowing bug.
// Before the fix, the "exec.approval." prefix in isWriteMethod's writePrefixes
// short-circuited the public→admin→write→read ordering in MethodRole,
// wrongly classifying exec.approval.list as RoleOperator. exec.approval.list
// is an explicit entry in isReadMethod and must resolve to RoleViewer.
// TestMethodRole_BitrixPortals_Classifications locks in the role required
// for each bitrix.portals.* method. Reads are tenant-member (viewer) so a
// channel-form dropdown can populate; writes are tenant-admin to gate
// portal credential entry. Regression coverage for issue caught in deploy
// where unclassified methods were RoleNone → "fail-closed" 403.
func TestMethodRole_BitrixPortals_Classifications(t *testing.T) {
cases := map[string]Role{
protocol.MethodBitrixPortalsList: RoleViewer,
protocol.MethodBitrixPortalsGetInstallURL: RoleViewer,
protocol.MethodBitrixPortalsCreate: RoleAdmin,
protocol.MethodBitrixPortalsDelete: RoleAdmin,
}
for method, want := range cases {
if got := MethodRole(method); got != want {
t.Errorf("MethodRole(%q) = %q, want %q", method, got, want)
}
}
}
func TestMethodRole_ApprovalsList_IsViewer(t *testing.T) {
if got := MethodRole(protocol.MethodApprovalsList); got != RoleViewer {
t.Fatalf("exec.approval.list must be RoleViewer (listed in isReadMethod); got %q", got)
}
if got := MethodRole(protocol.MethodApprovalsApprove); got != RoleOperator {
t.Fatalf("exec.approval.approve must be RoleOperator; got %q", got)
}
if got := MethodRole(protocol.MethodApprovalsDeny); got != RoleOperator {
t.Fatalf("exec.approval.deny must be RoleOperator; got %q", got)
}
}
// --- Drift coverage: parses pkg/protocol/methods.go at test time, enumerates
// every const Method* = "...", and asserts none resolve to RoleNone. New RPCs
// added without a matching allowlist entry will be caught here before shipping
// as fail-closed rejections in production.
func TestMethodRole_DriftCoverage_AllProtocolMethodsClassified(t *testing.T) {
_, thisFile, _, _ := runtime.Caller(0)
methodsGoPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "pkg", "protocol", "methods.go")
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, methodsGoPath, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", methodsGoPath, err)
}
var methods []string
for _, decl := range f.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.CONST {
continue
}
for _, spec := range gd.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for i, name := range vs.Names {
if !strings.HasPrefix(name.Name, "Method") {
continue
}
if i >= len(vs.Values) {
continue
}
lit, ok := vs.Values[i].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
methods = append(methods, strings.Trim(lit.Value, `"`))
}
}
}
if len(methods) < 100 {
t.Fatalf("expected 100+ Method* constants, parser collected %d — methods.go layout may have changed", len(methods))
}
var unclassified []string
for _, m := range methods {
if MethodRole(m) == RoleNone {
unclassified = append(unclassified, m)
}
}
if len(unclassified) > 0 {
t.Fatalf("RBAC drift — %d protocol method(s) resolve to RoleNone:\n %s\n\nAdd each to isPublicMethod, isAdminMethod, isWriteMethod, or isReadMethod in policy.go.",
len(unclassified), strings.Join(unclassified, "\n "))
}
}
// --- MethodScopes: verify pairing/approvals special routes ---
func TestMethodScopes_PairingMethod(t *testing.T) {
scopes := MethodScopes("pairing.request")
if len(scopes) != 2 {
t.Fatalf("expected 2 scopes for pairing method, got %d", len(scopes))
}
// Should require ScopePairing or ScopeAdmin
hasPairing, hasAdmin := false, false
for _, s := range scopes {
if s == ScopePairing {
hasPairing = true
}
if s == ScopeAdmin {
hasAdmin = true
}
}
if !hasPairing || !hasAdmin {
t.Fatalf("pairing method should require [pairing, admin], got %v", scopes)
}
}
func TestMethodScopes_ApprovalMethod(t *testing.T) {
scopes := MethodScopes("approvals.list")
hasScopeApprovals, hasAdmin := false, false
for _, s := range scopes {
if s == ScopeApprovals {
hasScopeApprovals = true
}
if s == ScopeAdmin {
hasAdmin = true
}
}
if !hasScopeApprovals || !hasAdmin {
t.Fatalf("approvals method should require [approvals, admin], got %v", scopes)
}
}