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.
115 lines
3.4 KiB
Go
115 lines
3.4 KiB
Go
package authz
|
|
|
|
import (
|
|
"agent-system-backend/internal/authz/catalog"
|
|
"agent-system-backend/internal/store"
|
|
)
|
|
|
|
type Descriptor = catalog.Descriptor
|
|
type Scope = catalog.Scope
|
|
|
|
const (
|
|
ScopeSystem = catalog.ScopeSystem
|
|
ScopeProject = catalog.ScopeProject
|
|
)
|
|
|
|
func Catalog() []Descriptor { return catalog.Catalog() }
|
|
|
|
type Decision struct {
|
|
Allowed bool `json:"allowed"`
|
|
ActionID string `json:"actionId"`
|
|
Source string `json:"source,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
AuthorityEpoch int64 `json:"authorityEpoch"`
|
|
}
|
|
|
|
type Service struct {
|
|
store *store.Store
|
|
}
|
|
|
|
func NewService(st *store.Store) *Service { return &Service{store: st} }
|
|
|
|
func (s *Service) AuthorizeSystem(actor, actionID string) Decision {
|
|
decision := Decision{ActionID: actionID}
|
|
descriptor, ok := catalog.DescriptorByID(actionID)
|
|
if !ok || descriptor.Scope != catalog.ScopeSystem || s == nil || s.store == nil {
|
|
decision.Reason = "unknown-system-action"
|
|
return decision
|
|
}
|
|
epoch, err := s.store.AuthorityEpoch()
|
|
if err != nil {
|
|
decision.Reason = "authority-unavailable"
|
|
return decision
|
|
}
|
|
decision.AuthorityEpoch = epoch
|
|
account, err := s.store.GetAccountByOwner(actor)
|
|
if err != nil || !account.Enabled {
|
|
decision.Reason = "account-unavailable"
|
|
return decision
|
|
}
|
|
// Project access comes only from the actor's role (R2): neither is_admin
|
|
// nor a system grant confers it.
|
|
if actionID == catalog.ProjectAccessAction {
|
|
roleAllowed, _, err := s.store.RolePermissionAllowed(actor, actionID)
|
|
if err != nil {
|
|
decision.Reason = "authority-unavailable"
|
|
return decision
|
|
}
|
|
if roleAllowed {
|
|
decision.Allowed, decision.Source = true, "role"
|
|
return decision
|
|
}
|
|
decision.Reason = "not-granted"
|
|
return decision
|
|
}
|
|
// is_admin remains the narrow bootstrap/root recovery source. Ordinary
|
|
// delegated management uses explicit system_grants.
|
|
if account.IsAdmin {
|
|
decision.Allowed, decision.Source = true, "bootstrap-admin"
|
|
return decision
|
|
}
|
|
allowed, err := s.store.SystemGrantAllowed(actor, actionID)
|
|
if err != nil {
|
|
decision.Reason = "authority-unavailable"
|
|
return decision
|
|
}
|
|
if allowed {
|
|
decision.Allowed, decision.Source = true, "system-grant"
|
|
return decision
|
|
}
|
|
decision.Reason = "not-granted"
|
|
return decision
|
|
}
|
|
|
|
// EffectiveLinkedCapabilities returns the own-project management actions that
|
|
// the actor's project.create links. HTTP and command readers share this call
|
|
// so the link is computed once per actor from the Backend authority.
|
|
func (s *Service) EffectiveLinkedCapabilities(actor string) (store.CapabilityAssociation, error) {
|
|
if s == nil || s.store == nil {
|
|
return store.CapabilityAssociation{LinkedActions: []string{}}, store.ErrAccountForbidden
|
|
}
|
|
return s.store.LinkedCapabilities(actor)
|
|
}
|
|
|
|
// ExplainCapabilities explains only the actor's own creator, member and
|
|
// document-duty capabilities; HTTP and Host providers share this call.
|
|
func (s *Service) ExplainCapabilities(actor string) (store.CapabilityExplanation, error) {
|
|
if s == nil || s.store == nil {
|
|
return store.CapabilityExplanation{}, store.ErrAccountForbidden
|
|
}
|
|
return s.store.ExplainCapabilities(actor)
|
|
}
|
|
|
|
func (s *Service) EffectiveSystemActions(actor string) []string {
|
|
actions := []string{}
|
|
for _, descriptor := range catalog.Catalog() {
|
|
if descriptor.Scope != catalog.ScopeSystem {
|
|
continue
|
|
}
|
|
if decision := s.AuthorizeSystem(actor, descriptor.ActionID); decision.Allowed {
|
|
actions = append(actions, descriptor.ActionID)
|
|
}
|
|
}
|
|
return actions
|
|
}
|