// Package authz contains the single authorization policy used by all control // surfaces. Clients identify a surface; the bus decides what it may emit. package authz import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "fmt" "net/http" "strings" "sync" "time" ) type Surface string const ( Telegram Surface = "telegram" Ntfy Surface = "ntfy" TUI Surface = "tui" Web Surface = "web" MCP Surface = "mcp" Maven Surface = "maven" // Agent identifies a coding session running inside a harness pane. It may // perform work and *request* lifecycle changes; it may never perform one. // Everything Orchestra owns — phase, review, submission, completion, lease // state — is denied to it by GatedWrite, at the endpoint and at the bus. Agent Surface = "agent" // System identifies the plane itself — the router, coordinator, provider // adapters, and lease-expiry reclaim. Per invariant 2 ("the plane emits // events, not the agent"), these are the only non-surface emitters and are // always full control. Every event must carry an explicit Surface; there // is no unauthenticated default, so an emitter that forgets to declare one // is rejected at the bus rather than silently treated as trusted. System Surface = "system" ) type Capability int const ( Observe Capability = iota NotifyOnly GatedWrite FullControl ) func ParseSurface(v string) Surface { return Surface(strings.ToLower(strings.TrimSpace(v))) } func CapabilityFor(s Surface) Capability { switch s { case Telegram, Ntfy: return NotifyOnly case TUI, Web, System: return FullControl case MCP, Maven, Agent: return GatedWrite default: return Observe } } func (s Surface) CanRead() bool { return CapabilityFor(s) >= Observe } func (s Surface) CanEmit(typ string) bool { if CapabilityFor(s) == FullControl { return true } return typ == "ApprovalRequested" && CapabilityFor(s) == GatedWrite } func (s Surface) RequiresApproval(typ string) bool { return CapabilityFor(s) == GatedWrite && typ != "ApprovalRequested" } // RequireCredentials refuses to serve a full-control surface that has no token. // An unset token means the middleware performs no check for that surface, so an // unconfigured FullControl surface is an unauthenticated control plane, not a // closed one. Found live during burn-in: with ORCHESTRA_TUI_TOKEN unset, any // LAN caller could lease, release, complete or block any task by declaring one // header. Web is exempt because Sessions makes its login mandatory and it // carries its own credentials. func RequireCredentials(tokens map[Surface]string) error { for _, s := range []Surface{TUI} { if CapabilityFor(s) == FullControl && strings.TrimSpace(tokens[s]) == "" { return fmt.Errorf("surface %q is full control and has no token: set its credential or leave the surface unused", s) } } return nil } func AuthorizeEvent(s Surface, typ string) error { if !s.CanEmit(typ) { return fmt.Errorf("surface %q cannot emit %s", s, typ) } return nil } // SessionCookie carries a browser's proof of a successful Web login. A // top-level document load cannot set an Authorization header, so browser // credentials are exchanged once for this HttpOnly receipt. const SessionCookie = "orchestra_session" // HarnessTurnPath authenticates its own bearer token inside the handler, the // way federation endpoints do. It needs an exemption from the surface gate // below for the same reason they do: an unlabelled request defaults to the Web // surface, which is session-gated, so a harness could never reach it. const HarnessTurnPath = "/v1/harness/turn" // GatedWritePaths are the only mutating paths a GatedWrite surface may reach. // Each one records a request — an approval, a bounded question, a deferred // finding — and none of them moves the lifecycle. Handlers re-check with // AuthorizeEvent, so widening this list alone cannot grant authority. func GatedWritePath(p string) bool { return strings.HasSuffix(p, "/approval") || strings.HasSuffix(p, "/decision-request") || strings.HasSuffix(p, "/deferred") } // SessionPath is the one Web-surface endpoint exempt from the session gate, // because it verifies login credentials and exchanges them for a cookie. 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 browserSession struct { Username string Expires time.Time } type Sessions struct { mu sync.Mutex TTL time.Duration ids map[string]browserSession } func (s *Sessions) ttl() time.Duration { if s.TTL > 0 { return s.TTL } return 12 * time.Hour } // Duration exposes the configured session lifetime for the cookie Max-Age. func (s *Sessions) Duration() time.Duration { return s.ttl() } // 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) { return s.IssueFor("") } // IssueFor mints a session bound to one database-backed operator identity. func (s *Sessions) IssueFor(username string) (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]browserSession{} } now := time.Now() for k, session := range s.ids { if now.After(session.Expires) { delete(s.ids, k) } } s.ids[hex.EncodeToString(sum[:])] = browserSession{Username: username, Expires: now.Add(s.ttl())} return v, nil } func (s *Sessions) Valid(v string) bool { _, ok := s.Username(v) return ok } // Username validates a receipt and returns the operator it belongs to. func (s *Sessions) Username(v string) (string, bool) { if v == "" { return "", false } sum := sha256.Sum256([]byte(v)) key := hex.EncodeToString(sum[:]) s.mu.Lock() defer s.mu.Unlock() session, ok := s.ids[key] if !ok { return "", false } if time.Now().After(session.Expires) { delete(s.ids, key) return "", false } return session.Username, true } // Revoke removes one browser session. It is deliberately idempotent so a // logout request remains safe after expiry or after a cookie was cleared by // the browser. func (s *Sessions) Revoke(v string) { if v == "" { return } sum := sha256.Sum256([]byte(v)) s.mu.Lock() defer s.mu.Unlock() delete(s.ids, hex.EncodeToString(sum[:])) } // RevokeUser ends every browser session for an identity after its username or // password changes. func (s *Sessions) RevokeUser(username string) { s.mu.Lock() defer s.mu.Unlock() for key, session := range s.ids { if strings.EqualFold(session.Username, username) { delete(s.ids, key) } } } // 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 accepts a valid browser session cookie only for the Web // surface. Supplying Sessions makes Web authentication mandatory even when // the legacy Web bearer-token slot is empty. func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Federation has per-worker credentials, not one shared surface token. // Let only its registration request and requests that name a worker // reach their handlers; those handlers authenticate the admission token // or worker token respectively. Without this exception, an authenticated // worker is incorrectly treated as the default Web surface. worker := r.Header.Get("X-Orchestra-Worker") != "" federationRegistration := r.Method == http.MethodPost && r.URL.Path == "/v1/federation/workers" workerPath := strings.HasPrefix(r.URL.Path, "/v1/federation/") || (r.Method == http.MethodGet && r.URL.Path == "/v1/tasks") || (r.Method == http.MethodPost && r.URL.Path == "/v1/artifacts") || (r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/artifacts/")) || // A federated worker renders its own launch instruction from the // reduced intent, so this read is as necessary to it as the task // list. Found live on the first burn-in task: the endpoint was // added for workers, the exemption was not, and every federated // launch failed with "effective intent: federation: 401 // Unauthorized: unauthorized surface". (r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/tasks/") && strings.HasSuffix(r.URL.Path, "/intent")) if federationRegistration || r.URL.Path == HarnessTurnPath || (worker && workerPath) { next.ServeHTTP(w, r) return } s := ParseSurface(r.Header.Get("X-Orchestra-Surface")) if s == "" { s = Web } // System means "the plane itself, in-process" (router, coordinator, // adapters, lease-expiry reclaim) and is always FullControl with no // token gate — it must never be reachable by declaring it over HTTP. // Without this, tokens[System] being unset (as it is by default: no // caller ever needs a System token) makes the check at line ~78 a // no-op, and any LAN request with this header gets unauthenticated // full control over every task. if s == System { s = Web } if s == Web && sessions != nil { ok := false if c, err := r.Cookie(SessionCookie); err == nil { ok = sessions.Valid(c.Value) } // The session endpoint authenticates login and session lookup itself, // and the SPA shell must // load before a browser can present a session. Static assets are not // secrets; every other /v1/ control path remains session-gated. if r.URL.Path == SessionPath || (!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 } } else if expected := tokens[s]; expected != "" && subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+expected)) != 1 { 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) return } if CapabilityFor(s) == GatedWrite && r.Method != http.MethodGet && r.Method != http.MethodHead && r.URL.Path != "/v1/events" { // Gated clients may only ask; ordinary control endpoints must never // become an accidental write path. if !GatedWritePath(r.URL.Path) { http.Error(w, "approval required", http.StatusForbidden) return } } next.ServeHTTP(w, r) }) }