Files
orca/internal/daemon/jobs_handler.go
T
Jon Chery 5232fcb808 fix(P04): wire ACL enforcement + WebAuthn reg auth + audit actor (REQ-153)
R-023: Zero-trust enforcement operationally wired.

ACL enforcement (C-45 staged rollout):
- acl.Check wired into all 5 daemon handlers (dispatch/jobs/nodes/tasks)
- health endpoints exempt (liveness probes not gated)
- ACL log-only mode default (config acl.enforce=false); enforce after
  bootstrap ACL verified
- sshpush auth: ORCA_OIDC_TOKEN validated against JWKS before apply
- txn apply: Authorize hook validates OIDC token before running pull
- acl.json mode 0600 (was 0644)
- flock on acl.json for concurrent grant/revoke
- bootstrap ACL: init grants cluster-admin to orca-admins group + SVID

Audit actor identity:
- currentActor reads OIDC sub from credentials.json (was hardcoded "cli")
- threaded through all audit.Record calls via context

WebAuthn registration auth:
- BeginRegistration/FinishRegistration require authenticated session
- fail-closed 401 when no authFunc configured

New files: internal/daemon/acl.go, internal/cli/authactor.go,
internal/engine/actor.go, internal/identity/authtoken.go,
internal/sshpush/auth.go, internal/txn/auth_test.go

---ci---
project: orca
phase: 4
milestone: v0.13
status: complete
requirements:
  covered: [153]
---/ci---
2026-08-07 20:33:39 +00:00

135 lines
3.8 KiB
Go

package daemon
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"git.cloudinit.dev/coreci/orca/internal/acl"
"git.cloudinit.dev/coreci/orca/internal/model"
"git.cloudinit.dev/coreci/orca/internal/store"
)
// handleJobsCollection handles /v1/jobs.
// - GET → list all jobs
// - POST → not yet supported (job submission is CLI-only in v0.1)
func (s *Server) handleJobsCollection(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// P04 ACL enforcement (C-44). Jobs are cluster-wide in v0.1, so
// the namespace is the default namespace. GET = read, POST = write.
ns := "_defaults"
switch r.Method {
case http.MethodGet:
if s.acl != nil {
if id, ok := s.acl.Check(r, ns, acl.PermRead); !ok {
deny(w, id, ns, acl.PermRead)
return
}
}
jobs, err := store.NewJobRepo(s.db).List(ctx)
if err != nil {
s.log.Error("list jobs",
slog.String("component", "daemon"),
slog.String("error", err.Error()))
writeError(w, http.StatusInternalServerError, "failed to list jobs")
return
}
if jobs == nil {
jobs = []*model.Job{}
}
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs, "count": len(jobs)})
case http.MethodPost:
if s.acl != nil {
if id, ok := s.acl.Check(r, ns, acl.PermWrite); !ok {
deny(w, id, ns, acl.PermWrite)
return
}
}
// Job submission via HTTP is intentionally not exposed in v0.1.
// The CLI submits jobs to the local store directly; the daemon
// exists for observability and lifecycle control.
writeError(w, http.StatusNotImplemented, "job submission via API is not supported in v0.1; use 'orca job run'")
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// handleJobsItem handles /v1/jobs/{id} and /v1/jobs/{id}/tasks.
// - GET /v1/jobs/{id} → job details
// - GET /v1/jobs/{id}/tasks → tasks for a job
func (s *Server) handleJobsItem(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// P04 ACL enforcement (C-44). Job detail + tasks list are reads.
ns := "_defaults"
if s.acl != nil {
if id, ok := s.acl.Check(r, ns, acl.PermRead); !ok {
deny(w, id, ns, acl.PermRead)
return
}
}
// Path is /v1/jobs/{id} or /v1/jobs/{id}/tasks
path := strings.TrimPrefix(r.URL.Path, "/v1/jobs/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
writeError(w, http.StatusBadRequest, "job id required")
return
}
id := parts[0]
if err := validateID(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// /v1/jobs/{id}/tasks
if len(parts) == 2 && parts[1] == "tasks" {
tasks, err := store.NewTaskRepo(s.db).ListByJob(ctx, id)
if err != nil {
s.log.Error("list tasks for job",
slog.String("component", "daemon"),
slog.String("job_id", id),
slog.String("error", err.Error()))
writeError(w, http.StatusInternalServerError, "failed to list tasks")
return
}
if tasks == nil {
tasks = []*model.Task{}
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "count": len(tasks), "job_id": id})
return
}
// /v1/jobs/{id} (with no further path)
if len(parts) != 1 {
writeError(w, http.StatusNotFound, "not found")
return
}
job, err := store.NewJobRepo(s.db).Get(ctx, id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "job not found")
return
}
s.log.Error("get job",
slog.String("component", "daemon"),
slog.String("job_id", id),
slog.String("error", err.Error()))
writeError(w, http.StatusInternalServerError, "failed to get job")
return
}
writeJSON(w, http.StatusOK, job)
}