Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/actions/deploy/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ runs:
LASTFM_API_KEY: op://ci-cd/lho-lambda/LASTFM_API_KEY
LASTFM_USERNAME: op://ci-cd/lho-lambda/LASTFM_USERNAME
SENTRY_DSN: op://ci-cd/lho-lambda/SENTRY_DSN
PUSHGATEWAY_AUTH_HEADER: op://ci-cd/lho-lambda/PUSHGATEWAY_AUTH_HEADER
OP_ENV_FILE: ".env"

- name: Set Terraform variables
Expand All @@ -67,7 +66,6 @@ runs:
echo "TF_VAR_lastfm_api_key=$LASTFM_API_KEY" >> $GITHUB_ENV
echo "TF_VAR_lastfm_username=$LASTFM_USERNAME" >> $GITHUB_ENV
echo "TF_VAR_sentry_dsn=$SENTRY_DSN" >> $GITHUB_ENV
echo "TF_VAR_pushgateway_auth_header=$PUSHGATEWAY_AUTH_HEADER" >> $GITHUB_ENV

- name: Terraform init
shell: bash
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ jobs:
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
# PAT with contents write; GITHUB_TOKEN cannot push to main under the main_protect ruleset
token: ${{ secrets.RELEASE_TOKEN }}

- name: Fetch latest commits
run: git stash && git fetch && git pull
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
# PAT with contents write; GITHUB_TOKEN cannot push to main under the main_protect ruleset
token: ${{ secrets.RELEASE_TOKEN }}

- name: Setup .NET
uses: actions/setup-dotnet@v4
Expand Down
16 changes: 15 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,22 @@ Runtime Configuration is the environment-backed settings required by the Lambda

Runtime Configuration should be one shared module used by the API Lambda, authorizer, and observability code. Observability should not maintain a separate environment reader.

Runtime Configuration should validate lazily by section. A missing provider secret should fail only the path that needs that provider. Missing Sentry or Pushgateway settings should disable that sink rather than fail requests. Missing authorizer API key should continue to deny authorization instead of throwing.
Runtime Configuration should validate lazily by section. A missing provider secret should fail only the path that needs that provider. Missing Sentry settings should disable that sink rather than fail requests. Missing authorizer API key should continue to deny authorization instead of throwing.

Runtime Configuration should expose typed section objects instead of raw environment-variable lookups. Sections should own their defaults, optional sink behaviour, and provider-specific credential rules.

Runtime Configuration should use typed `*Options` classes with `Section` constants, init properties/defaults, and targeted `IsValid(out errorMessage)` validation where a section has required values.

### Lambda Invocation

A Lambda Invocation is the observability envelope around one handler execution. Beginning an invocation starts the Sentry transaction and the duration timer; completing it finishes the transaction and emits the structured request log and Sentry invocation metrics in one place.

Handlers should not talk to individual observability sinks per request. They begin an invocation, record failures on it, set late-bound tags (provider, reason) as they become known, and complete it with a status code. Completion must fire even when the handler re-throws.

All sinks should share one canonical snake_case tag vocabulary (`request_id`, `operation`, `route`, `method`, `status_code`, `duration_ms`, `outcome`, `consumer`, `provider`, `reason`). A tag key should never be spelled differently in logs, metrics, and Sentry.

### Consumer

A Consumer is the calling application identified by the `x-consumer` header. The valid consumers are `lhowsam-dev`, `lhowsam-prod`, and `lhowsam-local`.

Consumer identity belongs to Runtime Configuration and should be one shared module. Normalisation maps a valid value to itself, a missing header to no consumer, and any other value to `unknown`. The authorizer denies unknown consumers; observability tags requests with the normalised consumer.
2 changes: 2 additions & 0 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseSharedCompilation>false</UseSharedCompilation>
<!-- The API serves ASCII/JSON only; skipping ICU load shortens Lambda cold start. -->
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
98 changes: 19 additions & 79 deletions src/Lho.Lambda.Authorizer/Functions/AuthorizerFunction.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Amazon.Lambda.Core;
Expand All @@ -11,40 +10,32 @@ namespace Lho.Lambda.Authorizer.Functions;

