Developer report — user-facing docs live on the wiki and in the visual guide.
cafe-grader · authorization audit · normal mode

Who Can Submit?

The as-is submission-authorization matrix, measured by driving every user category through every entry point with a real HTTP integration probe — not read off the code. Contest mode is a later pass.

probe test/integration/authz_submit_probe_test.rb run 2026-08-22 · 2 tests green mode system.mode=standard, both values of use_problem_group

What was driven

Actors
admin pure — group membership removed editor mary, role=editor in group_a reporter reba, role=reporter in group_a member john, role=user in group_a no-group jack, in no group
Problems
A available · in enabled group · gp.enabled=true B available · in enabled group · gp.enabled=false C draft (available=false) · in group D available · in no group
Entry points
manage page GET /problems new page GET direct_edit_problem web submit POST /main/submit edit page GET /submissions/:id/edit API submit POST /api/v1/problems/:id/submissions

Success means a Submission row was actually created (web) or HTTP 201 (API) — not just a 200 page. On top of the fixture data, every contests_problems row was flipped to enabled=false before both runs; the matrices below were unchanged by it (F4).

Where the gates actually are

Web pagesNew / Edit buttons · manage “Submit”
login session can_view_problem (:report ∪ :submit) renders the code editor form posts to /main/submit
Web submitPOST /main/submit
login session problem ∈ :submit ∪ :edit model validation save + judge job
API submitPOST /api/v1/problems/:id/submissions
JWT bearer problem ∈ :submit only model validation save + judge job
Manage pageGET /problems (its Submit button → web lane)
login session admin ∨ editor of ≥1 group page only — grants no extra submit right

Struck-through = Submission#must_have_valid_problem, which is a silent no-op on this Rails version (F2). The teal controller checks are the only enforcement that exists. :submit / :edit / :report refer to User#problems_for_action; admins bypass every gate.

Matrix — group mode ON (use_problem_group=true)

Actor Problem Manage pgGET /problems New pgdirect_edit Web submitPOST /main/submit Edit pgown submission API submitPOST …/submissions
adminrole: admin A · available + in group ✓ 201
B · available, gp.enabled=false ✓ 201
C · draft, in group ✓ 201
D · available, no group ✓ 201
editormary · group_a A · available + in group ✓ 201
B · available, gp.enabled=false ✓ savesF1 ✗ 403F1
C · draft, in group ✓ savesF1 ✗ 403F1
D · available, no group ✗ 403
reporterreba · group_a A · available + in group ✓ 201
B · available, gp.enabled=false ✓ pageF3 F3 ✓ pageF3 ✗ 403
C · draft, in group ✗ 403
D · available, no group ✗ 403
memberjohn · group_a A · available + in group ✓ 201
B · available, gp.enabled=false ✗ 403
C · draft, in group ✗ 403
D · available, no group ✗ 403
no-groupjack A · available + in group ✗ 403
B · available, gp.enabled=false ✗ 403
C · draft, in group ✗ 403
D · available, no group ✗ 403
allowed (submission created / page rendered) denied (redirect + alert, or 403) ✓/✗ anomaly — see finding

Matrix — group mode OFF (use_problem_group=false)

Groups, membership roles, and both join-table enabled flags become completely inert. Problems A, B, D all behave identically as “an available problem.” Only two rules remain:

Actor Manage pg Any available problemall web + API routes Draft problemavailable=false
admin
editorstill an editor of group_a
reporter / member / no-group

The editor keeps the manage page even here — group_editor_authorization checks group membership rows directly and deliberately ignores use_problem_group. But their draft-submit privilege disappears: in this mode problems_for_action(:edit) is Problem.none for non-admins, so the :edit OR in the web submit has nothing to match.

Findings

STATUSResolved at rev 1996 (2026-08-22) — findings kept below as the audit record

The intended design was confirmed: the running web app is authoritative, and the editor test-submit (F1) is a feature. All layers now share one predicate, User#can_submit_to_problem?: the API honors the editor test-submit (F1), the model validation is resurrected on the shared gate with the binary-skip hole closed (F2), and the reporter's dead-end submit form is now a view-only page (F3). Two follow-up decisions also landed in rev 1996: a disabled membership row grants no role (previously a disabled editor kept problem-level powers and a disabled reporter kept sight — an axis this report did not originally drive), and viva start uses the same gate (viva authorization matches normal problems). Regression tests: test/models/submission_authorization_lock_test.rb, test/integration/submission_view_only_test.rb.

F1Editors can web-submit to hidden problems — but the API refuses the same action

main#submit accepts a problem in :submit or :edit, and the editor’s :edit scope ignores available, group.enabled, and groups_problems.enabled. So an editor can submit-and-save to a draft (C) or group-disabled (B) problem from the browser — plausibly intended, “editor tests their own draft.” The API checks only :submit and returns 403 for the identical request. One of the two is wrong; decide the intent, then align the other side.

