Commit a87d93c4064 for woocommerce
commit a87d93c40649a4033e0fd49da44075824e808150
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Fri May 22 10:26:20 2026 +0200
Add granular type- and field-level GraphQL authorization (#65235)
diff --git a/plugins/woocommerce/bin/api-builder/ApiBuilder.php b/plugins/woocommerce/bin/api-builder/ApiBuilder.php
index 0d87ef90cd2..b8dd394b835 100644
--- a/plugins/woocommerce/bin/api-builder/ApiBuilder.php
+++ b/plugins/woocommerce/bin/api-builder/ApiBuilder.php
@@ -210,17 +210,22 @@ class ApiBuilder {
private string $principal_type = Principal::class;
/**
- * Map of FQCN => `['takes_principal' => bool]` for every autodiscovered authorization attribute.
+ * Map of FQCN => per-attribute slot flags for every autodiscovered authorization attribute.
*
* An attribute class qualifies when it lives in `Api/Attributes/` and declares an
- * `authorize()` method whose signature matches `(?<PrincipalType>): bool` or
- * `(): bool`. The flag captures whether the attribute's authorize() takes the
- * principal parameter (true) or opts out (false) — used by {@see self::resolve_authorization()}
- * to emit the runtime call with the right arity.
+ * `authorize()` method whose signature matches the contract enforced by
+ * {@see self::validate_attribute_authorize_shape()}. Each flag records whether the
+ * `authorize()` method declared the corresponding opt-in slot, so call-site
+ * emission can thread only the slots the attribute asked for:
+ *
+ * - `takes_principal` — the principal positional parameter.
+ * - `takes_metadata` — `array $_metadata` (the harvested metadata slices).
+ * - `takes_args` — `array $_args` (the GraphQL field arguments at the call site).
+ * - `takes_parent` — `mixed $_parent` (the parent value being resolved).
*
* Populated by {@see self::discover_authorization_attributes()}.
*
- * @var array<class-string, array{takes_principal: bool}>
+ * @var array<class-string, array{takes_principal: bool, takes_metadata: bool, takes_args: bool, takes_parent: bool}>
*/
private array $authorization_attribute_fqcns = array();
@@ -603,96 +608,164 @@ class ApiBuilder {
if ( ! $ref->hasMethod( 'authorize' ) ) {
return;
}
- $method = $ref->getMethod( 'authorize' );
- if ( ! $this->validate_attribute_authorize_shape( $fqcn, $method ) ) {
+ $method = $ref->getMethod( 'authorize' );
+ $validated = $this->validate_attribute_authorize_shape( $fqcn, $method );
+ if ( null === $validated ) {
return;
}
- if ( ! $this->attribute_principal_type_is_compatible( $method ) ) {
+ if ( null !== $validated['principal_param']
+ && ! $this->is_principal_type_compatible( self::param_type_name( $validated['principal_param'] ) )
+ ) {
if ( ! $is_core ) {
- $this->record_attribute_principal_mismatch_error( $fqcn, $method );
+ $this->record_attribute_principal_mismatch_error( $fqcn, $validated['principal_param'] );
}
return;
}
$this->authorization_attribute_fqcns[ $fqcn ] = array(
- 'takes_principal' => count( $method->getParameters() ) > 0,
+ 'takes_principal' => null !== $validated['principal_param'],
+ 'takes_metadata' => $validated['takes_metadata'],
+ 'takes_args' => $validated['takes_args'],
+ 'takes_parent' => $validated['takes_parent'],
);
}
/**
* Validate the *shape* of an attribute's `authorize()` method — return type
- * and arity — independent of principal-type compatibility.
+ * and per-parameter contract — independent of principal-type compatibility.
+ *
+ * Accepted parameters (any order, any subset):
+ * - One positional principal parameter: any name that does not start with
+ * `_`. Must be non-nullable and typed. Type compatibility with the
+ * registered principal type is checked separately by the caller.
+ * - `array $_metadata`: opt-in metadata slices (`query`, `type`, `field`).
+ * - `array $_args`: opt-in GraphQL field arguments at the call site.
+ * - `mixed $_parent` (or untyped): opt-in parent value at the call site.
*
- * The accepted shapes are:
- * - 0 parameters, returns bool. The attribute opts out of using the principal.
- * - 1 non-nullable parameter, returns bool. (Type compatibility with the registered
- * principal type is checked separately by {@see self::attribute_principal_type_is_compatible()}
- * so call sites can decide whether mismatch is a hard error or a silent skip.)
+ * Any other `_`-prefixed parameter name, more than one principal candidate,
+ * a wrongly typed infra parameter, or a malformed return type produces a
+ * build-time error.
*
- * Records build errors for genuinely malformed signatures and returns false.
+ * @return array{principal_param: ?\ReflectionParameter, takes_metadata: bool, takes_args: bool, takes_parent: bool}|null
+ * Structured per-parameter classification on success, or `null` on validation failure.
*/
- private function validate_attribute_authorize_shape( string $fqcn, \ReflectionMethod $method ): bool {
+ private function validate_attribute_authorize_shape( string $fqcn, \ReflectionMethod $method ): ?array {
if ( $method->isStatic() || ! $method->isPublic() ) {
$this->errors[] = "Authorization attribute {$fqcn}::authorize() must be a public non-static method.";
- return false;
+ return null;
}
$return_type = $method->getReturnType();
if ( ! $return_type instanceof \ReflectionNamedType || 'bool' !== $return_type->getName() ) {
$this->errors[] = "Authorization attribute {$fqcn}::authorize() must declare a `bool` return type.";
- return false;
+ return null;
}
- $params = $method->getParameters();
- if ( count( $params ) > 1 ) {
- $this->errors[] = "Authorization attribute {$fqcn}::authorize() must take 0 parameters or a single `<Principal>` parameter; got " . count( $params ) . '.';
- return false;
- }
- if ( 0 === count( $params ) ) {
- return true;
- }
+ $principal_param = null;
+ $takes_metadata = false;
+ $takes_args = false;
+ $takes_parent = false;
- $param = $params[0];
- $param_type = $param->getType();
- $type_name = $param_type instanceof \ReflectionNamedType ? $param_type->getName() : 'mixed';
+ foreach ( $method->getParameters() as $param ) {
+ $name = $param->getName();
- if ( ! $param_type instanceof \ReflectionNamedType || $param_type->allowsNull() ) {
- $this->errors[] = "Authorization attribute {$fqcn}::authorize() parameter must be non-nullable (anonymous requests are represented by a sentinel principal, not null); got `?{$type_name}`.";
- return false;
- }
+ if ( '_metadata' === $name ) {
+ if ( ! self::param_type_is_named( $param, 'array' ) ) {
+ $this->errors[] = "Authorization attribute {$fqcn}::authorize() parameter \$_metadata must be typed `array`; got `" . self::param_type_name( $param ) . '`.';
+ return null;
+ }
+ $takes_metadata = true;
+ continue;
+ }
- return true;
- }
+ if ( '_args' === $name ) {
+ if ( ! self::param_type_is_named( $param, 'array' ) ) {
+ $this->errors[] = "Authorization attribute {$fqcn}::authorize() parameter \$_args must be typed `array`; got `" . self::param_type_name( $param ) . '`.';
+ return null;
+ }
+ $takes_args = true;
+ continue;
+ }
- /**
- * Whether an attribute's `authorize()` parameter type is compatible with the
- * registered principal type.
- *
- * Zero-arg methods are trivially compatible. Otherwise the parameter type
- * must satisfy {@see self::is_principal_type_compatible()}. Used by
- * {@see self::try_register_authorization_attribute()} to decide whether to
- * include the attribute in the autodiscovered set.
- */
- private function attribute_principal_type_is_compatible( \ReflectionMethod $method ): bool {
- $params = $method->getParameters();
- if ( 0 === count( $params ) ) {
- return true;
+ if ( '_parent' === $name ) {
+ $takes_parent = true;
+ continue;
+ }
+
+ if ( '' !== $name && '_' === $name[0] ) {
+ $this->errors[] = "Authorization attribute {$fqcn}::authorize() has unknown infrastructure parameter \${$name}; accepted: \$_metadata, \$_args, \$_parent.";
+ return null;
+ }
+
+ if ( null !== $principal_param ) {
+ $this->errors[] = "Authorization attribute {$fqcn}::authorize() may declare at most one principal parameter; got \${$principal_param->getName()} and \${$name}.";
+ return null;
+ }
+
+ $param_type = $param->getType();
+ $type_name = self::param_type_name( $param );
+ if ( ! $param_type instanceof \ReflectionNamedType || $param_type->allowsNull() ) {
+ $this->errors[] = "Authorization attribute {$fqcn}::authorize() principal parameter must be non-nullable (anonymous requests are represented by a sentinel principal, not null); got `?{$type_name}`.";
+ return null;
+ }
+
+ $principal_param = $param;
}
- $param_type = $params[0]->getType();
- $type_name = $param_type instanceof \ReflectionNamedType ? $param_type->getName() : 'mixed';
- return $this->is_principal_type_compatible( $type_name );
+
+ return array(
+ 'principal_param' => $principal_param,
+ 'takes_metadata' => $takes_metadata,
+ 'takes_args' => $takes_args,
+ 'takes_parent' => $takes_parent,
+ );
}
/**
* Record a build error for a plugin-shipped attribute whose `authorize()`
- * parameter type is incompatible with the registered principal type.
+ * principal parameter type is incompatible with the registered principal type.
*/
- private function record_attribute_principal_mismatch_error( string $fqcn, \ReflectionMethod $method ): void {
- $param_type = $method->getParameters()[0]->getType();
- $type_name = $param_type instanceof \ReflectionNamedType ? $param_type->getName() : 'mixed';
+ private function record_attribute_principal_mismatch_error( string $fqcn, \ReflectionParameter $principal_param ): void {
+ $type_name = self::param_type_name( $principal_param );
$expected = 'object' === $this->principal_type
? '`object` (or any object class)'
: "`{$this->principal_type}` (or `object`)";
- $this->errors[] = "Authorization attribute {$fqcn}::authorize() parameter must be typed as {$expected}; got `{$type_name}`.";
+ $this->errors[] = "Authorization attribute {$fqcn}::authorize() principal parameter must be typed as {$expected}; got `{$type_name}`.";
+ }
+
+ /**
+ * Whether a parameter declares the named type (and only the named type).
+ *
+ * Used by the authorize-method validator to enforce that opt-in
+ * infrastructure parameters declare the type the contract expects
+ * (`array` for `$_metadata` and `$_args`; `$_parent` has no required type).
+ */
+ private static function param_type_is_named( \ReflectionParameter $param, string $expected ): bool {
+ $type = $param->getType();
+ return $type instanceof \ReflectionNamedType && $type->getName() === $expected;
+ }
+
+ /**
+ * Return a printable name for a parameter's declared type.
+ *
+ * `mixed` is returned for untyped parameters and for union/intersection
+ * types; the validator does not currently inspect those shapes.
+ */
+ private static function param_type_name( \ReflectionParameter $param ): string {
+ $type = $param->getType();
+ return $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+ }
+
+ /**
+ * Render a scalar/array value as a valid PHP expression that, when
+ * evaluated, reproduces the value. Used by the resolver-template emission
+ * to inline build-time-known data (e.g. metadata slices) into the
+ * generated code.
+ *
+ * The output is `var_export()`-style and is later normalised by phpcbf
+ * during the generated-file lint pass, so callers do not need to worry
+ * about formatting.
+ */
+ private static function render_php_literal( mixed $value ): string {
+ return var_export( $value, true );
}
// ========================================================================
@@ -1049,16 +1122,32 @@ class ApiBuilder {
} elseif ( $has_public_access ) {
$attribute_expr = 'true';
} else {
+ // Command-level metadata literal — `['query' => harvest_metadata($ref)]`.
+ // `$_args` and `$_parent` are field-level concepts; at the command
+ // level they carry neutral defaults (`[]` and `null`).
+ $query_metadata_literal = self::render_php_literal( array( 'query' => $this->harvest_metadata( $ref, $ref->getShortName() ) ) );
+
$expressions = array_map(
- function ( $u ) {
- $arg = $this->authorization_attribute_fqcns[ $u['fqcn'] ]['takes_principal']
- ? ' $principal '
- : '';
+ function ( $u ) use ( $query_metadata_literal ) {
+ $flags = $this->authorization_attribute_fqcns[ $u['fqcn'] ];
+ $args = array();
+ if ( $flags['takes_principal'] ) {
+ $args[] = '$principal';
+ }
+ if ( $flags['takes_metadata'] ) {
+ $args[] = '_metadata: ' . $query_metadata_literal;
+ }
+ if ( $flags['takes_args'] ) {
+ $args[] = '_args: array()';
+ }
+ if ( $flags['takes_parent'] ) {
+ $args[] = '_parent: null';
+ }
return sprintf(
'( new \%s(%s) )->authorize(%s)',
$u['fqcn'],
$u['args_php'],
- $arg
+ empty( $args ) ? '' : ' ' . implode( ', ', $args ) . ' '
);
},
$usages
@@ -1071,6 +1160,7 @@ class ApiBuilder {
'has_public_access' => $has_public_access,
'attribute_expr' => $attribute_expr,
'error' => $error,
+ 'descriptors' => $this->harvest_authorization_descriptors( $ref ),
);
}
@@ -1082,11 +1172,15 @@ class ApiBuilder {
* supplied at each usage site, so the generated resolver can construct the
* attribute with the same arguments.
*
- * @param \ReflectionClass $source The class/trait/interface to read attributes from.
+ * Accepts any reflector that supports `getAttributes()` (class, trait,
+ * interface, or property) — class-level discovery, ancestor walks, and
+ * field-level discovery share the same scanner.
+ *
+ * @param \ReflectionClass|\ReflectionProperty $source The reflector to read attributes from.
*
* @return array<int, array{fqcn: string, args_php: string, is_public_access: bool}>
*/
- private function collect_authorization_usages( \ReflectionClass $source ): array {
+ private function collect_authorization_usages( $source ): array {
$usages = array();
foreach ( array_keys( $this->authorization_attribute_fqcns ) as $attr_fqcn ) {
foreach ( $source->getAttributes( $attr_fqcn ) as $attr ) {
@@ -1102,6 +1196,284 @@ class ApiBuilder {
return $usages;
}
+ /**
+ * Whether the given reflector (class, property, parameter, …) should
+ * appear in the `_apiMetadata` discovery query.
+ *
+ * The check is per-target and uniform across attribute kinds: walk
+ * the reflector's direct attributes, and for each whose class
+ * declares a `shows_in_metadata_query(): bool` method, call it. If
+ * any returns `false`, the target is hidden — its `metadata` and
+ * `authorization` keys are dropped from the emitted config, and
+ * {@see \Automattic\WooCommerce\Api\Utils\SchemaHandle::get_all_metadata()}
+ * does not emit a row for it.
+ *
+ * Despite the colloquial name, this is unrelated to native GraphQL
+ * introspection (`__schema` / `__type`); those queries continue to
+ * expose the schema's shape regardless of this flag. The marker
+ * only affects the custom `_apiMetadata` channel.
+ *
+ * The runtime authorization gate is independent of this check: an
+ * authorization attribute whose `shows_in_metadata_query()` returns
+ * `false` still runs its `authorize()` method at request time. The
+ * stock {@see \Automattic\WooCommerce\Api\Attributes\HiddenFromMetadataQuery}
+ * marker is the recommended way to opt a target out without giving
+ * the attribute any other behaviour.
+ *
+ * @param \Reflector $reflector Class, property, parameter, or enum case.
+ */
+ private function is_target_metadata_visible( \Reflector $reflector ): bool {
+ foreach ( $reflector->getAttributes() as $attribute ) {
+ $name = $attribute->getName();
+ if ( ! class_exists( $name ) || ! method_exists( $name, 'shows_in_metadata_query' ) ) {
+ continue;
+ }
+ $instance = $attribute->newInstance();
+ if ( false === $instance->shows_in_metadata_query() ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Walk a class plus its parents / traits / interfaces (visit-set
+ * guarded), invoking `$collect` on each reflector and accumulating the
+ * returned descriptors. Shared between
+ * {@see self::collect_class_authorization_usages()} and
+ * {@see self::collect_class_authorization_descriptors()} so the gate
+ * emission and the `_apiMetadata` discovery agree on what counts as
+ * "from the type's hierarchy".
+ *
+ * Direct attributes shadow inherited ones — the same rule the existing
+ * command-class resolution uses: if the class itself contributes a
+ * non-empty list, ancestors are not walked.
+ *
+ * @param \ReflectionClass $ref Class to walk.
+ * @param callable $collect Maps each reflector to a list.
+ *
+ * @return list<mixed>
+ */
+ private function walk_class_hierarchy( \ReflectionClass $ref, callable $collect ): array {
+ $direct = $collect( $ref );
+ if ( ! empty( $direct ) ) {
+ return $direct;
+ }
+ $visited = array();
+ $stack = array_merge(
+ $ref->getParentClass() ? array( $ref->getParentClass() ) : array(),
+ $ref->getTraits(),
+ $ref->getInterfaces(),
+ );
+ $out = array();
+ while ( ! empty( $stack ) ) {
+ $source = array_shift( $stack );
+ $name = $source->getName();
+ if ( in_array( $name, $visited, true ) ) {
+ continue;
+ }
+ $visited[] = $name;
+ $out = array_merge( $out, $collect( $source ) );
+ if ( false !== $source->getParentClass() ) {
+ $stack[] = $source->getParentClass();
+ }
+ $stack = array_merge( $stack, $source->getTraits(), $source->getInterfaces() );
+ }
+ return $out;
+ }
+
+ /**
+ * Collect class-level authorization usages from a type class, mirroring
+ * {@see self::resolve_authorization()}'s inheritance walk (parent chain,
+ * traits, interfaces — visit-set guarded). Direct attributes shadow
+ * inherited ones; if the class declares any class-level usages directly,
+ * ancestors are not walked.
+ *
+ * Used by {@see self::build_field_definition()} to AND a type's
+ * class-level authorization into every one of its field gates, so an
+ * attribute on `class Event` (or on a trait it uses, like an
+ * `OrganizerOnlyTrait`) restricts read access to all of `Event`'s
+ * fields without having to repeat the attribute on each property.
+ *
+ * @param \ReflectionClass $ref The type class to read class-level usages from.
+ *
+ * @return array<int, array{fqcn: string, args_php: string, is_public_access: bool}>
+ */
+ private function collect_class_authorization_usages( \ReflectionClass $ref ): array {
+ return $this->walk_class_hierarchy(
+ $ref,
+ fn( \ReflectionClass $r ): array => $this->collect_authorization_usages( $r ),
+ );
+ }
+
+ /**
+ * Inheritance-aware counterpart to
+ * {@see self::harvest_authorization_descriptors()}: walks the class's
+ * parents / traits / interfaces so trait-supplied class-level
+ * authorization surfaces through `_apiMetadata`, not just through the
+ * runtime gate. Direct descriptors shadow inherited ones (same rule
+ * as the usages walk).
+ *
+ * @param \ReflectionClass $ref Class to walk.
+ *
+ * @return list<array{attribute: string, args: list<mixed>}>
+ */
+ private function collect_class_authorization_descriptors( \ReflectionClass $ref ): array {
+ return $this->walk_class_hierarchy(
+ $ref,
+ fn( \ReflectionClass $r ): array => $this->harvest_authorization_descriptors( $r ),
+ );
+ }
+
+ /**
+ * Harvest authorization-attribute descriptors for the `_apiMetadata`
+ * discovery endpoint.
+ *
+ * Mirrors {@see self::collect_authorization_usages()} but emits
+ * `{attribute, args}` records in the shape clients read through
+ * `_apiMetadata { authorization { attribute args } }`. The per-target
+ * `shows_in_metadata_query()` filter is applied by callers
+ * ({@see self::build_field_definition()} and the `generate_*_type`
+ * methods) — this method always returns every authorization attribute
+ * found on the reflector, including those whose target opts out of
+ * the metadata query. The caller decides whether to expose them.
+ *
+ * @param \ReflectionClass|\ReflectionProperty $reflector Source reflector.
+ *
+ * @return list<array{attribute: string, args: list<mixed>}>
+ */
+ private function harvest_authorization_descriptors( $reflector ): array {
+ $descriptors = array();
+ foreach ( array_keys( $this->authorization_attribute_fqcns ) as $attr_fqcn ) {
+ foreach ( $reflector->getAttributes( $attr_fqcn ) as $attr ) {
+ $short_name = ( false !== strrpos( $attr_fqcn, '\\' ) )
+ ? substr( $attr_fqcn, strrpos( $attr_fqcn, '\\' ) + 1 )
+ : $attr_fqcn;
+ $descriptors[] = array(
+ 'attribute' => $short_name,
+ 'args' => array_values( $attr->getArguments() ),
+ );
+ }
+ }
+ return $descriptors;
+ }
+
+ /**
+ * Resolve authorization for a single property (output field, input field,
+ * or trait property).
+ *
+ * Field-level gates differ from class-level gates in three ways:
+ *
+ * - The attribute call expression references the runtime locals
+ * `$principal`, `$_metadata`, `$_args`, `$_parent` rather than baking
+ * `$_metadata` as a build-time literal. Step-5 emission creates these
+ * locals before invoking the expression.
+ * - `#[PublicAccess]` placed on a property is a build warning (not a
+ * hard error) and is treated as a no-op: it always grants, which is
+ * indistinguishable from the default allow-by-default semantics that
+ * apply to fields with no authorization attribute.
+ * - There is no inheritance walk: a field's gate is the attributes
+ * declared directly on the property reflector. Trait-declared
+ * properties carry their attributes onto every implementing class
+ * naturally through PHP's reflection.
+ *
+ * @return array{usages: list<array{fqcn: string, args_php: string, is_public_access: bool}>, attribute_expr: string}
+ */
+ private function resolve_field_authorization( \ReflectionProperty $prop, array $type_level_usages = array(), array $type_level_descriptors = array() ): array {
+ $all_usages = $this->collect_authorization_usages( $prop );
+ $usages = array();
+ foreach ( $all_usages as $usage ) {
+ if ( $usage['is_public_access'] ) {
+ $label = $prop->getDeclaringClass()->getShortName() . '::$' . $prop->getName();
+ $this->warnings[] = "#[PublicAccess] on property {$label}: redundant with allow-by-default field semantics; the attribute is ignored at field level.";
+ continue;
+ }
+ $usages[] = $usage;
+ }
+
+ // AND in the type-class-level usages. PublicAccess at type level
+ // short-circuits to true, so it never contributes to a field gate; skip
+ // silently. Other type-level usages are concatenated with the field's
+ // own usages; the combined expression becomes the field's effective gate.
+ foreach ( $type_level_usages as $usage ) {
+ if ( $usage['is_public_access'] ) {
+ continue;
+ }
+ $usages[] = $usage;
+ }
+
+ // Descriptors advertised through `_apiMetadata` must mirror the
+ // *effective* gates: a no-op #[PublicAccess] on a field is dropped from
+ // $usages above, so drop it from the descriptors too — otherwise
+ // discovery would advertise a gate that never runs. $usages and the
+ // harvested descriptors are parallel (one per authorization attribute)
+ // and #[PublicAccess] is the only no-op, so keeping the descriptors whose
+ // attribute short name survives in $usages preserves every real gate and
+ // removes only the ignored ones.
+ $effective_shorts = array();
+ foreach ( $usages as $u ) {
+ $fqcn = $u['fqcn'];
+ $short = ( false !== strrpos( $fqcn, '\\' ) ) ? substr( $fqcn, strrpos( $fqcn, '\\' ) + 1 ) : $fqcn;
+ $effective_shorts[ $short ] = true;
+ }
+ $descriptors = array_values(
+ array_filter(
+ array_merge(
+ $this->harvest_authorization_descriptors( $prop ),
+ $type_level_descriptors,
+ ),
+ static fn( array $d ): bool => isset( $effective_shorts[ $d['attribute'] ] ),
+ )
+ );
+
+ if ( empty( $usages ) ) {
+ return array(
+ 'usages' => array(),
+ 'attribute_expr' => 'true',
+ 'first_attribute_short' => null,
+ 'descriptors' => $descriptors,
+ );
+ }
+
+ $expressions = array_map(
+ function ( $u ) {
+ $flags = $this->authorization_attribute_fqcns[ $u['fqcn'] ];
+ $args = array();
+ if ( $flags['takes_principal'] ) {
+ $args[] = '$principal';
+ }
+ if ( $flags['takes_metadata'] ) {
+ $args[] = '_metadata: $_metadata';
+ }
+ if ( $flags['takes_args'] ) {
+ $args[] = '_args: $_args';
+ }
+ if ( $flags['takes_parent'] ) {
+ $args[] = '_parent: $_parent';
+ }
+ return sprintf(
+ '( new \%s(%s) )->authorize(%s)',
+ $u['fqcn'],
+ $u['args_php'],
+ empty( $args ) ? '' : ' ' . implode( ', ', $args ) . ' '
+ );
+ },
+ $usages
+ );
+
+ $first_fqcn = $usages[0]['fqcn'];
+ $first_short = ( false !== strrpos( $first_fqcn, '\\' ) )
+ ? substr( $first_fqcn, strrpos( $first_fqcn, '\\' ) + 1 )
+ : $first_fqcn;
+
+ return array(
+ 'usages' => $usages,
+ 'attribute_expr' => implode( ' && ', $expressions ),
+ 'first_attribute_short' => $first_short,
+ 'descriptors' => $descriptors,
+ );
+ }
+
/**
* Render an attribute's runtime arguments as a comma-separated PHP literal list.
*
@@ -1318,17 +1690,19 @@ class ApiBuilder {
// ------ Interface ------
private function generate_interface( string $fqcn, \ReflectionClass $ref ): void {
- $graphql_name = $this->graphql_names[ $fqcn ];
- $description = $this->get_description( $ref );
- $use_stmts = array();
- $fields = array();
+ $graphql_name = $this->graphql_names[ $fqcn ];
+ $description = $this->get_description( $ref );
+ $use_stmts = array();
+ $fields = array();
+ $type_level_usages = $this->collect_class_authorization_usages( $ref );
+ $type_level_descriptors = $this->collect_class_authorization_descriptors( $ref );
foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
continue;
}
- $field = $this->build_field_definition( $prop, 'output', $use_stmts );
+ $field = $this->build_field_definition( $prop, 'output', $use_stmts, $type_level_usages, $type_level_descriptors );
if ( $field !== null ) {
$fields[] = $field;
}
@@ -1346,7 +1720,8 @@ class ApiBuilder {
);
}
- $code = $this->render_template(
+ $type_metadata_visible = $this->is_target_metadata_visible( $ref );
+ $code = $this->render_template(
'InterfaceTypeTemplate.php',
array(
'namespace' => $this->autogenerated_namespace . '\\GraphQLTypes\\Interfaces',
@@ -1356,7 +1731,8 @@ class ApiBuilder {
'use_statements' => array_unique( $use_stmts ),
'fields' => $fields,
'type_map' => $type_map,
- 'metadata' => $this->harvest_metadata( $ref, $ref->getShortName() ),
+ 'metadata' => $type_metadata_visible ? $this->harvest_metadata( $ref, $ref->getShortName() ) : array(),
+ 'authorization' => $type_metadata_visible ? $this->collect_class_authorization_descriptors( $ref ) : array(),
)
);
@@ -1368,18 +1744,20 @@ class ApiBuilder {
// ------ Output Type ------
private function generate_output_type( string $fqcn, \ReflectionClass $ref ): void {
- $graphql_name = $this->graphql_names[ $fqcn ];
- $description = $this->get_description( $ref );
- $use_stmts = array();
- $interfaces = array();
- $fields = array();
+ $graphql_name = $this->graphql_names[ $fqcn ];
+ $description = $this->get_description( $ref );
+ $use_stmts = array();
+ $interfaces = array();
+ $fields = array();
+ $type_level_usages = $this->collect_class_authorization_usages( $ref );
+ $type_level_descriptors = $this->collect_class_authorization_descriptors( $ref );
foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
continue;
}
- $field = $this->build_field_definition( $prop, 'output', $use_stmts );
+ $field = $this->build_field_definition( $prop, 'output', $use_stmts, $type_level_usages, $type_level_descriptors );
if ( $field !== null ) {
$fields[] = $field;
}
@@ -1397,17 +1775,25 @@ class ApiBuilder {
}
}
- $code = $this->render_template(
+ $type_metadata_visible = $this->is_target_metadata_visible( $ref );
+ $type_metadata_full = $this->harvest_metadata( $ref, $ref->getShortName() );
+ $code = $this->render_template(
'ObjectTypeTemplate.php',
array(
- 'namespace' => $this->autogenerated_namespace . '\\GraphQLTypes\\Output',
- 'class_name' => $ref->getShortName(),
- 'graphql_name' => $graphql_name,
- 'description' => $description,
- 'use_statements' => array_unique( $use_stmts ),
- 'interfaces' => $interfaces,
- 'fields' => $fields,
- 'metadata' => $this->harvest_metadata( $ref, $ref->getShortName() ),
+ 'namespace' => $this->autogenerated_namespace . '\\GraphQLTypes\\Output',
+ 'class_name' => $ref->getShortName(),
+ 'graphql_name' => $graphql_name,
+ 'description' => $description,
+ 'use_statements' => array_unique( $use_stmts ),
+ 'interfaces' => $interfaces,
+ 'fields' => $fields,
+ // `metadata` feeds the `_apiMetadata` discovery channel and so respects
+ // the per-target `shows_in_metadata_query()` opt-out; `metadata_runtime`
+ // always carries the full set because field gates thread it into the
+ // `$_metadata['type']` slice and that opt-out is discovery-only.
+ 'metadata' => $type_metadata_visible ? $type_metadata_full : array(),
+ 'metadata_runtime' => $type_metadata_full,
+ 'authorization' => $type_metadata_visible ? $this->collect_class_authorization_descriptors( $ref ) : array(),
)
);
@@ -1427,22 +1813,25 @@ class ApiBuilder {
$gen_class_name = substr( $gen_class_name, 0, -5 );
}
- $description = $this->get_description( $ref );
- $use_stmts = array();
- $fields = array();
+ $description = $this->get_description( $ref );
+ $use_stmts = array();
+ $fields = array();
+ $type_level_usages = $this->collect_class_authorization_usages( $ref );
+ $type_level_descriptors = $this->collect_class_authorization_descriptors( $ref );
foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
continue;
}
- $field = $this->build_field_definition( $prop, 'input', $use_stmts );
+ $field = $this->build_field_definition( $prop, 'input', $use_stmts, $type_level_usages, $type_level_descriptors );
if ( $field !== null ) {
$fields[] = $field;
}
}
- $code = $this->render_template(
+ $type_metadata_visible = $this->is_target_metadata_visible( $ref );
+ $code = $this->render_template(
'InputObjectTypeTemplate.php',
array(
'namespace' => $this->autogenerated_namespace . '\\GraphQLTypes\\Input',
@@ -1451,7 +1840,8 @@ class ApiBuilder {
'description' => $description,
'use_statements' => array_unique( $use_stmts ),
'fields' => $fields,
- 'metadata' => $this->harvest_metadata( $ref, $ref->getShortName() ),
+ 'metadata' => $type_metadata_visible ? $this->harvest_metadata( $ref, $ref->getShortName() ) : array(),
+ 'authorization' => $type_metadata_visible ? $this->collect_class_authorization_descriptors( $ref ) : array(),
)
);
@@ -1537,6 +1927,7 @@ class ApiBuilder {
'conversion' => null,
'is_infrastructure' => false,
'unroll' => $unroll,
+ 'input_fqcn' => $unroll['fqcn'],
);
continue;
}
@@ -1577,6 +1968,7 @@ class ApiBuilder {
'name' => $param_name,
'conversion' => $conversion,
'is_infrastructure' => false,
+ 'input_fqcn' => ( null !== $conversion && null !== $input_info && 'input_type' === $input_info['kind'] ) ? $type_name : null,
);
}
@@ -1598,6 +1990,54 @@ class ApiBuilder {
}
}
+ // Collect input-side authorization gate descriptors. For every
+ // execute() parameter whose runtime value is an input class instance
+ // (either unrolled, or built by an input-converter), walk that class's
+ // public properties for field-level authorization attributes. The
+ // emitted gate fires only for *provided* fields so an unset/optional
+ // property doesn't trigger a check, mirroring the
+ // {@see \Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields}
+ // convention that input types already follow.
+ $input_side_gates = array();
+ foreach ( $execute_params as $param ) {
+ if ( empty( $param['input_fqcn'] ) || ! class_exists( $param['input_fqcn'] ) ) {
+ continue;
+ }
+ $input_ref = new \ReflectionClass( $param['input_fqcn'] );
+ $input_short = $input_ref->getShortName();
+ $type_metadata_lit = self::render_php_literal( $this->harvest_metadata( $input_ref, $input_short ) );
+ $input_type_usages = $this->collect_class_authorization_usages( $input_ref );
+ $input_type_descriptors = $this->collect_class_authorization_descriptors( $input_ref );
+ $field_descriptors = array();
+ foreach ( $input_ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+ if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+ continue;
+ }
+ $field_auth = $this->resolve_field_authorization( $prop, $input_type_usages, $input_type_descriptors );
+ if ( 'true' === $field_auth['attribute_expr'] ) {
+ continue;
+ }
+ $field_metadata_lit = self::render_php_literal(
+ $this->harvest_metadata( $prop, $input_short . '::$' . $prop->getName() )
+ );
+ $field_descriptors[] = array(
+ 'field_name' => $prop->getName(),
+ 'attribute_expr' => $field_auth['attribute_expr'],
+ 'first_attribute_short' => $field_auth['first_attribute_short'],
+ 'type_metadata_literal' => $type_metadata_lit,
+ 'field_metadata_literal' => $field_metadata_lit,
+ );
+ }
+ if ( ! empty( $field_descriptors ) ) {
+ $input_side_gates[] = array(
+ 'exec_arg_name' => $param['name'],
+ 'input_fqcn' => $param['input_fqcn'],
+ 'input_short_name' => $input_short,
+ 'fields' => $field_descriptors,
+ );
+ }
+ }
+
// Resolve the attribute-declared authorization. The `attribute_expr` is
// the AND of all autodiscovered authorization attributes' authorize()
// calls (or `'true'` when the only attribute is PublicAccess, or when
@@ -1661,7 +2101,14 @@ class ApiBuilder {
'attribute_expr' => $attribute_expr,
'compute_preauthorized_param_type' => $compute_preauthorized_param_type,
'scalar_return' => $scalar_return,
- 'metadata' => $this->harvest_metadata( $ref, $ref->getShortName() ),
+ // `metadata` feeds the operation's `_apiMetadata` row (discovery, so it
+ // honours `shows_in_metadata_query()`); `metadata_runtime` is published
+ // into `$context['_query_metadata']` for downstream field gates and so
+ // always carries the full set (the opt-out is discovery-only).
+ 'metadata' => $this->is_target_metadata_visible( $ref ) ? $this->harvest_metadata( $ref, $ref->getShortName() ) : array(),
+ 'metadata_runtime' => $this->harvest_metadata( $ref, $ref->getShortName() ),
+ 'input_side_gates' => $input_side_gates,
+ 'authorization_descriptors' => $this->is_target_metadata_visible( $ref ) ? $auth['descriptors'] : array(),
)
);
@@ -2482,7 +2929,7 @@ class ApiBuilder {
* Root-operation arguments (declared as `execute()` parameters) do get
* per-argument metadata — that path runs through {@see self::generate_resolver()}.
*/
- private function build_field_definition( \ReflectionProperty $prop, string $context, array &$use_stmts ): ?array {
+ private function build_field_definition( \ReflectionProperty $prop, string $context, array &$use_stmts, array $type_level_usages = array(), array $type_level_descriptors = array() ): ?array {
$type = $prop->getType();
$type_name = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
$nullable = $type?->allowsNull() ?? false;
@@ -2556,6 +3003,24 @@ class ApiBuilder {
$field_context_label = $prop->getDeclaringClass()->getShortName() . '::$' . $prop->getName();
+ $metadata_visible = $this->is_target_metadata_visible( $prop );
+ $authorization = $this->resolve_field_authorization( $prop, $type_level_usages, $type_level_descriptors );
+ $metadata_full = $this->harvest_metadata( $prop, $field_context_label );
+ $metadata = $metadata_full;
+
+ // Per-target `_apiMetadata` opt-out: when any attribute on the
+ // property declares `shows_in_metadata_query(): false`, the
+ // field's row is omitted entirely. The runtime gate is
+ // unaffected — `attribute_expr` stays populated so the resolver
+ // template still emits the `'resolve'` callback; we only blank
+ // the descriptors and metadata that feed the discovery channel.
+ // `metadata_runtime` keeps the full set: the gate threads it into
+ // `$_metadata['field']`, and the opt-out is discovery-only.
+ if ( ! $metadata_visible ) {
+ $metadata = array();
+ $authorization['descriptors'] = array();
+ }
+
return array(
'name' => $prop->getName(),
'type_expr' => $type_expr,
@@ -2563,7 +3028,9 @@ class ApiBuilder {
'args' => $args,
'deprecation_reason' => ! empty( $deprecation ) ? $deprecation[0]->newInstance()->reason : null,
'paginated_connection' => $paginated_connection,
- 'metadata' => $this->harvest_metadata( $prop, $field_context_label ),
+ 'metadata' => $metadata,
+ 'metadata_runtime' => $metadata_full,
+ 'authorization' => $authorization,
);
}
@@ -2741,6 +3208,20 @@ class ApiBuilder {
return $nullable ? $expr : "Type::nonNull({$expr})";
}
+ // Check for ArrayOf on the method (plain list return). Mirrors the
+ // property-/parameter-level ArrayOf handling: a method declared
+ // `: array` with `#[ArrayOf( X::class )]` becomes `[X!]!` (or `[X!]`
+ // when the return type is nullable). The element expression is
+ // resolved through the same helper the field path uses, so scalar
+ // and object element types are handled identically.
+ $array_of_attr = $method->getAttributes( ArrayOf::class );
+ if ( ! empty( $array_of_attr ) && 'array' === $type_name ) {
+ $item_type = $array_of_attr[0]->newInstance()->type;
+ $item_expr = $this->type_string_to_graphql_expr( $item_type, $use_stmts );
+ $expr = "Type::listOf(Type::nonNull({$item_expr}))";
+ return $nullable ? $expr : "Type::nonNull({$expr})";
+ }
+
// Output type reference.
$class_info = $this->get_class_info( $type_name );
if ( $class_info !== null && $class_info['kind'] === 'type' ) {
diff --git a/plugins/woocommerce/bin/api-builder/code-templates/InputObjectTypeTemplate.php b/plugins/woocommerce/bin/api-builder/code-templates/InputObjectTypeTemplate.php
index 3335b37f6bc..d0d0c3a1336 100644
--- a/plugins/woocommerce/bin/api-builder/code-templates/InputObjectTypeTemplate.php
+++ b/plugins/woocommerce/bin/api-builder/code-templates/InputObjectTypeTemplate.php
@@ -69,6 +69,16 @@ class <?php echo $class_name; ?> {
<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
<?php endforeach; ?>
),
+<?php endif; ?>
+<?php if ( ! empty( $authorization ) ) : ?>
+ 'authorization' => array(
+<?php foreach ( $authorization as $descriptor ) : ?>
+ array(
+ 'attribute' => <?php echo var_export( $descriptor['attribute'], true ); ?>,
+ 'args' => <?php echo var_export( $descriptor['args'], true ); ?>,
+ ),
+<?php endforeach; ?>
+ ),
<?php endif; ?>
'fields' => fn() => array(
<?php foreach ( $fields as $field ) : ?>
diff --git a/plugins/woocommerce/bin/api-builder/code-templates/InterfaceTypeTemplate.php b/plugins/woocommerce/bin/api-builder/code-templates/InterfaceTypeTemplate.php
index c9feb70dd83..1c447962df9 100644
--- a/plugins/woocommerce/bin/api-builder/code-templates/InterfaceTypeTemplate.php
+++ b/plugins/woocommerce/bin/api-builder/code-templates/InterfaceTypeTemplate.php
@@ -70,6 +70,16 @@ class <?php echo $class_name; ?> {
<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
<?php endforeach; ?>
),
+<?php endif; ?>
+<?php if ( ! empty( $authorization ) ) : ?>
+ 'authorization' => array(
+<?php foreach ( $authorization as $descriptor ) : ?>
+ array(
+ 'attribute' => <?php echo var_export( $descriptor['attribute'], true ); ?>,
+ 'args' => <?php echo var_export( $descriptor['args'], true ); ?>,
+ ),
+<?php endforeach; ?>
+ ),
<?php endif; ?>
'fields' => fn() => array(
<?php foreach ( $fields as $field ) : ?>
diff --git a/plugins/woocommerce/bin/api-builder/code-templates/ObjectTypeTemplate.php b/plugins/woocommerce/bin/api-builder/code-templates/ObjectTypeTemplate.php
index 2d405157b73..75fd86a85bb 100644
--- a/plugins/woocommerce/bin/api-builder/code-templates/ObjectTypeTemplate.php
+++ b/plugins/woocommerce/bin/api-builder/code-templates/ObjectTypeTemplate.php
@@ -8,8 +8,9 @@
* @var string $description
* @var array $use_statements
* @var array $interfaces - each: ['alias' => string]
- * @var array $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string, 'paginated_connection' => bool, 'metadata' => array]
- * @var array $metadata - type-level metadata, name => scalar value.
+ * @var array $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string, 'paginated_connection' => bool, 'metadata' => array, 'metadata_runtime' => array]
+ * @var array $metadata - type-level metadata for discovery (`_apiMetadata`); blank when the type opts out via shows_in_metadata_query().
+ * @var array $metadata_runtime - full type-level metadata, threaded into field gates' $_metadata['type'] slice regardless of discovery opt-out.
*/
$escaped_description = addslashes( $description );
@@ -24,18 +25,24 @@ namespace <?php echo $namespace; ?>;
<?php
$has_paginated_connection = false;
+$has_authorized_field = false;
foreach ( $fields as $f ) {
if ( ! empty( $f['paginated_connection'] ) ) {
$has_paginated_connection = true;
- break;
+ }
+ if ( ! empty( $f['authorization']['attribute_expr'] ) && 'true' !== $f['authorization']['attribute_expr'] ) {
+ $has_authorized_field = true;
}
}
+$needs_utils_import = $has_paginated_connection || $has_authorized_field;
// Drop any caller-supplied import whose effective short name would collide
// with one of the hardcoded imports emitted below, otherwise the generated
// file wouldn't compile ("Cannot use ... because the name is already in use").
$reserved_short_names = array( 'ObjectType', 'Type' );
if ( $has_paginated_connection ) {
$reserved_short_names[] = 'Connection';
+}
+if ( $needs_utils_import ) {
$reserved_short_names[] = 'ResolverHelpers';
}
// PHP class-name resolution (including `use`) is case-insensitive, so the
@@ -61,8 +68,10 @@ $use_statements = array_values(
<?php foreach ( $use_statements as $use ) : ?>
use <?php echo $use; ?>;
<?php endforeach; ?>
-<?php if ( $has_paginated_connection ) : ?>
+<?php if ( $needs_utils_import ) : ?>
use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+<?php endif; ?>
+<?php if ( $has_paginated_connection ) : ?>
use Automattic\WooCommerce\Api\Pagination\Connection;
<?php endif; ?>
use Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType;
@@ -86,6 +95,16 @@ class <?php echo $class_name; ?> {
<?php endforeach; ?>
),
<?php endif; ?>
+<?php if ( ! empty( $authorization ) ) : ?>
+ 'authorization' => array(
+<?php foreach ( $authorization as $descriptor ) : ?>
+ array(
+ 'attribute' => <?php echo var_export( $descriptor['attribute'], true ); ?>,
+ 'args' => <?php echo var_export( $descriptor['args'], true ); ?>,
+ ),
+<?php endforeach; ?>
+ ),
+<?php endif; ?>
<?php if ( ! empty( $interfaces ) ) : ?>
'interfaces' => fn() => array(
<?php foreach ( $interfaces as $iface ) : ?>
@@ -106,6 +125,16 @@ class <?php echo $class_name; ?> {
<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
<?php endforeach; ?>
),
+<?php endif; ?>
+ <?php if ( ! empty( $field['authorization']['descriptors'] ) ) : ?>
+ 'authorization' => array(
+ <?php foreach ( $field['authorization']['descriptors'] as $descriptor ) : ?>
+ array(
+ 'attribute' => <?php echo var_export( $descriptor['attribute'], true ); ?>,
+ 'args' => <?php echo var_export( $descriptor['args'], true ); ?>,
+ ),
+<?php endforeach; ?>
+ ),
<?php endif; ?>
<?php if ( ! empty( $field['args'] ) ) : ?>
'args' => array(
@@ -132,8 +161,35 @@ class <?php echo $class_name; ?> {
<?php if ( ! empty( $field['deprecation_reason'] ) ) : ?>
'deprecationReason' => '<?php echo addslashes( $field['deprecation_reason'] ); ?>',
<?php endif; ?>
- <?php if ( ! empty( $field['paginated_connection'] ) ) : ?>
+ <?php
+ $has_field_auth = ! empty( $field['authorization']['attribute_expr'] ) && 'true' !== $field['authorization']['attribute_expr'];
+ $is_paginated = ! empty( $field['paginated_connection'] );
+ $field_metadata_expr = var_export( $field['metadata_runtime'], true );
+ $type_metadata_expr = var_export( $metadata_runtime, true );
+ ?>
+ <?php if ( $is_paginated ) : ?>
'complexity' => ResolverHelpers::complexity_from_pagination(...),
+<?php endif; ?>
+ <?php if ( $has_field_auth ) : ?>
+ 'resolve' => function( $parent, $args, $context ) {
+ $principal = $context['principal'];
+ $_metadata = array(
+ 'query' => $context['_query_metadata'] ?? array(),
+ 'type' => <?php echo $type_metadata_expr; ?>,
+ 'field' => <?php echo $field_metadata_expr; ?>,
+ );
+ $_args = $args;
+ $_parent = $parent;
+ if ( ! ( <?php echo $field['authorization']['attribute_expr']; ?> ) ) {
+ throw ResolverHelpers::build_field_authorization_error( $principal, '<?php echo $graphql_name; ?>', '<?php echo $field['name']; ?>', '<?php echo $field['authorization']['first_attribute_short']; ?>' );
+ }
+ <?php if ( $is_paginated ) : ?>
+ return ResolverHelpers::translate_exceptions( fn() => $parent-><?php echo $field['name']; ?>->slice( $args ) );
+<?php else : ?>
+ return $parent-><?php echo $field['name']; ?>;
+<?php endif; ?>
+ },
+ <?php elseif ( $is_paginated ) : ?>
'resolve' => fn( $parent, array $args ): Connection => ResolverHelpers::translate_exceptions( fn() => $parent-><?php echo $field['name']; ?>->slice( $args ) ),
<?php endif; ?>
),
diff --git a/plugins/woocommerce/bin/api-builder/code-templates/QueryResolverTemplate.php b/plugins/woocommerce/bin/api-builder/code-templates/QueryResolverTemplate.php
index d304f6635bd..b758ab65e44 100644
--- a/plugins/woocommerce/bin/api-builder/code-templates/QueryResolverTemplate.php
+++ b/plugins/woocommerce/bin/api-builder/code-templates/QueryResolverTemplate.php
@@ -11,7 +11,8 @@
* @var string $return_type_expr
* @var array $use_statements
* @var array $args - each: ['name', 'type_expr', 'description', 'has_default', 'default', 'metadata' => array]
- * @var array $metadata - root-field-level metadata, name => scalar value.
+ * @var array $metadata - root-field-level metadata for discovery (`_apiMetadata`); blank when the operation opts out via shows_in_metadata_query().
+ * @var array $metadata_runtime - full root-field-level metadata, published into $context['_query_metadata'] for downstream field gates regardless of discovery opt-out.
* @var bool $has_connection_of
* @var string $connection_type_alias
* @var bool $standalone_attribute_check - true when authorize() is absent and the attribute_expr is the sole authorization gate
@@ -99,6 +100,16 @@ class <?php echo $class_name; ?> {
<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
<?php endforeach; ?>
),
+<?php endif; ?>
+<?php if ( ! empty( $authorization_descriptors ) ) : ?>
+ 'authorization' => array(
+<?php foreach ( $authorization_descriptors as $descriptor ) : ?>
+ array(
+ 'attribute' => <?php echo var_export( $descriptor['attribute'], true ); ?>,
+ 'args' => <?php echo var_export( $descriptor['args'], true ); ?>,
+ ),
+<?php endforeach; ?>
+ ),
<?php endif; ?>
'args' => array(
<?php foreach ( $args as $arg ) : ?>
@@ -136,6 +147,13 @@ class <?php echo $class_name; ?> {
}
<?php endif; ?>
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = <?php echo var_export( $metadata_runtime, true ); ?>;
+
+
<?php if ( null !== $class_resolver_fqcn ) : ?>
$command = \<?php echo $class_resolver_fqcn; ?>::resolve_class( <?php echo $command_alias; ?>::class );
<?php else : ?>
@@ -175,6 +193,26 @@ foreach ( $execute_params as $param ) :
<?php endif; ?>
<?php endforeach; ?>
+<?php foreach ( $input_side_gates as $gate_set ) : ?>
+ if ( isset( $execute_args['<?php echo $gate_set['exec_arg_name']; ?>'] ) && $execute_args['<?php echo $gate_set['exec_arg_name']; ?>'] instanceof \<?php echo $gate_set['input_fqcn']; ?> ) {
+ $_parent = $execute_args['<?php echo $gate_set['exec_arg_name']; ?>'];
+ <?php foreach ( $gate_set['fields'] as $field_gate ) : ?>
+ if ( $_parent->was_provided( '<?php echo $field_gate['field_name']; ?>' ) ) {
+ $principal = $context['principal'];
+ $_metadata = array(
+ 'query' => $context['_query_metadata'] ?? array(),
+ 'type' => <?php echo $field_gate['type_metadata_literal']; ?>,
+ 'field' => <?php echo $field_gate['field_metadata_literal']; ?>,
+ );
+ $_args = $args;
+ if ( ! ( <?php echo $field_gate['attribute_expr']; ?> ) ) {
+ throw ResolverHelpers::build_field_authorization_error( $principal, '<?php echo $gate_set['input_short_name']; ?>', '<?php echo $field_gate['field_name']; ?>', '<?php echo $field_gate['first_attribute_short']; ?>' );
+ }
+ }
+ <?php endforeach; ?>
+ }
+<?php endforeach; ?>
+
<?php if ( $has_authorize ) : ?>
if ( ! ResolverHelpers::authorize_command( $command, array(
<?php foreach ( $authorize_param_names as $name ) : ?>
diff --git a/plugins/woocommerce/changelog/add-graphql-granular-authorization b/plugins/woocommerce/changelog/add-graphql-granular-authorization
new file mode 100644
index 00000000000..5543e074d0b
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-graphql-granular-authorization
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add granular type- and field-level authorization to the dual code+GraphQL API
diff --git a/plugins/woocommerce/changelog/fix-graphql-arrayof-return-type b/plugins/woocommerce/changelog/fix-graphql-arrayof-return-type
new file mode 100644
index 00000000000..1b29ce19d7b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-graphql-arrayof-return-type
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Honor `#[ArrayOf]` on query and mutation return types in the GraphQL API builder
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Api/Attributes/HiddenFromMetadataQuery.php b/plugins/woocommerce/src/Api/Attributes/HiddenFromMetadataQuery.php
new file mode 100644
index 00000000000..d7491f6e608
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/HiddenFromMetadataQuery.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Marker attribute that opts a code-API target out of the `_apiMetadata`
+ * discovery query without affecting any other behaviour. Apply on a
+ * class (output / input type, query, mutation) or on a property to hide
+ * that target's row — and all its metadata / authorization descriptors —
+ * from the `_apiMetadata` endpoint.
+ *
+ * This is unrelated to native GraphQL introspection (`__schema` /
+ * `__type`); those queries continue to expose the schema's shape as
+ * usual. The marker only affects the custom `_apiMetadata` channel.
+ *
+ * The runtime authorization gates emitted into the generated resolvers
+ * are unaffected: an authorization attribute placed alongside this one
+ * still runs its `authorize()` method; this marker just removes the
+ * declarative shape from the discovery channel.
+ *
+ * A target's `_apiMetadata` visibility is the AND of every attribute's
+ * `shows_in_metadata_query()` on the target — so combining
+ * `#[HiddenFromMetadataQuery]` with any other attribute that returns
+ * `true` (or none at all) still hides the target.
+ */
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::TARGET_CLASS_CONSTANT )]
+final class HiddenFromMetadataQuery {
+ /**
+ * Always returns `false`. ApiBuilder calls this during the per-target
+ * `_apiMetadata` visibility check; the target is omitted from the
+ * discovery output as a result.
+ */
+ public function shows_in_metadata_query(): bool {
+ return false;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Metadata.php b/plugins/woocommerce/src/Api/Attributes/Metadata.php
index 46595fe5e19..39a548137a1 100644
--- a/plugins/woocommerce/src/Api/Attributes/Metadata.php
+++ b/plugins/woocommerce/src/Api/Attributes/Metadata.php
@@ -50,6 +50,28 @@ class Metadata {
return $this->value;
}
+ /**
+ * Whether the element carrying this attribute should appear in the
+ * `_apiMetadata` discovery query.
+ *
+ * Returning `false` removes the element's row entirely from
+ * `_apiMetadata` — neither this metadata entry nor any other
+ * descriptor on the same target surfaces. The runtime gates and any
+ * description transforms are unaffected. Useful for plugins that
+ * attach internal routing or feature hints they prefer not to
+ * broadcast through the discovery channel.
+ *
+ * Despite the colloquial naming around it, this has nothing to do
+ * with native GraphQL introspection (`__schema` / `__type`); those
+ * queries continue to expose the schema's shape as usual.
+ *
+ * Because this is an instance method, subclasses can decide
+ * conditionally based on their own constructor arguments.
+ */
+ public function shows_in_metadata_query(): bool {
+ return true;
+ }
+
/**
* Transform the GraphQL `description` of the element this attribute is
* applied to.
diff --git a/plugins/woocommerce/src/Api/Attributes/PublicAccess.php b/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
index 1f10cd5dd5d..7c59267934f 100644
--- a/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
+++ b/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
@@ -10,9 +10,14 @@ use Attribute;
* Marks a query or mutation as publicly accessible without authentication.
*
* Mutually exclusive with #[RequiredCapability] (and any other authorization
- * attribute) on the same class.
+ * attribute) on the same class — this is a hard build error.
+ *
+ * Placement on a property (output field or input field) is accepted but is
+ * a build warning and a runtime no-op: it always grants, which is
+ * indistinguishable from the default allow-by-default semantics for fields
+ * that carry no authorization attribute.
*/
-#[Attribute( Attribute::TARGET_CLASS )]
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY )]
final class PublicAccess {
/**
* Always grants access.
diff --git a/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php b/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
index d229d5df9bf..5668bbbc8c8 100644
--- a/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
+++ b/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
@@ -8,14 +8,22 @@ use Attribute;
use Automattic\WooCommerce\Api\Infrastructure\Principal;
/**
- * Declares a WordPress capability required to execute a query or mutation.
+ * Declares a WordPress capability required to execute a query or mutation,
+ * or to read an output field, or to set an input field on a mutation.
*
* This attribute is repeatable: apply it multiple times to require several
* capabilities (logical AND).
*
- * Mutually exclusive with #[PublicAccess] on the same class.
+ * Mutually exclusive with #[PublicAccess] at the class level. At the field
+ * level a `#[PublicAccess]` placement on the same property is a build
+ * warning and is treated as a no-op.
+ *
+ * Targets: class (query/mutation/output type) and property (output field,
+ * input field, or trait-declared property). Trait-declared properties
+ * carry the attribute onto every implementing class through PHP's
+ * reflection.
*/
-#[Attribute( Attribute::IS_REPEATABLE | Attribute::TARGET_CLASS )]
+#[Attribute( Attribute::IS_REPEATABLE | Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY )]
final class RequiredCapability {
/**
* Constructor.
diff --git a/plugins/woocommerce/src/Api/Infrastructure/GraphQLControllerBase.php b/plugins/woocommerce/src/Api/Infrastructure/GraphQLControllerBase.php
index 080193abcef..a44a12331d9 100644
--- a/plugins/woocommerce/src/Api/Infrastructure/GraphQLControllerBase.php
+++ b/plugins/woocommerce/src/Api/Infrastructure/GraphQLControllerBase.php
@@ -424,17 +424,24 @@ abstract class GraphQLControllerBase {
$validation_rules[] = new DisableIntrospection( DisableIntrospection::ENABLED );
}
- // 7. Execute. The context value carries the resolved principal through
- // to autogenerated resolvers, which expose it as the `_principal`
- // infrastructure parameter when commands declare it on their
- // authorize()/execute() methods. Request-derived data that resolvers
- // need is carried by the principal class itself — populated by the
- // PrincipalResolver, the only component wired to the HTTP transport.
+ // 7. Execute. The context value is an ArrayObject (not a plain array)
+ // so root resolvers can mutate it — specifically to thread the root
+ // query's metadata into `$context['_query_metadata']` for downstream
+ // field-level authorization gates. ArrayObject preserves the
+ // `$context['key']` read syntax via ArrayAccess. The context carries
+ // the resolved principal through to autogenerated resolvers, which
+ // expose it as the `_principal` infrastructure parameter when commands
+ // declare it on their authorize()/execute() methods. Request-derived
+ // data that resolvers need is carried by the principal class itself —
+ // populated by the PrincipalResolver, the only component wired to the
+ // HTTP transport.
$result = GraphQL::executeQuery(
schema: $schema,
source: $source,
- contextValue: array(
- 'principal' => $principal,
+ contextValue: new \ArrayObject(
+ array(
+ 'principal' => $principal,
+ )
),
variableValues: $variables,
operationName: $operation_name,
diff --git a/plugins/woocommerce/src/Api/Infrastructure/MetadataController.php b/plugins/woocommerce/src/Api/Infrastructure/MetadataController.php
index 6a2d9d5d7e8..960ab3f5c2a 100644
--- a/plugins/woocommerce/src/Api/Infrastructure/MetadataController.php
+++ b/plugins/woocommerce/src/Api/Infrastructure/MetadataController.php
@@ -17,16 +17,21 @@ use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\NullValueNode;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\StringValueNode;
/**
- * Hand-written controller that contributes the `_apiMetadata` root query field
- * and the supporting `MetadataEntry`, `MetadataTarget`, and `MetadataValue`
- * types to the generated schema.
+ * Hand-written controller that contributes the `_apiMetadata` root query
+ * field and the supporting `MetadataEntry`, `MetadataTarget`, `MetadataValue`
+ * and `AuthEntry` types to the generated schema.
*
* The autogenerated `RootQueryType` references this controller alongside the
* autogenerated query resolvers, so the field appears on the root `Query`
* type without any special wiring at the controller level. The resolver
* delegates to {@see SchemaHandle::find_metadata()} for the schema walk and
* filter application, then reshapes the rows so each entry is exposed as the
- * `{ name, value }` pair that `MetadataEntry` expects.
+ * `{ name, value }` pair that `MetadataEntry` expects. Authorization
+ * descriptors (each row's `authorization` list) pass through unchanged.
+ *
+ * Access is gated by {@see self::can_query_metadata()}; once allowed, the
+ * returned content is principal-independent — the full declared shape of the
+ * schema, irrespective of who is calling.
*/
class MetadataController {
/**
@@ -50,6 +55,14 @@ class MetadataController {
*/
private static ?ObjectType $target_type = null;
+ /**
+ * Memoised `AuthEntry` output type — describes one authorization
+ * attribute attached to a schema target.
+ *
+ * @var ?ObjectType
+ */
+ private static ?ObjectType $auth_entry_type = null;
+
/**
* GraphQL field name used on the root `Query` type.
*/
@@ -70,18 +83,22 @@ class MetadataController {
'woocommerce'
),
'args' => array(
- 'name' => array(
+ 'name' => array(
'type' => Type::string(),
'description' => __( 'Match rows that carry a metadata entry with this name. Surviving rows have their entries trimmed to the matching one.', 'woocommerce' ),
),
- 'type' => array(
+ 'type' => array(
'type' => Type::string(),
'description' => __( 'Match rows whose target type equals this name.', 'woocommerce' ),
),
- 'field' => array(
+ 'field' => array(
'type' => Type::string(),
'description' => __( 'Match rows whose target field equals this name.', 'woocommerce' ),
),
+ 'attribute' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'Match rows whose authorization carries an attribute with this class short name. Surviving rows have their authorization trimmed to the matching descriptors.', 'woocommerce' ),
+ ),
),
'resolve' => array( self::class, 'resolve' ),
);
@@ -89,17 +106,25 @@ class MetadataController {
/**
* Resolver for the `_apiMetadata` root field. Signature matches the
- * engine's resolver contract; `$root` and `$context` are unused here
- * (root operations have no parent, and metadata is principal-independent).
+ * engine's resolver contract; `$root` is unused here (root operations
+ * have no parent). `$context` is read for the principal so the
+ * `can_query_metadata` ladder can run.
*
* @param ?array $root The engine passes null for root resolvers.
- * @param array $args GraphQL arguments (`name`, `type`, `field`).
- * @param array $context Per-request context the controller builds.
+ * @param array $args GraphQL arguments (`name`, `type`, `field`, `attribute`).
+ * @param mixed $context Per-request context — an ArrayObject wrapping {`principal`, `_query_metadata`}.
* @param ResolveInfo $info Carries the schema instance to walk.
* @return list<array<string, mixed>>
+ * @throws Error When the principal is not allowed to query `_apiMetadata`.
*/
- public static function resolve( ?array $root, array $args, array $context, ResolveInfo $info ): array {
- unset( $root, $context );
+ public static function resolve( ?array $root, array $args, mixed $context, ResolveInfo $info ): array {
+ unset( $root );
+
+ $principal = is_object( $context ) || is_array( $context ) ? ( $context['principal'] ?? null ) : null;
+ if ( ! self::can_query_metadata( $principal ) ) {
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Static error message + machine code; serialized as JSON, not HTML.
+ throw self::build_metadata_query_authorization_error( $principal );
+ }
// Wrap the resolver's engine-typed schema into the same handle clients
// receive from `GraphQLControllerBase::get_schema()`, so the resolver and
@@ -110,6 +135,7 @@ class MetadataController {
$args['name'] ?? null,
$args['type'] ?? null,
$args['field'] ?? null,
+ $args['attribute'] ?? null,
);
// SchemaHandle returns entries as an associative `name => value` map,
@@ -133,6 +159,91 @@ class MetadataController {
);
}
+ /**
+ * Whether the principal may run the `_apiMetadata` query.
+ *
+ * Tri-tier ladder, deliberately fail-closed:
+ *
+ * 1. If the principal declares `can_query_metadata(): bool`, use it.
+ * Plugins distinguish metadata-query access from native
+ * introspection access by declaring this method.
+ * 2. Else if the principal declares `can_introspect(): bool`, fall
+ * back to it — one switch then gates both metadata and
+ * introspection, which is the common case.
+ * 3. Else (neither method declared) deny. Plugin authors that don't
+ * opt their principal in get a locked-down endpoint rather than
+ * leaking schema shape and gate descriptors by default.
+ *
+ * The principal-derived decision is then passed through the
+ * {@see 'woocommerce_graphql_can_query_metadata'} filter so sites
+ * can grant or revoke access without subclassing the principal —
+ * useful for per-request rules (specific IPs, headers, query
+ * parameters, etc.).
+ *
+ * Fail-closed contract: null principal denies before the filter is
+ * consulted; either method's return is checked with `=== true`; any
+ * throw from the principal method or the filter denies; the filter
+ * must likewise return strictly `true` to allow.
+ *
+ * @param ?object $principal The resolved principal, or null when principal resolution failed.
+ */
+ private static function can_query_metadata( ?object $principal ): bool {
+ if ( null === $principal ) {
+ return false;
+ }
+
+ try {
+ if ( method_exists( $principal, 'can_query_metadata' ) ) {
+ $allowed = true === $principal->can_query_metadata();
+ } elseif ( method_exists( $principal, 'can_introspect' ) ) {
+ $allowed = true === $principal->can_introspect();
+ } else {
+ $allowed = false;
+ }
+
+ /**
+ * Filters whether the current principal may run the `_apiMetadata` query.
+ *
+ * The filter receives the principal-derived decision (see the tri-tier
+ * ladder in {@see MetadataController::can_query_metadata()}) and must
+ * return strictly `true` to grant access; any other return value
+ * denies. The filter is not invoked when principal resolution failed
+ * (i.e. when the resolver receives a null principal) — that case
+ * denies outright.
+ *
+ * @since 10.9.0
+ *
+ * @internal
+ *
+ * @param bool $allowed Whether the principal may query `_apiMetadata`.
+ * @param object $principal The resolved principal.
+ */
+ $allowed = apply_filters( 'woocommerce_graphql_can_query_metadata', $allowed, $principal );
+ } catch ( \Throwable $e ) {
+ return false;
+ }
+
+ return true === $allowed;
+ }
+
+ /**
+ * Build the GraphQL error thrown when `_apiMetadata` is queried by a
+ * principal that cannot. Mirrors
+ * {@see ResolverHelpers::build_authorization_error()}'s
+ * UNAUTHORIZED / FORBIDDEN distinction so clients can branch on
+ * `extensions.code` the same way they do for field-level denies.
+ *
+ * @param ?object $principal The resolved principal (null when principal resolution failed).
+ */
+ private static function build_metadata_query_authorization_error( ?object $principal ): Error {
+ $is_anonymous = null === $principal
+ || ( method_exists( $principal, 'is_authenticated' ) && ! $principal->is_authenticated() );
+ return new Error(
+ $is_anonymous ? 'Authentication required.' : 'You do not have permission to perform this action.',
+ extensions: array( 'code' => $is_anonymous ? 'UNAUTHORIZED' : 'FORBIDDEN' )
+ );
+ }
+
/**
* The `MetadataTarget` output type, lazily built and cached.
*/
@@ -146,26 +257,30 @@ class MetadataController {
'woocommerce'
),
'fields' => fn() => array(
- 'type' => array(
+ 'type' => array(
'type' => Type::nonNull( Type::string() ),
'description' => __( 'Name of the GraphQL type this row describes.', 'woocommerce' ),
),
- 'field' => array(
+ 'field' => array(
'type' => Type::string(),
'description' => __( 'Field name when this row describes a field (or a field argument); null for type-level rows.', 'woocommerce' ),
),
- 'argument' => array(
+ 'argument' => array(
'type' => Type::string(),
'description' => __( 'Argument name when this row describes a field argument; null otherwise.', 'woocommerce' ),
),
- 'enumValue' => array(
+ 'enumValue' => array(
'type' => Type::string(),
'description' => __( 'Enum value name when this row describes one specific enum value; null otherwise.', 'woocommerce' ),
),
- 'entries' => array(
+ 'entries' => array(
'type' => Type::nonNull( Type::listOf( Type::nonNull( self::get_entry_type() ) ) ),
'description' => __( 'Metadata entries attached to the target.', 'woocommerce' ),
),
+ 'authorization' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( self::get_auth_entry_type() ) ) ),
+ 'description' => __( 'Authorization attributes attached to the target (e.g. `RequiredCapability`, `PublicAccess`, or plugin-defined). Empty when the target carries no authorization attributes.', 'woocommerce' ),
+ ),
),
)
);
@@ -173,6 +288,33 @@ class MetadataController {
return self::$target_type;
}
+ /**
+ * The `AuthEntry` output type — one authorization attribute attached
+ * to a target. Carries the attribute's short class name and the
+ * scalar args supplied at the usage site.
+ */
+ private static function get_auth_entry_type(): ObjectType {
+ if ( null === self::$auth_entry_type ) {
+ self::$auth_entry_type = new ObjectType(
+ array(
+ 'name' => 'AuthEntry',
+ 'description' => __( 'One authorization attribute attached to a schema target.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'attribute' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'Short class name of the authorization attribute (e.g. `RequiredCapability`).', 'woocommerce' ),
+ ),
+ 'args' => array(
+ 'type' => Type::nonNull( Type::listOf( self::get_value_scalar() ) ),
+ 'description' => __( 'Constructor arguments supplied at the usage site, in source order. Element type is the same scalar union as `MetadataValue`.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$auth_entry_type;
+ }
+
/**
* The `MetadataEntry` output type, lazily built and cached.
*/
diff --git a/plugins/woocommerce/src/Api/Infrastructure/ResolverHelpers.php b/plugins/woocommerce/src/Api/Infrastructure/ResolverHelpers.php
index c6dab50f503..8516cae5c3c 100644
--- a/plugins/woocommerce/src/Api/Infrastructure/ResolverHelpers.php
+++ b/plugins/woocommerce/src/Api/Infrastructure/ResolverHelpers.php
@@ -139,6 +139,11 @@ class ResolverHelpers {
* that don't define it fall through to FORBIDDEN — generated resolvers
* still emit a coded error, just without the 401/403 distinction.
*
+ * Used for class-level denials (operation-level "you cannot call this
+ * query/mutation"). For field-level denials that should carry a
+ * structured `subject` payload (type / field / attribute), see
+ * {@see self::build_field_authorization_error()}.
+ *
* @param object $principal The resolved request principal.
*/
public static function build_authorization_error( object $principal ): Error {
@@ -149,6 +154,40 @@ class ResolverHelpers {
);
}
+ /**
+ * Like {@see self::build_authorization_error()} but carries a structured
+ * `subject` payload identifying *what* was denied — the enclosing type,
+ * the field (when applicable), and the attribute class name driving the
+ * decision. Clients can branch on `extensions.subject.field` to tell a
+ * field-level deny apart from an operation-level one.
+ *
+ * The error code (UNAUTHORIZED / FORBIDDEN) is preserved verbatim so
+ * existing client handlers continue to work; the subject payload is
+ * additive.
+ *
+ * @param object $principal The resolved request principal.
+ * @param string $type GraphQL type name carrying the gate.
+ * @param ?string $field Field name when the deny is field-level; null for type/operation-level denies.
+ * @param string $attribute_short Short class name of the deciding authorization attribute (no namespace).
+ */
+ public static function build_field_authorization_error( object $principal, string $type, ?string $field, string $attribute_short ): Error {
+ $is_anonymous = method_exists( $principal, 'is_authenticated' ) && ! $principal->is_authenticated();
+ $subject = array(
+ 'type' => $type,
+ 'attribute' => $attribute_short,
+ );
+ if ( null !== $field ) {
+ $subject['field'] = $field;
+ }
+ return new Error(
+ $is_anonymous ? 'Authentication required.' : 'You do not have permission to perform this action.',
+ extensions: array(
+ 'code' => $is_anonymous ? 'UNAUTHORIZED' : 'FORBIDDEN',
+ 'subject' => $subject,
+ )
+ );
+ }
+
/**
* Compute the value `_preauthorized` would carry for the given command and
* principal (the AND of the autodiscovered authorization attributes'
@@ -168,6 +207,12 @@ class ResolverHelpers {
* `_preauthorized` infrastructure parameter; this helper returns the value
* that `_preauthorized` would carry, not the final `authorize()` outcome.
*
+ * Scope is class-level (queries / mutations). Field-level authorization
+ * lives on output-type / input-type properties and is enforced inside
+ * the generated resolvers. To inspect a field's declared authorization
+ * from code, walk {@see \Automattic\WooCommerce\Api\Utils\SchemaHandle::find_metadata()}
+ * and read the `authorization` slice on each row.
+ *
* @param string $command_fqcn Fully-qualified command class name.
* @param object $principal The resolved principal. Anonymous requests are represented by a sentinel principal (e.g. {@see \Automattic\WooCommerce\Api\Infrastructure\Principal} whose underlying WP_User has ID=0), not by null.
*
@@ -210,11 +255,18 @@ class ResolverHelpers {
}
}
+ $query_metadata = self::harvest_class_metadata( $ref );
+
foreach ( $usages as $instance ) {
$auth_method = new \ReflectionMethod( $instance, 'authorize' );
- $result = $auth_method->getNumberOfParameters() > 0
- ? $instance->authorize( $principal )
- : $instance->authorize();
+ $call_args = self::build_authorize_call_args(
+ $auth_method,
+ $principal,
+ array( 'query' => $query_metadata ),
+ array(),
+ null
+ );
+ $result = $instance->authorize( ...$call_args );
if ( ! $result ) {
return false;
}
@@ -222,6 +274,67 @@ class ResolverHelpers {
return true;
}
+ /**
+ * Mirror of `ApiBuilder::harvest_metadata()` for the runtime path. Walks
+ * {@see \Automattic\WooCommerce\Api\Attributes\Metadata}-subclass attributes
+ * on a class reflector and returns `name => value`. Duplicate names are
+ * resolved last-wins — the build-time validator already errors on
+ * duplicates, so this is only relevant for in-process classes that
+ * never went through a build.
+ *
+ * The per-target `_apiMetadata` opt-out (`shows_in_metadata_query()`)
+ * is not applied here: the `$_metadata` slot threaded into a class-
+ * level attribute's `authorize()` is for policy input, not discovery,
+ * so attribute authors see every entry regardless of how it surfaces
+ * through `_apiMetadata`.
+ *
+ * @param \ReflectionClass $ref The class to read metadata from.
+ * @return array<string, bool|int|float|string|null>
+ */
+ private static function harvest_class_metadata( \ReflectionClass $ref ): array {
+ $entries = array();
+ foreach ( $ref->getAttributes( \Automattic\WooCommerce\Api\Attributes\Metadata::class, \ReflectionAttribute::IS_INSTANCEOF ) as $attribute ) {
+ $instance = $attribute->newInstance();
+ $entries[ $instance->get_name() ] = $instance->get_value();
+ }
+ return $entries;
+ }
+
+ /**
+ * Build the positional/named argument list for an attribute's `authorize()`
+ * method based on which opt-in slots its signature declares.
+ *
+ * The principal is always passed first (positionally) when the method
+ * declares a non-`_`-prefixed parameter; infrastructure parameters
+ * (`$_metadata`, `$_args`, `$_parent`) are passed as named arguments so
+ * the attribute can omit any subset without affecting the call shape.
+ *
+ * @param \ReflectionMethod $method The attribute's `authorize()` method.
+ * @param object $principal The resolved principal to pass when the method takes one.
+ * @param array $metadata Value for `$_metadata` (passed if the method declares it).
+ * @param array $args Value for `$_args` (passed if the method declares it).
+ * @param mixed $parent Value for `$_parent` (passed if the method declares it).
+ *
+ * @return array<int|string, mixed> Positional principal first (if any), then named infra slots. Use with `...` spread.
+ */
+ private static function build_authorize_call_args( \ReflectionMethod $method, object $principal, array $metadata, array $args, mixed $parent ): array {
+ $call_args = array();
+ foreach ( $method->getParameters() as $param ) {
+ $name = $param->getName();
+ if ( '_metadata' === $name ) {
+ $call_args['_metadata'] = $metadata;
+ } elseif ( '_args' === $name ) {
+ $call_args['_args'] = $args;
+ } elseif ( '_parent' === $name ) {
+ $call_args['_parent'] = $parent;
+ } elseif ( '' === $name || '_' !== $name[0] ) {
+ // Principal — positional, must be the first entry in the spread.
+ array_unshift( $call_args, $principal );
+ }
+ }
+ return $call_args;
+ }
+
/**
* Collect attribute instances declared on $source whose class declares an
* authorization-shaped `authorize()` method.
@@ -252,9 +365,10 @@ class ResolverHelpers {
/**
* Whether a method's shape matches the authorization-attribute contract:
- * public, non-static, returns bool, and takes either 0 parameters or
- * exactly 1 typed, non-nullable parameter (the principal — anonymous
- * requests use a sentinel non-null principal, so attributes never see null).
+ * public, non-static, returns bool, and parameters drawn from the accepted
+ * set — at most one principal (any non-`_`-prefixed name, non-nullable
+ * typed) plus any subset of `$_metadata` (array), `$_args` (array), and
+ * `$_parent` (any type).
*
* Mirrors the build-time `ApiBuilder::validate_attribute_authorize_shape()`
* check so the runtime helper recognises the same set of attributes ApiBuilder
@@ -270,15 +384,34 @@ class ResolverHelpers {
if ( ! $return_type instanceof \ReflectionNamedType || 'bool' !== $return_type->getName() ) {
return false;
}
- $params = $method->getParameters();
- if ( count( $params ) > 1 ) {
- return false;
- }
- if ( 0 === count( $params ) ) {
- return true;
+
+ $principal_seen = false;
+ foreach ( $method->getParameters() as $param ) {
+ $name = $param->getName();
+ if ( '_metadata' === $name || '_args' === $name ) {
+ $type = $param->getType();
+ if ( ! $type instanceof \ReflectionNamedType || 'array' !== $type->getName() ) {
+ return false;
+ }
+ continue;
+ }
+ if ( '_parent' === $name ) {
+ continue;
+ }
+ if ( '' !== $name && '_' === $name[0] ) {
+ // Unknown infra parameter — reject.
+ return false;
+ }
+ if ( $principal_seen ) {
+ return false;
+ }
+ $type = $param->getType();
+ if ( ! $type instanceof \ReflectionNamedType || $type->allowsNull() ) {
+ return false;
+ }
+ $principal_seen = true;
}
- $param_type = $params[0]->getType();
- return $param_type instanceof \ReflectionNamedType && ! $param_type->allowsNull();
+ return true;
}
/**
diff --git a/plugins/woocommerce/src/Api/Utils/SchemaHandle.php b/plugins/woocommerce/src/Api/Utils/SchemaHandle.php
index 0df691c4b2e..9bbceb76629 100644
--- a/plugins/woocommerce/src/Api/Utils/SchemaHandle.php
+++ b/plugins/woocommerce/src/Api/Utils/SchemaHandle.php
@@ -28,16 +28,20 @@ use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\Type;
* WooCommerce plugins implementing their own dual API reach a handle through
* their own concrete autogenerated controller the same way.
*
- * The current public surface is the metadata channel: {@see self::get_all_metadata()}
- * returns every metadata row in the schema, {@see self::find_metadata()} applies
- * filter-narrows semantics over the same set.
+ * The current public surface is the discovery channel: {@see self::get_all_metadata()}
+ * returns every row in the schema that carries either `#[Metadata]`-derived
+ * entries or authorization attributes, and {@see self::find_metadata()} applies
+ * filter-narrows semantics (`name`, `type`, `field`, `attribute`) over the same
+ * set. Authorization descriptors are exposed as a parallel `authorization`
+ * slice on each row, alongside the existing `entries`.
*
* @phpstan-type MetadataRow array{
* type: string,
* field: ?string,
* argument: ?string,
* enumValue: ?string,
- * entries: array<string, bool|int|float|string|null>
+ * entries: array<string, bool|int|float|string|null>,
+ * authorization: list<array{attribute: string, args: list<mixed>}>
* }
*/
final class SchemaHandle {
@@ -70,7 +74,7 @@ final class SchemaHandle {
* same row shape is used for every target kind; the three nullable
* position fields (`field`, `argument`, `enumValue`) discriminate.
*
- * @return list<array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>}>
+ * @return list<array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>, authorization: list<array{attribute: string, args: list<mixed>}>}>
*/
public function get_all_metadata(): array {
$rows = array();
@@ -81,22 +85,24 @@ final class SchemaHandle {
continue;
}
- $type_metadata = self::read_type_metadata( $type );
- if ( ! empty( $type_metadata ) ) {
- $rows[] = self::make_row( $type_name, null, null, null, $type_metadata );
+ $type_metadata = self::read_type_metadata( $type );
+ $type_authorization = self::read_type_authorization( $type );
+ if ( ! empty( $type_metadata ) || ! empty( $type_authorization ) ) {
+ $rows[] = self::make_row( $type_name, null, null, null, $type_metadata, $type_authorization );
}
if ( $type instanceof HasFieldsType ) {
foreach ( $type->getFields() as $field_name => $field ) {
- $field_metadata = self::read_element_metadata( $field );
- if ( ! empty( $field_metadata ) ) {
- $rows[] = self::make_row( $type_name, $field_name, null, null, $field_metadata );
+ $field_metadata = self::read_element_metadata( $field );
+ $field_authorization = self::read_element_authorization( $field );
+ if ( ! empty( $field_metadata ) || ! empty( $field_authorization ) ) {
+ $rows[] = self::make_row( $type_name, $field_name, null, null, $field_metadata, $field_authorization );
}
foreach ( $field->args as $arg ) {
$arg_metadata = self::read_element_metadata( $arg );
if ( ! empty( $arg_metadata ) ) {
- $rows[] = self::make_row( $type_name, $field_name, $arg->name, null, $arg_metadata );
+ $rows[] = self::make_row( $type_name, $field_name, $arg->name, null, $arg_metadata, array() );
}
}
}
@@ -105,9 +111,10 @@ final class SchemaHandle {
if ( $type instanceof InputObjectType ) {
foreach ( $type->getFields() as $field_name => $field ) {
- $field_metadata = self::read_element_metadata( $field );
- if ( ! empty( $field_metadata ) ) {
- $rows[] = self::make_row( $type_name, $field_name, null, null, $field_metadata );
+ $field_metadata = self::read_element_metadata( $field );
+ $field_authorization = self::read_element_authorization( $field );
+ if ( ! empty( $field_metadata ) || ! empty( $field_authorization ) ) {
+ $rows[] = self::make_row( $type_name, $field_name, null, null, $field_metadata, $field_authorization );
}
}
continue;
@@ -117,7 +124,7 @@ final class SchemaHandle {
foreach ( $type->getValues() as $value ) {
$value_metadata = self::read_element_metadata( $value );
if ( ! empty( $value_metadata ) ) {
- $rows[] = self::make_row( $type_name, null, null, $value->name, $value_metadata );
+ $rows[] = self::make_row( $type_name, null, null, $value->name, $value_metadata, array() );
}
}
}
@@ -135,13 +142,14 @@ final class SchemaHandle {
* asking "which elements are marked X" gets focused rows back, not the
* full multi-entry shape.
*
- * @param ?string $name Optional metadata name to match. When set, only rows containing this entry survive and their `entries` are trimmed to it.
- * @param ?string $type Optional GraphQL type name to match.
- * @param ?string $field Optional GraphQL field name to match.
+ * @param ?string $name Optional metadata name to match. When set, only rows containing this entry survive and their `entries` are trimmed to it.
+ * @param ?string $type Optional GraphQL type name to match.
+ * @param ?string $field Optional GraphQL field name to match.
+ * @param ?string $attribute Optional authorization-attribute short name to match. When set, only rows carrying this attribute survive and their `authorization` is trimmed to the matching descriptors.
*
- * @return list<array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>}>
+ * @return list<array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>, authorization: list<array{attribute: string, args: list<mixed>}>}>
*/
- public function find_metadata( ?string $name = null, ?string $type = null, ?string $field = null ): array {
+ public function find_metadata( ?string $name = null, ?string $type = null, ?string $field = null, ?string $attribute = null ): array {
$rows = $this->get_all_metadata();
$result = array();
@@ -158,6 +166,18 @@ final class SchemaHandle {
}
$row['entries'] = array( $name => $row['entries'][ $name ] );
}
+ if ( null !== $attribute ) {
+ $matching = array_values(
+ array_filter(
+ $row['authorization'],
+ static fn( array $descriptor ): bool => ( $descriptor['attribute'] ?? null ) === $attribute,
+ )
+ );
+ if ( empty( $matching ) ) {
+ continue;
+ }
+ $row['authorization'] = $matching;
+ }
$result[] = $row;
}
@@ -201,23 +221,61 @@ final class SchemaHandle {
return is_array( $metadata ) ? $metadata : array();
}
+ /**
+ * Read authorization descriptors attached to a wrapped engine type.
+ *
+ * The wrapper subclasses preserve the original config in `$type->config`;
+ * authorization descriptors emitted by ApiBuilder live under the
+ * `authorization` key as a list of `{attribute, args}` records.
+ *
+ * @param Type $type The GraphQL type to inspect.
+ * @return list<array{attribute: string, args: list<mixed>}>
+ */
+ private static function read_type_authorization( Type $type ): array {
+ if ( ! property_exists( $type, 'config' ) ) {
+ return array();
+ }
+ $authorization = $type->config['authorization'] ?? array();
+ return is_array( $authorization ) ? $authorization : array();
+ }
+
+ /**
+ * Read authorization descriptors from a field-/arg-/enum-value-level config.
+ *
+ * Mirrors {@see self::read_element_metadata()} but pulls the
+ * `authorization` key. Returns an empty list when the element carries
+ * no authorization descriptors.
+ *
+ * @param object $element FieldDefinition | Argument | InputObjectField | EnumValueDefinition.
+ * @return list<array{attribute: string, args: list<mixed>}>
+ */
+ private static function read_element_authorization( object $element ): array {
+ if ( ! property_exists( $element, 'config' ) ) {
+ return array();
+ }
+ $authorization = $element->config['authorization'] ?? array();
+ return is_array( $authorization ) ? $authorization : array();
+ }
+
/**
* Build a metadata row in the standard shape.
*
- * @param string $type GraphQL type name.
- * @param ?string $field Field name when the row describes a field; null otherwise.
- * @param ?string $argument Argument name when the row describes a field argument; null otherwise.
- * @param ?string $enum_value Enum value name when the row describes an enum value; null otherwise.
- * @param array<string, bool|int|float|string|null> $entries Name=>value entries to attach to the row.
- * @return array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>}
+ * @param string $type GraphQL type name.
+ * @param ?string $field Field name when the row describes a field; null otherwise.
+ * @param ?string $argument Argument name when the row describes a field argument; null otherwise.
+ * @param ?string $enum_value Enum value name when the row describes an enum value; null otherwise.
+ * @param array<string, bool|int|float|string|null> $entries Name=>value entries to attach to the row.
+ * @param list<array{attribute: string, args: list<mixed>}> $authorization Authorization descriptors attached to the row, or an empty list.
+ * @return array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>, authorization: list<array{attribute: string, args: list<mixed>}>}
*/
- private static function make_row( string $type, ?string $field, ?string $argument, ?string $enum_value, array $entries ): array {
+ private static function make_row( string $type, ?string $field, ?string $argument, ?string $enum_value, array $entries, array $authorization ): array {
return array(
- 'type' => $type,
- 'field' => $field,
- 'argument' => $argument,
- 'enumValue' => $enum_value,
- 'entries' => $entries,
+ 'type' => $type,
+ 'field' => $field,
+ 'argument' => $argument,
+ 'enumValue' => $enum_value,
+ 'entries' => $entries,
+ 'authorization' => $authorization,
);
}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
index bb53630757b..b9f424535ef 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
@@ -16,15 +16,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class CreateCoupon {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull( CouponType::get() ),
- 'description' => __( 'Create a new coupon.', 'woocommerce' ),
- 'args' => array(
+ 'type' => Type::nonNull( CouponType::get() ),
+ 'description' => __( 'Create a new coupon.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_woocommerce',
+ ),
+ ),
+ ),
+ 'args' => array(
'input' => array(
'type' => Type::nonNull( CreateCouponInput::get() ),
'description' => __( 'Data for the new coupon.', 'woocommerce' ),
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -35,6 +43,12 @@ class CreateCoupon {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( CreateCouponCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
index 684b7ff071f..386100e6513 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
@@ -16,15 +16,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class CreateProduct {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull( ProductInterface::get() ),
- 'description' => __( 'Create a new product.', 'woocommerce' ),
- 'args' => array(
+ 'type' => Type::nonNull( ProductInterface::get() ),
+ 'description' => __( 'Create a new product.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'edit_products',
+ ),
+ ),
+ ),
+ 'args' => array(
'input' => array(
'type' => Type::nonNull( CreateProductInput::get() ),
'description' => __( 'Data for the new product.', 'woocommerce' ),
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -35,6 +43,12 @@ class CreateProduct {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( CreateProductCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
index 1c3b1803427..95c9ca7f98f 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
@@ -15,9 +15,17 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class DeleteCoupon {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull( DeleteCouponResultType::get() ),
- 'description' => __( 'Delete a coupon.', 'woocommerce' ),
- 'args' => array(
+ 'type' => Type::nonNull( DeleteCouponResultType::get() ),
+ 'description' => __( 'Delete a coupon.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_woocommerce',
+ ),
+ ),
+ ),
+ 'args' => array(
'id' => array(
'type' => Type::nonNull( Type::int() ),
'description' => __( 'The ID of the coupon to delete.', 'woocommerce' ),
@@ -28,7 +36,7 @@ class DeleteCoupon {
'defaultValue' => false,
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -39,6 +47,12 @@ class DeleteCoupon {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( DeleteCouponCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
index c09384b0b80..df9fa4d9b66 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class DeleteProduct {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull(
+ 'type' => Type::nonNull(
new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
array(
'name' => 'DeleteProductResult',
@@ -24,8 +24,16 @@ class DeleteProduct {
)
)
),
- 'description' => __( 'Delete a product.', 'woocommerce' ),
- 'args' => array(
+ 'description' => __( 'Delete a product.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_woocommerce',
+ ),
+ ),
+ ),
+ 'args' => array(
'id' => array(
'type' => Type::nonNull( Type::int() ),
'description' => __( 'The ID of the product to delete.', 'woocommerce' ),
@@ -36,7 +44,7 @@ class DeleteProduct {
'defaultValue' => false,
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -47,6 +55,12 @@ class DeleteProduct {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( DeleteProductCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
index 9fcce5708de..c7062f39e72 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
@@ -16,15 +16,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class UpdateCoupon {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull( CouponType::get() ),
- 'description' => __( 'Update an existing coupon.', 'woocommerce' ),
- 'args' => array(
+ 'type' => Type::nonNull( CouponType::get() ),
+ 'description' => __( 'Update an existing coupon.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_woocommerce',
+ ),
+ ),
+ ),
+ 'args' => array(
'input' => array(
'type' => Type::nonNull( UpdateCouponInput::get() ),
'description' => __( 'The fields to update.', 'woocommerce' ),
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -35,6 +43,12 @@ class UpdateCoupon {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( UpdateCouponCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
index c529ee6a3e9..2c89deac4d2 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
@@ -16,15 +16,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class UpdateProduct {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull( ProductInterface::get() ),
- 'description' => __( 'Update an existing product.', 'woocommerce' ),
- 'args' => array(
+ 'type' => Type::nonNull( ProductInterface::get() ),
+ 'description' => __( 'Update an existing product.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_woocommerce',
+ ),
+ ),
+ ),
+ 'args' => array(
'input' => array(
'type' => Type::nonNull( UpdateProductInput::get() ),
'description' => __( 'The fields to update.', 'woocommerce' ),
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -35,6 +43,12 @@ class UpdateProduct {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( UpdateProductCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
index 9bfbe5fc423..039ee59be8d 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
@@ -15,9 +15,17 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class GetCoupon {
public static function get_field_definition(): array {
return array(
- 'type' => CouponType::get(),
- 'description' => __( 'Retrieve a single coupon by ID or code. Exactly one of the two arguments must be provided.', 'woocommerce' ),
- 'args' => array(
+ 'type' => CouponType::get(),
+ 'description' => __( 'Retrieve a single coupon by ID or code. Exactly one of the two arguments must be provided.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'read_private_shop_coupons',
+ ),
+ ),
+ ),
+ 'args' => array(
'id' => array(
'type' => Type::int(),
'description' => __( 'The ID of the coupon to retrieve.', 'woocommerce' ),
@@ -29,7 +37,7 @@ class GetCoupon {
'defaultValue' => null,
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -40,6 +48,12 @@ class GetCoupon {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( GetCouponCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
index fbb32249ada..9abc5643642 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
@@ -15,19 +15,33 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class GetProduct {
public static function get_field_definition(): array {
return array(
- 'type' => ProductInterface::get(),
- 'description' => __( 'Retrieve a single product by ID.', 'woocommerce' ),
- 'args' => array(
+ 'type' => ProductInterface::get(),
+ 'description' => __( 'Retrieve a single product by ID.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'read_product',
+ ),
+ ),
+ ),
+ 'args' => array(
'id' => array(
'type' => Type::nonNull( Type::int() ),
'description' => __( 'The ID of the product to retrieve.', 'woocommerce' ),
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( GetProductCommand::class );
$query_info = QueryInfoExtractor::extract_from_info( $info, $args );
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
index c80009d84c4..89b297a4d53 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
@@ -16,9 +16,17 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class ListCoupons {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull( CouponConnectionType::get() ),
- 'description' => __( 'List coupons with cursor-based pagination.', 'woocommerce' ),
- 'args' => array(
+ 'type' => Type::nonNull( CouponConnectionType::get() ),
+ 'description' => __( 'List coupons with cursor-based pagination.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'read_private_shop_coupons',
+ ),
+ ),
+ ),
+ 'args' => array(
'first' => array(
'type' => Type::int(),
'description' => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
@@ -45,8 +53,8 @@ class ListCoupons {
'defaultValue' => null,
),
),
- 'complexity' => ResolverHelpers::complexity_from_pagination( ... ),
- 'resolve' => array( self::class, 'resolve' ),
+ 'complexity' => ResolverHelpers::complexity_from_pagination( ... ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -57,6 +65,12 @@ class ListCoupons {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( ListCouponsCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
index 963bfabb1b5..b0c92452ff5 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
@@ -18,9 +18,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class ListProducts {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull( ProductConnectionType::get() ),
- 'description' => __( 'List products with cursor-based pagination and optional filtering.', 'woocommerce' ),
- 'args' => array(
+ 'type' => Type::nonNull( ProductConnectionType::get() ),
+ 'description' => __( 'List products with cursor-based pagination and optional filtering.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_woocommerce',
+ ),
+ ),
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'edit_products',
+ ),
+ ),
+ ),
+ 'args' => array(
'first' => array(
'type' => Type::int(),
'description' => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
@@ -62,8 +76,8 @@ class ListProducts {
'defaultValue' => null,
),
),
- 'complexity' => ResolverHelpers::complexity_from_pagination( ... ),
- 'resolve' => array( self::class, 'resolve' ),
+ 'complexity' => ResolverHelpers::complexity_from_pagination( ... ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -74,6 +88,12 @@ class ListProducts {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( ListProductsCommand::class );
$query_info = QueryInfoExtractor::extract_from_info( $info, $args );
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
index 70fe96ee5d7..a3da661a0ea 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
@@ -1 +1 @@
-2026-05-19T11:37:09+00:00
\ No newline at end of file
+2026-05-21T10:29:44+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
index 5ee939470e0..59f41b2e8ec 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
@@ -1 +1 @@
-664540c79e947e5fce1e6c925e7f028e7935641fa59ced226b9dba97141d50a6
\ No newline at end of file
+2965921ea12d55aff3aa621ff023f57cf0dde87517b5c2fabf7ad47752f8a128
\ No newline at end of file
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/AuthorizationAttributeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/AuthorizationAttributeTest.php
new file mode 100644
index 00000000000..e16c493bc94
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/AuthorizationAttributeTest.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for the contract surface of the authorization attributes
+ * shipped with the dual API ({@see PublicAccess} and
+ * {@see RequiredCapability}). Pins the {@code #[Attribute]} flag set so
+ * future widenings/narrowings are caught explicitly.
+ */
+class AuthorizationAttributeTest extends WC_Unit_Test_Case {
+ /**
+ * @return array<string, array{class-string, int}>
+ */
+ public function provider_attribute_targets(): array {
+ return array(
+ 'PublicAccess accepts TARGET_CLASS' => array( PublicAccess::class, \Attribute::TARGET_CLASS ),
+ 'PublicAccess accepts TARGET_PROPERTY' => array( PublicAccess::class, \Attribute::TARGET_PROPERTY ),
+ 'RequiredCapability accepts TARGET_CLASS' => array( RequiredCapability::class, \Attribute::TARGET_CLASS ),
+ 'RequiredCapability accepts TARGET_PROPERTY' => array( RequiredCapability::class, \Attribute::TARGET_PROPERTY ),
+ );
+ }
+
+ /**
+ * @testdox Authorization attribute accepts the target listed in the provider.
+ *
+ * @dataProvider provider_attribute_targets
+ * @param class-string $attribute_class The attribute class under test.
+ * @param int $target_flag A single {@see \Attribute} TARGET_* flag the class must accept.
+ */
+ public function test_attribute_accepts_target( string $attribute_class, int $target_flag ): void {
+ $reflection = new \ReflectionClass( $attribute_class );
+ $attributes = $reflection->getAttributes( \Attribute::class );
+
+ $this->assertNotEmpty( $attributes, $attribute_class . ' should be decorated with #[Attribute].' );
+
+ $attribute = $attributes[0]->newInstance();
+ $this->assertNotSame(
+ 0,
+ $attribute->flags & $target_flag,
+ $attribute_class . ' should accept the requested TARGET_* flag.'
+ );
+ }
+
+ /**
+ * @testdox RequiredCapability remains repeatable after the property-target widening.
+ */
+ public function test_required_capability_is_still_repeatable(): void {
+ $reflection = new \ReflectionClass( RequiredCapability::class );
+ $attributes = $reflection->getAttributes( \Attribute::class );
+
+ $attribute = $attributes[0]->newInstance();
+ $this->assertNotSame( 0, $attribute->flags & \Attribute::IS_REPEATABLE );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizationQueryDiscoveryTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizationQueryDiscoveryTest.php
new file mode 100644
index 00000000000..21587b96ab0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizationQueryDiscoveryTest.php
@@ -0,0 +1,111 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Api\Utils\SchemaHandle;
+
+/**
+ * End-to-end coverage for the `_apiMetadata.authorization` surface:
+ *
+ * - `Widget::$caption` carries `#[RequiredCapability('manage_woocommerce')]`
+ * and no opt-out attribute, so its row exposes the descriptor.
+ * - `Widget::$legacy_price` carries `#[HiddenFromMetadataQuery]`, which
+ * hides the whole target — neither its metadata nor any
+ * authorization descriptor reaches `_apiMetadata`.
+ *
+ * The discovery hide is independent of the runtime authorization gate:
+ * a target hidden from `_apiMetadata` can still be gated at resolution
+ * time (see {@see FieldAuthorizeTest} for the runtime coverage).
+ */
+class AuthorizationQueryDiscoveryTest extends AutogeneratedTestCase {
+ /**
+ * @testdox find_metadata exposes authorization descriptors on a visible gated field.
+ */
+ public function test_find_metadata_returns_authorization_descriptors(): void {
+ $handle = new SchemaHandle( $this->build_schema() );
+ $rows = $handle->find_metadata( type: 'Widget', field: 'caption' );
+
+ $this->assertCount( 1, $rows );
+ $this->assertArrayHasKey( 'authorization', $rows[0] );
+
+ $attributes = array_column( $rows[0]['authorization'], 'attribute' );
+ $this->assertContains( 'RequiredCapability', $attributes );
+ }
+
+ /**
+ * @testdox RequiredCapability args round-trip through the descriptor.
+ */
+ public function test_descriptor_carries_constructor_args(): void {
+ $handle = new SchemaHandle( $this->build_schema() );
+ $rows = $handle->find_metadata( type: 'Widget', field: 'caption' );
+
+ $required_cap = null;
+ foreach ( $rows[0]['authorization'] as $descriptor ) {
+ if ( 'RequiredCapability' === $descriptor['attribute'] ) {
+ $required_cap = $descriptor;
+ break;
+ }
+ }
+ $this->assertNotNull( $required_cap );
+ $this->assertSame( array( 'manage_woocommerce' ), $required_cap['args'] );
+ }
+
+ /**
+ * @testdox find_metadata filters by attribute short name, trimming non-matching descriptors.
+ */
+ public function test_find_metadata_filters_by_attribute(): void {
+ $handle = new SchemaHandle( $this->build_schema() );
+ $rows = $handle->find_metadata( attribute: 'RequiredCapability' );
+
+ $this->assertNotEmpty( $rows );
+ foreach ( $rows as $row ) {
+ foreach ( $row['authorization'] as $descriptor ) {
+ $this->assertSame( 'RequiredCapability', $descriptor['attribute'] );
+ }
+ }
+ }
+
+ /**
+ * @testdox A target with #[HiddenFromMetadataQuery] does not appear in _apiMetadata.
+ */
+ public function test_hidden_target_is_omitted_from_apimetadata(): void {
+ $handle = new SchemaHandle( $this->build_schema() );
+ $rows = $handle->find_metadata( type: 'Widget', field: 'legacy_price' );
+
+ $this->assertSame(
+ array(),
+ $rows,
+ 'legacy_price carries #[HiddenFromMetadataQuery]; the row must not surface.'
+ );
+ }
+
+ /**
+ * @testdox The _apiMetadata endpoint surfaces authorization through the GraphQL response.
+ */
+ public function test_apimetadata_endpoint_returns_authorization(): void {
+ // `_apiMetadata` is gated by the principal's `can_query_metadata()` /
+ // `can_introspect()` ladder; the default WC Principal grants only on
+ // `manage_woocommerce`, so the test runs as an admin to clear the gate.
+ $admin = self::factory()->user->create_and_get( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin->ID );
+
+ $result = $this->execute_query(
+ '{ _apiMetadata(type: "Widget", field: "caption") { type field authorization { attribute args } } }'
+ );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $rows = $result['data']['_apiMetadata'];
+ $this->assertCount( 1, $rows );
+
+ $found = false;
+ foreach ( $rows[0]['authorization'] as $entry ) {
+ if ( 'RequiredCapability' === $entry['attribute'] ) {
+ $found = true;
+ $this->assertSame( array( 'manage_woocommerce' ), $entry['args'] );
+ }
+ }
+ $this->assertTrue( $found, 'RequiredCapability should appear in the _apiMetadata authorization list.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php
index 116c5fe4b92..305281f260a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php
@@ -82,8 +82,10 @@ abstract class AutogeneratedTestCase extends WC_Unit_Test_Case {
$result = GraphQL::executeQuery(
schema: $this->build_schema(),
source: $query,
- contextValue: array(
- 'principal' => new Principal( wp_get_current_user() ),
+ contextValue: new \ArrayObject(
+ array(
+ 'principal' => new Principal( wp_get_current_user() ),
+ )
),
variableValues: $vars,
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/FieldAuthorizeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/FieldAuthorizeTest.php
new file mode 100644
index 00000000000..46a03c7fa96
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/FieldAuthorizeTest.php
@@ -0,0 +1,112 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+/**
+ * End-to-end coverage for per-field authorization gates emitted into the
+ * generated ObjectType resolvers.
+ *
+ * Fixture surface:
+ *
+ * - `Widget::$caption` carries `#[RequiredCapability('manage_woocommerce')]`,
+ * so reading this field is gated by the WP `manage_woocommerce` capability.
+ * - `Widget::$tag_ids` carries `#[PublicAccess]`, which at field level emits
+ * a build warning and is treated as a no-op (allow-by-default). The
+ * generated resolver therefore has no gate on `tag_ids`.
+ *
+ * Field-level denies surface as a per-field error in the GraphQL response;
+ * the field value becomes `null` and the rest of the type resolves normally.
+ */
+class FieldAuthorizeTest extends AutogeneratedTestCase {
+ /**
+ * @testdox An admin can read a gated field — the gate grants and the value is returned.
+ */
+ public function test_admin_can_read_gated_field(): void {
+ $admin = self::factory()->user->create_and_get( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin->ID );
+
+ $result = $this->execute_query( '{ publicWidget { caption } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result, 'Admin should not see any errors when reading a gated field.' );
+ $this->assertArrayHasKey( 'data', $result );
+ $this->assertArrayHasKey( 'publicWidget', $result['data'] );
+ // Caption is nullable in the fixture; admin still gets through the gate.
+ $this->assertArrayHasKey( 'caption', $result['data']['publicWidget'] );
+ }
+
+ /**
+ * @testdox An anonymous principal hits a field-level deny when reading a gated field.
+ */
+ public function test_anonymous_is_denied_on_gated_field(): void {
+ wp_set_current_user( 0 );
+
+ $result = $this->execute_query( '{ publicWidget { caption } }' );
+
+ $this->assertArrayHasKey( 'errors', $result, 'Anonymous reader should hit the field-level gate.' );
+ $this->assertNotEmpty( $result['errors'] );
+ $this->assertArrayHasKey( 'extensions', $result['errors'][0] );
+ $this->assertSame( 'UNAUTHORIZED', $result['errors'][0]['extensions']['code'] );
+
+ // The field value is null but other fields on the same type resolve normally.
+ $this->assertArrayHasKey( 'data', $result );
+ $this->assertNull( $result['data']['publicWidget']['caption'] );
+ }
+
+ /**
+ * @testdox An authenticated principal without the required capability hits a FORBIDDEN deny.
+ */
+ public function test_subscriber_is_forbidden_on_gated_field(): void {
+ $subscriber = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) );
+ wp_set_current_user( $subscriber->ID );
+
+ $result = $this->execute_query( '{ publicWidget { caption } }' );
+
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertSame( 'FORBIDDEN', $result['errors'][0]['extensions']['code'] );
+ $this->assertNull( $result['data']['publicWidget']['caption'] );
+ }
+
+ /**
+ * @testdox A field-level deny carries a structured subject payload identifying type, field, and attribute.
+ */
+ public function test_field_deny_carries_subject_payload(): void {
+ wp_set_current_user( 0 );
+
+ $result = $this->execute_query( '{ publicWidget { caption } }' );
+
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertArrayHasKey( 'extensions', $result['errors'][0] );
+ $this->assertArrayHasKey( 'subject', $result['errors'][0]['extensions'] );
+
+ $subject = $result['errors'][0]['extensions']['subject'];
+ $this->assertSame( 'Widget', $subject['type'] );
+ $this->assertSame( 'caption', $subject['field'] );
+ $this->assertSame( 'RequiredCapability', $subject['attribute'] );
+ }
+
+ /**
+ * @testdox A query that does not select the gated field is unaffected by its gate.
+ */
+ public function test_unselected_gated_field_does_not_fire_gate(): void {
+ wp_set_current_user( 0 );
+
+ $result = $this->execute_query( '{ publicWidget { id slug } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertSame( 'alpha', $result['data']['publicWidget']['slug'] );
+ }
+
+ /**
+ * @testdox A field decorated with #[PublicAccess] alone has no gate (warned and ignored at build).
+ */
+ public function test_public_access_property_has_no_gate(): void {
+ wp_set_current_user( 0 );
+
+ $result = $this->execute_query( '{ publicWidget { tag_ids } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result, 'PublicAccess at field level is a build-only marker; no runtime gate should fire.' );
+ $this->assertSame( array( 1, 2, 3 ), $result['data']['publicWidget']['tag_ids'] );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MetadataQueryHidingTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MetadataQueryHidingTest.php
new file mode 100644
index 00000000000..1dc50f33792
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MetadataQueryHidingTest.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Api\Utils\SchemaHandle;
+
+/**
+ * End-to-end coverage for the per-target `_apiMetadata` opt-out:
+ *
+ * - `Widget::$caption` carries `#[VisibleSampleMetadata]` and no
+ * opt-out attribute. The generated schema preserves the entry; a
+ * `_apiMetadata` row appears for the field.
+ * - `Widget::$legacy_price` carries `#[HiddenFromMetadataQuery]` (the
+ * stock marker). Any attribute on a target whose
+ * `shows_in_metadata_query()` returns false hides the whole target —
+ * the field does not appear in `_apiMetadata` at all, regardless of
+ * what other metadata / authorization is declared on it.
+ */
+class MetadataQueryHidingTest extends AutogeneratedTestCase {
+ /**
+ * @testdox Metadata attached to a visible target reaches `_apiMetadata`.
+ */
+ public function test_visible_metadata_appears_in_schema(): void {
+ $handle = new SchemaHandle( $this->build_schema() );
+ $rows = $handle->find_metadata( type: 'Widget', field: 'caption' );
+
+ $this->assertCount( 1, $rows, 'Expected exactly one metadata row for Widget.caption.' );
+ $this->assertArrayHasKey( 'visible_sample', $rows[0]['entries'] );
+ $this->assertSame( 'visible', $rows[0]['entries']['visible_sample'] );
+ }
+
+ /**
+ * @testdox A target carrying any attribute whose shows_in_metadata_query() returns false is omitted entirely.
+ */
+ public function test_target_with_hidden_attribute_is_omitted(): void {
+ $handle = new SchemaHandle( $this->build_schema() );
+ $rows = $handle->find_metadata( type: 'Widget', field: 'legacy_price' );
+
+ $this->assertSame(
+ array(),
+ $rows,
+ 'legacy_price carries #[HiddenFromMetadataQuery] and must not surface through _apiMetadata.'
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MutationInputAuthorizeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MutationInputAuthorizeTest.php
new file mode 100644
index 00000000000..c8a8db621ec
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MutationInputAuthorizeTest.php
@@ -0,0 +1,95 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+/**
+ * End-to-end coverage for input-side authorization gates. Fixture surface:
+ *
+ * - `publicCreateWidget` is a `#[PublicAccess]` mutation that takes a
+ * `CreateWidgetInput`. The class-level gate grants for any principal so
+ * the test can exercise the input-side path in isolation.
+ * - `CreateWidgetInput::$weight` carries `#[RequiredCapability('manage_woocommerce')]`.
+ * The generated resolver emits a gate that runs only when `weight` is
+ * actually present in the GraphQL request (mirroring
+ * {@see \Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields}).
+ */
+class MutationInputAuthorizeTest extends AutogeneratedTestCase {
+ /**
+ * @testdox An admin can create a widget while providing the gated input field.
+ */
+ public function test_admin_can_provide_gated_input_field(): void {
+ $admin = self::factory()->user->create_and_get( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin->ID );
+
+ $result = $this->execute_query(
+ 'mutation { publicCreateWidget( input: { label: "Test", color: RED, weight: 100 } ) { slug } }'
+ );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertArrayHasKey( 'data', $result );
+ $this->assertArrayHasKey( 'publicCreateWidget', $result['data'] );
+ }
+
+ /**
+ * @testdox An anonymous caller can create a widget without providing the gated field.
+ */
+ public function test_anonymous_can_create_without_gated_field(): void {
+ wp_set_current_user( 0 );
+
+ $result = $this->execute_query(
+ 'mutation { publicCreateWidget( input: { label: "TestAnon", color: GREEN } ) { slug } }'
+ );
+
+ $this->assertArrayNotHasKey( 'errors', $result, 'A non-provided gated input field should not trigger the gate.' );
+ $this->assertArrayHasKey( 'data', $result );
+ $this->assertArrayHasKey( 'publicCreateWidget', $result['data'] );
+ }
+
+ /**
+ * @testdox An anonymous caller hits the input-side gate when providing the gated field.
+ */
+ public function test_anonymous_is_denied_when_providing_gated_input_field(): void {
+ wp_set_current_user( 0 );
+
+ $result = $this->execute_query(
+ 'mutation { publicCreateWidget( input: { label: "TestDenied", color: GREEN, weight: 50 } ) { slug } }'
+ );
+
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertSame( 'UNAUTHORIZED', $result['errors'][0]['extensions']['code'] );
+ }
+
+ /**
+ * @testdox An input-side deny carries a structured subject payload identifying input type, field, and attribute.
+ */
+ public function test_input_deny_carries_subject_payload(): void {
+ wp_set_current_user( 0 );
+
+ $result = $this->execute_query(
+ 'mutation { publicCreateWidget( input: { label: "TestSubject", color: GREEN, weight: 99 } ) { slug } }'
+ );
+
+ $this->assertArrayHasKey( 'errors', $result );
+ $subject = $result['errors'][0]['extensions']['subject'];
+ $this->assertSame( 'CreateWidgetInput', $subject['type'] );
+ $this->assertSame( 'weight', $subject['field'] );
+ $this->assertSame( 'RequiredCapability', $subject['attribute'] );
+ }
+
+ /**
+ * @testdox A subscriber gets FORBIDDEN when providing a gated input field they lack the capability for.
+ */
+ public function test_subscriber_is_forbidden_when_providing_gated_input_field(): void {
+ $subscriber = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) );
+ wp_set_current_user( $subscriber->ID );
+
+ $result = $this->execute_query(
+ 'mutation { publicCreateWidget( input: { label: "TestSub", color: RED, weight: 75 } ) { slug } }'
+ );
+
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertSame( 'FORBIDDEN', $result['errors'][0]['extensions']['code'] );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/RuntimeMetadataVisibilityTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/RuntimeMetadataVisibilityTest.php
new file mode 100644
index 00000000000..d609c94b608
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/RuntimeMetadataVisibilityTest.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Api\Utils\SchemaHandle;
+
+/**
+ * Regression coverage for the `shows_in_metadata_query()` / `#[HiddenFromMetadataQuery]`
+ * opt-out being *discovery-only*: a target hidden from `_apiMetadata` must still
+ * feed its metadata into the runtime `$_metadata` slices that field gates read.
+ *
+ * `HiddenFlaggedQuery` (hidden, carries `runtime_flag`) returns `RuntimeMetaProbe`
+ * (hidden type, carries `runtime_flag`), whose three gated fields each read a
+ * different slice via `GrantsIfMetadataFlag`:
+ *
+ * - `by_type` → `$_metadata['type']` (the hidden type's metadata)
+ * - `by_field` → `$_metadata['field']` (the hidden field's own metadata)
+ * - `by_query` → `$_metadata['query']` (the hidden query's metadata)
+ *
+ * All three must grant. Before the runtime/discovery split, each slice was
+ * blanked when its carrying target opted out, so every field would have denied.
+ */
+class RuntimeMetadataVisibilityTest extends AutogeneratedTestCase {
+ /**
+ * @testdox runtime $_metadata slices stay populated for hidden targets (query / type / field).
+ */
+ public function test_hidden_targets_keep_runtime_metadata(): void {
+ wp_set_current_user( 0 );
+
+ $result = $this->execute_query( '{ hiddenFlagged { by_type by_field by_query } }' );
+
+ $this->assertArrayNotHasKey(
+ 'errors',
+ $result,
+ 'A discovery opt-out must not starve the runtime gates: ' . wp_json_encode( $result['errors'] ?? array() )
+ );
+ $this->assertSame( 'type-ok', $result['data']['hiddenFlagged']['by_type'], 'type slice was blanked at runtime' );
+ $this->assertSame( 'field-ok', $result['data']['hiddenFlagged']['by_field'], 'field slice was blanked at runtime' );
+ $this->assertSame( 'query-ok', $result['data']['hiddenFlagged']['by_query'], 'query slice was blanked at runtime' );
+ }
+
+ /**
+ * @testdox the discovery opt-out still hides the target from _apiMetadata.
+ */
+ public function test_hidden_field_absent_from_discovery(): void {
+ $handle = new SchemaHandle( $this->build_schema() );
+
+ // by_field carries metadata + an authorization attribute but is hidden,
+ // so it must not surface through _apiMetadata at all — confirming the
+ // runtime fix did not re-expose hidden targets in discovery.
+ $this->assertSame(
+ array(),
+ $handle->find_metadata( type: 'RuntimeMetaProbe', field: 'by_field' ),
+ 'by_field carries #[HiddenFromMetadataQuery] and must stay out of _apiMetadata.'
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/SchemaShapeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/SchemaShapeTest.php
index 99fa9b53bb9..ecf0115ac76 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/SchemaShapeTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/SchemaShapeTest.php
@@ -50,6 +50,7 @@ class SchemaShapeTest extends AutogeneratedTestCase {
$fields = $root_query->getFields();
$this->assertArrayHasKey( 'widget', $fields );
$this->assertArrayHasKey( 'widgets', $fields );
+ $this->assertArrayHasKey( 'widgetList', $fields );
$this->assertArrayHasKey( 'greeting', $fields );
$this->assertArrayHasKey( 'namedThing', $fields );
$this->assertArrayHasKey( 'failing', $fields );
@@ -58,6 +59,19 @@ class SchemaShapeTest extends AutogeneratedTestCase {
$this->assertArrayNotHasKey( 'IgnoredQuery', $fields );
}
+ /**
+ * @testdox a query returning #[ArrayOf( X::class )] array emits a [X!]! list field.
+ */
+ public function test_array_of_return_type_emits_non_null_list(): void {
+ // ListWidgetsArray::execute() is declared `: array` with
+ // `#[ArrayOf( Widget::class )]`. The builder must turn that into a
+ // non-null list of non-null Widget — not the scalar fallback. Regression
+ // guard for the return-type ArrayOf path (#[ConnectionOf] is covered by
+ // the `widgets` field separately).
+ $field = RootQueryType::get()->getFields()['widgetList'];
+ $this->assertSame( '[Widget!]!', (string) $field->getType() );
+ }
+
/**
* @testdox RootMutationType exposes every mutation and uses the right field names.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/ComputePreauthorizedTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/ComputePreauthorizedTest.php
index 2dd9323bfc3..f654c44fc85 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/ComputePreauthorizedTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/ComputePreauthorizedTest.php
@@ -7,6 +7,8 @@ namespace Automattic\WooCommerce\Tests\Internal\Api;
use Automattic\WooCommerce\Api\Infrastructure\Principal;
use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\ComposedAuthorizeQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\MetadataAwareInternalQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\MetadataAwareNoFlagQuery;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance\InheritedCapQuery;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance\InheritedPublicQuery;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance\MergedCapsQuery;
@@ -14,6 +16,8 @@ use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\Gra
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\InheritedCapQuery as InheritedCapResolver;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\InheritedPublicQuery as InheritedPublicResolver;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MergedCapsQuery as MergedCapsResolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MetadataAwareInternalQuery as MetadataAwareInternalResolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MetadataAwareNoFlagQuery as MetadataAwareNoFlagResolver;
use WC_Unit_Test_Case;
/**
@@ -166,4 +170,37 @@ class ComputePreauthorizedTest extends WC_Unit_Test_Case {
$this->expectException( \InvalidArgumentException::class );
ResolverHelpers::compute_preauthorized( '\\Definitely\\Not\\A\\Class', $this->principal_with_role( null ) );
}
+
+ /**
+ * @testdox the autogen-emitted helper threads the class's #[Metadata] entries into $_metadata.
+ */
+ public function test_autogen_helper_threads_metadata_slot(): void {
+ $principal = $this->principal_with_role( 'administrator' );
+
+ // The RequiresInternalFlag attribute grants only when $_metadata['query']['internal'] === true.
+ $this->assertTrue(
+ MetadataAwareInternalResolver::compute_preauthorized( $principal ),
+ '#[Internal] metadata should reach the attribute via $_metadata.'
+ );
+ $this->assertFalse(
+ MetadataAwareNoFlagResolver::compute_preauthorized( $principal ),
+ 'A command with no #[Internal] should produce an empty `query` slice and deny.'
+ );
+ }
+
+ /**
+ * @testdox ResolverHelpers::compute_preauthorized threads $_metadata to agree with the autogen helper.
+ */
+ public function test_utils_helper_threads_metadata_slot(): void {
+ $principal = $this->principal_with_role( 'administrator' );
+
+ $this->assertSame(
+ MetadataAwareInternalResolver::compute_preauthorized( $principal ),
+ ResolverHelpers::compute_preauthorized( MetadataAwareInternalQuery::class, $principal )
+ );
+ $this->assertSame(
+ MetadataAwareNoFlagResolver::compute_preauthorized( $principal ),
+ ResolverHelpers::compute_preauthorized( MetadataAwareNoFlagQuery::class, $principal )
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Attributes/GrantsIfMetadataFlag.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Attributes/GrantsIfMetadataFlag.php
new file mode 100644
index 00000000000..5b53d8f6cb7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Attributes/GrantsIfMetadataFlag.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes;
+
+use Attribute;
+use Automattic\WooCommerce\Api\Infrastructure\Principal;
+
+/**
+ * Fixture authorization attribute that grants iff a `runtime_flag` metadata
+ * entry (value `true`) is present in the requested `$_metadata` slice
+ * (`query`, `type`, or `field`).
+ *
+ * Used to prove that the runtime `$_metadata` slices stay populated even when
+ * the target carrying the metadata opts out of `_apiMetadata` via
+ * `#[HiddenFromMetadataQuery]` — that opt-out is discovery-only and must not
+ * starve the runtime gate.
+ */
+#[Attribute( Attribute::TARGET_PROPERTY )]
+final class GrantsIfMetadataFlag {
+ /**
+ * @param string $slice Which `$_metadata` slice to read: `query`, `type`, or `field`.
+ */
+ public function __construct( public readonly string $slice ) {
+ }
+
+ /**
+ * @param Principal $principal The resolved principal (unused; the decision is metadata-driven).
+ * @param array $_metadata The harvested metadata slices.
+ */
+ public function authorize( Principal $principal, array $_metadata ): bool {
+ unset( $principal );
+ return true === ( $_metadata[ $this->slice ]['runtime_flag'] ?? null );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Attributes/RequiresInternalFlag.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Attributes/RequiresInternalFlag.php
new file mode 100644
index 00000000000..f1971645637
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Attributes/RequiresInternalFlag.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes;
+
+use Attribute;
+use Automattic\WooCommerce\Api\Infrastructure\Principal;
+
+/**
+ * Fixture authorization attribute that exercises the opt-in `$_metadata`
+ * slot. Grants iff the surrounding command class carries an `#[Internal]`
+ * metadata entry (i.e. `$_metadata['query']['internal'] === true`).
+ *
+ * Declaring both the principal and `$_metadata` covers the
+ * happy-path mixed-positional/named call shape ApiBuilder and
+ * {@see \Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers::compute_preauthorized()}
+ * must produce.
+ */
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY )]
+final class RequiresInternalFlag {
+ public function authorize( Principal $principal, array $_metadata ): bool {
+ unset( $principal );
+ return true === ( $_metadata['query']['internal'] ?? null );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/CreateWidgetInput.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/CreateWidgetInput.php
index e03f80ae64a..d83aeca82a2 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/CreateWidgetInput.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/CreateWidgetInput.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes
use Automattic\WooCommerce\Api\Attributes\ArrayOf;
use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
use Automattic\WooCommerce\Api\Attributes\ScalarType;
use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Color;
@@ -22,6 +23,7 @@ class CreateWidgetInput {
public string $label;
#[Description( 'Optional weight in grams' )]
+ #[RequiredCapability( 'manage_woocommerce' )]
public ?int $weight = null;
#[Description( 'The widget color' )]
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Metadata/VisibleSampleMetadata.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Metadata/VisibleSampleMetadata.php
new file mode 100644
index 00000000000..305c48f9144
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Metadata/VisibleSampleMetadata.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Metadata;
+
+use Attribute;
+use Automattic\WooCommerce\Api\Attributes\Metadata;
+
+/**
+ * Plain fixture metadata subclass with the default
+ * `shows_in_metadata_query()` (returns `true`). Provides a known entry
+ * name (`visible_sample`) for tests that assert on the metadata query's
+ * output without bringing the side effects of stock subclasses like
+ * `#[Internal]` (which prefixes descriptions).
+ */
+#[Attribute( Attribute::TARGET_PROPERTY )]
+final class VisibleSampleMetadata extends Metadata {
+ public function __construct() {
+ parent::__construct( 'visible_sample', 'visible' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/PublicCreateWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/PublicCreateWidget.php
new file mode 100644
index 00000000000..953941b90bd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/PublicCreateWidget.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Mutations;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget;
+
+/**
+ * Publicly-accessible widget creator used by input-level authorization tests.
+ * Anyone can invoke the mutation (no class-level gate), but the
+ * `CreateWidgetInput::$weight` property carries a `#[RequiredCapability]`, so
+ * the input-side gate fires only when `weight` is provided in the request.
+ */
+#[Name( 'publicCreateWidget' )]
+#[Description( 'Create a widget without query-level gating; input-side gates apply.' )]
+#[PublicAccess]
+class PublicCreateWidget {
+ public function execute( CreateWidgetInput $input ): Widget {
+ $widget = Store::create_widget( $input->label, $input->color );
+ if ( null !== $input->weight ) {
+ $widget->caption = sprintf( 'weighs %d g', $input->weight );
+ }
+ return $widget;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/MetadataAwareInternalQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/MetadataAwareInternalQuery.php
new file mode 100644
index 00000000000..28a0e61b5ce
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/MetadataAwareInternalQuery.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Internal;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\RequiresInternalFlag;
+
+/**
+ * Carries an `#[Internal]` metadata entry and is gated by
+ * {@see RequiresInternalFlag}, which reads `$_metadata['query']['internal']`.
+ * The gate should grant: this is the happy path for the metadata slot.
+ */
+#[Name( 'metadataAwareInternalQuery' )]
+#[Description( 'Exercises RequiresInternalFlag against a class that carries #[Internal].' )]
+#[Internal]
+#[RequiresInternalFlag]
+class MetadataAwareInternalQuery {
+ public function execute(): string {
+ return 'ok-internal';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/MetadataAwareNoFlagQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/MetadataAwareNoFlagQuery.php
new file mode 100644
index 00000000000..aaf2d6639a4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/MetadataAwareNoFlagQuery.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\RequiresInternalFlag;
+
+/**
+ * Gated by {@see RequiresInternalFlag} but carries no `#[Internal]`
+ * metadata. The gate should deny: this is the negative path for the
+ * metadata slot — `$_metadata['query']` is empty, so the attribute's
+ * "is internal?" check returns false.
+ */
+#[Name( 'metadataAwareNoFlagQuery' )]
+#[Description( 'Exercises RequiresInternalFlag without the matching #[Internal] entry.' )]
+#[RequiresInternalFlag]
+class MetadataAwareNoFlagQuery {
+ public function execute(): string {
+ return 'ok-no-internal';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/PublicWidgetAccess.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/PublicWidgetAccess.php
new file mode 100644
index 00000000000..edf0cb24b41
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/PublicWidgetAccess.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget;
+
+/**
+ * Publicly-accessible Widget query used by the field-level authorization tests
+ * to reach a `Widget` instance without first passing through the class-level
+ * `#[RequiredCapability]` gate on `GetWidget`. Lets the field-level gates on
+ * `Widget`'s properties be exercised in isolation.
+ */
+#[Name( 'publicWidget' )]
+#[Description( 'Fetch a widget without query-level gating; field-level gates apply.' )]
+#[PublicAccess]
+class PublicWidgetAccess {
+ public function execute(): ?Widget {
+ return Store::get_widget( 1 );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/HiddenFlaggedQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/HiddenFlaggedQuery.php
new file mode 100644
index 00000000000..46a3c62ba85
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/HiddenFlaggedQuery.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\HiddenFromMetadataQuery;
+use Automattic\WooCommerce\Api\Attributes\Metadata;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\RuntimeMetaProbe;
+
+/**
+ * Public query that is itself hidden from `_apiMetadata` and carries a
+ * `runtime_flag` metadata entry. The `by_query` field on its returned type is
+ * gated on `$_metadata['query']['runtime_flag']`, so it only grants if the
+ * hidden query's metadata still reaches the runtime gate (the discovery
+ * opt-out must not blank the published `_query_metadata`).
+ */
+#[Name( 'hiddenFlagged' )]
+#[Description( 'Probe query for runtime metadata visibility under #[HiddenFromMetadataQuery].' )]
+#[PublicAccess]
+#[Metadata( 'runtime_flag', true )]
+#[HiddenFromMetadataQuery]
+class HiddenFlaggedQuery {
+ public function execute(): RuntimeMetaProbe {
+ $probe = new RuntimeMetaProbe();
+ $probe->by_type = 'type-ok';
+ $probe->by_field = 'field-ok';
+ $probe->by_query = 'query-ok';
+ return $probe;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/ListWidgetsArray.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/ListWidgetsArray.php
new file mode 100644
index 00000000000..528309bf2d4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/ListWidgetsArray.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget;
+
+/**
+ * Returns a plain (non-paginated) list of widgets — exercises the
+ * `#[ArrayOf]` element-type declaration on a query's `execute()` *return*
+ * value, which the generator turns into `[Widget!]!`.
+ *
+ * Regression coverage for the return-type list path: distinct from
+ * `#[ConnectionOf]` (which {@see ListWidgets} covers) and from `#[ArrayOf]`
+ * on properties / parameters (which {@see Widget} and the mutations cover).
+ * Before this was fixed, an `array` return with `#[ArrayOf]` fell through to
+ * the scalar fallback and emitted `String!`.
+ */
+#[Name( 'widgetList' )]
+#[Description( 'List widgets without pagination.' )]
+#[PublicAccess]
+class ListWidgetsArray {
+ /**
+ * @return Widget[]
+ */
+ #[ArrayOf( Widget::class )]
+ public function execute(): array {
+ return array();
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/RuntimeMetaProbe.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/RuntimeMetaProbe.php
new file mode 100644
index 00000000000..793fb68200c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/RuntimeMetaProbe.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types;
+
+use Automattic\WooCommerce\Api\Attributes\HiddenFromMetadataQuery;
+use Automattic\WooCommerce\Api\Attributes\Metadata;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\GrantsIfMetadataFlag;
+
+/**
+ * Output type for the runtime-metadata-visibility regression. The type, one of
+ * its fields, and the query that returns it ({@see \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\HiddenFlaggedQuery})
+ * all opt out of `_apiMetadata` via `#[HiddenFromMetadataQuery]` while carrying
+ * a `runtime_flag` metadata entry. Each gated field reads a different
+ * `$_metadata` slice and must still grant, proving the discovery opt-out does
+ * not blank the runtime metadata.
+ */
+#[Metadata( 'runtime_flag', true )]
+#[HiddenFromMetadataQuery]
+class RuntimeMetaProbe {
+ /**
+ * Reads the `type` slice: the enclosing (hidden) type carries `runtime_flag`.
+ */
+ #[GrantsIfMetadataFlag( 'type' )]
+ public ?string $by_type;
+
+ /**
+ * Reads the `field` slice: this field is itself hidden and carries `runtime_flag`.
+ */
+ #[Metadata( 'runtime_flag', true )]
+ #[HiddenFromMetadataQuery]
+ #[GrantsIfMetadataFlag( 'field' )]
+ public ?string $by_field;
+
+ /**
+ * Reads the `query` slice: the (hidden) query that resolves this type carries `runtime_flag`.
+ */
+ #[GrantsIfMetadataFlag( 'query' )]
+ public ?string $by_query;
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Widget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Widget.php
index 875dedba044..33762826444 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Widget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Widget.php
@@ -8,14 +8,18 @@ use Automattic\WooCommerce\Api\Attributes\ArrayOf;
use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
use Automattic\WooCommerce\Api\Attributes\Deprecated;
use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\HiddenFromMetadataQuery;
use Automattic\WooCommerce\Api\Attributes\Ignore;
use Automattic\WooCommerce\Api\Attributes\Parameter;
use Automattic\WooCommerce\Api\Attributes\ParameterDescription;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
use Automattic\WooCommerce\Api\Attributes\ScalarType;
use Automattic\WooCommerce\Api\Pagination\Connection;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Color;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Priority;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Interfaces\Named;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Metadata\VisibleSampleMetadata;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Scalars\DummyDateTime;
/**
@@ -29,6 +33,8 @@ class Widget {
public string $slug;
#[Description( 'An optional caption' )]
+ #[VisibleSampleMetadata]
+ #[RequiredCapability( 'manage_woocommerce' )]
public ?string $caption;
#[Description( 'The widget color' )]
@@ -39,6 +45,7 @@ class Widget {
#[Description( 'Tag IDs assigned to this widget' )]
#[ArrayOf( 'int' )]
+ #[PublicAccess]
public array $tag_ids;
#[Description( 'Notable comments left on this widget' )]
@@ -66,6 +73,7 @@ class Widget {
#[Description( 'A field flagged for removal' )]
#[Deprecated( 'Use price instead.' )]
+ #[HiddenFromMetadataQuery]
public string $legacy_price;
#[Ignore]
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php
index ea1b75bc835..45487720e96 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php
@@ -16,9 +16,17 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class CreateWidget {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull( WidgetType::get() ),
- 'description' => __( 'Create a new widget', 'woocommerce' ),
- 'args' => array(
+ 'type' => Type::nonNull( WidgetType::get() ),
+ 'description' => __( 'Create a new widget', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_options',
+ ),
+ ),
+ ),
+ 'args' => array(
'input' => array(
'type' => Type::nonNull( CreateWidgetInput::get() ),
'description' => __( 'The data for the new widget', 'woocommerce' ),
@@ -29,7 +37,7 @@ class CreateWidget {
'defaultValue' => null,
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -40,6 +48,12 @@ class CreateWidget {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( CreateWidgetCommand::class );
$execute_args = array();
@@ -50,6 +64,22 @@ class CreateWidget {
$execute_args['related_inputs'] = $args['related_inputs'];
}
+ if ( isset( $execute_args['input'] ) && $execute_args['input'] instanceof \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput ) {
+ $_parent = $execute_args['input'];
+ if ( $_parent->was_provided( 'weight' ) ) {
+ $principal = $context['principal'];
+ $_metadata = array(
+ 'query' => $context['_query_metadata'] ?? array(),
+ 'type' => array(),
+ 'field' => array(),
+ );
+ $_args = $args;
+ if ( ! ( ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'manage_woocommerce' ) )->authorize( $principal ) ) ) {
+ throw ResolverHelpers::build_field_authorization_error( $principal, 'CreateWidgetInput', 'weight', 'RequiredCapability' );
+ }
+ }
+ }
+
$result = ResolverHelpers::execute_command( $command, $execute_args );
return $result;
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php
index d7bb11c41c5..b8464304a1f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php
@@ -15,9 +15,17 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class DeleteWidget {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull( OperationResultType::get() ),
- 'description' => __( 'Delete a widget', 'woocommerce' ),
- 'args' => array(
+ 'type' => Type::nonNull( OperationResultType::get() ),
+ 'description' => __( 'Delete a widget', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_options',
+ ),
+ ),
+ ),
+ 'args' => array(
'id' => array(
'type' => Type::nonNull( Type::int() ),
'description' => __( 'The widget id to delete', 'woocommerce' ),
@@ -28,7 +36,7 @@ class DeleteWidget {
'defaultValue' => false,
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -39,6 +47,12 @@ class DeleteWidget {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( DeleteWidgetCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php
index a8d7f501ff6..8afdff89e90 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class Increment {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull(
+ 'type' => Type::nonNull(
new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
array(
'name' => 'IncrementResult',
@@ -24,8 +24,14 @@ class Increment {
)
)
),
- 'description' => __( 'Increment a value by an optional amount', 'woocommerce' ),
- 'args' => array(
+ 'description' => __( 'Increment a value by an optional amount', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'PublicAccess',
+ 'args' => array(),
+ ),
+ ),
+ 'args' => array(
'value' => array(
'type' => Type::nonNull( Type::int() ),
'description' => __( 'The starting value', 'woocommerce' ),
@@ -36,11 +42,17 @@ class Increment {
'defaultValue' => 1,
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( IncrementCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/PublicCreateWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/PublicCreateWidget.php
new file mode 100644
index 00000000000..baa07014195
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/PublicCreateWidget.php
@@ -0,0 +1,112 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Mutations\PublicCreateWidget as PublicCreateWidgetCommand;
+use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget as WidgetType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Input\CreateWidget as CreateWidgetInput;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ResolveInfo;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class PublicCreateWidget {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull( WidgetType::get() ),
+ 'description' => __( 'Create a widget without query-level gating; input-side gates apply.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'PublicAccess',
+ 'args' => array(),
+ ),
+ ),
+ 'args' => array(
+ 'input' => array(
+ 'type' => Type::nonNull( CreateWidgetInput::get() ),
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( PublicCreateWidgetCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'input', $args ) ) {
+ $execute_args['input'] = self::convert_create_widget_input( $args['input'] );
+ }
+
+ if ( isset( $execute_args['input'] ) && $execute_args['input'] instanceof \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput ) {
+ $_parent = $execute_args['input'];
+ if ( $_parent->was_provided( 'weight' ) ) {
+ $principal = $context['principal'];
+ $_metadata = array(
+ 'query' => $context['_query_metadata'] ?? array(),
+ 'type' => array(),
+ 'field' => array(),
+ );
+ $_args = $args;
+ if ( ! ( ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'manage_woocommerce' ) )->authorize( $principal ) ) ) {
+ throw ResolverHelpers::build_field_authorization_error( $principal, 'CreateWidgetInput', 'weight', 'RequiredCapability' );
+ }
+ }
+ }
+
+ $result = ResolverHelpers::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+
+ /**
+ * Compute the value `_preauthorized` would carry for a given principal —
+ * the AND of the autodiscovered authorization attributes' authorize()
+ * outcomes on this command. Single source of truth for both the resolver's
+ * own gates and external (code-API) callers asking about authorization
+ * without going through GraphQL execution.
+ *
+ * Returns true vacuously when the command has no authorization attributes
+ * (in that case authorize() on the command is the sole guard, and that
+ * method should be consulted instead).
+ */
+ public static function compute_preauthorized( \Automattic\WooCommerce\Api\Infrastructure\Principal $principal ): bool {
+ return true;
+ }
+
+ private static function convert_create_widget_input( array $data ): \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput {
+ $input = new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput();
+
+ if ( array_key_exists( 'label', $data ) ) {
+ $input->mark_provided( 'label' );
+ $input->label = $data['label'];
+ }
+ if ( array_key_exists( 'weight', $data ) ) {
+ $input->mark_provided( 'weight' );
+ $input->weight = $data['weight'];
+ }
+ if ( array_key_exists( 'color', $data ) ) {
+ $input->mark_provided( 'color' );
+ $input->color = $data['color'];
+ }
+ if ( array_key_exists( 'tag_ids', $data ) ) {
+ $input->mark_provided( 'tag_ids' );
+ $input->tag_ids = $data['tag_ids'];
+ }
+ if ( array_key_exists( 'expires_at', $data ) ) {
+ $input->mark_provided( 'expires_at' );
+ $input->expires_at = $data['expires_at'];
+ }
+
+ return $input;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php
index 1504871e30b..0d77556b7c0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php
@@ -35,6 +35,12 @@ class AuthorizeOnlyQuery {
}
public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( AuthorizeOnlyQueryCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php
index b8780269009..307633fcd51 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php
@@ -36,6 +36,12 @@ class AuthorizeThrowsQuery {
}
public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( AuthorizeThrowsQueryCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ComposedAuthorizeQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ComposedAuthorizeQuery.php
index f90591de93e..d3b6b81c34b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ComposedAuthorizeQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ComposedAuthorizeQuery.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class ComposedAuthorizeQuery {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull(
+ 'type' => Type::nonNull(
new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
array(
'name' => 'ComposedAuthorizeQueryResult',
@@ -24,13 +24,27 @@ class ComposedAuthorizeQuery {
)
)
),
- 'description' => __( 'Composes #[RequiredCapability] with authorize() via $_preauthorized', 'woocommerce' ),
- 'args' => array(),
- 'resolve' => array( self::class, 'resolve' ),
+ 'description' => __( 'Composes #[RequiredCapability] with authorize() via $_preauthorized', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_options',
+ ),
+ ),
+ ),
+ 'args' => array(),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( ComposedAuthorizeQueryCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php
index d803d5be750..726e944a899 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class FailingQuery {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull(
+ 'type' => Type::nonNull(
new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
array(
'name' => 'FailingQueryResult',
@@ -24,19 +24,31 @@ class FailingQuery {
)
)
),
- 'description' => __( 'Always throws an exception', 'woocommerce' ),
- 'args' => array(
+ 'description' => __( 'Always throws an exception', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'PublicAccess',
+ 'args' => array(),
+ ),
+ ),
+ 'args' => array(
'kind' => array(
'type' => Type::nonNull( Type::string() ),
'description' => __( 'What kind of failure to raise', 'woocommerce' ),
'defaultValue' => 'invalid_argument',
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( FailingQueryCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php
index a4fdd7e9147..f47a54ff2ce 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class GetGreeting {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull(
+ 'type' => Type::nonNull(
new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
array(
'name' => 'GetGreetingResult',
@@ -24,19 +24,31 @@ class GetGreeting {
)
)
),
- 'description' => __( 'Build a greeting', 'woocommerce' ),
- 'args' => array(
+ 'description' => __( 'Build a greeting', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'PublicAccess',
+ 'args' => array(),
+ ),
+ ),
+ 'args' => array(
'name' => array(
'type' => Type::string(),
'description' => __( 'Who to greet (defaults to \"world\")', 'woocommerce' ),
'defaultValue' => null,
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( GetGreetingCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php
index 42bc6b4aa35..c45f8bb0b68 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php
@@ -15,19 +15,31 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class GetIdentifiable {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull( NamedInterface::get() ),
- 'description' => __( 'Return either a Widget or a Gadget, both of which implement Named', 'woocommerce' ),
- 'args' => array(
+ 'type' => Type::nonNull( NamedInterface::get() ),
+ 'description' => __( 'Return either a Widget or a Gadget, both of which implement Named', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'PublicAccess',
+ 'args' => array(),
+ ),
+ ),
+ 'args' => array(
'kind' => array(
'type' => Type::nonNull( Type::string() ),
'description' => __( 'Which kind of object to return', 'woocommerce' ),
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( GetIdentifiableCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php
index 29bd884abae..77bf79f5b05 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php
@@ -15,15 +15,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class GetWidget {
public static function get_field_definition(): array {
return array(
- 'type' => WidgetType::get(),
- 'description' => __( 'Fetch a single widget by ID', 'woocommerce' ),
- 'args' => array(
+ 'type' => WidgetType::get(),
+ 'description' => __( 'Fetch a single widget by ID', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_options',
+ ),
+ ),
+ ),
+ 'args' => array(
'id' => array(
'type' => Type::nonNull( Type::int() ),
'description' => __( 'The ID of the widget to fetch', 'woocommerce' ),
),
),
- 'resolve' => array( self::class, 'resolve' ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -34,6 +42,12 @@ class GetWidget {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( GetWidgetCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/HiddenFlaggedQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/HiddenFlaggedQuery.php
new file mode 100644
index 00000000000..7558220b79c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/HiddenFlaggedQuery.php
@@ -0,0 +1,57 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\HiddenFlaggedQuery as HiddenFlaggedQueryCommand;
+use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\RuntimeMetaProbe as RuntimeMetaProbeType;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ResolveInfo;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class HiddenFlaggedQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull( RuntimeMetaProbeType::get() ),
+ 'description' => __( 'Probe query for runtime metadata visibility under #[HiddenFromMetadataQuery].', 'woocommerce' ),
+ 'args' => array(),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array(
+ 'runtime_flag' => true,
+ );
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( HiddenFlaggedQueryCommand::class );
+
+ $execute_args = array();
+
+ $result = ResolverHelpers::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+
+ /**
+ * Compute the value `_preauthorized` would carry for a given principal —
+ * the AND of the autodiscovered authorization attributes' authorize()
+ * outcomes on this command. Single source of truth for both the resolver's
+ * own gates and external (code-API) callers asking about authorization
+ * without going through GraphQL execution.
+ *
+ * Returns true vacuously when the command has no authorization attributes
+ * (in that case authorize() on the command is the sole guard, and that
+ * method should be consulted instead).
+ */
+ public static function compute_preauthorized( \Automattic\WooCommerce\Api\Infrastructure\Principal $principal ): bool {
+ return true;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/IgnoredAuthorizeQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/IgnoredAuthorizeQuery.php
index 68b176032a9..49759a0e7b6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/IgnoredAuthorizeQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/IgnoredAuthorizeQuery.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class IgnoredAuthorizeQuery {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull(
+ 'type' => Type::nonNull(
new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
array(
'name' => 'IgnoredAuthorizeQueryResult',
@@ -24,9 +24,17 @@ class IgnoredAuthorizeQuery {
)
)
),
- 'description' => __( 'authorize() with #[Ignore] is skipped; the cap check applies', 'woocommerce' ),
- 'args' => array(),
- 'resolve' => array( self::class, 'resolve' ),
+ 'description' => __( 'authorize() with #[Ignore] is skipped; the cap check applies', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_options',
+ ),
+ ),
+ ),
+ 'args' => array(),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -37,6 +45,12 @@ class IgnoredAuthorizeQuery {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( IgnoredAuthorizeQueryCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedCapQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedCapQuery.php
index 6db04a5175b..d20ceed9050 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedCapQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedCapQuery.php
@@ -37,6 +37,12 @@ class InheritedCapQuery {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( InheritedCapQueryCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedFromInterfaceQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedFromInterfaceQuery.php
index e24513f1b1c..773de3237c8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedFromInterfaceQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedFromInterfaceQuery.php
@@ -37,6 +37,12 @@ class InheritedFromInterfaceQuery {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( InheritedFromInterfaceQueryCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedPublicQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedPublicQuery.php
index e342897a22f..db5fac27d19 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedPublicQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedPublicQuery.php
@@ -31,6 +31,12 @@ class InheritedPublicQuery {
}
public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( InheritedPublicQueryCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php
index b518a932fdc..081ff2c36fa 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php
@@ -17,9 +17,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class ListWidgets {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull( WidgetConnectionType::get() ),
- 'description' => __( 'List widgets with cursor-based pagination', 'woocommerce' ),
- 'args' => array(
+ 'type' => Type::nonNull( WidgetConnectionType::get() ),
+ 'description' => __( 'List widgets with cursor-based pagination', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_options',
+ ),
+ ),
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'edit_posts',
+ ),
+ ),
+ ),
+ 'args' => array(
'first' => array(
'type' => Type::int(),
'description' => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
@@ -56,8 +70,8 @@ class ListWidgets {
'defaultValue' => null,
),
),
- 'complexity' => ResolverHelpers::complexity_from_pagination( ... ),
- 'resolve' => array( self::class, 'resolve' ),
+ 'complexity' => ResolverHelpers::complexity_from_pagination( ... ),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -68,6 +82,12 @@ class ListWidgets {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( ListWidgetsCommand::class );
$query_info = QueryInfoExtractor::extract_from_info( $info, $args );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgetsArray.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgetsArray.php
new file mode 100644
index 00000000000..0458a0d91c0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgetsArray.php
@@ -0,0 +1,61 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\ListWidgetsArray as ListWidgetsArrayCommand;
+use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ResolveInfo;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class ListWidgetsArray {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( Widget::get() ) ) ),
+ 'description' => __( 'List widgets without pagination.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'PublicAccess',
+ 'args' => array(),
+ ),
+ ),
+ 'args' => array(),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( ListWidgetsArrayCommand::class );
+
+ $execute_args = array();
+
+ $result = ResolverHelpers::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+
+ /**
+ * Compute the value `_preauthorized` would carry for a given principal —
+ * the AND of the autodiscovered authorization attributes' authorize()
+ * outcomes on this command. Single source of truth for both the resolver's
+ * own gates and external (code-API) callers asking about authorization
+ * without going through GraphQL execution.
+ *
+ * Returns true vacuously when the command has no authorization attributes
+ * (in that case authorize() on the command is the sole guard, and that
+ * method should be consulted instead).
+ */
+ public static function compute_preauthorized( \Automattic\WooCommerce\Api\Infrastructure\Principal $principal ): bool {
+ return true;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MergedCapsQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MergedCapsQuery.php
index 450a8d511b9..49913ff5fd8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MergedCapsQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MergedCapsQuery.php
@@ -37,6 +37,12 @@ class MergedCapsQuery {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( MergedCapsQueryCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MetadataAwareInternalQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MetadataAwareInternalQuery.php
new file mode 100644
index 00000000000..52ed18eb0b6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MetadataAwareInternalQuery.php
@@ -0,0 +1,88 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\MetadataAwareInternalQuery as MetadataAwareInternalQueryCommand;
+use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ResolveInfo;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class MetadataAwareInternalQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(
+ new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
+ array(
+ 'name' => 'MetadataAwareInternalQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull( Type::string() ) ),
+ ),
+ )
+ )
+ ),
+ 'description' => __( '[Internal] Exercises RequiresInternalFlag against a class that carries #[Internal].', 'woocommerce' ),
+ 'metadata' => array(
+ 'internal' => true,
+ ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiresInternalFlag',
+ 'args' => array(),
+ ),
+ ),
+ 'args' => array(),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Standalone authorization gate: no authorize() method on the command,
+ // so the autodiscovered authorization attributes are the sole guard.
+ if ( ! self::compute_preauthorized( $context['principal'] ) ) {
+ throw ResolverHelpers::build_authorization_error( $context['principal'] );
+ }
+
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array(
+ 'internal' => true,
+ );
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( MetadataAwareInternalQueryCommand::class );
+
+ $execute_args = array();
+
+ $result = ResolverHelpers::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+
+ /**
+ * Compute the value `_preauthorized` would carry for a given principal —
+ * the AND of the autodiscovered authorization attributes' authorize()
+ * outcomes on this command. Single source of truth for both the resolver's
+ * own gates and external (code-API) callers asking about authorization
+ * without going through GraphQL execution.
+ *
+ * Returns true vacuously when the command has no authorization attributes
+ * (in that case authorize() on the command is the sole guard, and that
+ * method should be consulted instead).
+ */
+ public static function compute_preauthorized( \Automattic\WooCommerce\Api\Infrastructure\Principal $principal ): bool {
+ return ( new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\RequiresInternalFlag() )->authorize(
+ $principal,
+ _metadata: array(
+ 'query' =>
+ array(
+ 'internal' => true,
+ ),
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MetadataAwareNoFlagQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MetadataAwareNoFlagQuery.php
new file mode 100644
index 00000000000..176a15b6e9b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MetadataAwareNoFlagQuery.php
@@ -0,0 +1,81 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\MetadataAwareNoFlagQuery as MetadataAwareNoFlagQueryCommand;
+use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ResolveInfo;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class MetadataAwareNoFlagQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(
+ new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
+ array(
+ 'name' => 'MetadataAwareNoFlagQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull( Type::string() ) ),
+ ),
+ )
+ )
+ ),
+ 'description' => __( 'Exercises RequiresInternalFlag without the matching #[Internal] entry.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiresInternalFlag',
+ 'args' => array(),
+ ),
+ ),
+ 'args' => array(),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Standalone authorization gate: no authorize() method on the command,
+ // so the autodiscovered authorization attributes are the sole guard.
+ if ( ! self::compute_preauthorized( $context['principal'] ) ) {
+ throw ResolverHelpers::build_authorization_error( $context['principal'] );
+ }
+
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( MetadataAwareNoFlagQueryCommand::class );
+
+ $execute_args = array();
+
+ $result = ResolverHelpers::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+
+ /**
+ * Compute the value `_preauthorized` would carry for a given principal —
+ * the AND of the autodiscovered authorization attributes' authorize()
+ * outcomes on this command. Single source of truth for both the resolver's
+ * own gates and external (code-API) callers asking about authorization
+ * without going through GraphQL execution.
+ *
+ * Returns true vacuously when the command has no authorization attributes
+ * (in that case authorize() on the command is the sole guard, and that
+ * method should be consulted instead).
+ */
+ public static function compute_preauthorized( \Automattic\WooCommerce\Api\Infrastructure\Principal $principal ): bool {
+ return ( new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\RequiresInternalFlag() )->authorize(
+ $principal,
+ _metadata: array(
+ 'query' =>
+ array(),
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenAuthorizeQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenAuthorizeQuery.php
index 0d7fdd15935..18c1e5c0d1d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenAuthorizeQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenAuthorizeQuery.php
@@ -31,6 +31,12 @@ class OverriddenAuthorizeQuery {
}
public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( OverriddenAuthorizeQueryCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenCapQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenCapQuery.php
index 33ad3b802b9..95240d9935c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenCapQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenCapQuery.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
class OverriddenCapQuery {
public static function get_field_definition(): array {
return array(
- 'type' => Type::nonNull(
+ 'type' => Type::nonNull(
new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
array(
'name' => 'OverriddenCapQueryResult',
@@ -24,9 +24,17 @@ class OverriddenCapQuery {
)
)
),
- 'description' => __( 'Overrides the inherited manage_options with manage_categories', 'woocommerce' ),
- 'args' => array(),
- 'resolve' => array( self::class, 'resolve' ),
+ 'description' => __( 'Overrides the inherited manage_options with manage_categories', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_categories',
+ ),
+ ),
+ ),
+ 'args' => array(),
+ 'resolve' => array( self::class, 'resolve' ),
);
}
@@ -37,6 +45,12 @@ class OverriddenCapQuery {
throw ResolverHelpers::build_authorization_error( $context['principal'] );
}
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( OverriddenCapQueryCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PrincipalAwareQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PrincipalAwareQuery.php
index 71a443d2657..4d1c6cd6d8e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PrincipalAwareQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PrincipalAwareQuery.php
@@ -31,6 +31,12 @@ class PrincipalAwareQuery {
}
public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( PrincipalAwareQueryCommand::class );
$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PublicWidgetAccess.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PublicWidgetAccess.php
new file mode 100644
index 00000000000..9c3c66a9a66
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PublicWidgetAccess.php
@@ -0,0 +1,61 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\PublicWidgetAccess as PublicWidgetAccessCommand;
+use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget as WidgetType;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ResolveInfo;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class PublicWidgetAccess {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => WidgetType::get(),
+ 'description' => __( 'Fetch a widget without query-level gating; field-level gates apply.', 'woocommerce' ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'PublicAccess',
+ 'args' => array(),
+ ),
+ ),
+ 'args' => array(),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ // Publish the root query's metadata so downstream field-level
+ // authorization gates can read it via `$_metadata['query']`.
+ // $context is an ArrayObject (see GraphQLController::process_request())
+ // so the mutation propagates to nested resolvers.
+ $context['_query_metadata'] = array();
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( PublicWidgetAccessCommand::class );
+
+ $execute_args = array();
+
+ $result = ResolverHelpers::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+
+ /**
+ * Compute the value `_preauthorized` would carry for a given principal —
+ * the AND of the autodiscovered authorization attributes' authorize()
+ * outcomes on this command. Single source of truth for both the resolver's
+ * own gates and external (code-API) callers asking about authorization
+ * without going through GraphQL execution.
+ *
+ * Returns true vacuously when the command has no authorization attributes
+ * (in that case authorize() on the command is the sole guard, and that
+ * method should be consulted instead).
+ */
+ public static function compute_preauthorized( \Automattic\WooCommerce\Api\Infrastructure\Principal $principal ): bool {
+ return true;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/RuntimeMetaProbe.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/RuntimeMetaProbe.php
new file mode 100644
index 00000000000..d75e5c44fca
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/RuntimeMetaProbe.php
@@ -0,0 +1,102 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class RuntimeMetaProbe {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'RuntimeMetaProbe',
+ 'fields' => fn() => array(
+ 'by_type' => array(
+ 'type' => Type::string(),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'GrantsIfMetadataFlag',
+ 'args' => array(
+ 0 => 'type',
+ ),
+ ),
+ ),
+ 'resolve' => function ( $parent, $args, $context ) {
+ $principal = $context['principal'];
+ $_metadata = array(
+ 'query' => $context['_query_metadata'] ?? array(),
+ 'type' => array(
+ 'runtime_flag' => true,
+ ),
+ 'field' => array(),
+ );
+ $_args = $args;
+ $_parent = $parent;
+ if ( ! ( ( new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\GrantsIfMetadataFlag( 'type' ) )->authorize( $principal, _metadata: $_metadata ) ) ) {
+ throw ResolverHelpers::build_field_authorization_error( $principal, 'RuntimeMetaProbe', 'by_type', 'GrantsIfMetadataFlag' );
+ }
+ return $parent->by_type;
+ },
+ ),
+ 'by_field' => array(
+ 'type' => Type::string(),
+ 'resolve' => function ( $parent, $args, $context ) {
+ $principal = $context['principal'];
+ $_metadata = array(
+ 'query' => $context['_query_metadata'] ?? array(),
+ 'type' => array(
+ 'runtime_flag' => true,
+ ),
+ 'field' => array(
+ 'runtime_flag' => true,
+ ),
+ );
+ $_args = $args;
+ $_parent = $parent;
+ if ( ! ( ( new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\GrantsIfMetadataFlag( 'field' ) )->authorize( $principal, _metadata: $_metadata ) ) ) {
+ throw ResolverHelpers::build_field_authorization_error( $principal, 'RuntimeMetaProbe', 'by_field', 'GrantsIfMetadataFlag' );
+ }
+ return $parent->by_field;
+ },
+ ),
+ 'by_query' => array(
+ 'type' => Type::string(),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'GrantsIfMetadataFlag',
+ 'args' => array(
+ 0 => 'query',
+ ),
+ ),
+ ),
+ 'resolve' => function ( $parent, $args, $context ) {
+ $principal = $context['principal'];
+ $_metadata = array(
+ 'query' => $context['_query_metadata'] ?? array(),
+ 'type' => array(
+ 'runtime_flag' => true,
+ ),
+ 'field' => array(),
+ );
+ $_args = $args;
+ $_parent = $parent;
+ if ( ! ( ( new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\GrantsIfMetadataFlag( 'query' ) )->authorize( $principal, _metadata: $_metadata ) ) ) {
+ throw ResolverHelpers::build_field_authorization_error( $principal, 'RuntimeMetaProbe', 'by_query', 'GrantsIfMetadataFlag' );
+ }
+ return $parent->by_query;
+ },
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php
index 8e9cd75b08c..f4fa31ea6a3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\Gra
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Pagination\WidgetReviewConnection as WidgetReviewConnectionType;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Scalars\DummyDateTime as DummyDateTimeType;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces\Named as NamedInterface;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
use Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType;
use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
@@ -32,8 +33,35 @@ class Widget {
'description' => __( 'A short slug', 'woocommerce' ),
),
'caption' => array(
- 'type' => Type::string(),
- 'description' => __( 'An optional caption', 'woocommerce' ),
+ 'type' => Type::string(),
+ 'description' => __( 'An optional caption', 'woocommerce' ),
+ 'metadata' => array(
+ 'visible_sample' => 'visible',
+ ),
+ 'authorization' => array(
+ array(
+ 'attribute' => 'RequiredCapability',
+ 'args' => array(
+ 0 => 'manage_woocommerce',
+ ),
+ ),
+ ),
+ 'resolve' => function ( $parent, $args, $context ) {
+ $principal = $context['principal'];
+ $_metadata = array(
+ 'query' => $context['_query_metadata'] ?? array(),
+ 'type' => array(),
+ 'field' => array(
+ 'visible_sample' => 'visible',
+ ),
+ );
+ $_args = $args;
+ $_parent = $parent;
+ if ( ! ( ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'manage_woocommerce' ) )->authorize( $principal ) ) ) {
+ throw ResolverHelpers::build_field_authorization_error( $principal, 'Widget', 'caption', 'RequiredCapability' );
+ }
+ return $parent->caption;
+ },
),
'color' => array(
'type' => Type::nonNull( ColorType::get() ),
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootMutationType.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootMutationType.php
index 218c7615d56..442384b392a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootMutationType.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootMutationType.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerat
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations\Increment;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations\CreateWidget;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations\PublicCreateWidget;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations\DeleteWidget;
use Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType;
@@ -19,9 +20,10 @@ class RootMutationType {
array(
'name' => 'Mutation',
'fields' => fn() => array(
- 'increment' => Increment::get_field_definition(),
- 'createWidget' => CreateWidget::get_field_definition(),
- 'deleteWidget' => DeleteWidget::get_field_definition(),
+ 'increment' => Increment::get_field_definition(),
+ 'createWidget' => CreateWidget::get_field_definition(),
+ 'publicCreateWidget' => PublicCreateWidget::get_field_definition(),
+ 'deleteWidget' => DeleteWidget::get_field_definition(),
),
)
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php
index 94c43ee40f8..e27b1c8bd43 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php
@@ -12,13 +12,18 @@ use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\Gra
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MergedCapsQuery;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\InheritedPublicQuery;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\GetWidget;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\PublicWidgetAccess;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\AuthorizeThrowsQuery;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\PrincipalAwareQuery;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\ComposedAuthorizeQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MetadataAwareInternalQuery;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\IgnoredAuthorizeQuery;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\OverriddenAuthorizeQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MetadataAwareNoFlagQuery;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\AuthorizeOnlyQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\HiddenFlaggedQuery;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\FailingQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\ListWidgetsArray;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\ListWidgets;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\GetGreeting;
use Automattic\WooCommerce\Api\Infrastructure\MetadataController;
@@ -40,13 +45,18 @@ class RootQueryType {
'mergedCaps' => MergedCapsQuery::get_field_definition(),
'inheritedPublic' => InheritedPublicQuery::get_field_definition(),
'widget' => GetWidget::get_field_definition(),
+ 'publicWidget' => PublicWidgetAccess::get_field_definition(),
'authorizeThrows' => AuthorizeThrowsQuery::get_field_definition(),
'principalAware' => PrincipalAwareQuery::get_field_definition(),
'composedAuthorize' => ComposedAuthorizeQuery::get_field_definition(),
+ 'metadataAwareInternalQuery' => MetadataAwareInternalQuery::get_field_definition(),
'ignoredAuthorize' => IgnoredAuthorizeQuery::get_field_definition(),
'overriddenAuthorize' => OverriddenAuthorizeQuery::get_field_definition(),
+ 'metadataAwareNoFlagQuery' => MetadataAwareNoFlagQuery::get_field_definition(),
'authorizeOnly' => AuthorizeOnlyQuery::get_field_definition(),
+ 'hiddenFlagged' => HiddenFlaggedQuery::get_field_definition(),
'failing' => FailingQuery::get_field_definition(),
+ 'widgetList' => ListWidgetsArray::get_field_definition(),
'widgets' => ListWidgets::get_field_definition(),
'greeting' => GetGreeting::get_field_definition(),
MetadataController::FIELD_NAME => MetadataController::get_field_definition(),
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt
index ce84cafa7c8..bc8bcfebc84 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt
@@ -1 +1 @@
-2026-05-19T11:37:11+00:00
\ No newline at end of file
+2026-05-21T11:44:02+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_source_hash.txt b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_source_hash.txt
index 80286b03311..a2a92acab36 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_source_hash.txt
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_source_hash.txt
@@ -1 +1 @@
-ec363c5fbb3e6b00910ae834390b114cba4d75123da6217f239a95618e5a270f
\ No newline at end of file
+f18a5e93805642a14bacb2ad90ab3c7db3e1731a75187d032992eaa872f67533
\ No newline at end of file
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/MetadataAttributeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/MetadataAttributeTest.php
index b2abdd73d52..5f1af955ca3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/MetadataAttributeTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/MetadataAttributeTest.php
@@ -65,4 +65,28 @@ class MetadataAttributeTest extends WC_Unit_Test_Case {
$attribute = $attributes[0]->newInstance();
$this->assertNotSame( 0, $attribute->flags & \Attribute::IS_REPEATABLE );
}
+
+ /**
+ * @testdox Metadata::shows_in_metadata_query() defaults to true so existing entries surface through `_apiMetadata`.
+ */
+ public function test_shows_in_metadata_query_defaults_to_true(): void {
+ $metadata = new Metadata( 'sample', 'value' );
+ $this->assertTrue( $metadata->shows_in_metadata_query() );
+ }
+
+ /**
+ * @testdox A Metadata subclass can override shows_in_metadata_query() to opt out of `_apiMetadata` exposure.
+ */
+ public function test_shows_in_metadata_query_can_be_overridden_to_false(): void {
+ $hidden = new class('hidden', 'value') extends Metadata {
+ /**
+ * Opt the carrying target out of the `_apiMetadata` query.
+ */
+ public function shows_in_metadata_query(): bool {
+ return false;
+ }
+ };
+
+ $this->assertFalse( $hidden->shows_in_metadata_query() );
+ }
}