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:
@@ -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{}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 != ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user