Repository navigation
fix(api): give every boot guard an explicit process role, and fail closed on unusable JWT keys #507
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1eeea2c
5dcae6e
73dcbf9
7141cfc
e265297
b556f36
cd9302b
098bdd0
5710a26
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,15 +10,18 @@ namespace Cluckwork.Api.Hosting; | |
|
|
||
| internal static class CluckworkTelemetryServiceCollectionExtensions | ||
| { | ||
| // isServingProcess is false for the one-off CLI verbs (migrate / seed / | ||
| // recover-admin). They dispatch after Build(), so a throw during service | ||
| // registration would abort them before CliDispatcher ever ran — see the | ||
| // endpoint-resolution block below for why that matters. | ||
| // role is OneShot for the operator verbs (migrate / seed / recover-admin / | ||
| // bootstrap-admin / healthcheck). They dispatch after Build(), so a throw | ||
| // during service registration would abort them before CliDispatcher ever ran | ||
| // — see the endpoint-resolution block below for why that matters. #347: the | ||
| // role is computed once in Program.cs and passed in, so this is the guard's | ||
| // own declared scope rather than a positional bool whose meaning lives at a | ||
| // call site far away. | ||
| public static CluckworkTelemetryRegistration AddCluckworkTelemetry( | ||
| this IServiceCollection services, | ||
| IConfiguration configuration, | ||
| IHostEnvironment environment, | ||
| bool isServingProcess = true) | ||
| ProcessRole role = ProcessRole.Serving) | ||
| { | ||
| // ReadFrom.Services lets DI-registered enrichers/sinks join the pipeline — | ||
| // the integration tests tap the logger this way (#214). | ||
|
|
@@ -55,32 +58,52 @@ public static CluckworkTelemetryRegistration AddCluckworkTelemetry( | |
|
|
||
| // OTLP export is config-gated (#214). Endpoint and protocol are validated | ||
| // eagerly so configuration errors fail at boot. | ||
| var otlp = configuration.GetSection(OtlpOptions.SectionName).Get<OtlpOptions>() | ||
| ?? new OtlpOptions(); | ||
| var protocol = otlp.ParseProtocol(); | ||
| var isProduction = environment.IsProduction(); | ||
| var otlp = new OtlpOptions(); | ||
| var protocol = OtlpOptions.DefaultProtocol; | ||
| Uri? traceEndpoint = null; | ||
| Uri? metricsEndpoint = null; | ||
| if (otlp.Enabled) | ||
| try | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a one-shot command inherits an invalid non-string OTLP value such as AGENTS.md reference: AGENTS.md:L58-L58 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed and fixed in
This was the fifth instance of one class across four rounds, so I stopped scoping subsystems one at a time and added |
||
| { | ||
| try | ||
| // Everything that can reject this section sits INSIDE this boundary — | ||
| // binding, protocol parsing, endpoint resolution — and that | ||
| // completeness is the whole point. Two rounds of review each found one | ||
| // more part of it outside: first ParseProtocol (so `Otlp:Protocol=bogus` | ||
| // killed `recover-admin` with SIGABRT 134, #331 verbatim, three lines | ||
| // from its own fix), then the BINDING itself (so a non-boolean | ||
| // `Otlp:AllowInsecureEndpoint` threw from Get<OtlpOptions>() before any | ||
| // of it ran). Scope the subsystem, not the setting that bit you. | ||
| otlp = configuration.GetSection(OtlpOptions.SectionName).Get<OtlpOptions>() | ||
| ?? new OtlpOptions(); | ||
| protocol = otlp.ParseProtocol(); | ||
| if (otlp.Enabled) | ||
| { | ||
| traceEndpoint = otlp.ResolveTraceEndpoint(isProduction); | ||
| metricsEndpoint = otlp.ResolveMetricsEndpoint(isProduction); | ||
| } | ||
| catch (InvalidOperationException ex) when (!isServingProcess) | ||
| { | ||
| // A one-off verb must not die on a telemetry misconfiguration it | ||
| // does not depend on. `recover-admin` in particular is the | ||
| // break-glass path for a locked-out farm (#265) and is | ||
| // deliberately NOT environment-gated — an unrelated bad | ||
| // Otlp:Endpoint blocking an emergency password reset would be a | ||
| // worse failure than the one this validation prevents. Degrade to | ||
| // export DISABLED rather than exporting insecurely: the verb runs, | ||
| // and nothing leaves the process over an unvalidated endpoint. | ||
| Console.Error.WriteLine( | ||
| $"warning: OTLP export disabled for this command — {ex.Message}"); | ||
| } | ||
| } | ||
| catch (InvalidOperationException ex) when (role is ProcessRole.OneShot) | ||
| { | ||
| // A one-off verb must not die on a telemetry misconfiguration it does | ||
| // not depend on. `recover-admin` in particular is the break-glass path | ||
| // for a locked-out farm (#265) and is deliberately NOT | ||
| // environment-gated — an unrelated bad Otlp:* setting blocking an | ||
| // emergency password reset would be a worse failure than the one this | ||
| // validation prevents. Degrade to export DISABLED rather than | ||
| // exporting insecurely: the verb runs, and nothing leaves the process | ||
| // over an unvalidated endpoint. | ||
| // | ||
| // Everything is reset, not just the part that threw: if the trace | ||
| // endpoint resolved and the metrics endpoint did not, keeping the | ||
| // first would export half a signal set from a configuration this | ||
| // process has already declared unusable — and a partially bound | ||
| // `otlp` would still feed Headers to an exporter. | ||
| otlp = new OtlpOptions(); | ||
| protocol = OtlpOptions.DefaultProtocol; | ||
| traceEndpoint = null; | ||
| metricsEndpoint = null; | ||
| Console.Error.WriteLine( | ||
| $"warning: OTLP export disabled for this command — {ex.Message}"); | ||
| } | ||
|
|
||
| Action<OtlpExporterOptions> ConfigureOtlpExporter(Uri endpoint) => options => | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a numeric or Boolean rate-limit setting cannot be converted, for example
RateLimiting__Login__PermitLimit=bogus,Get<RateLimitingOptions>()throws before this new role-scopedtry, so every one-shot verb can still be aborted by configuration for inbound HTTP machinery it never uses. Include binding in the degradation boundary and then replace the unusable result with defaults.AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in
b556f36d. Reproduced:RateLimiting__Login__PermitLimit=bogus→Failed to convert configuration value ... to type 'System.Int32', exit 134 onrecover-admin.Get<RateLimitingOptions>()is inside the boundary now, and the degrade replaces the unusable result with defaults as you suggested. The table also gained a row for the binding failure specifically, separate from the validation rows, because binding and validation were role-scoped at different times — which is exactly how this was missed.