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:
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user