Harden worker federation and operator UI
This commit is contained in:
@@ -141,6 +141,19 @@ func (s *Sessions) Valid(v string) bool {
|
||||
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.
|
||||
@@ -153,6 +166,21 @@ func HTTP(tokens map[Surface]string, next http.Handler) http.Handler {
|
||||
// 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) {
|
||||
// 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
|
||||
|
||||
@@ -96,6 +96,41 @@ func TestWebSessionCookieGatesControlPathsOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRequestsUseTheirOwnCredentials(t *testing.T) {
|
||||
tokens := map[Surface]string{Web: "web-secret"}
|
||||
h := HTTPWithSessions(tokens, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
worker string
|
||||
want int
|
||||
}{
|
||||
{name: "registration reaches admission handler", method: http.MethodPost, path: "/v1/federation/workers", want: http.StatusNoContent},
|
||||
{name: "worker request reaches worker handler", method: http.MethodGet, path: "/v1/federation/events", worker: "workpc-opencode", want: http.StatusNoContent},
|
||||
{name: "worker task reconciliation reaches worker handler", method: http.MethodGet, path: "/v1/tasks", worker: "workpc-opencode", want: http.StatusNoContent},
|
||||
{name: "worker artifact read reaches worker handler", method: http.MethodGet, path: "/v1/artifacts/ref", worker: "workpc-opencode", want: http.StatusNoContent},
|
||||
{name: "worker artifact upload reaches worker handler", method: http.MethodPost, path: "/v1/artifacts", worker: "workpc-opencode", want: http.StatusNoContent},
|
||||
{name: "unnamed worker request remains web gated", method: http.MethodGet, path: "/v1/federation/events", want: http.StatusUnauthorized},
|
||||
{name: "worker list remains web gated", method: http.MethodGet, path: "/v1/federation/workers", want: http.StatusUnauthorized},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
if tc.worker != "" {
|
||||
r.Header.Set("X-Orchestra-Worker", tc.worker)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("status = %d, want %d", w.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpires(t *testing.T) {
|
||||
s := &Sessions{TTL: time.Millisecond}
|
||||
v, err := s.Issue()
|
||||
@@ -110,3 +145,18 @@ func TestSessionExpires(t *testing.T) {
|
||||
t.Fatal("empty session accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRevoke(t *testing.T) {
|
||||
s := &Sessions{}
|
||||
v, err := s.Issue()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Valid(v) {
|
||||
t.Fatal("fresh session must be valid")
|
||||
}
|
||||
s.Revoke(v)
|
||||
if s.Valid(v) {
|
||||
t.Fatal("revoked session must not be valid")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user