Add trusted role administration compatibility

Why: Existing RDM role commands must reach the Backend authority during UI and migration cutover without dual-writing RDM role tables.

Evidence: The full Backend suite passed, including actor-bound role administration, owner compatibility, provider, and public-surface tests.

Impact: Adds temporary trusted Host administration methods and completes the project-action catalog used by RDM.

Refs: Focused role and permission system task 5
This commit is contained in:
weichen
2026-10-14 17:28:31 +00:00
parent 5abd851256
commit 94732f3c87
5 changed files with 244 additions and 0 deletions
+161
View File
@@ -5,9 +5,11 @@ import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"sync"
authzsvc "agent-system-backend/internal/authz"
"agent-system-backend/internal/authz/catalog"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/rag"
@@ -370,6 +372,17 @@ func (h inprocHost) RolePolicyForUser(ctx context.Context, userID string) (exten
return snapshot, nil
}
func (h inprocHost) RolePolicyForOwner(ctx context.Context, owner string) (extension.RolePolicySnapshot, error) {
if _, err := h.rolePolicyActor(); err != nil {
return extension.RolePolicySnapshot{}, err
}
account, err := h.caps.st.GetAccountByOwner(owner)
if err != nil {
return extension.RolePolicySnapshot{}, err
}
return h.RolePolicyForUser(ctx, account.ID)
}
func (h inprocHost) CheckRoleAction(ctx context.Context, userID, actionID string) (extension.RoleActionDecision, error) {
decision := extension.RoleActionDecision{ActionID: actionID}
if _, ok := catalog.DescriptorByID(actionID); !ok {
@@ -409,6 +422,154 @@ func (h inprocHost) CheckRoleAction(ctx context.Context, userID, actionID string
return decision, nil
}
func (h inprocHost) requireRoleAdminAction(actionID string) error {
if h.actor == "" || h.owner != h.actor {
return store.ErrAccountForbidden
}
decision := authzsvc.NewService(h.caps.st).AuthorizeSystem(h.actor, actionID)
if !decision.Allowed {
return store.ErrAccountForbidden
}
return nil
}
func roleAdminRecord(view store.RoleAdminView) extension.RoleAdminRecord {
out := extension.RoleAdminRecord{RoleID: view.RoleID, Name: view.Name, Description: view.Description,
Enabled: view.Enabled, RoleVersion: view.RoleVersion, BindingCount: view.BindingCount,
Permissions: map[string]extension.RolePermission{}}
for _, permission := range view.Permissions {
out.Permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed}
}
return out
}
func (h inprocHost) ListRoles(ctx context.Context) ([]extension.RoleAdminRecord, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := h.requireRoleAdminAction("system.roles.view"); err != nil {
return nil, err
}
result := []extension.RoleAdminRecord{}
cursor := ""
for {
roles, next, err := h.caps.st.ListRoles("", "fuzzy", "all", "", cursor, 100)
if err != nil {
return nil, err
}
for _, role := range roles {
result = append(result, roleAdminRecord(role))
}
if next == "" {
return result, nil
}
cursor = next
}
}
func (h inprocHost) CreateRole(ctx context.Context, name, description string) (extension.RoleAdminRecord, error) {
if err := ctx.Err(); err != nil {
return extension.RoleAdminRecord{}, err
}
if err := h.requireRoleAdminAction("system.roles.manage"); err != nil {
return extension.RoleAdminRecord{}, err
}
role, err := h.caps.st.CreateRole(h.actor, store.RoleCreate{Name: name, Description: description})
if err != nil {
return extension.RoleAdminRecord{}, err
}
view, err := h.caps.st.RoleAdmin(role.RoleID)
return roleAdminRecord(view), err
}
func (h inprocHost) SetRolePermission(ctx context.Context, roleID, actionID string, allowed, capAllowed bool, key string) (extension.RoleAdminRecord, error) {
if err := ctx.Err(); err != nil {
return extension.RoleAdminRecord{}, err
}
if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil {
return extension.RoleAdminRecord{}, err
}
view, err := h.caps.st.RoleAdmin(roleID)
if err != nil {
return extension.RoleAdminRecord{}, err
}
changes := make([]store.RolePermissionChange, 0, len(view.Permissions)+1)
found := false
for _, permission := range view.Permissions {
if permission.ActionID == actionID {
found = true
if permission.Allowed == allowed && permission.CapAllowed == capAllowed {
return roleAdminRecord(view), nil
}
permission.Allowed, permission.CapAllowed = allowed, capAllowed
}
changes = append(changes, store.RolePermissionChange{ActionID: permission.ActionID, Allowed: permission.Allowed, CapAllowed: permission.CapAllowed})
}
if !found {
changes = append(changes, store.RolePermissionChange{ActionID: actionID, Allowed: allowed, CapAllowed: capAllowed})
}
if key == "" {
return extension.RoleAdminRecord{}, store.ErrInvalidRolePolicy
}
if _, err := h.caps.st.ReplaceRolePermissions(h.actor, roleID, view.RoleVersion, changes, key+":"+strconv.FormatInt(view.RoleVersion, 10)); err != nil {
return extension.RoleAdminRecord{}, err
}
view, err = h.caps.st.RoleAdmin(roleID)
return roleAdminRecord(view), err
}
func (h inprocHost) AssignRoleByOwner(ctx context.Context, owner, roleID, key string) (extension.RoleBindingResult, error) {
if err := ctx.Err(); err != nil {
return extension.RoleBindingResult{}, err
}
if err := h.requireRoleAdminAction("system.roles.assign"); err != nil {
return extension.RoleBindingResult{}, err
}
target, err := h.caps.st.GetAccountByOwner(owner)
if err != nil {
return extension.RoleBindingResult{}, err
}
current, err := h.caps.st.RoleForUser(target.ID)
if err != nil {
return extension.RoleBindingResult{}, err
}
if current.Binding != nil && current.Binding.RoleID == roleID {
return extension.RoleBindingResult{BindingID: current.Binding.BindingID, UserID: target.ID, RoleID: roleID,
UserStateVersion: current.Binding.UserStateVersion, AuthorityEpoch: current.Binding.AuthorityEpoch}, nil
}
if key == "" {
return extension.RoleBindingResult{}, store.ErrInvalidRolePolicy
}
binding, err := h.caps.st.AssignUserRole(h.actor, target.ID, roleID, target.StateVersion, key+":"+strconv.FormatInt(target.StateVersion, 10))
if err != nil {
return extension.RoleBindingResult{}, err
}
return extension.RoleBindingResult{BindingID: binding.BindingID, UserID: binding.UserID, RoleID: binding.RoleID,
UserStateVersion: binding.UserStateVersion, AuthorityEpoch: binding.AuthorityEpoch}, nil
}
func (h inprocHost) SetRoleEnabled(ctx context.Context, roleID string, enabled bool) (extension.RoleAdminRecord, error) {
if err := ctx.Err(); err != nil {
return extension.RoleAdminRecord{}, err
}
if err := h.requireRoleAdminAction("system.roles.manage"); err != nil {
return extension.RoleAdminRecord{}, err
}
view, err := h.caps.st.RoleAdmin(roleID)
if err != nil {
return extension.RoleAdminRecord{}, err
}
if view.Enabled == enabled {
return roleAdminRecord(view), nil
}
role, err := h.caps.st.PatchRole(h.actor, roleID, view.RoleVersion, store.RolePatch{Enabled: &enabled})
if err != nil {
return extension.RoleAdminRecord{}, err
}
view, err = h.caps.st.RoleAdmin(role.RoleID)
return roleAdminRecord(view), err
}
func (h inprocHost) KVPut(key, value string) error {
if err := h.requireEffective(); err != nil {
return err
+39
View File
@@ -43,6 +43,10 @@ func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) {
if err != nil {
t.Fatal(err)
}
byOwner, err := provider.RolePolicyForOwner(context.Background(), "alice")
if err != nil || byOwner.UserID != alice.ID || byOwner.RoleID != role.RoleID {
t.Fatalf("owner policy=%+v err=%v", byOwner, err)
}
if snapshot.SchemaVersion != extension.RolePolicySchemaVersion || snapshot.UserID != alice.ID || !snapshot.AccountEnabled ||
snapshot.RoleID != role.RoleID || snapshot.RoleName != "软件工程师" || snapshot.RoleDescription != "负责软件研发" ||
!snapshot.RoleEnabled || snapshot.RoleVersion != receipt.RoleVersion || snapshot.AuthorityEpoch < receipt.AuthorityEpoch {
@@ -74,3 +78,38 @@ func TestRoleProviderRejectsUntrustedScopedHost(t *testing.T) {
t.Fatalf("empty actor provider: %v", err)
}
}
func TestRolePolicyAdminUsesBoundActorAndStableOwnerCompatibility(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/role-admin.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)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "admin", actor: "admin"}
admin, ok := any(h).(extension.RolePolicyAdmin)
if !ok {
t.Fatal("dispatch host missing RolePolicyAdmin")
}
role, err := admin.CreateRole(context.Background(), "软件工程师", "负责软件研发")
if err != nil {
t.Fatal(err)
}
role, err = admin.SetRolePermission(context.Background(), role.RoleID, "document.read", true, true, "legacy-action")
if err != nil || !role.Permissions["document.read"].Allowed {
t.Fatalf("role=%+v err=%v", role, err)
}
binding, err := admin.AssignRoleByOwner(context.Background(), "alice", role.RoleID, "legacy-assign")
if err != nil || binding.RoleID != role.RoleID {
t.Fatalf("binding=%+v err=%v", binding, err)
}
roles, err := admin.ListRoles(context.Background())
if err != nil || len(roles) != 1 || roles[0].BindingCount != 1 {
t.Fatalf("roles=%+v err=%v", roles, err)
}
}
+6
View File
@@ -32,6 +32,8 @@ var descriptors = []Descriptor{
{ActionID: "system.permissions.manage", Label: "管理系统权限", Description: "配置明确系统授权与平台上限", Group: "权限管理", Scope: ScopeSystem, Protected: true},
{ActionID: "project.create", Label: "创建项目", Description: "创建新的项目", Group: "项目入口", Scope: ScopeSystem},
{ActionID: "project.settings", Label: "项目设置", Description: "修改本项目设置", Group: "项目管理", Scope: ScopeProject},
{ActionID: "project.archive", Label: "归档项目", Description: "归档或恢复本人创建的项目", Group: "项目管理", Scope: ScopeProject},
{ActionID: "project.stage.advance", Label: "推进项目阶段", Description: "推进本人创建项目的阶段", Group: "项目管理", Scope: ScopeProject},
{ActionID: "project.members.manage", Label: "管理项目成员", Description: "维护本项目成员", Group: "项目管理", Scope: ScopeProject},
{ActionID: "project.permissions.manage", Label: "管理项目权限", Description: "维护本项目成员权限覆盖", Group: "项目管理", Scope: ScopeProject},
{ActionID: "document.duties.assign", Label: "分配文档三权", Description: "分配本项目文档编制、审核和批准职责", Group: "文档职责", Scope: ScopeProject},
@@ -39,6 +41,10 @@ var descriptors = []Descriptor{
{ActionID: "document.author", Label: "编制文档", Description: "履行已分配的文档编制职责", Group: "文档动作", Scope: ScopeProject},
{ActionID: "document.review", Label: "审核文档", Description: "履行已分配的文档审核职责", Group: "文档动作", Scope: ScopeProject},
{ActionID: "document.approve", Label: "批准文档", Description: "履行已分配的文档批准职责", Group: "文档动作", Scope: ScopeProject},
{ActionID: "document.publish", Label: "发布文档", Description: "发布已完成审签的项目文档", Group: "文档动作", Scope: ScopeProject},
{ActionID: "trace.edit", Label: "编辑追溯网", Description: "编辑本项目追溯关系", Group: "项目内容", Scope: ScopeProject},
{ActionID: "knowledge.publish", Label: "发布项目知识", Description: "发布或撤销本项目知识", Group: "项目内容", Scope: ScopeProject},
{ActionID: "project.audit.read", Label: "读取业务审计", Description: "读取获准项目的业务审计", Group: "项目内容", Scope: ScopeProject},
}
var descriptorByID = func() map[string]Descriptor {
+29
View File
@@ -40,5 +40,34 @@ type RoleActionDecision struct {
type RolePolicyProvider interface {
CurrentRolePolicy(context.Context) (RolePolicySnapshot, error)
RolePolicyForUser(context.Context, string) (RolePolicySnapshot, error)
RolePolicyForOwner(context.Context, string) (RolePolicySnapshot, error)
CheckRoleAction(context.Context, string, string) (RoleActionDecision, error)
}
type RoleAdminRecord struct {
RoleID string `json:"roleId"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
RoleVersion int64 `json:"roleVersion"`
BindingCount int `json:"bindingCount"`
Permissions map[string]RolePermission `json:"permissions"`
}
type RoleBindingResult struct {
BindingID string `json:"bindingId"`
UserID string `json:"userId"`
RoleID string `json:"roleId"`
UserStateVersion int64 `json:"userStateVersion"`
AuthorityEpoch int64 `json:"authorityEpoch"`
}
// RolePolicyAdmin is a temporary compatibility surface for trusted RDM
// commands while the UI moves to Backend HTTP APIs. The Host binds the actor.
type RolePolicyAdmin interface {
ListRoles(context.Context) ([]RoleAdminRecord, error)
CreateRole(context.Context, string, string) (RoleAdminRecord, error)
SetRolePermission(context.Context, string, string, bool, bool, string) (RoleAdminRecord, error)
AssignRoleByOwner(context.Context, string, string, string) (RoleBindingResult, error)
SetRoleEnabled(context.Context, string, bool) (RoleAdminRecord, error)
}
+9
View File
@@ -80,10 +80,19 @@ ResourceAuthorizer
ResourceAuthorizerV2
ResourceAuthzQuery
RoleActionDecision
RoleAdminRecord
RoleBindingResult
RolePermission
RolePolicyAdmin
RolePolicyAdmin.AssignRoleByOwner
RolePolicyAdmin.CreateRole
RolePolicyAdmin.ListRoles
RolePolicyAdmin.SetRoleEnabled
RolePolicyAdmin.SetRolePermission
RolePolicyProvider
RolePolicyProvider.CheckRoleAction
RolePolicyProvider.CurrentRolePolicy
RolePolicyProvider.RolePolicyForOwner
RolePolicyProvider.RolePolicyForUser
RolePolicySchemaVersion
RolePolicySnapshot