diff --git a/.github/codeql/codeql-pack.lock.yml b/.github/codeql/codeql-pack.lock.yml new file mode 100644 index 0000000..710e9f7 --- /dev/null +++ b/.github/codeql/codeql-pack.lock.yml @@ -0,0 +1,30 @@ +--- +lockVersion: 1.0.0 +dependencies: + codeql/concepts: + version: 0.0.18 + codeql/controlflow: + version: 2.0.28 + codeql/dataflow: + version: 2.1.0 + codeql/javascript-all: + version: 2.6.24 + codeql/mad: + version: 1.0.44 + codeql/regex: + version: 1.0.44 + codeql/ssa: + version: 2.0.20 + codeql/threat-models: + version: 1.0.44 + codeql/tutorial: + version: 1.0.44 + codeql/typetracking: + version: 2.0.28 + codeql/util: + version: 2.0.31 + codeql/xml: + version: 1.0.44 + codeql/yaml: + version: 1.0.44 +compiled: false diff --git a/.github/codeql/qlpack.yml b/.github/codeql/qlpack.yml new file mode 100644 index 0000000..f374d64 --- /dev/null +++ b/.github/codeql/qlpack.yml @@ -0,0 +1,9 @@ +name: fieldtrack/security-queries +version: 1.0.0 +description: > + Custom CodeQL queries for the FieldTrack 2.0 backend. + Targets Fastify + Supabase multi-tenant SaaS patterns: + tenant isolation, JWT claim misuse, and missing role guards. +library: false +dependencies: + codeql/javascript-all: '*' diff --git a/.github/codeql/queries/fastify-employee-route-missing-role-guard.ql b/.github/codeql/queries/fastify-employee-route-missing-role-guard.ql new file mode 100644 index 0000000..b20af22 --- /dev/null +++ b/.github/codeql/queries/fastify-employee-route-missing-role-guard.ql @@ -0,0 +1,67 @@ +/** + * @name Fastify EMPLOYEE-only route missing role guard + * @description A Fastify route that creates or accesses employee-scoped + * resources (attendance, expenses) uses only authenticate in + * preValidation but no requireRole("EMPLOYEE") guard. + * ADMIN users can call these routes; the only protection is + * a service-layer check, which violates defense-in-depth. + * @kind problem + * @problem.severity warning + * @id fieldtrack/fastify-employee-route-missing-role-guard + * @tags security + * authentication + * fastify + * @precision medium + */ +import javascript + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +predicate arrayContainsRequireRole(Expr arrayExpr) { + exists(CallExpr requireRoleCall | + requireRoleCall.getCallee().(Identifier).getName() = "requireRole" and + requireRoleCall = arrayExpr.(ArrayExpr).getAnElement() + ) +} + +predicate optionsHaveRoleGuard(ObjectExpr options) { + exists(Property preValidation | + preValidation.getParent() = options and + preValidation.getName() = "preValidation" and + arrayContainsRequireRole(preValidation.getInit()) + ) +} + +// ─── Query ─────────────────────────────────────────────────────────────────── + +from MethodCallExpr routeReg, StringLiteral path, ObjectExpr options +where + routeReg.getMethodName() in ["get", "post", "put", "patch", "delete"] and + path = routeReg.getArgument(0) and + options = routeReg.getArgument(1) and + + // Employee-scoped resource paths (not admin, not health, not internal) + ( + path.getStringValue().matches("%/attendance/%") or + path.getStringValue().matches("%/expenses%") or + path.getStringValue().matches("%/locations/%") + ) and + not path.getStringValue().matches("%/admin/%") and + + // Has authenticate but no requireRole + exists(Property preValidation, ArrayExpr arr | + preValidation.getParent() = options and + preValidation.getName() = "preValidation" and + arr = preValidation.getInit() and + exists(Expr elem | + elem = arr.getAnElement() and + elem.(Identifier).getName() = "authenticate" + ) + ) and + + not optionsHaveRoleGuard(options) + +select routeReg, + "Route '" + path.getStringValue() + + "' operates on employee-scoped data but has no requireRole() guard. " + + "Consider adding requireRole(\"EMPLOYEE\") for defense-in-depth." diff --git a/.github/codeql/queries/fastify-missing-role-guard.ql b/.github/codeql/queries/fastify-missing-role-guard.ql new file mode 100644 index 0000000..f7ec238 --- /dev/null +++ b/.github/codeql/queries/fastify-missing-role-guard.ql @@ -0,0 +1,70 @@ +/** + * @name Fastify route missing requireRole guard + * @description A Fastify route under /admin/ or with destructive methods has + * authenticate in preValidation but no requireRole() call. + * Any authenticated user with any role can invoke it. + * @kind problem + * @problem.severity error + * @id fieldtrack/fastify-missing-role-guard + * @tags security + * authentication + * fastify + * @precision high + */ +import javascript + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Returns true if the given expression is (or evaluates to) an array literal + * that contains a call to requireRole(…). + */ +predicate arrayContainsRequireRole(Expr arrayExpr) { + exists(CallExpr requireRoleCall | + requireRoleCall.getCallee().(Identifier).getName() = "requireRole" and + requireRoleCall = arrayExpr.(ArrayExpr).getAnElement() + ) +} + +/** + * Returns true if the options object passed to the route registration + * contains a preValidation array that calls requireRole(…). + */ +predicate optionsHaveRoleGuard(ObjectExpr options) { + exists(Property preValidation | + preValidation.getParent() = options and + preValidation.getName() = "preValidation" and + arrayContainsRequireRole(preValidation.getInit()) + ) +} + +// ─── Query ─────────────────────────────────────────────────────────────────── + +from MethodCallExpr routeReg, StringLiteral path, ObjectExpr options +where + // Match Fastify route helper calls: app.get / app.post / app.patch etc. + routeReg.getMethodName() in ["get", "post", "put", "patch", "delete"] and + path = routeReg.getArgument(0) and + options = routeReg.getArgument(1) and + + // Only flag /admin/ paths — these definitely require ADMIN role + path.getStringValue().matches("%/admin/%") and + + // The route DOES include authenticate (so it is not a public route) + exists(Property preValidation, ArrayExpr arr | + preValidation.getParent() = options and + preValidation.getName() = "preValidation" and + arr = preValidation.getInit() and + exists(Expr elem | + elem = arr.getAnElement() and + elem.(Identifier).getName() = "authenticate" + ) + ) and + + // But does NOT include requireRole(…) + not optionsHaveRoleGuard(options) + +select routeReg, + "Admin route '" + path.getStringValue() + + "' has authenticate but no requireRole() in preValidation. " + + "Any authenticated user — regardless of role — can call it." diff --git a/.github/codeql/queries/jwt-user-metadata-access.ql b/.github/codeql/queries/jwt-user-metadata-access.ql new file mode 100644 index 0000000..c501604 --- /dev/null +++ b/.github/codeql/queries/jwt-user-metadata-access.ql @@ -0,0 +1,40 @@ +/** + * @name JWT user_metadata accessed for authorization + * @description user_metadata in a Supabase JWT is controlled by the end user. + * Using it for authorization decisions (role, org_id, employee_id) + * is a security vulnerability — attackers can forge these values. + * Auth claims must come from app_metadata (server-controlled) or + * top-level claims injected by the custom_access_token_hook. + * @kind problem + * @problem.severity error + * @id fieldtrack/jwt-user-metadata-trust + * @tags security + * jwt + * authentication + * @precision high + */ +import javascript + +// ─── Query ─────────────────────────────────────────────────────────────────── + +/* + * Matches any property access of the form: + * decoded.user_metadata. + * payload.user_metadata.role + * token.user_metadata.org_id + * …where the outer access reads an authorization-sensitive field. + */ +from PropAccess userMetaAccess, PropAccess outerAccess, string authField +where + // The inner access is .user_metadata on any identifier + userMetaAccess.getPropertyName() = "user_metadata" and + + // The outer access reads a security-sensitive field from user_metadata + outerAccess.getBase() = userMetaAccess and + authField = outerAccess.getPropertyName() and + authField in ["role", "org_id", "organization_id", "employee_id", "is_admin", "permissions"] + +select outerAccess, + "Authorization field '" + authField + "' is read from user_metadata, " + + "which is user-controlled. Use app_metadata." + authField + + " or the top-level JWT claim injected by the Supabase auth hook instead." diff --git a/.github/codeql/queries/supabase-missing-tenant-filter.ql b/.github/codeql/queries/supabase-missing-tenant-filter.ql new file mode 100644 index 0000000..ab0118c --- /dev/null +++ b/.github/codeql/queries/supabase-missing-tenant-filter.ql @@ -0,0 +1,154 @@ +/** + * @name Supabase query missing organization_id tenant filter + * @description A direct Supabase .from() call is not wrapped by tenantQuery() + * or enforceTenant(), and does not have a chained + * .eq("organization_id", …) call. This can cause cross-tenant + * data exposure in the multi-tenant SaaS model where + * supabaseServiceClient bypasses RLS. + * @kind problem + * @problem.severity error + * @id fieldtrack/supabase-missing-tenant-filter + * @tags security + * multi-tenant + * supabase + * @precision medium + */ +import javascript + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** + * A call to supabase.from("table_name") using the service-role client. + * Matches both variable names used in the codebase: + * supabaseServiceClient.from(…) + * supabase.from(…) (local alias) + */ +class SupabaseFromCall extends MethodCallExpr { + SupabaseFromCall() { + this.getMethodName() = "from" and + ( + this.getReceiver().(VarAccess).getName().regexpMatch("supabase.*") or + this.getReceiver().(Identifier).getName().regexpMatch("supabase.*") + ) + } + + string getTableName() { + result = this.getArgument(0).(StringLiteral).getStringValue() + } +} + +/** + * Tables that are INTENTIONALLY global (no org scope): + * organizations — org lookup by id, never multi-tenant filtered + * users / auth — Supabase-managed + * queue_retry_intents — internal ops table (see issue C1 for the fix) + * + * Add known-safe tables here to reduce false positives. + */ +predicate isGlobalTable(string tableName) { + tableName in [ + "organizations", + "storage.objects" + ] +} + +/** + * Returns true if an .eq("organization_id", …) call is chained anywhere + * in the method call chain rooted at `base`. + */ +predicate hasOrganizationIdEq(MethodCallExpr base) { + exists(MethodCallExpr eqCall | + eqCall.getMethodName() = "eq" and + eqCall.getArgument(0).(StringLiteral).getStringValue() = "organization_id" and + ( + // Direct chain: base.select(...).eq("organization_id", ...) + eqCall.getReceiver+() = base or + // Reverse: the from() call is the receiver chain leading to eqCall + base.getReceiver+() = eqCall + ) + ) +} + +/** + * Returns true if the from() call is passed as an argument to tenantQuery(), + * enforceTenant(), or orgTable() — the approved isolation wrappers. + * + * orgTable() is the preferred wrapper in repository files: it constructs the + * Supabase query and applies .eq("organization_id", ...) at construction time, + * so the from() call never appears with a chained .eq() — the ql predicate must + * treat orgTable() calls as implicitly tenant-scoped. + * + * Note: INSERT/UPSERT operations legitimately call supabase.from() directly + * and set organization_id in the body payload, not as a .eq() filter. + * Those are flagged by hasOrganizationIdInPayload() below and suppressed. + */ +predicate isWrappedInTenantHelper(SupabaseFromCall fromCall) { + // Pattern 1: tenantQuery(request, supabase.from("x")) + exists(CallExpr wrapper | + wrapper.getCallee().(Identifier).getName() in ["tenantQuery", "enforceTenant"] and + wrapper.getAnArgument() = fromCall + ) + or + // Pattern 2: tenantQuery(request, supabase.from("x").select("*")) + exists(CallExpr wrapper, MethodCallExpr chain | + wrapper.getCallee().(Identifier).getName() in ["tenantQuery", "enforceTenant"] and + chain.getReceiver+() = fromCall and + wrapper.getAnArgument() = chain + ) + or + // Pattern 3: orgTable(request, "table_name") + // This wrapper does NOT call supabase.from() inline — it is a factory that + // internally calls supabase.from() + .eq("organization_id", ...). + // From the call-site perspective the findable pattern is: + // orgTable(request, "employees").select("*") + // The supabase.from() call inside orgTable's implementation IS flagged but + // it belongs to a trusted utility — suppress it by file path. + fromCall.getFile().getAbsolutePath().matches("%db/query%") +} + +/** + * Suppress findings in files that are intentionally cross-tenant: + * workers/ — BullMQ workers receive job payloads with pre-validated session IDs. + * They operate globally to process jobs from any org. Tenant scoping + * happens at the job-enqueue boundary, not inside the worker. + * scripts/ — One-off backfill/migration scripts that purposefully touch all orgs. + * plugins/prometheus.ts — Global metric aggregation (no per-org scope by design). + */ +predicate isIntentionallyGlobalContext(SupabaseFromCall fromCall) { + fromCall.getFile().getAbsolutePath().matches("%/workers/%") or + fromCall.getFile().getAbsolutePath().matches("%\\workers\\%") or + fromCall.getFile().getAbsolutePath().matches("%/scripts/%") or + fromCall.getFile().getAbsolutePath().matches("%\\scripts\\%") or + fromCall.getFile().getAbsolutePath().matches("%prometheus%") +} + +// ─── Query ─────────────────────────────────────────────────────────────────── + +/** + * Returns true when the Supabase call chain contains an INSERT/UPSERT (.insert + * or .upsert) where the payload object has an "organization_id" property. + * These are secure by construction and should not be flagged. + */ +predicate isInsertWithOrgId(SupabaseFromCall fromCall) { + exists(MethodCallExpr mutationCall, ObjectExpr payload, Property orgProp | + mutationCall.getMethodName() in ["insert", "upsert"] and + mutationCall.getReceiver+() = fromCall and + // First argument to insert/upsert is the payload object + payload = mutationCall.getArgument(0) and + orgProp.getParent() = payload and + orgProp.getName() = "organization_id" + ) +} + +from SupabaseFromCall fromCall +where + not isGlobalTable(fromCall.getTableName()) and + not isWrappedInTenantHelper(fromCall) and + not hasOrganizationIdEq(fromCall) and + not isInsertWithOrgId(fromCall) and + not isIntentionallyGlobalContext(fromCall) + +select fromCall, + "Supabase query on '" + fromCall.getTableName() + + "' uses the service-role client and is not scoped by organization_id. " + + "Wrap with tenantQuery() or add .eq(\"organization_id\", …)." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..78ad2f4 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,57 @@ +name: "CodeQL Security Scan" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: "0 3 * * 1" # Weekly scan (Monday 3 AM UTC) + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (CodeQL) + runs-on: ubuntu-latest + timeout-minutes: 20 + + strategy: + fail-fast: false + matrix: + language: [ "javascript" ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initialize CodeQL (official queries only) + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + queries: security-and-quality + + # Install dependencies (required for accurate analysis) + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + # Build project (VERY IMPORTANT for TypeScript) + - name: Build project + run: npm run build || echo "No build step" + + # Run CodeQL analysis + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 diff --git a/.gitignore b/.gitignore index 8edbae9..f8e1077 100644 --- a/.gitignore +++ b/.gitignore @@ -97,10 +97,26 @@ Thumbs.db ehthumbs.db desktop.ini +# ---------------- +# CodeQL +# ---------------- +codeql-db/ +results/ +*.sarif + +# ---------------- +# Supabase local dev +# ---------------- +.supabase/ + # ---------------- # IDE / Editors # ---------------- -.vscode/ +# Allow shared workspace settings and extension recommendations; +# ignore personal/machine-specific files (launch.json, mcp.json, tasks.json). +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json .idea/ *.swp *.swo diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3b66410 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "git.ignoreLimitWarning": true +} \ No newline at end of file diff --git a/apps/api/src/modules/admin/retry-intents.routes.ts b/apps/api/src/modules/admin/retry-intents.routes.ts index 70042a7..1da96f3 100644 --- a/apps/api/src/modules/admin/retry-intents.routes.ts +++ b/apps/api/src/modules/admin/retry-intents.routes.ts @@ -47,12 +47,16 @@ export async function adminRetryIntentsRoutes(app: FastifyInstance): Promise { - // Check in — any authenticated user + // Check in — EMPLOYEE only (ADMIN cannot participate in attendance) app.post( "/attendance/check-in", { schema: { tags: ["attendance"] }, - preValidation: [authenticate], + preValidation: [authenticate, requireRole("EMPLOYEE")], }, attendanceController.checkIn, ); - // Check out — any authenticated user + // Check out — EMPLOYEE only (ADMIN cannot participate in attendance) app.post( "/attendance/check-out", { schema: { tags: ["attendance"] }, - preValidation: [authenticate], + preValidation: [authenticate, requireRole("EMPLOYEE")], }, attendanceController.checkOut, ); diff --git a/apps/api/src/modules/expenses/expenses.routes.ts b/apps/api/src/modules/expenses/expenses.routes.ts index 4fccced..2db7d22 100644 --- a/apps/api/src/modules/expenses/expenses.routes.ts +++ b/apps/api/src/modules/expenses/expenses.routes.ts @@ -53,7 +53,10 @@ export async function expensesRoutes(app: FastifyInstance): Promise { mimeType: z.string().optional(), }), }, - preValidation: [authenticate], + // EMPLOYEE only — the signed URL path embeds employeeId; ADMIN has no + // employee record and will be rejected by requireEmployeeContext() in the + // service layer anyway. Route-level guard makes the contract explicit. + preValidation: [authenticate, requireRole("EMPLOYEE")], }, expensesController.getReceiptUploadUrl, ); diff --git a/apps/api/src/modules/session_summary/session_summary.service.ts b/apps/api/src/modules/session_summary/session_summary.service.ts index d375336..c728e93 100644 --- a/apps/api/src/modules/session_summary/session_summary.service.ts +++ b/apps/api/src/modules/session_summary/session_summary.service.ts @@ -224,6 +224,8 @@ export const sessionSummaryService = { // 4b. Sync pre-computed columns on attendance_sessions so the analytics layer // can aggregate directly without joining session_summaries (CRIT-01 fix). + // organization_id scope added as defense-in-depth: the service client bypasses + // RLS, so the WHERE clause must enforce tenant ownership explicitly. await supabase .from("attendance_sessions") .update({ @@ -231,7 +233,8 @@ export const sessionSummaryService = { total_duration_seconds: durationSeconds, distance_recalculation_status: "done", }) - .eq("id", sessionId); + .eq("id", sessionId) + .eq("organization_id", session.organization_id); // Keep employee_latest_sessions snapshot in sync — fire-and-forget. attendanceRepository @@ -352,6 +355,7 @@ export const sessionSummaryService = { // 4b. Sync pre-computed columns on attendance_sessions so the analytics layer // can aggregate directly without joining session_summaries (CRIT-01 fix). + // organization_id scope added as defense-in-depth (service client bypasses RLS). await supabase .from("attendance_sessions") .update({ @@ -359,7 +363,8 @@ export const sessionSummaryService = { total_duration_seconds: durationSeconds, distance_recalculation_status: "done", }) - .eq("id", sessionId); + .eq("id", sessionId) + .eq("organization_id", sessionData.organization_id as string); // Keep employee_latest_sessions snapshot in sync — fire-and-forget. attendanceRepository diff --git a/apps/api/src/workers/retry-intents.ts b/apps/api/src/workers/retry-intents.ts index 5f69390..7c3efda 100644 --- a/apps/api/src/workers/retry-intents.ts +++ b/apps/api/src/workers/retry-intents.ts @@ -46,6 +46,9 @@ export async function persistRetryIntent( queue_name: queueName, job_key: jobKey, payload, + // Promote organization_id to a top-level column so the admin endpoint + // can filter by org without parsing JSONB (C1 security fix). + organization_id: payload.organizationId ?? null, status: "pending", error_message: reason, next_retry_at: new Date().toISOString(), diff --git a/apps/api/tests/helpers/assertions.ts b/apps/api/tests/helpers/assertions.ts new file mode 100644 index 0000000..a186e84 --- /dev/null +++ b/apps/api/tests/helpers/assertions.ts @@ -0,0 +1,5 @@ +import { expect } from "vitest"; + +export function expectEmployeeRoleError(body: { error: string }) { + expect(body.error).toMatch(/requires employee role/i); +} diff --git a/apps/api/tests/integration/attendance/attendance.test.ts b/apps/api/tests/integration/attendance/attendance.test.ts index 8809c67..8aa0c24 100644 --- a/apps/api/tests/integration/attendance/attendance.test.ts +++ b/apps/api/tests/integration/attendance/attendance.test.ts @@ -161,7 +161,7 @@ describe("Attendance Integration Tests", () => { }); expect(res.statusCode).toBe(403); const body = JSON.parse(res.body) as { success: false; error: string }; - expect(body.error).toMatch(/admin users cannot check in/i); + expect(body.error).toMatch(/requires employee role/i); }); it("returns 404 when employee is not in the organization", async () => { @@ -241,7 +241,7 @@ describe("Attendance Integration Tests", () => { }); expect(res.statusCode).toBe(403); const body = JSON.parse(res.body) as { success: false; error: string }; - expect(body.error).toMatch(/admin users cannot check out/i); + expect(body.error).toMatch(/requires employee role/i); }); }); diff --git a/supabase/migrations/20260326000100_add_org_id_to_retry_intents.sql b/supabase/migrations/20260326000100_add_org_id_to_retry_intents.sql new file mode 100644 index 0000000..7b93190 --- /dev/null +++ b/supabase/migrations/20260326000100_add_org_id_to_retry_intents.sql @@ -0,0 +1,53 @@ +-- ============================================================================ +-- Migration: add organization_id to queue_retry_intents (C1 security fix) +-- +-- Problem: GET /admin/retry-intents queries the table without an org filter. +-- The service-role client bypasses RLS, so every ADMIN user sees +-- retry intents owned by ALL organizations. This is a cross-tenant +-- data leak. +-- +-- Fix strategy: +-- 1. Add a nullable organization_id FK column. +-- 2. Back-fill existing rows from the payload JSONB field (where present). +-- 3. Add a covering index for the common (org, status) query pattern. +-- 4. The application code (retry-intents.routes.ts + retry-intents.ts) +-- is updated separately to: +-- - Write organization_id on insert/upsert. +-- - Filter by organization_id on the admin read endpoint. +-- ============================================================================ + +BEGIN; + +-- 1. Add column — nullable so existing rows are not rejected. +-- Once backfill + app deploy is complete you may add NOT NULL + DEFAULT. +ALTER TABLE public.queue_retry_intents + ADD COLUMN IF NOT EXISTS organization_id UUID + REFERENCES public.organizations(id) ON DELETE CASCADE; + +-- 2. Backfill from payload JSONB. +-- The worker stores payload->>'organizationId' (camelCase) per the +-- RetryIntentPayload interface in src/workers/retry-intents.ts. +UPDATE public.queue_retry_intents +SET organization_id = (payload->>'organizationId')::uuid +WHERE organization_id IS NULL + AND payload->>'organizationId' IS NOT NULL + AND (payload->>'organizationId') ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; + +-- 3. Index for the admin endpoint access pattern: +-- WHERE organization_id = $1 [AND status = $2] +-- ORDER BY updated_at DESC +CREATE INDEX IF NOT EXISTS idx_queue_retry_intents_org_status + ON public.queue_retry_intents (organization_id, status, updated_at DESC) + WHERE organization_id IS NOT NULL; + +COMMIT; + +-- ============================================================================ +-- FOLLOW-UP (after app deploy + verification): +-- ALTER TABLE public.queue_retry_intents +-- ALTER COLUMN organization_id SET NOT NULL; +-- +-- Rows where organization_id is still NULL after backfill are pre-migration +-- orphan records that predate Phase 26. They can be deleted or left as-is +-- (they will not appear in the filtered admin view). +-- ============================================================================