IvanJS17 locks Mike behind real tenancy controls

This fork turns access control from a loose feature into a product-wide security boundary.

securitymulti-tenant

IvanJS17 has bundled a substantial hardening pass around who can enter a legal workspace, what they can see, and what happens when access changes.

  • Organisation, workspace and matter roles separate owners, administrators, editors and viewers.
  • Invitation-only accounts and multi-factor authentication tighten who gets through the front door.
  • File checks inspect uploaded content, helping block files disguised as legitimate documents.
  • A protected audit trail records key actions and produces encrypted daily exports.
  • Access revocations can stop long-running work that began before membership was removed.

The fork also removes email-based sharing pending formal invitations, and drops CourtListener from the product. Those are meaningful policy choices, not merely security plumbing.

So what Legal teams handling sensitive matters, and product teams building multi-tenant legal tools, should look closely, while treating the migration and its operational setup as work for an independent security review.

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-1014.md from inside the repo you want the changes in.

⬇ Download capture-thread-1014.md