Skip to content
7 changes: 4 additions & 3 deletions .github/workflows/nuget_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ jobs:
env:
PGPASSWORD: trax123
run: |
for db in trax_api_operations trax_api_workqueue trax_api_logs trax_api_health \
trax_api_auth_http trax_api_auth_model trax_api_auth_sub \
trax_api_auth_subprop trax_api_auth_authorize; do
# AuthE2E fixtures self-provision via AuthE2EHost.EnsureDatabaseExists,
# so new test classes in that suite require no changes here. Other
# E2E test categories still rely on this explicit list.
for db in trax_api_operations trax_api_workqueue trax_api_logs trax_api_health; do
psql -h localhost -U trax -d trax -c "CREATE DATABASE $db;"
done

Expand Down
7 changes: 4 additions & 3 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,11 @@ jobs:
- name: Create per-fixture test databases
env:
PGPASSWORD: trax123
# AuthE2E fixtures self-provision via AuthE2EHost.EnsureDatabaseExists,
# so new test classes in that suite require no changes here. Other
# E2E test categories still rely on this explicit list.
run: |
for db in trax_api_operations trax_api_workqueue trax_api_logs trax_api_health \
trax_api_auth_http trax_api_auth_model trax_api_auth_sub \
trax_api_auth_subprop trax_api_auth_authorize; do
for db in trax_api_operations trax_api_workqueue trax_api_logs trax_api_health; do
psql -h localhost -U trax -d trax -c "CREATE DATABASE $db;"
done
- run: dotnet test --configuration Release --no-build --collect:"XPlat Code Coverage" --results-directory ./TestResults
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using HotChocolate.AspNetCore;
using HotChocolate.Execution;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;

namespace Trax.Api.GraphQL.Authorization;

/// <summary>
/// HotChocolate HTTP request interceptor that populates <see cref="HttpContext.User"/>
/// for inbound GraphQL requests by attempting authentication against every registered
/// scheme until one succeeds. Wired automatically when at least one <c>[TraxQueryModel]</c>
/// entity carries <c>[TraxAuthorize]</c>.
///
/// <para>
/// Without this, HotChocolate's <c>@authorize</c> directive evaluates against an
/// anonymous principal whenever no default authentication scheme is configured on
/// <c>AddAuthentication()</c>. ASP.NET Core's <c>UseAuthentication()</c> middleware
/// only authenticates the default scheme; with multiple schemes registered (api-key
/// + JWT, api-key + cookie, etc.) there is no default, so HC sees no user even when
/// the request carries valid credentials for one of the registered schemes.
/// </para>
///
/// <para>
/// The interceptor:
/// </para>
/// <list type="number">
/// <item>Returns early if <c>HttpContext.User</c> is already authenticated (something
/// upstream — endpoint-level <c>RequireAuthorization</c>, a default scheme, a custom
/// interceptor — has handled it).</item>
/// <item>Otherwise, iterates over every registered authentication scheme and attempts
/// authentication. The first scheme that succeeds wins; the resulting principal is
/// assigned to <c>HttpContext.User</c>.</item>
/// <item>If no scheme succeeds, the principal stays anonymous and the request
/// proceeds — <c>@authorize</c> will then reject any gated field/type the request
/// touches.</item>
/// </list>
///
/// <para>
/// WebSocket upgrades and the Banana Cake Pop tool page are not affected: HotChocolate
/// invokes this interceptor only for actual GraphQL HTTP execution requests.
/// </para>
/// </summary>
internal sealed class QueryModelAuthenticationInterceptor(
IAuthenticationSchemeProvider schemeProvider
) : DefaultHttpRequestInterceptor
{
public override async ValueTask OnCreateAsync(
HttpContext context,
IRequestExecutor requestExecutor,
OperationRequestBuilder requestBuilder,
CancellationToken cancellationToken
)
{
if (context.User.Identity?.IsAuthenticated != true)
{
// Walk every registered scheme and attempt authentication. The first
// success wins. AuthenticateAsync against an inapplicable scheme returns
// NoResult (cheap) — schemes only do work when their credential header
// is present.
foreach (var scheme in await schemeProvider.GetAllSchemesAsync())
{
var result = await context.AuthenticateAsync(scheme.Name);
if (result.Succeeded && result.Principal is not null)
{
context.User = result.Principal;
break;
}
}
}

await base.OnCreateAsync(context, requestExecutor, requestBuilder, cancellationToken);
}
}
14 changes: 11 additions & 3 deletions src/Trax.Api.GraphQL/Configuration/QueryModelRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ namespace Trax.Api.GraphQL.Configuration;

