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:
2026-08-26 23:42:26 +04:00
parent 0ead6d2d02
commit 4fbf3ac966
18 changed files with 1163 additions and 165 deletions
+165
View File
@@ -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)
}
}
+94
View File
@@ -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)
}
}
+343
View File
@@ -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
}
+108
View File
@@ -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
View File
@@ -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)) {
+22 -24
View File
@@ -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
+10 -2
View File
@@ -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)
+24
View File
@@ -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
}