Files
Agent-System-Backend/internal/app/account_directory.go
T
agent-exec-transport 612e04402d
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m43s
agent-exec transport be0c4744ff5899017313eb3f4fbd4a3f/43fe057c8332
2026-10-20 11:57:53 +00:00

416 lines
13 KiB
Go

package app
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"sort"
"strings"
"sync"
"unicode"
"unicode/utf8"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
type accountDirectorySlot struct {
mu sync.RWMutex
pluginID string
resolver extension.AccountDirectoryScopeResolver
secret [32]byte
keyVersion int
}
func newAccountDirectorySlot(st *store.Store) (*accountDirectorySlot, error) {
key, err := st.GetAccountCursorKey()
if err != nil {
return nil, err
}
return &accountDirectorySlot{secret: key.Secret, keyVersion: key.Version}, nil
}
func (s *accountDirectorySlot) register(pluginID string, fn extension.AccountDirectoryScopeResolver) error {
if fn == nil || pluginID != "rdm" {
return store.ErrAccountForbidden
}
s.mu.Lock()
defer s.mu.Unlock()
if s.resolver != nil {
return errors.New("account directory resolver already registered")
}
s.pluginID, s.resolver = pluginID, fn
return nil
}
type directoryPolicy struct {
allowed map[string]bool
match []string
candidates map[string]bool
owners map[string]bool
rows []store.DirectoryAccountRow
all bool
status string
version string
}
type directoryCursor struct {
KeyVersion int `json:"k"`
Version string `json:"v"`
Owner string `json:"o"`
UserID string `json:"u"`
}
type directoryVisibleRow struct {
UserID string
Owner string
Enabled bool
Name string
StaffNo string
}
func knownDirectoryField(field string) bool {
return field == "owner" || field == "name" || field == "staffNo"
}
func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDirectoryQuery, loadRows bool) (directoryPolicy, error) {
var p directoryPolicy
if err := h.requireEffective(); err != nil {
return p, err
}
if h.actor == "" || h.caps.st == nil || h.caps.directory == nil || q.SchemaVersion != extension.AccountDirectorySchemaVersion {
return p, store.ErrAccountForbidden
}
actor, err := h.caps.st.GetUserAuthState(h.actor)
if err != nil || !actor.Enabled {
return p, store.ErrAccountForbidden
}
if q.Mode != extension.AccountDirectoryModeExact && q.Mode != extension.AccountDirectoryModeFuzzy {
return p, store.ErrInvalidAccountProfile
}
if !utf8.ValidString(q.Query) || utf8.RuneCountInString(q.Query) > 128 {
return p, store.ErrInvalidAccountProfile
}
for _, r := range q.Query {
if unicode.IsControl(r) {
return p, store.ErrInvalidAccountProfile
}
}
p.status = q.Status
if p.status == "" {
p.status = "enabled"
}
if p.status != "enabled" && p.status != "disabled" && p.status != "all" {
return p, store.ErrInvalidAccountProfile
}
var scope extension.AccountDirectoryScope
switch q.Purpose {
case extension.AccountDirectoryPurposeSystemUsers:
if !actor.IsAdmin || q.ProjectID != "" || q.DocumentID != "" {
return p, store.ErrAccountForbidden
}
scope = extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name", "staffNo"},
MatchFields: []string{"owner", "name", "staffNo"}, AllCandidates: true, ScopeVersion: "system.users.v1"}
case extension.AccountDirectoryPurposeProjectMembers, extension.AccountDirectoryPurposeProjectRoster, extension.AccountDirectoryPurposeDocumentDuties:
// project.member-roster is an explicit enumeration of current project
// members: only the trusted RDM resolver may name them, every account
// status is allowed and an empty query lists the whole roster.
roster := q.Purpose == extension.AccountDirectoryPurposeProjectRoster
if h.pluginID != "rdm" || q.ProjectID == "" || (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && q.DocumentID == "") ||
(q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || (roster && q.DocumentID != "") ||
(!roster && p.status != "enabled") {
return p, store.ErrAccountForbidden
}
h.caps.directory.mu.RLock()
resolver, pluginID := h.caps.directory.resolver, h.caps.directory.pluginID
h.caps.directory.mu.RUnlock()
if resolver == nil || pluginID != h.pluginID {
return p, store.ErrAccountForbidden
}
scope, err = resolver(ctx, extension.AccountDirectoryScopeRequest{
Actor: h.actor, PluginID: h.pluginID, Purpose: q.Purpose, ProjectID: q.ProjectID, DocumentID: q.DocumentID,
})
if err != nil {
return p, store.ErrAccountForbidden
}
if (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties || roster) && scope.AllCandidates {
return p, store.ErrAccountForbidden
}
default:
return p, store.ErrAccountForbidden
}
if scope.ScopeVersion == "" {
return p, store.ErrAccountForbidden
}
p.allowed = map[string]bool{"owner": true}
for _, field := range scope.AllowedFields {
if !knownDirectoryField(field) {
return p, store.ErrAccountForbidden
}
p.allowed[field] = true
}
matchAllowed := map[string]bool{}
for _, field := range scope.MatchFields {
if !knownDirectoryField(field) || !p.allowed[field] {
return p, store.ErrAccountForbidden
}
matchAllowed[field] = true
}
requested := q.SearchFields
if len(requested) == 0 {
requested = []string{"owner", "name"}
}
for _, field := range requested {
if !matchAllowed[field] {
return p, store.ErrAccountForbidden
}
p.match = append(p.match, field)
}
sort.Strings(p.match)
p.candidates = make(map[string]bool, len(scope.CandidateIDs))
p.owners = make(map[string]bool, len(scope.CandidateOwners))
for _, id := range scope.CandidateIDs {
if id == "" {
return p, store.ErrAccountForbidden
}
p.candidates[id] = true
}
for _, owner := range scope.CandidateOwners {
if owner == "" {
return p, store.ErrAccountForbidden
}
p.owners[owner] = true
}
p.all = scope.AllCandidates
if q.Purpose == extension.AccountDirectoryPurposeSystemUsers {
p.all = true
}
visible := make([]directoryVisibleRow, 0)
if loadRows {
rows, err := h.caps.st.ListDirectoryAccountRows()
if err != nil {
return p, err
}
for _, row := range rows {
if !directoryRowAllowed(row, p) {
continue
}
p.rows = append(p.rows, row)
item := directoryVisibleRow{UserID: row.UserID, Owner: row.Owner, Enabled: row.Enabled}
if p.allowed["name"] {
item.Name = row.Name
}
if p.allowed["staffNo"] {
item.StaffNo = row.StaffNo
}
visible = append(visible, item)
}
}
ids := make([]string, 0, len(p.candidates))
for id := range p.candidates {
ids = append(ids, id)
}
sort.Strings(ids)
owners := make([]string, 0, len(p.owners))
for owner := range p.owners {
owners = append(owners, owner)
}
sort.Strings(owners)
allowed := make([]string, 0, len(p.allowed))
for field := range p.allowed {
allowed = append(allowed, field)
}
sort.Strings(allowed)
fingerprint := struct {
Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Scope string
Match, Allowed, IDs, Owners []string
Visible []directoryVisibleRow
All bool
}{Actor: h.actor, Plugin: h.pluginID, Purpose: q.Purpose, Project: q.ProjectID, Document: q.DocumentID,
Mode: q.Mode, Query: strings.TrimSpace(q.Query), Status: p.status,
Scope: scope.ScopeVersion, Match: p.match, Allowed: allowed, IDs: ids, Owners: owners, Visible: visible, All: p.all}
encoded, err := json.Marshal(fingerprint)
if err != nil {
return p, err
}
digest := sha256.Sum256(encoded)
p.version = hex.EncodeToString(digest[:])
return p, nil
}
func (s *accountDirectorySlot) encodeCursor(c directoryCursor) (string, error) {
c.KeyVersion = s.keyVersion
data, err := json.Marshal(c)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, s.secret[:])
_, _ = mac.Write([]byte("iam-directory-cursor-v1\x00"))
_, _ = mac.Write(data)
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
func (s *accountDirectorySlot) decodeCursor(token, version string) (directoryCursor, error) {
var c directoryCursor
payload, signature, found := strings.Cut(token, ".")
if !found {
return c, store.ErrConflict
}
data, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil || len(data) > 1024 {
return c, store.ErrConflict
}
provided, err := base64.RawURLEncoding.DecodeString(signature)
if err != nil {
return c, store.ErrConflict
}
mac := hmac.New(sha256.New, s.secret[:])
_, _ = mac.Write([]byte("iam-directory-cursor-v1\x00"))
_, _ = mac.Write(data)
if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &c) != nil || c.KeyVersion != s.keyVersion || c.Version != version || c.Owner == "" || c.UserID == "" {
return directoryCursor{}, store.ErrConflict
}
return c, nil
}
func directoryEntry(row store.DirectoryAccountRow, p directoryPolicy) extension.AccountDirectoryEntry {
entry := extension.AccountDirectoryEntry{UserID: row.UserID, Owner: row.Owner, Valid: row.Enabled}
if p.allowed["name"] {
entry.Name = row.Name
}
if p.allowed["staffNo"] {
entry.StaffNo = row.StaffNo
}
return entry
}
func directoryMatches(row store.DirectoryAccountRow, fields []string, mode, query string) bool {
for _, field := range fields {
value := row.Owner
switch field {
case "name":
value = row.Name
case "staffNo":
value = row.StaffNo
}
if mode == extension.AccountDirectoryModeExact && strings.EqualFold(value, query) {
return true
}
if mode == extension.AccountDirectoryModeFuzzy && strings.Contains(strings.ToLower(value), strings.ToLower(query)) {
return true
}
}
return false
}
func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool {
if !p.all && !p.candidates[row.UserID] && !p.owners[row.Owner] {
return false
}
return p.status == "all" || (p.status == "enabled" && row.Enabled) || (p.status == "disabled" && !row.Enabled)
}
func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery) (extension.AccountDirectoryPage, error) {
query := strings.TrimSpace(q.Query)
// project.member-roster enumerates the resolved members: an empty query
// lists every row inside the scope with normal limit/cursor paging.
enumerate := query == "" && q.Purpose == extension.AccountDirectoryPurposeProjectRoster
p, err := h.directoryPolicy(ctx, q, query != "" || enumerate)
if err != nil {
return extension.AccountDirectoryPage{}, err
}
maxLimit := 50
if q.Purpose == extension.AccountDirectoryPurposeSystemUsers {
maxLimit = 100
}
if q.Limit == 0 {
q.Limit = 20
}
if q.Limit < 1 || q.Limit > maxLimit {
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
}
page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version}
if query == "" && !enumerate {
if q.Cursor != "" {
return extension.AccountDirectoryPage{}, store.ErrConflict
}
return page, nil
}
if query != "" && q.Mode == extension.AccountDirectoryModeFuzzy && utf8.RuneCountInString(query) < 2 {
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
}
var after directoryCursor
if q.Cursor != "" {
after, err = h.caps.directory.decodeCursor(q.Cursor, p.version)
if err != nil {
return extension.AccountDirectoryPage{}, err
}
}
matched := make([]store.DirectoryAccountRow, 0, q.Limit+1)
for _, row := range p.rows {
if !enumerate && !directoryMatches(row, p.match, q.Mode, query) {
continue
}
if after.Owner != "" && (row.Owner < after.Owner || (row.Owner == after.Owner && row.UserID <= after.UserID)) {
continue
}
matched = append(matched, row)
if len(matched) > q.Limit {
break
}
}
more := len(matched) > q.Limit
if more {
matched = matched[:q.Limit]
}
for _, row := range matched {
page.Entries = append(page.Entries, directoryEntry(row, p))
}
if more {
last := matched[len(matched)-1]
page.NextCursor, err = h.caps.directory.encodeCursor(directoryCursor{Version: p.version, Owner: last.Owner, UserID: last.UserID})
if err != nil {
return extension.AccountDirectoryPage{}, err
}
}
return page, nil
}
func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery, userID string) (extension.AccountDirectoryLookup, error) {
if userID == "" || q.Cursor != "" {
return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile
}
p, err := h.directoryPolicy(ctx, q, true)
if err != nil {
return extension.AccountDirectoryLookup{}, err
}
for _, row := range p.rows {
if row.UserID == userID {
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
}
}
return extension.AccountDirectoryLookup{}, store.ErrNotFound
}
func (h inprocHost) LookupAccountDirectoryOwner(ctx context.Context, q extension.AccountDirectoryQuery, owner string) (extension.AccountDirectoryLookup, error) {
if owner == "" || q.Cursor != "" {
return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile
}
p, err := h.directoryPolicy(ctx, q, true)
if err != nil {
return extension.AccountDirectoryLookup{}, err
}
for _, row := range p.rows {
if row.Owner == owner {
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
}
}
return extension.AccountDirectoryLookup{}, store.ErrNotFound
}
var _ extension.AccountDirectoryProvider = inprocHost{}