Skip to content

Employers cannot read any candidate, and profile_visibility is enforced nowhere #13

Description

@os-sam

Found during PM review of #11 (card 07). Two defects in card 06's security surface, discovered together because the talent-pool view #11 delivers is the thing they break. Not fixed in #11src/security/ is card 06's surface, outside that card.

1. The employer-side grant on ats_candidate resolves to zero rows

ats_candidate is sharingModel: 'private' (src/objects/candidate.object.ts:15) and carries a user field but no organization field.

Both employer sets grant it as:

ats_candidate: { allowRead: true, readScope: 'org' },   // EmployerAdminSet:97, EmployerRecruiterSet

readScope: 'org' widens a private object from owner-only to "rows whose owning organization is in the reader's accessible_org_ids". A candidate row is owned by the job seeker and has no organization at all, so no employer org can ever match it. The only RLS rule anywhere on ats_candidate is seeker_own_profile (using: 'user == current_user.id', permission-sets.ts:212), which belongs to the seeker set.

Consequence: ats_candidate.talent_pool and ats_candidate.gallery — the two views #11 just added — render empty for every employer, as does any candidate lookup from the pipeline. The views themselves are correct; there is no readable row behind them.

Note the contrast with ats_job (public_read), where the employer grant deliberately carries no readScope and relies on the OWD — that one works.

2. profile_visibility is a declared consent control that nothing enforces

profile_visibility: Field.select({
  required: true,
  defaultValue: 'limited',
  description: 'How much of this profile employers may see before the candidate agrees.',
  options: [ public, limited, hidden ],
})

grep -rn profile_visibility src/security/ returns nothing. No permission set, RLS rule or FLS entry reads it. The field's own description states an access rule that no code implements.

Today this is masked by defect 1 — employers see no candidates at all, so nobody is over-exposed. Fixing defect 1 without fixing this one turns a broken view into a privacy hole, because limited is the default and hidden would still be fully readable. They must be fixed together, in that order, in one change.

Why a view filter is the wrong fix

The reviewing agent asked whether talent_pool should filter out profile_visibility == 'hidden'. It should not, and its own reasoning was right: a view filter is presentation, not security — any REST call bypasses it. A filter here would create the appearance of enforcement while leaving the API open, which is worse than the current honest emptiness. The enforcement belongs in the permission set.

Suggested shape

Not pinned — whoever takes this should propose and get it ruled on, because it decides what "limited" means as a contract:

  • Employers reach a candidate through the application, not through a global pool: an RLS rule on ats_candidate admitting rows that have an ats_application to one of the reader's jobs. ⛔ Cross-object traversal in RLS is a hard compile error (ADR-0055) — this needs a stamped scalar on ats_candidate, the way employer_org was stamped elsewhere, or the talent pool has to be re-thought.
  • public / limited / hidden then select which fields are readable, i.e. FLS, alongside the existing expected_salary_* seals — not whether the row exists.

Both halves touch DESIGN.md §03's isolation model, so this likely needs a needs-user-decision before implementation rather than an agent's judgement call.

Verification is currently blocked

Neither defect can be confirmed at runtime while #10 stands — no employer-side rows can be written at all. Confirmed by reading the tree, not by a live request.

Activity

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions