diff --git a/Services/Auth/AuthService.cs b/Services/Auth/AuthService.cs index fc5848e..602bf50 100644 --- a/Services/Auth/AuthService.cs +++ b/Services/Auth/AuthService.cs @@ -84,23 +84,52 @@ public void InvalidateUserCache() // --- allowedUsers Table --- /// - /// Gets the CIPP roles for a user from the allowedUsers table. - /// Returns null if user is not authorized (not in table and AllowAllTenantUsers is false). + /// Gets the roles for a user from the allowedUsers table, keyed on a single identifier. + /// Returns null if the user is not authorized (not in the table and AllowAllTenantUsers is false). /// Returns ["anonymous", "authenticated"] as default roles if AllowAllTenantUsers is true. /// - public async Task GetUserRoles(string upn, CancellationToken ct = default) + public Task GetUserRoles(string upn, CancellationToken ct = default) => + GetUserRoles(new[] { upn }, ct); + + /// + /// Gets the roles for a user who may be listed in the allowedUsers table under any of several + /// identifiers, and unions the roles of every row that matches. This is what lets a non-Entra + /// login (a GitHub user) be authorised by either its login name or its numeric id — an admin can + /// list whichever is stable for them — and equally lets an Entra user be listed by UPN or object + /// id. A row keyed on any supplied identifier contributes its roles; empty identifiers are ignored. + /// Returns null (unauthorized) only when NONE of the identifiers is in the table and + /// AllowAllTenantUsers is false; otherwise the default roles when nothing matched, or the union + /// plus anonymous/authenticated when something did. + /// + public async Task GetUserRoles(IEnumerable identifiers, CancellationToken ct = default) { - var user = await GetAllowedUser(upn, ct); - if (user == null) + var roles = new List(); + var matched = false; + + foreach (var identifier in identifiers) + { + if (string.IsNullOrEmpty(identifier)) continue; + var user = await GetAllowedUser(identifier, ct); + if (user is null) continue; + + matched = true; + foreach (var role in user.Roles) + { + if (!roles.Contains(role, StringComparer.Ordinal)) roles.Add(role); + } + } + + if (!matched) { if (!_settings.Auth.AllowAllTenantUsers) { - _logger.LogWarning("[Auth] User {Upn} not in allowedUsers table — denied (AllowAllTenantUsers=false)", upn); + _logger.LogWarning("[Auth] None of [{Identifiers}] in allowedUsers table — denied (AllowAllTenantUsers=false)", + string.Join(", ", identifiers.Where(i => !string.IsNullOrEmpty(i)))); return null; } return new[] { "anonymous", "authenticated" }; } - var roles = new List(user.Roles); + if (!roles.Contains("anonymous")) roles.Add("anonymous"); if (!roles.Contains("authenticated")) roles.Add("authenticated"); return roles.ToArray(); diff --git a/Services/Auth/EasyAuthPrincipal.cs b/Services/Auth/EasyAuthPrincipal.cs index eae3c5c..8be18f2 100644 --- a/Services/Auth/EasyAuthPrincipal.cs +++ b/Services/Auth/EasyAuthPrincipal.cs @@ -12,7 +12,13 @@ namespace Craft.Auth; /// Entra object id of the user or service principal. /// Client application id, present on client-credentials tokens. /// The idtyp claim — "app" for app-only tokens. -public sealed record EasyAuthClaims(string? Upn, string? ObjectId, string? AppId, string? IdentityType) +/// +/// Provider login for a signed-in principal that carries no UPN — e.g. the GitHub username. Lets a +/// non-Entra interactive user be resolved and authorised the same way a UPN user is. For such a +/// principal carries the provider's own stable user id (the GitHub numeric +/// id), so the hosted app can match on either the login or the id. +/// +public sealed record EasyAuthClaims(string? Upn, string? ObjectId, string? AppId, string? IdentityType, string? Login = null) { /// /// Whether this is an app-only (client-credentials) token rather than a signed-in user. @@ -72,7 +78,7 @@ public static string Encode(T principal) => /// public static EasyAuthClaims ExtractClaims(JsonElement root) { - string? upn = null, preferredUsername = null, oid = null, appId = null, idtyp = null; + string? upn = null, preferredUsername = null, oid = null, appId = null, idtyp = null, login = null; if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("claims", out var claims) && @@ -104,6 +110,16 @@ public static EasyAuthClaims ExtractClaims(JsonElement root) case "idtyp": idtyp ??= val; break; + // GitHub (and other non-Entra providers) carry a login rather than a UPN, and no + // Entra object id. App Service emits the login as 'urn:github:login' and the numeric + // id as 'urn:github:id'; the id is reused as the object id (a github principal never + // also carries an Entra objectidentifier) so it surfaces as userId downstream. + case "urn:github:login": + login ??= val; + break; + case "urn:github:id": + oid ??= val; + break; default: break; } @@ -118,7 +134,8 @@ public static EasyAuthClaims ExtractClaims(JsonElement root) string.IsNullOrEmpty(upn) ? null : upn, string.IsNullOrEmpty(oid) ? null : oid, string.IsNullOrEmpty(appId) ? null : appId, - string.IsNullOrEmpty(idtyp) ? null : idtyp); + string.IsNullOrEmpty(idtyp) ? null : idtyp, + string.IsNullOrEmpty(login) ? null : login); } /// diff --git a/Services/Caching/CacheService.cs b/Services/Caching/CacheService.cs index b668a8c..85fe8f5 100644 --- a/Services/Caching/CacheService.cs +++ b/Services/Caching/CacheService.cs @@ -116,9 +116,9 @@ public CacheService(ILogger logger, CraftSettings settings, bool e } /// - /// Build a cache key from endpoint, query params (excluding control params), and user roles. + /// Build a cache key from endpoint, query params (excluding control params), and the caller. /// - public string BuildCacheKey(string endpoint, IQueryCollection query, string? userRoleHash) + public string BuildCacheKey(string endpoint, IQueryCollection query, string? userKey) { var sb = new StringBuilder(endpoint); @@ -129,19 +129,32 @@ public string BuildCacheKey(string endpoint, IQueryCollection query, string? use sb.Append('|').Append(kv.Key).Append('=').Append(kv.Value); } - if (!string.IsNullOrEmpty(userRoleHash)) + if (!string.IsNullOrEmpty(userKey)) { - sb.Append("|_roles=").Append(userRoleHash); + sb.Append("|_user=").Append(userKey); } return sb.ToString(); } /// - /// Extract a stable hash of the user's roles from the x-ms-client-principal header. - /// Returns null if the header is missing or unparseable. + /// Extract a stable per-user key component from the x-ms-client-principal header, so a cached + /// response is only ever served back to the same caller that produced it. + /// + /// Keyed to the identity, not the role set: two callers with identical roles are still different + /// users and must not share entries, because a cache hit skips the handler and with it every + /// per-user authorization and filtering it would have applied. Uses the normalised principal's + /// userId (Entra object id, GitHub numeric id, or a service principal's object/app id), + /// falling back to userDetails. + /// + /// + /// Returns null when there is no principal (an anonymous request) or the header is unparseable; + /// all such requests share one bucket, which is correct because they share one (empty) identity. + /// Hashed rather than raw: the value lands in the in-memory index keys and in cache log lines, + /// and there is no reason to carry a UPN through either. + /// /// - public static string? GetUserRoleHash(HttpContext context) + public static string? GetUserKey(HttpContext context) { if (!context.Request.Headers.TryGetValue("x-ms-client-principal", out var headerValue)) return null; @@ -150,28 +163,25 @@ public string BuildCacheKey(string endpoint, IQueryCollection query, string? use { var json = Encoding.UTF8.GetString(Convert.FromBase64String(headerValue.ToString())); using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; - if (doc.RootElement.TryGetProperty("userRoles", out var rolesElement)) - { - var roles = new List(); - foreach (var role in rolesElement.EnumerateArray()) - { - var r = role.GetString(); - if (r != null && r != "anonymous" && r != "authenticated") - roles.Add(r); - } - roles.Sort(StringComparer.OrdinalIgnoreCase); - var joined = string.Join(",", roles); - // Short hash — just for cache key differentiation, not security - var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(joined)))[..16]; - return hash; - } + var id = GetStringProperty(root, "userId"); + if (string.IsNullOrEmpty(id)) id = GetStringProperty(root, "userDetails"); + if (string.IsNullOrEmpty(id)) return null; + + // Short hash — just for cache key differentiation, not security. + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(id)))[..16]; } - catch { /* swallow parse errors — treat as no roles */ } + catch { /* swallow parse errors — treat as no principal */ } return null; } + private static string? GetStringProperty(JsonElement root, string name) => + root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var el) + ? el.GetString() + : null; + /// /// Get the TTL for a given endpoint. /// diff --git a/Services/Configuration/EgressLimitSettings.cs b/Services/Configuration/EgressLimitSettings.cs new file mode 100644 index 0000000..5d3a7a8 --- /dev/null +++ b/Services/Configuration/EgressLimitSettings.cs @@ -0,0 +1,104 @@ +using System.Globalization; + +namespace Craft.Configuration; + +/// +/// Per-instance daily API egress cap. Sums the outbound response bytes served to app-only API clients +/// (client-credentials callers, idp=aad) across the WHOLE instance against a single daily budget and — +/// once a budget is set — sheds further API requests with HTTP 429 + Retry-After until the next +/// UTC midnight. Interactive (UI) callers are never counted and never capped. +/// +/// This is a bandwidth cap, distinct from (a request +/// RATE cap) and (a simultaneous-in-flight cap). It +/// exists because a caller can pull tens of GB/day of egress while staying comfortably under both of +/// those — a single slow, large request every few seconds. +/// +/// +/// Two-step activation. Accounting (and the middleware) turns on when the env var named by +/// — default CIPP_HOSTED, the same flag the SKU profiles key off — is +/// present, or when CRAFT_API_EGRESS_LIMIT_ENABLED forces it. Enforcement is a separate +/// step: 429s only begin once (or its env override) is above zero, so a +/// deployment can run accounting-only first to observe real numbers before it starts rejecting. +/// +/// +/// State is one counter plus the UTC date it belongs to, kept in memory and flushed to a small local +/// file every (and on shutdown) so a restart does not reset the day's total. +/// Nothing is read back from Azure — the figure is only what this instance has served since the file +/// was last valid for today. +/// +/// +public class EgressLimitSettings +{ + /// + /// Name of the env var whose presence (any non-empty value) marks the hosted environment and turns + /// egress accounting on. Defaults to CIPP_HOSTED — the same flag + /// uses — so a single env var lights up both. Blank + /// means never auto-enable (only CRAFT_API_EGRESS_LIMIT_ENABLED=true can then turn it on). + /// + public string? HostedEnv { get; set; } = "CIPP_HOSTED"; + + /// + /// Daily egress budget in bytes for the whole instance. 0 (default) = accounting only, no 429s. + /// Env override: CRAFT_API_EGRESS_LIMIT_BYTES (e.g. 1073741824 for 1 GB/day). + /// + public long BytesPerDay { get; set; } + + /// + /// Seconds between background flushes of the counter to disk; a flush also runs on shutdown, and a + /// flush is skipped entirely when nothing changed since the last one, so an idle instance writes + /// nothing. The request path never touches disk. Default 60. Floored at 1. Env override: + /// CRAFT_API_EGRESS_FLUSH_SECONDS. + /// + public int FlushSeconds { get; set; } = 60; + + internal const string EnabledEnv = "CRAFT_API_EGRESS_LIMIT_ENABLED"; + internal const string BytesEnv = "CRAFT_API_EGRESS_LIMIT_BYTES"; + internal const string FlushEnv = "CRAFT_API_EGRESS_FLUSH_SECONDS"; + + /// + /// Whether egress accounting (and therefore the middleware) should be active, resolved through + /// . CRAFT_API_EGRESS_LIMIT_ENABLED wins when set — true forces + /// it on even off-host (for testing), false is a kill switch even in the hosted env — + /// otherwise it is on exactly when the var is present. + /// + public bool ResolveEnabled(Func env) + { + ArgumentNullException.ThrowIfNull(env); + + var forced = ParseFlag(env(EnabledEnv)); + if (forced is not null) return forced.Value; + + return !string.IsNullOrWhiteSpace(HostedEnv) && !string.IsNullOrWhiteSpace(env(HostedEnv)); + } + + /// Convenience overload resolving against the real process environment. + public bool ResolvedEnabled => ResolveEnabled(Environment.GetEnvironmentVariable); + + /// + /// Resolved daily budget in bytes, honouring the CRAFT_API_EGRESS_LIMIT_BYTES env override + /// (which wins when it parses to a non-negative long). 0 = accounting only / no enforcement. + /// + public long ResolvedBytesPerDay => + long.TryParse(Environment.GetEnvironmentVariable(BytesEnv), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var fromEnv) && fromEnv >= 0 + ? fromEnv + : Math.Max(0, BytesPerDay); + + /// + /// Resolved flush interval in seconds, honouring the CRAFT_API_EGRESS_FLUSH_SECONDS env + /// override (which wins when it parses to a positive int). Floored at 1. + /// + public int ResolvedFlushSeconds => + int.TryParse(Environment.GetEnvironmentVariable(FlushEnv), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var fromEnv) && fromEnv > 0 + ? fromEnv + : Math.Max(1, FlushSeconds); + + // Tri-state flag parse (mirrors Craft.Hosting.EnvFlag, inlined to keep Configuration free of a + // dependency on Hosting): null when unset/blank, true for "true"/"1" (any casing), false otherwise. + private static bool? ParseFlag(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + return value.Equals("true", StringComparison.OrdinalIgnoreCase) || value == "1"; + } +} diff --git a/Services/Configuration/RateLimitSettings.cs b/Services/Configuration/RateLimitSettings.cs index c168026..ca0aab8 100644 --- a/Services/Configuration/RateLimitSettings.cs +++ b/Services/Configuration/RateLimitSettings.cs @@ -43,6 +43,13 @@ public class RateLimitSettings /// public int ApiConcurrencyLimit { get; set; } + /// + /// Per-instance daily API egress (bandwidth) cap. See . Distinct + /// from the rate and concurrency caps above: those bound requests/second and simultaneous-in-flight, + /// this bounds total outbound bytes/day. Off unless the deployment is hosted. + /// + public EgressLimitSettings Egress { get; set; } = new(); + /// Resolved enabled state, honouring the CRAFT_RATELIMIT_ENABLED environment override. public bool IsEnabled => Enabled diff --git a/Services/Hosting/ApiEgressLimiterMiddleware.cs b/Services/Hosting/ApiEgressLimiterMiddleware.cs new file mode 100644 index 0000000..4f37b8d --- /dev/null +++ b/Services/Hosting/ApiEgressLimiterMiddleware.cs @@ -0,0 +1,98 @@ +using System.Globalization; + +namespace Craft.Hosting; + +/// +/// Sheds app-only API traffic once the instance has served its daily egress budget, and records the +/// outbound bytes of the responses it lets through. Runs only for API clients (client-credentials +/// callers); every interactive request passes straight through after a single header check. +/// +/// Registered only when egress accounting is enabled (hosted env, or forced) — see +/// CraftHostBuilderExtensions.AddCraftEgressLimiter. Placement mirrors the rate limiter: after +/// the auth middleware (so an app-only caller's AppId is resolved) and after static file serving (so a +/// page load's assets are never charged). Enforcement (the 429) only bites once a budget is configured; +/// with no budget it counts silently, which is the accounting-only rollout phase. +/// +/// +public sealed class ApiEgressLimiterMiddleware +{ + private readonly RequestDelegate _next; + private readonly EgressLedger _ledger; + private readonly ILogger _logger; + + public ApiEgressLimiterMiddleware(RequestDelegate next, EgressLedger ledger, ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(loggerFactory); + _next = next ?? throw new ArgumentNullException(nameof(next)); + _ledger = ledger ?? throw new ArgumentNullException(nameof(ledger)); + _logger = loggerFactory.CreateLogger("Craft.Hosting.ApiEgressLimiter"); + } + + public async Task InvokeAsync(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + // UI and anonymous callers are never counted or capped — the feature is only about app-only + // automation. One header check and out, so interactive traffic pays essentially nothing. + if (!CallerClassifier.IsApiClient(context)) + { + await _next(context); + return; + } + + // Already over budget for today → shed before running the (often minute-long) downstream call, + // saving both the compute and the egress. Accounting is post-hoc, so the request that tips the + // total over still completes; the NEXT one is the first to be refused. Combined with the API + // concurrency cap, the overshoot is bounded to (concurrency × largest response). + if (_ledger.ShouldReject()) + { + await RejectAsync(context); + return; + } + + // Count the body this request writes. Swapping Response.Body captures both a direct + // Body.WriteAsync and Response.WriteAsync(string), since the response writer is re-adapted onto + // our stream — see CountingStream. + var original = context.Response.Body; + var counting = new CountingStream(original); + context.Response.Body = counting; + try + { + await _next(context); + } + finally + { + context.Response.Body = original; + _ledger.Record(counting.BytesWritten); + } + } + + private async Task RejectAsync(HttpContext context) + { + var retryAfter = _ledger.SecondsToNextUtcMidnight(); + var resetUtc = _ledger.NextUtcMidnightIso(); + + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.Response.Headers.RetryAfter = retryAfter.ToString(CultureInfo.InvariantCulture); + context.Response.ContentType = "application/json"; + + // A shed request is otherwise invisible to the operator. Warning, not Error: expected and + // client-caused, but the caller (its AppId, via the same key the rate limiter logs) is exactly + // who you want to identify. Never let logging turn the shed into a 500. + try + { + _logger.LogWarning( + "Egress cap reached — 429 for {Client} on {Method} {Path}; served {Bytes}/{Cap} bytes today, Retry-After {RetryAfter}s", + RateLimitPartitionKey.Resolve(context), context.Request.Method, context.Request.Path.Value, + _ledger.CurrentBytes, _ledger.CapBytes, retryAfter); + } + catch { /* logging must never break the response */ } + + // The rejection body itself is deliberately not counted — it is tiny and we are already over. + await context.Response.WriteAsync( + "{\"error\":\"egress_quota_exceeded\"," + + "\"message\":\"Daily API egress limit reached for this instance. Requests resume after the UTC daily reset.\"," + + $"\"retryAfterSeconds\":{retryAfter}," + + $"\"resetUtc\":\"{resetUtc}\"}}"); + } +} diff --git a/Services/Hosting/CacheProfiler.cs b/Services/Hosting/CacheProfiler.cs index 74a6d00..082c520 100644 --- a/Services/Hosting/CacheProfiler.cs +++ b/Services/Hosting/CacheProfiler.cs @@ -8,8 +8,8 @@ namespace Craft.Hosting; /// cheap check. Windowed averages (µs/request) are logged every /// cacheable requests at Warning level so they surface without Info spam. /// -/// Segments per cacheable (List* GET) request: auth (the x-ms-client-principal middleware) → roleHash -/// (GetUserRoleHash: base64 decode + JSON parse + SHA256) → keyBuild (BuildCacheKey) → get (whole +/// Segments per cacheable (List* GET) request: auth (the x-ms-client-principal middleware) → userKey +/// (GetUserKey: base64 decode + JSON parse + SHA256) → keyBuild (BuildCacheKey) → get (whole /// CacheService.Get), of which getDisk (File.Exists + File.ReadAllTextAsync of the body) is the disk-backed /// part. set is the miss-path disk write. hits/misses count the outcome. /// @@ -20,8 +20,8 @@ internal static class CacheProfiler private const long ReportEvery = 2000; - private static long _count, _auth, _roleHash, _keyBuild, _get, _getDisk, _set, _hits, _misses, _memHits; - private static long _lc, _la, _lrh, _lkb, _lg, _lgd, _ls, _lh, _lm, _lmh; + private static long _count, _auth, _userKey, _keyBuild, _get, _getDisk, _set, _hits, _misses, _memHits; + private static long _lc, _la, _luk, _lkb, _lg, _lgd, _ls, _lh, _lm, _lmh; private static ILogger? _logger; internal static void SetLogger(ILogger logger) => _logger = logger; @@ -39,9 +39,9 @@ internal static class CacheProfiler internal static void RecordSet(long ticks) => Interlocked.Add(ref _set, ticks); /// One cacheable request: the handler-side segments + hit/miss. Advances the window counter. - internal static void RecordRequest(long roleHashTicks, long keyBuildTicks, long getTicks, bool hit) + internal static void RecordRequest(long userKeyTicks, long keyBuildTicks, long getTicks, bool hit) { - Interlocked.Add(ref _roleHash, roleHashTicks); + Interlocked.Add(ref _userKey, userKeyTicks); Interlocked.Add(ref _keyBuild, keyBuildTicks); Interlocked.Add(ref _get, getTicks); if (hit) Interlocked.Increment(ref _hits); else Interlocked.Increment(ref _misses); @@ -51,13 +51,13 @@ internal static void RecordRequest(long roleHashTicks, long keyBuildTicks, long private static void Report(long n) { - long c = n, a = _auth, rh = _roleHash, kb = _keyBuild, g = _get, gd = _getDisk, s = _set, h = _hits, m = _misses, mh = _memHits; + long c = n, a = _auth, uk = _userKey, kb = _keyBuild, g = _get, gd = _getDisk, s = _set, h = _hits, m = _misses, mh = _memHits; long dn = c - _lc; if (dn <= 0) return; double Avg(long cur, long last) => (cur - last) * 1_000_000.0 / Stopwatch.Frequency / dn; _logger?.LogWarning( - "[CacheProfile] window={Dn} hits={H} (mem={Mh}) misses={M} us/req: auth={A:F1} roleHash={Rh:F1} keyBuild={Kb:F1} get={G:F1} (disk={Gd:F1}) set={S:F1}", - dn, h - _lh, mh - _lmh, m - _lm, Avg(a, _la), Avg(rh, _lrh), Avg(kb, _lkb), Avg(g, _lg), Avg(gd, _lgd), Avg(s, _ls)); - _lc = c; _la = a; _lrh = rh; _lkb = kb; _lg = g; _lgd = gd; _ls = s; _lh = h; _lm = m; _lmh = mh; + "[CacheProfile] window={Dn} hits={H} (mem={Mh}) misses={M} us/req: auth={A:F1} userKey={Uk:F1} keyBuild={Kb:F1} get={G:F1} (disk={Gd:F1}) set={S:F1}", + dn, h - _lh, mh - _lmh, m - _lm, Avg(a, _la), Avg(uk, _luk), Avg(kb, _lkb), Avg(g, _lg), Avg(gd, _lgd), Avg(s, _ls)); + _lc = c; _la = a; _luk = uk; _lkb = kb; _lg = g; _lgd = gd; _ls = s; _lh = h; _lm = m; _lmh = mh; } } diff --git a/Services/Hosting/CountingStream.cs b/Services/Hosting/CountingStream.cs new file mode 100644 index 0000000..a481c41 --- /dev/null +++ b/Services/Hosting/CountingStream.cs @@ -0,0 +1,71 @@ +namespace Craft.Hosting; + +/// +/// A write-through decorator that counts the bytes written to it and delegates +/// everything else to the inner stream. Used by to measure a +/// response body without buffering it. +/// +/// Assigning to one of these captures every +/// body write path: a direct Response.Body.WriteAsync(...), and also Response.WriteAsync(...) +/// / Response.BodyWriter, because the framework re-adapts the response pipe writer onto whatever +/// stream Body currently is. +/// +/// +internal sealed class CountingStream : Stream +{ + private readonly Stream _inner; + private long _bytesWritten; + + public CountingStream(Stream inner) => _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + + /// Total bytes written through this stream so far. + public long BytesWritten => Interlocked.Read(ref _bytesWritten); + + // ── Writes: count, then delegate ────────────────────────────────────────────────────────────── + // Counting the attempted length (rather than awaiting completion) keeps the hot path allocation-free. + // A partial write on a faulted response over-counts by at most one buffer, which is harmless and + // conservative for a cap. + + public override void Write(byte[] buffer, int offset, int count) + { + Interlocked.Add(ref _bytesWritten, count); + _inner.Write(buffer, offset, count); + } + + public override void Write(ReadOnlySpan buffer) + { + Interlocked.Add(ref _bytesWritten, buffer.Length); + _inner.Write(buffer); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + Interlocked.Add(ref _bytesWritten, count); + return _inner.WriteAsync(buffer, offset, count, cancellationToken); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + Interlocked.Add(ref _bytesWritten, buffer.Length); + return _inner.WriteAsync(buffer, cancellationToken); + } + + public override void WriteByte(byte value) + { + Interlocked.Increment(ref _bytesWritten); + _inner.WriteByte(value); + } + + // ── Everything else: pure delegation so behaviour matches the real response stream exactly ────── + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => _inner.CanSeek; + public override bool CanWrite => _inner.CanWrite; + public override long Length => _inner.Length; + public override long Position { get => _inner.Position; set => _inner.Position = value; } + public override void Flush() => _inner.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => _inner.FlushAsync(cancellationToken); + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); + public override void SetLength(long value) => _inner.SetLength(value); +} diff --git a/Services/Hosting/CraftAuthMiddleware.cs b/Services/Hosting/CraftAuthMiddleware.cs index fa4b4bf..8a3cb6c 100644 --- a/Services/Hosting/CraftAuthMiddleware.cs +++ b/Services/Hosting/CraftAuthMiddleware.cs @@ -103,9 +103,18 @@ private static async Task TryNormalisePrincipalAsync( return true; } - if (string.IsNullOrEmpty(claims.Upn)) return true; - - var roles = await authService.GetUserRoles(claims.Upn); + // A signed-in interactive user: an Entra UPN, or a provider login (e.g. GitHub) for a + // principal that carries no UPN. Both are resolved against the allowedUsers table and + // normalised to the same SWA shape; a UPN wins if a principal somehow carries both. A + // principal with neither (an unrecognised provider) passes through untouched, exactly as + // before. For a login-only principal, ObjectId holds the provider's numeric id, so userId + // and userDetails together let the hosted app match on either the id or the login. + var userName = !string.IsNullOrEmpty(claims.Upn) ? claims.Upn : claims.Login; + if (string.IsNullOrEmpty(userName)) return true; + + // Resolve roles by the display name AND the stable object id (a GitHub user's numeric id, + // an Entra user's oid), so an allowedUsers row keyed on either one grants the user its roles. + var roles = await authService.GetUserRoles(new[] { userName, claims.ObjectId }); if (roles is null) { // Authenticated by the platform but not authorised here. Strip the header so nothing @@ -119,12 +128,12 @@ private static async Task TryNormalisePrincipalAsync( context.Request.Headers["x-ms-client-principal"] = EasyAuthPrincipal.Encode(new { identityProvider = realIdp, - userId = claims.ObjectId ?? claims.Upn, - userDetails = claims.Upn, + userId = claims.ObjectId ?? userName, + userDetails = userName, userRoles = roles, }); context.Request.Headers["x-ms-client-principal-idp"] = "azureStaticWebApps"; - context.Request.Headers["x-ms-client-principal-name"] = claims.Upn; + context.Request.Headers["x-ms-client-principal-name"] = userName; return true; } diff --git a/Services/Hosting/CraftHostBuilderExtensions.cs b/Services/Hosting/CraftHostBuilderExtensions.cs index 8e7b5ab..28222ff 100644 --- a/Services/Hosting/CraftHostBuilderExtensions.cs +++ b/Services/Hosting/CraftHostBuilderExtensions.cs @@ -409,4 +409,27 @@ public static IServiceCollection AddCraftRateLimiter( return services; } + + /// + /// Registers the per-instance API egress ledger and its background flush service, but only when + /// egress accounting is enabled — i.e. the deployment is hosted (the env var named by + /// RateLimit.Egress.HostedEnv, default CIPP_HOSTED, is present) or + /// CRAFT_API_EGRESS_LIMIT_ENABLED forces it on. When off, nothing is registered and the + /// middleware is never added, so a non-hosted instance pays nothing. See + /// and . + /// + public static IServiceCollection AddCraftEgressLimiter( + this IServiceCollection services, CraftSettings settings) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(settings); + + if (!settings.RateLimit.Egress.ResolvedEnabled) return services; + + // Singleton so the middleware and the flush loop share one counter; the same instance is the + // hosted service that loads on start and flushes on a timer and on shutdown. + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + return services; + } } diff --git a/Services/Hosting/EgressLedger.cs b/Services/Hosting/EgressLedger.cs new file mode 100644 index 0000000..76ce844 --- /dev/null +++ b/Services/Hosting/EgressLedger.cs @@ -0,0 +1,224 @@ +using System.Globalization; +using System.Text.Json; +using Craft.Configuration; + +namespace Craft.Hosting; + +/// +/// Instance-wide daily egress counter for app-only API clients, persisted to a small local file so a +/// restart does not reset the day's total. One number for the whole instance (deliberately NOT per +/// client): records the outbound bytes of each API-client +/// response into it, and asks it whether the instance is over budget before letting the next API +/// request run. +/// +/// The request path only ever touches memory under a short lock — no per-request IO. A background timer +/// flushes the counter to disk every FlushSeconds (and once more on shutdown), and only when it +/// has changed, so an idle instance writes nothing. The file is loaded in the constructor (before the +/// host serves traffic); if it is stamped with today's UTC date the total is restored, otherwise the +/// day starts fresh. Nothing is read back from Azure — see . +/// +/// +/// The file lives in the same directory as the log files (, +/// e.g. {home}/logs), which is the writable area confirmed to survive restarts and crashes and is +/// lost only on a slice/image replacement — when a fresh day is the correct behaviour anyway. Storing it +/// there also means it follows any App__FileLogging__Directory override the deployment sets. +/// +/// +public sealed class EgressLedger : BackgroundService +{ + private readonly ILogger _logger; + private readonly long _capBytes; + private readonly int _flushSeconds; + private readonly string _filePath; + private readonly Func _utcNow; + + private readonly object _lock = new(); + private DateOnly _dateUtc; + private long _bytes; + private bool _dirty; + + private static readonly JsonSerializerOptions s_json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + /// Production constructor — resolves cap/flush from settings and stores the ledger file in + /// the same directory as the log files, which is the writable area confirmed to survive restarts and + /// crashes (and honours an App__FileLogging__Directory override). + public EgressLedger(ILogger logger, CraftSettings settings) + : this(logger, + (settings ?? throw new ArgumentNullException(nameof(settings))).RateLimit.Egress.ResolvedBytesPerDay, + settings.RateLimit.Egress.ResolvedFlushSeconds, + Path.Combine(settings.FileLogging.ResolvedDirectory, "egress-ledger.json")) + { + } + + /// Test/explicit constructor. defaults to the real clock. + internal EgressLedger(ILogger logger, long capBytes, int flushSeconds, string filePath, + Func? utcNow = null) + { + _logger = logger; + _capBytes = Math.Max(0, capBytes); + _flushSeconds = Math.Max(1, flushSeconds); + _filePath = filePath; + _utcNow = utcNow ?? (() => DateTime.UtcNow); + _dateUtc = DateOnly.FromDateTime(_utcNow()); + Load(); + } + + /// Daily budget in bytes; 0 = accounting only (never rejects). + public long CapBytes => _capBytes; + + /// + /// True when a budget is set and the instance has already served at least that many bytes today. + /// Rolls the counter over to a new UTC day first, so this self-resets at midnight with no scheduler. + /// + public bool ShouldReject() + { + if (_capBytes <= 0) return false; + lock (_lock) + { + RolloverIfNeeded(); + return _bytes >= _capBytes; + } + } + + /// Add the outbound bytes of one response to today's running total. + public void Record(long bytes) + { + if (bytes <= 0) return; + lock (_lock) + { + RolloverIfNeeded(); + _bytes += bytes; + _dirty = true; + } + } + + /// Bytes served so far today (for logging / a future metrics surface). + public long CurrentBytes + { + get { lock (_lock) { RolloverIfNeeded(); return _bytes; } } + } + + /// Seconds from now until the next UTC midnight, floored at 1 — the Retry-After a + /// rejected caller is handed so it comes back once the daily window has reset. + public int SecondsToNextUtcMidnight() + { + var now = _utcNow(); + return Math.Max(1, (int)Math.Ceiling((now.Date.AddDays(1) - now).TotalSeconds)); + } + + /// ISO-8601 timestamp of the next UTC midnight, for the rejection body. + public string NextUtcMidnightIso() => + _utcNow().Date.AddDays(1).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture); + + // Must be called under _lock. + private void RolloverIfNeeded() + { + var today = DateOnly.FromDateTime(_utcNow()); + if (today == _dateUtc) return; + _dateUtc = today; + _bytes = 0; + _dirty = true; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "[Egress] API egress accounting active — cap {Cap}, flush every {Flush}s, file {File}", + _capBytes > 0 ? $"{_capBytes} bytes/day" : "accounting only (no cap)", + _flushSeconds, _filePath); + + try + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_flushSeconds)); + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + Flush(); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Host shutting down (the timer await was cancelled) — expected; fall through to the final flush. + } + + Flush(); // final flush so the last window of counts survives a graceful restart + } + + /// Load the persisted counter. Restores only when the file is stamped with today's UTC + /// date; a stale or unreadable file leaves today starting from zero. + internal void Load() + { + try + { + if (!File.Exists(_filePath)) return; + + var state = JsonSerializer.Deserialize(File.ReadAllText(_filePath), s_json); + if (state is null) return; + + var today = DateOnly.FromDateTime(_utcNow()); + var sameDay = DateOnly.TryParseExact(state.DateUtc, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var stored) && stored == today; + + if (sameDay && state.Bytes >= 0) + { + lock (_lock) + { + _dateUtc = today; + _bytes = state.Bytes; + _dirty = false; + } + _logger.LogInformation("[Egress] Restored {Bytes} bytes already served today from {File}", + state.Bytes, _filePath); + } + else + { + _logger.LogInformation("[Egress] Ledger file not for today ({Stored}) — starting the day fresh", + state.DateUtc); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Egress] Failed to load ledger — starting fresh"); + } + } + + /// Rewrite the counter to disk via a temp file + atomic rename, but only when it has + /// changed since the last flush. Failures are logged and retried on the next tick. + internal void Flush() + { + try + { + LedgerState snapshot; + lock (_lock) + { + if (!_dirty) return; + RolloverIfNeeded(); + snapshot = new LedgerState + { + DateUtc = _dateUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + Bytes = _bytes, + }; + _dirty = false; + } + + var dir = Path.GetDirectoryName(_filePath)!; + if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + + var tmp = _filePath + ".tmp"; + File.WriteAllText(tmp, JsonSerializer.Serialize(snapshot, s_json)); + File.Move(tmp, _filePath, overwrite: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Egress] Failed to flush ledger to disk"); + lock (_lock) { _dirty = true; } // don't lose the change — try again next tick + } + } + + private sealed class LedgerState + { + public string DateUtc { get; set; } = ""; + public long Bytes { get; set; } + } +} diff --git a/Services/Hosting/Endpoints/PowerShellDispatchEndpoint.cs b/Services/Hosting/Endpoints/PowerShellDispatchEndpoint.cs index 3cbd0c8..72394d8 100644 --- a/Services/Hosting/Endpoints/PowerShellDispatchEndpoint.cs +++ b/Services/Hosting/Endpoints/PowerShellDispatchEndpoint.cs @@ -176,10 +176,10 @@ private static void InvalidateScopedOrAll(HttpContext context, CacheService cach var profiling = CacheProfiler.Enabled; var t0 = profiling ? Stopwatch.GetTimestamp() : 0; - var userRoleHash = CacheService.GetUserRoleHash(context); + var userKey = CacheService.GetUserKey(context); var t1 = profiling ? Stopwatch.GetTimestamp() : 0; - var key = cache.BuildCacheKey(endpoint, context.Request.Query, userRoleHash); + var key = cache.BuildCacheKey(endpoint, context.Request.Query, userKey); var t2 = profiling ? Stopwatch.GetTimestamp() : 0; var cached = await cache.Get(key, endpoint); diff --git a/Services/Program.cs b/Services/Program.cs index 4c627a7..2f2c7fb 100644 --- a/Services/Program.cs +++ b/Services/Program.cs @@ -81,6 +81,9 @@ if (!nativeCatalog.IsEmpty) builder.Services.AddNativeEndpoints(nativeCatalog, builder.Configuration); builder.Services.AddCraftRateLimiter(craftSettings); +// Per-instance daily API egress cap. Self-gates to hosted (CIPP_HOSTED) or a forced env; registers +// nothing otherwise. See Services/Hosting/EgressLedger.cs and ApiEgressLimiterMiddleware.cs. +builder.Services.AddCraftEgressLimiter(craftSettings); var app = builder.Build(); @@ -390,6 +393,13 @@ void RunInitialization() if (CraftSettings.RateLimit.RequiresLimiterMiddleware) app.UseRateLimiter(); +// Per-instance daily API-egress cap. Same placement rules as the rate limiter above: after UseCraftAuth +// (so an app-only caller's AppId is resolved and CallerClassifier can tell API from UI) and after static +// serving (so a page load's assets are never charged). Only added when egress accounting is enabled — +// see AddCraftEgressLimiter. Counts only app-only API callers; enforces (429) only once a budget is set. +if (CraftSettings.RateLimit.Egress.ResolvedEnabled) + app.UseMiddleware(); + // Concurrent request tracking for diagnostics. A holder object, not an int: the dispatch endpoint // is registered elsewhere and a lambda cannot capture a ref local. var activeRequests = new RequestCounter(); diff --git a/appsettings.example.jsonc b/appsettings.example.jsonc index 6982b96..cae74f4 100644 --- a/appsettings.example.jsonc +++ b/appsettings.example.jsonc @@ -95,7 +95,26 @@ // "PermitPerWindow": 300, // requests allowed per window, per client // "WindowSeconds": 10, // window length in seconds // "QueueLimit": 0, // requests queued at the limit before rejecting (0 = reject immediately) - // "ApiConcurrencyLimit": 0 // max simultaneous in-flight requests per API client (0 = unlimited) + // "ApiConcurrencyLimit": 0, // max simultaneous in-flight requests per API client (0 = unlimited) + // + // // Egress: a THIRD, orthogonal cap — outbound response bytes/day for the WHOLE instance, counting + // // only app-only API clients (UI is never counted or capped). A caller can pull tens of GB/day + // // while staying under the rate and concurrency caps (one slow, large request every few seconds), + // // which is what this bounds. Two-step by design: + // // 1. Accounting turns on when the env named by HostedEnv (default CIPP_HOSTED — the same flag + // // the SKU profiles use) is present, or CRAFT_API_EGRESS_LIMIT_ENABLED forces it (true = on + // // even off-host for testing, false = kill switch even when hosted). + // // 2. Enforcement (429 + Retry-After to the next UTC midnight) begins only once BytesPerDay > 0, + // // so you can run accounting-only first and watch real numbers before rejecting anything. + // // The counter lives in memory (no per-request IO) and is flushed to egress-ledger.json in the same + // // directory as the log files every FlushSeconds and on shutdown, so a restart doesn't reset the day. + // // Nothing is read from Azure. + // // Env overrides: CRAFT_API_EGRESS_LIMIT_BYTES, CRAFT_API_EGRESS_FLUSH_SECONDS. + // "Egress": { + // "HostedEnv": "CIPP_HOSTED", // env var whose presence enables accounting ("" = never auto-enable) + // "BytesPerDay": 0, // per-instance daily budget in bytes (0 = accounting only, no 429s; 1 GB = 1073741824) + // "FlushSeconds": 60 // how often the counter is written to disk (also on shutdown) + // } // }, // Realtime SSE channel served at /.craft/events. Downstream code publishes job events through diff --git a/perf-harness/README.md b/perf-harness/README.md index 7ecbcb0..4819206 100644 --- a/perf-harness/README.md +++ b/perf-harness/README.md @@ -144,9 +144,11 @@ results/ outputs (gitignored) ── HTTP API mode (below) ── docker-compose.api.yml http-only SUT (CRAFT_SERVE_API=true) + PerfApi module mount +docker-compose.egress.yml overlay: egress accounting on (fast flush, known log dir) for run-egress.ps1 api-harness/API/Modules/PerfApi/ synthetic PS HTTP endpoints (dependency-free; not for prod) k6/api_load.js API load test (weighted endpoint mix or single-endpoint focus) scripts/run-api.ps1 API orchestrator (up → /healthz → warm → sample → k6 → report → down) +scripts/run-egress.ps1 e2e for the per-instance API egress cap (drive traffic → flush → read ledger → assert) scripts/compare-api.ps1 before/after diff of two API result JSONs (incl. per-endpoint p95) ``` @@ -187,3 +189,29 @@ rate so CPU-at-equal-load is comparable before/after; omit it (`-Vus N`) for pea Outputs, like the static harness, are `results/