From 1afc89eedf809eca0cddf6dcce8fd2050fedf95f Mon Sep 17 00:00:00 2001 From: imcvampire Date: Wed, 29 Apr 2026 11:38:29 +0300 Subject: [PATCH] feat(auth): inherit tenant_users.role for paired sessions Both WS pairing (router.go Path 3a) and HTTP X-GoClaw-Sender-Id auth were pinned to RoleOperator. Now they map tenant_users.role -> permissions.Role (owner/admin/operator|member/viewer) so a tenant admin who logs in via pairing actually gets admin in the dashboard. Falls back to RoleOperator when the user has no membership row (backward compatible). Tenant is inferred from a single membership when no hint is provided. Signed-off-by: imcvampire --- internal/gateway/router.go | 32 +++++++++++++++++++++++++++++--- internal/http/auth.go | 23 ++++++++++++++++++++--- internal/http/auth_test.go | 31 +++++++++++++++++++++++++++++-- internal/permissions/policy.go | 28 ++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/internal/gateway/router.go b/internal/gateway/router.go index 15eb69287e..b2373297c4 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -258,9 +258,8 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p return } if paired { - client.role = permissions.RoleOperator client.authenticated = true - client.userID = params.UserID + client.userID = params.UserID client.pairedSenderID = params.SenderID client.pairedChannel = "browser" tid, errCode := r.resolveTenantHint(ctx, params.TenantHint, params.UserID) @@ -268,8 +267,35 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p 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 - slog.Info("browser pairing authenticated", "sender_id", params.SenderID, "client", client.id, "tenant_id", client.tenantID) + // 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 } diff --git a/internal/http/auth.go b/internal/http/auth.go index d6edcd5cea..1cda4e8a9e 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -212,16 +212,33 @@ func resolveAuthWithBearer(r *http.Request, bearer string) authResult { } return res } - // Browser pairing → operator (via X-GoClaw-Sender-Id header) + // Browser pairing → role derived from tenant_users.role (via + // X-GoClaw-Sender-Id header). Falls back to RoleOperator when the user + // has no membership row, preserving pre-3.11 behaviour. if senderID := r.Header.Get("X-GoClaw-Sender-Id"); senderID != "" && pkgPairingStore != nil { paired, err := pkgPairingStore.IsPaired(r.Context(), senderID, "browser") if err == nil && paired { - tenantID, allowed := resolveTenantHint(r.Context(), r.Header.Get("X-GoClaw-Tenant-Id"), extractUserID(r)) + userID := extractUserID(r) + hint := r.Header.Get("X-GoClaw-Tenant-Id") + tenantID, allowed := resolveTenantHint(r.Context(), hint, userID) if !allowed { return authResult{} } + // No hint and resolution fell back to master: infer the user's + // working tenant when they have exactly one membership. + if hint == "" && tenantID == store.MasterTenantID && pkgTenantCache != nil && userID != "" { + if memberships, err := pkgTenantCache.store.ListUserTenants(r.Context(), userID); err == nil && len(memberships) == 1 { + tenantID = memberships[0].TenantID + } + } + role := permissions.RoleOperator + if pkgTenantCache != nil && userID != "" { + if tRole, err := pkgTenantCache.store.GetUserRole(r.Context(), tenantID, userID); err == nil && tRole != "" { + role = permissions.RoleFromTenantRole(tRole) + } + } return authResult{ - Role: permissions.RoleOperator, + Role: role, Authenticated: true, TenantID: tenantID, TenantSlug: resolveTenantSlug(r.Context(), tenantID), diff --git a/internal/http/auth_test.go b/internal/http/auth_test.go index bc2869e81a..49b5cc6a9a 100644 --- a/internal/http/auth_test.go +++ b/internal/http/auth_test.go @@ -370,14 +370,41 @@ func TestResolveAuth_BrowserPairingScopesToMemberTenant(t *testing.T) { if !auth.Authenticated { t.Fatal("expected authenticated") } - if auth.Role != permissions.RoleOperator { - t.Fatalf("role = %v, want operator", auth.Role) + // Paired session inherits the user's tenant_users.role for the resolved + // tenant: admin in acme → permissions.RoleAdmin (not the legacy hard-coded + // RoleOperator). + if auth.Role != permissions.RoleAdmin { + t.Fatalf("role = %v, want admin", auth.Role) } if auth.TenantID != tenantID { t.Fatalf("tenantID = %v, want %v", auth.TenantID, tenantID) } } +func TestResolveAuth_BrowserPairingFallsBackToOperatorWithoutMembership(t *testing.T) { + setupTestToken(t, "gateway-token") + ps := newMockPairingStore() + ps.paired["browser-1:browser"] = true + setupTestPairingStore(t, ps) + ts := newMockTenantStore() + setupTestTenantStore(t, ts) + + r := httptest.NewRequest("GET", "/v1/agents", nil) + r.Header.Set("X-GoClaw-Sender-Id", "browser-1") + r.Header.Set("X-GoClaw-User-Id", "user-1") + + auth := resolveAuth(r) + if !auth.Authenticated { + t.Fatal("expected authenticated") + } + // No membership row: preserve the pre-3.11 RoleOperator default so this + // change is backward compatible for users that pair without a tenant + // membership. + if auth.Role != permissions.RoleOperator { + t.Fatalf("role = %v, want operator (legacy fallback)", auth.Role) + } +} + func TestResolveAuth_BrowserPairingRejectsUnauthorizedTenantScope(t *testing.T) { setupTestToken(t, "gateway-token") ps := newMockPairingStore() diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go index 9c75d61df4..7bc0eff0af 100644 --- a/internal/permissions/policy.go +++ b/internal/permissions/policy.go @@ -124,6 +124,34 @@ func (pe *PolicyEngine) CanAccessWithScopes(scopes []Scope, method string) bool 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) {