Skip to content

Added policy interfaces - #10008

Open
michaelstaib wants to merge 87 commits into
mainfrom
mst/fusion-authorization
Open

michaelstaib wants to merge 87 commits into
mainfrom
mst/fusion-authorization

Conversation

@michaelstaib

Copy link
Copy Markdown
Member

No description provided.

Copilot AI review requested due to automatic review settings June 29, 2026 19:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces first-class support for authorization policy directives in Fusion composition and execution by (1) merging user @policy directives (including interface-to-implementor propagation), (2) stamping them into @fusion__policy in the composed schema, and (3) exposing parsed policy applications on Fusion object/field definitions for execution-time use.

Changes:

  • Add composition support for @policy (merge + dedupe + propagate from interfaces) and stamp results as @fusion__policy.
  • Add execution schema support for parsing/storing @fusion__policy applications on object types and fields, plus a schema-level HasPolicies flag.
  • Introduce policy runtime interfaces (IAuthorizationPolicy / IAuthorizationContext) and add targeted tests in both composition and execution layers.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/PolicyApplicationsTests.cs Adds execution-layer tests validating parsing/storage of @fusion__policy applications and HasPolicies.
src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.PolicyDirective.Tests.cs Adds composition-layer tests for stamping, interface propagation, and deduping behavior.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Policies/IAuthorizationPolicy.cs Introduces policy evaluation interfaces and an entity container for policy evaluation.
src/HotChocolate/Fusion/src/Fusion.Execution.Types/PolicyDenialBehavior.cs Adds runtime enum describing denial behavior semantics (Null/Error/Abort).
src/HotChocolate/Fusion/src/Fusion.Execution.Types/PolicyApplication.cs Adds runtime model representing a single policy application on a coordinate.
src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs Adds HasPolicies and computes it during schema sealing based on stored applications.
src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionOutputFieldDefinition.cs Adds PolicyApplications to output field definitions and wires it from completion context.
src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionObjectTypeDefinition.cs Adds PolicyApplications to object type definitions and wires it from completion context.
src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionBuiltIns.cs Adds fusion__policy built-in directive name constant.
src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs Parses @fusion__policy directives into PolicyApplication collections during completion.
src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeOutputFieldCompletionContext.cs Extends field completion context to carry policy applications.
src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeObjectTypeCompletionContext.cs Extends object completion context to carry policy applications.
src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs Adds well-known type names for policy denial behavior (user + fusion enum).
src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs Adds well-known directive names for policy and fusion__policy.
src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs Adds onDenied argument constant for policy directives.
src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs Implements policy directive merging, interface-policy propagation, and stamping to @fusion__policy; adds fusion enum/ ನಿರ್ದೇಶive definitions.
src/HotChocolate/Fusion/src/Fusion.Composition/Directives/PolicyDirective.cs Adds parsed representation of user @policy directives.
src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/PolicyDirectiveMerger.cs Adds merger logic: collect, dedupe by name, choose “max” denial behavior.
src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/PolicyMutableDirectiveDefinition.cs Defines canonical user @policy directive (repeatable, object/interface/field).
src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/PolicyDenialBehaviorMutableEnumTypeDefinition.cs Defines user-facing PolicyDenialBehavior enum for @policy.
src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionPolicyMutableDirectiveDefinition.cs Defines @fusion__policy directive for composed schema stamping.
src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionPolicyDenialBehaviorMutableEnumTypeDefinition.cs Defines fusion__PolicyDenialBehavior enum for @fusion__policy.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1517 to +1539
private static void AddFusionPolicyDirectives(
IDirectivesProvider member,
IReadOnlyList<PolicyDirective> policies,
MutableDirectiveDefinition directiveDefinition)
{
foreach (var policy in policies)
{
var arguments = new List<ArgumentAssignment>
{
new(ArgumentNames.Name, policy.Name)
};

if (policy.OnDenied != "NULL")
{
arguments.Add(
new ArgumentAssignment(
ArgumentNames.OnDenied,
new EnumValueNode(policy.OnDenied)));
}

member.AddDirective(new Directive(directiveDefinition, arguments));
}
}
# Conflicts:
#	src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs
#	src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs
# Conflicts:
#	.gitignore
#	AGENTS.md
#	CLAUDE.md
#	src/Directory.Packages.props
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ApolloParityBenchmark.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusParsingBenchmark.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningBenchmark.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningProbe.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/DbRowWriteBenchmark.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/DocumentRewriterBenchmark.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/FusionBenchmarkBase.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/GraphQLQueryBenchmark.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/GraphQLServerHelper.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/HashCodeBenchmark.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/InlineFragmentOperationRewriterBenchmark.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/LockStoreBenchmark.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationCompilerBenchmark.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationPlannerBenchmark.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/Program.cs
#	src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/VariableMergingBenchmark.cs
#	src/HotChocolate/Fusion/src/Fusion.Execution/Diagnostics/IFusionExecutionDiagnosticEvents.cs
#	src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs
#	src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs
#	src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs
#	src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs
#	src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj
#	src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs
#	src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs
#	src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx
#	src/HotChocolate/Fusion/src/Fusion.Execution/Types/Completion/SemanticIntrospectionSchema.cs
#	src/HotChocolate/Fusion/src/Fusion.Execution/Types/FusionObjectTypeDefinition.cs
#	src/HotChocolate/Fusion/src/Fusion.Execution/Types/FusionOutputFieldDefinition.cs
#	src/HotChocolate/Fusion/src/Fusion.Execution/Types/ScalarResultType.cs
#	src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs
VerifySignatureAsync accepted any certificate whose issuer and serial
number matched a trusted certificate, because
X509Certificate2Collection.Contains compares only those two fields.
A forged certificate carrying its own key pair but the same subject
and serial number as a trusted signer passed verification. Membership
is now checked by comparing the raw certificate bytes of every signer
against the trusted collection.

