diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 51abd53..3312b3f 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -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 diff --git a/internal/app/role_provider_test.go b/internal/app/role_provider_test.go index 1e45377..1804387 100644 --- a/internal/app/role_provider_test.go +++ b/internal/app/role_provider_test.go @@ -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) + } +} diff --git a/internal/authz/catalog/catalog.go b/internal/authz/catalog/catalog.go index 5ddd7a6..29c7271 100644 --- a/internal/authz/catalog/catalog.go +++ b/internal/authz/catalog/catalog.go @@ -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 { diff --git a/pkg/extension/role_policy.go b/pkg/extension/role_policy.go index 2bd374d..f9dab8b 100644 --- a/pkg/extension/role_policy.go +++ b/pkg/extension/role_policy.go @@ -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) +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 54a07aa..a738dbe 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -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