diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts index f9d1b64e6a5..aafaa156af4 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts @@ -40,6 +40,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ import { AuditAction, AuditResourceType } from '@sim/audit' import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application' +import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case' import { resolveCurrentOutboundRoute } from '@/lib/core/network/context.server' import type { OrchestrationError } from '@/lib/core/orchestration/types' import { CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION } from '@/lib/resource-policies/registry' @@ -494,3 +495,34 @@ describe('defineAuthorizedWorkspaceUseCase', () => { ) }) }) + +describe('projected audit workspace attribution', () => { + beforeEach(() => vi.clearAllMocks()) + + it.each([ + { override: undefined, expected: 'workspace-1' }, + { override: 'workspace-2', expected: 'workspace-2' }, + { override: null, expected: null }, + ])('records the canonical workspace override $override', ({ override, expected }) => { + recordProjectedUseCaseAuditEntries( + operation, + 'workspace-1', + sessionPrincipal, + undefined, + [ + { + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + workspaceId: override, + }, + ], + 'organization-1' + ) + expect(mocks.recordAudit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + workspaceId: expected, + metadata: expect.objectContaining({ organizationId: 'organization-1' }), + }) + ) + }) +}) diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts index 19ed9ff3965..5bc897715d1 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -17,8 +17,8 @@ import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types import type { ResourcePolicyBinding } from '@/lib/resource-policies/registry' export interface WorkspaceUseCaseAuditEntry { - /** Canonical workspace affected by a cross-workspace mutation, when different from its authorization scope. */ - workspaceId?: string + /** Canonical affected workspace; null keeps an organization event outside the authorization workspace. */ + workspaceId?: string | null action: AuditActionType resourceType: AuditResourceTypeValue resourceId?: string @@ -106,7 +106,7 @@ export function recordProjectedUseCaseAuditEntries( const attribution: PrincipalAuditAttribution = resolvePrincipalAuditAttribution(principal) for (const entry of entries) { recordAudit({ - workspaceId: entry.workspaceId ?? workspaceId, + workspaceId: entry.workspaceId === undefined ? workspaceId : entry.workspaceId, actorId: attribution.actorId, actorName: attribution.actorName, action: entry.action, diff --git a/apps/sim/lib/permission-access-requests/README.md b/apps/sim/lib/permission-access-requests/README.md index 07219b11f18..0f18a206667 100644 --- a/apps/sim/lib/permission-access-requests/README.md +++ b/apps/sim/lib/permission-access-requests/README.md @@ -15,7 +15,7 @@ Members request access from locked features, the block picker, or **My access re - Fulfillment updates the current governing group. The preview lists every required change, including parent restrictions, and a conservative upper bound of affected people/workspaces. It does not create individual grants or move members between groups. - Approval rechecks administrator authority, requester membership identity, workspace ownership, entitlement at admission, organization preference, group resolution, and the preview fingerprint. Membership, policy, or scope changes require a fresh review or request. - The group/credit-limit update, final request record, and notification enqueue share one database transaction. The final decision stores its original change and impact for history; later policy edits do not rewrite it. -- One pending request per requester/scope/target is enforced by a database index and organization serialization. Member cap requests share an organization-wide key. Submission is bounded to 25 requests per rolling day and 100 pending requests per requester/organization, in addition to HTTP rate admission. +- One pending request per requester/scope/target is enforced by a database index and organization serialization. Member cap requests share an organization-wide key. Submission is bounded to 100 requests per rolling 24 hours and 100 pending requests per requester/organization, in addition to HTTP rate admission. - A usage request increases the existing member credit cap. It does not change the pooled organization budget, buy credits, or alter temporary request-rate limits. - Notifications recheck current membership and reviewer authority. Outbox fan-out is bounded and replay-safe; delivery to an email provider remains at-least-once across a crash after send. diff --git a/apps/sim/lib/permission-access-requests/application/requests.test.ts b/apps/sim/lib/permission-access-requests/application/requests.test.ts index fd2e3400605..f764e7bad9a 100644 --- a/apps/sim/lib/permission-access-requests/application/requests.test.ts +++ b/apps/sim/lib/permission-access-requests/application/requests.test.ts @@ -285,7 +285,7 @@ describe('create access requests', () => { it.each([ { pending: 100, daily: 0, message: '100 pending requests' }, - { pending: 0, daily: 25, message: 'maximum of 25 requests today' }, + { pending: 0, daily: 100, message: '100 requests in the last 24 hours' }, ])('enforces bounded admissions ($message)', async ({ pending, daily, message }) => { queueTableRows(permissionAccessRequest, []) queueTableRows(permissionAccessRequest, [{ total: pending }]) @@ -297,6 +297,22 @@ describe('create access requests', () => { expect(mocks.outbox).not.toHaveBeenCalled() }) + it.each([25, 99])( + 'allows a new request after %s submissions in the rolling window', + async (daily) => { + queueTableRows(permissionAccessRequest, []) + queueTableRows(permissionAccessRequest, [{ total: 0 }]) + queueTableRows(permissionAccessRequest, [{ total: daily }]) + dbChainMockFns.returning.mockResolvedValueOnce([stored()]) + const result = await createAccessRequest.execute({ principal, input: { scope, target } }) + expect(result.changed).toBe(true) + expect(dbChainMockFns.insert).toHaveBeenCalledWith(permissionAccessRequest) + expect(mocks.audit.mock.calls[0][4]).toEqual([ + expect.objectContaining({ workspaceId: 'workspace', resourceId: 'request' }), + ]) + } + ) + it('normalizes member cap requests to one organization-wide request across workspaces', async () => { const cap = stored({ target: { kind: 'usage_limit', id: 'member' }, @@ -315,6 +331,9 @@ describe('create access requests', () => { input: { scope, target: { kind: 'usage_limit', id: 'member' } }, }) expect(first.changed).toBe(true) + expect(mocks.audit.mock.calls[0][4]).toEqual([ + expect.objectContaining({ workspaceId: null, resourceId: cap.id }), + ]) expect(dbChainMockFns.values).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: null, @@ -489,6 +508,27 @@ describe('discovery and request history', () => { }) describe('cancel access requests', () => { + it.each(['workspace', null])( + 'keeps cancellation in the stored request scope %s', + async (workspaceId) => { + const row = stored({ + workspaceId, + ...(workspaceId === null + ? { + scopeKey: 'organization:organization:member-limit', + target: { kind: 'usage_limit', id: 'member' }, + } + : {}), + }) + mocks.stored.mockResolvedValue(row) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...row, status: 'cancelled' }]) + await cancelAccessRequest.execute({ principal, input: { scope, requestId: row.id } }) + expect(mocks.audit.mock.calls[0][4]).toEqual([ + expect.objectContaining({ workspaceId, resourceId: row.id }), + ]) + } + ) + it('allows cancellation while disabled and does not resend a decision for terminal requests', async () => { mocks.enabled.mockResolvedValue(false) dbChainMockFns.returning.mockResolvedValueOnce([stored({ status: 'cancelled' })]) diff --git a/apps/sim/lib/permission-access-requests/application/requests.ts b/apps/sim/lib/permission-access-requests/application/requests.ts index b4b9c0ce1cd..efaaf7622fc 100644 --- a/apps/sim/lib/permission-access-requests/application/requests.ts +++ b/apps/sim/lib/permission-access-requests/application/requests.ts @@ -18,6 +18,11 @@ import { listAccessRequestTargets, loadAccessRequestCatalog, } from '@/lib/permission-access-requests/catalog' +import { + ACCESS_REQUEST_MAX_DAILY_SUBMISSIONS, + ACCESS_REQUEST_MAX_PENDING, + ACCESS_REQUEST_SUBMISSION_WINDOW_MS, +} from '@/lib/permission-access-requests/constants' import { PERMISSION_ACCESS_REQUEST_CREATED_EVENT, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, @@ -151,7 +156,7 @@ export const discoverAccessRequests = defineAuthorizedAccessRequestUseCase({ eq(permissionAccessRequest.status, 'pending') ) ) - .limit(100) + .limit(ACCESS_REQUEST_MAX_PENDING) const pendingMemberLimit = pending.find((row) => row.targetKey === 'usage_limit:member') const memberLimitMembershipMatches = pendingMemberLimit ? await hasCurrentMemberLimitMembership( @@ -351,10 +356,10 @@ export const createAccessRequest = defineAuthorizedAccessRequestUseCase({ eq(permissionAccessRequest.status, 'pending') ) ) - if ((outstanding?.total ?? 0) >= 100) + if ((outstanding?.total ?? 0) >= ACCESS_REQUEST_MAX_PENDING) throw new OrchestrationError( 'conflict', - 'You have 100 pending requests. Cancel an existing request before sending another.' + `You have ${ACCESS_REQUEST_MAX_PENDING} pending requests. Cancel an existing request before sending another.` ) const [daily] = await executor .select({ total: count() }) @@ -363,13 +368,16 @@ export const createAccessRequest = defineAuthorizedAccessRequestUseCase({ and( eq(permissionAccessRequest.organizationId, organizationId), eq(permissionAccessRequest.requesterId, principal.userId), - gte(permissionAccessRequest.createdAt, new Date(Date.now() - 86_400_000)) + gte( + permissionAccessRequest.createdAt, + new Date(Date.now() - ACCESS_REQUEST_SUBMISSION_WINDOW_MS) + ) ) ) - if ((daily?.total ?? 0) >= 25) + if ((daily?.total ?? 0) >= ACCESS_REQUEST_MAX_DAILY_SUBMISSIONS) throw new OrchestrationError( 'conflict', - 'You have sent the maximum of 25 requests today. Try again tomorrow.' + `You have sent ${ACCESS_REQUEST_MAX_DAILY_SUBMISSIONS} requests in the last 24 hours. Try again later.` ) const [row] = await executor .insert(permissionAccessRequest) @@ -402,6 +410,7 @@ export const createAccessRequest = defineAuthorizedAccessRequestUseCase({ action: AuditAction.PERMISSION_ACCESS_REQUEST_CLOSED, resourceType: AuditResourceType.PERMISSION_ACCESS_REQUEST, resourceId: result.closedRequest.id, + workspaceId: result.closedRequest.workspaceId, metadata: { target: result.closedRequest.target }, }, ] @@ -413,6 +422,7 @@ export const createAccessRequest = defineAuthorizedAccessRequestUseCase({ : AuditAction.PERMISSION_ACCESS_REQUEST_CREATED, resourceType: AuditResourceType.PERMISSION_ACCESS_REQUEST, resourceId: result.request.id, + workspaceId: result.request.workspaceId, metadata: { target: result.request.target }, }, ] @@ -492,6 +502,7 @@ export const cancelAccessRequest = defineAuthorizedAccessRequestUseCase({ action: AuditAction.PERMISSION_ACCESS_REQUEST_CANCELLED, resourceType: AuditResourceType.PERMISSION_ACCESS_REQUEST, resourceId: result.request.id, + workspaceId: result.request.workspaceId, }, ] : [], diff --git a/apps/sim/lib/permission-access-requests/application/review.test.ts b/apps/sim/lib/permission-access-requests/application/review.test.ts index 7bbc7e830ae..cc111932b7f 100644 --- a/apps/sim/lib/permission-access-requests/application/review.test.ts +++ b/apps/sim/lib/permission-access-requests/application/review.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { AuditAction } from '@sim/audit' import { db } from '@sim/db' import { organizationMemberUsageLimit, @@ -407,6 +408,44 @@ describe('permission request review', () => { expect(mocks.outbox).toHaveBeenCalledOnce() }) + it.each(['workspace', null])( + 'attributes a declined request to its stored scope %s', + async (workspaceId) => { + mocks.stored.mockResolvedValue(stored({ workspaceId })) + dbChainMockFns.returning.mockResolvedValueOnce([stored({ workspaceId, status: 'declined' })]) + await resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'decline', reason: 'Use the existing provider.' } }, + }) + expect(mocks.audit.mock.calls[0][4]).toEqual([ + expect.objectContaining({ + action: AuditAction.PERMISSION_ACCESS_REQUEST_DECLINED, + workspaceId, + }), + ]) + } + ) + + it('keeps request fulfillment workspace-scoped and the group change organization-scoped', async () => { + const before = await preview() + queueWorkspace() + dbChainMockFns.returning.mockResolvedValueOnce([stored({ status: 'fulfilled' })]) + await resolveAccessRequest.execute({ + principal, + input: { ...input, decision: { action: 'apply', expectedFingerprint: before.fingerprint } }, + }) + const [, defaultWorkspaceId, , , entries] = mocks.audit.mock.calls[0] + expect(defaultWorkspaceId).toBeNull() + expect(entries).toEqual([ + expect.objectContaining({ + action: AuditAction.PERMISSION_ACCESS_REQUEST_FULFILLED, + workspaceId: 'workspace', + }), + expect.objectContaining({ action: AuditAction.PERMISSION_GROUP_UPDATED }), + ]) + expect(entries[1]).not.toHaveProperty('workspaceId') + }) + it('does not apply or notify a second time after resolution', async () => { mocks.stored.mockResolvedValue(stored({ status: 'fulfilled' })) const result = await resolveAccessRequest.execute({ @@ -417,6 +456,7 @@ describe('permission request review', () => { expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mocks.outbox).not.toHaveBeenCalled() expect(mocks.catalog).not.toHaveBeenCalled() + expect(mocks.audit.mock.calls[0][4]).toEqual([]) }) it('reads fulfilled history from its stored decision without rebuilding the catalog', async () => { @@ -483,7 +523,11 @@ describe('member limit review', () => { }) queueLimit(2000) dbChainMockFns.returning.mockResolvedValueOnce([ - stored({ status: 'fulfilled', target: { kind: 'usage_limit', id: 'member' } }), + stored({ + workspaceId: null, + status: 'fulfilled', + target: { kind: 'usage_limit', id: 'member' }, + }), ]) await resolveAccessRequest.execute({ principal, @@ -498,6 +542,16 @@ describe('member limit review', () => { }) expect(mocks.setLimit).toHaveBeenCalledWith('organization', 'requester', 15, 'admin', db) expect(dbChainMockFns.update).not.toHaveBeenCalledWith(permissionGroup) + const [, defaultWorkspaceId, , , entries] = mocks.audit.mock.calls[0] + expect(defaultWorkspaceId).toBeNull() + expect(entries).toEqual([ + expect.objectContaining({ + action: AuditAction.PERMISSION_ACCESS_REQUEST_FULFILLED, + workspaceId: null, + }), + expect.objectContaining({ action: AuditAction.ORG_MEMBER_USAGE_LIMIT_CHANGED }), + ]) + expect(entries[1]).not.toHaveProperty('workspaceId') expect(mocks.outbox).toHaveBeenCalledWith(db, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, { requestId: 'request', }) diff --git a/apps/sim/lib/permission-access-requests/application/review.ts b/apps/sim/lib/permission-access-requests/application/review.ts index 9f349d0e1fd..630cd7d4fca 100644 --- a/apps/sim/lib/permission-access-requests/application/review.ts +++ b/apps/sim/lib/permission-access-requests/application/review.ts @@ -363,6 +363,7 @@ export const resolveAccessRequest = defineAuthorizedAccessRequestUseCase({ : AuditAction.PERMISSION_ACCESS_REQUEST_DECLINED, resourceType: AuditResourceType.PERMISSION_ACCESS_REQUEST, resourceId: result.request.id, + workspaceId: result.request.workspaceId, metadata: { target: result.request.target, requesterId: result.request.requester.id, diff --git a/apps/sim/lib/permission-access-requests/constants.ts b/apps/sim/lib/permission-access-requests/constants.ts index b43ff32b5ed..7bdb702c2f9 100644 --- a/apps/sim/lib/permission-access-requests/constants.ts +++ b/apps/sim/lib/permission-access-requests/constants.ts @@ -2,3 +2,6 @@ export const ACCESS_REQUEST_LIST_PAGE_SIZE = 25 export const ACCESS_REQUEST_MAX_OFFSET = 1_000_000 export const ACCESS_REQUEST_MAX_SEARCH_LENGTH = 200 export const ACCESS_REQUEST_MAX_ID_LENGTH = 128 +export const ACCESS_REQUEST_MAX_DAILY_SUBMISSIONS = 100 +export const ACCESS_REQUEST_MAX_PENDING = 100 +export const ACCESS_REQUEST_SUBMISSION_WINDOW_MS = 24 * 60 * 60 * 1000 diff --git a/apps/sim/lib/permission-access-requests/impact.postgres.test.ts b/apps/sim/lib/permission-access-requests/impact.postgres.test.ts index 26782c5e21d..3f9eb90359a 100644 --- a/apps/sim/lib/permission-access-requests/impact.postgres.test.ts +++ b/apps/sim/lib/permission-access-requests/impact.postgres.test.ts @@ -25,7 +25,7 @@ async function createFixture() { await client.unsafe(`SET search_path TO "${schema}"`) await client.unsafe(` CREATE TABLE workspace (id text PRIMARY KEY, name text, organization_id text, archived_at timestamp); - CREATE TABLE permission_group (id text PRIMARY KEY, organization_id text, is_default boolean, updated_at timestamp, membership_mode text); + CREATE TABLE permission_group (id text PRIMARY KEY, organization_id text, is_default boolean, updated_at timestamp, membership_mode text, created_at timestamp DEFAULT now()); CREATE TABLE permissions (id text PRIMARY KEY, user_id text, entity_id text, entity_type text, permission_type text, updated_at timestamp); CREATE TABLE member (id text PRIMARY KEY, user_id text, organization_id text, role text); CREATE TABLE permission_group_member (id text PRIMARY KEY, organization_id text, permission_group_id text, user_id text); @@ -74,6 +74,84 @@ describe.skipIf(!databaseUrl)('access request impact on PostgreSQL', () => { } }) + it('ignores disjoint group, assignment, scope, and ordinary member changes', async () => { + const fixture = await createFixture() + try { + const load = () => loadAccessRequestGroupImpact(fixture.executor, 'org', 'group') + const before = await load() + await fixture.client`INSERT INTO permission_group VALUES ('unrelated', 'org', false, now(), 'explicit')` + await fixture.client`INSERT INTO permission_group_workspace VALUES ('unrelated-scope', 'org', 'unrelated', 'two')` + await fixture.client`INSERT INTO permission_group_member VALUES ('unrelated-assignment', 'org', 'unrelated', 'unrelated-user')` + expect(await load()).toEqual(before) + await fixture.client`UPDATE permission_group SET updated_at = updated_at + interval '1 second' WHERE id = 'unrelated'` + expect(await load()).toEqual(before) + await fixture.client`UPDATE permission_group_member SET user_id = 'different-user' WHERE id = 'unrelated-assignment'` + expect(await load()).toEqual(before) + await fixture.client`DELETE FROM permission_group_workspace WHERE id = 'unrelated-scope'` + expect(await load()).toEqual(before) + await fixture.client`INSERT INTO member VALUES ('unrelated-membership', 'unrelated-member', 'org', 'member')` + expect(await load()).toEqual(before) + } finally { + await fixture.cleanup() + } + }) + + it('tracks competing group precedence but ignores their unrelated policy updates and scopes', async () => { + const fixture = await createFixture() + try { + const load = () => loadAccessRequestGroupImpact(fixture.executor, 'org', 'group') + const before = await load() + await fixture.client`INSERT INTO permission_group VALUES ('competitor', 'org', false, now(), 'inherit')` + await fixture.client`INSERT INTO permission_group_workspace VALUES ('competing-scope', 'org', 'competitor', 'one')` + const competing = await load() + expect(competing.revision).not.toBe(before.revision) + await fixture.client`UPDATE permission_group SET updated_at = updated_at + interval '1 second' WHERE id = 'competitor'` + await fixture.client`INSERT INTO permission_group_workspace VALUES ('other-scope', 'org', 'competitor', 'two')` + expect(await load()).toEqual(competing) + await fixture.client`INSERT INTO permission_group_member VALUES ('competing-member', 'org', 'competitor', 'unrelated-user')` + const assigned = await load() + expect(assigned.revision).not.toBe(competing.revision) + await fixture.client`UPDATE permission_group SET membership_mode = 'explicit' WHERE id = 'competitor'` + const explicit = await load() + expect(explicit.revision).not.toBe(assigned.revision) + await fixture.client`UPDATE permission_group SET created_at = created_at - interval '1 day' WHERE id = 'competitor'` + const reordered = await load() + expect(reordered.revision).not.toBe(explicit.revision) + await fixture.client`DELETE FROM permission_group_workspace WHERE id = 'competing-scope'` + expect((await load()).revision).not.toBe(reordered.revision) + } finally { + await fixture.cleanup() + } + }) + + it('tracks relevant membership and administrator roles, and keeps default-group scope broad', async () => { + const fixture = await createFixture() + try { + const load = () => loadAccessRequestGroupImpact(fixture.executor, 'org', 'group') + const before = await load() + await fixture.client`INSERT INTO member VALUES ('guest-member', 'guest', 'org', 'member')` + const joined = await load() + expect(joined.impact).toEqual(before.impact) + expect(joined.revision).not.toBe(before.revision) + await fixture.client`UPDATE member SET role = 'owner' WHERE id = 'm2'` + const promoted = await load() + expect(promoted.impact.memberCount).toBe(3) + expect(promoted.revision).not.toBe(joined.revision) + await fixture.client`UPDATE permission_group SET is_default = true WHERE id = 'group'` + const defaultGroup = await load() + await fixture.client`INSERT INTO member VALUES ('new-member', 'new-person', 'org', 'member')` + const added = await load() + expect(added.impact.memberCount).toBe(defaultGroup.impact.memberCount + 1) + expect(added.revision).not.toBe(defaultGroup.revision) + await fixture.client`UPDATE workspace SET archived_at = now() WHERE id = 'two'` + const archived = await load() + expect(archived.impact.workspaceCount).toBe(1) + expect(archived.revision).not.toBe(added.revision) + } finally { + await fixture.cleanup() + } + }) + it('detects in-place assignment, grant, and workspace changes with a bounded revision', async () => { const fixture = await createFixture() try { diff --git a/apps/sim/lib/permission-access-requests/impact.ts b/apps/sim/lib/permission-access-requests/impact.ts index f4ff2beb49a..2a0ae391b48 100644 --- a/apps/sim/lib/permission-access-requests/impact.ts +++ b/apps/sim/lib/permission-access-requests/impact.ts @@ -6,7 +6,7 @@ import { permissions, workspace, } from '@sim/db/schema' -import { and, count, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' +import { and, count, eq, inArray, isNull, or, type SQL, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' import type { AccessRequestImpact } from '@/lib/permission-access-requests/types' @@ -35,6 +35,32 @@ export async function loadAccessRequestGroupImpact( ? undefined : sql`exists (select 1 from ${permissionGroupWorkspace} where ${permissionGroupWorkspace.permissionGroupId} = ${groupId} and ${permissionGroupWorkspace.workspaceId} = ${workspace.id})` ) + const scopedWorkspaces = executor.select({ id: workspace.id }).from(workspace).where(scope) + const scopedGrantees = executor + .select({ userId: permissions.userId }) + .from(permissions) + .where( + and(eq(permissions.entityType, 'workspace'), inArray(permissions.entityId, scopedWorkspaces)) + ) + /** Competing groups in the same workspaces can change explicit/inherited group precedence. */ + const relevantGroups = executor + .select({ id: permissionGroup.id }) + .from(permissionGroup) + .where( + and( + eq(permissionGroup.organizationId, organizationId), + or( + eq(permissionGroup.id, groupId), + inArray( + permissionGroup.id, + executor + .select({ groupId: permissionGroupWorkspace.permissionGroupId }) + .from(permissionGroupWorkspace) + .where(inArray(permissionGroupWorkspace.workspaceId, scopedWorkspaces)) + ) + ) + ) + ) const names = await executor .select({ name: workspace.name }) .from(workspace) @@ -64,13 +90,19 @@ export async function loadAccessRequestGroupImpact( .where(scope) const [orgMembers] = await executor .select({ - total: count(), revision: membershipRevision( sql`jsonb_build_array(${member.id}, ${member.userId}, ${member.role})::text` ), }) .from(member) - .where(eq(member.organizationId, organizationId)) + .where( + and( + eq(member.organizationId, organizationId), + group?.isDefault + ? undefined + : or(inArray(member.role, ['admin', 'owner']), inArray(member.userId, scopedGrantees)) + ) + ) const [assignments] = await executor .select({ revision: membershipRevision( @@ -78,7 +110,12 @@ export async function loadAccessRequestGroupImpact( ), }) .from(permissionGroupMember) - .where(eq(permissionGroupMember.organizationId, organizationId)) + .where( + and( + eq(permissionGroupMember.organizationId, organizationId), + inArray(permissionGroupMember.permissionGroupId, relevantGroups) + ) + ) const [scopes] = await executor .select({ revision: membershipRevision( @@ -86,7 +123,12 @@ export async function loadAccessRequestGroupImpact( ), }) .from(permissionGroupWorkspace) - .where(eq(permissionGroupWorkspace.organizationId, organizationId)) + .where( + and( + eq(permissionGroupWorkspace.organizationId, organizationId), + inArray(permissionGroupWorkspace.workspaceId, scopedWorkspaces) + ) + ) const candidates = executor .select({ userId: permissions.userId }) .from(permissions) @@ -111,11 +153,11 @@ export async function loadAccessRequestGroupImpact( const [groupVersions] = await executor .select({ revision: membershipRevision( - sql`jsonb_build_array(${permissionGroup.id}, ${permissionGroup.updatedAt}, ${permissionGroup.membershipMode}, ${permissionGroup.isDefault})::text` + sql`jsonb_build_array(${permissionGroup.id}, ${permissionGroup.createdAt}, ${permissionGroup.membershipMode}, ${permissionGroup.isDefault})::text` ), }) .from(permissionGroup) - .where(eq(permissionGroup.organizationId, organizationId)) + .where(inArray(permissionGroup.id, relevantGroups)) return { impact: { memberCount: Number(people?.total ?? 0),