mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
* 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.
485 lines
15 KiB
Go
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)
|
|
}
|
|
}
|