IvanJS17 puts Mike behind a proper security perimeter

This fork turns access control from a set of promises into rules the product actively enforces.

securitymulti-tenant

IvanJS17 has bundled a substantial security programme into a single change, including several fixes caught during its own review process. The centre of gravity is clear: legal work should be scoped to the right organisation, workspace and matter, with removed members promptly losing access.

  • Organisation-based access controls that separate workspaces and matters.
  • Session revocation that invalidates access after membership changes.
  • Database-wide access rules, with automated checks to catch unprotected data paths.
  • An append-only audit trail plus encrypted daily exports for later review.
  • Upload checks that reject files whose contents do not match their claimed type.
  • Invitation-only sign-up and multi-factor authentication by default.

The same change also removes case-law search and informal email sharing, so those are product choices to assess separately rather than simple hardening.

So what Managing partners, GCs and legal-ops leads should look closely if tenant separation, defensible audit records and tighter client-data controls are prerequisites for deployment.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

1 commit from IvanJS17/mike, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
79c9a92b security: WS1 Gate A - CourtListener off, MFA, invitaciones (W1.3, W1.10, W1.11) (#2) Ivan Juarez Soria 2026-08-13 ↗ GitHub
commit body
* security: remove deliberate false-citation instruction from system prompt (W1.1)

* security: remove raw LLM stream logging entirely (console and file recorders) (W1.2)

* security: disable CourtListener product-wide (W1.3)

Remove US case-law from backend (router, client, chat tools, prompt
reminders, api-key provider), frontend (CaseLawPanel, events, endpoints),
Word add-in, and database configuration (tables + provider check +
legal_research_us flag). Document-citation verification (verifyCitations.ts)
and governed BYOK routing are preserved unchanged.

Verification: backend 513 pass / 15 skip, frontend 242 pass, tsc PASS,
lint 0 errors, gitleaks no leaks, zero functional CourtListener references.
Reviewed by 3 parallel Hermes subagents (security/secrets, contracts,
overreach); findings applied (env.example, README, legal_research_us).

* security: enforce MFA enrollment and invitation-only signup (W1.10, W1.11)

W1.10: mfa_on_login defaults to true for new profiles (migration
20260813_02); verify-mfa page now enrolls a TOTP factor when none is
verified (enroll -> QR -> challengeAndVerify), reusing the existing
MFA gate components.

W1.11: signup is invitation-only by default (GOTRUE_DISABLE_SIGNUP=true
in docker-compose + env examples); new POST /user/invite (requireAuth,
GoTrue inviteUserByEmail, responds only {ok:true}, never returns tokens);
new /accept-invite page + InvitePasswordSetup component (verifyOtp with
token_hash/type=invite, then sets password); login/signup pages detect
the invite flow.

Verification: backend build PASS, 513 pass / 15 skip; frontend tsc PASS,
242 pass; lint 0 errors; git diff --check clean. Reviewed by parallel
Hermes subagents: security PASS 5/5, contracts confirmed against
supabase-js types, scope in-scope (env-local package.json noise reverted).

* feat(ws1): tenancy foundations - organizations, workspaces, matters with RLS (W1.5)

Six new tables (organizations, organization_memberships, workspaces,
workspace_memberships, matters, matter_memberships) with role checks
(org_owner, workspace_admin, matter_owner, editor, viewer,
technical_operator) and row-level security verified against PostgreSQL 16:

- SECURITY DEFINER helpers (organization_role, is_organization_member,
  is_workspace_admin, matter_role) avoid the infinite-recursion Postgres
  rejects when policies subquery their own table.
- 15 policies enforce member visibility and owner/admin writes; anon gets
  zero access; authenticated is filtered by RLS.
- schema.sql updated (fresh-DB init) alongside the incremental migration.

RLS matrix verified live: member sees own org/matter, viewer insert is
rejected by policy, outsider sees nothing, org_owner can manage members.

* feat(ws1): RLS on every public table + CI security invariants (W1.6)

- Migration 20260813_04 enables row-level security on the 16 OSS tables
  that lacked it (plus workflow_open_source_submissions, contact_messages)
  with to_regclass guards; schema.sql updated for fresh databases.
- New scripts/check-security-invariants.sh enforces in CI: every public
  table defined in schema.sql/migrations has RLS enabled (documented
  courtlistener_* exception, dropped in W1.3), and no ordinary router uses
  the service_role path (createServerSupabase) without requireAuth.

Verification (PostgreSQL 16 ephemeral): schema + migrations apply clean;
0 tables without relrowsecurity; invariants script PASS (34/34 tables);
backend build PASS, suite 513/15.

* fix(ws1): remove anon execute grants on tenancy RLS helpers (W1.5 review)

Independent security review found the four SECURITY DEFINER helpers
(organization_role, is_organization_member, is_workspace_admin,
matter_role) granted EXECUTE to anon; the table-level REVOKEs do not cover
function privileges. No data leak today (helpers filter by auth.uid(), null
for anon), but the surface is unnecessary. Executes now go to
authenticated only; verified in PostgreSQL: anon grants = 0, schema applies
clean, RLS matrix still passes.

* feat(ws1): monotonic authorization epochs for revocation (W1.7)

- organizations.authorization_epoch bigint default 0 (schema.sql +
  migration 20260813_05) with atomic bump via RPC
  bump_authorization_epoch(org) (security definer, service_role only).
- backend/src/lib/tenancy.ts: revokeOrganizationMembership() deletes the
  membership, bumps the epoch and signs the user out of Auth; and
  assertEpochFresh() for long-running jobs - aborts when the epoch moved
  (a revocation happened mid-job).
- TDD: 4 unit tests (mock persistence); verified in PostgreSQL 16:
  schema + migration apply clean, epoch bumps 0->1->2 monotonically.
- Backend suite 517 passed / 15 skipped.

* fix(ws1): revoke PUBLIC/anon/authenticated execute on bump_authorization_epoch (W1.7 review)

Independent security review found the RPC inherited the PostgreSQL default
EXECUTE for PUBLIC; granting service_role did not make it exclusive, so any
role could bump any organization's epoch (invalidating other jobs/revokes).
Migration and schema now revoke execute from public, anon, authenticated
before granting service_role. Verified in PostgreSQL 16: leaked grants = 0,
schema applies clean.

* feat(ws1): remove email-based sharing without acceptance (W1.8)

shared_with (projects, tabular_reviews), workflow_shares and the People
share UI are removed; access is owner-only until tenancy memberships
(W1.5) and the nominal invitation flow (W1.11) take over collaboration.

- Migration 20260813_06 drops projects.shared_with + GIN index,
  tabular_reviews.shared_with and workflow_shares (verified on PG16:
  residuals 0|0|0|0).
- schema.sql updated (columns, index, workflow_shares table, and the
  get_*_overview RPCs lose their shared branches).
- Backend: access.ts, projects/tabular/workflows/downloads/documents/
  chat/projectChat routes, userDataExport/Cleanup, contextBuilders -
  owner-only access, share endpoints removed (net -417 lines).
- Frontend: PeopleModal removed, sharedWith types/API calls gone,
  InvitePasswordSetup effect reworked (lint fix: no sync setState in
  effect).
- Tests updated: backend 510 pass/15 skip, frontend 237 pass, tsc PASS,
  lint 0 errors.

* feat(ws1): authenticated downloads + upload content quarantine (W1.9)

Downloads were already authenticated server-side (GET /download/:token with
requireAuth + ensureDocAccess + S3 fetch, no signed URLs anywhere). This
commit closes the upload gap:

- New backend/src/lib/fileValidation.ts: detectFileKind() reads magic bytes
  (PDF %PDF-, OOXML PK\x03\x04, OLE2 D0CF11E0) and validateUploadContent()
  rejects files whose real content does not match the declared extension
  (blocks executables disguised as .pdf/.docx).
- documents.ts upload: 415 when content mismatches, before any row or
  object is created.
- Partial cleanup: on processing failure the uploaded object and its PDF
  rendition are deleted (deleteFile) so failed/revoked uploads never linger.
- TDD: 11 new tests (real/fake/executable cases). Backend suite 521/15.

* test(ws1): tenancy RLS authorization matrix integration test (W1.12)

New gated integration test (SUPABASE_TEST_URL / SERVICE_ROLE / ANON) that
proves the full authorization matrix against real RLS on a Supabase stack:
viewer sees own org+matter, viewer insert is rejected by policy, outsider
sees nothing, org_owner manages members. Skips cleanly without the stack.

The empirical matrix was already verified against PostgreSQL 16
(hermes-verify-w15.sh: member_sees_org, member_sees_matter,
outsider_sees_nothing, owner_manages, viewer_insert_blocked - 5/5) and the
owner-only endpoint matrix is covered by access.test.ts (W1.8).

* feat(ws1): insert-only audit trail with minimal events (W1.13)

- Migration 20260813_07: audit_events table (identity pk, actor, org,
  event_type, detail jsonb) + BEFORE UPDATE OR DELETE trigger that raises
  for every role (service_role included); browser roles get nothing,
  service_role gets insert+select. Verified on PostgreSQL 16: INSERT ok,
  UPDATE blocked, DELETE blocked, row preserved.
- backend/src/lib/audit.ts: recordAuditEvent() - single write path, never
  breaks the underlying operation.
- Events wired: user.invited (POST /user/invite), membership.revoked
  (tenancy revocation), document.uploaded (upload 201), document.deleted
  (DELETE /documents/:id), document.downloaded (GET /download/:token).
- schema.sql mirrors the table + trigger for fresh databases.
- Backend suite 521 passed / 16 skipped.

* feat(ws1): daily encrypted audit export job (W1.14)

- lib/auditExport.ts: AES-256-GCM JSONLines with deterministic daily key
  rotation (HMAC(master, date)) so yesterday's export stays decryptable
  without a KMS; expiredExportKeys() enforces the 30-day retention window.
- scripts/export-audit-daily.ts (npm run export:audit): reads yesterday's
  audit_events (UTC window), encrypts, writes audit-exports/daily/
  <date>.jsonl.enc with an AUDIT1 magic version marker, and deletes
  exports older than 30 days. Cron: 0 3 * * * (documented in header).
- storage.ts: uploadFileToBucket/deleteFileFromBucket for the export
  bucket (scoped, never touches the documents bucket).
- TDD: 6 tests (key derivation/rotation, encrypt-decrypt round-trip,
  wrong-day key rejection, retention boundaries). Backend suite 527/16.
- Format round-trip verified (magic + iv + ciphertext).

* fix(ws1): audit never breaks the operation + drop unused event types (W1.13 review)

Independent security review flagged: recordAuditEvent only handled {error}
responses - a thrown exception would propagate and break the underlying
operation; and AuditEventType declared 9 events while only 5 are wired.
Now: full try/catch (log and continue on any failure) and the type union
matches the 5 live events (user.invited, membership.revoked,
document.uploaded/deleted/downloaded).

* fix(ws1): audit-export retention scoped to the export bucket (W1.14 review)

Independent security review found the purge used listFiles(), which
enumerates the documents bucket (R2_BUCKET_NAME), not AUDIT_EXPORT_BUCKET:
expired exports were never listed, and keys matching daily/*.jsonl.enc in
the wrong bucket could be deleted. New listFilesFromBucket(bucket, prefix)
mirrors listFiles with an explicit bucket; the job now lists and deletes
only inside the export bucket.

* fix(ws1): CI portability + re-create overview RPCs in migration 06 (W1.8 gate)

Two CI failures caught before merge:
1. check-security-invariants.sh used rg, which is not installed on GitHub
   runners -> portable grep -ohE/-qE (verified: 35/35 RLS, isolation OK).
2. Migration 06 dropped shared_with but only schema.sql re-created the
   get_*_overview RPCs; migrated databases kept the old definitions
   referencing the dropped column -> PostgREST PGRST202. The migration now
   re-creates all four overview RPC bodies (verified on PostgreSQL 16:
   shared refs = 0 and get_projects_overview executes).

* chore: rebrand Mike -> LiTT (visible branding only)

User-visible strings (layout metadata, MFA factor friendly names,
placeholders, error messages), package names (litt / litt-backend) and
README (title + fork note + upstream attribution) now say LiTT. Internal
identifiers (MikeApiError, mikeApi.ts, repo/remote names) stay unchanged
to keep the diff narrow; AGPL-3.0 notices and upstream attribution are
preserved per the license (see docs/03-analisis-legal-agpl.md of
legal-ai-mx).

* fix(ws1): align tabular overview RPC callers with the shared-free signature (W1.8 gate)

W1.8 dropped p_user_email from get_tabular_reviews_overview (both the
paginated and simple variants) in schema.sql, but the gated stack test
still passed it -> PGRST202 'no function matches' on the Supabase stack
job (and the truncated RPC copy in migration 06 compounded it). Fixes:
- tabularPagination.supabase.test.ts: remove p_user_email from all rpc()
  calls (10 sites) and the now-unused ownerEmail variable.
- Migration 06: replace the truncated paginated RPC copy (cut at an inner
  $$;) with the full 134-line body from schema.sql.
Verified on PostgreSQL 16 (CI scenario, schema.sql only): the paginated
RPC executes with the test's exact signature and returns 0 rows without
error; build PASS; suite 527/16.

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-984.md from inside the repo you want the changes in.

⬇ Download capture-thread-984.md