Skip to content

Work out which tables need RLS policies, and what they'll cost #832

Open

Description

@bencap

Summary

Before writing policies (#809), we need the exact list of tables that can reveal private data, and confirmation the policies are fast enough at production size. This is an investigation. Its output is a comment on this issue: each table with a ruling (own rule, delegates to a parent, carries the key, carve-out, or public reference data) and the reason, plus production row counts and query plans.

Terms are defined in the glossary on #808. Work against the schema 2026.3.0 ships, which includes RT and 754.

How to decide whether a table needs a policy

A table needs a policy if any query could return its rows without also joining to the parent that decides visibility. For each table reachable by foreign key from scoresets, experiments or collections, check the real queries in routers/ and lib/, not just the schema.

A table that today is only reached through a join still gets listed. It's one new route away from leaking.

Don't find tables by searching for private or created_by_id. That only finds the ones we already know about.

What to produce

1. The guarded list

Every table with its ruling and the query that justifies it. These need an explicit ruling beyond the foreign-key walk:

  • scoreset_publication_identifiers, experiment_publication_identifiers, experiment_controlled_keywords, experiment_keywords, scoreset_keywords
  • score_calibration_functional_classification_variants, calibration_controls
  • mapped_variants_clinical_controls, gnomad_variants_mapped_variants, variant_annotation_status
  • From RT: alleles, mapping_records, mapping_record_alleles, annotation_event, clinvar_allele_links, gnomad_allele_links, vep_allele_consequences, clinvar_controls, variant_translations
  • gnomad_variants
  • The job tables: job_runs, pipelines, job_dependencies. Their routes are admin-only today, and job_params can name a private score set.
  • mondo_terms and urn_redirects. A redirect row can reveal that a temporary URN exists.

alleles get a policy: a row is visible only when the caller can see at least one mapping_record_alleles row linking it. An allele exists only because some score set's mapping produced it, so one produced only by a private score set discloses that someone measured it. The policy is an EXISTS over mapping_record_alleles, whose own policy applies inside the subquery. Retired links count, since they still record why the allele exists.

The reference rows fetched for alleles follow the same rule. gnomad_variants are fetched by our alleles' CAIDs, clinvar_controls rows exist only for ClinVar records that matched one of our alleles, and vep_allele_consequences and variant_translations are stored per allele. A gnomAD ID or ClinVar record names the variant outright, so each of these tables delegates to alleles, through its link table where it has one.

Measure the policy chain on large scans that touch many alleles, such as score-set variant listings.

mapped_variants stays without RLS until #747 drops it. Record it as a temporary carve-out, with the routes that read it.

2. The carve-outs

Confirm these are the only tables left without a policy: users, contributors, scoreset_contributors, experiment_contributors, experiment_set_contributors, users_roles, roles, collection_user_associations, access_keys. Record what each exposes to a direct query as mavedb_api.

3. Views and materialized views

  • Plain views: list each with its security_invoker setting. Every view needs WITH (security_invoker = true), or the api reads it with the owner's rights and sees every row. v_variant_annotations and RT's v_current_annotation_events are the known ones.
  • Materialized views: RLS never applies to them. List each with its columns. One may only carry data whose visibility depends on publication; with Enforce publication invariants with database constraints #893's constraint, published means public.

4. Row counts

SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC;

A table that reaches scoresets only through a large table has to carry scoreset_id itself. We expect that to be mapping_records and mapping_record_alleles (#833). Until #747's backfill, estimate mapping_records from mapped_variants' count.

5. Query plans at production size

Run the delegation policy at production row counts, using #809's policy shape. Confirm the plan builds the visible score set ids once and probes them per row, and that the contributor lookup (caller's ORCID from users, through contributors to scoreset_contributors) runs once per query.

Acceptance criteria

  • The guarded list is posted here, each table with its ruling and justifying query.
  • Tables safe only because no route reads them directly are listed as standing risks.
  • Carve-outs are confirmed against the policy bodies, with what each exposes.
  • Every view is listed with its security_invoker setting, and every materialized view is checked against the publication rule.
  • Production row counts are posted, and the carry-scoreset_id list comes from them.
  • Plans at production size are attached, including the contributor lookup.
  • Any foreign key Reconcile schema drift between prod, migrations, and models #829 adds or removes is rechecked against the list.
Baseline measurements to repeat at production size

PG 15.17, 2,000 score sets (10% private), 400k variants, policies written with app_user_id() and app_roles():

Query With policy Without
count(*) over 400k variants ~21 ms ~9.5 ms
One score set's variants (200 rows) 0.7 ms —

The plan to reproduce, with the visible score set ids hashed once and probed per row:

Parallel Seq Scan on public.variants
  Filter: (hashed SubPlan 2)
    Seq Scan on public.scoresets (actual rows=1806)
      Filter: ((NOT private) OR (created_by_id = (NULLIF(current_setting('app.user_id', true), ''))::integer) OR ... OR (hashed SubPlan 1))
        Index Scan using users_pkey on users u
        Index Scan using contributors_orcid_id_key on contributors c
        Index Scan using scoreset_contributors_contributor_id_idx on scoreset_contributors sc

The shape to confirm is absent, delegating through a large table:

Bitmap Index Scan on mv_ca_idx (actual rows=16)
Seq Scan on v (actual rows=360000)      ← hash built regardless
Execution Time: 647 ms

Activity

  1. added theissue type on Aug 7, 2026
  2. added this to the R2026.4 milestone on Sep 24, 2026
  3. changed the title [-]Step 0: enumerate the guarded set by FK reachability and measure delegation cost[/-] [+]Work out which tables need RLS policies, and what the policies will cost[/+] on Sep 25, 2026
  4. changed the title [-]Work out which tables need RLS policies, and what the policies will cost[/-] [+]Work out which tables need RLS policies, and what they'll cost[/+] on Sep 28, 2026
  5. changed the issue type fromtoon Sep 28, 2026
  6. bencap commented on Oct 21, 2026

    @bencap
    CollaboratorAuthor

    Findings

    Every table in the 2026.3.0 schema now has a ruling, and the policy shapes were measured at production size. Four results change #809:

    • IN (SELECT id FROM …) is only safe on variants. Below it, in the mapping and allele chain, it produces plans that run past a two-minute timeout. A correlated EXISTS there keeps current-data reads within a small multiple of the unpoliced time.
    • At reverse-translation fan-out, the allele check is the remaining cost: 3–10× on per-score-set reads. Alleles are shared across score sets, so they can't carry an owner key. Decision 1 adds a write-once alleles.first_published_at fast path.
    • Publication identifiers need natural-key citations. Under a policy, the api can't create or deduplicate them through find-or-create. The other identifier tables fold into the rows that use them. Each change has its own sub-issue.
    • The api gets no grant on the tables it never reads, so those need no policy. That includes the two largest tables.

    Measured on PG 15 against a local restore of the 2026.3.0 release dump, which has 3,336 score sets and 11.0M variants. Three score sets were reverse-translated locally for the fan-out numbers. Policies were created in transactions that were rolled back, and timed for an anonymous caller and for a signed-in user who owns and contributes to private score sets.

    1. Rulings

    Guarded tables fall into three classes, and three more groups sit outside RLS.

    Class Tables Policy
    Owners scoresets, experiments, experiment_sets, collections, score_calibrations their own rules, as in #809
    Owned: carry score_set_id variants scoreset_id IN (SELECT id FROM scoresets)
    mapping_records, mapping_record_alleles, projection_groups EXISTS (SELECT 1 FROM scoresets s WHERE s.id = <t>.score_set_id)
    target_genes scoreset_id IN (SELECT id FROM scoresets)
    Owned: delegate to one parent target_gene_external_identifiers, which replaces the three offset tables (decision 2) → target_genes
    score_calibration_functional_classifications, score_calibration_publication_identifiers, calibration_controls → score_calibrations
    score_calibration_functional_classification_variants (1.4M rows) → score_calibration_functional_classifications (1.5k rows, so no carried key is needed)
    the score set, experiment and experiment set link tables: publication, DOI, SRA, keywords, experiment_controlled_keywords → their parent
    scoreset_meta_analysis_sources, collection_score_sets, collection_experiments both sides visible
    urn_redirects the record new_urn names. It's read anonymously before auth, and rows are written only at publish
    pipelines reads: created_by_user_id = app_user_id() OR app_roles() && '{admin}'. Owner routes keep INSERT and DELETE
    job_runs, job_dependencies → pipelines. A run with no pipeline, an ad hoc run, is admin-only: 7,240 of 15,603 runs locally
    Shared: visible if a visible record links to it alleles first_published_at IS NOT NULL OR EXISTS (SELECT 1 FROM mapping_record_alleles m WHERE m.allele_id = alleles.id). Retired links count
    vep_allele_consequences, clinvar_allele_links, gnomad_allele_links EXISTS (SELECT 1 FROM alleles a WHERE a.id = <t>.allele_id)
    clinvar_controls, gnomad_variants EXISTS over their link table
    variant_translations EXISTS on alleles.clingen_allele_id for both sides
    publication_identifiers EXISTS over its four citation tables, keyed by (db_name, identifier)
    target_sequences EXISTS over target_genes.target_sequence_id
    Outside RLS Tables
    No api grant (worker and scripts only) annotation_event, variant_annotation_status, v_current_annotation_events, target_gene_mappings, mapped_variants and its two link tables until #747 drops them, the legacy keywords tables, and six unmodelled tables that #829 drops: mavedb_ldh_records, mavedb_ldh_records_audit, variants_to_sync, vep_functional_consequence_archive, mavedb_staging_mapped_clinvar_allele_ids_1, mavedb_staging_mapped_variants_clinical_controls
    Public reference data licenses, controlled_keywords, acmg_classifications, mondo_terms, genome_identifiers, taxonomies
    Carve-outs users (its sensitive column, email, is returned only to the user and to admins); contributors and the three *_contributors tables, users_roles, roles, collection_user_associations (all read by the parent policies); access_keys (looked up before the caller is known)
    Folded away doi_identifiers, sra_identifiers, ensembl_identifiers, refseq_identifiers, uniprot_identifiers, and the three offset tables (decision 2)

    Three routes filter in Python after an unfiltered query:

    • /clingen-alleles/{caid}/measurements
    • find_variants_by_vrs_identifier
    • the variant CSV's cross-score-set widening

    They're correct today, and the alleles policy is what protects them in SQL.

    2. Views

    3. Cost

    Two plan problems rule out #809's IN shape below variants:

    • Through alleles, IN can't be hashed. The 887k visible ids don't fit in work_mem, so Postgres scans a materialized list for every row.
    • On mapping_records and mapping_record_alleles, the planner misprices lookups. It charges the hashed subplan's startup cost to every index lookup, so it scans and hashes the whole 8.5M-row table instead.

    EXISTS against scoresets is a primary-key lookup, and the planner prices it correctly.

    Times in ms, warm; "none" is without policies.

    Query none #809 as written recommended shape
    count(*) variants 526–860 713–856 776–932
    one private score set's variants and alleles (7.9k) 29–175 3,865–4,274 82–166
    one allele with gnomAD and ClinVar 4–9 >120,000 3–6
    v_variant_annotations for one score set 120–282 841–13,945 58–97
    after RT: every allele per variant, plus VEP (23.7k / 45.7k variants) 345 / 316 — 923–1,033 / 1,697–1,713
    after RT: ClinVar controls for the score set 271–292 — 2,616–3,508
    after RT: v_variant_annotations, all columns 667–1,120 — 3,921–4,969
    after RT: count(*) alleles 203 — 5,220–6,256

    4. Decisions

    1. Allele visibility: alleles.first_published_at. A database trigger sets it when a link is written for a published score set, and at publish for the alleles already linked. The policy's EXISTS then runs only for alleles with no public link, about 15% of them.

    • Why it's acceptable. The rule exists so MaveDB can't be used to see what people are working on now. Once a record linking an allele is published, the allele's presence no longer says that, so the column only ever goes from null to set.
    • The guards:
    • Rejected:
      • policing only the link, which would reveal alleles only private score sets produced;
      • staging private alleles until publish, which is too large a change;
      • keeping the plain EXISTS, which costs 3–10× and grows with history.

    2. Identifier tables.

    • Publications are a shared entity that the product renders, so they stay shared. Citations reference them by (db_name, identifier). The api records a citation with INSERT … ON CONFLICT DO NOTHING, with no conflict target and no RETURNING, then the citation row, and never reads the publication row: Reference publications by their identifier so citations can be written under RLS #945.

    • DOI, SRA, Ensembl, RefSeq and UniProt identifiers are record attributes: every other column is a constant, always null, or derived from the identifier.

      • DOI and SRA strings move onto their link rows.
      • The three gene offset tables merge into one, target_gene_external_identifiers (target_gene_id, db_name, identifier, offset), which matches the single external_identifiers list the api already returns.

      That's Store DOI, raw read and target gene identifiers on the rows that use them #944.

    • What I verified on PG 15 for a caller who can't see the existing row:

      • a natural-key foreign key to a hidden row works;
      • an untargeted ON CONFLICT DO NOTHING is a silent no-op;
      • ON CONFLICT (col) and INSERT … RETURNING both fail the read policy.

      Those last two failures are why the current find-or-create can't work under a policy, even for new rows.

    3. No api grant on tables the api never reads (agreed, for #827). A later route that needs one adds the grant and a policy then.

    4. Rules for worker code, for #809's rules section. The worker sees every row, which is safe only while its output reaches people through policied tables:

    • Don't copy data from a guarded row into a less-guarded place: shared rows, materialized views, job metadata, logs or alerts.
    • A job that returns a result to a user re-checks that user's visibility.
    • The worker's database login stays out of the api process.

    5. Follow-ups

    Recommended policy text, as measured
    CREATE POLICY variants_rule ON variants FOR ALL USING (scoreset_id IN (SELECT id FROM scoresets));
    CREATE POLICY mapping_records_rule ON mapping_records FOR ALL
      USING (EXISTS (SELECT 1 FROM scoresets s WHERE s.id = mapping_records.score_set_id));
    CREATE POLICY mra_rule ON mapping_record_alleles FOR ALL
      USING (EXISTS (SELECT 1 FROM scoresets s WHERE s.id = mapping_record_alleles.score_set_id));
    CREATE POLICY alleles_rule ON alleles FOR ALL
      USING (EXISTS (SELECT 1 FROM mapping_record_alleles m WHERE m.allele_id = alleles.id));
    CREATE POLICY vep_rule ON vep_allele_consequences FOR ALL
      USING (EXISTS (SELECT 1 FROM alleles a WHERE a.id = vep_allele_consequences.allele_id));
    CREATE POLICY cal_rule ON clinvar_allele_links FOR ALL
      USING (EXISTS (SELECT 1 FROM alleles a WHERE a.id = clinvar_allele_links.allele_id));
    CREATE POLICY gal_rule ON gnomad_allele_links FOR ALL
      USING (EXISTS (SELECT 1 FROM alleles a WHERE a.id = gnomad_allele_links.allele_id));
    CREATE POLICY cc_rule ON clinvar_controls FOR ALL
      USING (EXISTS (SELECT 1 FROM clinvar_allele_links l WHERE l.clinvar_control_id = clinvar_controls.id));
    CREATE POLICY gv_rule ON gnomad_variants FOR ALL
      USING (EXISTS (SELECT 1 FROM gnomad_allele_links l WHERE l.gnomad_variant_id = gnomad_variants.id));
    CREATE POLICY vt_rule ON variant_translations FOR ALL USING (
      EXISTS (SELECT 1 FROM alleles a WHERE a.clingen_allele_id = variant_translations.aa_clingen_id)
      AND EXISTS (SELECT 1 FROM alleles a WHERE a.clingen_allele_id = variant_translations.nt_clingen_id));

    scoresets_rule is #809's text unchanged. alleles_rule gains the first_published_at IS NOT NULL OR prefix once decision 1 lands.

    Row counts (n_live_tup)
    variant_annotation_status                            25,315,790
    annotation_event                                     18,536,299
    published_variants_materialized_view                 12,643,192
    variants                                             11,040,361
    mapping_record_alleles                                8,521,465
    mapped_variants                                       8,007,750
    mapping_records                                       7,336,247
    projection_groups                                     3,424,977
    alleles                                               1,741,755
    vep_functional_consequence_archive                    1,466,832
    score_calibration_functional_classification_variants  1,442,481
    mapped_variants_clinical_controls                     1,276,776
    clinvar_allele_links                                    385,475
    mavedb_ldh_records / _audit                             363,460
    mavedb_staging_mapped_clinvar_allele_ids_1              354,922
    clinvar_controls                                        329,556
    variant_translations                                    324,565
    variants_to_sync                                        106,356
    vep_allele_consequences                                  64,734
    mavedb_staging_mapped_variants_clinical_controls         63,910
    gnomad_variants_mapped_variants                          20,730
    job_runs                                                 15,599
    gnomad_variants                                          12,713
    gnomad_allele_links                                       9,755
    job_dependencies                                          6,547
    (every other table is under 4,000)
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    app: backendTask implementation touches the backend

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions