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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 21 additions & 48 deletions apps/sim/app/api/organizations/[id]/removal-impact/route.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,25 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getMemberRemovalImpactContract } from '@/lib/api/contracts/organization'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getOrganizationTransferCredentialDependencies } from '@/lib/billing/organizations/membership'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

const logger = createLogger('OrganizationRemovalImpactAPI')
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { internalOrganizationErrorPolicy } from '@/lib/api/server/routes/organizations'
import { previewOrganizationMemberRemoval } from '@/lib/organizations/application/members'

/**
* Identity-bound credentials the target user owns in this organization's
* workspaces — the set that stops working when their workspace access is
* revoked. Readable by org admins (removing someone) and by the user
* themself (leaving).
* Identity-bound credentials the target user owns in this organization's workspaces. Readable by
* org admins (removing someone) and by the user themself (leaving).
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const parsed = await parseRequest(getMemberRemovalImpactContract, request, context)
if (!parsed.success) return parsed.response

const { id: organizationId } = parsed.data.params
const { userId: targetUserId } = parsed.data.query

try {
const isSelf = targetUserId === session.user.id
if (!isSelf && !(await isOrganizationOwnerOrAdmin(session.user.id, organizationId))) {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}

const credentials = await getOrganizationTransferCredentialDependencies(
targetUserId,
organizationId
)

return NextResponse.json({ credentials })
} catch (error) {
logger.error('Failed to compute member removal impact', {
organizationId,
targetUserId,
error,
})
return NextResponse.json({ error: 'Failed to compute removal impact' }, { status: 500 })
}
}
)
export const GET = defineInternalJsonRoute({
contract: getMemberRemovalImpactContract,
auth: internalSessionAuth,
operation: previewOrganizationMemberRemoval.operation,
rateLimit: internalRateLimits.none({
reason: 'Preserve existing organization member administration behavior.',
}),
errorPolicy: internalOrganizationErrorPolicy,
mapInput: ({ params, query }) => ({ organizationId: params.id, userId: query.userId }),
useCase: previewOrganizationMemberRemoval,
present: ({ credentials }) => ({ credentials }),
})
24 changes: 13 additions & 11 deletions apps/sim/lib/api/contracts/sim-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,23 @@ export type SimLinkBody = z.input<typeof simLinkFieldsSchema>

const linkResponse = z.object({ link: simLinkSchema })

export const listSimLinksQuerySchema = z
.object({
search: z.string().max(200).optional(),
cursor: z.string().min(1).max(128).optional(),
sort: z.enum(SIM_LINK_SORTS).optional(),
dir: z.enum(SORT_DIRECTIONS).optional(),
})
.refine((query) => !(query.sort === 'popular' && query.cursor), {
path: ['cursor'],
message: 'The most-used view is a single page',
})

export const listSimLinksContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/sim-links',
params,
query: z
.object({
search: z.string().max(200).optional(),
cursor: z.string().min(1).max(128).optional(),
sort: z.enum(SIM_LINK_SORTS).optional(),
dir: z.enum(SORT_DIRECTIONS).optional(),
})
.refine((query) => !(query.sort === 'popular' && query.cursor), {
path: ['cursor'],
message: 'The most-used view is a single page',
}),
query: listSimLinksQuerySchema,
response: {
mode: 'json',
schema: z.object({ links: z.array(simLinkSchema), nextCursor: z.string().nullable() }),
Expand Down
30 changes: 30 additions & 0 deletions apps/sim/lib/mothership/tools/server/settings-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
createPermissionGroupBodySchema,
updatePermissionGroupBodySchema,
} from '@/lib/api/contracts/permission-groups'
import { v2BulkAddPermissionGroupMembersBodySchema } from '@/lib/api/contracts/v2/permission-groups'
import { updateWorkspacePermissionsBodySchema } from '@/lib/api/contracts/workspaces'
import { getOrganizationUsageBreakdown } from '@/lib/billing/application/organization-usage/get-organization-usage-breakdown'
import { getOrganizationUsageSummary } from '@/lib/billing/application/organization-usage/get-organization-usage-summary'
Expand Down Expand Up @@ -77,12 +78,15 @@ import {
settingsWorkspaceId,
} from '@/lib/mothership/tools/server/settings-operation'
import { organizationDomainSettingsActions } from '@/lib/mothership/tools/server/settings-security'
import { simLinkSettingsActions } from '@/lib/mothership/tools/server/settings-sim-links'
import {
previewOrganizationMemberRemoval,
removeOrganizationMember,
updateOrganizationMember,
} from '@/lib/organizations/application/members'
import {
addPermissionGroupMember,
bulkAddPermissionGroupMembers,
createPermissionGroup,
deletePermissionGroup,
getPermissionGroup,
Expand Down Expand Up @@ -370,6 +374,22 @@ export const settingsOperations: Record<string, Record<string, SettingsOperation
).member,
})
),
bulk_add_members: settingsOperation(
'write',
z.strictObject({
groupId: z.string().min(1),
userIds: v2BulkAddPermissionGroupMembersBodySchema.shape.userIds,
addAllOrganizationMembers:
v2BulkAddPermissionGroupMembersBodySchema.shape.addAllOrganizationMembers,
}),
async (context, input) => {
const { added, skipped } = await bulkAddPermissionGroupMembers.execute({
principal: context.principal,
input: { ...input, organizationId: settingsOrganizationId(context) },
})
return { added, skipped }
}
),
remove_member: settingsOperation(
'write',
z.strictObject({ groupId: z.string().min(1), memberId: z.string().min(1) }),
Expand Down Expand Up @@ -412,6 +432,15 @@ export const settingsOperations: Record<string, Record<string, SettingsOperation
input: { ...input, organizationId: settingsOrganizationId(context) },
})
),
removal_impact: settingsOperation(
'read',
z.strictObject({ userId: z.string().min(1).max(200) }),
(context, input) =>
previewOrganizationMemberRemoval.execute({
principal: context.principal,
input: { ...input, organizationId: settingsOrganizationId(context) },
})
),
remove: settingsOperation(
'write',
z.strictObject({ userId: z.string().min(1) }),
Expand Down Expand Up @@ -443,6 +472,7 @@ export const settingsOperations: Record<string, Record<string, SettingsOperation
})
),
},
'organization/sim-links': simLinkSettingsActions,
'organization/usage': {
summary: settingsOperation(
'read',
Expand Down
97 changes: 97 additions & 0 deletions apps/sim/lib/mothership/tools/server/settings-sim-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { z } from 'zod'
import { listSimLinksQuerySchema } from '@/lib/api/contracts/sim-links'
import { presentSimLink } from '@/lib/api/server/sim-link-presenters'
import type { SettingsContext } from '@/lib/mothership/application/settings-context'
import {
settingsOperation,
settingsOrganizationId,
} from '@/lib/mothership/tools/server/settings-operation'
import {
createSimLink,
deleteSimLink,
getSimLinkView,
listSimLinks,
updateSimLink,
} from '@/lib/sim-links/application/use-cases'
import { simLinkFieldsSchema } from '@/lib/sim-links/validation'

const linkId = z.string().min(1).max(255)

/** The first page of links this member may browse, or the page `query` selects. */
export async function readSettingsSimLinks(
context: SettingsContext,
query: z.output<typeof listSimLinksQuerySchema> = {}
) {
const page = await listSimLinks.execute({
principal: context.principal,
input: { ...query, organizationId: settingsOrganizationId(context) },
})
return { links: page.links.map(presentSimLink), nextCursor: page.nextCursor }
}

