Files
Agent-System-Backend/internal/store/role_policy.go
T

2521 lines
92 KiB
Go

package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"agent-system-backend/internal/authz/catalog"
)
var (
ErrInvalidRolePolicy = errors.New("store: invalid role policy")
ErrLastPermissionManager = errors.New("store: final permission manager")
// Role permission writers reject a stale or missing role as an invalid
// policy; the wrapped conflict/not-found class keeps HTTP retry semantics.
ErrRolePolicyVersion = fmt.Errorf("%w: role version changed: %w", ErrInvalidRolePolicy, ErrConflict)
ErrRolePolicyRoleMissing = fmt.Errorf("%w: role missing: %w", ErrInvalidRolePolicy, ErrNotFound)
)
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 RolePermissionRecord struct {
ActionID string `json:"actionId"`
Allowed bool `json:"allowed"`
CapAllowed bool `json:"capAllowed"`
PermissionVersion int64 `json:"permissionVersion"`
}
// RoleAdminView exposes a role's single project-access capability both as
// ProjectAccess and, for existing consumers, as at most one project.create
// permission whose allowed and capAllowed carry the same effective value.
type RoleAdminView struct {
RoleRecord
BindingCount int `json:"bindingCount"`
ProjectAccess bool `json:"projectAccess"`
Permissions []RolePermissionRecord `json:"permissions"`
}
func projectAccessOf(permissions []RolePermissionRecord) bool {
for _, permission := range permissions {
if permission.ActionID == catalog.ProjectAccessAction {
return permission.Allowed && permission.CapAllowed
}
}
return false
}
type PolicyReceipt struct {
ReceiptID string `json:"receiptId"`
AuthorityEpoch int64 `json:"authorityEpoch"`
RoleVersion int64 `json:"roleVersion,omitempty"`
// LinkedExpansion lists linked rows the write added for project.create.
LinkedExpansion []string `json:"linkedExpansion,omitempty"`
MigrationNote string `json:"migrationNote,omitempty"`
}
// CapabilityAssociation is one actor's project.create link computed from a
// single authority snapshot. HTTP and command surfaces both read it here.
// Conflicts lists linked actions an enabled role still caps off.
type CapabilityAssociation struct {
ProjectCreate bool `json:"projectCreate"`
Source string `json:"source,omitempty"`
LinkedActions []string `json:"linkedActions"`
Conflicts []string `json:"conflicts,omitempty"`
AuthorityEpoch int64 `json:"authorityEpoch"`
}
// RoleRecord is the stable position definition stored by Backend.
type RoleRecord struct {
RoleID string `json:"roleId"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
RoleVersion int64 `json:"roleVersion"`
CreatedBy string `json:"createdBy"`
UpdatedBy string `json:"updatedBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// 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"`
UserStateVersion int64 `json:"userStateVersion"`
AuthorityEpoch int64 `json:"authorityEpoch"`
}
type RoleBindingView struct {
Binding *RoleBinding `json:"binding,omitempty"`
Role *RoleRecord `json:"role,omitempty"`
}
type RoleAuthorityImportRole struct {
RoleID string
Name string
Description string
Enabled bool
RoleVersion int64
Permissions []RolePermissionChange
Owners []string
}
type RoleAuthorityImport struct {
SourceSchemaVersion int
SourceSHA256 string
Roles []RoleAuthorityImportRole
}
type RoleAuthorityImportResult struct {
AuthorityEpoch int64
ImportedRoles int
ImportedUsers int
}
func normalizeRoleName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func (s *Store) seedSystemAdminGrantsTx(tx *sql.Tx, owner string) error {
var version sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version); err != nil {
return err
}
if !version.Valid || version.Int64 < 49 {
return nil
}
var userID string
if err := tx.QueryRow(s.q(`SELECT id FROM users WHERE owner=?`), owner).Scan(&userID); err != nil {
return err
}
now := nowISO()
for _, actionID := range rolePolicyAdminActionsV49 {
if _, err := tx.Exec(s.q(`INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at)
VALUES(?,?,?,?,?,?) ON CONFLICT(user_id,action_id) DO UPDATE SET allowed=excluded.allowed,updated_by=excluded.updated_by,updated_at=excluded.updated_at`),
userID, actionID, true, 1, userID, now); err != nil {
return err
}
}
return nil
}
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) requireRoleAuthorityWritableTx(tx *sql.Tx) error {
query := `SELECT state FROM role_authority_import_state WHERE id=1`
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
var state string
if err := tx.QueryRow(query).Scan(&state); err != nil {
return err
}
if state == "staged" {
return ErrConflict
}
return nil
}
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 {
if err := s.requireRoleAuthorityWritableTx(tx); err != nil {
return err
}
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 {
if err := s.requireRoleAuthorityWritableTx(tx); err != nil {
return err
}
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 {
if err := s.requireRoleAuthorityWritableTx(tx); err != nil {
return err
}
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 errors.Is(err, ErrNotFound) {
return ErrRolePolicyRoleMissing
}
if err != nil {
return err
}
if role.RoleVersion != expected {
return ErrRolePolicyVersion
}
seen := map[string]bool{}
next := map[string]RolePermissionChange{}
for _, change := range changes {
descriptor, ok := catalog.DescriptorByID(change.ActionID)
if !ok || descriptor.Protected || change.ActionID != catalog.ProjectAccessAction ||
seen[change.ActionID] || (change.Allowed && !change.CapAllowed) {
return ErrInvalidRolePolicy
}
seen[change.ActionID] = true
next[change.ActionID] = change
}
// Rows for other actions are inert legacy audit data and stay untouched.
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=? AND action_id=?`), roleID, catalog.ProjectAccessAction); err != nil {
return err
}
now := nowISO()
for _, change := range changes {
access := change.Allowed && change.CapAllowed
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, access, access, 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 ErrRolePolicyVersion
}
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 {
if err := requireSuperAdminTargetAccess(actorAccount, target); err != nil {
return err
}
if !actorAccount.IsSuperAdmin {
grantsAdministration, err := s.roleGrantsAccountAdministrationTx(tx, roleID)
if err != nil {
return err
}
if grantsAdministration {
return ErrAccountForbidden
}
}
if err := s.requireRoleAuthorityWritableTx(tx); err != nil {
return err
}
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 := s.checkCapabilityAssociationTx(tx, []string{userID}, nil, nil); err != nil {
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
}
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) GetRole(roleID string) (RoleRecord, error) {
return scanRole(s.db.QueryRow(s.q(`SELECT role_id,name,description,enabled,role_version,created_by,updated_by,created_at,updated_at FROM roles WHERE role_id=?`), roleID))
}
func (s *Store) RoleAdmin(roleID string) (RoleAdminView, error) {
role, err := s.GetRole(roleID)
if err != nil {
return RoleAdminView{}, err
}
view := RoleAdminView{RoleRecord: role}
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM user_role_bindings WHERE role_id=? AND active=?`), roleID, true).Scan(&view.BindingCount); err != nil {
return RoleAdminView{}, err
}
view.Permissions, err = s.RolePermissions(roleID)
view.ProjectAccess = projectAccessOf(view.Permissions)
return view, err
}
// RolePermissions returns the role's normalized capability: at most one
// project.create record whose allowed and capAllowed both equal the stored
// allowed&&capAllowed. Legacy rows for any other action are filtered out.
func (s *Store) RolePermissions(roleID string) ([]RolePermissionRecord, error) {
var permission RolePermissionRecord
var allowed, capAllowed bool
err := s.db.QueryRow(s.q(`SELECT allowed,cap_allowed,permission_version FROM role_permissions WHERE role_id=? AND action_id=?`),
roleID, catalog.ProjectAccessAction).Scan(&allowed, &capAllowed, &permission.PermissionVersion)
if errors.Is(err, sql.ErrNoRows) {
return []RolePermissionRecord{}, nil
}
if err != nil {
return nil, err
}
permission.ActionID = catalog.ProjectAccessAction
permission.Allowed = allowed && capAllowed
permission.CapAllowed = permission.Allowed
return []RolePermissionRecord{permission}, nil
}
func (s *Store) ListRoles(query, mode, status, actionID, cursor string, limit int) ([]RoleAdminView, string, error) {
if limit < 1 || limit > 100 {
limit = 20
}
where := []string{"role_id>?"}
args := []any{cursor}
normalized := normalizeRoleName(query)
if normalized != "" {
switch mode {
case "exact":
where = append(where, "normalized_name=?")
args = append(args, normalized)
case "fuzzy", "":
where = append(where, "(LOWER(name) LIKE ? OR LOWER(description) LIKE ?)")
args = append(args, "%"+normalized+"%", "%"+normalized+"%")
default:
return nil, "", ErrInvalidRolePolicy
}
}
switch status {
case "enabled":
where = append(where, "enabled=?")
args = append(args, true)
case "disabled":
where = append(where, "enabled=?")
args = append(args, false)
case "", "all":
default:
return nil, "", ErrInvalidRolePolicy
}
if actionID != "" {
if _, ok := catalog.DescriptorByID(actionID); !ok {
return nil, "", ErrInvalidRolePolicy
}
if actionID != catalog.ProjectAccessAction {
return []RoleAdminView{}, "", nil
}
where = append(where, "EXISTS(SELECT 1 FROM role_permissions p WHERE p.role_id=roles.role_id AND p.action_id=? AND p.allowed=? AND p.cap_allowed=?)")
args = append(args, actionID, true, true)
}
args = append(args, limit+1)
rows, err := s.db.Query(s.q(`SELECT role_id,name,description,enabled,role_version,created_by,updated_by,created_at,updated_at,
(SELECT COUNT(*) FROM user_role_bindings b WHERE b.role_id=roles.role_id AND b.active=TRUE)
FROM roles WHERE `+strings.Join(where, " AND ")+` ORDER BY role_id LIMIT ?`), args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
out := []RoleAdminView{}
for rows.Next() {
var view RoleAdminView
var createdAt, updatedAt string
if err := rows.Scan(&view.RoleID, &view.Name, &view.Description, &view.Enabled, &view.RoleVersion,
&view.CreatedBy, &view.UpdatedBy, &createdAt, &updatedAt, &view.BindingCount); err != nil {
return nil, "", err
}
view.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
view.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt)
out = append(out, view)
}
if err := rows.Err(); err != nil {
return nil, "", err
}
next := ""
if len(out) > limit {
next = out[limit-1].RoleID
out = out[:limit]
}
for i := range out {
permissions, err := s.RolePermissions(out[i].RoleID)
if err != nil {
return nil, "", err
}
out[i].Permissions = permissions
out[i].ProjectAccess = projectAccessOf(permissions)
}
return out, next, nil
}
func (s *Store) SystemGrantsForUser(userID string) ([]string, int64, error) {
if _, err := s.GetAccount(userID); err != nil {
return nil, 0, err
}
rows, err := s.db.Query(s.q(`SELECT action_id FROM system_grants WHERE user_id=? AND allowed=? ORDER BY action_id`), userID, true)
if err != nil {
return nil, 0, err
}
defer rows.Close()
actions := []string{}
for rows.Next() {
var actionID string
if err := rows.Scan(&actionID); err != nil {
return nil, 0, err
}
actions = append(actions, actionID)
}
epoch, err := s.AuthorityEpoch()
return actions, epoch, err
}
func (s *Store) SystemGrantAllowed(owner, actionID string) (bool, error) {
var allowed bool
err := s.db.QueryRow(s.q(`SELECT g.allowed FROM system_grants g JOIN users u ON u.id=g.user_id
WHERE u.owner=? AND u.enabled=? AND g.action_id=?`), owner, true, actionID).Scan(&allowed)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return allowed, err
}
// requireSystemActionTx rechecks delegated system authority in the same
// transaction that consumes it. Callers lock the actor account first, keeping
// the account-before-policy lock order used by policyWrite.
func (s *Store) requireSystemActionTx(tx *sql.Tx, actor Account, actionID string) error {
descriptor, ok := catalog.DescriptorByID(actionID)
if !ok || descriptor.Scope != catalog.ScopeSystem || !actor.Enabled {
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 actor.IsAdmin {
return nil
}
var allowed bool
err := tx.QueryRow(s.q(`SELECT allowed FROM system_grants WHERE user_id=? AND action_id=?`), actor.ID, actionID).Scan(&allowed)
if errors.Is(err, sql.ErrNoRows) || (err == nil && !allowed) {
return ErrAccountForbidden
}
return err
}
// RolePermissionAllowed keeps its historical (allowed, capAllowed) shape but
// both results now carry the same normalized project-access value: the stored
// allowed&&capAllowed of the actor's active, enabled role. Any action other
// than project.create is never role-authorized.
func (s *Store) RolePermissionAllowed(owner, actionID string) (bool, bool, error) {
if actionID != catalog.ProjectAccessAction {
return false, false, nil
}
var allowed, capAllowed bool
err := s.db.QueryRow(s.q(`SELECT p.allowed,p.cap_allowed FROM users u
JOIN user_role_bindings b ON b.user_id=u.id AND b.active=TRUE
JOIN roles r ON r.role_id=b.role_id AND r.enabled=TRUE
JOIN role_permissions p ON p.role_id=r.role_id AND p.action_id=?
WHERE u.owner=? AND u.enabled=TRUE`), actionID, owner).Scan(&allowed, &capAllowed)
if errors.Is(err, sql.ErrNoRows) {
return false, false, nil
}
if err != nil {
return false, false, err
}
access := allowed && capAllowed
return access, access, nil
}
// roleGrantsAccountAdministrationTx reports whether a role allows any
// account-administration action. Catalog validation already keeps protected
// actions out of roles; this keeps role assignment from becoming an elevation
// path even if that invariant is ever broken.
func (s *Store) roleGrantsAccountAdministrationTx(tx *sql.Tx, roleID string) (bool, error) {
rows, err := tx.Query(s.q(`SELECT action_id FROM role_permissions WHERE role_id=? AND allowed=?`), roleID, true)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var actionID string
if err := rows.Scan(&actionID); err != nil {
return false, err
}
if IsAccountAdministrationAction(actionID) {
return true, nil
}
}
return false, rows.Err()
}
// changesAccountAdministrationGrantsTx reports whether replacing userID's
// explicit grants with actions would add or remove any account-administration
// action. Only the super administrator may make such a change (R4, R5).
func (s *Store) changesAccountAdministrationGrantsTx(tx *sql.Tx, userID string, actions []string) (bool, error) {
current := map[string]bool{}
rows, err := tx.Query(s.q(`SELECT action_id FROM system_grants WHERE user_id=? AND allowed=?`), userID, true)
if err != nil {
return false, err
}
for rows.Next() {
var actionID string
if err := rows.Scan(&actionID); err != nil {
rows.Close()
return false, err
}
if IsAccountAdministrationAction(actionID) {
current[actionID] = true
}
}
if err := rows.Close(); err != nil {
return false, err
}
if err := rows.Err(); err != nil {
return false, err
}
requested := map[string]bool{}
for _, actionID := range actions {
if IsAccountAdministrationAction(actionID) {
requested[actionID] = true
}
}
if len(current) != len(requested) {
return true, nil
}
for actionID := range requested {
if !current[actionID] {
return true, nil
}
}
return false, nil
}
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 {
if err := requireSuperAdminTargetAccess(actorAccount, target); err != nil {
return err
}
if !actorAccount.IsSuperAdmin {
changes, err := s.changesAccountAdministrationGrantsTx(tx, target.ID, actions)
if err != nil {
return err
}
if changes {
return ErrAccountForbidden
}
}
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 := catalog.DescriptorByID(actionID)
if !ok || descriptor.Scope != catalog.ScopeSystem || seen[actionID] {
return ErrInvalidRolePolicy
}
seen[actionID] = true
}
previous, err := s.loadCapabilityAssociationTx(tx, []string{userID}, nil)
if err != nil {
return err
}
migrationNote := ""
if previous.creators[userID] && !seen[catalog.ProjectCreateAction] {
kept := []string{}
if roleID, bound := previous.bindings[userID]; bound {
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if _, exists := previous.roles[roleID][actionID]; exists {
kept = append(kept, actionID)
}
}
}
migrationNote = projectCreateDisabledNote(kept)
}
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 := s.checkCapabilityAssociationTx(tx, []string{userID}, nil, nil); err != nil {
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 = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, MigrationNote: migrationNote}
return s.saveReceipt(tx, actorAccount.ID, key, "system.grants.replace", requestHash, out)
})
return out, err
}
// ImportRoleAuthority stages a validated, invisible role projection. Live role
// authorization remains unchanged until ActivateRoleAuthorityImport commits it.
func (s *Store) ImportRoleAuthority(actor string, in RoleAuthorityImport, key string) (RoleAuthorityImportResult, error) {
if actor == "" || key == "" || in.SourceSchemaVersion < 1 || len(in.SourceSHA256) != 64 {
return RoleAuthorityImportResult{}, ErrInvalidRolePolicy
}
roles := append([]RoleAuthorityImportRole(nil), in.Roles...)
sort.Slice(roles, func(i, j int) bool { return roles[i].RoleID < roles[j].RoleID })
roleIDs, names, boundOwners := map[string]bool{}, map[string]bool{}, map[string]bool{}
owners := []string{actor}
for i := range roles {
role := &roles[i]
role.RoleID = strings.TrimSpace(role.RoleID)
var err error
role.Name, role.Description, err = validateRoleText(role.Name, role.Description)
if err != nil || role.RoleID == "" || role.RoleVersion < 1 || roleIDs[role.RoleID] || names[normalizeRoleName(role.Name)] {
return RoleAuthorityImportResult{}, ErrInvalidRolePolicy
}
roleIDs[role.RoleID], names[normalizeRoleName(role.Name)] = true, true
seenActions := map[string]bool{}
for _, permission := range role.Permissions {
descriptor, ok := catalog.DescriptorByID(permission.ActionID)
if !ok || descriptor.Protected || seenActions[permission.ActionID] || (permission.Allowed && !permission.CapAllowed) {
return RoleAuthorityImportResult{}, ErrInvalidRolePolicy
}
seenActions[permission.ActionID] = true
}
sort.Strings(role.Owners)
for _, owner := range role.Owners {
if owner == "" || boundOwners[owner] {
return RoleAuthorityImportResult{}, ErrInvalidRolePolicy
}
boundOwners[owner] = true
owners = append(owners, owner)
}
}
normalized := RoleAuthorityImport{SourceSchemaVersion: in.SourceSchemaVersion, SourceSHA256: in.SourceSHA256, Roles: roles}
payload, err := json.Marshal(normalized)
if err != nil {
return RoleAuthorityImportResult{}, err
}
requestHash := policyRequestHash(normalized)
tx, err := s.db.Begin()
if err != nil {
return RoleAuthorityImportResult{}, err
}
defer tx.Rollback()
accounts, err := s.lockAccountUsers(tx, owners...)
if err != nil {
return RoleAuthorityImportResult{}, err
}
actorAccount := accounts[actor]
if err := s.requireSystemActionTx(tx, actorAccount, "system.permissions.manage"); err != nil {
return RoleAuthorityImportResult{}, err
}
var receipt PolicyReceipt
replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "role.authority.import", requestHash, &receipt)
if err != nil {
return RoleAuthorityImportResult{}, err
}
if replayed {
return RoleAuthorityImportResult{AuthorityEpoch: receipt.AuthorityEpoch, ImportedRoles: len(roles), ImportedUsers: len(boundOwners)}, nil
}
var roleCount, bindingCount int
if err := tx.QueryRow(`SELECT COUNT(*) FROM roles`).Scan(&roleCount); err != nil {
return RoleAuthorityImportResult{}, err
}
if err := tx.QueryRow(`SELECT COUNT(*) FROM user_role_bindings WHERE active=TRUE`).Scan(&bindingCount); err != nil {
return RoleAuthorityImportResult{}, err
}
if roleCount != 0 || bindingCount != 0 {
return RoleAuthorityImportResult{}, ErrConflict
}
pending := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{}, bindings: map[string]string{}}
boundUsers := []string{}
for _, role := range roles {
permissions := map[string]RolePermissionChange{}
for _, permission := range role.Permissions {
permissions[permission.ActionID] = permission
}
pending.roles[role.RoleID] = permissions
for _, owner := range role.Owners {
pending.bindings[accounts[owner].ID] = role.RoleID
boundUsers = append(boundUsers, accounts[owner].ID)
}
}
if err := s.checkCapabilityAssociationTx(tx, boundUsers, nil, &pending); err != nil {
return RoleAuthorityImportResult{}, err
}
var epoch int64
query := `SELECT authority_epoch FROM authority_state WHERE id=1`
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
if err := tx.QueryRow(query).Scan(&epoch); err != nil {
return RoleAuthorityImportResult{}, err
}
stateQuery := `SELECT source_sha256,state,payload_json FROM role_authority_import_state WHERE id=1`
if s.Dialect() == "postgres" {
stateQuery += ` FOR UPDATE`
}
var sourceSHA, state, storedPayload string
if err := tx.QueryRow(stateQuery).Scan(&sourceSHA, &state, &storedPayload); err != nil {
return RoleAuthorityImportResult{}, err
}
if state != "idle" {
if sourceSHA != in.SourceSHA256 || storedPayload != string(payload) {
return RoleAuthorityImportResult{}, ErrConflict
}
} else if _, err := tx.Exec(s.q(`UPDATE role_authority_import_state SET source_sha256=?,state='staged',payload_json=?,staged_by=?,updated_at=? WHERE id=1 AND state='idle'`),
in.SourceSHA256, string(payload), actorAccount.ID, nowISO()); err != nil {
return RoleAuthorityImportResult{}, err
}
receipt = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch}
if err := s.saveReceipt(tx, actorAccount.ID, key, "role.authority.import", requestHash, receipt); err != nil {
return RoleAuthorityImportResult{}, err
}
if err := tx.Commit(); err != nil {
return RoleAuthorityImportResult{}, err
}
return RoleAuthorityImportResult{AuthorityEpoch: epoch, ImportedRoles: len(roles), ImportedUsers: len(boundOwners)}, nil
}
func (s *Store) ReadRoleAuthorityImport(actor, sourceSHA string) (RoleAuthorityImport, error) {
tx, err := s.db.Begin()
if err != nil {
return RoleAuthorityImport{}, err
}
defer tx.Rollback()
accounts, err := s.lockAccountUsers(tx, actor)
if err != nil {
return RoleAuthorityImport{}, err
}
if err := s.requireSystemActionTx(tx, accounts[actor], "system.permissions.manage"); err != nil {
return RoleAuthorityImport{}, err
}
var storedSHA, state, payload string
if err := tx.QueryRow(`SELECT source_sha256,state,payload_json FROM role_authority_import_state WHERE id=1`).Scan(&storedSHA, &state, &payload); err != nil {
return RoleAuthorityImport{}, err
}
if storedSHA != sourceSHA || (state != "staged" && state != "active") {
return RoleAuthorityImport{}, ErrConflict
}
var out RoleAuthorityImport
if err := json.Unmarshal([]byte(payload), &out); err != nil {
return RoleAuthorityImport{}, err
}
if err := tx.Commit(); err != nil {
return RoleAuthorityImport{}, err
}
return out, nil
}
func (s *Store) ActivateRoleAuthorityImport(actor, sourceSHA string, expectedEpoch int64) (RoleAuthorityImportResult, error) {
staged, err := s.ReadRoleAuthorityImport(actor, sourceSHA)
if err != nil {
return RoleAuthorityImportResult{}, err
}
owners := []string{actor}
for _, role := range staged.Roles {
owners = append(owners, role.Owners...)
}
tx, err := s.db.Begin()
if err != nil {
return RoleAuthorityImportResult{}, err
}
defer tx.Rollback()
accounts, err := s.lockAccountUsers(tx, owners...)
if err != nil {
return RoleAuthorityImportResult{}, err
}
actorAccount := accounts[actor]
if err := s.requireSystemActionTx(tx, actorAccount, "system.permissions.manage"); err != nil {
return RoleAuthorityImportResult{}, err
}
var epoch int64
query := `SELECT authority_epoch FROM authority_state WHERE id=1`
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
if err := tx.QueryRow(query).Scan(&epoch); err != nil {
return RoleAuthorityImportResult{}, err
}
stateQuery := `SELECT source_sha256,state FROM role_authority_import_state WHERE id=1`
if s.Dialect() == "postgres" {
stateQuery += ` FOR UPDATE`
}
var storedSHA, state string
if err := tx.QueryRow(stateQuery).Scan(&storedSHA, &state); err != nil {
return RoleAuthorityImportResult{}, err
}
if storedSHA != sourceSHA {
return RoleAuthorityImportResult{}, ErrConflict
}
if state == "active" {
return RoleAuthorityImportResult{AuthorityEpoch: epoch, ImportedRoles: len(staged.Roles), ImportedUsers: len(owners) - 1}, nil
}
if epoch != expectedEpoch {
return RoleAuthorityImportResult{}, ErrConflict
}
if state != "staged" {
return RoleAuthorityImportResult{}, ErrConflict
}
var roleCount, bindingCount int
if err := tx.QueryRow(`SELECT COUNT(*) FROM roles`).Scan(&roleCount); err != nil {
return RoleAuthorityImportResult{}, err
}
if err := tx.QueryRow(`SELECT COUNT(*) FROM user_role_bindings WHERE active=TRUE`).Scan(&bindingCount); err != nil {
return RoleAuthorityImportResult{}, err
}
if roleCount != 0 || bindingCount != 0 {
return RoleAuthorityImportResult{}, ErrConflict
}
now := nowISO()
importedUsers := 0
for _, role := range staged.Roles {
if _, 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(?,?,?,?,?,?,?,?,?,?)`), role.RoleID, role.Name, normalizeRoleName(role.Name), role.Description, role.Enabled,
role.RoleVersion, actorAccount.ID, actorAccount.ID, now, now); err != nil {
return RoleAuthorityImportResult{}, err
}
for _, permission := range role.Permissions {
if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at)
VALUES(?,?,?,?,?,?,?)`), role.RoleID, permission.ActionID, permission.Allowed, permission.CapAllowed, 1, actorAccount.ID, now); err != nil {
return RoleAuthorityImportResult{}, err
}
}
for _, owner := range role.Owners {
target := accounts[owner]
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(?,?,?,?,?,?,?,?)`), newID("binding"), target.ID, role.RoleID, true, 1, actorAccount.ID, now, ""); err != nil {
return RoleAuthorityImportResult{}, err
}
result, err := tx.Exec(s.q(`UPDATE users SET state_version=state_version+1 WHERE id=? AND state_version=?`), target.ID, target.StateVersion)
if err != nil {
return RoleAuthorityImportResult{}, err
}
if n, err := result.RowsAffected(); err != nil || n != 1 {
if err != nil {
return RoleAuthorityImportResult{}, err
}
return RoleAuthorityImportResult{}, ErrConflict
}
importedUsers++
}
}
importedRoleIDs := make([]string, 0, len(staged.Roles))
for _, role := range staged.Roles {
importedRoleIDs = append(importedRoleIDs, role.RoleID)
}
if err := s.checkCapabilityAssociationTx(tx, nil, importedRoleIDs, nil); err != nil {
return RoleAuthorityImportResult{}, 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 RoleAuthorityImportResult{}, err
}
stateResult, err := tx.Exec(s.q(`UPDATE role_authority_import_state SET state='active',updated_at=? WHERE id=1 AND source_sha256=? AND state='staged'`), now, sourceSHA)
if err != nil {
return RoleAuthorityImportResult{}, err
}
if n, err := stateResult.RowsAffected(); err != nil || n != 1 {
if err != nil {
return RoleAuthorityImportResult{}, err
}
return RoleAuthorityImportResult{}, ErrConflict
}
if err := tx.Commit(); err != nil {
return RoleAuthorityImportResult{}, err
}
return RoleAuthorityImportResult{AuthorityEpoch: epoch + 1, ImportedRoles: len(staged.Roles), ImportedUsers: importedUsers}, nil
}
// capabilityAssociation is the slice of authority that decides whether
// project.create and its linked own-project management actions agree.
type capabilityAssociation struct {
roles map[string]map[string]RolePermissionChange // roleID -> actionID -> bits
bindings map[string]string // userID -> active roleID
creators map[string]bool // userID -> project.create system grant
}
func projectCreateEnabled(permissions map[string]RolePermissionChange) bool {
permission, ok := permissions[catalog.ProjectCreateAction]
return ok && permission.Allowed && permission.CapAllowed
}
// linkedActionDenied reports an explicit hard deny: the row caps the action
// off. An absent row is unconfigured and derived from project.create; a row
// with allowed=false under an open cap still serves the creator's own projects.
func linkedActionDenied(permissions map[string]RolePermissionChange, actionID string) bool {
permission, ok := permissions[actionID]
return ok && !permission.CapAllowed
}
func deniedLinkedAction(permissions map[string]RolePermissionChange) string {
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if linkedActionDenied(permissions, actionID) {
return actionID
}
}
return ""
}
func projectCreateDisabledNote(kept []string) string {
if len(kept) == 0 {
return "project.create disabled: no linked capability rows existed; nothing was deleted"
}
return "project.create disabled: kept linked capability rows " + strings.Join(kept, ",") +
"; remove them explicitly to revoke own-project management"
}
func associationActionArgs() []any {
args := []any{catalog.ProjectCreateAction}
for _, actionID := range catalog.ProjectCreateLinkedActions() {
args = append(args, actionID)
}
return args
}
// loadCapabilityAssociationTx reads the users' project.create grants and active
// roles plus, for each role, its bound project.create grant holders and the
// role rows for project.create and the linked actions.
func (s *Store) loadCapabilityAssociationTx(tx *sql.Tx, userIDs, roleIDs []string) (capabilityAssociation, error) {
state := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{},
bindings: map[string]string{}, creators: map[string]bool{}}
roleIDs = append([]string(nil), roleIDs...)
for _, userID := range userIDs {
if _, loaded := state.creators[userID]; loaded {
continue
}
var allowed bool
err := tx.QueryRow(s.q(`SELECT allowed FROM system_grants WHERE user_id=? AND action_id=?`), userID, catalog.ProjectCreateAction).Scan(&allowed)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return state, err
}
state.creators[userID] = allowed
var roleID string
err = tx.QueryRow(s.q(`SELECT role_id FROM user_role_bindings WHERE user_id=? AND active=?`), userID, true).Scan(&roleID)
switch {
case err == nil:
state.bindings[userID] = roleID
roleIDs = append(roleIDs, roleID)
case !errors.Is(err, sql.ErrNoRows):
return state, err
}
}
actionArgs := associationActionArgs()
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(actionArgs)), ",")
for _, roleID := range roleIDs {
if _, loaded := state.roles[roleID]; loaded {
continue
}
permissions := map[string]RolePermissionChange{}
rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=? AND action_id IN (`+placeholders+`)`),
append([]any{roleID}, actionArgs...)...)
if err != nil {
return state, err
}
for rows.Next() {
var permission RolePermissionChange
if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil {
rows.Close()
return state, err
}
permissions[permission.ActionID] = permission
}
if err := rows.Close(); err != nil {
return state, err
}
state.roles[roleID] = permissions
holders, err := tx.Query(s.q(`SELECT b.user_id FROM user_role_bindings b
JOIN system_grants g ON g.user_id=b.user_id AND g.action_id=? AND g.allowed=?
WHERE b.role_id=? AND b.active=?`), catalog.ProjectCreateAction, true, roleID, true)
if err != nil {
return state, err
}
for holders.Next() {
var userID string
if err := holders.Scan(&userID); err != nil {
holders.Close()
return state, err
}
state.creators[userID] = true
state.bindings[userID] = roleID
}
if err := holders.Close(); err != nil {
return state, err
}
}
return state, nil
}
// checkCapabilityAssociationTx is the single in-transaction gate shared by all
// role authority writers. It rejects any role that enables project.create while
// denying a linked action, and any project.create grant holder whose active
// role denies one. pending overlays rows staged but not yet written.
func (s *Store) checkCapabilityAssociationTx(tx *sql.Tx, userIDs, roleIDs []string, pending *capabilityAssociation) error {
state, err := s.loadCapabilityAssociationTx(tx, userIDs, roleIDs)
if err != nil {
return err
}
if pending != nil {
for roleID, permissions := range pending.roles {
state.roles[roleID] = permissions
}
for userID, roleID := range pending.bindings {
state.bindings[userID] = roleID
}
for userID, allowed := range pending.creators {
state.creators[userID] = allowed
}
}
roleIDs = roleIDs[:0:0]
for roleID := range state.roles {
roleIDs = append(roleIDs, roleID)
}
sort.Strings(roleIDs)
for _, roleID := range roleIDs {
permissions := state.roles[roleID]
if denied := deniedLinkedAction(permissions); denied != "" && projectCreateEnabled(permissions) {
return fmt.Errorf("%w: role %s enables project.create but denies %s", ErrInvalidRolePolicy, roleID, denied)
}
}
userIDs = userIDs[:0:0]
for userID, creator := range state.creators {
if creator {
userIDs = append(userIDs, userID)
}
}
sort.Strings(userIDs)
for _, userID := range userIDs {
roleID, bound := state.bindings[userID]
if !bound {
continue
}
if denied := deniedLinkedAction(state.roles[roleID]); denied != "" {
return fmt.Errorf("%w: project.create grant holder %s has role %s denying %s", ErrInvalidRolePolicy, userID, roleID, denied)
}
}
return nil
}
// LinkedCapabilities computes, from one authority snapshot, whether the actor
// holds project.create and which own-project management actions it links.
// An explicit deny on the actor's enabled role still wins over the link.
func (s *Store) LinkedCapabilities(owner string) (CapabilityAssociation, error) {
out := CapabilityAssociation{LinkedActions: []string{}}
tx, err := s.db.Begin()
if err != nil {
return out, err
}
defer tx.Rollback()
if err := tx.QueryRow(`SELECT authority_epoch FROM authority_state WHERE id=1`).Scan(&out.AuthorityEpoch); err != nil {
return out, err
}
var userID string
var enabled, isAdmin bool
err = tx.QueryRow(s.q(`SELECT id,enabled,is_admin FROM users WHERE owner=?`), owner).Scan(&userID, &enabled, &isAdmin)
if errors.Is(err, sql.ErrNoRows) {
return out, ErrNotFound
}
if err != nil || !enabled {
return out, err
}
state, err := s.loadCapabilityAssociationTx(tx, []string{userID}, nil)
if err != nil {
return out, err
}
var rolePermissions map[string]RolePermissionChange
if roleID, bound := state.bindings[userID]; bound {
role, err := s.roleTx(tx, roleID, false)
if err != nil {
return out, err
}
if role.Enabled {
rolePermissions = state.roles[roleID]
}
}
switch {
case isAdmin:
out.Source = "bootstrap-admin"
case state.creators[userID]:
out.Source = "system-grant"
case projectCreateEnabled(rolePermissions):
out.Source = "role"
default:
return out, tx.Commit()
}
out.ProjectCreate = true
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if linkedActionDenied(rolePermissions, actionID) {
out.Conflicts = append(out.Conflicts, actionID)
continue
}
out.LinkedActions = append(out.LinkedActions, actionID)
}
return out, tx.Commit()
}
// =====================================================================
// IAM permission-management batch operations (ported from
// feature/iam-perm-ux-backend). Behaviour follows main's narrowed role
// model: the only role authority is project.create. Other catalog actions
// carry legacy audit rows in role_permissions but never authorize anything,
// so writers reject non-project.create change items the same way
// ReplaceRolePermissions already does (R2).
// =====================================================================
// RolePermissionPreviewInput is one role's proposed change set. Mode "replace"
// (the default) treats Changes as the full replace set; "patch" treats them as
// an explicit patch over the role's current rows.
type RolePermissionPreviewInput struct {
RoleID string `json:"roleId"`
ExpectedRoleVersion int64 `json:"expectedRoleVersion"`
Mode string `json:"mode,omitempty"`
Changes []RolePermissionChange `json:"changes"`
}
const (
RolePermissionPreviewReplace = "replace"
RolePermissionPreviewPatch = "patch"
)
// RolePermissionStateChange describes one action whose display state differs
// between the role's current rows and the planned-after rows.
type RolePermissionStateChange struct {
ActionID string `json:"actionId"`
Label string `json:"label"`
Before string `json:"before"`
After string `json:"after"`
}
// RolePermissionConflict names one action that the planned-after rows would
// leave in a state inconsistent with the bound project.create grant.
type RolePermissionConflict struct {
ActionID string `json:"actionId"`
Reason string `json:"reason"`
}
// RolePermissionPreview is the planned-effect view of one role's change set,
// without writing. Error reports an item-level rejection and never fails the
// whole batch; the commit revalidates every item.
type RolePermissionPreview struct {
RoleID string `json:"roleId"`
RoleName string `json:"roleName"`
ExpectedRoleVersion int64 `json:"expectedRoleVersion"`
CurrentRoleVersion int64 `json:"currentRoleVersion"`
VersionMatches bool `json:"versionMatches"`
Changes []RolePermissionStateChange `json:"changes"`
LinkedExpansion []string `json:"linkedExpansion"`
LinkedExpansionState string `json:"linkedExpansionState,omitempty"`
KeptLinkedActions []string `json:"keptLinkedActions"`
MigrationNote string `json:"migrationNote,omitempty"`
Conflicts []RolePermissionConflict `json:"conflicts"`
Error string `json:"error,omitempty"`
ErrorActionID string `json:"errorActionId,omitempty"`
}
const (
permissionStateDenied = "不允许"
permissionStateProject = "按项目授权"
permissionStateDefault = "默认允许"
permissionStateInvalid = "非法组合"
linkedExpansionLinked = "按项目授权 · 仅本人创建的项目"
maxRolePermissionPreviewItem = 100
)
// RolePermissionPatchInput is one role's explicit patch. Only listed actions
// change; every unlisted row of the role stays exactly as stored. Unlisted
// legacy audit rows are preserved untouched.
type RolePermissionPatchInput struct {
RoleID string `json:"roleId"`
ExpectedRoleVersion int64 `json:"expectedRoleVersion"`
Changes []RolePermissionChange `json:"changes"`
}
// RolePermissionBatchReceipt is one role's outcome inside a batch patch. All
// roles of a batch share the batch receipt ID and authority epoch.
type RolePermissionBatchReceipt struct {
RoleID string `json:"roleId"`
PolicyReceipt
PreviousRoleVersion int64 `json:"previousRoleVersion"`
Changes []RolePermissionStateChange `json:"changes"`
KeptLinkedActions []string `json:"keptLinkedActions"`
}
// RolePermissionBatch is the union of every role receipt in one write.
type RolePermissionBatch struct {
ReceiptID string `json:"receiptId"`
AuthorityEpoch int64 `json:"authorityEpoch"`
Receipts []RolePermissionBatchReceipt `json:"receipts"`
}
// UserRoleAssignmentInput is one assignment: replace the user's active
// binding with roleID. ExpectedStateVersion forbids stale state.
type UserRoleAssignmentInput struct {
UserID string `json:"userId"`
RoleID string `json:"roleId"`
ExpectedStateVersion int64 `json:"expectedStateVersion"`
}
// UserRoleAssignmentResult is one assignment's outcome inside a batch.
type UserRoleAssignmentResult struct {
RoleBinding
PreviousRoleID string `json:"previousRoleId"`
}
// UserRoleAssignmentBatch is the union of every assignment receipt in one
// rebind transaction. One batch ⇒ one authority epoch step.
type UserRoleAssignmentBatch struct {
ReceiptID string `json:"receiptId"`
AuthorityEpoch int64 `json:"authorityEpoch"`
Bindings []UserRoleAssignmentResult `json:"bindings"`
}
// UserRoleAssignmentPreview predicts one assignment without writing. It is not
// an authorization token: the commit revalidates every item.
type UserRoleAssignmentPreview struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
Name string `json:"name"`
CurrentRoleID string `json:"currentRoleId"`
CurrentRoleName string `json:"currentRoleName"`
TargetRoleID string `json:"targetRoleId"`
TargetRoleName string `json:"targetRoleName"`
StateMatches bool `json:"stateMatches"`
Error string `json:"error,omitempty"`
}
// RoleCopySource is the role a copy-and-assign reads from.
type RoleCopySource struct {
RoleID string `json:"roleId"`
ExpectedRoleVersion int64 `json:"expectedRoleVersion"`
}
// RoleCopyAssignment is one user the copy should rebind to the new role.
type RoleCopyAssignment struct {
UserID string `json:"userId"`
ExpectedStateVersion int64 `json:"expectedStateVersion"`
}
// RoleCopyAssignResult is the outcome of one copy-and-assign transaction.
type RoleCopyAssignResult struct {
ReceiptID string `json:"receiptId"`
AuthorityEpoch int64 `json:"authorityEpoch"`
Role RoleAdminView `json:"role"`
LinkedExpansion []string `json:"linkedExpansion"`
Bindings []UserRoleAssignmentResult `json:"bindings"`
}
// RoleMember is the minimal person view of one active role binding. It is
// what ListRoleMembersForActor returns per row, sorted by owner.
type RoleMember struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
StateVersion int64 `json:"stateVersion"`
AssignedAt time.Time `json:"assignedAt"`
BindingVersion int64 `json:"bindingVersion"`
}
// UserRoleSummary is the role side of one active binding returned by the
// account directory: only roles the caller may surface. Empty when the user
// has no active binding.
type UserRoleSummary struct {
RoleID string `json:"roleId"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
}
const (
maxRolePermissionBatch = 20
maxRoleAssignmentBatch = 100
roleAssignmentUserMissing = "用户不存在"
roleAssignmentUserDisabled = "用户已停用"
roleAssignmentStateChanged = "状态版本已变化"
roleAssignmentRoleInvalid = "角色不存在或已停用"
)
// rolePermissionChangeError names the batch item that makes a role replace or
// patch set invalid. Reason is user-facing Chinese text without raw IDs.
type rolePermissionChangeError struct {
ActionID string
Reason string
}
func (e *rolePermissionChangeError) Error() string {
return fmt.Sprintf("%s: %s: %s", ErrInvalidRolePolicy, e.ActionID, e.Reason)
}
func (e *rolePermissionChangeError) Unwrap() error { return ErrInvalidRolePolicy }
// actionLabel returns the catalog label for actionID, or the ID itself when
// the action is not registered.
func actionLabel(actionID string) string {
if descriptor, ok := catalog.DescriptorByID(actionID); ok {
return descriptor.Label
}
return actionID
}
// normalizePermissionChanges validates a role replace/patch set and indexes
// it by action. In main's narrowed role model, only project.create is a role
// authority; other actions are rejected with a user-facing reason so writers
// stay consistent with the existing ReplaceRolePermissions contract.
func normalizePermissionChanges(changes []RolePermissionChange) (map[string]RolePermissionChange, error) {
next := make(map[string]RolePermissionChange, len(changes))
for _, change := range changes {
descriptor, ok := catalog.DescriptorByID(change.ActionID)
_, duplicate := next[change.ActionID]
reason := ""
switch {
case !ok:
reason = "包含未登记的权限动作"
case descriptor.Protected:
reason = "「" + descriptor.Label + "」是受保护的系统授权,只能按用户单独授予"
case change.ActionID != catalog.ProjectAccessAction:
reason = "「" + descriptor.Label + "」不是角色可授权限,当前角色模型仅对 project.create 授权"
case duplicate:
reason = "「" + descriptor.Label + "」在同一批变更中重复出现"
case change.Allowed && !change.CapAllowed:
reason = "「" + descriptor.Label + "」不能设为默认允许却关闭上限,该组合不合法"
}
if reason != "" {
return nil, &rolePermissionChangeError{ActionID: change.ActionID, Reason: reason}
}
next[change.ActionID] = change
}
return next, nil
}
// policyWriteBatch is the batch-shaped policy transaction: it locks the actor
// and every target user in owner order, then authority_state, before fn runs.
// The locked rows must still be the enabled users the caller named; fn must
// not lock further users, and policyWriteBatch is not reentrant.
func (s *Store) policyWriteBatch(actor string, targetUserIDs []string, fn func(*sql.Tx, Account, map[string]Account, int64) error) error {
if actor == "" || fn == nil {
return ErrAccountForbidden
}
owners := []string{actor}
ownerByUser := make(map[string]string, len(targetUserIDs))
for _, userID := range targetUserIDs {
target, err := s.GetAccount(userID)
if err != nil {
return ErrAccountForbidden
}
ownerByUser[userID] = target.Owner
owners = append(owners, target.Owner)
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
accounts, err := s.lockAccountUsers(tx, owners...)
if err != nil {
return ErrAccountForbidden
}
actorAccount := accounts[actor]
if !actorAccount.Enabled {
return ErrAccountForbidden
}
targets := make(map[string]Account, len(ownerByUser))
for userID, owner := range ownerByUser {
target := accounts[owner]
if !target.Enabled || target.ID != userID {
return ErrAccountForbidden
}
targets[userID] = target
}
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, targets, epoch); err != nil {
return err
}
return tx.Commit()
}
// capabilityConflict is one linked action an enabled project.create denies.
// UserID is set when a bound project.create grant holder causes it.
type capabilityConflict struct {
RoleID string
UserID string
ActionID string
}
// conflicts lists, in a stable order, every role that enables project.create
// while denying a linked action, then every project.create grant holder whose
// active role denies one. Read surfaces use this to surface conflicts without
// writing; the commit gate (checkCapabilityAssociationTx) is unchanged.
func (state capabilityAssociation) conflicts() []capabilityConflict {
out := []capabilityConflict{}
roleIDs := make([]string, 0, len(state.roles))
for roleID := range state.roles {
roleIDs = append(roleIDs, roleID)
}
sort.Strings(roleIDs)
for _, roleID := range roleIDs {
permissions := state.roles[roleID]
if !projectCreateEnabled(permissions) {
continue
}
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if linkedActionDenied(permissions, actionID) {
out = append(out, capabilityConflict{RoleID: roleID, ActionID: actionID})
}
}
}
userIDs := []string{}
for userID, creator := range state.creators {
if creator {
userIDs = append(userIDs, userID)
}
}
sort.Strings(userIDs)
for _, userID := range userIDs {
roleID, bound := state.bindings[userID]
if !bound {
continue
}
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if linkedActionDenied(state.roles[roleID], actionID) {
out = append(out, capabilityConflict{RoleID: roleID, UserID: userID, ActionID: actionID})
}
}
}
return out
}
// capabilityConflictReason renders a human-readable explanation for the
// conflict. Reason text is the same for the preview and the commit gate.
func capabilityConflictReason(conflict capabilityConflict) string {
label := actionLabel(conflict.ActionID)
if conflict.UserID != "" {
return "使用此角色的用户已直接获授创建项目,其本人项目的「" + label + "」资格不能被明确禁止"
}
return "创建项目需要本人项目的「" + label + "」资格,但该资格已被明确禁止"
}
// rolePermissionQuerier is the read interface used by rolePermissionRecords
// to run inside either *sql.Tx (PreviewRolePermissions) or *sql.DB
// (RolePermissions).
type rolePermissionQuerier interface {
Query(string, ...any) (*sql.Rows, error)
}
// rolePermissionRecords reads every row of one role's permission table in a
// stable order; the public RolePermissions view still filters the response to
// the project.create row per main's narrowed contract.
func (s *Store) rolePermissionRecords(db rolePermissionQuerier, roleID string) ([]RolePermissionRecord, error) {
rows, err := db.Query(s.q(`SELECT action_id,allowed,cap_allowed,permission_version FROM role_permissions WHERE role_id=? ORDER BY action_id`), roleID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []RolePermissionRecord{}
for rows.Next() {
var permission RolePermissionRecord
if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed, &permission.PermissionVersion); err != nil {
return nil, err
}
out = append(out, permission)
}
return out, rows.Err()
}
// rolePermissionBitsTx reads every action bit of one role inside one tx.
func (s *Store) rolePermissionBitsTx(tx *sql.Tx, roleID string) (map[string]RolePermissionChange, error) {
rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=?`), roleID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]RolePermissionChange{}
for rows.Next() {
var permission RolePermissionChange
if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil {
return nil, err
}
out[permission.ActionID] = permission
}
return out, rows.Err()
}
// checkRoleAssignmentTx judges one assignment against current rows. account
// is nil when the user does not exist. class is the batch-wide error the item
// would cause at commit; reason is its user-facing Chinese text.
func (s *Store) checkRoleAssignmentTx(tx *sql.Tx, account *Account, item UserRoleAssignmentInput, lockRole bool) (roleAssignmentCheck, error) {
var check roleAssignmentCheck
role, err := s.roleTx(tx, item.RoleID, lockRole)
switch {
case err == nil:
check.role, check.roleFound = role, true
case !errors.Is(err, ErrNotFound):
return check, err
}
switch {
case account == nil:
check.reason, check.class = roleAssignmentUserMissing, ErrAccountForbidden
case !account.Enabled:
check.reason, check.class = roleAssignmentUserDisabled, ErrAccountForbidden
case account.StateVersion != item.ExpectedStateVersion:
check.reason, check.class = roleAssignmentStateChanged, ErrConflict
case !check.roleFound || !check.role.Enabled:
check.reason, check.class = roleAssignmentRoleInvalid, ErrInvalidRolePolicy
}
return check, nil
}
type roleAssignmentCheck struct {
role RoleRecord
roleFound bool
reason string
class error
}
// assignmentUsers validates batch shape: at most maxRoleAssignmentBatch
// items, each naming a distinct user with a positive expected state version.
func assignmentUsers(items []UserRoleAssignmentInput) ([]string, error) {
if len(items) > maxRoleAssignmentBatch {
return nil, ErrInvalidRolePolicy
}
seen := make(map[string]bool, len(items))
userIDs := make([]string, 0, len(items))
for _, item := range items {
if item.UserID == "" || item.ExpectedStateVersion < 1 || seen[item.UserID] {
return nil, ErrInvalidRolePolicy
}
seen[item.UserID] = true
userIDs = append(userIDs, item.UserID)
}
return userIDs, nil
}
// validateRoleAssignmentItems checks the full assignment shape: every role
// ID present, every user distinct, every state version positive.
func validateRoleAssignmentItems(items []UserRoleAssignmentInput) ([]string, error) {
if len(items) == 0 {
return nil, ErrInvalidRolePolicy
}
for _, item := range items {
if item.RoleID == "" {
return nil, ErrInvalidRolePolicy
}
}
return assignmentUsers(items)
}
// activeRoleTx reports the given user's currently-active role, or empty
// strings when the user has no active binding.
func (s *Store) activeRoleTx(tx *sql.Tx, userID string) (string, string, error) {
var roleID, name string
err := tx.QueryRow(s.q(`SELECT b.role_id,r.name FROM user_role_bindings b JOIN roles r ON r.role_id=b.role_id
WHERE b.user_id=? AND b.active=?`), userID, true).Scan(&roleID, &name)
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
}
return roleID, name, err
}
// rebindUserRoleTx ends the user's active binding, inserts a new binding, and
// advances the user's state version by one. The user must still be enabled at
// expectedState; otherwise ErrConflict.
func (s *Store) rebindUserRoleTx(tx *sql.Tx, actorID, userID, roleID string, expectedState int64, now string) (string, error) {
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, actorID, 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, err := result.RowsAffected(); err != nil {
return "", err
} else if n != 1 {
return "", ErrConflict
}
return bindingID, nil
}
// assignRolesTx validates every item, then rebinds each user. A rejected
// item rolls the whole batch back; targets are the locked accounts. The
// super-admin target protection and the no-elevation-through-role grant
// checks match AssignUserRole so the batch cannot become an elevation path
// (R4, R5).
func (s *Store) assignRolesTx(tx *sql.Tx, actorAccount Account, targets map[string]Account, items []UserRoleAssignmentInput, now string, epoch int64) ([]UserRoleAssignmentResult, error) {
for _, item := range items {
target, locked := targets[item.UserID]
account := &target
if !locked {
account = nil
}
if err := requireSuperAdminTargetAccess(actorAccount, target); err != nil {
return nil, err
}
if !actorAccount.IsSuperAdmin {
grantsAdministration, err := s.roleGrantsAccountAdministrationTx(tx, item.RoleID)
if err != nil {
return nil, err
}
if grantsAdministration {
return nil, ErrAccountForbidden
}
}
check, err := s.checkRoleAssignmentTx(tx, account, item, true)
if err != nil {
return nil, err
}
if check.class != nil {
return nil, check.class
}
}
assignedAt, _ := time.Parse(time.RFC3339Nano, now)
out := make([]UserRoleAssignmentResult, 0, len(items))
for _, item := range items {
previousRoleID, _, err := s.activeRoleTx(tx, item.UserID)
if err != nil {
return nil, err
}
bindingID, err := s.rebindUserRoleTx(tx, actorAccount.ID, item.UserID, item.RoleID, item.ExpectedStateVersion, now)
if err != nil {
return nil, err
}
out = append(out, UserRoleAssignmentResult{PreviousRoleID: previousRoleID, RoleBinding: RoleBinding{
BindingID: bindingID, UserID: item.UserID, RoleID: item.RoleID, Active: true, BindingVersion: 1,
AssignedBy: actorAccount.ID, AssignedAt: assignedAt, UserStateVersion: item.ExpectedStateVersion + 1, AuthorityEpoch: epoch + 1}})
}
return out, nil
}
// requireEnabledActorTx is the read-mode equivalent of policyWrite's actor
// precheck: the actor's users row must exist and still be enabled.
func (s *Store) requireEnabledActorTx(tx *sql.Tx, actor string) error {
var enabled bool
err := tx.QueryRow(s.q(`SELECT enabled FROM users WHERE owner=?`), actor).Scan(&enabled)
if errors.Is(err, sql.ErrNoRows) || (err == nil && !enabled) {
return ErrAccountForbidden
}
return err
}
// PreviewUserRoleAssignments judges each assignment with the commit's rule
// in one read transaction. It never writes rows, receipts, or the authority
// epoch.
func (s *Store) PreviewUserRoleAssignments(actor string, items []UserRoleAssignmentInput) ([]UserRoleAssignmentPreview, error) {
if actor == "" {
return nil, ErrAccountForbidden
}
if _, err := validateRoleAssignmentItems(items); err != nil {
return nil, err
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
if err := s.requireEnabledActorTx(tx, actor); err != nil {
return nil, err
}
out := make([]UserRoleAssignmentPreview, 0, len(items))
for _, item := range items {
preview := UserRoleAssignmentPreview{UserID: item.UserID, TargetRoleID: item.RoleID}
var account *Account
found, err := scanAccount(tx.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), item.UserID))
switch {
case err == nil:
account = &found
preview.Owner, preview.Name = found.Owner, found.Profile.Name
preview.StateMatches = found.StateVersion == item.ExpectedStateVersion
if preview.CurrentRoleID, preview.CurrentRoleName, err = s.activeRoleTx(tx, found.ID); err != nil {
return nil, err
}
case !errors.Is(err, ErrNotFound):
return nil, err
}
check, err := s.checkRoleAssignmentTx(tx, account, item, false)
if err != nil {
return nil, err
}
if check.roleFound {
preview.TargetRoleName = check.role.Name
}
preview.Error = check.reason
out = append(out, preview)
}
return out, nil
}
// AssignUserRolesBatch rebinds several users in one policy transaction with
// one authority epoch step and one idempotency receipt; any rejected item
// rolls back the whole batch.
func (s *Store) AssignUserRolesBatch(actor string, items []UserRoleAssignmentInput, key string) (UserRoleAssignmentBatch, error) {
userIDs, err := validateRoleAssignmentItems(items)
if err != nil {
return UserRoleAssignmentBatch{}, err
}
requestHash := policyRequestHash(struct{ Items []UserRoleAssignmentInput }{items})
var out UserRoleAssignmentBatch
err = s.policyWriteBatch(actor, userIDs, func(tx *sql.Tx, actorAccount Account, targets map[string]Account, epoch int64) error {
if err := s.requireRoleAuthorityWritableTx(tx); err != nil {
return err
}
replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "user.role.assign.batch", requestHash, &out)
if err != nil || replayed {
return err
}
now := nowISO()
bindings, err := s.assignRolesTx(tx, actorAccount, targets, items, now, epoch)
if err != nil {
return err
}
if err := s.checkCapabilityAssociationTx(tx, userIDs, nil, nil); err != nil {
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 = UserRoleAssignmentBatch{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, Bindings: bindings}
return s.saveReceipt(tx, actorAccount.ID, key, "user.role.assign.batch", requestHash, out)
})
return out, err
}
// PreviewRolePermissions predicts ReplaceRolePermissions for several roles in
// one read transaction. It never writes rows, receipts, or the authority
// epoch. The commit revalidates every item.
func (s *Store) PreviewRolePermissions(actor string, items []RolePermissionPreviewInput) ([]RolePermissionPreview, error) {
if actor == "" {
return nil, ErrAccountForbidden
}
if len(items) == 0 || len(items) > maxRolePermissionPreviewItem {
return nil, ErrInvalidRolePolicy
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
if err := s.requireEnabledActorTx(tx, actor); err != nil {
return nil, err
}
out := make([]RolePermissionPreview, 0, len(items))
for _, item := range items {
preview, err := s.previewRolePermissionsTx(tx, item)
if err != nil {
return nil, err
}
out = append(out, preview)
}
return out, nil
}
func (s *Store) previewRolePermissionsTx(tx *sql.Tx, item RolePermissionPreviewInput) (RolePermissionPreview, error) {
switch item.Mode {
case "", RolePermissionPreviewReplace, RolePermissionPreviewPatch:
default:
return RolePermissionPreview{}, ErrInvalidRolePolicy
}
role, err := s.roleTx(tx, item.RoleID, false)
if errors.Is(err, ErrNotFound) {
return RolePermissionPreview{RoleID: item.RoleID, Changes: []RolePermissionStateChange{},
LinkedExpansion: []string{}, KeptLinkedActions: []string{}, Conflicts: []RolePermissionConflict{},
Error: roleAssignmentRoleInvalid, ErrorActionID: ""}, nil
}
if err != nil {
return RolePermissionPreview{}, err
}
out := RolePermissionPreview{RoleID: role.RoleID, RoleName: role.Name, ExpectedRoleVersion: item.ExpectedRoleVersion,
CurrentRoleVersion: role.RoleVersion, VersionMatches: role.RoleVersion == item.ExpectedRoleVersion,
Changes: []RolePermissionStateChange{}, LinkedExpansion: []string{}, KeptLinkedActions: []string{},
Conflicts: []RolePermissionConflict{}}
next, err := normalizePermissionChanges(item.Changes)
var changeErr *rolePermissionChangeError
if errors.As(err, &changeErr) {
out.Error, out.ErrorActionID = changeErr.Reason, changeErr.ActionID
return out, nil
}
if err != nil {
return RolePermissionPreview{}, err
}
current, err := s.rolePermissionBitsTx(tx, role.RoleID)
if err != nil {
return RolePermissionPreview{}, err
}
after := map[string]RolePermissionChange{}
for actionID, row := range current {
after[actionID] = row
}
for actionID, row := range next {
after[actionID] = row
}
wasCreator := projectCreateEnabled(current)
isCreator := projectCreateEnabled(after)
if wasCreator && !isCreator {
kept := keptLinkedRows(current)
out.KeptLinkedActions = append(out.KeptLinkedActions, kept...)
out.MigrationNote = projectCreateDisabledPreviewNote(kept)
}
out.Changes = rolePermissionStateChanges(current, after, nil)
state, err := s.loadCapabilityAssociationTx(tx, nil, []string{role.RoleID})
if err != nil {
return RolePermissionPreview{}, err
}
state.roles[role.RoleID] = after
for _, conflict := range state.conflicts() {
if conflict.RoleID != role.RoleID {
continue
}
out.Conflicts = append(out.Conflicts, RolePermissionConflict{
ActionID: conflict.ActionID, Reason: capabilityConflictReason(conflict),
})
}
return out, nil
}
// keptLinkedRows lists the linked actions the role already has an explicit
// row for, so disabling project.create keeps them in place.
func keptLinkedRows(current map[string]RolePermissionChange) []string {
kept := []string{}
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if _, existed := current[actionID]; existed {
kept = append(kept, actionID)
}
}
return kept
}
func projectCreateDisabledPreviewNote(kept []string) string {
if len(kept) == 0 {
return "关闭创建项目后,没有需要保留的本人项目管理资格"
}
labels := make([]string, 0, len(kept))
for _, actionID := range kept {
labels = append(labels, "「"+actionLabel(actionID)+"」")
}
return "关闭创建项目后,以下既有资格将保留:" + strings.Join(labels, "、") + ";如需收回请明确移除"
}
// rolePermissionStateChanges lists every action whose display state differs
// between before and after, in catalog order. Linked-expansion rows are
// reported separately and skipped here.
func rolePermissionStateChanges(before, after map[string]RolePermissionChange, expansion []string) []RolePermissionStateChange {
expanded := map[string]bool{}
for _, actionID := range expansion {
expanded[actionID] = true
}
out := []RolePermissionStateChange{}
for _, descriptor := range catalog.Catalog() {
if expanded[descriptor.ActionID] {
continue
}
beforeName, afterName := permissionStateDenied, permissionStateDenied
if row, ok := before[descriptor.ActionID]; ok {
beforeName = permissionStateName(row.Allowed, row.CapAllowed)
}
if row, ok := after[descriptor.ActionID]; ok {
afterName = permissionStateName(row.Allowed, row.CapAllowed)
}
if beforeName != afterName {
out = append(out, RolePermissionStateChange{ActionID: descriptor.ActionID,
Label: descriptor.Label, Before: beforeName, After: afterName})
}
}
return out
}
// permissionStateName is the role permission view's user-facing state.
func permissionStateName(allowed, capAllowed bool) string {
switch {
case allowed && capAllowed:
return permissionStateDefault
case !allowed && capAllowed:
return permissionStateProject
case !allowed && !capAllowed:
return permissionStateDenied
}
return permissionStateInvalid
}
// patchRolePermissionsTx applies an explicit patch to one role: list-only
// rows are upserted, every other row of the role stays exactly as stored.
// Returns a per-role batch receipt; the surrounding transaction is rolled
// back on any error. Only project.create may appear in Changes per main's
// narrowed model; other rows in the role are inert legacy audit data.
func (s *Store) patchRolePermissionsTx(tx *sql.Tx, actorID string, item RolePermissionPatchInput, now string) (RolePermissionBatchReceipt, error) {
role, err := s.roleTx(tx, item.RoleID, true)
if errors.Is(err, ErrNotFound) {
return RolePermissionBatchReceipt{}, ErrRolePolicyRoleMissing
}
if err != nil {
return RolePermissionBatchReceipt{}, err
}
if role.RoleVersion != item.ExpectedRoleVersion {
return RolePermissionBatchReceipt{}, ErrRolePolicyVersion
}
patch, err := normalizePermissionChanges(item.Changes)
if err != nil {
return RolePermissionBatchReceipt{}, err
}
current, err := s.rolePermissionBitsTx(tx, role.RoleID)
if err != nil {
return RolePermissionBatchReceipt{}, err
}
after := map[string]RolePermissionChange{}
for actionID, row := range current {
after[actionID] = row
}
for actionID, row := range patch {
after[actionID] = row
}
if err := s.upsertRolePermissionsTx(tx, actorID, role.RoleID, current, after, now); err != nil {
return RolePermissionBatchReceipt{}, 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=?`), actorID, now, role.RoleID, item.ExpectedRoleVersion)
if err != nil {
return RolePermissionBatchReceipt{}, err
}
if n, err := result.RowsAffected(); err != nil {
return RolePermissionBatchReceipt{}, err
} else if n != 1 {
return RolePermissionBatchReceipt{}, ErrRolePolicyVersion
}
wasCreator := projectCreateEnabled(after)
afterPatch := map[string]RolePermissionChange{}
for actionID, row := range after {
afterPatch[actionID] = row
}
for actionID, row := range patch {
afterPatch[actionID] = row
}
isCreator := projectCreateEnabled(afterPatch)
disabling := wasCreator && !isCreator
kept := []string{}
if disabling {
kept = append(kept, keptLinkedRows(current)...)
}
out := RolePermissionBatchReceipt{RoleID: role.RoleID, PreviousRoleVersion: role.RoleVersion,
PolicyReceipt: PolicyReceipt{RoleVersion: role.RoleVersion + 1},
Changes: rolePermissionStateChanges(current, afterPatch, nil)}
if disabling {
out.KeptLinkedActions = append(out.KeptLinkedActions, kept...)
out.MigrationNote = projectCreateDisabledNote(kept)
}
return out, nil
}
// upsertRolePermissionsTx inserts or updates rows so that, after it returns,
// every row named in "after" matches its target (allowed, cap_allowed). Rows
// in "current" but not in "after" are left untouched (patch semantics).
func (s *Store) upsertRolePermissionsTx(tx *sql.Tx, actorID, roleID string, current, after map[string]RolePermissionChange, now string) error {
writes := make([]RolePermissionChange, 0, len(after))
for _, row := range after {
existing, exists := current[row.ActionID]
if !exists || existing.Allowed != row.Allowed || existing.CapAllowed != row.CapAllowed {
writes = append(writes, row)
}
}
for _, row := range writes {
if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(role_id,action_id) DO UPDATE SET allowed=excluded.allowed,cap_allowed=excluded.cap_allowed,
permission_version=permission_version+1,updated_by=excluded.updated_by,updated_at=excluded.updated_at`),
roleID, row.ActionID, row.Allowed, row.CapAllowed, 1, actorID, now); err != nil {
return err
}
}
return nil
}
// PatchRolePermissionsBatch applies explicit permission patches to several
// roles in one policy transaction: one authority epoch step, one idempotency
// receipt, and no write at all when any role is stale or invalid. Each
// role's role_version advance is a no-op for stale input.
func (s *Store) PatchRolePermissionsBatch(actor string, items []RolePermissionPatchInput, key string) (RolePermissionBatch, error) {
if len(items) == 0 || len(items) > maxRolePermissionBatch {
return RolePermissionBatch{}, ErrInvalidRolePolicy
}
roleIDs := make([]string, 0, len(items))
seen := make(map[string]bool, len(items))
for _, item := range items {
if item.RoleID == "" || seen[item.RoleID] {
return RolePermissionBatch{}, ErrInvalidRolePolicy
}
seen[item.RoleID] = true
roleIDs = append(roleIDs, item.RoleID)
}
requestHash := policyRequestHash(struct{ Items []RolePermissionPatchInput }{items})
var out RolePermissionBatch
err := s.policyWriteBatch(actor, nil, func(tx *sql.Tx, actorAccount Account, _ map[string]Account, epoch int64) error {
if err := s.requireRoleAuthorityWritableTx(tx); err != nil {
return err
}
replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "role.permissions.batch_patch", requestHash, &out)
if err != nil || replayed {
return err
}
now := nowISO()
batch := RolePermissionBatch{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1,
Receipts: make([]RolePermissionBatchReceipt, 0, len(items))}
for _, item := range items {
receipt, err := s.patchRolePermissionsTx(tx, actorAccount.ID, item, now)
if err != nil {
return err
}
receipt.ReceiptID, receipt.AuthorityEpoch = batch.ReceiptID, batch.AuthorityEpoch
batch.Receipts = append(batch.Receipts, receipt)
}
if err := s.checkCapabilityAssociationTx(tx, nil, roleIDs, nil); err != nil {
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 = batch
return s.saveReceipt(tx, actorAccount.ID, key, "role.permissions.batch_patch", requestHash, out)
})
return out, err
}
// CreateRoleCopyAndAssign copies a role's non-protected permission rows into
// a new enabled role, applies overrides as an explicit patch, and rebinds
// the assigned users, all in one policy transaction. Protected system
// grants, administrator identity, and project memberships are never copied.
func (s *Store) CreateRoleCopyAndAssign(actor string, source RoleCopySource, spec RoleCreate, overrides []RolePermissionChange, assignments []RoleCopyAssignment, key string) (RoleCopyAssignResult, error) {
name, description, err := validateRoleText(spec.Name, spec.Description)
if err != nil || source.RoleID == "" {
return RoleCopyAssignResult{}, ErrInvalidRolePolicy
}
items := make([]UserRoleAssignmentInput, 0, len(assignments))
for _, assignment := range assignments {
items = append(items, UserRoleAssignmentInput{UserID: assignment.UserID, RoleID: "pending", ExpectedStateVersion: assignment.ExpectedStateVersion})
}
userIDs, err := assignmentUsers(items)
if err != nil {
return RoleCopyAssignResult{}, err
}
requestHash := policyRequestHash(struct {
Source RoleCopySource
Name string
Description string
Overrides []RolePermissionChange
Assignments []RoleCopyAssignment
}{source, name, description, overrides, assignments})
var out RoleCopyAssignResult
err = s.policyWriteBatch(actor, userIDs, func(tx *sql.Tx, actorAccount Account, targets map[string]Account, epoch int64) error {
if err := s.requireRoleAuthorityWritableTx(tx); err != nil {
return err
}
replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "role.copy.assign", requestHash, &out)
if err != nil || replayed {
return err
}
sourceRole, err := s.roleTx(tx, source.RoleID, true)
if errors.Is(err, ErrNotFound) {
return ErrRolePolicyRoleMissing
}
if err != nil {
return err
}
if sourceRole.RoleVersion != source.ExpectedRoleVersion {
return ErrRolePolicyVersion
}
sourceRows, err := s.rolePermissionBitsTx(tx, sourceRole.RoleID)
if err != nil {
return err
}
copied := make(map[string]RolePermissionChange, len(sourceRows))
for actionID, row := range sourceRows {
if descriptor, ok := catalog.DescriptorByID(actionID); ok && !descriptor.Protected {
copied[actionID] = row
}
}
patch, err := normalizePermissionChanges(overrides)
if err != nil {
return err
}
merged := make(map[string]RolePermissionChange, len(copied)+len(patch))
for actionID, row := range copied {
merged[actionID] = row
}
for actionID, row := range patch {
merged[actionID] = row
}
sourceWasCreator := projectCreateEnabled(copied)
copyIsCreator := projectCreateEnabled(merged)
if sourceWasCreator && !copyIsCreator {
// Disabling a copy is rejected: the new role would lose the source's
// project.create authority, which is the whole point of copying.
return ErrInvalidRolePolicy
}
now := nowISO()
roleID := newID("role")
if _, 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); err != nil {
return err
}
// The role row was inserted in this same transaction, so the copy has
// no prior permission state: current is nil and every entry of the
// merged target is a fresh grant. That is what the original branch
// passed too (nil current against a freshly written role); faking a
// non-empty prior state here would misreport the helper's delta.
if err := s.upsertRolePermissionsTx(tx, actorAccount.ID, roleID, nil, merged, now); err != nil {
return err
}
for i := range items {
items[i].RoleID = roleID
}
bindings, err := s.assignRolesTx(tx, actorAccount, targets, items, now, epoch)
if err != nil {
return err
}
if err := s.checkCapabilityAssociationTx(tx, userIDs, []string{roleID}, nil); err != nil {
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
}
role, err := s.roleTx(tx, roleID, false)
if err != nil {
return err
}
permissions, err := s.rolePermissionRecords(tx, roleID)
if err != nil {
return err
}
// ProjectAccess is the normalized projection every other role
// read path fills in (RoleAdmin, ListRoles); the copy receipt must
// report the same value or a copied project.create role reads as
// access-less.
out = RoleCopyAssignResult{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1,
Role: RoleAdminView{RoleRecord: role, BindingCount: len(bindings),
ProjectAccess: projectAccessOf(permissions), Permissions: permissions},
Bindings: bindings}
return s.saveReceipt(tx, actorAccount.ID, key, "role.copy.assign", requestHash, out)
})
return out, err
}
// ListRoleMembersForActor reads a role's active members, ordered by owner,
// and the account directory revision from one snapshot. The actor's
// delegated authority is rechecked under lock before any member row is read.
func (s *Store) ListRoleMembersForActor(actorOwner, roleID string) ([]RoleMember, AccountDirectoryRevision, error) {
var tx *sql.Tx
var err error
if s.Dialect() == "postgres" {
tx, err = s.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
} else {
tx, err = s.db.Begin()
}
if err != nil {
return nil, AccountDirectoryRevision{}, err
}
defer tx.Rollback()
locked, err := s.lockAccountUsers(tx, actorOwner)
if errors.Is(err, ErrNotFound) {
return nil, AccountDirectoryRevision{}, ErrAccountForbidden
}
if err != nil {
return nil, AccountDirectoryRevision{}, err
}
for _, actionID := range []string{"system.users.view", "system.roles.view"} {
if err := s.requireSystemActionTx(tx, locked[actorOwner], actionID); err != nil {
return nil, AccountDirectoryRevision{}, err
}
}
if _, err := s.roleTx(tx, roleID, false); err != nil {
return nil, AccountDirectoryRevision{}, err
}
rows, err := tx.Query(s.q(`SELECT u.id,u.owner,COALESCE(u.name,''),u.enabled,u.state_version,b.assigned_at,b.binding_version
FROM user_role_bindings b JOIN users u ON u.id=b.user_id WHERE b.role_id=? AND b.active=? ORDER BY u.owner,u.id`), roleID, true)
if err != nil {
return nil, AccountDirectoryRevision{}, err
}
members := []RoleMember{}
for rows.Next() {
var member RoleMember
var assignedAt string
if err := rows.Scan(&member.UserID, &member.Owner, &member.Name, &member.Enabled, &member.StateVersion, &assignedAt, &member.BindingVersion); err != nil {
rows.Close()
return nil, AccountDirectoryRevision{}, err
}
member.AssignedAt, _ = time.Parse(time.RFC3339Nano, assignedAt)
members = append(members, member)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, AccountDirectoryRevision{}, err
}
rows.Close()
var revision AccountDirectoryRevision
if err := tx.QueryRow(`SELECT public_revision,staff_no_revision,private_revision FROM account_directory_revision WHERE id=1`).
Scan(&revision.Public, &revision.StaffNo, &revision.Private); err != nil {
return nil, AccountDirectoryRevision{}, err
}
if err := tx.Commit(); err != nil {
return nil, AccountDirectoryRevision{}, err
}
return members, revision, nil
}
// ActiveRolesForUsers returns each listed user's active role in one query.
// Users without an active binding are absent from the map.
func (s *Store) ActiveRolesForUsers(userIDs []string) (map[string]UserRoleSummary, error) {
out := make(map[string]UserRoleSummary, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
args := []any{true}
for _, userID := range userIDs {
args = append(args, userID)
}
rows, err := s.db.Query(s.q(`SELECT b.user_id,r.role_id,r.name,r.enabled FROM user_role_bindings b JOIN roles r ON r.role_id=b.role_id
WHERE b.active=? AND b.user_id IN (`+strings.TrimSuffix(strings.Repeat("?,", len(userIDs)), ",")+`)`), args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var userID string
var role UserRoleSummary
if err := rows.Scan(&userID, &role.RoleID, &role.Name, &role.Enabled); err != nil {
return nil, err
}
out[userID] = role
}
return out, rows.Err()
}