public class AuthorizerFunction
{
private static readonly HashSet<string> ValidConsumers = ["lhowsam-dev", "lhowsam-prod", "lhowsam-local"];

private readonly AuthorizerOptions _authorizerOptions;
private readonly ObservabilityOptions _observabilityOptions;

public AuthorizerFunction()
: this(RuntimeConfig.Current.Authorizer)
: this(RuntimeConfig.Current.Authorizer, RuntimeConfig.Current.Observability)
{

}

public AuthorizerFunction(AuthorizerOptions authorizerOptions)
public AuthorizerFunction(AuthorizerOptions authorizerOptions, ObservabilityOptions observabilityOptions)
{
_authorizerOptions = authorizerOptions;
_observabilityOptions = observabilityOptions;
}

public async Task<AuthorizerSimpleResponse> FunctionHandler(AuthorizerRequest request, ILambdaContext context)
{
var stopwatch = Stopwatch.StartNew();
var consumer = NormaliseConsumer(GetHeaderValue(request.Headers, "x-consumer"));
var consumer = Consumers.Normalise(GetHeaderValue(request.Headers, "x-consumer"));
var route = request.RouteKey ?? request.RequestContext?.Http?.Path ?? "authorizer";
var method = request.RequestContext?.Http?.Method ?? "AUTH";
var reason = "allowed";
var isAuthorized = false;
Exception? capturedException = null;
SentryTelemetry.Initialise(context.Logger);
using var transaction = SentryTelemetry.StartTransaction(context.Logger, $"{method} {route}", "http.server", new Dictionary<string, string?>
{
["function"] = context.FunctionName,
["request_id"] = context.AwsRequestId,
["operation"] = "authorizer",
["route"] = route,
["method"] = method,
["consumer"] = consumer
});

await using var invocation = LambdaInvocation.Begin(
context,
new InvocationDescriptor("authorizer", route, method, consumer, LogEventPrefix: "authorizer.request"),
_observabilityOptions);

try
{
Expand All @@ -53,72 +44,31 @@ public async Task<AuthorizerSimpleResponse> FunctionHandler(AuthorizerRequest re

if (!SecureCompare(apiKey, validKey))
{
reason = "invalid_api_key";
invocation.SetReason("invalid_api_key");
return new AuthorizerSimpleResponse(false);
}

if (consumer is not null && !ValidConsumers.Contains(consumer))
if (consumer is not null && !Consumers.IsValid(consumer))
{
reason = "invalid_consumer";
invocation.SetReason("invalid_consumer");
return new AuthorizerSimpleResponse(false);
}

isAuthorized = true;
invocation.SetReason("allowed");
return new AuthorizerSimpleResponse(true);
}
catch (Exception exception)
{
capturedException = exception;
reason = "exception";
StructuredLog.Error(context.Logger, "authorizer.error", exception, new Dictionary<string, object?>
{
["requestId"] = context.AwsRequestId,
["function"] = context.FunctionName,
["route"] = route,
["method"] = method,
["consumer"] = consumer
});
await SentryTelemetry.CaptureExceptionAsync(exception, context.Logger, new Dictionary<string, string?>
{
["function"] = context.FunctionName,
["request_id"] = context.AwsRequestId,
["operation"] = "authorizer",
["route"] = route,
["consumer"] = consumer
});
invocation.SetReason("exception");
await invocation.RecordFailureAsync(exception);
throw;
}
finally
{
stopwatch.Stop();
var statusCode = isAuthorized ? 200 : 401;
var outcome = isAuthorized ? "success" : "denied";
StructuredLog.Info(context.Logger, "authorizer.request", new Dictionary<string, object?>
{
["requestId"] = context.AwsRequestId,
["function"] = context.FunctionName,
["route"] = route,
["method"] = method,
["statusCode"] = statusCode,
["durationMs"] = Math.Round(stopwatch.Elapsed.TotalMilliseconds, 2),
["outcome"] = outcome,
["reason"] = reason,
["consumer"] = consumer
});

var metric = new InvocationMetric(
FunctionName: context.FunctionName,
Operation: "authorizer",
Route: route,
Method: method,
StatusCode: statusCode,
DurationMs: stopwatch.Elapsed.TotalMilliseconds,
Outcome: outcome,
Consumer: consumer);

transaction?.Finish(statusCode, capturedException);
await PrometheusMetrics.PushInvocationAsync(metric, context.Logger);
await SentryTelemetry.RecordInvocationAsync(metric, context.Logger);
await invocation.CompleteAsync(
isAuthorized ? 200 : 401,
isAuthorized ? "success" : "denied");
}
}

Expand Down Expand Up @@ -153,14 +103,4 @@ private static bool SecureCompare(string? first, string? second)
return firstBytes.Length == secondBytes.Length &&
CryptographicOperations.FixedTimeEquals(firstBytes, secondBytes);
}

private static string? NormaliseConsumer(string? consumer)
{
return consumer switch
{
"lhowsam-prod" or "lhowsam-dev" or "lhowsam-local" => consumer,
null or "" => null,
_ => "unknown"
};
}
}
4 changes: 3 additions & 1 deletion src/Lho.Lambda.Authorizer/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Amazon.Lambda.Core;
using Amazon.Lambda.Serialization.SystemTextJson;
using Lho.Lambda.Authorizer.Serialization;

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
[assembly: LambdaSerializer(typeof(SourceGeneratorLambdaJsonSerializer<LambdaEventsJsonContext>))]
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
using Lho.Lambda.Authorizer.Models;

namespace Lho.Lambda.Authorizer.Serialization;

[JsonSerializable(typeof(AuthorizerRequest))]
[JsonSerializable(typeof(AuthorizerSimpleResponse))]
public partial class LambdaEventsJsonContext : JsonSerializerContext;
12 changes: 12 additions & 0 deletions src/Lho.Lambda.Observability/InvocationMetric.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Lho.Lambda.Observability;

public sealed record InvocationMetric(
string FunctionName,
string Operation,
string Route,
string Method,
int StatusCode,
double DurationMs,
string Outcome,
string? Consumer = null,
string? Provider = null);
19 changes: 19 additions & 0 deletions src/Lho.Lambda.Observability/InvocationTags.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Lho.Lambda.Observability;

public static class InvocationTags
{
public const string Function = "function";
public const string RequestId = "request_id";
public const string Operation = "operation";
public const string Route = "route";
public const string Method = "method";
public const string Consumer = "consumer";
public const string Provider = "provider";
public const string Reason = "reason";
public const string StatusCode = "status_code";
public const string DurationMs = "duration_ms";
public const string Outcome = "outcome";
public const string TimeRange = "time_range";
public const string ErrorType = "error_type";
public const string ErrorMessage = "error_message";
}
Loading
Loading