Also close the empty-collection gap in
FileSystemFusionConfigurationProvider: a configured but empty
TrustedSigningCertificates collection was treated as no trust root,
silently accepting unsigned archives instead of rejecting everything.
The GetPolicyParentDependencies borrowing predicate now accepts a lift
whose downstream operation merely survived in the same rewritten
incremental-plan part, rather than requiring it to be paired with the
policy node, provided the lift was recorded against the immediate
parent scope, its requirement exactly provides the full response
leaf, and its own parent step is itself an immediate-parent provider
of that leaf.

This let the planner produce a non-empty parent-dependency closure for
a query shape where an outer @defer has no fields of its own (its
lifted fetch is hoisted straight into the root part and it never
materializes its own IncrementalPlan), which in turn exposed a
pre-existing gap in PolicyArtifactBinder.TryResolveParentPlanScope: it
only fell back to the root scope when the child's own DeliveryGroup
had no parent, not when the referenced parent group itself had none
and materialized no plan. TryResolveParentPlanScope now also resolves
to the root scope in that case; a parent group nested one level deeper
that still materialized no plan remains a known limitation and keeps
failing closed.
Introduces a v2 Rego policy format (policies/rego/2.0.0/) alongside the
existing flat policy-pair format: a manifest.json indexes every policy
package, shared library module, and the root data mount by canonical
relative path and sha256 digest, cross-checked against a scan of each
module's declared package and its # METADATA / entrypoint: true rules.
Unlisted, missing, duplicate, or tampered payloads are rejected at read.

FusionArchive gains SetRegoPolicyBundleAsync/GetRegoPolicyBundleAsync as
the single scanner/validator boundary shared by the reader and the new
`nitro fusion policy pack <root> --out <far|dir>` CLI command. Version
dispatch is based on the presence of a nested manifest.json rather than
the literal version number, so the existing flat-pair format keeps using
arbitrary version numbers of its own.

PackagePolicyContentReader gains a v2 branch and now rejects an archive
whose only Rego formats exceed what the runtime supports (fail closed)
instead of silently serving the schema with no policies; a runtime built
before this change still returns null for such an archive.
PolicyContentSnapshot gains a separate library/module catalog, and
RegoPolicyProvider compiles it into every policy set without ever
treating a library as a decision or falling back to a synthetic
'<name>.allow' policy for it.

RegoEntrypointScanner moves from Fusion.Policies.Rego to Fusion.Packaging
so the bundle manifest validator and the Rego policy provider share one
decision-discovery implementation instead of two.
F3: a malformed manifest (missing required property, duplicate sha256
key) now surfaces InvalidDataException via ThrowHelper instead of an
uncaught KeyNotFoundException or ArgumentException from the raw
JsonElement/ImmutableSortedDictionary calls.

