Skip to content

fix(sso): OIDC JIT provisioning granted Participant on every competition - #129

Merged
tbcsec merged 1 commit into
mainfrom
claude/fix-oidc-global-participant
Aug 1, 2026
Merged

fix(sso): OIDC JIT provisioning granted Participant on every competition#129
tbcsec merged 1 commit into
mainfrom
claude/fix-oidc-global-participant

Conversation

@tbcsec

@tbcsec tbcsec commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Scope note — read first

No tagged release is affected. OIDC (#58) landed in the v1.2.0 milestone, which is not yet tagged; git ls-tree v1.1.1 | grep oidc returns nothing. This is exploitable only on main/source builds, which is why it's an ordinary public PR rather than a security advisory.

The bug

JIT provisioning gave every SSO user the Participant role with competition_id=None. That's the site-wide shape — user_has_permission short-circuits on a NULL competition — so one OIDC login granted challenge_view, ticket_view and ticket_respond on every competition on the install, with no join and no invite code.

It compounded downstream:

  1. oidc_identity._assign_participant writes competition_id=None
  2. deps.user_has_permissionif assignment_competition_id is None: return True
  3. membership.has_global_role treats any unscoped row as administrator-equivalent
  4. competitions.list_competitions short-circuits its visibility filter on that
  5. CompetitionOut carries invite_code

Net effect: GET /api/competitions returned every private competition on the install, with its invite code, to anyone who could authenticate at the configured IdP.

The docstring claimed this mirrored public registration. It didn't — routers/auth.py:186 creates a registered user with no role assignment, and Participant is earned per-competition via ensure_participant_role on join. The admin API already refused this exact shape ("A competition-scoped role needs a competition"), and ARCHITECTURE.md §7.5 already documented the invariant. Nothing enforced it.

Fix — three layers

Layer Change
Stop creating it JIT provisioning grants no role assignment, matching local registration
Make it inert user_has_permission, effective_permissions and has_global_role require the role to be scope="global" before an unscoped assignment grants anything
Clean up Migration b3f7c21a9d04 deletes existing rows

Layer 2 is the important one: a malformed row now grants nothing instead of everything, however it gets written — including by a restored backup.

Verification

  • The new tests fail against the previous code. Stashed the fix and re-ran: all three fail, including assert not True where the old resolver granted challenge_view site-wide. The pre-existing test asserted the role name and passed the entire time the scope was wrong.
  • 571 backend tests pass with the fix applied.
  • Migration run against real Postgres 16 (not just SQLite — migrations bypass the suite per ADR-0006). Seeded all three row shapes: unscoped Participant deleted; global Administrator and competition-scoped Participant both preserved.

Operator impact

Installs running main with SSO enabled have these rows today; the migration removes them on upgrade. Affected users lose nothing in practice — they regain Participant per-competition on join, exactly as locally-registered users always have.

🤖 Generated with Claude Code

A just-in-time-provisioned SSO user was given the Participant role with
`competition_id=None`. That is the *site-wide* shape — `user_has_permission`
short-circuits on a NULL competition — so a single OIDC login granted
challenge_view, ticket_view and ticket_respond on every competition on the
install, with no join and no invite code.

It got worse downstream. `has_global_role` treated any unscoped assignment as
administrator-equivalent, which is what `_can_see` and `list_competitions` use
to decide private-competition visibility. `CompetitionOut` carries
`invite_code`, so `GET /api/competitions` returned every private competition on
the install together with its invite code.

The docstring claimed this mirrored public registration. It did not: local
registration creates a user with no role assignment at all, and earns
Participant per-competition via `ensure_participant_role` on join. The admin
API already refused to create this exact shape ("A competition-scoped role
needs a competition"), and ARCHITECTURE.md §7.5 already documented the
invariant — nothing enforced it.

Fixed in three layers, because one was clearly not enough:

- JIT provisioning grants no role assignment, matching local registration.
- Resolution requires the *role* to be `scope="global"` before an unscoped
  assignment grants anything, in `user_has_permission`,
  `effective_permissions` and `has_global_role`. A malformed row now grants
  nothing instead of everything, however it got written.
- A migration deletes existing rows. Verified against real Postgres: the
  unscoped Participant is removed, while a global Administrator and a
  competition-scoped Participant are both preserved.

The existing test asserted the role *name* and passed throughout. The new
tests assert the assignment set is empty and that a JIT user cannot see a
private competition; both fail against the previous code, as does the
resolver test.

Migrations aren't covered by the suite (ADR-0006), so the migration was run
against a throwaway Postgres 16 rather than SQLite only.

Co-Authored-By: Claude <noreply@anthropic.com>
@tbcsec
tbcsec merged commit b73ac51 into main Aug 1, 2026
4 checks passed
@tbcsec
tbcsec deleted the claude/fix-oidc-global-participant branch August 1, 2026 21:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant