225 lines
8.2 KiB
Go
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()
|
|
}
|