F4: read-time path collisions (a module and a library, or two packages,
claiming the same path) now throw InvalidDataException instead of the
write-time ArgumentException they were reusing. Added tests for manifest
path traversal, both path-collision shapes, and a tampered lib/*.rego or
data/data.json whose manifest hash was never updated.

F5: the CLI's --out <dir> extraction now goes entirely through
IFileSystem instead of raw File/Directory calls, and is covered by
Pack_Should_WriteUnpackedBundle_When_OutIsDirectory. Fixed a latent bug
this surfaced: a package with no <package>.graphql file was packed with
a non-null, zero-length Requirements value (the byte[]-to-
ReadOnlyMemory<byte> implicit conversion turns a null array into an
empty struct before it reaches the nullable wrapper) instead of null,
which failed the bundle's own requirements-prefix check on write.

F6: dropped the unused WellKnownVersions.RegoPolicyPairFormatVersion.
RegoEntrypointScanner goes back to internal, visible to
Fusion.Policies.Rego and its tests via InternalsVisibleTo, since ruling
699 keeps the scanner in Fusion.Packaging without asking for a public
surface.

F7: SetRegoPolicyAsync and SetRegoDataAsync now reject a format version
that already carries a manifest-indexed bundle, so a flat write can
never land inside a v2 bundle directory.

F8: split the two over-5-assert tests (the bundle round-trip and the
bundle read) into one focused test per concern instead of asserting
package, library, and data all in one test body.
Closes the six residual minors from zh8's final review (repo-ctf.25):
- provider collisions with no last-good snapshot are now reported as
  RegoDataProviderException(providerName) instead of a bare
  RegoDataMergeException, for both the single- and multi-provider case
- the FAR-publish compile precheck disposes its (never served)
  CompiledPolicySet instead of leaking it
- a provider refresh that leaves the served (FAR, providers) combination
  unchanged no longer triggers a redundant recompile and republish
- ProviderState's members are internal rather than public, matching the
  narrowest visibility this design supports
…e event message

Lifts the 6fl blanket rejection for a policy directly on a subscription root's
own (concrete) EventStream payload type when its requirement is derivable
from the composed @eventstream message projection. Such an application is
folded into the existing per-event slot path instead of a PolicyExecutionNode
(EventStream can never be a producer, provider or dependency of one), and
evaluated against the event's own materialized root element once merged into
the shared context. Non-derivable requirements keep hitting a narrowed,
specific rejection; nested/non-root subscription-field policies and abstract
payload types are unchanged.
…e coverage

A list-typed subscription root can carry more than one resource per event, so
GetConcreteEventStreamMessage and PolicyArtifactBinder's event-resource
coordinate check now exclude it, keeping the old unnarrowed rejection instead
of silently deriving from the message projection; PolicyRequestState also
denies outright (instead of leaving the decision undecided) if the per-event
slot path ever sees more than one root entity. Adds the missing negative
tests: JSON mutations of the round-trip plan (dropped requirements, changed
selectionSet, unrelated policy name) and a message mutation that only the
binder's independent derivability check catches, plus the list-typed root
rejection test. Amends IsRequirementCoveredByEventMessage's doc to note the
inline-fragment branch is currently unreachable.
A subscription event's per-event reset rebuilt the result document but left
FetchResultStore's cached policy-denial state (deny flags, plan, plan part)
from the previous event in place. AddPartialResults merges the next event's
data before that event's own policy re-evaluation refreshes this state, so
ValueCompletion.ApplyPolicyDenials replayed the prior event's denial against
the new, not-yet-decided root: for ABORT this nulled the fresh root outright,
crashing the next event's own per-event resource read with "Expected
StartObject but found Null"; for ERROR/NULL it silently re-denied an event
that should have been allowed. Reset now clears this state so each event
starts undecided until it is actually evaluated. Adds 3-event regressions
for ABORT and ERROR (allowed, denied, allowed) proving the third event is
no longer corrupted by the second, and trims two existing test comments and
one over-length test name flagged in review.
An action policy's decision depends on its own occurrence's arguments, so
two occurrences that share a field's shape (type, field, root-ness) but
differ by parent must never be aggregated by response name alone - two
distinct occurrences (e.g. the same field under different parents) can
share a response name. PolicyArtifactBinder.MatchesCoordinate and
MatchesCoordinatePosition now match an action coordinate by its first
occurrence's compiled position instead. OperationPlan.ValidatePolicyArtifacts
follows suit: the gate-identity fold and the per-slot field-coordinate
uniqueness check key on occurrence position rather than response names, so
the fix above does not immediately fail the duplicate-gate-identity check
it was previously masking.

OperationPlanner's coordinate loop now rejects an action slot that overflows
the 64-gate table with the documented "cannot contain more than 64 policy
gates" error instead of silently falling back to a residual (post-fetch)
PolicyExecutionNode target, which cannot carry action input and would let a
denied mutation's fetch run.

PolicyRequestState now evaluates a subscription-root action expression once,
at the initial (pre-subscribe) request-level pass, and replays that decision
for every subsequent per-event re-evaluation instead of re-running it once
per event; non-event passes (including every variable-batch item) never read
the replay cache, so variable-batch isolation is unaffected.

Adds regression coverage: planning and gateway tests for the shared
response-name aliasing fix, a 64/65-alias planning pair for the gate-cap
message, gateway defer and subscription tests for action policies (including
the once-per-stream evaluation count), and an HTTP-transport gateway test in
Fusion.AspNetCore.Tests confirming a denied mutation's source is never
invoked while an allowed one is.
…on decisions per name

The planner's action-slot gate disambiguator was keyed on
TypeName/FieldName/ResponseNames plus the registry's own allocation
length, a term that can never match an existing entry, so the "same
occurrence seen again" merge branch was unreachable. Key it on the
occurrence's compiled position (selection set + coordinate path +
occurrence ordinal) instead, supplied by the BuildPolicyTargets
occurrence loop, mirroring the binder's OccurrenceOrdinal.

PolicyRequestState cached a mixed action/non-action expression's whole
decision keyed by (slot, coordinate, expression) and replayed it for
every subscription per-event pass, so a non-action name sharing the
expression with an action name was silently stuck on its first
decision instead of being re-evaluated per event. Cache per action
name instead, and always route non-action names through
EvaluatePolicyOnceAsync so they see every pass.
# Conflicts:
#	src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs
#	src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/FusionOptionsTests.cs

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants