Skip to content

Return 403 access denied for auth failures on public projects - #868

Merged
epompeii merged 6 commits into
develfrom
claude/public-project-auth-errors-ChpPo
May 24, 2026
Merged

epompeii merged 6 commits into
develfrom
claude/public-project-auth-errors-ChpPo

Conversation

@epompeii

@epompeii epompeii commented May 23, 2026 •

Copy link
Copy Markdown
Member

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 — added project_auth_error(is_public, value, error) helper next to resource_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 — hoisted from_resource_id out 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) so query_project.is_public() is in scope when constructing the error. No extra DB query.
  • Covers all 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 — info hiding still applies.

Scenarios fixed

  1. Authenticated user without Edit/Delete role tries to modify a public project → now 403 with "access denied" (was misleading 404 "may be private").
  2. Request authenticates with a project API key scoped to project A but targets public project B → now 403 with "access denied" (was misleading 404 on reads, misleading 401 on writes).

Out of scope

Child-resource 404s (Benchmark, Branch, etc.) inherit the same misleading "may be private" wording. Fixing them would require plumbing &QueryProject into ~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 for project_auth_error (forbidden for public, not-found with info-hiding for private)
  • cargo test -p api_projects --test projects --features plus — 20/20, including new non_member_patch_public_project_returns_403 (JWT/role path) and non_member_patch_private_project_returns_404 (info hiding preserved)
  • cargo test -p api_projects --test project_key_auth --features plus — 29/29, including new project_key_for_other_project_on_public_target_returns_403 (renamed) and project_key_for_other_project_on_private_target_returns_404
  • cargo clippy --no-deps --all-targets --all-features -- -Dwarnings clean
  • cargo check --no-default-features clean
  • cargo fmt
  • CI green

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.
@github-actions

github-actions Bot commented May 23, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

PR: #868
Base: devel
Head: claude/public-project-auth-errors-ChpPo
Commit: bedccffab815e3d26777a91b1fc8dbd3555558a6


Now I have a thorough understanding of the changes. Here's my review:


PR Review: Project Auth Error Handling Refactor

Summary

This PR introduces a ProjectAuthState enum to systematically map (visibility x authentication) combinations to the correct HTTP status codes, replacing scattered ad-hoc error construction. It also adds thorough integration test coverage for all four auth/visibility combinations across multiple endpoints.

Findings

Positive

  1. Strong security improvement. The ProjectAuthState enum makes the info-hiding invariant (private projects return 404, not 403/401) structurally enforced rather than convention-based. This is a significant defense against project-existence enumeration.

  2. Good refactoring to &self methods. is_allowed_public, is_allowed_actor_inner no longer redundantly re-query the project from the DB — the from_resource_id call is done once in the public-facing method, then the inner method operates on &self. This is cleaner and avoids a potential TOCTOU-style double-fetch.

  3. Excellent test coverage. The new tests cover all four quadrants (public/private x anon/authed) for reads (branches), writes (testbed create), and project patch, plus the cross-project-key scenarios. The #[cfg(feature = "plus")] gating on private-project tests is correct since visibility requires the plus feature.

  4. is_allowed_actor_auth consolidation. The old code re-entered is_allowed() for the PublicUser::Auth arm, which did another from_resource_id query. The new code avoids this redundant DB call and centralizes rate limiting at the bottom.

Issues

1. Comment on is_allowed_public (minor, style)

lib/bencher_schema/src/model/project/mod.rs:443 — The comment // Private project + anonymous: outer caller wraps to info-hiding 404 is accurate but the CLAUDE.md says to default to no comments. This one is borderline; the comment documents a non-obvious security invariant (the unauthorized_error here does not surface as a 401 to the user). I'd keep it, but wanted to flag the tension.

2. ProjectAuthState doc comments vs. CLAUDE.md guidance (minor, style)

lib/bencher_schema/src/error.rs:216-261 — The doc comments on ProjectAuthState and its variants are well-written and justify themselves: they document a security invariant (the "why"). This is appropriate per CLAUDE.md guidelines.

3. Test comments in testbeds.rs (minor, style)

lib/api_projects/tests/testbeds.rs:572-574,595-597,630,647 — Several tests have multi-line comments explaining what code path they exercise (e.g., "Exercises is_allowed_actor_auth anonymous + public-project path"). These are useful for understanding coverage intent but are more descriptive than the typical project style. Consider whether the test names alone (which are already very descriptive, e.g. anonymous_create_testbed_on_public_project_returns_401) are sufficient.

4. Potential information leak in PublicAuthenticated error message (low risk)

lib/bencher_schema/src/error.rs:253-255 — The PublicAuthenticated arm includes "This {resource} is public but you do not have the required permission." This explicitly tells an attacker the project is public. This is fine for public projects (visibility is public information by definition), but worth noting that this message should never be reachable for private projects — and the enum structure guarantees it isn't.

5. resource_not_found_error is no longer used for the ProjectAuthState::auth_error import (nit)

lib/bencher_schema/src/model/project/mod.rs:19-22 — The old import included resource_not_found_error directly. It's now only used transitively through ProjectAuthState::auth_error. The import was correctly removed from the use statement.

Verified

  • ProjectKey actors always return is_auth() == true, so a project key targeting a private project it doesn't belong to correctly gets the PrivateAuthenticated → 404 path.
  • is_allowed_actor_auth for anonymous users early-returns with auth_error() before reaching try_allowed, preventing an anonymous user from falling through to an RBAC check that would panic or produce wrong errors.
  • Rate limiting (project_request) is still applied in all write paths — the consolidation at the bottom of is_allowed_actor_auth covers both Auth and ProjectKey arms.

Verdict

Clean, well-tested security hardening. The ProjectAuthState enum is a good pattern that prevents an entire class of information-disclosure bugs. No blocking issues found — the style nits on test comments are optional.


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`).
@github-actions

github-actions Bot commented May 23, 2026 •

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchclaude/public-project-auth-errors-ChpPo
Testbedintel-v1
Click to view all benchmark results
BenchmarkLatencyBenchmark 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%)
🐰 View full continuous benchmarking report in Bencher

claude added 4 commits May 23, 2026 23:02
- 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.
@epompeii
epompeii marked this pull request as ready for review May 24, 2026 16:27
@epompeii
epompeii merged commit e32ae21 into devel May 24, 2026
50 checks passed
@epompeii
epompeii deleted the claude/public-project-auth-errors-ChpPo branch May 24, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants