Bind service task HTTP input to authenticated caller before evaluation
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
parent
919ce25a37
commit
4f6946bbb3
8 changed files with 590 additions and 9 deletions
|
|
@ -20,6 +20,7 @@ import (
|
|||
"github.com/netkingdom/access-engine/internal/policy"
|
||||
"github.com/netkingdom/access-engine/internal/registry"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
"github.com/netkingdom/access-engine/internal/taskexecution"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
)
|
||||
|
||||
|
|
@ -153,9 +154,44 @@ func (e *Engine) decide(ctx context.Context, request api.CheckRequest) (api.Deci
|
|||
return envelope, nil
|
||||
}
|
||||
|
||||
// IsServicePilot selects the existing exact source package, never a request flag.
|
||||
func (e *Engine) IsServicePilot() bool {
|
||||
return e.policy.Metadata.ID == taskexecution.ServicePilotPackageID && e.policy.Metadata.Version == taskexecution.ServicePilotPackageVersion
|
||||
}
|
||||
|
||||
// ErrServicePilotUnavailable withholds any result that is not this B1 slice's
|
||||
// signed DENY. Provider joins and executable eligibility remain unimplemented.
|
||||
var ErrServicePilotUnavailable = errors.New("service pilot source path unavailable")
|
||||
|
||||
// CheckServicePilot records only a signed DENY after authenticating the exact
|
||||
// immutable service input. It does not validate Approval facts or grant an act.
|
||||
func (e *Engine) CheckServicePilot(ctx context.Context, input taskexecution.ServiceInput) (api.DecisionEnvelope, error) {
|
||||
if !e.IsServicePilot() {
|
||||
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
|
||||
}
|
||||
request, err := input.RequestAt(e.now())
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
envelope, err := e.decide(callerauth.WithRecord(ctx, input.Caller()), request)
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
if envelope.Effect != api.DecisionEffectDeny || envelope.Signature == nil || envelope.Signature.Mode != api.EnvelopeSignatureSigned {
|
||||
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
|
||||
}
|
||||
if err := e.recordDecisions([]api.DecisionEnvelope{envelope}); err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
return envelope, nil
|
||||
}
|
||||
|
||||
// Check evaluates one subject/action/resource request. The decision is
|
||||
// released only after its required record commits (GH-DEC-2026-024).
|
||||
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
|
||||
if e.IsServicePilot() || request.Action == "execute-ccr" {
|
||||
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
|
||||
}
|
||||
decision, err := e.decide(ctx, request)
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
|
|
@ -169,6 +205,9 @@ func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.Decis
|
|||
// BatchCheck evaluates one subject/action/context tuple against resources in
|
||||
// request order, and commits the whole batch in one durable write.
|
||||
func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest) ([]api.DecisionEnvelope, error) {
|
||||
if e.IsServicePilot() || request.Action == "execute-ccr" {
|
||||
return nil, ErrServicePilotUnavailable
|
||||
}
|
||||
decisions := make([]api.DecisionEnvelope, 0, len(request.Resources))
|
||||
for _, resource := range request.Resources {
|
||||
decision, err := e.decide(ctx, api.CheckRequest{
|
||||
|
|
@ -193,6 +232,9 @@ func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest)
|
|||
|
||||
// ListAllowed evaluates candidate resources and returns only allow decisions.
|
||||
func (e *Engine) ListAllowed(ctx context.Context, request ListAllowedRequest) ([]api.DecisionEnvelope, error) {
|
||||
if e.IsServicePilot() || request.Action == "execute-ccr" {
|
||||
return nil, ErrServicePilotUnavailable
|
||||
}
|
||||
candidates := e.store.ResourceRefs(request.System, request.ResourceType)
|
||||
decisions := make([]api.DecisionEnvelope, 0, len(candidates))
|
||||
for _, resource := range candidates {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Package taskexecution contains proposed source-only identity input validation.
|
||||
// It is not registered with HTTP evaluation and grants no executable admission.
|
||||
// Package taskexecution contains task input validation, not policy evaluation.
|
||||
// The human component remains source-only. The B1 service HTTP path can emit
|
||||
// only signed DENY and grants no executable admission.
|
||||
package taskexecution
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -1,20 +1,27 @@
|
|||
package taskexecution
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/netkingdom/access-engine/internal/callerauth"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrServiceInput = errors.New("service task execution input refused")
|
||||
|
||||
const ServicePilotPackageID = "secrets-engine.task-execution.service-pilot"
|
||||
const ServicePilotPackageVersion = "v1"
|
||||
|
||||
const ServicePilotMappingVersion = "proposed-service-task-caller/v1"
|
||||
const ServicePilotPrincipal = "system:serviceaccount:secrets-engine:secrets-engine"
|
||||
|
||||
// ServicePilotInputs is source preparation only. It installs the explicit
|
||||
// ServicePilotInputs installs the exact B1 service input mapping for signed DENY.
|
||||
// It does not validate Approval facts or establish executable eligibility.
|
||||
// It installs the explicit
|
||||
// proposed fixed subject-to-transport mapping, never a request-selected mapping.
|
||||
// No constructor parameter can select warn/disabled mode or another subject.
|
||||
type ServicePilotInputs struct{ auth *callerauth.Authenticator }
|
||||
|
|
@ -47,6 +54,44 @@ func (s ServiceInput) Request() (api.CheckRequest, error) {
|
|||
}
|
||||
func (s ServiceInput) Caller() callerauth.Record { return s.caller }
|
||||
|
||||
// RequestAt rechecks transport and operation lifetime at the runtime use site.
|
||||
// Request() remains a detached source snapshot, never an execution capability.
|
||||
func (s ServiceInput) RequestAt(now time.Time) (api.CheckRequest, error) {
|
||||
r, err := s.Request()
|
||||
if err != nil || now.IsZero() || s.caller.Audience != "flex-auth" || !s.caller.NotAfter.After(now) {
|
||||
return api.CheckRequest{}, ErrServiceInput
|
||||
}
|
||||
contract, ok := r.Context["execution_contract"].(map[string]any)
|
||||
if !ok {
|
||||
return api.CheckRequest{}, ErrServiceInput
|
||||
}
|
||||
value, ok := contract["operation_deadline"].(string)
|
||||
if !ok {
|
||||
return api.CheckRequest{}, ErrServiceInput
|
||||
}
|
||||
deadline, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil || !deadline.After(now) || deadline.After(s.caller.NotAfter) {
|
||||
return api.CheckRequest{}, ErrServiceInput
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// DecodeServiceRequest reuses the owner strict JSON scanner for this bounded
|
||||
// task-only route. It never changes producer canonicalization or legacy input.
|
||||
func DecodeServiceRequest(reader io.Reader) (api.CheckRequest, error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(reader, 524289))
|
||||
var request api.CheckRequest
|
||||
if err != nil || len(raw) > 524288 || strictObject(raw, &request) != nil {
|
||||
return request, ErrServiceInput
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&request) != nil {
|
||||
return api.CheckRequest{}, ErrServiceInput
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// DeclaredServiceBinding is public immutable metadata to bind at approval issue.
|
||||
// It is not authentication evidence; Prepare independently verifies its principal.
|
||||
func DeclaredServiceBinding() map[string]any {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package taskexecution
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/netkingdom/access-engine/internal/callerauth"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
"testing"
|
||||
|
|
@ -79,3 +81,33 @@ func TestServicePilotRefusals(t *testing.T) {
|
|||
t.Fatal("missing owner reviewer accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceInputRuntimeRecheckAndStrictHTTPDecode(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
r := serviceRequest(t, now)
|
||||
reviewer := &serviceReviewer{identity: callerauth.Identity{Username: ServicePilotPrincipal, Audiences: []string{"flex-auth"}, NotAfter: now.Add(120 * time.Second)}}
|
||||
adapter, _ := NewServicePilotInputs(reviewer)
|
||||
input, err := adapter.Prepare(context.Background(), "Bearer synthetic", r, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := input.RequestAt(now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, when := range []time.Time{time.Time{}, now.Add(60 * time.Second), now.Add(120 * time.Second)} {
|
||||
if _, err := input.RequestAt(when); err == nil {
|
||||
t.Fatal("stale/invalid runtime input accepted")
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(r)
|
||||
decoded, err := DecodeServiceRequest(bytes.NewReader(raw))
|
||||
if err != nil || api.RequestDigest(decoded) != api.RequestDigest(r) {
|
||||
t.Fatal("valid decode changed canonical submitted input")
|
||||
}
|
||||
bad := [][]byte{append(append([]byte{}, raw...), []byte(" {}")...), []byte(`{"action":"execute-ccr","action":"apply"}`), []byte(`{"context":{"human_control":true,"human_control":false}}`), []byte(`{"unknown_task_flag":true}`), bytes.Repeat([]byte(" "), 524289)}
|
||||
for _, body := range bad {
|
||||
if _, err := DecodeServiceRequest(bytes.NewReader(body)); err == nil {
|
||||
t.Fatal("malformed/oversized task request accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue