2521 lines
92 KiB
Go
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()
|
|
}
|