Add atomic role and binding policies

Why: Role assignment and permission changes must preserve one active position, version conflicts, idempotency, and the final policy manager.

Evidence: The full Store suite passed on SQLite and focused role/policy tests passed on isolated PostgreSQL.

Impact: Adds transactional role mutations and seeds explicit system grants for existing administrators.

Refs: Focused role and permission system task 2
This commit is contained in:
weichen
2026-10-14 16:51:57 +00:00
parent 6b1afab82b
commit 74096dbdd7
4 changed files with 653 additions and 10 deletions
+11 -1
View File
@@ -908,7 +908,7 @@ func init() {
}
func rolePolicyFoundationUp(boolType string) []string {
return []string{
statements := []string{
`CREATE TABLE roles (
role_id TEXT PRIMARY KEY,name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',enabled ` + boolType + ` NOT NULL,
@@ -943,6 +943,16 @@ func rolePolicyFoundationUp(boolType string) []string {
operation TEXT NOT NULL,request_hash TEXT NOT NULL,result_json TEXT NOT NULL,created_at TEXT NOT NULL,
UNIQUE(actor_user_id,idempotency_key),FOREIGN KEY(actor_user_id) REFERENCES users(id))`,
}
for _, actionID := range []string{
"system.settings.read", "system.settings.write",
"system.users.view", "system.users.create", "system.users.edit", "system.users.disable",
"system.roles.view", "system.roles.manage", "system.roles.assign",
"system.permissions.view", "system.permissions.manage", "project.create",
} {
statements = append(statements, `INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at)
SELECT id,'`+actionID+`',TRUE,1,id,created_at FROM users WHERE is_admin=TRUE`)
}
return statements
}
var rolePolicyFoundationDown = []string{
+426 -9
View File
@@ -1,6 +1,44 @@
package store
import "time"
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"agent-system-backend/internal/authz"
)
var (
ErrInvalidRolePolicy = errors.New("store: invalid role policy")
ErrLastPermissionManager = errors.New("store: final permission manager")
)
type RoleCreate struct {
Name string
Description string
}
type RolePatch struct {
Name *string
Description *string
Enabled *bool
}
type RolePermissionChange struct {
ActionID string `json:"actionId"`
Allowed bool `json:"allowed"`
CapAllowed bool `json:"capAllowed"`
}
type PolicyReceipt struct {
ReceiptID string `json:"receiptId"`
AuthorityEpoch int64 `json:"authorityEpoch"`
RoleVersion int64 `json:"roleVersion,omitempty"`
}
// RoleRecord is the stable position definition stored by Backend.
type RoleRecord struct {
@@ -18,12 +56,391 @@ type RoleRecord struct {
// RoleBinding records one appointment. At most one active binding exists for
// a user; inactive rows retain the appointment history.
type RoleBinding struct {
BindingID string `json:"bindingId"`
UserID string `json:"userId"`
RoleID string `json:"roleId"`
Active bool `json:"active"`
BindingVersion int64 `json:"bindingVersion"`
AssignedBy string `json:"assignedBy"`
AssignedAt time.Time `json:"assignedAt"`
EndedAt *time.Time `json:"endedAt,omitempty"`
BindingID string `json:"bindingId"`
UserID string `json:"userId"`
RoleID string `json:"roleId"`
Active bool `json:"active"`
BindingVersion int64 `json:"bindingVersion"`
AssignedBy string `json:"assignedBy"`
AssignedAt time.Time `json:"assignedAt"`
EndedAt *time.Time `json:"endedAt,omitempty"`
UserStateVersion int64 `json:"userStateVersion"`
AuthorityEpoch int64 `json:"authorityEpoch"`
}
type RoleBindingView struct {
Binding *RoleBinding `json:"binding,omitempty"`
Role *RoleRecord `json:"role,omitempty"`
}
func normalizeRoleName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func validateRoleText(name, description string) (string, string, error) {
name = strings.TrimSpace(name)
description = strings.TrimSpace(description)
if name == "" || len([]rune(name)) > 128 || len([]rune(description)) > 2000 {
return "", "", ErrInvalidRolePolicy
}
return name, description, nil
}
func (s *Store) policyWrite(actor, targetUserID string, fn func(*sql.Tx, Account, Account, int64) error) error {
if actor == "" || fn == nil {
return ErrAccountForbidden
}
targetOwner := actor
if targetUserID != "" {
target, err := s.GetAccount(targetUserID)
if err != nil {
return ErrAccountForbidden
}
targetOwner = target.Owner
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
accounts, err := s.lockAccountUsers(tx, actor, targetOwner)
if err != nil {
return ErrAccountForbidden
}
actorAccount := accounts[actor]
targetAccount := accounts[targetOwner]
if !actorAccount.Enabled || !targetAccount.Enabled || (targetUserID != "" && targetAccount.ID != targetUserID) {
return ErrAccountForbidden
}
query := `SELECT authority_epoch FROM authority_state WHERE id=1`
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
var epoch int64
if err := tx.QueryRow(query).Scan(&epoch); err != nil {
return err
}
if err := fn(tx, actorAccount, targetAccount, epoch); err != nil {
return err
}
return tx.Commit()
}
func scanRole(row *sql.Row) (RoleRecord, error) {
var role RoleRecord
var createdAt, updatedAt string
err := row.Scan(&role.RoleID, &role.Name, &role.Description, &role.Enabled, &role.RoleVersion,
&role.CreatedBy, &role.UpdatedBy, &createdAt, &updatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return RoleRecord{}, ErrNotFound
}
return RoleRecord{}, err
}
role.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
role.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt)
return role, nil
}
func (s *Store) roleTx(tx *sql.Tx, roleID string, lock bool) (RoleRecord, error) {
query := `SELECT role_id,name,description,enabled,role_version,created_by,updated_by,created_at,updated_at FROM roles WHERE role_id=?`
if lock && s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
return scanRole(tx.QueryRow(s.q(query), roleID))
}
func (s *Store) CreateRole(actor string, in RoleCreate) (RoleRecord, error) {
name, description, err := validateRoleText(in.Name, in.Description)
if err != nil {
return RoleRecord{}, err
}
var out RoleRecord
err = s.policyWrite(actor, "", func(tx *sql.Tx, actorAccount, _ Account, epoch int64) error {
now := nowISO()
roleID := newID("role")
_, err := tx.Exec(s.q(`INSERT INTO roles(role_id,name,normalized_name,description,enabled,role_version,created_by,updated_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`), roleID, name, normalizeRoleName(name), description, true, 1, actorAccount.ID, actorAccount.ID, now, now)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") || strings.Contains(strings.ToLower(err.Error()), "duplicate") {
return ErrConflict
}
return err
}
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
return err
}
out, err = s.roleTx(tx, roleID, false)
return err
})
return out, err
}
func (s *Store) PatchRole(actor, roleID string, expected int64, patch RolePatch) (RoleRecord, error) {
var out RoleRecord
err := s.policyWrite(actor, "", func(tx *sql.Tx, actorAccount, _ Account, epoch int64) error {
current, err := s.roleTx(tx, roleID, true)
if err != nil {
return err
}
if current.RoleVersion != expected {
return ErrConflict
}
name, description, enabled := current.Name, current.Description, current.Enabled
if patch.Name != nil {
name = *patch.Name
}
if patch.Description != nil {
description = *patch.Description
}
if patch.Enabled != nil {
enabled = *patch.Enabled
}
name, description, err = validateRoleText(name, description)
if err != nil {
return err
}
now := nowISO()
result, err := tx.Exec(s.q(`UPDATE roles SET name=?,normalized_name=?,description=?,enabled=?,role_version=role_version+1,updated_by=?,updated_at=? WHERE role_id=? AND role_version=?`),
name, normalizeRoleName(name), description, enabled, actorAccount.ID, now, roleID, expected)
if err != nil {
return err
}
if n, _ := result.RowsAffected(); n != 1 {
return ErrConflict
}
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
return err
}
out, err = s.roleTx(tx, roleID, false)
return err
})
return out, err
}
func policyRequestHash(value any) string {
data, _ := json.Marshal(value)
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func (s *Store) replayReceipt(tx *sql.Tx, actorID, key, operation, requestHash string, out any) (bool, error) {
if key == "" {
return false, ErrInvalidRolePolicy
}
var storedOperation, storedHash, result string
err := tx.QueryRow(s.q(`SELECT operation,request_hash,result_json FROM policy_receipts WHERE actor_user_id=? AND idempotency_key=?`), actorID, key).
Scan(&storedOperation, &storedHash, &result)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
if storedOperation != operation || storedHash != requestHash {
return false, ErrConflict
}
return true, json.Unmarshal([]byte(result), out)
}
func (s *Store) saveReceipt(tx *sql.Tx, actorID, key, operation, requestHash string, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
_, err = tx.Exec(s.q(`INSERT INTO policy_receipts(receipt_id,actor_user_id,idempotency_key,operation,request_hash,result_json,created_at)
VALUES(?,?,?,?,?,?,?)`), receiptID(value), actorID, key, operation, requestHash, string(data), nowISO())
return err
}
func receiptID(value any) string {
if receipt, ok := value.(PolicyReceipt); ok && receipt.ReceiptID != "" {
return receipt.ReceiptID
}
if binding, ok := value.(RoleBinding); ok && binding.BindingID != "" {
return "receipt_" + binding.BindingID
}
return newID("receipt")
}
func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, changes []RolePermissionChange, key string) (PolicyReceipt, error) {
requestHash := policyRequestHash(struct {
RoleID string
Expected int64
Changes []RolePermissionChange
}{roleID, expected, changes})
var out PolicyReceipt
err := s.policyWrite(actor, "", func(tx *sql.Tx, actorAccount, _ Account, epoch int64) error {
replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "role.permissions.replace", requestHash, &out)
if err != nil || replayed {
return err
}
role, err := s.roleTx(tx, roleID, true)
if err != nil {
return err
}
if role.RoleVersion != expected {
return ErrConflict
}
seen := map[string]bool{}
for _, change := range changes {
descriptor, ok := authz.DescriptorByID(change.ActionID)
if !ok || descriptor.Protected || seen[change.ActionID] || (change.Allowed && !change.CapAllowed) {
return ErrInvalidRolePolicy
}
seen[change.ActionID] = true
}
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=?`), roleID); err != nil {
return err
}
now := nowISO()
for _, change := range changes {
if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at)
VALUES(?,?,?,?,?,?,?)`), roleID, change.ActionID, change.Allowed, change.CapAllowed, 1, actorAccount.ID, now); err != nil {
return err
}
}
result, err := tx.Exec(s.q(`UPDATE roles SET role_version=role_version+1,updated_by=?,updated_at=? WHERE role_id=? AND role_version=?`), actorAccount.ID, now, roleID, expected)
if err != nil {
return err
}
if n, _ := result.RowsAffected(); n != 1 {
return ErrConflict
}
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
return err
}
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, RoleVersion: expected + 1}
return s.saveReceipt(tx, actorAccount.ID, key, "role.permissions.replace", requestHash, out)
})
return out, err
}
func (s *Store) AssignUserRole(actor, userID, roleID string, expectedState int64, key string) (RoleBinding, error) {
requestHash := policyRequestHash(struct {
UserID, RoleID string
Expected int64
}{userID, roleID, expectedState})
var out RoleBinding
err := s.policyWrite(actor, userID, func(tx *sql.Tx, actorAccount, target Account, epoch int64) error {
replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "user.role.assign", requestHash, &out)
if err != nil || replayed {
return err
}
if target.StateVersion != expectedState {
return ErrConflict
}
role, err := s.roleTx(tx, roleID, true)
if err != nil || !role.Enabled {
return ErrInvalidRolePolicy
}
now := nowISO()
if _, err := tx.Exec(s.q(`UPDATE user_role_bindings SET active=?,binding_version=binding_version+1,ended_at=? WHERE user_id=? AND active=?`), false, now, userID, true); err != nil {
return err
}
bindingID := newID("binding")
if _, err := tx.Exec(s.q(`INSERT INTO user_role_bindings(binding_id,user_id,role_id,active,binding_version,assigned_by,assigned_at,ended_at)
VALUES(?,?,?,?,?,?,?,?)`), bindingID, userID, roleID, true, 1, actorAccount.ID, now, ""); err != nil {
return err
}
result, err := tx.Exec(s.q(`UPDATE users SET state_version=state_version+1 WHERE id=? AND state_version=? AND enabled=?`), userID, expectedState, true)
if err != nil {
return err
}
if n, _ := result.RowsAffected(); n != 1 {
return ErrConflict
}
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
return err
}
assignedAt, _ := time.Parse(time.RFC3339Nano, now)
out = RoleBinding{BindingID: bindingID, UserID: userID, RoleID: roleID, Active: true, BindingVersion: 1,
AssignedBy: actorAccount.ID, AssignedAt: assignedAt, UserStateVersion: expectedState + 1, AuthorityEpoch: epoch + 1}
return s.saveReceipt(tx, actorAccount.ID, key, "user.role.assign", requestHash, out)
})
return out, err
}
func (s *Store) RoleForUser(userID string) (RoleBindingView, error) {
row := s.db.QueryRow(s.q(`SELECT b.binding_id,b.user_id,b.role_id,b.active,b.binding_version,b.assigned_by,b.assigned_at,b.ended_at,
r.name,r.description,r.enabled,r.role_version,r.created_by,r.updated_by,r.created_at,r.updated_at,u.state_version,a.authority_epoch
FROM user_role_bindings b JOIN roles r ON r.role_id=b.role_id JOIN users u ON u.id=b.user_id CROSS JOIN authority_state a
WHERE b.user_id=? AND b.active=? AND a.id=1`), userID, true)
var binding RoleBinding
var role RoleRecord
var assignedAt, endedAt, createdAt, updatedAt string
err := row.Scan(&binding.BindingID, &binding.UserID, &binding.RoleID, &binding.Active, &binding.BindingVersion,
&binding.AssignedBy, &assignedAt, &endedAt, &role.Name, &role.Description, &role.Enabled, &role.RoleVersion,
&role.CreatedBy, &role.UpdatedBy, &createdAt, &updatedAt, &binding.UserStateVersion, &binding.AuthorityEpoch)
if errors.Is(err, sql.ErrNoRows) {
return RoleBindingView{}, nil
}
if err != nil {
return RoleBindingView{}, err
}
role.RoleID = binding.RoleID
binding.AssignedAt, _ = time.Parse(time.RFC3339Nano, assignedAt)
if endedAt != "" {
parsed, _ := time.Parse(time.RFC3339Nano, endedAt)
binding.EndedAt = &parsed
}
role.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
role.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt)
return RoleBindingView{Binding: &binding, Role: &role}, nil
}
func (s *Store) AuthorityEpoch() (int64, error) {
var epoch int64
err := s.db.QueryRow(`SELECT authority_epoch FROM authority_state WHERE id=1`).Scan(&epoch)
return epoch, err
}
func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, actions []string, key string) (PolicyReceipt, error) {
requestHash := policyRequestHash(struct {
UserID string
Epoch int64
Actions []string
}{userID, expectedEpoch, actions})
var out PolicyReceipt
err := s.policyWrite(actor, userID, func(tx *sql.Tx, actorAccount, target Account, epoch int64) error {
replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "system.grants.replace", requestHash, &out)
if err != nil || replayed {
return err
}
if epoch != expectedEpoch {
return ErrConflict
}
seen := map[string]bool{}
for _, actionID := range actions {
descriptor, ok := authz.DescriptorByID(actionID)
if !ok || descriptor.Scope != authz.ScopeSystem || seen[actionID] {
return ErrInvalidRolePolicy
}
seen[actionID] = true
}
if _, err := tx.Exec(s.q(`DELETE FROM system_grants WHERE user_id=?`), userID); err != nil {
return err
}
now := nowISO()
for _, actionID := range actions {
if _, err := tx.Exec(s.q(`INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at)
VALUES(?,?,?,?,?,?)`), userID, actionID, true, 1, actorAccount.ID, now); err != nil {
return err
}
}
var managers int
if err := tx.QueryRow(s.q(`SELECT COUNT(DISTINCT g.user_id) FROM system_grants g JOIN users u ON u.id=g.user_id
WHERE g.action_id=? AND g.allowed=? AND u.enabled=?`), "system.permissions.manage", true, true).Scan(&managers); err != nil {
return err
}
if managers == 0 {
return ErrLastPermissionManager
}
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
return err
}
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1}
return s.saveReceipt(tx, actorAccount.ID, key, "system.grants.replace", requestHash, out)
})
return out, err
}
@@ -79,3 +79,24 @@ func TestRolePolicyMigrationCanRollbackAndReapply(t *testing.T) {
t.Fatalf("roles table missing after reapply: %v", err)
}
}
func TestRolePolicyMigrationSeedsLegacyAdministratorGrants(t *testing.T) {
st := openTestStore(t)
if err := st.MigrateDown(34); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
if err := st.migrate(); err != nil {
t.Fatal(err)
}
var grants int
if err := st.db.QueryRow(`SELECT COUNT(*) FROM system_grants g JOIN users u ON u.id=g.user_id
WHERE u.owner='admin' AND g.allowed=TRUE`).Scan(&grants); err != nil {
t.Fatal(err)
}
if grants != 12 {
t.Fatalf("legacy administrator grants=%d", grants)
}
}
+195
View File
@@ -0,0 +1,195 @@
package store
import (
"errors"
"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)
now := nowISO()
if _, err := st.db.Exec(st.q(`INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at)
VALUES(?,?,?,?,?,?)`), admin.ID, "system.permissions.manage", true, 1, admin.ID, now); err != nil {
t.Fatal(err)
}
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 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)
}
}