/// <summary>
/// Represents a discovered entity type marked with <see cref="TraxQueryModelAttribute"/>
/// and its owning DbContext type.
/// and its owning DbContext type. <see cref="AuthorizeAttributes"/> captures every
/// <see cref="TraxAuthorizeAttribute"/> applied to the entity (including those inherited
/// from base classes / interfaces) so the type module can attach the <c>@authorize</c>
/// directive at <c>ObjectType</c> level for transitive enforcement.
/// </summary>
public record QueryModelRegistration(
Type EntityType,
Type DbContextType,
TraxQueryModelAttribute Attribute,
Type? FilterInputType = null,
Type? SortInputType = null
);
Type? SortInputType = null,
IReadOnlyList<TraxAuthorizeAttribute>? AuthorizeAttributes = null
)
{
public IReadOnlyList<TraxAuthorizeAttribute> AuthorizeAttributes { get; init; } =
AuthorizeAttributes ?? Array.Empty<TraxAuthorizeAttribute>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@ internal GraphQLConfiguration Build()
FilterTypeOverrides.TryGetValue(entityType, out var filterType);
SortTypeOverrides.TryGetValue(entityType, out var sortType);

var authorizeAttributes = DiscoverAuthorizeAttributes(entityType);
ValidateAuthorizeAttributeShapes(entityType, authorizeAttributes);

modelRegistrations.Add(
new QueryModelRegistration(
entityType,
dbContextType,
attr,
filterType,
sortType
sortType,
authorizeAttributes
)
);
}
Expand All @@ -59,6 +63,70 @@ internal GraphQLConfiguration Build()
);
}

/// <summary>
/// Collects every <see cref="TraxAuthorizeAttribute"/> declared on the entity type
/// (including any inherited via base classes or interfaces). De-duplicates by
/// reference identity so a single attribute instance is not counted twice when the
/// CLR returns it via multiple inheritance paths.
/// </summary>
private static IReadOnlyList<TraxAuthorizeAttribute> DiscoverAuthorizeAttributes(
Type entityType
)
{
var seen = new HashSet<TraxAuthorizeAttribute>(ReferenceEqualityComparer.Instance);
var ordered = new List<TraxAuthorizeAttribute>();

// The entity class itself, plus every interface it implements. `Inherited = true`
// on the attribute already walks the base-class chain.
var carriers = new List<Type> { entityType };
carriers.AddRange(entityType.GetInterfaces());

foreach (var carrier in carriers)
{
foreach (var attr in carrier.GetCustomAttributes<TraxAuthorizeAttribute>(inherit: true))
{
if (seen.Add(attr))
ordered.Add(attr);
}
}

return ordered;
}

/// <summary>
/// Build-time shape validation for <see cref="TraxAuthorizeAttribute"/> on a query
/// model entity. Mirrors the train-side validator at
/// <c>AuthorizationRegistrationValidator.ValidateAttributeShapes</c>: whitespace
/// Policy and Roles values are caught here rather than producing a runtime gate
/// that silently denies everyone.
/// </summary>
private static void ValidateAuthorizeAttributeShapes(
Type entityType,
IReadOnlyList<TraxAuthorizeAttribute> attributes
)
{
foreach (var attribute in attributes)
{
if (attribute.Policy is not null && string.IsNullOrWhiteSpace(attribute.Policy))
throw new InvalidOperationException(
$"[TraxAuthorize] on '{entityType.FullName}' has an empty or whitespace "
+ "Policy value. Remove the parameter or provide a real policy name."
);

if (
attribute.Roles is not null
&& attribute
.Roles.Split(',', StringSplitOptions.TrimEntries)
.All(string.IsNullOrEmpty)
)
throw new InvalidOperationException(
$"[TraxAuthorize(Roles=\"{attribute.Roles}\")] on '{entityType.FullName}' "
+ "parsed to zero roles after splitting on ','. Remove the Roles "
+ "argument or provide one or more non-empty role names."
);
}
}

