From 612e04402ddaae77bcbc6489d7dc48d477670f96 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Tue, 20 Oct 2026 11:57:53 +0000 Subject: [PATCH] agent-exec transport be0c4744ff5899017313eb3f4fbd4a3f/43fe057c8332 --- internal/app/account_directory.go | 22 ++++-- internal/app/account_directory_test.go | 96 +++++++++++++++++++++++ internal/app/hostcaps.go | 42 ++++++++++ internal/app/role_provider_test.go | 68 ++++++++++++++++ internal/server/project_lifecycle_test.go | 52 +++++++++++- internal/server/server.go | 6 ++ pkg/extension/account_directory.go | 1 + pkg/extension/extension.go | 6 +- pkg/extension/role_policy.go | 15 ++++ pkg/extension/surface.txt | 4 + 10 files changed, 303 insertions(+), 9 deletions(-) diff --git a/internal/app/account_directory.go b/internal/app/account_directory.go index cafd8bc..aaa673f 100644 --- a/internal/app/account_directory.go +++ b/internal/app/account_directory.go @@ -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)) { diff --git a/internal/app/account_directory_test.go b/internal/app/account_directory_test.go index 1fc6b59..39b894b 100644 --- a/internal/app/account_directory_test.go +++ b/internal/app/account_directory_test.go @@ -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 == "" { diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 94bdd07..5de0282 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -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 diff --git a/internal/app/role_provider_test.go b/internal/app/role_provider_test.go index 08494f0..a534c67 100644 --- a/internal/app/role_provider_test.go +++ b/internal/app/role_provider_test.go @@ -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) + } +} diff --git a/internal/server/project_lifecycle_test.go b/internal/server/project_lifecycle_test.go index c56fce9..3a7082f 100644 --- a/internal/server/project_lifecycle_test.go +++ b/internal/server/project_lifecycle_test.go @@ -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") diff --git a/internal/server/server.go b/internal/server/server.go index b86dd73..22a36f6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 } diff --git a/pkg/extension/account_directory.go b/pkg/extension/account_directory.go index d8b4db5..ed73b00 100644 --- a/pkg/extension/account_directory.go +++ b/pkg/extension/account_directory.go @@ -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" diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index f37baba..6ab17f2 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -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") diff --git a/pkg/extension/role_policy.go b/pkg/extension/role_policy.go index aec8f93..4fe1dd6 100644 --- a/pkg/extension/role_policy.go +++ b/pkg/extension/role_policy.go @@ -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"` diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index ae3247c..6c140a6 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -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