/** Per-link edit, unlist and restrict rules, audit and the rollout flag stay in the use cases. */
export const simLinkSettingsActions = {
list: settingsOperation('read', listSimLinksQuerySchema, readSettingsSimLinks),
get: settingsOperation(
'read',
z.union([
z.strictObject({ linkId }),
z.strictObject({
name: z
.string()
.trim()
.min(1)
.max(255)
.describe('The name as typed, with or without the sim/ prefix'),
}),
]),
async (context, input) =>
presentSimLink(
await getSimLinkView.execute({
principal: context.principal,
input: {
organizationId: settingsOrganizationId(context),
...('linkId' in input
? { linkId: input.linkId }
: { name: input.name.replace(/^sim\//i, '') }),
},
})
)
),
create: settingsOperation('write', simLinkFieldsSchema, async (context, fields) =>
presentSimLink(
await createSimLink.execute({
principal: context.principal,
input: { ...fields, organizationId: settingsOrganizationId(context) },
})
)
),
update: settingsOperation(
'write',
z.strictObject({
linkId,
fields: simLinkFieldsSchema.describe(
'Every field of the link as it should be saved; read it with get first'
),
}),
async (context, input) =>
presentSimLink(
await updateSimLink.execute({
principal: context.principal,
input: {
...input.fields,
linkId: input.linkId,
organizationId: settingsOrganizationId(context),
},
})
)
),
delete: settingsOperation('write', z.strictObject({ linkId }), async (context, input) => ({
deleted: (
await deleteSimLink.execute({
principal: context.principal,
input: { ...input, organizationId: settingsOrganizationId(context) },
})
).id,
})),
}
2 changes: 2 additions & 0 deletions apps/sim/lib/mothership/tools/server/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
} from '@/lib/mothership/tools/server/settings-operation'
import { settingsOperations } from '@/lib/mothership/tools/server/settings-operations'
import { readSettingsDomains } from '@/lib/mothership/tools/server/settings-security'
import { readSettingsSimLinks } from '@/lib/mothership/tools/server/settings-sim-links'
import { organizationRoutes } from '@/lib/navigation/paths'
import {
getOrganizationDataRetention,
Expand Down Expand Up @@ -228,6 +229,7 @@ const adapters: Record<string, SettingAdapter> = {
}),
},
'organization/members': { get: readSettingsRoster },
'organization/sim-links': { get: (context) => readSettingsSimLinks(context) },
'organization/whitelabeling': {
schema: updateOrganizationWhitelabelBodySchema.strict(),
get: async (context) =>
Expand Down
27 changes: 26 additions & 1 deletion apps/sim/lib/organizations/application/member-removal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ vi.mock('@/lib/posthog/server', () => posthogServerMock)
vi.mock('@sim/audit', () => auditMock)
vi.mock('@/ee/scim/lib/managed-membership', () => ({ assertMembershipNotScimManaged: vi.fn() }))

import { removeOrganizationMember } from '@/lib/organizations/application/members'
import {
previewOrganizationMemberRemoval,
removeOrganizationMember,
} from '@/lib/organizations/application/members'
import { DELETE } from '@/app/api/organizations/[id]/members/[memberId]/route'

const mocks = {
Expand All @@ -40,6 +43,7 @@ const mocks = {
remove: organizationMembershipMockFns.mockRemoveUserFromOrganization,
external: organizationMembershipMockFns.mockRemoveExternalUserFromOrganizationWorkspaces,
audit: auditMockFns.mockRecordAudit,
impact: organizationMembershipMockFns.mockGetOrganizationTransferCredentialDependencies,
}

const principal = {
Expand Down Expand Up @@ -147,3 +151,24 @@ describe('organization member removal', () => {
expect(mocks.audit).not.toHaveBeenCalled()
})
})
describe('organization member removal preview', () => {
const credential = { id: 'cred', displayName: 'GitHub', type: 'oauth', workspaceId: 'ws' }
beforeEach(() => mocks.impact.mockReset().mockResolvedValue([credential]))
it("refuses a delegated member previewing somebody else's removal before reading credentials", async () => {
queueTableRows(member, [{ role: 'member' }])
await expect(
previewOrganizationMemberRemoval.execute({ principal, input })
).rejects.toMatchObject({ code: 'forbidden' })
expect(mocks.impact).not.toHaveBeenCalled()
})
it('lets a delegated member preview leaving', async () => {
queueTableRows(member, [{ role: 'member' }])
await expect(
previewOrganizationMemberRemoval.execute({
principal,
input: { organizationId: 'org', userId: 'actor' },
})
).resolves.toEqual({ credentials: [credential] })
expect(mocks.impact).toHaveBeenCalledExactlyOnceWith('actor', 'org')
})
})
25 changes: 25 additions & 0 deletions apps/sim/lib/organizations/application/members.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AuditAction, AuditResourceType } from '@sim/audit'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { getOrganizationTransferCredentialDependencies } from '@/lib/billing/organizations/membership'
import {
defineAuthorizedOrganizationUseCase,
type OrganizationUseCaseContext,
Expand Down Expand Up @@ -79,3 +80,27 @@ export const removeOrganizationMember = defineAuthorizedOrganizationUseCase({
},
}),
})

