Add read-only role options, a member roster directory and a removed-member send refusal #228
@@ -115,9 +115,14 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
|
||||
}
|
||||
scope = extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name", "staffNo"},
|
||||
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 == "") ||
|
||||
(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
|
||||
}
|
||||
h.caps.directory.mu.RLock()
|
||||
@@ -132,7 +137,7 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
|
||||
if err != nil {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
if q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && scope.AllCandidates {
|
||||
if (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties || roster) && scope.AllCandidates {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
return extension.AccountDirectoryPage{}, err
|
||||
}
|
||||
@@ -326,13 +334,13 @@ func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.Accou
|
||||
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
|
||||
}
|
||||
page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version}
|
||||
if query == "" {
|
||||
if query == "" && !enumerate {
|
||||
if q.Cursor != "" {
|
||||
return extension.AccountDirectoryPage{}, store.ErrConflict
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
for _, row := range p.rows {
|
||||
if !directoryMatches(row, p.match, q.Mode, query) {
|
||||
if !enumerate && !directoryMatches(row, p.match, q.Mode, query) {
|
||||
continue
|
||||
}
|
||||
if after.Owner != "" && (row.Owner < after.Owner || (row.Owner == after.Owner && row.UserID <= after.UserID)) {
|
||||
|
||||
@@ -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) {
|
||||
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
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) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return extension.RoleAdminRecord{}, err
|
||||
|
||||
@@ -130,3 +130,71 @@ func TestRolePolicyAdminUsesBoundActorAndStableOwnerCompatibility(t *testing.T)
|
||||
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())
|
||||
}
|
||||
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)
|
||||
if w.Code != 404 {
|
||||
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 {
|
||||
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) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
|
||||
@@ -1049,6 +1049,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const AccountDirectorySchemaVersion = 1
|
||||
const (
|
||||
AccountDirectoryPurposeSystemUsers = "system.users"
|
||||
AccountDirectoryPurposeProjectMembers = "project.member-candidate"
|
||||
AccountDirectoryPurposeProjectRoster = "project.member-roster"
|
||||
AccountDirectoryPurposeDocumentDuties = "document.duty-candidate"
|
||||
AccountDirectoryModeExact = "exact"
|
||||
AccountDirectoryModeFuzzy = "fuzzy"
|
||||
|
||||
@@ -71,7 +71,11 @@ import (
|
||||
// (CapabilityExplain / MemberBasicCollaborate / DocumentQualify for the
|
||||
// Host-bound actor) and categorized WorkFailure for create, member add and
|
||||
// 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 无法启动服务。
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ActionID string `json:"actionId"`
|
||||
Allowed bool `json:"allowed"`
|
||||
|
||||
@@ -9,6 +9,7 @@ AccountDirectoryProvider.LookupAccountDirectoryOwner
|
||||
AccountDirectoryProvider.QueryAccountDirectory
|
||||
AccountDirectoryPurposeDocumentDuties
|
||||
AccountDirectoryPurposeProjectMembers
|
||||
AccountDirectoryPurposeProjectRoster
|
||||
AccountDirectoryPurposeSystemUsers
|
||||
AccountDirectoryQuery
|
||||
AccountDirectoryRegistrar
|
||||
@@ -206,6 +207,9 @@ RoleAuthorityImportProjection
|
||||
RoleAuthorityImportResult
|
||||
RoleAuthorityImportRole
|
||||
RoleBindingResult
|
||||
RoleOption
|
||||
RoleOptionsProvider
|
||||
RoleOptionsProvider.ListRoleOptions
|
||||
RolePermission
|
||||
RolePolicyAdmin
|
||||
RolePolicyAdmin.ActivateRoleAuthorityImport
|
||||
|
||||
Reference in New Issue
Block a user