d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
556 lines
16 KiB
TypeScript
556 lines
16 KiB
TypeScript
import { db } from '@sim/db'
|
||
import { credential, credentialMember, permissions, workspace } from '@sim/db/schema'
|
||
import { generateId } from '@sim/utils/id'
|
||
import { and, eq, inArray, isNotNull, isNull, notInArray, or, sql } from 'drizzle-orm'
|
||
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||
|
||
export interface WorkspaceMembership {
|
||
ownerId: string | null
|
||
/** All workspace members: the owner plus everyone with a workspace permission. */
|
||
memberUserIds: string[]
|
||
}
|
||
|
||
/**
|
||
* Resolves a workspace's membership in one owner lookup + one permissions scan.
|
||
* Credential-admin status is derived from workspace role at access time, so
|
||
* members are seeded only for use access (the owner plus permission holders).
|
||
*/
|
||
export async function getWorkspaceMembership(workspaceId: string): Promise<WorkspaceMembership> {
|
||
const [workspaceRows, permissionRows] = await Promise.all([
|
||
db
|
||
.select({ ownerId: workspace.ownerId })
|
||
.from(workspace)
|
||
.where(eq(workspace.id, workspaceId))
|
||
.limit(1),
|
||
db
|
||
.select({ userId: permissions.userId })
|
||
.from(permissions)
|
||
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))),
|
||
])
|
||
|
||
const ownerId = workspaceRows[0]?.ownerId ?? null
|
||
const memberUserIds = new Set<string>(permissionRows.map((row) => row.userId))
|
||
if (ownerId) {
|
||
memberUserIds.add(ownerId)
|
||
}
|
||
|
||
return { ownerId, memberUserIds: Array.from(memberUserIds) }
|
||
}
|
||
|
||
export interface WorkspaceEnvKeyAdminAccess {
|
||
/** Keys for which the caller is an active credential admin. */
|
||
adminKeys: Set<string>
|
||
/** Keys that already have an `env_workspace` credential (regardless of role). */
|
||
knownKeys: Set<string>
|
||
}
|
||
|
||
/**
|
||
* For a set of workspace env keys, resolves which the caller may administer
|
||
* (active `credential_member` with role `admin`) and which already have an
|
||
* `env_workspace` credential at all. Keys absent from `knownKeys` have no ACL
|
||
* yet (new or legacy), letting routes fall back to a workspace-permission gate.
|
||
*/
|
||
export async function getWorkspaceEnvKeyAdminAccess(params: {
|
||
workspaceId: string
|
||
envKeys: string[]
|
||
userId: string
|
||
}): Promise<WorkspaceEnvKeyAdminAccess> {
|
||
const { workspaceId, envKeys, userId } = params
|
||
const keys = Array.from(new Set(envKeys.filter(Boolean)))
|
||
if (keys.length === 0) return { adminKeys: new Set(), knownKeys: new Set() }
|
||
|
||
const rows = await db
|
||
.select({
|
||
envKey: credential.envKey,
|
||
role: credentialMember.role,
|
||
status: credentialMember.status,
|
||
})
|
||
.from(credential)
|
||
.leftJoin(
|
||
credentialMember,
|
||
and(eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, userId))
|
||
)
|
||
.where(
|
||
and(
|
||
eq(credential.workspaceId, workspaceId),
|
||
eq(credential.type, 'env_workspace'),
|
||
inArray(credential.envKey, keys)
|
||
)
|
||
)
|
||
|
||
const knownKeys = new Set<string>()
|
||
const adminKeys = new Set<string>()
|
||
for (const row of rows) {
|
||
if (!row.envKey) continue
|
||
knownKeys.add(row.envKey)
|
||
if (row.role === 'admin' && row.status === 'active') adminKeys.add(row.envKey)
|
||
}
|
||
return { adminKeys, knownKeys }
|
||
}
|
||
|
||
interface AccessibleEnvCredential {
|
||
type: 'env_workspace' | 'env_personal'
|
||
envKey: string
|
||
envOwnerUserId: string | null
|
||
updatedAt: Date
|
||
}
|
||
|
||
export async function getUserWorkspaceIds(userId: string): Promise<string[]> {
|
||
const [permissionRows, ownedWorkspaceRows] = await Promise.all([
|
||
db
|
||
.select({ workspaceId: workspace.id })
|
||
.from(permissions)
|
||
.innerJoin(
|
||
workspace,
|
||
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspace.id))
|
||
)
|
||
.where(and(eq(permissions.userId, userId), isNull(workspace.archivedAt))),
|
||
db
|
||
.select({ workspaceId: workspace.id })
|
||
.from(workspace)
|
||
.where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt))),
|
||
])
|
||
|
||
const workspaceIds = new Set<string>(permissionRows.map((row) => row.workspaceId))
|
||
for (const row of ownedWorkspaceRows) {
|
||
workspaceIds.add(row.workspaceId)
|
||
}
|
||
|
||
return Array.from(workspaceIds)
|
||
}
|
||
|
||
async function ensureWorkspaceCredentialMemberships(
|
||
credentialId: string,
|
||
memberUserIds: string[],
|
||
invitedBy: string
|
||
) {
|
||
if (!memberUserIds.length) return
|
||
|
||
const existingMemberships = await db
|
||
.select({
|
||
userId: credentialMember.userId,
|
||
status: credentialMember.status,
|
||
})
|
||
.from(credentialMember)
|
||
.where(
|
||
and(
|
||
eq(credentialMember.credentialId, credentialId),
|
||
inArray(credentialMember.userId, memberUserIds)
|
||
)
|
||
)
|
||
|
||
// Revoked memberships are filtered out so ON CONFLICT cannot resurrect them.
|
||
const revokedUserIds = new Set<string>(
|
||
existingMemberships.filter((row) => row.status === 'revoked').map((row) => row.userId)
|
||
)
|
||
const targetUserIds = memberUserIds.filter((id) => !revokedUserIds.has(id))
|
||
if (targetUserIds.length === 0) return
|
||
|
||
const now = new Date()
|
||
const values = targetUserIds.map((memberUserId) => ({
|
||
id: generateId(),
|
||
credentialId,
|
||
userId: memberUserId,
|
||
role: 'member' as const,
|
||
status: 'active' as const,
|
||
joinedAt: now,
|
||
invitedBy,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
}))
|
||
|
||
// Existing roles (including manual per-secret overrides) are preserved on
|
||
// conflict; only membership activeness and a missing joinedAt are reconciled.
|
||
await db
|
||
.insert(credentialMember)
|
||
.values(values)
|
||
.onConflictDoUpdate({
|
||
target: [credentialMember.credentialId, credentialMember.userId],
|
||
set: {
|
||
status: 'active',
|
||
joinedAt: sql`COALESCE(${credentialMember.joinedAt}, excluded.joined_at)`,
|
||
updatedAt: now,
|
||
},
|
||
})
|
||
}
|
||
|
||
export async function syncWorkspaceEnvCredentials(params: {
|
||
workspaceId: string
|
||
envKeys: string[]
|
||
actingUserId: string
|
||
}) {
|
||
const { workspaceId, envKeys, actingUserId } = params
|
||
const { ownerId, memberUserIds } = await getWorkspaceMembership(workspaceId)
|
||
|
||
if (!ownerId) return
|
||
|
||
const normalizedKeys = Array.from(new Set(envKeys.filter(Boolean)))
|
||
const existingCredentials = await db
|
||
.select({
|
||
id: credential.id,
|
||
envKey: credential.envKey,
|
||
})
|
||
.from(credential)
|
||
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'env_workspace')))
|
||
|
||
const existingByKey = new Map(
|
||
existingCredentials
|
||
.filter((row): row is { id: string; envKey: string } => Boolean(row.envKey))
|
||
.map((row) => [row.envKey, row.id])
|
||
)
|
||
|
||
const credentialIdsToEnsureMembership = new Set<string>()
|
||
const now = new Date()
|
||
|
||
for (const envKey of normalizedKeys) {
|
||
const existingId = existingByKey.get(envKey)
|
||
if (existingId) credentialIdsToEnsureMembership.add(existingId)
|
||
}
|
||
|
||
const keysToCreate = normalizedKeys.filter((key) => !existingByKey.has(key))
|
||
if (keysToCreate.length > 0) {
|
||
const inserted = await db
|
||
.insert(credential)
|
||
.values(
|
||
keysToCreate.map((envKey) => ({
|
||
id: generateId(),
|
||
workspaceId,
|
||
type: 'env_workspace' as const,
|
||
displayName: envKey,
|
||
envKey,
|
||
createdBy: actingUserId,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
}))
|
||
)
|
||
.onConflictDoNothing()
|
||
.returning({ id: credential.id })
|
||
for (const row of inserted) {
|
||
credentialIdsToEnsureMembership.add(row.id)
|
||
}
|
||
}
|
||
|
||
for (const credentialId of credentialIdsToEnsureMembership) {
|
||
await ensureWorkspaceCredentialMemberships(credentialId, memberUserIds, ownerId)
|
||
}
|
||
|
||
if (normalizedKeys.length > 0) {
|
||
await db
|
||
.delete(credential)
|
||
.where(
|
||
and(
|
||
eq(credential.workspaceId, workspaceId),
|
||
eq(credential.type, 'env_workspace'),
|
||
notInArray(credential.envKey, normalizedKeys)
|
||
)
|
||
)
|
||
return
|
||
}
|
||
|
||
await db
|
||
.delete(credential)
|
||
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'env_workspace')))
|
||
}
|
||
|
||
/**
|
||
* Creates credential records and bulk-inserts memberships for newly added workspace env keys.
|
||
* Use this instead of `syncWorkspaceEnvCredentials` when the caller knows exactly which keys are new.
|
||
*/
|
||
export async function createWorkspaceEnvCredentials(params: {
|
||
workspaceId: string
|
||
newKeys: string[]
|
||
actingUserId: string
|
||
}): Promise<void> {
|
||
const { workspaceId, newKeys, actingUserId } = params
|
||
const keys = Array.from(new Set(newKeys.filter(Boolean)))
|
||
if (keys.length === 0) return
|
||
|
||
const { ownerId, memberUserIds } = await getWorkspaceMembership(workspaceId)
|
||
|
||
if (!ownerId) return
|
||
|
||
const now = new Date()
|
||
|
||
const inserted = await db
|
||
.insert(credential)
|
||
.values(
|
||
keys.map((envKey) => ({
|
||
id: generateId(),
|
||
workspaceId,
|
||
type: 'env_workspace' as const,
|
||
displayName: envKey,
|
||
envKey,
|
||
createdBy: actingUserId,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
}))
|
||
)
|
||
.onConflictDoNothing()
|
||
.returning({ id: credential.id })
|
||
const createdIds = inserted.map((row) => row.id)
|
||
|
||
if (createdIds.length === 0 || memberUserIds.length === 0) return
|
||
|
||
// Bulk-insert memberships for all new credentials × all workspace members in one query
|
||
const membershipValues = createdIds.flatMap((credentialId) =>
|
||
memberUserIds.map((memberUserId) => ({
|
||
id: generateId(),
|
||
credentialId,
|
||
userId: memberUserId,
|
||
role: (memberUserId === actingUserId ? 'admin' : 'member') as 'admin' | 'member',
|
||
status: 'active' as const,
|
||
joinedAt: now,
|
||
invitedBy: actingUserId,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
}))
|
||
)
|
||
|
||
await db.insert(credentialMember).values(membershipValues).onConflictDoNothing()
|
||
}
|
||
|
||
/**
|
||
* Deletes credential records (and their memberships via cascade) for removed workspace env keys.
|
||
* Use this instead of `syncWorkspaceEnvCredentials` when the caller knows exactly which keys were deleted.
|
||
*/
|
||
export async function deleteWorkspaceEnvCredentials(params: {
|
||
workspaceId: string
|
||
removedKeys: string[]
|
||
}): Promise<void> {
|
||
const { workspaceId, removedKeys } = params
|
||
const keys = removedKeys.filter(Boolean)
|
||
if (keys.length === 0) return
|
||
|
||
await db
|
||
.delete(credential)
|
||
.where(
|
||
and(
|
||
eq(credential.workspaceId, workspaceId),
|
||
eq(credential.type, 'env_workspace'),
|
||
inArray(credential.envKey, keys)
|
||
)
|
||
)
|
||
}
|
||
|
||
export async function syncPersonalEnvCredentialsForUser(params: {
|
||
userId: string
|
||
envKeys: string[]
|
||
}): Promise<void> {
|
||
const { userId, envKeys } = params
|
||
const workspaceIds = await getUserWorkspaceIds(userId)
|
||
if (!workspaceIds.length) return
|
||
|
||
const normalizedKeys = Array.from(new Set(envKeys.filter(Boolean)))
|
||
const now = new Date()
|
||
|
||
await Promise.all(
|
||
workspaceIds.map(async (workspaceId) => {
|
||
if (normalizedKeys.length > 0) {
|
||
await db
|
||
.insert(credential)
|
||
.values(
|
||
normalizedKeys.map((envKey) => ({
|
||
id: generateId(),
|
||
workspaceId,
|
||
type: 'env_personal' as const,
|
||
displayName: envKey,
|
||
envKey,
|
||
envOwnerUserId: userId,
|
||
createdBy: userId,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
}))
|
||
)
|
||
.onConflictDoNothing()
|
||
}
|
||
|
||
const currentCredentials =
|
||
normalizedKeys.length > 0
|
||
? await db
|
||
.select({ id: credential.id })
|
||
.from(credential)
|
||
.where(
|
||
and(
|
||
eq(credential.workspaceId, workspaceId),
|
||
eq(credential.type, 'env_personal'),
|
||
eq(credential.envOwnerUserId, userId),
|
||
inArray(credential.envKey, normalizedKeys)
|
||
)
|
||
)
|
||
: []
|
||
|
||
if (currentCredentials.length > 0) {
|
||
await db
|
||
.insert(credentialMember)
|
||
.values(
|
||
currentCredentials.map(({ id: credentialId }) => ({
|
||
id: generateId(),
|
||
credentialId,
|
||
userId,
|
||
role: 'admin' as const,
|
||
status: 'active' as const,
|
||
joinedAt: now,
|
||
invitedBy: userId,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
}))
|
||
)
|
||
.onConflictDoUpdate({
|
||
target: [credentialMember.credentialId, credentialMember.userId],
|
||
set: { role: 'admin', status: 'active', updatedAt: now },
|
||
})
|
||
}
|
||
|
||
if (normalizedKeys.length > 0) {
|
||
await db
|
||
.delete(credential)
|
||
.where(
|
||
and(
|
||
eq(credential.workspaceId, workspaceId),
|
||
eq(credential.type, 'env_personal'),
|
||
eq(credential.envOwnerUserId, userId),
|
||
notInArray(credential.envKey, normalizedKeys)
|
||
)
|
||
)
|
||
} else {
|
||
await db
|
||
.delete(credential)
|
||
.where(
|
||
and(
|
||
eq(credential.workspaceId, workspaceId),
|
||
eq(credential.type, 'env_personal'),
|
||
eq(credential.envOwnerUserId, userId)
|
||
)
|
||
)
|
||
}
|
||
})
|
||
)
|
||
}
|
||
|
||
export async function getAccessibleEnvCredentials(
|
||
workspaceId: string,
|
||
userId: string,
|
||
options?: { isWorkspaceAdmin?: boolean }
|
||
): Promise<AccessibleEnvCredential[]> {
|
||
const isWorkspaceAdmin =
|
||
options?.isWorkspaceAdmin ?? (await hasWorkspaceAdminAccess(userId, workspaceId))
|
||
|
||
const rows = await db
|
||
.select({
|
||
type: credential.type,
|
||
envKey: credential.envKey,
|
||
envOwnerUserId: credential.envOwnerUserId,
|
||
updatedAt: credential.updatedAt,
|
||
})
|
||
.from(credential)
|
||
.leftJoin(
|
||
credentialMember,
|
||
and(
|
||
eq(credentialMember.credentialId, credential.id),
|
||
eq(credentialMember.userId, userId),
|
||
eq(credentialMember.status, 'active')
|
||
)
|
||
)
|
||
.where(
|
||
and(
|
||
eq(credential.workspaceId, workspaceId),
|
||
inArray(credential.type, ['env_workspace', 'env_personal']),
|
||
or(
|
||
isNotNull(credentialMember.id),
|
||
eq(credential.envOwnerUserId, userId),
|
||
isWorkspaceAdmin ? eq(credential.type, 'env_workspace') : undefined
|
||
)
|
||
)
|
||
)
|
||
|
||
return rows
|
||
.filter(
|
||
(row): row is typeof row & { type: 'env_workspace' | 'env_personal'; envKey: string } =>
|
||
row.envKey !== null && (row.type === 'env_workspace' || row.type === 'env_personal')
|
||
)
|
||
.map((row) => ({
|
||
type: row.type,
|
||
envKey: row.envKey,
|
||
envOwnerUserId: row.envOwnerUserId,
|
||
updatedAt: row.updatedAt,
|
||
}))
|
||
}
|
||
|
||
export interface AccessibleOAuthCredential {
|
||
id: string
|
||
providerId: string
|
||
displayName: string
|
||
role: 'admin' | 'member'
|
||
updatedAt: Date
|
||
}
|
||
|
||
export async function getAccessibleOAuthCredentials(
|
||
workspaceId: string,
|
||
userId: string,
|
||
options?: { isWorkspaceAdmin?: boolean }
|
||
): Promise<AccessibleOAuthCredential[]> {
|
||
const isWorkspaceAdmin =
|
||
options?.isWorkspaceAdmin ?? (await hasWorkspaceAdminAccess(userId, workspaceId))
|
||
|
||
if (isWorkspaceAdmin) {
|
||
const rows = await db
|
||
.select({
|
||
id: credential.id,
|
||
providerId: credential.providerId,
|
||
displayName: credential.displayName,
|
||
updatedAt: credential.updatedAt,
|
||
})
|
||
.from(credential)
|
||
.where(
|
||
and(
|
||
eq(credential.workspaceId, workspaceId),
|
||
inArray(credential.type, ['oauth', 'service_account'])
|
||
)
|
||
)
|
||
|
||
return rows
|
||
.filter((row): row is typeof row & { providerId: string } => Boolean(row.providerId))
|
||
.map((row) => ({
|
||
id: row.id,
|
||
providerId: row.providerId,
|
||
displayName: row.displayName,
|
||
role: 'admin' as const,
|
||
updatedAt: row.updatedAt,
|
||
}))
|
||
}
|
||
|
||
const rows = await db
|
||
.select({
|
||
id: credential.id,
|
||
providerId: credential.providerId,
|
||
displayName: credential.displayName,
|
||
role: credentialMember.role,
|
||
updatedAt: credential.updatedAt,
|
||
})
|
||
.from(credential)
|
||
.innerJoin(
|
||
credentialMember,
|
||
and(
|
||
eq(credentialMember.credentialId, credential.id),
|
||
eq(credentialMember.userId, userId),
|
||
eq(credentialMember.status, 'active')
|
||
)
|
||
)
|
||
.where(
|
||
and(
|
||
eq(credential.workspaceId, workspaceId),
|
||
inArray(credential.type, ['oauth', 'service_account'])
|
||
)
|
||
)
|
||
|
||
return rows
|
||
.filter((row): row is AccessibleOAuthCredential => Boolean(row.providerId))
|
||
.map((row) => ({
|
||
id: row.id,
|
||
providerId: row.providerId!,
|
||
displayName: row.displayName,
|
||
role: row.role,
|
||
updatedAt: row.updatedAt,
|
||
}))
|
||
}
|