From e4c4e57b93ca7190b709678fc61e146f4e334c90 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Wed, 28 Oct 2026 23:27:12 +0800 Subject: [PATCH] E1.2: bind native process admission to retained identity state Add versioned native mapping state with admitted ID provenance separate from protected reservations. Preserve legacy supervisor reading and refuse automatic conversion of admitted or ambiguous authority. Extend the existing checked operation journal with native active-map projection, immutable generation and image evidence, and a durable one-shot admission/release attempt. Preserve IDs and owned objects through uncertain writes and recovery; configure and recovery never manufacture admission. Add state, projection, stale-evidence and crash tests. Public mapped execution remains inhibited; engine/waiter transport belongs to E1.3. --- .../+native-identity-admission-state.yaml | 2 + internal/deploy/identity_mapping_effects.go | 9 +- internal/deploy/identity_mapping_native.go | 184 +++++++ .../deploy/identity_mapping_native_state.go | 88 ++++ .../identity_mapping_native_state_test.go | 246 +++++++++ .../deploy/identity_mapping_native_test.go | 474 ++++++++++++++++++ .../deploy/identity_mapping_operations.go | 35 +- internal/deploy/identity_mapping_state.go | 68 ++- .../deploy/identity_mapping_state_file.go | 3 +- 9 files changed, 1073 insertions(+), 36 deletions(-) create mode 100644 .changes/unreleased/+native-identity-admission-state.yaml create mode 100644 internal/deploy/identity_mapping_native.go create mode 100644 internal/deploy/identity_mapping_native_state.go create mode 100644 internal/deploy/identity_mapping_native_state_test.go create mode 100644 internal/deploy/identity_mapping_native_test.go diff --git a/.changes/unreleased/+native-identity-admission-state.yaml b/.changes/unreleased/+native-identity-admission-state.yaml new file mode 100644 index 00000000..5b3f3eb1 --- /dev/null +++ b/.changes/unreleased/+native-identity-admission-state.yaml @@ -0,0 +1,2 @@ +kind: Added +body: Add internal native-process admission state with separate admitted ID provenance, retained reservations and one-shot release attempts. Existing supervisor admissions cannot migrate automatically; public mapped execution remains inhibited. diff --git a/internal/deploy/identity_mapping_effects.go b/internal/deploy/identity_mapping_effects.go index 21cb578c..f7952640 100644 --- a/internal/deploy/identity_mapping_effects.go +++ b/internal/deploy/identity_mapping_effects.go @@ -10,7 +10,8 @@ import ( // ObserveEffects checks the trusted caller's exact pending operation. It changes // no state and grants no payload permission. BeginEffects must already be durable. -// Active geometry is the selected candidate, never the retained reservation union. +// Configure requires the full candidate; native-execute requires its freshly +// checked stored active projection. Neither uses the retained reservation union. func (ops IdentityMappingOperationsV1) ObserveEffects(ctx context.Context, expected StateV1, active identitymapping.Geometry, operationID string) (StateV1, error) { if err := ops.Operation.RequireHeld(); err != nil { return StateV1{}, err @@ -25,7 +26,11 @@ func (ops IdentityMappingOperationsV1) ObserveEffects(ctx context.Context, expec return StateV1{}, fmt.Errorf("mapped create requires its recorded effects-pending operation") } canonical, err := identitymapping.ValidateGeometry(active) - if err != nil || !reflect.DeepEqual(canonical, m.PendingOperation.Candidate.numeric()) { + wanted := m.PendingOperation.Candidate.numeric() + if m.PendingOperation.Kind == "native-execute" && m.PendingOperation.Native != nil { + wanted = m.PendingOperation.Native.Active.numeric() + } + if err != nil || !reflect.DeepEqual(canonical, wanted) { return StateV1{}, fmt.Errorf("mapped create active geometry differs from its pending candidate: %v", err) } if err := ops.checkPending(ctx, state, operationID); err != nil { diff --git a/internal/deploy/identity_mapping_native.go b/internal/deploy/identity_mapping_native.go new file mode 100644 index 00000000..336fa78f --- /dev/null +++ b/internal/deploy/identity_mapping_native.go @@ -0,0 +1,184 @@ +package deploy + +import ( + "context" + "fmt" + "reflect" + "slices" + "strconv" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/identitymapping" +) + +// UpgradeNativeMapping changes only the schema/provenance representation under +// the existing checked writer. Legacy pending configuration stays owned by its +// original operation. Partial or admitted supervisor authority cannot migrate. +func (ops IdentityMappingOperationsV1) UpgradeNativeMapping(ctx context.Context, expected StateV1) (StateV1, error) { + return ops.change(ctx, expected, func(state *StateV1) error { + m := state.IdentityMapping + if m == nil || m.Schema != IdentityMappingStateSchemaV1 || m.Supervisor != nil || m.Engine != nil || m.AdmittedGeneration != nil || len(m.DormantUIDs)+len(m.DormantGIDs) != 0 { + return fmt.Errorf("native upgrade requires unambiguous never-admitted legacy authority") + } + m.Schema, m.ProcessModel = IdentityMappingStateSchemaV2, IdentityMappingNativeProcessModelV1 + uids, gids := []IdentityMappingIntervalV1{}, []IdentityMappingIntervalV1{} + m.AcceptedUIDs, m.AcceptedGIDs = &uids, &gids + // A configuration intent is unchanged except for the checked journal + // revision, so its original owner can still begin or cancel it. + if p := m.PendingOperation; p != nil { + if p.Kind != "configure" || p.Phase == "intent" && p.ExpectedRevision != m.Revision || !reflect.DeepEqual(p.ExpectedGeneration, state.Current) || p.PriorAccepted != nil { + return fmt.Errorf("legacy intent revision is stale") + } + if p.Phase == "intent" { + p.ExpectedRevision = strconv.FormatUint(mappingDecimalV1(m.Revision)+1, 10) + } + } + return nil + }) +} + +// nativePlan authenticates recorded provenance and selected immutable build +// evidence. The final change still compares the entire observation under lock. +func (ops IdentityMappingOperationsV1) nativePlan(ctx context.Context, state StateV1, candidate identitymapping.Geometry, native NativeIdentityOperationV1) (IdentityExecutionPlanV1, error) { + fail := func(reason string) (IdentityExecutionPlanV1, error) { + return IdentityExecutionPlanV1{}, fmt.Errorf("native identity admission: %s", reason) + } + if err := ops.Operation.RequireHeld(); err != nil { + return fail(err.Error()) + } + m := state.IdentityMapping + if m == nil || m.Schema != IdentityMappingStateSchemaV2 || state.Current == nil || ops.ValidateProfileOwner == nil { + return fail("requires native state, selected generation and trusted profile validator") + } + if err := ValidateStateV1(state); err != nil { + return fail(err.Error()) + } + if _, pending, err := ops.Operation.ReadPendingBuild(); err != nil || pending { + return fail(fmt.Sprintf("selected build publication is unresolved: %v", err)) + } + lock, found, err := ops.Operation.ReadBuildLock(state.Current.BuildLockDigest, ops.ValidateProfileOwner) + if err != nil || !found { + return fail(fmt.Sprintf("selected immutable build lock is unavailable: %v", err)) + } + generation := state.Current + policyDigest, _ := RuntimePolicyDigestV1(lock.RuntimePolicy) // ReadBuildLock validates this policy. + digest, err := blueprint.ResolvedDocumentDigestV1(state.Blueprint) + if err != nil || digest != lock.BlueprintDigest || generation.ImageDigest != lock.FinalImage.Digest || generation.RootFSSubject != lock.FinalImage.RootFSSubject || generation.Platform != lock.Platform || state.Platform != lock.Platform || generation.RuntimePolicyDigest != policyDigest { + return fail("selected blueprint or generation differs from immutable build evidence") + } + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil || lock.IdentityPolicy == nil || !reflect.DeepEqual(document.Environment.Runtime.IdentityPolicy, &lock.IdentityPolicy.Policy) { + return fail("selected identity policy differs from immutable image evidence") + } + if _, _, err := ops.check(ctx, state, candidate); err != nil { + return fail(err.Error()) + } + var reserved *identitymapping.Geometry + if len(m.UIDAssignments) != 0 { + reserved = geometryPointerV1(m.geometry().numeric()) + } + return PlanIdentityExecutionV1(IdentityExecutionInputV1{Generation: generation.BuildLockDigest, Policy: lock.IdentityPolicy, Candidate: candidate, Reservations: reserved, Accepted: m.acceptedGeometry(), RuntimeUsers: native.RuntimeUsers, RuntimeGroups: native.RuntimeGroups}) +} + +// PrepareNativeExecution records an inhibited native intent. The trusted effect +// owner must identify a fresh owned instance and verify all existing inventory; +// this metadata seam performs no create or release and enables no public path. +func (ops IdentityMappingOperationsV1) PrepareNativeExecution(ctx context.Context, expected StateV1, candidate identitymapping.Geometry, operationID string, native NativeIdentityOperationV1) (StateV1, error) { + if !safeRecoveryIdentity(operationID) || !safeRecoveryIdentity(native.Engine) || !safeRecoveryIdentity(native.Process) { + return StateV1{}, fmt.Errorf("native intent requires exact safe operation and instance identities") + } + canonical, err := identitymapping.ValidateGeometry(candidate) + if err != nil { + return StateV1{}, err + } + plan, err := ops.nativePlan(ctx, expected, canonical, native) + if err != nil { + return StateV1{}, err + } + native.Active = mappingGeometryStateV1(plan.Active) + return ops.change(ctx, expected, func(state *StateV1) error { + m := state.IdentityMapping + if m.PendingOperation != nil || ops.Evidence == nil || m.NativeProcess != nil && *m.NativeProcess == native.Process && m.Engine != nil && *m.Engine == native.Engine { + return fmt.Errorf("native intent requires no pending operation and a fresh owned instance") + } + handles, err := ops.Evidence.Plan(ctx, cloneMappingStateV1(*state), plan.Active) + if err != nil || validateMappingHandlesV1(handles) != nil { + return fmt.Errorf("native inactive inventory is unknown or invalid: %v", err) + } + m.PendingOperation = &IdentityMappingOperationV1{OperationID: operationID, Kind: "native-execute", ExpectedRevision: strconv.FormatUint(mappingDecimalV1(m.Revision)+1, 10), ExpectedGeneration: state.Current, Candidate: mappingGeometryStateV1(canonical), PriorAccepted: m.AdmittedGeneration, OwnedObjects: append([]string(nil), handles...), Phase: "intent", Inhibited: true, Native: &native} + *state = cloneMappingStateV1(*state) + return nil + }) +} + +func (ops IdentityMappingOperationsV1) checkNativePending(ctx context.Context, expected StateV1) (IdentityExecutionPlanV1, error) { + p := expected.IdentityMapping.PendingOperation + plan, err := ops.nativePlan(ctx, expected, p.Candidate.numeric(), *p.Native) + if err != nil { + return plan, err + } + if !sameExecutionGeometryV1(plan.Active, p.Native.Active.numeric()) { + return plan, fmt.Errorf("native stored active map differs from the fresh execution plan") + } + return plan, nil +} + +// AdmitNativeExecution commits provenance and the one-shot release attempt +// before the trusted owner may release its waiter. An uncertain write grants +// no release. A retry, including after readback, cannot issue a second grant. +func (ops IdentityMappingOperationsV1) AdmitNativeExecution(ctx context.Context, expected StateV1, operationID string) (StateV1, error) { + m := expected.IdentityMapping + if m == nil || m.PendingOperation == nil || m.PendingOperation.Native == nil || m.PendingOperation.Kind != "native-execute" { + return StateV1{}, fmt.Errorf("native admission requires its recorded operation") + } + if _, err := ops.ObserveEffects(ctx, expected, m.PendingOperation.Native.Active.numeric(), operationID); err != nil { + return StateV1{}, err + } + plan, err := ops.checkNativePending(ctx, expected) + if err != nil { + return StateV1{}, err + } + return ops.change(ctx, expected, func(state *StateV1) error { + m := state.IdentityMapping + p := m.PendingOperation + if ops.Evidence == nil { + return fmt.Errorf("native admission requires exact inhibited-instance evidence") + } + if err := ops.Evidence.Verify(ctx, cloneMappingStateV1(*state)); err != nil { + return fmt.Errorf("native instance remains unverified: %w", err) + } + reserved, accepted := mappingGeometryStateV1(plan.ProspectiveReservations), mappingGeometryStateV1(plan.ProspectiveAccepted) + m.UIDAssignments, m.GIDAssignments = reserved.UIDs, reserved.GIDs + m.AcceptedUIDs, m.AcceptedGIDs = &accepted.UIDs, &accepted.GIDs + m.DormantUIDs = mappingGeometryStateV1(identitymapping.Geometry{UIDs: plan.DormantUIDs}).UIDs + m.DormantGIDs = mappingGeometryStateV1(identitymapping.Geometry{GIDs: plan.DormantGIDs}).GIDs + m.AdmittedGeneration, m.Engine, m.NativeProcess = p.ExpectedGeneration, &p.Native.Engine, &p.Native.Process + p.Phase, p.ExpectedRevision = "release-attempted", strconv.FormatUint(mappingDecimalV1(m.Revision)+1, 10) + return nil + }) +} + +// recoverNative requires phase-specific trusted evidence: exact acknowledged +// release or verified owned retirement for release-attempted, never mere engine +// presence/absence. It retains reservations and never creates admission. +func (ops IdentityMappingOperationsV1) recoverNative(ctx context.Context, expected StateV1) (StateV1, error) { + return ops.change(ctx, expected, func(state *StateV1) error { + if err := ops.Evidence.Verify(ctx, cloneMappingStateV1(*state)); err != nil { + return fmt.Errorf("native operation remains uncertain: %w", err) + } + m := state.IdentityMapping + var previous *identitymapping.Geometry + if len(m.UIDAssignments) != 0 { + previous = geometryPointerV1(m.geometry().numeric()) + } + reserved, err := identitymapping.RetainGeometry(m.PendingOperation.Candidate.numeric(), previous) + if err != nil { + return err + } + wire := mappingGeometryStateV1(reserved) + m.UIDAssignments, m.GIDAssignments = wire.UIDs, wire.GIDs + m.RetainedStorage = slices.Compact(slices.Sorted(slices.Values(append(m.RetainedStorage, m.PendingOperation.OwnedObjects...)))) + m.PendingOperation = nil + return nil + }) +} diff --git a/internal/deploy/identity_mapping_native_state.go b/internal/deploy/identity_mapping_native_state.go new file mode 100644 index 00000000..b7c91cf1 --- /dev/null +++ b/internal/deploy/identity_mapping_native_state.go @@ -0,0 +1,88 @@ +package deploy + +import ( + "fmt" + "reflect" + + "github.com/omry/reploy/internal/identitymapping" +) + +// NativeIdentityOperationV1 binds the pending create and one-shot release to +// its computed active map. Engine/Process identify the trusted effect owner's +// exact owned instance, not an image name or an inspect-based adoption claim. +type NativeIdentityOperationV1 struct { + Active IdentityMappingGeometryV1 `json:"active"` + Engine string `json:"engine"` + Process string `json:"process"` + RuntimeUsers []string `json:"runtime_users"` + RuntimeGroups []string `json:"runtime_groups"` +} + +func (m IdentityMappingStateV1) acceptedGeometry() *identitymapping.Geometry { + if m.AcceptedUIDs == nil || m.AcceptedGIDs == nil || len(*m.AcceptedUIDs) == 0 { + return nil + } + return geometryPointerV1((IdentityMappingGeometryV1{Profile: m.Profile, UIDs: *m.AcceptedUIDs, GIDs: *m.AcceptedGIDs}).numeric()) +} + +func validateNativeMappingStateV1(m *IdentityMappingStateV1) error { + p := m.PendingOperation + if m.Schema == IdentityMappingStateSchemaV1 { + if m.ProcessModel != "" || m.AcceptedUIDs != nil || m.AcceptedGIDs != nil || m.NativeProcess != nil || p != nil && (p.Native != nil || p.Kind == "native-execute") { + return fmt.Errorf("legacy mapping cannot carry native admission authority") + } + return nil + } + if m.ProcessModel != IdentityMappingNativeProcessModelV1 || m.Supervisor != nil || m.AcceptedUIDs == nil || m.AcceptedGIDs == nil || *m.AcceptedUIDs == nil || *m.AcceptedGIDs == nil { + return fmt.Errorf("native mapping requires its process model and explicit accepted arrays without a supervisor") + } + if (len(*m.AcceptedUIDs) == 0) != (len(*m.AcceptedGIDs) == 0) { + return fmt.Errorf("native admission requires both accepted ID domains") + } + accepted := m.acceptedGeometry() + if accepted == nil { + if m.AdmittedGeneration != nil || m.Engine != nil || m.NativeProcess != nil || len(m.DormantUIDs)+len(m.DormantGIDs) != 0 { + return fmt.Errorf("empty accepted provenance cannot carry admission or dormancy") + } + } else { + wire := IdentityMappingGeometryV1{Profile: m.Profile, UIDs: *m.AcceptedUIDs, GIDs: *m.AcceptedGIDs} + if err := requireCanonicalMappingGeometryV1(wire); err != nil { + return err + } + reserved := m.geometry().numeric() + union, err := identitymapping.RetainGeometry(*accepted, &reserved) + if err != nil || !sameExecutionGeometryV1(union, reserved) || m.AdmittedGeneration == nil || m.NativeProcess == nil || !safeRecoveryIdentity(*m.NativeProcess) { + return fmt.Errorf("native accepted provenance requires protected identical bindings and native admission") + } + active := identitymapping.Geometry{Profile: m.Profile, UIDs: subtractExecutionIntervalsV1(accepted.UIDs, (IdentityMappingGeometryV1{UIDs: m.DormantUIDs}).numeric().UIDs), GIDs: subtractExecutionIntervalsV1(accepted.GIDs, (IdentityMappingGeometryV1{GIDs: m.DormantGIDs}).numeric().GIDs)} + if _, err := identitymapping.ValidateGeometry(active); err != nil || m.Profile == "bounded" && (len(m.DormantUIDs)+len(m.DormantGIDs) != 0 || !sameExecutionGeometryV1(*accepted, reserved)) { + return fmt.Errorf("native admission requires valid active geometry and unchanged bounded authority") + } + } + if p == nil { + return nil + } + if p.Kind == "configure" && p.Native == nil { + return nil + } + if p.Kind != "native-execute" || p.Native == nil || p.ExpectedGeneration == nil || p.ExpectedRevision != m.Revision || !safeRecoveryIdentity(p.Native.Engine) || !safeRecoveryIdentity(p.Native.Process) || p.Phase == "verified" { + return fmt.Errorf("native operation requires exact kind, generation, revision and owned bindings") + } + if err := requireCanonicalMappingGeometryV1(p.Native.Active); err != nil { + return err + } + union, err := identitymapping.RetainGeometry(p.Native.Active.numeric(), geometryPointerV1(p.Candidate.numeric())) + if err != nil || !sameExecutionGeometryV1(union, p.Candidate.numeric()) { + return fmt.Errorf("native active map must be a subset of the candidate") + } + if p.Phase == "release-attempted" { + if !reflect.DeepEqual(m.AdmittedGeneration, p.ExpectedGeneration) || m.Engine == nil || *m.Engine != p.Native.Engine || m.NativeProcess == nil || *m.NativeProcess != p.Native.Process || accepted == nil { + return fmt.Errorf("release attempt requires its exact committed native admission") + } + active := identitymapping.Geometry{Profile: m.Profile, UIDs: subtractExecutionIntervalsV1(accepted.UIDs, (IdentityMappingGeometryV1{UIDs: m.DormantUIDs}).numeric().UIDs), GIDs: subtractExecutionIntervalsV1(accepted.GIDs, (IdentityMappingGeometryV1{GIDs: m.DormantGIDs}).numeric().GIDs)} + if !sameExecutionGeometryV1(active, p.Native.Active.numeric()) { + return fmt.Errorf("release attempt active map differs from admitted provenance") + } + } + return nil +} diff --git a/internal/deploy/identity_mapping_native_state_test.go b/internal/deploy/identity_mapping_native_state_test.go new file mode 100644 index 00000000..110452e1 --- /dev/null +++ b/internal/deploy/identity_mapping_native_state_test.go @@ -0,0 +1,246 @@ +package deploy + +import ( + "bytes" + "encoding/json" + "os" + "reflect" + "testing" + + "github.com/omry/reploy/internal/identitymapping" +) + +func TestNativeMappingV1ClosedSchemaAndProvenance(t *testing.T) { + _, ops, empty, evidence, candidate := nativeMappingFixtureV1(t) + intent, err := ops.PrepareNativeExecution(t.Context(), empty, candidate, "schema", nativeBindingV1("schema")) + if err != nil { + t.Fatal(err) + } + effects, err := ops.BeginEffects(t.Context(), intent, "schema") + if err != nil { + t.Fatal(err) + } + evidence.effects = true + admitted, err := ops.AdmitNativeExecution(t.Context(), effects, "schema") + if err != nil { + t.Fatal(err) + } + for name, entry := range map[string]struct { + source StateV1 + mutate func(*IdentityMappingStateV1) + }{ + "model": {empty, func(m *IdentityMappingStateV1) { m.ProcessModel = "supervisor-v1" }}, + "supervisor": {empty, func(m *IdentityMappingStateV1) { v := "supervisor"; m.Supervisor = &v }}, + "missing accepted": {empty, func(m *IdentityMappingStateV1) { m.AcceptedUIDs = nil }}, + "null accepted": {empty, func(m *IdentityMappingStateV1) { *m.AcceptedUIDs = nil }}, + "one accepted domain": {admitted, func(m *IdentityMappingStateV1) { *m.AcceptedGIDs = []IdentityMappingIntervalV1{} }}, + "empty admitted": {empty, func(m *IdentityMappingStateV1) { m.AdmittedGeneration = admitted.Current }}, + "empty engine": {empty, func(m *IdentityMappingStateV1) { v := "engine"; m.Engine = &v }}, + "empty process": {empty, func(m *IdentityMappingStateV1) { v := "process"; m.NativeProcess = &v }}, + "missing process": {admitted, func(m *IdentityMappingStateV1) { m.NativeProcess = nil }}, + "unsafe process": {admitted, func(m *IdentityMappingStateV1) { v := "-unsafe"; m.NativeProcess = &v }}, + "missing generation": {admitted, func(m *IdentityMappingStateV1) { m.AdmittedGeneration = nil }}, + "invalid accepted": {admitted, func(m *IdentityMappingStateV1) { (*m.AcceptedUIDs)[0].Count = "0" }}, + "unreserved accepted": {admitted, func(m *IdentityMappingStateV1) { (*m.AcceptedUIDs)[0].HostStart = "999999" }}, + "unaccepted dormant": {admitted, func(m *IdentityMappingStateV1) { + m.PendingOperation = nil + m.DormantUIDs = []IdentityMappingIntervalV1{{ContainerStart: "600", HostStart: "100600", Count: "1"}} + m.UIDAssignments = append(m.UIDAssignments, m.DormantUIDs...) + }}, + "all dormant": {admitted, func(m *IdentityMappingStateV1) { + m.PendingOperation = nil + m.DormantUIDs = *m.AcceptedUIDs + m.DormantGIDs = *m.AcceptedGIDs + }}, + "foreign kind": {intent, func(m *IdentityMappingStateV1) { m.PendingOperation.Kind = "future" }}, + "configure projection": {intent, func(m *IdentityMappingStateV1) { m.PendingOperation.Kind = "configure" }}, + "missing active": {intent, func(m *IdentityMappingStateV1) { m.PendingOperation.Native = nil }}, + "invalid active": {intent, func(m *IdentityMappingStateV1) { m.PendingOperation.Native.Active.UIDs[0].Count = "0" }}, + "active outside candidate": {intent, func(m *IdentityMappingStateV1) { m.PendingOperation.Native.Active.UIDs[0].HostStart = "999999" }}, + "missing pending generation": {intent, func(m *IdentityMappingStateV1) { m.PendingOperation.ExpectedGeneration = nil }}, + "stale pending revision": {intent, func(m *IdentityMappingStateV1) { m.Revision = "99" }}, + "unknown native phase": {intent, func(m *IdentityMappingStateV1) { m.PendingOperation.Phase = "verified" }}, + "unsafe instance": {intent, func(m *IdentityMappingStateV1) { m.PendingOperation.Native.Process = "-invalid" }}, + "unsafe engine": {intent, func(m *IdentityMappingStateV1) { m.PendingOperation.Native.Engine = "-invalid" }}, + "uncommitted release": {intent, func(m *IdentityMappingStateV1) { m.PendingOperation.Phase = "release-attempted" }}, + "wrong release process": {admitted, func(m *IdentityMappingStateV1) { m.PendingOperation.Native.Process = "different" }}, + "wrong release engine": {admitted, func(m *IdentityMappingStateV1) { m.PendingOperation.Native.Engine = "different" }}, + "wrong release generation": {admitted, func(m *IdentityMappingStateV1) { + g := *m.PendingOperation.ExpectedGeneration + g.Reference += "-different" + m.PendingOperation.ExpectedGeneration = &g + }}, + "wrong admitted active": {admitted, func(m *IdentityMappingStateV1) { + m.PendingOperation.Native.Active.UIDs = m.PendingOperation.Native.Active.UIDs[:1] + }}, + "legacy native fields": {empty, func(m *IdentityMappingStateV1) { m.Schema = IdentityMappingStateSchemaV1 }}, + "legacy native journal": {intent, func(m *IdentityMappingStateV1) { + m.Schema = IdentityMappingStateSchemaV1 + m.ProcessModel = "" + m.AcceptedUIDs = nil + m.AcceptedGIDs = nil + }}, + } { + t.Run(name, func(t *testing.T) { + s := cloneMappingStateV1(entry.source) + entry.mutate(s.IdentityMapping) + if _, err := EncodeStateV1(s); err == nil { + t.Fatal("invalid native authority encoded") + } + }) + } + for _, state := range []StateV1{empty, intent, effects, admitted} { + content, err := EncodeStateV1(state) + if err != nil { + t.Fatal(err) + } + got, err := DecodeStateV1(content) + if err != nil || !reflect.DeepEqual(got, state) { + t.Fatalf("native canonical roundtrip: %v", err) + } + if state.IdentityMapping.acceptedGeometry() == nil && !bytes.Contains(content, []byte(`"accepted_uids":[]`)) { + t.Fatal("empty P was not explicit") + } + } + content, err := EncodeStateV1(intent) + if err != nil { + t.Fatal(err) + } + for _, replacement := range []string{`"native":null`, `"native":{"unexpected":true}`} { + var fields map[string]json.RawMessage + if err := json.Unmarshal(content, &fields); err != nil { + t.Fatal(err) + } + // Exercise the real closed decoder with an unknown nested field. + bad := bytes.Replace(content, []byte(`"native":{`), []byte(`"native":{"unexpected":true,`), 1) + if replacement == `"native":null` { + bad = bytes.Replace(content, []byte(`"accepted_uids":[]`), []byte(`"accepted_uids":null`), 1) + } + if _, err := DecodeStateV1(bad); err == nil { + t.Fatal("native decoder accepted missing/unknown authority") + } + } +} + +func TestNativeMappingV1ExactProjectionAndNeverAdmittedSurplus(t *testing.T) { + for _, accepted := range []bool{false, true} { + t.Run(map[bool]string{false: "reserved-only", true: "prior-admission"}[accepted], func(t *testing.T) { + path, ops, state, evidence, candidate := nativeMappingFixtureV1(t) + extra := executionGeometryV1([]uint32{700}, []uint32{700}) + all, err := identitymapping.RetainGeometry(candidate, &extra) + if err != nil { + t.Fatal(err) + } + wire := mappingGeometryStateV1(all) + m := state.IdentityMapping + m.UIDAssignments, m.GIDAssignments = wire.UIDs, wire.GIDs + if accepted { + u, g := append([]IdentityMappingIntervalV1(nil), wire.UIDs...), append([]IdentityMappingIntervalV1(nil), wire.GIDs...) + m.AcceptedUIDs, m.AcceptedGIDs = &u, &g + engine, process := "old-engine", "old-process" + m.Engine, m.NativeProcess, m.AdmittedGeneration = &engine, &process, state.Current + d := mappingGeometryStateV1(extra) + m.DormantUIDs, m.DormantGIDs = d.UIDs, d.GIDs + } + writeMappingFixtureV1(t, path, state) + intent, err := ops.PrepareNativeExecution(t.Context(), state, all, "project", nativeBindingV1("project")) + if !accepted { + if err == nil { + t.Fatal("never-admitted surplus authorized by R") + } + return + } + if err != nil { + t.Fatal(err) + } + if !sameExecutionGeometryV1(intent.IdentityMapping.PendingOperation.Native.Active.numeric(), candidate) { + t.Fatal("native active map retained dormant rows") + } + effects, err := ops.BeginEffects(t.Context(), intent, "project") + if err != nil { + t.Fatal(err) + } + if _, err := ops.ObserveEffects(t.Context(), effects, all, "project"); err == nil { + t.Fatal("native observer accepted C instead of A") + } + if _, err := ops.ObserveEffects(t.Context(), effects, candidate, "project"); err != nil { + t.Fatal(err) + } + evidence.effects = true + admitted, err := ops.AdmitNativeExecution(t.Context(), effects, "project") + if err != nil { + t.Fatal(err) + } + if !sameExecutionGeometryV1(*admitted.IdentityMapping.acceptedGeometry(), all) || !reflect.DeepEqual(admitted.IdentityMapping.DormantUIDs, m.DormantUIDs) { + t.Fatal("projected admission lost prior P or dormancy") + } + }) + } +} + +func TestNativeMappingV1LegacyBytesAndUncheckedWritesRemainClosed(t *testing.T) { + path, ops, state, _ := operationFixtureV1(t, false, false) + legacy, err := EncodeStateV1(state) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"accepted_uids", "accepted_gids", "process_model", "native_process", "native"} { + if bytes.Contains(legacy, []byte(`"`+key+`":`)) { + t.Fatal("legacy canonical bytes gained native fields") + } + } + state.IdentityMapping = nil + writeMappingFixtureV1(t, path, state) + for _, mutate := range []func(*IdentityMappingStateV1){ + func(m *IdentityMappingStateV1) { m.Schema = IdentityMappingStateSchemaV2 }, + func(m *IdentityMappingStateV1) { m.ProcessModel = IdentityMappingNativeProcessModelV1 }, + func(m *IdentityMappingStateV1) { v := []IdentityMappingIntervalV1{}; m.AcceptedUIDs = &v }, + func(m *IdentityMappingStateV1) { v := []IdentityMappingIntervalV1{}; m.AcceptedGIDs = &v }, + func(m *IdentityMappingStateV1) { v := "process"; m.NativeProcess = &v }, + } { + next := IdentityMappingStateV1{Schema: IdentityMappingStateSchemaV1, Profile: "exact", OperatorUID: "1000", OperatorGID: "1000"} + mutate(&next) + if _, err := ops.Operation.CommitIdentityMappingV1(state.Current, "", 0, next); err == nil { + t.Fatal("generic first-configuration writer admitted native fields") + } + } + stored := readOperationStateV1(t, ops) + if stored.IdentityMapping != nil { + t.Fatal("rejected generic writes changed state") + } + if _, err := ops.UpgradeNativeMapping(t.Context(), stored); err == nil { + t.Fatal("unconfigured owner acquired native state") + } + if err := os.WriteFile(path, legacy, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestNativeMappingV1BoundedAdmissionKeepsWholeRange(t *testing.T) { + path, ops, state, evidence, _ := nativeMappingFixtureV1(t) + state.IdentityMapping.Profile = "bounded" + writeMappingFixtureV1(t, path, state) + geometry := identitymapping.Geometry{Profile: "bounded", UIDs: []identitymapping.Interval{{ContainerStart: 0, HostStart: 100000, Count: 512}}, GIDs: []identitymapping.Interval{{ContainerStart: 0, HostStart: 200000, Count: 512}}} + effects := nativeEffectsV1(t, ops, state, geometry, evidence, "bounded") + admitted, err := ops.AdmitNativeExecution(t.Context(), effects, "bounded") + if err != nil { + t.Fatal(err) + } + if !sameExecutionGeometryV1(*admitted.IdentityMapping.acceptedGeometry(), geometry) || len(admitted.IdentityMapping.DormantUIDs)+len(admitted.IdentityMapping.DormantGIDs) != 0 { + t.Fatal("bounded admission projected or fabricated dormancy") + } + for _, change := range []string{"partial-accepted", "dormant"} { + t.Run(change, func(t *testing.T) { + s := cloneMappingStateV1(admitted) + s.IdentityMapping.PendingOperation = nil + if change == "partial-accepted" { + (*s.IdentityMapping.AcceptedUIDs)[0].Count = "511" + } else { + s.IdentityMapping.DormantUIDs = []IdentityMappingIntervalV1{{ContainerStart: "511", HostStart: "100511", Count: "1"}} + } + if _, err := EncodeStateV1(s); err == nil { + t.Fatal("bounded accepted authority changed") + } + }) + } +} diff --git a/internal/deploy/identity_mapping_native_test.go b/internal/deploy/identity_mapping_native_test.go new file mode 100644 index 00000000..8b55d668 --- /dev/null +++ b/internal/deploy/identity_mapping_native_test.go @@ -0,0 +1,474 @@ +package deploy + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/identitymapping" +) + +func nativeMappingFixtureV1(t *testing.T) (string, IdentityMappingOperationsV1, StateV1, *mappingOperationEvidenceV1, identitymapping.Geometry) { + t.Helper() + path, ops, state, evidence := operationFixtureV1(t, false, false) + lock := identityPolicyBuildLockFixtureV1(t) + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + document.Environment.Runtime.IdentityPolicy = &lock.IdentityPolicy.Policy + document.Environment.Runtime.User = blueprint.DefaultRuntimeUser + state.Blueprint, err = blueprint.EncodeResolvedDocumentV1(document) + if err != nil { + t.Fatal(err) + } + lock.BlueprintDigest, err = blueprint.ResolvedDocumentDigestV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + digest, err := ops.Operation.PublishBuildLock(lock, acceptBuildLockProfile) + if err != nil { + t.Fatal(err) + } + policy, err := RuntimePolicyDigestV1(lock.RuntimePolicy) + if err != nil { + t.Fatal(err) + } + state.Current = &EnvironmentGenerationState{Reference: state.Current.Reference, ImageDigest: lock.FinalImage.Digest, RootFSSubject: lock.FinalImage.RootFSSubject, Platform: lock.Platform, RuntimePolicyDigest: policy, BuildLockDigest: digest} + state.Platform = lock.Platform + writeMappingFixtureV1(t, path, state) + ops.ValidateProfileOwner = acceptBuildLockProfile + state, err = ops.UpgradeNativeMapping(t.Context(), state) + if err != nil { + t.Fatal(err) + } + evidence.handles = []string{"owned-data"} + return path, ops, state, evidence, canonicalExecutionGeometryV1(t, executionInputV1(t).Candidate) +} + +func nativeBindingV1(process string) NativeIdentityOperationV1 { + return NativeIdentityOperationV1{Engine: "rootless-podman-fixture", Process: process, RuntimeUsers: []string{"worker"}, RuntimeGroups: []string{"worker"}} +} + +func nativeEffectsV1(t *testing.T, ops IdentityMappingOperationsV1, state StateV1, candidate identitymapping.Geometry, evidence *mappingOperationEvidenceV1, process string) StateV1 { + t.Helper() + intent, err := ops.PrepareNativeExecution(t.Context(), state, candidate, process, nativeBindingV1(process)) + if err != nil { + t.Fatal(err) + } + effects, err := ops.BeginEffects(t.Context(), intent, process) + if err != nil { + t.Fatal(err) + } + evidence.effects = true + return effects +} + +func TestNativeMappingV1AdmissionIsOneDurableTransaction(t *testing.T) { + path, ops, state, evidence, candidate := nativeMappingFixtureV1(t) + // Recover-only history is reserved, not previously admitted. + reserved := mappingGeometryStateV1(executionGeometryV1([]uint32{600}, []uint32{600})) + state.IdentityMapping.UIDAssignments, state.IdentityMapping.GIDAssignments = reserved.UIDs, reserved.GIDs + writeMappingFixtureV1(t, path, state) + effects := nativeEffectsV1(t, ops, state, candidate, evidence, "instance-one") + if effects.IdentityMapping.AdmittedGeneration != nil || len(*effects.IdentityMapping.AcceptedUIDs) != 0 { + t.Fatal("intent or effects preparation created admission") + } + admitted, err := ops.AdmitNativeExecution(t.Context(), effects, "instance-one") + if err != nil { + t.Fatal(err) + } + m := admitted.IdentityMapping + if m.PendingOperation.Phase != "release-attempted" || !m.PendingOperation.Inhibited || m.AdmittedGeneration == nil || *m.NativeProcess != "instance-one" || *m.Engine != "rootless-podman-fixture" || m.Supervisor != nil { + t.Fatalf("incomplete admission/release-attempt boundary: %#v", m) + } + if !sameExecutionGeometryV1(*m.acceptedGeometry(), candidate) || len(m.DormantUIDs)+len(m.DormantGIDs) != 0 || len(m.UIDAssignments) <= len(candidate.UIDs) { + t.Fatal("admission promoted recover-only reservations or lost protected history") + } + before, _ := os.ReadFile(path) + for _, observation := range []StateV1{effects, admitted} { + if _, err := ops.AdmitNativeExecution(t.Context(), observation, "instance-one"); err == nil { + t.Fatal("repeated admission issued a second release grant") + } + } + if _, err := ops.Recover(t.Context(), admitted, "instance-one", true); err == nil { + t.Fatal("admitted release attempt was cancelled as unused") + } + evidence.verifyErr = errors.New("release acknowledgement or owned retirement is unknown") + if _, err := ops.Recover(t.Context(), admitted, "instance-one", false); err == nil { + t.Fatal("uncertain release was forgotten") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("rejected retry/cancellation/recovery changed durable authority") + } + evidence.verifyErr = nil + recovered, err := ops.Recover(t.Context(), admitted, "instance-one", false) + if err != nil || recovered.IdentityMapping.PendingOperation != nil || !reflect.DeepEqual(recovered.IdentityMapping.AcceptedUIDs, m.AcceptedUIDs) || !reflect.DeepEqual(recovered.IdentityMapping.AcceptedGIDs, m.AcceptedGIDs) { + t.Fatalf("checked exact acknowledgement/retirement recovery: %v", err) + } + if _, err := ops.PrepareNativeExecution(t.Context(), recovered, candidate, "second-operation", nativeBindingV1("instance-one")); err == nil { + t.Fatal("same admitted instance acquired another release attempt") + } + if _, err := ops.PrepareNativeExecution(t.Context(), recovered, candidate, "instance-two", nativeBindingV1("instance-two")); err != nil { + t.Fatalf("fresh inactive owned replacement was refused: %v", err) + } +} + +func TestNativeMappingV1NeverAdmittedRecoveryAndConfiguration(t *testing.T) { + for _, phase := range []string{"intent", "effects-pending"} { + t.Run(phase, func(t *testing.T) { + _, ops, state, evidence, candidate := nativeMappingFixtureV1(t) + intent, err := ops.PrepareNativeExecution(t.Context(), state, candidate, "recover", nativeBindingV1("recover")) + if err != nil { + t.Fatal(err) + } + if phase == "effects-pending" { + intent, err = ops.BeginEffects(t.Context(), intent, "recover") + if err != nil { + t.Fatal(err) + } + evidence.effects = true + } + if phase == "intent" { + if _, err := ops.Recover(t.Context(), intent, "recover", false); err == nil { + t.Fatal("unexecuted intent was recovered as effects") + } + } + recovered, err := ops.Recover(t.Context(), intent, "recover", phase == "intent") + if err != nil || recovered.IdentityMapping.AdmittedGeneration != nil || len(*recovered.IdentityMapping.AcceptedUIDs) != 0 || len(*recovered.IdentityMapping.AcceptedGIDs) != 0 { + t.Fatalf("recovery created admission: %v", err) + } + if phase == "effects-pending" && (!sameExecutionGeometryV1(recovered.IdentityMapping.geometry().numeric(), candidate) || !reflect.DeepEqual(recovered.IdentityMapping.RetainedStorage, evidence.handles)) { + t.Fatal("recovery lost reserved IDs or owned objects") + } + // Configuration and B recovery can grow R even in v2, never P. + configuration, err := ops.Configure(t.Context(), recovered, operationGeometryV1(100000), "configure") + if err != nil { + t.Fatal(err) + } + configuration, err = ops.BeginEffects(t.Context(), configuration, "configure") + if err != nil { + t.Fatal(err) + } + evidence.effects = true + if _, err := ops.ObserveEffects(t.Context(), configuration, candidate, "configure"); err == nil { + t.Fatal("configure accepted native projection semantics") + } + if _, err := ops.ObserveEffects(t.Context(), configuration, operationGeometryV1(100000), "configure"); err != nil { + t.Fatal(err) + } + configuration, err = ops.Recover(t.Context(), configuration, "configure", false) + if err != nil || configuration.IdentityMapping.AdmittedGeneration != nil || len(*configuration.IdentityMapping.AcceptedUIDs) != 0 { + t.Fatalf("configuration recovery created P: %v", err) + } + }) + } +} + +func TestNativeMappingV1AdmissionWriteFailureNeverGrantsRelease(t *testing.T) { + for _, afterReplace := range []bool{false, true} { + t.Run(fmt.Sprint(afterReplace), func(t *testing.T) { + path, ops, state, evidence, candidate := nativeMappingFixtureV1(t) + effects := nativeEffectsV1(t, ops, state, candidate, evidence, "uncertain") + fault := errors.New("lost durable write answer") + ops.write = func(path string, content []byte) error { + if afterReplace { + if err := writeAtomicStateFile(path, content, 0o600); err != nil { + return err + } + } + return fault + } + if _, err := ops.AdmitNativeExecution(t.Context(), effects, "uncertain"); !errors.Is(err, fault) { + t.Fatalf("uncertain admission granted release: %v", err) + } + stored := readOperationStateV1(t, ops) + if afterReplace { + if stored.IdentityMapping.PendingOperation.Phase != "release-attempted" || stored.IdentityMapping.AdmittedGeneration == nil { + t.Fatal("atomic replacement left partial native admission") + } + ops.write = nil + if _, err := ops.AdmitNativeExecution(t.Context(), stored, "uncertain"); err == nil { + t.Fatal("uncertain successful write was replayed") + } + } else if !reflect.DeepEqual(stored, effects) { + t.Fatal("pre-replacement failure changed P or pending ownership") + } + if _, err := os.Stat(path); err != nil { + t.Fatal("uncertain write removed state") + } + }) + } +} + +func TestNativeMappingV1SelectedEvidenceIsFresh(t *testing.T) { + for name, mutate := range map[string]func(*IdentityMappingOperationsV1, *StateV1, string){ + "no generation": func(_ *IdentityMappingOperationsV1, s *StateV1, _ string) { s.Current = nil }, + "legacy state": func(_ *IdentityMappingOperationsV1, s *StateV1, _ string) { + s.IdentityMapping.Schema = IdentityMappingStateSchemaV1 + s.IdentityMapping.ProcessModel, s.IdentityMapping.AcceptedUIDs, s.IdentityMapping.AcceptedGIDs = "", nil, nil + }, + "no validator": func(o *IdentityMappingOperationsV1, _ *StateV1, _ string) { o.ValidateProfileOwner = nil }, + "image": func(_ *IdentityMappingOperationsV1, s *StateV1, _ string) { + s.Current.ImageDigest = buildLockTestDigest("a") + }, + "rootfs": func(_ *IdentityMappingOperationsV1, s *StateV1, _ string) { + s.Current.RootFSSubject = buildLockTestDigest("a") + }, + "runtime policy": func(_ *IdentityMappingOperationsV1, s *StateV1, _ string) { + s.Current.RuntimePolicyDigest = buildLockTestDigest("a") + }, + "platform": func(_ *IdentityMappingOperationsV1, s *StateV1, _ string) { + s.Current.Platform, _ = blueprint.ParsePlatform("linux/arm64") + }, + "blueprint": func(_ *IdentityMappingOperationsV1, s *StateV1, _ string) { + d, _ := blueprint.DecodeResolvedDocumentV1(s.Blueprint) + d.Environment.Runtime.IdentityPolicy = nil + s.Blueprint, _ = blueprint.EncodeResolvedDocumentV1(d) + }, + "missing lock": func(_ *IdentityMappingOperationsV1, s *StateV1, _ string) { + s.Current.BuildLockDigest = buildLockTestDigest("a") + }, + "corrupt lock": func(_ *IdentityMappingOperationsV1, s *StateV1, path string) { + p := filepath.Join(filepath.Dir(path), buildLockDirectoryName, buildLockFilename(s.Current.BuildLockDigest)) + if err := os.WriteFile(p, []byte("corrupt"), 0o600); err != nil { + panic(err) + } + }, + "host authority": func(o *IdentityMappingOperationsV1, _ *StateV1, _ string) { + o.hostCheck = func(context.Context, string, []identitymapping.Interval, []identitymapping.Interval) (identitymapping.HostIdentity, error) { + return identitymapping.HostIdentity{}, errors.New("host authority changed") + } + }, + "no inventory": func(o *IdentityMappingOperationsV1, _ *StateV1, _ string) { o.Evidence = nil }, + } { + t.Run(name, func(t *testing.T) { + path, ops, state, _, candidate := nativeMappingFixtureV1(t) + mutate(&ops, &state, path) + writeMappingFixtureV1(t, path, state) + before, _ := os.ReadFile(path) + if _, err := ops.PrepareNativeExecution(t.Context(), state, candidate, "rejected", nativeBindingV1("rejected")); err == nil { + t.Fatal("unverified native intent accepted") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("failed evidence check changed authority") + } + }) + } +} + +func TestNativeMappingV1UpgradePreservesLegacyReservationsAndPendingOwnership(t *testing.T) { + for _, phase := range []string{"none", "intent", "effects-pending", "verified"} { + t.Run(phase, func(t *testing.T) { + path, ops, state, evidence := operationFixtureV1(t, false, false) + wire := mappingGeometryStateV1(operationGeometryV1(100000)) + state.IdentityMapping.UIDAssignments, state.IdentityMapping.GIDAssignments = wire.UIDs, wire.GIDs + state.IdentityMapping.RetainedStorage = []string{"historical-data"} + writeMappingFixtureV1(t, path, state) + var err error + if phase != "none" { + state, err = ops.Configure(t.Context(), state, operationGeometryV1(100000), "legacy-owner") + if err != nil { + t.Fatal(err) + } + if phase != "intent" { + state, err = ops.BeginEffects(t.Context(), state, "legacy-owner") + if err != nil { + t.Fatal(err) + } + evidence.effects = true + } + if phase == "verified" { + state.IdentityMapping.PendingOperation.Phase = phase + writeMappingFixtureV1(t, path, state) + } + } + prior := cloneMappingStateV1(state) + upgraded, err := ops.UpgradeNativeMapping(t.Context(), state) + if err != nil { + t.Fatal(err) + } + m := upgraded.IdentityMapping + if m.Schema != IdentityMappingStateSchemaV2 || m.acceptedGeometry() != nil || m.AdmittedGeneration != nil || !reflect.DeepEqual(m.UIDAssignments, prior.IdentityMapping.UIDAssignments) || !reflect.DeepEqual(m.RetainedStorage, prior.IdentityMapping.RetainedStorage) { + t.Fatal("upgrade changed reservation or admission provenance") + } + if phase != "none" { + if m.PendingOperation.OperationID != "legacy-owner" || m.PendingOperation.Phase != phase { + t.Fatal("upgrade replaced pending owner") + } + if _, err := ops.Configure(t.Context(), upgraded, operationGeometryV1(100000), "legacy-owner"); phase == "intent" && err != nil { + t.Fatal(err) + } + if _, err := ops.Recover(t.Context(), upgraded, "legacy-owner", phase == "intent"); err != nil { + t.Fatalf("original owner cannot reconcile: %v", err) + } + } + }) + } + for _, binding := range []string{"supervisor", "engine", "admitted"} { + t.Run(binding, func(t *testing.T) { + path, ops, state, _ := operationFixtureV1(t, false, false) + value := "legacy-binding" + if binding == "supervisor" || binding == "admitted" { + state.IdentityMapping.Supervisor = &value + } + if binding == "engine" || binding == "admitted" { + state.IdentityMapping.Engine = &value + } + if binding == "admitted" { + wire := mappingGeometryStateV1(operationGeometryV1(100000)) + state.IdentityMapping.UIDAssignments, state.IdentityMapping.GIDAssignments = wire.UIDs, wire.GIDs + state.IdentityMapping.AdmittedGeneration = state.Current + } + writeMappingFixtureV1(t, path, state) + before, _ := os.ReadFile(path) + if _, err := ops.UpgradeNativeMapping(t.Context(), state); err == nil { + t.Fatal("ambiguous/admitted legacy binding migrated") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("rejected legacy upgrade changed authority") + } + }) + } +} + +func TestNativeMappingV1RejectsStalePlanAndAdmissionEvidence(t *testing.T) { + for _, failure := range []string{"stale-owner", "stale-generation", "stale-revision", "host-change", "late-host-change", "unknown-runtime", "stored-projection", "no-evidence", "unverified-instance", "cancelled"} { + t.Run(failure, func(t *testing.T) { + path, ops, state, evidence, candidate := nativeMappingFixtureV1(t) + effects := nativeEffectsV1(t, ops, state, candidate, evidence, "fenced") + expected := cloneMappingStateV1(effects) + switch failure { + case "stale-owner": + expected.IdentityMapping.InstallationID = "different-owner" + case "stale-generation": + expected.Current.Reference += "-different" + case "stale-revision": + expected.IdentityMapping.Revision = "99" + case "host-change": + ops.hostCheck = func(context.Context, string, []identitymapping.Interval, []identitymapping.Interval) (identitymapping.HostIdentity, error) { + return identitymapping.HostIdentity{}, errors.New("delegation changed") + } + case "late-host-change": + calls := 0 + ops.hostCheck = func(context.Context, string, []identitymapping.Interval, []identitymapping.Interval) (identitymapping.HostIdentity, error) { + calls++ + if calls > 2 { + return identitymapping.HostIdentity{}, errors.New("delegation changed after observation") + } + return identitymapping.HostIdentity{Username: "operator", UID: 1000, GID: 1000}, nil + } + case "unknown-runtime": + expected.IdentityMapping.PendingOperation.Native.RuntimeGroups = []string{"undeclared"} + writeMappingFixtureV1(t, path, expected) + case "stored-projection": + expected.IdentityMapping.PendingOperation.Native.Active.UIDs = expected.IdentityMapping.PendingOperation.Native.Active.UIDs[:1] + writeMappingFixtureV1(t, path, expected) + case "no-evidence": + ops.Evidence = nil + case "unverified-instance": + evidence.verifyErr = errors.New("owned instance not verified") + case "cancelled": + } + ctx := t.Context() + if failure == "cancelled" { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + } + before, _ := os.ReadFile(path) + if _, err := ops.AdmitNativeExecution(ctx, expected, "fenced"); err == nil { + t.Fatal("stale or unverified admission succeeded") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("rejected admission changed reservations/provenance") + } + }) + } +} + +func TestNativeMappingV1PrepareInputAndInventoryFailures(t *testing.T) { + for _, failure := range []string{"operation-id", "engine-id", "process-id", "geometry", "runtime-user", "inventory-error", "inventory-order", "held-lock", "pending-build", "policy-lock", "invalid-observation"} { + t.Run(failure, func(t *testing.T) { + path, ops, state, evidence, candidate := nativeMappingFixtureV1(t) + native := nativeBindingV1("prepare") + operationID := "prepare" + switch failure { + case "operation-id": + operationID = "-invalid" + case "engine-id": + native.Engine = "-invalid" + case "process-id": + native.Process = "-invalid" + case "geometry": + candidate.UIDs[0].Count = 0 + case "runtime-user": + native.RuntimeUsers = []string{"undeclared"} + case "inventory-error": + evidence.planErr = errors.New("inventory unavailable") + case "inventory-order": + evidence.handles = []string{"b", "a"} + case "held-lock": + if err := ops.Operation.Unlock(); err != nil { + t.Fatal(err) + } + case "pending-build": + if err := ops.Operation.WritePendingBuild(validPendingBuild(t)); err != nil { + t.Fatal(err) + } + case "policy-lock": + lock, found, err := ops.Operation.ReadBuildLock(state.Current.BuildLockDigest, acceptBuildLockProfile) + if err != nil || !found { + t.Fatal(err) + } + lock.IdentityPolicy = nil + state.Current.BuildLockDigest, err = ops.Operation.PublishBuildLock(lock, acceptBuildLockProfile) + if err != nil { + t.Fatal(err) + } + writeMappingFixtureV1(t, path, state) + case "invalid-observation": + state.IdentityMapping.AcceptedUIDs = nil + } + before, _ := os.ReadFile(path) + if _, err := ops.PrepareNativeExecution(t.Context(), state, candidate, operationID, native); err == nil { + t.Fatal("invalid or unowned native intent accepted") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("failed prepare changed authority") + } + if _, err := ops.AdmitNativeExecution(t.Context(), state, operationID); err == nil { + t.Fatal("missing pending operation admitted") + } + }) + } +} + +func TestNativeMappingV1UpgradeRejectsStaleIntent(t *testing.T) { + path, ops, state, _ := operationFixtureV1(t, false, false) + intent, err := ops.Configure(t.Context(), state, operationGeometryV1(100000), "legacy") + if err != nil { + t.Fatal(err) + } + intent.IdentityMapping.Revision = "99" + writeMappingFixtureV1(t, path, intent) + before, _ := os.ReadFile(path) + if _, err := ops.UpgradeNativeMapping(t.Context(), intent); err == nil { + t.Fatal("stale legacy intent was upgraded") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("rejected upgrade changed legacy ownership") + } +} diff --git a/internal/deploy/identity_mapping_operations.go b/internal/deploy/identity_mapping_operations.go index 9066d184..58aacd87 100644 --- a/internal/deploy/identity_mapping_operations.go +++ b/internal/deploy/identity_mapping_operations.go @@ -13,6 +13,7 @@ import ( "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/identitymapping" + "github.com/omry/reploy/internal/providers" ) // IdentityMappingEvidenceV1 is a trusted effect-owner seam, not operator input. @@ -20,6 +21,8 @@ import ( // returns exact planned retained-storage handles without effects. Unused proves that the // exact intent's candidate has never been used, including storage/preparation. // Verify reconciles the recorded owner, operation, generations and exact handles. +// For native-execute it proves the exact inhibited instance before admission; +// in release-attempted it must prove its acknowledgement or owned retirement. // Container absence alone cannot satisfy any of these proofs. G/H supply real // effect owners; B5 exercises their metadata boundary with injected effects. type IdentityMappingEvidenceV1 interface { @@ -31,10 +34,11 @@ type IdentityMappingEvidenceV1 interface { // IdentityMappingOperationsV1 holds no independent authority or admission cache. // Each call rereads the selected owner under its existing operation lock. type IdentityMappingOperationsV1 struct { - Operation *OperationLock - Evidence IdentityMappingEvidenceV1 - hostCheck func(context.Context, string, []identitymapping.Interval, []identitymapping.Interval) (identitymapping.HostIdentity, error) - write func(string, []byte) error + Operation *OperationLock + Evidence IdentityMappingEvidenceV1 + ValidateProfileOwner providers.RequirementProfileOwnerValidator + hostCheck func(context.Context, string, []identitymapping.Interval, []identitymapping.Interval) (identitymapping.HostIdentity, error) + write func(string, []byte) error } type IdentityMappingStatusV1 struct { @@ -219,12 +223,19 @@ func (ops IdentityMappingOperationsV1) BeginEffects(ctx context.Context, expecte if ops.Evidence == nil { return fmt.Errorf("effects require fresh inactive inventory") } - handles, err := ops.Evidence.Plan(ctx, cloneMappingStateV1(*state), p.Candidate.numeric()) + active := p.Candidate.numeric() + if p.Native != nil { + active = p.Native.Active.numeric() + } + handles, err := ops.Evidence.Plan(ctx, cloneMappingStateV1(*state), active) handles = append([]string(nil), handles...) if err != nil || !reflect.DeepEqual(handles, p.OwnedObjects) { return fmt.Errorf("effect inventory differs from the recorded intent: %v", err) } p.Phase = "effects-pending" + if p.Native != nil { + p.ExpectedRevision = strconv.FormatUint(mappingDecimalV1(state.IdentityMapping.Revision)+1, 10) + } return nil }) } @@ -254,6 +265,9 @@ func (ops IdentityMappingOperationsV1) Recover(ctx context.Context, expected Sta if expected.IdentityMapping.PendingOperation.Phase == "intent" { return StateV1{}, fmt.Errorf("intent has no effects to reconcile; retain it or prove it unused") } + if expected.IdentityMapping.PendingOperation.Kind == "native-execute" { + return ops.recoverNative(ctx, expected) + } verified, err := ops.change(ctx, expected, func(state *StateV1) error { if err := ops.Evidence.Verify(ctx, cloneMappingStateV1(*state)); err != nil { return fmt.Errorf("exact effects remain unverified: %w", err) @@ -284,11 +298,18 @@ func (ops IdentityMappingOperationsV1) Recover(ctx context.Context, expected Sta func (ops IdentityMappingOperationsV1) checkPending(ctx context.Context, expected StateV1, operationID string) error { m := expected.IdentityMapping - if m == nil || m.PendingOperation == nil || m.PendingOperation.Kind != "configure" || m.PendingOperation.OperationID != operationID || - !reflect.DeepEqual(m.PendingOperation.ExpectedGeneration, expected.Current) || !reflect.DeepEqual(m.PendingOperation.PriorAccepted, m.AdmittedGeneration) || + if m == nil || m.PendingOperation == nil || (m.PendingOperation.Kind != "configure" && m.PendingOperation.Kind != "native-execute") || m.PendingOperation.OperationID != operationID || + !reflect.DeepEqual(m.PendingOperation.ExpectedGeneration, expected.Current) || m.PendingOperation.Phase != "release-attempted" && !reflect.DeepEqual(m.PendingOperation.PriorAccepted, m.AdmittedGeneration) || m.PendingOperation.Phase == "intent" && m.PendingOperation.ExpectedRevision != m.Revision { return fmt.Errorf("recovery requires its exact pending owner, operation and generations") } + if m.PendingOperation.Kind == "native-execute" { + if m.PendingOperation.Native == nil { + return fmt.Errorf("native recovery requires its recorded active map") + } + _, err := ops.checkNativePending(ctx, expected) + return err + } _, _, err := ops.check(ctx, expected, m.PendingOperation.Candidate.numeric()) return err } diff --git a/internal/deploy/identity_mapping_state.go b/internal/deploy/identity_mapping_state.go index 8cc6cb3c..8692b5e1 100644 --- a/internal/deploy/identity_mapping_state.go +++ b/internal/deploy/identity_mapping_state.go @@ -12,34 +12,42 @@ import ( ) const IdentityMappingStateSchemaV1 = "identity-mapping-v1" +const IdentityMappingStateSchemaV2 = "identity-mapping-v2" +const IdentityMappingNativeProcessModelV1 = "native-master-v1" -// IdentityMappingStateV1 is deployment-local retained authority, not execution -// admission. InstallationID identifies the mapping owner across uninstall. +// IdentityMappingStateV1 holds versioned deployment-local mapping authority. +// Protected assignments alone are not native admission; v2 records it separately. +// InstallationID identifies the mapping owner across uninstall. // InstallationBinding identifies only its currently installed service. type IdentityMappingStateV1 struct { - Schema string `json:"schema"` - InstallationID string `json:"installation_id"` - OwnerDir string `json:"owner_dir"` - InstallationBinding *string `json:"installation_binding"` - Revision string `json:"revision"` - OperatorUID string `json:"operator_uid"` - OperatorGID string `json:"operator_gid"` - Profile string `json:"profile"` - UIDAssignments []IdentityMappingIntervalV1 `json:"uid_assignments"` - GIDAssignments []IdentityMappingIntervalV1 `json:"gid_assignments"` - DormantUIDs []IdentityMappingIntervalV1 `json:"dormant_uids"` - DormantGIDs []IdentityMappingIntervalV1 `json:"dormant_gids"` - Supervisor *string `json:"supervisor"` - Engine *string `json:"engine"` - AdmittedGeneration *EnvironmentGenerationState `json:"admitted_generation"` - RetainedStorage []string `json:"retained_storage"` - Revoked bool `json:"revoked"` - PendingOperation *IdentityMappingOperationV1 `json:"pending_operation"` + Schema string `json:"schema"` + InstallationID string `json:"installation_id"` + OwnerDir string `json:"owner_dir"` + InstallationBinding *string `json:"installation_binding"` + Revision string `json:"revision"` + OperatorUID string `json:"operator_uid"` + OperatorGID string `json:"operator_gid"` + Profile string `json:"profile"` + UIDAssignments []IdentityMappingIntervalV1 `json:"uid_assignments"` + GIDAssignments []IdentityMappingIntervalV1 `json:"gid_assignments"` + DormantUIDs []IdentityMappingIntervalV1 `json:"dormant_uids"` + DormantGIDs []IdentityMappingIntervalV1 `json:"dormant_gids"` + Supervisor *string `json:"supervisor"` + Engine *string `json:"engine"` + AdmittedGeneration *EnvironmentGenerationState `json:"admitted_generation"` + RetainedStorage []string `json:"retained_storage"` + Revoked bool `json:"revoked"` + PendingOperation *IdentityMappingOperationV1 `json:"pending_operation"` + ProcessModel string `json:"process_model,omitempty"` + AcceptedUIDs *[]IdentityMappingIntervalV1 `json:"accepted_uids,omitempty"` + AcceptedGIDs *[]IdentityMappingIntervalV1 `json:"accepted_gids,omitempty"` + NativeProcess *string `json:"native_process,omitempty"` } // IdentityMappingOperationV1 retains an inhibited candidate separately from -// protected assignments. A pending candidate is reserved, never admitted. -// B5 owns operation transitions; later effect owners must verify exact handles. +// protected assignments. Intent and effects-pending reserve without admission; +// a native release-attempted operation retains its committed admission attempt. +// Trusted effect owners must verify exact handles for each phase. type IdentityMappingOperationV1 struct { OperationID string `json:"operation_id"` Kind string `json:"kind"` @@ -50,6 +58,7 @@ type IdentityMappingOperationV1 struct { OwnedObjects []string `json:"owned_objects"` Phase string `json:"phase"` Inhibited bool `json:"inhibited"` + Native *NativeIdentityOperationV1 `json:"native,omitempty"` } func validateIdentityMappingStateV1(state StateV1) error { @@ -57,7 +66,7 @@ func validateIdentityMappingStateV1(state StateV1) error { if m == nil { return nil } - if m.Schema != IdentityMappingStateSchemaV1 || !safeRecoveryIdentity(m.InstallationID) || !validMappingDecimalV1(m.Revision, 64, true) { + if (m.Schema != IdentityMappingStateSchemaV1 && m.Schema != IdentityMappingStateSchemaV2) || !safeRecoveryIdentity(m.InstallationID) || !validMappingDecimalV1(m.Revision, 64, true) { return fmt.Errorf("identity mapping requires its schema, stable owner ID and positive revision") } if !filepath.IsAbs(m.OwnerDir) || filepath.Clean(m.OwnerDir) != m.OwnerDir || !safeRecoveryIdentity(m.OwnerDir) { @@ -96,7 +105,7 @@ func validateIdentityMappingStateV1(state StateV1) error { if err := ValidateEnvironmentGenerationState(*m.AdmittedGeneration); err != nil { return fmt.Errorf("identity mapping admitted generation: %w", err) } - if len(m.UIDAssignments) == 0 || m.Supervisor == nil || m.Engine == nil { + if len(m.UIDAssignments) == 0 || m.Schema == IdentityMappingStateSchemaV1 && m.Supervisor == nil || m.Engine == nil { return fmt.Errorf("identity mapping admission requires retained assignments and bindings") } } @@ -114,6 +123,12 @@ func validateIdentityMappingStateV1(state StateV1) error { if i == 1 { protected = m.GIDAssignments } + if m.Schema == IdentityMappingStateSchemaV2 && m.AcceptedUIDs != nil && m.AcceptedGIDs != nil { + protected = *m.AcceptedUIDs + if i == 1 { + protected = *m.AcceptedGIDs + } + } for _, value := range values { if !slices.ContainsFunc(protected, func(old IdentityMappingIntervalV1) bool { return mappingDecimalV1(value.ContainerStart) >= mappingDecimalV1(old.ContainerStart) && @@ -128,9 +143,10 @@ func validateIdentityMappingStateV1(state StateV1) error { return err } if pending := m.PendingOperation; pending != nil { + nativeRelease := m.Schema == IdentityMappingStateSchemaV2 && pending.Kind == "native-execute" && pending.Phase == "release-attempted" if !safeRecoveryIdentity(pending.OperationID) || !safeRecoveryIdentity(pending.Kind) || !pending.Inhibited || !validMappingDecimalV1(pending.ExpectedRevision, 64, true) || mappingDecimalV1(pending.ExpectedRevision) > mappingDecimalV1(m.Revision) || - (pending.Phase != "intent" && pending.Phase != "effects-pending" && pending.Phase != "verified") { + (pending.Phase != "intent" && pending.Phase != "effects-pending" && pending.Phase != "verified" && !nativeRelease) { return fmt.Errorf("identity mapping pending operation requires exact inhibited authority and phase") } if pending.Candidate.Profile != m.Profile { @@ -156,7 +172,7 @@ func validateIdentityMappingStateV1(state StateV1) error { return err } } - return nil + return validateNativeMappingStateV1(m) } func (m IdentityMappingStateV1) geometry() IdentityMappingGeometryV1 { diff --git a/internal/deploy/identity_mapping_state_file.go b/internal/deploy/identity_mapping_state_file.go index 05042f61..ed357445 100644 --- a/internal/deploy/identity_mapping_state_file.go +++ b/internal/deploy/identity_mapping_state_file.go @@ -112,7 +112,8 @@ func (lock *OperationLock) CommitIdentityMappingV1(expectedGeneration *Environme } if next.InstallationID != "" || next.OwnerDir != "" || next.Revision != "" || next.InstallationBinding != nil || len(next.UIDAssignments)+len(next.GIDAssignments)+len(next.DormantUIDs)+len(next.DormantGIDs)+len(next.RetainedStorage) != 0 || - next.AdmittedGeneration != nil || next.Supervisor != nil || next.Engine != nil || next.Revoked || next.PendingOperation != nil { + next.AdmittedGeneration != nil || next.Supervisor != nil || next.Engine != nil || next.Revoked || next.PendingOperation != nil || + next.Schema != IdentityMappingStateSchemaV1 || next.ProcessModel != "" || next.AcceptedUIDs != nil || next.AcceptedGIDs != nil || next.NativeProcess != nil { return StateV1{}, fmt.Errorf("first mapping configuration cannot supply retained authority") } next.InstallationID, err = newOpaqueAdmissionIDV1(rand.Reader, "mapping", "mapping owner")