{"record":{"id":"c40bb2c218c8f069","repo":"elsa-workflows/elsa-core","slug":"a-managed-secret-writer-returned-the-live-secret-reference","errorCode":null,"errorMessage":"A managed secret writer returned the live secret reference instead of a fresh staged reference.","messagePattern":"A managed secret writer returned the live secret reference instead of a fresh staged reference\\.","errorType":"exception","errorClass":"InvalidOperationException","httpStatus":null,"severity":"error","filePath":"src/modules/Elsa.ExternalAuthentication/Endpoints/Connections/ConnectionManagementEndpoints.cs","lineNumber":388,"sourceCode":"            return;\n        }\n        if (effective.Connection.Revision != revision)\n        {\n            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status412PreconditionFailed, \"revision_conflict\", \"The connection has changed; reload it before replacing its secret.\", cancellationToken);\n            return;\n        }\n        if (!adapters.TryGet(effective.Connection.AdapterType, out var adapter) || !adapter.Describe().Fields.Any(x => x.IsSecretBinding && string.Equals(x.Name, fieldName, StringComparison.Ordinal)))\n        {\n            await ConnectionEndpointSupport.SendErrorAsync(HttpContext, StatusCodes.Status400BadRequest, \"undeclared_secret_field\", \"The adapter does not declare this secret field.\", cancellationToken);\n            return;\n        }\n\n        using var value = new SensitiveString(request.Value);\n        var stagedBinding = await writer.StageAsync(new(effective.Connection.Id, fieldName, value), cancellationToken);\n        if (effective.Connection.SecretBindings.TryGetValue(fieldName, out var liveBinding) &&\n            string.Equals(liveBinding.ResolverType, stagedBinding.ResolverType, StringComparison.Ordinal) &&\n            string.Equals(liveBinding.Reference, stagedBinding.Reference, StringComparison.Ordinal))\n            throw new InvalidOperationException(\"A managed secret writer returned the live secret reference instead of a fresh staged reference.\");\n        var candidate = IdentityProviderConnectionCloner.Clone(effective.Connection);\n        candidate.SecretBindings[fieldName] = stagedBinding;\n        ManagementConnectionMutationResult result;\n        try\n        {\n            result = await management.UpdateAsync(candidate.Id, candidate, revision, User, tenantAccessor.TenantId, false, cancellationToken: cancellationToken);\n        }\n        catch\n        {\n            await CleanupAfterExceptionalFailureAsync();\n            throw;\n        }\n        if (result is not ManagementConnectionMutationResult.Success(var connection))\n        {\n            await ManagedSecretBindingCleanup.TryRemoveAsync(writer, stagedBinding, effective.Connection.Id, logger);\n            await ConnectionEndpointSupport.SendMutationResultAsync(HttpContext, result, management, cancellationToken);\n            return;\n        }","sourceCodeStart":370,"sourceCodeEnd":406,"githubUrl":"https://github.com/elsa-workflows/elsa-core/blob/fe9217bdfa0e27f0e09e45006eb6898f616e513d/src/modules/Elsa.ExternalAuthentication/Endpoints/Connections/ConnectionManagementEndpoints.cs#L370-L406","documentation":"When updating a connection's secret, the endpoint stages a fresh value via the managed secret writer and then verifies the staged binding does not equal the connection's existing live binding (same ResolverType AND same Reference). If the writer handed back a binding identical to the live one, it failed to create a new staged version, so the update would be a no-op masquerading as a rotation — this InvalidOperationException guards that invariant.","triggerScenarios":"Posting a secret update to the connection management endpoint where writer.StageAsync returns a binding whose ResolverType and Reference exactly match the connection's existing SecretBindings[fieldName] — i.e. a custom/buggy ISecretWriter that returns the live binding instead of staging a fresh secret version.","commonSituations":"Custom secret writer implementations that cache and return the existing binding for unchanged values; a writer bug where staging is skipped when the value appears unchanged; test doubles (fakes) for ISecretWriter that echo back the stored binding.","solutions":["Fix or replace the ISecretWriter implementation so StageAsync always returns a new staged reference distinct from the live binding","Ensure the value passed to StageAsync is the new user-supplied value (request.Value), not the stored value read back from the connection","If your writer intentionally dedupes identical values, change the flow to short-circuit before staging instead of returning the live reference","Use the production managed secret writer rather than a fake/mock that echoes bindings"],"exampleFix":"// before: custom writer returns existing binding for unchanged values\npublic ValueTask<SecretBinding> StageAsync(...) => new(connection.SecretBindings[field]); // wrong\n// after\npublic ValueTask<SecretBinding> StageAsync(StageRequest request, CancellationToken ct)\n    => new(new SecretBinding { ResolverType = ResolverType, Reference = CreateStagedReference(request) }); // fresh reference","handlingStrategy":"try-catch","validationCode":"// before calling the endpoint, ensure the writer stages distinct references\nvar staged = await writer.StageAsync(stageRequest, ct);\nif (connection.SecretBindings.TryGetValue(field, out var live) &&\n    live.ResolverType == staged.ResolverType && live.Reference == staged.Reference)\n    throw new InvalidOperationException(\"Writer returned the live binding; fix the ISecretWriter.\");","typeGuard":"bool IsFreshReference(SecretBinding staged, SecretBinding? live) =>\n    live is null || !string.Equals(live.ResolverType, staged.ResolverType, StringComparison.Ordinal) ||\n    !string.Equals(live.Reference, staged.Reference, StringComparison.Ordinal);","tryCatchPattern":"try { await UpdateConnectionSecretAsync(request, ct); }\ncatch (InvalidOperationException ex) when (ex.Message.Contains(\"live secret reference\"))\n{ logger.LogError(ex, \"Secret writer did not stage a fresh reference for field {Field}.\", fieldName); }","preventionTips":["Unit-test custom ISecretWriter implementations to assert StageAsync always returns a new reference","Never feed a stored value back into StageAsync; always pass the new user input","Use integration tests that rotate a connection secret end-to-end","Keep mocks out of production paths; fakes that echo bindings will trip this invariant"],"tags":["secrets","staging","invariant"],"backgroundTag":"internal-invariant-violation","analyzedSha":"fe9217bdfa0e27f0e09e45006eb6898f616e513d","analyzedAt":"2026-09-13T20:32:34.702Z","contentChangedAt":"2026-09-13T20:32:34.702Z","schemaVersion":2},"datasetVersion":"2026-09-15T23:17:13.987Z"}