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")