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) mux.HandleFunc("POST /api/v1/worker-profiles/with-role", s.handleCreateWorkerWithRole) return 3 } // handleCreateWorkerWithRole records a NEW person and their first declared role // in one transaction. // // Under `worker-profiles` rather than `employee-roles` because the worker is // what the request creates; the role comes with it. A more specific literal // than the generated `POST /api/v1/worker-profiles`, so the mux prefers it and // neither route shadows the other. // // This is a CREATION flow. It takes a name and an email and never a worker id, // and nothing in it searches for an existing person — an organization may // employ many people who share a name, so a name cannot select anybody. // Recording a second role for someone who already exists is // POST /api/v1/employee-roles, unchanged. func (s *Server) handleCreateWorkerWithRole(w http.ResponseWriter, r *http.Request) { ident, ok := s.authorizeAll(w, r, requirement{"worker-profiles", domain.OpCreate}, requirement{"employee-roles", domain.OpCreate}, ) if !ok { return } body, err := decodeBody(r) if err != nil { writeError(w, s.log, err) return } result, err := s.workflows.CreateWorkerWithRole(r.Context(), ident, body) if err != nil { writeError(w, s.log, err) return } /* The worker's identity is not logged: an email is the person, and §10 puts record content at DEBUG behind a per-tenant flag rather than at INFO. */ s.log.Info("worker recorded with a declared role", "user_id", ident.UserID, "worker_profile_id", result.Worker["id"], "employee_role_id", result.Role["id"]) writeJSON(w, http.StatusCreated, envelope{Data: result}) } // 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}, // The workflow may now FILE an application as well as patch one, for a // worker placed on a posting they never applied to. A write the handler // performs has to appear in the list it is authorized against, even // when — as here — the resulting permission set is unchanged. requirement{"job-applications", domain.OpCreate}, 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}) }