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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
12 changes: 12 additions & 0 deletions Proxytrace.Api/Auth/AppSettingsLocalSigningKeyStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ internal sealed class AppSettingsLocalSigningKeyStore : ISigningKeyStore

private readonly IHostEnvironment environment;

/// <summary>
/// Initializes a new instance of the <see cref="AppSettingsLocalSigningKeyStore"/> class.
/// </summary>
public AppSettingsLocalSigningKeyStore(IHostEnvironment environment)
{
this.environment = environment;
Expand All @@ -26,6 +29,10 @@ public AppSettingsLocalSigningKeyStore(IHostEnvironment environment)
AllowTrailingCommas = true,
};

/// <summary>
/// Reads <c>Authentication:Local:SigningKey</c> from <c>appsettings.local.json</c> in the
/// content root, returning <see langword="null"/> when the file is absent or unparseable.
/// </summary>
public string? Load()
{
var path = Path.Combine(environment.ContentRootPath, FileName);
Expand All @@ -43,6 +50,11 @@ public AppSettingsLocalSigningKeyStore(IHostEnvironment environment)
}
}

/// <summary>
/// Writes <paramref name="signingKey"/> into <c>appsettings.local.json</c> under
/// <c>Authentication:Local:SigningKey</c>, merging into the existing JSON so unrelated
/// configuration keys are not overwritten.
/// </summary>
public void Persist(string signingKey)
{
var path = Path.Combine(environment.ContentRootPath, FileName);
Expand Down
14 changes: 14 additions & 0 deletions Proxytrace.Api/Auth/AuthUserResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ internal class LocalUserResolver : IAuthUserResolver
{
private readonly IRepository<IUser> users;

/// <summary>
/// Initializes a new instance of the <see cref="LocalUserResolver"/> class.
/// </summary>
public LocalUserResolver(IRepository<IUser> users)
{
this.users = users;
}

/// <summary>
/// Looks up the <see cref="IUser"/> by the <c>sub</c> claim GUID. Fails the token validation
/// context and returns <see langword="null"/> when the sub is unparseable or the user no longer exists.
/// </summary>
public async Task<IUser?> Resolve(TokenValidatedContext context, ClaimsPrincipal principal)
{
var sub = principal.FindFirstValue("sub")
Expand All @@ -46,12 +53,19 @@ internal class JitUserResolver : IAuthUserResolver
private readonly IJitUserProvisioner provisioner;
private readonly AuthOptions options;

/// <summary>
/// Initializes a new instance of the <see cref="JitUserResolver"/> class.
/// </summary>
public JitUserResolver(IJitUserProvisioner provisioner, AuthOptions options)
{
this.provisioner = provisioner;
this.options = options;
}

/// <summary>
/// Derives an external subject identifier from the token's issuer and subject claims, then
/// JIT-provisions or retrieves the matching local user via <c>IJitUserProvisioner</c>.
/// </summary>
public async Task<IUser?> Resolve(TokenValidatedContext context, ClaimsPrincipal principal)
{
var issuer = principal.FindFirstValue("iss")
Expand Down
6 changes: 6 additions & 0 deletions Proxytrace.Api/Auth/CurrentUserAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@ internal sealed class CurrentUserAccessor : ICurrentUserAccessor
private readonly IHttpContextAccessor httpContextAccessor;
private readonly IRepository<IUser> users;

/// <summary>
/// Initializes a new instance of the <see cref="CurrentUserAccessor"/> class.
/// </summary>
public CurrentUserAccessor(IHttpContextAccessor httpContextAccessor, IRepository<IUser> users)
{
this.httpContextAccessor = httpContextAccessor;
this.users = users;
}

/// <summary>
/// Gets the current user asynchronously.
/// </summary>
public async Task<IUser?> GetCurrentUserAsync(CancellationToken cancellationToken = default)
{
var ctx = httpContextAccessor.HttpContext;
Expand Down
11 changes: 11 additions & 0 deletions Proxytrace.Api/Auth/DataDirectorySigningKeyStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,19 @@ internal sealed class DataDirectorySigningKeyStore : ISigningKeyStore

private readonly string directory;

/// <summary>
/// Initializes a new instance of the <see cref="DataDirectorySigningKeyStore"/> class.
/// </summary>
public DataDirectorySigningKeyStore(string directory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
this.directory = directory;
}

/// <summary>
/// Reads the signing key from the <c>signing-key</c> file in the data directory, returning
/// <see langword="null"/> when the file is absent or empty.
/// </summary>
public string? Load()
{
var path = Path.Combine(directory, FileName);
Expand All @@ -28,6 +35,10 @@ public DataDirectorySigningKeyStore(string directory)
return key.Length == 0 ? null : key;
}

/// <summary>
/// Writes <paramref name="signingKey"/> to the <c>signing-key</c> file in the data directory,
/// creating the directory if it does not exist.
/// </summary>
public void Persist(string signingKey)
{
Directory.CreateDirectory(directory);
Expand Down
6 changes: 6 additions & 0 deletions Proxytrace.Api/Auth/HttpContextAuditActorAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,17 @@ internal sealed class HttpContextAuditActorAccessor : IAuditActorAccessor
{
private readonly IHttpContextAccessor httpContextAccessor;

/// <summary>
/// Initializes a new instance of the <see cref="HttpContextAuditActorAccessor"/> class.
/// </summary>
public HttpContextAuditActorAccessor(IHttpContextAccessor httpContextAccessor)
{
this.httpContextAccessor = httpContextAccessor;
}

/// <summary>
/// Gets the current actor.
/// </summary>
public AuditActor GetCurrentActor()
{
var http = httpContextAccessor.HttpContext;
Expand Down
12 changes: 12 additions & 0 deletions Proxytrace.Api/Auth/IProjectAccessGuard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ internal sealed class ProjectAccessGuard : IProjectAccessGuard
private readonly IProjectRepository projects;
private readonly IHttpContextAccessor httpContextAccessor;

/// <summary>
/// Initializes a new instance of the <see cref="ProjectAccessGuard"/> class.
/// </summary>
public ProjectAccessGuard(
ICurrentUserAccessor currentUser,
IProjectRepository projects,
Expand All @@ -41,6 +44,10 @@ public ProjectAccessGuard(
this.httpContextAccessor = httpContextAccessor;
}

/// <summary>
/// Returns <see langword="true"/> when the caller is an admin or a member of
/// <paramref name="projectId"/>, and the request's API key (if any) is confined to that project.
/// </summary>
public async Task<bool> CanAccessProjectAsync(Guid projectId, CancellationToken cancellationToken = default)
{
// A REST API key is confined to the project it was minted for, on top of whatever its owner
Expand All @@ -59,6 +66,11 @@ public async Task<bool> CanAccessProjectAsync(Guid projectId, CancellationToken
return memberships.Any(p => p.Id == projectId);
}

/// <summary>
/// Returns the set of project ids the caller may see — <see langword="null"/> for an admin who
/// may see all, an empty collection when the caller may see none, and the caller's member
/// projects otherwise. A REST API key further narrows the result to its single project.
/// </summary>
public async Task<IReadOnlyCollection<Guid>?> GetAccessibleProjectIdsAsync(CancellationToken cancellationToken = default)
{
var user = await currentUser.GetCurrentUserAsync(cancellationToken);
Expand Down
5 changes: 5 additions & 0 deletions Proxytrace.Api/Auth/JwtBearerEventsFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ internal static class JwtBearerEventsFactory
{
private const string StreamTicketQueryKey = "stream_ticket";

/// <summary>
/// Builds the <see cref="JwtBearerEvents"/> that handle stream-ticket redemption, the SSE
/// <c>?access_token</c> fallback, the httpOnly session-cookie fallback, and live role-claim
/// overwriting so a demoted user loses privileges on their next request rather than at token expiry.
/// </summary>
public static JwtBearerEvents Create() => new()
{
OnMessageReceived = async context =>
Expand Down
6 changes: 6 additions & 0 deletions Proxytrace.Api/Auth/Kiosk/KioskAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@ namespace Proxytrace.Api.Auth.Kiosk;

internal sealed class KioskAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
/// <summary>
/// The scheme name constant value.
/// </summary>
public const string SchemeName = "Kiosk";

private readonly IUserRepository users;
private readonly KioskOptions kioskOptions;

/// <summary>
/// Initializes a new instance of the <see cref="KioskAuthenticationHandler"/> class.
/// </summary>
public KioskAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
Expand Down
6 changes: 6 additions & 0 deletions Proxytrace.Api/Auth/Licensing/LicenseEnforcementFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ internal sealed class LicenseEnforcementFilter : IAsyncAuthorizationFilter
{
private readonly ILicenseService licenseService;

/// <summary>
/// Initializes a new instance of the <see cref="LicenseEnforcementFilter"/> class.
/// </summary>
public LicenseEnforcementFilter(ILicenseService licenseService)
{
this.licenseService = licenseService;
}

/// <summary>
/// On authorization asynchronously.
/// </summary>
public Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
ArgumentNullException.ThrowIfNull(context);
Expand Down
3 changes: 3 additions & 0 deletions Proxytrace.Api/Auth/Licensing/RequiresFeatureAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ namespace Proxytrace.Api.Auth.Licensing;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class RequiresFeatureAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="RequiresFeatureAttribute"/> class.
/// </summary>
public RequiresFeatureAttribute(LicenseFeature feature)
{
Feature = feature;
Expand Down
6 changes: 6 additions & 0 deletions Proxytrace.Api/Auth/Mcp/McpApiKeyAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ namespace Proxytrace.Api.Auth.Mcp;
/// </summary>
internal sealed class McpApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
/// <summary>
/// The scheme name constant value.
/// </summary>
public const string SchemeName = "McpApiKey";

/// <summary>Request-item key under which the authenticated API key's id is stashed (for audit attribution).</summary>
Expand All @@ -24,6 +27,9 @@ internal sealed class McpApiKeyAuthenticationHandler : AuthenticationHandler<Aut

private readonly IApiKeyRepository apiKeys;

/// <summary>
/// Initializes a new instance of the <see cref="McpApiKeyAuthenticationHandler"/> class.
/// </summary>
public McpApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
Expand Down
3 changes: 3 additions & 0 deletions Proxytrace.Api/Auth/RequireLocalModeAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ namespace Proxytrace.Api.Auth;

internal sealed class RequireLocalModeAttribute : Attribute, IAuthorizationFilter
{
/// <summary>
/// On authorization.
/// </summary>
public void OnAuthorization(AuthorizationFilterContext ctx)
{
var opts = ctx.HttpContext.RequestServices.GetRequiredService<AuthOptions>();
Expand Down
3 changes: 3 additions & 0 deletions Proxytrace.Api/Auth/Rest/ApiKeyScopeHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ internal sealed class ApiKeyScopeHandler : AuthorizationHandler<ApiKeyScopeRequi
{
private readonly IHttpContextAccessor httpContextAccessor;

/// <summary>
/// Initializes a new instance of the <see cref="ApiKeyScopeHandler"/> class.
/// </summary>
public ApiKeyScopeHandler(IHttpContextAccessor httpContextAccessor)
{
this.httpContextAccessor = httpContextAccessor;
Expand Down
15 changes: 15 additions & 0 deletions Proxytrace.Api/Auth/SessionCookie.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,26 @@ public interface ISessionCookie
/// </summary>
internal sealed class SessionCookie : ISessionCookie
{
/// <summary>
/// The cookie name used for the local-mode session JWT (<c>proxytrace_session</c>).
/// </summary>
public const string Name = "proxytrace_session";

private readonly SessionCookieOptions options;

/// <summary>
/// Initializes a new instance of the <see cref="SessionCookie"/> class.
/// </summary>
public SessionCookie(SessionCookieOptions options)
{
this.options = options;
}

/// <summary>
/// Sets the httpOnly session cookie on <paramref name="response"/> carrying <paramref name="token"/>,
/// expiring at <paramref name="expiresAt"/>. Applies <c>SameSite=Strict</c> and the configured
/// <c>Secure</c> flag.
/// </summary>
public void Append(HttpResponse response, string token, DateTimeOffset expiresAt) =>
response.Cookies.Append(Name, token, new CookieOptions
{
Expand All @@ -62,6 +73,10 @@ public void Append(HttpResponse response, string token, DateTimeOffset expiresAt
Expires = expiresAt,
});

/// <summary>
/// Expires the session cookie on <paramref name="response"/>, effectively logging the user out
/// on the browser side.
/// </summary>
public void Delete(HttpResponse response) =>
response.Cookies.Delete(Name, new CookieOptions
{
Expand Down
9 changes: 9 additions & 0 deletions Proxytrace.Api/Auth/SigningKeyProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,20 @@ internal sealed class SigningKeyProvider : ISigningKeyProvider

private readonly ISigningKeyStore store;

/// <summary>
/// Initializes a new instance of the <see cref="SigningKeyProvider"/> class.
/// </summary>
public SigningKeyProvider(ISigningKeyStore store)
{
this.store = store;
}

/// <summary>
/// Returns the JWT signing key to use for local-mode authentication. Prefers
/// <paramref name="configured"/> when set (must be at least 32 characters), then falls back to a
/// previously generated key from the store, and finally generates and persists a new key when none
/// exists.
/// </summary>
public string EnsureSigningKey(string? configured)
{
if (!string.IsNullOrWhiteSpace(configured))
Expand Down
6 changes: 6 additions & 0 deletions Proxytrace.Api/Configuration/HostEnvironmentName.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ namespace Proxytrace.Api.Configuration;
/// </summary>
internal static class HostEnvironmentName
{
/// <summary>
/// The production constant value.
/// </summary>
public const string Production = "Production";
/// <summary>
/// The development constant value.
/// </summary>
public const string Development = "Development";

/// <summary>
Expand Down
17 changes: 17 additions & 0 deletions Proxytrace.Api/Configuration/SearchRequestOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,28 @@ namespace Proxytrace.Api.Configuration;
/// </summary>
public sealed record SearchRequestOptions
{
/// <summary>
/// Minimum number of characters a search query must contain before the request is accepted.
/// </summary>
public int MinQueryLength { get; init; } = 2;
/// <summary>
/// Maximum number of characters a search query may contain; requests exceeding this are rejected
/// with a 400.
/// </summary>
public int MaxQueryLength { get; init; } = 200;
/// <summary>
/// Minimum character length of a snippet submitted for snippet-search indexing.
/// </summary>
public int MinSnippetLength { get; init; } = 20;
/// <summary>
/// Maximum character length of a snippet; snippets longer than this are rejected with a 400.
/// </summary>
public int MaxSnippetLength { get; init; } = 1000;

/// <summary>
/// Asserts that the configured bounds are internally consistent; throws
/// <see cref="InvalidOperationException"/> on startup when they are not.
/// </summary>
public void Validate()
{
if (MinQueryLength < 1 || MinQueryLength > MaxQueryLength)
Expand Down
Loading
Loading