chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,955 @@
|
||||
import { db } from '@sim/db'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
checkWorkspaceAccess,
|
||||
getManageableWorkspaces,
|
||||
getUserEntityPermissions,
|
||||
getUsersWithPermissions,
|
||||
getWorkspaceById,
|
||||
getWorkspaceWithOwner,
|
||||
hasWorkspaceAdminAccess,
|
||||
workspaceExists,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const mockDb = db as any
|
||||
type PermissionType = 'admin' | 'write' | 'read'
|
||||
|
||||
function createMockChain(finalResult: any) {
|
||||
const chain: any = {}
|
||||
|
||||
chain.then = vi.fn().mockImplementation((resolve: any) => resolve(finalResult))
|
||||
chain.select = vi.fn().mockReturnValue(chain)
|
||||
chain.from = vi.fn().mockReturnValue(chain)
|
||||
chain.where = vi.fn().mockReturnValue(chain)
|
||||
chain.limit = vi.fn().mockReturnValue(chain)
|
||||
chain.innerJoin = vi.fn().mockReturnValue(chain)
|
||||
chain.leftJoin = vi.fn().mockReturnValue(chain)
|
||||
chain.orderBy = vi.fn().mockReturnValue(chain)
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
describe('Permission Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getUserEntityPermissions', () => {
|
||||
it('should return null when user has no permissions', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return the highest permission when user has multiple permissions', async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
{ permissionType: 'admin' as PermissionType },
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow456')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it('should return single permission when user has only one', async () => {
|
||||
const mockResults = [{ permissionType: 'read' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow789')
|
||||
|
||||
expect(result).toBe('read')
|
||||
})
|
||||
|
||||
it('should prioritize admin over other permissions', async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
{ permissionType: 'admin' as PermissionType },
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user999', 'workflow', 'workflow999')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it('should return write permission when user only has write access', async () => {
|
||||
const mockResults = [{ permissionType: 'write' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456')
|
||||
|
||||
expect(result).toBe('write')
|
||||
})
|
||||
|
||||
it('should prioritize write over read permissions', async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow456')
|
||||
|
||||
expect(result).toBe('write')
|
||||
})
|
||||
|
||||
it('should work with workflow entity type', async () => {
|
||||
const mockResults = [{ permissionType: 'admin' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow789')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it('should work with organization entity type', async () => {
|
||||
const mockResults = [{ permissionType: 'read' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'organization', 'org456')
|
||||
|
||||
expect(result).toBe('read')
|
||||
})
|
||||
|
||||
it('should handle generic entity types', async () => {
|
||||
const mockResults = [{ permissionType: 'write' as PermissionType }]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'custom_entity', 'entity123')
|
||||
|
||||
expect(result).toBe('write')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsersWithPermissions', () => {
|
||||
function mockSelectSequence(results: any[][]) {
|
||||
let index = 0
|
||||
mockDb.select.mockImplementation(() => createMockChain(results[index++] ?? []))
|
||||
}
|
||||
|
||||
const joinedAt = new Date('2026-04-22T00:00:00.000Z')
|
||||
|
||||
it('should return empty array when the workspace does not exist', async () => {
|
||||
mockSelectSequence([[]])
|
||||
|
||||
const result = await getUsersWithPermissions('workspace123')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return users with their explicit permissions for a personal workspace', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'workspace456', ownerId: 'owner-user', organizationId: null }],
|
||||
[
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice Smith',
|
||||
image: 'https://example.com/alice.png',
|
||||
permissionType: 'admin' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('workspace456')
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice Smith',
|
||||
image: 'https://example.com/alice.png',
|
||||
permissionType: 'admin',
|
||||
isExternal: false,
|
||||
joinedAt: '2026-04-22T00:00:00.000Z',
|
||||
roleSource: 'explicit',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('tags the workspace owner with roleSource owner', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'workspace456', ownerId: 'user1', organizationId: null }],
|
||||
[
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'owner@example.com',
|
||||
name: 'Owner',
|
||||
image: null,
|
||||
permissionType: 'admin' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('workspace456')
|
||||
|
||||
expect(result[0].roleSource).toBe('owner')
|
||||
})
|
||||
|
||||
it('merges organization admins as derived workspace admins', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'owner-user', organizationId: 'org-1' }],
|
||||
[
|
||||
{
|
||||
userId: 'member-user',
|
||||
email: 'member@example.com',
|
||||
name: 'Member',
|
||||
image: null,
|
||||
permissionType: 'read' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: 'org-1',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
userId: 'org-admin-user',
|
||||
email: 'orgadmin@example.com',
|
||||
name: 'Org Admin',
|
||||
image: null,
|
||||
joinedAt,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('ws')
|
||||
const orgAdmin = result.find((u) => u.userId === 'org-admin-user')
|
||||
|
||||
expect(orgAdmin).toMatchObject({
|
||||
permissionType: 'admin',
|
||||
roleSource: 'org-admin',
|
||||
isExternal: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('marks users as external when they are not members of the workspace organization', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'internal-user', organizationId: 'org-1' }],
|
||||
[
|
||||
{
|
||||
userId: 'internal-user',
|
||||
email: 'internal@example.com',
|
||||
name: 'Internal User',
|
||||
image: null,
|
||||
permissionType: 'admin' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: 'org-1',
|
||||
},
|
||||
{
|
||||
userId: 'external-user',
|
||||
email: 'external@example.com',
|
||||
name: 'External User',
|
||||
image: null,
|
||||
permissionType: 'write' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: 'org-2',
|
||||
},
|
||||
],
|
||||
[],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('ws')
|
||||
const byEmail = new Map(result.map((u) => [u.email, u.isExternal]))
|
||||
|
||||
expect(byEmail.get('internal@example.com')).toBe(false)
|
||||
expect(byEmail.get('external@example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('marks a non-owner member of another org as external on a personal workspace', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'owner-user', organizationId: null }],
|
||||
[
|
||||
{
|
||||
userId: 'owner-user',
|
||||
email: 'owner@example.com',
|
||||
name: 'Owner',
|
||||
image: null,
|
||||
permissionType: 'admin' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
{
|
||||
userId: 'guest-user',
|
||||
email: 'guest@example.com',
|
||||
name: 'Guest',
|
||||
image: null,
|
||||
permissionType: 'write' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: 'org-guest',
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('workspace-personal')
|
||||
const byEmail = new Map(result.map((u) => [u.email, u.isExternal]))
|
||||
|
||||
expect(byEmail.get('owner@example.com')).toBe(false)
|
||||
expect(byEmail.get('guest@example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return multiple users sorted by email', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'owner-user', organizationId: null }],
|
||||
[
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'a-admin@example.com',
|
||||
name: 'Admin User',
|
||||
image: null,
|
||||
permissionType: 'admin' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
{
|
||||
userId: 'user2',
|
||||
email: 'b-writer@example.com',
|
||||
name: 'Writer User',
|
||||
image: null,
|
||||
permissionType: 'write' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
{
|
||||
userId: 'user3',
|
||||
email: 'c-reader@example.com',
|
||||
name: 'Reader User',
|
||||
image: null,
|
||||
permissionType: 'read' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('workspace456')
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].permissionType).toBe('admin')
|
||||
expect(result[1].permissionType).toBe('write')
|
||||
expect(result[2].permissionType).toBe('read')
|
||||
})
|
||||
|
||||
it('should handle users with empty names', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'owner-user', organizationId: null }],
|
||||
[
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'test@example.com',
|
||||
name: '',
|
||||
image: null,
|
||||
permissionType: 'read' as PermissionType,
|
||||
joinedAt,
|
||||
userOrganizationId: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await getUsersWithPermissions('workspace123')
|
||||
|
||||
expect(result[0].name).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasWorkspaceAdminAccess', () => {
|
||||
it('should return true for the workspace owner via their explicit admin row', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'user123' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'admin' }])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when user has direct admin permission', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'admin' }])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when user has no admin access', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when user has write permission but not admin', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when user has read permission but not admin', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty workspace ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', '')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty user ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases and Security Tests', () => {
|
||||
it('should handle SQL injection attempts in user IDs', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions(
|
||||
"'; DROP TABLE users; --",
|
||||
'workspace',
|
||||
'workspace123'
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle very long entity IDs', async () => {
|
||||
const longEntityId = 'a'.repeat(1000)
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workspace', longEntityId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle unicode characters in entity names', async () => {
|
||||
const chain = createMockChain([{ permissionType: 'read' as PermissionType }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', '📝workspace', '🏢org-id')
|
||||
|
||||
expect(result).toBe('read')
|
||||
})
|
||||
|
||||
it('should verify permission hierarchy ordering is consistent', () => {
|
||||
const permissionOrder: Record<PermissionType, number> = { admin: 3, write: 2, read: 1 }
|
||||
|
||||
expect(permissionOrder.admin).toBeGreaterThan(permissionOrder.write)
|
||||
expect(permissionOrder.write).toBeGreaterThan(permissionOrder.read)
|
||||
})
|
||||
|
||||
it('should handle workspace ownership checks with null owner IDs', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: null }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle null user ID correctly when owner ID is different', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await hasWorkspaceAdminAccess(null as any, 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getManageableWorkspaces', () => {
|
||||
it('should return empty array when user has no manageable workspaces', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return owned workspaces', async () => {
|
||||
const mockWorkspaces = [
|
||||
{ id: 'ws1', name: 'My Workspace 1', ownerId: 'user123' },
|
||||
{ id: 'ws2', name: 'My Workspace 2', ownerId: 'user123' },
|
||||
]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain(mockWorkspaces) // Owned workspaces
|
||||
}
|
||||
return createMockChain([]) // No admin workspaces
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: 'ws1', name: 'My Workspace 1', ownerId: 'user123', accessType: 'owner' },
|
||||
{ id: 'ws2', name: 'My Workspace 2', ownerId: 'user123', accessType: 'owner' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should return workspaces with direct admin permissions', async () => {
|
||||
const mockAdminWorkspaces = [{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user' }]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([]) // No owned workspaces
|
||||
}
|
||||
return createMockChain(mockAdminWorkspaces) // Admin workspaces
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user', accessType: 'direct' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should combine owned and admin workspaces without duplicates', async () => {
|
||||
const mockOwnedWorkspaces = [
|
||||
{ id: 'ws1', name: 'My Workspace', ownerId: 'user123' },
|
||||
{ id: 'ws2', name: 'Another Workspace', ownerId: 'user123' },
|
||||
]
|
||||
const mockAdminWorkspaces = [
|
||||
{ id: 'ws1', name: 'My Workspace', ownerId: 'user123' }, // Duplicate (should be filtered)
|
||||
{ id: 'ws3', name: 'Shared Workspace', ownerId: 'other-user' },
|
||||
]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain(mockOwnedWorkspaces) // Owned workspaces
|
||||
}
|
||||
return createMockChain(mockAdminWorkspaces) // Admin workspaces
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result).toEqual([
|
||||
{ id: 'ws1', name: 'My Workspace', ownerId: 'user123', accessType: 'owner' },
|
||||
{ id: 'ws2', name: 'Another Workspace', ownerId: 'user123', accessType: 'owner' },
|
||||
{ id: 'ws3', name: 'Shared Workspace', ownerId: 'other-user', accessType: 'direct' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle empty workspace names', async () => {
|
||||
const mockWorkspaces = [{ id: 'ws1', name: '', ownerId: 'user123' }]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain(mockWorkspaces)
|
||||
}
|
||||
return createMockChain([])
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result[0].name).toBe('')
|
||||
})
|
||||
|
||||
it('should handle multiple admin permissions for same workspace', async () => {
|
||||
const mockAdminWorkspaces = [
|
||||
{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user' },
|
||||
{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user' }, // Duplicate
|
||||
]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([]) // No owned workspaces
|
||||
}
|
||||
return createMockChain(mockAdminWorkspaces) // Admin workspaces with duplicates
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle empty user ID gracefully', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getManageableWorkspaces('')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkspaceById', () => {
|
||||
it.concurrent('should return workspace when it exists', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceById('workspace123')
|
||||
|
||||
expect(result).toEqual({ id: 'workspace123' })
|
||||
})
|
||||
|
||||
it.concurrent('should return null when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceById('non-existent')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty workspace ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceById('')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkspaceWithOwner', () => {
|
||||
it.concurrent('should return workspace with owner when it exists', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123', ownerId: 'owner456' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceWithOwner('workspace123')
|
||||
|
||||
expect(result).toEqual({ id: 'workspace123', ownerId: 'owner456' })
|
||||
})
|
||||
|
||||
it.concurrent('should return null when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceWithOwner('non-existent')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('should handle workspace with null owner ID', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123', ownerId: null }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceWithOwner('workspace123')
|
||||
|
||||
expect(result).toEqual({ id: 'workspace123', ownerId: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceExists', () => {
|
||||
it.concurrent('should return true when workspace exists', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await workspaceExists('workspace123')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return false when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await workspaceExists('non-existent')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty workspace ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await workspaceExists('')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkWorkspaceAccess', () => {
|
||||
it('should return exists=false when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await checkWorkspaceAccess('non-existent', 'user123')
|
||||
|
||||
expect(result).toEqual({
|
||||
exists: false,
|
||||
hasAccess: false,
|
||||
canWrite: false,
|
||||
canAdmin: false,
|
||||
workspace: null,
|
||||
permission: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return full access for the workspace owner via their explicit admin row', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'user123' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'admin' }])
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result).toEqual({
|
||||
exists: true,
|
||||
hasAccess: true,
|
||||
canWrite: true,
|
||||
canAdmin: true,
|
||||
workspace: { id: 'workspace123', ownerId: 'user123' },
|
||||
permission: 'admin',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return hasAccess=false when user has no permissions', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([]) // No permissions
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result.exists).toBe(true)
|
||||
expect(result.hasAccess).toBe(false)
|
||||
expect(result.canWrite).toBe(false)
|
||||
})
|
||||
|
||||
it('should return canWrite=true when user has admin permission', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'admin' }])
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result.exists).toBe(true)
|
||||
expect(result.hasAccess).toBe(true)
|
||||
expect(result.canWrite).toBe(true)
|
||||
})
|
||||
|
||||
it('should return canWrite=true when user has write permission', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'write' }])
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result.exists).toBe(true)
|
||||
expect(result.hasAccess).toBe(true)
|
||||
expect(result.canWrite).toBe(true)
|
||||
})
|
||||
|
||||
it('should return canWrite=false when user has read permission', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'read' }])
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result.exists).toBe(true)
|
||||
expect(result.hasAccess).toBe(true)
|
||||
expect(result.canWrite).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty user ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', '')
|
||||
|
||||
expect(result.exists).toBe(false)
|
||||
expect(result.hasAccess).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty workspace ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await checkWorkspaceAccess('', 'user123')
|
||||
|
||||
expect(result.exists).toBe(false)
|
||||
expect(result.hasAccess).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('organization admin inheritance', () => {
|
||||
function mockSelectSequence(results: any[][]) {
|
||||
let index = 0
|
||||
mockDb.select.mockImplementation(() => createMockChain(results[index++] ?? []))
|
||||
}
|
||||
|
||||
it('checkWorkspaceAccess grants admin to org admins without an explicit row', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'other-user', organizationId: 'org-1' }],
|
||||
[],
|
||||
[{ role: 'admin' }],
|
||||
])
|
||||
|
||||
const result = await checkWorkspaceAccess('ws', 'org-admin-user')
|
||||
|
||||
expect(result.hasAccess).toBe(true)
|
||||
expect(result.canWrite).toBe(true)
|
||||
expect(result.canAdmin).toBe(true)
|
||||
})
|
||||
|
||||
it('getUserEntityPermissions returns admin for an org owner without an explicit row', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'other-user', organizationId: 'org-1' }],
|
||||
[],
|
||||
[{ role: 'owner' }],
|
||||
])
|
||||
|
||||
const result = await getUserEntityPermissions('org-owner-user', 'workspace', 'ws')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it('hasWorkspaceAdminAccess is true for an org admin of the workspace org', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'other-user', organizationId: 'org-1' }],
|
||||
[],
|
||||
[{ role: 'admin' }],
|
||||
])
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('org-admin-user', 'ws')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('does not elevate a plain org member', async () => {
|
||||
mockSelectSequence([
|
||||
[{ id: 'ws', ownerId: 'other-user', organizationId: 'org-1' }],
|
||||
[],
|
||||
[{ role: 'member' }],
|
||||
])
|
||||
|
||||
const result = await checkWorkspaceAccess('ws', 'org-member-user')
|
||||
|
||||
expect(result.hasAccess).toBe(false)
|
||||
expect(result.canAdmin).toBe(false)
|
||||
})
|
||||
|
||||
it('does not elevate org admins on a workspace with no organization', async () => {
|
||||
mockSelectSequence([[{ id: 'ws', ownerId: 'other-user', organizationId: null }], []])
|
||||
|
||||
const result = await checkWorkspaceAccess('ws', 'some-user')
|
||||
|
||||
expect(result.hasAccess).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,541 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissions, user, type WorkspaceMode, workspace } from '@sim/db/schema'
|
||||
import {
|
||||
isOrgAdminRole,
|
||||
ORG_ADMIN_ROLES,
|
||||
PERMISSION_RANK,
|
||||
type PermissionType,
|
||||
permissionSatisfies,
|
||||
resolveEffectiveWorkspacePermission,
|
||||
} from '@sim/platform-authz/workspace'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { HttpError } from '@/lib/core/utils/http-error'
|
||||
import { getOrgAdminWorkspaceRows } from '@/lib/workspaces/utils'
|
||||
|
||||
export type { PermissionType }
|
||||
export interface WorkspaceBasic {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface WorkspaceWithOwner {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
organizationId: string | null
|
||||
workspaceMode: WorkspaceMode
|
||||
billedAccountUserId: string
|
||||
archivedAt?: Date | null
|
||||
}
|
||||
|
||||
export interface WorkspaceAccess {
|
||||
exists: boolean
|
||||
hasAccess: boolean
|
||||
canWrite: boolean
|
||||
canAdmin: boolean
|
||||
workspace: WorkspaceWithOwner | null
|
||||
/** The viewer's raw effective permission, or `null` when the workspace doesn't exist or they have none. */
|
||||
permission: PermissionType | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a workspace exists
|
||||
*
|
||||
* @param workspaceId - The workspace ID to check
|
||||
* @returns True if the workspace exists, false otherwise
|
||||
*/
|
||||
export async function workspaceExists(
|
||||
workspaceId: string,
|
||||
options?: { includeArchived?: boolean }
|
||||
): Promise<boolean> {
|
||||
const { includeArchived = false } = options ?? {}
|
||||
const [ws] = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(
|
||||
includeArchived
|
||||
? eq(workspace.id, workspaceId)
|
||||
: and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return !!ws
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a workspace by ID for existence check
|
||||
*
|
||||
* @param workspaceId - The workspace ID to look up
|
||||
* @returns The workspace if found, null otherwise
|
||||
*/
|
||||
export async function getWorkspaceById(
|
||||
workspaceId: string,
|
||||
options?: { includeArchived?: boolean }
|
||||
): Promise<WorkspaceBasic | null> {
|
||||
const exists = await workspaceExists(workspaceId, options)
|
||||
return exists ? { id: workspaceId } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a workspace with owner info by ID
|
||||
*
|
||||
* @param workspaceId - The workspace ID to look up
|
||||
* @returns The workspace with owner info if found, null otherwise
|
||||
*/
|
||||
export async function getWorkspaceWithOwner(
|
||||
workspaceId: string,
|
||||
options?: { includeArchived?: boolean }
|
||||
): Promise<WorkspaceWithOwner | null> {
|
||||
const { includeArchived = false } = options ?? {}
|
||||
const [ws] = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
organizationId: workspace.organizationId,
|
||||
workspaceMode: workspace.workspaceMode,
|
||||
billedAccountUserId: workspace.billedAccountUserId,
|
||||
archivedAt: workspace.archivedAt,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(
|
||||
includeArchived
|
||||
? eq(workspace.id, workspaceId)
|
||||
: and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return ws || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective workspace permission for a user under the governance
|
||||
* inheritance model: the owners/admins of the organization that owns the
|
||||
* workspace are derived workspace admins. Returns the higher of any explicit
|
||||
* grant and the org-admin derivation. The workspace owner is not special-cased —
|
||||
* they always hold an explicit `admin` row, so the resolver's lookup covers them.
|
||||
*
|
||||
* Delegates to the shared resolver in `@sim/platform-authz/workspace` so the
|
||||
* rule has a single source of truth shared with the realtime server.
|
||||
*
|
||||
* @param userId - The user to resolve the permission for
|
||||
* @param ws - The workspace (organization already loaded)
|
||||
*/
|
||||
export async function getEffectiveWorkspacePermission(
|
||||
userId: string,
|
||||
ws: Pick<WorkspaceWithOwner, 'id' | 'organizationId'>
|
||||
): Promise<PermissionType | null> {
|
||||
return resolveEffectiveWorkspacePermission(userId, ws.id, ws.organizationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check workspace access for a user
|
||||
*
|
||||
* Verifies the workspace exists and the user has access to it.
|
||||
* Returns access level (read/write) based on ownership, explicit permissions,
|
||||
* and organization-admin inheritance.
|
||||
*
|
||||
* @param workspaceId - The workspace ID to check
|
||||
* @param userId - The user ID to check access for
|
||||
* @returns WorkspaceAccess object with exists, hasAccess, canWrite, and workspace data
|
||||
*/
|
||||
export async function checkWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspaceAccess> {
|
||||
const ws = await getWorkspaceWithOwner(workspaceId)
|
||||
|
||||
if (!ws) {
|
||||
return {
|
||||
exists: false,
|
||||
hasAccess: false,
|
||||
canWrite: false,
|
||||
canAdmin: false,
|
||||
workspace: null,
|
||||
permission: null,
|
||||
}
|
||||
}
|
||||
|
||||
const permission = await getEffectiveWorkspacePermission(userId, ws)
|
||||
const hasAccess = permission !== null
|
||||
const canWrite = permissionSatisfies(permission, 'write')
|
||||
const canAdmin = permissionSatisfies(permission, 'admin')
|
||||
|
||||
return { exists: true, hasAccess, canWrite, canAdmin, workspace: ws, permission }
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a user attempts to access a workspace they don't have access to,
|
||||
* or that doesn't exist / has been archived. Carries `statusCode = 403` so the
|
||||
* centralized route wrapper maps it to HTTP 403 instead of defaulting to 500.
|
||||
* The `message` is intentionally client-safe and is exposed to API responses.
|
||||
*/
|
||||
export class WorkspaceAccessDeniedError extends HttpError {
|
||||
readonly statusCode = 403
|
||||
readonly workspaceId: string
|
||||
|
||||
constructor(workspaceId: string) {
|
||||
super(`Workspace access denied: ${workspaceId}`)
|
||||
this.name = 'WorkspaceAccessDeniedError'
|
||||
this.workspaceId = workspaceId
|
||||
}
|
||||
}
|
||||
|
||||
export function isWorkspaceAccessDeniedError(error: unknown): error is WorkspaceAccessDeniedError {
|
||||
return error instanceof WorkspaceAccessDeniedError
|
||||
}
|
||||
|
||||
export async function assertActiveWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspaceAccess> {
|
||||
const access = await checkWorkspaceAccess(workspaceId, userId)
|
||||
if (!access.exists || !access.hasAccess) {
|
||||
throw new WorkspaceAccessDeniedError(workspaceId)
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the highest permission level a user has for a specific entity
|
||||
*
|
||||
* @param userId - The ID of the user to check permissions for
|
||||
* @param entityType - The type of entity (e.g., 'workspace', 'workflow', etc.)
|
||||
* @param entityId - The ID of the specific entity
|
||||
* @returns Promise<PermissionType | null> - The highest permission the user has for the entity, or null if none
|
||||
*/
|
||||
export async function getUserEntityPermissions(
|
||||
userId: string,
|
||||
entityType: string,
|
||||
entityId: string
|
||||
): Promise<PermissionType | null> {
|
||||
if (entityType === 'workspace') {
|
||||
return (await checkWorkspaceAccess(entityId, userId)).permission
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select({ permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, entityType),
|
||||
eq(permissions.entityId, entityId)
|
||||
)
|
||||
)
|
||||
|
||||
if (result.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const highestPermission = result.reduce((highest, current) => {
|
||||
return PERMISSION_RANK[current.permissionType] > PERMISSION_RANK[highest.permissionType]
|
||||
? current
|
||||
: highest
|
||||
})
|
||||
|
||||
return highestPermission.permissionType
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of users with their associated permissions for a given workspace.
|
||||
*
|
||||
* A member is `isExternal` when they hold workspace access but belong to a
|
||||
* different organization than the workspace (or to any organization when the
|
||||
* workspace is personal/grandfathered and has none). The workspace owner is
|
||||
* never external. This mirrors the accept-time `external` membership intent so
|
||||
* the UI tag matches how the member actually joined.
|
||||
*
|
||||
* @param workspaceId - The ID of the workspace to retrieve user permissions for.
|
||||
* @returns A promise that resolves to an array of user objects, each containing user details and their permission type.
|
||||
*/
|
||||
export type MemberRoleSource = 'owner' | 'explicit' | 'org-admin'
|
||||
|
||||
export interface WorkspaceMemberWithRole {
|
||||
userId: string
|
||||
email: string
|
||||
name: string
|
||||
image: string | null
|
||||
permissionType: PermissionType
|
||||
isExternal: boolean
|
||||
joinedAt: string
|
||||
/**
|
||||
* Where the effective role comes from. `org-admin` and `owner` roles are
|
||||
* derived and cannot be changed through the member UI.
|
||||
*/
|
||||
roleSource: MemberRoleSource
|
||||
}
|
||||
|
||||
export async function getUsersWithPermissions(
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceMemberWithRole[]> {
|
||||
const ws = await getWorkspaceWithOwner(workspaceId)
|
||||
if (!ws) return []
|
||||
|
||||
const explicitRows = await db
|
||||
.select({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
permissionType: permissions.permissionType,
|
||||
joinedAt: permissions.createdAt,
|
||||
userOrganizationId: member.organizationId,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.leftJoin(member, eq(member.userId, user.id))
|
||||
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)))
|
||||
|
||||
const byUser = new Map<string, WorkspaceMemberWithRole>()
|
||||
|
||||
for (const row of explicitRows) {
|
||||
const isOwner = row.userId === ws.ownerId
|
||||
byUser.set(row.userId, {
|
||||
userId: row.userId,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
image: row.image ?? null,
|
||||
permissionType: row.permissionType,
|
||||
isExternal: !isOwner && row.userOrganizationId !== ws.organizationId,
|
||||
joinedAt: row.joinedAt.toISOString(),
|
||||
roleSource: isOwner ? 'owner' : 'explicit',
|
||||
})
|
||||
}
|
||||
|
||||
if (ws.organizationId) {
|
||||
const orgAdmins = await db
|
||||
.select({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
joinedAt: member.createdAt,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, ws.organizationId),
|
||||
inArray(member.role, [...ORG_ADMIN_ROLES])
|
||||
)
|
||||
)
|
||||
|
||||
for (const row of orgAdmins) {
|
||||
const isOwner = row.userId === ws.ownerId
|
||||
const existing = byUser.get(row.userId)
|
||||
if (existing) {
|
||||
existing.permissionType = 'admin'
|
||||
existing.isExternal = false
|
||||
if (existing.roleSource !== 'owner') {
|
||||
existing.roleSource = isOwner ? 'owner' : 'org-admin'
|
||||
}
|
||||
} else {
|
||||
byUser.set(row.userId, {
|
||||
userId: row.userId,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
image: row.image ?? null,
|
||||
permissionType: 'admin',
|
||||
isExternal: false,
|
||||
joinedAt: row.joinedAt.toISOString(),
|
||||
roleSource: isOwner ? 'owner' : 'org-admin',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byUser.values()).sort((a, b) => a.email.localeCompare(b.email))
|
||||
}
|
||||
|
||||
/** Lightweight profile data for workspace member display (avatars, owner cells). */
|
||||
export interface WorkspaceMemberProfile {
|
||||
userId: string
|
||||
name: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches minimal profile data (id, name, image) for all members of a workspace.
|
||||
* Use this instead of getUsersWithPermissions when you only need display info.
|
||||
*/
|
||||
export async function getWorkspaceMemberProfiles(
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceMemberProfile[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: user.id,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId),
|
||||
isNull(workspace.archivedAt)
|
||||
)
|
||||
)
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
export interface WorkspacePermissionsForViewer {
|
||||
users: WorkspaceMemberWithRole[]
|
||||
total: number
|
||||
viewer: {
|
||||
userId: string
|
||||
isAdmin: boolean
|
||||
permissionType: PermissionType
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the workspace permissions payload for a viewer: the full member list plus
|
||||
* the viewer's own resolved permission. Shared by `GET /api/workspaces/[id]/permissions`
|
||||
* and the sidebar prefetch so the two never drift.
|
||||
*
|
||||
* @param workspaceId - The workspace ID to build permissions for
|
||||
* @param userId - The viewer's user ID
|
||||
* @returns The permissions payload, or `null` if the workspace doesn't exist or the viewer lacks access
|
||||
*/
|
||||
export async function getWorkspacePermissionsForViewer(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspacePermissionsForViewer | null> {
|
||||
const ws = await getWorkspaceWithOwner(workspaceId)
|
||||
if (!ws) return null
|
||||
|
||||
const permission = await getEffectiveWorkspacePermission(userId, ws)
|
||||
if (permission === null) return null
|
||||
|
||||
const users = await getUsersWithPermissions(workspaceId)
|
||||
|
||||
return {
|
||||
users,
|
||||
total: users.length,
|
||||
viewer: { userId, isAdmin: permission === 'admin', permissionType: permission },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has admin access to a specific workspace
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @param workspaceId - The ID of the workspace to check
|
||||
* @returns Promise<boolean> - True if the user has admin access to the workspace, false otherwise
|
||||
*/
|
||||
export async function hasWorkspaceAdminAccess(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
return (await checkWorkspaceAccess(workspaceId, userId)).canAdmin
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a user is an owner or admin of a specific organization.
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @param organizationId - The ID of the organization to check
|
||||
* @returns Promise<boolean> - True when the user is the organization owner or an admin
|
||||
*/
|
||||
export async function isOrganizationAdminOrOwner(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
return isOrgAdminRole(row?.role)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a user is a member (any role) of a specific organization.
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @param organizationId - The ID of the organization to check
|
||||
* @returns Promise<boolean> - True when the user has an organization membership row
|
||||
*/
|
||||
export async function isOrganizationMember(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
return !!row
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of workspaces that the user has access to
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @returns Promise<Array<{
|
||||
* id: string
|
||||
* name: string
|
||||
* ownerId: string
|
||||
* accessType: 'direct' | 'owner'
|
||||
* }>> - A list of workspaces that the user has access to
|
||||
*/
|
||||
export async function getManageableWorkspaces(userId: string): Promise<
|
||||
Array<{
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
accessType: 'direct' | 'owner'
|
||||
}>
|
||||
> {
|
||||
const ownedWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt)))
|
||||
|
||||
const adminWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
})
|
||||
.from(workspace)
|
||||
.innerJoin(permissions, eq(permissions.entityId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
isNull(workspace.archivedAt),
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.permissionType, 'admin')
|
||||
)
|
||||
)
|
||||
|
||||
const orgAdminWorkspaces = (await getOrgAdminWorkspaceRows(userId, 'active')).map((ws) => ({
|
||||
id: ws.id,
|
||||
name: ws.name,
|
||||
ownerId: ws.ownerId,
|
||||
}))
|
||||
|
||||
const ownedSet = new Set(ownedWorkspaces.map((w) => w.id))
|
||||
const seen = new Set(ownedSet)
|
||||
const combined: Array<{
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
accessType: 'direct' | 'owner'
|
||||
}> = ownedWorkspaces.map((ws) => ({ ...ws, accessType: 'owner' as const }))
|
||||
|
||||
for (const ws of [...adminWorkspaces, ...orgAdminWorkspaces]) {
|
||||
if (seen.has(ws.id)) continue
|
||||
seen.add(ws.id)
|
||||
combined.push({ ...ws, accessType: 'direct' as const })
|
||||
}
|
||||
|
||||
return combined
|
||||
}
|
||||
Reference in New Issue
Block a user