main_controller.rb:57 (:submit ∪ :edit) · api/v1/submissions_controller.rb:53 (:submit only)

F2The model-level authorization validation is a silent no-op

Submission#must_have_valid_problem guards non-admin submits with errors[:base] << "Authorization error…". On Rails ≥ 6.1, errors[:base] returns a throwaway array — the shovel adds nothing. Verified directly: 0 errors after <<, 1 after errors.add. The matrix confirms it: the editor’s draft submit saved even though the problem is not in their :submit scope. Today every route happens to be covered by controller checks, so nothing exploitable surfaced — but the intended last line of defense is dead, and any future code path that saves a Submission directly gets no protection. The same method also returns early when source is nil, so binary submissions were always exempt. Note: fixing it naïvely to errors.add would break the F1 web path unless the check also accepts :edit.

# app/models/submission.rb:401 — never fires
errors[:base] << "Authorization error: you have no right to submit to this problem" \
  if (!self.user.problems_for_action(:submit).include?(self.problem)) and (self.new_record?)
submission.rb:392–403 · verified with ActiveModel::Errors in rails runner

F3Reporters see a live code editor for group-disabled problems, but every submit fails

The reporter scope deliberately ignores groups_problems.enabled (“a problem disabled within a group is a student-only hide; staff still report”). Since page access is gated on :report ∪ :submit, a reporter can open the New page and their own past submissions for problem B — the full editor UI with a Submit button — yet the POST always bounces (“not available for you”) and the API 403s. Consistent with the visibility intent, but the UI presents an action that can never succeed. Drafts (C) are different: the reporter scope requires available=true, so those are fully hidden.

problem.rb:115–128 (reporter scope) · user.rb:436 (can_view_problem?)

F4contests_problems.enabled is inert in normal mode — proven, not assumed

Both matrix runs executed with every contests_problems row flipped to enabled=false (fixtures place problems A and D inside contests). Every cell was identical to the earlier run with the flags on. The normal-mode branch of problems_for_action never consults contest tables; this flag belongs on the contest-mode matrix, in the later pass.

user.rb:110–129 (normal-mode branch) · probe setup: ContestProblem.update_all(enabled: false)

F5Role never matters for submitting — only membership does

group_submittable_by_user has no role condition: editor, reporter, and plain member all submit under exactly the same rule (enabled membership × available × group.enabled × gp.enabled). The reporter and editor roles only add powers on top; they never restrict.

problem.rb:92–100

The rules, compressed

admin
always, everywhere — every gate short-circuits on admin?
any group member
(user / reporter / editor)
user.enabled membership.enabled group.enabled groups_problems.enabled problem.available
editor extra
(web submit only)
role=editor in any group containing the problem — no flags consulted; API does not honor this (F1)
group mode OFF
user.enabled problem.available — membership, roles, and all join flags inert
model layer
enforces nothing — dead validation (F2); controllers are the only gates

Test coverage today

Already covered

  • test/models/user_authorization_test.rb — ~50 tests on problems_for_action across standard/group/contest modes, plus all the can_*? predicates
  • test/models/problem_scope_authorization_test.rb — editor-vs-reporter scope semantics (drafts, archived groups)
  • test/integration/authorization_test.rb — GET-page access: direct_edit in standard-no-group + contest modes, manage-page gating, submission viewing
  • API: outsider → group problem → 403 (authorization_spec.rb); token/lockdown sweep; one 201 happy path + 422 (submissions_spec.rb)
  • One happy-path admin web submit (main_controller_test.rb:32)

Gaps — nothing tests these

  • No denial test for POST /main/submit at all — the actual mutating endpoint; all existing web tests stop at the GET pages
  • The editor :edit-OR web path (F1) — the web/API asymmetry is invisible to the suite
  • Reporter/editor submitting as ordinary members (F5)
  • must_have_valid_problem — zero tests, which is why the dead validation (F2) went unnoticed
  • API group-mode-ON positives (member/reporter/editor → 201) and draft/group-disabled → 403
  • groups_problems.enabled=false at the HTTP level (member hidden, reporter page-not-submit)

Open decisions

  1. Pick the intended semantics for editor submits to hidden problems (F1): if “editor tests their own draft” is intended, add :edit to the API check; if not, drop it from main#submit.
  2. Resurrect the model validation (F2) with errors.add(:base, …), matching whichever semantics decision (1) lands on — and decide whether binary (source-less) submissions should keep their exemption.
  3. Promote the probe into permanent tests — at minimum: denial tests for POST /main/submit, the F1 pair (web ✓ / API behavior), and the F3 reporter row. The probe file already exercises all of them.
  4. Contest-mode pass — same driver, third test method; that is where contests_problems.enabled, contest windows, and per-user time offsets enter.