Skip to content
Merged
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
7 changes: 5 additions & 2 deletions apps/sim/app/api/credentials/[id]/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ interface RouteContext {

async function requireWorkspaceAdminMembership(credentialId: string, userId: string) {
const [cred] = await db
.select({ id: credential.id, workspaceId: credential.workspaceId })
.select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
Expand All @@ -39,7 +39,7 @@ async function requireWorkspaceAdminMembership(credentialId: string, userId: str
if (!membership || membership.status !== 'active' || membership.role !== 'admin') {
return null
}
return membership
return { ...membership, credentialType: cred.type }
}

export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => {
Expand Down Expand Up @@ -104,6 +104,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
if (!admin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
if (admin.credentialType === 'env_personal') {
return NextResponse.json({ error: 'Personal secrets cannot be shared' }, { status: 400 })
}

const parsed = await parseRequest(upsertWorkspaceCredentialMemberContract, request, context)
if (!parsed.success) return parsed.response
Expand Down
28 changes: 13 additions & 15 deletions apps/sim/app/api/credentials/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { account, credential, credentialMember, workspace } from '@sim/db/schema'
import { account, credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
Expand All @@ -22,7 +22,7 @@ import {
normalizeAtlassianDomain,
validateAtlassianServiceAccount,
} from '@/lib/credentials/atlassian-service-account'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
import { getWorkspaceMembership } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import {
Expand Down Expand Up @@ -498,11 +498,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

const now = new Date()
const credentialId = generateId()
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
const {
ownerId: workspaceOwnerId,
memberUserIds: workspaceMemberUserIds,
adminUserIds: workspaceAdminUserIds,
} = await getWorkspaceMembership(workspaceId)

await db.transaction(async (tx) => {
// service_account has no DB-level unique index on (workspaceId, providerId,
Expand Down Expand Up @@ -534,18 +534,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
updatedAt: now,
})

if ((type === 'env_workspace' || type === 'service_account') && workspaceRow?.ownerId) {
const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId)
if (workspaceUserIds.length > 0) {
for (const memberUserId of workspaceUserIds) {
if ((type === 'env_workspace' || type === 'service_account') && workspaceOwnerId) {
if (workspaceMemberUserIds.length > 0) {
for (const memberUserId of workspaceMemberUserIds) {
const isAdmin =
memberUserId === session.user.id || workspaceAdminUserIds.has(memberUserId)
await tx.insert(credentialMember).values({
id: generateId(),
credentialId,
userId: memberUserId,
role:
memberUserId === workspaceRow.ownerId || memberUserId === session.user.id
? 'admin'
: 'member',
role: isAdmin ? 'admin' : 'member',
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
Expand Down
71 changes: 63 additions & 8 deletions apps/sim/app/api/workspaces/[id]/environment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
createWorkspaceEnvCredentials,
deleteWorkspaceEnvCredentials,
getWorkspaceEnvKeyAdminAccess,
} from '@/lib/credentials/environment'
import {
getPersonalAndWorkspaceEnv,
Expand Down Expand Up @@ -91,15 +92,46 @@ export const PUT = withRouteHandler(
}

const userId = session.user.id
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission || (permission !== 'admin' && permission !== 'write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}

const parsed = await parseRequest(upsertWorkspaceEnvironmentContract, request, context)
if (!parsed.success) return parsed.response
const { variables } = parsed.data.body

// Caller must have workspace access at all (blocks non-member writes);
// per-key gating below then requires credential-admin to edit existing
// secrets and write/admin to add brand-new keys.
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}

const incomingKeys = Object.keys(variables)
if (incomingKeys.length === 0) {
return NextResponse.json({ success: true })
}
const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({
workspaceId,
envKeys: incomingKeys,
userId,
})
const forbiddenExisting = incomingKeys.filter((k) => knownKeys.has(k) && !adminKeys.has(k))
if (forbiddenExisting.length > 0) {
return NextResponse.json(
{ error: 'You must be an admin of these secrets to edit them' },
{ status: 403 }
)
}
if (
incomingKeys.some((k) => !knownKeys.has(k)) &&
permission !== 'admin' &&
permission !== 'write'
) {
return NextResponse.json(
{ error: 'Write access is required to add new secrets' },
{ status: 403 }
)
}

const encryptedIncoming = await Promise.all(
Object.entries(variables).map(async ([key, value]) => {
const { encrypted } = await encryptSecret(value)
Expand Down Expand Up @@ -184,15 +216,38 @@ export const DELETE = withRouteHandler(
}

const userId = session.user.id
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission || (permission !== 'admin' && permission !== 'write')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}

const parsed = await parseRequest(removeWorkspaceEnvironmentContract, request, context)
if (!parsed.success) return parsed.response
const { keys } = parsed.data.body

// Caller must have workspace access at all; deleting an existing secret then
// requires being its credential admin, while a key with no credential yet
// (legacy) falls back to workspace write/admin.
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}

const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({
workspaceId,
envKeys: keys,
userId,
})
const forbiddenExisting = keys.filter((k) => knownKeys.has(k) && !adminKeys.has(k))
if (forbiddenExisting.length > 0) {
return NextResponse.json(
{ error: 'You must be an admin of these secrets to delete them' },
{ status: 403 }
)
}
if (keys.some((k) => !knownKeys.has(k)) && permission !== 'admin' && permission !== 'write') {
return NextResponse.json(
{ error: 'Write access is required to remove these secrets' },
{ status: 403 }
)
}

const result = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${workspaceId}))`)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
'use client'

import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import {
Chip,
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
toast,
} from '@/components/emcn'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
useUpsertWorkspaceCredentialMember,
useWorkspaceCredentialMembers,
type WorkspaceCredentialRole,
} from '@/hooks/queries/credentials'
import { ROLE_OPTIONS } from '../roles'
import { partitionSettledFailures, resolveAddEmail } from '../sharing'

const logger = createLogger('AddPeopleModal')

interface AddPeopleModalProps {
credentialId: string
open: boolean
onOpenChange: (open: boolean) => void
}

/**
* Shared "Add people" modal: grants existing workspace members access to a
* credential with a chosen role. Emails are validated against the workspace
* roster and current membership; each add is an idempotent upsert and partial
* failures keep only the people that still need adding.
*/
export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleModalProps) {
const { workspacePermissions } = useWorkspacePermissionsContext()
const { data: members = [] } = useWorkspaceCredentialMembers(credentialId)
const upsertMember = useUpsertWorkspaceCredentialMember()

const [emailsToAdd, setEmailsToAdd] = useState<string[]>([])
const [roleToAdd, setRoleToAdd] = useState<WorkspaceCredentialRole>('member')
const [isAdding, setIsAdding] = useState(false)

const workspaceUserIdByEmail = useMemo(
() =>
new Map(
(workspacePermissions?.users ?? []).map((user) => [user.email.toLowerCase(), user.userId])
),
[workspacePermissions?.users]
)

const existingMemberEmails = useMemo(
() =>
new Set(
members
.filter((member) => member.status === 'active')
.map((member) => (member.userEmail ?? '').toLowerCase())
.filter(Boolean)
),
[members]
)

const validateAddEmail = useCallback(
(email: string): string | null => {
const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
return 'error' in result ? result.error : null
},
[workspaceUserIdByEmail, existingMemberEmails]
)
Comment thread
icecrasher321 marked this conversation as resolved.

const handleClose = useCallback(() => {
setEmailsToAdd([])
setRoleToAdd('member')
onOpenChange(false)
}, [onOpenChange])

const handleAddPeople = useCallback(async () => {
if (emailsToAdd.length === 0 || isAdding) return
const targets = emailsToAdd
.map((email) => {
const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
return 'userId' in result ? { email, userId: result.userId } : null
})
.filter((target): target is { email: string; userId: string } => target !== null)
if (targets.length === 0) return

setIsAdding(true)
try {
const results = await Promise.allSettled(
targets.map((target) =>
upsertMember.mutateAsync({ credentialId, userId: target.userId, role: roleToAdd })
)
)
const failures = partitionSettledFailures(targets, results)
if (failures.length === 0) {
handleClose()
return
}
setEmailsToAdd(failures.map((target) => target.email))
const firstError = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
)
logger.error('Failed to add some credential members', firstError?.reason)
toast.error(
failures.length === targets.length
? "Couldn't add people"
: `Couldn't add ${failures.length} of ${targets.length} people`,
{ description: getErrorMessage(firstError?.reason, 'Please try again in a moment.') }
)
} finally {
setIsAdding(false)
}
}, [
credentialId,
emailsToAdd,
isAdding,
workspaceUserIdByEmail,
existingMemberEmails,
roleToAdd,
upsertMember,
handleClose,
])

return (
<ChipModal
open={open}
onOpenChange={(next) => {
if (!next) handleClose()
}}
srTitle='Add people'
>
<ChipModalHeader onClose={handleClose}>Add people</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='emails'
title='Emails'
value={emailsToAdd}
onChange={setEmailsToAdd}
validate={validateAddEmail}
placeholder='Enter emails'
disabled={isAdding}
/>
<ChipModalField
type='dropdown'
title='Role'
options={ROLE_OPTIONS}
value={roleToAdd}
placeholder='Select role'
align='start'
onChange={(role) => setRoleToAdd(role as WorkspaceCredentialRole)}
disabled={isAdding}
/>
</ChipModalBody>
<ChipModalFooter>
<Chip
variant='primary'
onClick={handleAddPeople}
disabled={emailsToAdd.length === 0 || isAdding}
>
{isAdding ? 'Adding...' : 'Add'}
</Chip>
</ChipModalFooter>
</ChipModal>
)
}
Loading