Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/tests/test_detached_supervision.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No
config.write_text(
json.dumps(
{
"schema_version": 4,
"identity": {"human": {"kind": "literal", "value": "contract"}},
"schema_version": 5,
"identity": {"default": "developer", "roles": {"developer": {"kind": "literal", "value": "contract"}}},
"project": {"name": "fixture", "default_branch": "main", "commands": {}},
"policy": {"plan_approval": "human", "visual_evidence": "optional"},
"hosts": ["cli", "cursor", "codex", "claude", "gemini", "mcp"],
Expand Down Expand Up @@ -250,8 +250,8 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) -
config.write_text(
json.dumps(
{
"schema_version": 4,
"identity": {"human": {"kind": "literal", "value": "contract"}},
"schema_version": 5,
"identity": {"default": "developer", "roles": {"developer": {"kind": "literal", "value": "contract"}}},
"project": {"name": "driver-fixture", "default_branch": "main", "commands": {}},
"policy": {"plan_approval": "human", "visual_evidence": "optional"},
"hosts": ["cli", "codex"],
Expand Down Expand Up @@ -352,8 +352,8 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali
config.write_text(
json.dumps(
{
"schema_version": 4,
"identity": {"human": {"kind": "literal", "value": "contract"}},
"schema_version": 5,
"identity": {"default": "developer", "roles": {"developer": {"kind": "literal", "value": "contract"}}},
"project": {
"name": "retained-authority-fixture",
"default_branch": "main",
Expand Down
14 changes: 9 additions & 5 deletions boatstack/cmd/boatstack-helper/declarative_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,14 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o
}
requiresHumanAuthority := declarativeRequiresHumanAuthority(transition, operator)
if requiresHumanAuthority || transitionUsesHostInput(transition) {
presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-suspension", runtimeContext.controlBundle, nil)
presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-suspension", compiled.Document.Program.HumanIdentity, runtimeContext.controlBundle, nil)
if identityErr != nil {
return identityErr
}
runtimeContext.authorityContextFingerprint, identityErr = presentation.BindingFingerprint()
if identityErr != nil {
return identityErr
}
runtimeContext.authorityContextFingerprint = presentation.ProviderFingerprint
runtimeContext.humanIdentity = &presentation
}
result, materializationContext, err := materializeDeclarativeInvocation(runtimeContext, transition, operator)
Expand Down Expand Up @@ -234,12 +237,13 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o
return err
}
if runtimeContext.humanIdentity != nil {
current, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-commit", runtimeContext.controlBundle, nil)
current, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-commit", compiled.Document.Program.HumanIdentity, runtimeContext.controlBundle, nil)
if identityErr != nil {
return identityErr
}
if current.ProviderFingerprint != runtimeContext.authorityContextFingerprint {
return fmt.Errorf("HUMAN_IDENTITY_DRIFT: verified identity provider changed before declarative state commit")
currentBinding, bindingErr := current.BindingFingerprint()
if bindingErr != nil || currentBinding != runtimeContext.authorityContextFingerprint {
return fmt.Errorf("HUMAN_IDENTITY_DRIFT: verified identity role or provider changed before declarative state commit")
}
}
fresh, err := invocation.Materialize(operator.Parameters, transition.Parameters, materializationContext, nil)
Expand Down
15 changes: 12 additions & 3 deletions boatstack/cmd/boatstack-helper/declarative_flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import (
"testing"

"github.com/operatorstack/boatstack/boatstack/controlprogram"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity"
)

func declarativeInvocationDocument() controlprogram.Document {
truth := true
mitigated := "mitigated"
return controlprogram.Document{
Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision,
Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1"},
Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1", HumanIdentity: "developer"},
Declarations: controlprogram.Declarations{Authorities: []string{"human"}, Verifiers: []string{"state-effect"}},
Facets: []controlprogram.Facet{{ID: "incident", Kind: "enum", Values: []string{"open", "mitigated"}}},
Evidence: []controlprogram.Evidence{{ID: "state-effect", Subject: "incident", Kind: "state-observation"}},
Expand Down Expand Up @@ -68,7 +69,7 @@ func twoStepDeclarativeDocument() controlprogram.Document {
contained, mitigated := "contained", "mitigated"
return controlprogram.Document{
Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision,
Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1"},
Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1", HumanIdentity: "developer"},
Declarations: controlprogram.Declarations{Authorities: []string{"human"}, Verifiers: []string{"state-effect"}},
Facets: []controlprogram.Facet{{ID: "incident", Kind: "enum", Values: []string{"open", "contained", "mitigated"}}},
Evidence: []controlprogram.Evidence{{ID: "state-effect", Subject: "incident", Kind: "state-observation"}},
Expand Down Expand Up @@ -255,7 +256,15 @@ func TestDeclarativeIdentityRotationSupersedesInputAndAuthoritySuspensions(t *te
requestFingerprintA, _ := requestA["fingerprint"].(string)
authorityContextA, _ := requestA["authority_context_fingerprint"].(string)
identityA := start["human_identity"].(map[string]any)
if len(authorityContextA) != 64 || authorityContextA != identityA["provider_fingerprint"] {
presentationA, err := humanidentity.NewPresentation("developer", humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "operator"})
if err != nil {
t.Fatal(err)
}
bindingA, err := presentationA.BindingFingerprint()
if err != nil {
t.Fatal(err)
}
if len(authorityContextA) != 64 || authorityContextA != bindingA || identityA["role"] != "developer" || identityA["provider_fingerprint"] != presentationA.ProviderFingerprint {
t.Fatalf("provider A suspension is not bound: %s", startRaw)
}

Expand Down
25 changes: 19 additions & 6 deletions boatstack/cmd/boatstack-helper/delegation_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ func runFlowAuthorize(arguments []string) error {
}
return err
}
resolveRequest, err := buildRequest(surfaces.OperationResolve, bound)
if err != nil {
return err
}
programChange, err := preflightDelegatedProgramChange(context.Background(), resolveRequest)
if err != nil {
return err
}
if programChange != nil {
return fmt.Errorf("DELEGATION_PROGRAM_UNADMITTED: reconcile the exact candidate program before authorizing product delegation")
}
if bound.delegationRequestFingerprint == "" || requestFingerprint != bound.delegationRequestFingerprint || bound.runID != options.runID {
return fmt.Errorf("DELEGATION_REQUEST_MISMATCH: authorization does not match the exact current request")
}
Expand Down Expand Up @@ -161,7 +172,8 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request
record := delegation.Record{
Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision,
Request: request, RequestFingerprint: requestFingerprint,
ReceiptID: authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint, existing.Revision+1, now), Actor: actor,
ReceiptID: authorizationReceiptID(requestFingerprint, actor, request.HumanIdentityRole, identityProviderFingerprint, existing.Revision+1, now), Actor: actor,
ActorIdentityRole: request.HumanIdentityRole,
ActorIdentityProviderFingerprint: identityProviderFingerprint,
AuthorizedAt: now, Revision: existing.Revision + 1, Status: "active",
}
Expand All @@ -170,7 +182,7 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request
}
return record, true, nil
}
if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.ActorIdentityProviderFingerprint != identityProviderFingerprint || existing.Status != "active" {
if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.ActorIdentityRole != request.HumanIdentityRole || existing.ActorIdentityProviderFingerprint != identityProviderFingerprint || existing.Status != "active" {
return delegation.Record{}, false, fmt.Errorf("DELEGATION_CONFLICT: run already has a different authorization, actor, or status")
}
if existing.ExpiresAt.IsZero() || now.Before(existing.ExpiresAt) {
Expand All @@ -183,14 +195,15 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request
if expiresIn > 0 {
record.ExpiresAt = now.Add(expiresIn)
}
record.ReceiptID = authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint, record.Revision, now)
record.ReceiptID = authorizationReceiptID(requestFingerprint, actor, request.HumanIdentityRole, identityProviderFingerprint, record.Revision, now)
record.RevokedAt, record.EndedAt, record.EndReason = time.Time{}, time.Time{}, ""
return record, true, nil
}
record := delegation.Record{
Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision,
Request: request, RequestFingerprint: requestFingerprint,
ReceiptID: authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint, 1, now), Actor: actor,
ReceiptID: authorizationReceiptID(requestFingerprint, actor, request.HumanIdentityRole, identityProviderFingerprint, 1, now), Actor: actor,
ActorIdentityRole: request.HumanIdentityRole,
ActorIdentityProviderFingerprint: identityProviderFingerprint,
AuthorizedAt: now, Revision: 1, Status: "active",
}
Expand All @@ -200,8 +213,8 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request
return record, true, nil
}

func authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint string, revision uint64, authorizedAt time.Time) string {
receiptDigest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s", requestFingerprint, actor, identityProviderFingerprint, revision, authorizedAt.UTC().Format(time.RFC3339Nano))))
func authorizationReceiptID(requestFingerprint, actor, identityRole, identityProviderFingerprint string, revision uint64, authorizedAt time.Time) string {
receiptDigest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%s", requestFingerprint, actor, identityRole, identityProviderFingerprint, revision, authorizedAt.UTC().Format(time.RFC3339Nano))))
return "authorization-" + hex.EncodeToString(receiptDigest[:12])
}

Expand Down
8 changes: 4 additions & 4 deletions boatstack/cmd/boatstack-helper/delegation_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func canReprojectDelegation(layout ports.ControllerLayout, invocation model.Invo
prior,
current,
func() (bool, error) {
return effects.ConfigurationReprojectionAdmits(layout, current.RunID, invocation, current.HumanIdentityProviderFingerprint, current.ControlBundleFingerprint)
return effects.ConfigurationReprojectionAdmits(layout, current.RunID, invocation, current.HumanIdentityRole, current.HumanIdentityProviderFingerprint, current.ControlBundleFingerprint)
},
func() (bool, error) {
return effects.InstallationReprojectionAdmits(layout, current.RunID, invocation, current.ControlBundleFingerprint)
Expand All @@ -48,7 +48,7 @@ func canReprojectDelegation(layout ports.ControllerLayout, invocation model.Invo
}

func admittedDelegationReprojection(prior, current delegation.Request, configurationAdmits, installationAdmits func() (bool, error)) (bool, error) {
configurationChanged := prior.ControlBundleFingerprint != current.ControlBundleFingerprint || prior.HumanIdentityProviderFingerprint != current.HumanIdentityProviderFingerprint
configurationChanged := prior.ControlBundleFingerprint != current.ControlBundleFingerprint || prior.HumanIdentityRole != current.HumanIdentityRole || prior.HumanIdentityProviderFingerprint != current.HumanIdentityProviderFingerprint
if prior.ProgramFingerprint == current.ProgramFingerprint && configurationChanged {
admitted, err := configurationAdmits()
if err != nil || admitted {
Expand Down Expand Up @@ -130,13 +130,13 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo
releaseOnError()
return nil, nil, err
}
if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.ControlBundleFingerprint != request.ControlBundleFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint || record.Request.HumanIdentityProviderFingerprint != presentation.ProviderFingerprint || record.ActorIdentityProviderFingerprint != presentation.ProviderFingerprint {
if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.ControlBundleFingerprint != request.ControlBundleFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint || record.Request.HumanIdentityRole != presentation.Role || record.Request.HumanIdentityProviderFingerprint != presentation.ProviderFingerprint || record.ActorIdentityRole != presentation.Role || record.ActorIdentityProviderFingerprint != presentation.ProviderFingerprint {
current := record.Request
current.RunID, current.ProgramID, current.ProgramFingerprint, current.ControlBundleFingerprint = request.FlowID, request.ProgramID, request.ProgramFingerprint, request.ControlBundleFingerprint
current.EntryID, current.TargetID, current.ObjectiveID, current.DeliveryID = request.EntryID, string(request.Objective.TargetID), request.Objective.ID, request.Objective.DeliveryID
current.RepositoryID, current.GitCommonID = invocation.RepositoryID, invocation.GitCommonID
current.InitialWorktreeID, current.InitialRef = invocation.WorktreeID, invocation.Ref
current.BindingFingerprint, current.HumanIdentityProviderFingerprint = request.DelegationBindingFingerprint, presentation.ProviderFingerprint
current.BindingFingerprint, current.HumanIdentityRole, current.HumanIdentityProviderFingerprint = request.DelegationBindingFingerprint, presentation.Role, presentation.ProviderFingerprint
current.RequestedAuthorities = make([]string, len(request.DelegatedAuthorities))
for index, authority := range request.DelegatedAuthorities {
current.RequestedAuthorities[index] = string(authority)
Expand Down
41 changes: 31 additions & 10 deletions boatstack/cmd/boatstack-helper/flow_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import (
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol"
)

const flowCompilerVersion = "control-program.compiler.6"
const flowCompilerVersion = "control-program.compiler.7"

type flowCommandOptions struct {
repository string
Expand Down Expand Up @@ -107,7 +107,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error {
if err != nil {
return err
}
configPath, configRaw, _, projections, err := loadProjectProjectionSelection(options.repository)
configPath, configRaw, _, config, projections, err := loadProjectProjectionSelection(options.repository)
if err != nil {
return err
}
Expand Down Expand Up @@ -143,6 +143,9 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error {
if err := validateCompiledFlow(ctx, options.repository, compiled, resolver); err != nil {
return err
}
if err := validateProgramHumanIdentity(compiled, config); err != nil {
return err
}
artifactPath, err := resolveArtifactPath(options.repository, options.artifact, compiled.Document.Program.ID)
if err != nil {
return err
Expand Down Expand Up @@ -623,32 +626,50 @@ func renderFlowResult(status, artifactPath string, artifact controlprogram.Artif
})
}

func loadProjectProjectionSelection(repository string) (string, []byte, string, []hostprojection.ID, error) {
func loadProjectProjectionSelection(repository string) (string, []byte, string, protocol.ProjectConfig, []hostprojection.ID, error) {
path, err := exactRepositoryPath(repository, filepath.Join(".boatstack", "project.json"))
if err != nil {
return "", nil, "", nil, err
return "", nil, "", protocol.ProjectConfig{}, nil, err
}
raw, err := os.ReadFile(path)
if err != nil {
return "", nil, "", nil, fmt.Errorf("PROJECT_PROJECTIONS_REQUIRED: read project configuration: %w", err)
return "", nil, "", protocol.ProjectConfig{}, nil, fmt.Errorf("PROJECT_PROJECTIONS_REQUIRED: read project configuration: %w", err)
}
config, fingerprint, err := protocol.ProjectConfigFingerprint(raw)
if err != nil {
return "", nil, "", nil, err
return "", nil, "", protocol.ProjectConfig{}, nil, err
}
projections, err := config.ProjectionIDs()
if err != nil {
return "", nil, "", nil, err
return "", nil, "", protocol.ProjectConfig{}, nil, err
}
return path, raw, fingerprint, projections, nil
return path, raw, fingerprint, config, projections, nil
}

func checkArtifactForCurrentProject(repository string, artifact controlprogram.Artifact, resolver controlprogram.BindingResolver) (controlprogram.Compiled, error) {
_, _, _, projections, err := loadProjectProjectionSelection(repository)
_, _, _, config, projections, err := loadProjectProjectionSelection(repository)
if err != nil {
return controlprogram.Compiled{}, err
}
compiled, err := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, projections, generateSoftwareFlowProjections)
if err != nil {
return controlprogram.Compiled{}, err
}
return controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, projections, generateSoftwareFlowProjections)
if err := validateProgramHumanIdentity(compiled, config); err != nil {
return controlprogram.Compiled{}, err
}
return compiled, nil
}

func validateProgramHumanIdentity(compiled controlprogram.Compiled, config protocol.ProjectConfig) error {
role := compiled.Document.Program.HumanIdentity
if role == "" {
return nil
}
if _, ok := config.Identity.Roles[role]; !ok {
return fmt.Errorf("CONTROL_PROGRAM_HUMAN_IDENTITY_UNBOUND: program %q requires identity role %q, but the current project configuration does not define it", compiled.Document.Program.ID, role)
}
return nil
}

func entryIDs(entries []controlprogram.Entry) []string {
Expand Down
Loading
Loading