fix(auth): authenticate producer-signed decisions before authorization
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
parent
6bd7129eac
commit
c80a944105
30 changed files with 2059 additions and 32 deletions
685
verifier/pkg/api/canonical.go
Normal file
685
verifier/pkg/api/canonical.go
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProtectedSystemManifest describes a system that delegates authorization to
|
||||
// flex-auth.
|
||||
type ProtectedSystemManifest struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
||||
ClientBinding *ClientBinding `json:"client_binding,omitempty" yaml:"client_binding,omitempty"`
|
||||
ResourceTypes []ResourceType `json:"resource_types,omitempty" yaml:"resource_types,omitempty"`
|
||||
Actions []ActionDefinition `json:"actions,omitempty" yaml:"actions,omitempty"`
|
||||
CaringProfiles []string `json:"caring_profiles,omitempty" yaml:"caring_profiles,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ClientBinding names the key-cape client a protected system's users sign in
|
||||
// through. It is descriptive only: the readback registry projection lists it,
|
||||
// and it never decides an allow (FLEX-DEC-2026-019).
|
||||
type ClientBinding struct {
|
||||
Issuer string `json:"issuer" yaml:"issuer"`
|
||||
ClientID string `json:"client_id" yaml:"client_id"`
|
||||
}
|
||||
|
||||
// ResourceType describes a resource namespace entry owned by a protected system.
|
||||
type ResourceType struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
ParentTypes []string `json:"parent_types,omitempty" yaml:"parent_types,omitempty"`
|
||||
ScopeLevel ScopeLevel `json:"scope_level,omitempty" yaml:"scope_level,omitempty"`
|
||||
Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ActionDefinition maps a protected-system action to CARING capabilities.
|
||||
type ActionDefinition struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Capabilities []Capability `json:"capabilities,omitempty" yaml:"capabilities,omitempty"`
|
||||
Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"`
|
||||
ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// SubjectManifest declares subjects, groups, teams, and tenants for local
|
||||
// registry loading.
|
||||
type SubjectManifest struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Subjects []Subject `json:"subjects,omitempty" yaml:"subjects,omitempty"`
|
||||
Groups []Group `json:"groups,omitempty" yaml:"groups,omitempty"`
|
||||
Teams []Team `json:"teams,omitempty" yaml:"teams,omitempty"`
|
||||
Tenants []Tenant `json:"tenants,omitempty" yaml:"tenants,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Subject is a human, service, automation, agent, or other acting identity.
|
||||
type Subject struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type SubjectType `json:"type" yaml:"type"`
|
||||
DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"`
|
||||
OrganizationRelation OrganizationRelation `json:"organization_relation,omitempty" yaml:"organization_relation,omitempty"`
|
||||
Roles []CanonicalRole `json:"roles,omitempty" yaml:"roles,omitempty"`
|
||||
Groups []string `json:"groups,omitempty" yaml:"groups,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Claims map[string]any `json:"claims,omitempty" yaml:"claims,omitempty"`
|
||||
CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Group is an assignment convenience, not a canonical role.
|
||||
type Group struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"`
|
||||
Members []string `json:"members,omitempty" yaml:"members,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Team is a group-like ownership unit used by protected systems.
|
||||
type Team struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"`
|
||||
Members []string `json:"members,omitempty" yaml:"members,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Tenant is a structural isolation boundary.
|
||||
type Tenant struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// RelationshipFact records a relation between subjects, groups, teams, tenants,
|
||||
// and resources.
|
||||
type RelationshipFact struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
System string `json:"system,omitempty" yaml:"system,omitempty"`
|
||||
Subject string `json:"subject" yaml:"subject"`
|
||||
Relation string `json:"relation" yaml:"relation"`
|
||||
Object string `json:"object" yaml:"object"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Conditions []Condition `json:"conditions,omitempty" yaml:"conditions,omitempty"`
|
||||
Caring *CaringAccessDescriptor `json:"caring,omitempty" yaml:"caring,omitempty"`
|
||||
Provenance map[string]any `json:"provenance,omitempty" yaml:"provenance,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyPackageMetadata is the frontmatter contract for Rego-in-Markdown
|
||||
// policy packages.
|
||||
type PolicyPackageMetadata struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
|
||||
Version string `json:"version" yaml:"version"`
|
||||
Status string `json:"status,omitempty" yaml:"status,omitempty"`
|
||||
Package string `json:"package" yaml:"package"`
|
||||
Actions []string `json:"actions,omitempty" yaml:"actions,omitempty"`
|
||||
Owner string `json:"owner,omitempty" yaml:"owner,omitempty"`
|
||||
Fixtures []string `json:"fixtures,omitempty" yaml:"fixtures,omitempty"`
|
||||
Caring CaringPolicyMetadata `json:"caring" yaml:"caring"`
|
||||
Activation map[string]any `json:"activation,omitempty" yaml:"activation,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
// AllowTTL is a Go duration (for example "15m") that bounds every allow
|
||||
// this package produces. Omit to use DefaultAllowTTL. "none" or "0s"
|
||||
// means no stated end; the engine denies those allows (§9.7.1).
|
||||
AllowTTL string `json:"allow_ttl,omitempty" yaml:"allow_ttl,omitempty"`
|
||||
}
|
||||
|
||||
// CaringPolicyMetadata declares the CARING envelope a policy governs.
|
||||
type CaringPolicyMetadata struct {
|
||||
Profile string `json:"profile" yaml:"profile"`
|
||||
Enforce bool `json:"enforce,omitempty" yaml:"enforce,omitempty"`
|
||||
CanonicalRoles []CanonicalRole `json:"canonical_roles,omitempty" yaml:"canonical_roles,omitempty"`
|
||||
OrganizationRelations []OrganizationRelation `json:"organization_relations,omitempty" yaml:"organization_relations,omitempty"`
|
||||
Scopes []CaringScope `json:"scopes,omitempty" yaml:"scopes,omitempty"`
|
||||
Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"`
|
||||
Capabilities []Capability `json:"capabilities,omitempty" yaml:"capabilities,omitempty"`
|
||||
ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"`
|
||||
Conditions []Condition `json:"conditions,omitempty" yaml:"conditions,omitempty"`
|
||||
Restrictions []Restriction `json:"restrictions,omitempty" yaml:"restrictions,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyFixture binds a check request to an expected decision.
|
||||
type PolicyFixture struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Request CheckRequest `json:"request" yaml:"request"`
|
||||
Expect DecisionExpectation `json:"expect" yaml:"expect"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionExpectation is the compact fixture expectation for policy tests.
|
||||
type DecisionExpectation struct {
|
||||
Effect DecisionEffect `json:"effect" yaml:"effect"`
|
||||
Reason string `json:"reason,omitempty" yaml:"reason,omitempty"`
|
||||
Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"`
|
||||
ConformanceFindings []CaringConformanceFinding `json:"conformance_findings,omitempty" yaml:"conformance_findings,omitempty"`
|
||||
}
|
||||
|
||||
// CheckRequest is the stable protected-system-facing decision request.
|
||||
type CheckRequest struct {
|
||||
ID string `json:"id,omitempty" yaml:"id,omitempty"`
|
||||
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"`
|
||||
CaringContext *CaringAccessDescriptor `json:"caring_context,omitempty" yaml:"caring_context,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
|
||||
// Purpose is empty on every enforcement request. The readback pin sets it
|
||||
// to PurposeReadback, and it is hashed into every request digest, so an
|
||||
// advisory readback decision can never pass a PEP's §6.4.2 replay test for
|
||||
// an enforcement request (FLEX-DEC-2026-019). /v1/check and /v1/batch_check
|
||||
// refuse a request that carries it.
|
||||
Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"`
|
||||
}
|
||||
|
||||
// PurposeReadback marks an advisory application-access readback decision.
|
||||
const PurposeReadback = "readback"
|
||||
|
||||
// BatchCheckRequest evaluates one subject/action against multiple resources.
|
||||
type BatchCheckRequest struct {
|
||||
ID string `json:"id,omitempty" yaml:"id,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Resources []ResourceRef `json:"resources" yaml:"resources"`
|
||||
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
}
|
||||
|
||||
// SubjectRef is a normalized subject reference in request and decision shapes.
|
||||
type SubjectRef struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type SubjectType `json:"type,omitempty" yaml:"type,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceRef is a normalized resource reference in request and decision shapes.
|
||||
type ResourceRef struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type string `json:"type,omitempty" yaml:"type,omitempty"`
|
||||
System string `json:"system,omitempty" yaml:"system,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionEffect is the stable decision outcome vocabulary.
|
||||
type DecisionEffect string
|
||||
|
||||
const (
|
||||
DecisionEffectAllow DecisionEffect = "allow"
|
||||
DecisionEffectDeny DecisionEffect = "deny"
|
||||
DecisionEffectRedact DecisionEffect = "redact"
|
||||
DecisionEffectAuditOnly DecisionEffect = "audit_only"
|
||||
DecisionEffectNotApplicable DecisionEffect = "not_applicable"
|
||||
)
|
||||
|
||||
// DecisionEffects returns the complete effect vocabulary. Every effect is an
|
||||
// emission class in cadence.yaml; a test fails if a constant above is missing
|
||||
// here, so a new effect cannot ship unclassified (FLEX-WP-0031-T03).
|
||||
func DecisionEffects() []DecisionEffect {
|
||||
return []DecisionEffect{
|
||||
DecisionEffectAllow,
|
||||
DecisionEffectDeny,
|
||||
DecisionEffectRedact,
|
||||
DecisionEffectAuditOnly,
|
||||
DecisionEffectNotApplicable,
|
||||
}
|
||||
}
|
||||
|
||||
// Restricts reports whether an effect withholds authority. This classification
|
||||
// does not exempt an effect from required durable emission (GH-DEC-2026-024).
|
||||
func (e DecisionEffect) Restricts() bool {
|
||||
switch e {
|
||||
case DecisionEffectDeny, DecisionEffectRedact, DecisionEffectNotApplicable:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DecisionRecordContractV1 is the published decision-record contract identifier.
|
||||
const DecisionRecordContractV1 = "flex-auth.decision-record.v1"
|
||||
|
||||
// DecisionEnvelope is the stable response produced by standalone and delegated
|
||||
// evaluators. It is flex-auth's published decision-record contract (§17).
|
||||
type DecisionEnvelope struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
ContractVersion string `json:"contract_version,omitempty" yaml:"contract_version,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty" yaml:"request_id,omitempty"`
|
||||
Effect DecisionEffect `json:"effect" yaml:"effect"`
|
||||
Reason string `json:"reason,omitempty" yaml:"reason,omitempty"`
|
||||
MatchedPolicyVersion string `json:"matched_policy_version,omitempty" yaml:"matched_policy_version,omitempty"`
|
||||
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"`
|
||||
Lifetime *DecisionLifetime `json:"lifetime,omitempty" yaml:"lifetime,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"`
|
||||
Signature *EnvelopeSignature `json:"signature,omitempty" yaml:"signature,omitempty"`
|
||||
Caring *CaringDecisionMetadata `json:"caring,omitempty" yaml:"caring,omitempty"`
|
||||
}
|
||||
|
||||
// EnvelopeSignatureMode is how the responder authenticates the envelope.
|
||||
type EnvelopeSignatureMode string
|
||||
|
||||
const (
|
||||
EnvelopeSignatureSigned EnvelopeSignatureMode = "signed"
|
||||
EnvelopeSignatureUnsigned EnvelopeSignatureMode = "unsigned"
|
||||
)
|
||||
|
||||
// EnvelopeSignature is a detached signature over the canonical envelope with
|
||||
// this field omitted (FLEX-WP-0024). It is not decision material and is not
|
||||
// part of request_digest.
|
||||
type EnvelopeSignature struct {
|
||||
Mode EnvelopeSignatureMode `json:"mode" yaml:"mode"`
|
||||
Alg string `json:"alg,omitempty" yaml:"alg,omitempty"`
|
||||
Kid string `json:"kid,omitempty" yaml:"kid,omitempty"`
|
||||
Value string `json:"value,omitempty" yaml:"value,omitempty"`
|
||||
}
|
||||
|
||||
func UnsignedEnvelopeSignature() *EnvelopeSignature {
|
||||
return &EnvelopeSignature{Mode: EnvelopeSignatureUnsigned}
|
||||
}
|
||||
|
||||
// DecisionLifetimeKind identifies how an allow ends.
|
||||
type DecisionLifetimeKind string
|
||||
|
||||
const (
|
||||
DecisionLifetimeTTL DecisionLifetimeKind = "ttl"
|
||||
)
|
||||
|
||||
// DefaultAllowTTL is the engine default when a policy package omits allow_ttl.
|
||||
const DefaultAllowTTL = 15 * time.Minute
|
||||
|
||||
// ReasonAllowLifetimeUnstated is the deny reason for an allow with no stated end.
|
||||
const ReasonAllowLifetimeUnstated = "allow_lifetime_unstated"
|
||||
|
||||
// DecisionLifetime bounds an allow (§9.7.1). flex-auth has no session concept,
|
||||
// so the first honest shape is a policy-package-declared TTL.
|
||||
type DecisionLifetime struct {
|
||||
Kind DecisionLifetimeKind `json:"kind" yaml:"kind"`
|
||||
TTL string `json:"ttl,omitempty" yaml:"ttl,omitempty"`
|
||||
NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"`
|
||||
ExpiresAt string `json:"expires_at" yaml:"expires_at"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
// Purpose is "readback" on an advisory readback decision and absent on an
|
||||
// enforcement decision (FLEX-DEC-2026-019).
|
||||
Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"`
|
||||
RequestDigest string `json:"request_digest" yaml:"request_digest"`
|
||||
|
||||
// ApprovalBindingDigest is RequestDigest computed with the approval claim
|
||||
// removed from context. It exists to break a circularity: an approval's
|
||||
// pdp_digest is recorded at issue time, but the request that later carries
|
||||
// the claim inside its hashed context has a different RequestDigest by
|
||||
// construction, so a claim can never name the request that carries it.
|
||||
//
|
||||
// This digest is stable across attaching the claim, so it is the value
|
||||
// claim.binding.pdp_digest must equal (GH-DEC-2026-008). It is NOT a replay
|
||||
// identity and must not be used as one: two requests differing only in
|
||||
// which approval was presented share it, and the decision does not.
|
||||
ApprovalBindingDigest string `json:"approval_binding_digest,omitempty" yaml:"approval_binding_digest,omitempty"`
|
||||
|
||||
// SubmittedRequestDigest is RequestDigest over the request exactly as the
|
||||
// consumer sent it, before the evaluator overlays registry facts onto the
|
||||
// subject and resource.
|
||||
//
|
||||
// It exists because RequestDigest is not consumer-computable and was
|
||||
// published as though it were. The evaluator hashes the ENRICHED request —
|
||||
// subject.tenant, subject.attributes, and resource attributes the registry
|
||||
// contributed — and the registry is flex-auth's, so a consumer recomputing
|
||||
// the digest over what it sent gets a different value on every real allow.
|
||||
// secrets-engine found this on its first live request (FLEX-DEC-2026-012).
|
||||
//
|
||||
// This is the digest a PEP compares for §6.4 obligation 2's replay test.
|
||||
// Nothing is lost by hashing the pre-enrichment form: enrichment is a
|
||||
// function of the request and the registry snapshot, and
|
||||
// provenance.registry_snapshot_digest already pins the snapshot, so
|
||||
// SubmittedRequestDigest together with that digest identifies the evaluated
|
||||
// request completely.
|
||||
SubmittedRequestDigest string `json:"submitted_request_digest,omitempty" yaml:"submitted_request_digest,omitempty"`
|
||||
}
|
||||
|
||||
// ApprovalContextKey is the context key carrying an approval-engine
|
||||
// approval-claim. It is excluded from ApprovalBindingDigest and from nothing
|
||||
// else.
|
||||
const ApprovalContextKey = "approval"
|
||||
|
||||
// requestDigestMaterial is the exact tuple hashed for §6.4.2 replay. Request
|
||||
// id, policy version, and caring_context are excluded: id is correlation, the
|
||||
// version is provenance, and caring_context is an input-claim digest.
|
||||
type requestDigestMaterial struct {
|
||||
Tenant string `json:"tenant,omitempty"`
|
||||
Subject SubjectRef `json:"subject"`
|
||||
Action string `json:"action"`
|
||||
Resource ResourceRef `json:"resource"`
|
||||
Context map[string]any `json:"context,omitempty"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
}
|
||||
|
||||
// NewDecisionBinding returns a stable structured binding for the exact request
|
||||
// an evaluator consumed.
|
||||
//
|
||||
// Prefer NewDecisionBindingFor, which also records the digest of the request as
|
||||
// submitted. This form leaves SubmittedRequestDigest empty, which reads as "the
|
||||
// evaluator did not record one" rather than "the two are equal".
|
||||
func NewDecisionBinding(request CheckRequest) *DecisionBinding {
|
||||
contextCopy := make(map[string]any, len(request.Context))
|
||||
for key, value := range request.Context {
|
||||
contextCopy[key] = value
|
||||
}
|
||||
binding := &DecisionBinding{
|
||||
Tenant: request.Tenant,
|
||||
Subject: request.Subject,
|
||||
Action: request.Action,
|
||||
Resource: request.Resource,
|
||||
Context: contextCopy,
|
||||
Purpose: request.Purpose,
|
||||
RequestDigest: RequestDigest(request),
|
||||
}
|
||||
if _, carries := request.Context[ApprovalContextKey]; carries {
|
||||
binding.ApprovalBindingDigest = ApprovalBindingDigest(request)
|
||||
}
|
||||
return binding
|
||||
}
|
||||
|
||||
// NewDecisionBindingFor records the binding of the evaluated request together
|
||||
// with the digest of the request as submitted.
|
||||
//
|
||||
// The two arguments are the same request before and after the evaluator
|
||||
// overlaid registry facts. Where a request named nothing the registry knows they
|
||||
// are equal, and the field is emitted anyway: a consumer that only sees it on
|
||||
// enriched decisions would build a check that passes by absence.
|
||||
func NewDecisionBindingFor(evaluated, submitted CheckRequest) *DecisionBinding {
|
||||
binding := NewDecisionBinding(evaluated)
|
||||
binding.SubmittedRequestDigest = RequestDigest(submitted)
|
||||
return binding
|
||||
}
|
||||
|
||||
// ApprovalBindingDigest is RequestDigest over the same material with the
|
||||
// approval claim removed from context.
|
||||
//
|
||||
// When a request carries no approval claim the two are identical, which is the
|
||||
// point: an approval issued against a claim-free Check records that Check's
|
||||
// request_digest, and the later claim-bearing request reproduces the same value
|
||||
// here. Comparing it to claim.binding.pdp_digest establishes correspondence by
|
||||
// identity rather than by translating between two action vocabularies
|
||||
// (GH-DEC-2026-008).
|
||||
func ApprovalBindingDigest(request CheckRequest) string {
|
||||
if _, carries := request.Context[ApprovalContextKey]; !carries {
|
||||
return RequestDigest(request)
|
||||
}
|
||||
stripped := make(map[string]any, len(request.Context))
|
||||
for key, value := range request.Context {
|
||||
if key == ApprovalContextKey {
|
||||
continue
|
||||
}
|
||||
stripped[key] = value
|
||||
}
|
||||
if len(stripped) == 0 {
|
||||
stripped = nil
|
||||
}
|
||||
return CanonicalDigest(requestDigestMaterial{
|
||||
Tenant: request.Tenant,
|
||||
Subject: request.Subject,
|
||||
Action: request.Action,
|
||||
Resource: request.Resource,
|
||||
Context: stripped,
|
||||
Purpose: request.Purpose,
|
||||
})
|
||||
}
|
||||
|
||||
// RequestDigest is the mechanical §6.4.2 replay test: SHA-256 over canonical
|
||||
// JSON of tenant, subject, action, resource, context, and purpose. Purpose is
|
||||
// omitted when empty, so enforcement digests are unchanged by its existence.
|
||||
func RequestDigest(request CheckRequest) string {
|
||||
return CanonicalDigest(requestDigestMaterial{
|
||||
Tenant: request.Tenant,
|
||||
Subject: request.Subject,
|
||||
Action: request.Action,
|
||||
Resource: request.Resource,
|
||||
Context: request.Context,
|
||||
Purpose: request.Purpose,
|
||||
})
|
||||
}
|
||||
|
||||
// CanonicalDigest returns "sha256:" plus the hex SHA-256 of canonical JSON.
|
||||
// encoding/json sorts map keys, so two equal Go values agree.
|
||||
func CanonicalDigest(value any) string {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
sum := sha256.Sum256(nil)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// InputClaimDigests hashes the request-time claim classes the evaluator joined.
|
||||
func InputClaimDigests(request CheckRequest) map[string]string {
|
||||
digests := make(map[string]string)
|
||||
if len(request.Context) > 0 {
|
||||
digests["context"] = CanonicalDigest(request.Context)
|
||||
}
|
||||
if request.CaringContext != nil {
|
||||
digests["caring_context"] = CanonicalDigest(request.CaringContext)
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
return nil
|
||||
}
|
||||
return digests
|
||||
}
|
||||
|
||||
// DecisionCompletion carries evaluator-side inputs used to finish an envelope.
|
||||
type DecisionCompletion struct {
|
||||
AllowTTL string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// CompleteDecision stamps contract version, input-claim digests, decision time,
|
||||
// and an explicit allow lifetime. An allow with no stated end becomes a deny.
|
||||
func CompleteDecision(envelope *DecisionEnvelope, request CheckRequest, completion DecisionCompletion) {
|
||||
if envelope == nil {
|
||||
return
|
||||
}
|
||||
if envelope.ContractVersion == "" {
|
||||
envelope.ContractVersion = DecisionRecordContractV1
|
||||
}
|
||||
if envelope.Provenance.InputClaimDigests == nil {
|
||||
envelope.Provenance.InputClaimDigests = InputClaimDigests(request)
|
||||
}
|
||||
ApplyAllowLifetime(envelope, completion.AllowTTL, completion.Now)
|
||||
}
|
||||
|
||||
// ParseAllowTTL resolves a package-declared TTL. ok is false when the allow
|
||||
// would have no stated end. Invalid strings return an error so package
|
||||
// validation can reject them.
|
||||
func ParseAllowTTL(declared string) (time.Duration, error) {
|
||||
trimmed := strings.TrimSpace(declared)
|
||||
if trimmed == "" {
|
||||
return DefaultAllowTTL, nil
|
||||
}
|
||||
if strings.EqualFold(trimmed, "none") {
|
||||
return 0, nil
|
||||
}
|
||||
ttl, err := time.ParseDuration(trimmed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("allow_ttl %q is not a Go duration: %w", declared, err)
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return ttl, nil
|
||||
}
|
||||
|
||||
// ApplyAllowLifetime sets DecisionTime and, for allows, an explicit TTL. A
|
||||
// missing or zero TTL denies the allow rather than mint a standing grant.
|
||||
func ApplyAllowLifetime(envelope *DecisionEnvelope, declaredTTL string, now time.Time) {
|
||||
if envelope == nil {
|
||||
return
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
if envelope.Provenance.DecisionTime == "" {
|
||||
envelope.Provenance.DecisionTime = now.Format(time.RFC3339)
|
||||
}
|
||||
if envelope.Effect != DecisionEffectAllow {
|
||||
return
|
||||
}
|
||||
ttl, err := ParseAllowTTL(declaredTTL)
|
||||
if err != nil || ttl <= 0 {
|
||||
if envelope.Diagnostics == nil {
|
||||
envelope.Diagnostics = map[string]any{}
|
||||
}
|
||||
if envelope.Reason != "" {
|
||||
envelope.Diagnostics["unstated_allow_reason"] = envelope.Reason
|
||||
}
|
||||
envelope.Effect = DecisionEffectDeny
|
||||
envelope.Reason = ReasonAllowLifetimeUnstated
|
||||
envelope.MatchedRule = ReasonAllowLifetimeUnstated
|
||||
envelope.Lifetime = nil
|
||||
return
|
||||
}
|
||||
display := strings.TrimSpace(declaredTTL)
|
||||
if display == "" {
|
||||
display = "15m"
|
||||
}
|
||||
envelope.Lifetime = &DecisionLifetime{
|
||||
Kind: DecisionLifetimeTTL,
|
||||
TTL: display,
|
||||
NotBefore: now.Format(time.RFC3339),
|
||||
ExpiresAt: now.Add(ttl).Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Parameters map[string]any `json:"parameters,omitempty" yaml:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionProvenance captures evaluator and policy provenance.
|
||||
type DecisionProvenance struct {
|
||||
Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
|
||||
RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
|
||||
DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
|
||||
DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"`
|
||||
}
|
||||
|
||||
// CallerProvenance records how the request was authenticated to flex-auth.
|
||||
// It is not decision material: the same request from a different authenticated
|
||||
// caller must decide identically. Mode is required. Under disabled the object
|
||||
// is {"mode":"disabled"} with no principal (FLEX-DEC-2026-009).
|
||||
type CallerProvenance struct {
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
Principal string `json:"principal,omitempty" yaml:"principal,omitempty"`
|
||||
Audience string `json:"audience,omitempty" yaml:"audience,omitempty"`
|
||||
NotAfter string `json:"not_after,omitempty" yaml:"not_after,omitempty"`
|
||||
}
|
||||
|
||||
// CaringDecisionMetadata carries CARING descriptor and conformance details in
|
||||
// a decision envelope.
|
||||
type CaringDecisionMetadata struct {
|
||||
Profile string `json:"profile" yaml:"profile"`
|
||||
Descriptor *CaringAccessDescriptor `json:"descriptor,omitempty" yaml:"descriptor,omitempty"`
|
||||
RestrictionsEvaluated []Restriction `json:"restrictions_evaluated,omitempty" yaml:"restrictions_evaluated,omitempty"`
|
||||
ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"`
|
||||
DerivedCapabilities []CaringDerivedCapability `json:"derived_capabilities,omitempty" yaml:"derived_capabilities,omitempty"`
|
||||
ConformanceFindings []CaringConformanceFinding `json:"conformance_findings,omitempty" yaml:"conformance_findings,omitempty"`
|
||||
ExposureEvent *CaringExposureEvent `json:"exposure_event,omitempty" yaml:"exposure_event,omitempty"`
|
||||
}
|
||||
|
||||
// AuditEvent is the local log shape for decisions and exposure events.
|
||||
type AuditEvent struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Type string `json:"type" yaml:"type"`
|
||||
DecisionID string `json:"decision_id,omitempty" yaml:"decision_id,omitempty"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Resource ResourceRef `json:"resource,omitempty" yaml:"resource,omitempty"`
|
||||
Action string `json:"action,omitempty" yaml:"action,omitempty"`
|
||||
Effect DecisionEffect `json:"effect,omitempty" yaml:"effect,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty" yaml:"timestamp,omitempty"`
|
||||
ExposureEvent *CaringExposureEvent `json:"exposure_event,omitempty" yaml:"exposure_event,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue