Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions internal/gateway/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,18 +258,44 @@ 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)
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
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
}
Expand Down
23 changes: 20 additions & 3 deletions internal/http/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
31 changes: 29 additions & 2 deletions internal/http/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
28 changes: 28 additions & 0 deletions internal/permissions/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down