feat(auth): inherit tenant_users.role for paired sessions (#1066)

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 <anhnq0@gmail.com>
This commit is contained in:
Quoc-Anh Nguyen authored and GitHub committed 2026-06-26 22:55:17 +07:00
1 parent 3a4d0a762a
commit ce4535f305
4 files changed
+105 -7

No files matched your search

+28 -2
View File
@@ -259,7 +259,6 @@ 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.pairedSenderID = params.SenderID
@@ -269,8 +268,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
}
+20 -3
View File
@@ -219,16 +219,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),
+29 -2
View File
@@ -391,14 +391,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()
+28
View File
@@ -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) {