Files
Agent-System-Backend/internal/store/role_policy_test.go
T
weichen 06ef17a70a Harden delegated role authority boundaries
Why: Independent review found delegated account actions, stale resource policy, and role migration cutover gaps in the initial U4 implementation.

Evidence: Backend full SQLite suite passed; isolated PostgreSQL role and migration suite passed; independent final review found no remaining findings.

Impact: Enforces transactional system grants, current role snapshots, staged role import with v36 migration, and fail-closed activation.

Refs: feature/iam-u4-role-authority
2026-10-15 02:27:50 +00:00

262 lines
9.9 KiB
Go

package store
import (
"errors"
"strings"
"sync"
"testing"
)
func rolePolicyAccounts(t *testing.T, st *Store) (Account, Account, Account) {
t.Helper()
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)
}
if err := st.CreateAccount("bob", "Bob", "hash", false); err != nil {
t.Fatal(err)
}
admin, _ := st.GetAccountByOwner("admin")
alice, _ := st.GetAccountByOwner("alice")
bob, _ := st.GetAccountByOwner("bob")
return admin, alice, bob
}
func TestAssignUserRoleKeepsOneEffectiveBinding(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
engineer, err := st.CreateRole("admin", RoleCreate{Name: "软件工程师", Description: "软件研发"})
if err != nil {
t.Fatal(err)
}
reviewer, err := st.CreateRole("admin", RoleCreate{Name: "审核工程师"})
if err != nil {
t.Fatal(err)
}
first, err := st.AssignUserRole("admin", alice.ID, engineer.RoleID, alice.StateVersion, "assign-1")
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", bob.ID, engineer.RoleID, bob.StateVersion, "assign-2"); err != nil {
t.Fatal(err)
}
second, err := st.AssignUserRole("admin", alice.ID, reviewer.RoleID, first.UserStateVersion, "assign-3")
if err != nil {
t.Fatal(err)
}
if second.RoleID != reviewer.RoleID {
t.Fatalf("binding=%+v", second)
}
var active, engineerUsers int
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM user_role_bindings WHERE user_id=? AND active=?`), alice.ID, true).Scan(&active); err != nil {
t.Fatal(err)
}
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM user_role_bindings WHERE role_id=? AND active=?`), engineer.RoleID, true).Scan(&engineerUsers); err != nil {
t.Fatal(err)
}
if active != 1 || engineerUsers != 1 {
t.Fatalf("alice active=%d engineer users=%d", active, engineerUsers)
}
}
func TestAssignUserRoleConcurrentExpectedVersionAllowsOne(t *testing.T) {
st := openTestStore(t)
_, alice, _ := rolePolicyAccounts(t, st)
first, _ := st.CreateRole("admin", RoleCreate{Name: "职位一"})
second, _ := st.CreateRole("admin", RoleCreate{Name: "职位二"})
start := make(chan struct{})
errs := make(chan error, 2)
var wg sync.WaitGroup
for i, roleID := range []string{first.RoleID, second.RoleID} {
wg.Add(1)
go func(index int, id string) {
defer wg.Done()
<-start
_, err := st.AssignUserRole("admin", alice.ID, id, alice.StateVersion, "race-"+string(rune('a'+index)))
errs <- err
}(i, roleID)
}
close(start)
wg.Wait()
close(errs)
var passed, conflicted int
for err := range errs {
switch {
case err == nil:
passed++
case errors.Is(err, ErrConflict):
conflicted++
default:
t.Fatalf("unexpected error: %v", err)
}
}
if passed != 1 || conflicted != 1 {
t.Fatalf("passed=%d conflicted=%d", passed, conflicted)
}
}
func TestReplaceRolePermissionsIsAtomicAndIdempotent(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "软件工程师"})
if err != nil {
t.Fatal(err)
}
bad := []RolePermissionChange{
{ActionID: "project.create", Allowed: true, CapAllowed: true},
{ActionID: "document.read", Allowed: true, CapAllowed: false},
}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, bad, "policy-bad"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("invalid batch: %v", err)
}
var count int
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM role_permissions WHERE role_id=?`), role.RoleID).Scan(&count); err != nil || count != 0 {
t.Fatalf("partial writes=%d err=%v", count, err)
}
good := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, good, "policy-good")
if err != nil {
t.Fatal(err)
}
replayed, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, good, "policy-good")
if err != nil || replayed.ReceiptID != receipt.ReceiptID || replayed.AuthorityEpoch != receipt.AuthorityEpoch {
t.Fatalf("replay=%+v err=%v original=%+v", replayed, err, receipt)
}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, good, "policy-stale"); !errors.Is(err, ErrConflict) {
t.Fatalf("stale role version: %v", err)
}
}
func TestReplaceSystemGrantsProtectsFinalPermissionManager(t *testing.T) {
st := openTestStore(t)
admin, alice, _ := rolePolicyAccounts(t, st)
epoch, err := st.AuthorityEpoch()
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, nil, "remove-last"); !errors.Is(err, ErrLastPermissionManager) {
t.Fatalf("remove final manager: %v", err)
}
if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"system.permissions.manage"}, "add-second"); err != nil {
t.Fatal(err)
}
epoch, _ = st.AuthorityEpoch()
if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, nil, "remove-first"); err != nil {
t.Fatal(err)
}
}
func TestDisableProtectsFinalPermissionManager(t *testing.T) {
st := openTestStore(t)
admin, alice, _ := rolePolicyAccounts(t, st)
epoch, _ := st.AuthorityEpoch()
if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"system.permissions.manage"}, "grant-final-manager"); err != nil {
t.Fatal(err)
}
epoch, _ = st.AuthorityEpoch()
if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, nil, "remove-root-explicit-grants"); err != nil {
t.Fatal(err)
}
alice, _ = st.GetAccount(alice.ID)
if _, err := st.SetAccountEnabled("admin", alice.ID, alice.StateVersion, false); !errors.Is(err, ErrLastPermissionManager) {
t.Fatalf("disabled final permission manager: %v", err)
}
}
func TestPatchRoleKeepsStableIDAndDisablingBlocksAssignment(t *testing.T) {
st := openTestStore(t)
_, alice, _ := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "软件工程师"})
if err != nil {
t.Fatal(err)
}
description := "负责软件研发"
updated, err := st.PatchRole("admin", role.RoleID, role.RoleVersion, RolePatch{Description: &description})
if err != nil {
t.Fatal(err)
}
if updated.RoleID != role.RoleID || updated.Description != description || updated.RoleVersion != role.RoleVersion+1 {
t.Fatalf("updated role=%+v original=%+v", updated, role)
}
disabled := false
updated, err = st.PatchRole("admin", role.RoleID, updated.RoleVersion, RolePatch{Enabled: &disabled})
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "disabled-role"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("disabled role assignment: %v", err)
}
}
func TestRolePolicyIdempotencyKeyCannotChangeRequest(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "软件工程师"})
if err != nil {
t.Fatal(err)
}
first := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, first, "same-key"); err != nil {
t.Fatal(err)
}
changed := []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion+1, changed, "same-key"); !errors.Is(err, ErrConflict) {
t.Fatalf("changed request reused idempotency key: %v", err)
}
}
func TestImportRoleAuthorityPreservesStableIDsAndBindingsAtomically(t *testing.T) {
st := openTestStore(t)
for _, row := range []struct {
owner string
admin bool
}{{"admin", true}, {"alice", false}, {"bob", false}} {
if err := st.CreateAccount(row.owner, row.owner, "hash", row.admin); err != nil {
t.Fatal(err)
}
}
in := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("a", 64), Roles: []RoleAuthorityImportRole{{
RoleID: "role_legacy_engineer", Name: "软件工程师", Description: "迁移职位", Enabled: true, RoleVersion: 4,
Permissions: []RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}},
Owners: []string{"alice", "bob"},
}}}
result, err := st.ImportRoleAuthority("admin", in, "import-role-authority")
if err != nil || result.ImportedRoles != 1 || result.ImportedUsers != 2 {
t.Fatalf("result=%+v err=%v", result, err)
}
if _, err := st.RoleAdmin("role_legacy_engineer"); !errors.Is(err, ErrNotFound) {
t.Fatalf("staged role became live before activation: %v", err)
}
if _, err := st.CreateRole("admin", RoleCreate{Name: "并发角色"}); !errors.Is(err, ErrConflict) {
t.Fatalf("live role write bypassed staged migration fence: %v", err)
}
staged, err := st.ReadRoleAuthorityImport("admin", in.SourceSHA256)
if err != nil || len(staged.Roles) != 1 || len(staged.Roles[0].Owners) != 2 {
t.Fatalf("staged=%+v err=%v", staged, err)
}
activated, err := st.ActivateRoleAuthorityImport("admin", in.SourceSHA256, result.AuthorityEpoch)
if err != nil || activated.AuthorityEpoch != result.AuthorityEpoch+1 {
t.Fatalf("activated=%+v err=%v", activated, err)
}
view, err := st.RoleAdmin("role_legacy_engineer")
if err != nil || view.RoleVersion != 4 || view.BindingCount != 2 || view.Description != "迁移职位" {
t.Fatalf("view=%+v err=%v", view, err)
}
for _, owner := range []string{"alice", "bob"} {
account, _ := st.GetAccountByOwner(owner)
binding, err := st.RoleForUser(account.ID)
if err != nil || binding.Role == nil || binding.Role.RoleID != "role_legacy_engineer" {
t.Fatalf("owner=%s binding=%+v err=%v", owner, binding, err)
}
}
replayed, err := st.ImportRoleAuthority("admin", in, "import-role-authority")
if err != nil || replayed.AuthorityEpoch != result.AuthorityEpoch {
t.Fatalf("replay=%+v err=%v", replayed, err)
}
if _, err := st.ImportRoleAuthority("admin", in, "second-import"); !errors.Is(err, ErrConflict) {
t.Fatalf("non-empty authority accepted a second import: %v", err)
}
}