Files
agent-exec-transport e942a35b73
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 1m18s
agent-exec transport 8315c523cef26780543b9b2130f4f4a4/5c8333ec4e92
2026-10-19 12:21:32 +00:00

225 lines
8.2 KiB
Go

package store
import (
"database/sql"
"errors"
"strings"
"agent-system-backend/internal/authz/catalog"
)
const (
CapabilitySourceRole = "role"
CapabilitySourceSystemGrant = "system-grant"
CapabilitySourceBootstrapAdmin = "bootstrap-admin"
CapabilityScopeSystem = "system"
CapabilityScopeOwnProjects = "own-projects"
CapabilityScopeMemberProjects = "member-projects"
)
// memberBasicCollaborateActions are the role defaults a member brings into a
// project; documentQualifyDuties are the three assignable document duties.
var (
memberBasicCollaborateActions = []string{"document.read", "trace.edit", "knowledge.publish", "project.audit.read"}
documentQualifyDuties = []string{"document.author", "document.review", "document.approve"}
)
type CapabilitySubject struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
AuthorityEpoch int64 `json:"authorityEpoch"`
RoleID string `json:"roleId,omitempty"`
RoleName string `json:"roleName,omitempty"`
RoleEnabled bool `json:"roleEnabled"`
}
type CapabilityActionExplain struct {
ActionID string `json:"actionId"`
Scope string `json:"scope"`
Allowed bool `json:"allowed"`
CapAllowed bool `json:"capAllowed"`
Source string `json:"source,omitempty"`
LinkedVia string `json:"linkedVia,omitempty"`
Conflict bool `json:"conflict,omitempty"`
Reason string `json:"reason,omitempty"`
}
type CapabilityExplain struct {
ProjectCreate CapabilityActionExplain `json:"projectCreate"`
Linked []CapabilityActionExplain `json:"linked"`
Conflicts []string `json:"conflicts"`
MigrationNote string `json:"migrationNote,omitempty"`
}
type MemberBasicCollaborate struct {
Actions []CapabilityActionExplain `json:"actions"`
}
type DocumentDutyQualify struct {
ActionID string `json:"actionId"`
Qualified bool `json:"qualified"`
DefaultAllowed bool `json:"defaultAllowed"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
}
type DocumentQualify struct {
Duties []DocumentDutyQualify `json:"duties"`
}
// CapabilityExplanation is one actor's creator, member and document-duty
// explanation computed from a single authority snapshot.
type CapabilityExplanation struct {
CapabilitySubject
Capability CapabilityExplain `json:"capability"`
MemberBasicCollaborate MemberBasicCollaborate `json:"memberBasicCollaborate"`
DocumentQualify DocumentQualify `json:"documentQualify"`
}
func (s *Store) rolePermissionsTx(tx *sql.Tx, roleID string) (map[string]RolePermissionChange, error) {
rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=?`), roleID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]RolePermissionChange{}
for rows.Next() {
var permission RolePermissionChange
if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil {
return nil, err
}
out[permission.ActionID] = permission
}
return out, rows.Err()
}
// roleActionExplain uses the same reason vocabulary as the Host role check.
func roleActionExplain(actionID, scope string, bound, enabled bool, permissions map[string]RolePermissionChange) CapabilityActionExplain {
out := CapabilityActionExplain{ActionID: actionID, Scope: scope}
if !bound {
out.Reason = "role-unassigned"
return out
}
if !enabled {
out.Reason = "role-disabled"
return out
}
permission, ok := permissions[actionID]
switch {
case !ok:
out.Reason = "action-unconfigured"
case !permission.CapAllowed:
out.Reason = "role-cap"
case !permission.Allowed:
out.CapAllowed, out.Reason = true, "role-default-deny"
default:
out.Allowed, out.CapAllowed, out.Source = true, true, CapabilitySourceRole
}
return out
}
func capabilityExplainNote(projectCreate bool, conflicts, kept []string) string {
if projectCreate && len(conflicts) > 0 {
return "project.create linked actions capped by the enabled role: " + strings.Join(conflicts, ",") +
"; the cap wins on created projects until the role row is changed explicitly"
}
if !projectCreate && len(kept) > 0 {
return "project.create not held: linked capability rows " + strings.Join(kept, ",") +
" remain on the role; remove them explicitly to revoke own-project management"
}
return ""
}
// ExplainCapabilities explains the owner's own capabilities. A missing or
// disabled account is ErrAccountForbidden so callers cannot probe presence.
func (s *Store) ExplainCapabilities(owner string) (CapabilityExplanation, error) {
out := CapabilityExplanation{}
if owner == "" {
return out, ErrAccountForbidden
}
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 enabled, isAdmin bool
err = tx.QueryRow(s.q(`SELECT id,owner,enabled,is_admin FROM users WHERE owner=?`), owner).Scan(&out.UserID, &out.Owner, &enabled, &isAdmin)
if errors.Is(err, sql.ErrNoRows) || (err == nil && !enabled) {
return CapabilityExplanation{}, ErrAccountForbidden
}
if err != nil {
return CapabilityExplanation{}, err
}
state, err := s.loadCapabilityAssociationTx(tx, []string{out.UserID}, nil)
if err != nil {
return CapabilityExplanation{}, err
}
roleID, bound := state.bindings[out.UserID]
permissions := map[string]RolePermissionChange{}
if bound {
role, err := s.roleTx(tx, roleID, false)
if err != nil {
return CapabilityExplanation{}, err
}
out.RoleID, out.RoleName, out.RoleEnabled = role.RoleID, role.Name, role.Enabled
if permissions, err = s.rolePermissionsTx(tx, roleID); err != nil {
return CapabilityExplanation{}, err
}
}
var enabledPermissions map[string]RolePermissionChange
if out.RoleEnabled {
enabledPermissions = permissions
}
create := roleActionExplain(catalog.ProjectCreateAction, CapabilityScopeSystem, bound, out.RoleEnabled, permissions)
switch {
case isAdmin:
create.Allowed, create.Source, create.Reason = true, CapabilitySourceBootstrapAdmin, ""
case state.creators[out.UserID]:
create.Allowed, create.Source, create.Reason = true, CapabilitySourceSystemGrant, ""
case create.Allowed:
case create.Reason == "role-unassigned" || create.Reason == "action-unconfigured":
create.Reason = "not-granted"
}
capability := CapabilityExplain{ProjectCreate: create, Linked: []CapabilityActionExplain{}, Conflicts: []string{}}
kept := []string{}
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if _, exists := permissions[actionID]; exists {
kept = append(kept, actionID)
}
linked := CapabilityActionExplain{ActionID: actionID, Scope: CapabilityScopeOwnProjects}
switch {
case !create.Allowed:
linked.Reason = "project-create-not-held"
case linkedActionDenied(enabledPermissions, actionID):
linked.LinkedVia, linked.Conflict, linked.Reason = catalog.ProjectCreateAction, true, "role-cap"
capability.Conflicts = append(capability.Conflicts, actionID)
default:
linked.Allowed, linked.CapAllowed, linked.Source, linked.LinkedVia = true, true, create.Source, catalog.ProjectCreateAction
}
capability.Linked = append(capability.Linked, linked)
}
capability.MigrationNote = capabilityExplainNote(create.Allowed, capability.Conflicts, kept)
out.Capability = capability
out.MemberBasicCollaborate.Actions = make([]CapabilityActionExplain, 0, len(memberBasicCollaborateActions))
for _, actionID := range memberBasicCollaborateActions {
out.MemberBasicCollaborate.Actions = append(out.MemberBasicCollaborate.Actions,
roleActionExplain(actionID, CapabilityScopeMemberProjects, bound, out.RoleEnabled, permissions))
}
out.DocumentQualify.Duties = make([]DocumentDutyQualify, 0, len(documentQualifyDuties))
for _, actionID := range documentQualifyDuties {
decision := roleActionExplain(actionID, CapabilityScopeMemberProjects, bound, out.RoleEnabled, permissions)
duty := DocumentDutyQualify{ActionID: actionID, Qualified: decision.CapAllowed, DefaultAllowed: decision.Allowed, Reason: decision.Reason}
if duty.Qualified {
duty.Source = CapabilitySourceRole
}
out.DocumentQualify.Duties = append(out.DocumentQualify.Duties, duty)
}
return out, tx.Commit()
}