Skip to content

Commit 2d6a146

Browse files
authored
refactor(api): expose abilities as enum arrays of granted values (#2336)
Replace the boolean-flags ability objects (SubmissionAbilities et al.) with GraphQL enum arrays carrying only the abilities the viewer holds: submission.abilities: [SubmissionAbility!]! publication.abilities: [PublicationAbility!]! currentUser.abilities: [UserAbility!]! An ability enum case joins the public contract only when annotated #[Exposed('...')]; the required description flows into the schema as the enum value's description. The deprecated LegacyUpdate bridge is unexposed and leaves the public API. The @abilityEnum directive (replacing @abilityFields, now removed) builds the GraphQL enums from the exposed cases via the shared AbilityExposure derivation, so schema, resolvers, and client types cannot drift from the PHP enums. Enum values are constructed as AST nodes — never spliced into SDL — so no description text can break schema parsing. Exposure reflection is memoized per process. Resolvers emit granted exposed names through the same engines the policies use (Bouncer global, ScopedAbilityResolver scoped); guests get an empty array. admin_area is a real exposed case, derived server-side as the union of granted admin_* abilities. Exposed names stay lowercase snake_case (matching UserRoles and the former field names), so client gate strings are untouched by the shape change. Descriptions state what an ability permits, never when it is granted — grant state travels as presence in the abilities array. Conformance tests lock the contract in both directions: every exposed scoped ability is granted by at least one role, and every granted ability is exposed or explicitly allowlisted as server-only. The client schema snapshot is regenerated, and the snapshot test's failure hint now names the real regeneration flow (client codegen's schema-ast output).
1 parent cf681cd commit 2d6a146

19 files changed

Lines changed: 804 additions & 468 deletions
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\Auth\Abilities;
5+
6+
use Illuminate\Support\Str;
7+
use ReflectionEnum;
8+
use UnitEnum;
9+
10+
/**
11+
* Reads the {@see Exposed} attribute off ability enum cases: which cases are
12+
* part of the public GraphQL contract, their exposed names, and their required
13+
* descriptions. The single derivation both sides share — the `@abilityEnum`
14+
* schema directive builds the GraphQL enum values from it, and the `abilities`
15+
* resolvers emit granted exposed names through it — so the schema and the runtime
16+
* cannot drift.
17+
*/
18+
final class AbilityExposure
19+
{
20+
/**
21+
* The exposed cases of an ability enum, keyed by exposed name, with their
22+
* descriptions.
23+
*
24+
* Pure over the enum class — attributes are code constants — so the
25+
* reflection is memoized for the process lifetime (same rationale as
26+
* {@see \App\Auth\Roles\ScopedRole::rolesGranting}): the abilities
27+
* resolvers call this per entity, and a list endpoint must not pay a
28+
* ReflectionEnum walk per row.
29+
*
30+
* @param class-string<\UnitEnum> $enum
31+
* @return array<string, array{case: \UnitEnum, description: string}>
32+
*/
33+
public static function exposed(string $enum): array
34+
{
35+
static $cache = [];
36+
if (isset($cache[$enum])) {
37+
return $cache[$enum];
38+
}
39+
40+
$reflection = new ReflectionEnum($enum);
41+
42+
$exposed = [];
43+
foreach ($reflection->getCases() as $case) {
44+
$attributes = $case->getAttributes(Exposed::class);
45+
if ($attributes === []) {
46+
continue;
47+
}
48+
/** @var \App\Auth\Abilities\Exposed $attribute */
49+
$attribute = $attributes[0]->newInstance();
50+
$instance = $case->getValue();
51+
$exposed[self::exposedName($instance)] = [
52+
'case' => $instance,
53+
'description' => $attribute->description,
54+
];
55+
}
56+
57+
return $cache[$enum] = $exposed;
58+
}
59+
60+
/**
61+
* The GraphQL enum value for a case: `Str::snake` of the case name,
62+
* matching this schema's lowercase enum-value convention (`UserRoles`,
63+
* `PublicationRole`) and the former ability field names.
64+
*/
65+
public static function exposedName(UnitEnum $case): string
66+
{
67+
return Str::snake($case->name);
68+
}
69+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\Auth\Abilities;
5+
6+
use Attribute;
7+
8+
/**
9+
* Marks an ability enum case as part of the public GraphQL API contract.
10+
*
11+
* Cases are server-only by default; annotating one exposes it as a value of the
12+
* corresponding GraphQL ability enum (via the `@abilityEnum` schema directive)
13+
* and includes it in the granted-abilities arrays the `abilities` resolvers
14+
* return. The description is required — nothing can be exposed undocumented —
15+
* and flows into the schema as the enum value's description (introspection,
16+
* GraphiQL, codegen docs).
17+
*
18+
* An unexposed case fails safe: invisible to clients, skipped by the resolvers,
19+
* still fully usable server-side (policies, grants, Bouncer).
20+
*/
21+
#[Attribute(Attribute::TARGET_CLASS_CONSTANT)]
22+
final class Exposed
23+
{
24+
/**
25+
* @param string $description Required: becomes the GraphQL enum value's
26+
* description in the public schema.
27+
*/
28+
public function __construct(public readonly string $description)
29+
{
30+
}
31+
}

backend/app/Auth/Abilities/GlobalAbility.php

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,45 @@
1717
* check.
1818
*
1919
* The backing value is the Bouncer ability name, and it is deliberately the
20-
* SAME string as the generated GraphQL field — the snake_case of the case name.
20+
* SAME string as the GraphQL enum value — the snake_case of the case name.
2121
* So `AdminUserViewAny` is the Bouncer ability `admin_user_view_any` and the
22-
* UserAbilities field `admin_user_view_any`: one identifier, no dotted/snake
22+
* UserAbility enum value `admin_user_view_any`: one identifier, no dotted/snake
2323
* split to keep in sync. (Unlike {@see SubmissionAbility}, whose dotted values
2424
* are pinned by legacy pivot data, these global abilities are new and unseeded —
2525
* nothing grants them except the application administrator's everything()
26-
* wildcard — so the value is free to mirror the field name.)
26+
* wildcard — so the value is free to mirror the exposed name.)
2727
*
28-
* Cases whose name is prefixed `Admin` surface as `admin_*` flags, and the
29-
* client treats holding ANY `admin_*` ability as "may reach the admin area".
30-
* There is deliberately no single "can access admin" ability — admin visibility
31-
* is the union of admin capabilities, so a new global role that adds an
32-
* `admin_*` ability extends admin access with no client change.
28+
* Cases annotated {@see Exposed} are part of the public GraphQL contract: they
29+
* become values of the `UserAbility` GraphQL enum and appear in the viewer's
30+
* granted-abilities array. Unannotated cases stay server-only.
31+
*
32+
* Cases whose name is prefixed `Admin` are admin capabilities; the client gates
33+
* the admin area on {@see self::AdminArea}, the derived union of them, so a new
34+
* global role that adds an `admin_*` ability extends admin access with no
35+
* client change.
3336
*/
3437
enum GlobalAbility: string
3538
{
39+
#[Exposed('Viewer may create a publication.')]
3640
case PublicationCreate = 'publication_create';
3741

42+
#[Exposed("Viewer may see an individual user's admin detail page.")]
3843
case AdminUserView = 'admin_user_view';
44+
45+
#[Exposed('Viewer may browse the admin user list.')]
3946
case AdminUserViewAny = 'admin_user_view_any';
47+
48+
#[Exposed('Viewer may edit users in the admin area.')]
4049
case AdminUserUpdate = 'admin_user_update';
50+
51+
#[Exposed('Viewer may manage beta access and feature opt-ins for users.')]
4152
case AdminUserManageBeta = 'admin_user_manage_beta';
53+
54+
/**
55+
* DERIVED, never granted directly: held when the viewer holds ANY `admin_*`
56+
* ability. {@see \App\Models\User::globalAbilities()} computes the union
57+
* instead of asking Bouncer for this case.
58+
*/
59+
#[Exposed('Viewer may reach the admin area.')]
60+
case AdminArea = 'admin_area';
4261
}

backend/app/Auth/Abilities/PublicationAbility.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,17 @@
99
* Resolved against the publication by {@see ScopedAbilityResolver} via the
1010
* {@see ScopedRole} grant map. The backing value is the legacy dotted
1111
* identifier.
12+
*
13+
* Cases annotated {@see Exposed} are part of the public GraphQL contract: they
14+
* become values of the `PublicationAbility` GraphQL enum and appear in the
15+
* viewer's granted-abilities array on a publication. Unannotated cases stay
16+
* server-only.
1217
*/
1318
enum PublicationAbility: string implements ScopedAbility
1419
{
20+
#[Exposed('Viewer may read this publication.')]
1521
case View = 'publication.view';
22+
23+
#[Exposed('Viewer may edit this publication\'s settings, users, and content.')]
1624
case Update = 'publication.update';
1725
}

backend/app/Auth/Abilities/SubmissionAbility.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,15 @@
99
* Resolved against the submission (and its parent publication's admin roles) by
1010
* {@see ScopedAbilityResolver} via the {@see ScopedRole} grant map. The backing
1111
* value is the legacy dotted identifier.
12+
*
13+
* Cases annotated {@see Exposed} are part of the public GraphQL contract: they
14+
* become values of the `SubmissionAbility` GraphQL enum and appear in the
15+
* viewer's granted-abilities array on a submission. Unannotated cases stay
16+
* server-only.
1217
*/
1318
enum SubmissionAbility: string implements ScopedAbility
1419
{
20+
#[Exposed('Viewer may read this submission.')]
1521
case View = 'submission.view';
1622

1723
/**
@@ -20,21 +26,25 @@ enum SubmissionAbility: string implements ScopedAbility
2026
* manuscript-content mutations (and the title) gate on this. Folds in the
2127
* former UpdateTitle (title is content).
2228
*/
29+
#[Exposed('Viewer may edit the manuscript — body, file, and title.')]
2330
case UpdateContent = 'submission.update-content';
2431

2532
/**
2633
* Access the manuscript and post comments, held by reviewers (and inherited
2734
* up the role chain) only while the submission is reviewable (UNDER_REVIEW).
2835
*/
36+
#[Exposed('Viewer may access the manuscript and post comments.')]
2937
case Review = 'submission.review';
3038

3139
/**
3240
* Send a DRAFT in for review — the submitter's one forward action, distinct
3341
* from the editorial UpdateStatus state machine. Held by a submitter only
3442
* while DRAFT.
3543
*/
44+
#[Exposed('Viewer may send this submission in for review.')]
3645
case Submit = 'submission.submit';
3746

47+
#[Exposed('Viewer may change this submission\'s status.')]
3848
case UpdateStatus = 'submission.update-status';
3949

4050
/**
@@ -44,12 +54,20 @@ enum SubmissionAbility: string implements ScopedAbility
4454
* intent-shaped mutations. Nothing else should gate on it. Removed together
4555
* with `updateSubmission` once the migration is complete.
4656
*
57+
* Deliberately NOT exposed: the deprecated bridge is server-only and never
58+
* part of the public contract.
59+
*
4760
* @deprecated Transitional. Use {@see self::UpdateContent},
4861
* {@see self::Submit}, {@see self::UpdateStatus}, {@see self::Review}, etc.
4962
*/
5063
case LegacyUpdate = 'submission.legacy-update';
5164

65+
#[Exposed('Viewer may add or remove submitters.')]
5266
case UpdateSubmitters = 'submission.update-submitters';
67+
68+
#[Exposed('Viewer may assign or unassign reviewers.')]
5369
case UpdateReviewers = 'submission.update-reviewers';
70+
71+
#[Exposed('Viewer may assign or unassign review coordinators.')]
5472
case UpdateReviewCoordinators = 'submission.update-review-coordinators';
5573
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\GraphQL\Directives;
5+
6+
use App\Auth\Abilities\AbilityExposure;
7+
use GraphQL\Language\AST\EnumTypeDefinitionNode;
8+
use GraphQL\Language\AST\EnumValueDefinitionNode;
9+
use GraphQL\Language\AST\NameNode;
10+
use GraphQL\Language\AST\NodeList;
11+
use GraphQL\Language\AST\StringValueNode;
12+
use GraphQL\Language\AST\TypeDefinitionNode;
13+
use Nuwave\Lighthouse\Exceptions\DefinitionException;
14+
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
15+
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
16+
use Nuwave\Lighthouse\Support\Contracts\TypeManipulator;
17+
18+
/**
19+
* Builds a GraphQL ability enum's values from the {@see \App\Auth\Abilities\Exposed}
20+
* cases of a PHP ability enum, so the public ability vocabulary is generated from
21+
* the single source of truth and cannot drift. A new exposed case needs no schema
22+
* edit; an unannotated case never reaches the schema.
23+
*
24+
* Value names use {@see AbilityExposure::exposedName} (`Str::snake` of the case
25+
* name) — the same derivation the `abilities()` resolvers emit — and each value
26+
* carries the case's required `Exposed` description into introspection.
27+
*/
28+
class AbilityEnumDirective extends BaseDirective implements TypeManipulator
29+
{
30+
/**
31+
* The directive's SDL definition.
32+
*
33+
* @return string
34+
*/
35+
public static function definition(): string
36+
{
37+
// phpcs:disable
38+
return /** @lang GraphQL */ <<<'GRAPHQL'
39+
"""
40+
Build this enum's values from the `Exposed` cases of the given PHP ability enum,
41+
named by `Str::snake` of the case name — the same derivation the `abilities()`
42+
resolvers emit. Keeps the public ability vocabulary locked to the PHP enum.
43+
"""
44+
directive @abilityEnum(
45+
"""
46+
Fully-qualified ability enum class, e.g. "App\\Auth\\Abilities\\SubmissionAbility".
47+
"""
48+
enum: String!
49+
) on ENUM
50+
GRAPHQL;
51+
// phpcs:enable
52+
}
53+
54+
/**
55+
* Append one enum value per exposed PHP enum case.
56+
*
57+
* @param \Nuwave\Lighthouse\Schema\AST\DocumentAST $documentAST
58+
* @param \GraphQL\Language\AST\TypeDefinitionNode&\GraphQL\Language\AST\Node $typeDefinition
59+
* @return void
60+
* @throws \Nuwave\Lighthouse\Exceptions\DefinitionException
61+
*/
62+
public function manipulateTypeDefinition(DocumentAST &$documentAST, TypeDefinitionNode &$typeDefinition): void
63+
{
64+
if (! $typeDefinition instanceof EnumTypeDefinitionNode) {
65+
throw new DefinitionException(
66+
'@abilityEnum may only decorate an enum type, used on ' . $typeDefinition->getName()->value . '.'
67+
);
68+
}
69+
70+
$enum = $this->directiveArgValue('enum');
71+
if (! is_string($enum) || ! enum_exists($enum)) {
72+
throw new DefinitionException(
73+
"@abilityEnum on {$typeDefinition->getName()->value} requires a valid `enum` class, got "
74+
. var_export($enum, true) . '.'
75+
);
76+
}
77+
78+
$exposed = AbilityExposure::exposed($enum);
79+
if ($exposed === []) {
80+
throw new DefinitionException(
81+
"@abilityEnum on {$typeDefinition->getName()->value}: {$enum} exposes no cases."
82+
);
83+
}
84+
85+
$existing = [];
86+
foreach ($typeDefinition->values as $value) {
87+
$existing[$value->name->value] = true;
88+
}
89+
90+
foreach ($exposed as $exposedName => $exposure) {
91+
if (isset($existing[$exposedName])) {
92+
continue;
93+
}
94+
// Built as AST nodes, not spliced into SDL and re-parsed: a
95+
// StringValueNode carries the description verbatim, so no
96+
// character in it can break block-string lexing.
97+
$typeDefinition->values[] = new EnumValueDefinitionNode([
98+
'name' => new NameNode(['value' => $exposedName]),
99+
'description' => new StringValueNode([
100+
'value' => $exposure['description'],
101+
'block' => true,
102+
]),
103+
'directives' => new NodeList([]),
104+
]);
105+
}
106+
}
107+
}

0 commit comments

Comments
 (0)