- Shipped
- September 3, 2026 at 10:01 PM UTC
- Author
- Kamo
- Commit
- 1e4eeac
Two controllers behind one feature, and the split is the point. /careers/** is HR's and requires MANAGE_JOB_POSTINGS; /careers/member/** is the employee's and requires VIEW_JOB_LISTINGS. CareersAccess admits an HR right holder to the member endpoints as well — somebody who can read every application can obviously read the adverts — and never the reverse. Every mapping is exactly ONE call to guarded(), which resolves the session (401) then applies the gate (403). An endpoint that authorizes on its own is the drift this shape exists to prevent, and it is not hypothetical: DocsService's ResourceServerConfig is anyRequest().permitAll() and OTKPreAuthFilter CONTINUES the chain when no OTK is present, so a controller that forgets to check is world reachable through APIService's /api/docs/** forward. All seven of the legal module's original endpoints shipped authorizing on org membership alone. No member endpoint takes a member id. The session decides whose applications come back, which makes the surface structurally incapable of returning somebody else's submission — the same construction the member Timecard tab uses. VALIDATION LIVES IN THE SERVICE, NOT THE FORM. A posting decides which questions it asks through twelve ask* settings and the form honours them, but the form is a convenience and this endpoint is directly reachable. So every REQUIRED answer is re-checked, and — the part that matters more — answers to questions the posting did NOT ask are DISCARDED rather than stored. Without that, a crafted request could file a salary expectation against a posting whose HR team deliberately chose not to ask for one, and it would sit in the review screen looking like something the candidate volunteered. Applying is an upsert, not an insert. The unique constraint on (posting, member) means one person's interest in one job is one row, so re-applying after a withdrawal revives that row rather than stacking a second one HR would have to reconcile. It comes back SUBMITTED and unread, because the answers are new. canApply is decided ONCE, in CareersMapper, and JobApplicationService refuses on the same three rules — held, closed, already applied. A button that is present always works and one that is absent always would not; a second copy of the rules on the client is how a UI ends up offering a button whose only outcome is a 409. Two DTOs per row rather than one with fields blanked. hrNotes is on the HR shape and simply does not exist on the applicant's, so the way to leak it is to add the field, not to forget a conditional. Same for an unpublished pay range: it never reaches the member DTO at all, rather than being blanked in the UI where it would still be on the wire. The resume streams through these endpoints rather than through the imaging surface. Imaging authorizes on document access levels; an application's attachment is governed by MANAGE_JOB_POSTINGS, and routing it here keeps one rule. The file is sniffed from its first bytes, never trusted from the multipart Content-Type. Postings and applications are counted in grouped queries, not one per row. The Applicants tab is the one surface guaranteed to render everything it loads, and a per-posting count loop is exactly the shape that made /leads slow. 45 tests. Writing them found a real defect: the URL check tested for "://", so javascript:alert(1) — which carries no slashes — fell through to the "assume https" branch and was stored as https://javascript:alert(1). It now refuses anything declaring a scheme that is not http or https, matched as an anchored RFC 3986 scheme so a colon in a path or a port is not mistaken for one.