diff --git a/internal/app/account_directory.go b/internal/app/account_directory.go new file mode 100644 index 0000000..faf0b48 --- /dev/null +++ b/internal/app/account_directory.go @@ -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{} diff --git a/internal/app/account_directory_test.go b/internal/app/account_directory_test.go new file mode 100644 index 0000000..65588d0 --- /dev/null +++ b/internal/app/account_directory_test.go @@ -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) + } +} diff --git a/internal/app/account_lister_test.go b/internal/app/account_lister_test.go index 6ee2898..2facd50 100644 --- a/internal/app/account_lister_test.go +++ b/internal/app/account_lister_test.go @@ -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) diff --git a/internal/app/app.go b/internal/app/app.go index cbc202b..777f837 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/app/compiled.go b/internal/app/compiled.go index da47164..1e3c90a 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -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 diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 866a4f4..0fd237e 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -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 { diff --git a/internal/store/account_directory.go b/internal/store/account_directory.go new file mode 100644 index 0000000..e426043 --- /dev/null +++ b/internal/store/account_directory.go @@ -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() +} diff --git a/internal/store/auth.go b/internal/store/auth.go index ea62d12..c451f7e 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -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 != "" diff --git a/pkg/extension/account_directory.go b/pkg/extension/account_directory.go new file mode 100644 index 0000000..6be3a9d --- /dev/null +++ b/pkg/extension/account_directory.go @@ -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 +} diff --git a/pkg/extension/account_directory_test.go b/pkg/extension/account_directory_test.go new file mode 100644 index 0000000..9404747 --- /dev/null +++ b/pkg/extension/account_directory_test.go @@ -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") + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 2454e4e..bbfe1d1 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -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) } diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 7d88ba4..25f7d2b 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -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