// 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" // 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: 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" } 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 the Web-surface token. A // top-level document load cannot set an Authorization header, so a // bearer-only gate forces operators to run the UI with no token at all // (AUDIT.md B18). The cookie is the browser-presentable equivalent; it is // never a second credential, only a receipt for the same token. const SessionCookie = "orchestra_session" // SessionPath is the one Web-surface endpoint exempt from the token gate, // because it *is* the token check: it verifies the Web token itself and // exchanges it for a cookie. Gating it would make login unreachable. 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 Sessions struct { mu sync.Mutex TTL time.Duration ids map[string]time.Time } func (s *Sessions) ttl() time.Duration { if s.TTL > 0 { return s.TTL } return 12 * time.Hour } // 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) { 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]time.Time{} } now := time.Now() for k, exp := range s.ids { if now.After(exp) { delete(s.ids, k) } } s.ids[hex.EncodeToString(sum[:])] = now.Add(s.ttl()) return v, nil } func (s *Sessions) Valid(v string) bool { if v == "" { return false } sum := sha256.Sum256([]byte(v)) s.mu.Lock() defer s.mu.Unlock() exp, ok := s.ids[hex.EncodeToString(sum[:])] if !ok { return false } if time.Now().After(exp) { delete(s.ids, hex.EncodeToString(sum[:])) return false } return true } // 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 additionally accepts a valid session cookie in place of a // Bearer token, but only for the Web surface — every non-browser surface // still has to present the token directly. func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 expected := tokens[s]; expected != "" && subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+expected)) != 1 { ok := false if s == Web && sessions != nil { if c, err := r.Cookie(SessionCookie); err == nil { ok = sessions.Valid(c.Value) } // The login endpoint authenticates itself, and the SPA shell // must load before a browser can present anything. Static // assets are not secrets; every /v1/ control path stays gated. if r.URL.Path == SessionPath { ok = true } if !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 } } if (s == Telegram || s == Ntfy) && r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "notify-only surface", http.StatusForbidden) return } if (s == MCP || s == Maven) && r.Method != http.MethodGet && r.Method != http.MethodHead && r.URL.Path != "/v1/events" { // Gated clients may submit only approval requests; ordinary control // endpoints must never become an accidental write path. if !strings.HasSuffix(r.URL.Path, "/approval") { http.Error(w, "approval required", http.StatusForbidden) return } } next.ServeHTTP(w, r) }) }