diff --git a/Dockerfile.api b/Dockerfile.api index 65ca221..78dc061 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -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"] diff --git a/cmd/orchestra-password/main.go b/cmd/orchestra-password/main.go deleted file mode 100644 index 2fc4287..0000000 --- a/cmd/orchestra-password/main.go +++ /dev/null @@ -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)) -} diff --git a/cmd/orchestra-user/main.go b/cmd/orchestra-user/main.go new file mode 100644 index 0000000..12f22e3 --- /dev/null +++ b/cmd/orchestra-user/main.go @@ -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) + } +} diff --git a/cmd/orchestra/main.go b/cmd/orchestra/main.go index 5828297..23bd312 100644 --- a/cmd/orchestra/main.go +++ b/cmd/orchestra/main.go @@ -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 { diff --git a/go.mod b/go.mod index af0256c..a939fcf 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 7a5ab42..d8c4934 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/authn/http.go b/internal/authn/http.go new file mode 100644 index 0000000..c3ad56c --- /dev/null +++ b/internal/authn/http.go @@ -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) + } +} diff --git a/internal/authn/http_test.go b/internal/authn/http_test.go new file mode 100644 index 0000000..5718f43 --- /dev/null +++ b/internal/authn/http_test.go @@ -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) + } +} diff --git a/internal/authn/store.go b/internal/authn/store.go new file mode 100644 index 0000000..cc19b96 --- /dev/null +++ b/internal/authn/store.go @@ -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 +} diff --git a/internal/authn/store_test.go b/internal/authn/store_test.go new file mode 100644 index 0000000..9ee55ce --- /dev/null +++ b/internal/authn/store_test.go @@ -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") + } +} diff --git a/internal/authz/authz.go b/internal/authz/authz.go index a7b6f3d..5cda532 100644 --- a/internal/authz/authz.go +++ b/internal/authz/authz.go @@ -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)) { diff --git a/internal/authz/authz_test.go b/internal/authz/authz_test.go index e384cfd..b18f42f 100644 --- a/internal/authz/authz_test.go +++ b/internal/authz/authz_test.go @@ -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 diff --git a/internal/router/router.go b/internal/router/router.go index def8990..57d6c78 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 8802ccd..01cafd1 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -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 +} diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 84aa708..87d048a 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -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'})) + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index b136733..eb1f135 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + 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 +} + 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 } async function logout() { @@ -68,8 +76,15 @@ async function logout() { } export const api = { + session, login, logout, + account: () => request('/v1/ui/account'), + updateAccount: (body: { current_password: string; username: string; new_password?: string }) => + request('/v1/ui/account', { + method: 'PUT', + body: JSON.stringify(body), + }), overview: () => request('/v1/ui/overview'), detail: (id: string) => request(`/v1/ui/tasks/${id}`), artifact: (ref: string) => text(`/v1/ui/artifacts/${ref}`), diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 6e67233..9fbc3e8 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 diff --git a/web/src/main.tsx b/web/src/main.tsx index a9f82f2..3f622ee 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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 = { 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 = { 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 }) { ), + settings: ( + <> + + + + ), + shield: , terminal: ( <> @@ -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: () Workers {onlineWorkers}/{workers.length} + + + Settings +
@@ -506,13 +538,17 @@ function Shell({ children, onLogout }: { children: React.ReactNode; onLogout: () aria-expanded={accountOpen} onClick={() => setAccountOpen((open) => !open)} > - OP - Operator + {initials(account.username)} + {account.username} {accountOpen && (
- Browser session active - +
+ {initials(account.username)} + {account.username}Operator account +
+ setAccountOpen(false)}> Account settings +
)}
@@ -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 ( +
+
+
+ Operator identity +

Your account.

+

Change the credentials you use for this control plane. No environment hash is involved.

+
+
+ +
+ + +
+
+ +

Sign-in credentials

Changing either field signs out every browser using this account.

+
+
+ + { setUsername(event.target.value); setFormError('') }} /> + +
Optional password change
+
+ + +
+ {newPassword && ( +
+ 10+ characters + 72 bytes or fewer + Passwords match +
+ )} + +
+ +

Required to save account changes.

+ { setCurrentPassword(event.target.value); setFormError('') }} /> +
+ {(formError || mutation.error) &&

{formError || errorMessage(mutation.error)}

} + +
+
+
+
+ ) +} + function Artifact() { const { ref = '' } = useParams() const query = useQuery({ queryKey: ['artifact', ref], queryFn: () => api.artifact(ref) })