Files
orchestra/internal/authz/authz.go
T
2026-07-30 14:34:29 +04:00

260 lines
8.4 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"
// 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 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"
// 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/"))
if federationRegistration || (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 (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)
})
}