Return 403 access denied for auth failures on public projects - #868
Conversation
Previously, any project auth failure (anonymous, missing role, or a project key scoped to a different project) responded with a generic 404 warning that the project "may be private and require authentication or it may not exist." That message exists so private projects do not leak their existence, but it is actively misleading for public projects whose existence is already obvious from the UI. Add `project_auth_error` next to `resource_not_found_error` and branch its message on visibility: public projects get a 403 with a clear "access denied" explanation; private/missing projects keep the existing info-hiding 404. Hoist `from_resource_id` into the public auth entry points so the loaded `QueryProject` (and thus `is_public()`) is in scope when building the error — no extra DB query. Covers the three project auth paths: `is_allowed` (JWT + permission), `is_allowed_actor_pub` (read endpoints), and the project-key branch of `is_allowed_actor_auth` (write endpoints). The anonymous-on-private path inside `is_allowed_public` is unchanged in outward behavior. Includes unit tests for the new helper and integration tests covering both the new 403 path and the preserved 404 info-hiding path for private projects, plus updates to the two existing tests whose 404 / 401 expectations now correctly observe the 403 access-denied response.
🤖 Claude Code ReviewPR: #868 Now I have a thorough understanding of the changes. Here's my review: PR Review: Project Auth Error Handling RefactorSummaryThis PR introduces a FindingsPositive
Issues1. Comment on
2.
3. Test comments in
4. Potential information leak in
5.
Verified
VerdictClean, well-tested security hardening. The Model: claude-opus-4-6 |
- Drop verbose narrative comments from test cases and the helper doc; CLAUDE.md prefers no comments when the identifier conveys intent. - Pass `query_project.uuid` into the inner `unauthorized_error` for the private-anonymous branch so the discarded inner error keeps the project identifier for logging (regression from prior commit that passed `BencherResource::Project`).
|
| Branch | claude/public-project-auth-errors-ChpPo |
| Testbed | intel-v1 |
Click to view all benchmark results
| Benchmark | Latency | Benchmark Result microseconds (µs) (Result Δ%) | Upper Boundary microseconds (µs) (Limit %) |
|---|---|---|---|
| Adapter::Json | 📈 view plot 🚷 view threshold | 4.62 µs(+1.12%)Baseline: 4.57 µs | 4.74 µs (97.49%) |
| Adapter::Magic (JSON) | 📈 view plot 🚷 view threshold | 4.48 µs(+0.77%)Baseline: 4.45 µs | 4.60 µs (97.45%) |
| Adapter::Magic (Rust) | 📈 view plot 🚷 view threshold | 25.66 µs(+1.04%)Baseline: 25.40 µs | 26.25 µs (97.77%) |
| Adapter::Rust | 📈 view plot 🚷 view threshold | 3.48 µs(+0.75%)Baseline: 3.46 µs | 3.55 µs (98.05%) |
| Adapter::RustBench | 📈 view plot 🚷 view threshold | 3.49 µs(+1.15%)Baseline: 3.46 µs | 3.55 µs (98.58%) |
- Close info-hiding gap in is_allowed_actor_auth's ProjectKey arm: for a private project + wrong key, return the standard project_auth_error (404 "may be private") instead of the raw 401 from verify_project. Without this fix a key holder could distinguish "private exists" from "doesn't exist" via 401 vs 404 and enumerate private slugs. - Inline is_allowed_inner — after the from_resource_id hoist it was a single delegation to try_allowed. - Reshape is_allowed_public and is_allowed_actor_inner as &self methods instead of taking a `query_project: &Self` parameter. - Lock in the new 404 wording for the private-write + wrong-key path with project_key_cannot_create_in_wrong_private_project_returns_404.
`is_allowed_actor_auth` was returning 401 "Authentication required" for anonymous requests before checking the project's visibility. Combined with the 404 returned by `from_resource_id` for nonexistent projects, this let an unauthenticated caller distinguish "private project exists" (401) from "no such project" (404) just by hitting a write endpoint. Restructure the method so `from_resource_id` runs first for every actor variant, then branch by visibility on the anonymous arm: - public project → honest 401 (auth required) - private project → 404 with the same "may be private" message used for nonexistent projects, hiding existence This also unifies the three actor arms around a single loaded `query_project`, matching the pattern already used in `is_allowed` and `is_allowed_actor_pub`. Add integration coverage in `testbeds.rs` for both anonymous-write paths (public → 401, private → 404).
Round out the actor matrix for `is_allowed_actor_auth`'s
`Public(Auth)` arm with two cases that were missing at the
child-resource level (existing PATCH non-member tests on
/v0/projects/{slug} go through BearerToken, not ApiActor):
- Non-member POST /testbeds on a public project → 403 "access denied"
- Non-member POST /testbeds on a private project → 404 info-hide
The free helper only knew the 403 vs 404 split; the new ApiActor::Public(PublicUser::Public(_)) write path also needs 401 "Authentication required" on public projects (anonymous + public) without leaking private-project existence (anonymous + private). Introduces a `ProjectAuthState` enum in `error.rs` covering the four visibility x authentication combinations (PublicAnonymous, PublicAuthenticated, PrivateAnonymous, PrivateAuthenticated). The `auth_error` method on the enum centralizes the 401/403/404 policy in one place, so callers cannot accidentally pair a visibility with the wrong status. Adds two helpers on `QueryProject` so call sites read the project's own visibility rather than spelling `query_project.is_public()` at each map_err: - `auth_state(&self, &ApiActor)` for the actor-aware paths - `auth_state_authenticated(&self)` for paths that have already required authentication (no ApiActor in scope) `is_allowed_actor_auth` now closes over a single `auth_error` closure shared by all three actor arms, replacing the previous inline if/else conditional in the anonymous arm.
Summary
Previously, any project auth failure (anonymous, missing role, or a project
key scoped to a different project) responded with a generic 404 warning that
the project "may be private and require authentication or it may not exist."
That message exists so private projects do not leak their existence, but it
is actively misleading for public projects whose existence is already obvious
from the UI.
This PR makes the API return a clear 403 Forbidden with an "access denied"
explanation when the target project is provably public, while preserving the
info-hiding 404 for private/missing projects.
Changes
lib/bencher_schema/src/error.rs— addedproject_auth_error(is_public, value, error)helper next toresource_not_found_error. Branches the response on visibility: 403 + "access denied" for public projects, existing 404 info-hiding wording otherwise.lib/bencher_schema/src/model/project/mod.rs— hoistedfrom_resource_idout of the three private auth helpers (is_allowed_inner,is_allowed_public,is_allowed_actor_inner) and into the public entry points (is_allowed,is_allowed_actor_pub,is_allowed_actor_auth) soquery_project.is_public()is in scope when constructing the error. No extra DB query.is_allowed(JWT + permission),is_allowed_actor_pub(read endpoints), and the project-key branch ofis_allowed_actor_auth(write endpoints).is_allowed_publicis unchanged in outward behavior — info hiding still applies.Scenarios fixed
Out of scope
Child-resource 404s (
Benchmark,Branch, etc.) inherit the same misleading "may be private" wording. Fixing them would require plumbing&QueryProjectinto ~30 call sites or replacing each with a hand-written visibility branch. Left as a follow-up.Test plan
cargo test -p bencher_schema --features plus --lib error::tests— new unit tests forproject_auth_error(forbidden for public, not-found with info-hiding for private)cargo test -p api_projects --test projects --features plus— 20/20, including newnon_member_patch_public_project_returns_403(JWT/role path) andnon_member_patch_private_project_returns_404(info hiding preserved)cargo test -p api_projects --test project_key_auth --features plus— 29/29, including newproject_key_for_other_project_on_public_target_returns_403(renamed) andproject_key_for_other_project_on_private_target_returns_404cargo clippy --no-deps --all-targets --all-features -- -Dwarningscleancargo check --no-default-featurescleancargo fmt