From a2680a5ebcc1f8468c304e5edeb53c3e7a1b1540 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Wed, 9 Sep 2026 23:20:15 +0800
Subject: [PATCH 1/4] feat(auth): normalise GitHub EasyAuth principals like
Entra users
---
Services/Auth/EasyAuthPrincipal.cs | 23 +++++++++++--
Services/Hosting/CraftAuthMiddleware.cs | 19 +++++++----
tests/Craft.Tests/EasyAuthPrincipalTests.cs | 37 +++++++++++++++++++++
3 files changed, 70 insertions(+), 9 deletions(-)
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/Hosting/CraftAuthMiddleware.cs b/Services/Hosting/CraftAuthMiddleware.cs
index fa4b4bf..35bda61 100644
--- a/Services/Hosting/CraftAuthMiddleware.cs
+++ b/Services/Hosting/CraftAuthMiddleware.cs
@@ -103,9 +103,16 @@ 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;
+
+ var roles = await authService.GetUserRoles(userName);
if (roles is null)
{
// Authenticated by the platform but not authorised here. Strip the header so nothing
@@ -119,12 +126,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/tests/Craft.Tests/EasyAuthPrincipalTests.cs b/tests/Craft.Tests/EasyAuthPrincipalTests.cs
index 38ec1d9..146d2d5 100644
--- a/tests/Craft.Tests/EasyAuthPrincipalTests.cs
+++ b/tests/Craft.Tests/EasyAuthPrincipalTests.cs
@@ -143,6 +143,43 @@ public void MissingOrMalformedClaimsArray_DoesNotThrow()
Assert.Null(EasyAuthPrincipal.ExtractClaims(Principal("""{"claims":[{"val":"orphan"}]}""")).Upn);
}
+ [Fact]
+ public void GitHubLoginAndId_AreExtracted()
+ {
+ // A GitHub principal carries no UPN. App Service emits the login (the Sponsors key) on
+ // urn:github:login and the numeric id on urn:github:id; the id is reused as the object id so
+ // it surfaces as userId. Observed on the test deployment 2026-09-09.
+ var claims = EasyAuthPrincipal.ExtractClaims(WithClaims(
+ ("urn:github:login", "octocat"),
+ ("urn:github:id", "583231")));
+
+ Assert.Null(claims.Upn);
+ Assert.Equal("octocat", claims.Login);
+ Assert.Equal("583231", claims.ObjectId);
+ Assert.False(claims.IsAppOnly); // a login is an interactive user, not a service principal
+ }
+
+ [Fact]
+ public void GitHubLogin_NeverLeaksIntoUpn()
+ {
+ // The login must stay in Login and never populate Upn — the two are authorised differently
+ // and a login is not a UPN.
+ var claims = EasyAuthPrincipal.ExtractClaims(WithClaims(("urn:github:login", "octocat")));
+ Assert.Null(claims.Upn);
+ Assert.Equal("octocat", claims.Login);
+ }
+
+ [Fact]
+ public void UpnWins_WhenAPrincipalSomehowCarriesBothUpnAndLogin()
+ {
+ var claims = EasyAuthPrincipal.ExtractClaims(WithClaims(
+ ("upn", "real@contoso.com"),
+ ("urn:github:login", "octocat")));
+
+ Assert.Equal("real@contoso.com", claims.Upn);
+ Assert.Equal("octocat", claims.Login);
+ }
+
[Fact]
public void IdentityProviderHeader_WinsOverAuthTyp()
{
From aa3a1c185eb9d448e398161d53c6974f73d80565 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Wed, 9 Sep 2026 23:48:50 +0800
Subject: [PATCH 2/4] fix(auth): resolve roles by name and object id
Add a multi-identifier role lookup in AuthService so roles are merged from any matching allowedUsers entries (e.g., UPN/login and object ID). Authorization now fails only when no identifiers match and AllowAllTenantUsers is false. Update CraftAuthMiddleware to request roles using both the display name and stable object ID, allowing GitHub and Entra users to be authorized by either key.
---
Services/Auth/AuthService.cs | 43 +++++++++++++++++++++----
Services/Hosting/CraftAuthMiddleware.cs | 4 ++-
2 files changed, 39 insertions(+), 8 deletions(-)
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/Hosting/CraftAuthMiddleware.cs b/Services/Hosting/CraftAuthMiddleware.cs
index 35bda61..8a3cb6c 100644
--- a/Services/Hosting/CraftAuthMiddleware.cs
+++ b/Services/Hosting/CraftAuthMiddleware.cs
@@ -112,7 +112,9 @@ private static async Task TryNormalisePrincipalAsync(
var userName = !string.IsNullOrEmpty(claims.Upn) ? claims.Upn : claims.Login;
if (string.IsNullOrEmpty(userName)) return true;
- var roles = await authService.GetUserRoles(userName);
+ // 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
From 621cae058d9a3a5e2637c6f8d9ab12e951d64bfc Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Fri, 11 Sep 2026 00:31:21 +0800
Subject: [PATCH 3/4] fix(cache): scope cache to user identity
Replace the role-based cache key component with a stable per-user key derived from the Easy Auth principal so callers with identical roles cannot share cached responses. Update cache profiling and perf-harness terminology to match, and add coverage for key generation and end-to-end cache isolation across authenticated, anonymous, and app-only callers.
---
Services/Caching/CacheService.cs | 56 +++--
Services/Hosting/CacheProfiler.cs | 20 +-
.../Endpoints/PowerShellDispatchEndpoint.cs | 4 +-
perf-harness/scripts/run-cache.ps1 | 4 +-
tests/Craft.Tests/CacheUserKeyTests.cs | 205 ++++++++++++++++++
5 files changed, 252 insertions(+), 37 deletions(-)
create mode 100644 tests/Craft.Tests/CacheUserKeyTests.cs
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/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/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/perf-harness/scripts/run-cache.ps1 b/perf-harness/scripts/run-cache.ps1
index 84507c8..d8e4981 100644
--- a/perf-harness/scripts/run-cache.ps1
+++ b/perf-harness/scripts/run-cache.ps1
@@ -5,10 +5,10 @@
.DESCRIPTION
Brings up CRAFT with the Frontend + Http roles (so the disk-backed response cache is on and the auth header
middleware runs), warms the cache, then drives k6 load at /API/ListPerf with an x-ms-client-principal
- header. Reads the cache/auth profiler's windowed breakdown (auth / roleHash / keyBuild / get / disk / set)
+ header. Reads the cache/auth profiler's windowed breakdown (auth / userKey / keyBuild / get / disk / set)
plus k6 latency + the X-Cache hit ratio.
- -Mode hit : fixed query → all cache hits after the first (profiles the hit path: roleHash + get + disk read)
+ -Mode hit : fixed query → all cache hits after the first (profiles the hit path: userKey + get + disk read)
-Mode miss : unique query per iteration → all misses (profiles PS invoke + cache Set disk write)
.EXAMPLE
diff --git a/tests/Craft.Tests/CacheUserKeyTests.cs b/tests/Craft.Tests/CacheUserKeyTests.cs
new file mode 100644
index 0000000..7bdbd51
--- /dev/null
+++ b/tests/Craft.Tests/CacheUserKeyTests.cs
@@ -0,0 +1,205 @@
+using Craft.Auth;
+using Craft.Caching;
+using Craft.Configuration;
+using Craft.Services;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Craft.Tests;
+
+///
+/// The response cache key is keyed to the caller's identity, not their role set. This is a security
+/// boundary: a cache hit skips the PowerShell handler entirely, so the key is the only thing standing
+/// between one caller and another caller's data. If two different users ever produce the same key for
+/// the same request, the second is served the first one's response — the exact leak that made the cache
+/// key user-scoped in the first place. Every test here is a direction that leak could come back from.
+///
+public class CacheUserKeyTests
+{
+ // A disabled cache still builds keys the same way (BuildCacheKey does not consult _enabled); disabled
+ // just keeps these pure tests off the disk and eviction timer.
+ private static CacheService KeyBuilder() =>
+ new(NullLogger.Instance, new CraftSettings(), enabled: false);
+
+ /// Builds a context carrying a normalised SWA principal, exactly as the auth middleware writes it.
+ private static DefaultHttpContext Principal(string? userId, string? userDetails = null, string[]? roles = null)
+ {
+ var context = new DefaultHttpContext();
+ context.Request.Headers["x-ms-client-principal"] = EasyAuthPrincipal.Encode(new
+ {
+ userId,
+ userDetails,
+ userRoles = roles ?? Array.Empty(),
+ });
+ return context;
+ }
+
+ private static HttpContext WithQuery(HttpContext context, string queryString)
+ {
+ context.Request.QueryString = new QueryString(queryString);
+ return context;
+ }
+
+ private static string KeyFor(CacheService cache, string endpoint, HttpContext context) =>
+ cache.BuildCacheKey(endpoint, context.Request.Query, CacheService.GetUserKey(context));
+
+ [Fact]
+ public void SameUser_SameRequest_ProducesTheSameKey()
+ {
+ var cache = KeyBuilder();
+ var a = WithQuery(Principal("oid-alice"), "?tenantFilter=contoso.com");
+ var b = WithQuery(Principal("oid-alice"), "?tenantFilter=contoso.com");
+
+ Assert.Equal(KeyFor(cache, "ListUsers", a), KeyFor(cache, "ListUsers", b));
+ }
+
+ [Fact]
+ public void DifferentUsers_SameRolesAndQuery_ProduceDifferentKeys()
+ {
+ // The bug this whole change exists to close: two callers with identical roles hitting the same
+ // endpoint with the same query must NOT collide, or a hit serves one of them the other's data.
+ var cache = KeyBuilder();
+ var alice = WithQuery(Principal("oid-alice", roles: ["editor"]), "?tenantFilter=contoso.com");
+ var bob = WithQuery(Principal("oid-bob", roles: ["editor"]), "?tenantFilter=contoso.com");
+
+ Assert.NotEqual(KeyFor(cache, "ListUsers", alice), KeyFor(cache, "ListUsers", bob));
+ }
+
+ [Fact]
+ public void SameUser_DifferentRoles_ProducesTheSameKey()
+ {
+ // Keyed to identity, not roles — documents the deliberate change from the old role-hash scheme.
+ // A user's own entries are theirs regardless of the role list the principal happens to carry.
+ var cache = KeyBuilder();
+ var admin = WithQuery(Principal("oid-alice", roles: ["admin", "editor"]), "?tenantFilter=contoso.com");
+ var reader = WithQuery(Principal("oid-alice", roles: ["readonly"]), "?tenantFilter=contoso.com");
+
+ Assert.Equal(KeyFor(cache, "ListUsers", admin), KeyFor(cache, "ListUsers", reader));
+ }
+
+ [Fact]
+ public void AuthenticatedRequest_CarriesAUserComponent()
+ {
+ var cache = KeyBuilder();
+ var key = KeyFor(cache, "ListUsers", Principal("oid-alice"));
+
+ Assert.Contains("|_user=", key);
+ }
+
+ [Fact]
+ public void AnonymousRequest_HasNoUserComponent_AndAllAnonymousShareOneBucket()
+ {
+ // No principal → no identity to key on. All anonymous callers collapsing into one bucket is
+ // correct: they share one (empty) authorization, so there is nothing to leak between them.
+ var cache = KeyBuilder();
+
+ Assert.Null(CacheService.GetUserKey(new DefaultHttpContext()));
+
+ var one = cache.BuildCacheKey("ListUsers", new DefaultHttpContext().Request.Query, null);
+ var two = cache.BuildCacheKey("ListUsers", new DefaultHttpContext().Request.Query, null);
+ Assert.DoesNotContain("|_user=", one);
+ Assert.Equal(one, two);
+ }
+
+ [Fact]
+ public void AppOnlyClients_WithDifferentIds_ProduceDifferentKeys()
+ {
+ // Service principals arrive with an empty userRoles array. Under the old role-hash scheme every
+ // such client hashed identically and shared one bucket; keying on userId keeps them apart.
+ var cache = KeyBuilder();
+ var clientA = Principal("app-1111", userDetails: "1111", roles: []);
+ var clientB = Principal("app-2222", userDetails: "2222", roles: []);
+
+ Assert.NotEqual(KeyFor(cache, "ListUsers", clientA), KeyFor(cache, "ListUsers", clientB));
+ }
+
+ [Fact]
+ public void UserId_IsPreferred_AndUserDetails_IsTheFallback()
+ {
+ // userId is the stable identifier; userDetails only stands in when a principal lacks one. A
+ // principal whose userId equals another's userDetails must therefore key the same.
+ Assert.Equal(
+ CacheService.GetUserKey(Principal(userId: null, userDetails: "alice@contoso.com")),
+ CacheService.GetUserKey(Principal(userId: "alice@contoso.com", userDetails: "ignored")));
+ }
+
+ [Fact]
+ public void UnparseableHeader_IsTreatedAsAnonymous()
+ {
+ var context = new DefaultHttpContext();
+ context.Request.Headers["x-ms-client-principal"] = "}}not base64 json{{";
+
+ Assert.Null(CacheService.GetUserKey(context));
+ }
+
+ [Fact]
+ public void QueryScope_StillDifferentiates_ForTheSameUser()
+ {
+ // User keying layers on top of query scoping; it must not flatten two tenants into one entry.
+ var cache = KeyBuilder();
+ var contoso = WithQuery(Principal("oid-alice"), "?tenantFilter=contoso.com");
+ var fabrikam = WithQuery(Principal("oid-alice"), "?tenantFilter=fabrikam.com");
+
+ Assert.NotEqual(KeyFor(cache, "ListUsers", contoso), KeyFor(cache, "ListUsers", fabrikam));
+ }
+}
+
+///
+/// The same guarantee, driven through the real Set → Get path rather than string comparison: a response
+/// cached for one user is never handed back to another, while the original caller still gets their hit.
+/// This is the end-to-end proof that the key isolation actually gates what
+/// serves, not just what strings BuildCacheKey emits.
+///
+public class CacheUserIsolationTests
+{
+ private static readonly string[] EditorRole = ["editor"];
+
+ private static DefaultHttpContext Principal(string userId)
+ {
+ var context = new DefaultHttpContext();
+ context.Request.QueryString = new QueryString("?tenantFilter=contoso.com");
+ context.Request.Headers["x-ms-client-principal"] = EasyAuthPrincipal.Encode(new
+ {
+ userId,
+ userDetails = userId,
+ userRoles = EditorRole,
+ });
+ return context;
+ }
+
+ private static string KeyFor(CacheService cache, HttpContext context) =>
+ cache.BuildCacheKey("ListUsers", context.Request.Query, CacheService.GetUserKey(context));
+
+ [Fact]
+ public async Task CachedResponse_IsNotServedToADifferentUser()
+ {
+ using var cache = new CacheService(NullLogger.Instance, new CraftSettings(), enabled: true);
+
+ var aliceKey = KeyFor(cache, Principal("oid-alice"));
+ var bobKey = KeyFor(cache, Principal("oid-bob"));
+
+ await cache.Set(aliceKey, new ScriptResult { StatusCode = 200, Body = "{\"secret\":\"alice-only\"}" });
+
+ // Bob, same roles and same query, gets a clean miss — the handler will run for him.
+ Assert.Null(await cache.Get(bobKey, "ListUsers"));
+
+ // Alice still hits her own entry.
+ var aliceHit = await cache.Get(aliceKey, "ListUsers");
+ Assert.NotNull(aliceHit);
+ Assert.Equal("{\"secret\":\"alice-only\"}", aliceHit!.Result.Body);
+ }
+
+ [Fact]
+ public async Task SameUser_HitsTheirOwnCachedResponse()
+ {
+ using var cache = new CacheService(NullLogger.Instance, new CraftSettings(), enabled: true);
+
+ var key = KeyFor(cache, Principal("oid-alice"));
+ await cache.Set(key, new ScriptResult { StatusCode = 200, Body = "{\"ok\":true}" });
+
+ // A second request from the same principal keys identically and hits.
+ var hit = await cache.Get(KeyFor(cache, Principal("oid-alice")), "ListUsers");
+ Assert.NotNull(hit);
+ Assert.Equal("{\"ok\":true}", hit!.Result.Body);
+ }
+}
From 9f5021c7e76e8eccbd074b742c971264d0fd4637 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Fri, 11 Sep 2026 17:00:18 +0800
Subject: [PATCH 4/4] feat(hosting): add daily api egress limiter
Introduce a hosted-only daily API egress cap with configurable settings, persisted ledger state, and middleware that counts response bytes for app-only callers and returns 429 with Retry-After after the budget is exhausted. Wire the feature into startup, document the new configuration, and add tests for settings resolution, ledger persistence/rollover, and middleware behavior.
---
Services/Configuration/EgressLimitSettings.cs | 104 ++++++++
Services/Configuration/RateLimitSettings.cs | 7 +
.../Hosting/ApiEgressLimiterMiddleware.cs | 98 ++++++++
Services/Hosting/CountingStream.cs | 71 ++++++
.../Hosting/CraftHostBuilderExtensions.cs | 23 ++
Services/Hosting/EgressLedger.cs | 224 ++++++++++++++++++
Services/Program.cs | 10 +
appsettings.example.jsonc | 21 +-
perf-harness/README.md | 28 +++
perf-harness/docker-compose.egress.yml | 22 ++
perf-harness/scripts/run-egress.ps1 | 184 ++++++++++++++
.../ApiEgressLimiterMiddlewareTests.cs | 224 ++++++++++++++++++
tests/Craft.Tests/EgressLedgerTests.cs | 216 +++++++++++++++++
tests/Craft.Tests/EgressLimitSettingsTests.cs | 195 +++++++++++++++
14 files changed, 1426 insertions(+), 1 deletion(-)
create mode 100644 Services/Configuration/EgressLimitSettings.cs
create mode 100644 Services/Hosting/ApiEgressLimiterMiddleware.cs
create mode 100644 Services/Hosting/CountingStream.cs
create mode 100644 Services/Hosting/EgressLedger.cs
create mode 100644 perf-harness/docker-compose.egress.yml
create mode 100644 perf-harness/scripts/run-egress.ps1
create mode 100644 tests/Craft.Tests/ApiEgressLimiterMiddlewareTests.cs
create mode 100644 tests/Craft.Tests/EgressLedgerTests.cs
create mode 100644 tests/Craft.Tests/EgressLimitSettingsTests.cs
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/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/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/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/