Skip to content

feat(graphql): [TraxAuthorize] on [TraxQueryModel] entities#54

Merged
Theauxm merged 8 commits into
mainfrom
feat/trax-authorize-on-query-model
May 14, 2026
Merged

feat(graphql): [TraxAuthorize] on [TraxQueryModel] entities#54
Theauxm merged 8 commits into
mainfrom
feat/trax-authorize-on-query-model

Conversation

@Theauxm
Copy link
Copy Markdown
Member

@Theauxm Theauxm commented May 14, 2026

Summary

  • Honors [TraxAuthorize] on [TraxQueryModel] entities, attaching HotChocolate's @authorize directive at both the entry field (under discover.*) and the ObjectType. Field-level gating blocks Connection scalars (totalCount, pageInfo, where: filter probes); type-level gating catches transitive navigation through ungated parents.
  • Combinator semantics mirror the train-side [TraxAuthorize] exactly: policies AND across attributes, roles OR within CSV / OR across attributes.
  • Auto-wired QueryModelAuthenticationInterceptor populates HttpContext.User for inbound HTTP GraphQL requests by walking every registered authentication scheme, so multi-scheme hosts (api-key + JWT, etc.) work without consumer configuration.
  • QueryModelAuthorizationValidator throws at host start if any entity references an unregistered policy. QueryModelAuthorizationSchemaValidator re-asserts directive presence on the materialised schema, catching consumer-side ConfigureSchema callbacks that strip directives. QueryModelTypeModule is sealed to prevent DI replacement bypasses.

Test plan

  • 25 E2E tests in QueryModelAuthorizeE2ETests including the burning transitive-navigation contract (Player traversing owners[].books is rejected and the response body contains zero leaked title strings) and the Connection-scalar side-channel closures (totalCount-only, pageInfo-only).
  • 4 validator unit tests in QueryModelAuthorizeSchemaValidatorTests proving the post-build validator catches stripped directives.
  • 8 discovery unit tests in QueryModelAuthorizeDiscoveryTests pinning attribute discovery, inheritance, ExposeAs composition, and build-time shape validation.
  • Full Trax.Api test suite (1321 tests) green.

Security contract pinned by the tests

A Player API key cannot read a gated entity (a) directly, (b) transitively through a navigation property on an ungated parent, or (c) via Connection scalars that never resolve a node. The transitive-navigation regression guards explicitly assert no seeded title strings appear anywhere in the response body — data, errors, or extensions.

Theauxm added 5 commits May 14, 2026 10:37
Honors [TraxAuthorize] on entity classes exposed via [TraxQueryModel],
applying HotChocolate's @authorize directive at both the entry-field level
(under discover.*) and the ObjectType level. Field-level gating blocks the
entry point unconditionally — including Connection scalars like totalCount,
pageInfo, and where: filter probes. Type-level gating catches transitive
navigation: an unauthorized caller cannot read a gated entity by reaching
it through a navigation property on an ungated parent.

Combinator semantics mirror the train-side TraxAuthorize: policies AND
across attributes, roles OR (CSV split, unioned across attributes). Bare
[TraxAuthorize] requires an authenticated user. Errors surface as
TRAX_AUTHORIZATION with the public "Not authorized." message; the entity
name, policy name, and role names never reach the wire.

QueryModelAuthorizationValidator throws at host start if any entity
references an authorization policy that is not registered with
services.AddAuthorization. Build-time validation rejects whitespace
policy values and all-empty roles CSV.
Prevents a consumer from subclassing the type module and registering a
replacement via services.Replace<QueryModelTypeModule>(...) that skips
emitting the @authorize directives. The DI factory now must return an
exact QueryModelTypeModule instance, and there's no other class that
satisfies that constraint.
…xQueryModel]

Adds a QueryModelAuthorizationSchemaValidator hosted service that runs at
host start, materialises the GraphQL schema, and re-asserts that every
[TraxAuthorize]-gated entity still carries the @authorize directive at
both the entry-field and ObjectType levels. Closes the only remaining
escape hatch in the configuration surface — a consumer-supplied
ConfigureSchema callback that installs a TypeInterceptor capable of
stripping directives Trax emitted earlier in the pipeline. If a gate has
been removed, the host refuses to start with a message naming the entity
and the missing directive location.

Coverage:
  - Unit tests build minimal schemas with the directive stripped at
    type, field, and both levels — validator throws naming entity / gate.
  - E2E test boots a real host with the standard wiring and confirms the
    validator signs off on a normally-configured schema.
