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

524 lines
15 KiB
Go

// Package permissions provides role-based access control for gateway methods.
//
// GoClaw uses a 5-layer permission system:
//
// 1. Gateway Auth (token/password, scopes: admin/read/write/approvals/pairing)
// 2. Global Tool Policy (tools.allow[], tools.deny[], tools.profile)
// 3. Per-Agent Policy (agents.list[].tools.allow/deny)
// 4. Per-Channel/Group Policy (channels.*.groups.*.tools.policy)
// 5. Owner-Only Tools (senderIsOwner check)
//
// This package handles layers 1 and 5. Layer 2-4 are handled by internal/tools/policy.go.
package permissions
import (
"slices"
"strings"
"sync"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// Role represents a user's permission level.
type Role string
const (
RoleOwner Role = "owner" // Tenant management + full access (superset of admin)
RoleAdmin Role = "admin" // Full access to all methods
RoleOperator Role = "operator" // Read + write access (no admin operations)
RoleViewer Role = "viewer" // Read-only access
// RoleNone is a sentinel returned by MethodRole for methods that have no
// explicit classification. The router treats it as deny-for-everyone so
// newly-added RPCs are secure-by-default (fail-closed).
RoleNone Role = ""
)
// Scope represents a specific permission scope.
type Scope string
const (
ScopeAdmin Scope = "operator.admin"
ScopeRead Scope = "operator.read"
ScopeWrite Scope = "operator.write"
ScopeApprovals Scope = "operator.approvals"
ScopePairing Scope = "operator.pairing"
ScopeProvision Scope = "operator.provision"
)
// AllScopes is the set of all valid API key scopes.
var AllScopes = map[Scope]bool{
ScopeAdmin: true,
ScopeRead: true,
ScopeWrite: true,
ScopeApprovals: true,
ScopePairing: true,
ScopeProvision: true,
}
// ValidScope reports whether s is a recognised API key scope.
func ValidScope(s string) bool {
return AllScopes[Scope(s)]
}
// PolicyEngine evaluates user permissions for gateway method access.
type PolicyEngine struct {
ownerIDs map[string]bool // sender IDs that are considered "owner"
mu sync.RWMutex
}
// NewPolicyEngine creates a new permission policy engine.
func NewPolicyEngine(ownerIDs []string) *PolicyEngine {
owners := make(map[string]bool, len(ownerIDs))
for _, id := range ownerIDs {
owners[id] = true
}
return &PolicyEngine{
ownerIDs: owners,
}
}
// IsOwner checks if a sender ID is an owner.
// When no owner IDs are configured, "system" is treated as owner (fail-closed default).
func (pe *PolicyEngine) IsOwner(senderID string) bool {
if senderID == "" {
return false
}
pe.mu.RLock()
defer pe.mu.RUnlock()
if len(pe.ownerIDs) == 0 {
return senderID == "system"
}
return pe.ownerIDs[senderID]
}
// CanAccess checks if a role has access to a gateway RPC method.
// Unclassified methods (MethodRole == RoleNone) are denied for every role
// including owner — callers must surface an explicit PERMISSION_DENIED/
// UNAUTHORIZED and never silently permit.
func (pe *PolicyEngine) CanAccess(role Role, method string) bool {
requiredRole := MethodRole(method)
if requiredRole == RoleNone {
return false
}
return roleLevel(role) >= roleLevel(requiredRole)
}
// CanAccessWithScopes checks if the given scopes permit access to a method.
func (pe *PolicyEngine) CanAccessWithScopes(scopes []Scope, method string) bool {
required := MethodScopes(method)
if len(required) == 0 {
return true // no scope restriction
}
scopeSet := make(map[Scope]bool, len(scopes))
for _, s := range scopes {
scopeSet[s] = true
}
for _, r := range required {
if scopeSet[r] {
return true
}
}
return false
}
// RoleFromTenantRole maps a `tenant_users.role` value to the gateway's
// permissions.Role used by CanAccess. Used by the WS and HTTP browser-pairing
// auth paths so a paired session inherits the role the user already has in
// their tenant instead of a hard-coded operator default. String literals
// rather than store.TenantRole* constants are used to avoid an import cycle
// (store depends on this package transitively).
//
// Mapping:
//
// owner → RoleOwner
// admin → RoleAdmin
// operator, member → RoleOperator
// viewer, "" → RoleViewer
func RoleFromTenantRole(tenantRole string) Role {
switch tenantRole {
case "owner":
return RoleOwner
case "admin":
return RoleAdmin
case "operator", "member":
return RoleOperator
case "viewer":
return RoleViewer
default:
return RoleViewer
}
}
// RoleFromScopes determines the effective role from a set of scopes.
func RoleFromScopes(scopes []Scope) Role {
if slices.Contains(scopes, ScopeAdmin) {
return RoleAdmin
}
if slices.Contains(scopes, ScopeWrite) ||
slices.Contains(scopes, ScopeApprovals) ||
slices.Contains(scopes, ScopePairing) {
return RoleOperator
}
if slices.Contains(scopes, ScopeRead) {
return RoleViewer
}
return RoleViewer
}
// IsProvisionMethod reports whether method is one of the tenant-provisioning
// RPCs that ScopeProvision exists to grant (issue #1524). ScopeProvision is a
// least-privilege scope for automated tenant onboarding: it admits exactly
// these two methods and nothing else — never a role promotion or a broad
// admin bypass.
func IsProvisionMethod(method string) bool {
switch method {
case protocol.MethodTenantsCreate, protocol.MethodTenantsUsersAdd:
return true
}
return false
}
// HasProvisionScope reports whether scopes include ScopeProvision.
func HasProvisionScope(scopes []Scope) bool {
return slices.Contains(scopes, ScopeProvision)
}
// MethodRole returns the minimum role required for a given RPC method.
//
// Policy is fail-closed (default-deny): methods absent from every allowlist
// return RoleNone. The gateway dispatcher must reject RoleNone with an
// UNAUTHORIZED / PERMISSION_DENIED error rather than granting viewer access.
// This is the fix for issue #866 where default-permit let unauthenticated
// clients invoke mutation/exfiltration RPCs (heartbeat.*, logs.tail, etc.).
func MethodRole(method string) Role {
// System methods that bypass auth entirely (pre-auth handshake only).
if isPublicMethod(method) {
return RoleViewer
}
// Admin-only methods
if isAdminMethod(method) {
return RoleAdmin
}
// Write methods (require operator or above)
if isWriteMethod(method) {
return RoleOperator
}
// Read-only methods (viewer and above)
if isReadMethod(method) {
return RoleViewer
}
// Fail-closed: unknown / unclassified method → deny.
return RoleNone
}
// isPublicMethod lists methods every authenticated (and some pre-auth) client
// is allowed to call. Kept tiny on purpose — do not expand without review.
func isPublicMethod(method string) bool {
switch method {
case protocol.MethodConnect,
protocol.MethodHealth,
protocol.MethodStatus,
protocol.MethodBrowserPairingStatus:
return true
}
return false
}
// MethodScopes returns the scopes required for a method.
func MethodScopes(method string) []Scope {
if isAdminMethod(method) {
return []Scope{ScopeAdmin}
}
if strings.HasPrefix(method, "approvals.") {
return []Scope{ScopeApprovals, ScopeAdmin}
}
if strings.HasPrefix(method, "pairing.") || strings.HasPrefix(method, "device.pair") {
return []Scope{ScopePairing, ScopeAdmin}
}
if isWriteMethod(method) {
return []Scope{ScopeWrite, ScopeAdmin}
}
return []Scope{ScopeRead, ScopeWrite, ScopeAdmin}
}
func isAdminMethod(method string) bool {
adminMethods := []string{
// Config — admin/owner only. Additional master-scope/owner guards live
// in the handler middleware, but classify here for defense-in-depth.
protocol.MethodConfigGet,
protocol.MethodConfigApply,
protocol.MethodConfigPatch,
protocol.MethodConfigSchema,
protocol.MethodConfigDefaults,
protocol.MethodChatBehaviorPreview,
protocol.MethodConfigPermissionsList,
protocol.MethodConfigPermissionsCheck,
protocol.MethodConfigPermissionsGrant,
protocol.MethodConfigPermissionsRevoke,
// Agents — create/update/delete and link mutations.
protocol.MethodAgentsCreate,
protocol.MethodAgentsUpdate,
protocol.MethodAgentsDelete,
protocol.MethodAgentsLinksCreate,
protocol.MethodAgentsLinksUpdate,
protocol.MethodAgentsLinksDelete,
// Channels.
protocol.MethodChannelsToggle,
protocol.MethodChannelInstancesCreate,
protocol.MethodChannelInstancesUpdate,
protocol.MethodChannelInstancesDelete,
// Bitrix24 portal management — admin-only writes (credentials + delete).
protocol.MethodBitrixPortalsCreate,
protocol.MethodBitrixPortalsDelete,
// Pairing management (approve/revoke/list/deny require admin).
protocol.MethodPairingApprove,
protocol.MethodPairingDeny,
protocol.MethodPairingList,
protocol.MethodPairingRevoke,
protocol.MethodPairingUpdate,
// Teams — create/delete/update/member management.
protocol.MethodTeamsCreate,
protocol.MethodTeamsDelete,
protocol.MethodTeamsUpdate,
protocol.MethodTeamsMembersAdd,
protocol.MethodTeamsMembersRemove,
protocol.MethodTeamsTaskDelete,
protocol.MethodTeamsTaskDeleteBulk,
// Tenants — write paths.
protocol.MethodTenantsCreate,
protocol.MethodTenantsUpdate,
protocol.MethodTenantsUsersAdd,
protocol.MethodTenantsUsersRemove,
// API keys expose secret material — gate list + mutations as admin.
protocol.MethodAPIKeysList,
protocol.MethodAPIKeysCreate,
protocol.MethodAPIKeysRevoke,
// Skills (can rewrite agent behavior).
protocol.MethodSkillsUpdate,
// Heartbeat — any write/test path (closes CVE #866 step 2 + step 4).
protocol.MethodHeartbeatSet,
protocol.MethodHeartbeatToggle,
protocol.MethodHeartbeatTest,
protocol.MethodHeartbeatChecklistSet,
// Live server logs — data exfiltration risk (closes CVE #866 step 3).
protocol.MethodLogsTail,
// Hooks mutations (the handler middleware also enforces this).
protocol.MethodHooksCreate,
protocol.MethodHooksUpdate,
protocol.MethodHooksDelete,
protocol.MethodHooksToggle,
// Voice catalogue refresh touches provider credentials.
protocol.MethodVoicesRefresh,
// TTS config mutations touch provider credentials / global state.
protocol.MethodTTSEnable,
protocol.MethodTTSDisable,
protocol.MethodTTSSetProvider,
// Workstations — credentials + remote exec; create/update/delete and
// agent linking + permission mutations are admin-only.
protocol.MethodWorkstationsCreate,
protocol.MethodWorkstationsUpdate,
protocol.MethodWorkstationsDelete,
protocol.MethodWorkstationsLinkAgent,
protocol.MethodWorkstationsUnlinkAgent,
protocol.MethodWorkstationsPermAdd,
protocol.MethodWorkstationsPermRemove,
protocol.MethodWorkstationsPermToggle,
}
return slices.Contains(adminMethods, method)
}
func isWriteMethod(method string) bool {
writeExact := []string{
protocol.MethodChatSend,
protocol.MethodChatAbort,
protocol.MethodChatInject,
protocol.MethodSessionsDelete,
protocol.MethodSessionsReset,
protocol.MethodSessionsPatch,
protocol.MethodSessionsCompact,
protocol.MethodCronCreate,
protocol.MethodCronUpdate,
protocol.MethodCronDelete,
protocol.MethodCronToggle,
protocol.MethodCronRun,
protocol.MethodSend,
protocol.MethodLLMComplete,
protocol.MethodAgentsFileSet,
protocol.MethodTeamsTaskApprove,
protocol.MethodTeamsTaskReject,
protocol.MethodTeamsTaskComment,
protocol.MethodTeamsTaskCreate,
protocol.MethodTeamsTaskAssign,
protocol.MethodTeamsTaskCancel,
protocol.MethodTeamsTaskRetry,
protocol.MethodTeamsWorkspaceDelete,
protocol.MethodHooksTest,
protocol.MethodPairingRequest,
protocol.MethodApprovalsApprove,
protocol.MethodApprovalsDeny,
// TTS synthesis — invokes provider API (quota/credentials).
protocol.MethodTTSConvert,
// Browser automation — performs side-effecting actions.
protocol.MethodBrowserAct,
// Channel pairing starts (QR scan flows).
protocol.MethodZaloPersonalQRStart,
protocol.MethodWhatsAppQRStart,
// Workstations — connection test invokes SSH side-effects.
protocol.MethodWorkstationsTest,
}
return slices.Contains(writeExact, method)
}
// isReadMethod is the explicit viewer-allowlist for read-only RPCs. Keeping
// this as a closed list (rather than "everything else") is what turns the
// policy into fail-closed. New read RPCs must be added here explicitly.
func isReadMethod(method string) bool {
readMethods := []string{
// Agent identity / wait
protocol.MethodAgent,
protocol.MethodAgentWait,
protocol.MethodAgentIdentityGet,
// Chat read
protocol.MethodChatHistory,
protocol.MethodChatSessionStatus,
// Agents read
protocol.MethodAgentsList,
protocol.MethodAgentsFileList,
protocol.MethodAgentsFileGet,
protocol.MethodAgentsLinksList,
// Sessions read
protocol.MethodSessionsList,
protocol.MethodSessionsPreview,
protocol.MethodRunTimelineGet,
// Skills read
protocol.MethodSkillsList,
protocol.MethodSkillsGet,
// Cron read
protocol.MethodCronList,
protocol.MethodCronStatus,
protocol.MethodCronRuns,
// Channels read
protocol.MethodChannelsList,
protocol.MethodChannelsStatus,
protocol.MethodChannelInstancesList,
protocol.MethodChannelInstancesGet,
// Bitrix24 portal read — any tenant member can list portals to populate
// the channel-form dropdown; get_install_url is needed to resume a
// half-finished authorize flow.
protocol.MethodBitrixPortalsList,
protocol.MethodBitrixPortalsGetInstallURL,
// Usage / quota
protocol.MethodUsageGet,
protocol.MethodUsageSummary,
protocol.MethodQuotaUsage,
// Heartbeat read
protocol.MethodHeartbeatGet,
protocol.MethodHeartbeatLogs,
protocol.MethodHeartbeatChecklistGet,
protocol.MethodHeartbeatTargets,
// Voices
protocol.MethodVoicesList,
// Tenants read
protocol.MethodTenantsList,
protocol.MethodTenantsGet,
protocol.MethodTenantsUsersList,
protocol.MethodTenantsMine,
// Teams read
protocol.MethodTeamsList,
protocol.MethodTeamsGet,
protocol.MethodTeamsTaskList,
protocol.MethodTeamsTaskGet,
protocol.MethodTeamsTaskGetLight,
protocol.MethodTeamsTaskComments,
protocol.MethodTeamsTaskEvents,
protocol.MethodTeamsTaskActiveBySession,
protocol.MethodTeamsWorkspaceList,
protocol.MethodTeamsWorkspaceRead,
protocol.MethodTeamsEventsList,
protocol.MethodTeamsKnownUsers,
protocol.MethodTeamsScopes,
// Hooks read
protocol.MethodHooksList,
protocol.MethodHooksHistory,
// Approvals read-only listing
protocol.MethodApprovalsList,
// TTS read-only (status/providers listing)
protocol.MethodTTSStatus,
protocol.MethodTTSProviders,
// Browser observation (no side effects)
protocol.MethodBrowserSnapshot,
protocol.MethodBrowserScreenshot,
// Zalo personal contacts listing
protocol.MethodZaloPersonalContacts,
// Workstations read
protocol.MethodWorkstationsList,
protocol.MethodWorkstationsGet,
protocol.MethodWorkstationsPermList,
protocol.MethodWorkstationsListActivity,
}
return slices.Contains(readMethods, method)
}
// HasMinRole checks if the given role meets the minimum required level.
func HasMinRole(role, required Role) bool {
return roleLevel(role) >= roleLevel(required)
}
func roleLevel(r Role) int {
switch r {
case RoleOwner:
return 4
case RoleAdmin:
return 3
case RoleOperator:
return 2
case RoleViewer:
return 1
default:
return 0
}
}