Require a token for the web UI and reconcile AUDIT.md
The UI is a full control plane: it can create tasks, release or complete them, and inject approval keystrokes into live panes. authz.HTTP did cover it (an absent surface header defaults to Web), but the gate is opt-in and the deployed env sets no tokens while binding all interfaces, so in practice it was reachable unauthenticated from the LAN. Setting the token alone did not work either: a browser cannot put a bearer token on a document load, so the UI would 401 on index.html. - ORCHESTRA_WEB_TOKEN is now mandatory; startup fails rather than silently serving an open control plane. - authz.Sessions issues random values stored SHA-256-hashed, with a TTL, so a leaked snapshot yields nothing usable. - POST /v1/ui/session verifies the token in constant time and returns it as an HttpOnly, SameSite=Strict, Secure cookie. This is a presentable form of the same credential, not a new authority. - HTTPWithSessions accepts that cookie in place of the bearer token, and only for the Web surface. The login endpoint and non-/v1/ GETs (the SPA shell) are exempt by necessity; every /v1/ control path stays gated. Note this is a breaking config change: .orchestra-config/orchestra.env sets no tokens, so the service will not start until it does, and setting a Web token newly gates the other /v1/ surfaces that default to Web. AUDIT.md is reconciled against the code rather than against itself. B14, B15 and B16 are closed with their evidence; B17 is closed on the worker path only; the stale claim that B13 was open is corrected. Adds the previously undocumented command channel and web UI, and files what that implementation pass surfaced: federated approvals emit no event (B19), the local capture revision is a timestamp rather than a change counter and can silently defeat approvals (B20), the command queue never prunes (B21), the ntfy token serves two unrelated purposes (S12), and a dead copy of the authorization policy sits in main.go (S13). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01535A3Y8RtkAi8wYuWhtkEd
This commit is contained in:
+143
-3
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -20,6 +21,8 @@ import (
|
||||
"orchestra/internal/registry"
|
||||
"orchestra/internal/router"
|
||||
"orchestra/internal/store"
|
||||
"orchestra/internal/ui"
|
||||
"orchestra/internal/webui"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -198,6 +201,58 @@ func main() {
|
||||
}
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
// B18: the UI is a full control plane — it can create tasks, release or
|
||||
// complete them, and inject approval keystrokes into live panes. Refuse
|
||||
// to serve it unauthenticated rather than silently exposing that on
|
||||
// whatever interface the listener binds to.
|
||||
webToken := os.Getenv("ORCHESTRA_WEB_TOKEN")
|
||||
if webToken == "" {
|
||||
log.Fatal("ORCHESTRA_WEB_TOKEN must be set: it gates the web UI's task, lifecycle and approval controls")
|
||||
}
|
||||
sessions := &authz.Sessions{}
|
||||
// A browser cannot put a Bearer token on a document load, so it trades
|
||||
// the token once for an HttpOnly cookie. Same credential, presentable
|
||||
// form; no new authority is created here.
|
||||
mux.HandleFunc("/v1/ui/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body)
|
||||
supplied := body.Token
|
||||
if supplied == "" {
|
||||
supplied = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(supplied), []byte(webToken)) != 1 {
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
v, err := sessions.Issue()
|
||||
if err != nil {
|
||||
http.Error(w, "session unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: authz.SessionCookie, Value: v, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteStrictMode,
|
||||
Secure: os.Getenv("ORCHESTRA_UI_INSECURE_COOKIE") == "",
|
||||
MaxAge: int((12 * time.Hour).Seconds()),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
// Browser-specific endpoints intentionally present a joined read model;
|
||||
// raw lifecycle endpoints below remain stable for workers and harnesses.
|
||||
mux.Handle("/v1/ui/", ui.Server{Store: s, Workers: workers, Coordinator: coordinator, Route: func(e domain.Event) error {
|
||||
if rt == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := rt.HandleEvent(e)
|
||||
return err
|
||||
}}.Handler())
|
||||
mux.Handle("/", webui.Handler())
|
||||
workers.OnOffline = func(w federation.Worker) {
|
||||
for _, t := range s.Tasks() {
|
||||
if t.State == domain.StateLeased && t.Lease != nil && t.Lease.HarnessID == w.ID {
|
||||
@@ -831,6 +886,68 @@ func main() {
|
||||
}
|
||||
return wid, nil
|
||||
}
|
||||
mux.HandleFunc("/v1/federation/commands", func(w http.ResponseWriter, r *http.Request) {
|
||||
wid, err := workerAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
out, err := workers.Commands(wid)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(out)
|
||||
})
|
||||
mux.HandleFunc("/v1/federation/commands/", func(w http.ResponseWriter, r *http.Request) {
|
||||
wid, err := workerAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.NewDecoder(r.Body).Decode(&body) != nil || (body.Status != "acknowledged" && body.Status != "stale" && body.Status != "rejected") {
|
||||
http.Error(w, "invalid command result", 400)
|
||||
return
|
||||
}
|
||||
commandID := strings.TrimPrefix(r.URL.Path, "/v1/federation/commands/")
|
||||
command, ok := workers.Command(wid, commandID)
|
||||
if !ok {
|
||||
http.Error(w, "command not found", 404)
|
||||
return
|
||||
}
|
||||
if err := workers.CompleteCommand(wid, commandID, body.Status, body.Message); err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
// Audit lifecycle evidence only after the worker reports the herdr
|
||||
// input was acknowledged; a queued browser click is never an approval.
|
||||
if body.Status == "acknowledged" {
|
||||
if t, ok := s.Task(command.TaskID); ok {
|
||||
typ := "ApprovalGranted"
|
||||
if command.Kind == "deny_approval" {
|
||||
typ = "ApprovalDenied"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"subject_ref": command.ID, "pane_id": command.PaneID, "capture_revision": command.CaptureRevision})
|
||||
e := domain.Event{ID: id(), Type: typ, TaskID: command.TaskID, Version: t.Version + 1, Payload: payload, Surface: string(authz.System)}
|
||||
if err := s.Append(e); err != nil {
|
||||
log.Printf("record approval %s: %v", command.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("/v1/federation/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
wid, err := workerAuth(r)
|
||||
if err != nil {
|
||||
@@ -873,7 +990,7 @@ func main() {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("/v1/federation/workers/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || (!strings.HasSuffix(r.URL.Path, "/heartbeat") && !strings.HasSuffix(r.URL.Path, "/handoff") && !strings.HasSuffix(r.URL.Path, "/complete")) {
|
||||
if r.Method != http.MethodPost || (!strings.HasSuffix(r.URL.Path, "/heartbeat") && !strings.HasSuffix(r.URL.Path, "/handoff") && !strings.HasSuffix(r.URL.Path, "/complete") && !strings.HasSuffix(r.URL.Path, "/captures")) {
|
||||
http.Error(w, "not found", 404)
|
||||
return
|
||||
}
|
||||
@@ -882,10 +999,15 @@ func main() {
|
||||
http.Error(w, "not found", 404)
|
||||
return
|
||||
}
|
||||
if _, err := workerAuth(r); err != nil {
|
||||
wid, err := workerAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 401)
|
||||
return
|
||||
}
|
||||
if wid != parts[3] {
|
||||
http.Error(w, "worker identity mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/heartbeat") {
|
||||
if err := workers.Heartbeat(parts[3]); err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
@@ -894,6 +1016,24 @@ func main() {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/captures") {
|
||||
var c federation.Capture
|
||||
if json.NewDecoder(r.Body).Decode(&c) != nil {
|
||||
http.Error(w, "invalid capture", 400)
|
||||
return
|
||||
}
|
||||
if t, ok := s.Task(c.TaskID); !ok || t.Lease == nil || t.Lease.HarnessID != parts[3] {
|
||||
http.Error(w, "lease not owned", 409)
|
||||
return
|
||||
}
|
||||
out, err := workers.PutCapture(parts[3], c)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(out)
|
||||
return
|
||||
}
|
||||
var b struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TTLSeconds int `json:"ttl_seconds"`
|
||||
@@ -1067,7 +1207,7 @@ func main() {
|
||||
authz.MCP: os.Getenv("ORCHESTRA_MCP_TOKEN"), authz.Maven: os.Getenv("ORCHESTRA_MAVEN_TOKEN"),
|
||||
authz.Telegram: os.Getenv("ORCHESTRA_TELEGRAM_TOKEN"), authz.Ntfy: os.Getenv("ORCHESTRA_NTFY_TOKEN"),
|
||||
}
|
||||
log.Fatal(http.ListenAndServe(":"+port, authz.HTTP(tokens, mux)))
|
||||
log.Fatal(http.ListenAndServe(":"+port, authz.HTTPWithSessions(tokens, sessions, mux)))
|
||||
}
|
||||
func auth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user