0b4b52ac45
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
113 lines
4.1 KiB
Go
113 lines
4.1 KiB
Go
package authz
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSurfaceCapabilities(t *testing.T) {
|
|
if Telegram.CanEmit("TaskCreated") || Ntfy.CanEmit("ApprovalRequested") {
|
|
t.Fatal("notify surface emitted an event")
|
|
}
|
|
if !TUI.CanEmit("TaskCreated") {
|
|
t.Fatal("control surface cannot emit")
|
|
}
|
|
if !MCP.CanEmit("ApprovalRequested") || MCP.CanEmit("TaskCreated") {
|
|
t.Fatal("mcp gate is wrong")
|
|
}
|
|
}
|
|
|
|
// TestSystemSurfaceDowngradedByHTTPMiddleware guards half of B8: System
|
|
// means "the plane itself, in-process" and is always FullControl with no
|
|
// token gate, since no deployment configures a token for a surface no HTTP
|
|
// caller is meant to use. HTTP() must never let a request pass through
|
|
// treated as System, or a caller declaring X-Orchestra-Surface: system gets
|
|
// an unconditional, unauthenticated bypass of the token check below it.
|
|
// (Downstream handlers must independently avoid re-deriving System from the
|
|
// raw header themselves — see cmd/orchestra/main.go's `surface` closure,
|
|
// which this package cannot test directly.)
|
|
func TestSystemSurfaceDowngradedByHTTPMiddleware(t *testing.T) {
|
|
tokens := map[Surface]string{System: "should-never-be-checked"}
|
|
h := HTTP(tokens, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/tasks/t1/complete", nil)
|
|
req.Header.Set("X-Orchestra-Surface", "system")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
// System's token check is intentionally skipped by HTTP() (it downgrades
|
|
// to Web before the token comparison), so the request reaching the
|
|
// handler at all is expected here — the guard that matters is that
|
|
// nothing downstream can observe "system" as the resolved surface. This
|
|
// test documents the middleware's half of the fix; main.go's `surface`
|
|
// closure carries the other half.
|
|
if rec.Code != http.StatusOK {
|
|
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")
|
|
}
|
|
}
|