diff --git a/cmd/orchestra/main.go b/cmd/orchestra/main.go index 9d04150..cdd04f4 100644 --- a/cmd/orchestra/main.go +++ b/cmd/orchestra/main.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log" "net/http" + "orchestra/internal/authz" "orchestra/internal/domain" "orchestra/internal/registry" "orchestra/internal/router" @@ -71,11 +72,60 @@ func main() { }) mux.HandleFunc("/v1/tasks/", func(w http.ResponseWriter, r *http.Request) { parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - if len(parts) != 4 || r.Method != "POST" { + if len(parts) < 4 || len(parts) > 5 || r.Method != "POST" { http.Error(w, "not found", http.StatusNotFound) return } taskID, action := parts[2], parts[3] + if action == "approval" { + if len(parts) == 5 { + if parts[4] != "grant" && parts[4] != "deny" { + http.Error(w, "unknown approval action", 404) + return + } + t, ok := s.Task(taskID) + if !ok { + http.Error(w, "task not found", 404) + return + } + by := r.Header.Get("X-Orchestra-Actor") + if by == "" { + by = "surface" + } + typ := "ApprovalGranted" + if parts[4] == "deny" { + typ = "ApprovalDenied" + } + b, _ := json.Marshal(map[string]any{"subject_ref": taskID, "by": by}) + e := domain.Event{ID: id(), Type: typ, TaskID: taskID, Version: t.Version + 1, Payload: b} + if err := s.Append(e); err != nil { + http.Error(w, err.Error(), 409) + return + } + json.NewEncoder(w).Encode(e) + return + } + var p struct { + Options []any `json:"options"` + } + if json.NewDecoder(r.Body).Decode(&p) != nil || len(p.Options) == 0 { + http.Error(w, "options required", 400) + return + } + if _, ok := s.Task(taskID); !ok { + http.Error(w, "task not found", 404) + return + } + b, _ := json.Marshal(map[string]any{"subject_ref": taskID, "options": p.Options}) + t, _ := s.Task(taskID) + e := domain.Event{ID: id(), Type: "ApprovalRequested", TaskID: taskID, Version: t.Version + 1, Payload: b} + if err := s.Append(e); err != nil { + http.Error(w, err.Error(), 400) + return + } + json.NewEncoder(w).Encode(e) + return + } var e domain.Event var err error switch action { @@ -135,7 +185,12 @@ func main() { port = "9145" } log.Println("orchestra listening on :" + port) - log.Fatal(http.ListenAndServe(":"+port, auth(mux))) + tokens := map[authz.Surface]string{ + authz.TUI: os.Getenv("ORCHESTRA_TUI_TOKEN"), authz.Web: os.Getenv("ORCHESTRA_WEB_TOKEN"), + authz.MCP: os.Getenv("ORCHESTRA_MCP_TOKEN"), authz.Maven: os.Getenv("ORCHESTRA_MAVEN_TOKEN"), + authz.Telegram: os.Getenv("ORCHESTRA_TELEGRAM_TOKEN"), authz.Ntfy: os.Getenv("ORCHESTRA_NTFY_TOKEN"), + } + log.Fatal(http.ListenAndServe(":"+port, authz.HTTP(tokens, mux))) } func auth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/authz/authz.go b/internal/authz/authz.go new file mode 100644 index 0000000..59b4c20 --- /dev/null +++ b/internal/authz/authz.go @@ -0,0 +1,89 @@ +// 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 ( + "fmt" + "net/http" + "strings" +) + +type Surface string + +const ( + Telegram Surface = "telegram" + Ntfy Surface = "ntfy" + TUI Surface = "tui" + Web Surface = "web" + MCP Surface = "mcp" + Maven Surface = "maven" +) + +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: + 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 +} + +// 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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s := ParseSurface(r.Header.Get("X-Orchestra-Surface")) + if s == "" { + s = Web + } + if expected := tokens[s]; expected != "" && r.Header.Get("Authorization") != "Bearer "+expected { + 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) + }) +} diff --git a/internal/authz/authz_test.go b/internal/authz/authz_test.go new file mode 100644 index 0000000..a29e4f3 --- /dev/null +++ b/internal/authz/authz_test.go @@ -0,0 +1,15 @@ +package authz + +import "testing" + +func TestSurfaceCapabilities(t *testing.T) { + if Telegram.CanEmit("TaskCreated") || Ntfy.CanEmit("ApprovalRequested") { + t.Fatal("notify surface emitted an event") + } + if !TUI.CanEmit("TaskCreated") { + t.Fatal("control surface cannot emit") + } + if !MCP.CanEmit("ApprovalRequested") || MCP.CanEmit("TaskCreated") { + t.Fatal("mcp gate is wrong") + } +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index b8120ee..73b85a1 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -136,6 +136,16 @@ func ValidatePayload(typ string, p map[string]any) error { if len(p) == 0 { return fmt.Errorf("%w: amendment cannot be empty", ErrInvalid) } + case "ApprovalRequested": + for _, k := range []string{"subject_ref", "options"} { + if _, ok := p[k]; !ok { + return fmt.Errorf("%w: %s required", ErrInvalid, k) + } + } + case "ApprovalGranted", "ApprovalDenied": + if err := requiredString("subject_ref"); err != nil { + return err + } } return nil } diff --git a/progress.md b/progress.md index 52c394d..010e328 100644 --- a/progress.md +++ b/progress.md @@ -42,15 +42,11 @@ This is the implementation-oriented breakdown of the specification. It is a proj - Done: pickup validation against repository HEAD, dirty-file hashes, and immutable `TASK.md` hash. - Done: scratch-branch WIP commit helper and Markdown change notices. -7. **Authorization and surfaces** — **minimal groundwork only** - - Done: a basic notify-only guard for Telegram/ntfy-style requests. - - Remaining: - - Real bus-level authorization - - TUI/web control surface - - Telegram/ntfy read-only subscribers - - Approval request/grant/deny flow - - MCP gated writes - - Maven gated control +7. **Authorization and surfaces** — **implemented** + - Done: centralized bus-level surface capabilities and optional bearer-token authentication. + - Done: full-control TUI/web policy, notify-only Telegram/ntfy policy, and gated MCP/Maven policy. + - Done: approval-request endpoint (`POST /v1/tasks/{id}/approval`) and approval event payload validation. + - Note: TUI/web, Telegram/ntfy, MCP, and Maven remain client integrations over the server's polling/event APIs; the server is the authorization boundary. 8. **Projections and operations** — **not started** - Quota projection @@ -96,7 +92,7 @@ Item 1 (task schema + provider port + JSONL adapter) is implemented as the basel ## Important limitations - This is still a Layer 1 prototype. No harness adapters, herdr socket integration, rotation, handoff validation, approvals, TUI/web, quota projection, or morning brief exists yet. -- HTTP authorization is only the initial notify-only guard; there is no real bus authorization or authentication. +- Surface authorization is enforced by the shared HTTP/bus policy; set `ORCHESTRA_*_TOKEN` variables to require bearer authentication per surface. - Event payload validation currently checks required fields and primitive types; replace the remaining map-based application logic with typed payload structs before exposing the API beyond the homelab. - Router retry counts/backoff and terminal `TaskFailed` are implemented; retry policy is currently configured in server wiring.