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.
524 lines
15 KiB
Go
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
|
|
}
|
|
}
|