2026-05-17 04:59:18 +02:00
|
|
|
package api
|
|
|
|
|
|
2026-08-23 13:18:26 +02:00
|
|
|
import (
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"encoding/json"
|
2026-09-03 23:48:45 +02:00
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
2026-08-23 13:18:26 +02:00
|
|
|
)
|
|
|
|
|
|
2026-05-17 04:59:18 +02:00
|
|
|
// 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"`
|
|
|
|
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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"`
|
2026-05-17 05:30:40 +02:00
|
|
|
Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
|
2026-05-17 04:59:18 +02:00
|
|
|
Version string `json:"version" yaml:"version"`
|
|
|
|
|
Status string `json:"status,omitempty" yaml:"status,omitempty"`
|
|
|
|
|
Package string `json:"package" yaml:"package"`
|
2026-05-17 05:30:40 +02:00
|
|
|
Actions []string `json:"actions,omitempty" yaml:"actions,omitempty"`
|
|
|
|
|
Owner string `json:"owner,omitempty" yaml:"owner,omitempty"`
|
|
|
|
|
Fixtures []string `json:"fixtures,omitempty" yaml:"fixtures,omitempty"`
|
2026-05-17 04:59:18 +02:00
|
|
|
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"`
|
2026-09-03 23:48:45 +02:00
|
|
|
// 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"`
|
2026-05-17 04:59:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CaringPolicyMetadata declares the CARING envelope a policy governs.
|
|
|
|
|
type CaringPolicyMetadata struct {
|
|
|
|
|
Profile string `json:"profile" yaml:"profile"`
|
2026-05-17 05:30:40 +02:00
|
|
|
Enforce bool `json:"enforce,omitempty" yaml:"enforce,omitempty"`
|
2026-05-17 04:59:18 +02:00
|
|
|
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"`
|
2026-06-23 21:17:42 +02:00
|
|
|
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
2026-05-17 04:59:18 +02:00
|
|
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// BatchCheckRequest evaluates one subject/action against multiple resources.
|
|
|
|
|
type BatchCheckRequest struct {
|
|
|
|
|
ID string `json:"id,omitempty" yaml:"id,omitempty"`
|
2026-06-23 21:17:42 +02:00
|
|
|
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
|
2026-05-17 04:59:18 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-03 23:48:45 +02:00
|
|
|
// DecisionRecordContractV1 is the published decision-record contract identifier.
|
|
|
|
|
const DecisionRecordContractV1 = "flex-auth.decision-record.v1"
|
|
|
|
|
|
2026-05-17 04:59:18 +02:00
|
|
|
// DecisionEnvelope is the stable response produced by standalone and delegated
|
2026-09-03 23:48:45 +02:00
|
|
|
// evaluators. It is flex-auth's published decision-record contract (§17).
|
2026-05-17 04:59:18 +02:00
|
|
|
type DecisionEnvelope struct {
|
|
|
|
|
ID string `json:"id" yaml:"id"`
|
2026-09-03 23:48:45 +02:00
|
|
|
ContractVersion string `json:"contract_version,omitempty" yaml:"contract_version,omitempty"`
|
2026-05-17 04:59:18 +02:00
|
|
|
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"`
|
2026-08-23 13:18:26 +02:00
|
|
|
Binding *DecisionBinding `json:"binding,omitempty" yaml:"binding,omitempty"`
|
2026-09-03 23:48:45 +02:00
|
|
|
Lifetime *DecisionLifetime `json:"lifetime,omitempty" yaml:"lifetime,omitempty"`
|
2026-05-17 04:59:18 +02:00
|
|
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 23:48:45 +02:00
|
|
|
// 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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 13:18:26 +02:00
|
|
|
// 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"`
|
Publish approval_binding_digest: a claim cannot name the request carrying it
secrets-engine confirmed T03, and re-verifying against the regenerated
destroy fixture found something neither repository can fix alone: a
pdp_digest recorded at issue time can never equal the request_digest of a
request that carries the claim in its context, because the claim is part
of the context that is hashed. Embedding the claim changes the very
digest the claim would need to name.
Not fixture staleness. It holds for every dual-control request whose
claim travels in context -- the shape GH-DEC-2026-008 had just ruled
mandatory. Left unresolved that ruling was unimplementable for exactly
the case it was written for, and destroy would have been permanently
un-allowable in production, failing closed forever on a check that could
never pass.
flex-auth owns the canonical request digest, so the fix is ours.
binding.approval_binding_digest is the same material with
context.approval removed, emitted only when a claim was carried. An
approval issued against a claim-free Check records that Check's
request_digest; the claim-bearing request reproduces it here.
DELIBERATELY ADDITIVE, and the reason matters. The tempting fix is to
drop context.approval from request_digest entirely. That is wrong:
request_digest is the replay identity, and two requests differing only in
which approval was presented must not share one, because their decisions
differ -- one allows, the other denies dual_control_required. Collapsing
them would let an allow obtained with a valid claim be replayed against a
request carrying none. So request_digest still covers the claim and still
moves; approval_binding_digest deliberately does not, and is documented
as not a replay identity. The tests assert the two functions DISAGREE on
a claim-bearing request, which is approval-engine's formulation of how to
defend a distinction that looks like duplication.
The fixture now demonstrates the property rather than asserting it: its
claim's pdp_digest equals the envelope's approval_binding_digest with
pdp_path true, and changing the claim's contents moved request_digest
while leaving approval_binding_digest untouched. Two files a consumer can
diff.
Also picked up approval-engine's new required binding.pdp_path via the
cross-repo schema test added yesterday -- which is the test doing exactly
what it was built for, one day later.
T03 is done. secrets-engine's own digest-material defect, which our two
real envelopes caught, is recorded in the workplan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTbVXpEiXA7mNJVpDnEPcB
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 412054@bnt-lap001
Assistant-Session: 3968fae1-8d59-4209-9bd6-c22594b8ab19
2026-09-06 14:52:33 +02:00
|
|
|
|
|
|
|
|
// 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"`
|
fix(decision): registry facts win over caller-supplied attributes
secrets-engine's first live request rejected our allow: binding.
request_digest is computed over material they never sent, because we
enrich subject and resource from the registry before hashing. Answering
that meant reading the enrichment path, which had a worse defect in it.
Enrichment was additive-if-absent — addAttribute wrote a registry value
only where the request had no value for that key. So where a caller
supplied a key, the caller's value won and the registry's never applied.
Every registry ceiling and allowlist was advisory. Verified against the
shipped ops-warden package, each one added key on an otherwise-denied
request:
max_ttl_hours: 99 registry says 8 -> allowed a 12h certificate
allowed_principals registry allowlist -> disallowed_principal bypassed
allowed_subjects registry allowlist -> unknown_subject bypassed
The third is the one to read twice: a subject the registry does not know
authorized itself by naming itself in the allowlist it was being checked
against.
Not remotely reachable today — the PEP builds the CheckRequest,
ops-warden sends no resource.attributes, and enforce admits one identity.
It is a defence-in-depth failure: any path that lets attacker-influenced
data into a CheckRequest field became a full policy bypass rather than a
bounded input problem. Callers sending resource.attributes is not
hypothetical; secrets-engine does it on every request.
Registry facts now win, and diagnostics.registry_overrode names every
displaced key, because a registry that silently discards a contradicting
claim hides that a caller asserted authority it did not have.
subject.type is carved out, and the reason is a finding of its own.
Making the registry win there denied every secrets-engine allow: the
registry's type is CARING vocabulary (Human, Agent, Automation, Service)
and the request's is the protected system's actor vocabulary (service,
adm, agt, atm). Two fields sharing a name; substituting one for the other
is translation rather than identity, which GH-DEC-2026-008 ruled against.
Note what surfaced it — the registry's type had been dead data since the
field existed, because the caller's value always won.
Also publishes binding.submitted_request_digest, over the request exactly
as sent. request_digest was published as the consumer replay test and
cannot be one. Nothing is lost hashing the pre-enrichment form:
enrichment is a function of the request and the snapshot, and
registry_snapshot_digest already pins the snapshot.
Existing pins do not move. All three replay fixtures' request_digest and
approval_binding_digest values are byte-identical — those requests
contradict no registry fact. A field to add, not a value to correct.
Regression tests verified failing against the old behaviour before being
kept. FLEX-DEC-2026-012; FLEX-WP-0025 carries the residual, that a policy
still cannot tell a fact from an assertion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aQMM1dPXaPiXVn6DwwtLd
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715613@bnt-lap001
Assistant-Session: fabd95c1-4c9e-4080-8849-8707ae025f80
2026-09-07 13:43:33 +02:00
|
|
|
|
|
|
|
|
// 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"`
|
2026-08-23 13:18:26 +02:00
|
|
|
}
|
|
|
|
|
|
Publish approval_binding_digest: a claim cannot name the request carrying it
secrets-engine confirmed T03, and re-verifying against the regenerated
destroy fixture found something neither repository can fix alone: a
pdp_digest recorded at issue time can never equal the request_digest of a
request that carries the claim in its context, because the claim is part
of the context that is hashed. Embedding the claim changes the very
digest the claim would need to name.
Not fixture staleness. It holds for every dual-control request whose
claim travels in context -- the shape GH-DEC-2026-008 had just ruled
mandatory. Left unresolved that ruling was unimplementable for exactly
the case it was written for, and destroy would have been permanently
un-allowable in production, failing closed forever on a check that could
never pass.
flex-auth owns the canonical request digest, so the fix is ours.
binding.approval_binding_digest is the same material with
context.approval removed, emitted only when a claim was carried. An
approval issued against a claim-free Check records that Check's
request_digest; the claim-bearing request reproduces it here.
DELIBERATELY ADDITIVE, and the reason matters. The tempting fix is to
drop context.approval from request_digest entirely. That is wrong:
request_digest is the replay identity, and two requests differing only in
which approval was presented must not share one, because their decisions
differ -- one allows, the other denies dual_control_required. Collapsing
them would let an allow obtained with a valid claim be replayed against a
request carrying none. So request_digest still covers the claim and still
moves; approval_binding_digest deliberately does not, and is documented
as not a replay identity. The tests assert the two functions DISAGREE on
a claim-bearing request, which is approval-engine's formulation of how to
defend a distinction that looks like duplication.
The fixture now demonstrates the property rather than asserting it: its
claim's pdp_digest equals the envelope's approval_binding_digest with
pdp_path true, and changing the claim's contents moved request_digest
while leaving approval_binding_digest untouched. Two files a consumer can
diff.
Also picked up approval-engine's new required binding.pdp_path via the
cross-repo schema test added yesterday -- which is the test doing exactly
what it was built for, one day later.
T03 is done. secrets-engine's own digest-material defect, which our two
real envelopes caught, is recorded in the workplan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTbVXpEiXA7mNJVpDnEPcB
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 412054@bnt-lap001
Assistant-Session: 3968fae1-8d59-4209-9bd6-c22594b8ab19
2026-09-06 14:52:33 +02:00
|
|
|
// ApprovalContextKey is the context key carrying an approval-engine
|
|
|
|
|
// approval-claim. It is excluded from ApprovalBindingDigest and from nothing
|
|
|
|
|
// else.
|
|
|
|
|
const ApprovalContextKey = "approval"
|
|
|
|
|
|
2026-09-03 23:48:45 +02:00
|
|
|
// 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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 13:18:26 +02:00
|
|
|
// NewDecisionBinding returns a stable structured binding for the exact request
|
|
|
|
|
// an evaluator consumed.
|
fix(decision): registry facts win over caller-supplied attributes
secrets-engine's first live request rejected our allow: binding.
request_digest is computed over material they never sent, because we
enrich subject and resource from the registry before hashing. Answering
that meant reading the enrichment path, which had a worse defect in it.
Enrichment was additive-if-absent — addAttribute wrote a registry value
only where the request had no value for that key. So where a caller
supplied a key, the caller's value won and the registry's never applied.
Every registry ceiling and allowlist was advisory. Verified against the
shipped ops-warden package, each one added key on an otherwise-denied
request:
max_ttl_hours: 99 registry says 8 -> allowed a 12h certificate
allowed_principals registry allowlist -> disallowed_principal bypassed
allowed_subjects registry allowlist -> unknown_subject bypassed
The third is the one to read twice: a subject the registry does not know
authorized itself by naming itself in the allowlist it was being checked
against.
Not remotely reachable today — the PEP builds the CheckRequest,
ops-warden sends no resource.attributes, and enforce admits one identity.
It is a defence-in-depth failure: any path that lets attacker-influenced
data into a CheckRequest field became a full policy bypass rather than a
bounded input problem. Callers sending resource.attributes is not
hypothetical; secrets-engine does it on every request.
Registry facts now win, and diagnostics.registry_overrode names every
displaced key, because a registry that silently discards a contradicting
claim hides that a caller asserted authority it did not have.
subject.type is carved out, and the reason is a finding of its own.
Making the registry win there denied every secrets-engine allow: the
registry's type is CARING vocabulary (Human, Agent, Automation, Service)
and the request's is the protected system's actor vocabulary (service,
adm, agt, atm). Two fields sharing a name; substituting one for the other
is translation rather than identity, which GH-DEC-2026-008 ruled against.
Note what surfaced it — the registry's type had been dead data since the
field existed, because the caller's value always won.
Also publishes binding.submitted_request_digest, over the request exactly
as sent. request_digest was published as the consumer replay test and
cannot be one. Nothing is lost hashing the pre-enrichment form:
enrichment is a function of the request and the snapshot, and
registry_snapshot_digest already pins the snapshot.
Existing pins do not move. All three replay fixtures' request_digest and
approval_binding_digest values are byte-identical — those requests
contradict no registry fact. A field to add, not a value to correct.
Regression tests verified failing against the old behaviour before being
kept. FLEX-DEC-2026-012; FLEX-WP-0025 carries the residual, that a policy
still cannot tell a fact from an assertion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aQMM1dPXaPiXVn6DwwtLd
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715613@bnt-lap001
Assistant-Session: fabd95c1-4c9e-4080-8849-8707ae025f80
2026-09-07 13:43:33 +02:00
|
|
|
//
|
|
|
|
|
// 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".
|
2026-08-23 13:18:26 +02:00
|
|
|
func NewDecisionBinding(request CheckRequest) *DecisionBinding {
|
|
|
|
|
contextCopy := make(map[string]any, len(request.Context))
|
|
|
|
|
for key, value := range request.Context {
|
|
|
|
|
contextCopy[key] = value
|
|
|
|
|
}
|
Publish approval_binding_digest: a claim cannot name the request carrying it
secrets-engine confirmed T03, and re-verifying against the regenerated
destroy fixture found something neither repository can fix alone: a
pdp_digest recorded at issue time can never equal the request_digest of a
request that carries the claim in its context, because the claim is part
of the context that is hashed. Embedding the claim changes the very
digest the claim would need to name.
Not fixture staleness. It holds for every dual-control request whose
claim travels in context -- the shape GH-DEC-2026-008 had just ruled
mandatory. Left unresolved that ruling was unimplementable for exactly
the case it was written for, and destroy would have been permanently
un-allowable in production, failing closed forever on a check that could
never pass.
flex-auth owns the canonical request digest, so the fix is ours.
binding.approval_binding_digest is the same material with
context.approval removed, emitted only when a claim was carried. An
approval issued against a claim-free Check records that Check's
request_digest; the claim-bearing request reproduces it here.
DELIBERATELY ADDITIVE, and the reason matters. The tempting fix is to
drop context.approval from request_digest entirely. That is wrong:
request_digest is the replay identity, and two requests differing only in
which approval was presented must not share one, because their decisions
differ -- one allows, the other denies dual_control_required. Collapsing
them would let an allow obtained with a valid claim be replayed against a
request carrying none. So request_digest still covers the claim and still
moves; approval_binding_digest deliberately does not, and is documented
as not a replay identity. The tests assert the two functions DISAGREE on
a claim-bearing request, which is approval-engine's formulation of how to
defend a distinction that looks like duplication.
The fixture now demonstrates the property rather than asserting it: its
claim's pdp_digest equals the envelope's approval_binding_digest with
pdp_path true, and changing the claim's contents moved request_digest
while leaving approval_binding_digest untouched. Two files a consumer can
diff.
Also picked up approval-engine's new required binding.pdp_path via the
cross-repo schema test added yesterday -- which is the test doing exactly
what it was built for, one day later.
T03 is done. secrets-engine's own digest-material defect, which our two
real envelopes caught, is recorded in the workplan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTbVXpEiXA7mNJVpDnEPcB
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 412054@bnt-lap001
Assistant-Session: 3968fae1-8d59-4209-9bd6-c22594b8ab19
2026-09-06 14:52:33 +02:00
|
|
|
binding := &DecisionBinding{
|
2026-08-23 13:18:26 +02:00
|
|
|
Tenant: request.Tenant,
|
|
|
|
|
Subject: request.Subject,
|
|
|
|
|
Action: request.Action,
|
|
|
|
|
Resource: request.Resource,
|
|
|
|
|
Context: contextCopy,
|
2026-09-03 23:48:45 +02:00
|
|
|
RequestDigest: RequestDigest(request),
|
|
|
|
|
}
|
Publish approval_binding_digest: a claim cannot name the request carrying it
secrets-engine confirmed T03, and re-verifying against the regenerated
destroy fixture found something neither repository can fix alone: a
pdp_digest recorded at issue time can never equal the request_digest of a
request that carries the claim in its context, because the claim is part
of the context that is hashed. Embedding the claim changes the very
digest the claim would need to name.
Not fixture staleness. It holds for every dual-control request whose
claim travels in context -- the shape GH-DEC-2026-008 had just ruled
mandatory. Left unresolved that ruling was unimplementable for exactly
the case it was written for, and destroy would have been permanently
un-allowable in production, failing closed forever on a check that could
never pass.
flex-auth owns the canonical request digest, so the fix is ours.
binding.approval_binding_digest is the same material with
context.approval removed, emitted only when a claim was carried. An
approval issued against a claim-free Check records that Check's
request_digest; the claim-bearing request reproduces it here.
DELIBERATELY ADDITIVE, and the reason matters. The tempting fix is to
drop context.approval from request_digest entirely. That is wrong:
request_digest is the replay identity, and two requests differing only in
which approval was presented must not share one, because their decisions
differ -- one allows, the other denies dual_control_required. Collapsing
them would let an allow obtained with a valid claim be replayed against a
request carrying none. So request_digest still covers the claim and still
moves; approval_binding_digest deliberately does not, and is documented
as not a replay identity. The tests assert the two functions DISAGREE on
a claim-bearing request, which is approval-engine's formulation of how to
defend a distinction that looks like duplication.
The fixture now demonstrates the property rather than asserting it: its
claim's pdp_digest equals the envelope's approval_binding_digest with
pdp_path true, and changing the claim's contents moved request_digest
while leaving approval_binding_digest untouched. Two files a consumer can
diff.
Also picked up approval-engine's new required binding.pdp_path via the
cross-repo schema test added yesterday -- which is the test doing exactly
what it was built for, one day later.
T03 is done. secrets-engine's own digest-material defect, which our two
real envelopes caught, is recorded in the workplan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTbVXpEiXA7mNJVpDnEPcB
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 412054@bnt-lap001
Assistant-Session: 3968fae1-8d59-4209-9bd6-c22594b8ab19
2026-09-06 14:52:33 +02:00
|
|
|
if _, carries := request.Context[ApprovalContextKey]; carries {
|
|
|
|
|
binding.ApprovalBindingDigest = ApprovalBindingDigest(request)
|
|
|
|
|
}
|
|
|
|
|
return binding
|
|
|
|
|
}
|
|
|
|
|
|
fix(decision): registry facts win over caller-supplied attributes
secrets-engine's first live request rejected our allow: binding.
request_digest is computed over material they never sent, because we
enrich subject and resource from the registry before hashing. Answering
that meant reading the enrichment path, which had a worse defect in it.
Enrichment was additive-if-absent — addAttribute wrote a registry value
only where the request had no value for that key. So where a caller
supplied a key, the caller's value won and the registry's never applied.
Every registry ceiling and allowlist was advisory. Verified against the
shipped ops-warden package, each one added key on an otherwise-denied
request:
max_ttl_hours: 99 registry says 8 -> allowed a 12h certificate
allowed_principals registry allowlist -> disallowed_principal bypassed
allowed_subjects registry allowlist -> unknown_subject bypassed
The third is the one to read twice: a subject the registry does not know
authorized itself by naming itself in the allowlist it was being checked
against.
Not remotely reachable today — the PEP builds the CheckRequest,
ops-warden sends no resource.attributes, and enforce admits one identity.
It is a defence-in-depth failure: any path that lets attacker-influenced
data into a CheckRequest field became a full policy bypass rather than a
bounded input problem. Callers sending resource.attributes is not
hypothetical; secrets-engine does it on every request.
Registry facts now win, and diagnostics.registry_overrode names every
displaced key, because a registry that silently discards a contradicting
claim hides that a caller asserted authority it did not have.
subject.type is carved out, and the reason is a finding of its own.
Making the registry win there denied every secrets-engine allow: the
registry's type is CARING vocabulary (Human, Agent, Automation, Service)
and the request's is the protected system's actor vocabulary (service,
adm, agt, atm). Two fields sharing a name; substituting one for the other
is translation rather than identity, which GH-DEC-2026-008 ruled against.
Note what surfaced it — the registry's type had been dead data since the
field existed, because the caller's value always won.
Also publishes binding.submitted_request_digest, over the request exactly
as sent. request_digest was published as the consumer replay test and
cannot be one. Nothing is lost hashing the pre-enrichment form:
enrichment is a function of the request and the snapshot, and
registry_snapshot_digest already pins the snapshot.
Existing pins do not move. All three replay fixtures' request_digest and
approval_binding_digest values are byte-identical — those requests
contradict no registry fact. A field to add, not a value to correct.
Regression tests verified failing against the old behaviour before being
kept. FLEX-DEC-2026-012; FLEX-WP-0025 carries the residual, that a policy
still cannot tell a fact from an assertion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aQMM1dPXaPiXVn6DwwtLd
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715613@bnt-lap001
Assistant-Session: fabd95c1-4c9e-4080-8849-8707ae025f80
2026-09-07 13:43:33 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
Publish approval_binding_digest: a claim cannot name the request carrying it
secrets-engine confirmed T03, and re-verifying against the regenerated
destroy fixture found something neither repository can fix alone: a
pdp_digest recorded at issue time can never equal the request_digest of a
request that carries the claim in its context, because the claim is part
of the context that is hashed. Embedding the claim changes the very
digest the claim would need to name.
Not fixture staleness. It holds for every dual-control request whose
claim travels in context -- the shape GH-DEC-2026-008 had just ruled
mandatory. Left unresolved that ruling was unimplementable for exactly
the case it was written for, and destroy would have been permanently
un-allowable in production, failing closed forever on a check that could
never pass.
flex-auth owns the canonical request digest, so the fix is ours.
binding.approval_binding_digest is the same material with
context.approval removed, emitted only when a claim was carried. An
approval issued against a claim-free Check records that Check's
request_digest; the claim-bearing request reproduces it here.
DELIBERATELY ADDITIVE, and the reason matters. The tempting fix is to
drop context.approval from request_digest entirely. That is wrong:
request_digest is the replay identity, and two requests differing only in
which approval was presented must not share one, because their decisions
differ -- one allows, the other denies dual_control_required. Collapsing
them would let an allow obtained with a valid claim be replayed against a
request carrying none. So request_digest still covers the claim and still
moves; approval_binding_digest deliberately does not, and is documented
as not a replay identity. The tests assert the two functions DISAGREE on
a claim-bearing request, which is approval-engine's formulation of how to
defend a distinction that looks like duplication.
The fixture now demonstrates the property rather than asserting it: its
claim's pdp_digest equals the envelope's approval_binding_digest with
pdp_path true, and changing the claim's contents moved request_digest
while leaving approval_binding_digest untouched. Two files a consumer can
diff.
Also picked up approval-engine's new required binding.pdp_path via the
cross-repo schema test added yesterday -- which is the test doing exactly
what it was built for, one day later.
T03 is done. secrets-engine's own digest-material defect, which our two
real envelopes caught, is recorded in the workplan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTbVXpEiXA7mNJVpDnEPcB
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 412054@bnt-lap001
Assistant-Session: 3968fae1-8d59-4209-9bd6-c22594b8ab19
2026-09-06 14:52:33 +02:00
|
|
|
// 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,
|
|
|
|
|
})
|
2026-09-03 23:48:45 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RequestDigest is the mechanical §6.4.2 replay test: SHA-256 over canonical
|
|
|
|
|
// JSON of tenant, subject, action, resource, and context.
|
|
|
|
|
func RequestDigest(request CheckRequest) string {
|
|
|
|
|
return CanonicalDigest(requestDigestMaterial{
|
|
|
|
|
Tenant: request.Tenant,
|
|
|
|
|
Subject: request.Subject,
|
|
|
|
|
Action: request.Action,
|
|
|
|
|
Resource: request.Resource,
|
|
|
|
|
Context: request.Context,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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),
|
2026-08-23 13:18:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 04:59:18 +02:00
|
|
|
// 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 {
|
2026-09-03 23:48:45 +02:00
|
|
|
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"`
|
2026-05-17 04:59:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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"`
|
|
|
|
|
}
|