{
  "//": "Annotated tenant-guard config. `npx tenant-guard init` generates one for your repo, autodetecting the paths. Every guard is opt-in and SKIPS when it doesn't apply to your stack — a skip is never a pass, and the CLI says so. Keys prefixed with // are comments (JSON has none); tenant-guard ignores unknown keys.",

  "migrations": {
    "//": "Static. Shared by migration-collisions and definer-grants. Autodetected from supabase/migrations, migrations, db/migrations, drizzle, prisma/migrations.",
    "dir": "supabase/migrations",
    "grandfather": ["031", "101"]
  },

  "definerGrants": {
    "//": "Static. A mutating SECURITY DEFINER function whose FINAL definition isn't revoked from PUBLIC/anon. Judged on the net state of history, so a fix in a later repair migration counts. `baseline` lets you adopt on a legacy repo without fixing everything on day one.",
    "baseline": 189,
    "allowlist": ["record_login_attempt", "check_login_rate_limit", "validate_public_token"]
  },

  "updatableViews": {
    "//": "Static. A view over one relation with no aggregation is AUTO-UPDATABLE, so Postgres passes INSERT/UPDATE/DELETE through to the base table; with the default security_invoker = false those writes run as the view's OWNER and the base table's RLS never applies to the caller; and on Supabase the default privileges grant anon/authenticated writes on every new object, so GRANT SELECT does not make a view read-only. Reported from production, where DELETE on a public profile view wiped the users table. assumeDefaultWriteGrants: 'auto' looks for the evidence in your own migrations rather than assuming a platform.",
    "exposedRoles": ["anon", "authenticated"],
    "assumeDefaultWriteGrants": "auto",
    "allowlist": []
  },

  "routeOrgScoping": {
    "//": "Static. Authenticated route + filters by bare id + never mentions a tenant column. Bare-id patterns ship for Supabase, Prisma and Drizzle; raw SQL is left out of the default to avoid false positives on self-loads.",
    "routesDir": "src/app/api",
    "authSignals": ["withApiAuth", "requirePermission", "getUser("],
    "tenantSignals": ["organization_id", "organizationId", "tenant_id", "account_id"],
    "allowlist": ["src/app/api/health/route.ts"]
  },

  "rlsProof": {
    "//": "Runtime. The core proof: two tenants, your real app role, reads AND the full write path (UPDATE/DELETE, tenant-hop, INSERT, omitted-tenant). Runs only when the URL env var is set, so it stays skipped until you opt in.",
    "urlEnv": "TENANT_GUARD_DATABASE_URL",
    "role": "authenticated",
    "claim": "org_id",
    "//claim": "Supabase shortcut: builds the request.jwt.claims impersonation and sets role=authenticated, so CI never needs your JWT secret. Use `becomeTenant` instead if your policies resolve the tenant some other way — an explicit becomeTenant always wins over claim.",
    "//becomeTenant": ["select set_config('app.current_tenant', $1, true)"],
    "//seed": {
      "//": "Optional. Manufactures two synthetic tenants inside the rolled-back transaction, so the proof works on an EMPTY CI database and on policies that read a memberships table (a bare claim can't satisfy those). Each statement runs privileged, once per tenant, with $1 = the tenant id, in dependency order.",
      "setup": [
        "insert into organizations (id) values ($1)",
        "insert into memberships (user_id, organization_id) values (gen_random_uuid(), $1)",
        "insert into invoices (organization_id, amount) values ($1, 100)"
      ]
    },
    "tenantColumns": ["organization_id", "tenant_id"],
    "grandfather": ["shared_lookup"]
  },

  "rlsDrift": {
    "//": "Runtime. Policies/RLS that exist in the database but that no migration declares — the hand-edit in the dashboard that never reached a pull request.",
    "schemas": ["public"],
    "allowlist": ["public.some_supabase_managed_table"]
  },

  "anonReads": {
    "//": "Runtime. The CVE-2025-48757 class: can the public anon key SELECT tenant data with no login? Covers tables, views and materialized views. Probed as anon, so a safe `TO public USING (auth.uid() = ...)` policy is proven safe rather than false-flagged.",
    "role": "anon",
    "schemas": ["public"],
    "allowlist": ["public.published_posts"]
  },

  "anonWrites": {
    "//": "Runtime. The cache-poisoning class: a table with no tenant column that anon can write, so anyone can rewrite what every user reads.",
    "role": "anon",
    "schemas": ["public"],
    "allowlist": []
  },

  "mfaEnforcement": {
    "//": "Runtime, catalog-only. Is your second factor actually a factor? PostgREST honours whatever JWT the client presents, so the only thing that can refuse a single-factor token at the data layer is a policy checking the assurance level — and an aal2 check written PERMISSIVELY enforces NOTHING, because Postgres ORs permissive policies and ANDs restrictive ones. Verified: with a permissive gate an aal1 session reads every row; with AS RESTRICTIVE it reads none. Also notes when factors are enrolled but no policy checks aal at all (MFA gates the login screen, not the data), and when enforcement covers only some tenant tables.",
    "schemas": ["public"],
    "allowlist": []
  },

  "identityTrust": {
    "//": "Runtime. Can the caller FORGE the identity your policies authorize from? Flags user_metadata used for authorization, a callable SECURITY DEFINER that sets the tenant GUC from an argument, a membership table the caller can write, and \u2014 the one most apps have \u2014 an admin flag a user can set on their OWN row. RLS is ROW-level: a correct self-update policy pins which ROWS you may touch and says nothing about which COLUMNS, so `update profiles set is_admin = true where id = me` succeeds. Only a column-level GRANT stops it, and this recognises one. Inherits role/becomeTenant/claim from rlsProof.",
    "schemas": ["public"],
    "//authorizationColumns": "Column names that decide access in YOUR app, if they are not in the default list (role, roles, user_role, is_admin, is_superadmin, is_staff, is_owner, admin, permissions, scopes, access_level, plan, tier). A column no policy reads is reported as a note \u2014 the database does not treat it as a boundary, but the name says you do.",
    "authorizationColumns": ["role", "is_admin", "plan"],
    "allowlist": []
  },

  "viewIsolation": {
    "//": "Runtime. A view runs with its OWNER's rights unless security_invoker is set, and RLS never applies to a materialized view at all — so a perfectly-RLS'd table can still be handed out wholesale by the view beside it. Inherits identity from rlsProof.",
    "schemas": ["public"],
    "allowlist": ["public.admin_reporting_view"]
  },

  "storageIsolation": {
    "//": "Runtime, Supabase only (skips cleanly otherwise). Storage keys tenancy off the object PATH (org_A/invoices/q1.pdf) and the CLIENT chooses that path on upload. Also flags public buckets holding more than one tenant's objects, since those are served with no auth and no RLS at all.",
    "pathSegment": 1,
    "allowlist": ["brand-assets"]
  },

  "schemaTenancy": {
    "//": "Runtime. The OTHER multi-tenant architecture: one schema per tenant, where the boundary is GRANTs and nothing else (search_path is not a control — anyone can write tenant_b.docs directly). Proves how many tenant schemas one role can actually READ. Tenant schemas are inferred by shape; set schemaPattern for an irregular layout. Skips cleanly on a column-tenancy database.",
    "allowlist": []
  },

  "createGrants": {
    "//": "Catalog-only, nothing executed. Threat-model 7.3 — CREATE on a schema is not a tenant leak by itself, it is the PRECONDITION that turns an unpinned SECURITY DEFINER search_path into privilege escalation: you can only shadow an object if you can create one (CVE-2018-1058's shape, and why Postgres 15 stopped granting CREATE on public to PUBLIC). Fails on PUBLIC and on unauthenticated roles; the app role is a note, because running migrations as it is legitimate and SQL cannot tell. Deliberately does NOT re-report the exploitable combination — definer-rpc owns that from the function side.",
    "schemas": ["public"],
    "allowlist": []
  },

  "crossTenantFk": {
    "//": "Runtime. Threat-model 3.11 — the only guard where one tenant DESTROYS another tenant's data rather than reading it. Referential-integrity checks always bypass row security (they must, or a constraint could be defeated by hiding a row), so a foreign key carrying an id but not the tenant lets tenant A point their row at tenant B's, and ON DELETE CASCADE then means B deleting their own row deletes A's. Flags rows that ALREADY cross tenants (conclusive, no probe) and proves reachability with a re-point probe in a rolled-back transaction. Skips when every FK already carries the tenant, since the bad row is then unrepresentable. Identity is inherited from rlsProof.",
    "schemas": ["public"],
    "allowlist": []
  },

  "defaultPrivileges": {
    "//": "Runtime. Threat-model 7.2 — the only guard about the database as it WILL be. ALTER DEFAULT PRIVILEGES grants on every table created AFTER it, and Postgres never enables RLS by default, so a green run says nothing about the table somebody adds next week: it arrives already granted, with no policy, and no migration diff shows a security change. Proves it by creating a table inside a rolled-back transaction and reading what it inherited. Fails only on PUBLIC by default — granting to anon/authenticated is the stock Supabase shape, so it is reported as a note; add a role to failRoles[] to block the build on it.",
    "schemas": ["public"],
    "failRoles": ["PUBLIC"],
    "allowlist": []
  },

  "poolerBleed": {
    "//": "Runtime + source. Threat-model 6.1, and the only guard that must read BOTH halves of the repo. A policy keyed on current_setting('app.tenant') is only as good as the SCOPE it is set with: set_config(guc, v, false) and a bare SET last for the whole CONNECTION, so on a pooled connection the next request inherits the previous tenant and the policy hands over their rows working exactly as designed. Every single-request test passes. Skips cleanly when no policy uses a custom GUC (the usual Supabase auth.jwt() case).",
    "schemas": ["public"],
    "sourceDirs": ["src", "app", "lib", "server", "api", "db"],
    "allowlist": []
  },

  "shadowTables": {
    "//": "Runtime. Follows triggers on tenant tables to the tables they WRITE into. An audit log or outbox with no tenant column and no RLS holds every tenant activity record, and the tenant-column guards walk past it. Reads the function body, because plpgsql records no catalog dependency for what it writes.",
    "schemas": ["public"],
    "allowlist": []
  },

  "roleCapabilities": {
    "//": "Catalog-only, nothing executed. Capabilities that defeat RLS outright (dblink opens a NEW connection as whatever role its string names; pg_read_file never touches the policy layer) and direct grants on the auth schema. Outbound HTTP (pg_net, http) is surfaced as a NOTE: real, but exfiltration rather than a cross-tenant read.",
    "allowlist": []
  },

  "definerRpc": {
    "//": "Runtime. A SECURITY DEFINER function runs as its OWNER and bypasses RLS on everything it touches, and PostgREST exposes it at /rest/v1/rpc/<name>. Flags ones that don't re-filter by tenant, or that trust a tenant id the caller passes in. Only STABLE/IMMUTABLE functions are ever CALLED — Postgres enforces that those cannot write; VOLATILE ones are reported from their body as explicitly unproven. Inherits identity from rlsProof.",
    "schemas": ["public"],
    "allowlist": []
  },

  "realtimeIsolation": {
    "//": "Runtime, Supabase only (skips cleanly otherwise). Broadcast/Presence authorize channels through RLS on realtime.messages, and the tenant lives in the TOPIC (org_A:notifications). Joining a channel is a write, so an unpinned INSERT policy lets anyone publish into another tenant's channel.",
    "topicSeparator": ":",
    "allowlist": []
  },

  "constraintOracles": {
    "//": "Catalog-only, no probing. RLS hides rows, not constraints: a globally UNIQUE natural key on a tenant table lets anyone test whether a value exists in another tenant. Allowlist keys that are global by design (a public slug, a billing id).",
    "schemas": ["public"],
    "allowlist": ["public.orgs"]
  }
}
