2753a8df62
Found live on the first burn-in task. Every federated launch failed with `effective intent: federation: 401 Unauthorized: unauthorized surface`, and the task nacked back to queued. GET /v1/tasks/<id>/intent was added so a worker renders its own launch instruction from the reduced intent, but authz.HTTPWithSessions never got the matching worker-path exemption. An unlabelled request defaults to the Web surface, which is session-gated, so the one caller the endpoint exists for could never reach it. Twenty test packages passed throughout, because the authz tests covered the surface capability table and not the worker path list. Only the live owner path established this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
281 lines
11 KiB
Go
281 lines
11 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)
|
|
}
|
|
}
|