0ead6d2d02
F7, security. An unset surface token makes the middleware skip its check, so a full-control surface with no credential is an open control plane rather than a closed one. With ORCHESTRA_TUI_TOKEN unset, any LAN caller could lease, release, complete or block any task by declaring one header, which is how this session's manual leases were issued. authz.RequireCredentials now refuses startup instead of logging. Web is exempt: Sessions makes its login mandatory. F5, lifecycle. router.go's silent `continue` was the first bug, not the predicate behind it. Every eligibility gate now records a router.Rejection with task, herdr and reason, exposed at GET /v1/router/health, reset per pass. No gate was weakened: a direct Store.Lease succeeding proves the lease path, not that eligibility should have selected that worker. F8, correctness. Reconcile iterated every configured source for every task, so a task's external id was looked up in whatever repository each source pointed at. Once two repositories share an issue number, an unrelated human comment becomes an authoritative decision for the wrong task. Reconciliation is now bound to task.Source, the provider:project identity the ingest stamped, and a source that cannot prove it owns the task is skipped. A task with no matching source reconciles to nothing and still launches, because nothing to import is not a failure to read. The integration fixture ingested from "jsonl" while reconciling from "gitea", which is exactly the shape F8 makes impossible; it now ingests from the source it reconciles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
300 lines
12 KiB
Go
300 lines
12 KiB
Go
package authz
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
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) {
|
|
// An empty legacy Web bearer-token slot must not open the browser surface:
|
|
// providing Sessions means the caller needs a session cookie.
|
|
tokens := map[Surface]string{}
|
|
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 TestWebCredentialsAuthenticate(t *testing.T) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("correct horse battery staple"), bcrypt.MinCost)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := WebCredentials{Username: "operator", PasswordHash: string(hash)}
|
|
if err := c.Validate(); err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
if !c.Authenticate("operator", "correct horse battery staple") {
|
|
t.Fatal("correct credentials rejected")
|
|
}
|
|
for _, attempt := range []struct{ username, password string }{{"operator", "wrong"}, {"other", "correct horse battery staple"}} {
|
|
if c.Authenticate(attempt.username, attempt.password) {
|
|
t.Fatalf("invalid credentials accepted: %+v", attempt)
|
|
}
|
|
}
|
|
if err := (WebCredentials{Username: "operator", PasswordHash: "not-a-bcrypt-hash"}).Validate(); err == nil {
|
|
t.Fatal("invalid bcrypt hash accepted")
|
|
}
|
|
}
|
|
|
|
func TestFederationRequestsUseTheirOwnCredentials(t *testing.T) {
|
|
tokens := map[Surface]string{Web: "web-secret"}
|
|
h := HTTPWithSessions(tokens, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
worker string
|
|
want int
|
|
}{
|
|
{name: "registration reaches admission handler", method: http.MethodPost, path: "/v1/federation/workers", want: http.StatusNoContent},
|
|
{name: "worker request reaches worker handler", method: http.MethodGet, path: "/v1/federation/events", worker: "workpc-opencode", want: http.StatusNoContent},
|
|
{name: "worker task reconciliation reaches worker handler", method: http.MethodGet, path: "/v1/tasks", worker: "workpc-opencode", want: http.StatusNoContent},
|
|
{name: "worker artifact read reaches worker handler", method: http.MethodGet, path: "/v1/artifacts/ref", worker: "workpc-opencode", want: http.StatusNoContent},
|
|
{name: "worker artifact upload reaches worker handler", method: http.MethodPost, path: "/v1/artifacts", worker: "workpc-opencode", want: http.StatusNoContent},
|
|
{name: "worker intent read reaches worker handler", method: http.MethodGet, path: "/v1/tasks/06G3YR34117MAYT6KEAC9RJHD0/intent", worker: "workpc-opencode", want: http.StatusNoContent},
|
|
{name: "unnamed intent read remains web gated", method: http.MethodGet, path: "/v1/tasks/06G3YR34117MAYT6KEAC9RJHD0/intent", want: http.StatusUnauthorized},
|
|
{name: "unnamed worker request remains web gated", method: http.MethodGet, path: "/v1/federation/events", want: http.StatusUnauthorized},
|
|
{name: "worker list remains web gated", method: http.MethodGet, path: "/v1/federation/workers", want: http.StatusUnauthorized},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest(tc.method, tc.path, nil)
|
|
if tc.worker != "" {
|
|
r.Header.Set("X-Orchestra-Worker", tc.worker)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != tc.want {
|
|
t.Fatalf("status = %d, want %d", w.Code, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestSessionRevoke(t *testing.T) {
|
|
s := &Sessions{}
|
|
v, err := s.Issue()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !s.Valid(v) {
|
|
t.Fatal("fresh session must be valid")
|
|
}
|
|
s.Revoke(v)
|
|
if s.Valid(v) {
|
|
t.Fatal("revoked session must not be valid")
|
|
}
|
|
}
|
|
|
|
// The agent boundary: an agent may perform work and request lifecycle changes,
|
|
// never perform one. Both halves are proven here — the bus refuses the event
|
|
// types Orchestra owns, and the middleware refuses their endpoints — because
|
|
// an agent that reaches a handler with a valid token would otherwise be
|
|
// indistinguishable from the browser operator.
|
|
func TestAgentSurfaceCannotMutateLifecycle(t *testing.T) {
|
|
for _, typ := range []string{
|
|
"TaskLeased", "TaskReleased", "TaskCompleted", "TaskBlocked",
|
|
"WorkPhaseChanged", "ReviewRecorded", "TaskSubmitted",
|
|
"TaskChangesRequested", "HumanDecisionRecorded", "ApprovalGranted",
|
|
} {
|
|
if Agent.CanEmit(typ) {
|
|
t.Errorf("agent surface emitted %s", typ)
|
|
}
|
|
if err := AuthorizeEvent(Agent, typ); err == nil {
|
|
t.Errorf("AuthorizeEvent(agent, %s) allowed", typ)
|
|
}
|
|
}
|
|
if !Agent.CanEmit("ApprovalRequested") {
|
|
t.Fatal("agent surface cannot ask")
|
|
}
|
|
if !Agent.CanRead() {
|
|
t.Fatal("agent surface cannot read")
|
|
}
|
|
}
|
|
|
|
func TestAgentSurfaceReachesOnlyRequestEndpoints(t *testing.T) {
|
|
tokens := map[Surface]string{Agent: "agent-secret"}
|
|
h := HTTPWithSessions(tokens, &Sessions{}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
do := func(method, path, auth string) int {
|
|
r := httptest.NewRequest(method, path, nil)
|
|
r.Header.Set("X-Orchestra-Surface", "agent")
|
|
if auth != "" {
|
|
r.Header.Set("Authorization", auth)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
return w.Code
|
|
}
|
|
const bearer = "Bearer agent-secret"
|
|
for _, tc := range []struct {
|
|
path string
|
|
want int
|
|
}{
|
|
// May ask.
|
|
{"/v1/tasks/t1/decision-request", http.StatusNoContent},
|
|
{"/v1/tasks/t1/deferred", http.StatusNoContent},
|
|
{"/v1/tasks/t1/approval", http.StatusNoContent},
|
|
// May not act.
|
|
{"/v1/tasks/t1/phase", http.StatusForbidden},
|
|
{"/v1/tasks/t1/review", http.StatusForbidden},
|
|
{"/v1/tasks/t1/submission", http.StatusForbidden},
|
|
{"/v1/tasks/t1/complete", http.StatusForbidden},
|
|
{"/v1/tasks/t1/lease", http.StatusForbidden},
|
|
{"/v1/tasks/t1/release", http.StatusForbidden},
|
|
{"/v1/tasks/t1/approval/grant", http.StatusForbidden},
|
|
{"/v1/standup/apply", http.StatusForbidden},
|
|
{"/v1/artifacts", http.StatusForbidden},
|
|
} {
|
|
if got := do(http.MethodPost, tc.path, bearer); got != tc.want {
|
|
t.Errorf("POST %s = %d, want %d", tc.path, got, tc.want)
|
|
}
|
|
}
|
|
// The token still gates the surface: no credential, no request endpoint.
|
|
if got := do(http.MethodPost, "/v1/tasks/t1/decision-request", ""); got != http.StatusUnauthorized {
|
|
t.Errorf("uncredentialed agent = %d, want 401", got)
|
|
}
|
|
// A session cookie must not authenticate an agent, and an agent token must
|
|
// not authenticate the browser surface.
|
|
if got := do(http.MethodGet, "/v1/tasks", "Bearer wrong"); got != http.StatusUnauthorized {
|
|
t.Errorf("wrong agent token = %d, want 401", got)
|
|
}
|
|
}
|
|
|
|
// The harness turn endpoint authenticates its own bearer token in the handler.
|
|
// Before this exemption it defaulted to the session-gated Web surface, so every
|
|
// harness call returned 401 in any deployment with web credentials configured.
|
|
func TestHarnessTurnBypassesSurfaceGate(t *testing.T) {
|
|
h := HTTPWithSessions(map[Surface]string{}, &Sessions{}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, HarnessTurnPath, nil))
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("harness turn = %d, want 204", w.Code)
|
|
}
|
|
}
|
|
|
|
// An unset surface token means no check, so a full-control surface without a
|
|
// credential is an open control plane. Startup must refuse, not warn.
|
|
func TestRequireCredentialsRefusesUncredentialedFullControl(t *testing.T) {
|
|
if err := RequireCredentials(map[Surface]string{}); err == nil {
|
|
t.Fatal("an uncredentialed TUI surface was accepted")
|
|
}
|
|
if err := RequireCredentials(map[Surface]string{TUI: " "}); err == nil {
|
|
t.Fatal("a blank TUI token was accepted")
|
|
}
|
|
if err := RequireCredentials(map[Surface]string{TUI: "tui-secret"}); err != nil {
|
|
t.Fatalf("a credentialed deployment was refused: %v", err)
|
|
}
|
|
// Gated and notify-only surfaces are bounded by capability, so an unset
|
|
// token there is a deployment choice rather than an open control plane.
|
|
if err := RequireCredentials(map[Surface]string{TUI: "tui-secret", MCP: "", Agent: ""}); err != nil {
|
|
t.Fatalf("gated surfaces must not block startup: %v", err)
|
|
}
|
|
}
|