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

593 lines
21 KiB
Go

package gateway
import (
"context"
"crypto/subtle"
"encoding/json"
"log/slog"
"slices"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// MethodHandler processes a single RPC method request.
type MethodHandler func(ctx context.Context, client *Client, req *protocol.RequestFrame)
// MethodRouter maps method names to handlers.
type MethodRouter struct {
handlers map[string]MethodHandler
server *Server
tenantStore store.TenantStore // optional, for enriching connect response
permCache *cache.PermissionCache // optional, for caching tenant membership checks
}
func NewMethodRouter(server *Server) *MethodRouter {
r := &MethodRouter{
handlers: make(map[string]MethodHandler),
server: server,
}
r.registerDefaults()
return r
}
// SetTenantStore sets the tenant store for enriching connect responses with tenant name/slug.
func (r *MethodRouter) SetTenantStore(ts store.TenantStore) { r.tenantStore = ts }
// SetPermissionCache sets the permission cache for tenant membership checks.
func (r *MethodRouter) SetPermissionCache(pc *cache.PermissionCache) { r.permCache = pc }
// Register adds a method handler.
func (r *MethodRouter) Register(method string, handler MethodHandler) {
r.handlers[method] = handler
}
// Handle dispatches a request to the appropriate handler.
func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol.RequestFrame) {
handler, ok := r.handlers[req.Method]
if !ok {
slog.Warn("unknown method", "method", req.Method, "client", client.id)
locale := i18n.Normalize(client.locale)
client.SendResponse(protocol.NewErrorResponse(
req.ID,
protocol.ErrInvalidRequest,
i18n.T(locale, i18n.MsgUnknownMethod, req.Method),
))
return
}
// Permission check: skip for connect, health, and browser pairing status (used by unauthenticated clients)
if req.Method != protocol.MethodConnect && req.Method != protocol.MethodHealth && req.Method != protocol.MethodBrowserPairingStatus {
if pe := r.server.policyEngine; pe != nil {
// provisionScopeAllowed implements the narrow method-scoped
// exception for operator.provision credentials (issue #1524, a
// regression from the CVE #866 fail-closed hardening). The tenant
// handlers already admit ScopeProvision on tenants.create and
// tenants.users.add, but the role-only check below maps
// provision-only keys to viewer and rejects them before the
// handler runs. The exception grants exactly those two methods to
// credentials carrying ScopeProvision — no role promotion, and
// every other admin/write surface stays denied.
if !pe.CanAccess(client.role, req.Method) && !provisionScopeAllowed(client, req.Method) {
required := permissions.MethodRole(req.Method)
slog.Warn("security.permission_denied",
"method", req.Method,
"role", client.role,
"required", string(required),
"client", client.id,
)
locale := i18n.Normalize(client.locale)
var msg string
if required == permissions.RoleNone {
// Unclassified method — fail-closed (issue #866 fix).
msg = i18n.T(locale, i18n.MsgPermissionDenied, req.Method+" is not permitted for this session")
} else {
msg = i18n.T(locale, i18n.MsgPermissionDenied, req.Method+" requires "+string(required)+" role")
}
client.SendResponse(protocol.NewErrorResponse(
req.ID,
protocol.ErrUnauthorized,
msg,
))
return
}
}
}
// Inject locale + tenant + role into context.
// All connect paths guarantee client.tenantID is set (owner defaults to MasterTenantID).
// Role injection is required so store.IsOwnerRole / store.IsMasterScope work
// from WS handlers — without it, ctx-based permission helpers silently
// evaluate as non-owner. HTTP layer does the same via enrichContext.
ctx = store.WithLocale(ctx, i18n.Normalize(client.locale))
if client.TenantID() != uuid.Nil {
ctx = store.WithTenantID(ctx, client.TenantID())
}
if slug := client.TenantSlug(); slug != "" {
ctx = store.WithTenantSlug(ctx, slug)
}
if role := client.Role(); role != "" {
ctx = store.WithRole(ctx, string(role))
}
slog.Debug("handling method", "method", req.Method, "client", client.id, "req_id", req.ID)
handler(ctx, client, req)
}
// registerDefaults registers built-in Phase 1 method handlers.
func (r *MethodRouter) registerDefaults() {
// System
r.Register(protocol.MethodConnect, r.handleConnect)
r.Register(protocol.MethodHealth, r.handleHealth)
r.Register(protocol.MethodStatus, r.handleStatus)
}
// provisionScopeAllowed reports whether a client carrying the operator.provision
// scope may call the given method. True only for the two tenant-provisioning
// RPCs — see permissions.IsProvisionMethod.
func provisionScopeAllowed(c *Client, method string) bool {
return permissions.HasProvisionScope(c.scopes) && permissions.IsProvisionMethod(method)
}
// --- Built-in handlers ---
func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *protocol.RequestFrame) {
// Parse connect params
var params struct {
Token string `json:"token"`
UserID string `json:"user_id"`
SenderID string `json:"sender_id"` // browser pairing: stored sender ID for reconnect
Locale string `json:"locale"` // user's preferred locale (en, vi, zh)
TenantHint string `json:"tenant_hint"` // optional tenant slug for browser pairing multi-tenant
TenantID string `json:"tenant_id"` // cross-tenant admin: narrow scope to specific tenant (UUID or slug)
TenantScope string `json:"tenant_scope"` // deprecated: alias for tenant_id (backward compat)
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
// Set locale on client (persists across all requests for this connection)
client.locale = i18n.Normalize(params.Locale)
configToken := r.server.cfg.Gateway.Token
// Path 1: Valid gateway token → admin (constant-time comparison)
if configToken != "" && subtle.ConstantTimeCompare([]byte(params.Token), []byte(configToken)) == 1 {
client.role = permissions.RoleAdmin
client.authenticated = true
client.userID = params.UserID
// Owner IDs get RoleOwner; others keep RoleAdmin but are scoped to their tenant.
isOwner := isOwnerID(params.UserID, r.server.cfg.Gateway.OwnerIDs)
if isOwner {
client.role = permissions.RoleOwner
// Owner can narrow scope to a specific tenant via param
tenantScope := params.TenantID
if tenantScope == "" {
tenantScope = params.TenantScope // backward compat
}
r.applyTenantScope(ctx, client, tenantScope)
if client.tenantID == uuid.Nil {
client.tenantID = store.MasterTenantID
}
} else {
// Non-owner with gateway token: resolve tenant via hint or membership
hint := params.TenantID
if hint == "" {
hint = params.TenantHint
}
if hint == "" {
hint = params.TenantScope // deprecated
}
tid, errCode := r.resolveTenantHint(ctx, hint, params.UserID)
if errCode != "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked"))
return
}
client.tenantID = tid
}
r.sendConnectResponse(ctx, client, req.ID)
return
}
// Path 1b: API key → role derived from scopes (uses shared cache)
if params.Token != "" {
if keyData, role := httpapi.ResolveAPIKey(ctx, params.Token); keyData != nil {
scopes := make([]permissions.Scope, len(keyData.Scopes))
for i, s := range keyData.Scopes {
scopes[i] = permissions.Scope(s)
}
client.role = role
client.scopes = scopes
client.authenticated = true
// If the key has a bound owner, force user_id to owner_id.
if keyData.OwnerID != "" {
if params.UserID != "" && params.UserID != keyData.OwnerID {
slog.Warn("security.ws_api_key_owner_override",
"param_user_id", params.UserID,
"owner_id", keyData.OwnerID,
)
}
client.userID = keyData.OwnerID
} else {
client.userID = params.UserID
}
if keyData.TenantID == uuid.Nil {
// System-level API keys keep their scope-derived role and may
// optionally narrow to a tenant without gaining owner privileges.
apiKeyScope := params.TenantID
if apiKeyScope == "" {
apiKeyScope = params.TenantScope // backward compat
}
r.applyTenantScope(ctx, client, apiKeyScope)
if client.tenantID == uuid.Nil {
client.tenantID = store.MasterTenantID
}
slog.Debug("security.ws_connect_resolved",
"client", client.id,
"role", string(client.role),
"tenant_id", client.tenantID.String(),
)
} else {
client.tenantID = keyData.TenantID
slog.Debug("security.ws_connect_resolved",
"client", client.id,
"role", string(client.role),
"tenant_id", client.tenantID.String(),
)
}
r.sendConnectResponse(ctx, client, req.ID)
return
}
}
// Path 2: No token configured → operator (backward compat)
if configToken == "" && config.GatewayNoAuthFallbackAllowed(r.server.cfg.Gateway) {
client.role = permissions.RoleOperator
client.authenticated = true
client.userID = params.UserID
client.tenantID = store.MasterTenantID
r.sendConnectResponse(ctx, client, req.ID)
return
}
// Path 3: Token configured but not provided/wrong → check browser pairing
ps := r.server.pairingService
// Path 3a: Reconnecting with a previously-paired sender_id
if ps != nil && params.SenderID != "" {
paired, pairErr := ps.IsPaired(ctx, params.SenderID, "browser")
if pairErr != nil {
slog.Warn("security.pairing_check_failed",
"sender_id", params.SenderID, "error", pairErr)
// Fail-closed: deny access on DB error instead of granting operator role.
locale := i18n.Normalize(client.locale)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal,
i18n.T(locale, i18n.MsgInternalError, pairErr.Error())))
return
}
if paired {
client.authenticated = true
client.userID = params.UserID
client.pairedSenderID = params.SenderID
client.pairedChannel = "browser"
tid, errCode := r.resolveTenantHint(ctx, params.TenantHint, params.UserID)
if errCode != "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked"))
return
}
// When the caller didn't pass a tenant hint and resolution fell
// back to master, try to infer a working tenant from the user's
// memberships. A single membership is unambiguous; with multiple,
// require an explicit hint and stay on master.
hint := params.TenantHint
if hint == "" {
hint = params.TenantID
}
if hint == "" && tid == store.MasterTenantID && r.tenantStore != nil && params.UserID != "" {
if memberships, err := r.tenantStore.ListUserTenants(ctx, params.UserID); err == nil && len(memberships) == 1 {
tid = memberships[0].TenantID
}
}
client.tenantID = tid
// Derive the gateway role from the user's tenant_users.role for
// the resolved tenant. Falling back to RoleOperator preserves the
// pre-3.11 behaviour for users without a tenant membership row.
client.role = permissions.RoleOperator
if r.tenantStore != nil && params.UserID != "" {
if tRole, _ := r.getUserTenantRole(ctx, tid, params.UserID); tRole != "" {
client.role = permissions.RoleFromTenantRole(tRole)
}
}
slog.Info("browser pairing authenticated",
"sender_id", params.SenderID,
"client", client.id,
"tenant_id", client.tenantID,
"role", string(client.role),
)
r.sendConnectResponse(ctx, client, req.ID)
return
}
}
// Path 3b: No token, no valid pairing → initiate browser pairing (if service available)
if ps != nil && params.Token == "" {
code, err := ps.RequestPairing(ctx, client.id, "browser", "", "default", nil)
if err != nil {
slog.Warn("browser pairing request failed", "error", err, "client", client.id)
// Fall through to viewer role
} else {
client.pairingCode = code
client.pairingPending = true
// Not authenticated — can only call browser.pairing.status
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"protocol": protocol.ProtocolVersion,
"status": "pending_pairing",
"pairing_code": code,
"sender_id": client.id,
"server": map[string]any{
"name": "goclaw",
"version": r.server.version,
},
}))
return
}
}
// Path 4: Fail-closed — no valid token, no valid pairing → reject.
// Previously fell back to viewer+authenticated=true, which allowed any
// unauthenticated client to exercise the default-permit policy (CVE #866).
slog.Warn("security.ws_connect_rejected",
"reason", "no_valid_credentials",
"client", client.id,
"has_token", params.Token != "",
"has_sender_id", params.SenderID != "",
)
client.authenticated = false
locale := i18n.Normalize(client.locale)
client.SendResponse(protocol.NewErrorResponse(
req.ID,
protocol.ErrUnauthorized,
i18n.T(locale, i18n.MsgPermissionDenied, "valid token or active pairing required"),
))
}
func (r *MethodRouter) sendConnectResponse(ctx context.Context, client *Client, reqID string) {
// Now that the client is authenticated, promote the upgrade-request URL
// into the gateway-wide PublicURLSnapshot. RPC methods that advertise URLs
// back to external systems (e.g. bitrix.portals.create) read from this
// snapshot. Gating on authentication is what blocks the
// `Host: evil.com /health` poisoning vector — unauthenticated probes
// never make it this far.
//
// SetIfPublic additionally skips loopback/private hosts so a developer
// connecting via an SSH tunnel (Host=localhost:NNNN) doesn't pollute the
// snapshot for other admins on the public URL.
if r.server.publicURLSnapshot != nil {
if url := client.UpgradeURL(); url != "" {
if !r.server.publicURLSnapshot.SetIfPublic(url) {
slog.Debug("public_url snapshot skipped: non-public upgrade host",
"url", url, "user_id", client.UserID())
}
}
}
// Build scoped ctx that store.IsMasterScope expects: role + tenant.
// Owner role short-circuits regardless of tenant; non-owner relies on
// tenant_id == MasterTenantID. See store.IsMasterScope at context.go:346.
scopedCtx := store.WithTenantID(ctx, client.tenantID)
if client.IsOwner() {
scopedCtx = store.WithRole(scopedCtx, store.RoleOwner)
}
resp := map[string]any{
"protocol": protocol.ProtocolVersion,
"role": string(client.role),
"user_id": client.userID,
"tenant_id": client.tenantID.String(),
"is_owner": client.IsOwner(),
"is_master_scope": store.IsMasterScope(scopedCtx),
"edition": edition.Current().Name,
"server": map[string]any{
"name": "goclaw",
"version": r.server.version,
},
}
// Enrich with tenant name/slug if tenant store available and tenant is set
if r.tenantStore != nil && client.tenantID != uuid.Nil {
if t, err := r.tenantStore.GetTenant(ctx, client.tenantID); err == nil && t != nil {
resp["tenant_name"] = t.Name
resp["tenant_slug"] = t.Slug
client.tenantName = t.Name
client.tenantSlug = t.Slug
}
}
client.SendResponse(protocol.NewOKResponse(reqID, resp))
}
// isOwnerID checks if the given user ID is in the configured owner list.
// If no owner IDs configured, only "system" is treated as owner (fail-closed).
func isOwnerID(userID string, ownerIDs []string) bool {
if userID == "" {
return false
}
if len(ownerIDs) == 0 {
return userID == "system"
}
return slices.Contains(ownerIDs, userID)
}
// resolveTenantHint resolves a tenant slug/UUID hint to a UUID with membership validation.
// Non-admin users must be a member of the tenant.
// Returns (uuid.Nil, ErrTenantAccessRevoked) when the user is not a member of the requested tenant.
// Returns (MasterTenantID, "") when no hint is provided.
func (r *MethodRouter) resolveTenantHint(ctx context.Context, hint, userID string) (uuid.UUID, string) {
if hint == "" || r.tenantStore == nil {
return store.MasterTenantID, ""
}
t, err := r.tenantStore.GetTenantBySlug(ctx, hint)
if err != nil || t == nil {
slog.Debug("tenant_hint not resolved, falling back to master", "hint", hint)
return store.MasterTenantID, ""
}
// Validate membership: user must belong to the requested tenant.
// Deny tenant access for anonymous users (no userID) — fail-closed.
if userID == "" {
slog.Warn("security.tenant_hint_denied_anonymous", "hint", hint, "tenant_id", t.ID)
return uuid.Nil, protocol.ErrTenantAccessRevoked
}
role, err := r.getUserTenantRole(ctx, t.ID, userID)
if err != nil || role == "" {
slog.Warn("security.tenant_access_revoked",
"hint", hint, "user", userID, "tenant_id", t.ID, "error", err)
return uuid.Nil, protocol.ErrTenantAccessRevoked
}
return t.ID, ""
}
// getUserTenantRole returns the user's role in a tenant, using permission cache if available.
func (r *MethodRouter) getUserTenantRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) {
// Check cache first
if r.permCache != nil {
if role, ok := r.permCache.GetTenantRole(ctx, tenantID, userID); ok {
slog.Debug("perm_cache.tenant_role.hit", "tenant", tenantID, "user", userID, "role", role)
return role, nil
}
slog.Debug("perm_cache.tenant_role.miss", "tenant", tenantID, "user", userID)
}
// Fallback to DB
role, err := r.tenantStore.GetUserRole(ctx, tenantID, userID)
if err != nil {
return "", err
}
// Cache the result (including empty role = not a member)
if r.permCache != nil {
r.permCache.SetTenantRole(ctx, tenantID, userID, role)
}
return role, nil
}
// applyTenantScope narrows an owner client's data scope to a specific tenant.
// Sets client.tenantID so the router injects WithTenantID for data filtering.
// Accepts both UUID and slug values.
func (r *MethodRouter) applyTenantScope(ctx context.Context, client *Client, tenantVal string) {
if tenantVal == "" || r.tenantStore == nil {
return
}
var t *store.TenantData
var err error
if tid, parseErr := uuid.Parse(tenantVal); parseErr == nil {
t, err = r.tenantStore.GetTenant(ctx, tid)
} else {
t, err = r.tenantStore.GetTenantBySlug(ctx, tenantVal)
}
if err != nil || t == nil {
slog.Debug("tenant scope not resolved, keeping master", "value", tenantVal)
return
}
client.tenantID = t.ID
slog.Info("tenant scope applied", "client", client.id, "tenant", t.Slug, "tenant_id", t.ID)
}
func (r *MethodRouter) handleHealth(ctx context.Context, client *Client, req *protocol.RequestFrame) {
s := r.server
uptimeMs := time.Since(s.startedAt).Milliseconds()
mode := "managed"
// Database status (real ping)
dbStatus := "n/a"
if s.db != nil {
pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := s.db.PingContext(pingCtx); err != nil {
dbStatus = "error"
} else {
dbStatus = "ok"
}
}
// Connected clients list
type clientInfo struct {
ID string `json:"id"`
RemoteAddr string `json:"remoteAddr"`
UserID string `json:"userId"`
Role string `json:"role"`
ConnectedAt string `json:"connectedAt"`
}
clients := s.ClientList()
clientList := make([]clientInfo, 0, len(clients))
for _, c := range clients {
clientList = append(clientList, clientInfo{
ID: c.ID(),
RemoteAddr: c.RemoteAddr(),
UserID: c.UserID(),
Role: string(c.Role()),
ConnectedAt: c.ConnectedAt().UTC().Format(time.RFC3339),
})
}
// Tool count
toolCount := 0
if s.tools != nil {
toolCount = s.tools.Count()
}
resp := map[string]any{
"status": "ok",
"version": s.version,
"uptime": uptimeMs,
"mode": mode,
"database": dbStatus,
"tools": toolCount,
"clients": clientList,
"currentId": client.ID(),
}
if s.updateChecker != nil {
if info := s.updateChecker.Info(); info != nil {
resp["latestVersion"] = info.LatestVersion
resp["updateAvailable"] = info.UpdateAvailable
resp["updateUrl"] = info.UpdateURL
resp["releaseNotes"] = info.ReleaseNotes
}
}
client.SendResponse(protocol.NewOKResponse(req.ID, resp))
}
func (r *MethodRouter) handleStatus(ctx context.Context, client *Client, req *protocol.RequestFrame) {
agents := r.server.agents.ListInfo()
sessionCount := 0
if r.server.sessions != nil {
sessionCount = len(r.server.sessions.List(ctx, ""))
}
// Agents are lazily resolved — router only has loaded agents.
// Query the DB store for the real total count.
agentTotal := len(agents)
if r.server.agentStore != nil {
if dbAgents, err := r.server.agentStore.List(ctx, ""); err == nil && len(dbAgents) > agentTotal {
agentTotal = len(dbAgents)
}
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"agents": agents,
"agentTotal": agentTotal,
"clients": len(r.server.clients),
"sessions": sessionCount,
}))
}