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:
kami
2026-07-28 23:15:00 +04:00
parent b57894b183
commit 0b4b52ac45
4 changed files with 711 additions and 28 deletions
+102 -3
View File
@@ -3,9 +3,15 @@
package authz
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
type Surface string
@@ -66,10 +72,86 @@ func AuthorizeEvent(s Surface, typ string) error {
return nil
}
// SessionCookie carries a browser's proof of the Web-surface token. A
// top-level document load cannot set an Authorization header, so a
// bearer-only gate forces operators to run the UI with no token at all
// (AUDIT.md B18). The cookie is the browser-presentable equivalent; it is
// never a second credential, only a receipt for the same token.
const SessionCookie = "orchestra_session"
// SessionPath is the one Web-surface endpoint exempt from the token gate,
// because it *is* the token check: it verifies the Web token itself and
// exchanges it for a cookie. Gating it would make login unreachable.
const SessionPath = "/v1/ui/session"
// Sessions issues and validates those receipts. Values are random and stored
// hashed, so a leaked snapshot of this map does not yield a usable cookie.
type Sessions struct {
mu sync.Mutex
TTL time.Duration
ids map[string]time.Time
}
func (s *Sessions) ttl() time.Duration {
if s.TTL > 0 {
return s.TTL
}
return 12 * time.Hour
}
// Issue mints a session value. The caller must have already verified the
// Web-surface token; Issue does not check credentials itself.
func (s *Sessions) Issue() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
v := hex.EncodeToString(b)
sum := sha256.Sum256([]byte(v))
s.mu.Lock()
defer s.mu.Unlock()
if s.ids == nil {
s.ids = map[string]time.Time{}
}
now := time.Now()
for k, exp := range s.ids {
if now.After(exp) {
delete(s.ids, k)
}
}
s.ids[hex.EncodeToString(sum[:])] = now.Add(s.ttl())
return v, nil
}
func (s *Sessions) Valid(v string) bool {
if v == "" {
return false
}
sum := sha256.Sum256([]byte(v))
s.mu.Lock()
defer s.mu.Unlock()
exp, ok := s.ids[hex.EncodeToString(sum[:])]
if !ok {
return false
}
if time.Now().After(exp) {
delete(s.ids, hex.EncodeToString(sum[:]))
return false
}
return true
}
// HTTP enforces the same policy at the bus boundary. Authentication is
// optional for local development; when a token is supplied, control surfaces
// must present it as a Bearer token.
func HTTP(tokens map[Surface]string, next http.Handler) http.Handler {
return HTTPWithSessions(tokens, nil, next)
}
// HTTPWithSessions additionally accepts a valid session cookie in place of a
// Bearer token, but only for the Web surface — every non-browser surface
// still has to present the token directly.
func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s := ParseSurface(r.Header.Get("X-Orchestra-Surface"))
if s == "" {
@@ -85,9 +167,26 @@ func HTTP(tokens map[Surface]string, next http.Handler) http.Handler {
if s == System {
s = Web
}
if expected := tokens[s]; expected != "" && r.Header.Get("Authorization") != "Bearer "+expected {
http.Error(w, "unauthorized surface", http.StatusUnauthorized)
return
if expected := tokens[s]; expected != "" && subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+expected)) != 1 {
ok := false
if s == Web && sessions != nil {
if c, err := r.Cookie(SessionCookie); err == nil {
ok = sessions.Valid(c.Value)
}
// The login endpoint authenticates itself, and the SPA shell
// must load before a browser can present anything. Static
// assets are not secrets; every /v1/ control path stays gated.
if r.URL.Path == SessionPath {
ok = true
}
if !strings.HasPrefix(r.URL.Path, "/v1/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
ok = true
}
}
if !ok {
http.Error(w, "unauthorized surface", http.StatusUnauthorized)
return
}
}
if (s == Telegram || s == Ntfy) && r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "notify-only surface", http.StatusForbidden)
+64
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestSurfaceCapabilities(t *testing.T) {
@@ -46,3 +47,66 @@ func TestSystemSurfaceDowngradedByHTTPMiddleware(t *testing.T) {
t.Fatalf("unexpected status %d", rec.Code)
}
}
// B18: the web UI is a full control plane. A session cookie must be an
// alternative *presentation* of the Web token, never a widening of it.
func TestWebSessionCookieGatesControlPathsOnly(t *testing.T) {
tokens := map[Surface]string{Web: "secret"}
sessions := &Sessions{}
h := HTTPWithSessions(tokens, sessions, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
do := func(method, path string, c *http.Cookie, surface string) int {
req := httptest.NewRequest(method, path, nil)
if c != nil {
req.AddCookie(c)
}
if surface != "" {
req.Header.Set("X-Orchestra-Surface", surface)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
if got := do(http.MethodPost, "/v1/ui/tasks", nil, ""); got != http.StatusUnauthorized {
t.Fatalf("uncredentialed control path = %d, want 401", got)
}
// The SPA shell and the login endpoint must stay reachable, or no
// browser could ever obtain a cookie in the first place.
if got := do(http.MethodGet, "/index.html", nil, ""); got != http.StatusOK {
t.Fatalf("SPA shell = %d, want 200", got)
}
if got := do(http.MethodPost, SessionPath, nil, ""); got != http.StatusOK {
t.Fatalf("login endpoint = %d, want 200", got)
}
v, err := sessions.Issue()
if err != nil {
t.Fatal(err)
}
if got := do(http.MethodPost, "/v1/ui/tasks", &http.Cookie{Name: SessionCookie, Value: v}, ""); got != http.StatusOK {
t.Fatalf("session-cookie control path = %d, want 200", got)
}
if got := do(http.MethodPost, "/v1/ui/tasks", &http.Cookie{Name: SessionCookie, Value: "forged"}, ""); got != http.StatusUnauthorized {
t.Fatalf("forged cookie = %d, want 401", got)
}
// A cookie must not authenticate a non-browser surface.
tokens[TUI] = "tui-secret"
if got := do(http.MethodPost, "/v1/tasks", &http.Cookie{Name: SessionCookie, Value: v}, "tui"); got != http.StatusUnauthorized {
t.Fatalf("cookie on TUI surface = %d, want 401", got)
}
}
func TestSessionExpires(t *testing.T) {
s := &Sessions{TTL: time.Millisecond}
v, err := s.Issue()
if err != nil {
t.Fatal(err)
}
time.Sleep(5 * time.Millisecond)
if s.Valid(v) {
t.Fatal("expired session accepted")
}
if s.Valid("") {
t.Fatal("empty session accepted")
}
}