mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 22:17:36 +00:00
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:
1 parent
3a4d0a762a
commit
ce4535f305
4 files changed
+105
-7
No files matched your search
@@ -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
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user