Add scoped account directory provider

Why: The old account lister exposed a global, always-valid directory without a domain locator or field authorization.

Evidence: SELF receipts /tmp/iam-u3-u3-backend-sqlite.json and /tmp/iam-u3-u3-backend-pg.json.

Impact: Adds versioned optional query and exact lookup, trusted RDM scope registration, visible-field cursor binding, and restricts legacy EXT-19 to effective system administrators. RDM consumption remains a separate U3-5 unit.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
This commit is contained in:
2026-10-14 03:29:26 +00:00
parent 8dfc223fb6
commit 2fb505c0eb
12 changed files with 709 additions and 26 deletions
+353
View File
@@ -0,0 +1,353 @@
package app
import (
"context"
"crypto/hmac"
"crypto/rand"
"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
}
func newAccountDirectorySlot() (*accountDirectorySlot, error) {
slot := &accountDirectorySlot{}
if _, err := rand.Read(slot.secret[:]); err != nil {
return nil, err
}
return slot, 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
all bool
status string
version string
}
type directoryCursor struct {
Version string `json:"v"`
Owner string `json:"o"`
UserID string `json:"u"`
}
func knownDirectoryField(field string) bool {
return field == "owner" || field == "name" || field == "staffNo"
}
func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDirectoryQuery) (directoryPolicy, error) {
var p directoryPolicy
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.AccountDirectoryPurposeDocumentDuties:
if h.pluginID != "rdm" || q.ProjectID == "" || (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && q.DocumentID == "") ||
(q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || 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 && 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))
for _, id := range scope.CandidateIDs {
if id == "" {
return p, store.ErrAccountForbidden
}
p.candidates[id] = true
}
p.all = scope.AllCandidates
if q.Purpose == extension.AccountDirectoryPurposeSystemUsers {
p.all = true
}
revision, err := h.caps.st.GetAccountDirectoryRevision()
if err != nil {
return p, err
}
ids := make([]string, 0, len(p.candidates))
for id := range p.candidates {
ids = append(ids, id)
}
sort.Strings(ids)
fingerprint := struct {
Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Public, Staff, Scope string
Match, IDs []string
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, Public: revision.Public,
Scope: scope.ScopeVersion, Match: p.match, IDs: ids, All: p.all}
if p.allowed["staffNo"] {
fingerprint.Staff = revision.StaffNo
}
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) {
data, err := json.Marshal(c)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, s.secret[:])
_, _ = 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(data)
if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &c) != nil || 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] {
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) {
p, err := h.directoryPolicy(ctx, q)
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}
query := strings.TrimSpace(q.Query)
if query == "" {
if q.Cursor != "" {
return extension.AccountDirectoryPage{}, store.ErrConflict
}
return page, nil
}
if 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
}
}
rows, err := h.caps.st.ListDirectoryAccountRows()
if err != nil {
return extension.AccountDirectoryPage{}, err
}
matched := make([]store.DirectoryAccountRow, 0, q.Limit+1)
for _, row := range rows {
if !directoryRowAllowed(row, p) || !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)
if err != nil {
return extension.AccountDirectoryLookup{}, err
}
if !p.all && !p.candidates[userID] {
return extension.AccountDirectoryLookup{}, store.ErrNotFound
}
account, err := h.caps.st.GetAccount(userID)
if err != nil {
return extension.AccountDirectoryLookup{}, err
}
row := store.DirectoryAccountRow{UserID: account.ID, Owner: account.Owner, Name: account.Profile.Name,
StaffNo: account.Profile.StaffNo, Enabled: account.Enabled}
if !directoryRowAllowed(row, p) {
return extension.AccountDirectoryLookup{}, store.ErrNotFound
}
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
}
var _ extension.AccountDirectoryProvider = inprocHost{}
+129
View File
@@ -0,0 +1,129 @@
package app
import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func openDirectoryTestStore(t *testing.T) *store.Store {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "directory.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
schema := "t_" + strings.ToLower(store.NewID("dir"))
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`)
_ = admin.Close()
})
separator := "?"
if strings.Contains(dsn, "?") {
separator = "&"
}
st, err := store.OpenPostgres(dsn+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) {
st := openDirectoryTestStore(t)
t.Logf("directory provider dialect=%s", st.Dialect())
if err := st.CreateAccount("admin", "", "hash", true); err != nil {
t.Fatal(err)
}
alice, err := st.CreatePendingAccount("admin", "alice", store.AccountProfile{Name: "同名", StaffNo: "hidden-a"})
if err != nil {
t.Fatal(err)
}
bob, err := st.CreatePendingAccount("admin", "bob", store.AccountProfile{Name: "同名", StaffNo: "hidden-b"})
if err != nil {
t.Fatal(err)
}
_, err = st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "同名", StaffNo: "hidden-x"})
if err != nil {
t.Fatal(err)
}
slot, err := newAccountDirectorySlot()
if err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st, directory: slot}, pluginID: "rdm", actor: "admin", owner: "admin"}
query := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
Purpose: extension.AccountDirectoryPurposeProjectMembers, ProjectID: "project-1",
Mode: extension.AccountDirectoryModeFuzzy, Query: "同名", Limit: 1}
if _, err := h.QueryAccountDirectory(context.Background(), query); err == nil {
t.Fatal("domain directory ran without trusted resolver")
}
if err := (pluginRegistry{pluginID: "rdm", caps: h.caps}).RegisterAccountDirectoryScopeResolver(func(context.Context, extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) {
return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"},
CandidateIDs: []string{alice.ID, bob.ID}, ScopeVersion: "scope-1"}, nil
}); err != nil {
t.Fatal(err)
}
page, err := h.QueryAccountDirectory(context.Background(), query)
if err != nil || len(page.Entries) != 1 || page.Entries[0].UserID != alice.ID || page.Entries[0].StaffNo != "" || page.NextCursor == "" {
t.Fatalf("first scoped page: %+v %v", page, err)
}
query.Cursor = page.NextCursor
second, err := h.QueryAccountDirectory(context.Background(), query)
if err != nil || len(second.Entries) != 1 || second.Entries[0].UserID != bob.ID || second.NextCursor != "" {
t.Fatalf("second scoped page: %+v %v", second, err)
}
query.Cursor = ""
query.Query = "hidden-x"
if found, err := h.QueryAccountDirectory(context.Background(), query); err != nil || len(found.Entries) != 0 {
t.Fatalf("hidden staff number matched: %+v %v", found, err)
}
query.SearchFields = []string{"staffNo"}
if _, err := h.QueryAccountDirectory(context.Background(), query); err == nil {
t.Fatal("hidden staff number accepted as search field")
}
query.SearchFields = nil
query.Query = "同名"
if _, err := h.LookupAccountDirectory(context.Background(), query, "not-in-scope"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("exact lookup leaked outsider: %v", err)
}
page, err = h.QueryAccountDirectory(context.Background(), query)
if err != nil {
t.Fatal(err)
}
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion, map[string]string{"phone": "private-edit"}); err != nil {
t.Fatal(err)
}
query.Cursor = page.NextCursor
if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil {
t.Fatalf("private edit invalidated candidate cursor: %v", err)
}
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+1, map[string]string{"name": "changed"}); err != nil {
t.Fatal(err)
}
if _, err := h.QueryAccountDirectory(context.Background(), query); !errors.Is(err, store.ErrConflict) {
t.Fatalf("visible edit did not invalidate cursor: %v", err)
}
}
+18 -3
View File
@@ -20,7 +20,13 @@ func TestInprocHostListAccounts(t *testing.T) {
t.Fatal(err)
}
}
h := inprocHost{caps: hostcaps{st: st}}
if _, err := st.DB().Exec(`UPDATE users SET is_admin=TRUE WHERE owner='alice'`); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, owner: "alice", actor: "alice"}
if _, _, err := (inprocHost{caps: hostcaps{st: st}}).ListAccounts("", 2); err == nil {
t.Fatal("legacy directory allowed a host without a trusted dispatch actor")
}
// 首页(limit=2):alice/bob,next=bob。
page, next, err := h.ListAccounts("", 2)
@@ -50,6 +56,12 @@ func TestInprocHostListAccounts(t *testing.T) {
if _, _, err := h.ListAccounts("", 999); err != nil {
t.Fatalf("limit 超限应钳制不报错: %v", err)
}
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
t.Fatal(err)
}
if _, _, err := h.ListAccounts("", 2); err == nil {
t.Fatal("disabled administrator retained legacy directory access")
}
}
// TestInprocHostSearchAccounts:EXT-19 扩展 SearchAccounts——账号/姓名子串
@@ -65,7 +77,7 @@ func TestInprocHostSearchAccounts(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "王小明", hash, false); err != nil {
if err := st.CreateAccount("alice", "王小明", hash, true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("bob", "李大明", hash, false); err != nil {
@@ -74,7 +86,10 @@ func TestInprocHostSearchAccounts(t *testing.T) {
if err := st.CreateAccount("carol", "", hash, false); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}}
h := inprocHost{caps: hostcaps{st: st}, owner: "alice", actor: "alice"}
if _, err := (inprocHost{caps: hostcaps{st: st}, owner: "bob", actor: "bob"}).SearchAccounts("alice", 50); err == nil {
t.Fatal("ordinary actor gained legacy system directory search")
}
// 姓名子串(同名区分场景):两个"大明/小明"都以"明"命中。
got, err := h.SearchAccounts("明", 50)
+5 -1
View File
@@ -609,7 +609,11 @@ func Run(opt Options) error {
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
authzSlot := &projectAuthz{}
projectSlot := &projectLifecycleSlot{}
caps := hostcaps{projects: projectSlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
directorySlot, err := newAccountDirectorySlot()
if err != nil {
return err
}
caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
+19
View File
@@ -116,6 +116,17 @@ func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorize
return errors.New("extension: declarative plugins cannot register resource authorizers")
}
func (r compiledRegistry) RegisterAccountDirectoryScopeResolver(extension.AccountDirectoryScopeResolver) error {
return errors.New("extension: declarative plugins cannot register account directory authorization")
}
func (r pluginRegistry) RegisterAccountDirectoryScopeResolver(fn extension.AccountDirectoryScopeResolver) error {
if r.pluginID != "rdm" || r.caps.directory == nil {
return errors.New("extension: account directory resolver requires trusted RDM registration")
}
return r.caps.directory.register(r.pluginID, fn)
}
// RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽
// (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。
// 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
@@ -150,6 +161,14 @@ type recordRegistry struct {
sum *server.CompiledPlugin
}
func (r recordRegistry) RegisterAccountDirectoryScopeResolver(fn extension.AccountDirectoryScopeResolver) error {
registrar, ok := r.Registry.(extension.AccountDirectoryRegistrar)
if !ok {
return errors.New("extension: account directory registration unavailable")
}
return registrar.RegisterAccountDirectoryScopeResolver(fn)
}
func (r recordRegistry) AddCommand(spec json.RawMessage) error {
if err := r.Registry.AddCommand(spec); err != nil {
return err
+26 -13
View File
@@ -21,12 +21,13 @@ import (
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
type hostcaps struct {
projects *projectLifecycleSlot
st *store.Store
pipe *rag.Pipeline
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
projects *projectLifecycleSlot
directory *accountDirectorySlot
st *store.Store
pipe *rag.Pipeline
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
}
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
@@ -190,14 +191,23 @@ func (h inprocHost) IsAdmin() bool {
if err != nil {
return false
}
return state.IsAdmin
return state.Enabled && state.IsAdmin
}
// ListAccounts 实现 EXT-19 AccountLister(最小账号目录):仅授权管理读面用。
// 有效态=账号存在且非停用(当前 users 表无停用位——must_change_password 不影响
// 有效性;后续加停用位在此映射)。P3(2026-10-13):携带 Name 供同名区分。
// 游标=owner 升序偏移。
func (h inprocHost) legacySystemDirectoryAuthorized() bool {
if h.actor == "" {
return false
}
state, err := h.caps.st.GetUserAuthState(h.actor)
return err == nil && state.Enabled && state.IsAdmin
}
// ListAccounts 保留 EXT-19 旧签名,仅供有受信分发主体的系统管理上下文;
// 项目和文档候选必须走带 locator 的 AccountDirectoryProvider。
func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountEntry, string, error) {
if !h.legacySystemDirectoryAuthorized() {
return nil, "", store.ErrAccountForbidden
}
if limit <= 0 || limit > 200 {
limit = 50
}
@@ -207,7 +217,7 @@ func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountE
}
entries := make([]extension.AccountEntry, 0, len(users))
for _, u := range users {
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true})
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: u.Enabled})
}
// 排序+游标(owner 升序;cursor 为上一页末 owner,空=首页)。
for i := 0; i < len(entries); i++ {
@@ -238,6 +248,9 @@ func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountE
// 姓名子串检索(大小写不敏感;空 query = 首页候选)。结果按 owner 升序,
// 有效态口径与 ListAccounts 一致。
func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.AccountEntry, error) {
if !h.legacySystemDirectoryAuthorized() {
return nil, store.ErrAccountForbidden
}
if limit <= 0 || limit > 200 {
limit = 50
}
@@ -253,7 +266,7 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account
!strings.Contains(strings.ToLower(u.Name), q) {
continue
}
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true})
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: u.Enabled})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Owner < entries[j].Owner })
if len(entries) > limit {
+30
View File
@@ -0,0 +1,30 @@
package store
// DirectoryAccountRow is an internal candidate projection. It intentionally
// excludes contact details, notes, credentials and administrator privileges.
type DirectoryAccountRow struct {
UserID string
Owner string
Name string
StaffNo string
Enabled bool
}
// ListDirectoryAccountRows only supplies data to the authorized directory
// service; this method itself is not an authorization boundary.
func (s *Store) ListDirectoryAccountRows() ([]DirectoryAccountRow, error) {
rows, err := s.db.Query(`SELECT id,owner,COALESCE(name,''),staff_no,enabled FROM users ORDER BY owner,id`)
if err != nil {
return nil, err
}
defer rows.Close()
results := []DirectoryAccountRow{}
for rows.Next() {
var row DirectoryAccountRow
if err := rows.Scan(&row.UserID, &row.Owner, &row.Name, &row.StaffNo, &row.Enabled); err != nil {
return nil, err
}
results = append(results, row)
}
return results, rows.Err()
}
+3 -2
View File
@@ -167,6 +167,7 @@ const sessionTTL = 7 * 24 * time.Hour
type AdminUser struct {
Owner string `json:"owner"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
IsAdmin bool `json:"isAdmin"`
MustChangePassword bool `json:"mustChangePassword"`
HasPassword bool `json:"hasPassword"`
@@ -447,7 +448,7 @@ func (s *Store) DeleteUserSessions(owner, exceptToken string) {
// ListUsersAdmin 管理员用户列表(按创建序)。
func (s *Store) ListUsersAdmin() ([]AdminUser, error) {
rows, err := s.db.Query(s.q(`SELECT owner,COALESCE(name,''),password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`))
rows, err := s.db.Query(s.q(`SELECT owner,COALESCE(name,''),enabled,password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`))
if err != nil {
return nil, err
}
@@ -456,7 +457,7 @@ func (s *Store) ListUsersAdmin() ([]AdminUser, error) {
for rows.Next() {
var u AdminUser
var hash, psa, ca string
if err := rows.Scan(&u.Owner, &u.Name, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil {
if err := rows.Scan(&u.Owner, &u.Name, &u.Enabled, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil {
return nil, err
}
u.HasPassword = hash != ""
+88
View File
@@ -0,0 +1,88 @@
package extension
import "context"
const AccountDirectorySchemaVersion = 1
const (
AccountDirectoryPurposeSystemUsers = "system.users"
AccountDirectoryPurposeProjectMembers = "project.member-candidate"
AccountDirectoryPurposeDocumentDuties = "document.duty-candidate"
AccountDirectoryModeExact = "exact"
AccountDirectoryModeFuzzy = "fuzzy"
)
// AccountDirectoryQuery is a request for account candidates. Actor and field
// permissions are deliberately absent: the host supplies the authenticated
// dispatch actor and obtains current domain authorization from its registrar.
type AccountDirectoryQuery struct {
SchemaVersion int `json:"schemaVersion"`
Purpose string `json:"purpose"`
ProjectID string `json:"projectId,omitempty"`
DocumentID string `json:"documentId,omitempty"`
Mode string `json:"mode"`
Query string `json:"query"`
SearchFields []string `json:"searchFields,omitempty"`
Status string `json:"status,omitempty"` // enabled|disabled|all; domain candidates require enabled
Cursor string `json:"cursor,omitempty"`
Limit int `json:"limit,omitempty"`
}
// AccountDirectoryEntry contains only identity, effective state and fields
// specifically authorized for the current purpose. Contact details never use
// this candidate DTO.
type AccountDirectoryEntry struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
Name string `json:"name,omitempty"`
StaffNo string `json:"staffNo,omitempty"`
Valid bool `json:"valid"`
}
type AccountDirectoryPage struct {
Entries []AccountDirectoryEntry `json:"entries"`
DirectoryVersion string `json:"directoryVersion"`
NextCursor string `json:"nextCursor,omitempty"`
}
type AccountDirectoryLookup struct {
Entry AccountDirectoryEntry `json:"entry"`
DirectoryVersion string `json:"directoryVersion"`
}
// AccountDirectoryProvider is optional on trusted dispatch Host handles.
// Missing capability is a denial, not permission to use EXT-19 as a fallback.
type AccountDirectoryProvider interface {
QueryAccountDirectory(context.Context, AccountDirectoryQuery) (AccountDirectoryPage, error)
LookupAccountDirectory(context.Context, AccountDirectoryQuery, string) (AccountDirectoryLookup, error)
}
// AccountDirectoryScopeRequest is constructed by the Backend from the trusted
// dispatch actor, registered plugin and untrusted locator. A resolver must
// validate the locator and current member.manage / duty.assign authority.
type AccountDirectoryScopeRequest struct {
Actor string
PluginID string
Purpose string
ProjectID string
DocumentID string
}
// AccountDirectoryScope is returned by the trusted in-process domain resolver.
// AllCandidates is explicit: a nil candidate list never means global access.
// Candidates are stable user IDs and are applied before search and pagination.
type AccountDirectoryScope struct {
AllowedFields []string
MatchFields []string
AllCandidates bool
CandidateIDs []string
ScopeVersion string
}
type AccountDirectoryScopeResolver func(context.Context, AccountDirectoryScopeRequest) (AccountDirectoryScope, error)
// AccountDirectoryRegistrar is optional on a compiled in-process Registry.
// Only the trusted RDM plugin may register the domain authorization resolver.
type AccountDirectoryRegistrar interface {
RegisterAccountDirectoryScopeResolver(AccountDirectoryScopeResolver) error
}
+13
View File
@@ -0,0 +1,13 @@
package extension
import "testing"
func TestAccountDirectoryContractIsOptional(t *testing.T) {
var host Host
_, _ = host.(AccountDirectoryProvider)
var registry Registry
_, _ = registry.(AccountDirectoryRegistrar)
if AccountDirectorySchemaVersion < 1 {
t.Fatal("directory schema version must be explicit")
}
}
+7 -7
View File
@@ -34,7 +34,9 @@ import (
// 分区组件自身的命令面 fail-closed)。
// 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted
// ResourceAuthzQuery.Source for per-document publication authorization.
const SurfaceVersion = "2.12.0-experimental"
// 2.13.0-experimental: optional AccountDirectoryProvider and trusted in-process
// AccountDirectoryRegistrar for scoped, current account candidate decisions.
const SurfaceVersion = "2.13.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
@@ -97,17 +99,15 @@ type AccountEntry struct {
Valid bool `json:"valid"`
}
// AccountLister EXT-19 可选能力接口(实现由宿主注入):游标分页列举系统账号。
// 未实现时下游回落现行行为(成员添加不做账号有效性校验);实现后仅用于
// 授权管理读面——不授予任何写权。
// AccountLister EXT-19 旧接口:仅有受信系统管理员分发上下文时可读取。
// 新领域消费者必须使用 AccountDirectoryProvider,不得以本接口作为降级路径。
type AccountLister interface {
ListAccounts(cursor string, limit int) (entries []AccountEntry, next string, err error)
}
// AccountSearcher EXT-19 扩展可选能力(P3,2026-10-13):按账号或姓名检索账号
// 目录,供人员选择器的输入搜索。query 匹配账号子串或姓名子串(大小写不敏感;
// 空串=不限,返回首页候选)。仅授权管理读面使用;未实现时下游回落
// AccountLister 分页浏览。
// 目录。与 AccountLister 相同,它不含领域定位,只允许受信系统管理员上下文;
// 新领域消费者不得回落到此接口。
type AccountSearcher interface {
SearchAccounts(query string, limit int) (entries []AccountEntry, err error)
}
+18
View File
@@ -1,3 +1,21 @@
AccountDirectoryEntry
AccountDirectoryLookup
AccountDirectoryModeExact
AccountDirectoryModeFuzzy
AccountDirectoryPage
AccountDirectoryProvider
AccountDirectoryProvider.LookupAccountDirectory
AccountDirectoryProvider.QueryAccountDirectory
AccountDirectoryPurposeDocumentDuties
AccountDirectoryPurposeProjectMembers
AccountDirectoryPurposeSystemUsers
AccountDirectoryQuery
AccountDirectoryRegistrar
AccountDirectoryRegistrar.RegisterAccountDirectoryScopeResolver
AccountDirectorySchemaVersion
AccountDirectoryScope
AccountDirectoryScopeRequest
AccountDirectoryScopeResolver
AccountEntry
AccountLister
AccountLister.ListAccounts