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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions Services/Auth/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,23 +84,52 @@ public void InvalidateUserCache()
// --- allowedUsers Table ---

/// <summary>
/// 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.
/// </summary>
public async Task<string[]?> GetUserRoles(string upn, CancellationToken ct = default)
public Task<string[]?> GetUserRoles(string upn, CancellationToken ct = default) =>
GetUserRoles(new[] { upn }, ct);

/// <summary>
/// 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.
/// </summary>
public async Task<string[]?> GetUserRoles(IEnumerable<string?> identifiers, CancellationToken ct = default)
{
var user = await GetAllowedUser(upn, ct);
if (user == null)
var roles = new List<string>();
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<string>(user.Roles);

if (!roles.Contains("anonymous")) roles.Add("anonymous");
if (!roles.Contains("authenticated")) roles.Add("authenticated");
return roles.ToArray();
Expand Down
23 changes: 20 additions & 3 deletions Services/Auth/EasyAuthPrincipal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ namespace Craft.Auth;
/// <param name="ObjectId">Entra object id of the user or service principal.</param>
/// <param name="AppId">Client application id, present on client-credentials tokens.</param>
/// <param name="IdentityType">The <c>idtyp</c> claim — <c>"app"</c> for app-only tokens.</param>
public sealed record EasyAuthClaims(string? Upn, string? ObjectId, string? AppId, string? IdentityType)
/// <param name="Login">
/// 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 <see cref="ObjectId"/> 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.
/// </param>
public sealed record EasyAuthClaims(string? Upn, string? ObjectId, string? AppId, string? IdentityType, string? Login = null)
{
/// <summary>
/// Whether this is an app-only (client-credentials) token rather than a signed-in user.
Expand Down Expand Up @@ -72,7 +78,7 @@ public static string Encode<T>(T principal) =>
/// </summary>
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) &&
Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
}

/// <summary>
Expand Down
56 changes: 33 additions & 23 deletions Services/Caching/CacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ public CacheService(ILogger<CacheService> logger, CraftSettings settings, bool e
}

/// <summary>
/// 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.
/// </summary>
public string BuildCacheKey(string endpoint, IQueryCollection query, string? userRoleHash)
public string BuildCacheKey(string endpoint, IQueryCollection query, string? userKey)
{
var sb = new StringBuilder(endpoint);

Expand All @@ -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();
}

/// <summary>
/// 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.
/// <para>
/// 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
/// <c>userId</c> (Entra object id, GitHub numeric id, or a service principal's object/app id),
/// falling back to <c>userDetails</c>.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
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;
Expand All @@ -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<string>();
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;

/// <summary>
/// Get the TTL for a given endpoint.
/// </summary>
Expand Down
104 changes: 104 additions & 0 deletions Services/Configuration/EgressLimitSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System.Globalization;

namespace Craft.Configuration;

/// <summary>
/// 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 + <c>Retry-After</c> until the next
/// UTC midnight. Interactive (UI) callers are never counted and never capped.
/// <para>
/// This is a bandwidth cap, distinct from <see cref="RateLimitSettings.PermitPerWindow"/> (a request
/// RATE cap) and <see cref="RateLimitSettings.ApiConcurrencyLimit"/> (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.
/// </para>
/// <para>
/// <b>Two-step activation.</b> Accounting (and the middleware) turns on when the env var named by
/// <see cref="HostedEnv"/> — default <c>CIPP_HOSTED</c>, the same flag the SKU profiles key off — is
/// present, or when <c>CRAFT_API_EGRESS_LIMIT_ENABLED</c> forces it. <b>Enforcement</b> is a separate
/// step: 429s only begin once <see cref="BytesPerDay"/> (or its env override) is above zero, so a
/// deployment can run accounting-only first to observe real numbers before it starts rejecting.
/// </para>
/// <para>
/// State is one counter plus the UTC date it belongs to, kept in memory and flushed to a small local
/// file every <see cref="FlushSeconds"/> (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.
/// </para>
/// </summary>
public class EgressLimitSettings
{
/// <summary>
/// Name of the env var whose presence (any non-empty value) marks the hosted environment and turns
/// egress accounting on. Defaults to <c>CIPP_HOSTED</c> — the same flag
/// <see cref="WorkerSettings.SkuProfilesAltEnv"/> uses — so a single env var lights up both. Blank
/// means never auto-enable (only <c>CRAFT_API_EGRESS_LIMIT_ENABLED=true</c> can then turn it on).
/// </summary>
public string? HostedEnv { get; set; } = "CIPP_HOSTED";

/// <summary>
/// Daily egress budget in bytes for the whole instance. 0 (default) = accounting only, no 429s.
/// Env override: <c>CRAFT_API_EGRESS_LIMIT_BYTES</c> (e.g. 1073741824 for 1 GB/day).
/// </summary>
public long BytesPerDay { get; set; }

/// <summary>
/// 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:
/// <c>CRAFT_API_EGRESS_FLUSH_SECONDS</c>.
/// </summary>
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";

/// <summary>
/// Whether egress accounting (and therefore the middleware) should be active, resolved through
/// <paramref name="env"/>. <c>CRAFT_API_EGRESS_LIMIT_ENABLED</c> wins when set — <c>true</c> forces
/// it on even off-host (for testing), <c>false</c> is a kill switch even in the hosted env —
/// otherwise it is on exactly when the <see cref="HostedEnv"/> var is present.
/// </summary>
public bool ResolveEnabled(Func<string, string?> 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));
}

/// <summary>Convenience overload resolving against the real process environment.</summary>
public bool ResolvedEnabled => ResolveEnabled(Environment.GetEnvironmentVariable);

/// <summary>
/// Resolved daily budget in bytes, honouring the <c>CRAFT_API_EGRESS_LIMIT_BYTES</c> env override
/// (which wins when it parses to a non-negative long). 0 = accounting only / no enforcement.
/// </summary>
public long ResolvedBytesPerDay =>
long.TryParse(Environment.GetEnvironmentVariable(BytesEnv), NumberStyles.Integer,
CultureInfo.InvariantCulture, out var fromEnv) && fromEnv >= 0
? fromEnv
: Math.Max(0, BytesPerDay);

/// <summary>
/// Resolved flush interval in seconds, honouring the <c>CRAFT_API_EGRESS_FLUSH_SECONDS</c> env
/// override (which wins when it parses to a positive int). Floored at 1.
/// </summary>
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";
}
}
7 changes: 7 additions & 0 deletions Services/Configuration/RateLimitSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ public class RateLimitSettings
/// </summary>
public int ApiConcurrencyLimit { get; set; }

/// <summary>
/// Per-instance daily API egress (bandwidth) cap. See <see cref="EgressLimitSettings"/>. 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.
/// </summary>
public EgressLimitSettings Egress { get; set; } = new();

/// <summary>Resolved enabled state, honouring the CRAFT_RATELIMIT_ENABLED environment override.</summary>
public bool IsEnabled =>
Enabled
Expand Down
Loading
Loading