4fbf3ac966
A task in retry backoff was filtered out before the candidate loop, so it recorded no rejection at all: queued, apparently assignable, and silent. That is the exact shape that made F5 take a live session to diagnose. It now reports "retry backoff until <time>". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
306 lines
11 KiB
Go
306 lines
11 KiB
Go
// 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)
|
|
})
|
|
}
|