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.
205 lines
5.9 KiB
Go
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})
|
|
}
|