Files
Agent-System-Backend/internal/server/account_search.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

205 lines
5.9 KiB
Go

package server
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"agent-system-backend/internal/store"
)
type adminSearchCursor struct {
KeyVersion int `json:"k"`
Version string `json:"v"`
Owner string `json:"o"`
UserID string `json:"u"`
}
func (s *Server) signAdminCursor(cursor adminSearchCursor) string {
cursor.KeyVersion = s.accountSearchKey.Version
data, _ := json.Marshal(cursor)
mac := hmac.New(sha256.New, s.accountSearchKey.Secret[:])
_, _ = mac.Write([]byte("iam-admin-search-cursor-v1\x00"))
_, _ = mac.Write(data)
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (s *Server) parseAdminCursor(encoded, version string) (adminSearchCursor, bool) {
var cursor adminSearchCursor
payload, signature, found := strings.Cut(encoded, ".")
if !found {
return cursor, false
}
data, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil || len(data) > 1024 {
return cursor, false
}
provided, err := base64.RawURLEncoding.DecodeString(signature)
if err != nil {
return cursor, false
}
mac := hmac.New(sha256.New, s.accountSearchKey.Secret[:])
_, _ = mac.Write([]byte("iam-admin-search-cursor-v1\x00"))
_, _ = mac.Write(data)
if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &cursor) != nil || cursor.KeyVersion != s.accountSearchKey.Version || cursor.Version != version || cursor.Owner == "" || cursor.UserID == "" {
return adminSearchCursor{}, false
}
return cursor, true
}
func searchAccountField(account store.Account, field string) string {
switch field {
case "userId":
return account.ID
case "owner":
return account.Owner
case "name":
return account.Profile.Name
case "phone":
return account.Profile.Phone
case "email":
return account.Profile.Email
case "staffNo":
return account.Profile.StaffNo
case "department":
return account.Profile.Department
case "notes":
return account.Profile.Notes
}
return ""
}
func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) {
if s.accountSearchKeyErr != nil || s.accountSearchKey.Version < 1 {
http.Error(w, "account directory signing unavailable", http.StatusServiceUnavailable)
return
}
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
params := r.URL.Query()
query := strings.TrimSpace(params.Get("query"))
if !utf8.ValidString(query) || utf8.RuneCountInString(query) > 128 {
http.Error(w, "invalid account search query", http.StatusBadRequest)
return
}
for _, ch := range query {
if unicode.IsControl(ch) {
http.Error(w, "invalid account search query", http.StatusBadRequest)
return
}
}
mode := params.Get("mode")
if mode == "" {
mode = "fuzzy"
}
if mode != "exact" && mode != "fuzzy" {
http.Error(w, "unknown account search mode", http.StatusBadRequest)
return
}
status := params.Get("status")
if status == "" {
status = "all"
}
if status != "all" && status != "enabled" && status != "disabled" {
http.Error(w, "unknown account status filter", http.StatusBadRequest)
return
}
limit := 20
if raw := params.Get("limit"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > 100 {
http.Error(w, "invalid account search limit", http.StatusBadRequest)
return
}
limit = parsed
}
fields := []string{"owner", "name", "userId"}
if raw := params.Get("fields"); raw != "" {
fields = strings.Split(raw, ",")
}
allowed := map[string]bool{"owner": true, "name": true, "userId": true, "phone": true, "email": true,
"staffNo": true, "department": true, "notes": true}
seen := map[string]bool{}
for _, field := range fields {
if !allowed[field] || seen[field] {
http.Error(w, "unknown or duplicate account search field", http.StatusBadRequest)
return
}
seen[field] = true
}
sort.Strings(fields)
accounts, revision, err := s.st.ListAdminAccountsForActor(actor)
if err != nil {
writeStoreErr(w, err)
return
}
visibleRevision := sha256.Sum256([]byte(revision.Private))
directoryVersion := hex.EncodeToString(visibleRevision[:])
fingerprint, _ := json.Marshal(struct {
Actor, Query, Mode, Status, Revision string
Fields []string
}{actor, query, mode, status, directoryVersion, fields})
digest := sha256.Sum256(fingerprint)
version := hex.EncodeToString(digest[:])
var after adminSearchCursor
if token := params.Get("cursor"); token != "" {
var valid bool
after, valid = s.parseAdminCursor(token, version)
if !valid {
http.Error(w, "account search cursor expired or invalid", http.StatusConflict)
return
}
}
page := make([]store.Account, 0, limit+1)
for _, account := range accounts {
if status == "enabled" && !account.Enabled || status == "disabled" && account.Enabled {
continue
}
if after.Owner != "" && (account.Owner < after.Owner || account.Owner == after.Owner && account.ID <= after.UserID) {
continue
}
if query != "" {
matched := false
for _, field := range fields {
value := searchAccountField(account, field)
if mode == "exact" && strings.EqualFold(value, query) || mode == "fuzzy" && strings.Contains(strings.ToLower(value), strings.ToLower(query)) {
matched = true
break
}
}
if !matched {
continue
}
}
page = append(page, account)
if len(page) > limit {
break
}
}
more := len(page) > limit
if more {
page = page[:limit]
}
items := make([]map[string]any, 0, len(page))
for _, account := range page {
items = append(items, accountProfileJSON(account))
}
next := ""
if more {
last := page[len(page)-1]
next = s.signAdminCursor(adminSearchCursor{Version: version, Owner: last.Owner, UserID: last.ID})
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{"accounts": items, "nextCursor": next, "directoryVersion": directoryVersion})
}