Why: compose the lifecycle line (role narrowing to a single project-access boolean, U3b member-history read path) with Backend main (capability association, explanation, super-admin). Per plan 2026-10-19-1440 R2/KTD2 the narrowed single-boolean write model supersedes the linked-cap expansion and its association invariant; main non-conflicting work (explanation, super-admin, tavily, O2 pins) is incorporated. Impact: integration merge on the lifecycle branch; suites arbitrate.
1380 lines
49 KiB
Go
1380 lines
49 KiB
Go
package store
|
|
|
|
import (
|
|
"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()
|
|
}
|