Files
Agent-System-Backend/internal/store/role_policy.go
T
weichen 4a38266320 Merge Backend main into the lifecycle line (narrowed role model wins per plan R2)
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.
2026-10-20 11:02:16 +00:00

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()
}