fix(mcp): allow the configured first admin to bootstrap an empty allowlist #103

Merged
David merged 2 commits from fix/YT-46-admin-bootstrap-allowlist into main 2026-07-05 19:55:20 +02:00
Owner

What

Fixes YT-46: on a fresh yt mcp serve install the allowlist is empty, so every Hub login was denied with Access denied: not on the allowlist, including the login configured as YOUTRACK_MCP_ADMIN_HUB_LOGIN. The admin could not self-serve via /admin because the browser login flows through the same gate. The first-admin bootstrap the docs promise was unreachable.

Root cause

HubClient::complete_login denies before anything else when is_allowed is false, and is_allowed checked only the allowlist table. The admin flag was computed only after the gate, and nothing seeded the admin login into the allowlist at startup. Empty allowlist -> nobody admitted -> no way to add the first entry.

Fix

is_allowed now treats an identity whose login matches admin_hub_login as implicitly allowed, through a new shared is_bootstrap_admin predicate that complete_login also uses for the is_admin grant. The "implicitly allowed" set and the "granted admin" set are therefore one predicate and cannot diverge. Login-equality matching is unchanged from the prior is_admin logic, so a non-admin, non-allowlisted identity is still denied and an admin_hub_login set to a non-login value neither auto-allows nor auto-admins.

Invariant sweep (login admission + is_admin single-source)

Site Classification
oauth_hub.rs is_allowed (login gate) fixed: bootstrap admin implicitly allowed before the allowlist lookup
oauth_hub.rs complete_login is_admin fixed: uses shared is_bootstrap_admin
admin.rs:100 !user.is_admin (admin UI authz) N/A: reads the persisted flag complete_login now sets; downstream of the gate
oauth_as.rs:376 LoginOutcome::Denied N/A: renders the gate result; fix is upstream
oauth_as.rs is_allowed_redirect N/A: Claude redirect-URI allowlist, unrelated concept

Tests

  • New admin_bootstrap_allowed_on_empty_allowlist: admin logs in against an EMPTY allowlist, is granted admin, and re-login is idempotent (one user row).
  • Existing non_allowlisted_user_is_denied_with_no_row already covers non-admin denial on an empty allowlist; other allowlist tests unchanged.
  • Full just pre-commit green (fmt, clippy -D warnings, build, 83 tests).

Follow-ups

  • YT-47 (linked): add yt mcp allowlist add/list/remove so operators never need raw SQL against the SQLite allowlist table.
  • Docs reconciliation tracked in YT-45.
## What Fixes YT-46: on a fresh `yt mcp serve` install the allowlist is empty, so every Hub login was denied with `Access denied: not on the allowlist`, including the login configured as `YOUTRACK_MCP_ADMIN_HUB_LOGIN`. The admin could not self-serve via `/admin` because the browser login flows through the same gate. The first-admin bootstrap the docs promise was unreachable. ## Root cause `HubClient::complete_login` denies before anything else when `is_allowed` is false, and `is_allowed` checked only the `allowlist` table. The admin flag was computed only after the gate, and nothing seeded the admin login into the allowlist at startup. Empty allowlist -> nobody admitted -> no way to add the first entry. ## Fix `is_allowed` now treats an identity whose login matches `admin_hub_login` as implicitly allowed, through a new shared `is_bootstrap_admin` predicate that `complete_login` also uses for the `is_admin` grant. The "implicitly allowed" set and the "granted admin" set are therefore one predicate and cannot diverge. Login-equality matching is unchanged from the prior `is_admin` logic, so a non-admin, non-allowlisted identity is still denied and an `admin_hub_login` set to a non-login value neither auto-allows nor auto-admins. ## Invariant sweep (login admission + is_admin single-source) | Site | Classification | | --- | --- | | `oauth_hub.rs` `is_allowed` (login gate) | fixed: bootstrap admin implicitly allowed before the allowlist lookup | | `oauth_hub.rs` `complete_login` is_admin | fixed: uses shared `is_bootstrap_admin` | | `admin.rs:100` `!user.is_admin` (admin UI authz) | N/A: reads the persisted flag `complete_login` now sets; downstream of the gate | | `oauth_as.rs:376` `LoginOutcome::Denied` | N/A: renders the gate result; fix is upstream | | `oauth_as.rs` `is_allowed_redirect` | N/A: Claude redirect-URI allowlist, unrelated concept | ## Tests - New `admin_bootstrap_allowed_on_empty_allowlist`: admin logs in against an EMPTY allowlist, is granted admin, and re-login is idempotent (one user row). - Existing `non_allowlisted_user_is_denied_with_no_row` already covers non-admin denial on an empty allowlist; other allowlist tests unchanged. - Full `just pre-commit` green (fmt, clippy `-D warnings`, build, 83 tests). ## Follow-ups - YT-47 (linked): add `yt mcp allowlist add/list/remove` so operators never need raw SQL against the SQLite `allowlist` table. - Docs reconciliation tracked in YT-45.
fix(mcp): allow the configured first admin to bootstrap an empty allowlist
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 23s
b930ade397
The HTTP server denied every Hub login whose identity was not already on the allowlist, and computed the admin flag only after that gate. Nothing seeded the allowlist at startup, so a fresh install denied everyone including YOUTRACK_MCP_ADMIN_HUB_LOGIN, and the admin could not reach /admin to add themselves because the browser login flows through the same gate. Every login failed with "Access denied: not on the allowlist", contradicting the documented "granted admin on first login".

Treat an identity whose login matches admin_hub_login as implicitly allowed in is_allowed, via a shared is_bootstrap_admin predicate that complete_login also uses for the is_admin grant, so the "implicitly allowed" and "granted admin" sets cannot diverge. Login-equality matching is unchanged from the previous is_admin check; a non-admin, non-allowlisted identity is still denied.

#YT-46

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(mcp): reconcile the complete_login flow comment with the bootstrap gate
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 37s
Create release / Create release from merged PR (pull_request) Has been skipped
9d332c3b30
The module flow-surface bullet still said a non-allowlisted user is always denied; the first-admin bootstrap login is now implicitly allowed, so update the wording to match.

#YT-46

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
David merged commit 66fb343f63 into main 2026-07-05 19:55:20 +02:00
David deleted branch fix/YT-46-admin-bootstrap-allowlist 2026-07-05 19:55:20 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
pandoras-box/youtrack-cli!103
No description provided.