CORS
cors.go never set Access-Control-Allow-Credentials, so the
cookie-authenticated API was unreadable from any cross-origin frontend:
the server answered correctly and the browser blocked the page from
reading it. Set for allowlisted origins on both the preflight and the
actual response. Three tests added.
HTTP_COOKIE_SAMESITE (lax|none|strict, default lax) is new. CORS is only
half of what a cross-origin browser call needs; SameSite is judged on
registrable domain, so a frontend on an unrelated domain gets perfect CORS
headers and still no cookie. "none" is the only value that survives that,
and validate() refuses it without the Secure flag.
The "*" rejection now explains itself: browsers refuse Allow-Origin "*"
together with credentials, so it would break every authenticated call
rather than loosen anything.
Transactional endpoints (api-contract.md 12.1)
POST /api/v1/job-applications/{id}/hire
POST /api/v1/job-postings/{id}/assignments
Replaces two client-side loops that wrote several records with no
transaction and no rollback. Each is now one endpoint and one transaction,
built over repo.Repo so org scoping, derived columns, type casts and error
translation are not re-derived. Authorization reuses the existing policy
table rather than adding a parallel one: a workflow is exactly as
privileged as the writes it performs. 13 tests, including both rollback
paths.
Bug fix in the repository layer
repo.bindValue handled int64/int/float64/string but not int32, which is
what pgx returns for a PostgreSQL `int` column. Nothing previously read a
record and wrote one of its fields elsewhere, so it never surfaced; the
hire flow does exactly that and failed with "ai_score must be a number".
Both KindInt and KindFloat now accept the widths pgx actually produces.
Deployment
infrastructure/Dockerfile.api multi-stage, cross-compiling (BUILDPLATFORM
+ GOARCH) so linux/amd64 builds from arm64 are compiled rather than
emulated. Alpine runtime, non-root uid 10001, 22.1 MB. Ships api, seed,
setpassword and migrate, plus the migrations, so a Kubernetes
initContainer can apply the schema from the same image and tag as the
API. HEALTHCHECK keys on status code, not body, so a "degraded" instance
is not pulled from rotation during a migration window.
infrastructure/docker-compose.yml migrations run to completion before the
API starts. Assumes a managed PostgreSQL; the local-db overlay adds one
with TLS enabled so APP_ENV=production is met rather than dodged.
scripts/drop_public_tables.go the one-off used to clear an unrelated
schema from krowdb on 2026-08-24, kept for the record. Build-tagged
ignore and gated on CONFIRM_DROP=yes.
Verified against PostgreSQL: 16/16 new tests pass, and the image was built,
run and exercised end to end (login, CORS preflight, authenticated reads,
transaction rollback).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmQiGq73Uyfq7J4yR8Vxxw
154 lines
5.1 KiB
Go
154 lines
5.1 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
|
"github.com/krow/krow-backend/go-api/internal/service"
|
|
)
|
|
|
|
// The multi-record endpoints from api-contract.md §12.1.
|
|
//
|
|
// These are the first routes that are not a plain CRUD projection of a table,
|
|
// and they are shaped as verbs on the record they act on — `.../{id}/hire`,
|
|
// `.../{id}/assignments` — rather than as new collections. The action is the
|
|
// thing being requested, and it has no independent existence to GET.
|
|
//
|
|
// AUTHORIZATION REUSES THE POLICY TABLE RATHER THAN ADDING TO IT.
|
|
//
|
|
// A workflow is exactly as privileged as the writes it performs, so each one
|
|
// is gated on the operations it will actually carry out — hire needs UPDATE on
|
|
// job-applications and CREATE on staff; assign needs CREATE on assignments and
|
|
// UPDATE on job-applications. Inventing a separate `hire` permission would
|
|
// create a second place where the answer to "who may do this" lives, and the
|
|
// two would eventually disagree. Every pair below resolves to `operators`
|
|
// today, which is the intended answer: a talent user cannot hire themselves or
|
|
// place themselves on a shift.
|
|
|
|
// requirement is one (resource, operation) pair a workflow depends on.
|
|
type requirement struct {
|
|
path string
|
|
op domain.Op
|
|
}
|
|
|
|
// authorizeAll refuses unless the caller may perform every listed operation.
|
|
//
|
|
// All-or-nothing, checked before any transaction opens: a caller who may update
|
|
// an application but not create staff must not get halfway through a hire and
|
|
// be rolled back. The refusal is the same 403 a single-operation handler gives,
|
|
// and names no resource — see domain.Forbidden.
|
|
func (s *Server) authorizeAll(w http.ResponseWriter, r *http.Request,
|
|
reqs ...requirement) (authctx.Identity, bool) {
|
|
|
|
ident, err := authctx.MustFrom(r.Context())
|
|
if err != nil {
|
|
// Unreachable: the middleware refuses an unauthenticated request before
|
|
// the router sees it. A missing identity here is a wiring bug.
|
|
writeError(w, s.log, domain.Internal(err))
|
|
return authctx.Identity{}, false
|
|
}
|
|
|
|
role, known := domain.ParseRole(ident.Role)
|
|
if !known {
|
|
s.log.Warn("workflow refused: unknown role",
|
|
"user_id", ident.UserID, "role", ident.Role, "path", r.URL.Path)
|
|
writeError(w, s.log, domain.Forbidden())
|
|
return authctx.Identity{}, false
|
|
}
|
|
|
|
for _, req := range reqs {
|
|
svc, ok := s.api.Get(req.path)
|
|
if !ok {
|
|
writeError(w, s.log, domain.Internal(
|
|
errUnregisteredResource(req.path)))
|
|
return authctx.Identity{}, false
|
|
}
|
|
if !svc.Resource().Policy.Allows(req.op, role) {
|
|
s.log.Warn("workflow authorization refused",
|
|
"user_id", ident.UserID, "role", ident.Role,
|
|
"required_resource", req.path, "path", r.URL.Path)
|
|
writeError(w, s.log, domain.Forbidden())
|
|
return authctx.Identity{}, false
|
|
}
|
|
}
|
|
return ident, true
|
|
}
|
|
|
|
type unregisteredResourceError string
|
|
|
|
func (e unregisteredResourceError) Error() string {
|
|
return "httpserver: workflow depends on unregistered resource " + string(e)
|
|
}
|
|
|
|
func errUnregisteredResource(path string) error { return unregisteredResourceError(path) }
|
|
|
|
func (s *Server) routeWorkflows(mux *http.ServeMux) int {
|
|
mux.HandleFunc("POST /api/v1/job-applications/{id}/hire", s.handleHire)
|
|
mux.HandleFunc("POST /api/v1/job-postings/{id}/assignments", s.handleAssign)
|
|
return 2
|
|
}
|
|
|
|
// handleHire moves an application to `hired` and creates the staff record in
|
|
// one transaction. Replaces the two-call sequence at krowHooks.js:302-303.
|
|
func (s *Server) handleHire(w http.ResponseWriter, r *http.Request) {
|
|
ident, ok := s.authorizeAll(w, r,
|
|
requirement{"job-applications", domain.OpUpdate},
|
|
requirement{"staff", domain.OpCreate},
|
|
requirement{"user-activity", domain.OpCreate},
|
|
)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
body, err := decodeBody(r)
|
|
if err != nil {
|
|
writeError(w, s.log, err)
|
|
return
|
|
}
|
|
|
|
result, err := s.workflows.Hire(r.Context(), ident, r.PathValue("id"), body)
|
|
if err != nil {
|
|
writeError(w, s.log, err)
|
|
return
|
|
}
|
|
|
|
s.log.Info("candidate hired", "user_id", ident.UserID,
|
|
"application_id", r.PathValue("id"), "staff_id", result.Staff["id"])
|
|
|
|
// 201: the request created a staff record. The application it also updated
|
|
// is returned alongside so the caller can render the new state without a
|
|
// second read.
|
|
writeJSON(w, http.StatusCreated, envelope{Data: result})
|
|
}
|
|
|
|
// handleAssign places workers on a posting in one transaction. Replaces the 3n
|
|
// sequential round-trips at krowHooks.js:421/449/466.
|
|
func (s *Server) handleAssign(w http.ResponseWriter, r *http.Request) {
|
|
ident, ok := s.authorizeAll(w, r,
|
|
requirement{"assignments", domain.OpCreate},
|
|
requirement{"job-applications", domain.OpUpdate},
|
|
requirement{"user-activity", domain.OpCreate},
|
|
)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req service.AssignRequest
|
|
if err := decodeInto(r, &req); err != nil {
|
|
writeError(w, s.log, err)
|
|
return
|
|
}
|
|
|
|
result, err := s.workflows.Assign(r.Context(), ident, r.PathValue("id"), req)
|
|
if err != nil {
|
|
writeError(w, s.log, err)
|
|
return
|
|
}
|
|
|
|
s.log.Info("workers assigned", "user_id", ident.UserID,
|
|
"job_posting_id", r.PathValue("id"), "count", result.Count)
|
|
|
|
writeJSON(w, http.StatusCreated, envelope{Data: result})
|
|
}
|