-- ════════════════════════════════════════════════════════════════════════════ -- audit-grants-bundle-shape.sql — Pre-D-Prep diagnostic -- ════════════════════════════════════════════════════════════════════════════ -- Purpose: confirm the assumption that >99% of existing `storage.access_grants` -- rows already cluster into the standard role bundles -- (viewer / commenter / contributor / editor / owner). The answer determines -- whether the access_grants → role_grants migration is fully mechanical (just -- run the backfill) or whether per-row decisions are needed for some edge -- cases. -- -- READ-ONLY. Safe to run against any environment including production. -- -- Usage: -- psql "$DATABASE_URL" -f tools/audit-grants-bundle-shape.sql -- -- Reference role bundles (mirror `Role::expand()` in -- `src/application/dtos/grant_dto.rs`): -- viewer = {read} -- commenter = {read, comment} ← reserved variant -- contributor = {read, create} ← new in D-Prep -- editor = {read, comment, create, update} -- owner = {read, comment, create, update, share, delete} -- (post-D-Prep: + manage, when Group-as-Resource lands) -- ════════════════════════════════════════════════════════════════════════════ \echo '' \echo '─────────────────────────────────────────────────────────────────────────' \echo ' 0. Total population' \echo '─────────────────────────────────────────────────────────────────────────' SELECT count(*) AS total_grant_rows, count(DISTINCT (subject_type, subject_id, resource_type, resource_id)) AS distinct_clusters, ROUND( count(*)::numeric / NULLIF(count(DISTINCT (subject_type, subject_id, resource_type, resource_id)), 0), 2 ) AS avg_rows_per_cluster FROM storage.access_grants; \echo '' \echo '─────────────────────────────────────────────────────────────────────────' \echo ' 1. Bundle distribution — which permission sets exist, how popular?' \echo '─────────────────────────────────────────────────────────────────────────' \echo ' Each row = a unique permission set; cluster_count = how many (subject,' \echo ' resource) pairs have exactly that set.' WITH cluster AS ( SELECT subject_type, subject_id, resource_type, resource_id, array_agg(permission ORDER BY permission) AS perms FROM storage.access_grants GROUP BY 1, 2, 3, 4 ), known_bundles AS ( SELECT ARRAY['read']::text[] AS perms, 'viewer' AS role UNION ALL SELECT ARRAY['comment','read']::text[], 'commenter' UNION ALL SELECT ARRAY['create','read']::text[], 'contributor' UNION ALL SELECT ARRAY['comment','create','read','update']::text[], 'editor' UNION ALL SELECT ARRAY['comment','create','delete','read','share','update']::text[], 'owner' ) SELECT cluster.perms, count(*) AS cluster_count, ROUND(100.0 * count(*) / SUM(count(*)) OVER (), 2) AS pct, COALESCE(known_bundles.role, '(non-bundle)') AS maps_to_role FROM cluster LEFT JOIN known_bundles ON known_bundles.perms = cluster.perms GROUP BY cluster.perms, known_bundles.role ORDER BY cluster_count DESC; \echo '' \echo '─────────────────────────────────────────────────────────────────────────' \echo ' 2. Bundle-shaped vs not — the headline number' \echo '─────────────────────────────────────────────────────────────────────────' WITH cluster AS ( SELECT subject_type, subject_id, resource_type, resource_id, array_agg(permission ORDER BY permission) AS perms FROM storage.access_grants GROUP BY 1, 2, 3, 4 ), known_bundles AS ( SELECT ARRAY['read']::text[] AS perms UNION ALL SELECT ARRAY['comment','read']::text[] UNION ALL SELECT ARRAY['create','read']::text[] UNION ALL SELECT ARRAY['comment','create','read','update']::text[] UNION ALL SELECT ARRAY['comment','create','delete','read','share','update']::text[] ), shape AS ( SELECT CASE WHEN cluster.perms = ANY(SELECT kb.perms FROM known_bundles kb) THEN 'bundle-shaped' ELSE 'NON-bundle (needs per-row decision)' END AS shape FROM cluster ) SELECT shape, count(*) AS clusters, ROUND(100.0 * count(*) / SUM(count(*)) OVER (), 2) AS pct FROM shape GROUP BY shape ORDER BY clusters DESC; \echo '' \echo '─────────────────────────────────────────────────────────────────────────' \echo ' 3. Non-bundle clusters in detail (the <1% — investigate each)' \echo '─────────────────────────────────────────────────────────────────────────' \echo ' If this returns 0 rows, the migration is FULLY mechanical. Otherwise' \echo ' eyeball each row and decide: closest role + audit log entry, or' \echo ' refuse-to-migrate (rare).' WITH cluster AS ( SELECT subject_type, subject_id, resource_type, resource_id, array_agg(permission ORDER BY permission) AS perms FROM storage.access_grants GROUP BY 1, 2, 3, 4 ), known_bundles AS ( SELECT ARRAY['read']::text[] AS perms UNION ALL SELECT ARRAY['comment','read']::text[] UNION ALL SELECT ARRAY['create','read']::text[] UNION ALL SELECT ARRAY['comment','create','read','update']::text[] UNION ALL SELECT ARRAY['comment','create','delete','read','share','update']::text[] ) SELECT cluster.subject_type, cluster.subject_id, cluster.resource_type, cluster.resource_id, cluster.perms FROM cluster WHERE NOT (cluster.perms = ANY(SELECT kb.perms FROM known_bundles kb)) ORDER BY cluster.resource_type, cluster.resource_id LIMIT 200; \echo '' \echo '─────────────────────────────────────────────────────────────────────────' \echo ' 4. Sanity checks — invariants that should be true today' \echo '─────────────────────────────────────────────────────────────────────────' \echo '' \echo ' 4a. Clusters with no Read permission (broken? if >0, investigate)' WITH cluster AS ( SELECT subject_type, subject_id, resource_type, resource_id, array_agg(permission ORDER BY permission) AS perms FROM storage.access_grants GROUP BY 1, 2, 3, 4 ) SELECT count(*) AS clusters_without_read FROM cluster WHERE NOT ('read' = ANY(perms)); \echo '' \echo ' 4b. Subject-type distribution (sanity check on token / external counts)' SELECT subject_type, count(*) AS rows, count(DISTINCT subject_id) AS distinct_subjects, count(DISTINCT (resource_type, resource_id)) AS distinct_resources FROM storage.access_grants GROUP BY subject_type ORDER BY subject_type; \echo '' \echo ' 4c. Resource-type distribution' SELECT resource_type, count(*) AS rows FROM storage.access_grants GROUP BY resource_type ORDER BY rows DESC; \echo '' \echo ' 4d. Permission distribution (total rows per permission value)' SELECT permission, count(*) AS rows FROM storage.access_grants GROUP BY permission ORDER BY rows DESC; \echo '' \echo '─────────────────────────────────────────────────────────────────────────' \echo ' 5. Migration cost estimate' \echo '─────────────────────────────────────────────────────────────────────────' \echo ' role_grants will have ~ (distinct clusters) rows, replacing ~ (total)' \echo ' rows in access_grants. The ratio is the per-row reduction factor.' WITH cluster AS ( SELECT subject_type, subject_id, resource_type, resource_id FROM storage.access_grants GROUP BY 1, 2, 3, 4 ) SELECT (SELECT count(*) FROM storage.access_grants) AS access_grants_rows_today, (SELECT count(*) FROM cluster) AS role_grants_rows_after, ROUND( (SELECT count(*) FROM storage.access_grants)::numeric / NULLIF((SELECT count(*) FROM cluster), 0), 2 ) AS reduction_factor; \echo '' \echo '─────────────────────────────────────────────────────────────────────────' \echo ' DONE — share section 2 (headline) and section 3 (non-bundle detail)' \echo ' to make the D-Prep PR scope decision.' \echo '─────────────────────────────────────────────────────────────────────────' \echo ''