Skip to content

Commit fba42e5

Browse files
authored
Add named human identity roles to Control Programs (#227)
* Add named human identity roles * Allow first Flow admission after standard install * Close identity admission and recovery gaps
1 parent 0ab3f7b commit fba42e5

65 files changed

Lines changed: 1302 additions & 266 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/tests/test_detached_supervision.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,8 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No
175175
config.write_text(
176176
json.dumps(
177177
{
178-
"schema_version": 4,
179-
"identity": {"human": {"kind": "literal", "value": "contract"}},
178+
"schema_version": 5,
179+
"identity": {"default": "developer", "roles": {"developer": {"kind": "literal", "value": "contract"}}},
180180
"project": {"name": "fixture", "default_branch": "main", "commands": {}},
181181
"policy": {"plan_approval": "human", "visual_evidence": "optional"},
182182
"hosts": ["cli", "cursor", "codex", "claude", "gemini", "mcp"],
@@ -250,8 +250,8 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) -
250250
config.write_text(
251251
json.dumps(
252252
{
253-
"schema_version": 4,
254-
"identity": {"human": {"kind": "literal", "value": "contract"}},
253+
"schema_version": 5,
254+
"identity": {"default": "developer", "roles": {"developer": {"kind": "literal", "value": "contract"}}},
255255
"project": {"name": "driver-fixture", "default_branch": "main", "commands": {}},
256256
"policy": {"plan_approval": "human", "visual_evidence": "optional"},
257257
"hosts": ["cli", "codex"],
@@ -352,8 +352,8 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali
352352
config.write_text(
353353
json.dumps(
354354
{
355-
"schema_version": 4,
356-
"identity": {"human": {"kind": "literal", "value": "contract"}},
355+
"schema_version": 5,
356+
"identity": {"default": "developer", "roles": {"developer": {"kind": "literal", "value": "contract"}}},
357357
"project": {
358358
"name": "retained-authority-fixture",
359359
"default_branch": "main",

boatstack/cmd/boatstack-helper/declarative_flow.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,14 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o
182182
}
183183
requiresHumanAuthority := declarativeRequiresHumanAuthority(transition, operator)
184184
if requiresHumanAuthority || transitionUsesHostInput(transition) {
185-
presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-suspension", runtimeContext.controlBundle, nil)
185+
presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-suspension", compiled.Document.Program.HumanIdentity, runtimeContext.controlBundle, nil)
186+
if identityErr != nil {
187+
return identityErr
188+
}
189+
runtimeContext.authorityContextFingerprint, identityErr = presentation.BindingFingerprint()
186190
if identityErr != nil {
187191
return identityErr
188192
}
189-
runtimeContext.authorityContextFingerprint = presentation.ProviderFingerprint
190193
runtimeContext.humanIdentity = &presentation
191194
}
192195
result, materializationContext, err := materializeDeclarativeInvocation(runtimeContext, transition, operator)
@@ -234,12 +237,13 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o
234237
return err
235238
}
236239
if runtimeContext.humanIdentity != nil {
237-
current, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-commit", runtimeContext.controlBundle, nil)
240+
current, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-commit", compiled.Document.Program.HumanIdentity, runtimeContext.controlBundle, nil)
238241
if identityErr != nil {
239242
return identityErr
240243
}
241-
if current.ProviderFingerprint != runtimeContext.authorityContextFingerprint {
242-
return fmt.Errorf("HUMAN_IDENTITY_DRIFT: verified identity provider changed before declarative state commit")
244+
currentBinding, bindingErr := current.BindingFingerprint()
245+
if bindingErr != nil || currentBinding != runtimeContext.authorityContextFingerprint {
246+
return fmt.Errorf("HUMAN_IDENTITY_DRIFT: verified identity role or provider changed before declarative state commit")
243247
}
244248
}
245249
fresh, err := invocation.Materialize(operator.Parameters, transition.Parameters, materializationContext, nil)

boatstack/cmd/boatstack-helper/declarative_flow_test.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ import (
1010
"testing"
1111

1212
"github.com/operatorstack/boatstack/boatstack/controlprogram"
13+
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity"
1314
)
1415

1516
func declarativeInvocationDocument() controlprogram.Document {
1617
truth := true
1718
mitigated := "mitigated"
1819
return controlprogram.Document{
1920
Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision,
20-
Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1"},
21+
Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1", HumanIdentity: "developer"},
2122
Declarations: controlprogram.Declarations{Authorities: []string{"human"}, Verifiers: []string{"state-effect"}},
2223
Facets: []controlprogram.Facet{{ID: "incident", Kind: "enum", Values: []string{"open", "mitigated"}}},
2324
Evidence: []controlprogram.Evidence{{ID: "state-effect", Subject: "incident", Kind: "state-observation"}},
@@ -68,7 +69,7 @@ func twoStepDeclarativeDocument() controlprogram.Document {
6869
contained, mitigated := "contained", "mitigated"
6970
return controlprogram.Document{
7071
Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision,
71-
Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1"},
72+
Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1", HumanIdentity: "developer"},
7273
Declarations: controlprogram.Declarations{Authorities: []string{"human"}, Verifiers: []string{"state-effect"}},
7374
Facets: []controlprogram.Facet{{ID: "incident", Kind: "enum", Values: []string{"open", "contained", "mitigated"}}},
7475
Evidence: []controlprogram.Evidence{{ID: "state-effect", Subject: "incident", Kind: "state-observation"}},
@@ -255,7 +256,15 @@ func TestDeclarativeIdentityRotationSupersedesInputAndAuthoritySuspensions(t *te
255256
requestFingerprintA, _ := requestA["fingerprint"].(string)
256257
authorityContextA, _ := requestA["authority_context_fingerprint"].(string)
257258
identityA := start["human_identity"].(map[string]any)
258-
if len(authorityContextA) != 64 || authorityContextA != identityA["provider_fingerprint"] {
259+
presentationA, err := humanidentity.NewPresentation("developer", humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "operator"})
260+
if err != nil {
261+
t.Fatal(err)
262+
}
263+
bindingA, err := presentationA.BindingFingerprint()
264+
if err != nil {
265+
t.Fatal(err)
266+
}
267+
if len(authorityContextA) != 64 || authorityContextA != bindingA || identityA["role"] != "developer" || identityA["provider_fingerprint"] != presentationA.ProviderFingerprint {
259268
t.Fatalf("provider A suspension is not bound: %s", startRaw)
260269
}
261270

boatstack/cmd/boatstack-helper/delegation_command.go

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ func runFlowAuthorize(arguments []string) error {
8080
}
8181
return err
8282
}
83+
resolveRequest, err := buildRequest(surfaces.OperationResolve, bound)
84+
if err != nil {
85+
return err
86+
}
87+
programChange, err := preflightDelegatedProgramChange(context.Background(), resolveRequest)
88+
if err != nil {
89+
return err
90+
}
91+
if programChange != nil {
92+
return fmt.Errorf("DELEGATION_PROGRAM_UNADMITTED: reconcile the exact candidate program before authorizing product delegation")
93+
}
8394
if bound.delegationRequestFingerprint == "" || requestFingerprint != bound.delegationRequestFingerprint || bound.runID != options.runID {
8495
return fmt.Errorf("DELEGATION_REQUEST_MISMATCH: authorization does not match the exact current request")
8596
}
@@ -161,7 +172,8 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request
161172
record := delegation.Record{
162173
Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision,
163174
Request: request, RequestFingerprint: requestFingerprint,
164-
ReceiptID: authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint, existing.Revision+1, now), Actor: actor,
175+
ReceiptID: authorizationReceiptID(requestFingerprint, actor, request.HumanIdentityRole, identityProviderFingerprint, existing.Revision+1, now), Actor: actor,
176+
ActorIdentityRole: request.HumanIdentityRole,
165177
ActorIdentityProviderFingerprint: identityProviderFingerprint,
166178
AuthorizedAt: now, Revision: existing.Revision + 1, Status: "active",
167179
}
@@ -170,7 +182,7 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request
170182
}
171183
return record, true, nil
172184
}
173-
if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.ActorIdentityProviderFingerprint != identityProviderFingerprint || existing.Status != "active" {
185+
if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.ActorIdentityRole != request.HumanIdentityRole || existing.ActorIdentityProviderFingerprint != identityProviderFingerprint || existing.Status != "active" {
174186
return delegation.Record{}, false, fmt.Errorf("DELEGATION_CONFLICT: run already has a different authorization, actor, or status")
175187
}
176188
if existing.ExpiresAt.IsZero() || now.Before(existing.ExpiresAt) {
@@ -183,14 +195,15 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request
183195
if expiresIn > 0 {
184196
record.ExpiresAt = now.Add(expiresIn)
185197
}
186-
record.ReceiptID = authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint, record.Revision, now)
198+
record.ReceiptID = authorizationReceiptID(requestFingerprint, actor, request.HumanIdentityRole, identityProviderFingerprint, record.Revision, now)
187199
record.RevokedAt, record.EndedAt, record.EndReason = time.Time{}, time.Time{}, ""
188200
return record, true, nil
189201
}
190202
record := delegation.Record{
191203
Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision,
192204
Request: request, RequestFingerprint: requestFingerprint,
193-
ReceiptID: authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint, 1, now), Actor: actor,
205+
ReceiptID: authorizationReceiptID(requestFingerprint, actor, request.HumanIdentityRole, identityProviderFingerprint, 1, now), Actor: actor,
206+
ActorIdentityRole: request.HumanIdentityRole,
194207
ActorIdentityProviderFingerprint: identityProviderFingerprint,
195208
AuthorizedAt: now, Revision: 1, Status: "active",
196209
}
@@ -200,8 +213,8 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request
200213
return record, true, nil
201214
}
202215

203-
func authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint string, revision uint64, authorizedAt time.Time) string {
204-
receiptDigest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s", requestFingerprint, actor, identityProviderFingerprint, revision, authorizedAt.UTC().Format(time.RFC3339Nano))))
216+
func authorizationReceiptID(requestFingerprint, actor, identityRole, identityProviderFingerprint string, revision uint64, authorizedAt time.Time) string {
217+
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))))
205218
return "authorization-" + hex.EncodeToString(receiptDigest[:12])
206219
}
207220

boatstack/cmd/boatstack-helper/delegation_runtime.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func canReprojectDelegation(layout ports.ControllerLayout, invocation model.Invo
3939
prior,
4040
current,
4141
func() (bool, error) {
42-
return effects.ConfigurationReprojectionAdmits(layout, current.RunID, invocation, current.HumanIdentityProviderFingerprint, current.ControlBundleFingerprint)
42+
return effects.ConfigurationReprojectionAdmits(layout, current.RunID, invocation, current.HumanIdentityRole, current.HumanIdentityProviderFingerprint, current.ControlBundleFingerprint)
4343
},
4444
func() (bool, error) {
4545
return effects.InstallationReprojectionAdmits(layout, current.RunID, invocation, current.ControlBundleFingerprint)
@@ -48,7 +48,7 @@ func canReprojectDelegation(layout ports.ControllerLayout, invocation model.Invo
4848
}
4949

5050
func admittedDelegationReprojection(prior, current delegation.Request, configurationAdmits, installationAdmits func() (bool, error)) (bool, error) {
51-
configurationChanged := prior.ControlBundleFingerprint != current.ControlBundleFingerprint || prior.HumanIdentityProviderFingerprint != current.HumanIdentityProviderFingerprint
51+
configurationChanged := prior.ControlBundleFingerprint != current.ControlBundleFingerprint || prior.HumanIdentityRole != current.HumanIdentityRole || prior.HumanIdentityProviderFingerprint != current.HumanIdentityProviderFingerprint
5252
if prior.ProgramFingerprint == current.ProgramFingerprint && configurationChanged {
5353
admitted, err := configurationAdmits()
5454
if err != nil || admitted {
@@ -130,13 +130,13 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo
130130
releaseOnError()
131131
return nil, nil, err
132132
}
133-
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 {
133+
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 {
134134
current := record.Request
135135
current.RunID, current.ProgramID, current.ProgramFingerprint, current.ControlBundleFingerprint = request.FlowID, request.ProgramID, request.ProgramFingerprint, request.ControlBundleFingerprint
136136
current.EntryID, current.TargetID, current.ObjectiveID, current.DeliveryID = request.EntryID, string(request.Objective.TargetID), request.Objective.ID, request.Objective.DeliveryID
137137
current.RepositoryID, current.GitCommonID = invocation.RepositoryID, invocation.GitCommonID
138138
current.InitialWorktreeID, current.InitialRef = invocation.WorktreeID, invocation.Ref
139-
current.BindingFingerprint, current.HumanIdentityProviderFingerprint = request.DelegationBindingFingerprint, presentation.ProviderFingerprint
139+
current.BindingFingerprint, current.HumanIdentityRole, current.HumanIdentityProviderFingerprint = request.DelegationBindingFingerprint, presentation.Role, presentation.ProviderFingerprint
140140
current.RequestedAuthorities = make([]string, len(request.DelegatedAuthorities))
141141
for index, authority := range request.DelegatedAuthorities {
142142
current.RequestedAuthorities[index] = string(authority)

boatstack/cmd/boatstack-helper/flow_command.go

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import (
2323
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol"
2424
)
2525

26-
const flowCompilerVersion = "control-program.compiler.6"
26+
const flowCompilerVersion = "control-program.compiler.7"
2727

2828
type flowCommandOptions struct {
2929
repository string
@@ -107,7 +107,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error {
107107
if err != nil {
108108
return err
109109
}
110-
configPath, configRaw, _, projections, err := loadProjectProjectionSelection(options.repository)
110+
configPath, configRaw, _, config, projections, err := loadProjectProjectionSelection(options.repository)
111111
if err != nil {
112112
return err
113113
}
@@ -143,6 +143,9 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error {
143143
if err := validateCompiledFlow(ctx, options.repository, compiled, resolver); err != nil {
144144
return err
145145
}
146+
if err := validateProgramHumanIdentity(compiled, config); err != nil {
147+
return err
148+
}
146149
artifactPath, err := resolveArtifactPath(options.repository, options.artifact, compiled.Document.Program.ID)
147150
if err != nil {
148151
return err
@@ -623,32 +626,50 @@ func renderFlowResult(status, artifactPath string, artifact controlprogram.Artif
623626
})
624627
}
625628

626-
func loadProjectProjectionSelection(repository string) (string, []byte, string, []hostprojection.ID, error) {
629+
func loadProjectProjectionSelection(repository string) (string, []byte, string, protocol.ProjectConfig, []hostprojection.ID, error) {
627630
path, err := exactRepositoryPath(repository, filepath.Join(".boatstack", "project.json"))
628631
if err != nil {
629-
return "", nil, "", nil, err
632+
return "", nil, "", protocol.ProjectConfig{}, nil, err
630633
}
631634
raw, err := os.ReadFile(path)
632635
if err != nil {
633-
return "", nil, "", nil, fmt.Errorf("PROJECT_PROJECTIONS_REQUIRED: read project configuration: %w", err)
636+
return "", nil, "", protocol.ProjectConfig{}, nil, fmt.Errorf("PROJECT_PROJECTIONS_REQUIRED: read project configuration: %w", err)
634637
}
635638
config, fingerprint, err := protocol.ProjectConfigFingerprint(raw)
636639
if err != nil {
637-
return "", nil, "", nil, err
640+
return "", nil, "", protocol.ProjectConfig{}, nil, err
638641
}
639642
projections, err := config.ProjectionIDs()
640643
if err != nil {
641-
return "", nil, "", nil, err
644+
return "", nil, "", protocol.ProjectConfig{}, nil, err
642645
}
643-
return path, raw, fingerprint, projections, nil
646+
return path, raw, fingerprint, config, projections, nil
644647
}
645648

646649
func checkArtifactForCurrentProject(repository string, artifact controlprogram.Artifact, resolver controlprogram.BindingResolver) (controlprogram.Compiled, error) {
647-
_, _, _, projections, err := loadProjectProjectionSelection(repository)
650+
_, _, _, config, projections, err := loadProjectProjectionSelection(repository)
651+
if err != nil {
652+
return controlprogram.Compiled{}, err
653+
}
654+
compiled, err := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, projections, generateSoftwareFlowProjections)
648655
if err != nil {
649656
return controlprogram.Compiled{}, err
650657
}
651-
return controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, projections, generateSoftwareFlowProjections)
658+
if err := validateProgramHumanIdentity(compiled, config); err != nil {
659+
return controlprogram.Compiled{}, err
660+
}
661+
return compiled, nil
662+
}
663+
664+
func validateProgramHumanIdentity(compiled controlprogram.Compiled, config protocol.ProjectConfig) error {
665+
role := compiled.Document.Program.HumanIdentity
666+
if role == "" {
667+
return nil
668+
}
669+
if _, ok := config.Identity.Roles[role]; !ok {
670+
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)
671+
}
672+
return nil
652673
}
653674

654675
func entryIDs(entries []controlprogram.Entry) []string {

0 commit comments

Comments
 (0)