Report queued tasks the scheduling pass never considers
A task in retry backoff was filtered out before the candidate loop, so it recorded no rejection at all: queued, apparently assignable, and silent. That is the exact shape that made F5 take a live session to diagnose. It now reports "retry backoff until <time>". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -6,12 +6,14 @@ ARG BUILD_DIRTY=unknown
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
COPY . ./
|
||||
RUN go build -trimpath -ldflags="-s -w -X orchestra/internal/buildinfo.Revision=${BUILD_REVISION} -X orchestra/internal/buildinfo.Time=${BUILD_TIME} -X orchestra/internal/buildinfo.Dirty=${BUILD_DIRTY}" -o /out/orchestra ./cmd/orchestra
|
||||
RUN go build -trimpath -ldflags="-s -w -X orchestra/internal/buildinfo.Revision=${BUILD_REVISION} -X orchestra/internal/buildinfo.Time=${BUILD_TIME} -X orchestra/internal/buildinfo.Dirty=${BUILD_DIRTY}" -o /out/orchestra ./cmd/orchestra && \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/orchestra-user ./cmd/orchestra-user
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN adduser -D -u 10001 orchestra
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/orchestra /app/orchestra
|
||||
COPY --from=build /out/orchestra-user /app/orchestra-user
|
||||
RUN mkdir /data && chown orchestra:orchestra /data
|
||||
ENV ORCHESTRA_DATA=/data ORCHESTRA_PORT=9145
|
||||
VOLUME ["/data"]
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// orchestra-password prints a bcrypt hash suitable for ORCHESTRA_WEB_PASSWORD_HASH.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprint(os.Stderr, "Password: ")
|
||||
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil || len(password) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "password is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "hash password:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(string(hash))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// orchestra-user manages browser-operator accounts in Orchestra's embedded
|
||||
// credential database. Passwords are read from the terminal and hashed inside
|
||||
// the database; no reusable hash has to be copied into deployment config.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"orchestra/internal/authn"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage:")
|
||||
fmt.Fprintln(os.Stderr, " orchestra-user set -data DIR -username NAME")
|
||||
fmt.Fprintln(os.Stderr, " orchestra-user list -data DIR")
|
||||
}
|
||||
|
||||
func readPassword(prompt string) (string, error) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
value, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return string(value), err
|
||||
}
|
||||
|
||||
func open(data string) *authn.Store {
|
||||
if data == "" {
|
||||
log.Fatal("-data is required")
|
||||
}
|
||||
users, err := authn.Open(authn.Path(data))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "set":
|
||||
flags := flag.NewFlagSet("set", flag.ExitOnError)
|
||||
data := flags.String("data", "", "Orchestra data directory")
|
||||
username := flags.String("username", "", "operator username")
|
||||
_ = flags.Parse(os.Args[2:])
|
||||
password, err := readPassword("New password: ")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
confirmation, err := readPassword("Confirm password: ")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if password != confirmation {
|
||||
log.Fatal("passwords do not match")
|
||||
}
|
||||
users := open(*data)
|
||||
defer users.Close()
|
||||
user, created, err := users.SetPassword(*username, password)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
fmt.Printf("created operator %s\n", user.Username)
|
||||
} else {
|
||||
fmt.Printf("updated password for %s\n", user.Username)
|
||||
}
|
||||
case "list":
|
||||
flags := flag.NewFlagSet("list", flag.ExitOnError)
|
||||
data := flags.String("data", "", "Orchestra data directory")
|
||||
_ = flags.Parse(os.Args[2:])
|
||||
users := open(*data)
|
||||
defer users.Close()
|
||||
list, err := users.Users()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, user := range list {
|
||||
fmt.Println(user.Username)
|
||||
}
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
+38
-46
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"orchestra/internal/admin"
|
||||
"orchestra/internal/authn"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/buildinfo"
|
||||
"orchestra/internal/delivery"
|
||||
@@ -150,6 +151,37 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
users, err := authn.Open(authn.Path(dir))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer users.Close()
|
||||
userCount, err := users.Count()
|
||||
if err != nil {
|
||||
log.Fatalf("read operator database: %v", err)
|
||||
}
|
||||
legacyUsername := os.Getenv("ORCHESTRA_WEB_USERNAME")
|
||||
legacyPasswordHash := os.Getenv("ORCHESTRA_WEB_PASSWORD_HASH")
|
||||
if userCount == 0 {
|
||||
if legacyUsername != "" || legacyPasswordHash != "" {
|
||||
if legacyUsername == "" || legacyPasswordHash == "" {
|
||||
log.Fatal("operator database is empty and the legacy web credential is incomplete: both ORCHESTRA_WEB_USERNAME and ORCHESTRA_WEB_PASSWORD_HASH are required for one-time migration")
|
||||
}
|
||||
imported, importErr := users.ImportBcrypt(legacyUsername, legacyPasswordHash)
|
||||
if importErr != nil {
|
||||
log.Fatalf("migrate legacy web credential: %v", importErr)
|
||||
}
|
||||
if imported {
|
||||
userCount = 1
|
||||
log.Printf("migrated web operator %q into %s; remove ORCHESTRA_WEB_USERNAME and ORCHESTRA_WEB_PASSWORD_HASH from the deployment environment", legacyUsername, authn.Path(dir))
|
||||
}
|
||||
}
|
||||
if userCount == 0 {
|
||||
log.Fatalf("operator database is empty: stop Orchestra and run orchestra-user set -data %s -username NAME", dir)
|
||||
}
|
||||
} else if legacyUsername != "" || legacyPasswordHash != "" {
|
||||
log.Printf("operator database already contains %d account(s); legacy ORCHESTRA_WEB_USERNAME and ORCHESTRA_WEB_PASSWORD_HASH are ignored and should be removed", userCount)
|
||||
}
|
||||
var rr registry.Registry
|
||||
var rt *router.Router
|
||||
var coordinator *orchestrator.Coordinator
|
||||
@@ -275,53 +307,13 @@ func main() {
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
// B18: the UI is a full control plane — it can create tasks, release or
|
||||
// complete them, and inject approval keystrokes into live panes. It has
|
||||
// one explicit operator identity and is never enabled by a missing env var.
|
||||
webCredentials := authz.WebCredentials{Username: os.Getenv("ORCHESTRA_WEB_USERNAME"), PasswordHash: os.Getenv("ORCHESTRA_WEB_PASSWORD_HASH")}
|
||||
if err := webCredentials.Validate(); err != nil {
|
||||
log.Fatalf("web login configuration: %v", err)
|
||||
}
|
||||
// complete them, and inject approval keystrokes into live panes. Browser
|
||||
// sessions are backed by operator accounts in the embedded auth database;
|
||||
// no missing environment variable can open this surface.
|
||||
sessions := &authz.Sessions{}
|
||||
// A browser login exchanges verified credentials for an HttpOnly cookie.
|
||||
mux.HandleFunc("/v1/ui/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
// Expire the browser credential even if it is already absent or stale.
|
||||
// The client never has access to the HttpOnly value, so this is the
|
||||
// only reliable way for an operator to end a browser session.
|
||||
if cookie, err := r.Cookie(authz.SessionCookie); err == nil {
|
||||
sessions.Revoke(cookie.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: authz.SessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: os.Getenv("ORCHESTRA_UI_INSECURE_COOKIE") == ""})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&body); err != nil || !webCredentials.Authenticate(body.Username, body.Password) {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
v, err := sessions.Issue()
|
||||
if err != nil {
|
||||
http.Error(w, "session unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: authz.SessionCookie, Value: v, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteStrictMode,
|
||||
Secure: os.Getenv("ORCHESTRA_UI_INSECURE_COOKIE") == "",
|
||||
MaxAge: int((12 * time.Hour).Seconds()),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
browserAuth := authn.HTTP{Users: users, Sessions: sessions, SecureCookie: os.Getenv("ORCHESTRA_UI_INSECURE_COOKIE") == ""}
|
||||
mux.HandleFunc(authz.SessionPath, browserAuth.Session)
|
||||
mux.HandleFunc("/v1/ui/account", browserAuth.Account)
|
||||
// Browser-specific endpoints intentionally present a joined read model;
|
||||
// raw lifecycle endpoints below remain stable for workers and harnesses.
|
||||
mux.Handle("/v1/ui/", ui.Server{Store: s, Workers: workers, Coordinator: coordinator, Route: func(e domain.Event) error {
|
||||
|
||||
@@ -3,6 +3,7 @@ module orchestra
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
go.etcd.io/bbolt v1.3.11
|
||||
golang.org/x/crypto v0.29.0
|
||||
golang.org/x/term v0.26.0
|
||||
)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
|
||||
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
|
||||
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
|
||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
|
||||
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
|
||||
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"orchestra/internal/authz"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxLoginBody = 8 << 10
|
||||
|
||||
type HTTP struct {
|
||||
Users *Store
|
||||
Sessions *authz.Sessions
|
||||
SecureCookie bool
|
||||
}
|
||||
|
||||
func (h HTTP) cookie(value string, maxAge int) *http.Cookie {
|
||||
cookie := &http.Cookie{
|
||||
Name: authz.SessionCookie,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: h.SecureCookie,
|
||||
}
|
||||
if maxAge < 0 {
|
||||
cookie.Expires = time.Unix(1, 0)
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, dst any) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBody)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err == nil {
|
||||
return errors.New("multiple JSON values")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func (h HTTP) sessionUser(r *http.Request) (User, bool) {
|
||||
if h.Users == nil || h.Sessions == nil {
|
||||
return User{}, false
|
||||
}
|
||||
cookie, err := r.Cookie(authz.SessionCookie)
|
||||
if err != nil {
|
||||
return User{}, false
|
||||
}
|
||||
username, ok := h.Sessions.Username(cookie.Value)
|
||||
if !ok {
|
||||
return User{}, false
|
||||
}
|
||||
user, err := h.Users.User(username)
|
||||
return user, err == nil
|
||||
}
|
||||
|
||||
func (h HTTP) Session(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Users == nil || h.Sessions == nil {
|
||||
http.Error(w, "login unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
user, ok := h.sessionUser(r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decode(w, r, &body); err != nil {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user, err := h.Users.Authenticate(body.Username, body.Password)
|
||||
if err != nil {
|
||||
// Credential failures are deliberately indistinguishable. Database
|
||||
// failures are not exposed either, but they remain a server error.
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Error(w, "login unavailable", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
value, err := h.Sessions.IssueFor(user.Username)
|
||||
if err != nil {
|
||||
http.Error(w, "session unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, h.cookie(value, int(h.Sessions.Duration().Seconds())))
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
case http.MethodDelete:
|
||||
if cookie, err := r.Cookie(authz.SessionCookie); err == nil {
|
||||
h.Sessions.Revoke(cookie.Value)
|
||||
}
|
||||
http.SetCookie(w, h.cookie("", -1))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST, DELETE")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h HTTP) Account(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := h.sessionUser(r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
case http.MethodPut:
|
||||
var body struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
Username string `json:"username"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := decode(w, r, &body); err != nil || body.CurrentPassword == "" {
|
||||
http.Error(w, "current password is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
updated, err := h.Users.Update(user.Username, body.CurrentPassword, body.Username, body.NewPassword)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials):
|
||||
http.Error(w, "current password is incorrect", http.StatusUnauthorized)
|
||||
case errors.Is(err, ErrUsernameExists):
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Credential changes revoke every browser holding this identity. The
|
||||
// response carries the updated display name, then the UI signs in again.
|
||||
h.Sessions.RevokeUser(user.Username)
|
||||
http.SetCookie(w, h.cookie("", -1))
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, PUT")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"orchestra/internal/authz"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTTPSessionLoginLookupAndLogout(t *testing.T) {
|
||||
users, _ := openTestStore(t)
|
||||
if _, _, err := users.SetPassword("kami", "correct horse battery"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := HTTP{Users: users, Sessions: &authz.Sessions{}}
|
||||
|
||||
login := httptest.NewRequest(http.MethodPost, authz.SessionPath, bytes.NewBufferString(`{"username":"kami","password":"correct horse battery"}`))
|
||||
w := httptest.NewRecorder()
|
||||
h.Session(w, login)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login status=%d body=%s", w.Code, w.Body)
|
||||
}
|
||||
response := w.Result()
|
||||
cookies := response.Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != authz.SessionCookie || !cookies[0].HttpOnly {
|
||||
t.Fatalf("cookies=%+v", cookies)
|
||||
}
|
||||
var account User
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &account); err != nil || account.Username != "kami" {
|
||||
t.Fatalf("account=%+v err=%v", account, err)
|
||||
}
|
||||
|
||||
lookup := httptest.NewRequest(http.MethodGet, authz.SessionPath, nil)
|
||||
lookup.AddCookie(cookies[0])
|
||||
w = httptest.NewRecorder()
|
||||
h.Session(w, lookup)
|
||||
if w.Code != http.StatusOK || !bytes.Contains(w.Body.Bytes(), []byte(`"username":"kami"`)) {
|
||||
t.Fatalf("lookup status=%d body=%s", w.Code, w.Body)
|
||||
}
|
||||
|
||||
logout := httptest.NewRequest(http.MethodDelete, authz.SessionPath, nil)
|
||||
logout.AddCookie(cookies[0])
|
||||
w = httptest.NewRecorder()
|
||||
h.Session(w, logout)
|
||||
if w.Code != http.StatusNoContent || h.Sessions.Valid(cookies[0].Value) {
|
||||
t.Fatalf("logout status=%d valid=%v", w.Code, h.Sessions.Valid(cookies[0].Value))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPLoginDoesNotRevealUnknownUsername(t *testing.T) {
|
||||
users, _ := openTestStore(t)
|
||||
if _, _, err := users.SetPassword("kami", "correct horse battery"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := HTTP{Users: users, Sessions: &authz.Sessions{}}
|
||||
for _, body := range []string{
|
||||
`{"username":"kami","password":"wrong password"}`,
|
||||
`{"username":"unknown","password":"wrong password"}`,
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
h.Session(w, httptest.NewRequest(http.MethodPost, authz.SessionPath, bytes.NewBufferString(body)))
|
||||
if w.Code != http.StatusUnauthorized || w.Body.String() != "invalid credentials\n" {
|
||||
t.Fatalf("status=%d body=%q", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAccountUpdateRevokesExistingSessions(t *testing.T) {
|
||||
users, _ := openTestStore(t)
|
||||
if _, _, err := users.SetPassword("operator", "original password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions := &authz.Sessions{}
|
||||
h := HTTP{Users: users, Sessions: sessions}
|
||||
value, err := sessions.IssueFor("operator")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPut, "/v1/ui/account", bytes.NewBufferString(`{"current_password":"original password","username":"kami","new_password":"replacement password"}`))
|
||||
req.AddCookie(&http.Cookie{Name: authz.SessionCookie, Value: value})
|
||||
w := httptest.NewRecorder()
|
||||
h.Account(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body)
|
||||
}
|
||||
if sessions.Valid(value) {
|
||||
t.Fatal("credential update retained an old browser session")
|
||||
}
|
||||
if _, err := users.Authenticate("kami", "replacement password"); err != nil {
|
||||
t.Fatalf("updated login: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// Package authn owns browser-operator identities and credential verification.
|
||||
// Authorization policy remains in authz; this package only proves who signed
|
||||
// in. Operator records live in a small embedded bbolt database so a deployment
|
||||
// never needs to carry a reusable password hash in its environment.
|
||||
package authn
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
DatabaseFile = "auth.db"
|
||||
MinimumPassword = 10
|
||||
maximumPassword = 72 // bcrypt rejects passwords longer than 72 bytes.
|
||||
maximumUsername = 128
|
||||
databaseOpenWait = 2 * time.Second
|
||||
databaseFileMode = 0600
|
||||
databaseDirectory = 0700
|
||||
)
|
||||
|
||||
var (
|
||||
usersBucket = []byte("operator_users")
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrUsernameExists = errors.New("username already exists")
|
||||
// Unknown users still take a bcrypt comparison. The hash is generated once
|
||||
// at process start with the same cost used for real records so the login
|
||||
// response does not disclose whether an account exists.
|
||||
dummyPasswordHash = func() []byte {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("orchestra-invalid-login-sentinel"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hash
|
||||
}()
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Username string `json:"username"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type storedUser struct {
|
||||
User
|
||||
PasswordHash string `json:"password_hash"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *bolt.DB
|
||||
}
|
||||
|
||||
func Path(dataDir string) string { return filepath.Join(dataDir, DatabaseFile) }
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, errors.New("auth database path is required")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), databaseDirectory); err != nil {
|
||||
return nil, fmt.Errorf("create auth database directory: %w", err)
|
||||
}
|
||||
db, err := bolt.Open(path, databaseFileMode, &bolt.Options{Timeout: databaseOpenWait})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open auth database: %w", err)
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := db.Update(func(tx *bolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists(usersBucket)
|
||||
return err
|
||||
}); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("initialize auth database: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func normalizedUsername(username string) string {
|
||||
return strings.ToLower(strings.TrimSpace(username))
|
||||
}
|
||||
|
||||
func ValidateUsername(username string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return errors.New("username is required")
|
||||
}
|
||||
if len(username) > maximumUsername {
|
||||
return fmt.Errorf("username must be at most %d bytes", maximumUsername)
|
||||
}
|
||||
if !utf8.ValidString(username) {
|
||||
return errors.New("username must be valid UTF-8")
|
||||
}
|
||||
for _, r := range username {
|
||||
if unicode.IsControl(r) {
|
||||
return errors.New("username must not contain control characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidatePassword(password string) error {
|
||||
if len(password) < MinimumPassword {
|
||||
return fmt.Errorf("password must be at least %d characters", MinimumPassword)
|
||||
}
|
||||
if len([]byte(password)) > maximumPassword {
|
||||
return fmt.Errorf("password must be at most %d bytes", maximumPassword)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeUser(raw []byte) (storedUser, error) {
|
||||
var user storedUser
|
||||
if err := json.Unmarshal(raw, &user); err != nil {
|
||||
return storedUser{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) Count() (int, error) {
|
||||
count := 0
|
||||
err := s.db.View(func(tx *bolt.Tx) error {
|
||||
count = tx.Bucket(usersBucket).Stats().KeyN
|
||||
return nil
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *Store) Users() ([]User, error) {
|
||||
users := []User{}
|
||||
err := s.db.View(func(tx *bolt.Tx) error {
|
||||
return tx.Bucket(usersBucket).ForEach(func(_, raw []byte) error {
|
||||
stored, err := decodeUser(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users = append(users, stored.User)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (s *Store) User(username string) (User, error) {
|
||||
var out User
|
||||
err := s.db.View(func(tx *bolt.Tx) error {
|
||||
raw := tx.Bucket(usersBucket).Get([]byte(normalizedUsername(username)))
|
||||
if raw == nil {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
stored, err := decodeUser(raw)
|
||||
if err == nil {
|
||||
out = stored.User
|
||||
}
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SetPassword creates an operator or replaces that operator's password. It is
|
||||
// intended for the local orchestra-user command; browser changes use Update,
|
||||
// which also proves the current password.
|
||||
func (s *Store) SetPassword(username, password string) (User, bool, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
if err := ValidatePassword(password); err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
return s.putHash(username, string(hash), false)
|
||||
}
|
||||
|
||||
// ImportBcrypt is the one-time compatibility bridge from the old environment
|
||||
// credential. It only creates the named user when the database is empty.
|
||||
func (s *Store) ImportBcrypt(username, passwordHash string) (bool, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil {
|
||||
return false, fmt.Errorf("legacy web password hash must be bcrypt: %w", err)
|
||||
}
|
||||
_, created, err := s.putHash(username, passwordHash, true)
|
||||
return created, err
|
||||
}
|
||||
|
||||
func (s *Store) putHash(username, passwordHash string, onlyIfEmpty bool) (User, bool, error) {
|
||||
now := time.Now().UTC()
|
||||
key := []byte(normalizedUsername(username))
|
||||
var out User
|
||||
created := false
|
||||
err := s.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket(usersBucket)
|
||||
if onlyIfEmpty && bucket.Stats().KeyN != 0 {
|
||||
return nil
|
||||
}
|
||||
stored := storedUser{User: User{Username: username, CreatedAt: now, UpdatedAt: now}, PasswordHash: passwordHash}
|
||||
if raw := bucket.Get(key); raw != nil {
|
||||
current, err := decodeUser(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored.CreatedAt = current.CreatedAt
|
||||
} else {
|
||||
created = true
|
||||
}
|
||||
encoded, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = stored.User
|
||||
return bucket.Put(key, encoded)
|
||||
})
|
||||
return out, created, err
|
||||
}
|
||||
|
||||
func (s *Store) Authenticate(username, password string) (User, error) {
|
||||
var stored storedUser
|
||||
found := false
|
||||
err := s.db.View(func(tx *bolt.Tx) error {
|
||||
raw := tx.Bucket(usersBucket).Get([]byte(normalizedUsername(username)))
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
stored, err = decodeUser(raw)
|
||||
found = err == nil
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
hash := dummyPasswordHash
|
||||
if found {
|
||||
hash = []byte(stored.PasswordHash)
|
||||
}
|
||||
passwordOK := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
|
||||
usernameOK := found && subtle.ConstantTimeCompare(
|
||||
[]byte(normalizedUsername(username)),
|
||||
[]byte(normalizedUsername(stored.Username)),
|
||||
) == 1
|
||||
if !passwordOK || !usernameOK {
|
||||
return User{}, ErrInvalidCredentials
|
||||
}
|
||||
return stored.User, nil
|
||||
}
|
||||
|
||||
// Update changes the authenticated operator's username and/or password. The
|
||||
// current password is required even though the endpoint also requires a live
|
||||
// browser session, protecting an unattended unlocked browser.
|
||||
func (s *Store) Update(currentUsername, currentPassword, newUsername, newPassword string) (User, error) {
|
||||
current, err := s.Authenticate(currentUsername, currentPassword)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
newUsername = strings.TrimSpace(newUsername)
|
||||
if newUsername == "" {
|
||||
newUsername = current.Username
|
||||
}
|
||||
if err := ValidateUsername(newUsername); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
var newHash string
|
||||
if newPassword != "" {
|
||||
if err := ValidatePassword(newPassword); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
newHash = string(hash)
|
||||
}
|
||||
|
||||
oldKey := []byte(normalizedUsername(current.Username))
|
||||
newKey := []byte(normalizedUsername(newUsername))
|
||||
var out User
|
||||
err = s.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket(usersBucket)
|
||||
raw := bucket.Get(oldKey)
|
||||
if raw == nil {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
stored, err := decodeUser(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Refuse a stale credential update if another password change landed
|
||||
// between Authenticate and this write transaction.
|
||||
if bcrypt.CompareHashAndPassword([]byte(stored.PasswordHash), []byte(currentPassword)) != nil {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if !bytesEqual(oldKey, newKey) && bucket.Get(newKey) != nil {
|
||||
return ErrUsernameExists
|
||||
}
|
||||
stored.Username = newUsername
|
||||
stored.UpdatedAt = time.Now().UTC()
|
||||
if newHash != "" {
|
||||
stored.PasswordHash = newHash
|
||||
}
|
||||
encoded, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bytesEqual(oldKey, newKey) {
|
||||
if err := bucket.Delete(oldKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := bucket.Put(newKey, encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
out = stored.User
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
return len(a) == len(b) && subtle.ConstantTimeCompare(a, b) == 1
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func openTestStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), DatabaseFile)
|
||||
store, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
return store, path
|
||||
}
|
||||
|
||||
func TestPasswordRecordPersistsAndAuthenticates(t *testing.T) {
|
||||
store, path := openTestStore(t)
|
||||
created, wasCreated, err := store.SetPassword("Kami", "correct horse battery")
|
||||
if err != nil || !wasCreated || created.Username != "Kami" {
|
||||
t.Fatalf("created=%+v new=%v err=%v", created, wasCreated, err)
|
||||
}
|
||||
if _, err := store.Authenticate("KAMI", "correct horse battery"); err != nil {
|
||||
t.Fatalf("authenticate: %v", err)
|
||||
}
|
||||
if _, err := store.Authenticate("Kami", "wrong password"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("wrong password error = %v", err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
if _, err := reopened.Authenticate("kami", "correct horse battery"); err != nil {
|
||||
t.Fatalf("persisted authentication: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRequiresCurrentPasswordAndMovesUsername(t *testing.T) {
|
||||
store, _ := openTestStore(t)
|
||||
if _, _, err := store.SetPassword("operator", "original password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Update("operator", "wrong password", "kami", "replacement password"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("wrong current password error = %v", err)
|
||||
}
|
||||
updated, err := store.Update("operator", "original password", "kami", "replacement password")
|
||||
if err != nil || updated.Username != "kami" {
|
||||
t.Fatalf("updated=%+v err=%v", updated, err)
|
||||
}
|
||||
if _, err := store.Authenticate("operator", "original password"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("old credential error = %v", err)
|
||||
}
|
||||
if _, err := store.Authenticate("kami", "replacement password"); err != nil {
|
||||
t.Fatalf("new credential: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRefusesExistingUsername(t *testing.T) {
|
||||
store, _ := openTestStore(t)
|
||||
if _, _, err := store.SetPassword("one", "password one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := store.SetPassword("two", "password two"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Update("one", "password one", "TWO", ""); !errors.Is(err, ErrUsernameExists) {
|
||||
t.Fatalf("collision error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyHashImportsOnlyIntoEmptyDatabase(t *testing.T) {
|
||||
store, _ := openTestStore(t)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("legacy password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if imported, err := store.ImportBcrypt("legacy", string(hash)); err != nil || !imported {
|
||||
t.Fatalf("imported=%v err=%v", imported, err)
|
||||
}
|
||||
if imported, err := store.ImportBcrypt("intruder", string(hash)); err != nil || imported {
|
||||
t.Fatalf("second import=%v err=%v", imported, err)
|
||||
}
|
||||
if _, err := store.Authenticate("legacy", "legacy password"); err != nil {
|
||||
t.Fatalf("imported credential: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialValidation(t *testing.T) {
|
||||
store, _ := openTestStore(t)
|
||||
if _, _, err := store.SetPassword("", "a sufficiently long password"); err == nil {
|
||||
t.Fatal("blank username accepted")
|
||||
}
|
||||
if _, _, err := store.SetPassword("operator", "short"); err == nil {
|
||||
t.Fatal("short password accepted")
|
||||
}
|
||||
if _, _, err := store.SetPassword("operator", string(make([]byte, maximumPassword+1))); err == nil {
|
||||
t.Fatal("oversized bcrypt password accepted")
|
||||
}
|
||||
}
|
||||
+46
-44
@@ -12,8 +12,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type Surface string
|
||||
@@ -120,41 +118,17 @@ func GatedWritePath(p string) bool {
|
||||
// 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 browserSession struct {
|
||||
Username string
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
type Sessions struct {
|
||||
mu sync.Mutex
|
||||
TTL time.Duration
|
||||
ids map[string]time.Time
|
||||
ids map[string]browserSession
|
||||
}
|
||||
|
||||
func (s *Sessions) ttl() time.Duration {
|
||||
@@ -164,9 +138,17 @@ func (s *Sessions) ttl() time.Duration {
|
||||
return 12 * time.Hour
|
||||
}
|
||||
|
||||
// Duration exposes the configured session lifetime for the cookie Max-Age.
|
||||
func (s *Sessions) Duration() time.Duration { return s.ttl() }
|
||||
|
||||
// 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) {
|
||||
return s.IssueFor("")
|
||||
}
|
||||
|
||||
// IssueFor mints a session bound to one database-backed operator identity.
|
||||
func (s *Sessions) IssueFor(username string) (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
@@ -176,34 +158,41 @@ func (s *Sessions) Issue() (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.ids == nil {
|
||||
s.ids = map[string]time.Time{}
|
||||
s.ids = map[string]browserSession{}
|
||||
}
|
||||
now := time.Now()
|
||||
for k, exp := range s.ids {
|
||||
if now.After(exp) {
|
||||
for k, session := range s.ids {
|
||||
if now.After(session.Expires) {
|
||||
delete(s.ids, k)
|
||||
}
|
||||
}
|
||||
s.ids[hex.EncodeToString(sum[:])] = now.Add(s.ttl())
|
||||
s.ids[hex.EncodeToString(sum[:])] = browserSession{Username: username, Expires: now.Add(s.ttl())}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *Sessions) Valid(v string) bool {
|
||||
_, ok := s.Username(v)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Username validates a receipt and returns the operator it belongs to.
|
||||
func (s *Sessions) Username(v string) (string, bool) {
|
||||
if v == "" {
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
sum := sha256.Sum256([]byte(v))
|
||||
key := hex.EncodeToString(sum[:])
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
exp, ok := s.ids[hex.EncodeToString(sum[:])]
|
||||
session, ok := s.ids[key]
|
||||
if !ok {
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
if time.Now().After(exp) {
|
||||
delete(s.ids, hex.EncodeToString(sum[:]))
|
||||
return false
|
||||
if time.Now().After(session.Expires) {
|
||||
delete(s.ids, key)
|
||||
return "", false
|
||||
}
|
||||
return true
|
||||
return session.Username, true
|
||||
}
|
||||
|
||||
// Revoke removes one browser session. It is deliberately idempotent so a
|
||||
@@ -219,6 +208,18 @@ func (s *Sessions) Revoke(v string) {
|
||||
delete(s.ids, hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
// RevokeUser ends every browser session for an identity after its username or
|
||||
// password changes.
|
||||
func (s *Sessions) RevokeUser(username string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for key, session := range s.ids {
|
||||
if strings.EqualFold(session.Username, username) {
|
||||
delete(s.ids, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -272,7 +273,8 @@ func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.H
|
||||
if c, err := r.Cookie(SessionCookie); err == nil {
|
||||
ok = sessions.Valid(c.Value)
|
||||
}
|
||||
// The login endpoint authenticates itself, and the SPA shell must
|
||||
// The session endpoint authenticates login and session lookup 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)) {
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestSurfaceCapabilities(t *testing.T) {
|
||||
@@ -100,28 +98,6 @@ func TestWebSessionCookieGatesControlPathsOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebCredentialsAuthenticate(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("correct horse battery staple"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := WebCredentials{Username: "operator", PasswordHash: string(hash)}
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if !c.Authenticate("operator", "correct horse battery staple") {
|
||||
t.Fatal("correct credentials rejected")
|
||||
}
|
||||
for _, attempt := range []struct{ username, password string }{{"operator", "wrong"}, {"other", "correct horse battery staple"}} {
|
||||
if c.Authenticate(attempt.username, attempt.password) {
|
||||
t.Fatalf("invalid credentials accepted: %+v", attempt)
|
||||
}
|
||||
}
|
||||
if err := (WebCredentials{Username: "operator", PasswordHash: "not-a-bcrypt-hash"}).Validate(); err == nil {
|
||||
t.Fatal("invalid bcrypt hash accepted")
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -189,6 +165,28 @@ func TestSessionRevoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTracksAndRevokesOperator(t *testing.T) {
|
||||
s := &Sessions{}
|
||||
one, err := s.IssueFor("kami")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
two, err := s.IssueFor("other")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if username, ok := s.Username(one); !ok || username != "kami" {
|
||||
t.Fatalf("username=%q ok=%v", username, ok)
|
||||
}
|
||||
s.RevokeUser("KAMI")
|
||||
if s.Valid(one) {
|
||||
t.Fatal("operator session survived credential change")
|
||||
}
|
||||
if !s.Valid(two) {
|
||||
t.Fatal("another operator's session was revoked")
|
||||
}
|
||||
}
|
||||
|
||||
// The agent boundary: an agent may perform work and request lifecycle changes,
|
||||
// never perform one. Both halves are proven here — the bus refuses the event
|
||||
// types Orchestra owns, and the middleware refuses their endpoints — because
|
||||
|
||||
@@ -182,9 +182,17 @@ func (r *Router) AssignPending() ([]domain.Event, error) {
|
||||
snapshot := r.Store.SchedulingSnapshot()
|
||||
var queued []domain.Task
|
||||
for _, t := range snapshot.Tasks {
|
||||
if t.State == domain.StateQueued && (t.NextRetryAt.IsZero() || !now.Before(t.NextRetryAt)) {
|
||||
queued = append(queued, t)
|
||||
if t.State != domain.StateQueued {
|
||||
continue
|
||||
}
|
||||
if !t.NextRetryAt.IsZero() && now.Before(t.NextRetryAt) {
|
||||
// A queued task the pass never even considers is the most
|
||||
// confusing state of all: it looks assignable and nothing is
|
||||
// recorded against it. Say so.
|
||||
r.reject(t.ID, "", "retry backoff until "+t.NextRetryAt.UTC().Format(time.RFC3339))
|
||||
continue
|
||||
}
|
||||
queued = append(queued, t)
|
||||
}
|
||||
sort.SliceStable(queued, func(i, j int) bool { return importance(queued[i], now).Before(importance(queued[j], now)) })
|
||||
health := r.healthSnapshot(now)
|
||||
|
||||
@@ -364,6 +364,25 @@ func TestAssignPendingRecordsWhyItPlacedNothing(t *testing.T) {
|
||||
t.Fatalf("pre-lease refusal not reported: %+v", rt.Rejections())
|
||||
}
|
||||
|
||||
// A queued task in retry backoff is skipped before the candidate loop, so
|
||||
// it needs its own reason: it looks assignable and nothing else records it.
|
||||
if err := s.Append(domain.Event{ID: "backoff", TaskID: "t", Type: "TaskCorrected", Version: 2, Payload: backoffPayload(time.Now().Add(time.Hour)), Surface: string(authz.System)}); err == nil {
|
||||
if got, _ := s.Task("t"); !got.NextRetryAt.IsZero() {
|
||||
if _, err := rt.AssignPending(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backoff := false
|
||||
for _, rej := range rt.Rejections() {
|
||||
if strings.Contains(rej.Reason, "retry backoff") {
|
||||
backoff = true
|
||||
}
|
||||
}
|
||||
if !backoff {
|
||||
t.Fatalf("a task in retry backoff was silently skipped: %+v", rt.Rejections())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A successful pass leaves nothing behind to misread.
|
||||
s.PreLease = nil
|
||||
if got, err := rt.AssignPending(); err != nil || len(got) != 1 {
|
||||
@@ -373,3 +392,8 @@ func TestAssignPendingRecordsWhyItPlacedNothing(t *testing.T) {
|
||||
t.Fatalf("stale rejections after a successful pass: %+v", reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func backoffPayload(at time.Time) []byte {
|
||||
b, _ := json.Marshal(map[string]any{"next_retry_at": at.UTC().Format(time.RFC3339Nano)})
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -17,9 +17,20 @@ describe('UI API client',()=>{
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/artifacts',expect.objectContaining({method:'POST',body:'report'}))
|
||||
})
|
||||
it('submits username and password to the browser login endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(null,{status:204}))
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'operator'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await api.login('operator','not stored in the browser')
|
||||
await expect(api.login('operator','not stored in the browser')).resolves.toEqual({username:'operator'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/session',expect.objectContaining({method:'POST',body:JSON.stringify({username:'operator',password:'not stored in the browser'})}))
|
||||
})
|
||||
it('treats a missing browser session as a normal signed-out state',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response('unauthorized',{status:401}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.session()).resolves.toBeUndefined()
|
||||
})
|
||||
it('updates account credentials through the session-gated account endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'kami'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'}))
|
||||
})
|
||||
})
|
||||
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
import type { CreatedEvent, Detail, Overview } from './types'
|
||||
import type { Account, CreatedEvent, Detail, Overview } from './types'
|
||||
|
||||
function sessionExpired(response: Response) {
|
||||
if (response.status === 401) {
|
||||
@@ -49,6 +49,13 @@ async function upload(body: string) {
|
||||
return ((await response.json()) as { ref: string }).ref
|
||||
}
|
||||
|
||||
async function session(): Promise<Account | undefined> {
|
||||
const response = await fetch('/v1/ui/session', { credentials: 'same-origin' })
|
||||
if (response.status === 401) return undefined
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const response = await fetch('/v1/ui/session', {
|
||||
method: 'POST',
|
||||
@@ -57,6 +64,7 @@ async function login(username: string, password: string) {
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
@@ -68,8 +76,15 @@ async function logout() {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
session,
|
||||
login,
|
||||
logout,
|
||||
account: () => request<Account>('/v1/ui/account'),
|
||||
updateAccount: (body: { current_password: string; username: string; new_password?: string }) =>
|
||||
request<Account>('/v1/ui/account', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
overview: () => request<Overview>('/v1/ui/overview'),
|
||||
detail: (id: string) => request<Detail>(`/v1/ui/tasks/${id}`),
|
||||
artifact: (ref: string) => text(`/v1/ui/artifacts/${ref}`),
|
||||
|
||||
+17
-1
@@ -1,4 +1,11 @@
|
||||
export type TaskState = 'queued' | 'leased' | 'blocked' | 'completed' | 'failed'
|
||||
export type TaskState =
|
||||
| 'queued'
|
||||
| 'leased'
|
||||
| 'needs_attention'
|
||||
| 'blocked'
|
||||
| 'in_review'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
|
||||
export type BlockReason =
|
||||
| 'lease_failure'
|
||||
@@ -8,8 +15,17 @@ export type BlockReason =
|
||||
| 'handoff_validation'
|
||||
| 'operator_block'
|
||||
| 'system_error'
|
||||
| 'trajectory_gate'
|
||||
| 'human_decision'
|
||||
| 'operator_required'
|
||||
| 'unknown'
|
||||
|
||||
export interface Account {
|
||||
username: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SessionEvidence {
|
||||
pane_id?: string
|
||||
harness_id?: string
|
||||
|
||||
+160
-11
@@ -19,6 +19,7 @@ import {
|
||||
} from '@tanstack/react-query'
|
||||
import { api } from './api/client'
|
||||
import type {
|
||||
Account,
|
||||
Action,
|
||||
BlockReason,
|
||||
Capture,
|
||||
@@ -38,14 +39,16 @@ const client = new QueryClient({
|
||||
},
|
||||
})
|
||||
|
||||
const allStates: TaskState[] = ['queued', 'leased', 'blocked', 'completed', 'failed']
|
||||
const activeStates: TaskState[] = ['queued', 'leased', 'blocked']
|
||||
const allStates: TaskState[] = ['queued', 'leased', 'needs_attention', 'blocked', 'in_review', 'completed', 'failed']
|
||||
const activeStates: TaskState[] = ['queued', 'leased', 'needs_attention', 'blocked', 'in_review']
|
||||
const historyStates: TaskState[] = ['completed', 'failed']
|
||||
|
||||
const stateLabel: Record<TaskState, string> = {
|
||||
queued: 'Queued',
|
||||
leased: 'In session',
|
||||
needs_attention: 'Needs attention',
|
||||
blocked: 'Blocked',
|
||||
in_review: 'In review',
|
||||
completed: 'Complete',
|
||||
failed: 'Failed',
|
||||
}
|
||||
@@ -58,6 +61,9 @@ const blockLabel: Record<BlockReason, string> = {
|
||||
handoff_validation: 'Handoff validation',
|
||||
operator_block: 'Operator block',
|
||||
system_error: 'System error',
|
||||
trajectory_gate: 'Plan approval',
|
||||
human_decision: 'Decision needed',
|
||||
operator_required: 'Operator required',
|
||||
unknown: 'Unknown',
|
||||
}
|
||||
|
||||
@@ -94,6 +100,8 @@ type IconName =
|
||||
| 'refresh'
|
||||
| 'search'
|
||||
| 'server'
|
||||
| 'settings'
|
||||
| 'shield'
|
||||
| 'terminal'
|
||||
| 'users'
|
||||
| 'x'
|
||||
@@ -161,6 +169,13 @@ function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
|
||||
<path d="M7 7h.01M7 17h.01" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Zm-3-10 2 2 4-5" />,
|
||||
terminal: (
|
||||
<>
|
||||
<path d="m4 6 5 5-5 5M11 18h9" />
|
||||
@@ -240,6 +255,11 @@ function humanize(value: string) {
|
||||
.replace(/^./, (letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
function initials(username: string) {
|
||||
const parts = username.trim().split(/[\s._-]+/).filter(Boolean)
|
||||
return (parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : username.slice(0, 2)).toUpperCase()
|
||||
}
|
||||
|
||||
function sessionFor(task: Task, overview: Overview) {
|
||||
const sessions = overview.sessions ?? []
|
||||
const captured = sessions.find((session) => session.capture?.task_id === task.id)
|
||||
@@ -264,9 +284,13 @@ function taskExplanation(task: Task, overview: Overview) {
|
||||
if (session?.blocker) return session.blocker
|
||||
return session?.capture ? 'Harness is publishing live output' : 'Leased · capture unavailable'
|
||||
}
|
||||
if (task.state === 'needs_attention') {
|
||||
return task.blocker || 'The current lease is retained while an operator investigates'
|
||||
}
|
||||
if (task.state === 'blocked') {
|
||||
return task.blocker || 'No blocker detail was retained for this task'
|
||||
}
|
||||
if (task.state === 'in_review') return 'Submitted change is waiting for human review'
|
||||
if (task.state === 'failed') return task.last_error || 'Review the failure before retrying'
|
||||
return 'Work and completion evidence retained'
|
||||
}
|
||||
@@ -320,6 +344,7 @@ function CommandPalette({ close }: { close: () => void }) {
|
||||
['board', 'Open dispatch board', '', 'grid'],
|
||||
['new', 'Create a new task', 'N', 'plus'],
|
||||
['workers', 'Open worker pool', '', 'server'],
|
||||
['settings', 'Open account settings', '', 'settings'],
|
||||
['refresh', 'Refresh live data', 'R', 'refresh'],
|
||||
] as const,
|
||||
[],
|
||||
@@ -339,6 +364,7 @@ function CommandPalette({ close }: { close: () => void }) {
|
||||
const choose = (id: string) => {
|
||||
if (id === 'board') navigate('/')
|
||||
if (id === 'workers') navigate('/workers')
|
||||
if (id === 'settings') navigate('/settings')
|
||||
if (id === 'new') {
|
||||
navigate('/')
|
||||
window.setTimeout(() => window.dispatchEvent(new Event('orchestra:new-task')), 0)
|
||||
@@ -405,7 +431,7 @@ function CommandPalette({ close }: { close: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Shell({ children, onLogout }: { children: React.ReactNode; onLogout: () => void }) {
|
||||
function Shell({ children, account, onLogout }: { children: React.ReactNode; account: Account; onLogout: () => void }) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [palette, setPalette] = useState(false)
|
||||
@@ -425,6 +451,8 @@ function Shell({ children, onLogout }: { children: React.ReactNode; onLogout: ()
|
||||
? 'Dispatch board'
|
||||
: location.pathname === '/workers'
|
||||
? 'Worker pool'
|
||||
: location.pathname === '/settings'
|
||||
? 'Account settings'
|
||||
: location.pathname.startsWith('/artifacts/')
|
||||
? 'Evidence artifact'
|
||||
: 'Task record'
|
||||
@@ -473,6 +501,10 @@ function Shell({ children, onLogout }: { children: React.ReactNode; onLogout: ()
|
||||
<span>Workers</span>
|
||||
<b className="nav-count">{onlineWorkers}/{workers.length}</b>
|
||||
</NavLink>
|
||||
<NavLink to="/settings">
|
||||
<Icon name="settings" />
|
||||
<span>Settings</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="sidebar-status">
|
||||
<span className={onlineWorkers ? 'signal online' : 'signal'} />
|
||||
@@ -506,13 +538,17 @@ function Shell({ children, onLogout }: { children: React.ReactNode; onLogout: ()
|
||||
aria-expanded={accountOpen}
|
||||
onClick={() => setAccountOpen((open) => !open)}
|
||||
>
|
||||
<span className="avatar">OP</span>
|
||||
<span className="account-label">Operator</span>
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span className="account-label">{account.username}</span>
|
||||
</button>
|
||||
{accountOpen && (
|
||||
<div className="account-menu">
|
||||
<span>Browser session active</span>
|
||||
<button type="button" onClick={onLogout}>Sign out</button>
|
||||
<div className="account-menu-user">
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span><b>{account.username}</b><small>Operator account</small></span>
|
||||
</div>
|
||||
<Link to="/settings" onClick={() => setAccountOpen(false)}><Icon name="settings" size={15} /> Account settings</Link>
|
||||
<button type="button" onClick={onLogout}><Icon name="arrow-left" size={15} /> Sign out</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -905,9 +941,9 @@ function OverviewPage() {
|
||||
const projects = [...new Set(data.tasks.map((task) => task.project).filter(Boolean))].sort()
|
||||
const pendingApprovals = sessions.filter((session) => session.pending_approval)
|
||||
const approvalTask = pendingApprovals.find((session) => session.capture?.task_id)?.capture?.task_id
|
||||
const inSession = data.tasks.filter((task) => task.state === 'leased').length
|
||||
const inSession = data.tasks.filter((task) => task.state === 'leased' || task.state === 'needs_attention').length
|
||||
const queued = data.tasks.filter((task) => task.state === 'queued').length
|
||||
const attention = data.tasks.filter((task) => task.state === 'blocked' || task.state === 'failed').length
|
||||
const attention = data.tasks.filter((task) => task.state === 'needs_attention' || task.state === 'blocked' || task.state === 'failed').length
|
||||
const history = data.tasks.filter((task) => historyStates.includes(task.state)).length
|
||||
const onlineWorkers = data.workers.filter((worker) => worker.online).length
|
||||
const term = search.trim().toLowerCase()
|
||||
@@ -1231,14 +1267,14 @@ function TaskDiagnosis({ detail }: { detail: Detail }) {
|
||||
const observed = evidence?.captured_at || evidence?.checked_at || detail.session?.capture?.at
|
||||
const title = approval
|
||||
? 'Waiting for operator approval'
|
||||
: detail.task.state === 'blocked'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
? blockLabel[detail.task.block_reason || 'unknown']
|
||||
: detail.task.state === 'leased'
|
||||
? detail.session?.capture ? 'Agent session is active' : 'Session capture is unavailable'
|
||||
: stateLabel[detail.task.state]
|
||||
const explanation = approval
|
||||
? 'The harness is paused at a permission boundary. Review the exact request below.'
|
||||
: detail.task.state === 'blocked'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
? detail.task.blocker || 'No blocker detail was retained.'
|
||||
: detail.task.state === 'leased'
|
||||
? detail.session?.capture
|
||||
@@ -1246,6 +1282,8 @@ function TaskDiagnosis({ detail }: { detail: Detail }) {
|
||||
: detail.session?.blocker || 'The lease exists, but Orchestra cannot read current pane output.'
|
||||
: detail.task.state === 'queued'
|
||||
? 'This task is eligible for routing when a compatible worker has capacity.'
|
||||
: detail.task.state === 'in_review'
|
||||
? 'The implementation was submitted and is waiting for the bound human review.'
|
||||
: 'This is a terminal task record with retained evidence.'
|
||||
|
||||
return (
|
||||
@@ -1542,6 +1580,117 @@ function Workers() {
|
||||
)
|
||||
}
|
||||
|
||||
function Settings({ account, onCredentialsChanged }: { account: Account; onCredentialsChanged: (username: string) => void }) {
|
||||
const [username, setUsername] = useState(account.username)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.updateAccount({
|
||||
current_password: currentPassword,
|
||||
username: username.trim(),
|
||||
...(newPassword ? { new_password: newPassword } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => onCredentialsChanged(updated.username),
|
||||
})
|
||||
const usernameChanged = username.trim() !== account.username
|
||||
const changed = usernameChanged || !!newPassword
|
||||
const passwordLongEnough = newPassword.length >= 10
|
||||
const passwordWithinLimit = new TextEncoder().encode(newPassword).length <= 72
|
||||
const passwordsMatch = newPassword === confirmation
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
if (!username.trim()) {
|
||||
setFormError('Username cannot be empty.')
|
||||
return
|
||||
}
|
||||
if (!changed) {
|
||||
setFormError('Change the username or enter a new password first.')
|
||||
return
|
||||
}
|
||||
if (!currentPassword) {
|
||||
setFormError('Enter your current password to authorize this change.')
|
||||
return
|
||||
}
|
||||
if (newPassword && (!passwordLongEnough || !passwordWithinLimit || !passwordsMatch)) {
|
||||
setFormError(!passwordsMatch ? 'The new passwords do not match.' : 'Use a password between 10 and 72 bytes.')
|
||||
return
|
||||
}
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page settings-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Operator identity</span>
|
||||
<h1>Your account.</h1>
|
||||
<p>Change the credentials you use for this control plane. No environment hash is involved.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="settings-layout">
|
||||
<aside className="profile-card">
|
||||
<span className="profile-avatar">{initials(account.username)}</span>
|
||||
<h2>{account.username}</h2>
|
||||
<p>Full-control operator</p>
|
||||
<dl>
|
||||
<div><dt>Account created</dt><dd>{date(account.created_at)}</dd></div>
|
||||
<div><dt>Credentials updated</dt><dd>{date(account.updated_at)}</dd></div>
|
||||
</dl>
|
||||
<div className="database-badge"><Icon name="shield" size={17} /><span><b>Local credential database</b><small>Password hashes stay inside Orchestra’s data volume.</small></span></div>
|
||||
</aside>
|
||||
|
||||
<section className="settings-card">
|
||||
<header>
|
||||
<span className="settings-icon"><Icon name="settings" /></span>
|
||||
<div><h2>Sign-in credentials</h2><p>Changing either field signs out every browser using this account.</p></div>
|
||||
</header>
|
||||
<form onSubmit={submit} noValidate>
|
||||
<label htmlFor="account-username">Username</label>
|
||||
<input id="account-username" autoComplete="username" value={username} onChange={(event) => { setUsername(event.target.value); setFormError('') }} />
|
||||
|
||||
<div className="settings-divider"><span>Optional password change</span></div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="account-new-password">New password
|
||||
<div className="password-field">
|
||||
<input id="account-new-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={newPassword} onChange={(event) => { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" />
|
||||
<button type="button" onClick={() => setVisible((value) => !value)}>{visible ? 'Hide' : 'Show'}</button>
|
||||
</div>
|
||||
</label>
|
||||
<label htmlFor="account-confirm-password">Confirm new password
|
||||
<input id="account-confirm-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={confirmation} onChange={(event) => { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" />
|
||||
</label>
|
||||
</div>
|
||||
{newPassword && (
|
||||
<div className="password-rules" aria-live="polite">
|
||||
<span className={passwordLongEnough ? 'met' : ''}><Icon name="check" size={13} /> 10+ characters</span>
|
||||
<span className={passwordWithinLimit ? 'met' : ''}><Icon name="check" size={13} /> 72 bytes or fewer</span>
|
||||
<span className={passwordsMatch && !!confirmation ? 'met' : ''}><Icon name="check" size={13} /> Passwords match</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="current-password-block">
|
||||
<label htmlFor="account-current-password">Current password</label>
|
||||
<p>Required to save account changes.</p>
|
||||
<input id="account-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => { setCurrentPassword(event.target.value); setFormError('') }} />
|
||||
</div>
|
||||
{(formError || mutation.error) && <p className="form-error" role="alert"><Icon name="alert" size={15} /> {formError || errorMessage(mutation.error)}</p>}
|
||||
<footer className="settings-actions">
|
||||
<span>You’ll sign in again after saving.</span>
|
||||
<button type="submit" disabled={mutation.isPending || !changed}>{mutation.isPending ? 'Saving…' : 'Save credentials'}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Artifact() {
|
||||
const { ref = '' } = useParams()
|
||||
const query = useQuery({ queryKey: ['artifact', ref], queryFn: () => api.artifact(ref) })
|
||||
|
||||
Reference in New Issue
Block a user