/**
* Identity-bound credentials the target user owns in this organization's workspaces: the set that
* stops working when their workspace access is revoked. Administrators preview removing anyone;
* members preview leaving.
*/
export const previewOrganizationMemberRemoval = defineAuthorizedOrganizationUseCase({
operation: organizationOperations.previewMemberRemoval,
authorizeResource: ({ input, context }) => {
if (!isOrgAdminRole(context.role) && input.userId !== context.userId)
throw new ForbiddenOperationError(
'ORGANIZATION_ADMIN_REQUIRED',
'Forbidden - Admin access required'
)
},
execute: async ({
input,
}: OrganizationUseCaseContext<{ organizationId: string; userId: string }>) => ({
credentials: await getOrganizationTransferCredentialDependencies(
input.userId,
input.organizationId
),
}),
})
12 changes: 12 additions & 0 deletions apps/sim/lib/organizations/application/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ export const organizationOperations = {
delegatedServices: ['copilot'],
oauthScope: 'api:write',
}),
/**
* permission-group-exempt: the application allows administrators to preview any member's removal and members to preview leaving.
*/
previewMemberRemoval: defineOrganizationOperation({
id: 'organizations.members.removal_impact.read',
contentDelivery: 'none',
minimumRole: 'member',
capability: 'none',
principalKinds: ['session', 'organization_delegated'],
delegationAudience: 'sim:settings',
delegatedServices: ['copilot'],
}),
/**
* permission-group-exempt: administrators can inspect existing invitations even when new invitations are disabled.
*/
Expand Down
Loading
Loading