diff --git a/.github/workflows/nuget_release.yml b/.github/workflows/nuget_release.yml index cd66567..3bee542 100644 --- a/.github/workflows/nuget_release.yml +++ b/.github/workflows/nuget_release.yml @@ -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 diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 1328742..4d513c0 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -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 diff --git a/src/Trax.Api.GraphQL/Authorization/QueryModelAuthenticationInterceptor.cs b/src/Trax.Api.GraphQL/Authorization/QueryModelAuthenticationInterceptor.cs new file mode 100644 index 0000000..6c97b93 --- /dev/null +++ b/src/Trax.Api.GraphQL/Authorization/QueryModelAuthenticationInterceptor.cs @@ -0,0 +1,73 @@ +using HotChocolate.AspNetCore; +using HotChocolate.Execution; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Trax.Api.GraphQL.Authorization; + +/// +/// HotChocolate HTTP request interceptor that populates +/// for inbound GraphQL requests by attempting authentication against every registered +/// scheme until one succeeds. Wired automatically when at least one [TraxQueryModel] +/// entity carries [TraxAuthorize]. +/// +/// +/// Without this, HotChocolate's @authorize directive evaluates against an +/// anonymous principal whenever no default authentication scheme is configured on +/// AddAuthentication(). ASP.NET Core's UseAuthentication() 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. +/// +/// +/// +/// The interceptor: +/// +/// +/// Returns early if HttpContext.User is already authenticated (something +/// upstream — endpoint-level RequireAuthorization, a default scheme, a custom +/// interceptor — has handled it). +/// Otherwise, iterates over every registered authentication scheme and attempts +/// authentication. The first scheme that succeeds wins; the resulting principal is +/// assigned to HttpContext.User. +/// If no scheme succeeds, the principal stays anonymous and the request +/// proceeds — @authorize will then reject any gated field/type the request +/// touches. +/// +/// +/// +/// WebSocket upgrades and the Banana Cake Pop tool page are not affected: HotChocolate +/// invokes this interceptor only for actual GraphQL HTTP execution requests. +/// +/// +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); + } +} diff --git a/src/Trax.Api.GraphQL/Configuration/QueryModelRegistration.cs b/src/Trax.Api.GraphQL/Configuration/QueryModelRegistration.cs index e312441..26ad006 100644 --- a/src/Trax.Api.GraphQL/Configuration/QueryModelRegistration.cs +++ b/src/Trax.Api.GraphQL/Configuration/QueryModelRegistration.cs @@ -4,12 +4,20 @@ namespace Trax.Api.GraphQL.Configuration; /// /// Represents a discovered entity type marked with -/// and its owning DbContext type. +/// and its owning DbContext type. captures every +/// applied to the entity (including those inherited +/// from base classes / interfaces) so the type module can attach the @authorize +/// directive at ObjectType level for transitive enforcement. /// public record QueryModelRegistration( Type EntityType, Type DbContextType, TraxQueryModelAttribute Attribute, Type? FilterInputType = null, - Type? SortInputType = null -); + Type? SortInputType = null, + IReadOnlyList? AuthorizeAttributes = null +) +{ + public IReadOnlyList AuthorizeAttributes { get; init; } = + AuthorizeAttributes ?? Array.Empty(); +} diff --git a/src/Trax.Api.GraphQL/Configuration/TraxGraphQLBuilder/TraxGraphQLBuilder.Build.cs b/src/Trax.Api.GraphQL/Configuration/TraxGraphQLBuilder/TraxGraphQLBuilder.Build.cs index 29f89c2..e2109de 100644 --- a/src/Trax.Api.GraphQL/Configuration/TraxGraphQLBuilder/TraxGraphQLBuilder.Build.cs +++ b/src/Trax.Api.GraphQL/Configuration/TraxGraphQLBuilder/TraxGraphQLBuilder.Build.cs @@ -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 ) ); } @@ -59,6 +63,70 @@ internal GraphQLConfiguration Build() ); } + /// + /// Collects every 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. + /// + private static IReadOnlyList DiscoverAuthorizeAttributes( + Type entityType + ) + { + var seen = new HashSet(ReferenceEqualityComparer.Instance); + var ordered = new List(); + + // The entity class itself, plus every interface it implements. `Inherited = true` + // on the attribute already walks the base-class chain. + var carriers = new List { entityType }; + carriers.AddRange(entityType.GetInterfaces()); + + foreach (var carrier in carriers) + { + foreach (var attr in carrier.GetCustomAttributes(inherit: true)) + { + if (seen.Add(attr)) + ordered.Add(attr); + } + } + + return ordered; + } + + /// + /// Build-time shape validation for on a query + /// model entity. Mirrors the train-side validator at + /// AuthorizationRegistrationValidator.ValidateAttributeShapes: whitespace + /// Policy and Roles values are caught here rather than producing a runtime gate + /// that silently denies everyone. + /// + private static void ValidateAuthorizeAttributeShapes( + Type entityType, + IReadOnlyList 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) diff --git a/src/Trax.Api.GraphQL/Errors/TraxErrorFilter.cs b/src/Trax.Api.GraphQL/Errors/TraxErrorFilter.cs index b2c7dfc..17ead52 100644 --- a/src/Trax.Api.GraphQL/Errors/TraxErrorFilter.cs +++ b/src/Trax.Api.GraphQL/Errors/TraxErrorFilter.cs @@ -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; diff --git a/src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs b/src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs index 94a0d3f..185c8bd 100644 --- a/src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs +++ b/src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs @@ -181,6 +181,21 @@ Func configure services.AddSingleton(); graphqlBuilder.AddTypeModule(); + // 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(); + services.AddHostedService(); + services.AddHostedService(); + } + // Register DiscoverQueries base type and discover field on RootQuery. // TrainTypeModule will skip creating these when it detects model registrations. graphqlBuilder.AddType(new ObjectType()); diff --git a/src/Trax.Api.GraphQL/Startup/GraphQLModelExposureWarningService.cs b/src/Trax.Api.GraphQL/Startup/GraphQLModelExposureWarningService.cs index 3d9e51d..290a9cb 100644 --- a/src/Trax.Api.GraphQL/Startup/GraphQLModelExposureWarningService.cs +++ b/src/Trax.Api.GraphQL/Startup/GraphQLModelExposureWarningService.cs @@ -6,17 +6,19 @@ namespace Trax.Api.GraphQL.Startup; /// /// Warns at host start when a GraphQL schema exposes AddDbContext-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 -/// configure callback on UseTraxGraphQL. +/// model queries that are NOT individually gated by [TraxAuthorize]. The +/// ungated model surface relies entirely on endpoint-level authorization; if the +/// endpoint is anonymous, every authenticated caller can read every ungated +/// registered entity. /// /// /// Emits at 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 RequireAuthorization(...) to the endpoint or gate -/// specific queries with [TraxAuthorize] (when that surface lands). +/// should either add RequireAuthorization(...) to the endpoint or attach +/// [TraxAuthorize] 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). /// internal sealed class GraphQLModelExposureWarningService( GraphQLConfiguration configuration, @@ -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 ); diff --git a/src/Trax.Api.GraphQL/Startup/QueryModelAuthorizationSchemaValidator.cs b/src/Trax.Api.GraphQL/Startup/QueryModelAuthorizationSchemaValidator.cs new file mode 100644 index 0000000..2ad5ead --- /dev/null +++ b/src/Trax.Api.GraphQL/Startup/QueryModelAuthorizationSchemaValidator.cs @@ -0,0 +1,163 @@ +using HotChocolate.Authorization; +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Trax.Api.GraphQL.Configuration; +using Trax.Api.GraphQL.TypeModules; +using Trax.Effect.Attributes; + +namespace Trax.Api.GraphQL.Startup; + +/// +/// Post-build invariant check that every [TraxAuthorize]-decorated +/// [TraxQueryModel] entity still carries the @authorize +/// directive on its ObjectType and on its entry field under +/// discover after the schema is fully built. +/// +/// +/// This closes the only remaining escape hatch: a consumer-supplied +/// ConfigureSchema callback (which runs after the standard Trax +/// type-module wiring) could in principle add or replace types in a way that +/// strips the directives Trax attached. The discovery, registration, and +/// type-module layers all run inside Trax-controlled code and have no +/// "skip auth" knob, but ConfigureSchema has full IRequestExecutorBuilder +/// access by design. This validator runs at host start, materialises the +/// schema, and re-asserts the invariant. If a gate has been removed, the +/// host fails to start with a message naming the entity and the missing +/// directive location. +/// +/// +/// +/// The check is read-only and idempotent. It does not modify the schema. +/// It runs once at startup and never again. +/// +/// +internal sealed class QueryModelAuthorizationSchemaValidator( + GraphQLConfiguration configuration, + IServiceProvider serviceProvider +) : IHostedService +{ + /// Schema name registered by Trax for the GraphQL endpoint. + private const string TraxSchemaName = "trax"; + + public async Task StartAsync(CancellationToken cancellationToken) + { + var gated = configuration + .ModelRegistrations.Where(r => r.AuthorizeAttributes.Count > 0) + .ToList(); + + if (gated.Count == 0) + return; + + // Materialise the schema. Building it now (a) catches any other schema + // misconfiguration at host start instead of on the first request, and + // (b) lets us walk the resolved types. + using var scope = serviceProvider.CreateScope(); + var resolver = scope.ServiceProvider.GetRequiredService(); + var executor = await resolver.GetRequestExecutorAsync(TraxSchemaName, cancellationToken); + var schema = executor.Schema; + + var queryType = schema.QueryType; + + foreach (var reg in gated) + { + VerifyTypeLevelDirective(schema, reg); + VerifyEntryFieldDirective(queryType, reg); + } + } + + /// + /// Walks the schema for the built from the entity's CLR + /// type and confirms it has at least one @authorize directive. This is the + /// gate that enforces transitive navigation — without it, any field elsewhere in + /// the schema whose return type is this entity would be readable. + /// + private static void VerifyTypeLevelDirective(ISchema schema, QueryModelRegistration reg) + { + var objectType = schema + .Types.OfType() + .FirstOrDefault(t => t.RuntimeType == reg.EntityType); + + if (objectType is null) + throw new InvalidOperationException( + $"[TraxAuthorize] invariant violated: no ObjectType for " + + $"'{reg.EntityType.FullName}' is present in the built schema. " + + "A ConfigureSchema callback may have replaced or removed it. " + + "Trax cannot enforce type-level authorization on a type it " + + "cannot find." + ); + + if (!HasAuthorizeDirective(objectType.Directives)) + throw new InvalidOperationException( + $"[TraxAuthorize] invariant violated: ObjectType for " + + $"'{reg.EntityType.FullName}' has no @authorize directive in " + + "the built schema. The directive was attached during type-module " + + "wiring but is now missing — a ConfigureSchema callback has " + + "replaced the ObjectType with an unauthorized variant. Remove the " + + "override, or remove [TraxAuthorize] from the entity if the " + + "exposure is intentional." + ); + } + + /// + /// Walks the discover namespace down to the entry field for this entity + /// and confirms the field carries an @authorize directive. The field-level + /// gate is what blocks Connection-shaped scalars (totalCount, pageInfo) + /// from leaking through when the request never resolves an entity node. + /// + private static void VerifyEntryFieldDirective(IObjectType rootQuery, QueryModelRegistration reg) + { + // Path: RootQuery.discover -> (DiscoverQueries[.namespace]). + var discoverField = rootQuery.Fields.FirstOrDefault(f => f.Name == "discover"); + if (discoverField is null) + throw new InvalidOperationException( + $"[TraxAuthorize] invariant violated: the `discover` field is not " + + $"present on RootQuery, so the entry point for " + + $"'{reg.EntityType.FullName}' cannot be located. Likely a " + + "ConfigureSchema callback has removed the discover namespace." + ); + + var discoverType = (IObjectType)discoverField.Type.NamedType(); + var container = discoverType; + + if (reg.Attribute.Namespace is not null) + { + var nsFieldName = TrainTypeModule.CamelCase(reg.Attribute.Namespace); + var nsField = discoverType.Fields.FirstOrDefault(f => f.Name == nsFieldName); + if (nsField is null) + throw new InvalidOperationException( + $"[TraxAuthorize] invariant violated: namespace field " + + $"'{nsFieldName}' is missing under `discover` for " + + $"'{reg.EntityType.FullName}'." + ); + container = (IObjectType)nsField.Type.NamedType(); + } + + var fieldName = + reg.Attribute.Name ?? QueryModelTypeModule.DeriveModelName(reg.EntityType.Name); + var entryField = container.Fields.FirstOrDefault(f => f.Name == fieldName); + if (entryField is null) + throw new InvalidOperationException( + $"[TraxAuthorize] invariant violated: entry field '{fieldName}' is " + + $"missing under `discover` for '{reg.EntityType.FullName}'." + ); + + if (!HasAuthorizeDirective(entryField.Directives)) + throw new InvalidOperationException( + $"[TraxAuthorize] invariant violated: entry field '{fieldName}' for " + + $"'{reg.EntityType.FullName}' has no @authorize directive in the " + + "built schema. Without it, an unauthorized caller can read " + + "Connection-shaped scalars like `totalCount` and `pageInfo` " + + "even though they never resolve a node of the gated type. " + + "A ConfigureSchema callback has stripped the directive — " + + "remove the override, or remove [TraxAuthorize] from the " + + "entity if the exposure is intentional." + ); + } + + private static bool HasAuthorizeDirective(IDirectiveCollection directives) => + directives.Any(d => d.Type.Name == "authorize"); + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Trax.Api.GraphQL/Startup/QueryModelAuthorizationValidator.cs b/src/Trax.Api.GraphQL/Startup/QueryModelAuthorizationValidator.cs new file mode 100644 index 0000000..746f5be --- /dev/null +++ b/src/Trax.Api.GraphQL/Startup/QueryModelAuthorizationValidator.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Hosting; +using Trax.Api.GraphQL.Configuration; + +namespace Trax.Api.GraphQL.Startup; + +/// +/// Fail-loud startup check that every +/// directive emitted for a [TraxQueryModel] entity points at a policy +/// the host has actually registered. Without this, a typoed +/// [TraxAuthorize(Policy = "AdmnPolicy")] would compile, ship, and +/// silently deny every caller at runtime — the worst-of-both: insecure to +/// reason about (looks gated, isn't), and broken in production. +/// +/// +/// Mirrors TraxGraphQLAuthPolicyValidator, which performs the same +/// check for the endpoint-level RequireAuthorization() opt-in. +/// +internal sealed class QueryModelAuthorizationValidator( + GraphQLConfiguration configuration, + IAuthorizationPolicyProvider policyProvider +) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var reg in configuration.ModelRegistrations) + { + foreach (var attr in reg.AuthorizeAttributes) + { + if (string.IsNullOrWhiteSpace(attr.Policy)) + continue; + if (!seen.Add(attr.Policy)) + continue; + + var policy = await policyProvider.GetPolicyAsync(attr.Policy); + if (policy is null) + throw new InvalidOperationException( + $"[TraxAuthorize(Policy = \"{attr.Policy}\")] on '{reg.EntityType.FullName}' " + + "references an authorization policy that is not registered. Call " + + $"`services.AddAuthorization(opts => opts.AddPolicy(\"{attr.Policy}\", ...))` " + + "during host setup, or pass an existing policy name to [TraxAuthorize]." + ); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Trax.Api.GraphQL/Trax.Api.GraphQL.csproj b/src/Trax.Api.GraphQL/Trax.Api.GraphQL.csproj index 2e6db3b..3948de6 100644 --- a/src/Trax.Api.GraphQL/Trax.Api.GraphQL.csproj +++ b/src/Trax.Api.GraphQL/Trax.Api.GraphQL.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Trax.Api.GraphQL/TypeModules/QueryModelTypeModule.cs b/src/Trax.Api.GraphQL/TypeModules/QueryModelTypeModule.cs index c48b580..dcf35ea 100644 --- a/src/Trax.Api.GraphQL/TypeModules/QueryModelTypeModule.cs +++ b/src/Trax.Api.GraphQL/TypeModules/QueryModelTypeModule.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq.Expressions; using System.Reflection; +using HotChocolate.Authorization; using HotChocolate.Data; using HotChocolate.Data.Filters; using HotChocolate.Data.Sorting; @@ -23,7 +24,7 @@ namespace Trax.Api.GraphQL.TypeModules; /// field under discover with optional cursor pagination, filtering, /// sorting, and projection based on the attribute configuration. /// -public class QueryModelTypeModule(GraphQLConfiguration configuration) : TypeModule +public sealed class QueryModelTypeModule(GraphQLConfiguration configuration) : TypeModule { /// /// Discovers all registered query model entities and generates the GraphQL schema types: @@ -49,7 +50,7 @@ CancellationToken cancellationToken var objectType = (ITypeSystemMember) CreateObjectTypeMethod .MakeGenericMethod(reg.EntityType) - .Invoke(null, [reg.Attribute])!; + .Invoke(null, [reg.Attribute, reg.AuthorizeAttributes])!; types.Add(objectType); } } @@ -154,6 +155,16 @@ QueryModelRegistration reg { var attr = reg.Attribute; + // Apply [TraxAuthorize] at field level so the entry point itself is + // gated. Type-level @authorize alone leaves a hole: a request that + // selects only Connection-shaped scalars like `totalCount` or + // `pageInfo.hasNextPage` never resolves a node of the entity type, + // so the type-level directive does not fire. Field-level enforcement + // blocks the entry point unconditionally; type-level enforcement + // (in CreateObjectType) covers transitive navigation from ungated + // parents. + ApplyAuthorizeDirectives(field, reg.AuthorizeAttributes); + // Apply features in the correct middleware pipeline order: // Paging > Projection > Filtering > Sorting if (attr.Paging) @@ -230,7 +241,10 @@ QueryModelRegistration reg }); } - private static ObjectType CreateObjectType(TraxQueryModelAttribute attr) + private static ObjectType CreateObjectType( + TraxQueryModelAttribute attr, + IReadOnlyList authorizeAttributes + ) where TEntity : class { // ExposeAs takes precedence: build the GraphQL type from the supplied @@ -259,11 +273,20 @@ var prop in typeof(TEntity).GetProperties( if (allowedNames.Contains(prop.Name)) descriptor.Field(prop); } + + ApplyAuthorizeDirectives(descriptor, authorizeAttributes); }); } if (attr.BindFields != FieldBindingBehavior.Explicit) - return new ObjectType(); + { + if (authorizeAttributes.Count == 0) + return new ObjectType(); + + return new ObjectType(descriptor => + ApplyAuthorizeDirectives(descriptor, authorizeAttributes) + ); + } return new ObjectType(descriptor => { @@ -278,9 +301,97 @@ var prop in typeof(TEntity).GetProperties( if (prop.GetCustomAttribute() is not null) descriptor.Field(prop); } + + ApplyAuthorizeDirectives(descriptor, authorizeAttributes); }); } + /// + /// Attaches HotChocolate @authorize directives to an + /// according to the supplied set. The directive + /// is applied at type level (not field level) so any field whose return + /// type is this object enforces the gate, including navigation properties reached + /// transitively from an ungated parent type. + /// + /// + /// Combinator semantics mirror 's + /// documented behavior for trains: + /// + /// + /// Bare [TraxAuthorize] with no policy or roles is materialised by + /// emitting an empty @authorize directive, which the HotChocolate authorization + /// middleware treats as "require authenticated user." + /// Every becomes its own directive; + /// HotChocolate evaluates them with AND semantics. + /// All values across every attached + /// attribute are unioned (CSV split, trimmed, distinct) and emitted as a single + /// directive — the principal must hold at least one. Multiple role directives would + /// AND the OR-sets together, which is not the documented contract. + /// + /// + private static void ApplyAuthorizeDirectives( + IObjectTypeDescriptor descriptor, + IReadOnlyList attributes + ) + where TEntity : class + { + ExtractRules(attributes, out var policies, out var roles); + + foreach (var policy in policies) + descriptor.Authorize(policy, ApplyPolicy.BeforeResolver); + + if (roles.Length > 0) + descriptor.Authorize(roles); + else if (policies.Length == 0 && attributes.Count > 0) + descriptor.Authorize(ApplyPolicy.BeforeResolver); + } + + private static void ApplyAuthorizeDirectives( + IObjectFieldDescriptor descriptor, + IReadOnlyList attributes + ) + { + ExtractRules(attributes, out var policies, out var roles); + + foreach (var policy in policies) + descriptor.Authorize(policy, ApplyPolicy.BeforeResolver); + + if (roles.Length > 0) + descriptor.Authorize(roles); + else if (policies.Length == 0 && attributes.Count > 0) + descriptor.Authorize(ApplyPolicy.BeforeResolver); + } + + /// + /// Reduces a set of instances into the + /// distinct policy and role lists used to emit @authorize directives. + /// Policies AND across attributes; roles OR within an attribute (CSV split) + /// and OR across attributes (unioned). The semantics mirror + /// 's + /// train-side enforcement so a model and a train that declare the same + /// [TraxAuthorize] shape have identical access rules. + /// + private static void ExtractRules( + IReadOnlyList attributes, + out string[] policies, + out string[] roles + ) + { + roles = attributes + .Where(a => a.Roles is not null) + .SelectMany(a => a.Roles!.Split(',', StringSplitOptions.TrimEntries)) + .Where(r => !string.IsNullOrEmpty(r)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + policies = attributes + .Select(a => a.Policy) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p!) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + private static readonly MethodInfo FilterFieldGeneric = typeof(QueryModelTypeModule).GetMethod( nameof(AddFilterField), BindingFlags.NonPublic | BindingFlags.Static diff --git a/tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs b/tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs index ca7d931..f83d041 100644 --- a/tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs +++ b/tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs @@ -32,7 +32,10 @@ namespace Trax.Api.Tests.AuthE2E; public static class AuthE2EHost { // Each AuthE2E test class passes its own database name to keep migrations - // and advisory locks isolated; CI provisions the per-class DBs upfront. + // and advisory locks isolated; the database is provisioned on demand by + // EnsureDatabaseExists below (called from StartAsync and from the seeder + // helpers on the TestDbContexts), so no changes to CI workflows are + // required when a new fixture is added. // // Connection knobs: // - Timeout=30 — CI runners occasionally need >15s (the Npgsql default) @@ -48,6 +51,50 @@ public static string ConnectionString(string database) => + "Maximum Pool Size=8;Minimum Pool Size=0;Connection Idle Lifetime=30;" + "Timeout=30;Tcp Keepalive=true"; + /// + /// Idempotently creates the per-fixture test database. Each AuthE2E test + /// class uses its own database name so migrations and advisory locks stay + /// isolated; rather than pushing that list of names into the CI workflow + /// (where it would drift the moment someone adds a new fixture and forgets + /// to update the YAML), the host provisions on demand. + /// + /// + /// Connects to the cluster's maintenance database trax (which is + /// guaranteed to exist by the docker-compose entrypoint / CI service + /// image) and runs CREATE DATABASE. Catches the duplicate-database + /// SQLState (42P04) so re-runs are no-ops. + /// + public static void EnsureDatabaseExists(string database) + { + const string maintenanceConnectionString = + "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123;Timeout=30"; + + using var connection = new Npgsql.NpgsqlConnection(maintenanceConnectionString); + connection.Open(); + using var command = connection.CreateCommand(); + // Database names are validated against an allowlist of characters here + // because the CREATE DATABASE statement does not support parameter + // binding for identifiers. The test fixtures always pass compile-time + // literals, but enforce the shape defensively to make accidental + // misuse loud. + if (!System.Text.RegularExpressions.Regex.IsMatch(database, "^[a-z_][a-z0-9_]*$")) + throw new ArgumentException( + $"Database name '{database}' contains characters outside [a-z0-9_]. " + + "Test fixtures must use snake_case ASCII identifiers.", + nameof(database) + ); + + command.CommandText = $"CREATE DATABASE \"{database}\""; + try + { + command.ExecuteNonQuery(); + } + catch (Npgsql.PostgresException ex) when (ex.SqlState == "42P04") + { + // Database already exists — idempotent no-op. + } + } + public const string JwtIssuer = "https://trax-e2e-tests"; public const string JwtAudience = "trax-e2e"; public static readonly byte[] JwtKey = Encoding.UTF8.GetBytes(new string('e', 32)); @@ -66,6 +113,7 @@ public enum Schemes public static async Task StartAsync(Schemes schemes, string database) { + EnsureDatabaseExists(database); var connectionString = ConnectionString(database); var host = new HostBuilder() .ConfigureWebHost(web => @@ -112,12 +160,23 @@ public static async Task StartAsync(Schemes schemes, string database) o.UseNpgsql(connectionString) ); + // Second DbContext for [TraxAuthorize]-on-[TraxQueryModel] + // coverage. Lives in the `test_authz` schema so it does + // not interfere with the unauthorized fixtures. Only the + // QueryModelAuthorize E2E suite seeds it; the other + // suites tolerate its presence because none of their + // queries touch its fields. + services.AddDbContextFactory(o => + o.UseNpgsql(connectionString) + ); + services.AddTraxGraphQL(graphql => graphql // Default is generous (15) and covers the // discover/namespace/entity/nodes/field chain // without needing an explicit override. .AddDbContext() + .AddDbContext() .AddTypeExtensions(typeof(AuthE2EHost).Assembly) // Add custom subscription + mutation type // extensions for principal-propagation tests. diff --git a/tests/Trax.Api.Tests/AuthE2E/AuthzTestDbContext.cs b/tests/Trax.Api.Tests/AuthE2E/AuthzTestDbContext.cs new file mode 100644 index 0000000..25026b8 --- /dev/null +++ b/tests/Trax.Api.Tests/AuthE2E/AuthzTestDbContext.cs @@ -0,0 +1,211 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; +using Trax.Effect.Attributes; + +namespace Trax.Api.Tests.AuthE2E; + +/// +/// Test entities for type-level [TraxAuthorize] on [TraxQueryModel]. +/// Lives in its own test_authz schema so the data does not collide with +/// the unauthorized fixtures in . +/// +/// The relationship between and is +/// the centerpiece of the transitive-navigation security test: the parent +/// type is ungated, the child type carries [TraxAuthorize(Roles="Admin")], +/// and the E2E suite verifies that a Player principal cannot reach the child +/// through the parent's books navigation property. +/// +[TraxQueryModel(Namespace = "vault", Description = "Owners of vault items (ungated).")] +[Table("owners", Schema = "test_authz")] +public class Owner +{ + [Key] + [Column("id")] + public long Id { get; set; } + + [Column("name")] + public string Name { get; set; } = ""; + + public List Books { get; set; } = new(); +} + +/// +/// Child entity gated with [TraxAuthorize(Roles="Admin")]. Direct access +/// (top-level ownedBooks) and transitive access (through +/// owners[].books) must both honor the role gate. +/// +[TraxQueryModel(Namespace = "vault", Description = "Admin-only books.")] +[TraxAuthorize(Roles = "Admin")] +[Table("owned_books", Schema = "test_authz")] +public class OwnedBook +{ + [Key] + [Column("id")] + public long Id { get; set; } + + [Column("owner_id")] + public long OwnerId { get; set; } + + [Column("title")] + public string Title { get; set; } = ""; + + public Owner Owner { get; set; } = null!; +} + +/// +/// Entity gated with a CSV role list (Roles="Admin,Player"). Both roles +/// satisfy the gate (OR semantics within a single attribute). +/// +[TraxQueryModel(Namespace = "vault", Description = "Memos visible to Admin or Player.")] +[TraxAuthorize(Roles = "Admin,Player")] +[Table("memos", Schema = "test_authz")] +public class Memo +{ + [Key] + [Column("id")] + public long Id { get; set; } + + [Column("body")] + public string Body { get; set; } = ""; +} + +/// +/// Entity gated with two stacked attributes. AND semantics across attributes: +/// the principal must hold Admin AND satisfy AdminPolicy. Both +/// reduce to the same underlying claim in this test host, so the practical +/// effect is "Admin only with both checks executed." The point of the test is +/// that both directives fire. +/// +[TraxQueryModel(Namespace = "vault", Description = "Stacked-attribute restricted documents.")] +[TraxAuthorize(Roles = "Admin")] +[TraxAuthorize(Policy = "AdminPolicy")] +[Table("restricted_docs", Schema = "test_authz")] +public class RestrictedDoc +{ + [Key] + [Column("id")] + public long Id { get; set; } + + [Column("payload")] + public string Payload { get; set; } = ""; +} + +/// +/// Entity gated with bare [TraxAuthorize]: any authenticated user passes, +/// anonymous callers fail. +/// +[TraxQueryModel(Namespace = "vault", Description = "Members-only area.")] +[TraxAuthorize] +[Table("member_areas", Schema = "test_authz")] +public class MemberArea +{ + [Key] + [Column("id")] + public long Id { get; set; } + + [Column("name")] + public string Name { get; set; } = ""; +} + +public class AuthzTestDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Owners { get; set; } = null!; + public DbSet OwnedBooks { get; set; } = null!; + public DbSet Memos { get; set; } = null!; + public DbSet RestrictedDocs { get; set; } = null!; + public DbSet MemberAreas { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("test_authz"); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.HasMany(e => e.Books).WithOne(b => b.Owner).HasForeignKey(b => b.OwnerId); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.HasIndex(e => e.OwnerId); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + }); + } + + /// + /// Idempotently provisions the test_authz schema and a deterministic + /// fixture row set. The owners + books fixture is the key payload for the + /// transitive-navigation security tests: each owner has at least one book + /// so the navigation actually attempts to materialize children. + /// + public static void EnsureSeeded(string connectionString) + { + AuthE2EHost.EnsureDatabaseExists( + new Npgsql.NpgsqlConnectionStringBuilder(connectionString).Database! + ); + + var opts = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .Options; + using var db = new AuthzTestDbContext(opts); + + db.Database.ExecuteSqlRaw("CREATE SCHEMA IF NOT EXISTS test_authz"); + try + { + db.Database.ExecuteSqlRaw(db.Database.GenerateCreateScript()); + } + catch (Npgsql.PostgresException ex) when (ex.SqlState == "42P07") + { + // Already exists. + } + + db.Database.ExecuteSqlRaw("TRUNCATE TABLE test_authz.owned_books RESTART IDENTITY CASCADE"); + db.Database.ExecuteSqlRaw("TRUNCATE TABLE test_authz.owners RESTART IDENTITY CASCADE"); + db.Database.ExecuteSqlRaw("TRUNCATE TABLE test_authz.memos RESTART IDENTITY"); + db.Database.ExecuteSqlRaw("TRUNCATE TABLE test_authz.restricted_docs RESTART IDENTITY"); + db.Database.ExecuteSqlRaw("TRUNCATE TABLE test_authz.member_areas RESTART IDENTITY"); + + var alice = new Owner + { + Name = "Alice", + Books = + { + new OwnedBook { Title = "Alice's First Book" }, + new OwnedBook { Title = "Alice's Second Book" }, + }, + }; + var bob = new Owner + { + Name = "Bob", + Books = { new OwnedBook { Title = "Bob's Only Book" } }, + }; + db.Owners.AddRange(alice, bob); + + db.Memos.AddRange(new Memo { Body = "memo-1" }, new Memo { Body = "memo-2" }); + db.RestrictedDocs.Add(new RestrictedDoc { Payload = "restricted-1" }); + db.MemberAreas.Add(new MemberArea { Name = "lounge" }); + + db.SaveChanges(); + } +} diff --git a/tests/Trax.Api.Tests/AuthE2E/QueryModelAuthorizeE2ETests.cs b/tests/Trax.Api.Tests/AuthE2E/QueryModelAuthorizeE2ETests.cs new file mode 100644 index 0000000..890b88b --- /dev/null +++ b/tests/Trax.Api.Tests/AuthE2E/QueryModelAuthorizeE2ETests.cs @@ -0,0 +1,538 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using FluentAssertions; +using static Trax.Api.Tests.AuthE2E.AuthE2EHost; + +namespace Trax.Api.Tests.AuthE2E; + +/// +/// End-to-end coverage for [TraxAuthorize] applied to +/// [TraxQueryModel]-annotated entities. The attribute attaches the +/// @authorize directive to the generated ObjectType so the +/// gate enforces uniformly regardless of how the type is reached: +/// +/// +/// top-level discover.vault.ownedBooks (direct entry point) +/// transitively via discover.vault.owners[].books (the navigation +/// path through an ungated parent) +/// +/// +/// The transitive case is the security-critical contract: a Player must not be +/// able to read rows by traversing into them from +/// , which is itself ungated. +/// +[TestFixture] +[NonParallelizable] +public class QueryModelAuthorizeE2ETests +{ + private const string Database = "trax_api_auth_querymodel"; + + private static Task StartAsync(Schemes s) => + AuthE2EHost.StartAsync(s, Database); + + [OneTimeSetUp] + public void SeedDatabase() + { + // Both contexts use the same DB; seed the unauthorized fixtures too so + // any shared startup paths that touch test_auth do not blow up. + TestDbContext.EnsureSeeded(AuthE2EHost.ConnectionString(Database)); + AuthzTestDbContext.EnsureSeeded(AuthE2EHost.ConnectionString(Database)); + } + + // ── Queries ─────────────────────────────────────────────────────────── + + private const string DirectOwnedBooksQuery = """ + { discover { vault { ownedBooks { totalCount nodes { title } } } } } + """; + + private const string OwnersOnlyQuery = """ + { discover { vault { owners { totalCount nodes { name } } } } } + """; + + /// + /// The transitive-navigation probe. Requests OwnedBook through the Owner's + /// books navigation. Must fail for anyone lacking the OwnedBook gate. + /// + private const string OwnersWithBooksQuery = """ + { + discover { + vault { + owners { + nodes { name books { title } } + } + } + } + } + """; + + private const string MemosQuery = """ + { discover { vault { memos { totalCount } } } } + """; + + private const string RestrictedDocsQuery = """ + { discover { vault { restrictedDocs { totalCount } } } } + """; + + private const string MemberAreasQuery = """ + { discover { vault { memberAreas { totalCount } } } } + """; + + // ── Top-level gating: Roles="Admin" ────────────────────────────────── + + [Test] + public async Task DirectOwnedBooks_AsAdmin_Succeeds() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + DirectOwnedBooksQuery, + req => req.Headers.Add("X-Api-Key", AdminApiKey) + ); + + AssertNoErrors(doc); + OwnedBooksField(doc).GetProperty("totalCount").GetInt32().Should().Be(3); + } + + [Test] + public async Task DirectOwnedBooks_AsPlayer_ReturnsAuthorizationError() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + DirectOwnedBooksQuery, + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + AssertTraxAuthorizationError(doc); + } + + [Test] + public async Task DirectOwnedBooks_Anonymous_ReturnsAuthorizationError() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync(DirectOwnedBooksQuery); + + AssertTraxAuthorizationError(doc); + } + + // ── Ungated sibling remains reachable ──────────────────────────────── + + [Test] + public async Task UngatedOwners_AsPlayer_Succeeds() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + OwnersOnlyQuery, + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + AssertNoErrors(doc); + doc.RootElement.GetProperty("data") + .GetProperty("discover") + .GetProperty("vault") + .GetProperty("owners") + .GetProperty("totalCount") + .GetInt32() + .Should() + .Be(2); + } + + [Test] + public async Task UngatedOwners_Anonymous_Succeeds() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync(OwnersOnlyQuery); + + AssertNoErrors(doc); + doc.RootElement.GetProperty("data") + .GetProperty("discover") + .GetProperty("vault") + .GetProperty("owners") + .GetProperty("totalCount") + .GetInt32() + .Should() + .Be(2); + } + + // ── CRITICAL: transitive navigation enforcement ────────────────────── + + [Test] + public async Task TransitiveBooks_AsPlayer_FailsAuthorizationOnChildren() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + OwnersWithBooksQuery, + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + AssertTraxAuthorizationError(doc); + } + + [Test] + public async Task TransitiveBooks_Anonymous_FailsAuthorizationOnChildren() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync(OwnersWithBooksQuery); + + AssertTraxAuthorizationError(doc); + } + + [Test] + public async Task TransitiveBooks_AsAdmin_Succeeds() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + OwnersWithBooksQuery, + req => req.Headers.Add("X-Api-Key", AdminApiKey) + ); + + AssertNoErrors(doc); + var owners = doc + .RootElement.GetProperty("data") + .GetProperty("discover") + .GetProperty("vault") + .GetProperty("owners") + .GetProperty("nodes") + .EnumerateArray() + .ToList(); + owners.Should().HaveCount(2); + var alice = owners.Single(o => o.GetProperty("name").GetString() == "Alice"); + alice.GetProperty("books").GetArrayLength().Should().Be(2); + } + + [Test] + public async Task TransitiveBooks_AsPlayer_DoesNotLeakBookTitlesInPayload() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + OwnersWithBooksQuery, + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + // No book title from the seed fixture may appear anywhere in the + // response body — not in data, not in errors, not in extensions. + var raw = doc.RootElement.GetRawText(); + raw.Should().NotContain("Alice's First Book"); + raw.Should().NotContain("Alice's Second Book"); + raw.Should().NotContain("Bob's Only Book"); + } + + [Test] + public async Task TransitiveBooks_Anonymous_DoesNotLeakBookTitlesInPayload() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync(OwnersWithBooksQuery); + + var raw = doc.RootElement.GetRawText(); + raw.Should().NotContain("Alice's First Book"); + raw.Should().NotContain("Alice's Second Book"); + raw.Should().NotContain("Bob's Only Book"); + } + + // ── JWT parity ────────────────────────────────────────────────────── + + [Test] + public async Task DirectOwnedBooks_AsAdminJwt_Succeeds() + { + using var host = await StartAsync(Schemes.Jwt); + var token = SignJwt("alice", "Alice", "Admin"); + + using var doc = await host.PostGraphQLAsync( + DirectOwnedBooksQuery, + req => req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token) + ); + + AssertNoErrors(doc); + OwnedBooksField(doc).GetProperty("totalCount").GetInt32().Should().Be(3); + } + + [Test] + public async Task DirectOwnedBooks_AsPlayerJwt_ReturnsAuthorizationError() + { + using var host = await StartAsync(Schemes.Jwt); + var token = SignJwt("alice", "Alice", "Player"); + + using var doc = await host.PostGraphQLAsync( + DirectOwnedBooksQuery, + req => req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token) + ); + + AssertTraxAuthorizationError(doc); + } + + [Test] + public async Task TransitiveBooks_AsPlayerJwt_FailsAuthorizationOnChildren() + { + using var host = await StartAsync(Schemes.Jwt); + var token = SignJwt("alice", "Alice", "Player"); + + using var doc = await host.PostGraphQLAsync( + OwnersWithBooksQuery, + req => req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token) + ); + + AssertTraxAuthorizationError(doc); + } + + // ── CSV roles (OR within attribute) ────────────────────────────────── + + [Test] + public async Task CsvRoles_AsAdmin_Succeeds() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + MemosQuery, + req => req.Headers.Add("X-Api-Key", AdminApiKey) + ); + + AssertNoErrors(doc); + VaultField(doc, "memos").GetProperty("totalCount").GetInt32().Should().Be(2); + } + + [Test] + public async Task CsvRoles_AsPlayer_Succeeds() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + MemosQuery, + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + AssertNoErrors(doc); + VaultField(doc, "memos").GetProperty("totalCount").GetInt32().Should().Be(2); + } + + [Test] + public async Task CsvRoles_Anonymous_ReturnsAuthorizationError() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync(MemosQuery); + + AssertTraxAuthorizationError(doc); + } + + // ── Stacked attributes (AND across) ────────────────────────────────── + + [Test] + public async Task StackedAttributes_AsAdmin_SatisfiesBoth() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + RestrictedDocsQuery, + req => req.Headers.Add("X-Api-Key", AdminApiKey) + ); + + AssertNoErrors(doc); + VaultField(doc, "restrictedDocs").GetProperty("totalCount").GetInt32().Should().Be(1); + } + + [Test] + public async Task StackedAttributes_AsPlayer_FailsOnRole() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + RestrictedDocsQuery, + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + AssertTraxAuthorizationError(doc); + } + + // ── Bare [TraxAuthorize] (any authenticated user) ─────────────────── + + [Test] + public async Task BareAuthorize_AsAdmin_Succeeds() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + MemberAreasQuery, + req => req.Headers.Add("X-Api-Key", AdminApiKey) + ); + + AssertNoErrors(doc); + VaultField(doc, "memberAreas").GetProperty("totalCount").GetInt32().Should().Be(1); + } + + [Test] + public async Task BareAuthorize_AsPlayer_Succeeds() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + MemberAreasQuery, + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + AssertNoErrors(doc); + VaultField(doc, "memberAreas").GetProperty("totalCount").GetInt32().Should().Be(1); + } + + [Test] + public async Task BareAuthorize_Anonymous_ReturnsAuthorizationError() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync(MemberAreasQuery); + + AssertTraxAuthorizationError(doc); + } + + // ── Connection-shape side channels (totalCount, pageInfo) ─────────── + // + // Type-level @authorize alone is not enough: a request that selects only + // Connection scalars like `totalCount` or `pageInfo` never resolves a + // node of the gated entity type, so the type-level directive never fires. + // The implementation also gates the entry field, which closes the + // side channel. These tests pin the closure: they MUST fail for an + // unauthorized caller, even though they ask for nothing about the entity + // itself. + + [Test] + public async Task DirectOwnedBooks_TotalCountOnly_AsPlayer_StillBlocked() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + "{ discover { vault { ownedBooks { totalCount } } } }", + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + AssertTraxAuthorizationError(doc); + } + + [Test] + public async Task DirectOwnedBooks_PageInfoOnly_AsPlayer_StillBlocked() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + "{ discover { vault { ownedBooks { pageInfo { hasNextPage } } } } }", + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + AssertTraxAuthorizationError(doc); + } + + [Test] + public async Task DirectOwnedBooks_TotalCountOnly_Anonymous_StillBlocked() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + "{ discover { vault { ownedBooks { totalCount } } } }" + ); + + AssertTraxAuthorizationError(doc); + } + + [Test] + public async Task DirectOwnedBooks_EdgesCursorOnly_AsPlayer_StillBlocked() + { + // edges.cursor doesn't materialize a node, but the entry field itself + // is gated — the request must still be denied. + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + "{ discover { vault { ownedBooks { edges { cursor } } } } }", + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + AssertTraxAuthorizationError(doc); + } + + [Test] + public async Task DirectOwnedBooks_FilteredCount_AsPlayer_StillBlocked() + { + // totalCount with a `where` clause could otherwise be used to probe + // for the presence of rows matching arbitrary predicates (e.g., + // "does a book titled 'secret' exist?"). Field-level auth closes + // that probe. + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + "{ discover { vault { ownedBooks(where: { title: { contains: \"secret\" } }) { totalCount } } } }", + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + AssertTraxAuthorizationError(doc); + } + + [Test] + public async Task TransitiveBooks_TypenameOnly_AsPlayer_StillBlocked() + { + // Even __typename on a transitively reached gated type must fail — + // the type-level directive should fire before any field on the + // gated type is exposed, including the meta __typename field. + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + "{ discover { vault { owners { nodes { name books { __typename } } } } } }", + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + AssertTraxAuthorizationError(doc); + } + + // ── Error-shape / opacity guards ───────────────────────────────────── + + [Test] + public async Task ErrorMessage_DoesNotLeakEntityNameOrRoleName() + { + using var host = await StartAsync(Schemes.ApiKey); + + using var doc = await host.PostGraphQLAsync( + DirectOwnedBooksQuery, + req => req.Headers.Add("X-Api-Key", PlayerApiKey) + ); + + var raw = doc.RootElement.GetRawText(); + raw.Should().NotContain("OwnedBook"); + raw.Should().NotContain("Admin", "role name should not leak in the error body"); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private static JsonElement OwnedBooksField(JsonDocument doc) => VaultField(doc, "ownedBooks"); + + private static JsonElement VaultField(JsonDocument doc, string fieldName) => + doc + .RootElement.GetProperty("data") + .GetProperty("discover") + .GetProperty("vault") + .GetProperty(fieldName); + + private static void AssertNoErrors(JsonDocument doc) => + doc + .RootElement.TryGetProperty("errors", out _) + .Should() + .BeFalse(doc.RootElement.GetRawText()); + + private static void AssertTraxAuthorizationError(JsonDocument doc) + { + doc.RootElement.TryGetProperty("errors", out var errors).Should().BeTrue(); + errors.GetArrayLength().Should().BeGreaterThan(0); + + var first = errors[0]; + first.GetProperty("message").GetString().Should().Be("Not authorized."); + first + .GetProperty("extensions") + .GetProperty("code") + .GetString() + .Should() + .Be("TRAX_AUTHORIZATION"); + } +} diff --git a/tests/Trax.Api.Tests/AuthE2E/QueryModelAuthorizeSchemaInvariantE2ETests.cs b/tests/Trax.Api.Tests/AuthE2E/QueryModelAuthorizeSchemaInvariantE2ETests.cs new file mode 100644 index 0000000..828ade2 --- /dev/null +++ b/tests/Trax.Api.Tests/AuthE2E/QueryModelAuthorizeSchemaInvariantE2ETests.cs @@ -0,0 +1,105 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Trax.Api.GraphQL.Extensions; +using Trax.Effect.Data.Extensions; +using Trax.Effect.Data.Postgres.Extensions; +using Trax.Effect.Extensions; +using Trax.Effect.Provider.Json.Extensions; +using Trax.Mediator.Extensions; + +namespace Trax.Api.Tests.AuthE2E; + +/// +/// Defense-in-depth coverage for QueryModelAuthorizationSchemaValidator. +/// The validator runs at host start, materialises the GraphQL schema, and +/// reasserts that every [TraxAuthorize]-gated entity still carries +/// its @authorize directive at both type level and entry-field level. +/// +/// +/// The point of these tests is to prove that the gate survives the full +/// configuration pipeline end-to-end. A separate suite of unit tests +/// (QueryModelAuthorizationSchemaValidatorTests) exercises the +/// validator directly against hand-rolled schemas where the directive has +/// been stripped, since the "naive" bypass attempts via ConfigureSchema +/// (e.g. duplicate ObjectType registrations) get rejected by +/// HotChocolate's own type-uniqueness check before the validator even runs. +/// That's a fine outcome — the host still fails to start, the security +/// posture holds — but it does not exercise the validator's code path. +/// +/// +[TestFixture] +[NonParallelizable] +public class QueryModelAuthorizeSchemaInvariantE2ETests +{ + private const string Database = "trax_api_auth_schema_inv"; + + [OneTimeSetUp] + public void SeedDatabase() => + AuthzTestDbContext.EnsureSeeded(AuthE2EHost.ConnectionString(Database)); + + /// + /// Baseline: a normally-configured host starts cleanly and the validator + /// signs off on the materialised schema. Pins that the validator is not + /// over-zealous and rejecting valid schemas. + /// + [Test] + public async Task NormalHost_PassesValidator_AndHostStartsSuccessfully() + { + using var host = await BuildHostAsync(); + host.Should().NotBeNull(); + // Reaching here means StartAsync completed without the validator + // throwing — every gated entity passed both type-level and entry-field + // directive checks against the real, fully-built schema. + } + + private static async Task BuildHostAsync() + { + AuthE2EHost.EnsureDatabaseExists(Database); + var connectionString = AuthE2EHost.ConnectionString(Database); + var host = new HostBuilder() + .ConfigureWebHost(web => + web.UseTestServer() + .ConfigureServices(services => + { + services.AddLogging(); + services.AddRouting(); + services.AddAuthorization(opts => + opts.AddPolicy("AdminPolicy", p => p.RequireRole("Admin")) + ); + + services.AddTrax(trax => + trax.AddEffects(effects => + effects.UsePostgres(connectionString).AddJson() + ) + .AddMediator( + typeof(QueryModelAuthorizeSchemaInvariantE2ETests).Assembly + ) + ); + + services.AddDbContextFactory(o => + o.UseNpgsql(connectionString) + ); + + services.AddTraxGraphQL(graphql => + graphql.AddDbContext() + ); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + endpoints.MapGraphQL("/trax/graphql", "trax") + ); + }) + ) + .Build(); + + await host.StartAsync(); + return host; + } +} diff --git a/tests/Trax.Api.Tests/AuthE2E/TestDbContext.cs b/tests/Trax.Api.Tests/AuthE2E/TestDbContext.cs index d7fba43..88223aa 100644 --- a/tests/Trax.Api.Tests/AuthE2E/TestDbContext.cs +++ b/tests/Trax.Api.Tests/AuthE2E/TestDbContext.cs @@ -50,6 +50,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) /// public static void EnsureSeeded(string connectionString) { + AuthE2EHost.EnsureDatabaseExists( + new Npgsql.NpgsqlConnectionStringBuilder(connectionString).Database! + ); + var opts = new DbContextOptionsBuilder().UseNpgsql(connectionString).Options; using var db = new TestDbContext(opts); diff --git a/tests/Trax.Api.Tests/GraphQLModelExposureWarningServiceTests.cs b/tests/Trax.Api.Tests/GraphQLModelExposureWarningServiceTests.cs new file mode 100644 index 0000000..a9cc8c5 --- /dev/null +++ b/tests/Trax.Api.Tests/GraphQLModelExposureWarningServiceTests.cs @@ -0,0 +1,195 @@ +using System.ComponentModel.DataAnnotations.Schema; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder; +using Trax.Api.GraphQL.Startup; +using Trax.Effect.Attributes; + +namespace Trax.Api.Tests; + +/// +/// Coverage for the silent-success early-return paths in +/// . The warning's existence +/// is exercised wherever an ungated model is registered (e.g. +/// ModelQueryAuthE2ETests), but the early-out paths — no models at all, +/// or every model gated — never log. A regression that removes those early +/// returns would emit spurious warnings on well-configured hosts; these tests +/// pin the contract. +/// +[TestFixture] +public class GraphQLModelExposureWarningServiceTests +{ + [Test] + public async Task StartAsync_NoModelRegistrations_LogsNothing() + { + // Host that registers no [TraxQueryModel] entities at all. The + // warning service must short-circuit before computing anything. + var services = new ServiceCollection(); + var config = new TraxGraphQLBuilder(services).Build(); + config.ModelRegistrations.Should().BeEmpty(); + + var logger = new RecordingLogger(); + var sut = new GraphQLModelExposureWarningService(config, logger); + + await sut.StartAsync(CancellationToken.None); + + logger.Entries.Should().BeEmpty(); + } + + [Test] + public async Task StartAsync_AllRegistrationsGated_LogsNothing() + { + // Every registered entity carries [TraxAuthorize] — there's no + // ungated surface to warn about. The warning would be misleading + // ("Trax GraphQL: 0 of N model query registration(s) carry no + // [TraxAuthorize]") so the service must skip emission entirely. + var services = new ServiceCollection(); + var config = new TraxGraphQLBuilder(services).AddDbContext().Build(); + config.ModelRegistrations.Should().NotBeEmpty(); + config + .ModelRegistrations.All(r => r.AuthorizeAttributes.Count > 0) + .Should() + .BeTrue("the fixture must register only gated entities"); + + var logger = new RecordingLogger(); + var sut = new GraphQLModelExposureWarningService(config, logger); + + await sut.StartAsync(CancellationToken.None); + + logger.Entries.Should().BeEmpty(); + } + + [Test] + public async Task StartAsync_SomeUngated_LogsWarningWithBothCounts() + { + // One gated + one ungated. The warning message must name both the + // ungated count and the total — that's how a reviewer of host logs + // distinguishes "I forgot to gate one entity" from "the gate isn't + // wired at all." + var services = new ServiceCollection(); + var config = new TraxGraphQLBuilder(services).AddDbContext().Build(); + config.ModelRegistrations.Should().HaveCount(2); + + var logger = new RecordingLogger(); + var sut = new GraphQLModelExposureWarningService(config, logger); + + await sut.StartAsync(CancellationToken.None); + + logger.Entries.Should().HaveCount(1); + var entry = logger.Entries.Single(); + entry.Level.Should().Be(LogLevel.Warning); + entry.Message.Should().Contain("model query registration"); + // Count tokens get materialised via structured-log argument formatting; + // assert both 1 (ungated) and 2 (total) appear somewhere in the rendered + // body so a refactor that drops one count is caught. + entry.RenderedMessage.Should().Contain("1"); + entry.RenderedMessage.Should().Contain("2"); + } + + [Test] + public async Task StopAsync_CompletesSynchronously() + { + // The warning service owns no resources. Pin the IHostedService + // contract — a future refactor that adds async cleanup to release + // something must update this test instead of silently changing the + // host-shutdown shape. + var services = new ServiceCollection(); + var config = new TraxGraphQLBuilder(services).Build(); + var logger = new RecordingLogger(); + var sut = new GraphQLModelExposureWarningService(config, logger); + + var task = sut.StopAsync(CancellationToken.None); + + task.IsCompletedSuccessfully.Should().BeTrue(); + await task; + } + + // ── Fixture entities ──────────────────────────────────────────────── + + [TraxQueryModel] + [TraxAuthorize(Roles = "Admin")] + [Table("gated_a", Schema = "test_warn")] + private class GatedA + { + [Column("id")] + public long Id { get; set; } + } + + [TraxQueryModel] + [TraxAuthorize(Roles = "Admin")] + [Table("gated_b", Schema = "test_warn")] + private class GatedB + { + [Column("id")] + public long Id { get; set; } + } + + [TraxQueryModel] + [Table("ungated", Schema = "test_warn")] + private class Ungated + { + [Column("id")] + public long Id { get; set; } + } + + private class AllGatedDbContext(DbContextOptions options) + : DbContext(options) + { + public DbSet A { get; set; } = null!; + public DbSet B { get; set; } = null!; + } + + private class MixedDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Gated { get; set; } = null!; + public DbSet Ungated { get; set; } = null!; + } + + // ── Logger ────────────────────────────────────────────────────────── + + private sealed class RecordingLogger : ILogger + { + public List Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) + where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + Entries.Add( + new LogEntry + { + Level = logLevel, + Message = state?.ToString() ?? string.Empty, + RenderedMessage = formatter(state, exception), + } + ); + } + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + + public void Dispose() { } + } + } + + private sealed class LogEntry + { + public LogLevel Level { get; init; } + public string Message { get; init; } = ""; + public string RenderedMessage { get; init; } = ""; + } +} diff --git a/tests/Trax.Api.Tests/QueryModelAuthenticationInterceptorTests.cs b/tests/Trax.Api.Tests/QueryModelAuthenticationInterceptorTests.cs new file mode 100644 index 0000000..497dd05 --- /dev/null +++ b/tests/Trax.Api.Tests/QueryModelAuthenticationInterceptorTests.cs @@ -0,0 +1,206 @@ +using System.Security.Claims; +using FluentAssertions; +using HotChocolate.Execution; +using HotChocolate.Execution.Configuration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using Trax.Api.GraphQL.Authorization; + +namespace Trax.Api.Tests; + +/// +/// Direct unit coverage for . +/// The interceptor populates HttpContext.User by walking every registered +/// authentication scheme; the E2E suite proves the happy path against real HC +/// infrastructure, while these tests pin the branches around it — the +/// short-circuit when the request is already authenticated, and the silent +/// no-op when no scheme matches the inbound credentials. +/// +[TestFixture] +public class QueryModelAuthenticationInterceptorTests +{ + [Test] + public async Task OnCreateAsync_UserAlreadyAuthenticated_DoesNotWalkSchemes() + { + // Upstream middleware (e.g. endpoint-level RequireAuthorization or a + // default-scheme UseAuthentication() pass) has already authenticated + // the request. The interceptor must NOT walk schemes again — doing so + // would double-authenticate, potentially overwriting a richer principal + // with one from a fall-through scheme. + var schemeProvider = Substitute.For(); + var httpContext = BuildHttpContext( + authenticatedAs: new ClaimsPrincipal( + new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Name, "alice") }, + authenticationType: "preauth" + ) + ) + ); + + var sut = new QueryModelAuthenticationInterceptor(schemeProvider); + + await sut.OnCreateAsync( + httpContext, + Substitute.For(), + OperationRequestBuilder.New(), + CancellationToken.None + ); + + await schemeProvider.DidNotReceive().GetAllSchemesAsync(); + httpContext.User.Identity!.Name.Should().Be("alice"); + httpContext.User.Identity.AuthenticationType.Should().Be("preauth"); + } + + [Test] + public async Task OnCreateAsync_NoSchemeSucceeds_LeavesUserAnonymous() + { + // None of the registered schemes recognise the request's credentials + // (e.g. an anonymous request, or a request whose Bearer token does not + // match any configured issuer). The interceptor must finish with the + // anonymous principal intact so HC's @authorize directive can reject + // the request on its own terms — not crash trying to read a partial + // half-authenticated principal. + var schemeA = new AuthenticationScheme( + "schemeA", + displayName: "A", + typeof(NoResultAuthenticationHandler) + ); + var schemeB = new AuthenticationScheme( + "schemeB", + displayName: "B", + typeof(NoResultAuthenticationHandler) + ); + + var schemeProvider = Substitute.For(); + schemeProvider.GetAllSchemesAsync().Returns(new[] { schemeA, schemeB }); + + var httpContext = BuildHttpContext(authenticatedAs: null); + + var sut = new QueryModelAuthenticationInterceptor(schemeProvider); + + await sut.OnCreateAsync( + httpContext, + Substitute.For(), + OperationRequestBuilder.New(), + CancellationToken.None + ); + + httpContext.User.Identity!.IsAuthenticated.Should().BeFalse(); + } + + [Test] + public async Task OnCreateAsync_FirstSchemeSucceeds_AssignsItsPrincipalAndStopsWalking() + { + // Two schemes are registered; the first one returns a successful + // ticket. The interceptor must assign that principal and stop — + // continuing into the second scheme could overwrite the principal + // and would also waste cycles (e.g. JWT validation against a JWKS). + var winningPrincipal = new ClaimsPrincipal( + new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Name, "winner") }, + authenticationType: "schemeA" + ) + ); + + var winningScheme = new AuthenticationScheme( + "schemeA", + displayName: "A", + typeof(SuccessAuthenticationHandler) + ); + var loserScheme = new AuthenticationScheme( + "schemeB", + displayName: "B", + typeof(ThrowingAuthenticationHandler) + ); + var schemeProvider = Substitute.For(); + schemeProvider.GetAllSchemesAsync().Returns(new[] { winningScheme, loserScheme }); + + var httpContext = BuildHttpContext( + authenticatedAs: null, + successPrincipalForScheme: ("schemeA", winningPrincipal) + ); + + var sut = new QueryModelAuthenticationInterceptor(schemeProvider); + + await sut.OnCreateAsync( + httpContext, + Substitute.For(), + OperationRequestBuilder.New(), + CancellationToken.None + ); + + httpContext.User.Identity!.Name.Should().Be("winner"); + httpContext.User.Identity.AuthenticationType.Should().Be("schemeA"); + } + + // ── Helpers ───────────────────────────────────────────────────────── + + /// + /// Builds an HttpContext wired with an IAuthenticationService whose + /// AuthenticateAsync produces either NoResult (for every scheme) or a + /// successful ticket only for a named scheme. + /// + private static HttpContext BuildHttpContext( + ClaimsPrincipal? authenticatedAs, + (string Scheme, ClaimsPrincipal Principal)? successPrincipalForScheme = null + ) + { + var ctx = new DefaultHttpContext(); + if (authenticatedAs is not null) + ctx.User = authenticatedAs; + + var authService = Substitute.For(); + if (successPrincipalForScheme is { } match) + { + authService + .AuthenticateAsync(ctx, match.Scheme) + .Returns( + AuthenticateResult.Success( + new AuthenticationTicket(match.Principal, match.Scheme) + ) + ); + } + // All other scheme names return NoResult — the default for a substitute + // returns null which IAuthenticationService.AuthenticateAsync interprets + // as failure, but to keep the path explicit we wire NoResult for any + // unhandled scheme name. + authService + .AuthenticateAsync(ctx, Arg.Any()) + .Returns(callInfo => + { + if (successPrincipalForScheme is { } m && callInfo.ArgAt(1) == m.Scheme) + return Task.FromResult( + AuthenticateResult.Success(new AuthenticationTicket(m.Principal, m.Scheme)) + ); + return Task.FromResult(AuthenticateResult.NoResult()); + }); + + var services = new ServiceCollection(); + services.AddSingleton(authService); + ctx.RequestServices = services.BuildServiceProvider(); + return ctx; + } + + // The handler types below exist solely as type tokens on AuthenticationScheme. + // Authentication is short-circuited via the IAuthenticationService substitute + // wired into HttpContext.RequestServices, so these handlers never execute. + private class NoResultAuthenticationHandler : IAuthenticationHandler + { + public Task AuthenticateAsync() => + Task.FromResult(AuthenticateResult.NoResult()); + + public Task ChallengeAsync(AuthenticationProperties? properties) => Task.CompletedTask; + + public Task ForbidAsync(AuthenticationProperties? properties) => Task.CompletedTask; + + public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context) => + Task.CompletedTask; + } + + private sealed class SuccessAuthenticationHandler : NoResultAuthenticationHandler { } + + private sealed class ThrowingAuthenticationHandler : NoResultAuthenticationHandler { } +} diff --git a/tests/Trax.Api.Tests/QueryModelAuthorizationValidatorTests.cs b/tests/Trax.Api.Tests/QueryModelAuthorizationValidatorTests.cs new file mode 100644 index 0000000..fdc7c37 --- /dev/null +++ b/tests/Trax.Api.Tests/QueryModelAuthorizationValidatorTests.cs @@ -0,0 +1,262 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder; +using Trax.Api.GraphQL.Startup; +using Trax.Effect.Attributes; + +namespace Trax.Api.Tests; + +/// +/// Direct unit coverage for . +/// The validator's whole reason for existing is to refuse to start a host +/// whose gated entities point at policies the consumer never registered — +/// the exact failure that, if missed, would turn into a silent deny-all in +/// production. These tests pin both directions of that check. +/// +[TestFixture] +public class QueryModelAuthorizationValidatorTests +{ + [TraxQueryModel] + [TraxAuthorize(Policy = "RegisteredPolicy")] + private class GatedWithRegisteredPolicy + { + public int Id { get; set; } + } + + [TraxQueryModel] + [TraxAuthorize(Policy = "MissingPolicy")] + private class GatedWithMissingPolicy + { + public int Id { get; set; } + } + + [TraxQueryModel] + [TraxAuthorize(Roles = "Admin")] + private class GatedRolesOnly + { + public int Id { get; set; } + } + + private class RegisteredPolicyDbContext(DbContextOptions options) + : DbContext(options) + { + public DbSet Rows { get; set; } = null!; + } + + private class MissingPolicyDbContext(DbContextOptions options) + : DbContext(options) + { + public DbSet Rows { get; set; } = null!; + } + + private class RolesOnlyDbContext(DbContextOptions options) + : DbContext(options) + { + public DbSet Rows { get; set; } = null!; + } + + [Test] + public async Task Validator_RegisteredPolicy_DoesNotThrow() + { + var config = new TraxGraphQLBuilder(new ServiceCollection()) + .AddDbContext() + .Build(); + + var services = new ServiceCollection(); + services.AddAuthorization(opts => + opts.AddPolicy("RegisteredPolicy", p => p.RequireAuthenticatedUser()) + ); + var policyProvider = services + .BuildServiceProvider() + .GetRequiredService(); + + var validator = new QueryModelAuthorizationValidator(config, policyProvider); + + await validator + .Invoking(v => v.StartAsync(CancellationToken.None)) + .Should() + .NotThrowAsync(); + } + + [Test] + public async Task Validator_MissingPolicy_ThrowsNamingPolicyAndEntity() + { + var config = new TraxGraphQLBuilder(new ServiceCollection()) + .AddDbContext() + .Build(); + + var services = new ServiceCollection(); + services.AddAuthorization(); // Default options only; "MissingPolicy" is intentionally not registered. + var policyProvider = services + .BuildServiceProvider() + .GetRequiredService(); + + var validator = new QueryModelAuthorizationValidator(config, policyProvider); + + var act = async () => await validator.StartAsync(CancellationToken.None); + + var assertion = await act.Should().ThrowAsync(); + assertion + .Which.Message.Should() + .Contain("MissingPolicy", "the error must name the policy the consumer typo'd") + .And.Contain( + typeof(GatedWithMissingPolicy).FullName!, + "the error must name the entity that declared the policy" + ) + .And.Contain("AddAuthorization", "the error must point the consumer at the fix"); + } + + [Test] + public async Task Validator_RolesOnlyEntity_NeverConsultsPolicyProvider() + { + // Roles-only attributes do not reference an ASP.NET Core policy, so the + // validator must skip them entirely. Pin that with a probe provider + // that throws on any GetPolicyAsync call. + var config = new TraxGraphQLBuilder(new ServiceCollection()) + .AddDbContext() + .Build(); + config + .ModelRegistrations.Single() + .AuthorizeAttributes.Should() + .HaveCount(1, "the fixture must register one [TraxAuthorize(Roles=...)] attribute"); + + var policyProvider = new ThrowingPolicyProvider(); + + var validator = new QueryModelAuthorizationValidator(config, policyProvider); + + await validator + .Invoking(v => v.StartAsync(CancellationToken.None)) + .Should() + .NotThrowAsync( + "a roles-only attribute carries no policy name, so the validator must not consult the policy provider" + ); + policyProvider.GetPolicyCallCount.Should().Be(0); + } + + [Test] + public async Task Validator_DuplicatePolicyReferences_ConsultsProviderOncePerName() + { + // Two entities reference the same policy. The validator deduplicates + // by policy name before calling GetPolicyAsync — pin the optimisation + // so a refactor that drops the `seen` set does not silently blow up + // policy-provider call volume on large schemas. + var config = new TraxGraphQLBuilder(new ServiceCollection()) + .AddDbContext() + .Build(); + config + .ModelRegistrations.Should() + .HaveCount( + 2, + "the fixture must register two entities for the dedup check to be meaningful" + ); + + var policyProvider = new CountingPolicyProvider(); + // Make the policy resolvable so the test isolates the call-count check + // from the missing-policy throw path. + policyProvider.Register( + "SharedPolicy", + new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build() + ); + + var validator = new QueryModelAuthorizationValidator(config, policyProvider); + + await validator + .Invoking(v => v.StartAsync(CancellationToken.None)) + .Should() + .NotThrowAsync(); + policyProvider + .GetPolicyCallCount.Should() + .Be(1, "the validator must dedup policy lookups by name across entities"); + } + + [Test] + public async Task Validator_StopAsync_CompletesSynchronously() + { + // The validator owns no resources to release. Pinning that StopAsync + // returns a synchronously-completed Task forces a future refactor that + // introduces async cleanup to update this contract explicitly. + var config = new TraxGraphQLBuilder(new ServiceCollection()) + .AddDbContext() + .Build(); + var validator = new QueryModelAuthorizationValidator(config, new ThrowingPolicyProvider()); + + var task = validator.StopAsync(CancellationToken.None); + + task.IsCompletedSuccessfully.Should().BeTrue(); + await task; + } + + [TraxQueryModel] + [TraxAuthorize(Policy = "SharedPolicy")] + private class FirstGatedBySharedPolicy + { + public int Id { get; set; } + } + + [TraxQueryModel] + [TraxAuthorize(Policy = "SharedPolicy")] + private class SecondGatedBySharedPolicy + { + public int Id { get; set; } + } + + private class DuplicatePolicyDbContext(DbContextOptions options) + : DbContext(options) + { + public DbSet First { get; set; } = null!; + public DbSet Second { get; set; } = null!; + } + + /// + /// Throws on any call to so a test can prove + /// the validator's roles-only short-circuit never reaches the provider. + /// + private sealed class ThrowingPolicyProvider : IAuthorizationPolicyProvider + { + public int GetPolicyCallCount { get; private set; } + + public Task GetDefaultPolicyAsync() => + throw new InvalidOperationException("validator must not request the default policy"); + + public Task GetFallbackPolicyAsync() => + throw new InvalidOperationException("validator must not request the fallback policy"); + + public Task GetPolicyAsync(string policyName) + { + GetPolicyCallCount++; + throw new InvalidOperationException( + $"validator must not look up policy '{policyName}' for a roles-only attribute" + ); + } + } + + /// + /// Records every policy-name lookup so the dedup test can assert the + /// validator called the provider exactly once per distinct name. + /// + private sealed class CountingPolicyProvider : IAuthorizationPolicyProvider + { + private readonly Dictionary _policies = new( + StringComparer.Ordinal + ); + + public int GetPolicyCallCount { get; private set; } + + public void Register(string name, AuthorizationPolicy policy) => _policies[name] = policy; + + public Task GetDefaultPolicyAsync() => + Task.FromResult(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()); + + public Task GetFallbackPolicyAsync() => + Task.FromResult(null); + + public Task GetPolicyAsync(string policyName) + { + GetPolicyCallCount++; + _policies.TryGetValue(policyName, out var policy); + return Task.FromResult(policy); + } + } +} diff --git a/tests/Trax.Api.Tests/QueryModelAuthorizeBuildTests.cs b/tests/Trax.Api.Tests/QueryModelAuthorizeBuildTests.cs new file mode 100644 index 0000000..ca720dd --- /dev/null +++ b/tests/Trax.Api.Tests/QueryModelAuthorizeBuildTests.cs @@ -0,0 +1,151 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Trax.Api.GraphQL.Configuration; +using Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder; +using Trax.Api.GraphQL.Startup; +using Trax.Effect.Attributes; + +namespace Trax.Api.Tests; + +/// +/// Coverage for . Discovery shape +/// is exercised separately in QueryModelAuthorizeDiscoveryTests; runtime +/// enforcement is in QueryModelAuthorizeE2ETests. This file focuses on +/// the fail-loud startup check that every [TraxAuthorize(Policy = ...)] +/// references a policy the host has registered. +/// +[TestFixture] +public class QueryModelAuthorizeBuildTests +{ + [TraxQueryModel] + [TraxAuthorize(Policy = "AdminPolicy")] + private class PolicyGatedEntity + { + public int Id { get; set; } + } + + [TraxQueryModel] + [TraxAuthorize(Roles = "Admin")] + private class RoleOnlyEntity + { + public int Id { get; set; } + } + + private class PolicyDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Items { get; set; } = null!; + } + + private class RolesOnlyDbContext(DbContextOptions options) + : DbContext(options) + { + public DbSet Items { get; set; } = null!; + } + + [Test] + public async Task Validator_PolicyRegistered_DoesNotThrow() + { + var config = new TraxGraphQLBuilder(new ServiceCollection()) + .AddDbContext() + .Build(); + + var provider = Substitute.For(); + provider + .GetPolicyAsync(Arg.Any()) + .Returns(new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build()); + + var validator = new QueryModelAuthorizationValidator(config, provider); + + await validator + .Invoking(v => v.StartAsync(CancellationToken.None)) + .Should() + .NotThrowAsync(); + } + + [Test] + public async Task Validator_PolicyMissing_ThrowsWithEntityAndPolicyName() + { + var config = new TraxGraphQLBuilder(new ServiceCollection()) + .AddDbContext() + .Build(); + + var provider = Substitute.For(); + provider + .GetPolicyAsync(Arg.Any()) + .Returns(Task.FromResult(null)); + + var validator = new QueryModelAuthorizationValidator(config, provider); + + var act = async () => await validator.StartAsync(CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("*AdminPolicy*") + .WithMessage("*PolicyGatedEntity*not registered*"); + } + + [Test] + public async Task Validator_NoPolicies_DoesNotInvokeProvider() + { + // Entities gated only by roles (or bare [TraxAuthorize]) should never + // hit the provider — the validator's job is policy reachability only. + var config = new TraxGraphQLBuilder(new ServiceCollection()) + .AddDbContext() + .Build(); + + var provider = Substitute.For(); + var validator = new QueryModelAuthorizationValidator(config, provider); + + await validator.StartAsync(CancellationToken.None); + + await provider.DidNotReceive().GetPolicyAsync(Arg.Any()); + } + + [TraxQueryModel] + [TraxAuthorize(Policy = "AdminPolicy")] + private class DupPolicyA + { + public int Id { get; set; } + } + + [TraxQueryModel] + [TraxAuthorize(Policy = "AdminPolicy")] + private class DupPolicyB + { + public int Id { get; set; } + } + + private class DupPolicyDbContext(DbContextOptions options) + : DbContext(options) + { + public DbSet A { get; set; } = null!; + public DbSet B { get; set; } = null!; + } + + [Test] + public async Task Validator_SamePolicyOnMultipleEntities_QueriesProviderOnce() + { + // Two entities reference the same policy name. The validator's + // dedup check (`seen.Add` returning false) must skip the second + // lookup — proves we don't redundantly hit the policy provider once + // per entity in production hosts where a single role policy is + // applied to dozens of [TraxQueryModel] entities. + var config = new TraxGraphQLBuilder(new ServiceCollection()) + .AddDbContext() + .Build(); + config.ModelRegistrations.Should().HaveCount(2); + + var provider = Substitute.For(); + provider + .GetPolicyAsync("AdminPolicy") + .Returns(new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build()); + + var validator = new QueryModelAuthorizationValidator(config, provider); + + await validator.StartAsync(CancellationToken.None); + + await provider.Received(1).GetPolicyAsync("AdminPolicy"); + } +} diff --git a/tests/Trax.Api.Tests/QueryModelAuthorizeDiscoveryTests.cs b/tests/Trax.Api.Tests/QueryModelAuthorizeDiscoveryTests.cs new file mode 100644 index 0000000..bd1c740 --- /dev/null +++ b/tests/Trax.Api.Tests/QueryModelAuthorizeDiscoveryTests.cs @@ -0,0 +1,261 @@ +using System.ComponentModel.DataAnnotations.Schema; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder; +using Trax.Effect.Attributes; + +namespace Trax.Api.Tests; + +/// +/// Unit coverage for how discovers +/// [TraxAuthorize] on [TraxQueryModel] entities and threads the +/// attribute set through QueryModelRegistration.AuthorizeAttributes. +/// The E2E suite proves the directive is wired correctly through the full +/// HotChocolate pipeline; these tests pin the discovery surface so the data +/// reaching the type module is the source of truth a future refactor must +/// preserve. +/// +[TestFixture] +public class QueryModelAuthorizeDiscoveryTests +{ + // ── Discovery: AuthorizeAttributes is populated ────────────────────── + + [Test] + public void Build_EntityWithSingleRoleAttribute_ExposesIt() + { + var sut = new TraxGraphQLBuilder( + new Microsoft.Extensions.DependencyInjection.ServiceCollection() + ); + sut.AddDbContext(); + + var config = sut.Build(); + + var reg = config.ModelRegistrations.Single(r => r.EntityType == typeof(GatedRow)); + reg.AuthorizeAttributes.Should().HaveCount(1); + reg.AuthorizeAttributes[0].Roles.Should().Be("Admin"); + } + + [Test] + public void Build_EntityWithoutAuthorize_HasEmptyList() + { + var sut = new TraxGraphQLBuilder( + new Microsoft.Extensions.DependencyInjection.ServiceCollection() + ); + sut.AddDbContext(); + + var config = sut.Build(); + + var reg = config.ModelRegistrations.Single(r => r.EntityType == typeof(OpenRow)); + reg.AuthorizeAttributes.Should().BeEmpty(); + } + + [Test] + public void Build_EntityWithStackedAuthorize_DiscoversAllAttributes() + { + var sut = new TraxGraphQLBuilder( + new Microsoft.Extensions.DependencyInjection.ServiceCollection() + ); + sut.AddDbContext(); + + var config = sut.Build(); + + var reg = config.ModelRegistrations.Single(r => r.EntityType == typeof(StackedRow)); + reg.AuthorizeAttributes.Should().HaveCount(2); + reg.AuthorizeAttributes.Should().Contain(a => a.Roles == "Admin"); + reg.AuthorizeAttributes.Should().Contain(a => a.Policy == "AdminPolicy"); + } + + [Test] + public void Build_EntityInheritingAuthorize_DiscoversFromBase() + { + var sut = new TraxGraphQLBuilder( + new Microsoft.Extensions.DependencyInjection.ServiceCollection() + ); + sut.AddDbContext(); + + var config = sut.Build(); + + var reg = config.ModelRegistrations.Single(r => r.EntityType == typeof(InheritedGatedRow)); + reg.AuthorizeAttributes.Should().HaveCount(1); + reg.AuthorizeAttributes[0].Roles.Should().Be("BaseAdmin"); + } + + [Test] + public void Build_EntityWithBareAuthorize_RecordsAttributeWithoutPolicyOrRoles() + { + var sut = new TraxGraphQLBuilder( + new Microsoft.Extensions.DependencyInjection.ServiceCollection() + ); + sut.AddDbContext(); + + var config = sut.Build(); + + var reg = config.ModelRegistrations.Single(r => r.EntityType == typeof(BareGatedRow)); + reg.AuthorizeAttributes.Should().HaveCount(1); + reg.AuthorizeAttributes[0].Policy.Should().BeNull(); + reg.AuthorizeAttributes[0].Roles.Should().BeNull(); + } + + // ── Build-time shape validation ────────────────────────────────────── + + [Test] + public void Build_WhitespacePolicy_Throws() + { + var sut = new TraxGraphQLBuilder( + new Microsoft.Extensions.DependencyInjection.ServiceCollection() + ); + sut.AddDbContext(); + + var act = () => sut.Build(); + + act.Should() + .Throw() + .WithMessage("*Policy value*") + .WithMessage($"*{typeof(WhitespacePolicyRow).FullName}*"); + } + + [Test] + public void Build_RolesEmptyAfterSplit_Throws() + { + var sut = new TraxGraphQLBuilder( + new Microsoft.Extensions.DependencyInjection.ServiceCollection() + ); + sut.AddDbContext(); + + var act = () => sut.Build(); + + act.Should().Throw().WithMessage("*parsed to zero roles*"); + } + + // ── ExposeAs + [TraxAuthorize] compose, no conflict ────────────────── + + [Test] + public void Build_ExposeAsAndAuthorize_Coexist() + { + var sut = new TraxGraphQLBuilder( + new Microsoft.Extensions.DependencyInjection.ServiceCollection() + ); + sut.AddDbContext(); + + var config = sut.Build(); + + var reg = config.ModelRegistrations.Single(); + reg.Attribute.ExposeAs.Should().Be(typeof(IExposedRow)); + reg.AuthorizeAttributes.Should().HaveCount(1); + reg.AuthorizeAttributes[0].Roles.Should().Be("Admin"); + } + + // ── Test entities ──────────────────────────────────────────────────── + + [TraxQueryModel(Name = "openRows")] + [Table("open_rows", Schema = "test_disc")] + public class OpenRow + { + [Column("id")] + public long Id { get; set; } + } + + [TraxQueryModel(Name = "gatedRows")] + [TraxAuthorize(Roles = "Admin")] + [Table("gated_rows", Schema = "test_disc")] + public class GatedRow + { + [Column("id")] + public long Id { get; set; } + } + + [TraxQueryModel(Name = "stackedRows")] + [TraxAuthorize(Roles = "Admin")] + [TraxAuthorize(Policy = "AdminPolicy")] + [Table("stacked_rows", Schema = "test_disc")] + public class StackedRow + { + [Column("id")] + public long Id { get; set; } + } + + [TraxAuthorize(Roles = "BaseAdmin")] + public abstract class GatedBase + { + [Column("id")] + public long Id { get; set; } + } + + [TraxQueryModel(Name = "inheritedGatedRows")] + [Table("inherited_gated_rows", Schema = "test_disc")] + public class InheritedGatedRow : GatedBase { } + + [TraxQueryModel(Name = "bareGatedRows")] + [TraxAuthorize] + [Table("bare_gated_rows", Schema = "test_disc")] + public class BareGatedRow + { + [Column("id")] + public long Id { get; set; } + } + + public class DiscoveryContext(DbContextOptions options) : DbContext(options) + { + public DbSet OpenRows { get; set; } = null!; + public DbSet GatedRows { get; set; } = null!; + public DbSet StackedRows { get; set; } = null!; + public DbSet InheritedGatedRows { get; set; } = null!; + public DbSet BareGatedRows { get; set; } = null!; + } + + [TraxQueryModel(Name = "whitespacePolicy")] + [TraxAuthorize(Policy = " ")] + [Table("ws_policy_rows", Schema = "test_disc")] + public class WhitespacePolicyRow + { + [Column("id")] + public long Id { get; set; } + } + + public class WhitespacePolicyContext(DbContextOptions options) + : DbContext(options) + { + public DbSet Rows { get; set; } = null!; + } + + [TraxQueryModel(Name = "emptyRolesRows")] + [TraxAuthorize(Roles = ",,,")] + [Table("empty_roles_rows", Schema = "test_disc")] + public class EmptyRolesRow + { + [Column("id")] + public long Id { get; set; } + } + + public class EmptyRolesContext(DbContextOptions options) : DbContext(options) + { + public DbSet Rows { get; set; } = null!; + } + + public interface IExposedRow + { + long Id { get; } + string Name { get; } + } + + [TraxQueryModel(Name = "exposedRows", ExposeAs = typeof(IExposedRow))] + [TraxAuthorize(Roles = "Admin")] + [Table("exposed_rows", Schema = "test_disc")] + public class ExposedRow : IExposedRow + { + [Column("id")] + public long Id { get; set; } + + [Column("name")] + public string Name { get; set; } = ""; + + [Column("secret")] + public string Secret { get; set; } = ""; + } + + public class ExposeAsAuthorizeContext(DbContextOptions options) + : DbContext(options) + { + public DbSet Rows { get; set; } = null!; + } +} diff --git a/tests/Trax.Api.Tests/QueryModelAuthorizeSchemaValidatorTests.cs b/tests/Trax.Api.Tests/QueryModelAuthorizeSchemaValidatorTests.cs new file mode 100644 index 0000000..e00b7e0 --- /dev/null +++ b/tests/Trax.Api.Tests/QueryModelAuthorizeSchemaValidatorTests.cs @@ -0,0 +1,527 @@ +using FluentAssertions; +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Trax.Api.GraphQL.Configuration; +using Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder; +using Trax.Api.GraphQL.Startup; +using Trax.Effect.Attributes; + +namespace Trax.Api.Tests; + +/// +/// Direct unit coverage for . +/// Builds GraphQL schemas by hand where the @authorize directive has +/// been deliberately stripped from a gated entity, then verifies the validator +/// throws at StartAsync with a message naming the entity and the +/// missing gate location. This is the suite that proves the validator's +/// failure paths — the corresponding success path runs through the full +/// AddTraxGraphQL pipeline in +/// QueryModelAuthorizeSchemaInvariantE2ETests. +/// +[TestFixture] +public class QueryModelAuthorizeSchemaValidatorTests +{ + [TraxQueryModel] + [TraxAuthorize(Roles = "Admin")] + private class GatedThing + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + private class GatedDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Things { get; set; } = null!; + } + + [Test] + public async Task Validator_BothDirectivesStripped_ThrowsNamingEntity() + { + var (config, services) = await BuildSchemaAsync( + includeAuthorizeOnType: false, + includeAuthorizeOnField: false + ); + var validator = new QueryModelAuthorizationSchemaValidator(config, services); + + var act = async () => await validator.StartAsync(CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("*[TraxAuthorize] invariant violated*") + .WithMessage("*GatedThing*"); + } + + [Test] + public async Task Validator_OnlyFieldDirectiveStripped_ThrowsNamingEntryField() + { + // Type-level gate is present, but the entry field has been stripped. + // This is the more subtle failure — transitive navigation would still + // be blocked, but `totalCount` and `pageInfo` would leak. The + // validator must still catch it. + var (config, services) = await BuildSchemaAsync( + includeAuthorizeOnType: true, + includeAuthorizeOnField: false + ); + var validator = new QueryModelAuthorizationSchemaValidator(config, services); + + var act = async () => await validator.StartAsync(CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("*[TraxAuthorize] invariant violated*") + .WithMessage("*entry field*") + .WithMessage("*gatedThings*"); + } + + [Test] + public async Task Validator_OnlyTypeDirectiveStripped_ThrowsNamingType() + { + // Entry field guarded but the type itself isn't — transitive nav + // through this type from another (ungated) entity would leak rows. + var (config, services) = await BuildSchemaAsync( + includeAuthorizeOnType: false, + includeAuthorizeOnField: true + ); + var validator = new QueryModelAuthorizationSchemaValidator(config, services); + + var act = async () => await validator.StartAsync(CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("*[TraxAuthorize] invariant violated*") + .WithMessage("*ObjectType*") + .WithMessage("*GatedThing*"); + } + + [Test] + public async Task Validator_BothDirectivesPresent_DoesNotThrow() + { + var (config, services) = await BuildSchemaAsync( + includeAuthorizeOnType: true, + includeAuthorizeOnField: true + ); + var validator = new QueryModelAuthorizationSchemaValidator(config, services); + + await validator + .Invoking(v => v.StartAsync(CancellationToken.None)) + .Should() + .NotThrowAsync(); + } + + // ── Schema-shape failure paths ─────────────────────────────────────── + // + // Beyond directive-stripping, the validator must also catch a hostile + // ConfigureSchema callback that *deletes* parts of the schema the gate + // relies on. Each test below removes one expected element and confirms + // the validator throws with a message that names what is missing. + + /// + /// A consumer's ConfigureSchema callback removes the + /// discover root field, so the validator cannot navigate to the + /// gated entity's entry field at all. The validator must throw rather + /// than silently passing the now-unreachable entity. + /// + [Test] + public async Task Validator_DiscoverFieldMissing_ThrowsNamingDiscover() + { + var (config, services) = await BuildSchemaAsync(includeDiscoverField: false); + var validator = new QueryModelAuthorizationSchemaValidator(config, services); + + var act = async () => await validator.StartAsync(CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("*`discover` field is not present*") + .WithMessage("*GatedThing*"); + } + + /// + /// A consumer keeps discover but the entry field + /// (gatedThings) has been removed. The validator must throw + /// rather than passing on the assumption that "absence implies + /// inaccessibility" — the test pins that the validator is strict about + /// every gated entity having a reachable entry. + /// + [Test] + public async Task Validator_EntryFieldMissing_ThrowsNamingEntryField() + { + var (config, services) = await BuildSchemaAsync(includeEntryField: false); + var validator = new QueryModelAuthorizationSchemaValidator(config, services); + + var act = async () => await validator.StartAsync(CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("*entry field*missing*") + .WithMessage("*gatedThings*"); + } + + /// + /// Happy path for entities declared with a Namespace: the + /// validator must descend into the namespace intermediate type and find + /// the entry field there. Without this test, a refactor that broke the + /// namespace walk would only surface on namespaced models in production. + /// + [Test] + public async Task Validator_NamespacedEntity_BothDirectivesPresent_DoesNotThrow() + { + var (config, services) = await BuildNamespacedSchemaAsync( + includeAuthorizeOnType: true, + includeAuthorizeOnField: true, + includeNamespaceField: true + ); + var validator = new QueryModelAuthorizationSchemaValidator(config, services); + + await validator + .Invoking(v => v.StartAsync(CancellationToken.None)) + .Should() + .NotThrowAsync(); + } + + /// + /// A namespaced entity where the namespace intermediate type + /// (discover.testns) is missing. The validator must report the + /// namespace field rather than the entry field — the failure-mode + /// distinction matters when a maintainer is reading the error message + /// trying to figure out which override broke things. + /// + [Test] + public async Task Validator_NamespaceFieldMissing_ThrowsNamingNamespaceField() + { + var (config, services) = await BuildNamespacedSchemaAsync( + includeAuthorizeOnType: true, + includeAuthorizeOnField: true, + includeNamespaceField: false + ); + var validator = new QueryModelAuthorizationSchemaValidator(config, services); + + var act = async () => await validator.StartAsync(CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("*namespace field*") + .WithMessage("*testns*"); + } + + /// + /// HotChocolate accepts a schema in which the gated entity's + /// ObjectType is never registered, as long as nothing references it. + /// Production code always registers it via the type module, but a hostile + /// ConfigureSchema that fully replaces the discover surface could + /// leave us without it. The validator must throw with a message that names + /// the entity rather than silently passing on an unreachable gate. + /// + [Test] + public async Task Validator_ObjectTypeMissingFromSchema_ThrowsNamingMissingType() + { + var (config, services) = await BuildSchemaWithoutGatedObjectTypeAsync(); + var validator = new QueryModelAuthorizationSchemaValidator(config, services); + + var act = async () => await validator.StartAsync(CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("*no ObjectType*") + .WithMessage("*GatedThing*"); + } + + /// + /// IHostedService contract: StopAsync must return a synchronously + /// completed task — the validator holds no resources to release. Pinning + /// this catches a future refactor that accidentally introduces async + /// cleanup without explicit consideration of the host lifecycle. + /// + [Test] + public async Task Validator_StopAsync_CompletesSynchronously() + { + var emptyConfig = new TraxGraphQLBuilder(new ServiceCollection()).Build(); + var validator = new QueryModelAuthorizationSchemaValidator( + emptyConfig, + new ServiceCollection().BuildServiceProvider() + ); + + var task = validator.StopAsync(CancellationToken.None); + + task.IsCompletedSuccessfully.Should().BeTrue(); + await task; + } + + /// + /// The validator must short-circuit when no [TraxQueryModel] + /// entity is gated. Important because it means a host with only ungated + /// query models does not pay the schema-materialisation cost at startup. + /// + [Test] + public async Task Validator_NoGatedEntities_ReturnsWithoutResolvingSchema() + { + var services = new ServiceCollection(); + // Build a configuration whose entity carries [TraxQueryModel] but + // no [TraxAuthorize]. The validator should never touch the service + // provider, so an empty provider is sufficient to prove it. + var config = new TraxGraphQLBuilder(services).AddDbContext().Build(); + config.ModelRegistrations.Should().NotBeEmpty("the test fixture must register a model"); + config + .ModelRegistrations.Single() + .AuthorizeAttributes.Should() + .BeEmpty("the test fixture's entity must be ungated"); + + var throwingProvider = new ThrowingServiceProvider(); + var validator = new QueryModelAuthorizationSchemaValidator(config, throwingProvider); + + await validator + .Invoking(v => v.StartAsync(CancellationToken.None)) + .Should() + .NotThrowAsync("no gated entities means the validator must not resolve the schema"); + throwingProvider.CreateScopeCallCount.Should().Be(0); + } + + [TraxQueryModel] + private class UngatedThing + { + public int Id { get; set; } + } + + private class UngatedDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Things { get; set; } = null!; + } + + /// + /// Service provider that throws on any access, used to prove the + /// validator's early-return path never consults DI when there is no + /// work to do. + /// + private sealed class ThrowingServiceProvider : IServiceProvider + { + public int CreateScopeCallCount { get; private set; } + + public object? GetService(Type serviceType) + { + if (serviceType == typeof(IServiceScopeFactory)) + { + CreateScopeCallCount++; + } + throw new InvalidOperationException( + "validator must not resolve any services when no entities are gated." + ); + } + } + + /// + /// Builds the canonical "single ungated namespace" schema variant the + /// extra tests share. Mirrors , parameterised + /// to omit either the discover root field or the entry field on the + /// namespace. + /// + private static async Task<( + GraphQLConfiguration Config, + IServiceProvider Services + )> BuildSchemaAsync( + bool includeDiscoverField = true, + bool includeEntryField = true, + bool includeAuthorizeOnType = true, + bool includeAuthorizeOnField = true + ) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAuthorizationCore(); + + var config = new TraxGraphQLBuilder(services).AddDbContext().Build(); + services.AddSingleton(config); + services.AddDbContextFactory(o => o.UseInMemoryDatabase("svtests-shape")); + + var gql = services.AddGraphQLServer("trax").AddAuthorization(); + + ObjectType objectType = includeAuthorizeOnType + ? new ObjectType(d => d.Authorize(new[] { "Admin" })) + : new ObjectType(); + gql.AddType(objectType); + + gql.AddQueryType(d => + { + d.Name("RootQuery"); + if (includeDiscoverField) + d.Field("discover").Type().Resolve(_ => new object()); + else + // RootQuery must have at least one field or HC refuses to + // build. Add a sentinel that the validator does not look at. + d.Field("ping").Type().Resolve(_ => "pong"); + }); + + if (includeDiscoverField) + { + gql.AddTypeExtension( + new ObjectTypeExtension(d => + { + d.Name("DiscoverQueries"); + if (!includeEntryField) + { + // DiscoverQueries needs at least one field to be a + // valid GraphQL type even when the entry field for + // GatedThing is intentionally absent. + d.Field("sentinel") + .Type() + .Resolve(_ => "ok"); + return; + } + + var field = d.Field("gatedThings") + .Type>>() + .Resolve(_ => Array.Empty()); + if (includeAuthorizeOnField) + field.Authorize(new[] { "Admin" }); + }) + ); + } + + var sp = services.BuildServiceProvider(); + var resolver = sp.GetRequiredService(); + _ = await resolver.GetRequestExecutorAsync("trax"); + + return (config, sp); + } + + /// + /// Variant of for entities declared with + /// a . The entity here is + /// with Namespace = "testns"; + /// the validator must descend through discover.testns.namespacedGatedThings. + /// + private static async Task<( + GraphQLConfiguration Config, + IServiceProvider Services + )> BuildNamespacedSchemaAsync( + bool includeAuthorizeOnType, + bool includeAuthorizeOnField, + bool includeNamespaceField + ) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAuthorizationCore(); + + var config = new TraxGraphQLBuilder(services) + .AddDbContext() + .Build(); + services.AddSingleton(config); + services.AddDbContextFactory(o => + o.UseInMemoryDatabase("svtests-ns") + ); + + var gql = services.AddGraphQLServer("trax").AddAuthorization(); + + ObjectType objectType = includeAuthorizeOnType + ? new ObjectType(d => d.Authorize(new[] { "Admin" })) + : new ObjectType(); + gql.AddType(objectType); + + gql.AddQueryType(d => + { + d.Name("RootQuery"); + d.Field("discover").Type().Resolve(_ => new object()); + }); + + if (includeNamespaceField) + { + gql.AddType(new ObjectType(d => d.Name("DiscoverQueries_testns"))); + gql.AddTypeExtension( + new ObjectTypeExtension(d => + { + d.Name("DiscoverQueries"); + d.Field("testns") + .Type(new HotChocolate.Language.NamedTypeNode("DiscoverQueries_testns")) + .Resolve(_ => new object()); + }) + ); + gql.AddTypeExtension( + new ObjectTypeExtension(d => + { + d.Name("DiscoverQueries_testns"); + var field = d.Field("namespacedGatedThings") + .Type>>() + .Resolve(_ => Array.Empty()); + if (includeAuthorizeOnField) + field.Authorize(new[] { "Admin" }); + }) + ); + } + else + { + gql.AddTypeExtension( + new ObjectTypeExtension(d => + { + d.Name("DiscoverQueries"); + d.Field("sentinel").Type().Resolve(_ => "ok"); + }) + ); + } + + var sp = services.BuildServiceProvider(); + var resolver = sp.GetRequiredService(); + _ = await resolver.GetRequestExecutorAsync("trax"); + + return (config, sp); + } + + /// + /// Builds a schema where the gated entity's is + /// never registered. The DiscoverQueries placeholder field returns a + /// string so HC can build the schema without referencing GatedThing's + /// CLR type at all. + /// + private static async Task<( + GraphQLConfiguration Config, + IServiceProvider Services + )> BuildSchemaWithoutGatedObjectTypeAsync() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAuthorizationCore(); + + var config = new TraxGraphQLBuilder(services).AddDbContext().Build(); + services.AddSingleton(config); + services.AddDbContextFactory(o => o.UseInMemoryDatabase("svtests-noobj")); + + var gql = services.AddGraphQLServer("trax").AddAuthorization(); + + // NO AddType>. RootQuery exposes a `discover` + // field but DiscoverQueries has only a placeholder string field — + // GatedThing's CLR type is never reachable from the schema, so HC + // will not auto-discover the ObjectType either. + gql.AddQueryType(d => + { + d.Name("RootQuery"); + d.Field("discover").Type().Resolve(_ => new object()); + }); + + gql.AddTypeExtension( + new ObjectTypeExtension(d => + { + d.Name("DiscoverQueries"); + d.Field("placeholder").Type().Resolve(_ => "ok"); + }) + ); + + var sp = services.BuildServiceProvider(); + var resolver = sp.GetRequiredService(); + _ = await resolver.GetRequestExecutorAsync("trax"); + + return (config, sp); + } + + [TraxQueryModel(Namespace = "testns")] + [TraxAuthorize(Roles = "Admin")] + private class NamespacedGatedThing + { + public int Id { get; set; } + } + + private class NamespacedGatedDbContext(DbContextOptions options) + : DbContext(options) + { + public DbSet Things { get; set; } = null!; + } + + private sealed class DiscoverObjectType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) => + descriptor.Name("DiscoverQueries"); + } +} diff --git a/tests/Trax.Api.Tests/QueryModelTypeModuleAuthorizeCompositionTests.cs b/tests/Trax.Api.Tests/QueryModelTypeModuleAuthorizeCompositionTests.cs new file mode 100644 index 0000000..5465ce0 --- /dev/null +++ b/tests/Trax.Api.Tests/QueryModelTypeModuleAuthorizeCompositionTests.cs @@ -0,0 +1,165 @@ +using System.ComponentModel.DataAnnotations.Schema; +using FluentAssertions; +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Trax.Api.GraphQL.Configuration; +using Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder; +using Trax.Api.GraphQL.Queries; +using Trax.Api.GraphQL.TypeModules; +using Trax.Effect.Attributes; + +namespace Trax.Api.Tests; + +/// +/// Coverage for the corners of +/// and that compose +/// [TraxAuthorize] with the other [TraxQueryModel] knobs. +/// +/// DeprecationReason is read and emitted as @deprecated on +/// the entry field — a regression that drops the call would silently lose the +/// schema-level deprecation marker. +/// BindFields = Explicit + [TraxAuthorize] compose without +/// dropping the directive. The bind-explicit branch is a separate code path +/// from the implicit / ExposeAs branches that the other tests cover. +/// +/// +[TestFixture] +public class QueryModelTypeModuleAuthorizeCompositionTests +{ + // ── DeprecationReason wiring ──────────────────────────────────────── + + [TraxQueryModel(DeprecationReason = "use NewThing instead")] + [Table("legacy_things", Schema = "test_tm")] + private class LegacyThing + { + [Column("id")] + public long Id { get; set; } + + [Column("name")] + public string Name { get; set; } = ""; + } + + private class LegacyDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Things { get; set; } = null!; + } + + [Test] + public async Task ConfigureField_DeprecationReasonSet_EntryFieldCarriesDeprecation() + { + var schema = await BuildSchemaAsync(); + + var discoverField = schema.QueryType.Fields.Single(f => f.Name == "discover"); + var discoverType = (IObjectType)discoverField.Type.NamedType(); + var entry = discoverType.Fields.Single(f => f.Name == "legacyThings"); + + entry.IsDeprecated.Should().BeTrue(); + entry.DeprecationReason.Should().Be("use NewThing instead"); + } + + // ── BindFields.Explicit + [TraxAuthorize] compose ──────────────────── + + [TraxQueryModel(BindFields = FieldBindingBehavior.Explicit)] + [TraxAuthorize(Roles = "Admin")] + [Table("audit_rows", Schema = "test_tm")] + private class AuditRow + { + [Column("id")] + public long Id { get; set; } + + [Column("public_name")] + public string PublicName { get; set; } = ""; + + // No [Column] — under Explicit binding this property must be hidden + // from the GraphQL schema. This is the property that proves the + // explicit-binding branch ran at all (vs. the implicit fallback that + // would have surfaced it). + public string InternalSecret { get; set; } = ""; + } + + private class AuditDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Rows { get; set; } = null!; + } + + [Test] + public async Task CreateObjectType_ExplicitBindingWithAuthorize_HidesNonColumnPropertiesAndEmitsDirective() + { + var schema = await BuildSchemaAsync(); + + var auditType = schema + .Types.OfType() + .First(t => t.RuntimeType == typeof(AuditRow)); + + // Only [Column]-decorated properties appear. If the BindFields.Explicit + // branch regressed to fall through to implicit binding, InternalSecret + // would leak in. (`__typename` is HC's reserved introspection field + // and is filtered here to focus the assertion on entity-shape fields.) + var fieldNames = auditType + .Fields.Where(f => !f.IsIntrospectionField) + .Select(f => f.Name) + .Order() + .ToArray(); + fieldNames.Should().BeEquivalentTo(new[] { "id", "publicName" }); + + // The @authorize directive must STILL be attached. The bind-explicit + // branch is a separate descriptor configuration path from the implicit + // one — a regression that called .Authorize() only in the implicit + // factory would silently un-gate every entity that uses explicit + // binding. + auditType.Directives.Any(d => d.Type.Name == "authorize").Should().BeTrue(); + } + + // ── Schema-build helper ───────────────────────────────────────────── + + /// + /// Builds a minimal HotChocolate schema using + /// against an in-memory EF context. Avoids the full AddTraxGraphQL + /// dependency graph (TraxMarker, train discovery, etc.) so the test + /// focuses on the type module's emission behavior. + /// + private static async Task BuildSchemaAsync() + where TContext : DbContext + { + var services = new ServiceCollection(); + services.AddDbContext(o => o.UseInMemoryDatabase("tmcomp_" + Guid.NewGuid())); + + var builder = new TraxGraphQLBuilder(services); + builder.AddDbContext(); + var config = builder.Build(); + + services.AddSingleton(config); + services.AddSingleton(); + + services + .AddGraphQLServer() + .AddAuthorization() + .AddQueryType() + .AddType() + .AddTypeModule() + .AddFiltering() + .AddSorting() + .AddProjections(); + + var provider = services.BuildServiceProvider(); + var resolver = provider.GetRequiredService(); + var executor = await resolver.GetRequestExecutorAsync(); + return executor.Schema; + } + + public class TestRootQuery + { + public DiscoverQueries Discover() => new(); + } + + public class DiscoverQueriesType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("DiscoverQueries"); + } + } +}