Add proposed task execution policy and initiating identity contract
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
parent
1d3a8727f5
commit
24c1f56f00
8 changed files with 658 additions and 0 deletions
86
docs/reviews/2026-10-11-task-execution-provider-source.md
Normal file
86
docs/reviews/2026-10-11-task-execution-provider-source.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Access owner source contract and readiness receipt
|
||||
|
||||
reviewer: access-engine owner (agent access_task_execution_policy, Codex)
|
||||
disposition: bounded source contract acknowledged; executable readiness refused
|
||||
work_item: FLEX-WP-ADHOC-2026-10-11-T04
|
||||
|
||||
The completed own source-preparation anchor was persisted before this receipt:
|
||||
Access commit 1d3a8727f50d4e855e5ddda9f8746d0f2dabbc12, exact
|
||||
workplans/ADHOC-2026-10-11.md SHA256
|
||||
4df32aafda0339a073bc2a512eef8d0db6686617ce6027ed554b3f5c68e4f2c6.
|
||||
All eight staged hashes were rechecked against immutable source manifest
|
||||
/tmp/access-execute-ccr-stage/manifest.json SHA256
|
||||
e7a36575c1e521613cc6092371058833d7f43b6739c3cee5b0929cb90c708d88.
|
||||
The source maker and this owner-role reviewer are the same agent: this receipt
|
||||
is not an independent code review or foreign issuer/provider acceptance.
|
||||
|
||||
## Bounded source agreement
|
||||
|
||||
Access INTENT Responsibility Boundary reserves policy decisions to the sole PDP,
|
||||
authentication to the identity layer, and protected effects to the consumer.
|
||||
The separate proposed secrets-engine.task-execution v1 package/action execute-ccr
|
||||
preserves that boundary: it neither overwrites lifecycle v2 nor translates apply
|
||||
into scoped task execution. Its literal owner_admission=false keeps the entire
|
||||
package closed today, including requests asserting verified, human_control or
|
||||
owner_admission. No consumer boolean can enable it.
|
||||
|
||||
The actual Secrets immutable descriptor and existing canonical full/approval-free
|
||||
digest joins are the appropriate input for exact operation/CCR/grant/catalogue,
|
||||
source, ordered target/body plan, expected states, budget and deadline binding.
|
||||
The candidate policy models one two-object GET/PUT/GET plan, not arbitrary
|
||||
apply/provision/exec. Independent Platform source/body compiler and native ACL
|
||||
scope remain required; shape/hash matching never establishes owner-source trust.
|
||||
|
||||
The source-only cryptographic verifier reuses existing KeyCape RS256 human
|
||||
access-token claim fields out of band. It uses owner-selected issuer/audience/
|
||||
public-key pins and trusted server time; rejects service subjects, foreign or
|
||||
registration-only tenant provenance, missing/stale MFA, expiry/future activation,
|
||||
wrong issuer/audience/key/signature and ambiguous JSON. It returns opaque identity
|
||||
only. It is not wired into HTTP evaluation, issues no key/token, selects no real
|
||||
client, accepts no native issuer and makes no authorization decision.
|
||||
|
||||
## Readiness refusal and owner dependencies
|
||||
|
||||
No authenticated initiating-human channel, admitted User/Tenant PIP retrieval,
|
||||
transport-to-human representation or server projection exists in this patch.
|
||||
The proposed task_execution_projection is an internal integration sketch, not an
|
||||
accepted public identity claim. Raw human JWTs must not enter CheckRequest,
|
||||
context, audit/decision records or receipts. Server implementation must reject or
|
||||
discard caller projection fields, independently verify KeyCape identity, acquire
|
||||
User membership/canonical-user facts and Tenant lifecycle facts through their
|
||||
actual authenticated interfaces, and impose the earliest freshness/lifetime
|
||||
ceiling. Service TokenReview proves transport only. Approval's human approver is
|
||||
not automatically the initiating human; a registration tenant is not membership.
|
||||
|
||||
This receipt does not accept anything on KeyCape, User Engine, Tenant Engine,
|
||||
Approval Engine, Platform or Secrets Engine's behalf. Actual client/audience,
|
||||
issuer/key admission, PIP transport and freshness contracts, and representation
|
||||
must receive their owners' acceptance and integration evidence. Removal of the
|
||||
hard refusal requires a separately reviewed versioned source successor, never
|
||||
an unsigned flag, helm value or literal flip in a runtime test.
|
||||
|
||||
Served approval claim validation and exact issue/full/submitted digest joins,
|
||||
trusted consume confirmation, owner compiler, durable journal and task grant/
|
||||
cleanup mediation remain required independently. Existing SECRETS-WP-0013 and
|
||||
RPF-WP-0035-T12 drive integration; no clone driver or operational closure is
|
||||
introduced. Native lifecycle adoption and standing service authority stay false.
|
||||
|
||||
## Actual source verification
|
||||
|
||||
Maker command in isolated staged Access source:
|
||||
GOCACHE=/tmp/access-taskexecution-go-cache go test -json
|
||||
./internal/taskexecution ./internal/policy ./pkg/api.
|
||||
Terminal exit zero: 118 passing Go test events, including 24 new parent/subtest
|
||||
events, zero failures. Log /tmp/access-execute-ccr-stage/test-results.jsonl.
|
||||
Actual Secrets Python ExecutionContract.parse and canonical request/approval-free
|
||||
digest functions produce the same complete synthetic descriptor and digests as
|
||||
Go. OPA candidate allow and mutation denials use an isolated in-memory package
|
||||
with the hard false literal replaced for the test alone. The actual staged
|
||||
package remains false and denies all requests. These synthetic results prove
|
||||
source semantics/interoperability, not provider facts or an available runtime
|
||||
allow path. The cross-repo generator currently relies on the estate's Secrets
|
||||
virtualenv, and is not claimed to be a portable standalone fixture.
|
||||
|
||||
No native call, credential retrieval, package activation, foreign owner checkout
|
||||
write or privileged-action trial occurred. Exact source publication and independent
|
||||
code verification remain with the coordinator before adoption.
|
||||
97
docs/task-execution-initiating-provider-contract.md
Normal file
97
docs/task-execution-initiating-provider-contract.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Proposed Secrets exact execution policy and initiating-provider boundary
|
||||
|
||||
Prepared for Secrets SECRETS-WP-0013-T01 and Platform RPF-WP-0035-T12.
|
||||
This is a proposed Access source contract. It adopts no new identity protocol,
|
||||
issuer/client/key, runtime provider, source standard or native entitlement.
|
||||
|
||||
## Why the separate policy exists
|
||||
|
||||
Lifecycle v2 deliberately serves twelve generic operations. Existing apply
|
||||
ignores execution_contract and its service subject cannot prove which human
|
||||
initiated a task. The new package secrets-engine.task-execution v1 recognizes
|
||||
only execute-ccr and is separate from secrets-engine.catalog-lane.lifecycle v2.
|
||||
The entire execution descriptor, including operation/CCR/grant/catalogue/source,
|
||||
ordered two-object read/write/readback plan, budget and deadline, participates in
|
||||
existing canonical request and approval-free digest joins. No translation to
|
||||
apply and no service administrative grant occurs.
|
||||
|
||||
The shipped source package is deliberately closed: owner_admission is a literal
|
||||
false. An unsigned verified, owner_admission or human_control request flag cannot
|
||||
open it. Synthetic future-provider tests replace that literal in an isolated
|
||||
in-memory package only; those allows are not runtime admission or native evidence.
|
||||
This is an enforced refusal until the identity boundary exists, not a switch to
|
||||
turn on once a human approves a proposal.
|
||||
|
||||
## Reuse the actual providers
|
||||
|
||||
* KeyCape owns authentication/MFA and RS256 access-token issuance. Reuse
|
||||
docs/approval-engine-auth-contract.md and docs/tenant-claim-contract.md from
|
||||
KeyCape: exact issuer/audience/subject/principal_type/tenant/tenant_source,
|
||||
expiry and assurance level/mfa/source/at. internal/taskexecution's proposed
|
||||
source verifier consumes the token out of band using locally owner-pinned RSA
|
||||
keys, trusted server time and no JWT-selected discovery URL. It returns an
|
||||
opaque Initiator, never a token or an authorization decision. No real issuer,
|
||||
key or client is installed in the source. Human initiating audience/client
|
||||
admission is still missing; an approval-engine access audience cannot silently
|
||||
be reused for task execution.
|
||||
* Access's existing callerauth TokenReview choke point authenticates the service
|
||||
transport and binds secrets-engine to its exact Kubernetes principal. It does
|
||||
not authenticate the request's subject human or prove representation. Reuse
|
||||
that authenticated server identity, never caller transport_principal text.
|
||||
* User Engine owns canonical user and active tenant account/membership facts;
|
||||
Tenant Engine owns authoritative tenant existence/lifecycle. Their live,
|
||||
authenticated, freshness-bounded read interfaces must be integrated by Access
|
||||
(PIP consumption), with unavailable/stale facts reducing authority. No new
|
||||
parallel identity engine or caller-authored membership provider is introduced.
|
||||
* Approval Engine supplies the served approval claim and authenticated consumption
|
||||
facts. Its human approver can differ from the initiating human: a successful
|
||||
approval is not initiating identity. Existing claim/PDP canonical correspondence
|
||||
and later independent consume confirmation remain mandatory.
|
||||
|
||||
KeyCape explicitly states registration-supplied tenant is store isolation, not
|
||||
human membership. The source verifier therefore requires directory provenance,
|
||||
exact tenant:platform, verified MFA within 300 seconds and a currently valid
|
||||
RS256 token. This strict proposed ceiling currently refuses registration-only
|
||||
humans; changing that requirement requires owner-reviewed doctrine, not relabeling
|
||||
registration as directory. Cryptographic verification alone still grants nothing:
|
||||
User/Tenant membership, representation and exact operation are separate facts.
|
||||
|
||||
## Required server injection and activation proof
|
||||
|
||||
The task_execution_projection shape in the candidate policy is a proposed
|
||||
internal server representation, not an accepted public claim format. Access
|
||||
must acquire initiator token on a separate authenticated channel, verify it,
|
||||
resolve canonical user/membership/tenant using the owning PIPs, correlate it with
|
||||
the authenticated transport and full exact operation, and inject a bounded
|
||||
projection. Raw human JWTs must never enter CheckRequest context or decision/audit
|
||||
records. An HTTP-supplied projection must be rejected or discarded before any
|
||||
trusted projection is injected, including nested/duplicate-field collisions.
|
||||
|
||||
Projection expiry must be the earliest identity/session expiry, provider
|
||||
freshness deadline, approved operation deadline and PDP authority ceiling.
|
||||
Fresh PIP retrieval/refusal and revocation visibility must be recorded; cached
|
||||
membership alone cannot silently extend a KeyCape session. Package activation
|
||||
requires an actual authenticated KeyCape origin/client, admitted User/Tenant read
|
||||
routes/identities, server-controlled provenance and exact request/claim binding.
|
||||
No raw JWT verifier endpoint, projection injection, transport-to-human binding,
|
||||
PIP resolver, new server route or native package deployment is implemented here.
|
||||
Only a reviewed versioned source successor may remove the hard refusal after
|
||||
these gates have executable counterexamples. There is no unsigned enable flag.
|
||||
|
||||
## Tested scope and unfinished integration
|
||||
|
||||
Source tests verify RSA signatures and wrong issuer/audience/principal/tenant,
|
||||
registration provenance, missing/stale MFA, expired/future token and absent key
|
||||
admission. Strict nested duplicate/trailing JSON is refused. Python uses the
|
||||
actual Secrets ExecutionContract parser and canonical request/approval digest
|
||||
functions; Go and OPA evaluate the same complete synthetic two-object descriptor.
|
||||
Synthetic future-projection allow and mutation denials prove candidate semantics;
|
||||
current package denies regardless of asserted flags. They do not prove providers.
|
||||
|
||||
The candidate predicate is not a full independent typed owner compiler: source
|
||||
and body provenance, exact estate origin/CCR readiness, bounded parser admission,
|
||||
platform plan compilation and immutable body mediation remain Platform/Secrets
|
||||
requirements. Nor does it independently validate served approval claims, consume,
|
||||
run journal admission, dispatch or issue/revoke native grants. Existing journal,
|
||||
confirmation and descriptor work must be integrated before this can perform one
|
||||
metadata action. No existing operational task closes from this source package.
|
||||
16
examples/secrets-engine-task-execution/policy_fixtures.yaml
Normal file
16
examples/secrets-engine-task-execution/policy_fixtures.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
- id: unsigned-provider-refused
|
||||
request:
|
||||
tenant: tenant:platform
|
||||
subject: {id: secrets-engine, type: service}
|
||||
action: execute-ccr
|
||||
resource: {id: catalog:test, type: secret-catalog-lane, system: secrets-engine}
|
||||
context: {human_control: true, verified: true, owner_admission: true}
|
||||
expect: {effect: deny, reason: initiating_provider_unadmitted}
|
||||
- id: legacy-action-cannot-borrow-task-policy
|
||||
request:
|
||||
tenant: tenant:platform
|
||||
subject: {id: secrets-engine, type: service}
|
||||
action: apply
|
||||
resource: {id: catalog:test, type: secret-catalog-lane, system: secrets-engine}
|
||||
context: {human_control: true}
|
||||
expect: {effect: deny, reason: initiating_provider_unadmitted}
|
||||
113
examples/secrets-engine-task-execution/policy_package.md
Normal file
113
examples/secrets-engine-task-execution/policy_package.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
---
|
||||
id: secrets-engine.task-execution
|
||||
name: Secrets exact task execution
|
||||
namespace: secrets-engine:secret-catalog-lane
|
||||
version: v1
|
||||
status: proposed
|
||||
package: flexauth.secrets_engine.task_execution
|
||||
allow_ttl: 15s
|
||||
actions: [execute-ccr]
|
||||
owner: team:platform-security
|
||||
fixtures: [policy_fixtures.yaml]
|
||||
caring:
|
||||
profile: caring-0.4.0-rc2
|
||||
enforce: false
|
||||
activation:
|
||||
mode: local
|
||||
---
|
||||
|
||||
# Proposed task execution contract
|
||||
|
||||
Source only. The owner-admission constant is false because no authenticated
|
||||
initiating-principal projection or User/Tenant membership provider has been
|
||||
admitted. Request flags, even `verified=true`, cannot change that constant.
|
||||
Activating this package today denies every request. It does not replace lifecycle
|
||||
v2. Any successor that changes owner_admission requires review of the actual
|
||||
server injection boundary and authoritative providers, not a values switch.
|
||||
|
||||
The candidate predicate models the future join, not a trustworthy caller claim.
|
||||
It is exercised with synthetic server projections in tests. Raw human tokens
|
||||
must never enter CheckRequest/context/decision records. See the accompanying
|
||||
source verifier and provider contract; neither is wired into HTTP evaluation.
|
||||
|
||||
```rego
|
||||
owner_admission := false
|
||||
|
||||
default decision := {"effect": "deny", "reason": "initiating_provider_unadmitted"}
|
||||
|
||||
decision := {"effect": "allow", "reason": "exact_task_execution_matched"} {
|
||||
owner_admission
|
||||
candidate_matches
|
||||
}
|
||||
|
||||
hash_ok(x) { is_string(x); regex.match("^sha256:[0-9a-f]{64}$", x) }
|
||||
|
||||
candidate_matches {
|
||||
input.action == "execute-ccr"
|
||||
input.tenant == "tenant:platform"
|
||||
input.subject.id == "secrets-engine"
|
||||
input.subject.type == "service"
|
||||
input.resource.system == "secrets-engine"
|
||||
input.resource.type == "secret-catalog-lane"
|
||||
input.context.human_control == true
|
||||
c := input.context.execution_contract
|
||||
c.contract_version == "SECRETS-EXEC-20261011-v1"
|
||||
c.operation == "metadata-apply-ccr"
|
||||
c.execution_actor == "service:secrets-engine"
|
||||
regex.match("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", c.operation_id)
|
||||
c.ccr_id != c.execution_grant_ccr_id
|
||||
hash_ok(c.ccr_immutable_sha256)
|
||||
hash_ok(c.grant_immutable_sha256)
|
||||
hash_ok(c.catalog_sha256)
|
||||
hash_ok(c.expected_before)
|
||||
hash_ok(c.expected_after)
|
||||
c.backend_budget.ttl_seconds <= 120
|
||||
c.backend_budget.ttl_seconds >= 30
|
||||
c.backend_budget.uses == 12
|
||||
count(c.operations) == 6
|
||||
c.operations[0].method == "GET"
|
||||
c.operations[1].method == "PUT"
|
||||
c.operations[2].method == "GET"
|
||||
c.operations[3].method == "GET"
|
||||
c.operations[4].method == "PUT"
|
||||
c.operations[5].method == "GET"
|
||||
regex.match("^sys/policies/acl/[A-Za-z0-9_-]+$", c.operations[0].path)
|
||||
regex.match("^auth/[A-Za-z0-9_-]+/role/[A-Za-z0-9_-]+$", c.operations[3].path)
|
||||
c.operations[0].path == c.operations[1].path
|
||||
c.operations[1].path == c.operations[2].path
|
||||
c.operations[3].path == c.operations[4].path
|
||||
c.operations[4].path == c.operations[5].path
|
||||
every_operation_hash_valid
|
||||
p := input.context.task_execution_projection
|
||||
p.version == "proposed-task-execution-projection/v1"
|
||||
p.principal_type == "human"
|
||||
p.subject != ""
|
||||
p.tenant == input.tenant
|
||||
p.tenant_source == "directory"
|
||||
p.mfa == true
|
||||
p.transport_principal == "system:serviceaccount:secrets-engine:secrets-engine"
|
||||
p.execution_contract_digest == concat("", ["sha256:", crypto.sha256(json.marshal(c))])
|
||||
p.resource_id == input.resource.id
|
||||
p.membership_source == "user-engine"
|
||||
p.tenant_state_source == "tenant-engine"
|
||||
p.member_active == true
|
||||
p.tenant_active == true
|
||||
p.not_after_ns > time.now_ns()
|
||||
p.not_after_ns <= time.parse_rfc3339_ns(c.operation_deadline)
|
||||
input.context.approval.kind == "approval-claim"
|
||||
input.context.approval.binding.human_control == true
|
||||
}
|
||||
|
||||
every_operation_hash_valid {
|
||||
count([x | x := input.context.execution_contract.operations[_]; hash_ok(x.payload_sha256)]) == 6
|
||||
}
|
||||
```
|
||||
|
||||
```rego test
|
||||
import data.flexauth.secrets_engine.task_execution
|
||||
|
||||
test_unsigned_admission_flags_cannot_open {
|
||||
task_execution.decision == {"effect": "deny", "reason": "initiating_provider_unadmitted"}
|
||||
with input as {"context": {"verified": true, "owner_admission": true, "human_control": true}}
|
||||
}
|
||||
```
|
||||
104
internal/policy/task_execution_test.go
Normal file
104
internal/policy/task_execution_test.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package policy_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/netkingdom/access-engine/internal/policy"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskExecutionPackageClosedAdmission(t *testing.T) {
|
||||
p, e := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "secrets-engine-task-execution", "policy_package.md"))
|
||||
if e != nil || !p.Valid {
|
||||
t.Fatalf("package: %v %+v", e, p.Validation)
|
||||
}
|
||||
for _, action := range []string{"execute-ccr", "apply", "exec"} {
|
||||
r := api.CheckRequest{Tenant: "tenant:platform", Subject: api.SubjectRef{ID: "secrets-engine", Type: "service"}, Action: action, Resource: api.ResourceRef{ID: "synthetic", Type: "secret-catalog-lane", System: "secrets-engine"}, Context: map[string]any{"owner_admission": true, "verified": true, "human_control": true, "task_execution_projection": map[string]any{"verified": true}}}
|
||||
d, e := p.Evaluate(context.Background(), r)
|
||||
if e != nil || d.Effect != api.DecisionEffectDeny {
|
||||
t.Fatalf("unsigned caller opened admission: %v %+v", e, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestTaskExecutionSourceHasNoActivationSwitch(t *testing.T) {
|
||||
raw, e := os.ReadFile(filepath.Join("..", "..", "examples", "secrets-engine-task-execution", "policy_package.md"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if !strings.Contains(string(raw), "owner_admission := false") {
|
||||
t.Fatal("source refusal gate absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskExecutionPythonDescriptorAndSyntheticFutureProjection(t *testing.T) {
|
||||
output, e := exec.Command("/home/worsch/secrets-engine/.venv/bin/python", "../../tools/task_execution_interop_fixture.py").Output()
|
||||
if e != nil {
|
||||
t.Fatal("Python actual descriptor/canonical generator failed:", e)
|
||||
}
|
||||
var fixture struct {
|
||||
Request api.CheckRequest `json:"request"`
|
||||
RequestDigest string `json:"request_digest"`
|
||||
ApprovalDigest string `json:"approval_binding_digest"`
|
||||
DescriptorDigest string `json:"descriptor_digest"`
|
||||
}
|
||||
if json.Unmarshal(output, &fixture) != nil {
|
||||
t.Fatal("fixture JSON")
|
||||
}
|
||||
r := fixture.Request
|
||||
if api.RequestDigest(r) != fixture.RequestDigest || api.ApprovalBindingDigest(r) != fixture.ApprovalDigest || api.CanonicalDigest(r.Context["execution_contract"]) != fixture.DescriptorDigest {
|
||||
t.Fatal("actual Python/Go canonical mismatch")
|
||||
}
|
||||
raw, e := os.ReadFile(filepath.Join("..", "..", "examples", "secrets-engine-task-execution", "policy_package.md"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
// This source replacement is intentionally test-only; no caller/config flag
|
||||
// can change the production package's hard admission=false constant.
|
||||
raw = []byte(strings.Replace(string(raw), "owner_admission := false", "owner_admission := true", 1))
|
||||
p, e := policy.Load(raw, "synthetic-future-provider-test.md")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
d, e := p.Evaluate(context.Background(), r)
|
||||
if e != nil || d.Effect != api.DecisionEffectAllow {
|
||||
t.Fatalf("synthetic future projection did not match: %v %+v", e, d)
|
||||
}
|
||||
cases := map[string]func(*api.CheckRequest){
|
||||
"action": func(r *api.CheckRequest) { r.Action = "apply" }, "service subject": func(r *api.CheckRequest) { r.Subject.ID = "other" }, "foreign tenant": func(r *api.CheckRequest) { r.Tenant = "tenant:coulomb" },
|
||||
"plan hash": func(r *api.CheckRequest) {
|
||||
r.Context["execution_contract"].(map[string]any)["expected_after"] = "sha256:" + strings.Repeat("b", 64)
|
||||
},
|
||||
"unsigned human": func(r *api.CheckRequest) { delete(r.Context, "task_execution_projection") },
|
||||
"registered tenant": func(r *api.CheckRequest) {
|
||||
r.Context["task_execution_projection"].(map[string]any)["tenant_source"] = "registration"
|
||||
},
|
||||
"membership": func(r *api.CheckRequest) {
|
||||
r.Context["task_execution_projection"].(map[string]any)["member_active"] = false
|
||||
},
|
||||
"transport": func(r *api.CheckRequest) {
|
||||
r.Context["task_execution_projection"].(map[string]any)["transport_principal"] = "other"
|
||||
},
|
||||
"expiry": func(r *api.CheckRequest) {
|
||||
r.Context["task_execution_projection"].(map[string]any)["not_after_ns"] = float64(1)
|
||||
},
|
||||
"no human control": func(r *api.CheckRequest) { r.Context["human_control"] = false },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var f struct {
|
||||
Request api.CheckRequest `json:"request"`
|
||||
}
|
||||
json.Unmarshal(output, &f)
|
||||
mutate(&f.Request)
|
||||
d, e := p.Evaluate(context.Background(), f.Request)
|
||||
if e != nil || d.Effect != api.DecisionEffectDeny {
|
||||
t.Fatalf("mutation allowed: %v %+v", e, d)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
168
internal/taskexecution/identity.go
Normal file
168
internal/taskexecution/identity.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// Package taskexecution contains proposed source-only identity input validation.
|
||||
// It is not registered with HTTP evaluation and grants no executable admission.
|
||||
package taskexecution
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrIdentity = errors.New("task execution initiating identity refused")
|
||||
|
||||
// IssuerPins must come from reviewed owner configuration, never CheckRequest.
|
||||
// No production pins or default issuer/client are supplied by this package.
|
||||
type IssuerPins struct {
|
||||
Issuer, Audience string
|
||||
Keys map[string]*rsa.PublicKey
|
||||
}
|
||||
|
||||
// Initiator has no exported constructor or mutable fields. Verification proves
|
||||
// identity only, not membership, representation, approval or policy allowance.
|
||||
type Initiator struct {
|
||||
subject string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func (i Initiator) Subject() string { return i.subject }
|
||||
func (i Initiator) Expires() time.Time { return i.expires }
|
||||
|
||||
// strictObject rejects duplicate names (including nested ones), trailing JSON
|
||||
// and excessive depth. JWT claims are never copied to logs or CheckRequest.
|
||||
func strictObject(raw []byte, out any) error {
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
depth := 0
|
||||
var walk func() error
|
||||
walk = func() error {
|
||||
depth++
|
||||
defer func() { depth-- }()
|
||||
if depth > 16 {
|
||||
return ErrIdentity
|
||||
}
|
||||
t, e := d.Token()
|
||||
if e != nil {
|
||||
return ErrIdentity
|
||||
}
|
||||
delim, ok := t.(json.Delim)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch delim {
|
||||
case '{':
|
||||
seen := map[string]bool{}
|
||||
for d.More() {
|
||||
k, e := d.Token()
|
||||
s, ok := k.(string)
|
||||
if e != nil || !ok || seen[s] {
|
||||
return ErrIdentity
|
||||
}
|
||||
seen[s] = true
|
||||
if walk() != nil {
|
||||
return ErrIdentity
|
||||
}
|
||||
}
|
||||
case '[':
|
||||
for d.More() {
|
||||
if walk() != nil {
|
||||
return ErrIdentity
|
||||
}
|
||||
}
|
||||
default:
|
||||
return ErrIdentity
|
||||
}
|
||||
_, e = d.Token()
|
||||
return e
|
||||
}
|
||||
if walk() != nil {
|
||||
return ErrIdentity
|
||||
}
|
||||
if _, e := d.Token(); e == nil {
|
||||
return ErrIdentity
|
||||
}
|
||||
if json.Unmarshal(raw, out) != nil {
|
||||
return ErrIdentity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyKeyCapeHuman reuses KeyCape's RS256 access-token claim contract. Pins
|
||||
// and trusted time are server inputs. No URL/key discovery from token headers.
|
||||
func VerifyKeyCapeHuman(token string, pins IssuerPins, now time.Time) (Initiator, error) {
|
||||
refuse := func() (Initiator, error) { return Initiator{}, ErrIdentity }
|
||||
if len(token) > 16384 || len(token) == 0 || pins.Issuer == "" || pins.Audience == "" || now.IsZero() {
|
||||
return refuse()
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return refuse()
|
||||
}
|
||||
decode := func(s string) ([]byte, error) { return base64.RawURLEncoding.Strict().DecodeString(s) }
|
||||
header, e := decode(parts[0])
|
||||
if e != nil {
|
||||
return refuse()
|
||||
}
|
||||
payload, e := decode(parts[1])
|
||||
if e != nil {
|
||||
return refuse()
|
||||
}
|
||||
signature, e := decode(parts[2])
|
||||
if e != nil {
|
||||
return refuse()
|
||||
}
|
||||
var h map[string]any
|
||||
if strictObject(header, &h) != nil || h["alg"] != "RS256" {
|
||||
return refuse()
|
||||
}
|
||||
kid, ok := h["kid"].(string)
|
||||
if !ok {
|
||||
return refuse()
|
||||
}
|
||||
for k := range h {
|
||||
if k != "alg" && k != "kid" && k != "typ" {
|
||||
return refuse()
|
||||
}
|
||||
}
|
||||
key := pins.Keys[kid]
|
||||
if key == nil || key.N == nil || key.N.BitLen() < 2048 || key.E < 3 {
|
||||
return refuse()
|
||||
}
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], signature) != nil {
|
||||
return refuse()
|
||||
}
|
||||
var c struct {
|
||||
Issuer string `json:"iss"`
|
||||
Audience json.RawMessage `json:"aud"`
|
||||
Subject string `json:"sub"`
|
||||
Principal string `json:"principal_type"`
|
||||
Tenant string `json:"tenant"`
|
||||
TenantSource string `json:"tenant_source"`
|
||||
Expires int64 `json:"exp"`
|
||||
Issued int64 `json:"iat"`
|
||||
NotBefore int64 `json:"nbf"`
|
||||
Assurance struct {
|
||||
Level string `json:"level"`
|
||||
MFA bool `json:"mfa"`
|
||||
Source string `json:"source"`
|
||||
At int64 `json:"at"`
|
||||
} `json:"assurance"`
|
||||
}
|
||||
if strictObject(payload, &c) != nil {
|
||||
return refuse()
|
||||
}
|
||||
var aud string
|
||||
if json.Unmarshal(c.Audience, &aud) != nil || aud != pins.Audience {
|
||||
return refuse()
|
||||
}
|
||||
n := now.Unix()
|
||||
if c.Issuer != pins.Issuer || c.Subject == "" || len(c.Subject) > 512 || strings.ContainsAny(c.Subject, "\r\n") || c.Principal != "human" || c.Tenant != "tenant:platform" || c.TenantSource != "directory" || c.Expires <= n || c.Issued > n || c.Issued <= 0 || c.NotBefore > n || c.Expires <= c.Issued || c.Assurance.Level != "aal2" || !c.Assurance.MFA || c.Assurance.Source != "key-cape" || c.Assurance.At <= 0 || c.Assurance.At > n || n-c.Assurance.At > 300 {
|
||||
return refuse()
|
||||
}
|
||||
return Initiator{subject: c.Subject, expires: time.Unix(c.Expires, 0)}, nil
|
||||
}
|
||||
61
internal/taskexecution/identity_test.go
Normal file
61
internal/taskexecution/identity_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package taskexecution
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCryptographicInitiatorContract(t *testing.T) {
|
||||
key, e := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
now := time.Now()
|
||||
pins := IssuerPins{Issuer: "https://synthetic.invalid", Audience: "synthetic-task-test", Keys: map[string]*rsa.PublicKey{"synthetic": &key.PublicKey}}
|
||||
claims := func() map[string]any {
|
||||
return map[string]any{"iss": pins.Issuer, "aud": pins.Audience, "sub": "synthetic-human", "principal_type": "human", "tenant": "tenant:platform", "tenant_source": "directory", "iat": now.Unix() - 1, "exp": now.Unix() + 60, "assurance": map[string]any{"level": "aal2", "mfa": true, "source": "key-cape", "at": now.Unix() - 1}}
|
||||
}
|
||||
sign := func(c map[string]any) string {
|
||||
h, _ := json.Marshal(map[string]any{"alg": "RS256", "kid": "synthetic"})
|
||||
b, _ := json.Marshal(c)
|
||||
v := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(b)
|
||||
d := sha256.Sum256([]byte(v))
|
||||
s, _ := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, d[:])
|
||||
return v + "." + base64.RawURLEncoding.EncodeToString(s)
|
||||
}
|
||||
token := sign(claims())
|
||||
i, e := VerifyKeyCapeHuman(token, pins, now)
|
||||
if e != nil || i.Subject() != "synthetic-human" {
|
||||
t.Fatal("synthetic signature verification failed")
|
||||
}
|
||||
cases := map[string]func(map[string]any){"service": func(c map[string]any) { c["principal_type"] = "service" }, "registered tenant": func(c map[string]any) { c["tenant_source"] = "registration" }, "foreign tenant": func(c map[string]any) { c["tenant"] = "tenant:coulomb" }, "issuer": func(c map[string]any) { c["iss"] = "https://wrong.invalid" }, "audience": func(c map[string]any) { c["aud"] = "approval-engine" }, "expired": func(c map[string]any) { c["exp"] = now.Unix() }, "future": func(c map[string]any) { c["nbf"] = now.Unix() + 1 }, "no mfa": func(c map[string]any) { c["assurance"].(map[string]any)["mfa"] = false }, "old mfa": func(c map[string]any) { c["assurance"].(map[string]any)["at"] = now.Unix() - 301 }}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
c := claims()
|
||||
mutate(c)
|
||||
if _, e := VerifyKeyCapeHuman(sign(c), pins, now); e == nil {
|
||||
t.Fatal("invalid human accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, e := VerifyKeyCapeHuman(token, IssuerPins{}, now); e == nil {
|
||||
t.Fatal("unadmitted issuer accepted")
|
||||
}
|
||||
if _, e := VerifyKeyCapeHuman(token+"x", pins, now); e == nil {
|
||||
t.Fatal("tampered signature accepted")
|
||||
}
|
||||
}
|
||||
func TestStrictClaimJSON(t *testing.T) {
|
||||
for _, raw := range []string{`{"iss":"a","iss":"b"}`, `{"assurance":{"mfa":true,"mfa":false}}`, `{"x":1} {"x":2}`} {
|
||||
var v map[string]any
|
||||
if strictObject([]byte(raw), &v) == nil {
|
||||
t.Fatal("ambiguous claims accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
13
tools/task_execution_interop_fixture.py
Normal file
13
tools/task_execution_interop_fixture.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Synthetic interoperability generator; never obtains credentials or native state."""
|
||||
import hashlib, json, sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
sys.path.insert(0, '/home/worsch/secrets-engine/src')
|
||||
from secrets_engine.execution_contract import ExecutionContract, catalog_target_digest
|
||||
from secrets_engine.authorization import request_digest, approval_binding_digest
|
||||
h='sha256:'+'a'*64
|
||||
catalog={'lane':'synthetic','stage':'prod'}
|
||||
c={'contract_version':'SECRETS-EXEC-20261011-v1','operation_id':'cbfbced0-09d5-48c8-a1e7-a2ea4dc69153','operation':'metadata-apply-ccr','execution_actor':'service:secrets-engine','ccr_id':'CCR-2099-0001','execution_grant_ccr_id':'CCR-2099-0002','ccr_immutable_sha256':h,'grant_immutable_sha256':h,'catalog_sha256':catalog_target_digest(catalog),'expected_before':h,'expected_after':h,'source_bundle':{r:{'path':'/synthetic/'+r+'.py','sha256':h} for r in ['engine_dispatcher','platform_mediator','typed_ccr','policy_auth']},'backend_origin':'http://127.0.0.1:18200','cluster_uid':'synthetic-not-native','backend_budget':{'ttl_seconds':120,'uses':12},'operation_deadline':(datetime.now(timezone.utc)+timedelta(seconds=100)).isoformat(),'operations':[{'method':method,'path':path,'payload_sha256':h} for path in ['sys/policies/acl/synthetic','auth/synthetic/role/synthetic'] for method in ['GET','PUT','GET']]}
|
||||
c=ExecutionContract.parse(c).as_dict()
|
||||
p={'version':'proposed-task-execution-projection/v1','subject':'synthetic-human','principal_type':'human','tenant':'tenant:platform','tenant_source':'directory','mfa':True,'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine','execution_contract_digest':catalog_target_digest(c),'resource_id':'catalog:synthetic','membership_source':'user-engine','tenant_state_source':'tenant-engine','member_active':True,'tenant_active':True,'not_after_ns':int((datetime.now(timezone.utc)+timedelta(seconds=60)).timestamp())*1000000000}
|
||||
r={'tenant':'tenant:platform','subject':{'id':'secrets-engine','type':'service'},'action':'execute-ccr','resource':{'id':'catalog:synthetic','type':'secret-catalog-lane','system':'secrets-engine'},'context':{'execution_contract':c,'catalog_target':catalog,'human_control':True,'task_execution_projection':p,'approval':{'kind':'approval-claim','binding':{'human_control':True}}}}
|
||||
print(json.dumps({'request':r,'request_digest':request_digest(r),'approval_binding_digest':approval_binding_digest(r),'descriptor_digest':catalog_target_digest(c)}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue