|
1 | 1 | import type { Principal, SessionPrincipal } from '@sim/auth/principal' |
2 | | -import { member, permissions, project, projectWorkspace, workspace } from '@sim/db/schema' |
| 2 | +import { |
| 3 | + member, |
| 4 | + permissionGroup, |
| 5 | + permissions, |
| 6 | + project, |
| 7 | + projectWorkspace, |
| 8 | + workspace, |
| 9 | +} from '@sim/db/schema' |
3 | 10 | import { createLogger } from '@sim/logger' |
4 | 11 | import { isOrgAdminRole } from '@sim/platform-authz/workspace' |
5 | | -import { and, asc, eq, inArray } from 'drizzle-orm' |
| 12 | +import { and, asc, eq, inArray, sql } from 'drizzle-orm' |
6 | 13 | import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization' |
7 | 14 | import { OrchestrationError } from '@/lib/core/orchestration/types' |
| 15 | +import { textArrayLiteral } from '@/lib/db/arrays' |
8 | 16 | import type { DbTransaction } from '@/lib/db/types' |
9 | 17 | import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' |
10 | 18 | import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' |
@@ -48,7 +56,8 @@ type ProjectAccess = Awaited<ReturnType<typeof loadProjectAccess>> |
48 | 56 |
|
49 | 57 | /** |
50 | 58 | * Loads the caller's org role and every environment with its grant for `records` in three |
51 | | - * queries, whatever their count. |
| 59 | + * queries, whatever their count. A snapshot read also learns, in one more query, which |
| 60 | + * records any permission group restricts, so unrestricted ones skip per-environment policy. |
52 | 61 | */ |
53 | 62 | async function loadProjectAccess( |
54 | 63 | tx: DbTransaction, |
@@ -106,7 +115,27 @@ async function loadProjectAccess( |
106 | 115 | if (rows) rows.push(access) |
107 | 116 | else environmentsByProject.set(projectId, [access]) |
108 | 117 | } |
| 118 | + const organizationIds = [ |
| 119 | + ...new Set(records.flatMap((record) => (record.organizationId ? [record.organizationId] : []))), |
| 120 | + ] |
| 121 | + const restricted = |
| 122 | + mode === 'snapshot' && organizationIds.length |
| 123 | + ? new Set( |
| 124 | + ( |
| 125 | + await tx.execute<{ id: string }>(sql` |
| 126 | + SELECT DISTINCT denied.id FROM ${permissionGroup}, |
| 127 | + jsonb_array_elements_text( |
| 128 | + COALESCE(${permissionGroup.config}->'deniedPartialAccessProjectIssues', '[]'::jsonb) |
| 129 | + ) AS denied(id) |
| 130 | + WHERE ${inArray(permissionGroup.organizationId, organizationIds)} |
| 131 | + AND denied.id = ANY(${textArrayLiteral(records.map((record) => record.id))}) |
| 132 | + `) |
| 133 | + ).map((row) => row.id) |
| 134 | + ) |
| 135 | + : null |
109 | 136 | return { |
| 137 | + /** Whether a permission group might restrict Issues for the Project; held reads check all. */ |
| 138 | + mayRestrictIssues: (projectId: string) => restricted === null || restricted.has(projectId), |
110 | 139 | isOrgAdmin: (organizationId: string | null) => |
111 | 140 | organizationId !== null && |
112 | 141 | membership?.organizationId === organizationId && |
@@ -145,7 +174,12 @@ async function evaluateProjectAccess( |
145 | 174 | 'Organization admin or admin access to every environment is required' |
146 | 175 | ) |
147 | 176 | let canUseIssues = !record.archivedAt && visible.length > 0 |
148 | | - if (canUseIssues && visible.length < active.length && record.organizationId) { |
| 177 | + if ( |
| 178 | + canUseIssues && |
| 179 | + visible.length < active.length && |
| 180 | + record.organizationId && |
| 181 | + access.mayRestrictIssues(record.id) |
| 182 | + ) { |
149 | 183 | for (const environment of visible) { |
150 | 184 | const { config } = await resolveVerifiedUserAccessControlContext( |
151 | 185 | principal.userId, |
|
0 commit comments