feat(authz): bind decisions to exact actions
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02e47-6aac-7ee1-914d-0584c75d3c81
This commit is contained in:
parent
7323dd1a60
commit
c473f1971d
28 changed files with 644 additions and 12 deletions
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue