feat(authz): bind decisions to exact actions
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 37s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02e47-6aac-7ee1-914d-0584c75d3c81
This commit is contained in:
tegwick 2026-08-23 13:18:26 +02:00
parent 7323dd1a60
commit c473f1971d
28 changed files with 644 additions and 12 deletions

View file

@ -1,5 +1,11 @@
package api
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
)
// ProtectedSystemManifest describes a system that delegates authorization to
// flex-auth.
type ProtectedSystemManifest struct {
@ -207,12 +213,91 @@ type DecisionEnvelope struct {
MatchedRule string `json:"matched_rule,omitempty" yaml:"matched_rule,omitempty"`
Resource ResourceRef `json:"resource" yaml:"resource"`
Subject SubjectRef `json:"subject" yaml:"subject"`
Binding *DecisionBinding `json:"binding,omitempty" yaml:"binding,omitempty"`
Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"`
Diagnostics map[string]any `json:"diagnostics,omitempty" yaml:"diagnostics,omitempty"`
Provenance DecisionProvenance `json:"provenance" yaml:"provenance"`
Caring *CaringDecisionMetadata `json:"caring,omitempty" yaml:"caring,omitempty"`
}
// DecisionBinding is the exact normalized authorization request evaluated by
// a decision. It lets a consumer verify structured action, target, actor, and
// context fields without parsing reason or diagnostic prose.
type DecisionBinding struct {
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
Subject SubjectRef `json:"subject" yaml:"subject"`
Action string `json:"action" yaml:"action"`
Resource ResourceRef `json:"resource" yaml:"resource"`
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
RequestDigest string `json:"request_digest" yaml:"request_digest"`
}
// NewDecisionBinding returns a stable structured binding for the exact request
// an evaluator consumed.
func NewDecisionBinding(request CheckRequest) *DecisionBinding {
data, _ := json.Marshal(request)
sum := sha256.Sum256(data)
contextCopy := make(map[string]any, len(request.Context))
for key, value := range request.Context {
contextCopy[key] = value
}
return &DecisionBinding{
Tenant: request.Tenant,
Subject: request.Subject,
Action: request.Action,
Resource: request.Resource,
Context: contextCopy,
RequestDigest: "sha256:" + hex.EncodeToString(sum[:]),
}
}
// ActionAuthorizationStatus is the lifecycle state of a durable authorization.
type ActionAuthorizationStatus string
const (
ActionAuthorizationPending ActionAuthorizationStatus = "pending"
ActionAuthorizationApproved ActionAuthorizationStatus = "approved"
ActionAuthorizationDenied ActionAuthorizationStatus = "denied"
ActionAuthorizationSuperseded ActionAuthorizationStatus = "superseded"
ActionAuthorizationExpired ActionAuthorizationStatus = "expired"
ActionAuthorizationRevoked ActionAuthorizationStatus = "revoked"
)
// ActionAuthorization joins a durable approval lifecycle to one exact
// flex-auth request and decision. Storage and approval collection remain the
// responsibility of the organizational decision authority.
type ActionAuthorization struct {
SchemaVersion string `json:"schema_version" yaml:"schema_version"`
ID string `json:"id" yaml:"id"`
Status ActionAuthorizationStatus `json:"status" yaml:"status"`
SupersededBy string `json:"superseded_by,omitempty" yaml:"superseded_by,omitempty"`
Request CheckRequest `json:"request" yaml:"request"`
Validity ActionAuthorizationValidity `json:"validity" yaml:"validity"`
Approvals ActionAuthorizationApprovals `json:"approvals" yaml:"approvals"`
Decision DecisionEnvelope `json:"decision" yaml:"decision"`
Provenance map[string]any `json:"provenance,omitempty" yaml:"provenance,omitempty"`
}
// ActionAuthorizationValidity bounds execution of an approved action.
type ActionAuthorizationValidity struct {
NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"`
ExpiresAt string `json:"expires_at" yaml:"expires_at"`
}
// ActionAuthorizationApprovals declares the approval threshold and evidence.
type ActionAuthorizationApprovals struct {
RequiredCount int `json:"required_count" yaml:"required_count"`
Entries []ActionAuthorizationApprovalEntry `json:"entries" yaml:"entries"`
}
// ActionAuthorizationApprovalEntry is one authenticated approver's evidence.
type ActionAuthorizationApprovalEntry struct {
SubjectID string `json:"subject_id" yaml:"subject_id"`
ApprovedAt string `json:"approved_at" yaml:"approved_at"`
Assurance string `json:"assurance,omitempty" yaml:"assurance,omitempty"`
EvidenceRef string `json:"evidence_ref,omitempty" yaml:"evidence_ref,omitempty"`
}
// Obligation describes a follow-up behavior required by a decision.
type Obligation struct {
Type string `json:"type" yaml:"type"`

View file

@ -93,6 +93,20 @@ func TestDecisionAndAuditExamplesParse(t *testing.T) {
}
}
func TestActionAuthorizationExampleParses(t *testing.T) {
var authorization api.ActionAuthorization
loadJSON(t, filepath.Join("..", "..", "examples", "caring", "action_authorization.json"), &authorization)
if authorization.Status != api.ActionAuthorizationApproved {
t.Fatalf("Status = %q; want approved", authorization.Status)
}
if authorization.Request.Action != "destroy" || authorization.Decision.Binding == nil {
t.Fatalf("authorization is not action-bound: %+v", authorization)
}
if authorization.Approvals.RequiredCount != 2 || len(authorization.Approvals.Entries) != 2 {
t.Fatalf("Approvals = %+v; want two-person approval", authorization.Approvals)
}
}
func TestSchemaFilesAreJSON(t *testing.T) {
schemaDir := filepath.Join("..", "..", "schemas")
entries, err := os.ReadDir(schemaDir)