Implement authorization and control surfaces
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user