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/