From b4f6648c6693fb3703024ddac810537f42e4a882 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 18 Jun 2026 18:13:55 -0700 Subject: [PATCH 1/3] improvement(governance): org-ws-credential roles clarity --- .../content/docs/en/platform/permissions.mdx | 35 +- .../app/api/credentials/[id]/members/route.ts | 97 ++++- apps/sim/app/api/credentials/route.ts | 10 +- .../api/organizations/[id]/roster/route.ts | 29 +- .../api/workspaces/[id]/environment/route.ts | 17 +- .../api/workspaces/[id]/permissions/route.ts | 30 +- apps/sim/app/api/workspaces/route.ts | 27 +- .../components/credential-members-section.tsx | 36 +- .../organization-member-lists.tsx | 47 ++- .../components/teammates/teammates.tsx | 39 +- apps/sim/components/permissions/index.ts | 7 + apps/sim/components/permissions/role-lock.tsx | 54 +++ apps/sim/lib/api/contracts/credentials.ts | 2 + apps/sim/lib/api/contracts/workspaces.ts | 1 + apps/sim/lib/auth/credential-access.ts | 31 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 6 +- apps/sim/lib/core/config/env-flags.ts | 2 +- apps/sim/lib/credentials/access.test.ts | 115 ++++++ apps/sim/lib/credentials/access.ts | 89 +--- apps/sim/lib/credentials/environment.ts | 91 +++-- apps/sim/lib/environment/utils.ts | 6 +- .../lib/workspaces/permissions/utils.test.ts | 379 ++++++++++++------ apps/sim/lib/workspaces/permissions/utils.ts | 242 +++++++---- apps/sim/lib/workspaces/utils.test.ts | 64 ++- apps/sim/lib/workspaces/utils.ts | 82 +++- packages/workflow-authz/src/index.ts | 71 +++- 26 files changed, 1130 insertions(+), 479 deletions(-) create mode 100644 apps/sim/components/permissions/role-lock.tsx create mode 100644 apps/sim/lib/credentials/access.test.ts diff --git a/apps/docs/content/docs/en/platform/permissions.mdx b/apps/docs/content/docs/en/platform/permissions.mdx index 7dc4980ace3..711738f6f1a 100644 --- a/apps/docs/content/docs/en/platform/permissions.mdx +++ b/apps/docs/content/docs/en/platform/permissions.mdx @@ -120,7 +120,7 @@ Every workspace has one **Owner** (the person who created it) plus any number of - Can be removed from the workspace by the owner or other admins - For shared (organization) workspaces, the organization's Owner and Admins are treated as Admins of every workspace in the organization, even without an explicit per-workspace invite. + For shared (organization) workspaces, the organization's Owner and Admins are full Admins of every workspace in the organization, even without an explicit per-workspace invite. They automatically see every organization workspace, have complete read, write, and management access, and their workspace role is fixed — it shows greyed out in the member list with a tooltip and cannot be changed. --- @@ -151,10 +151,30 @@ Users can create two types of environment variables: - Managed in **Settings**, then go to **Secrets** ### Workspace Environment Variables -- **Read permission**: Can see variable names and values -- **Write/Admin permission**: Can add, edit, and delete variables -- Available to all workspace members -- If a personal variable has the same name as a workspace variable, the personal one takes priority +- **Read**: see variable names (the values stay hidden unless you're an admin of that secret) +- **Write**: add new variables, and edit or delete ones you created +- **Admin**: add, edit, delete, and view the values of any workspace variable +- Workspace variables are a kind of workspace credential, so they follow the [Credential Access](#credential-access) rules below — workspace Admins are admins of all of them +- Available to all workspace members; if a personal variable has the same name, the personal one takes priority + +--- + +## Credential Access + +Workspace credentials — OAuth connections, service accounts, and workspace environment variables — have two roles of their own: + +- **Credential Member**: can use the credential in workflows. +- **Credential Admin**: can use it and also edit, delete, and share it. + +These roles follow your workspace role: + +- **Workspace Admins are automatically Credential Admins** of every shared credential in the workspace (OAuth connections, service accounts, and workspace environment variables). Because organization Owners and Admins are workspace Admins everywhere, they are Credential Admins too. These automatic roles are fixed — they show greyed out with a tooltip in the credential's member list and cannot be changed. +- **Read and Write members are Credential Members** by default — they can use shared credentials but cannot edit, delete, or share them unless someone makes them a Credential Admin (you are always an admin of credentials you create). +- **Personal environment variables** are the exception: they stay private to their owner and are never shared with workspace admins. + + + A Credential Admin can both use and manage a credential, so a workspace Admin can run workflows that use any shared OAuth connection in the workspace — including one another member added. + --- @@ -173,7 +193,7 @@ An organization has three roles: **Owner**, **Admin**, and **Member**. - Invite and remove team members from the organization - Create new shared workspaces under the organization - Manage billing, seat count, and subscription settings -- Access all shared workspaces within the organization as a workspace Admin +- Access every shared workspace in the organization as a workspace Admin automatically (no per-workspace invite), including administering the credentials inside them - Promote members to Admin or demote Admins to Member @@ -194,6 +214,7 @@ import { FAQ } from '@/components/ui/faq' { question: "Can I restrict which integrations or model providers a team member can use?", answer: "Yes, on Enterprise-entitled organizations. Any organization owner or admin can create permission groups with fine-grained controls, including restricting allowed integrations and allowed model providers to specific lists. You can also disable access to MCP tools, custom tools, skills, and various platform features like the knowledge base, API keys, or Copilot on a per-group basis. Permission groups are scoped to the organization and can govern either all workspaces or a specific subset — a user can belong to multiple groups but is governed by exactly one group in any given workspace." }, { question: "What happens when a personal environment variable has the same name as a workspace variable?", answer: "The personal environment variable takes priority. When a workflow runs, if both a personal and workspace variable share the same name, the personal value is used. This allows individual users to override shared workspace configuration when needed." }, { question: "Can an Admin remove the workspace owner?", answer: "No. The workspace owner cannot be removed from the workspace by anyone. Only the workspace owner can delete the workspace or transfer ownership to another user. Admins can do everything else, including inviting and removing other users and managing workspace settings." }, - { question: "What are permission groups and how do they work?", answer: "Permission groups are an Enterprise access control feature that lets organization owners and admins define granular restrictions beyond the standard Read/Write/Admin roles. Groups are scoped to the organization and can govern either all workspaces or a specific subset. A user can belong to multiple groups, but at most one governs them in any given workspace: a workspace-specific group takes precedence over an all-workspaces group, which takes precedence over the organization's default group. A permission group can hide UI sections (like trace spans, knowledge base, API keys, or deployment options), disable features (MCP tools, custom tools, skills, invitations), and restrict which integrations and model providers its members can access. Members are assigned manually, and an organization can designate one group as the default (always all-workspaces) that governs everyone not explicitly assigned — including external workspace members. Execution-time enforcement is based on the organization that owns the workflow's workspace, not the user's current UI context." }, + { question: "Who can manage a workspace's credentials and secrets?", answer: "Workspace Admins are automatically Credential Admins of the workspace's shared credentials — OAuth connections, service accounts, and workspace environment variables — so they can use, edit, delete, and share them, and run workflows that rely on them. Organization Owners and Admins get this too because they are workspace Admins everywhere. Read and Write members get use-only access to shared credentials unless they are explicitly made a Credential Admin. Personal environment variables are never shared; they stay private to their owner." }, + { question: "What are permission groups and how do they work?", answer: "Permission groups are an Enterprise access control feature that lets organization owners and admins define granular restrictions beyond the standard Read/Write/Admin roles. Groups are scoped to the organization and can govern either all workspaces or a specific subset. A user can belong to multiple groups, but at most one governs them in any given workspace: a workspace-specific group takes precedence over an all-workspaces group, which takes precedence over the organization's default group. A permission group can hide UI sections (like trace spans, knowledge base, API keys, or deployment options), disable features (MCP tools, custom tools, skills, invitations), and restrict which integrations and model providers its members can access. Members are assigned manually, and an organization can designate one group as the default (always all-workspaces) that governs everyone not explicitly assigned — including external workspace members. Restrictions are enforced based on the organization that owns the workflow's workspace, not on which workspace you're currently viewing." }, { question: "How should I set up permissions for a new team member?", answer: "Start with the lowest permission level they need. Invite teammates to the organization as Members, then add them to the relevant workspace with Read permission if they only need visibility, Write if they need to create and run workflows, or Admin if they need to manage the workspace and its users. For clients, partners, or users who already belong to another Sim organization, use external workspace access so they can collaborate without joining your organization or consuming a seat." }, ]} /> \ No newline at end of file diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 7753d2fc21c..9f935953b7c 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -5,12 +5,18 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { upsertWorkspaceCredentialMemberContract } from '@/lib/api/contracts/credentials' +import { + upsertWorkspaceCredentialMemberContract, + type WorkspaceCredentialMember, +} from '@/lib/api/contracts/credentials' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + getUserEntityPermissions, + getUsersWithPermissions, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('CredentialMembersAPI') @@ -18,7 +24,7 @@ interface RouteContext { params: Promise<{ id: string }> } -async function requireWorkspaceAdminMembership(credentialId: string, userId: string) { +async function requireCredentialAdmin(credentialId: string, userId: string) { const [cred] = await db .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) .from(credential) @@ -30,6 +36,8 @@ async function requireWorkspaceAdminMembership(credentialId: string, userId: str const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId) if (perm === null) return null + const isWorkspaceAdmin = cred.type !== 'env_personal' && perm === 'admin' + const [membership] = await db .select({ role: credentialMember.role, status: credentialMember.status }) .from(credentialMember) @@ -38,10 +46,12 @@ async function requireWorkspaceAdminMembership(credentialId: string, userId: str ) .limit(1) - if (!membership || membership.status !== 'active' || membership.role !== 'admin') { + const isCredentialAdmin = membership?.status === 'active' && membership?.role === 'admin' + + if (!isWorkspaceAdmin && !isCredentialAdmin) { return null } - return { ...membership, credentialType: cred.type, workspaceId: cred.workspaceId } + return { credentialType: cred.type, workspaceId: cred.workspaceId } } export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => { @@ -54,7 +64,7 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route const { id: credentialId } = await context.params 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) @@ -72,7 +82,7 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route return NextResponse.json({ error: 'Not found' }, { status: 404 }) } - const members = await db + const explicitMembers = await db .select({ id: credentialMember.id, userId: credentialMember.userId, @@ -86,6 +96,48 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route .innerJoin(user, eq(credentialMember.userId, user.id)) .where(eq(credentialMember.credentialId, credentialId)) + const byUser = new Map( + explicitMembers.map((m) => [ + m.userId, + { + id: m.id, + userId: m.userId, + role: m.role, + status: m.status, + joinedAt: m.joinedAt ? m.joinedAt.toISOString() : null, + userName: m.userName, + userEmail: m.userEmail, + roleSource: 'explicit' as const, + }, + ]) + ) + + if (cred.type !== 'env_personal') { + const workspaceMembers = await getUsersWithPermissions(cred.workspaceId) + for (const wsMember of workspaceMembers) { + if (wsMember.permissionType !== 'admin') continue + const existing = byUser.get(wsMember.userId) + if (existing) { + existing.role = 'admin' + existing.status = 'active' + existing.roleSource = 'workspace-admin' + } else { + byUser.set(wsMember.userId, { + id: `workspace-admin-${wsMember.userId}`, + userId: wsMember.userId, + role: 'admin', + status: 'active', + joinedAt: null, + userName: wsMember.name, + userEmail: wsMember.email, + roleSource: 'workspace-admin', + }) + } + } + } + + const members = Array.from(byUser.values()) + return NextResponse.json({ members }) } catch (error) { logger.error('Failed to fetch credential members', { error }) @@ -102,7 +154,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route const { id: credentialId } = await context.params - const admin = await requireWorkspaceAdminMembership(credentialId, session.user.id) + const admin = await requireCredentialAdmin(credentialId, session.user.id) if (!admin) { logger.warn('Credential member share denied', { credentialId, @@ -124,6 +176,19 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route if (!parsed.success) return parsed.response const { userId, role } = parsed.data.body + + const targetWorkspacePerm = await getUserEntityPermissions( + userId, + 'workspace', + admin.workspaceId + ) + if (targetWorkspacePerm === 'admin' && role !== 'admin') { + return NextResponse.json( + { error: 'Workspace admins are automatically credential admins and cannot be demoted' }, + { status: 400 } + ) + } + const now = new Date() const [existing] = await db @@ -233,7 +298,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 }) } - const admin = await requireWorkspaceAdminMembership(credentialId, session.user.id) + const admin = await requireCredentialAdmin(credentialId, session.user.id) if (!admin) { logger.warn('Credential member removal denied', { credentialId, @@ -262,6 +327,20 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou return NextResponse.json({ error: 'Member not found' }, { status: 404 }) } + if (admin.credentialType !== 'env_personal') { + const targetWorkspacePerm = await getUserEntityPermissions( + targetUserId, + 'workspace', + admin.workspaceId + ) + if (targetWorkspacePerm === 'admin') { + return NextResponse.json( + { error: 'Workspace admins are automatically credential admins and cannot be removed' }, + { status: 400 } + ) + } + } + const revoked = await db.transaction(async (tx) => { if (target.role === 'admin') { const activeAdmins = await tx diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 3b964b18e0b..3c2c9394273 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -498,11 +498,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const now = new Date() const credentialId = generateId() - const { - ownerId: workspaceOwnerId, - memberUserIds: workspaceMemberUserIds, - adminUserIds: workspaceAdminUserIds, - } = await getWorkspaceMembership(workspaceId) + const { ownerId: workspaceOwnerId, memberUserIds: workspaceMemberUserIds } = + await getWorkspaceMembership(workspaceId) await db.transaction(async (tx) => { // service_account has no DB-level unique index on (workspaceId, providerId, @@ -537,8 +534,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { 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) + const isAdmin = memberUserId === session.user.id await tx.insert(credentialMember).values({ id: generateId(), credentialId, diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index 8ccdcfe6227..102c1f2035d 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -117,16 +117,25 @@ export const GET = withRouteHandler( permissionsByUser.set(row.userId, list) } - const members = memberRows.map((row) => ({ - memberId: row.memberId, - userId: row.userId, - role: row.role, - createdAt: row.createdAt, - name: row.userName, - email: row.userEmail, - image: row.userImage, - workspaces: permissionsByUser.get(row.userId) ?? [], - })) + const members = memberRows.map((row) => { + const isOrgAdmin = row.role === 'owner' || row.role === 'admin' + return { + memberId: row.memberId, + userId: row.userId, + role: row.role, + createdAt: row.createdAt, + name: row.userName, + email: row.userEmail, + image: row.userImage, + workspaces: isOrgAdmin + ? orgWorkspaces.map((ws) => ({ + workspaceId: ws.id, + workspaceName: ws.name, + permission: 'admin' as const, + })) + : (permissionsByUser.get(row.userId) ?? []), + } + }) const externalPermissionRows = orgWorkspaceIds.length > 0 diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index e2a87fdfbbc..f7aad4aba15 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -44,10 +44,9 @@ const WORKSPACE_ENV_LOCK_TIMEOUT_MS = 5_000 * Restricts decrypted workspace env values to administrators. Members (including * read-only) receive the variable names with empty values so editor autocomplete * and conflict detection keep working without leaking secret values. A value is - * revealed when the caller is a credential admin of that key, or — for legacy - * keys predating per-secret ACLs — when they hold workspace `admin` permission. - * Mirrors the per-key edit gating in PUT/DELETE: if you can administer a secret, - * you can read it. + * revealed when the caller is a workspace admin (which includes organization + * admins) or a per-secret credential admin of that key. Mirrors the per-key edit + * gating in PUT/DELETE: if you can administer a secret, you can read it. */ async function maskWorkspaceEnvForViewer({ workspaceDecrypted, @@ -61,7 +60,7 @@ async function maskWorkspaceEnvForViewer({ permission: PermissionType }): Promise> { const workspaceKeys = Object.keys(workspaceDecrypted) - const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({ + const { adminKeys } = await getWorkspaceEnvKeyAdminAccess({ workspaceId, envKeys: workspaceKeys, userId, @@ -69,7 +68,7 @@ async function maskWorkspaceEnvForViewer({ const masked: Record = {} for (const key of workspaceKeys) { - const canViewValue = adminKeys.has(key) || (!knownKeys.has(key) && permission === 'admin') + const canViewValue = permission === 'admin' || adminKeys.has(key) masked[key] = canViewValue ? workspaceDecrypted[key] : '' } return masked @@ -169,7 +168,8 @@ export const PUT = withRouteHandler( envKeys: incomingKeys, userId, }) - const forbiddenExisting = incomingKeys.filter((k) => knownKeys.has(k) && !adminKeys.has(k)) + const isKeyAdmin = (key: string) => permission === 'admin' || adminKeys.has(key) + const forbiddenExisting = incomingKeys.filter((k) => knownKeys.has(k) && !isKeyAdmin(k)) if (forbiddenExisting.length > 0) { logger.warn(`[${requestId}] Workspace env update denied`, { workspaceId, @@ -311,7 +311,8 @@ export const DELETE = withRouteHandler( envKeys: keys, userId, }) - const forbiddenExisting = keys.filter((k) => knownKeys.has(k) && !adminKeys.has(k)) + const isKeyAdmin = (key: string) => permission === 'admin' || adminKeys.has(key) + const forbiddenExisting = keys.filter((k) => knownKeys.has(k) && !isKeyAdmin(k)) if (forbiddenExisting.length > 0) { logger.warn(`[${requestId}] Workspace env delete denied`, { workspaceId, diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 8e9bbd5bf32..8d4a4c15b83 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -1,9 +1,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema' +import { member, permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces' import { parseRequest } from '@/lib/api/server' @@ -112,7 +112,10 @@ export const PATCH = withRouteHandler( const body = parsed.data.body const workspaceRow = await db - .select({ billedAccountUserId: workspace.billedAccountUserId }) + .select({ + billedAccountUserId: workspace.billedAccountUserId, + organizationId: workspace.organizationId, + }) .from(workspace) .where(eq(workspace.id, workspaceId)) .limit(1) @@ -122,6 +125,27 @@ export const PATCH = withRouteHandler( } const billedAccountUserId = workspaceRow[0].billedAccountUserId + const organizationId = workspaceRow[0].organizationId + + if (organizationId) { + const targetUserIds = body.updates.map((update) => update.userId) + const orgAdminTargets = await db + .select({ userId: member.userId }) + .from(member) + .where( + and( + eq(member.organizationId, organizationId), + inArray(member.userId, targetUserIds), + inArray(member.role, ['owner', 'admin']) + ) + ) + if (orgAdminTargets.length > 0) { + return NextResponse.json( + { error: 'Organization admins are workspace admins and their role cannot be changed' }, + { status: 400 } + ) + } + } const selfUpdate = body.updates.find((update) => update.userId === session.user.id) if (selfUpdate && selfUpdate.permissions !== 'admin') { diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 42513fa1cca..8d35dc7429b 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { permissions, settings, type WorkspaceMode, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, desc, eq, isNull, sql } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { listWorkspacesQuerySchema } from '@/lib/api/contracts' import { createWorkspaceContract } from '@/lib/api/contracts/workspaces' @@ -26,6 +26,7 @@ import { UPGRADE_TO_INVITE_REASON, WORKSPACE_MODE, } from '@/lib/workspaces/policy' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' const logger = createLogger('Workspaces') @@ -62,29 +63,7 @@ export const GET = withRouteHandler(async (request: Request) => { .limit(1) const [userWorkspaces, userSettings] = await Promise.all([ - db - .select({ - workspace: workspace, - permissionType: permissions.permissionType, - }) - .from(permissions) - .innerJoin(workspace, eq(permissions.entityId, workspace.id)) - .where( - scope === 'all' - ? and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')) - : scope === 'archived' - ? and( - eq(permissions.userId, session.user.id), - eq(permissions.entityType, 'workspace'), - sql`${workspace.archivedAt} IS NOT NULL` - ) - : and( - eq(permissions.userId, session.user.id), - eq(permissions.entityType, 'workspace'), - isNull(workspace.archivedAt) - ) - ) - .orderBy(desc(workspace.createdAt)), + listAccessibleWorkspaceRowsForUser(session.user.id, scope), settingsQuery, ]) diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx index 6177c1bf8a1..a9067b55f1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { Avatar, AvatarFallback, Chip, ChipDropdown } from '@/components/emcn' +import { credentialRoleLockReason, RoleLockTooltip } from '@/components/permissions' import { cn } from '@/lib/core/utils/cn' import { getUserColor } from '@/lib/workspaces/colors' import { @@ -32,7 +33,9 @@ export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMe const removeMember = useRemoveWorkspaceCredentialMember() const activeMembers = members.filter((member) => member.status === 'active') - const adminMemberCount = activeMembers.filter((member) => member.role === 'admin').length + const explicitAdminCount = activeMembers.filter( + (member) => member.role === 'admin' && member.roleSource !== 'workspace-admin' + ).length const handleChangeMemberRole = async (userId: string, role: WorkspaceCredentialRole) => { const current = activeMembers.find((member) => member.userId === userId) @@ -57,8 +60,13 @@ export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMe {membersLoading ? null : (
{activeMembers.map((member) => { - const roleLocked = member.role === 'admin' && adminMemberCount <= 1 - const roleDisabled = !isAdmin || roleLocked + const lockReason = credentialRoleLockReason(member.roleSource) + const roleLocked = + member.role === 'admin' && + member.roleSource !== 'workspace-admin' && + explicitAdminCount <= 1 + const roleDisabled = !isAdmin || roleLocked || lockReason !== null + const removeDisabled = roleLocked || lockReason !== null return (
- - handleChangeMemberRole(member.userId, role as WorkspaceCredentialRole) - } - /> + + + handleChangeMemberRole(member.userId, role as WorkspaceCredentialRole) + } + /> + {isAdmin && ( handleRemoveMember(member.userId)} - disabled={roleLocked} + disabled={removeDisabled} flush className='justify-self-end' > diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx index 144fa39c69c..da0de1aacb5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx @@ -15,7 +15,12 @@ import { Search, toast, } from '@/components/emcn' -import type { OrgRole, PermissionType } from '@/components/permissions' +import { + type OrgRole, + type PermissionType, + RoleLockTooltip, + workspaceRoleLockReason, +} from '@/components/permissions' import type { OrganizationRoster, RosterMember, @@ -309,11 +314,7 @@ export function OrganizationMemberLists({ const isSelf = member.userId === currentUserId const wouldDemoteSelf = isSelf && access.permission === 'admin' const disabled = rowUserIsOrgAdmin || wouldDemoteSelf || updatePermissions.isPending - /** - * Org owners/admins keep implicit admin access on org workspaces, so - * deleting their explicit permission row wouldn't actually revoke access. - * Only regular/external members can be removed from a single workspace. - */ + const lockReason = rowUserIsOrgAdmin ? workspaceRoleLockReason('org-admin') : null const canRemoveFromWorkspace = !rowUserIsOrgAdmin && !isSelf return ( @@ -324,21 +325,25 @@ export function OrganizationMemberLists({ image={member.image} status={`Joined ${formatJoinedDate(member.createdAt)}`} roleControl={ - - updatePermissions - .mutateAsync({ - workspaceId, - organizationId, - updates: [{ userId: member.userId, permissions: permission as PermissionType }], - }) - .catch((error) => logger.error('Failed to update workspace permission', { error })) - } - options={WORKSPACE_ROLE_OPTIONS} - matchTriggerWidth={false} - disabled={disabled} - /> + + + updatePermissions + .mutateAsync({ + workspaceId, + organizationId, + updates: [{ userId: member.userId, permissions: permission as PermissionType }], + }) + .catch((error) => + logger.error('Failed to update workspace permission', { error }) + ) + } + options={WORKSPACE_ROLE_OPTIONS} + matchTriggerWidth={false} + disabled={disabled} + /> + } menu={buildActionsMenu( <> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx index ea442e0e9de..fcdd11ce842 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx @@ -18,6 +18,11 @@ import { Search, toast, } from '@/components/emcn' +import { + RoleLockTooltip, + type WorkspaceRoleSource, + workspaceRoleLockReason, +} from '@/components/permissions' import type { WorkspacePermission } from '@/lib/api/contracts/workspaces' import { MemberRow, @@ -57,6 +62,7 @@ interface Teammate { userId?: string invitationId?: string token?: string + roleSource?: WorkspaceRoleSource } function formatJoinedDate(iso: string) { @@ -129,6 +135,7 @@ export function Teammates() { status: `Joined ${formatJoinedDate(member.joinedAt)}`, isPending: false, userId: member.userId, + roleSource: member.roleSource, })) const pending: Teammate[] = (invitations ?? []).map((invitation) => ({ @@ -215,17 +222,27 @@ export function Teammates() { email={teammate.email} image={teammate.image} status={teammate.status} - roleControl={ - handleRoleChange(teammate, role as WorkspacePermission)} - options={ROLE_OPTIONS} - matchTriggerWidth={false} - disabled={ - teammate.isPending || !canManage || teammate.userId === viewer?.userId - } - /> - } + roleControl={(() => { + const lockReason = teammate.isPending + ? null + : workspaceRoleLockReason(teammate.roleSource) + return ( + + handleRoleChange(teammate, role as WorkspacePermission)} + options={ROLE_OPTIONS} + matchTriggerWidth={false} + disabled={ + teammate.isPending || + !canManage || + teammate.userId === viewer?.userId || + lockReason !== null + } + /> + + ) + })()} menu={ diff --git a/apps/sim/components/permissions/index.ts b/apps/sim/components/permissions/index.ts index d51f10d8805..69aff65d947 100644 --- a/apps/sim/components/permissions/index.ts +++ b/apps/sim/components/permissions/index.ts @@ -4,3 +4,10 @@ export { PermissionSelector, type PermissionType, } from './permission-selector' +export { + type CredentialRoleSource, + credentialRoleLockReason, + RoleLockTooltip, + type WorkspaceRoleSource, + workspaceRoleLockReason, +} from './role-lock' diff --git a/apps/sim/components/permissions/role-lock.tsx b/apps/sim/components/permissions/role-lock.tsx new file mode 100644 index 00000000000..a20029a9ce4 --- /dev/null +++ b/apps/sim/components/permissions/role-lock.tsx @@ -0,0 +1,54 @@ +'use client' + +import type { ReactNode } from 'react' +import { Tooltip } from '@/components/emcn' + +export type WorkspaceRoleSource = 'owner' | 'explicit' | 'org-admin' +export type CredentialRoleSource = 'explicit' | 'workspace-admin' + +/** + * Explanation shown when a workspace member's role is fixed by inheritance and + * cannot be edited. Returns null for editable (`explicit`) roles. + */ +export function workspaceRoleLockReason( + roleSource: WorkspaceRoleSource | undefined +): string | null { + if (roleSource === 'org-admin') return 'Organization admins are automatically workspace admins' + if (roleSource === 'owner') return 'Workspace owner' + return null +} + +/** + * Explanation shown when a credential member's role is fixed because they are a + * workspace admin. Returns null for editable (`explicit`) roles. + */ +export function credentialRoleLockReason( + roleSource: CredentialRoleSource | undefined +): string | null { + if (roleSource === 'workspace-admin') { + return 'Workspace admins are automatically credential admins' + } + return null +} + +interface RoleLockTooltipProps { + reason: string | null + children: ReactNode +} + +/** + * Wraps a disabled role control in a tooltip explaining why the role is fixed. + * Renders children unchanged when there is no lock reason. + */ +export function RoleLockTooltip({ reason, children }: RoleLockTooltipProps) { + if (!reason) return <>{children} + + return ( + + +
{children}
+
+ {reason} +
+ ) +} diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 20c46095a31..cec3b73c7e8 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -229,6 +229,8 @@ export const workspaceCredentialMemberSchema = z.object({ userName: z.string().nullable(), userEmail: z.string().nullable(), userImage: z.string().nullable().optional(), + /** `workspace-admin` roles are derived from workspace admin and cannot be changed. */ + roleSource: z.enum(['explicit', 'workspace-admin']).optional(), }) export type WorkspaceCredentialMember = z.output diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 361004bdf25..ebee4b66972 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -84,6 +84,7 @@ export const workspaceUserSchema = z.object({ permissionType: workspacePermissionSchema, isExternal: z.boolean(), joinedAt: z.string(), + roleSource: z.enum(['owner', 'explicit', 'org-admin']), }) export type WorkspaceUser = z.output diff --git a/apps/sim/lib/auth/credential-access.ts b/apps/sim/lib/auth/credential-access.ts index 97a9bf50b15..5dcddf5d45f 100644 --- a/apps/sim/lib/auth/credential-access.ts +++ b/apps/sim/lib/auth/credential-access.ts @@ -97,16 +97,16 @@ export async function authorizeCredentialUse( ) .limit(1) - if (!membership) { + if (requesterPerm === null) { + return { ok: false, error: 'You do not have access to this workspace.' } + } + if (!membership && requesterPerm !== 'admin') { return { ok: false, error: 'You do not have access to this credential. Ask the credential admin to add you as a member.', } } - if (requesterPerm === null) { - return { ok: false, error: 'You do not have access to this workspace.' } - } return { ok: true, @@ -155,16 +155,16 @@ export async function authorizeCredentialUse( ) .limit(1) - if (!membership) { + if (requesterPerm === null) { return { ok: false, - error: `You do not have access to this credential. Ask the credential admin to add you as a member.`, + error: 'You do not have access to this workspace.', } } - if (requesterPerm === null) { + if (!membership && requesterPerm !== 'admin') { return { ok: false, - error: 'You do not have access to this workspace.', + error: `You do not have access to this credential. Ask the credential admin to add you as a member.`, } } @@ -232,10 +232,17 @@ export async function authorizeCredentialUse( .limit(1) if (!membership) { - return { - ok: false, - error: - 'You do not have access to this credential. Ask the credential admin to add you as a member.', + const requesterPerm = await getUserEntityPermissions( + actingUserId, + 'workspace', + workflowContext.workspaceId + ) + if (requesterPerm !== 'admin') { + return { + ok: false, + error: + 'You do not have access to this credential. Ask the credential admin to add you as a member.', + } } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 0038755e603..bb24e9221e1 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -120,6 +120,7 @@ import { assertActiveWorkspaceAccess, getUsersWithPermissions, getWorkspaceWithOwner, + hasWorkspaceAdminAccess, } from '@/lib/workspaces/permissions/utils' import { computeNeedsRedeployment } from '@/app/api/workflows/utils' import { getAllBlocks } from '@/blocks/registry' @@ -2151,9 +2152,10 @@ export class WorkspaceVFS { envVariables: WorkspaceMdData['envVariables'] }> { try { + const isWorkspaceAdmin = await hasWorkspaceAdminAccess(userId, workspaceId) const [envCredentials, oauthCredentials, apiKeyRows, envData] = await Promise.all([ - getAccessibleEnvCredentials(workspaceId, userId), - getAccessibleOAuthCredentials(workspaceId, userId), + getAccessibleEnvCredentials(workspaceId, userId, { isWorkspaceAdmin }), + getAccessibleOAuthCredentials(workspaceId, userId, { isWorkspaceAdmin }), listApiKeys(workspaceId), getPersonalAndWorkspaceEnv(userId, workspaceId), ]) diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index e980a452429..81ac39aea01 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -29,7 +29,7 @@ try { } catch { // invalid URL — isHosted stays false } -export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') +export const isHosted = true /** * Is billing enforcement enabled diff --git a/apps/sim/lib/credentials/access.test.ts b/apps/sim/lib/credentials/access.test.ts new file mode 100644 index 00000000000..f40fee1d931 --- /dev/null +++ b/apps/sim/lib/credentials/access.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckWorkspaceAccess, dbState } = vi.hoisted(() => ({ + mockCheckWorkspaceAccess: vi.fn(), + dbState: { results: [] as any[][] }, +})) + +function makeChain() { + const chain: any = {} + chain.from = vi.fn(() => chain) + chain.where = vi.fn(() => chain) + chain.limit = vi.fn(() => Promise.resolve(dbState.results.shift() ?? [])) + return chain +} + +vi.mock('@sim/db', () => ({ + db: { select: vi.fn(() => makeChain()) }, +})) + +vi.mock('@sim/db/schema', () => ({ + credential: { + id: 'credential.id', + workspaceId: 'credential.workspaceId', + type: 'credential.type', + }, + credentialMember: { + credentialId: 'credentialMember.credentialId', + userId: 'credentialMember.userId', + status: 'credentialMember.status', + role: 'credentialMember.role', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => ({ and: args })), + eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), + inArray: vi.fn((a: unknown, b: unknown) => ({ inArray: [a, b] })), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +import { getCredentialActorContext } from '@/lib/credentials/access' + +const workspaceAdminAccess = { hasAccess: true, canWrite: true, canAdmin: true } +const noWorkspaceAccess = { hasAccess: false, canWrite: false, canAdmin: false } + +describe('getCredentialActorContext', () => { + beforeEach(() => { + vi.clearAllMocks() + dbState.results = [] + }) + + it('treats an explicit credential admin membership as admin', async () => { + dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], [{ role: 'admin' }]] + mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + + const ctx = await getCredentialActorContext('c1', 'user1') + + expect(ctx.isAdmin).toBe(true) + }) + + it('derives credential admin from workspace admin for shared credentials', async () => { + dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], []] + mockCheckWorkspaceAccess.mockResolvedValue(workspaceAdminAccess) + + const ctx = await getCredentialActorContext('c1', 'admin-user') + + expect(ctx.isAdmin).toBe(true) + }) + + it('does not derive credential admin on personal env credentials', async () => { + dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'env_personal' }], []] + mockCheckWorkspaceAccess.mockResolvedValue(workspaceAdminAccess) + + const ctx = await getCredentialActorContext('c1', 'admin-user') + + expect(ctx.isAdmin).toBe(false) + }) + + it('is not admin for a non-admin without membership', async () => { + dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], []] + mockCheckWorkspaceAccess.mockResolvedValue({ + hasAccess: true, + canWrite: false, + canAdmin: false, + }) + + const ctx = await getCredentialActorContext('c1', 'reader-user') + + expect(ctx.isAdmin).toBe(false) + }) + + it('returns empty context when the credential does not exist', async () => { + dbState.results = [[]] + + const ctx = await getCredentialActorContext('missing', 'user1') + + expect(ctx.credential).toBeNull() + expect(ctx.isAdmin).toBe(false) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + }) + + it('exposes workspace access flags from checkWorkspaceAccess', async () => { + dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], []] + mockCheckWorkspaceAccess.mockResolvedValue(noWorkspaceAccess) + + const ctx = await getCredentialActorContext('c1', 'outsider') + + expect(ctx.hasWorkspaceAccess).toBe(false) + expect(ctx.canWriteWorkspace).toBe(false) + expect(ctx.isAdmin).toBe(false) + }) +}) diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 0593160b739..19249ef9614 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -1,13 +1,9 @@ import { db } from '@sim/db' -import { credential, credentialMember, workspace } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, inArray, ne } from 'drizzle-orm' +import { credential, credentialMember } from '@sim/db/schema' +import { and, eq, inArray } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -const logger = createLogger('CredentialAccess') - type ActiveCredentialMember = typeof credentialMember.$inferSelect type CredentialRecord = typeof credential.$inferSelect @@ -55,7 +51,9 @@ export async function getCredentialActorContext( ) .limit(1) - const isAdmin = memberRow?.role === 'admin' + const isAdmin = + memberRow?.role === 'admin' || + (credentialRow.type !== 'env_personal' && workspaceAccess.canAdmin) return { credential: credentialRow, @@ -67,9 +65,9 @@ export async function getCredentialActorContext( } /** - * Revokes all credential memberships for a user across a workspace. - * Before revoking, ensures the workspace owner is an admin on any credential - * where the removed user is the sole active admin, preventing orphaned credentials. + * Revokes all credential memberships for a user across a workspace. Workspace + * owners and admins are derived credential admins, so no per-credential owner + * promotion is needed to avoid orphaning a credential. */ export async function revokeWorkspaceCredentialMemberships( workspaceId: string, @@ -92,77 +90,6 @@ export async function revokeWorkspaceCredentialMembershipsTx( const credIds = workspaceCredentialIds.map((c) => c.id) - const [workspaceRow] = await tx - .select({ ownerId: workspace.ownerId }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - - const ownerId = workspaceRow?.ownerId - - if (ownerId && ownerId !== userId) { - const userAdminMemberships = await tx - .select({ credentialId: credentialMember.credentialId }) - .from(credentialMember) - .where( - and( - eq(credentialMember.userId, userId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active'), - inArray(credentialMember.credentialId, credIds) - ) - ) - - for (const { credentialId: credId } of userAdminMemberships) { - const otherAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active'), - ne(credentialMember.userId, userId) - ) - ) - .limit(1) - - if (otherAdmins.length > 0) continue - - const now = new Date() - const [existingOwnerMembership] = await tx - .select({ id: credentialMember.id, status: credentialMember.status }) - .from(credentialMember) - .where(and(eq(credentialMember.credentialId, credId), eq(credentialMember.userId, ownerId))) - .limit(1) - - if (existingOwnerMembership) { - await tx - .update(credentialMember) - .set({ role: 'admin', status: 'active', updatedAt: now }) - .where(eq(credentialMember.id, existingOwnerMembership.id)) - } else { - await tx.insert(credentialMember).values({ - id: generateId(), - credentialId: credId, - userId: ownerId, - role: 'admin', - status: 'active', - joinedAt: now, - invitedBy: ownerId, - createdAt: now, - updatedAt: now, - }) - } - - logger.info('Assigned workspace owner as credential admin before member removal', { - credentialId: credId, - ownerId, - removedUserId: userId, - }) - } - } - await tx .update(credentialMember) .set({ status: 'revoked', updatedAt: new Date() }) diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index 1910ccd2903..5fd7b711adf 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -1,25 +1,19 @@ import { db } from '@sim/db' import { credential, credentialMember, permissions, workspace } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNull, notInArray, sql } from 'drizzle-orm' +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[] - /** - * Members who default to a credential **admin** role on shared workspace - * credentials (secrets and service accounts): the owner plus anyone with - * workspace `admin` permission. Manual per-credential overrides are preserved - * separately on re-sync. - */ - adminUserIds: Set } /** - * Resolves a workspace's membership in one owner lookup + one permissions scan, - * returning both the full member set and the admin-defaulting subset (owner + - * workspace `admin` permission). + * 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 { const [workspaceRows, permissionRows] = await Promise.all([ @@ -29,22 +23,18 @@ export async function getWorkspaceMembership(workspaceId: string): Promise(permissionRows.map((row) => row.userId)) - const adminUserIds = new Set( - permissionRows.filter((row) => row.permissionType === 'admin').map((row) => row.userId) - ) if (ownerId) { memberUserIds.add(ownerId) - adminUserIds.add(ownerId) } - return { ownerId, memberUserIds: Array.from(memberUserIds), adminUserIds } + return { ownerId, memberUserIds: Array.from(memberUserIds) } } export interface WorkspaceEnvKeyAdminAccess { @@ -132,7 +122,6 @@ export async function getUserWorkspaceIds(userId: string): Promise { async function ensureWorkspaceCredentialMemberships( credentialId: string, memberUserIds: string[], - adminUserIds: Set, invitedBy: string ) { if (!memberUserIds.length) return @@ -162,7 +151,7 @@ async function ensureWorkspaceCredentialMemberships( id: generateId(), credentialId, userId: memberUserId, - role: (adminUserIds.has(memberUserId) ? 'admin' : 'member') as 'admin' | 'member', + role: 'member' as const, status: 'active' as const, joinedAt: now, invitedBy, @@ -191,7 +180,7 @@ export async function syncWorkspaceEnvCredentials(params: { actingUserId: string }) { const { workspaceId, envKeys, actingUserId } = params - const { ownerId, memberUserIds, adminUserIds } = await getWorkspaceMembership(workspaceId) + const { ownerId, memberUserIds } = await getWorkspaceMembership(workspaceId) if (!ownerId) return @@ -242,7 +231,7 @@ export async function syncWorkspaceEnvCredentials(params: { } for (const credentialId of credentialIdsToEnsureMembership) { - await ensureWorkspaceCredentialMemberships(credentialId, memberUserIds, adminUserIds, ownerId) + await ensureWorkspaceCredentialMemberships(credentialId, memberUserIds, ownerId) } if (normalizedKeys.length > 0) { @@ -276,7 +265,7 @@ export async function createWorkspaceEnvCredentials(params: { const keys = Array.from(new Set(newKeys.filter(Boolean))) if (keys.length === 0) return - const { ownerId, memberUserIds, adminUserIds } = await getWorkspaceMembership(workspaceId) + const { ownerId, memberUserIds } = await getWorkspaceMembership(workspaceId) if (!ownerId) return @@ -308,9 +297,7 @@ export async function createWorkspaceEnvCredentials(params: { id: generateId(), credentialId, userId: memberUserId, - role: (adminUserIds.has(memberUserId) || memberUserId === actingUserId - ? 'admin' - : 'member') as 'admin' | 'member', + role: (memberUserId === actingUserId ? 'admin' : 'member') as 'admin' | 'member', status: 'active' as const, joinedAt: now, invitedBy: actingUserId, @@ -442,8 +429,12 @@ export async function syncPersonalEnvCredentialsForUser(params: { export async function getAccessibleEnvCredentials( workspaceId: string, - userId: string + userId: string, + options?: { isWorkspaceAdmin?: boolean } ): Promise { + const isWorkspaceAdmin = + options?.isWorkspaceAdmin ?? (await hasWorkspaceAdminAccess(userId, workspaceId)) + const rows = await db .select({ type: credential.type, @@ -452,7 +443,7 @@ export async function getAccessibleEnvCredentials( updatedAt: credential.updatedAt, }) .from(credential) - .innerJoin( + .leftJoin( credentialMember, and( eq(credentialMember.credentialId, credential.id), @@ -463,18 +454,23 @@ export async function getAccessibleEnvCredentials( .where( and( eq(credential.workspaceId, workspaceId), - inArray(credential.type, ['env_workspace', 'env_personal']) + 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 AccessibleEnvCredential => - (row.type === 'env_workspace' || row.type === 'env_personal') && Boolean(row.envKey) + (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!, + envKey: row.envKey, envOwnerUserId: row.envOwnerUserId, updatedAt: row.updatedAt, })) @@ -490,8 +486,39 @@ export interface AccessibleOAuthCredential { export async function getAccessibleOAuthCredentials( workspaceId: string, - userId: string + userId: string, + options?: { isWorkspaceAdmin?: boolean } ): Promise { + 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, diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index 20a5e4e95ae..d4922c3076e 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -101,11 +101,13 @@ export async function getPersonalAndWorkspaceEnv( conflicts: string[] decryptionFailures: string[] }> { + let workspaceCanAdmin = false if (workspaceId) { const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.hasAccess) { throw new Error(`Access denied to workspace ${workspaceId}`) } + workspaceCanAdmin = access.canAdmin } const [personalRows, workspaceRows, accessibleEnvCredentials] = await Promise.all([ @@ -117,7 +119,9 @@ export async function getPersonalAndWorkspaceEnv( .where(eq(workspaceEnvironment.workspaceId, workspaceId)) .limit(1) : Promise.resolve([] as any[]), - workspaceId ? getAccessibleEnvCredentials(workspaceId, userId) : Promise.resolve([]), + workspaceId + ? getAccessibleEnvCredentials(workspaceId, userId, { isWorkspaceAdmin: workspaceCanAdmin }) + : Promise.resolve([]), ]) const ownPersonalEncrypted: Record = (personalRows[0]?.variables as any) || {} diff --git a/apps/sim/lib/workspaces/permissions/utils.test.ts b/apps/sim/lib/workspaces/permissions/utils.test.ts index 1a9c8c96f9f..aec68b1eb4c 100644 --- a/apps/sim/lib/workspaces/permissions/utils.test.ts +++ b/apps/sim/lib/workspaces/permissions/utils.test.ts @@ -54,7 +54,7 @@ describe('Permission Utils', () => { const chain = createMockChain(mockResults) mockDb.select.mockReturnValue(chain) - const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456') + const result = await getUserEntityPermissions('user123', 'workflow', 'workflow456') expect(result).toBe('admin') }) @@ -78,7 +78,7 @@ describe('Permission Utils', () => { const chain = createMockChain(mockResults) mockDb.select.mockReturnValue(chain) - const result = await getUserEntityPermissions('user999', 'workspace', 'workspace999') + const result = await getUserEntityPermissions('user999', 'workflow', 'workflow999') expect(result).toBe('admin') }) @@ -101,7 +101,7 @@ describe('Permission Utils', () => { const chain = createMockChain(mockResults) mockDb.select.mockReturnValue(chain) - const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456') + const result = await getUserEntityPermissions('user123', 'workflow', 'workflow456') expect(result).toBe('write') }) @@ -194,29 +194,36 @@ describe('Permission Utils', () => { }) describe('getUsersWithPermissions', () => { - it('should return empty array when no users have permissions for workspace', async () => { - const usersChain = createMockChain([]) - mockDb.select.mockReturnValue(usersChain) + 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 permissions for workspace', async () => { - const mockUsersResults = [ - { - userId: 'user1', - email: 'alice@example.com', - name: 'Alice Smith', - image: 'https://example.com/alice.png', - permissionType: 'admin' as PermissionType, - joinedAt: new Date('2026-04-22T00:00:00.000Z'), - }, - ] - - const usersChain = createMockChain(mockUsersResults) - mockDb.select.mockReturnValue(usersChain) + 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') @@ -229,111 +236,165 @@ describe('Permission Utils', () => { permissionType: 'admin', isExternal: false, joinedAt: '2026-04-22T00:00:00.000Z', + roleSource: 'explicit', }, ]) }) - it('marks users as external when they are not members of the workspace organization', async () => { - const mockUsersResults = [ - { - userId: 'internal-user', - email: 'internal@example.com', - name: 'Internal User', - image: null, - permissionType: 'admin' as PermissionType, - joinedAt: new Date('2026-04-22T00:00:00.000Z'), - workspaceOrganizationId: 'org-1', - workspaceOwnerId: 'internal-user', - userOrganizationId: 'org-1', - }, - { - userId: 'external-user', - email: 'external@example.com', - name: 'External User', - image: null, - permissionType: 'write' as PermissionType, - joinedAt: new Date('2026-04-22T00:00:00.000Z'), - workspaceOrganizationId: 'org-1', - workspaceOwnerId: 'internal-user', - userOrganizationId: 'org-2', - }, - ] - - const usersChain = createMockChain(mockUsersResults) - mockDb.select.mockReturnValue(usersChain) + 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.map((u) => ({ email: u.email, isExternal: u.isExternal }))).toEqual([ - { email: 'internal@example.com', isExternal: false }, - { email: 'external@example.com', isExternal: true }, + 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, + }, + ], ]) - }) - it('marks a non-owner member of another org as external on a personal workspace', async () => { - const mockUsersResults = [ - { - userId: 'owner-user', - email: 'owner@example.com', - name: 'Owner', - image: null, - permissionType: 'admin' as PermissionType, - joinedAt: new Date('2026-04-22T00:00:00.000Z'), - workspaceOrganizationId: null, - workspaceOwnerId: 'owner-user', - userOrganizationId: null, - }, - { - userId: 'guest-user', - email: 'guest@example.com', - name: 'Guest', - image: null, - permissionType: 'write' as PermissionType, - joinedAt: new Date('2026-04-22T00:00:00.000Z'), - workspaceOrganizationId: null, - workspaceOwnerId: 'owner-user', - userOrganizationId: 'org-guest', - }, - ] + const result = await getUsersWithPermissions('ws') + const orgAdmin = result.find((u) => u.userId === 'org-admin-user') - const usersChain = createMockChain(mockUsersResults) - mockDb.select.mockReturnValue(usersChain) - - const result = await getUsersWithPermissions('workspace-personal') + expect(orgAdmin).toMatchObject({ + permissionType: 'admin', + roleSource: 'org-admin', + isExternal: false, + }) + }) - expect(result.map((u) => ({ email: u.email, isExternal: u.isExternal }))).toEqual([ - { email: 'owner@example.com', isExternal: false }, - { email: 'guest@example.com', isExternal: true }, + 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('should return multiple users with different permission levels', async () => { - const mockUsersResults = [ - { - userId: 'user1', - email: 'admin@example.com', - name: 'Admin User', - permissionType: 'admin' as PermissionType, - joinedAt: new Date('2026-04-22T00:00:00.000Z'), - }, - { - userId: 'user2', - email: 'writer@example.com', - name: 'Writer User', - permissionType: 'write' as PermissionType, - joinedAt: new Date('2026-04-22T00:00:00.000Z'), - }, - { - userId: 'user3', - email: 'reader@example.com', - name: 'Reader User', - permissionType: 'read' as PermissionType, - joinedAt: new Date('2026-04-22T00:00:00.000Z'), - }, - ] + 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 usersChain = createMockChain(mockUsersResults) - mockDb.select.mockReturnValue(usersChain) + 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') @@ -344,18 +405,20 @@ describe('Permission Utils', () => { }) it('should handle users with empty names', async () => { - const mockUsersResults = [ - { - userId: 'user1', - email: 'test@example.com', - name: '', - permissionType: 'read' as PermissionType, - joinedAt: new Date('2026-04-22T00:00:00.000Z'), - }, - ] - - const usersChain = createMockChain(mockUsersResults) - mockDb.select.mockReturnValue(usersChain) + 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') @@ -380,7 +443,7 @@ describe('Permission Utils', () => { if (callCount === 1) { return createMockChain([{ ownerId: 'other-user' }]) } - return createMockChain([{ id: 'perm1' }]) + return createMockChain([{ permissionType: 'admin' }]) }) const result = await hasWorkspaceAdminAccess('user123', 'workspace456') @@ -647,7 +710,7 @@ describe('Permission Utils', () => { const result = await getManageableWorkspaces('user123') - expect(result).toHaveLength(2) // Should include duplicates from admin permissions + expect(result).toHaveLength(1) }) it('should handle empty user ID gracefully', async () => { @@ -758,6 +821,7 @@ describe('Permission Utils', () => { exists: false, hasAccess: false, canWrite: false, + canAdmin: false, workspace: null, }) }) @@ -772,6 +836,7 @@ describe('Permission Utils', () => { exists: true, hasAccess: true, canWrite: true, + canAdmin: true, workspace: { id: 'workspace123', ownerId: 'user123' }, }) }) @@ -864,4 +929,70 @@ describe('Permission Utils', () => { 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) + }) + }) }) diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index c2db39c7135..0c4b0b8b93b 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -7,8 +7,9 @@ import { type WorkspaceMode, workspace, } from '@sim/db/schema' -import { and, eq, isNull } from 'drizzle-orm' +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 = (typeof permissionTypeEnum.enumValues)[number] export interface WorkspaceBasic { @@ -29,6 +30,7 @@ export interface WorkspaceAccess { exists: boolean hasAccess: boolean canWrite: boolean + canAdmin: boolean workspace: WorkspaceWithOwner | null } @@ -102,28 +104,23 @@ export async function getWorkspaceWithOwner( return ws || null } +const PERMISSION_RANK: Record = { admin: 3, write: 2, read: 1 } + /** - * 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 and permissions. + * Resolve the effective workspace permission for a user under the governance + * inheritance model: the workspace owner and the owners/admins of the + * organization that owns the workspace are workspace admins. Returns the higher + * of any explicit grant and any derived admin. * - * @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 + * @param userId - The user to resolve the permission for + * @param ws - The workspace (owner + organization already loaded) */ -export async function checkWorkspaceAccess( - workspaceId: string, - userId: string -): Promise { - const ws = await getWorkspaceWithOwner(workspaceId) - - if (!ws) { - return { exists: false, hasAccess: false, canWrite: false, workspace: null } - } - +export async function getEffectiveWorkspacePermission( + userId: string, + ws: Pick +): Promise { if (ws.ownerId === userId) { - return { exists: true, hasAccess: true, canWrite: true, workspace: ws } + return 'admin' } const [permissionRow] = await db @@ -133,19 +130,49 @@ export async function checkWorkspaceAccess( and( eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) + eq(permissions.entityId, ws.id) ) ) .limit(1) - if (!permissionRow) { - return { exists: true, hasAccess: false, canWrite: false, workspace: ws } + const explicit = permissionRow?.permissionType ?? null + + if (ws.organizationId && explicit !== 'admin') { + if (await isOrganizationAdminOrOwner(userId, ws.organizationId)) { + return 'admin' + } } - const canWrite = - permissionRow.permissionType === 'write' || permissionRow.permissionType === 'admin' + return explicit +} - return { exists: true, hasAccess: true, canWrite, workspace: ws } +/** + * 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 { + const ws = await getWorkspaceWithOwner(workspaceId) + + if (!ws) { + return { exists: false, hasAccess: false, canWrite: false, canAdmin: false, workspace: null } + } + + const permission = await getEffectiveWorkspacePermission(userId, ws) + const hasAccess = permission !== null + const canWrite = permission === 'write' || permission === 'admin' + const canAdmin = permission === 'admin' + + return { exists: true, hasAccess, canWrite, canAdmin, workspace: ws } } /** @@ -194,10 +221,11 @@ export async function getUserEntityPermissions( entityId: string ): Promise { if (entityType === 'workspace') { - const activeWorkspace = await workspaceExists(entityId) - if (!activeWorkspace) { + const ws = await getWorkspaceWithOwner(entityId) + if (!ws) { return null } + return getEffectiveWorkspacePermission(userId, ws) } const result = await db @@ -215,9 +243,8 @@ export async function getUserEntityPermissions( return null } - const permissionOrder: Record = { admin: 3, write: 2, read: 1 } const highestPermission = result.reduce((highest, current) => { - return permissionOrder[current.permissionType] > permissionOrder[highest.permissionType] + return PERMISSION_RANK[current.permissionType] > PERMISSION_RANK[highest.permissionType] ? current : highest }) @@ -261,18 +288,30 @@ export async function hasAdminPermission(userId: string, workspaceId: string): P * @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 async function getUsersWithPermissions(workspaceId: string): Promise< - Array<{ - userId: string - email: string - name: string - image: string | null - permissionType: PermissionType - isExternal: boolean - joinedAt: string - }> -> { - const usersWithPermissions = await db +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 { + const ws = await getWorkspaceWithOwner(workspaceId) + if (!ws) return [] + + const explicitRows = await db .select({ userId: user.id, email: user.email, @@ -280,33 +319,69 @@ export async function getUsersWithPermissions(workspaceId: string): Promise< image: user.image, permissionType: permissions.permissionType, joinedAt: permissions.createdAt, - workspaceOrganizationId: workspace.organizationId, - workspaceOwnerId: workspace.ownerId, userOrganizationId: member.organizationId, }) .from(permissions) .innerJoin(user, eq(permissions.userId, user.id)) - .innerJoin(workspace, eq(permissions.entityId, workspace.id)) .leftJoin(member, eq(member.userId, user.id)) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - isNull(workspace.archivedAt) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) + + const byUser = new Map() + + 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, ['owner', 'admin'])) ) - ) - .orderBy(user.email) - - return usersWithPermissions.map((row) => ({ - userId: row.userId, - email: row.email, - name: row.name, - image: row.image ?? null, - permissionType: row.permissionType, - isExternal: - row.userId !== row.workspaceOwnerId && row.userOrganizationId !== row.workspaceOrganizationId, - joinedAt: row.joinedAt.toISOString(), - })) + + 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). */ @@ -360,15 +435,7 @@ export async function hasWorkspaceAdminAccess( return false } - if (ws.ownerId === userId) { - return true - } - - if (await hasAdminPermission(userId, workspaceId)) { - return true - } - - return await isOrganizationAdminOrOwnerOfWorkspace(userId, ws) + return (await getEffectiveWorkspacePermission(userId, ws)) === 'admin' } /** @@ -409,14 +476,6 @@ export async function isOrganizationMember( return !!row } -async function isOrganizationAdminOrOwnerOfWorkspace( - userId: string, - ws: Pick -): Promise { - if (!ws.organizationId) return false - return isOrganizationAdminOrOwner(userId, ws.organizationId) -} - /** * Get a list of workspaces that the user has access to * @@ -462,13 +521,26 @@ export async function getManageableWorkspaces(userId: string): Promise< ) ) + 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 combined = [ - ...ownedWorkspaces.map((ws) => ({ ...ws, accessType: 'owner' as const })), - ...adminWorkspaces - .filter((ws) => !ownedSet.has(ws.id)) - .map((ws) => ({ ...ws, accessType: 'direct' as const })), - ] + 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 } diff --git a/apps/sim/lib/workspaces/utils.test.ts b/apps/sim/lib/workspaces/utils.test.ts index 00227a9d795..d540670dc70 100644 --- a/apps/sim/lib/workspaces/utils.test.ts +++ b/apps/sim/lib/workspaces/utils.test.ts @@ -1,13 +1,30 @@ /** * @vitest-environment node */ +import { db } from '@sim/db' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/db', () => ({ - db: {}, + db: { select: vi.fn() }, })) -import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx } from '@/lib/workspaces/utils' +import { + listAccessibleWorkspaceRowsForUser, + reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, +} from '@/lib/workspaces/utils' + +const mockDb = db as unknown as { select: ReturnType } + +function createMockChain(finalResult: unknown) { + const chain: any = {} + chain.then = vi.fn().mockImplementation((resolve: any) => resolve(finalResult)) + chain.from = vi.fn().mockReturnValue(chain) + chain.where = vi.fn().mockReturnValue(chain) + chain.innerJoin = vi.fn().mockReturnValue(chain) + chain.limit = vi.fn().mockReturnValue(chain) + chain.orderBy = vi.fn().mockReturnValue(chain) + return chain +} function createSelectChain(result: unknown) { const limit = vi.fn().mockResolvedValue(result) @@ -132,3 +149,46 @@ describe('reassignWorkflowOwnershipForWorkspaceMemberRemovalTx', () => { expect(result).toEqual({ reassigned: [], unresolved: ['workspace-1'] }) }) }) + +describe('listAccessibleWorkspaceRowsForUser', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('elevates an org admin to admin on an org workspace where they hold a lower explicit grant', async () => { + const orgWorkspace = { id: 'ws-1', name: 'Shared', ownerId: 'owner-x', organizationId: 'org-1' } + + mockDb.select + .mockReturnValueOnce(createMockChain([{ workspace: orgWorkspace, permissionType: 'write' }])) + .mockReturnValueOnce(createMockChain([{ organizationId: 'org-1', role: 'admin' }])) + .mockReturnValueOnce(createMockChain([orgWorkspace])) + + const rows = await listAccessibleWorkspaceRowsForUser('user-1', 'active') + + expect(rows).toEqual([{ workspace: orgWorkspace, permissionType: 'admin' }]) + }) + + it('keeps a lower explicit grant on a workspace owned by a different organization', async () => { + const externalWorkspace = { + id: 'ws-ext', + name: 'External', + ownerId: 'owner-y', + organizationId: 'org-2', + } + const orgWorkspace = { id: 'ws-1', name: 'Shared', ownerId: 'owner-x', organizationId: 'org-1' } + + mockDb.select + .mockReturnValueOnce( + createMockChain([{ workspace: externalWorkspace, permissionType: 'write' }]) + ) + .mockReturnValueOnce(createMockChain([{ organizationId: 'org-1', role: 'admin' }])) + .mockReturnValueOnce(createMockChain([orgWorkspace])) + + const rows = await listAccessibleWorkspaceRowsForUser('user-1', 'active') + + expect(rows).toEqual([ + { workspace: externalWorkspace, permissionType: 'write' }, + { workspace: orgWorkspace, permissionType: 'admin' }, + ]) + }) +}) diff --git a/apps/sim/lib/workspaces/utils.ts b/apps/sim/lib/workspaces/utils.ts index 88ea402ecdf..17f34f1669e 100644 --- a/apps/sim/lib/workspaces/utils.ts +++ b/apps/sim/lib/workspaces/utils.ts @@ -1,9 +1,10 @@ import { db } from '@sim/db' -import { permissions, workflow, workspace as workspaceTable } from '@sim/db/schema' +import { member, permissions, workflow, workspace as workspaceTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, count, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' +import type { PermissionType } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceUtils') @@ -45,14 +46,48 @@ export async function getWorkspaceBilledAccountUserId(workspaceId: string): Prom return settings?.billedAccountUserId ?? null } -export async function listUserWorkspaces(userId: string, scope: WorkspaceScope = 'active') { - const workspaces = await db - .select({ - workspaceId: workspaceTable.id, - workspaceName: workspaceTable.name, - ownerId: workspaceTable.ownerId, - permissionType: permissions.permissionType, - }) +/** + * Workspaces the user administers purely through organization owner/admin role, + * with no explicit permission row required. Empty when the user is not an org + * owner/admin. Implements the workspace-permission inheritance model. + */ +export async function getOrgAdminWorkspaceRows( + userId: string, + scope: WorkspaceScope = 'active' +): Promise> { + const [membership] = await db + .select({ organizationId: member.organizationId, role: member.role }) + .from(member) + .where(eq(member.userId, userId)) + .limit(1) + + if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) { + return [] + } + + const orgFilter = eq(workspaceTable.organizationId, membership.organizationId) + const where = + scope === 'all' + ? orgFilter + : scope === 'archived' + ? and(orgFilter, sql`${workspaceTable.archivedAt} IS NOT NULL`) + : and(orgFilter, isNull(workspaceTable.archivedAt)) + + return db.select().from(workspaceTable).where(where).orderBy(desc(workspaceTable.createdAt)) +} + +/** + * Every workspace a user can access: explicit permission grants plus workspaces + * derived from organization owner/admin role. Deduped with explicit rows first. + */ +export async function listAccessibleWorkspaceRowsForUser( + userId: string, + scope: WorkspaceScope = 'active' +): Promise< + Array<{ workspace: typeof workspaceTable.$inferSelect; permissionType: PermissionType }> +> { + const explicit = await db + .select({ workspace: workspaceTable, permissionType: permissions.permissionType }) .from(permissions) .innerJoin(workspaceTable, eq(permissions.entityId, workspaceTable.id)) .where( @@ -72,10 +107,31 @@ export async function listUserWorkspaces(userId: string, scope: WorkspaceScope = ) .orderBy(desc(workspaceTable.createdAt)) - return workspaces.map((row) => ({ - workspaceId: row.workspaceId, - workspaceName: row.workspaceName, - role: row.ownerId === userId ? 'owner' : row.permissionType, + const orgRows = await getOrgAdminWorkspaceRows(userId, scope) + if (orgRows.length === 0) { + return explicit + } + + const orgWorkspaceIds = new Set(orgRows.map((ws) => ws.id)) + const seen = new Set(explicit.map((row) => row.workspace.id)) + + const elevatedExplicit = explicit.map((row) => + orgWorkspaceIds.has(row.workspace.id) ? { ...row, permissionType: 'admin' as const } : row + ) + const derived = orgRows + .filter((ws) => !seen.has(ws.id)) + .map((ws) => ({ workspace: ws, permissionType: 'admin' as const })) + + return [...elevatedExplicit, ...derived] +} + +export async function listUserWorkspaces(userId: string, scope: WorkspaceScope = 'active') { + const rows = await listAccessibleWorkspaceRowsForUser(userId, scope) + + return rows.map(({ workspace: ws, permissionType }) => ({ + workspaceId: ws.id, + workspaceName: ws.name, + role: ws.ownerId === userId ? 'owner' : permissionType, })) } diff --git a/packages/workflow-authz/src/index.ts b/packages/workflow-authz/src/index.ts index 5eef77f8e08..93e5aee8812 100644 --- a/packages/workflow-authz/src/index.ts +++ b/packages/workflow-authz/src/index.ts @@ -1,5 +1,6 @@ import { db, + member, permissions, type permissionTypeEnum, workflow, @@ -13,6 +14,8 @@ export type ActiveWorkflowRecord = typeof workflow.$inferSelect export interface ActiveWorkflowContext { workflow: ActiveWorkflowRecord workspaceId: string + workspaceOwnerId: string + workspaceOrganizationId: string | null } export async function getActiveWorkflowContext( @@ -22,6 +25,8 @@ export async function getActiveWorkflowContext( .select({ workflow, workspaceId: workspace.id, + workspaceOwnerId: workspace.ownerId, + workspaceOrganizationId: workspace.organizationId, }) .from(workflow) .innerJoin(workspace, eq(workflow.workspaceId, workspace.id)) @@ -37,6 +42,8 @@ export async function getActiveWorkflowContext( return { workflow: rows[0].workflow, workspaceId: rows[0].workspaceId, + workspaceOwnerId: rows[0].workspaceOwnerId, + workspaceOrganizationId: rows[0].workspaceOrganizationId, } } @@ -245,6 +252,51 @@ export interface WorkflowWorkspaceAuthorizationResult { workspacePermission: PermissionType | null } +/** + * Resolves the effective workspace permission under the governance inheritance + * model: the workspace owner and the owners/admins of the organization that + * owns the workspace are workspace admins. Mirrors + * `getEffectiveWorkspacePermission` in `apps/sim` (duplicated here because this + * package may not import app code). + */ +async function resolveEffectiveWorkspacePermission( + userId: string, + workspaceId: string, + workspaceOwnerId: string, + workspaceOrganizationId: string | null +): Promise { + if (workspaceOwnerId === userId) { + return 'admin' + } + + const [permissionRow] = await db + .select({ permissionType: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .limit(1) + + const explicit = (permissionRow?.permissionType as PermissionType | undefined) ?? null + + if (workspaceOrganizationId && explicit !== 'admin') { + const [memberRow] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, workspaceOrganizationId))) + .limit(1) + if (memberRow?.role === 'owner' || memberRow?.role === 'admin') { + return 'admin' + } + } + + return explicit +} + export async function authorizeWorkflowByWorkspacePermission(params: { workflowId: string userId: string @@ -276,19 +328,12 @@ export async function authorizeWorkflowByWorkspacePermission(params: { } } - const [permissionRow] = await db - .select({ permissionType: permissions.permissionType }) - .from(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, wf.workspaceId) - ) - ) - .limit(1) - - const workspacePermission = (permissionRow?.permissionType as PermissionType | undefined) ?? null + const workspacePermission = await resolveEffectiveWorkspacePermission( + userId, + wf.workspaceId, + activeContext.workspaceOwnerId, + activeContext.workspaceOrganizationId + ) if (workspacePermission === null) { return { From fc753a8a337de61e3c621eec4f45ee312dfac771 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 18 Jun 2026 18:17:35 -0700 Subject: [PATCH 2/3] revert isHosted --- apps/sim/lib/core/config/env-flags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 81ac39aea01..e980a452429 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -29,7 +29,7 @@ try { } catch { // invalid URL — isHosted stays false } -export const isHosted = true +export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') /** * Is billing enforcement enabled From 3ab3a0cae675d38126e508e395b276c514812f4b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 18 Jun 2026 21:14:13 -0700 Subject: [PATCH 3/3] improvement(credentials): code cleanup --- .claude/rules/sim-architecture.md | 2 +- .cursor/rules/sim-architecture.mdc | 2 +- .cursor/rules/sim-testing.mdc | 2 +- .github/CONTRIBUTING.md | 2 +- AGENTS.md | 4 +- CLAUDE.md | 4 +- apps/realtime/package.json | 2 +- apps/realtime/src/database/operations.ts | 2 +- apps/realtime/src/handlers/operations.ts | 2 +- apps/realtime/src/handlers/subblocks.ts | 2 +- apps/realtime/src/handlers/variables.ts | 2 +- .../src/middleware/permissions.test.ts | 2 +- apps/realtime/src/middleware/permissions.ts | 2 +- apps/sim/AGENTS.md | 2 +- .../app/api/auth/oauth/credentials/route.ts | 75 ++++----- apps/sim/app/api/chat/utils.ts | 2 +- apps/sim/app/api/copilot/chat/queries.ts | 2 +- apps/sim/app/api/copilot/chats/route.test.ts | 9 + apps/sim/app/api/copilot/chats/route.ts | 42 ++--- .../api/copilot/checkpoints/revert/route.ts | 2 +- apps/sim/app/api/copilot/checkpoints/route.ts | 2 +- .../app/api/credentials/[id]/members/route.ts | 17 +- apps/sim/app/api/credentials/[id]/route.ts | 61 +++---- apps/sim/app/api/credentials/draft/route.ts | 21 +-- apps/sim/app/api/credentials/route.ts | 51 +++--- apps/sim/app/api/files/authorization.ts | 4 +- .../app/api/folders/[id]/duplicate/route.ts | 2 +- apps/sim/app/api/folders/[id]/route.ts | 2 +- apps/sim/app/api/folders/reorder/route.ts | 2 +- apps/sim/app/api/folders/route.test.ts | 2 +- apps/sim/app/api/folders/route.ts | 2 +- apps/sim/app/api/guardrails/validate/route.ts | 2 +- apps/sim/app/api/jobs/[jobId]/route.test.ts | 2 +- apps/sim/app/api/jobs/[jobId]/route.ts | 4 +- .../documents/[documentId]/chunks/route.ts | 2 +- .../app/api/knowledge/[id]/documents/route.ts | 2 +- .../knowledge/[id]/documents/upsert/route.ts | 2 +- apps/sim/app/api/knowledge/search/route.ts | 2 +- .../api/logs/execution/[executionId]/route.ts | 34 ++-- apps/sim/app/api/logs/export/route.ts | 23 ++- apps/sim/app/api/logs/stats/route.ts | 35 ++-- apps/sim/app/api/logs/triggers/route.ts | 16 +- apps/sim/app/api/mcp/discover/route.ts | 22 +-- .../organizations/[id]/invitations/route.ts | 5 +- .../[id]/members/[memberId]/route.ts | 7 +- .../api/organizations/[id]/members/route.ts | 5 +- .../api/organizations/[id]/roster/route.ts | 3 +- apps/sim/app/api/organizations/[id]/route.ts | 5 +- apps/sim/app/api/organizations/route.ts | 3 +- apps/sim/app/api/schedules/[id]/route.ts | 2 +- apps/sim/app/api/schedules/route.ts | 2 +- apps/sim/app/api/table/utils.ts | 9 +- apps/sim/app/api/tools/custom/route.ts | 2 +- .../app/api/tools/deployments/deploy/route.ts | 2 +- .../api/tools/deployments/promote/route.ts | 2 +- .../app/api/tools/deployments/routes.test.ts | 3 +- .../api/tools/deployments/undeploy/route.ts | 2 +- apps/sim/app/api/tools/deployments/utils.ts | 2 +- .../v1/admin/workflows/[id]/deploy/route.ts | 2 +- .../app/api/v1/admin/workflows/[id]/route.ts | 2 +- .../versions/[versionId]/activate/route.ts | 2 +- .../v1/admin/workflows/[id]/versions/route.ts | 2 +- apps/sim/app/api/v1/logs/route.ts | 18 +- apps/sim/app/api/v1/middleware.ts | 8 +- .../v1/workflows/[id]/deploy/route.test.ts | 3 +- .../app/api/v1/workflows/[id]/deploy/route.ts | 2 +- .../v1/workflows/[id]/rollback/route.test.ts | 3 +- .../api/v1/workflows/[id]/rollback/route.ts | 2 +- apps/sim/app/api/v1/workflows/[id]/route.ts | 2 +- apps/sim/app/api/v1/workflows/utils.ts | 2 +- apps/sim/app/api/webhooks/[id]/route.ts | 2 +- apps/sim/app/api/webhooks/route.ts | 17 +- .../api/workflows/[id]/autolayout/route.ts | 4 +- .../api/workflows/[id]/chat/status/route.ts | 2 +- .../app/api/workflows/[id]/deploy/route.ts | 2 +- .../deployments/[version]/revert/route.ts | 2 +- .../app/api/workflows/[id]/duplicate/route.ts | 2 +- .../app/api/workflows/[id]/execute/route.ts | 2 +- .../executions/[executionId]/cancel/route.ts | 2 +- .../[executionId]/stream/route.test.ts | 2 +- .../executions/[executionId]/stream/route.ts | 2 +- .../app/api/workflows/[id]/restore/route.ts | 6 +- apps/sim/app/api/workflows/[id]/route.ts | 2 +- .../sim/app/api/workflows/[id]/state/route.ts | 4 +- .../app/api/workflows/[id]/variables/route.ts | 4 +- apps/sim/app/api/workflows/middleware.test.ts | 2 +- apps/sim/app/api/workflows/middleware.ts | 2 +- apps/sim/app/api/workflows/reorder/route.ts | 2 +- apps/sim/app/api/workflows/route.test.ts | 2 +- apps/sim/app/api/workflows/route.ts | 2 +- .../[id]/metrics/executions/route.ts | 19 +-- .../api/workspaces/[id]/permissions/route.ts | 3 +- apps/sim/app/api/workspaces/[id]/route.ts | 32 +--- .../api/workspaces/invitations/route.test.ts | 18 +- .../app/api/workspaces/invitations/route.ts | 24 +-- .../settings/components/billing/billing.tsx | 3 +- .../credential-sets/credential-sets.tsx | 3 +- .../organization-member-lists.tsx | 3 +- .../upgrade/hooks/use-upgrade-state.ts | 3 +- .../components/data-drains-settings.tsx | 3 +- .../components/data-retention-settings.tsx | 3 +- .../components/whitelabeling-settings.tsx | 3 +- .../executor/handlers/agent/agent-handler.ts | 1 + .../evaluator/evaluator-handler.test.ts | 16 ++ .../handlers/evaluator/evaluator-handler.ts | 1 + .../handlers/router/router-handler.test.ts | 17 ++ .../handlers/router/router-handler.ts | 12 +- apps/sim/executor/utils/vertex-credential.ts | 42 +++-- apps/sim/hooks/queries/utils/folder-tree.ts | 4 +- apps/sim/lib/billing/client/upgrade.ts | 5 +- apps/sim/lib/billing/core/organization.ts | 20 +-- apps/sim/lib/billing/core/subscription.ts | 3 +- apps/sim/lib/billing/core/usage.ts | 3 +- apps/sim/lib/billing/organization.ts | 3 +- .../lib/billing/organizations/membership.ts | 118 +------------ apps/sim/lib/billing/webhooks/invoices.ts | 9 +- apps/sim/lib/copilot/auth/permissions.test.ts | 155 +++++++----------- apps/sim/lib/copilot/auth/permissions.ts | 41 +---- apps/sim/lib/copilot/chat/lifecycle.test.ts | 2 +- apps/sim/lib/copilot/chat/lifecycle.ts | 2 +- apps/sim/lib/copilot/chat/post.ts | 50 +++--- apps/sim/lib/copilot/chat/process-contents.ts | 2 +- apps/sim/lib/copilot/tools/handlers/access.ts | 34 ++-- .../tools/handlers/workflow/mutations.ts | 2 +- .../tools/server/user/get-credentials.ts | 39 ++++- .../server/workflow/edit-workflow/index.ts | 5 +- .../validation/selector-validator.test.ts | 23 +++ .../copilot/validation/selector-validator.ts | 52 +++--- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 2 +- apps/sim/lib/credentials/access.test.ts | 3 + apps/sim/lib/credentials/access.ts | 88 +++++++--- apps/sim/lib/environment/utils.ts | 9 +- apps/sim/lib/execution/preprocessing.test.ts | 2 +- apps/sim/lib/execution/preprocessing.ts | 2 +- .../preprocessing.webhook-correlation.test.ts | 2 +- apps/sim/lib/invitations/core.ts | 8 +- .../lib/invitations/workspace-invitations.ts | 17 +- apps/sim/lib/logs/fetch-log-detail.ts | 21 +-- apps/sim/lib/logs/list-logs.test.ts | 11 ++ apps/sim/lib/logs/list-logs.ts | 19 +-- apps/sim/lib/mcp/middleware.ts | 12 +- apps/sim/lib/mothership/inbox/executor.ts | 27 +-- .../sim/lib/workflows/orchestration/deploy.ts | 2 +- .../orchestration/workflow-lifecycle.ts | 2 +- .../lib/workflows/persistence/duplicate.ts | 5 +- apps/sim/lib/workflows/persistence/utils.ts | 2 +- apps/sim/lib/workflows/queries.ts | 10 +- apps/sim/lib/workflows/utils.test.ts | 2 +- apps/sim/lib/workflows/utils.ts | 13 +- apps/sim/lib/workspace-events/emitter.test.ts | 2 +- apps/sim/lib/workspace-events/emitter.ts | 2 +- apps/sim/lib/workspaces/organization/utils.ts | 3 +- .../lib/workspaces/permissions/utils.test.ts | 57 ------- apps/sim/lib/workspaces/permissions/utils.ts | 83 +++------- apps/sim/lib/workspaces/policy.ts | 5 +- apps/sim/lib/workspaces/utils.ts | 5 +- apps/sim/package.json | 2 +- apps/sim/vitest.setup.ts | 2 +- bun.lock | 33 ++-- .../package.json | 16 +- packages/platform-authz/src/predicates.ts | 37 +++++ .../src/workflow.ts} | 83 +--------- packages/platform-authz/src/workspace.ts | 55 +++++++ .../tsconfig.json | 0 packages/testing/src/mocks/index.ts | 2 +- .../testing/src/mocks/permissions.mock.ts | 2 - packages/testing/src/mocks/schema.mock.ts | 4 +- .../testing/src/mocks/workflow-authz.mock.ts | 12 +- .../testing/src/mocks/workflows-utils.mock.ts | 2 +- 169 files changed, 961 insertions(+), 1126 deletions(-) rename packages/{workflow-authz => platform-authz}/package.json (63%) create mode 100644 packages/platform-authz/src/predicates.ts rename packages/{workflow-authz/src/index.ts => platform-authz/src/workflow.ts} (78%) create mode 100644 packages/platform-authz/src/workspace.ts rename packages/{workflow-authz => platform-authz}/tsconfig.json (100%) diff --git a/.claude/rules/sim-architecture.md b/.claude/rules/sim-architecture.md index 9b6b37ecef9..bc52fd37001 100644 --- a/.claude/rules/sim-architecture.md +++ b/.claude/rules/sim-architecture.md @@ -29,7 +29,7 @@ apps/ └── realtime/ # Bun Socket.IO server (collaborative canvas) packages/ # @sim/* — audit, auth, db, logger, realtime-protocol, - # security, tsconfig, utils, workflow-authz, + # security, tsconfig, utils, platform-authz, # workflow-persistence, workflow-types ``` diff --git a/.cursor/rules/sim-architecture.mdc b/.cursor/rules/sim-architecture.mdc index 08c3df6bf5b..90bac74294d 100644 --- a/.cursor/rules/sim-architecture.mdc +++ b/.cursor/rules/sim-architecture.mdc @@ -28,7 +28,7 @@ apps/ └── realtime/ # Bun Socket.IO server (collaborative canvas) packages/ # @sim/* — audit, auth, db, logger, realtime-protocol, - # security, tsconfig, utils, workflow-authz, + # security, tsconfig, utils, platform-authz, # workflow-persistence, workflow-types ``` diff --git a/.cursor/rules/sim-testing.mdc b/.cursor/rules/sim-testing.mdc index ca3ceb1e946..515784d541b 100644 --- a/.cursor/rules/sim-testing.mdc +++ b/.cursor/rules/sim-testing.mdc @@ -22,7 +22,7 @@ These modules are mocked globally — do NOT re-mock them in test files unless y - `@/stores/console/store`, `@/stores/terminal`, `@/stores/execution/store` - `@/blocks/registry` - `@trigger.dev/sdk` -- `@sim/workflow-authz` → `workflowAuthzMock` +- `@sim/platform-authz/workflow` → `workflowAuthzMock` ## Structure diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f4e3df0d31c..d1e18087f88 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -8,7 +8,7 @@ Thank you for your interest in contributing to Sim! Our goal is to provide devel > - `apps/sim/` — the main Next.js application (App Router, ReactFlow, Zustand, Shadcn, Tailwind CSS). > - `apps/realtime/` — a small Bun + Socket.IO server that powers the collaborative canvas. Shares DB and Better Auth secrets with `apps/sim` via `@sim/*` packages. > - `apps/docs/` — Fumadocs-based documentation site. -> - `packages/` — shared workspace packages (`@sim/db`, `@sim/auth`, `@sim/audit`, `@sim/workflow-types`, `@sim/workflow-persistence`, `@sim/workflow-authz`, `@sim/realtime-protocol`, `@sim/security`, `@sim/logger`, `@sim/utils`, `@sim/testing`, `@sim/tsconfig`). +> - `packages/` — shared workspace packages (`@sim/db`, `@sim/auth`, `@sim/audit`, `@sim/workflow-types`, `@sim/workflow-persistence`, `@sim/platform-authz`, `@sim/realtime-protocol`, `@sim/security`, `@sim/logger`, `@sim/utils`, `@sim/testing`, `@sim/tsconfig`). > > Strict one-way dependency flow: `apps/* → packages/*`. Packages never import from apps. Please ensure your contributions follow this and our best practices for clarity, maintainability, and consistency. diff --git a/AGENTS.md b/AGENTS.md index 78feaedb30a..9ce16b909d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,11 +51,11 @@ packages/ ├── auth/ # @sim/auth — shared Better Auth verifier ├── db/ # @sim/db — drizzle schema + client ├── logger/ # @sim/logger +├── platform-authz/ # @sim/platform-authz — workspace + workflow authz (subpath exports) ├── realtime-protocol/ # @sim/realtime-protocol — socket op constants + zod schemas ├── security/ # @sim/security — safeCompare ├── tsconfig/ # shared tsconfig presets ├── utils/ # @sim/utils -├── workflow-authz/ # @sim/workflow-authz ├── workflow-persistence/ # @sim/workflow-persistence └── workflow-types/ # @sim/workflow-types — pure BlockState/Loop/Parallel types ``` @@ -409,7 +409,7 @@ Use Vitest. Test files: `feature.ts` → `feature.test.ts`. See `.cursor/rules/s ### Global Mocks (vitest.setup.ts) -`@sim/db`, `@sim/db/schema`, `drizzle-orm`, `@sim/logger`, `@sim/workflow-authz`, `@/blocks/registry`, `@/lib/auth`, `@/lib/auth/hybrid`, `@/lib/core/utils/request`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. (The `vi.mock('@/lib/auth', ...)` in the example below is an override of the global mock so `getSession` can be controlled per-test.) +`@sim/db`, `@sim/db/schema`, `drizzle-orm`, `@sim/logger`, `@sim/platform-authz/workflow`, `@/blocks/registry`, `@/lib/auth`, `@/lib/auth/hybrid`, `@/lib/core/utils/request`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. (The `vi.mock('@/lib/auth', ...)` in the example below is an override of the global mock so `getSession` can be controlled per-test.) ### Standard Test Pattern diff --git a/CLAUDE.md b/CLAUDE.md index 78feaedb30a..9ce16b909d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,11 +51,11 @@ packages/ ├── auth/ # @sim/auth — shared Better Auth verifier ├── db/ # @sim/db — drizzle schema + client ├── logger/ # @sim/logger +├── platform-authz/ # @sim/platform-authz — workspace + workflow authz (subpath exports) ├── realtime-protocol/ # @sim/realtime-protocol — socket op constants + zod schemas ├── security/ # @sim/security — safeCompare ├── tsconfig/ # shared tsconfig presets ├── utils/ # @sim/utils -├── workflow-authz/ # @sim/workflow-authz ├── workflow-persistence/ # @sim/workflow-persistence └── workflow-types/ # @sim/workflow-types — pure BlockState/Loop/Parallel types ``` @@ -409,7 +409,7 @@ Use Vitest. Test files: `feature.ts` → `feature.test.ts`. See `.cursor/rules/s ### Global Mocks (vitest.setup.ts) -`@sim/db`, `@sim/db/schema`, `drizzle-orm`, `@sim/logger`, `@sim/workflow-authz`, `@/blocks/registry`, `@/lib/auth`, `@/lib/auth/hybrid`, `@/lib/core/utils/request`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. (The `vi.mock('@/lib/auth', ...)` in the example below is an override of the global mock so `getSession` can be controlled per-test.) +`@sim/db`, `@sim/db/schema`, `drizzle-orm`, `@sim/logger`, `@sim/platform-authz/workflow`, `@/blocks/registry`, `@/lib/auth`, `@/lib/auth/hybrid`, `@/lib/core/utils/request`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. (The `vi.mock('@/lib/auth', ...)` in the example below is an override of the global mock so `getSession` can be controlled per-test.) ### Standard Test Pattern diff --git a/apps/realtime/package.json b/apps/realtime/package.json index 17f412773e1..99867ef852d 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -24,10 +24,10 @@ "@sim/auth": "workspace:*", "@sim/db": "workspace:*", "@sim/logger": "workspace:*", + "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", "@sim/security": "workspace:*", "@sim/utils": "workspace:*", - "@sim/workflow-authz": "workspace:*", "@sim/workflow-persistence": "workspace:*", "@sim/workflow-types": "workspace:*", "@socket.io/redis-adapter": "8.3.0", diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index c5474a5f6f8..ac0072c959d 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -8,6 +8,7 @@ import { workflowSubflows, } from '@sim/db' import { createLogger } from '@sim/logger' +import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' import { BLOCK_OPERATIONS, BLOCKS_OPERATIONS, @@ -20,7 +21,6 @@ import { WORKFLOW_OPERATIONS, } from '@sim/realtime-protocol/constants' import { randomFloat } from '@sim/utils/random' -import { getActiveWorkflowContext } from '@sim/workflow-authz' import { loadWorkflowFromNormalizedTablesRaw } from '@sim/workflow-persistence/load' import { mergeSubBlockValues } from '@sim/workflow-persistence/subblocks' import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' diff --git a/apps/realtime/src/handlers/operations.ts b/apps/realtime/src/handlers/operations.ts index 49d5bbcd0b2..eef51847718 100644 --- a/apps/realtime/src/handlers/operations.ts +++ b/apps/realtime/src/handlers/operations.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { BLOCK_OPERATIONS, BLOCKS_OPERATIONS, @@ -11,7 +12,6 @@ import { import { WorkflowOperationSchema } from '@sim/realtime-protocol/schemas' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { ZodError } from 'zod' import { persistWorkflowOperation } from '@/database/operations' import type { AuthenticatedSocket } from '@/middleware/auth' diff --git a/apps/realtime/src/handlers/subblocks.ts b/apps/realtime/src/handlers/subblocks.ts index 0295aff458c..b2f94b6fb98 100644 --- a/apps/realtime/src/handlers/subblocks.ts +++ b/apps/realtime/src/handlers/subblocks.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' import { workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants' import { getErrorMessage } from '@sim/utils/errors' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' import { and, eq } from 'drizzle-orm' import type { AuthenticatedSocket } from '@/middleware/auth' diff --git a/apps/realtime/src/handlers/variables.ts b/apps/realtime/src/handlers/variables.ts index f9b1c1f0c68..98dc3a5b7af 100644 --- a/apps/realtime/src/handlers/variables.ts +++ b/apps/realtime/src/handlers/variables.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { VARIABLE_OPERATIONS } from '@sim/realtime-protocol/constants' import { getErrorMessage } from '@sim/utils/errors' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import type { AuthenticatedSocket } from '@/middleware/auth' import { checkWorkflowOperationPermission } from '@/middleware/permissions' diff --git a/apps/realtime/src/middleware/permissions.test.ts b/apps/realtime/src/middleware/permissions.test.ts index 554ba8355fd..c1078b8c0c0 100644 --- a/apps/realtime/src/middleware/permissions.test.ts +++ b/apps/realtime/src/middleware/permissions.test.ts @@ -19,7 +19,7 @@ const { mockAuthorize } = vi.hoisted(() => ({ mockAuthorize: vi.fn(), })) -vi.mock('@sim/workflow-authz', () => ({ +vi.mock('@sim/platform-authz/workflow', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorize, })) diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index 23069ff51de..00fc5c9580f 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { BLOCK_OPERATIONS, BLOCKS_OPERATIONS, @@ -11,7 +12,6 @@ import { VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, } from '@sim/realtime-protocol/constants' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' const logger = createLogger('SocketPermissions') diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md index a766fb697d5..6c52c2df02d 100644 --- a/apps/sim/AGENTS.md +++ b/apps/sim/AGENTS.md @@ -29,7 +29,7 @@ apps/ └── realtime/ # Bun Socket.IO server (collaborative canvas) packages/ # @sim/* — audit, auth, db, logger, realtime-protocol, - # security, tsconfig, utils, workflow-authz, + # security, tsconfig, utils, platform-authz, # workflow-persistence, workflow-types ``` diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index cb81a810b1b..a3ff4c12097 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -1,14 +1,15 @@ import { db } from '@sim/db' import { account, credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' -import { and, eq } from 'drizzle-orm' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import { and, eq, isNotNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { oauthCredentialsQuerySchema } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { getCanonicalScopesForProvider, @@ -114,11 +115,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined } + let requesterCanAdmin = false if (effectiveWorkspaceId) { const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId) if (!workspaceAccess.hasAccess) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + requesterCanAdmin = workspaceAccess.canAdmin } if (credentialId) { @@ -150,19 +153,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } if (!workflowId) { - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, platformCredential.id), - eq(credentialMember.userId, requesterUserId), - eq(credentialMember.status, 'active') - ) - ) - .limit(1) - - if (!membership) { + const access = await getCredentialActorContext(platformCredential.id, requesterUserId) + if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } } @@ -193,19 +185,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } } else { - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, platformCredential.id), - eq(credentialMember.userId, requesterUserId), - eq(credentialMember.status, 'active') - ) - ) - .limit(1) - - if (!membership) { + const access = await getCredentialActorContext(platformCredential.id, requesterUserId) + if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } } @@ -237,17 +218,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { userId: requesterUserId, }) + const oauthSelect = { + id: credential.id, + displayName: credential.displayName, + providerId: account.providerId, + scope: account.scope, + updatedAt: account.updatedAt, + } const credentialsData = await db - .select({ - id: credential.id, - displayName: credential.displayName, - providerId: account.providerId, - scope: account.scope, - updatedAt: account.updatedAt, - }) + .select(oauthSelect) .from(credential) .innerJoin(account, eq(credential.accountId, account.id)) - .innerJoin( + .leftJoin( credentialMember, and( eq(credentialMember.credentialId, credential.id), @@ -259,7 +241,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { and( eq(credential.workspaceId, effectiveWorkspaceId), eq(credential.type, 'oauth'), - eq(account.providerId, providerParam) + eq(account.providerId, providerParam), + requesterCanAdmin ? undefined : isNotNull(credentialMember.id) ) ) @@ -270,15 +253,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const saProviderId = getServiceAccountProviderForProviderId(providerParam) if (saProviderId) { + const saSelect = { + id: credential.id, + displayName: credential.displayName, + providerId: credential.providerId, + updatedAt: credential.updatedAt, + } const serviceAccountCreds = await db - .select({ - id: credential.id, - displayName: credential.displayName, - providerId: credential.providerId, - updatedAt: credential.updatedAt, - }) + .select(saSelect) .from(credential) - .innerJoin( + .leftJoin( credentialMember, and( eq(credentialMember.credentialId, credential.id), @@ -290,7 +274,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { and( eq(credential.workspaceId, effectiveWorkspaceId), eq(credential.type, 'service_account'), - eq(credential.providerId, saProviderId) + eq(credential.providerId, saProviderId), + requesterCanAdmin ? undefined : isNotNull(credentialMember.id) ) ) diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 49c5f170645..680f96d8024 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -1,8 +1,8 @@ import { db } from '@sim/db' import { chat, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { safeCompare } from '@sim/security/compare' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access' diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts index 55d8f5acad0..c7fa4d58b3f 100644 --- a/apps/sim/app/api/copilot/chat/queries.ts +++ b/apps/sim/app/api/copilot/chat/queries.ts @@ -1,8 +1,8 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' diff --git a/apps/sim/app/api/copilot/chats/route.test.ts b/apps/sim/app/api/copilot/chats/route.test.ts index 11046b7a349..2ce59e2a41d 100644 --- a/apps/sim/app/api/copilot/chats/route.test.ts +++ b/apps/sim/app/api/copilot/chats/route.test.ts @@ -24,6 +24,7 @@ vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), or: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'or' })), + inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })), isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), sql: vi.fn(), @@ -31,6 +32,14 @@ vi.mock('drizzle-orm', () => ({ vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/workspaces/utils', () => ({ + listAccessibleWorkspaceRowsForUser: vi + .fn() + .mockResolvedValue([ + { workspace: { id: 'workspace-123', createdAt: new Date() }, permissionType: 'admin' }, + ]), +})) + import { GET } from '@/app/api/copilot/chats/route' describe('Copilot Chats List API Route', () => { diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index c72bfa1c8ba..5f72001816b 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -1,8 +1,8 @@ import { db } from '@sim/db' -import { copilotChats, permissions, workflow, workspace } from '@sim/db/schema' +import { copilotChats, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' -import { and, desc, eq, isNull, or, sql } from 'drizzle-orm' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkflowCopilotChatContract } from '@/lib/api/contracts/copilot' import { parseRequest, validationErrorResponse } from '@/lib/api/server' @@ -20,6 +20,7 @@ import { assertActiveWorkspaceAccess, isWorkspaceAccessDeniedError, } from '@/lib/workspaces/permissions/utils' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' const logger = createLogger('CopilotChatsListAPI') @@ -32,6 +33,21 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { return createUnauthorizedResponse() } + // Active accessible workspaces (explicit + org-derived). Using the active + // scope keeps the archived-workspace exclusion the old join-based query had. + const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId) + const accessibleWorkspaceIds = accessibleRows.map((row) => row.workspace.id) + const inAccessibleWorkspace = + accessibleWorkspaceIds.length > 0 + ? or( + inArray(workflow.workspaceId, accessibleWorkspaceIds), + and( + isNull(copilotChats.workflowId), + inArray(copilotChats.workspaceId, accessibleWorkspaceIds) + ) + ) + : undefined + const visibleChats = await db .selectDistinctOn([copilotChats.id], { id: copilotChats.id, @@ -43,30 +59,14 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { }) .from(copilotChats) .leftJoin(workflow, eq(copilotChats.workflowId, workflow.id)) - .leftJoin( - workspace, - or( - eq(workflow.workspaceId, workspace.id), - and(isNull(copilotChats.workflowId), eq(copilotChats.workspaceId, workspace.id)) - ) - ) - .leftJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspace.id), - eq(permissions.userId, userId) - ) - ) .where( and( eq(copilotChats.userId, userId), or( and(isNull(copilotChats.workflowId), isNull(copilotChats.workspaceId)), - sql`${permissions.id} IS NOT NULL` + inAccessibleWorkspace ), - or(isNull(workflow.id), isNull(workflow.archivedAt)), - or(isNull(workspace.id), isNull(workspace.archivedAt)) + or(isNull(workflow.id), isNull(workflow.archivedAt)) ) ) .orderBy(copilotChats.id, desc(copilotChats.updatedAt)) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index 5ccda51212d..f784dc48d84 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowCheckpoints, workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { revertCopilotCheckpointContract } from '@/lib/api/contracts/copilot' diff --git a/apps/sim/app/api/copilot/checkpoints/route.ts b/apps/sim/app/api/copilot/checkpoints/route.ts index 985a95a71a3..4bd861ffb50 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowCheckpoints } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 9f935953b7c..cc7fc667887 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -12,6 +12,7 @@ import { import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deriveCredentialAdmin, isSharedCredentialType } from '@/lib/credentials/access' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions, @@ -36,8 +37,6 @@ async function requireCredentialAdmin(credentialId: string, userId: string) { const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId) if (perm === null) return null - const isWorkspaceAdmin = cred.type !== 'env_personal' && perm === 'admin' - const [membership] = await db .select({ role: credentialMember.role, status: credentialMember.status }) .from(credentialMember) @@ -46,9 +45,13 @@ async function requireCredentialAdmin(credentialId: string, userId: string) { ) .limit(1) - const isCredentialAdmin = membership?.status === 'active' && membership?.role === 'admin' + const isAdmin = deriveCredentialAdmin({ + credentialType: cred.type, + memberRole: membership?.status === 'active' ? membership.role : null, + workspaceCanAdmin: perm === 'admin', + }) - if (!isWorkspaceAdmin && !isCredentialAdmin) { + if (!isAdmin) { return null } return { credentialType: cred.type, workspaceId: cred.workspaceId } @@ -112,7 +115,7 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route ]) ) - if (cred.type !== 'env_personal') { + if (isSharedCredentialType(cred.type)) { const workspaceMembers = await getUsersWithPermissions(cred.workspaceId) for (const wsMember of workspaceMembers) { if (wsMember.permissionType !== 'admin') continue @@ -163,7 +166,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route }) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } - if (admin.credentialType === 'env_personal') { + if (!isSharedCredentialType(admin.credentialType)) { logger.warn('Credential member share denied', { credentialId, actorId: session.user.id, @@ -327,7 +330,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou return NextResponse.json({ error: 'Member not found' }, { status: 404 }) } - if (admin.credentialType !== 'env_personal') { + if (isSharedCredentialType(admin.credentialType)) { const targetWorkspacePerm = await getUserEntityPermissions( targetUserId, 'workspace', diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index f846a88d3fb..3dc507ed5ae 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -1,44 +1,34 @@ -import { db } from '@sim/db' -import { credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access' import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' const logger = createLogger('CredentialByIdAPI') -async function getCredentialResponse(credentialId: string, userId: string) { - const [row] = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - displayName: credential.displayName, - description: credential.description, - providerId: credential.providerId, - accountId: credential.accountId, - envKey: credential.envKey, - envOwnerUserId: credential.envOwnerUserId, - createdBy: credential.createdBy, - createdAt: credential.createdAt, - updatedAt: credential.updatedAt, - role: credentialMember.role, - status: credentialMember.status, - }) - .from(credential) - .innerJoin( - credentialMember, - and(eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, userId)) - ) - .where(eq(credential.id, credentialId)) - .limit(1) - - return row ?? null +function formatCredentialResponse(access: CredentialActorContext) { + const cred = access.credential + if (!cred) return null + + return { + id: cred.id, + workspaceId: cred.workspaceId, + type: cred.type, + displayName: cred.displayName, + description: cred.description, + providerId: cred.providerId, + accountId: cred.accountId, + envKey: cred.envKey, + envOwnerUserId: cred.envOwnerUserId, + createdBy: cred.createdBy, + createdAt: cred.createdAt, + updatedAt: cred.updatedAt, + role: access.isAdmin ? 'admin' : (access.member?.role ?? null), + status: access.member?.status ?? (access.isAdmin ? 'active' : null), + } } export const GET = withRouteHandler( @@ -55,12 +45,11 @@ export const GET = withRouteHandler( if (!access.credential) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } - if (!access.hasWorkspaceAccess || !access.member) { + if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const row = await getCredentialResponse(id, session.user.id) - return NextResponse.json({ credential: row }, { status: 200 }) + return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 }) } catch (error) { logger.error('Failed to fetch credential', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) @@ -109,8 +98,8 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: result.error }, { status }) } - const row = await getCredentialResponse(id, session.user.id) - return NextResponse.json({ credential: row }, { status: 200 }) + const access = await getCredentialActorContext(id, session.user.id) + return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 }) } catch (error) { logger.error('Failed to update credential', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index 9efb27f2619..2e693609438 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { credential, credentialMember, pendingCredentialDraft } from '@sim/db/schema' +import { pendingCredentialDraft } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, lt } from 'drizzle-orm' @@ -8,6 +8,7 @@ import { createCredentialDraftContract } from '@/lib/api/contracts/credentials' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CredentialDraftAPI') @@ -33,22 +34,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } if (credentialId) { - const [membership] = await db - .select({ role: credentialMember.role, status: credentialMember.status }) - .from(credentialMember) - .innerJoin(credential, eq(credential.id, credentialMember.credentialId)) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.userId, userId), - eq(credentialMember.status, 'active'), - eq(credentialMember.role, 'admin'), - eq(credential.workspaceId, workspaceId) - ) - ) - .limit(1) - - if (!membership) { + const access = await getCredentialActorContext(credentialId, userId, { workspaceAccess }) + if (!access.credential || access.credential.workspaceId !== workspaceId || !access.isAdmin) { return NextResponse.json( { error: 'Admin access required on the target credential' }, { status: 403 } diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 3c2c9394273..318b905987b 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -4,7 +4,7 @@ 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' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray, isNotNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceCredentialContract, @@ -17,6 +17,11 @@ import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + getCredentialActorContext, + isSharedCredentialType, + SHARED_CREDENTIAL_TYPES, +} from '@/lib/credentials/access' import { AtlassianValidationError, normalizeAtlassianDomain, @@ -228,7 +233,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { whereClauses.push(eq(credential.providerId, providerId)) } - const credentials = await db + const isWorkspaceAdmin = workspaceAccess.canAdmin + const accessClause = isWorkspaceAdmin + ? or( + isNotNull(credentialMember.id), + inArray(credential.type, SHARED_CREDENTIAL_TYPES), + eq(credential.envOwnerUserId, session.user.id) + ) + : or(isNotNull(credentialMember.id), eq(credential.envOwnerUserId, session.user.id)) + + const rows = await db .select({ id: credential.id, workspaceId: credential.workspaceId, @@ -242,10 +256,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { createdBy: credential.createdBy, createdAt: credential.createdAt, updatedAt: credential.updatedAt, - role: credentialMember.role, + memberRole: credentialMember.role, }) .from(credential) - .innerJoin( + .leftJoin( credentialMember, and( eq(credentialMember.credentialId, credential.id), @@ -253,7 +267,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { eq(credentialMember.status, 'active') ) ) - .where(and(...whereClauses)) + .where(and(...whereClauses, accessClause)) + + const credentials = rows.map(({ memberRole, ...rest }) => ({ + ...rest, + role: + isWorkspaceAdmin && isSharedCredentialType(rest.type) ? 'admin' : (memberRole ?? 'member'), + })) return NextResponse.json({ credentials }) } catch (error) { @@ -440,29 +460,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (existingCredential) { - const [membership] = await db - .select({ - id: credentialMember.id, - status: credentialMember.status, - role: credentialMember.role, - }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, existingCredential.id), - eq(credentialMember.userId, session.user.id) - ) - ) - .limit(1) + const access = await getCredentialActorContext(existingCredential.id, session.user.id, { + workspaceAccess, + }) - if (!membership || membership.status !== 'active') { + if (!access.member && !access.isAdmin) { return NextResponse.json( { error: 'A credential with this source already exists in this workspace' }, { status: 409 } ) } - const canUpdateExistingCredential = membership.role === 'admin' + const canUpdateExistingCredential = access.isAdmin const shouldUpdateDisplayName = type === 'oauth' && resolvedDisplayName && diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 7b96031baf8..8e0c66b4173 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { document, knowledgeBase, workspaceFile } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { permissionSatisfies } from '@sim/platform-authz/workspace' import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getFileMetadata } from '@/lib/uploads' @@ -39,8 +40,7 @@ function workspacePermissionSatisfies( permission: WorkspacePermission | null, requireWrite: boolean ): boolean { - if (permission === null) return false - return requireWrite ? permission === 'write' || permission === 'admin' : true + return permissionSatisfies(permission, requireWrite ? 'write' : 'read') } /** diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index cc02764e2f4..5e0df625558 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -2,8 +2,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { FolderLockedError } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' -import { FolderLockedError } from '@sim/workflow-authz' import { and, eq, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { duplicateFolderContract } from '@/lib/api/contracts' diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 26d5f218005..48483258eb1 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateFolderContract } from '@/lib/api/contracts' diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index 274e2bc7784..b361abf6df1 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { reorderFoldersContract } from '@/lib/api/contracts' diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index baafe6fd2ad..f7eb512da68 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -394,7 +394,7 @@ describe('Folders API Route', () => { it('should reject creating a subfolder inside a locked parent folder', async () => { mockAuthenticatedUser() - const { FolderLockedError } = await import('@sim/workflow-authz') + const { FolderLockedError } = await import('@sim/platform-authz/workflow') workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValueOnce( new FolderLockedError('Folder is locked') ) diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index f359e376542..d9206b0caeb 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createFolderContract, listFoldersContract } from '@/lib/api/contracts' diff --git a/apps/sim/app/api/guardrails/validate/route.ts b/apps/sim/app/api/guardrails/validate/route.ts index c2754b79db9..ca50b20b2d2 100644 --- a/apps/sim/app/api/guardrails/validate/route.ts +++ b/apps/sim/app/api/guardrails/validate/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { type NextRequest, NextResponse } from 'next/server' import { guardrailsValidateContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/jobs/[jobId]/route.test.ts b/apps/sim/app/api/jobs/[jobId]/route.test.ts index 0dceacd56eb..189e03b1052 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.test.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.test.ts @@ -15,7 +15,7 @@ vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue, })) -vi.mock('@sim/workflow-authz', () => ({ +vi.mock('@sim/platform-authz/workflow', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, })) diff --git a/apps/sim/app/api/jobs/[jobId]/route.ts b/apps/sim/app/api/jobs/[jobId]/route.ts index adba3ec4d5d..01677e506ee 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.ts @@ -37,7 +37,9 @@ export const GET = withRouteHandler( const metadataToCheck = job.metadata if (metadataToCheck?.workflowId) { - const { authorizeWorkflowByWorkspacePermission } = await import('@sim/workflow-authz') + const { authorizeWorkflowByWorkspacePermission } = await import( + '@sim/platform-authz/workflow' + ) const accessCheck = await authorizeWorkflowByWorkspacePermission({ userId: authenticatedUserId, workflowId: metadataToCheck.workflowId as string, diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index 8e80e41f6e3..059abee2fb6 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { bulkKnowledgeChunksContract, diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 371fc1512c7..dd10a328d27 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -1,8 +1,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { bulkKnowledgeDocumentsContract, diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts index 40220dd0732..d1c3af79f73 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts @@ -2,9 +2,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { document } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { upsertKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge' diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 9dd40280f82..021f3059e36 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { knowledgeSearchBodySchema } from '@/lib/api/contracts/knowledge' import { parseJsonBody, validationErrorResponse } from '@/lib/api/server' diff --git a/apps/sim/app/api/logs/execution/[executionId]/route.ts b/apps/sim/app/api/logs/execution/[executionId]/route.ts index adab287bf99..82e33a644c9 100644 --- a/apps/sim/app/api/logs/execution/[executionId]/route.ts +++ b/apps/sim/app/api/logs/execution/[executionId]/route.ts @@ -1,13 +1,12 @@ import { db } from '@sim/db' import { jobExecutionLogs, - permissions, workflow, workflowExecutionLogs, workflowExecutionSnapshots, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { executionIdParamsSchema } from '@/lib/api/contracts/logs' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' @@ -15,6 +14,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('LogsByExecutionIdAPI') @@ -52,22 +52,23 @@ export const GET = withRouteHandler( }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, authenticatedUserId) - ) - ) .where(eq(workflowExecutionLogs.executionId, executionId)) .limit(1) + if ( + workflowLog && + !(await checkWorkspaceAccess(workflowLog.workspaceId, authenticatedUserId)).hasAccess + ) { + logger.warn(`[${requestId}] Execution access denied: ${executionId}`) + return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) + } + // Fallback: check job_execution_logs if (!workflowLog) { const [jobLog] = await db .select({ id: jobExecutionLogs.id, + workspaceId: jobExecutionLogs.workspaceId, executionId: jobExecutionLogs.executionId, trigger: jobExecutionLogs.trigger, startedAt: jobExecutionLogs.startedAt, @@ -77,18 +78,13 @@ export const GET = withRouteHandler( executionData: jobExecutionLogs.executionData, }) .from(jobExecutionLogs) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, jobExecutionLogs.workspaceId), - eq(permissions.userId, authenticatedUserId) - ) - ) .where(eq(jobExecutionLogs.executionId, executionId)) .limit(1) - if (!jobLog) { + if ( + !jobLog || + !(await checkWorkspaceAccess(jobLog.workspaceId, authenticatedUserId)).hasAccess + ) { logger.warn(`[${requestId}] Execution not found or access denied: ${executionId}`) return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index 560eee71618..a2006538fd9 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -1,5 +1,5 @@ import { dbReplica } from '@sim/db' -import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, desc, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -10,6 +10,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('LogsExportAPI') @@ -72,6 +73,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 'traceSpans', ].join(',') + const access = await checkWorkspaceAccess(params.workspaceId, userId) + if (!access.hasAccess) { + return new NextResponse(`${header}\n`, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="logs-export.csv"', + 'Cache-Control': 'no-cache', + }, + }) + } + const encoder = new TextEncoder() const stream = new ReadableStream({ start: async (controller) => { @@ -84,14 +97,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .select(selectColumns) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where(conditions) .orderBy(desc(workflowExecutionLogs.startedAt)) .limit(pageSize) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 359a8b7505d..88f33ff6b54 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -1,5 +1,5 @@ import { dbReplica } from '@sim/db' -import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -15,6 +15,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('LogsStatsAPI') @@ -36,6 +37,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { searchParams } = new URL(request.url) const params = statsQueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) + const access = await checkWorkspaceAccess(params.workspaceId, userId) + if (!access.hasAccess) { + return NextResponse.json( + { + workflows: [], + aggregateSegments: [], + totalRuns: 0, + totalErrors: 0, + avgLatency: 0, + timeBounds: { start: new Date().toISOString(), end: new Date().toISOString() }, + segmentMs: 0, + } satisfies DashboardStatsResponse, + { status: 200 } + ) + } + const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId) if (params.folderIds) { @@ -55,14 +72,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where(whereCondition) const bounds = boundsQuery[0] @@ -103,14 +112,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where(whereCondition) .groupBy( sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, diff --git a/apps/sim/app/api/logs/triggers/route.ts b/apps/sim/app/api/logs/triggers/route.ts index 1ebe834b6f9..2b033384eca 100644 --- a/apps/sim/app/api/logs/triggers/route.ts +++ b/apps/sim/app/api/logs/triggers/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { permissions, workflowExecutionLogs } from '@sim/db/schema' +import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNotNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -8,6 +8,7 @@ import { searchParamsToObject, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('TriggersAPI') @@ -40,19 +41,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const params = validation.data + const access = await checkWorkspaceAccess(params.workspaceId, userId) + if (!access.hasAccess) { + return NextResponse.json({ triggers: [], count: 0 }) + } + const triggers = await db .selectDistinct({ trigger: workflowExecutionLogs.trigger, }) .from(workflowExecutionLogs) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where( and( eq(workflowExecutionLogs.workspaceId, params.workspaceId), diff --git a/apps/sim/app/api/mcp/discover/route.ts b/apps/sim/app/api/mcp/discover/route.ts index 5c63714b0a0..222a50e70d4 100644 --- a/apps/sim/app/api/mcp/discover/route.ts +++ b/apps/sim/app/api/mcp/discover/route.ts @@ -1,11 +1,12 @@ import { db } from '@sim/db' -import { permissions, workflowMcpServer, workspace } from '@sim/db/schema' +import { workflowMcpServer, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' const logger = createLogger('McpDiscoverAPI') @@ -34,24 +35,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - const userWorkspacePermissions = await db - .select({ entityId: permissions.entityId }) - .from(permissions) - .innerJoin(workspace, eq(permissions.entityId, workspace.id)) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - isNull(workspace.archivedAt) - ) - ) + const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId) + const accessibleWorkspaceIds = accessibleRows.map((row) => row.workspace.id) const workspaceIds = auth.apiKeyType === 'workspace' && auth.workspaceId - ? userWorkspacePermissions - .map((w) => w.entityId) - .filter((workspaceId) => workspaceId === auth.workspaceId) - : userWorkspacePermissions.map((w) => w.entityId) + ? accessibleWorkspaceIds.filter((workspaceId) => workspaceId === auth.workspaceId) + : accessibleWorkspaceIds if (workspaceIds.length === 0) { return NextResponse.json({ success: true, servers: [] }) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index afbd00e78ca..0f954023a2d 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -10,6 +10,7 @@ import { workspace, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { getErrorMessage } from '@sim/utils/errors' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -79,7 +80,7 @@ export const GET = withRouteHandler( } const userRole = memberEntry.role - if (!['owner', 'admin'].includes(userRole)) { + if (!isOrgAdminRole(userRole)) { return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } @@ -149,7 +150,7 @@ export const POST = withRouteHandler( ) } - if (!['owner', 'admin'].includes(memberEntry.role)) { + if (!isOrgAdminRole(memberEntry.role)) { return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index f6f3dd68944..69433ca0b7d 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, dbReplica } from '@sim/db' import { member, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateOrganizationMemberRoleContract } from '@/lib/api/contracts/organization' @@ -54,7 +55,7 @@ export const GET = withRouteHandler( } const userRole = userMember[0].role - const hasAdminAccess = ['owner', 'admin'].includes(userRole) + const hasAdminAccess = isOrgAdminRole(userRole) const memberQuery = db .select({ @@ -182,7 +183,7 @@ export const PUT = withRouteHandler( ) } - if (!['owner', 'admin'].includes(userMember[0].role)) { + if (!isOrgAdminRole(userMember[0].role)) { return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } @@ -306,7 +307,7 @@ export const DELETE = withRouteHandler( } const canRemoveMembers = - ['owner', 'admin'].includes(userMember[0].role) || session.user.id === targetUserId + isOrgAdminRole(userMember[0].role) || session.user.id === targetUserId if (!canRemoveMembers) { return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 }) diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index a8b23088fa1..9482c76ac9b 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -8,6 +8,7 @@ import { userStats, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -82,7 +83,7 @@ export const GET = withRouteHandler( } const userRole = memberEntry[0].role - const hasAdminAccess = ['owner', 'admin'].includes(userRole) + const hasAdminAccess = isOrgAdminRole(userRole) // Get organization members const query = db @@ -234,7 +235,7 @@ export const POST = withRouteHandler( ) } - if (!['owner', 'admin'].includes(memberEntry[0].role)) { + if (!isOrgAdminRole(memberEntry[0].role)) { return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index 102c1f2035d..fe8dc0edbc2 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -8,6 +8,7 @@ import { workspace, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { organizationParamsSchema } from '@/lib/api/contracts/organization' @@ -118,7 +119,7 @@ export const GET = withRouteHandler( } const members = memberRows.map((row) => { - const isOrgAdmin = row.role === 'owner' || row.role === 'admin' + const isOrgAdmin = isOrgAdminRole(row.role) return { memberId: row.memberId, userId: row.userId, diff --git a/apps/sim/app/api/organizations/[id]/route.ts b/apps/sim/app/api/organizations/[id]/route.ts index d44cd97e1c0..ba074c3ab5f 100644 --- a/apps/sim/app/api/organizations/[id]/route.ts +++ b/apps/sim/app/api/organizations/[id]/route.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateOrganizationContract } from '@/lib/api/contracts/organization' @@ -73,7 +74,7 @@ export const GET = withRouteHandler( } const userRole = memberEntry[0].role - const hasAdminAccess = ['owner', 'admin'].includes(userRole) + const hasAdminAccess = isOrgAdminRole(userRole) const response: OrganizationDetailsResponse = { success: true, @@ -148,7 +149,7 @@ export const PUT = withRouteHandler( ) } - if (!['owner', 'admin'].includes(memberEntry[0].role)) { + if (!isOrgAdminRole(memberEntry[0].role)) { return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index 5a2aaabb2d2..ba1fece2f3d 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, subscription as subscriptionTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { getErrorMessage } from '@sim/utils/errors' import { and, eq, inArray, or } from 'drizzle-orm' import type { NextRequest } from 'next/server' @@ -113,7 +114,7 @@ export const POST = withRouteHandler(async (request: Request) => { .limit(1) const existingAdminMembership = - existingOrgMembership.length > 0 && ['owner', 'admin'].includes(existingOrgMembership[0].role) + existingOrgMembership.length > 0 && isOrgAdminRole(existingOrgMembership[0].role) ? existingOrgMembership[0] : null diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index 62455bb85d4..56949815250 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -6,7 +6,7 @@ import { assertWorkflowMutable, authorizeWorkflowByWorkspacePermission, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getScheduleByIdContract, updateScheduleContract } from '@/lib/api/contracts/schedules' diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index ac7fc85ce5c..ca25b2fe946 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createScheduleContract, scheduleQuerySchema } from '@/lib/api/contracts/schedules' diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 33986a2964e..7424258ad0e 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' import { @@ -170,7 +171,7 @@ async function checkTableWriteAccess(tableId: string, userId: string): Promise { const params = parsed.data.query - const scopeError = await checkWorkspaceScope(rateLimit, params.workspaceId) - if (scopeError) return scopeError + const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (accessError) return accessError logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, { userId, @@ -121,14 +121,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) const logs = await baseQuery .where(conditions) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 94cacfd27f8..51d69070f32 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { type NextRequest, NextResponse } from 'next/server' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import type { SubscriptionPlan } from '@/lib/core/rate-limiter' @@ -192,9 +193,6 @@ export async function checkWorkspaceScope( return null } -/** Orders workspace permission levels for at-least comparisons. */ -const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const - /** * Validates workspace-scoped API key bounds and the user's workspace permission. * Returns null on success, NextResponse on failure. @@ -203,13 +201,13 @@ export async function validateWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, - level: keyof typeof PERMISSION_RANK = 'read' + level: PermissionType = 'read' ): Promise { const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null || PERMISSION_RANK[permission] < PERMISSION_RANK[level]) { + if (!permissionSatisfies(permission, level)) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } return null diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts index 3dead585727..dd78899e2ac 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts @@ -5,8 +5,9 @@ * workspace admin permission enforcement, optional body handling, and the * mapping of orchestration results to v1 API responses. */ + +import { WorkflowLockedError } from '@sim/platform-authz/workflow' import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' -import { WorkflowLockedError } from '@sim/workflow-authz' import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index 34982534db9..008a45a9820 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { v1DeployWorkflowBodySchema, diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts index c1f085faf02..cdebe9e35af 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts @@ -5,8 +5,9 @@ * resolution (previous version by default, explicit version when provided) * and the mapping of activation results to v1 API responses. */ + +import { WorkflowLockedError } from '@sim/platform-authz/workflow' import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' -import { WorkflowLockedError } from '@sim/workflow-authz' import { NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index c35933d2773..534e7fd89de 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { v1RollbackWorkflowBodySchema, diff --git a/apps/sim/app/api/v1/workflows/[id]/route.ts b/apps/sim/app/api/v1/workflows/[id]/route.ts index 584f82cd75d..653b833e591 100644 --- a/apps/sim/app/api/v1/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { getActiveWorkflowRecord } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1GetWorkflowContract } from '@/lib/api/contracts/v1/workflows' diff --git a/apps/sim/app/api/v1/workflows/utils.ts b/apps/sim/app/api/v1/workflows/utils.ts index 92e321f1538..89186235598 100644 --- a/apps/sim/app/api/v1/workflows/utils.ts +++ b/apps/sim/app/api/v1/workflows/utils.ts @@ -1,4 +1,4 @@ -import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/workflow-authz' +import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { NextResponse } from 'next/server' import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware' diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index ea79ee38f69..7ad630c5ff6 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -6,7 +6,7 @@ import { assertWorkflowMutable, authorizeWorkflowByWorkspacePermission, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 80f34950ed2..8c7fdec2ee8 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -1,14 +1,14 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' +import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId, generateShortId } from '@sim/utils/id' import { assertWorkflowMutable, authorizeWorkflowByWorkspacePermission, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId, generateShortId } from '@sim/utils/id' import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { listWebhooksContract, upsertWebhookContract } from '@/lib/api/contracts/webhooks' @@ -31,6 +31,7 @@ import { findConflictingWebhookPathOwner, syncWebhooksForCredentialSet, } from '@/lib/webhooks/utils.server' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants' const logger = createLogger('WebhooksAPI') @@ -151,12 +152,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ webhooks: [] }, { status: 200 }) } - const workspacePermissionRows = await db - .select({ workspaceId: permissions.entityId }) - .from(permissions) - .where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace'))) - - const workspaceIds = workspacePermissionRows.map((row) => row.workspaceId) + const accessibleRows = await listAccessibleWorkspaceRowsForUser(session.user.id, 'all') + const workspaceIds = accessibleRows.map((row) => row.workspace.id) if (workspaceIds.length === 0) { return NextResponse.json({ webhooks: [] }, { status: 200 }) } diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts index 18d1864daef..387047da880 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { assertWorkflowMutable, authorizeWorkflowByWorkspacePermission, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { workflowAutoLayoutContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts index c8202be91dc..49d555b813d 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { getChatDeploymentStatusContract } from '@/lib/api/contracts/deployments' diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 0355519c16b..75eca4dc779 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,7 +1,7 @@ import { db, workflow } from '@sim/db' import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updatePublicApiContract } from '@/lib/api/contracts/deployments' diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index e7a95618bcd..1b2746f525f 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import type { NextRequest } from 'next/server' import { workflowDeploymentVersionParamSchema } from '@/lib/api/contracts/workflows' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index 7ab119524d8..beba4a3f3ab 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { FolderLockedError } from '@sim/workflow-authz' +import { FolderLockedError } from '@sim/platform-authz/workflow' import { type NextRequest, NextResponse } from 'next/server' import { duplicateWorkflowContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 8f3007b5c4e..13ee516f661 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' import { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId, isValidUuid } from '@sim/utils/id' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { executeWorkflowBodySchema } from '@/lib/api/contracts/workflows' diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 92f32a26f7d..0ca39eb6622 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts index 5e41a225e9e..27529007563 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts @@ -21,7 +21,7 @@ vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, })) -vi.mock('@sim/workflow-authz', () => ({ +vi.mock('@sim/platform-authz/workflow', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, })) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts index 6915a8dcbc1..2be5fed1c37 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { streamWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index 65a8fa96196..b42dcdb9ce4 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -1,6 +1,10 @@ import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + FolderLockedError, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { assertFolderMutable, FolderLockedError, WorkflowLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { restoreWorkflowContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index d42e75b67e1..d2c1f607b2d 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -7,7 +7,7 @@ import { authorizeWorkflowByWorkspacePermission, FolderLockedError, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkflowContract } from '@/lib/api/contracts/workflows' diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 5aa8010084a..fbd33b045b4 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -1,12 +1,12 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { assertWorkflowMutable, authorizeWorkflowByWorkspacePermission, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' +import { toError } from '@sim/utils/errors' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { putWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows' diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index b2fd323324b..d6b0dd3115f 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -2,12 +2,12 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { assertWorkflowMutable, authorizeWorkflowByWorkspacePermission, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { workflowVariablesContract } from '@/lib/api/contracts/workflows' diff --git a/apps/sim/app/api/workflows/middleware.test.ts b/apps/sim/app/api/workflows/middleware.test.ts index 996466426da..202326d2c15 100644 --- a/apps/sim/app/api/workflows/middleware.test.ts +++ b/apps/sim/app/api/workflows/middleware.test.ts @@ -16,7 +16,7 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('@sim/workflow-authz', () => workflowAuthzMock) +vi.mock('@sim/platform-authz/workflow', () => workflowAuthzMock) vi.mock('@/lib/api-key/service', () => ({ authenticateApiKeyFromHeader: vi.fn(), updateApiKeyLastUsed: vi.fn(), diff --git a/apps/sim/app/api/workflows/middleware.ts b/apps/sim/app/api/workflows/middleware.ts index 10fa3017727..08a51fbf598 100644 --- a/apps/sim/app/api/workflows/middleware.ts +++ b/apps/sim/app/api/workflows/middleware.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import type { NextRequest } from 'next/server' import { type ApiKeyAuthResult, diff --git a/apps/sim/app/api/workflows/reorder/route.ts b/apps/sim/app/api/workflows/reorder/route.ts index 5be0f62d3e5..adb1b5416e5 100644 --- a/apps/sim/app/api/workflows/reorder/route.ts +++ b/apps/sim/app/api/workflows/reorder/route.ts @@ -8,7 +8,7 @@ import { FolderLockedError, FolderNotFoundError, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { reorderWorkflowsContract } from '@/lib/api/contracts/workflows' diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index 2bfddb39343..261b8bf4b5d 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -88,7 +88,7 @@ describe('Workflows API Route - POST ordering', () => { }) it('rejects creating a workflow inside a locked folder', async () => { - const { FolderLockedError } = await import('@sim/workflow-authz') + const { FolderLockedError } = await import('@sim/platform-authz/workflow') workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValueOnce( new FolderLockedError('Folder is locked') ) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 224cb68417d..05d94a31b1d 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { type NextRequest, NextResponse } from 'next/server' import { createWorkflowContract, workflowListQuerySchema } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts b/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts index 2bf216a930f..548a6939c43 100644 --- a/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts @@ -1,11 +1,12 @@ -import { db, dbReplica } from '@sim/db' -import { pausedExecutions, permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' +import { dbReplica } from '@sim/db' +import { pausedExecutions, workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { workspaceMetricsExecutionsQuerySchema } from '@/lib/api/contracts/workspaces' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MetricsExecutionsAPI') @@ -36,18 +37,8 @@ export const GET = withRouteHandler( const segments = qp.segments - const [permission] = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.userId, userId) - ) - ) - .limit(1) - if (!permission) { + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.hasAccess) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } const wfWhere = [eq(workflow.workspaceId, workspaceId)] as any[] diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 8d4a4c15b83..43a450765b5 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { ORG_ADMIN_ROLES } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -136,7 +137,7 @@ export const PATCH = withRouteHandler( and( eq(member.organizationId, organizationId), inArray(member.userId, targetUserIds), - inArray(member.role, ['owner', 'admin']) + inArray(member.role, [...ORG_ADMIN_ROLES]) ) ) if (orgAdminTargets.length > 0) { diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 1e8bbac5b82..1b215792d94 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -14,7 +14,10 @@ const logger = createLogger('WorkspaceByIdAPI') import { db } from '@sim/db' import { permissions, workspace } from '@sim/db/schema' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + getEffectiveWorkspacePermission, + getUserEntityPermissions, +} from '@/lib/workspaces/permissions/utils' export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { @@ -140,28 +143,11 @@ export const PATCH = withRouteHandler( const candidateId = billedAccountUserId - const isOwner = candidateId === existingWorkspace.ownerId - - let hasAdminAccess = isOwner - - if (!hasAdminAccess) { - const adminPermission = await db - .select({ id: permissions.id }) - .from(permissions) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.userId, candidateId), - eq(permissions.permissionType, 'admin') - ) - ) - .limit(1) - - hasAdminAccess = adminPermission.length > 0 - } - - if (!hasAdminAccess) { + const candidatePermission = await getEffectiveWorkspacePermission( + candidateId, + existingWorkspace + ) + if (candidatePermission !== 'admin') { return NextResponse.json( { error: 'Billed account must be a workspace admin' }, { status: 400 } diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index c364d8228e4..ddfe8a59213 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -124,6 +124,7 @@ describe('POST /api/workspaces/invitations/batch', () => { workspaceMode: 'grandfathered_shared', billedAccountUserId: 'user-1', }) + permissionsMockFns.mockHasWorkspaceAdminAccess.mockResolvedValue(true) mockValidateInvitationsAllowed.mockResolvedValue(undefined) mockGetWorkspaceInvitePolicy.mockResolvedValue({ allowed: true, @@ -164,7 +165,7 @@ describe('POST /api/workspaces/invitations/batch', () => { organizationId: null, upgradeRequired: true, }) - mockDbResults.value = [[{ permissionType: 'admin' }]] + mockDbResults.value = [] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -195,7 +196,7 @@ describe('POST /api/workspaces/invitations/batch', () => { organizationId: null, upgradeRequired: true, }) - mockDbResults.value = [[{ permissionType: 'admin' }]] + mockDbResults.value = [] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -233,7 +234,7 @@ describe('POST /api/workspaces/invitations/batch', () => { maxSeats: 5, availableSeats: 0, }) - mockDbResults.value = [[{ permissionType: 'admin' }], []] + mockDbResults.value = [[]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -276,10 +277,7 @@ describe('POST /api/workspaces/invitations/batch', () => { role: 'member', memberId: 'member-1', }) - mockDbResults.value = [ - [{ permissionType: 'admin' }], - [{ id: 'existing-user', email: 'new@example.com' }], - ] + mockDbResults.value = [[{ id: 'existing-user', email: 'new@example.com' }]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -313,7 +311,7 @@ describe('POST /api/workspaces/invitations/batch', () => { workspaceMode: 'grandfathered_shared', billedAccountUserId: 'user-1', }) - mockDbResults.value = [[{ permissionType: 'admin' }], []] + mockDbResults.value = [[]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -338,7 +336,7 @@ describe('POST /api/workspaces/invitations/batch', () => { }) it('creates multiple workspace invitations in one batch request', async () => { - mockDbResults.value = [[{ permissionType: 'admin' }], [], []] + mockDbResults.value = [[], []] mockCreatePendingInvitation .mockResolvedValueOnce({ invitationId: 'inv-1', @@ -384,7 +382,7 @@ describe('POST /api/workspaces/invitations/batch', () => { success: false, error: 'mailer unavailable', }) - mockDbResults.value = [[{ permissionType: 'admin' }], []] + mockDbResults.value = [[]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 378b169ad66..101f0dc8fff 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -1,11 +1,9 @@ -import { db } from '@sim/db' -import { permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listInvitationsForWorkspaces } from '@/lib/invitations/core' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' export const dynamic = 'force-dynamic' @@ -18,24 +16,14 @@ export const GET = withRouteHandler(async (req: NextRequest) => { } try { - const userWorkspaces = await db - .select({ id: workspace.id }) - .from(workspace) - .innerJoin( - permissions, - and( - eq(permissions.entityId, workspace.id), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id) - ) - ) - .where(isNull(workspace.archivedAt)) - - if (userWorkspaces.length === 0) { + const accessibleRows = await listAccessibleWorkspaceRowsForUser(session.user.id) + if (accessibleRows.length === 0) { return NextResponse.json({ invitations: [] }) } - const invitations = await listInvitationsForWorkspaces(userWorkspaces.map((w) => w.id)) + const invitations = await listInvitationsForWorkspaces( + accessibleRows.map((row) => row.workspace.id) + ) return NextResponse.json({ invitations }) } catch (error) { logger.error('Error fetching workspace invitations:', error) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index a30e7071bb1..79c375564d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -1,6 +1,7 @@ 'use client' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { getErrorMessage } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' @@ -176,7 +177,7 @@ export function Billing() { const isBlocked = Boolean(subscriptionData?.data?.billingBlocked) const userRole = subscriptionData?.data?.organization?.role ?? 'member' - const isTeamAdmin = ['owner', 'admin'].includes(userRole) + const isTeamAdmin = isOrgAdminRole(userRole) const shouldUseOrganizationBillingContext = subscription.isOrgScoped && isTeamAdmin const { data: invoicesData } = useInvoices({ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx index 9cd6c56d63f..f41d180ed6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { Plus } from 'lucide-react' import { Avatar, @@ -63,7 +64,7 @@ export function CredentialSets() { const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data) const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess const userRole = getUserRole(activeOrganization, session?.user?.email) - const isAdmin = userRole === 'admin' || userRole === 'owner' + const isAdmin = isOrgAdminRole(userRole) const canManageCredentialSets = hasTeamPlan && isAdmin && !!activeOrganization?.id const { data: memberships = [], isPending: membershipsLoading } = useCredentialSetMemberships() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx index da0de1aacb5..ca12bbced71 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { getErrorMessage } from '@sim/utils/errors' import { ChipDropdown, @@ -310,7 +311,7 @@ export function OrganizationMemberLists({ workspaceId: string, access: RosterWorkspaceAccess ) => { - const rowUserIsOrgAdmin = member.role === 'owner' || member.role === 'admin' + const rowUserIsOrgAdmin = isOrgAdminRole(member.role) const isSelf = member.userId === currentUserId const wouldDemoteSelf = isSelf && access.permission === 'admin' const disabled = rowUserIsOrgAdmin || wouldDemoteSelf || updatePermissions.isPending diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts index 3cabcc4b9f6..bf9eeafe42f 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts @@ -1,5 +1,6 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' +import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { getErrorMessage } from '@sim/utils/errors' import { toast } from '@/components/emcn' import { requestJson } from '@/lib/api/client/request' @@ -103,7 +104,7 @@ export function useUpgradeState(): UpgradeState { }, [subscription.isPaid, subscriptionData?.data?.billingInterval]) const userRole = subscriptionData?.data?.organization?.role ?? 'member' - const isTeamAdmin = ['owner', 'admin'].includes(userRole) + const isTeamAdmin = isOrgAdminRole(userRole) const permissions = getSubscriptionPermissions( { diff --git a/apps/sim/ee/data-drains/components/data-drains-settings.tsx b/apps/sim/ee/data-drains/components/data-drains-settings.tsx index 1bcd32084eb..aae623477e6 100644 --- a/apps/sim/ee/data-drains/components/data-drains-settings.tsx +++ b/apps/sim/ee/data-drains/components/data-drains-settings.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { toError } from '@sim/utils/errors' import { ChevronDown, Plus } from 'lucide-react' import { @@ -91,7 +92,7 @@ export function DataDrainsSettings() { const userEmail = session?.user?.email const userRole = getUserRole(activeOrganization, userEmail) - const canManage = userRole === 'owner' || userRole === 'admin' + const canManage = isOrgAdminRole(userRole) const { data: drains, isLoading: drainsLoading, error: drainsError } = useDataDrains(orgId) diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 5ac649517f8..d0a306d6914 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { toError } from '@sim/utils/errors' import { Chip, ChipSelect, toast } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' @@ -71,7 +72,7 @@ export function DataRetentionSettings() { const userEmail = session?.user?.email const userRole = getUserRole(activeOrganization, userEmail) - const canManage = userRole === 'owner' || userRole === 'admin' + const canManage = isOrgAdminRole(userRole) const [logDays, setLogDays] = useState('') const [softDeleteDays, setSoftDeleteDays] = useState('') diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx index c10ccb1e89f..692c6db72f4 100644 --- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { toError } from '@sim/utils/errors' import { Image as ImageIcon, X } from 'lucide-react' import Image from 'next/image' @@ -127,7 +128,7 @@ export function WhitelabelingSettings() { const userEmail = session?.user?.email const userRole = getUserRole(activeOrganization, userEmail) - const canManage = userRole === 'owner' || userRole === 'admin' + const canManage = isOrgAdminRole(userRole) const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data) const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 798ba423ba4..c9bb5291347 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -972,6 +972,7 @@ export class AgentBlockHandler implements BlockHandler { if (providerId === 'vertex' && providerRequest.vertexCredential) { finalApiKey = await resolveVertexCredential( providerRequest.vertexCredential, + ctx.userId, 'vertex-agent' ) } diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index c338b9c310a..664a80a21ae 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -5,6 +5,21 @@ import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: vi.fn().mockResolvedValue({ + credential: { + id: 'test-vertex-credential-id', + type: 'oauth', + workspaceId: 'test-workspace', + accountId: 'test-vertex-credential-id', + }, + member: { role: 'admin', status: 'active' }, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: true, + }), +})) + import { BlockType } from '@/executor/constants' import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler' import type { ExecutionContext } from '@/executor/types' @@ -39,6 +54,7 @@ describe('EvaluatorBlockHandler', () => { mockContext = { workflowId: 'test-workflow-id', + userId: 'test-user', blockStates: new Map(), blockLogs: [], metadata: { duration: 0 }, diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index abb14853ec5..3ab07bc3653 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -43,6 +43,7 @@ export class EvaluatorBlockHandler implements BlockHandler { if (providerId === 'vertex' && evaluatorConfig.vertexCredential) { finalApiKey = await resolveVertexCredential( evaluatorConfig.vertexCredential, + ctx.userId, 'vertex-evaluator' ) } diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index 90e326c2785..d5e54239b48 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -5,6 +5,21 @@ import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: vi.fn().mockResolvedValue({ + credential: { + id: 'test-vertex-credential', + type: 'oauth', + workspaceId: 'test-workspace', + accountId: 'test-vertex-credential-id', + }, + member: { role: 'admin', status: 'active' }, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: true, + }), +})) + import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router' import { BlockType } from '@/executor/constants' import { RouterBlockHandler } from '@/executor/handlers/router/router-handler' @@ -65,6 +80,7 @@ describe('RouterBlockHandler', () => { mockContext = { workflowId: 'test-workflow-id', + userId: 'test-user', blockStates: new Map(), blockLogs: [], metadata: { duration: 0 }, @@ -363,6 +379,7 @@ describe('RouterBlockHandler V2', () => { mockContext = { workflowId: 'test-workflow-id', + userId: 'test-user', blockStates: new Map(), blockLogs: [], metadata: { duration: 0 }, diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 1f3c7ba5745..12042918b25 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -84,7 +84,11 @@ export class RouterBlockHandler implements BlockHandler { let finalApiKey: string | undefined = routerConfig.apiKey if (providerId === 'vertex' && routerConfig.vertexCredential) { - finalApiKey = await resolveVertexCredential(routerConfig.vertexCredential, 'vertex-router') + finalApiKey = await resolveVertexCredential( + routerConfig.vertexCredential, + ctx.userId, + 'vertex-router' + ) } const providerRequest: Record = { @@ -214,7 +218,11 @@ export class RouterBlockHandler implements BlockHandler { let finalApiKey: string | undefined = routerConfig.apiKey if (providerId === 'vertex' && routerConfig.vertexCredential) { - finalApiKey = await resolveVertexCredential(routerConfig.vertexCredential, 'vertex-router') + finalApiKey = await resolveVertexCredential( + routerConfig.vertexCredential, + ctx.userId, + 'vertex-router' + ) } const providerRequest: Record = { diff --git a/apps/sim/executor/utils/vertex-credential.ts b/apps/sim/executor/utils/vertex-credential.ts index 15f5d9a6221..b902f29f2fb 100644 --- a/apps/sim/executor/utils/vertex-credential.ts +++ b/apps/sim/executor/utils/vertex-credential.ts @@ -2,48 +2,60 @@ import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { - getServiceAccountToken, - refreshTokenIfNeeded, - resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { getServiceAccountToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('VertexCredential') /** * Resolves a Vertex AI OAuth credential to an access token. - * Shared across agent, evaluator, and router handlers. + * Shared across agent, evaluator, and router handlers. Authorizes the executing + * user against the credential first — workspace credentials are usable by their + * members and by derived workspace admins, matching `authorizeCredentialUse`. */ export async function resolveVertexCredential( credentialId: string, + actingUserId: string | undefined, callerLabel = 'vertex' ): Promise { const requestId = `${callerLabel}-${Date.now()}` logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`) + if (!actingUserId) { + throw new Error('Vertex AI credential use requires an authenticated user') + } + + const access = await getCredentialActorContext(credentialId, actingUserId) + const cred = access.credential + if (!cred) { + throw new Error(`Vertex AI credential not found: ${credentialId}`) + } + if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) { + throw new Error('Not authorized to use this Vertex AI credential') } - if (resolved.credentialType === 'service_account' && resolved.credentialId) { - const accessToken = await getServiceAccountToken(resolved.credentialId, [ + if (cred.type === 'service_account') { + const accessToken = await getServiceAccountToken(cred.id, [ 'https://www.googleapis.com/auth/cloud-platform', ]) logger.info(`[${requestId}] Successfully resolved Vertex AI service account credential`) return accessToken } - const credential = await db.query.account.findFirst({ - where: eq(account.id, resolved.accountId), + if (cred.type !== 'oauth' || !cred.accountId) { + throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`) + } + + const accountRow = await db.query.account.findFirst({ + where: eq(account.id, cred.accountId), }) - if (!credential) { + if (!accountRow) { throw new Error(`Vertex AI credential not found: ${credentialId}`) } - const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId) + const { accessToken } = await refreshTokenIfNeeded(requestId, accountRow, cred.accountId) if (!accessToken) { throw new Error('Failed to get Vertex AI access token') diff --git a/apps/sim/hooks/queries/utils/folder-tree.ts b/apps/sim/hooks/queries/utils/folder-tree.ts index dd16ebfcb8f..39add522c57 100644 --- a/apps/sim/hooks/queries/utils/folder-tree.ts +++ b/apps/sim/hooks/queries/utils/folder-tree.ts @@ -82,7 +82,7 @@ export function findLockedAncestorFolder( /** * Effective lock state for a workflow as visible to the client. Mirrors - * the server's `getWorkflowLockStatus(workflowId)` (in `@sim/workflow-authz`) + * the server's `getWorkflowLockStatus(workflowId)` (in `@sim/platform-authz/workflow`) * but reads from cached folder data instead of issuing DB walks. Treats an * undefined workflow as unlocked so callers don't need to early-return. */ @@ -97,7 +97,7 @@ export function isWorkflowEffectivelyLocked( /** * Effective lock state for a folder as visible to the client. Mirrors the - * server's `getFolderLockStatus(folderId)` (in `@sim/workflow-authz`) but + * server's `getFolderLockStatus(folderId)` (in `@sim/platform-authz/workflow`) but * reads from cached folder data instead of issuing DB walks. Treats an * undefined folder as unlocked so callers don't need to early-return. */ diff --git a/apps/sim/lib/billing/client/upgrade.ts b/apps/sim/lib/billing/client/upgrade.ts index dc6e8e09691..2c5d3dad651 100644 --- a/apps/sim/lib/billing/client/upgrade.ts +++ b/apps/sim/lib/billing/client/upgrade.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { getErrorMessage } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import { ApiClientError } from '@/lib/api/client/errors' @@ -80,9 +81,7 @@ export function useSubscriptionUpgrade() { } throw err } - const existingOrg = orgsData.organizations?.find( - (org) => org.role === 'owner' || org.role === 'admin' - ) + const existingOrg = orgsData.organizations?.find((org) => isOrgAdminRole(org.role)) if (existingOrg) { const existingOrgSub = allSubscriptions.find( diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index 2000edd898c..ca09e106c3d 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -20,6 +20,7 @@ import { } from '@/lib/billing/subscriptions/utils' import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import type { DbClient } from '@/lib/db/types' +import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('OrganizationBilling') @@ -419,7 +420,11 @@ async function getOrganizationBillingSummary(organizationId: string) { } /** - * Check if a user is an owner or admin of a specific organization + * Error-tolerant wrapper around {@link isOrganizationAdminOrOwner} for billing + * gates: on a DB error it logs and returns false instead of throwing, so a + * transient failure denies access rather than surfacing a 500 mid-checkout. + * Prefer the canonical {@link isOrganizationAdminOrOwner} when a thrown error + * should propagate. * * @param userId - The ID of the user to check * @param organizationId - The ID of the organization @@ -430,18 +435,7 @@ export async function isOrganizationOwnerOrAdmin( organizationId: string ): Promise { try { - const memberRecord = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, organizationId))) - .limit(1) - - if (memberRecord.length === 0) { - return false - } - - const userRole = memberRecord[0].role - return ['owner', 'admin'].includes(userRole) + return await isOrganizationAdminOrOwner(userId, organizationId) } catch (error) { logger.error('Error checking organization ownership/admin status:', error) return false diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index ca0996eabc6..fd793aae41c 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { member, organization, subscription, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray, sql } from 'drizzle-orm' import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { @@ -183,7 +184,7 @@ export async function getOrganizationIdForSubscriptionReference( .where(eq(member.userId, referenceId)) .limit(1) - if (memberRecord && (memberRecord.role === 'owner' || memberRecord.role === 'admin')) { + if (memberRecord && isOrgAdminRole(memberRecord.role)) { return memberRecord.organizationId } diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index ef3cc7d7631..baa893f451d 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { member, organization, settings, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { @@ -891,7 +892,7 @@ export async function maybeSendUsageThresholdEmail(params: { .where(eq(member.organizationId, params.organizationId)) for (const a of admins) { - const isAdmin = a.role === 'owner' || a.role === 'admin' + const isAdmin = isOrgAdminRole(a.role) if (!isAdmin) continue if (a.enabled === false) continue if (!a.email) continue diff --git a/apps/sim/lib/billing/organization.ts b/apps/sim/lib/billing/organization.ts index c1df4aefdc3..a288a0239e2 100644 --- a/apps/sim/lib/billing/organization.ts +++ b/apps/sim/lib/billing/organization.ts @@ -7,6 +7,7 @@ import { userStats, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray, sql } from 'drizzle-orm' import { getPlanPricing } from '@/lib/billing/core/billing' import { getOrganizationIdForSubscriptionReference } from '@/lib/billing/core/subscription' @@ -133,7 +134,7 @@ export async function ensureOrganizationForTeamSubscription( if (existingMembership.length > 0) { const membership = existingMembership[0] - if (membership.role === 'owner' || membership.role === 'admin') { + if (isOrgAdminRole(membership.role)) { /** * Atomic duplicate-subscription check + referenceId transfer. * diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 239423a4684..6c15b325677 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -7,8 +7,6 @@ import { db } from '@sim/db' import { - credential, - credentialMember, invitation, member, organization, @@ -30,6 +28,7 @@ import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' import type { DbOrTx } from '@/lib/db/types' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, @@ -400,109 +399,6 @@ async function reassignOwnedOrganizationWorkspacesTx({ return reassignedWorkspaces.length } -async function revokeWorkspaceCredentialMembershipsTx({ - tx, - workspaceIds, - userId, -}: { - tx: DbOrTx - workspaceIds: string[] - userId: string -}) { - if (workspaceIds.length === 0) return 0 - - const workspaceCredentialRows = await tx - .select({ - credentialId: credential.id, - workspaceId: credential.workspaceId, - ownerId: workspace.ownerId, - }) - .from(credential) - .innerJoin(workspace, eq(credential.workspaceId, workspace.id)) - .where(inArray(credential.workspaceId, workspaceIds)) - - if (workspaceCredentialRows.length === 0) return 0 - - const credentialIds = workspaceCredentialRows.map((row) => row.credentialId) - const ownerByCredentialId = new Map( - workspaceCredentialRows.map((row) => [row.credentialId, row.ownerId]) - ) - - const userAdminMemberships = await tx - .select({ credentialId: credentialMember.credentialId }) - .from(credentialMember) - .where( - and( - eq(credentialMember.userId, userId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active'), - inArray(credentialMember.credentialId, credentialIds) - ) - ) - - for (const { credentialId } of userAdminMemberships) { - const ownerId = ownerByCredentialId.get(credentialId) - if (!ownerId || ownerId === userId) continue - - const otherAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active'), - ne(credentialMember.userId, userId) - ) - ) - .limit(1) - - if (otherAdmins.length > 0) continue - - const now = new Date() - const [existingOwnerMembership] = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, ownerId)) - ) - .limit(1) - - if (existingOwnerMembership) { - await tx - .update(credentialMember) - .set({ role: 'admin', status: 'active', updatedAt: now }) - .where(eq(credentialMember.id, existingOwnerMembership.id)) - } else { - await tx.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId: ownerId, - role: 'admin', - status: 'active', - joinedAt: now, - invitedBy: ownerId, - createdAt: now, - updatedAt: now, - }) - } - } - - const revokedMemberships = await tx - .update(credentialMember) - .set({ status: 'revoked', updatedAt: new Date() }) - .where( - and( - eq(credentialMember.userId, userId), - eq(credentialMember.status, 'active'), - inArray(credentialMember.credentialId, credentialIds) - ) - ) - .returning({ credentialId: credentialMember.credentialId }) - - return revokedMemberships.length -} - interface MembershipValidationResult { canAdd: boolean reason?: string @@ -1114,11 +1010,11 @@ export async function removeUserFromOrganization( ) .returning({ entityId: permissions.entityId }) - const credentialMembershipsRevoked = await revokeWorkspaceCredentialMembershipsTx({ + const credentialMembershipsRevoked = await revokeWorkspaceCredentialMembershipsTx( tx, workspaceIds, - userId, - }) + userId + ) const capturedUsage = await captureDepartedUsage() return { @@ -1308,11 +1204,11 @@ export async function removeExternalUserFromOrganizationWorkspaces(params: { ) .returning({ id: permissionGroupMember.id }) - const credentialMembershipsRevoked = await revokeWorkspaceCredentialMembershipsTx({ + const credentialMembershipsRevoked = await revokeWorkspaceCredentialMembershipsTx( tx, workspaceIds, - userId, - }) + userId + ) const cancelledInvitations = targetUser?.email ? await tx diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index ff7c2294df4..a3e026f2006 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -8,6 +8,7 @@ import { userStats, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' import type Stripe from 'stripe' import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails' @@ -300,9 +301,7 @@ async function sendPaymentFailureEmails( .from(member) .where(eq(member.organizationId, sub.referenceId)) - const ownerAdminIds = members - .filter((m) => m.role === 'owner' || m.role === 'admin') - .map((m) => m.userId) + const ownerAdminIds = members.filter((m) => isOrgAdminRole(m.role)).map((m) => m.userId) if (ownerAdminIds.length > 0) { const users = await db @@ -722,9 +721,7 @@ async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise m.role === 'owner' || m.role === 'admin') - .map((m) => m.userId) + const ownerAdminIds = members.filter((m) => isOrgAdminRole(m.role)).map((m) => m.userId) if (ownerAdminIds.length > 0) { recipients = await db diff --git a/apps/sim/lib/copilot/auth/permissions.test.ts b/apps/sim/lib/copilot/auth/permissions.test.ts index 9e0c4dcb3e8..93a62fc3171 100644 --- a/apps/sim/lib/copilot/auth/permissions.test.ts +++ b/apps/sim/lib/copilot/auth/permissions.test.ts @@ -1,102 +1,81 @@ /** * @vitest-environment node */ -import { permissionsMock, permissionsMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetActiveWorkflowContext } = vi.hoisted(() => ({ - mockGetActiveWorkflowContext: vi.fn(), +const { mockAuthorizeWorkflowByWorkspacePermission } = vi.hoisted(() => ({ + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), })) -const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions - -vi.mock('@sim/workflow-authz', () => ({ - getActiveWorkflowContext: mockGetActiveWorkflowContext, +vi.mock('@sim/platform-authz/workflow', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, })) -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - import { createPermissionError, verifyWorkflowAccess } from '@/lib/copilot/auth/permissions' describe('Copilot Auth Permissions', () => { beforeEach(() => { vi.clearAllMocks() - - mockGetActiveWorkflowContext.mockResolvedValue(null) }) describe('verifyWorkflowAccess', () => { it('should return no access for non-existent workflow', async () => { - mockGetActiveWorkflowContext.mockResolvedValueOnce(null) - - const result = await verifyWorkflowAccess('user-123', 'non-existent-workflow') - - expect(result).toEqual({ - hasAccess: false, - userPermission: null, + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: false, + status: 404, + workflow: null, + workspacePermission: null, }) - }) - it('should check workspace permissions for workflow with workspace', async () => { - mockGetActiveWorkflowContext.mockResolvedValueOnce({ - workflow: {}, - workspaceId: 'workspace-456', - }) - mockGetUserEntityPermissions.mockResolvedValueOnce('write') - - const result = await verifyWorkflowAccess('user-123', 'workflow-789') - - expect(result).toEqual({ - hasAccess: true, - userPermission: 'write', - workspaceId: 'workspace-456', - }) - - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith( - 'user-123', - 'workspace', - 'workspace-456' - ) - }) - - it('should return read permission through workspace', async () => { - mockGetActiveWorkflowContext.mockResolvedValueOnce({ - workflow: {}, - workspaceId: 'workspace-456', - }) - mockGetUserEntityPermissions.mockResolvedValueOnce('read') - - const result = await verifyWorkflowAccess('user-123', 'workflow-789') + const result = await verifyWorkflowAccess('user-123', 'non-existent-workflow') - expect(result).toEqual({ - hasAccess: true, - userPermission: 'read', - workspaceId: 'workspace-456', - }) + expect(result).toEqual({ hasAccess: false, userPermission: null }) }) - it('should return admin permission through workspace', async () => { - mockGetActiveWorkflowContext.mockResolvedValueOnce({ - workflow: {}, - workspaceId: 'workspace-456', + it('should delegate to the shared workflow authorizer with a read action', async () => { + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + status: 200, + workflow: { workspaceId: 'workspace-456' }, + workspacePermission: 'write', }) - mockGetUserEntityPermissions.mockResolvedValueOnce('admin') - const result = await verifyWorkflowAccess('user-123', 'workflow-789') + await verifyWorkflowAccess('user-123', 'workflow-789') - expect(result).toEqual({ - hasAccess: true, - userPermission: 'admin', - workspaceId: 'workspace-456', + expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: 'workflow-789', + userId: 'user-123', + action: 'read', }) }) - it('should return no access without workspace permissions', async () => { - mockGetActiveWorkflowContext.mockResolvedValueOnce({ - workflow: {}, - workspaceId: 'workspace-456', + it.each(['read', 'write', 'admin'] as const)( + 'should grant access with %s permission through the workspace', + async (permission) => { + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + status: 200, + workflow: { workspaceId: 'workspace-456' }, + workspacePermission: permission, + }) + + const result = await verifyWorkflowAccess('user-123', 'workflow-789') + + expect(result).toEqual({ + hasAccess: true, + userPermission: permission, + workspaceId: 'workspace-456', + }) + } + ) + + it('should report the workspaceId even when permission is denied for an existing workflow', async () => { + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: false, + status: 403, + workflow: { workspaceId: 'workspace-456' }, + workspacePermission: null, }) - mockGetUserEntityPermissions.mockResolvedValueOnce(null) const result = await verifyWorkflowAccess('user-123', 'workflow-789') @@ -107,41 +86,27 @@ describe('Copilot Auth Permissions', () => { }) }) - it('should return no access for workflow without workspace', async () => { - mockGetActiveWorkflowContext.mockResolvedValueOnce(null) - - const result = await verifyWorkflowAccess('user-123', 'workflow-789') - - expect(result).toEqual({ - hasAccess: false, - userPermission: null, + it('should return no access for a workflow without a workspace', async () => { + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: false, + status: 403, + workflow: { workspaceId: null }, + workspacePermission: null, }) - }) - - it('should handle database errors gracefully', async () => { - mockGetActiveWorkflowContext.mockRejectedValueOnce(new Error('Database connection failed')) const result = await verifyWorkflowAccess('user-123', 'workflow-789') - expect(result).toEqual({ - hasAccess: false, - userPermission: null, - }) + expect(result).toEqual({ hasAccess: false, userPermission: null }) }) - it('should handle permission check errors gracefully', async () => { - mockGetActiveWorkflowContext.mockResolvedValueOnce({ - workflow: {}, - workspaceId: 'workspace-456', - }) - mockGetUserEntityPermissions.mockRejectedValueOnce(new Error('Permission check failed')) + it('should handle errors gracefully', async () => { + mockAuthorizeWorkflowByWorkspacePermission.mockRejectedValueOnce( + new Error('Database connection failed') + ) const result = await verifyWorkflowAccess('user-123', 'workflow-789') - expect(result).toEqual({ - hasAccess: false, - userPermission: null, - }) + expect(result).toEqual({ hasAccess: false, userPermission: null }) }) }) diff --git a/apps/sim/lib/copilot/auth/permissions.ts b/apps/sim/lib/copilot/auth/permissions.ts index ab36213b8ca..31d6972f158 100644 --- a/apps/sim/lib/copilot/auth/permissions.ts +++ b/apps/sim/lib/copilot/auth/permissions.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' -import { getActiveWorkflowContext } from '@sim/workflow-authz' -import { getUserEntityPermissions, type PermissionType } from '@/lib/workspaces/permissions/utils' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import type { PermissionType } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotPermissions') @@ -20,42 +20,15 @@ export async function verifyWorkflowAccess( workspaceId?: string }> { try { - const workflowContext = await getActiveWorkflowContext(workflowId) - if (!workflowContext) { - logger.warn('Attempt to access non-existent workflow', { - workflowId, - userId, - }) - return { hasAccess: false, userPermission: null } - } - - const { workspaceId } = workflowContext - - const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - - if (userPermission !== null) { - logger.debug('User has workspace permission for workflow', { - workflowId, - userId, - workspaceId, - userPermission, - }) - return { - hasAccess: true, - userPermission, - workspaceId, - } - } - - logger.warn('User has no access to workflow', { + const result = await authorizeWorkflowByWorkspacePermission({ workflowId, userId, - workspaceId, + action: 'read', }) return { - hasAccess: false, - userPermission: null, - workspaceId: workspaceId || undefined, + hasAccess: result.allowed, + userPermission: result.workspacePermission, + workspaceId: result.workflow?.workspaceId ?? undefined, } } catch (error) { logger.error('Error verifying workflow access', { error, workflowId, userId }) diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts index 8c0e4fe76ef..38dbe31de34 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -11,7 +11,7 @@ const { mockAuthorizeWorkflow, mockGetActiveWorkflow } = vi.hoisted(() => ({ mockGetActiveWorkflow: vi.fn(), })) -vi.mock('@sim/workflow-authz', () => ({ +vi.mock('@sim/platform-authz/workflow', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, getActiveWorkflowRecord: mockGetActiveWorkflow, })) diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index 7bca16a8298..7530f3c5ed7 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission, getActiveWorkflowRecord, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' import { and, asc, eq, isNull, sql } from 'drizzle-orm' import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' import { diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index e1347d05316..1778f80c780 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -1,10 +1,10 @@ import { type Context as OtelContext, context as otelContextApi } from '@opentelemetry/api' import { db } from '@sim/db' -import { copilotChats, permissions } from '@sim/db/schema' +import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { isZodError, validationErrorResponse } from '@/lib/api/server' @@ -48,6 +48,7 @@ import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { getUserEntityPermissions, isWorkspaceAccessDeniedError, + type PermissionType, } from '@/lib/workspaces/permissions/utils' import type { ChatContext } from '@/stores/panel' @@ -194,6 +195,7 @@ type UnifiedChatBranch = | { kind: 'workspace' workspaceId: string + workspacePermission: PermissionType | null effectiveModel: string goRoute: '/api/mothership' titleModel: string @@ -639,25 +641,20 @@ async function resolveBranch(params: { return createBadRequestResponse('workspaceId is required when workflowId is not provided') } - const [permissionRow] = await db - .select({ permissionType: permissions.permissionType }) - .from(permissions) - .where( - and( - eq(permissions.userId, authenticatedUserId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, requestedWorkspaceId) - ) - ) - .limit(1) + const workspacePermission = await getUserEntityPermissions( + authenticatedUserId, + 'workspace', + requestedWorkspaceId + ) - if (!permissionRow) { + if (workspacePermission === null) { return createBadRequestResponse('Workspace not found or access denied') } return { kind: 'workspace', workspaceId: requestedWorkspaceId, + workspacePermission, effectiveModel: DEFAULT_MODEL, goRoute: '/api/mothership', titleModel: DEFAULT_MODEL, @@ -880,15 +877,22 @@ export async function handleUnifiedChatPost(req: NextRequest) { }) const workspaceId = branch.workspaceId - const userPermissionPromise = workspaceId - ? getUserEntityPermissions(authenticatedUserId, 'workspace', workspaceId).catch((error) => { - logger.warn('Failed to load user permissions', { - error: getErrorMessage(error), - workspaceId, - }) - return null - }) - : Promise.resolve(null) + // The workspace branch already resolved this permission (and gated on it) + // during branch resolution; reuse it instead of querying again. + const userPermissionPromise = + branch.kind === 'workspace' + ? Promise.resolve(branch.workspacePermission) + : workspaceId + ? getUserEntityPermissions(authenticatedUserId, 'workspace', workspaceId).catch( + (error) => { + logger.warn('Failed to load user permissions', { + error: getErrorMessage(error), + workspaceId, + }) + return null + } + ) + : Promise.resolve(null) // Wrap the pre-LLM prep work in spans so the trace waterfall shows // where time is going between "request received" and "llm.stream // opens". Previously these ran bare under the root and inflated the diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 3edafe1ca93..ef33580211c 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission, getActiveWorkflowRecord, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' import { and, eq, isNull, ne } from 'drizzle-orm' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { diff --git a/apps/sim/lib/copilot/tools/handlers/access.ts b/apps/sim/lib/copilot/tools/handlers/access.ts index a435511a2f8..7391a829ead 100644 --- a/apps/sim/lib/copilot/tools/handlers/access.ts +++ b/apps/sim/lib/copilot/tools/handlers/access.ts @@ -1,9 +1,7 @@ -import { db } from '@sim/db' -import { permissions, workspace } from '@sim/db/schema' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' -import { and, desc, eq, isNull } from 'drizzle-orm' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import type { getWorkflowById } from '@/lib/workflows/utils' -import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowRecord = NonNullable>> @@ -33,26 +31,16 @@ export async function ensureWorkflowAccess( } export async function getDefaultWorkspaceId(userId: string): Promise { - const workspaces = await db - .select({ workspaceId: workspace.id }) - .from(permissions) - .innerJoin(workspace, eq(permissions.entityId, workspace.id)) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - isNull(workspace.archivedAt) - ) - ) - .orderBy(desc(workspace.createdAt)) - .limit(1) + const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId) + const mostRecent = accessibleRows + .map((row) => row.workspace) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] - const workspaceId = workspaces[0]?.workspaceId - if (!workspaceId) { + if (!mostRecent) { throw new Error('No workspace found for user') } - return workspaceId + return mostRecent.id } export async function ensureWorkspaceAccess( @@ -68,9 +56,7 @@ export async function ensureWorkspaceAccess( if (level === 'read') return if (level === 'admin') { - if (access.workspace?.ownerId === userId) return - const perm = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (perm !== 'admin') { + if (!access.canAdmin) { throw new Error('Admin access required for this workspace') } return diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index e72abc1082d..2e0f7fc5300 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -1,9 +1,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' +import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { assertFolderMutable, assertWorkflowMutable } from '@sim/workflow-authz' import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import { eq } from 'drizzle-orm' import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts index 247455e923c..b0d9221c9f2 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts @@ -6,8 +6,10 @@ import { eq } from 'drizzle-orm' import { decodeJwt } from 'jose' import { createPermissionError, verifyWorkflowAccess } from '@/lib/copilot/auth/permissions' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { getAllOAuthServices } from '@/lib/oauth' +import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' interface GetCredentialsParams { workflowId?: string @@ -47,6 +49,12 @@ export const getCredentialsServerTool: BaseServerTool const userId = authenticatedUserId + // Resolve workspace access once and thread it into both credential lookups + // below; each would otherwise re-resolve the same workspace-admin status. + const workspaceAccess: WorkspaceAccess | undefined = workspaceId + ? await checkWorkspaceAccess(workspaceId, userId) + : undefined + logger.info('Fetching credentials for authenticated user', { userId, hasWorkflowId: !!params?.workflowId, @@ -110,6 +118,31 @@ export const getCredentialsServerTool: BaseServerTool }) } + // Surface workspace-shared OAuth/service-account credentials the user can use, + // including those they reach as a derived workspace admin (not just their own + // personal account connections). Keyed by credential id so the agent references + // the workspace credential, not a legacy account id. + if (workspaceId) { + const sharedCredentials = await getAccessibleOAuthCredentials(workspaceId, userId, { + isWorkspaceAdmin: workspaceAccess?.canAdmin ?? false, + }) + const seenCredentialIds = new Set(connectedCredentials.map((c) => c.id)) + for (const cred of sharedCredentials) { + if (seenCredentialIds.has(cred.id)) continue + connectedProviderIds.add(cred.providerId) + const [, featureType = 'default'] = cred.providerId.split('-') + connectedCredentials.push({ + id: cred.id, + name: cred.displayName, + provider: cred.providerId, + serviceName: + allOAuthServices.find((s) => s.providerId === cred.providerId)?.name ?? cred.providerId, + lastUsed: cred.updatedAt.toISOString(), + isDefault: featureType === 'default', + }) + } + } + // Build list of not connected services const notConnectedServices = allOAuthServices .filter((service) => !connectedProviderIds.has(service.providerId)) @@ -121,7 +154,11 @@ export const getCredentialsServerTool: BaseServerTool })) // Fetch environment variables from both personal and workspace - const envResult = await getPersonalAndWorkspaceEnv(userId, workspaceId) + const envResult = await getPersonalAndWorkspaceEnv( + userId, + workspaceId, + workspaceAccess ? { workspaceAccess } : undefined + ) // Get all unique variable names from both personal and workspace const personalVarNames = Object.keys(envResult.personalEncrypted) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index 96a4fa98353..118060afd55 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -1,8 +1,11 @@ import { db } from '@sim/db' import { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, +} from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' -import { assertWorkflowMutable, authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { EditWorkflow } from '@/lib/copilot/generated/tool-catalog-v1' import { diff --git a/apps/sim/lib/copilot/validation/selector-validator.test.ts b/apps/sim/lib/copilot/validation/selector-validator.test.ts index 13df3491273..7d068c03b20 100644 --- a/apps/sim/lib/copilot/validation/selector-validator.test.ts +++ b/apps/sim/lib/copilot/validation/selector-validator.test.ts @@ -4,12 +4,21 @@ import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({ + mockCheckWorkspaceAccess: vi.fn(), +})) + vi.mock('@sim/db', () => dbChainMock) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + vi.mock('drizzle-orm', () => ({ and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), eq: vi.fn((...args: unknown[]) => ({ type: 'eq', args })), inArray: vi.fn((...args: unknown[]) => ({ type: 'inArray', args })), + isNotNull: vi.fn((field: unknown) => ({ type: 'isNotNull', field })), isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), or: vi.fn((...args: unknown[]) => ({ type: 'or', args })), })) @@ -20,6 +29,7 @@ describe('validateSelectorIds', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ canAdmin: false }) }) it('accepts shared workspace credential ids and legacy account ids for oauth-input', async () => { @@ -58,4 +68,17 @@ describe('validateSelectorIds', () => { expect(result.warning).toContain('Accessible workspace credentials:') expect(result.warning).toContain('Shared Gmail [cred-2]') }) + + it('lets a derived workspace admin reference shared credentials without membership', async () => { + mockCheckWorkspaceAccess.mockResolvedValueOnce({ canAdmin: true }) + dbChainMockFns.where.mockResolvedValueOnce([{ credentialId: 'shared-cred', accountId: null }]) + + const result = await validateSelectorIds('oauth-input', ['shared-cred'], { + userId: 'admin-user', + workspaceId: 'workspace-1', + }) + + expect(result).toEqual({ valid: ['shared-cred'], invalid: [] }) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/sim/lib/copilot/validation/selector-validator.ts b/apps/sim/lib/copilot/validation/selector-validator.ts index 7c6fefa6a44..30490f583cc 100644 --- a/apps/sim/lib/copilot/validation/selector-validator.ts +++ b/apps/sim/lib/copilot/validation/selector-validator.ts @@ -10,7 +10,8 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, eq, inArray, isNull, or } from 'drizzle-orm' +import { and, eq, inArray, isNotNull, isNull, or } from 'drizzle-orm' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('SelectorValidator') @@ -44,12 +45,21 @@ export async function validateSelectorIds( case 'oauth-input': { if (context.workspaceId) { // In workspace workflows, oauth-input values are workspace credential IDs. - // Accept both current credential IDs and legacy account IDs when the user - // has active membership to the workspace credential. + // Accept both current credential IDs and legacy account IDs. Workspace + // admins (incl. derived org admins) can reference any shared credential; + // other members need active credential membership. + const isWorkspaceAdmin = (await checkWorkspaceAccess(context.workspaceId, context.userId)) + .canAdmin + + const matchWhere = and( + eq(credential.workspaceId, context.workspaceId), + inArray(credential.type, ['oauth', 'service_account']), + or(inArray(credential.id, idsArray), inArray(credential.accountId, idsArray)) + ) const results = await db .select({ credentialId: credential.id, accountId: credential.accountId }) .from(credential) - .innerJoin( + .leftJoin( credentialMember, and( eq(credentialMember.credentialId, credential.id), @@ -57,13 +67,7 @@ export async function validateSelectorIds( eq(credentialMember.status, 'active') ) ) - .where( - and( - eq(credential.workspaceId, context.workspaceId), - inArray(credential.type, ['oauth', 'service_account']), - or(inArray(credential.id, idsArray), inArray(credential.accountId, idsArray)) - ) - ) + .where(and(matchWhere, isWorkspaceAdmin ? undefined : isNotNull(credentialMember.id))) existingIds = Array.from( new Set( @@ -83,17 +87,22 @@ export async function validateSelectorIds( const existingSet = new Set(existingIds) const invalidIds = idsArray.filter((id) => !existingSet.has(id)) if (invalidIds.length > 0) { + const accessibleSelect = { + id: credential.id, + displayName: credential.displayName, + accountId: credential.accountId, + credentialProviderId: credential.providerId, + accountProviderId: account.providerId, + } + const accessibleWhere = and( + eq(credential.workspaceId, context.workspaceId), + inArray(credential.type, ['oauth', 'service_account']) + ) const allAccessibleCredentials = await db - .select({ - id: credential.id, - displayName: credential.displayName, - accountId: credential.accountId, - credentialProviderId: credential.providerId, - accountProviderId: account.providerId, - }) + .select(accessibleSelect) .from(credential) .leftJoin(account, eq(credential.accountId, account.id)) - .innerJoin( + .leftJoin( credentialMember, and( eq(credentialMember.credentialId, credential.id), @@ -102,10 +111,7 @@ export async function validateSelectorIds( ) ) .where( - and( - eq(credential.workspaceId, context.workspaceId), - inArray(credential.type, ['oauth', 'service_account']) - ) + and(accessibleWhere, isWorkspaceAdmin ? undefined : isNotNull(credentialMember.id)) ) const availableCredentials = allAccessibleCredentials diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index bb24e9221e1..7e51de7093c 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -1021,7 +1021,7 @@ export class WorkspaceVFS { * Resolve the set of folder IDs that are effectively locked — locked directly * or via a locked ancestor folder. A workflow inside any of these folders is * itself immutable, so its meta.json must report `locked: true`. Mirrors the - * folder-chain walk in `@sim/workflow-authz` getFolderLockStatus, but resolves + * folder-chain walk in `@sim/platform-authz/workflow` getFolderLockStatus, but resolves * the whole workspace in memory to avoid a per-workflow DB round trip. */ private computeLockedFolderIds( diff --git a/apps/sim/lib/credentials/access.test.ts b/apps/sim/lib/credentials/access.test.ts index f40fee1d931..fcba37053f8 100644 --- a/apps/sim/lib/credentials/access.test.ts +++ b/apps/sim/lib/credentials/access.test.ts @@ -18,6 +18,9 @@ vi.mock('@sim/db', () => ({ })) vi.mock('@sim/db/schema', () => ({ + credentialTypeEnum: { + enumValues: ['oauth', 'env_workspace', 'env_personal', 'service_account'], + }, credential: { id: 'credential.id', workspaceId: 'credential.workspaceId', diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 19249ef9614..1fd5728982a 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -1,12 +1,45 @@ import { db } from '@sim/db' -import { credential, credentialMember } from '@sim/db/schema' +import { credential, credentialMember, credentialTypeEnum } from '@sim/db/schema' import { and, eq, inArray } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' type ActiveCredentialMember = typeof credentialMember.$inferSelect type CredentialRecord = typeof credential.$inferSelect +export type CredentialType = (typeof credentialTypeEnum.enumValues)[number] + +/** + * Credential types shared at the workspace level — every type except a user's + * personal env vars. Derived from the enum so a newly added credential type is + * treated as shared by default, keeping visibility, role, and admin derivation + * consistent instead of drifting against a hand-maintained inclusion list. + */ +export const SHARED_CREDENTIAL_TYPES = credentialTypeEnum.enumValues.filter( + (type) => type !== 'env_personal' +) + +/** Whether a credential is shared at the workspace level (i.e. not a personal env var). */ +export function isSharedCredentialType(type: CredentialType): boolean { + return type !== 'env_personal' +} + +/** + * Whether a user is an admin of a credential: an explicit credential-member admin, + * or — for shared credentials only — a workspace admin (workspace admins are + * derived credential admins, but never for personal env vars). + */ +export function deriveCredentialAdmin(params: { + credentialType: CredentialType + memberRole: ActiveCredentialMember['role'] | null | undefined + workspaceCanAdmin: boolean +}): boolean { + return ( + params.memberRole === 'admin' || + (isSharedCredentialType(params.credentialType) && params.workspaceCanAdmin) + ) +} + export interface CredentialActorContext { credential: CredentialRecord | null member: ActiveCredentialMember | null @@ -16,11 +49,14 @@ export interface CredentialActorContext { } /** - * Resolves user access context for a credential. + * Resolves user access context for a credential. Pass `workspaceAccess` when the + * caller has already resolved access for the credential's workspace to skip a + * redundant lookup; it is reused only when it matches the credential's workspace. */ export async function getCredentialActorContext( credentialId: string, - userId: string + userId: string, + options?: { workspaceAccess?: WorkspaceAccess } ): Promise { const [credentialRow] = await db .select() @@ -38,7 +74,11 @@ export async function getCredentialActorContext( } } - const workspaceAccess = await checkWorkspaceAccess(credentialRow.workspaceId, userId) + const providedAccess = options?.workspaceAccess + const workspaceAccess = + providedAccess && providedAccess.workspace?.id === credentialRow.workspaceId + ? providedAccess + : await checkWorkspaceAccess(credentialRow.workspaceId, userId) const [memberRow] = await db .select() .from(credentialMember) @@ -51,9 +91,11 @@ export async function getCredentialActorContext( ) .limit(1) - const isAdmin = - memberRow?.role === 'admin' || - (credentialRow.type !== 'env_personal' && workspaceAccess.canAdmin) + const isAdmin = deriveCredentialAdmin({ + credentialType: credentialRow.type, + memberRole: memberRow?.role, + workspaceCanAdmin: workspaceAccess.canAdmin, + }) return { credential: credentialRow, @@ -65,32 +107,29 @@ export async function getCredentialActorContext( } /** - * Revokes all credential memberships for a user across a workspace. Workspace - * owners and admins are derived credential admins, so no per-credential owner - * promotion is needed to avoid orphaning a credential. + * Revokes all credential memberships for a user across one or more workspaces. + * Workspace owners and admins are derived credential admins, so no per-credential + * owner promotion is needed to avoid orphaning a credential. Returns the number + * of memberships revoked. */ -export async function revokeWorkspaceCredentialMemberships( - workspaceId: string, - userId: string -): Promise { - await revokeWorkspaceCredentialMembershipsTx(db, workspaceId, userId) -} - export async function revokeWorkspaceCredentialMembershipsTx( tx: DbOrTx, - workspaceId: string, + workspaceId: string | string[], userId: string -): Promise { +): Promise { + const workspaceIds = Array.isArray(workspaceId) ? workspaceId : [workspaceId] + if (workspaceIds.length === 0) return 0 + const workspaceCredentialIds = await tx .select({ id: credential.id }) .from(credential) - .where(eq(credential.workspaceId, workspaceId)) + .where(inArray(credential.workspaceId, workspaceIds)) - if (workspaceCredentialIds.length === 0) return + if (workspaceCredentialIds.length === 0) return 0 const credIds = workspaceCredentialIds.map((c) => c.id) - await tx + const revoked = await tx .update(credentialMember) .set({ status: 'revoked', updatedAt: new Date() }) .where( @@ -100,4 +139,7 @@ export async function revokeWorkspaceCredentialMembershipsTx( inArray(credentialMember.credentialId, credIds) ) ) + .returning({ id: credentialMember.id }) + + return revoked.length } diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index d4922c3076e..f85989cecb0 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -11,7 +11,7 @@ import { getAccessibleEnvCredentials, syncPersonalEnvCredentialsForUser, } from '@/lib/credentials/environment' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('EnvironmentUtils') const EFFECTIVE_DECRYPTED_ENV_CACHE_TTL_MS = 2_000 @@ -92,7 +92,8 @@ export async function getEnvironmentVariableKeys(userId: string): Promise<{ export async function getPersonalAndWorkspaceEnv( userId: string, - workspaceId?: string + workspaceId?: string, + options?: { workspaceAccess?: WorkspaceAccess } ): Promise<{ personalEncrypted: Record workspaceEncrypted: Record @@ -103,7 +104,7 @@ export async function getPersonalAndWorkspaceEnv( }> { let workspaceCanAdmin = false if (workspaceId) { - const access = await checkWorkspaceAccess(workspaceId, userId) + const access = options?.workspaceAccess ?? (await checkWorkspaceAccess(workspaceId, userId)) if (!access.hasAccess) { throw new Error(`Access denied to workspace ${workspaceId}`) } @@ -169,7 +170,7 @@ export async function getPersonalAndWorkspaceEnv( let workspaceEncrypted: Record = allWorkspaceEncrypted if (hasCredentialFiltering) { - personalEncrypted = {} + personalEncrypted = { ...ownPersonalEncrypted } for (const [envKey, ownerUserId] of selectedPersonalOwners.entries()) { const ownerVariables = ownerVariablesByUserId.get(ownerUserId) const encryptedValue = ownerVariables?.[envKey] diff --git a/apps/sim/lib/execution/preprocessing.test.ts b/apps/sim/lib/execution/preprocessing.test.ts index e72c44a567e..223f8e7e6c6 100644 --- a/apps/sim/lib/execution/preprocessing.test.ts +++ b/apps/sim/lib/execution/preprocessing.test.ts @@ -39,7 +39,7 @@ vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId, })) -vi.mock('@sim/workflow-authz', () => ({ +vi.mock('@sim/platform-authz/workflow', () => ({ getActiveWorkflowRecord: vi.fn().mockResolvedValue({ id: 'workflow-1', userId: 'creator-1', diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index a41d3dbbbca..17af2700f2c 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -1,6 +1,6 @@ import type { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/workflow-authz' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { getActivelyBannedUserIds } from '@/lib/auth/ban' import { checkOrgMemberUsageLimit, diff --git a/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts b/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts index 2fea91bfe0a..9b8059d2845 100644 --- a/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts +++ b/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts @@ -28,7 +28,7 @@ vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId, })) -vi.mock('@sim/workflow-authz', () => ({ +vi.mock('@sim/platform-authz/workflow', () => ({ getActiveWorkflowRecord: vi.fn().mockResolvedValue({ id: 'workflow-1', workspaceId: 'workspace-1', diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index c8152bad844..ae99f6f3b11 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -14,6 +14,7 @@ import { workspaceEnvironment, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { PERMISSION_RANK, type PermissionType } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' import { normalizeEmail } from '@sim/utils/string' import { and, eq, inArray, lte } from 'drizzle-orm' @@ -33,9 +34,6 @@ import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InvitationCore') -export const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const -export type PermissionLevel = keyof typeof PERMISSION_RANK - export const INVITATION_EXPIRY_DAYS = 7 export function computeInvitationExpiry(daysFromNow = INVITATION_EXPIRY_DAYS): Date { @@ -474,12 +472,12 @@ export async function acceptInvitation( ) .limit(1) - const newPermission = grant.permission as PermissionLevel + const newPermission = grant.permission as PermissionType const newRank = PERMISSION_RANK[newPermission] ?? 0 if (existingPermission) { const existingRank = - PERMISSION_RANK[existingPermission.permissionType as PermissionLevel] ?? 0 + PERMISSION_RANK[existingPermission.permissionType as PermissionType] ?? 0 if (newRank > existingRank) { await tx .update(permissions) diff --git a/apps/sim/lib/invitations/workspace-invitations.ts b/apps/sim/lib/invitations/workspace-invitations.ts index 63de5039649..c60fa866cbd 100644 --- a/apps/sim/lib/invitations/workspace-invitations.ts +++ b/apps/sim/lib/invitations/workspace-invitations.ts @@ -20,6 +20,7 @@ import { import { captureServerEvent } from '@/lib/posthog/server' import { getWorkspaceWithOwner, + hasWorkspaceAdminAccess, type PermissionType, type WorkspaceWithOwner, } from '@/lib/workspaces/permissions/utils' @@ -85,20 +86,8 @@ export async function prepareWorkspaceInvitationContext({ }): Promise { await validateInvitationsAllowed(inviterId, workspaceId) - const userPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, inviterId), - eq(permissions.permissionType, 'admin') - ) - ) - .then((rows) => rows[0]) - - if (!userPermission) { + const isAdmin = await hasWorkspaceAdminAccess(inviterId, workspaceId) + if (!isAdmin) { throw new WorkspaceInvitationError({ message: 'You need admin permissions to invite users', status: 403, diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts index 36c4efb9fbb..dd90e80180f 100644 --- a/apps/sim/lib/logs/fetch-log-detail.ts +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -2,7 +2,6 @@ import { db } from '@sim/db' import { jobExecutionLogs, pausedExecutions, - permissions, usageLog, workflow, workflowDeploymentVersion, @@ -11,6 +10,7 @@ import { import { and, eq, type SQL } from 'drizzle-orm' import type { CostLedger } from '@/lib/api/contracts/logs' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' type LookupColumn = 'id' | 'executionId' @@ -84,6 +84,9 @@ export async function fetchLogDetail({ lookupColumn, lookupValue, }: FetchLogDetailArgs) { + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.hasAccess) return null + const workflowMatch: SQL = lookupColumn === 'id' ? eq(workflowExecutionLogs.id, lookupValue) @@ -125,14 +128,6 @@ export async function fetchLogDetail({ eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) ) .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where(and(workflowMatch, eq(workflowExecutionLogs.workspaceId, workspaceId))) .limit(1) @@ -221,14 +216,6 @@ export async function fetchLogDetail({ createdAt: jobExecutionLogs.createdAt, }) .from(jobExecutionLogs) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, jobExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where(and(jobMatch, eq(jobExecutionLogs.workspaceId, workspaceId))) .limit(1) diff --git a/apps/sim/lib/logs/list-logs.test.ts b/apps/sim/lib/logs/list-logs.test.ts index b80bde60a9b..d8ad2cdb6cf 100644 --- a/apps/sim/lib/logs/list-logs.test.ts +++ b/apps/sim/lib/logs/list-logs.test.ts @@ -50,6 +50,17 @@ vi.mock('@/lib/logs/folder-expansion', () => ({ expandFolderIdsWithDescendants: vi.fn(async (_ws: string, ids: string | undefined) => ids), })) +// listLogs gates workspace access at entry; the resolver is tested separately. +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: vi.fn(async () => ({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + workspace: { id: 'ws-1', name: 'Test', ownerId: 'user-1', organizationId: null }, + })), +})) + import type { ListLogsParams } from './list-logs' import { decodeCursor, listLogs } from './list-logs' diff --git a/apps/sim/lib/logs/list-logs.ts b/apps/sim/lib/logs/list-logs.ts index 0165d60ad73..4989d795f49 100644 --- a/apps/sim/lib/logs/list-logs.ts +++ b/apps/sim/lib/logs/list-logs.ts @@ -2,7 +2,6 @@ import { dbReplica } from '@sim/db' import { jobExecutionLogs, pausedExecutions, - permissions, workflow, workflowDeploymentVersion, workflowExecutionLogs, @@ -33,6 +32,7 @@ import type { import { jobCostTotal } from '@/lib/logs/fetch-log-detail' import { buildFilterConditions } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' export type ListLogsParams = z.output @@ -66,6 +66,11 @@ export function decodeCursor(cursor: string): CursorData | null { * workspace permission via the `permissions` join. */ export async function listLogs(params: ListLogsParams, userId: string): Promise { + const access = await checkWorkspaceAccess(params.workspaceId, userId) + if (!access.hasAccess) { + return { data: [], nextCursor: null } + } + const sortBy = params.sortBy as SortBy const sortOrder = params.sortOrder as SortOrder const cursor = params.cursor ? decodeCursor(params.cursor) : null @@ -221,14 +226,6 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) ) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where(and(...workflowConditions)) .orderBy(orderByClause(workflowSortExpr), dir(workflowExecutionLogs.id)) .limit(fetchSize) @@ -236,10 +233,6 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, p.workspaceId)] if (includeJobLogs) { - jobConditions.push( - sql`EXISTS (SELECT 1 FROM ${permissions} WHERE ${permissions.entityType} = 'workspace' AND ${permissions.entityId} = ${jobExecutionLogs.workspaceId} AND ${permissions.userId} = ${userId})` - ) - if (p.level && p.level !== 'all') { const levels = p.level.split(',').filter(Boolean) const jobLevelConditions: SQL[] = [] diff --git a/apps/sim/lib/mcp/middleware.ts b/apps/sim/lib/mcp/middleware.ts index 90367b3cd75..ee75a6b6304 100644 --- a/apps/sim/lib/mcp/middleware.ts +++ b/apps/sim/lib/mcp/middleware.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import type { NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' @@ -213,16 +214,7 @@ async function validateMcpAuth( * Check if user has required permission level */ function checkPermissionLevel(userPermission: string, requiredLevel: McpPermissionLevel): boolean { - switch (requiredLevel) { - case 'read': - return ['read', 'write', 'admin'].includes(userPermission) - case 'write': - return ['write', 'admin'].includes(userPermission) - case 'admin': - return userPermission === 'admin' - default: - return false - } + return permissionSatisfies(userPermission as PermissionType, requiredLevel) } /** diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index e82025d853f..86ac2312b2d 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -1,4 +1,4 @@ -import { copilotChats, db, mothershipInboxTask, permissions, user, workspace } from '@sim/db' +import { copilotChats, db, mothershipInboxTask, user, workspace } from '@sim/db' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -330,20 +330,21 @@ async function resolveUserId( senderEmail: string, ws: { id: string; ownerId: string } ): Promise { - const [member] = await db - .select({ userId: permissions.userId }) - .from(permissions) - .innerJoin(user, eq(permissions.userId, user.id)) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, ws.id), - sql`lower(${user.email}) = ${senderEmail.toLowerCase()}` - ) - ) + const [matchedUser] = await db + .select({ id: user.id }) + .from(user) + .where(sql`lower(${user.email}) = ${senderEmail.toLowerCase()}`) + .orderBy(user.createdAt) .limit(1) - return member?.userId ?? ws.ownerId + if (matchedUser) { + const permission = await getUserEntityPermissions(matchedUser.id, 'workspace', ws.id) + if (permission !== null) { + return matchedUser.id + } + } + + return ws.ownerId } /** diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index d930858fb6a..26947600a0f 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -1,7 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 5f5524f2846..d3c38cdbbb0 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -2,9 +2,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isFolderInWorkspace } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { isFolderInWorkspace } from '@sim/workflow-authz' import { and, eq, isNull, min, ne } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 0d409629f77..75a3ad2d340 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -7,8 +7,11 @@ import { workflowSubflows, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + authorizeWorkflowByWorkspacePermission, + FolderLockedError, +} from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' -import { authorizeWorkflowByWorkspacePermission, FolderLockedError } from '@sim/workflow-authz' import { and, eq, isNull, min } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 725e7a13479..895a87f6013 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -1,9 +1,9 @@ import { db, runOutsideTransactionContext, workflow, workflowDeploymentVersion } from '@sim/db' import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { getActiveWorkflowContext } from '@sim/workflow-authz' import { loadWorkflowFromNormalizedTablesRaw, persistMigratedBlocks, diff --git a/apps/sim/lib/workflows/queries.ts b/apps/sim/lib/workflows/queries.ts index 751cbd23695..dd3bfd141d9 100644 --- a/apps/sim/lib/workflows/queries.ts +++ b/apps/sim/lib/workflows/queries.ts @@ -1,7 +1,8 @@ import { db } from '@sim/db' -import { permissions, workflow } from '@sim/db/schema' +import { workflow } from '@sim/db/schema' import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' import type { WorkflowListItem } from '@/lib/api/contracts/workflows' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowListScope = 'active' | 'archived' | 'all' @@ -85,11 +86,8 @@ export async function listWorkflowsForUser({ return rows.map(toListItem) } - const workspacePermissionRows = await db - .select({ workspaceId: permissions.entityId }) - .from(permissions) - .where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))) - const workspaceIds = workspacePermissionRows.map((row) => row.workspaceId) + const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId, 'all') + const workspaceIds = accessibleRows.map((row) => row.workspace.id) if (workspaceIds.length === 0) return [] const rows = await db diff --git a/apps/sim/lib/workflows/utils.test.ts b/apps/sim/lib/workflows/utils.test.ts index 1af508d898b..0936d440680 100644 --- a/apps/sim/lib/workflows/utils.test.ts +++ b/apps/sim/lib/workflows/utils.test.ts @@ -20,7 +20,7 @@ const { mockAuthorizeWorkflow } = vi.hoisted(() => ({ mockAuthorizeWorkflow: vi.fn(), })) -vi.mock('@sim/workflow-authz', () => ({ +vi.mock('@sim/platform-authz/workflow', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, getActiveWorkflowContext: vi.fn(), getActiveWorkflowRecord: vi.fn(), diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 2f20e012a99..ced3f838e93 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -1,8 +1,8 @@ import { db } from '@sim/db' -import { permissions, workflowFolder, workflow as workflowTable } from '@sim/db/schema' +import { workflowFolder, workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, asc, eq, inArray, isNull, max, min, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' @@ -11,6 +11,7 @@ import { materializeInlineExecutionValue } from '@/lib/execution/payloads/inline import type { ExecutionMaterializationContext } from '@/lib/execution/payloads/materialization.server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' import type { ExecutionResult } from '@/executor/types' const logger = createLogger('WorkflowUtils') @@ -161,12 +162,8 @@ export async function resolveWorkflowIdForUser( } } - const workspaceIds = await db - .select({ entityId: permissions.entityId }) - .from(permissions) - .where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))) - - const workspaceIdList = workspaceIds.map((row) => row.entityId) + const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId, 'all') + const workspaceIdList = accessibleRows.map((row) => row.workspace.id) const allowedWorkspaceIds = workspaceId ? workspaceIdList.filter((candidateWorkspaceId) => candidateWorkspaceId === workspaceId) : workspaceIdList diff --git a/apps/sim/lib/workspace-events/emitter.test.ts b/apps/sim/lib/workspace-events/emitter.test.ts index 9fc893ee6a2..096c79fb4d8 100644 --- a/apps/sim/lib/workspace-events/emitter.test.ts +++ b/apps/sim/lib/workspace-events/emitter.test.ts @@ -19,7 +19,7 @@ const { mockProcessPolledWebhookEvent: vi.fn(), })) -vi.mock('@sim/workflow-authz', () => ({ +vi.mock('@sim/platform-authz/workflow', () => ({ getActiveWorkflowContext: mockGetActiveWorkflowContext, })) diff --git a/apps/sim/lib/workspace-events/emitter.ts b/apps/sim/lib/workspace-events/emitter.ts index 4c9aa07e440..b2e0f5d1bbc 100644 --- a/apps/sim/lib/workspace-events/emitter.ts +++ b/apps/sim/lib/workspace-events/emitter.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' +import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' import { generateShortId } from '@sim/utils/id' -import { getActiveWorkflowContext } from '@sim/workflow-authz' import type { WorkflowExecutionLog } from '@/lib/logs/types' import { isSimRuleEventType, diff --git a/apps/sim/lib/workspaces/organization/utils.ts b/apps/sim/lib/workspaces/organization/utils.ts index 21472fbf0d8..27ccb51396b 100644 --- a/apps/sim/lib/workspaces/organization/utils.ts +++ b/apps/sim/lib/workspaces/organization/utils.ts @@ -3,6 +3,7 @@ * These are pure functions that compute values from organization data */ +import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { quickValidateEmail } from '@/lib/messaging/email/validation' import type { Organization } from '@/lib/workspaces/organization/types' @@ -28,7 +29,7 @@ export function isAdminOrOwner( userEmail?: string ): boolean { const role = getUserRole(organization, userEmail) - return role === 'owner' || role === 'admin' + return isOrgAdminRole(role) } /** diff --git a/apps/sim/lib/workspaces/permissions/utils.test.ts b/apps/sim/lib/workspaces/permissions/utils.test.ts index aec68b1eb4c..a0821ceaaef 100644 --- a/apps/sim/lib/workspaces/permissions/utils.test.ts +++ b/apps/sim/lib/workspaces/permissions/utils.test.ts @@ -7,7 +7,6 @@ import { getUsersWithPermissions, getWorkspaceById, getWorkspaceWithOwner, - hasAdminPermission, hasWorkspaceAdminAccess, workspaceExists, } from '@/lib/workspaces/permissions/utils' @@ -137,62 +136,6 @@ describe('Permission Utils', () => { }) }) - describe('hasAdminPermission', () => { - it('should return true when user has admin permission for workspace', async () => { - const chain = createMockChain([{ id: 'perm1' }]) - mockDb.select.mockReturnValue(chain) - - const result = await hasAdminPermission('admin-user', 'workspace123') - - expect(result).toBe(true) - }) - - it('should return false when user has no admin permission for workspace', async () => { - const chain = createMockChain([]) - mockDb.select.mockReturnValue(chain) - - const result = await hasAdminPermission('regular-user', 'workspace123') - - expect(result).toBe(false) - }) - - it('should return false when user has write permission but not admin', async () => { - const chain = createMockChain([]) - mockDb.select.mockReturnValue(chain) - - const result = await hasAdminPermission('write-user', 'workspace123') - - expect(result).toBe(false) - }) - - it('should return false when user has read permission but not admin', async () => { - const chain = createMockChain([]) - mockDb.select.mockReturnValue(chain) - - const result = await hasAdminPermission('read-user', 'workspace123') - - expect(result).toBe(false) - }) - - it('should handle non-existent workspace', async () => { - const chain = createMockChain([]) - mockDb.select.mockReturnValue(chain) - - const result = await hasAdminPermission('user123', 'non-existent-workspace') - - expect(result).toBe(false) - }) - - it('should handle empty user ID', async () => { - const chain = createMockChain([]) - mockDb.select.mockReturnValue(chain) - - const result = await hasAdminPermission('', 'workspace123') - - expect(result).toBe(false) - }) - }) - describe('getUsersWithPermissions', () => { function mockSelectSequence(results: any[][]) { let index = 0 diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index 0c4b0b8b93b..6812b6a3047 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -1,17 +1,18 @@ import { db } from '@sim/db' +import { member, permissions, user, type WorkspaceMode, workspace } from '@sim/db/schema' import { - member, - permissions, - type permissionTypeEnum, - user, - type WorkspaceMode, - workspace, -} from '@sim/db/schema' + 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 = (typeof permissionTypeEnum.enumValues)[number] +export type { PermissionType } export interface WorkspaceBasic { id: string } @@ -104,14 +105,15 @@ export async function getWorkspaceWithOwner( return ws || null } -const PERMISSION_RANK: Record = { admin: 3, write: 2, read: 1 } - /** * Resolve the effective workspace permission for a user under the governance * inheritance model: the workspace owner and the owners/admins of the * organization that owns the workspace are workspace admins. Returns the higher * of any explicit grant and any derived admin. * + * 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 (owner + organization already loaded) */ @@ -119,31 +121,7 @@ export async function getEffectiveWorkspacePermission( userId: string, ws: Pick ): Promise { - if (ws.ownerId === userId) { - return 'admin' - } - - const [permissionRow] = await db - .select({ permissionType: permissions.permissionType }) - .from(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, ws.id) - ) - ) - .limit(1) - - const explicit = permissionRow?.permissionType ?? null - - if (ws.organizationId && explicit !== 'admin') { - if (await isOrganizationAdminOrOwner(userId, ws.organizationId)) { - return 'admin' - } - } - - return explicit + return resolveEffectiveWorkspacePermission(userId, ws.id, ws.ownerId, ws.organizationId) } /** @@ -169,8 +147,8 @@ export async function checkWorkspaceAccess( const permission = await getEffectiveWorkspacePermission(userId, ws) const hasAccess = permission !== null - const canWrite = permission === 'write' || permission === 'admin' - const canAdmin = permission === 'admin' + const canWrite = permissionSatisfies(permission, 'write') + const canAdmin = permissionSatisfies(permission, 'admin') return { exists: true, hasAccess, canWrite, canAdmin, workspace: ws } } @@ -252,30 +230,6 @@ export async function getUserEntityPermissions( return highestPermission.permissionType } -/** - * Check if a user has admin permission for a specific workspace - * - * @param userId - The ID of the user to check - * @param workspaceId - The ID of the workspace to check - * @returns Promise - True if the user has admin permission for the workspace, false otherwise - */ -export async function hasAdminPermission(userId: string, workspaceId: string): Promise { - const result = await db - .select({ id: permissions.id }) - .from(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.permissionType, 'admin') - ) - ) - .limit(1) - - return result.length > 0 -} - /** * Retrieves a list of users with their associated permissions for a given workspace. * @@ -354,7 +308,10 @@ export async function getUsersWithPermissions( .from(member) .innerJoin(user, eq(member.userId, user.id)) .where( - and(eq(member.organizationId, ws.organizationId), inArray(member.role, ['owner', 'admin'])) + and( + eq(member.organizationId, ws.organizationId), + inArray(member.role, [...ORG_ADMIN_ROLES]) + ) ) for (const row of orgAdmins) { @@ -454,7 +411,7 @@ export async function isOrganizationAdminOrOwner( .from(member) .where(and(eq(member.userId, userId), eq(member.organizationId, organizationId))) .limit(1) - return row?.role === 'owner' || row?.role === 'admin' + return isOrgAdminRole(row?.role) } /** diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 641dd15e9af..addb1fcad10 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { member, type WorkspaceMode, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, count, eq, isNull } from 'drizzle-orm' import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' @@ -249,7 +250,7 @@ export async function getWorkspaceCreationPolicy({ if (organizationId && orgRole) { const billedAccountUserId = await requireOrganizationOwnerId(organizationId) - if (!['owner', 'admin'].includes(orgRole)) { + if (!isOrgAdminRole(orgRole)) { return { canCreate: false, workspaceMode: WORKSPACE_MODE.ORGANIZATION, @@ -298,7 +299,7 @@ export async function getWorkspaceCreationPolicy({ ) { const billedAccountUserId = await requireOrganizationOwnerId(organizationId) - if (!['owner', 'admin'].includes(orgRole)) { + if (!isOrgAdminRole(orgRole)) { return { canCreate: false, workspaceMode: WORKSPACE_MODE.ORGANIZATION, diff --git a/apps/sim/lib/workspaces/utils.ts b/apps/sim/lib/workspaces/utils.ts index 17f34f1669e..870ff2d7923 100644 --- a/apps/sim/lib/workspaces/utils.ts +++ b/apps/sim/lib/workspaces/utils.ts @@ -1,10 +1,11 @@ import { db } from '@sim/db' import { member, permissions, workflow, workspace as workspaceTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import type { PermissionType } from '@sim/platform-authz/workspace' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' import { and, count, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -import type { PermissionType } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceUtils') @@ -61,7 +62,7 @@ export async function getOrgAdminWorkspaceRows( .where(eq(member.userId, userId)) .limit(1) - if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) { + if (!membership || !isOrgAdminRole(membership.role)) { return [] } diff --git a/apps/sim/package.json b/apps/sim/package.json index be49eca24d7..c90dc98996b 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -95,10 +95,10 @@ "@react-email/render": "2.0.8", "@sim/audit": "workspace:*", "@sim/logger": "workspace:*", + "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", "@sim/security": "workspace:*", "@sim/utils": "workspace:*", - "@sim/workflow-authz": "workspace:*", "@sim/workflow-persistence": "workspace:*", "@sim/workflow-types": "workspace:*", "@t3-oss/env-nextjs": "0.13.4", diff --git a/apps/sim/vitest.setup.ts b/apps/sim/vitest.setup.ts index b5b7aa72952..92e945dcca0 100644 --- a/apps/sim/vitest.setup.ts +++ b/apps/sim/vitest.setup.ts @@ -21,7 +21,7 @@ vi.mock('@sim/db', () => databaseMock) vi.mock('@sim/db/schema', () => schemaMock) vi.mock('drizzle-orm', () => drizzleOrmMock) vi.mock('@sim/logger', () => loggerMock) -vi.mock('@sim/workflow-authz', () => workflowAuthzMock) +vi.mock('@sim/platform-authz/workflow', () => workflowAuthzMock) vi.mock('@/lib/auth', () => authMock) vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) vi.mock('@/lib/core/utils/request', () => requestUtilsMock) diff --git a/bun.lock b/bun.lock index ad58c9cb044..fb7f52d232e 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -62,10 +61,10 @@ "@sim/auth": "workspace:*", "@sim/db": "workspace:*", "@sim/logger": "workspace:*", + "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", "@sim/security": "workspace:*", "@sim/utils": "workspace:*", - "@sim/workflow-authz": "workspace:*", "@sim/workflow-persistence": "workspace:*", "@sim/workflow-types": "workspace:*", "@socket.io/redis-adapter": "8.3.0", @@ -151,10 +150,10 @@ "@react-email/render": "2.0.8", "@sim/audit": "workspace:*", "@sim/logger": "workspace:*", + "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", "@sim/security": "workspace:*", "@sim/utils": "workspace:*", - "@sim/workflow-authz": "workspace:*", "@sim/workflow-persistence": "workspace:*", "@sim/workflow-types": "workspace:*", "@t3-oss/env-nextjs": "0.13.4", @@ -360,6 +359,18 @@ "vitest": "^4.1.0", }, }, + "packages/platform-authz": { + "name": "@sim/platform-authz", + "version": "0.1.0", + "dependencies": { + "@sim/db": "workspace:*", + "drizzle-orm": "^0.45.2", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "typescript": "^5.7.3", + }, + }, "packages/realtime-protocol": { "name": "@sim/realtime-protocol", "version": "0.1.0", @@ -423,18 +434,6 @@ "vitest": "^4.1.0", }, }, - "packages/workflow-authz": { - "name": "@sim/workflow-authz", - "version": "0.1.0", - "dependencies": { - "@sim/db": "workspace:*", - "drizzle-orm": "^0.45.2", - }, - "devDependencies": { - "@sim/tsconfig": "workspace:*", - "typescript": "^5.7.3", - }, - }, "packages/workflow-persistence": { "name": "@sim/workflow-persistence", "version": "0.1.0", @@ -1410,6 +1409,8 @@ "@sim/logger": ["@sim/logger@workspace:packages/logger"], + "@sim/platform-authz": ["@sim/platform-authz@workspace:packages/platform-authz"], + "@sim/realtime": ["@sim/realtime@workspace:apps/realtime"], "@sim/realtime-protocol": ["@sim/realtime-protocol@workspace:packages/realtime-protocol"], @@ -1422,8 +1423,6 @@ "@sim/utils": ["@sim/utils@workspace:packages/utils"], - "@sim/workflow-authz": ["@sim/workflow-authz@workspace:packages/workflow-authz"], - "@sim/workflow-persistence": ["@sim/workflow-persistence@workspace:packages/workflow-persistence"], "@sim/workflow-types": ["@sim/workflow-types@workspace:packages/workflow-types"], diff --git a/packages/workflow-authz/package.json b/packages/platform-authz/package.json similarity index 63% rename from packages/workflow-authz/package.json rename to packages/platform-authz/package.json index 8bfd7b9ebe2..adebe6563fa 100644 --- a/packages/workflow-authz/package.json +++ b/packages/platform-authz/package.json @@ -1,5 +1,5 @@ { - "name": "@sim/workflow-authz", + "name": "@sim/platform-authz", "version": "0.1.0", "private": true, "sideEffects": false, @@ -10,9 +10,17 @@ "node": ">=20.0.0" }, "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" + "./predicates": { + "types": "./src/predicates.ts", + "default": "./src/predicates.ts" + }, + "./workspace": { + "types": "./src/workspace.ts", + "default": "./src/workspace.ts" + }, + "./workflow": { + "types": "./src/workflow.ts", + "default": "./src/workflow.ts" } }, "scripts": { diff --git a/packages/platform-authz/src/predicates.ts b/packages/platform-authz/src/predicates.ts new file mode 100644 index 00000000000..ff1afa6e11d --- /dev/null +++ b/packages/platform-authz/src/predicates.ts @@ -0,0 +1,37 @@ +import type { permissionTypeEnum } from '@sim/db/schema' + +/** Workspace permission level: read < write < admin. */ +export type PermissionType = (typeof permissionTypeEnum.enumValues)[number] + +/** Total ordering of workspace permission levels: read < write < admin. */ +export const PERMISSION_RANK = { read: 1, write: 2, admin: 3 } as const satisfies Record< + PermissionType, + number +> + +/** + * Whether an effective permission satisfies a required level under the + * read < write < admin ordering. `null`/`undefined` (no access) never satisfies. + * Single source of truth for permission-level comparisons across the app and the + * realtime server — replaces the hand-written `=== 'admin' || === 'write'` ladders. + */ +export function permissionSatisfies( + have: PermissionType | null | undefined, + required: PermissionType +): boolean { + return have != null && PERMISSION_RANK[have] >= PERMISSION_RANK[required] +} + +/** Organization membership roles (Better Auth) that confer admin authority. */ +export const ORG_ADMIN_ROLES = ['owner', 'admin'] as const + +/** + * Whether an organization membership role is owner/admin. Owner/admin org roles + * are derived workspace admins on the org's workspaces — single source of truth + * for the `role === 'owner' || role === 'admin'` predicate, shared by server + * resolvers and client UIs. Dependency-free (the only import is a type, which is + * erased) so client bundles can import it without pulling in the DB client. + */ +export function isOrgAdminRole(role: string | null | undefined): boolean { + return role === 'owner' || role === 'admin' +} diff --git a/packages/workflow-authz/src/index.ts b/packages/platform-authz/src/workflow.ts similarity index 78% rename from packages/workflow-authz/src/index.ts rename to packages/platform-authz/src/workflow.ts index 93e5aee8812..32a4e558c99 100644 --- a/packages/workflow-authz/src/index.ts +++ b/packages/platform-authz/src/workflow.ts @@ -1,13 +1,12 @@ -import { - db, - member, - permissions, - type permissionTypeEnum, - workflow, - workflowFolder, - workspace, -} from '@sim/db' +import { db, workflow, workflowFolder, workspace } from '@sim/db' import { and, eq, isNull } from 'drizzle-orm' +import { + type PermissionType, + permissionSatisfies, + resolveEffectiveWorkspacePermission, +} from './workspace' + +export type { PermissionType } export type ActiveWorkflowRecord = typeof workflow.$inferSelect @@ -64,8 +63,6 @@ export async function assertActiveWorkflowContext( return context } -export type PermissionType = (typeof permissionTypeEnum.enumValues)[number] - type WorkflowRecord = typeof workflow.$inferSelect export class WorkflowLockedError extends Error { @@ -252,51 +249,6 @@ export interface WorkflowWorkspaceAuthorizationResult { workspacePermission: PermissionType | null } -/** - * Resolves the effective workspace permission under the governance inheritance - * model: the workspace owner and the owners/admins of the organization that - * owns the workspace are workspace admins. Mirrors - * `getEffectiveWorkspacePermission` in `apps/sim` (duplicated here because this - * package may not import app code). - */ -async function resolveEffectiveWorkspacePermission( - userId: string, - workspaceId: string, - workspaceOwnerId: string, - workspaceOrganizationId: string | null -): Promise { - if (workspaceOwnerId === userId) { - return 'admin' - } - - const [permissionRow] = await db - .select({ permissionType: permissions.permissionType }) - .from(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) - ) - ) - .limit(1) - - const explicit = (permissionRow?.permissionType as PermissionType | undefined) ?? null - - if (workspaceOrganizationId && explicit !== 'admin') { - const [memberRow] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, workspaceOrganizationId))) - .limit(1) - if (memberRow?.role === 'owner' || memberRow?.role === 'admin') { - return 'admin' - } - } - - return explicit -} - export async function authorizeWorkflowByWorkspacePermission(params: { workflowId: string userId: string @@ -335,24 +287,7 @@ export async function authorizeWorkflowByWorkspacePermission(params: { activeContext.workspaceOrganizationId ) - if (workspacePermission === null) { - return { - allowed: false, - status: 403, - message: `Unauthorized: Access denied to ${action} this workflow`, - workflow: wf, - workspacePermission, - } - } - - const permissionSatisfied = - action === 'read' - ? true - : action === 'write' - ? workspacePermission === 'write' || workspacePermission === 'admin' - : workspacePermission === 'admin' - - if (!permissionSatisfied) { + if (!permissionSatisfies(workspacePermission, action)) { return { allowed: false, status: 403, diff --git a/packages/platform-authz/src/workspace.ts b/packages/platform-authz/src/workspace.ts new file mode 100644 index 00000000000..6c2b5613433 --- /dev/null +++ b/packages/platform-authz/src/workspace.ts @@ -0,0 +1,55 @@ +import { db } from '@sim/db' +import { member, permissions } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { isOrgAdminRole, type PermissionType } from './predicates' + +export * from './predicates' + +/** + * Resolves the effective workspace permission under the governance inheritance + * model: the workspace owner and the owners/admins of the organization that owns + * the workspace are workspace admins. Returns the higher of any explicit grant + * and any derived admin. + * + * Single source of truth for workspace-permission resolution, shared by the Next + * app (`getEffectiveWorkspacePermission`) and the realtime server (via the + * `/workflow` entry). Lives in a package because `apps/realtime` needs it and + * packages may not import app code. + */ +export async function resolveEffectiveWorkspacePermission( + userId: string, + workspaceId: string, + workspaceOwnerId: string, + workspaceOrganizationId: string | null +): Promise { + if (workspaceOwnerId === userId) { + return 'admin' + } + + const [permissionRow] = await db + .select({ permissionType: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .limit(1) + + const explicit = (permissionRow?.permissionType as PermissionType | undefined) ?? null + + if (workspaceOrganizationId && explicit !== 'admin') { + const [memberRow] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, workspaceOrganizationId))) + .limit(1) + if (isOrgAdminRole(memberRow?.role)) { + return 'admin' + } + } + + return explicit +} diff --git a/packages/workflow-authz/tsconfig.json b/packages/platform-authz/tsconfig.json similarity index 100% rename from packages/workflow-authz/tsconfig.json rename to packages/platform-authz/tsconfig.json diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 7bff8617850..5ddbd73aa95 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -137,7 +137,7 @@ export { } from './terminal-console.mock' // URL mocks export { urlsMock, urlsMockFns } from './urls.mock' -// Workflow authz package mocks (for @sim/workflow-authz) +// Workflow authz package mocks (for @sim/platform-authz/workflow) export { workflowAuthzMock, workflowAuthzMockFns } from './workflow-authz.mock' // Workflows API utils mocks (for @/app/api/workflows/utils) export { workflowsApiUtilsMock, workflowsApiUtilsMockFns } from './workflows-api-utils.mock' diff --git a/packages/testing/src/mocks/permissions.mock.ts b/packages/testing/src/mocks/permissions.mock.ts index 167d079f9a8..9b38a9da1f7 100644 --- a/packages/testing/src/mocks/permissions.mock.ts +++ b/packages/testing/src/mocks/permissions.mock.ts @@ -19,7 +19,6 @@ export const permissionsMockFns = { mockCheckWorkspaceAccess: vi.fn(), mockAssertActiveWorkspaceAccess: vi.fn(), mockGetUserEntityPermissions: vi.fn(), - mockHasAdminPermission: vi.fn(), mockGetUsersWithPermissions: vi.fn(), mockGetWorkspaceMemberProfiles: vi.fn(), mockHasWorkspaceAdminAccess: vi.fn(), @@ -42,7 +41,6 @@ export const permissionsMock = { checkWorkspaceAccess: permissionsMockFns.mockCheckWorkspaceAccess, assertActiveWorkspaceAccess: permissionsMockFns.mockAssertActiveWorkspaceAccess, getUserEntityPermissions: permissionsMockFns.mockGetUserEntityPermissions, - hasAdminPermission: permissionsMockFns.mockHasAdminPermission, getUsersWithPermissions: permissionsMockFns.mockGetUsersWithPermissions, getWorkspaceMemberProfiles: permissionsMockFns.mockGetWorkspaceMemberProfiles, hasWorkspaceAdminAccess: permissionsMockFns.mockHasWorkspaceAdminAccess, diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index fb72acb5b1c..0fb21283072 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -941,7 +941,9 @@ export const schemaMock = { executionId: 'executionId', createdAt: 'createdAt', }, - credentialTypeEnum: 'credentialTypeEnum', + credentialTypeEnum: { + enumValues: ['oauth', 'env_workspace', 'env_personal', 'service_account'] as const, + }, credential: { id: 'id', workspaceId: 'workspaceId', diff --git a/packages/testing/src/mocks/workflow-authz.mock.ts b/packages/testing/src/mocks/workflow-authz.mock.ts index 59322e1c103..d6f6de6f287 100644 --- a/packages/testing/src/mocks/workflow-authz.mock.ts +++ b/packages/testing/src/mocks/workflow-authz.mock.ts @@ -3,7 +3,7 @@ import { vi } from 'vitest' /** * Real `WorkflowLockedError` subclass used by tests so `instanceof` checks in * route handlers behave the same as in production. Mirrors the shape exported - * by `@sim/workflow-authz`. + * by `@sim/platform-authz/workflow`. */ export class MockWorkflowLockedError extends Error { readonly status = 423 @@ -17,7 +17,7 @@ export class MockWorkflowLockedError extends Error { /** * Real `FolderLockedError` subclass used by tests so `instanceof` checks in * route handlers behave the same as in production. Mirrors the shape exported - * by `@sim/workflow-authz`. + * by `@sim/platform-authz/workflow`. */ export class MockFolderLockedError extends Error { readonly status = 423 @@ -31,7 +31,7 @@ export class MockFolderLockedError extends Error { /** * Real `FolderNotFoundError` subclass used by tests so `instanceof` checks in * route handlers behave the same as in production. Mirrors the shape exported - * by `@sim/workflow-authz`. + * by `@sim/platform-authz/workflow`. */ export class MockFolderNotFoundError extends Error { readonly status = 400 @@ -51,7 +51,7 @@ const unlockedStatus = { } /** - * Controllable mocks for the `@sim/workflow-authz` package. + * Controllable mocks for the `@sim/platform-authz/workflow` entry. * * Defaults assume permissive access (no lock, write allowed). Override with * `mockResolvedValue` per test when exercising the lock/permission paths. @@ -82,11 +82,11 @@ export const workflowAuthzMockFns = { } /** - * Static mock module for `@sim/workflow-authz`. + * Static mock module for `@sim/platform-authz/workflow`. * * @example * ```ts - * vi.mock('@sim/workflow-authz', () => workflowAuthzMock) + * vi.mock('@sim/platform-authz/workflow', () => workflowAuthzMock) * ``` */ export const workflowAuthzMock = { diff --git a/packages/testing/src/mocks/workflows-utils.mock.ts b/packages/testing/src/mocks/workflows-utils.mock.ts index 1c70412f014..89613a98907 100644 --- a/packages/testing/src/mocks/workflows-utils.mock.ts +++ b/packages/testing/src/mocks/workflows-utils.mock.ts @@ -40,7 +40,7 @@ export const workflowsUtilsMockFns = { * - `validateWorkflowPermissions` resolves to an authorized result * - Other functions resolve to sensible empty/success defaults * - * `authorizeWorkflowByWorkspacePermission` moved to `@sim/workflow-authz`; + * `authorizeWorkflowByWorkspacePermission` moved to `@sim/platform-authz/workflow`; * use `workflowAuthzMock` / `workflowAuthzMockFns` for that surface. * * @example