When any [TraxQueryModel] entity carries [TraxAuthorize], wire a HotChocolate
IHttpRequestInterceptor (QueryModelAuthenticationInterceptor) that populates
HttpContext.User by walking every registered authentication scheme until one
succeeds. Without it, multi-scheme hosts (e.g. api-key + JWT) without an
explicit default scheme leave HC seeing an anonymous principal, causing every
gated query to reject every caller — including those whose credentials would
have authenticated against any of the registered schemes.

Runs only for HTTP GraphQL execution requests, so WebSocket subscription
upgrades and the Banana Cake Pop tool page are not affected. Subscriptions
authenticate via the per-scheme socket interceptors as before.

Zero consumer configuration required; the interceptor activates automatically
alongside the @authorize directives.
QueryModelAuthorizeE2ETests uses `trax_api_auth_querymodel` and
QueryModelAuthorizeSchemaInvariantE2ETests uses `trax_api_auth_schema_inv`.
The CI runner provisions per-class databases upfront so test fixtures don't
race to CREATE them; both new names need to be in the provisioning list.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 14, 2026

Codecov Report

❌ Patch coverage is 99.53052% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...thorization/QueryModelAuthenticationInterceptor.cs 90.90% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Theauxm added 3 commits May 14, 2026 11:34
The previous failure mode: adding a new AuthE2E test class with its own
per-fixture database required also updating two GitHub Actions workflows to
add the database name to a CREATE DATABASE loop. Forgetting that step (easy
to do) produced a setup-time Postgres "database does not exist" failure on
CI for every test in the new class.

Move provisioning into the test code itself: AuthE2EHost.EnsureDatabaseExists
connects to the maintenance database and idempotently creates the per-class
DB, called from both StartAsync and the EnsureSeeded helpers on the test
DbContexts. Validates the database identifier shape since CREATE DATABASE
does not support parameter binding for identifiers.

CI workflows no longer enumerate the AuthE2E database list — adding a new
fixture is a code-only change.
…eptor

Three new direct-unit test classes pin the failure paths that codecov
flagged on the original PR:

QueryModelAuthorizationSchemaValidatorTests — new cases for the schema-shape
failures: discover field deleted, entry field deleted, namespace-routed
entity with the namespace field deleted, and the early-return path when no
entity is gated (proved with a ThrowingServiceProvider that fails any DI
access so the assertion is "the validator did not attempt to resolve the
schema," not just "no exception thrown").

QueryModelAuthorizationValidatorTests — the missing-policy throw path
(message must name the policy, the entity, and the AddAuthorization fix),
plus a ThrowingPolicyProvider that proves roles-only attributes never
consult the provider, and a CountingPolicyProvider that proves the validator
dedupes by policy name across entities (one provider call for N entities
sharing a policy).

QueryModelAuthenticationInterceptorTests — every branch of the scheme walk:
already-authenticated short-circuit (no scheme is queried, so the upstream
principal cannot be downgraded), no-scheme-matches keeps user anonymous,
first-scheme-succeeds stops the walk, second-scheme-succeeds proves the
walk continues past NoResult, and the defensive guard that Success with a
null Principal must not assign the anonymous user.

Every assertion pins behaviour that would break in production if the code
regressed: schema-shape errors must name what is missing so maintainers can
debug their override; policy-name typos must fail loud; an interceptor
that stops walking after NoResult would prevent JWT requests from ever
authenticating on api-key+JWT hosts.
…del]

Adds direct unit coverage for the failure modes the Codecov report flagged:

- QueryModelAuthorizationSchemaValidator: namespace happy path,
  namespace-field-missing, entry-field-missing, discover-field-missing,
  ObjectType-missing-from-schema, no-gated-entities early return (with
  a throwing service provider that fails the test if the validator
  resolves anything).
- QueryModelAuthenticationInterceptor: already-authenticated short
  circuit (asserts the scheme provider is never asked), no-scheme-matches
  leaves principal anonymous, first-success wins and stops walking.
- GraphQLModelExposureWarningService: no-models silent skip,
  all-gated silent skip, some-ungated warning carries both counts.
- QueryModelAuthorizationValidator: same policy referenced by multiple
  entities hits the provider exactly once (dedup branch).
- QueryModelTypeModule: DeprecationReason wired through to @deprecated,
  BindFields.Explicit + [TraxAuthorize] compose without dropping either
  the column-only field set or the @authorize directive.

Every test asserts a behaviour that would break under a realistic
regression — none are added purely to bump the coverage number.
@Theauxm Theauxm merged commit 39f5666 into main May 14, 2026
2 checks passed
@Theauxm Theauxm deleted the feat/trax-authorize-on-query-model branch May 14, 2026 18:31
@traxsharp
Copy link
Copy Markdown

traxsharp Bot commented May 14, 2026

This PR is included in version 1.30.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant