diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 7f91529..ee6bc6f 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -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{ diff --git a/internal/store/role_policy.go b/internal/store/role_policy.go index 9534c05..2e78135 100644 --- a/internal/store/role_policy.go +++ b/internal/store/role_policy.go @@ -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 } diff --git a/internal/store/role_policy_migration_test.go b/internal/store/role_policy_migration_test.go index 2aba458..3e103eb 100644 --- a/internal/store/role_policy_migration_test.go +++ b/internal/store/role_policy_migration_test.go @@ -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) + } +} diff --git a/internal/store/role_policy_test.go b/internal/store/role_policy_test.go new file mode 100644 index 0000000..726e885 --- /dev/null +++ b/internal/store/role_policy_test.go @@ -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) + } +}