agent-exec transport be0c4744ff5899017313eb3f4fbd4a3f/43fe057c8332
This commit is contained in:
@@ -115,9 +115,14 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
|
|||||||
}
|
}
|
||||||
scope = extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name", "staffNo"},
|
scope = extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name", "staffNo"},
|
||||||
MatchFields: []string{"owner", "name", "staffNo"}, AllCandidates: true, ScopeVersion: "system.users.v1"}
|
MatchFields: []string{"owner", "name", "staffNo"}, AllCandidates: true, ScopeVersion: "system.users.v1"}
|
||||||
case extension.AccountDirectoryPurposeProjectMembers, extension.AccountDirectoryPurposeDocumentDuties:
|
case extension.AccountDirectoryPurposeProjectMembers, extension.AccountDirectoryPurposeProjectRoster, extension.AccountDirectoryPurposeDocumentDuties:
|
||||||
|
// project.member-roster is an explicit enumeration of current project
|
||||||
|
// members: only the trusted RDM resolver may name them, every account
|
||||||
|
// status is allowed and an empty query lists the whole roster.
|
||||||
|
roster := q.Purpose == extension.AccountDirectoryPurposeProjectRoster
|
||||||
if h.pluginID != "rdm" || q.ProjectID == "" || (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && q.DocumentID == "") ||
|
if h.pluginID != "rdm" || q.ProjectID == "" || (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && q.DocumentID == "") ||
|
||||||
(q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || p.status != "enabled" {
|
(q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || (roster && q.DocumentID != "") ||
|
||||||
|
(!roster && p.status != "enabled") {
|
||||||
return p, store.ErrAccountForbidden
|
return p, store.ErrAccountForbidden
|
||||||
}
|
}
|
||||||
h.caps.directory.mu.RLock()
|
h.caps.directory.mu.RLock()
|
||||||
@@ -132,7 +137,7 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return p, store.ErrAccountForbidden
|
return p, store.ErrAccountForbidden
|
||||||
}
|
}
|
||||||
if q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && scope.AllCandidates {
|
if (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties || roster) && scope.AllCandidates {
|
||||||
return p, store.ErrAccountForbidden
|
return p, store.ErrAccountForbidden
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -311,7 +316,10 @@ func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool
|
|||||||
|
|
||||||
func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery) (extension.AccountDirectoryPage, error) {
|
func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery) (extension.AccountDirectoryPage, error) {
|
||||||
query := strings.TrimSpace(q.Query)
|
query := strings.TrimSpace(q.Query)
|
||||||
p, err := h.directoryPolicy(ctx, q, query != "")
|
// project.member-roster enumerates the resolved members: an empty query
|
||||||
|
// lists every row inside the scope with normal limit/cursor paging.
|
||||||
|
enumerate := query == "" && q.Purpose == extension.AccountDirectoryPurposeProjectRoster
|
||||||
|
p, err := h.directoryPolicy(ctx, q, query != "" || enumerate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return extension.AccountDirectoryPage{}, err
|
return extension.AccountDirectoryPage{}, err
|
||||||
}
|
}
|
||||||
@@ -326,13 +334,13 @@ func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.Accou
|
|||||||
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
|
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
|
||||||
}
|
}
|
||||||
page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version}
|
page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version}
|
||||||
if query == "" {
|
if query == "" && !enumerate {
|
||||||
if q.Cursor != "" {
|
if q.Cursor != "" {
|
||||||
return extension.AccountDirectoryPage{}, store.ErrConflict
|
return extension.AccountDirectoryPage{}, store.ErrConflict
|
||||||
}
|
}
|
||||||
return page, nil
|
return page, nil
|
||||||
}
|
}
|
||||||
if q.Mode == extension.AccountDirectoryModeFuzzy && utf8.RuneCountInString(query) < 2 {
|
if query != "" && q.Mode == extension.AccountDirectoryModeFuzzy && utf8.RuneCountInString(query) < 2 {
|
||||||
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
|
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
|
||||||
}
|
}
|
||||||
var after directoryCursor
|
var after directoryCursor
|
||||||
@@ -344,7 +352,7 @@ func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.Accou
|
|||||||
}
|
}
|
||||||
matched := make([]store.DirectoryAccountRow, 0, q.Limit+1)
|
matched := make([]store.DirectoryAccountRow, 0, q.Limit+1)
|
||||||
for _, row := range p.rows {
|
for _, row := range p.rows {
|
||||||
if !directoryMatches(row, p.match, q.Mode, query) {
|
if !enumerate && !directoryMatches(row, p.match, q.Mode, query) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if after.Owner != "" && (row.Owner < after.Owner || (row.Owner == after.Owner && row.UserID <= after.UserID)) {
|
if after.Owner != "" && (row.Owner < after.Owner || (row.Owner == after.Owner && row.UserID <= after.UserID)) {
|
||||||
|
|||||||
@@ -154,6 +154,102 @@ func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// project.member-roster:可信 RDM 解析方点名的项目成员清单。空 query 枚举作用域内
|
||||||
|
// 全部行(含已停用账号),limit/游标翻页照旧;非 rdm 插件、带 documentId、
|
||||||
|
// AllCandidates 解析结果一律拒绝;member-candidate 的空 query 仍是空页。
|
||||||
|
func TestAccountDirectoryProjectRosterEnumeratesResolvedMembers(t *testing.T) {
|
||||||
|
st := openDirectoryTestStore(t)
|
||||||
|
if err := st.CreateAccount("admin", "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)
|
||||||
|
}
|
||||||
|
if _, err := st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "局外人", StaffNo: "hidden-x"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var requests []extension.AccountDirectoryScopeRequest
|
||||||
|
resolver := func(_ context.Context, req extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) {
|
||||||
|
requests = append(requests, req)
|
||||||
|
return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"},
|
||||||
|
CandidateOwners: []string{alice.Owner, bob.Owner}, ScopeVersion: "roster-1"}, nil
|
||||||
|
}
|
||||||
|
slot, err := newAccountDirectorySlot(st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
slot.pluginID, slot.resolver = "rdm", resolver
|
||||||
|
h := inprocHost{caps: hostcaps{st: st, directory: slot}, pluginID: "rdm", actor: "admin", owner: "admin"}
|
||||||
|
roster := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
|
||||||
|
Purpose: extension.AccountDirectoryPurposeProjectRoster, ProjectID: "project-1",
|
||||||
|
Mode: extension.AccountDirectoryModeFuzzy, Status: "all"}
|
||||||
|
page, err := h.QueryAccountDirectory(context.Background(), roster)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(page.Entries) != 2 || page.NextCursor != "" {
|
||||||
|
t.Fatalf("roster page=%+v", page)
|
||||||
|
}
|
||||||
|
if page.Entries[0] != (extension.AccountDirectoryEntry{UserID: alice.ID, Owner: "alice", Name: "阿丽", Valid: true}) ||
|
||||||
|
page.Entries[1] != (extension.AccountDirectoryEntry{UserID: bob.ID, Owner: "bob", Name: "小博", Valid: false}) {
|
||||||
|
t.Fatalf("roster entries=%+v", page.Entries)
|
||||||
|
}
|
||||||
|
if len(requests) != 1 || requests[0].Purpose != extension.AccountDirectoryPurposeProjectRoster ||
|
||||||
|
requests[0].ProjectID != "project-1" || requests[0].DocumentID != "" || requests[0].Actor != "admin" {
|
||||||
|
t.Fatalf("resolver requests=%+v", requests)
|
||||||
|
}
|
||||||
|
// 游标翻页:同一签名游标,limit 1 两页走完。
|
||||||
|
roster.Limit = 1
|
||||||
|
first, err := h.QueryAccountDirectory(context.Background(), roster)
|
||||||
|
if err != nil || len(first.Entries) != 1 || first.Entries[0].UserID != alice.ID || first.NextCursor == "" {
|
||||||
|
t.Fatalf("roster first page=%+v %v", first, err)
|
||||||
|
}
|
||||||
|
roster.Cursor = first.NextCursor
|
||||||
|
second, err := h.QueryAccountDirectory(context.Background(), roster)
|
||||||
|
if err != nil || len(second.Entries) != 1 || second.Entries[0].UserID != bob.ID || second.NextCursor != "" {
|
||||||
|
t.Fatalf("roster second page=%+v %v", second, err)
|
||||||
|
}
|
||||||
|
roster.Cursor, roster.Limit = "", 0
|
||||||
|
// 非检索目的的空 query 依旧空页:member-candidate 行为不变。
|
||||||
|
roster.Purpose, roster.Status = extension.AccountDirectoryPurposeProjectMembers, "enabled"
|
||||||
|
if page, err := h.QueryAccountDirectory(context.Background(), roster); err != nil || len(page.Entries) != 0 {
|
||||||
|
t.Fatalf("member candidate empty query=%+v %v", page, err)
|
||||||
|
}
|
||||||
|
roster.Purpose = extension.AccountDirectoryPurposeProjectRoster
|
||||||
|
// 非 rdm 插件、documentId 限定、全量作用域解析结果:均拒绝。
|
||||||
|
other := h
|
||||||
|
other.pluginID = "genui"
|
||||||
|
if _, err := other.QueryAccountDirectory(context.Background(), roster); !errors.Is(err, store.ErrAccountForbidden) {
|
||||||
|
t.Fatalf("non-rdm roster: %v", err)
|
||||||
|
}
|
||||||
|
withDocument := roster
|
||||||
|
withDocument.DocumentID = "doc-1"
|
||||||
|
if _, err := h.QueryAccountDirectory(context.Background(), withDocument); !errors.Is(err, store.ErrAccountForbidden) {
|
||||||
|
t.Fatalf("document roster: %v", err)
|
||||||
|
}
|
||||||
|
wideSlot, err := newAccountDirectorySlot(st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wideSlot.pluginID, wideSlot.resolver = "rdm", func(context.Context, extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) {
|
||||||
|
return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"},
|
||||||
|
AllCandidates: true, ScopeVersion: "roster-wide"}, nil
|
||||||
|
}
|
||||||
|
wide := h
|
||||||
|
wide.caps.directory = wideSlot
|
||||||
|
if _, err := wide.QueryAccountDirectory(context.Background(), roster); !errors.Is(err, store.ErrAccountForbidden) {
|
||||||
|
t.Fatalf("all-candidate roster: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPGDirectoryCursorContinuesInAnotherProcess(t *testing.T) {
|
func TestPGDirectoryCursorContinuesInAnotherProcess(t *testing.T) {
|
||||||
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
|
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
|
||||||
if baseDSN == "" {
|
if baseDSN == "" {
|
||||||
|
|||||||
@@ -565,6 +565,48 @@ func (h inprocHost) ListRoles(ctx context.Context) ([]extension.RoleAdminRecord,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListRoleOptions is the read-only role directory for non-admin dispatch
|
||||||
|
// actors: enabled roles only, projected as roleId+name, sorted by name then
|
||||||
|
// roleId. It never requires system.roles.view and never exposes permissions,
|
||||||
|
// bindings or counts; callers still authorize their own domain action.
|
||||||
|
func (h inprocHost) ListRoleOptions(ctx context.Context) ([]extension.RoleOption, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := h.requireEffective(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := h.rolePolicyActor(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := []extension.RoleOption{}
|
||||||
|
cursor := ""
|
||||||
|
for {
|
||||||
|
roles, next, err := h.caps.st.ListRoles("", "fuzzy", "all", "", cursor, 100)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, role := range roles {
|
||||||
|
if !role.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, extension.RoleOption{RoleID: role.RoleID, Name: role.Name})
|
||||||
|
}
|
||||||
|
if next == "" {
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
if result[i].Name != result[j].Name {
|
||||||
|
return result[i].Name < result[j].Name
|
||||||
|
}
|
||||||
|
return result[i].RoleID < result[j].RoleID
|
||||||
|
})
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
cursor = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ extension.RoleOptionsProvider = inprocHost{}
|
||||||
|
|
||||||
func (h inprocHost) CreateRole(ctx context.Context, name, description string) (extension.RoleAdminRecord, error) {
|
func (h inprocHost) CreateRole(ctx context.Context, name, description string) (extension.RoleAdminRecord, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return extension.RoleAdminRecord{}, err
|
return extension.RoleAdminRecord{}, err
|
||||||
|
|||||||
@@ -130,3 +130,71 @@ func TestRolePolicyAdminUsesBoundActorAndStableOwnerCompatibility(t *testing.T)
|
|||||||
t.Fatalf("roles=%+v err=%v", roles, err)
|
t.Fatalf("roles=%+v err=%v", roles, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 非管理员分发主体(项目创建者)只读已启用职位:roleId+name,按 name→roleId 排序,
|
||||||
|
// 不含权限/绑定/计数;空 actor(scoped host)与已停用账号一律拒绝。
|
||||||
|
func TestRoleOptionsExposeEnabledRolesToNonAdminActor(t *testing.T) {
|
||||||
|
st, err := store.OpenSQLite(t.TempDir() + "/role-options.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
alice, err := st.GetAccountByOwner("alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
beta, err := st.CreateRole("admin", store.RoleCreate{Name: "Beta 职位", Description: "停用前"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
alpha, err := st.CreateRole("admin", store.RoleCreate{Name: "Alpha 职位"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
retired, err := st.CreateRole("admin", store.RoleCreate{Name: "Gamma 职位"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
disabled := false
|
||||||
|
if _, err := st.PatchRole("admin", retired.RoleID, retired.RoleVersion, store.RolePatch{Enabled: &disabled}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: "alice"}
|
||||||
|
options, ok := any(h).(extension.RoleOptionsProvider)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("dispatch host missing RoleOptionsProvider")
|
||||||
|
}
|
||||||
|
// 管理面仍然拒绝非管理员:只读目录不放大权限。
|
||||||
|
if _, err := any(h).(extension.RolePolicyAdmin).ListRoles(context.Background()); !errors.Is(err, store.ErrAccountForbidden) {
|
||||||
|
t.Fatalf("non-admin ListRoles: %v", err)
|
||||||
|
}
|
||||||
|
got, err := options.ListRoleOptions(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != (extension.RoleOption{RoleID: alpha.RoleID, Name: "Alpha 职位"}) ||
|
||||||
|
got[1] != (extension.RoleOption{RoleID: beta.RoleID, Name: "Beta 职位"}) {
|
||||||
|
t.Fatalf("options=%+v", got)
|
||||||
|
}
|
||||||
|
scoped := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice"}
|
||||||
|
if _, err := any(scoped).(extension.RoleOptionsProvider).ListRoleOptions(context.Background()); !errors.Is(err, store.ErrAccountForbidden) {
|
||||||
|
t.Fatalf("empty actor options: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.SetAccountEnabled("admin", alice.ID, alice.StateVersion, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := options.ListRoleOptions(context.Background()); !errors.Is(err, store.ErrAccountForbidden) {
|
||||||
|
t.Fatalf("disabled actor options: %v", err)
|
||||||
|
}
|
||||||
|
cancelled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := options.ListRoleOptions(cancelled); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancelled context: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,16 +115,66 @@ func TestProjectLifecycleMemberPrivateWork(t *testing.T) {
|
|||||||
t.Fatalf("/new=%d %s", w.Code, w.Body.String())
|
t.Fatalf("/new=%d %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
ready = false
|
ready = false
|
||||||
for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {alice, "POST", "/api/conversations/" + ac.ID + "/messages", `{"text":"new work"}`}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} {
|
for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} {
|
||||||
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
|
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
|
||||||
if w.Code != 404 {
|
if w.Code != 404 {
|
||||||
t.Fatalf("revoked %s=%d %s", q.path, w.Code, w.Body.String())
|
t.Fatalf("revoked %s=%d %s", q.path, w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 已被移出项目的会话属主:发送消息答 403 not-project-member(不再谎称找不到会话)。
|
||||||
|
if msgs, err := st.ListMessages(ac.ID); err != nil || len(msgs) != 0 {
|
||||||
|
t.Fatalf("revoked history unreadable: %v %v", msgs, err)
|
||||||
|
}
|
||||||
|
if w := lifecycleRequest(t, s, alice, "POST", "/api/conversations/"+ac.ID+"/messages", `{"text":"new work"}`); w.Code != 403 ||
|
||||||
|
!bytes.Contains(w.Body.Bytes(), []byte("not-project-member")) {
|
||||||
|
t.Fatalf("revoked send=%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+bc.ID, ""); w.Code != 200 {
|
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+bc.ID, ""); w.Code != 200 {
|
||||||
t.Fatalf("owned revoked history=%d", w.Code)
|
t.Fatalf("owned revoked history=%d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仍是会话属主、但项目工作授权被拒(被移出项目)时,发送消息必须说清
|
||||||
|
// not-project-member 而不是 404,且不落库。
|
||||||
|
func TestProjectLifecycleRemovedMemberMessageIsForbidden(t *testing.T) {
|
||||||
|
s, st := newAuthTestServer(t)
|
||||||
|
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||||
|
p, _ := st.CreateProject("alice", "Shared")
|
||||||
|
conv, _ := st.CreateConversation("alice", p.ID, "Alice private", "")
|
||||||
|
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
|
||||||
|
member := true
|
||||||
|
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
|
||||||
|
return member && actor == "alice" && id == core.ID(p.ID), nil
|
||||||
|
}}, nil)
|
||||||
|
if w := lifecycleRequest(t, s, alice, "POST", "/api/conversations/"+conv.ID+"/messages", `{"text":"仍在项目内"}`); w.Code != 200 {
|
||||||
|
t.Fatalf("member send=%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if msgs, err := st.ListMessages(conv.ID); err != nil || len(msgs) == 0 {
|
||||||
|
t.Fatalf("member message missing: %v %v", msgs, err)
|
||||||
|
}
|
||||||
|
member = false
|
||||||
|
w := lifecycleRequest(t, s, alice, "POST", "/api/conversations/"+conv.ID+"/messages", `{"text":"已被移出项目"}`)
|
||||||
|
if w.Code != 403 || !bytes.Contains(w.Body.Bytes(), []byte("not-project-member")) {
|
||||||
|
t.Fatalf("removed member send=%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
msgs, err := st.ListMessages(conv.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, msg := range msgs {
|
||||||
|
if msg.Role != core.RoleUser {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, part := range msg.Parts {
|
||||||
|
if part.Type == core.PartText && part.Text == "已被移出项目" {
|
||||||
|
t.Fatalf("denied send stored a message: %+v", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w := lifecycleRequest(t, s, alice, "GET", "/api/conversations/"+conv.ID, ""); w.Code != 200 {
|
||||||
|
t.Fatalf("owned conversation unreadable after removal=%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
|
func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
|
||||||
s, st := newAuthTestServer(t)
|
s, st := newAuthTestServer(t)
|
||||||
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||||
|
|||||||
@@ -1049,6 +1049,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil {
|
if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil {
|
||||||
|
// 已拥有并可读该会话、却已被移出项目的成员:说明“不再是项目成员”
|
||||||
|
// 比“找不到会话”更可行动,且不泄露会话是否存在。
|
||||||
|
if conv.ProjectID != "" && errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.Error(w, "conversation: not-project-member", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
writeWorkFailure(w, extension.WorkOperationConversation, err)
|
writeWorkFailure(w, extension.WorkOperationConversation, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const AccountDirectorySchemaVersion = 1
|
|||||||
const (
|
const (
|
||||||
AccountDirectoryPurposeSystemUsers = "system.users"
|
AccountDirectoryPurposeSystemUsers = "system.users"
|
||||||
AccountDirectoryPurposeProjectMembers = "project.member-candidate"
|
AccountDirectoryPurposeProjectMembers = "project.member-candidate"
|
||||||
|
AccountDirectoryPurposeProjectRoster = "project.member-roster"
|
||||||
AccountDirectoryPurposeDocumentDuties = "document.duty-candidate"
|
AccountDirectoryPurposeDocumentDuties = "document.duty-candidate"
|
||||||
AccountDirectoryModeExact = "exact"
|
AccountDirectoryModeExact = "exact"
|
||||||
AccountDirectoryModeFuzzy = "fuzzy"
|
AccountDirectoryModeFuzzy = "fuzzy"
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ import (
|
|||||||
// (CapabilityExplain / MemberBasicCollaborate / DocumentQualify for the
|
// (CapabilityExplain / MemberBasicCollaborate / DocumentQualify for the
|
||||||
// Host-bound actor) and categorized WorkFailure for create, member add and
|
// Host-bound actor) and categorized WorkFailure for create, member add and
|
||||||
// conversation work.
|
// conversation work.
|
||||||
const SurfaceVersion = "2.27.0-experimental"
|
// 2.28.0-experimental: optional RoleOptionsProvider (read-only enabled role
|
||||||
|
// directory: RoleOption roleId+name, no permissions or bindings) and the
|
||||||
|
// AccountDirectory purpose project.member-roster, an explicit project member
|
||||||
|
// enumeration inside the trusted RDM scope.
|
||||||
|
const SurfaceVersion = "2.28.0-experimental"
|
||||||
|
|
||||||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||||
|
|||||||
@@ -56,6 +56,21 @@ type RolePolicyProvider interface {
|
|||||||
CheckRoleAction(context.Context, string, string) (RoleActionDecision, error)
|
CheckRoleAction(context.Context, string, string) (RoleActionDecision, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoleOption is the public directory projection of one enabled role: a stable
|
||||||
|
// identifier and its display name only. It never carries permissions,
|
||||||
|
// bindings or counts, so a non-admin caller learns nothing about authority.
|
||||||
|
type RoleOption struct {
|
||||||
|
RoleID string `json:"roleId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleOptionsProvider is optional on a trusted dispatch Host. The Host binds
|
||||||
|
// the actor; callers still authorize their own domain action before offering
|
||||||
|
// the options.
|
||||||
|
type RoleOptionsProvider interface {
|
||||||
|
ListRoleOptions(context.Context) ([]RoleOption, error)
|
||||||
|
}
|
||||||
|
|
||||||
type SystemActionDecision struct {
|
type SystemActionDecision struct {
|
||||||
ActionID string `json:"actionId"`
|
ActionID string `json:"actionId"`
|
||||||
Allowed bool `json:"allowed"`
|
Allowed bool `json:"allowed"`
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ AccountDirectoryProvider.LookupAccountDirectoryOwner
|
|||||||
AccountDirectoryProvider.QueryAccountDirectory
|
AccountDirectoryProvider.QueryAccountDirectory
|
||||||
AccountDirectoryPurposeDocumentDuties
|
AccountDirectoryPurposeDocumentDuties
|
||||||
AccountDirectoryPurposeProjectMembers
|
AccountDirectoryPurposeProjectMembers
|
||||||
|
AccountDirectoryPurposeProjectRoster
|
||||||
AccountDirectoryPurposeSystemUsers
|
AccountDirectoryPurposeSystemUsers
|
||||||
AccountDirectoryQuery
|
AccountDirectoryQuery
|
||||||
AccountDirectoryRegistrar
|
AccountDirectoryRegistrar
|
||||||
@@ -206,6 +207,9 @@ RoleAuthorityImportProjection
|
|||||||
RoleAuthorityImportResult
|
RoleAuthorityImportResult
|
||||||
RoleAuthorityImportRole
|
RoleAuthorityImportRole
|
||||||
RoleBindingResult
|
RoleBindingResult
|
||||||
|
RoleOption
|
||||||
|
RoleOptionsProvider
|
||||||
|
RoleOptionsProvider.ListRoleOptions
|
||||||
RolePermission
|
RolePermission
|
||||||
RolePolicyAdmin
|
RolePolicyAdmin
|
||||||
RolePolicyAdmin.ActivateRoleAuthorityImport
|
RolePolicyAdmin.ActivateRoleAuthorityImport
|
||||||
|
|||||||
Reference in New Issue
Block a user