private static void ValidateExposeAs(Type entityType, TraxQueryModelAttribute attr)
{
if (attr.ExposeAs is not { } exposeAs)
Expand Down
10 changes: 10 additions & 0 deletions src/Trax.Api.GraphQL/Errors/TraxErrorFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ internal class TraxErrorFilter : IErrorFilter
{
public IError OnError(IError error)
{
// HotChocolate's @authorize directive (used by [TraxAuthorize] on
// [TraxQueryModel] entities) raises errors without an attached
// exception — they carry only a code. Normalise both authentication-
// and authorization-failure codes to the Trax public shape so callers
// see a single uniform error regardless of which path failed.
if (error.Code is "AUTH_NOT_AUTHENTICATED" or "AUTH_NOT_AUTHORIZED")
return error
.WithMessage(TrainAuthorizationException.PublicMessage)
.WithCode("TRAX_AUTHORIZATION");

if (error.Exception is null)
return error;

Expand Down
15 changes: 15 additions & 0 deletions src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,21 @@ Func<TraxGraphQLBuilder, TraxGraphQLBuilder> configure
services.AddSingleton<QueryModelTypeModule>();
graphqlBuilder.AddTypeModule<QueryModelTypeModule>();

// Wire HotChocolate's @authorize directive handler whenever any
// model entity carries [TraxAuthorize]. The directive runs against
// ASP.NET Core's IAuthorizationService, so RequireRole / policy
// definitions registered via services.AddAuthorization(...) apply.
// Wiring is conditional so the dependency is opt-in for hosts that
// expose no gated models (the directive handler pulls in ASP.NET
// Core authorization machinery).
if (config.ModelRegistrations.Any(r => r.AuthorizeAttributes.Count > 0))
{
graphqlBuilder.AddAuthorization();
graphqlBuilder.AddHttpRequestInterceptor<QueryModelAuthenticationInterceptor>();
services.AddHostedService<QueryModelAuthorizationValidator>();
services.AddHostedService<QueryModelAuthorizationSchemaValidator>();
}

// Register DiscoverQueries base type and discover field on RootQuery.
// TrainTypeModule will skip creating these when it detects model registrations.
graphqlBuilder.AddType(new ObjectType<DiscoverQueries>());
Expand Down
34 changes: 23 additions & 11 deletions src/Trax.Api.GraphQL/Startup/GraphQLModelExposureWarningService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@ namespace Trax.Api.GraphQL.Startup;

/// <summary>
/// Warns at host start when a GraphQL schema exposes <c>AddDbContext</c>-driven
/// model queries without an endpoint-level authorization gate. The model-query
/// surface has no per-field authorization hook; anonymous clients can read every
/// registered entity unless the consumer gates the endpoint explicitly via the
/// <c>configure</c> callback on <c>UseTraxGraphQL</c>.
/// model queries that are NOT individually gated by <c>[TraxAuthorize]</c>. The
/// ungated model surface relies entirely on endpoint-level authorization; if the
/// endpoint is anonymous, every authenticated caller can read every ungated
/// registered entity.
/// </summary>
/// <remarks>
/// Emits at <see cref="LogLevel.Warning"/> only — it is a reminder, not a fatal
/// misconfiguration. Teams that intentionally ship public model-query endpoints
/// can ignore the log or filter it out. Teams that did not intend public reads
/// should either add <c>RequireAuthorization(...)</c> to the endpoint or gate
/// specific queries with <c>[TraxAuthorize]</c> (when that surface lands).
/// should either add <c>RequireAuthorization(...)</c> to the endpoint or attach
/// <c>[TraxAuthorize]</c> to the sensitive entity classes so the directive runs
/// at type level (and is enforced even when the entity is reached transitively
/// via a navigation property on an ungated parent).
/// </remarks>
internal sealed class GraphQLModelExposureWarningService(
GraphQLConfiguration configuration,
Expand All @@ -28,12 +30,22 @@ public Task StartAsync(CancellationToken cancellationToken)
if (configuration.ModelRegistrations.Count == 0)
return Task.CompletedTask;

var ungated = configuration
.ModelRegistrations.Where(r => r.AuthorizeAttributes.Count == 0)
.Count();

if (ungated == 0)
return Task.CompletedTask;

logger.LogWarning(
"Trax GraphQL: {Count} model query registration(s) are active. "
+ "AddDbContext-backed model queries currently have no per-field authorization. "
+ "Unless you gate the endpoint via `UseTraxGraphQL(configure: e => e.RequireAuthorization(...))` "
+ "or accept that every authenticated caller can read every registered entity, "
+ "review this surface before shipping to production.",
"Trax GraphQL: {Ungated} of {Total} model query registration(s) carry no "
+ "[TraxAuthorize]. Ungated model queries are exposed to every caller that "
+ "reaches the endpoint. Either gate the endpoint via "
+ "`UseTraxGraphQL(configure: e => e.RequireAuthorization(...))`, attach "
+ "[TraxAuthorize] to the sensitive entity classes (which enforces at type "
+ "level, including transitively through navigation properties), or accept "
+ "the public-read posture intentionally.",
ungated,
configuration.ModelRegistrations.Count
);

Expand Down
Loading
Loading