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>
304 lines
11 KiB
Go
304 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"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
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"
|
|
|
|
// WebCredentials is the single configured browser operator identity. Only a
|
|
// bcrypt password hash is accepted; Orchestra has no self-service account
|
|
// creation or password-reset surface.
|
|
type WebCredentials struct {
|
|
Username string
|
|
PasswordHash string
|
|
}
|
|
|
|
func (c WebCredentials) Validate() error {
|
|
if strings.TrimSpace(c.Username) == "" {
|
|
return fmt.Errorf("web username is required")
|
|
}
|
|
if c.PasswordHash == "" {
|
|
return fmt.Errorf("web password hash is required")
|
|
}
|
|
if _, err := bcrypt.Cost([]byte(c.PasswordHash)); err != nil {
|
|
return fmt.Errorf("web password hash must be bcrypt: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Authenticate always performs bcrypt, even for an unknown username, so the
|
|
// response does not reveal whether the configured username was correct.
|
|
func (c WebCredentials) Authenticate(username, password string) bool {
|
|
passwordOK := bcrypt.CompareHashAndPassword([]byte(c.PasswordHash), []byte(password)) == nil
|
|
usernameOK := subtle.ConstantTimeCompare([]byte(username), []byte(c.Username)) == 1
|
|
return passwordOK && usernameOK
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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[:]))
|
|
}
|
|
|
|
// 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 login endpoint authenticates 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)
|
|
})
|
|
}
|