diff --git a/apps/api/internal/identityruntime/manager.go b/apps/api/internal/identityruntime/manager.go index 5a9cb46..e43e10a 100644 --- a/apps/api/internal/identityruntime/manager.go +++ b/apps/api/internal/identityruntime/manager.go @@ -17,6 +17,7 @@ type Repository interface { IdentityConfigurationRevision(context.Context, string) (identity.Revision, error) ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error) MarkIdentityRevisionValidated(context.Context, string, int64, string, string) (identity.Revision, error) + RevalidateActiveIdentityRevision(context.Context, string, int64, string, string) (identity.Revision, error) MarkIdentityRevisionFailed(context.Context, string, int64, string, string, string) (identity.Revision, error) ActivateIdentityRevision(context.Context, string, int64, string, string) (identity.Revision, bool, error) DisableActiveIdentityRevision(context.Context, int64, string, string) (identity.Revision, error) @@ -84,14 +85,27 @@ func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion if err != nil { return identity.Revision{}, err } - if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionSuperseded { + if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionSuperseded && revision.State != identity.RevisionActive { return identity.Revision{}, identity.ErrRevisionConflict } candidate, buildErr := manager.builder.Build(ctx, revision) if buildErr != nil { - _, _ = manager.repository.MarkIdentityRevisionFailed(ctx, id, expectedVersion, "validation_failed", traceID, auditID) + if revision.State != identity.RevisionActive { + _, _ = manager.repository.MarkIdentityRevisionFailed(ctx, id, expectedVersion, "validation_failed", traceID, auditID) + } return identity.Revision{}, buildErr } + if revision.State == identity.RevisionActive { + revalidated, err := manager.repository.RevalidateActiveIdentityRevision(ctx, id, expectedVersion, traceID, auditID) + if err != nil { + candidate.Close() + return identity.Revision{}, err + } + candidate.Revision = revalidated + old := manager.current.Swap(candidate) + retireRuntime(old) + return revalidated, nil + } candidate.Close() return manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID) } diff --git a/apps/api/internal/identityruntime/manager_test.go b/apps/api/internal/identityruntime/manager_test.go index 4d1f9c0..2d03e99 100644 --- a/apps/api/internal/identityruntime/manager_test.go +++ b/apps/api/internal/identityruntime/manager_test.go @@ -36,6 +36,15 @@ func (f *runtimeRepositoryFake) MarkIdentityRevisionValidated(_ context.Context, f.revisions[id] = revision return revision, nil } +func (f *runtimeRepositoryFake) RevalidateActiveIdentityRevision(_ context.Context, id string, expected int64, traceID, auditID string) (identity.Revision, error) { + revision := f.revisions[id] + if revision.Version != expected || revision.State != identity.RevisionActive { + return identity.Revision{}, identity.ErrRevisionConflict + } + revision.Version, revision.LastTraceID, revision.LastAuditID = revision.Version+1, traceID, auditID + f.revisions[id], f.active = revision, revision + return revision, nil +} func (f *runtimeRepositoryFake) MarkIdentityRevisionFailed(_ context.Context, id string, expected int64, category, _, _ string) (identity.Revision, error) { revision := f.revisions[id] if revision.Version != expected { @@ -136,3 +145,20 @@ func TestRollbackValidatesAndAtomicallyReplacesActiveRuntime(t *testing.T) { t.Fatalf("rollback did not replace active runtime: revision=%#v current=%#v", rolledBack, manager.Current()) } } + +func TestActiveRevalidationSwapsOnlyAfterSuccessfulValidation(t *testing.T) { + active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active} + builder := &runtimeBuilderFake{} + manager := NewManager(repository, builder) + original := &Runtime{Revision: active} + manager.current.Store(original) + + revalidated, err := manager.Validate(context.Background(), active.ID, active.Version, "trace-new", "audit-new") + if err != nil { + t.Fatal(err) + } + if revalidated.State != identity.RevisionActive || revalidated.Version != 5 || manager.Current() == original || manager.Current().Revision.LastAuditID != "audit-new" { + t.Fatalf("active runtime was not safely revalidated: %#v", revalidated) + } +} diff --git a/apps/api/internal/store/identity_configurations.go b/apps/api/internal/store/identity_configurations.go index 0c66665..ec52884 100644 --- a/apps/api/internal/store/identity_configurations.go +++ b/apps/api/internal/store/identity_configurations.go @@ -153,6 +153,18 @@ RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID)) return revision, err } +func (s *Store) RevalidateActiveIdentityRevision(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` +UPDATE gateway_identity_configuration_revisions SET validated_at=now(),last_error_category=NULL, +last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now() +WHERE id=$1::uuid AND version=$2 AND state='active' +RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, identity.ErrRevisionConflict + } + return revision, err +} + func (s *Store) MarkIdentityRevisionFailed(ctx context.Context, id string, expectedVersion int64, category, traceID, auditID string) (identity.Revision, error) { revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` UPDATE gateway_identity_configuration_revisions SET state='failed',last_error_category=NULLIF($3,''),