diff --git a/docs/plans/2026-06-24-2406-permission-fresh-spatie-port.md b/docs/plans/2026-06-24-2406-permission-fresh-spatie-port.md index 38a76915a..f5d742c63 100644 --- a/docs/plans/2026-06-24-2406-permission-fresh-spatie-port.md +++ b/docs/plans/2026-06-24-2406-permission-fresh-spatie-port.md @@ -4,7 +4,7 @@ Fresh-port the current `spatie/laravel-permission` package into `src/permission` for Hypervel 0.4, while preserving the useful Hypervel-specific improvements from the existing package: -- Forbidden permissions, where a denied permission overrides direct and role-granted allows. +- Denied permissions, where an explicit deny overrides direct and role-granted allows. - Hot permission-check caching tuned for long-lived Swoole workers. - Coroutine-safe request/team state. - Strict PHP 8.4+ types, docblocks, and PHPStan-clean code. @@ -73,7 +73,7 @@ The current upstream state inspected for this plan was commit `c2c871a` with tag Teams are a supported Spatie feature and should be available in Hypervel. The base published migration should support teams when teams are enabled before the initial migration, and `permission:setup-teams` should be ported so an app can enable teams after its first install by publishing the teams upgrade migration stub. -7. Preserve forbidden permissions. +7. Preserve denied permissions. This is a real Hypervel improvement. It should be integrated into the Spatie API shape rather than kept as a separate old implementation. @@ -147,18 +147,18 @@ database/migrations/add_teams_fields.php.stub ## Current Hypervel Improvements To Preserve -### Forbidden Permissions +### Denied Permissions -Preserve the current `is_forbidden` concept: +Preserve the explicit denial concept using the final single-edge representation: -- Add `is_forbidden` to `role_has_permissions`. -- Add `is_forbidden` to `model_has_permissions`. -- `giveForbiddenTo(...)` attaches permissions with `is_forbidden = true`. -- `hasForbiddenPermission(...)` checks direct forbidden permissions. -- `hasForbiddenPermissionViaRoles(...)` checks forbidden permissions inherited through assigned roles. -- `hasPermissionTo(...)`, `checkPermissionTo(...)`, Gate checks, and middleware checks must return false when a forbidden permission applies. -- If a permission is both allowed and forbidden, forbidden wins. -- `getAllPermissions()` and `getPermissionsViaRoles()` should exclude forbidden permissions from the allowed result sets. +- Add `is_denied` to `role_has_permissions`. +- Add `is_denied` to `model_has_permissions`. +- `denyPermissionTo(...)` attaches permissions with `is_denied = true`. +- `hasDeniedPermission(...)` checks direct denied permissions. +- `hasDeniedPermissionViaRoles(...)` checks denied permissions inherited through assigned roles. +- `hasPermissionTo(...)`, `checkPermissionTo(...)`, Gate checks, and middleware checks must return false when a denied permission applies. +- If a permission appears in both allowed and denied sync inputs, denied wins. +- `getAllPermissions()` and `getPermissionsViaRoles()` should exclude denied permissions from the allowed result sets. Use Spatie's canonical methods as the public surface: @@ -183,7 +183,7 @@ Keep the useful idea from the current `PermissionManager`: direct model roles an Implement this through the new `PermissionRegistrar`: -- Global permission/role cache stores current role and permission records needed to hydrate checks, extended to include `is_forbidden` pivot data. +- Global permission/role cache stores current role and permission records needed to hydrate checks, extended to include `is_denied` pivot data. - Model direct-role cache stores assignment identifiers and pivot flags, not full role records. - Model direct-permission cache stores assignment identifiers and pivot flags, not full permission records. - Cache keys include morph class, model key, and active team id when teams are enabled. @@ -291,7 +291,7 @@ Register `AboutCommand` under `Hypervel Permissions`, not `Spatie Permissions`. - Teams - Wildcard Permissions - Passport Client Credentials -- Forbidden Permissions +- Denied Permissions ### Intentional Difference Comments @@ -373,7 +373,7 @@ Create the greenfield base migration for Hypervel 0.4 and port the teams upgrade Changes from Spatie: - Use Hypervel namespaces. -- Include `is_forbidden` columns. +- Include `is_denied` columns. - Keep teams support in the primary migration and also ship the teams upgrade stub for later opt-in. - Use `timestamp`, not `timestampTz`. - Include a real `down()` method on the base create migration, matching Spatie and the existing Hypervel migration. @@ -400,15 +400,15 @@ When teams are enabled: - role unique constraints include team foreign key. - pivot foreign keys still cascade on delete. -Add `is_forbidden`: +Add `is_denied`: ```php -$table->boolean('is_forbidden')->default(false); +$table->boolean('is_denied')->default(false); ``` -Use default false because this is a security flag and because old Hypervel semantics treat normal grants as allowed unless explicitly forbidden. +Use default false because normal grants are allowed unless explicitly denied. -Include `is_forbidden` in the primary keys for `model_has_permissions` and `role_has_permissions`. This allows an allow row and a forbidden row to coexist for the same permission tuple so forbidden permissions can override allows. +Keep `is_denied` outside the primary keys for `model_has_permissions` and `role_has_permissions`. Each assignment tuple has one row, and assigning the opposite effect updates that existing edge. ### Permission Registrar @@ -446,7 +446,7 @@ When serializing the global permission cache, use a simple explicit payload inst - permission attributes - role relation - role attributes -- `is_forbidden` pivot data for role-permission rows +- `is_denied` pivot data for role-permission rows Honor `permission.cache.column_names_except` when building the explicit payload so timestamp and soft-delete columns can be stripped without keeping Spatie's alias-compression complexity. @@ -568,7 +568,7 @@ Required model behavior: - role/permission relations use configured pivot keys. - user/model inverse relations use `model` morph naming. - teams support on role creation and queries. -- forbidden permission support in relations and checks. +- denied permission support in relations and checks. Use `CarbonInterface` or `CarbonImmutable` annotations where needed, not mutable `Carbon`. @@ -583,15 +583,15 @@ HasPermissions HasRoles ``` -Then merge Hypervel forbidden-permission behavior into `HasPermissions`. +Then merge Hypervel denied-permission behavior into `HasPermissions`. Important points: - `HasRoles` uses `HasPermissions`. - `Role` uses `HasAssignedModels`, `HasPermissions`, and `RefreshesPermissionCache`. - `Permission` uses `HasRoles` and `RefreshesPermissionCache`. -- `HasPermissions::permissions()` must include `withPivot('is_forbidden')`. -- `Role::permissions()` and `Permission::roles()` must include `withPivot('is_forbidden')`. +- `HasPermissions::permissions()` must include `withPivot('is_denied')`. +- `Role::permissions()` and `Permission::roles()` must include `withPivot('is_denied')`. - team pivot behavior must match Spatie. - `teams()` relation should return a harmless empty relation when teams are disabled, matching upstream v7.4.1+. - `scopeRole`, `scopeWithoutRole`, `scopePermission`, `scopeWithoutPermission`, `scopeTeam`, and `scopeWithoutTeam` must be ported. @@ -600,16 +600,16 @@ Important points: - `Model::preventLazyLoading()` tests should pass. - `Permission::getPermissions()` and `Role::getRoles()` must not mutate singleton registrar model classes as part of normal lookup. Upstream Spatie calls setters in some subclass paths; in Hypervel, lookup methods must pass the requested model class into registrar query helpers so one coroutine cannot change the class used by another coroutine. -Forbidden logic should fit into canonical methods: +Denied logic should fit into canonical methods: ```php public function hasPermissionTo($permission, ?string $guardName = null): bool { - if ($this->hasForbiddenPermission($permission, $guardName)) { + if ($this->hasDeniedPermission($permission, $guardName)) { return false; } - if ($this->hasForbiddenPermissionViaRoles($permission, $guardName)) { + if ($this->hasDeniedPermissionViaRoles($permission, $guardName)) { return false; } @@ -623,9 +623,9 @@ public function hasPermissionTo($permission, ?string $guardName = null): bool } ``` -When merging the archived forbidden-permission behavior, do not copy the old rough edges: +When merging the archived denied-permission behavior, do not copy the old rough edges: -- use strict comparisons and `(bool)` pivot casts for `is_forbidden` +- use strict comparisons and `(bool)` pivot casts for `is_denied` - use `getKey()` / `getKeyName()` and configured pivot keys, never hardcoded `id` For role models, `hasPermissionTo()` must still validate guard compatibility against the permission's guard. @@ -636,9 +636,9 @@ Port `Contracts\Wildcard`, `WildcardPermission`, wildcard exceptions, and all wi Keep Spatie's algorithm. It is already optimized upstream and includes recent performance work. Do not add a worker-lifetime wildcard index unless implementation proves it is needed and can be invalidated with the same model assignment versioning. The first fresh port should keep the wildcard behavior easy to compare with upstream. -Forbidden permissions must apply before wildcard allows. +Denied permissions must apply before wildcard allows. -Forbidden checks that run before the wildcard path must match the input against the subject's forbidden permission names, IDs, and enum values without routing wildcard-pattern strings through `filterPermission()`. A wildcard input such as `posts.*` should not throw `PermissionDoesNotExist` before the wildcard matcher has a chance to evaluate it. +Denied checks that run before the wildcard path must match the input against the subject's denied permission names, IDs, and enum values without routing wildcard-pattern strings through `filterPermission()`. A wildcard input such as `posts.*` should not throw `PermissionDoesNotExist` before the wildcard matcher has a chance to evaluate it. ### Events @@ -744,7 +744,7 @@ Update the package README to include: - current status for Hypervel 0.4 - installation - key differences from Spatie Laravel Permission -- forbidden permissions +- denied permissions - Swoole/cache notes - Passport support @@ -757,7 +757,7 @@ Add `Differences From Spatie Laravel Permission`: request/team state uses coroutine context, and permission cache freshness is handled by configured cache stores, per-coroutine memoization, stack cache, and explicit invalidation. -- Hypervel adds forbidden permissions. A forbidden permission explicitly denies +- Hypervel adds denied permissions. A denied permission explicitly rejects an ability and wins over direct or role-granted allows. ``` @@ -773,7 +773,7 @@ Update `src/boost/docs/permission.md` to match the fresh port: - commands. - route macros. - Blade directives. -- forbidden permissions. +- denied permissions. - cache memo / stack advice. Remove old `owner_*` docs. @@ -856,23 +856,23 @@ Key adaptations: - typed injected config repository - no runtime config mutation - configured cache repository plus memo wrapper -- forbidden pivot data in serialized cache +- denied pivot data in serialized cache - coroutine-safe team id resolver - cache key helpers for model role/direct permission caches - static `flushState()` for test cleanup ### 4. Port Models And Traits -Port `Role`, `Permission`, and traits. Merge forbidden permission support into `HasPermissions` after the Spatie base is compiling. +Port `Role`, `Permission`, and traits. Merge denied permission support into `HasPermissions` after the Spatie base is compiling. Why: this preserves upstream behavior first, then layers Hypervel's improvement into the correct points. -Forbidden methods to add: +Denied methods to add: ```php -public function giveForbiddenTo(...$permissions): static; -public function hasForbiddenPermission($permission, ?string $guardName = null): bool; -public function hasForbiddenPermissionViaRoles($permission, ?string $guardName = null): bool; +public function denyPermissionTo(...$permissions): static; +public function hasDeniedPermission($permission, ?string $guardName = null): bool; +public function hasDeniedPermissionViaRoles($permission, ?string $guardName = null): bool; ``` `syncPermissions()` keeps Spatie's variadic API and return type: @@ -881,13 +881,13 @@ public function hasForbiddenPermissionViaRoles($permission, ?string $guardName = $model->syncPermissions(['edit articles', 'delete articles']); ``` -Add a dedicated dual-list helper for forbidden sync so Spatie call sites stay intact: +Add a dedicated dual-effect helper so Spatie call sites stay intact: ```php -public function syncPermissionsWithForbidden(array|Collection $allowed = [], array|Collection $forbidden = []): array; +public function syncPermissionEffects(array|Collection $allowed = [], array|Collection $denied = []): array; ``` -The helper accepts allowed and forbidden permission lists, syncs both direct-pivot sets, invalidates direct permission caches, and returns the `BelongsToMany::sync()` change array. `syncPermissions(...$permissions)` must continue to return `static`, matching Spatie. +The helper accepts allowed and denied permission lists, synchronizes direct-pivot effects, invalidates direct permission caches, and returns the assignment change array. `syncPermissions(...$permissions)` must continue to return `static`, matching Spatie. ### 5. Port Middleware, Events, Blade, Route Macros, Commands @@ -1022,23 +1022,23 @@ Only add these comments where upstream tests would otherwise be ported. Add tests beyond upstream where Hypervel architecture or improvements require proof. -#### Forbidden Permissions +#### Denied Permissions Cover: -- direct forbidden permission denies `hasPermissionTo()` -- direct forbidden denies `can()` through Gate -- role forbidden denies role-granted allow -- role forbidden denies direct allow -- forbidden wins when allowed and forbidden are both passed -- forbidden permissions inherited through roles work with custom role and permission primary keys +- direct denied permission denies `hasPermissionTo()` +- direct denied permission denies `can()` through Gate +- role denied permission denies role-granted allow +- role denied permission denies direct allow +- denied wins when allowed and denied are both passed +- denied permissions inherited through roles work with custom role and permission primary keys - pure unit enums work for role and permission assignment/check paths -- `getAllPermissions()` excludes forbidden permissions -- `getPermissionsViaRoles()` excludes forbidden role permissions +- `getAllPermissions()` excludes denied permissions +- `getPermissionsViaRoles()` excludes denied role permissions - `syncPermissions()` keeps Spatie behavior -- forbidden sync helper returns correct attached/detached/updated data -- role-permission forbidden pivot is included in global cache -- model direct forbidden pivot is included in model direct-permission cache +- denied sync helper returns correct attached/detached/updated data +- role-permission denied pivot is included in global cache +- model direct denied pivot is included in model direct-permission cache #### Cache And Memo @@ -1047,7 +1047,7 @@ Cover: - repeated permission checks in one coroutine do not repeatedly hit the underlying configured cache store - `Cache::memo()` over a configured stack store works for permission cache reads - model direct role cache invalidates after `assignRole`, `removeRole`, and `syncRoles` -- model direct permission cache invalidates after `givePermissionTo`, `giveForbiddenTo`, `revokePermissionTo`, and forbidden sync helper +- model direct permission cache invalidates after `givePermissionTo`, `denyPermissionTo`, `revokePermissionTo`, and denied sync helper - role/permission model save/delete clears global permission cache - role/permission model save/delete bumps the model assignment-cache version so old per-subject cache entries are bypassed - `permission:cache-reset` clears the global catalog and bumps the model assignment-cache version @@ -1156,7 +1156,7 @@ composer fix - The package source is a fresh Hypervel port of the current upstream Spatie package. - Public APIs match Spatie unless this plan records an intentional Hypervel difference. -- Forbidden permissions are preserved and fully tested. +- Denied permissions are preserved and fully tested. - Teams are coroutine-safe. - Passport client-credentials support is present and fake-tested. - Events stay dormant when no listeners exist. @@ -1176,7 +1176,7 @@ composer fix 3. Pull `/tmp/spatie-laravel-permission`. 4. Confirm current package has been archived and only skeleton files remain. 5. Copy and port source files one at a time from upstream. -6. Merge the archived Hypervel forbidden-permission and cache improvements into the Spatie-shaped implementation. +6. Merge the archived Hypervel denied-permission and cache improvements into the Spatie-shaped implementation. 7. Port tests file by file, running each file immediately. 8. Add Hypervel-specific tests listed above. 9. Update README and Boost docs. diff --git a/docs/plans/2026-07-02-permission-forbidden-single-state-hardening.md b/docs/plans/2026-07-02-permission-denied-single-state-hardening.md similarity index 80% rename from docs/plans/2026-07-02-permission-forbidden-single-state-hardening.md rename to docs/plans/2026-07-02-permission-denied-single-state-hardening.md index 2dd267481..0bdf7e71c 100644 --- a/docs/plans/2026-07-02-permission-forbidden-single-state-hardening.md +++ b/docs/plans/2026-07-02-permission-denied-single-state-hardening.md @@ -1,17 +1,17 @@ -# Permission Forbidden Single-State Hardening Plan +# Permission Denied Single-State Hardening Plan ## Objective -Fix the permission package so forbidden permissions are modeled as a single state on each assignment edge, not as a second row that can coexist beside an allowed row. +Fix the permission package so denied permissions are modeled as a single state on each assignment edge, not as a second row that can coexist beside an allowed row. The final package must read as if it was designed this way from the start: - one database row per subject / permission / team edge -- `is_forbidden` is the effect stored on that edge +- `is_denied` is the effect stored on that edge - write methods flip the edge effect instead of creating competing rows -- read methods treat forbidden as deny and never depend on relation order +- read methods treat denied as deny and never depend on relation order - query scopes use the same effective permission semantics as `hasPermissionTo()` -- allowed permission accessors do not return forbidden rows +- allowed permission accessors do not return denied rows - assignment cache invalidation uses a fresh namespace token instead of numeric increments - README and Boost docs describe the final behavior without stale dual-row wording @@ -26,16 +26,16 @@ Greptile flagged order-dependent reads in: The real root cause is schema and write behavior, not only those readers. -Current migrations include `is_forbidden` in the primary key: +Current migrations include `is_denied` in the primary key: ```php $table->primary( - [$pivotPermission, $modelMorphKey, 'model_type', 'is_forbidden'], + [$pivotPermission, $modelMorphKey, 'model_type', 'is_denied'], 'model_has_permissions_permission_model_type_primary', ); $table->primary( - [$pivotPermission, $pivotRole, 'is_forbidden'], + [$pivotPermission, $pivotRole, 'is_denied'], 'role_has_permissions_permission_id_role_id_primary', ); ``` @@ -43,45 +43,45 @@ $table->primary( That allows two rows for the same edge: ```text -model_id=1, permission_id=5, is_forbidden=false -model_id=1, permission_id=5, is_forbidden=true +model_id=1, permission_id=5, is_denied=false +model_id=1, permission_id=5, is_denied=true ``` -Then reads that call `first()` can return whichever row the relation gives first. Even when a caller checks forbidden before allowed, the data model is still contradictory and has to be defended in too many places. +Then reads that call `first()` can return whichever row the relation gives first. Even when a caller checks denied before allowed, the data model is still contradictory and has to be defended in too many places. The correct model is one row per edge: ```text -model_id=1, permission_id=5, is_forbidden=true +model_id=1, permission_id=5, is_denied=true ``` Assigning allow or deny for the same edge updates that row's effect. -During implementation, `scopePermission()` was also found to ignore forbidden +During implementation, `scopePermission()` was also found to ignore denied effects. A model where `hasPermissionTo()` returns `false` could still match -`User::permission()` because the scope counted forbidden direct pivots and -forbidden role-permission pivots as grants. That is the same class of -forbidden-correctness bug and must be fixed in this plan. +`User::permission()` because the scope counted denied direct pivots and +denied role-permission pivots as grants. That is the same class of +denied-correctness bug and must be fixed in this plan. ## Agreed Decisions -1. Keep forbidden permissions as a Hypervel improvement. +1. Keep denied permissions as a Hypervel improvement. - Explicit deny is useful in role/permission systems. A forbidden assignment must win over a direct allow, a role allow, or another role's allow. + Explicit deny is useful in role/permission systems. A denied assignment must win over a direct allow, a role allow, or another role's allow. -2. Store forbidden as an effect, not an identity field. +2. Store denied as an effect, not an identity field. - `is_forbidden` stays as a boolean column on `model_has_permissions` and `role_has_permissions`, but it must not be part of the primary key. + `is_denied` stays as a boolean column on `model_has_permissions` and `role_has_permissions`, but it must not be part of the primary key. 3. Make write paths enforce single-state. - `givePermissionTo()` and `giveForbiddenTo()` must insert missing edges and update existing edges to the requested effect. They must not rely on duplicate rows. + `givePermissionTo()` and `denyPermissionTo()` must insert missing edges and update existing edges to the requested effect. They must not rely on duplicate rows. 4. Keep defensive read hardening. - Once schema and writes are fixed, duplicates cannot be created through public APIs. Readers still handle bad manual data by making forbidden win deterministically. + Once schema and writes are fixed, duplicates cannot be created through public APIs. Readers still handle bad manual data by making denied win deterministically. -5. Exclude forbidden rows from allowed permission accessors. +5. Exclude denied rows from allowed permission accessors. `getDirectPermissions()` and `getPermissionNames()` are allow-oriented APIs. They must not return explicit denies. @@ -99,7 +99,7 @@ forbidden-correctness bug and must be fixed in this plan. 8. Update docs to describe the final model only. - Documentation must not mention the old dual-row shape or leave wording that suggests allow and forbid rows can coexist for the same edge. + Documentation must not mention the old dual-row shape or leave wording that suggests allowed and denied rows can coexist for the same edge. ## Files To Change @@ -120,19 +120,19 @@ Current primary keys: ```php // Base migration, teams enabled. $table->primary( - [$teamForeignKey, $pivotPermission, $modelMorphKey, 'model_type', 'is_forbidden'], + [$teamForeignKey, $pivotPermission, $modelMorphKey, 'model_type', 'is_denied'], 'model_has_permissions_permission_model_type_primary', ); // Base migration, teams disabled. $table->primary( - [$pivotPermission, $modelMorphKey, 'model_type', 'is_forbidden'], + [$pivotPermission, $modelMorphKey, 'model_type', 'is_denied'], 'model_has_permissions_permission_model_type_primary', ); // Base migration, role permissions. $table->primary( - [$pivotPermission, $pivotRole, 'is_forbidden'], + [$pivotPermission, $pivotRole, 'is_denied'], 'role_has_permissions_permission_id_role_id_primary', ); ``` @@ -187,7 +187,7 @@ $table->primary( Keep: ```php -$table->boolean('is_forbidden')->default(false); +$table->boolean('is_denied')->default(false); ``` ### Assignment Writes @@ -199,18 +199,18 @@ File: Current write path: ```php -private function attachPermissions(array $permissions, bool $isForbidden): static +private function attachPermissions(array $permissions, bool $isDenied): static { $permissions = $this->collectPermissions($permissions); $model = $this; $registrar = $this->permissionRegistrar(); $teamPivot = $registrar->teams && ! $this instanceof Role ? [$registrar->teamsKey => getPermissionsTeamId()] : []; - $pivot = $teamPivot + ['is_forbidden' => $isForbidden]; + $pivot = $teamPivot + ['is_denied' => $isDenied]; if ($model->exists) { $currentPermissions = $this->relationCollection($this->loadMissing('permissions'), 'permissions') - ->filter(fn (Model $permission): bool => $this->pivotIsForbidden($permission) === $isForbidden) + ->filter(fn (Model $permission): bool => $this->pivotIsDenied($permission) === $isDenied) ->map(fn (Model $permission) => $permission->getKey()) ->toArray(); @@ -229,7 +229,7 @@ Target behavior: - collect permission ids - build the team pivot once - for persisted models, find existing related permission ids regardless of effect -- attach missing ids with the requested `is_forbidden` value +- attach missing ids with the requested `is_denied` value - update existing ids whose pivot effect differs - do not keep same-flag dedup logic - unset the relation after mutation @@ -238,12 +238,12 @@ Target behavior: Target shape: ```php -private function attachPermissions(array $permissions, bool $isForbidden): static +private function attachPermissions(array $permissions, bool $isDenied): static { $permissions = $this->collectPermissions($permissions); $model = $this; $registrar = $this->permissionRegistrar(); - $pivot = $this->permissionAssignmentPivot($isForbidden); + $pivot = $this->permissionAssignmentPivot($isDenied); if ($model->exists) { $this->upsertPermissionAssignments($permissions, $pivot); @@ -273,14 +273,14 @@ Add a helper for the pivot data so direct and queued paths share the same source * * @return array */ -private function permissionAssignmentPivot(bool $isForbidden): array +private function permissionAssignmentPivot(bool $isDenied): array { $registrar = $this->permissionRegistrar(); $teamPivot = $registrar->teams && ! $this instanceof Role ? [$registrar->teamsKey => getPermissionsTeamId()] : []; - return $teamPivot + ['is_forbidden' => $isForbidden]; + return $teamPivot + ['is_denied' => $isDenied]; } ``` @@ -302,7 +302,7 @@ private function upsertPermissionAssignments(array $permissions, array $pivot): $relation = $this->permissions(); $currentPermissions = $relation->get() ->keyBy(fn (Model $permission): string => (string) $permission->getKey()); - $effect = ['is_forbidden' => (bool) $pivot['is_forbidden']]; + $effect = ['is_denied' => (bool) $pivot['is_denied']]; $attach = []; $updated = false; @@ -316,7 +316,7 @@ private function upsertPermissionAssignments(array $permissions, array $pivot): continue; } - if ($this->pivotIsForbidden($currentPermission) !== $effect['is_forbidden']) { + if ($this->pivotIsDenied($currentPermission) !== $effect['is_denied']) { $updated = $relation->updateExistingPivot($permission, $effect, false) > 0 || $updated; } } @@ -335,7 +335,7 @@ private function upsertPermissionAssignments(array $permissions, array $pivot): Upsert notes: - `permissions()` already scopes model permissions by team through `wherePivot($teamsKey, getPermissionsTeamId())`, so `updateExistingPivot()` and `attach()` operate in the current team context for user/model assignments. -- For model assignments, pass the full pivot data to `attach()` so new rows get the team id, but pass only `['is_forbidden' => ...]` to `updateExistingPivot()`. The relation already scopes the update to the current team, so setting the team column again is noise. +- For model assignments, pass the full pivot data to `attach()` so new rows get the team id, but pass only `['is_denied' => ...]` to `updateExistingPivot()`. The relation already scopes the update to the current team, so setting the team column again is noise. - Role permission assignments have no team pivot by design, so the same helper works for `Role`. - The helper queries the relation fresh instead of using `loadMissing()`. This matters because callers may already have a stale `permissions` relation loaded, and queued assignments can process allow/deny calls back-to-back after save. - `touchIfTouching()` is called once after a changed batch so flipping multiple rows does not touch repeatedly, and no-op calls do not touch related models. @@ -364,7 +364,7 @@ private function attachPermissionAssignments(array $permissions, array $pivot): This helper is only for paths that have already proved the assignment set is empty for the target `(model, permission, team)` identity. Do not replace the -normal persisted `givePermissionTo()` / `giveForbiddenTo()` path with it. +normal persisted `givePermissionTo()` / `denyPermissionTo()` path with it. Known-empty paths use `attachPermissionAssignments()` to avoid the read that `upsertPermissionAssignments()` needs for possibly-existing rows. @@ -396,7 +396,7 @@ protected function attachQueuedPermissionAssignments(): void } ``` -This will hit primary-key conflicts under the new schema if an unsaved model receives both allow and forbid for the same permission before it is saved. +This will hit primary-key conflicts under the new schema if an unsaved model receives both allow and deny for the same permission before it is saved. Target: @@ -458,7 +458,7 @@ private function collapseQueuedPermissionAssignments(): array foreach ($collapsed as $assignment) { $pivot = $assignment['pivot']; $batchKey = $this->queuedPermissionAssignmentTeamKey($pivot) . '|' - . ((bool) $pivot['is_forbidden'] ? 'forbidden' : 'allowed'); + . ((bool) $pivot['is_denied'] ? 'denied' : 'allowed'); $batches[$batchKey] ??= [ 'permissions' => [], @@ -492,11 +492,11 @@ This keeps queued calls ordered per assignment edge. For example: ```php $user = new User; $user->givePermissionTo('edit articles'); -$user->giveForbiddenTo('edit articles'); +$user->denyPermissionTo('edit articles'); $user->save(); ``` -The saved row must be forbidden. Reversing the calls must save an allowed row. +The saved row must be denied. Reversing the calls must save an allowed row. The collapse key is permission id plus team identity, not permission id alone: ```php @@ -513,7 +513,7 @@ assignments for the same permission and same team collapse, with the last effect winning. Do not dispatch `PermissionAttachedEvent` from `attachQueuedPermissionAssignments()`. -The existing API dispatches at `givePermissionTo()` / `giveForbiddenTo()` call +The existing API dispatches at `givePermissionTo()` / `denyPermissionTo()` call time, including unsaved-model queued calls. The queued flush must only persist rows and invalidate caches/wildcard indexes, or queued calls would double-fire events. @@ -527,15 +527,15 @@ relation. ### Sync Behavior -`syncPermissionsWithForbidden()` already builds one `$syncData[$permissionId]` entry per permission id: +`syncPermissionEffects()` already builds one `$syncData[$permissionId]` entry per permission id: ```php foreach ($allowedIds as $permissionId) { - $syncData[$permissionId] = $teamPivot + ['is_forbidden' => false]; + $syncData[$permissionId] = $teamPivot + ['is_denied' => false]; } -foreach ($forbiddenIds as $permissionId) { - $syncData[$permissionId] = $teamPivot + ['is_forbidden' => true]; +foreach ($deniedIds as $permissionId) { + $syncData[$permissionId] = $teamPivot + ['is_denied' => true]; } $changes = $this->permissions()->sync($syncData); @@ -545,9 +545,9 @@ Because `sync()` updates existing pivot attributes for existing ids, this method Still verify these paths with tests: -- allow to forbid returns the changed permission id under `updated` -- forbid to allow returns the changed permission id under `updated` -- permission present in both `allowed` and `forbidden` ends forbidden +- allow to deny returns the changed permission id under `updated` +- deny to allow returns the changed permission id under `updated` +- permission present in both `allowed` and `denied` ends denied - custom primary key tests still pass `syncPermissions()` should collect ids once, detach existing rows, then use the @@ -601,15 +601,15 @@ Invalidate the wildcard permission index before dispatching so synchronous listeners rebuild from current assignment state instead of reading a stale wildcard index. -`syncPermissionsWithForbidden()` must support unsaved models with the same +`syncPermissionEffects()` must support unsaved models with the same scope-aware queued replacement. Its return value remains the database sync change set. For unsaved models, no rows are changed until the model is saved, so -it returns an empty change set after queueing the future allowed and forbidden -assignments. Apply the existing allowed-minus-forbidden filtering before -queueing so a permission present in both arrays still ends forbidden. +it returns an empty change set after queueing the future allowed and denied +assignments. Apply the existing allowed-minus-denied filtering before +queueing so a permission present in both arrays still ends denied. -`syncPermissionsWithForbidden()` also dispatches `PermissionAttachedEvent` once -with the desired allowed and forbidden ids, matching `syncPermissions()` event +`syncPermissionEffects()` also dispatches `PermissionAttachedEvent` once +with the desired allowed and denied ids, matching `syncPermissions()` event timing. For unsaved models, the event fires when the sync method is called; the queued flush on save must not dispatch it again. @@ -631,10 +631,10 @@ public function revokePermissionTo($permission): static No source change is expected here, but add coverage proving a direct deny can be revoked: ```php -$user->giveForbiddenTo('edit articles'); +$user->denyPermissionTo('edit articles'); $user->revokePermissionTo('edit articles'); -$this->assertFalse($user->hasForbiddenPermission('edit articles')); +$this->assertFalse($user->hasDeniedPermission('edit articles')); $this->assertFalse($user->hasPermissionTo('edit articles')); $this->assertSame([], $user->getDirectPermissions()->all()); ``` @@ -656,7 +656,7 @@ public function hasDirectPermission($permission): bool ->first(fn (Model $directPermission): bool => $directPermission->getKey() === $permission->getKey()); return $matchedPermission !== null - && ! $this->pivotIsForbidden($matchedPermission); + && ! $this->pivotIsDenied($matchedPermission); } ``` @@ -671,7 +671,7 @@ public function hasDirectPermission($permission): bool ->filter(fn (Model $directPermission): bool => $directPermission->getKey() === $permission->getKey()); return $matches->isNotEmpty() - && ! $matches->contains(fn (Model $directPermission): bool => $this->pivotIsForbidden($directPermission)); + && ! $matches->contains(fn (Model $directPermission): bool => $this->pivotIsDenied($directPermission)); } ``` @@ -690,7 +690,7 @@ $matchedPermission = $this->loadMissing('permissions') $pivot = $matchedPermission?->getRelation('pivot'); return $matchedPermission !== null - && (! $pivot instanceof Pivot || ! (bool) $pivot->getAttribute('is_forbidden')); + && (! $pivot instanceof Pivot || ! (bool) $pivot->getAttribute('is_denied')); ``` Target: @@ -701,7 +701,7 @@ $matches = $this->loadMissing('permissions') ->filter(fn (Model $rolePermission): bool => $rolePermission->getKey() === $permission->getKey()); return $matches->isNotEmpty() - && ! $matches->contains(fn (Model $rolePermission): bool => $this->pivotIsForbidden($rolePermission)); + && ! $matches->contains(fn (Model $rolePermission): bool => $this->pivotIsDenied($rolePermission)); ``` This removes the last direct `Pivot` class reference in `Role.php`. Remove the `use Hypervel\Database\Eloquent\Relations\Pivot;` import. @@ -713,7 +713,7 @@ File: - `src/permission/src/Traits/HasPermissions.php` `scopePermission()` currently counts direct and role permission rows without -checking the forbidden effect: +checking the denied effect: ```php return $query->where( @@ -735,19 +735,19 @@ return $query->where( ); ``` -This is wrong for forbidden permissions: +This is wrong for denied permissions: -- direct forbidden rows match `permission()` as if they were allows -- role forbidden rows match `permission()` as if they were allows +- direct denied rows match `permission()` as if they were allows +- role denied rows match `permission()` as if they were allows - `withoutPermission()` is not a true inverse once deny semantics are involved Target semantics for one model `M` and permission `p`: ```text -directAllow(M, p) = direct edge for p with is_forbidden=false -directDeny(M, p) = direct edge for p with is_forbidden=true -roleAllow(M, p) = assigned role edge for p with is_forbidden=false -roleDeny(M, p) = assigned role edge for p with is_forbidden=true +directAllow(M, p) = direct edge for p with is_denied=false +directDeny(M, p) = direct edge for p with is_denied=true +roleAllow(M, p) = assigned role edge for p with is_denied=false +roleDeny(M, p) = assigned role edge for p with is_denied=true effective(M, p) = (directAllow(M, p) OR roleAllow(M, p)) AND NOT (directDeny(M, p) OR roleDeny(M, p)) @@ -817,17 +817,17 @@ protected function whereEffectivePermission(Builder $query, array $permissionIds /** * Add a permission-effect predicate for direct and role-granted permissions. */ -protected function wherePermissionEffect(Builder $query, int|string $permissionId, bool $forbidden): Builder +protected function wherePermissionEffect(Builder $query, int|string $permissionId, bool $denied): Builder { $query->whereHas( 'permissions', - fn (Builder $query) => $this->whereDirectPermissionEffect($query, $permissionId, $forbidden), + fn (Builder $query) => $this->whereDirectPermissionEffect($query, $permissionId, $denied), ); if (! $this instanceof Role) { $query->orWhereHas( 'roles.permissions', - fn (Builder $query) => $this->whereRolePermissionEffect($query, $permissionId, $forbidden), + fn (Builder $query) => $this->whereRolePermissionEffect($query, $permissionId, $denied), ); } @@ -837,7 +837,7 @@ protected function wherePermissionEffect(Builder $query, int|string $permissionI /** * Add a direct permission-effect predicate. */ -protected function whereDirectPermissionEffect(Builder $query, int|string $permissionId, bool $forbidden): Builder +protected function whereDirectPermissionEffect(Builder $query, int|string $permissionId, bool $denied): Builder { $permissionKey = Guard::getModelKeyName($this->getPermissionClass()); $pivotTable = $this instanceof Role @@ -846,19 +846,19 @@ protected function whereDirectPermissionEffect(Builder $query, int|string $permi return $query ->where(Config::permissionsTable() . ".{$permissionKey}", $permissionId) - ->where("{$pivotTable}.is_forbidden", $forbidden); + ->where("{$pivotTable}.is_denied", $denied); } /** * Add a role permission-effect predicate. */ -protected function whereRolePermissionEffect(Builder $query, int|string $permissionId, bool $forbidden): Builder +protected function whereRolePermissionEffect(Builder $query, int|string $permissionId, bool $denied): Builder { $permissionKey = Guard::getModelKeyName($this->getPermissionClass()); return $query ->where(Config::permissionsTable() . ".{$permissionKey}", $permissionId) - ->where(Config::roleHasPermissionsTable() . '.is_forbidden', $forbidden); + ->where(Config::roleHasPermissionsTable() . '.is_denied', $denied); } ``` @@ -875,7 +875,7 @@ Important implementation constraints: the role's own `role_has_permissions` edge and must not traverse `roles.permissions`. - Do not change `scopeRole()` or `scopeWithoutRole()`. Role assignment has no - forbidden effect column; it is pure membership. + denied effect column; it is pure membership. - The direct branch inherits team scoping from `permissions()` for models. The nested role branch inherits team scoping from `roles()`, while `role_has_permissions` remains unscoped by team by design. @@ -926,7 +926,7 @@ Target: protected function allowedDirectPermissions(): Collection { return $this->getCachedDirectPermissions() - ->reject(fn (Model $permission): bool => $this->pivotIsForbidden($permission)) + ->reject(fn (Model $permission): bool => $this->pivotIsDenied($permission)) ->values(); } @@ -957,7 +957,7 @@ The global catalog payload already carries role-permission pivot effects: 'pivot' => [ $this->pivotPermission => $permission->getKey(), $this->pivotRole => $role->getKey(), - 'is_forbidden' => $this->pivotIsForbidden($role), + 'is_denied' => $this->pivotIsDenied($role), ], ]) ->values() @@ -977,9 +977,9 @@ $role->setRelation('pivot', Pivot::fromRawAttributes( During implementation, verify this still passes after single-state writes and schema changes. Keep tests around: -- payload includes `is_forbidden=false` for allowed role permissions -- payload includes `is_forbidden=true` for forbidden role permissions -- hydrated cache preserves forbidden role behavior after clearing coroutine-local catalog state +- payload includes `is_denied=false` for allowed role permissions +- payload includes `is_denied=true` for denied role permissions +- hydrated cache preserves denied role behavior after clearing coroutine-local catalog state ### Assignment Cache Namespace Token @@ -1115,11 +1115,11 @@ Files: Update docs to say: -- `is_forbidden` is an effect column on permission assignment tables. +- `is_denied` is an effect column on permission assignment tables. - Each assignment edge has one row. -- Calling `givePermissionTo()` after `giveForbiddenTo()` flips that edge back to allowed. -- Calling `giveForbiddenTo()` after `givePermissionTo()` flips that edge to forbidden. -- `revokePermissionTo()` removes the edge regardless of whether it is allowed or forbidden. +- Calling `givePermissionTo()` after `denyPermissionTo()` flips that edge back to allowed. +- Calling `denyPermissionTo()` after `givePermissionTo()` flips that edge to denied. +- `revokePermissionTo()` removes the edge regardless of whether it is allowed or denied. - `getDirectPermissions()` and `getPermissionNames()` return allowed direct permissions, not explicit denies. - `permission()` and `withoutPermission()` query scopes use effective concrete permission semantics: an allow counts only when the same permission is not @@ -1130,15 +1130,15 @@ Update docs to say: - Permission assignment caches use namespace tokens; resetting permission cache makes old model assignment keys unreachable until TTL cleanup. - Update the existing Boost docs prose that says "assignment-cache version" to "assignment-cache token", including the current text around `src/boost/docs/permission.md:1081` and `src/boost/docs/permission.md:1178`. -Suggested Boost docs replacement around forbidden permissions: +Suggested Boost docs replacement around denied permissions: ```md -Forbidden permissions explicitly deny access. The permission assignment tables store -`is_forbidden` as the effect for the assignment edge, so a model or role has one +Denied permissions explicitly reject access. The permission assignment tables store +`is_denied` as the effect for the assignment edge, so a model or role has one row for a given permission in the current team context. -Calling `giveForbiddenTo` for an allowed permission flips that assignment to a -deny. Calling `givePermissionTo` for a forbidden permission flips it back to an +Calling `denyPermissionTo` for an allowed permission flips that assignment to a +deny. Calling `givePermissionTo` for a denied permission flips it back to an allow. ``` @@ -1146,9 +1146,9 @@ Suggested retrieval note: ```md `getDirectPermissions`, `getPermissionsViaRoles`, `getAllPermissions`, and -`getPermissionNames` return allowed permissions. Explicitly forbidden permissions -are checked through `hasForbiddenPermission` and -`hasForbiddenPermissionViaRoles`. +`getPermissionNames` return allowed permissions. Explicitly denied permissions +are checked through `hasDeniedPermission` and +`hasDeniedPermissionViaRoles`. ``` Suggested query-scope note: @@ -1173,7 +1173,7 @@ the configured cache TTL. Update README differences: ```md -- Hypervel adds forbidden permissions. A forbidden permission explicitly denies +- Hypervel adds denied permissions. A denied permission explicitly rejects an ability and wins over direct or role-granted allows. The deny flag is stored as the effect on the assignment row, so assigning allow or deny for the same model/role and permission updates the existing edge. @@ -1182,7 +1182,7 @@ Update README differences: invalidated independently. ``` -Remove or rewrite any wording that implies an allowed and forbidden row can both exist for the same assignment edge. +Remove or rewrite any wording that implies an allowed and denied row can both exist for the same assignment edge. ## Testing Plan @@ -1192,7 +1192,7 @@ Run tests after each changed test file. File: -- `tests/Permission/ForbiddenPermissionTest.php` +- `tests/Permission/DeniedPermissionTest.php` Add imports as needed: @@ -1205,29 +1205,29 @@ use Hypervel\Permission\Support\Config; Add/update coverage: ```php -public function testDirectForbiddenPermissionFlipsExistingAllowedPermission(): void +public function testDirectDeniedPermissionFlipsExistingAllowedPermission(): void { $this->testUser->givePermissionTo('edit-articles'); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->testUser->refresh(); $this->assertSame(1, $this->testUser->permissions()->count()); - $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermission('edit-articles')); $this->assertFalse($this->testUser->hasDirectPermission('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); $this->assertSame([], $this->testUser->getPermissionNames()->all()); } -public function testDirectAllowedPermissionFlipsExistingForbiddenPermission(): void +public function testDirectAllowedPermissionFlipsExistingDeniedPermission(): void { - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->testUser->givePermissionTo('edit-articles'); $this->testUser->refresh(); $this->assertSame(1, $this->testUser->permissions()->count()); - $this->assertFalse($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertFalse($this->testUser->hasDeniedPermission('edit-articles')); $this->assertTrue($this->testUser->hasDirectPermission('edit-articles')); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); $this->assertSame(['edit-articles'], $this->testUser->getPermissionNames()->all()); @@ -1237,27 +1237,27 @@ public function testDirectAllowedPermissionFlipsExistingForbiddenPermission(): v Role edge flips: ```php -public function testRoleForbiddenPermissionFlipsExistingAllowedPermission(): void +public function testRoleDeniedPermissionFlipsExistingAllowedPermission(): void { $this->testUserRole->givePermissionTo('edit-articles'); - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->testUserRole->refresh(); $this->assertSame(1, $this->testUserRole->permissions()->count()); - $this->assertTrue($this->testUserRole->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUserRole->hasDeniedPermission('edit-articles')); $this->assertFalse($this->testUserRole->hasPermissionTo('edit-articles')); } -public function testRoleAllowedPermissionFlipsExistingForbiddenPermission(): void +public function testRoleAllowedPermissionFlipsExistingDeniedPermission(): void { - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->testUserRole->givePermissionTo('edit-articles'); $this->testUserRole->refresh(); $this->assertSame(1, $this->testUserRole->permissions()->count()); - $this->assertFalse($this->testUserRole->hasForbiddenPermission('edit-articles')); + $this->assertFalse($this->testUserRole->hasDeniedPermission('edit-articles')); $this->assertTrue($this->testUserRole->hasPermissionTo('edit-articles')); } ``` @@ -1265,33 +1265,33 @@ public function testRoleAllowedPermissionFlipsExistingForbiddenPermission(): voi Queued unsaved-model flips: ```php -public function testQueuedDirectForbiddenPermissionFlipsExistingQueuedAllowedPermission(): void +public function testQueuedDirectDeniedPermissionFlipsExistingQueuedAllowedPermission(): void { $user = new User(['email' => 'queued@example.com']); $user->givePermissionTo('edit-articles'); - $user->giveForbiddenTo('edit-articles'); + $user->denyPermissionTo('edit-articles'); $user->save(); $user->refresh(); $this->assertSame(1, $user->permissions()->count()); - $this->assertTrue($user->hasForbiddenPermission('edit-articles')); + $this->assertTrue($user->hasDeniedPermission('edit-articles')); $this->assertFalse($user->hasPermissionTo('edit-articles')); } -public function testQueuedDirectAllowedPermissionFlipsExistingQueuedForbiddenPermission(): void +public function testQueuedDirectAllowedPermissionFlipsExistingQueuedDeniedPermission(): void { $user = new User(['email' => 'queued@example.com']); - $user->giveForbiddenTo('edit-articles'); + $user->denyPermissionTo('edit-articles'); $user->givePermissionTo('edit-articles'); $user->save(); $user->refresh(); $this->assertSame(1, $user->permissions()->count()); - $this->assertFalse($user->hasForbiddenPermission('edit-articles')); + $this->assertFalse($user->hasDeniedPermission('edit-articles')); $this->assertTrue($user->hasPermissionTo('edit-articles')); } ``` @@ -1334,15 +1334,15 @@ known-empty path instead of reading current pivots before attaching. Revoke deny: ```php -public function testRevokePermissionRemovesDirectForbiddenPermission(): void +public function testRevokePermissionRemovesDirectDeniedPermission(): void { - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->testUser->revokePermissionTo('edit-articles'); $this->testUser->refresh(); $this->assertSame(0, $this->testUser->permissions()->count()); - $this->assertFalse($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertFalse($this->testUser->hasDeniedPermission('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); } ``` @@ -1350,11 +1350,11 @@ public function testRevokePermissionRemovesDirectForbiddenPermission(): void Layered reveal: ```php -public function testRemovingDirectForbiddenPermissionRevealsRoleAllowedPermission(): void +public function testRemovingDirectDeniedPermissionRevealsRoleAllowedPermission(): void { $this->testUserRole->givePermissionTo('edit-articles'); $this->testUser->assignRole($this->testUserRole); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); @@ -1371,7 +1371,7 @@ public function testDirectPermissionChecksDenyWhenRelationContainsDuplicateEffec { $permission = $this->app->make(PermissionContract::class)::findByName('edit-articles'); $allowed = clone $permission; - $forbidden = clone $permission; + $denied = clone $permission; $allowed->setRelation('pivot', Pivot::fromRawAttributes( $this->testUser, @@ -1379,25 +1379,25 @@ public function testDirectPermissionChecksDenyWhenRelationContainsDuplicateEffec $this->app->make(PermissionRegistrar::class)->pivotPermission => $permission->getKey(), Config::morphKey() => $this->testUser->getKey(), 'model_type' => $this->testUser->getMorphClass(), - 'is_forbidden' => false, + 'is_denied' => false, ], Config::modelHasPermissionsTable(), true, )); - $forbidden->setRelation('pivot', Pivot::fromRawAttributes( + $denied->setRelation('pivot', Pivot::fromRawAttributes( $this->testUser, [ $this->app->make(PermissionRegistrar::class)->pivotPermission => $permission->getKey(), Config::morphKey() => $this->testUser->getKey(), 'model_type' => $this->testUser->getMorphClass(), - 'is_forbidden' => true, + 'is_denied' => true, ], Config::modelHasPermissionsTable(), true, )); - $this->testUser->setRelation('permissions', collect([$allowed, $forbidden])); + $this->testUser->setRelation('permissions', collect([$allowed, $denied])); $this->assertFalse($this->testUser->hasDirectPermission('edit-articles')); } @@ -1412,38 +1412,38 @@ public function testRolePermissionChecksDenyWhenRelationContainsDuplicateEffects { $permission = $this->app->make(PermissionContract::class)::findByName('edit-articles'); $allowed = clone $permission; - $forbidden = clone $permission; + $denied = clone $permission; $allowed->setRelation('pivot', Pivot::fromRawAttributes( $this->testUserRole, [ $this->app->make(PermissionRegistrar::class)->pivotPermission => $permission->getKey(), $this->app->make(PermissionRegistrar::class)->pivotRole => $this->testUserRole->getKey(), - 'is_forbidden' => false, + 'is_denied' => false, ], Config::roleHasPermissionsTable(), true, )); - $forbidden->setRelation('pivot', Pivot::fromRawAttributes( + $denied->setRelation('pivot', Pivot::fromRawAttributes( $this->testUserRole, [ $this->app->make(PermissionRegistrar::class)->pivotPermission => $permission->getKey(), $this->app->make(PermissionRegistrar::class)->pivotRole => $this->testUserRole->getKey(), - 'is_forbidden' => true, + 'is_denied' => true, ], Config::roleHasPermissionsTable(), true, )); - $this->testUserRole->setRelation('permissions', collect([$allowed, $forbidden])); + $this->testUserRole->setRelation('permissions', collect([$allowed, $denied])); $this->assertFalse($this->testUserRole->hasDirectPermission('edit-articles')); $this->assertFalse($this->testUserRole->hasPermissionTo('edit-articles')); } ``` -The `hasDirectPermission()` assertion exercises the hardened duplicate-match filter. `Role::hasPermissionTo()` first calls `hasForbiddenPermission()`, so its assertion confirms the public role permission check still denies, but it does not by itself prove the rewritten `$matches` block in `Role::hasPermissionTo()` is reached. +The `hasDirectPermission()` assertion exercises the hardened duplicate-match filter. `Role::hasPermissionTo()` first calls `hasDeniedPermission()`, so its assertion confirms the public role permission check still denies, but it does not by itself prove the rewritten `$matches` block in `Role::hasPermissionTo()` is reached. ### Query Scopes @@ -1451,22 +1451,22 @@ Files: - `tests/Permission/Traits/HasPermissionsTest.php` - `tests/Permission/Traits/TeamHasPermissionsTest.php` -- `tests/Permission/ForbiddenPermissionTest.php` +- `tests/Permission/DeniedPermissionTest.php` Add coverage for effective permission query scopes: ```php -public function testPermissionScopeExcludesDirectForbiddenPermission(): void +public function testPermissionScopeExcludesDirectDeniedPermission(): void { - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->assertFalse(User::permission('edit-articles')->get()->contains($this->testUser)); $this->assertTrue(User::withoutPermission('edit-articles')->get()->contains($this->testUser)); } -public function testPermissionScopeExcludesRoleForbiddenPermission(): void +public function testPermissionScopeExcludesRoleDeniedPermission(): void { - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->testUser->assignRole($this->testUserRole); $this->assertFalse(User::permission('edit-articles')->get()->contains($this->testUser)); @@ -1477,7 +1477,7 @@ public function testPermissionScopeLetsDirectDenyOverrideRoleAllow(): void { $this->testUserRole->givePermissionTo('edit-articles'); $this->testUser->assignRole($this->testUserRole); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->assertFalse(User::permission('edit-articles')->get()->contains($this->testUser)); $this->assertTrue(User::withoutPermission('edit-articles')->get()->contains($this->testUser)); @@ -1485,7 +1485,7 @@ public function testPermissionScopeLetsDirectDenyOverrideRoleAllow(): void public function testPermissionScopeLetsRoleDenyOverrideDirectAllow(): void { - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->testUser->assignRole($this->testUserRole); $this->testUser->givePermissionTo('edit-articles'); @@ -1500,14 +1500,14 @@ Add tests that catch per-permission correlation bugs: public function testPermissionScopeMatchesAllowedPermissionWhenDifferentRequestedPermissionIsDenied(): void { $this->testUser->givePermissionTo('edit-articles'); - $this->testUser->giveForbiddenTo('edit-news'); + $this->testUser->denyPermissionTo('edit-news'); $this->assertTrue(User::permission(['edit-articles', 'edit-news'])->get()->contains($this->testUser)); } public function testPermissionScopeMatchesSecondAllowedPermissionWhenFirstRequestedPermissionIsDenied(): void { - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->testUser->assignRole($this->testUserRole); $this->testUser->givePermissionTo('edit-articles'); $this->testUser->givePermissionTo('edit-news'); @@ -1533,9 +1533,9 @@ public function testWithoutPermissionScopeWithNoPermissionsMatchesAllModels(): v Add role-subject coverage: ```php -public function testRolePermissionScopeExcludesForbiddenRolePermissionEdges(): void +public function testRolePermissionScopeExcludesDeniedRolePermissionEdges(): void { - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->assertFalse($this->app->make(RoleContract::class)::permission('edit-articles')->get()->contains($this->testUserRole)); } @@ -1552,10 +1552,10 @@ File: Add or update coverage: -- allow then forbid in team 1 leaves one row in team 1 and does not affect team 2 -- forbid then allow in team 1 leaves one allowed row in team 1 -- `getPermissionNames()` excludes forbidden direct permissions in the active team -- `revokePermissionTo()` removes a forbidden row in the active team only +- allow then deny in team 1 leaves one row in team 1 and does not affect team 2 +- deny then allow in team 1 leaves one allowed row in team 1 +- `getPermissionNames()` excludes denied direct permissions in the active team +- `revokePermissionTo()` removes a denied row in the active team only - `permission()` respects direct allow/deny effects in the active team - role-granted permissions match in a team where the role is assigned and do not match in a team where the role is not assigned @@ -1619,7 +1619,7 @@ Add assertions for primary key shape where the schema inspection API supports it ```php $this->testUser->givePermissionTo('edit-articles'); -$this->testUser->giveForbiddenTo('edit-articles'); +$this->testUser->denyPermissionTo('edit-articles'); $this->assertSame(1, $this->testUser->permissions()->count()); ``` @@ -1631,7 +1631,7 @@ Run the custom model test path too, because `tests/Permission/TestCase.php` has After each changed test file: ```sh -./vendor/bin/phpunit --no-progress tests/Permission/ForbiddenPermissionTest.php +./vendor/bin/phpunit --no-progress tests/Permission/DeniedPermissionTest.php ./vendor/bin/phpunit --no-progress tests/Permission/Traits/HasPermissionsTest.php ./vendor/bin/phpunit --no-progress tests/Permission/Traits/TeamHasPermissionsTest.php ./vendor/bin/phpunit --no-progress tests/Permission/CacheTest.php @@ -1650,21 +1650,21 @@ composer fix ## Implementation Checklist -1. Change all schema primary keys to remove `is_forbidden` from identity while keeping the column. +1. Change all schema primary keys to remove `is_denied` from identity while keeping the column. 2. Add permission assignment pivot/upsert/known-empty attach helpers in `HasPermissions`. 3. Update `attachPermissions()` to call the upsert helper for persisted models. 4. Update `attachQueuedPermissionAssignments()` to collapse queued assignments by permission and team, then attach with the known-empty helper after save. 5. Remove same-flag dedup logic that only existed for dual-row schema. 6. Update `syncPermissions()` to collect ids once and attach through the known-empty helper after detach. 7. Update queued replace-style sync paths to replace only the current assignment scope. -8. Update unsaved `syncPermissionsWithForbidden()` to queue allowed and forbidden assignments, return an empty change set, and preserve forbidden-wins filtering. -9. Preserve permission attach event timing: dispatch from give/sync methods, including `syncPermissionsWithForbidden()`, not from queued flushes. Invalidate wildcard indexes before dispatch so synchronous listeners see fresh wildcard state. -10. Harden `hasDirectPermission()` and `Role::hasPermissionTo()` to deny if any matching pivot is forbidden. +8. Update unsaved `syncPermissionEffects()` to queue allowed and denied assignments, return an empty change set, and preserve denied-wins filtering. +9. Preserve permission attach event timing: dispatch from give/sync methods, including `syncPermissionEffects()`, not from queued flushes. Invalidate wildcard indexes before dispatch so synchronous listeners see fresh wildcard state. +10. Harden `hasDirectPermission()` and `Role::hasPermissionTo()` to deny if any matching pivot is denied. 11. Rework `scopePermission()` around the effective permission predicate, and keep `scopeWithoutPermission()` as a delegate to `scopePermission(..., true)`. 12. Add `allowedDirectPermissions()` in `HasPermissions`, make `getPermissionNames()` use it, and make `HasRoles::getDirectPermissions()` delegate to it. -13. Verify and keep global catalog serialization/hydration of `is_forbidden`. +13. Verify and keep global catalog serialization/hydration of `is_denied`. 14. Rename numeric model assignment cache versions to ULID namespace tokens across source, config, tests, README, and Boost docs. -15. Update tests for forbidden flips, queued flips, query-count preservation, revoke deny, effective scopes, teams, cache token, and accessors. +15. Update tests for denied flips, queued flips, query-count preservation, revoke deny, effective scopes, teams, cache token, and accessors. 16. Update README and Boost docs, including the concrete-grant query-scope boundary for wildcard permissions. 17. Run focused tests after each changed test file. 18. Run `composer fix`. @@ -1675,26 +1675,26 @@ composer fix Check all of these before asking for review: -- `grep -R "is_forbidden.*primary\|primary.*is_forbidden" -n src/permission tests/Permission` returns nothing outside archived material. +- `grep -R "is_denied.*primary\|primary.*is_denied" -n src/permission tests/Permission` returns nothing outside archived material. - No public write path can create two rows for the same edge. -- `givePermissionTo()` after `giveForbiddenTo()` flips deny to allow. -- `giveForbiddenTo()` after `givePermissionTo()` flips allow to deny. +- `givePermissionTo()` after `denyPermissionTo()` flips deny to allow. +- `denyPermissionTo()` after `givePermissionTo()` flips allow to deny. - queued unsaved model calls preserve call order and end with one row. - queued unsaved model calls for the same permission in different teams create separate team edges. - unsaved `syncPermissions()` replaces only the current assignment scope in the pending queue. -- unsaved `syncPermissionsWithForbidden()` replaces only the current assignment - scope, queues both effects, applies forbidden-wins filtering, and returns an +- unsaved `syncPermissionEffects()` replaces only the current assignment + scope, queues both effects, applies denied-wins filtering, and returns an empty change set because no database rows changed yet. - queued unsaved model flushes do not dispatch a second attach event. -- `syncPermissionsWithForbidden()` dispatches `PermissionAttachedEvent` once for +- `syncPermissionEffects()` dispatches `PermissionAttachedEvent` once for saved and unsaved models. - permission attach events fire after wildcard index invalidation, so synchronous listeners see fresh wildcard permission state. - `syncPermissions()` collects ids once and does not read current pivots after detaching. -- `syncPermissionsWithForbidden()` still reports `attached`, `detached`, and `updated` correctly. +- `syncPermissionEffects()` still reports `attached`, `detached`, and `updated` correctly. - existing query-count tests for new permissions and queued unsaved-model assignments still expect two queries and pass. - `revokePermissionTo()` removes a direct deny. @@ -1712,8 +1712,8 @@ Check all of these before asking for review: `roles.permissions`. - wildcard docs state that permission query scopes filter concrete stored grants and do not expand wildcard grammar. -- allowed accessors exclude forbidden rows. -- global catalog cache payload contains `is_forbidden` and hydration preserves it. +- allowed accessors exclude denied rows. +- global catalog cache payload contains `is_denied` and hydration preserves it. - model assignment cache keys include a ULID token. - tests no longer compare assignment cache namespace values numerically. - `grep -R "assertGreaterThan.*modelAssignmentCache\\|modelAssignmentCache.*assertGreaterThan" -n tests/Permission` returns nothing. @@ -1721,16 +1721,16 @@ Check all of these before asking for review: - `grep -R "assignment-cache version\|cache version" -n src/permission tests/Permission src/boost/docs/permission.md src/permission/README.md` returns nothing. - permission cache docs and comments describe the assignment cache namespace as a token, not a numeric version. - `grep -R "use Hypervel\\\\Database\\\\Eloquent\\\\Relations\\\\Pivot;" -n src/permission/src/Models/Role.php` returns nothing. -- docs no longer imply dual allow/forbid rows can coexist for one edge. +- docs no longer imply dual allow/deny rows can coexist for one edge. - all changed files have no unused imports, dead code, stale comments, or stale wording. ## Expected Commit Split After Everything Is Green The final split can be decided after implementation. The expected clean split is: -1. schema and write/read behavior for single-state forbidden permissions +1. schema and write/read behavior for single-state denied permissions 2. effective permission query-scope behavior -3. regression tests for forbidden single-state and effective-scope behavior +3. regression tests for denied single-state and effective-scope behavior 4. assignment cache namespace token cleanup 5. permission docs/README updates diff --git a/docs/plans/2026-07-02-permission-review-hardening-and-performance.md b/docs/plans/2026-07-02-permission-review-hardening-and-performance.md index 8082941c4..f17f6d8e8 100644 --- a/docs/plans/2026-07-02-permission-review-hardening-and-performance.md +++ b/docs/plans/2026-07-02-permission-review-hardening-and-performance.md @@ -48,7 +48,7 @@ This is Hypervel 0.4 framework work. Backwards compatibility and diff size are n 1. `HasPermissions::hasPermissionTo()` checks deny paths before `filterPermission()`. When no guard is passed, `storedPermissionMatches()` skips guard filtering, so a deny under one guard can block an allow under another guard. -2. A naive default-guard fix would create a fail-open bug for concrete `Permission` objects. If an API-guard `Permission` object is passed while the model default is web, forcing the default guard before matching would hide API forbidden rows. +2. A naive default-guard fix would create a fail-open bug for concrete `Permission` objects. If an API-guard `Permission` object is passed while the model default is web, forcing the default guard before matching would hide API denied rows. 3. `PermissionMiddleware`, `RoleOrPermissionMiddleware`, and `RoleMiddleware` can call `UnauthorizedException::missingTraitHasRoles(Authorizable $user)` with an authenticated object that is not `Authorizable`. The exception only needs `$user::class`. @@ -60,7 +60,7 @@ This is Hypervel 0.4 framework work. Backwards compatibility and diff size are n 7. `PermissionRegistrar::getPermissions()` and `getRoles()` scan the whole hydrated catalog for common exact lookups. This affects `findByName()`, `findById()`, `filterPermission()`, and direct assignment hydration. -8. `hasForbiddenPermissionViaRoles()` materializes every permission's role pivots on every role-deny check. +8. `hasDeniedPermissionViaRoles()` materializes every permission's role pivots on every role-deny check. 9. Most deployments have no role-permission deny edges. A catalog-level boolean can skip the role-deny path cheaply. @@ -90,9 +90,9 @@ This is Hypervel 0.4 framework work. Backwards compatibility and diff size are n 7. Add registrar catalog indexes instead of a separate direct-permission hydrated memo. This fixes the root O(catalog) lookup cost. -8. Add a serialized `hasForbiddenRolePermissions` catalog flag. +8. Add a serialized `hasDeniedRolePermissions` catalog flag. -9. Narrow `hasForbiddenPermissionViaRoles()` to the requested permission and effective guard. +9. Narrow `hasDeniedPermissionViaRoles()` to the requested permission and effective guard. 10. Add a coroutine-local via-role materialization memo for public getters only, with centralized invalidation. @@ -123,7 +123,7 @@ protected function storedPermissionMatches(Model $storedPermission, mixed $permi } ``` -`hasPermissionTo('edit')` passes `null` to both forbidden checks before the allow path resolves the default guard. That means `edit@api` denied can block `edit@web` allowed in default-web contexts. +`hasPermissionTo('edit')` passes `null` to both denied checks before the allow path resolves the default guard. That means `edit@api` denied can block `edit@web` allowed in default-web contexts. #### How @@ -151,23 +151,23 @@ protected function guardNameForPermissionMatch(mixed $permission, ?string $guard } ``` -Update public forbidden helper methods to use it: +Update public denied helper methods to use it: ```php -public function hasForbiddenPermission($permission, ?string $guardName = null): bool +public function hasDeniedPermission($permission, ?string $guardName = null): bool { $guardName = $this->guardNameForPermissionMatch($permission, $guardName); return $this->getCachedDirectPermissions() ->contains( - fn (Model $storedPermission): bool => $this->pivotIsForbidden($storedPermission) + fn (Model $storedPermission): bool => $this->pivotIsDenied($storedPermission) && $this->storedPermissionMatches($storedPermission, $permission, $guardName) ); } ``` ```php -public function hasForbiddenPermissionViaRoles($permission, ?string $guardName = null): bool +public function hasDeniedPermissionViaRoles($permission, ?string $guardName = null): bool { if ($this instanceof Role || $this instanceof Permission) { return false; @@ -185,11 +185,11 @@ Update non-wildcard `hasPermissionTo()` to resolve once: public function hasPermissionTo($permission, ?string $guardName = null): bool { if ($this->getWildcardClass()) { - if ($this->hasForbiddenPermission($permission, $guardName)) { + if ($this->hasDeniedPermission($permission, $guardName)) { return false; } - if ($this->hasForbiddenPermissionViaRoles($permission, $guardName)) { + if ($this->hasDeniedPermissionViaRoles($permission, $guardName)) { return false; } @@ -198,11 +198,11 @@ public function hasPermissionTo($permission, ?string $guardName = null): bool $permission = $this->filterPermission($permission, $guardName); - if ($this->hasForbiddenPermission($permission, $guardName)) { + if ($this->hasDeniedPermission($permission, $guardName)) { return false; } - if ($this->hasForbiddenPermissionViaRoles($permission, $guardName)) { + if ($this->hasDeniedPermissionViaRoles($permission, $guardName)) { return false; } @@ -216,7 +216,7 @@ Apply the same resolve-once shape to `Role::hasPermissionTo()`: public function hasPermissionTo(UnitEnum|int|string|PermissionContract $permission, ?string $guardName = null): bool { if ($this->getWildcardClass()) { - if ($this->hasForbiddenPermission($permission, $guardName)) { + if ($this->hasDeniedPermission($permission, $guardName)) { return false; } @@ -225,7 +225,7 @@ public function hasPermissionTo(UnitEnum|int|string|PermissionContract $permissi $permission = $this->filterPermission($permission, $guardName); - if ($this->hasForbiddenPermission($permission, $guardName)) { + if ($this->hasDeniedPermission($permission, $guardName)) { return false; } @@ -241,7 +241,7 @@ Keep `storedPermissionMatches()` as the final comparison helper. It can still ac #### Tests -Add tests to `tests/Permission/ForbiddenPermissionTest.php`: +Add tests to `tests/Permission/DeniedPermissionTest.php`: - same permission name exists under `web` and `api` - user has allow for `web` @@ -262,13 +262,13 @@ Add role-path equivalents: Add `Role::hasPermissionTo()` tests: -- role with API forbidden permission checked by concrete API permission object returns false +- role with API denied permission checked by concrete API permission object returns false - role with default web context does not miss the concrete object's deny Run immediately: ```shell -./vendor/bin/phpunit --no-progress tests/Permission/ForbiddenPermissionTest.php +./vendor/bin/phpunit --no-progress tests/Permission/DeniedPermissionTest.php ``` ### 2. Middleware TypeError @@ -612,7 +612,7 @@ to: * roleByKey: array, * roleByNameAndGuard: array>, * roleOrderByKey: array, - * hasForbiddenRolePermissions: bool + * hasDeniedRolePermissions: bool * } */ private function permissionCatalog(): array @@ -633,7 +633,7 @@ $catalog = [ 'roleByKey' => $this->indexModelsByKey($roles), 'roleByNameAndGuard' => $this->indexModelsByNameAndGuard($roles), 'roleOrderByKey' => $this->indexModelOrderByKey($roles), - 'hasForbiddenRolePermissions' => (bool) ($payload['hasForbiddenRolePermissions'] ?? false), + 'hasDeniedRolePermissions' => (bool) $payload['hasDeniedRolePermissions'], ]; ``` @@ -868,7 +868,7 @@ Preserve return type and visible behavior: - fall back to `filterModels()` for any param shape not exactly handled - use `!== null` for the indexed fast path so an empty lookup result is still returned directly -#### Add Forbidden Edge Flag +#### Add Denied Edge Flag Update serialized payload: @@ -877,21 +877,21 @@ private function getSerializedPermissionsForCache(): array { $except = $this->config->array('permission.cache.column_names_except', ['created_at', 'updated_at', 'deleted_at']); $permissions = $this->getPermissionsWithRoles(); - $hasForbiddenRolePermissions = false; + $hasDeniedRolePermissions = false; return [ 'permissions' => $permissions - ->map(function (Model $permission) use (&$hasForbiddenRolePermissions, $except): array { + ->map(function (Model $permission) use (&$hasDeniedRolePermissions, $except): array { $roles = $this->relationCollection($permission, 'roles') - ->map(function (Model $role) use ($permission, &$hasForbiddenRolePermissions): array { - $isForbidden = $this->pivotIsForbidden($role); - $hasForbiddenRolePermissions = $hasForbiddenRolePermissions || $isForbidden; + ->map(function (Model $role) use ($permission, &$hasDeniedRolePermissions): array { + $isDenied = $this->pivotIsDenied($role); + $hasDeniedRolePermissions = $hasDeniedRolePermissions || $isDenied; return [ 'pivot' => [ $this->pivotPermission => $permission->getKey(), $this->pivotRole => $role->getKey(), - 'is_forbidden' => $isForbidden, + 'is_denied' => $isDenied, ], ]; }) @@ -911,12 +911,12 @@ private function getSerializedPermissionsForCache(): array ]) ->values() ->all(), - 'hasForbiddenRolePermissions' => $hasForbiddenRolePermissions, + 'hasDeniedRolePermissions' => $hasDeniedRolePermissions, ]; } ``` -Because this changes only the value stored under the package cache key and the package already tolerates recaching, no migration is needed. The hydration path should use `?? false` so old cache payloads are harmless until they expire or are flushed. +Because this changes only the value stored under the package cache key and the package clears its catalog cache with the schema migration, no compatibility migration or fallback is needed. The hydration path treats `hasDeniedRolePermissions` as a required package-generated payload field. #### Tests @@ -933,7 +933,7 @@ Add registrar tests in the existing `tests/Permission/Integration/PermissionRegi 9. stale coroutine catalog plus a duplicate `Permission::create()` still throws `PermissionAlreadyExists`, not a raw database unique-constraint exception. 10. stale coroutine catalog plus `Permission::findOrCreate()` returns the existing DB row instead of throwing. 11. catalog-resolved roles expose the expected key/name/guard/team data and do not require timestamp attributes that the catalog intentionally strips. -12. old cache payload without `hasForbiddenRolePermissions` is treated as false if practical to seed. +12. the normal serialized catalog reports `hasDeniedRolePermissions` accurately before and after a denied role-permission assignment. Performance behavior can be tested by creating unrelated permissions and asserting a permission check result remains correct. Avoid brittle tests that depend on private loop counts unless a clean spy pattern already exists. @@ -958,7 +958,7 @@ Current role-deny check: ```php return $this->getPermissionsViaRolesWithPivots() ->contains( - fn (Model $storedPermission): bool => $this->pivotIsForbidden($storedPermission) + fn (Model $storedPermission): bool => $this->pivotIsDenied($storedPermission) && $this->storedPermissionMatches($storedPermission, $permission, $guardName) ); ``` @@ -971,11 +971,11 @@ Add registrar method: ```php /** - * Determine if any cached role-permission edge is forbidden. + * Determine if any cached role-permission edge is denied. */ -public function hasForbiddenRolePermissions(): bool +public function hasDeniedRolePermissions(): bool { - return (bool) $this->permissionCatalog()['hasForbiddenRolePermissions']; + return (bool) $this->permissionCatalog()['hasDeniedRolePermissions']; } ``` @@ -1012,10 +1012,10 @@ protected function permissionForMatch(mixed $permission, string $guardName): ?Mo } ``` -Then rewrite `hasForbiddenPermissionViaRoles()`: +Then rewrite `hasDeniedPermissionViaRoles()`: ```php -public function hasForbiddenPermissionViaRoles($permission, ?string $guardName = null): bool +public function hasDeniedPermissionViaRoles($permission, ?string $guardName = null): bool { if ($this instanceof Role || $this instanceof Permission) { return false; @@ -1029,7 +1029,7 @@ public function hasForbiddenPermissionViaRoles($permission, ?string $guardName = $registrar = $this->permissionRegistrar(); - if (! $registrar->hasForbiddenRolePermissions()) { + if (! $registrar->hasDeniedRolePermissions()) { return false; } @@ -1045,7 +1045,7 @@ public function hasForbiddenPermissionViaRoles($permission, ?string $guardName = return $this->relationCollection($storedPermission, 'roles') ->contains( fn (Model $role): bool => isset($roleIds[(string) $role->getKey()]) - && $this->pivotIsForbidden($role) + && $this->pivotIsDenied($role) ); } ``` @@ -1054,16 +1054,16 @@ Keep the fast ordering: 1. role/permission model early return 2. no cached roles early return -3. no forbidden role edges early return +3. no denied role edges early return 4. resolve requested permission 5. inspect only that permission's role pivots #### Tests -Add to `tests/Permission/ForbiddenPermissionTest.php`: +Add to `tests/Permission/DeniedPermissionTest.php`: -- forbidden role permission under another guard does not deny default guard -- forbidden role permission for a different permission does not deny requested permission +- denied role permission under another guard does not deny default guard +- denied role permission for a different permission does not deny requested permission - explicit guard with role deny still denies - concrete permission object from denied guard is denied - missing permission still returns false from `checkPermissionTo()` and throws from `hasPermissionTo()` as before @@ -1072,7 +1072,7 @@ Add to `tests/Permission/ForbiddenPermissionTest.php`: Run: ```shell -./vendor/bin/phpunit --no-progress tests/Permission/ForbiddenPermissionTest.php +./vendor/bin/phpunit --no-progress tests/Permission/DeniedPermissionTest.php ``` ### 8. Via-Role Materialization Memo And Deduping @@ -1208,8 +1208,8 @@ Apply this exact keep/remove list: | `HasPermissions::attachPermissions()` | around line 644 | Keep for unsaved models; remove from branches that already called `forgetModelPermissionCache()` or `forgetCachedPermissions()` by restructuring the method so the direct call only runs when no cache-clearing helper ran | unsaved models only queue assignments and have no registrar cache helper to centralize through | | `HasPermissions::attachQueuedPermissionAssignments()` | around line 791 | Remove | saved model flush goes through `forgetModelPermissionCache()` or `forgetCachedPermissions()` | | `HasPermissions::syncPermissions()` | around line 916 | Remove for saved models; keep only in any unsaved/queued branch that does not call a registrar helper | saved direct mutations go through `forgetModelPermissionCache()` | -| `HasPermissions::syncPermissionsWithForbidden()` | around line 962 | Keep | this is the unsaved queued branch and no registrar model-cache helper runs | -| `HasPermissions::syncPermissionsWithForbidden()` | around line 990 | Remove | saved direct mutations go through `forgetModelPermissionCache()` | +| `HasPermissions::syncPermissionEffects()` | around line 962 | Keep | this is the unsaved queued branch and no registrar model-cache helper runs | +| `HasPermissions::syncPermissionEffects()` | around line 990 | Remove | saved direct mutations go through `forgetModelPermissionCache()` | | `HasPermissions::revokePermissionTo()` | around line 1015 | Remove | saved direct mutations go through `forgetModelPermissionCache()` | | `HasRoles::assignRole()` | around line 321 | Keep for unsaved models; remove from branches that already called `forgetModelRoleCache()` or `forgetCachedPermissions()` by restructuring the method so the direct call only runs when no cache-clearing helper ran | unsaved models only queue assignments and have no registrar cache helper to centralize through | | `HasRoles::attachQueuedRoleAssignments()` | around line 366 | Remove | saved model flush goes through `forgetModelRoleCache()` or `forgetCachedPermissions()` | @@ -1253,7 +1253,7 @@ Update `getPermissionsViaRoles()` to dedupe: ```php return $permissions - ->reject(fn (Model $permission): bool => isset($forbiddenPermissionKeys[$this->permissionComparisonKey($permission)])) + ->reject(fn (Model $permission): bool => isset($deniedPermissionKeys[$this->permissionComparisonKey($permission)])) ->unique(fn (Model $permission): string => $this->permissionComparisonKey($permission)) ->sort() ->values(); @@ -1264,7 +1264,7 @@ Also update `getAllPermissions()` to use the same comparison key: ```php return $directPermissions ->merge($viaRolePermissions) - ->reject(fn (Model $permission): bool => isset($forbiddenPermissionKeys[$this->permissionComparisonKey($permission)])) + ->reject(fn (Model $permission): bool => isset($deniedPermissionKeys[$this->permissionComparisonKey($permission)])) ->unique(fn (Model $permission): string => $this->permissionComparisonKey($permission)) ->sort() ->values(); @@ -1296,16 +1296,16 @@ Add to `tests/Permission/CacheTest.php`: 5. Mutate a role's permission edges. 6. Assert later `getAllPermissions()` reflects the mutation. -Add to `tests/Permission/ForbiddenPermissionTest.php`: +Add to `tests/Permission/DeniedPermissionTest.php`: - same permission granted through two roles appears once from `getPermissionsViaRoles()` -- forbidden duplicate remains excluded +- denied duplicate remains excluded Run: ```shell ./vendor/bin/phpunit --no-progress tests/Permission/CacheTest.php -./vendor/bin/phpunit --no-progress tests/Permission/ForbiddenPermissionTest.php +./vendor/bin/phpunit --no-progress tests/Permission/DeniedPermissionTest.php ``` ### 9. Remove Storage Connection Option @@ -1411,14 +1411,14 @@ Run the full permission test suite after all permission changes: Current: ```php -$relation = $this->morphToMany(...)->withPivot('is_forbidden'); +$relation = $this->morphToMany(...)->withPivot('is_denied'); if (! Config::teamsEnabled()) { return $relation; } $teamsKey = Config::teamForeignKey(); -$relation->withPivot($teamsKey, 'is_forbidden'); +$relation->withPivot($teamsKey, 'is_denied'); ``` Target: @@ -1428,14 +1428,14 @@ $teamsKey = Config::teamForeignKey(); $relation->withPivot($teamsKey); ``` -Only add the team pivot in the team branch because `is_forbidden` was already added. +Only add the team pivot in the team branch because `is_denied` was already added. #### README And Boost Docs `src/boost/docs/permission.md` already says allowed getters return allowed permissions only. Add concise bullets under `Differences From Spatie Laravel Permission` in both `src/permission/README.md` and `src/boost/docs/permission.md`: ```md -- `getDirectPermissions()`, `getPermissionsViaRoles()`, `getAllPermissions()`, and `getPermissionNames()` return effective allowed permissions. Explicit denies are exposed through `hasForbiddenPermission()` and `hasForbiddenPermissionViaRoles()`. +- `getDirectPermissions()`, `getPermissionsViaRoles()`, `getAllPermissions()`, and `getPermissionNames()` return effective allowed permissions. Explicit denied edges are exposed through `hasDeniedPermission()` and `hasDeniedPermissionViaRoles()`. - Undefined `permission.cache.store` values fail fast through Hypervel's cache manager instead of silently falling back to an array store. ``` @@ -1482,12 +1482,12 @@ Specific review checks: 1. Cross-guard tests cover direct user, role-via-user, and Role model paths. 2. Concrete permission object checks never become fail-open. -3. Wildcard checks still deny explicit forbidden permissions before wildcard allow. +3. Wildcard checks still deny explicit denied permissions before wildcard allow. 4. `HasAssignedModels` team tests prove other teams survive assign, remove, sync, and empty sync. 5. Delete hooks do not bump global token for plain models. 6. Role/Permission deletes still bump global token through `RefreshesPermissionCache`. 7. Catalog indexes preserve `Collection` return behavior and only fast-path supported param shapes. -8. Old cache payloads without `hasForbiddenRolePermissions` are safe. +8. The normal cache payload always includes the required `hasDeniedRolePermissions` field and reports it accurately. 9. Via-role memo clears on same-coroutine role assignment mutation and role-permission mutation. 10. Storage connection config has zero remaining references. 11. README and Boost docs have no stale storage-connection language. diff --git a/docs/plans/2026-07-13-permission-row-partitioning.md b/docs/plans/2026-07-13-permission-row-partitioning.md index a6abf1721..04d91c15d 100644 --- a/docs/plans/2026-07-13-permission-row-partitioning.md +++ b/docs/plans/2026-07-13-permission-row-partitioning.md @@ -67,7 +67,7 @@ The following implementation surfaces were read and traced: - the package config and stock migration - `src/permission/README.md` - all of `src/boost/docs/permission.md` -- the current Permission test base, cache/query-count tests, model tests, relation/assignment tests, teams tests, forbidden/wildcard tests, command tests, event tests, and custom-model fixtures. +- the current Permission test base, cache/query-count tests, model tests, relation/assignment tests, teams tests, denied/wildcard tests, command tests, event tests, and custom-model fixtures. ### Eloquent and cache internals read @@ -99,10 +99,10 @@ Important verified behavior: 12. Eloquent fires `saved` before `finishSave()` synchronizes a newly inserted model's original attributes. During that callback, `wasRecentlyCreated` is true, the inserted partition exists in raw current attributes, and raw original attributes do not yet contain it. Record-derived invalidation must therefore prefer a present raw original value, then fall back to a present raw current value only for a newly inserted model. It must never read the fallback through casts/accessors or use it for an existing narrowed model. 13. Eloquent eager loading constructs each relation under `Relation::noConstraints()` on an attribute-less, non-persisted `newInstance()` prototype. Partition-aware Role/Permission relations must allow only that prototype to define the relation; real persisted parents and unsaved parents carrying an explicit partition attribute still validate. The eager query remains protected by the captured related-table and pivot predicates, while `addEagerConstraints()` supplies the real parent keys. 14. Unsaved-subject assignment queues currently retain only Role/Permission IDs and pivot data, then resolve one ambient relation context again from the model's `saved` callback. If context changes before the model is saved, the captured pivot and newly resolved relation disagree. Multiple deferred batches can also partially commit before a later batch fails, leaving the retained queue inconsistent with the database on retry. Deferred assignments must retain their immutable `PermissionRelationContext`, flush every Role and Permission batch in one model-connection transaction, clear queues only after commit, and invalidate each captured context only after commit. -15. Pure-add APIs currently queue and invalidate even when their resolved Role or Permission ID list is empty. The replacement/sync queue path already skips empty lists. `assignRole()`, `givePermissionTo()`, and `giveForbiddenTo()` must treat an empty resolved add as no write, queue, or cache mutation, while still resolving and validating the current partition before the no-op guard. Their existing Spatie-shaped events continue reporting the collected request, including an empty or already-satisfied request; event behavior is a public contract independent of whether a pivot write was necessary. Sync-to-empty remains a real detach mutation for saved models. +15. Pure-add APIs currently queue and invalidate even when their resolved Role or Permission ID list is empty. The replacement/sync queue path already skips empty lists. `assignRole()`, `givePermissionTo()`, and `denyPermissionTo()` must treat an empty resolved add as no write, queue, or cache mutation, while still resolving and validating the current partition before the no-op guard. Their existing Spatie-shaped events continue reporting the collected request, including an empty or already-satisfied request; event behavior is a public contract independent of whether a pivot write was necessary. Sync-to-empty remains a real detach mutation for saved models. 16. `syncRoles()` delegates to `assignRole()` for an unsaved subject, so it appends instead of replacing earlier queued roles. `removeRole()` and `revokePermissionTo()` issue no-op database deletes for unsaved subjects without reconciling their queued entries. Deferred sync/removal must update only the exact captured `PermissionRelationContext` queue, drop empty entries, and preserve other contexts. Existing assignment events still dispatch synchronously at the public method call boundary; the later saved callback emits nothing because it only commits already-reported deferred intent. 17. Assignment events are established Spatie-shaped public APIs that describe the collected assignment request, not a database change-set. Preserve their pre-branch payload types, requested IDs, no-op behavior, and saved/unsaved dispatch timing. In particular, `PermissionDetachedEvent` receives the stored Permission model or collection, and Permission sync emits only its established attached event. Do not add queries, sorting, payload conversion, or event suppression to make these events report persisted deltas. Keep `eventsEnabled()` and `hasListeners()` guards so disabled or unobserved events add no construction/dispatch overhead. -18. Native `BelongsToMany::sync()` cannot produce truthful direct-permission effect deltas when desired records carry `is_forbidden` attributes. It calls `updateExistingPivot()` for every current record with non-empty attributes, rewrites unchanged effects, and treats driver matched-row counts as `updated`. Role sync is unaffected because plain Role IDs have empty per-record attributes. Direct-permission add/sync must share one pivot-only read/compare/minimal-write synchronizer that returns PHP-computed deltas. +18. Native `BelongsToMany::sync()` cannot produce truthful direct-permission effect deltas when desired records carry `is_denied` attributes. It calls `updateExistingPivot()` for every current record with non-empty attributes, rewrites unchanged effects, and treats driver matched-row counts as `updated`. Role sync is unaffected because plain Role IDs have empty per-record attributes. Direct-permission add/sync must share one pivot-only read/compare/minimal-write synchronizer that returns PHP-computed deltas. 19. Package-owned operations that genuinely perform multiple writes must be atomic. A failed later write after an earlier delete/attach otherwise leaves a partial authorization graph. Direct-permission synchronization, Role bulk replacement, `syncModels()`, deferred multi-context flushing, multi-table record cleanup, and discovery-plus-delete subject cleanup use model-connection transactions and invalidate only after commit. Simple one-write attach/detach methods use native `attach()` / `detach()` without adding package transaction policy; applications wrap Permission calls in their own transaction when they need atomicity with model touches or other application work. 20. Pre-branch Hypervel Role synchronization already uses one detach and one bulk attach, unlike native `BelongsToMany::sync()`'s per-ID inserts. Preserve that efficient bulk replacement shape with a captured partition/team relation and wrap the two writes in one transaction. Do not add delta indexing or sorting. When `RoleDetachedEvent` is observed, one listener-gated pivot-only read captures the pre-operation current IDs required by that established event; the ordinary path performs no current-pivot read. This replaces pre-branch full Role hydration with the same query count and lower cost on the observed-event path. 21. `removeRole()` and `revokePermissionTo()` preserve their established request-oriented events. Issue the normal captured-scope detach without an event-only discovery read, then dispatch the collected Role IDs or stored Permission model/collection exactly as before. Do not branch SQL shape on listener presence. Event guards still prevent construction and dispatch when disabled or unobserved. @@ -110,7 +110,7 @@ Important verified behavior: 23. Reverse Role assignment helpers can write several morph-class batches, while `syncModels()` can commit its scope-wide delete before any replacement attach succeeds. Precompute and validate every group before writing. `assignToModels()` and `removeFromModels()` execute zero or one morph-class write directly and use one parent Role connection transaction only when several morph-class writes must succeed together. `syncModels()` always keeps one transaction around its delete and replacement inserts. Perform cache/token/relation cleanup only after successful writes. Permission authorization tables and pivots form one shared schema on the parent Role/Permission connection; split authorization-table connections are not supported. 24. `partitionFromRecord()` must report an absent or invalid raw persisted partition as a record constraint violation, not fabricate an `unresolved` ambient partition merely to reuse a context-mismatch message. The new exception covers context mismatch, immutable-column mutation, missing/invalid persisted values, and conflicting pivot input, so use the general `PermissionPartitionViolation` name rather than a mismatch-only name. Use distinct factories for each invariant. Render `null`, an empty string, and non-scalar types visibly without dumping state, and test each raw-record failure with an unchanged database row. 25. Unsaved Role and Permission queue mutations still clear wildcard runtime state before any pivot exists. Authorization reads do not consume deferred queues, so those clears cannot expose queued intent; for Roles they clear the whole ambient partition and can target the wrong partition when the queue retained another captured context. Remove every pre-save wildcard clear. The successful saved-callback flush is the sole deferred invalidation boundary: after commit it invalidates each distinct stored partition/team context exactly, while rollback retains queues and cache state unchanged. -26. The direct-permission synchronizer batches attaches and detaches but performs one `updateExistingPivot()` statement per real `is_forbidden` flip. Since the effect has only two target values, collect changed IDs into allowed and forbidden groups and execute at most two captured-scope `whereIn(...)->update(...)` statements inside the existing transaction. This bypasses native pivot timestamp/custom-class hooks intentionally because Permission assignment pivots have neither timestamps nor a custom pivot class by design; record that assumption beside the code and revisit batching if the schema contract changes. The Hypervel-owned `syncPermissionsWithForbidden()` return value remains a PHP-computed accurate change-set and preserves natural pivot-read order; assignment events remain request-oriented and do not consume this change-set. +26. The direct-permission synchronizer batches attaches and detaches but performs one `updateExistingPivot()` statement per real `is_denied` flip. Since the effect has only two target values, collect changed IDs into allowed and denied groups and execute at most two captured-scope `whereIn(...)->update(...)` statements inside the existing transaction. This bypasses native pivot timestamp/custom-class hooks intentionally because Permission assignment pivots have neither timestamps nor a custom pivot class by design; record that assumption beside the code and revisit batching if the schema contract changes. The Hypervel-owned `syncPermissionEffects()` return value remains a PHP-computed accurate change-set and preserves natural pivot-read order; assignment events remain request-oriented and do not consume this change-set. 27. Current Spatie and Hypervel `HasAssignedModels` treat `assignToModels()` on an unsaved Role as a fluent no-op, but `removeFromModels()` still issues a useless null-key delete and `syncModels()` attempts non-null/FK-invalid pivot inserts with a null Role key. Preserve the existing public API contract by adding the same top-level unsaved guard to all three methods. Do not invent a deferred reverse-assignment queue or change the established method to throw. Record this as a fixed upstream bug and recommend the same two guards back to Spatie. 28. Saved `assignRole()`, `removeRole()`, and `revokePermissionTo()` retain native plain `attach()` / `detach()` semantics. `touchIfTouching()` can perform real application-configured model touches, but making a simple pivot write and those optional touches atomic is application transaction policy rather than a Permission-owned guarantee. Role/Permission record deletion is different because the package itself owns two pivot-table cleanups: wrap both captured-partition deletes in one model-connection transaction and invalidate only after the model deletion commits. Built-in Role and Permission models do not use soft deletes; an application subclass that does must wrap `forceDelete()` in an application transaction when the record row and package cleanup must commit together because Eloquent has no `forceDeleteOrFail()`. 29. Consolidating all subject cleanup into `HasRoles` broke the supported composition of a model using `HasPermissions` without `HasRoles`, while moving Role/Permission record cleanup into `RefreshesPermissionCache` made cleanup depend on an optional cache concern instead of the public authorization traits. Preserve the Spatie-shaped ownership boundary: `HasPermissions` owns direct-subject and Role-record cleanup; `HasRoles` owns role-subject and Permission-record cleanup; `RefreshesPermissionCache` owns saved/deleted catalog invalidation only. Each subject trait discovers and deletes its own table. This costs two cold-path discovery reads for a full `HasRoles` subject instead of one UNION, but it preserves every public trait composition and avoids shared deletion machinery. @@ -179,9 +179,9 @@ This queue is in-process deferred work on one unsaved Eloquent instance. It is s ### Empty pure-add calls are validated no-ops -An empty resolved input to `assignRole()`, `givePermissionTo()`, or `giveForbiddenTo()` does not describe an authorization mutation. Queuing an inert entry, writing a pivot, or invalidating cache for it is wrong. Its established request-oriented attach event still dispatches when enabled and observed, including for an empty collected request. +An empty resolved input to `assignRole()`, `givePermissionTo()`, or `denyPermissionTo()` does not describe an authorization mutation. Queuing an inert entry, writing a pivot, or invalidating cache for it is wrong. Its established request-oriented attach event still dispatches when enabled and observed, including for an empty collected request. -Decision: resolve the relation context and collect/validate supplied models first, then return immediately when the collected ID list is empty. This single placement covers zero arguments and inputs filtered to empty while preserving fail-closed subject validation: an empty add on a partition-bearing subject from A under context B still throws `PermissionPartitionViolation`. Do not place a pre-context zero-argument shortcut before validation. Do not apply the guard to `syncRoles([])`, `syncPermissions([])`, or `syncPermissionsWithForbidden([], [])`; on a saved model, those calls intentionally remove existing assignments and invalidate their caches. +Decision: resolve the relation context and collect/validate supplied models first, then return immediately when the collected ID list is empty. This single placement covers zero arguments and inputs filtered to empty while preserving fail-closed subject validation: an empty add on a partition-bearing subject from A under context B still throws `PermissionPartitionViolation`. Do not place a pre-context zero-argument shortcut before validation. Do not apply the guard to `syncRoles([])`, `syncPermissions([])`, or `syncPermissionEffects([], [])`; on a saved model, those calls intentionally remove existing assignments and invalidate their caches. ### Deferred queues preserve context; assignment events preserve their public contract @@ -189,7 +189,7 @@ An unsaved subject has no assignment edge yet, but the package's established ass - `syncRoles()` replaces queued roles only in the captured partition/team context; - `removeRole()` removes matching queued roles only from that context; -- `revokePermissionTo()` removes matching queued permissions from both allowed and forbidden batches only in that context; +- `revokePermissionTo()` removes matching queued permissions from both allowed and denied batches only in that context; - entries that become empty are removed; - other queued partition/team contexts remain unchanged; - unsaved add, sync, remove, and revoke operations retain their pre-branch requested-input event behavior; @@ -197,7 +197,7 @@ An unsaved subject has no assignment edge yet, but the package's established ass For saved subjects, events also retain the pre-branch request-oriented contract. `RoleAttachedEvent`, `RoleDetachedEvent`, and `PermissionAttachedEvent` receive collected requested ID arrays, including already-satisfied or empty requests. `PermissionDetachedEvent` receives the stored Permission model or collection returned by `getStoredPermission()`. Permission synchronization emits only its established attached event; it does not invent a detached event from the internal change-set. Invalidate changed cache state before dispatch so listeners observe current authorization data, but do not invalidate an already-correct cache solely because a no-op request still emits its public event. -Saved Role synchronization uses one captured relation to detach the old set and bulk-attach the requested replacement inside one transaction. This preserves pre-branch Hypervel's efficient bulk write shape while preventing a failed attach from leaving all Roles removed. It does not compute deltas. Only an observed `RoleDetachedEvent` triggers one pre-operation pivot-only ID read because its established payload is the current set; remove/revoke events need no such read because their payload is the already-known request. Saved direct-permission add/sync uses the transactional pivot-only effect-aware synchronizer because the `is_forbidden` edge state requires real comparison and minimal updates. +Saved Role synchronization uses one captured relation to detach the old set and bulk-attach the requested replacement inside one transaction. This preserves pre-branch Hypervel's efficient bulk write shape while preventing a failed attach from leaving all Roles removed. It does not compute deltas. Only an observed `RoleDetachedEvent` triggers one pre-operation pivot-only ID read because its established payload is the current set; remove/revoke events need no such read because their payload is the already-known request. Saved direct-permission add/sync uses the transactional pivot-only effect-aware synchronizer because the `is_denied` edge state requires real comparison and minimal updates. The Role sync transaction is a deliberate correctness improvement, not a restoration of pre-branch transaction behavior. Both Role sync events dispatch after commit. Their payloads and order remain unchanged, but a detached-event listener that queries the database sees the final replacement graph instead of the pre-branch mid-sync empty graph. Post-commit visibility is the correct contract for the new atomic operation. @@ -205,7 +205,7 @@ Detach and revoke issue the same blind captured-scope delete regardless of liste The direct-permission synchronizer's `attached` and effect `updated` return values preserve caller order. Its detached return values preserve the natural pivot-read order, matching the prior native relation-sync behavior as closely as the custom effect-aware implementation permits. Do not sort them or expose any of these internal deltas through assignment events. -The direct-permission synchronizer is also the single implementation for `givePermissionTo()` and `giveForbiddenTo()` with detaching disabled. Its pivot-only read replaces the existing full Permission-model join/hydration read. Narrow non-detaching reads to requested IDs; sync reads the full captured partition/team pivot scope to compute removals. Within one model-connection transaction, batch allowed/forbidden inserts, group real effect flips by their target boolean into at most two scoped updates, touch once, and return driver-independent PHP-computed deltas. One shared `permissionEffectIsForbidden()` normalizes hydrated and raw pivot effects; comment that it relies on framework connections returning native booleans/0-1 integers, and protect that assumption with supported-database integration tests. The raw effect updates rely on the package's timestamp-free, non-custom Permission assignment pivot schema; if that schema contract changes, the batching path must add the corresponding timestamp/custom-pivot behavior. +The direct-permission synchronizer is also the single implementation for `givePermissionTo()` and `denyPermissionTo()` with detaching disabled. Its pivot-only read replaces the existing full Permission-model join/hydration read. Narrow non-detaching reads to requested IDs; sync reads the full captured partition/team pivot scope to compute removals. Within one model-connection transaction, batch allowed/denied inserts, group real effect flips by their target boolean into at most two scoped updates, touch once, and return driver-independent PHP-computed deltas. One shared `permissionEffectIsDenied()` normalizes hydrated and raw pivot effects; comment that it relies on framework connections returning native booleans/0-1 integers, and protect that assumption with supported-database integration tests. The raw effect updates rely on the package's timestamp-free, non-custom Permission assignment pivot schema; if that schema contract changes, the batching path must add the corresponding timestamp/custom-pivot behavior. Keep the collision-safe ID indexing, raw-pivot normalization, and optional-ID-constrained pivot-only read directly with the permission synchronizer. Desired and current Permission IDs both normalize then pass through `PermissionPartition::encodeCacheSegment()`, so driver-hydrated string `'5'` matches desired integer `5` while carried write/return IDs retain the proper key type. Do not retain a generic assignment synchronization engine after the Role synchronizer is removed. @@ -903,7 +903,7 @@ Same-name Role and Permission rows are valid in different partitions because app Partition-aware relations cover: - `assignRole`, `removeRole`, `syncRoles`, queued role assignment; -- `givePermissionTo`, `giveForbiddenTo`, `revokePermissionTo`, `syncPermissions`, `syncPermissionsWithForbidden`, queued permission assignment; +- `givePermissionTo`, `denyPermissionTo`, `revokePermissionTo`, `syncPermissions`, `syncPermissionEffects`, queued permission assignment; - `assignToModels`, `removeFromModels`, `syncModels`; - Role-Permission assignment from either model direction; - public relation `attach`, `detach`, `toggle`, `sync`, `syncWithoutDetaching`, `syncWithPivotValues`, `updateExistingPivot`, and `...OrFail` variants. @@ -916,7 +916,7 @@ Reverse Role assignment helpers group and validate all supplied subject models b `assignToModels()` detects already-present requested IDs from the captured relation's pivot-only query. It must not use the joined related-model query because subject global scopes, including soft deletes, do not remove the assignment pivot and must not make an existing edge appear absent. -For assignments queued on an unsaved subject, capture and retain the full `PermissionRelationContext` at the call boundary. The `saved` callback must not resolve ambient partition or team context. It groups deferred entries by the stored context, deduplicates Role IDs within each context, preserves Permission replacement/forbidden semantics within each context, and writes all Role and Permission batches in one transaction on the subject model's connection. Queues are cleared only after commit; exact per-context cache invalidation runs after commit and is deduplicated across Role and Permission batches. +For assignments queued on an unsaved subject, capture and retain the full `PermissionRelationContext` at the call boundary. The `saved` callback must not resolve ambient partition or team context. It groups deferred entries by the stored context, deduplicates Role IDs within each context, preserves Permission replacement/denied semantics within each context, and writes all Role and Permission batches in one transaction on the subject model's connection. Queues are cleared only after commit; exact per-context cache invalidation runs after commit and is deduplicated across Role and Permission batches. Pure-add methods skip queue, write, and invalidation after context/model validation when their collected ID list is empty, but retain the existing requested-input attach event. Sync methods retain their native empty-input detach meaning. @@ -940,12 +940,12 @@ Search both `src/` and `tests/` for every authorization table helper after imple - an explicit raw query with a bound partition; - an intentionally cross-partition/team subject-deletion discovery query for one owning assignment table. -### Guards, forbidden permissions, and wildcards +### Guards, denied permissions, and wildcards No special query system is added for these features: - guard matching remains inside the already partitioned catalog; -- `is_forbidden` remains an edge attribute on partitioned pivots; +- `is_denied` remains an edge attribute on partitioned pivots; - wildcard indexes use partitioned model runtime cache identities; - via-role permission materialization uses partitioned catalog and assignment identities. @@ -1017,7 +1017,7 @@ The per-partition assignment token is not used here. Bumping it would avoid team - The resolver is an in-memory Context lookup and performs no query. - Existing cache TTL behavior remains. - Swoole stores hash the longer logical key, so applications only need to size table row count/value capacity for their workload; Permission requires no Swoole-specific branch. -- Partitioned and unpartitioned modes use identical query counts for the same synchronization operation. Saved Role sync uses the pre-branch bulk delete/insert shape in both modes. Direct-permission synchronization reads current pivots in both modes to compare `is_forbidden` effects and avoid rewriting unchanged edges; this mode-independent read is not partition overhead. +- Partitioned and unpartitioned modes use identical query counts for the same synchronization operation. Saved Role sync uses the pre-branch bulk delete/insert shape in both modes. Direct-permission synchronization reads current pivots in both modes to compare `is_denied` effects and avoid rewriting unchanged edges; this mode-independent read is not partition overhead. ### Cache reset @@ -1097,7 +1097,7 @@ Schema::create('role_has_permissions', function (Blueprint $table) use ($partiti $table->uuid($partition); $table->uuid('permission_id'); $table->uuid('role_id'); - $table->boolean('is_forbidden')->default(false); + $table->boolean('is_denied')->default(false); $table->primary([$partition, 'permission_id', 'role_id']); @@ -1135,7 +1135,7 @@ Schema::create('model_has_permissions', function (Blueprint $table) use ($partit $table->uuid($partition); $table->uuid('permission_id'); $table->uuidMorphs('model'); - $table->boolean('is_forbidden')->default(false); + $table->boolean('is_denied')->default(false); $table->primary([$partition, 'permission_id', 'model_id', 'model_type']); $table->index( @@ -1325,13 +1325,13 @@ Add `tests/Permission/PartitionRelationsTest.php` covering every relation from b - global subjects can have different assignments in A and B; - teams nest inside partition and neither dimension replaces the other. - unsaved global subjects retain the partition/team captured by each queued Role and Permission assignment even when saved under another or missing ambient context; -- one unsaved global subject may queue assignments in multiple partitions and teams, with Role IDs deduplicated per captured context and Permission replacement/forbidden behavior kept per captured context; +- one unsaved global subject may queue assignments in multiple partitions and teams, with Role IDs deduplicated per captured context and Permission replacement/denied behavior kept per captured context; - all deferred Role and Permission batches flush atomically: a forced later-batch failure rolls back earlier batches, retains the queues, and a clean retry inserts each edge exactly once; - deferred assignment invalidation occurs once per distinct captured context and only after a successful commit. - deferred queue add, sync, remove, and revoke change no wildcard, catalog, or model runtime cache before save; a multi-partition queue invalidates every stored context only after commit and changes no cache state on rollback; -- empty `assignRole()`, `givePermissionTo()`, and `giveForbiddenTo()` calls on saved and unsaved subjects perform no queue, pivot write, or invalidation while retaining their requested-input attach event; +- empty `assignRole()`, `givePermissionTo()`, and `denyPermissionTo()` calls on saved and unsaved subjects perform no queue, pivot write, or invalidation while retaining their requested-input attach event; - empty pure-add calls still reject a partition-bearing subject whose record partition conflicts with the current context; -- saved `syncRoles([])`, `syncPermissions([])`, and `syncPermissionsWithForbidden([], [])` still detach existing edges and invalidate, proving the pure-add guard does not change sync semantics. +- saved `syncRoles([])`, `syncPermissions([])`, and `syncPermissionEffects([], [])` still detach existing edges and invalidate, proving the pure-add guard does not change sync semantics. - unsaved role sync replaces only its captured context instead of appending, and unsaved role/permission removal drops matching queued IDs without touching other contexts; - queued entries that become empty are removed; - deferred operations dispatch their established requested-input events at the public call boundary, and the saved-callback flush emits no duplicate event even when save runs under another or missing ambient context; @@ -1342,7 +1342,7 @@ Add `tests/Permission/PartitionRelationsTest.php` covering every relation from b - the direct-permission public change-set preserves caller order for desired-side entries and natural pivot-read order for detached entries without sorting; - unchanged permission effects are neither rewritten nor reported as updated on any supported database; - mixed permission effect synchronization batches all real flips into exactly two target-value updates while leaving unchanged edges unwritten; -- a permission present in both allowed and forbidden sync input deterministically resolves to forbidden; +- a permission present in both allowed and denied sync input deterministically resolves to denied; - listeners observe invalidated, post-mutation authorization state. - Role sync and permission synchronization roll back all earlier writes when any later batch/update fails, retain pre-operation cache state, emit no post-failure events, and retry cleanly; - syncing many new Roles uses one bulk insert rather than one insert per Role; @@ -1409,13 +1409,13 @@ Add `tests/Permission/PartitionDeletionTest.php` covering: - no discovery query occurs only when both partitioning and teams are disabled; - stable morph identity requirement is enforced by fixture design and documented. -### Guards, forbidden, wildcard, and teams +### Guards, denied, wildcard, and teams Extend or add focused cases so both partitions cover: - same names under different guards; -- direct forbidden override; -- role-inherited forbidden override; +- direct denied override; +- role-inherited denied override; - wildcard allow and deny; - team-global and team-specific roles inside each partition; - `role()`, `withoutRole()`, `permission()`, `withoutPermission()`, `team()`, and `withoutTeam()` scopes. @@ -1623,7 +1623,7 @@ Database CI must run `tests/Integration/Database/PermissionPartitionTest.php` un - [ ] Laravel/Spatie relation types, assignment APIs, commands, events, contracts, Gate, middleware, and Blade surfaces remain intact. - [ ] Same-name roles/permissions work across partitions and collide inside one partition. - [ ] Partition, team, and guard compose independently. -- [ ] Direct, inherited, forbidden, wildcard, and query-scope paths are isolated. +- [ ] Direct, inherited, denied, wildcard, and query-scope paths are isolated. - [ ] Loaded relation provenance handles lazy, eager, nested, and empty collections. - [ ] Teams-only warm authorization reuses partition-independent hydrated catalog relations without database queries. - [ ] Switching teams no longer requires manual relation unsetting. diff --git a/docs/plans/2026-07-15-permission-denied-terminology.md b/docs/plans/2026-07-15-permission-denied-terminology.md new file mode 100644 index 000000000..7bf2cd9cf --- /dev/null +++ b/docs/plans/2026-07-15-permission-denied-terminology.md @@ -0,0 +1,632 @@ +# Permission Denied Terminology Plan + +## Objective + +Align Hypervel Permission's explicit negative permission effect with the established authorization vocabulary of **allow** and **deny**. + +The finished package must read as if explicit denial had always been designed with this vocabulary: + +- public methods use `deny` / `denied` consistently; +- assignment pivots store a boolean `is_denied` effect; +- runtime variables, helpers, cache payloads, tests, package metadata, and documentation use the same terms; +- `givePermissionTo()` remains the positive Spatie-shaped assignment API; +- `revokePermissionTo()` continues to remove an assignment edge rather than creating a denied edge; +- behavior, query shape, cache architecture, events, partitioning, teams, guards, wildcard checks, UUID/ULID support, and integer support do not otherwise change. + +This is a direct source migration. Backwards compatibility and upgrade choreography are not design constraints. Do not add deprecated aliases, dual columns, fallback cache fields, compatibility reads, follow-up migrations, or transitional documentation. The final tree must contain one vocabulary and one implementation. + +## Why Deny Is The Correct Vocabulary + +### Authorization conventions + +The package models a binary effect on an existing permission assignment edge. One state grants the permission and the other explicitly rejects it. `allow` / `deny` is the clearest pair for that model: + +- [AWS IAM policy evaluation](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_evaluation-logic.html) describes `Allow` and `Deny` effects and specifies that an explicit deny overrides an allow. +- [XACML 3.0](https://docs.oasis-open.org/xacml/3.0/xacml-3.0-core-spec-en.html) uses `Permit` and `Deny` authorization decisions. +- [Cedar authorization](https://docs.cedarpolicy.com/auth/authorization.html) uses policy-language verbs `permit` and `forbid`, but its authorization engine still returns `Allow` or `Deny`. This shows that `forbid` is valid inside Cedar's specifically paired policy grammar, not that it is the clearest name for a general permission-assignment API. +- Hypervel Auth already exposes `Response::allow()`, `Response::deny()`, `Response::allowed()`, and `Response::denied()`, plus Gate methods such as `allows()` and `denies()`. Permission should use the same framework vocabulary. + +The current package already calls the positive effect `allowed` in the dual-effect synchronization API and documentation. Renaming the negative half to `denied` removes a mixed `allowed` / `forbidden` vocabulary. + +### Spatie baseline + +The local upstream reference at `examples/spatie/permission` has no negative assignment effect or corresponding public API. This capability is Hypervel-owned, so choosing the terminology does not diverge from an upstream Spatie method name. Spatie's positive APIs remain unchanged. + +### Repository inventory + +A case-insensitive source audit found the Permission-specific terminology in 28 live package/document/test files, four prior Permission plan documents, and 14 files under `_archive/`. Two other plan files contain unrelated ordinary-English uses that must remain unchanged. The live implementation is concentrated in the stock migration, three effect-bearing relations, `HasPermissions`, `PermissionRegistrar`, the About feature label, the package README, and the Boost Permission guide. No config key, contract interface, event class, exception class, command class, Blade directive, middleware name, or route macro carries the terminology. + +The same whole-tree audit also found unrelated HTTP 403 APIs and ordinary-English uses. Those are classified explicitly below rather than being swept into this change. + +### Explicit denial versus implicit denial + +The package must distinguish: + +- **implicit denial**: no effective allowed permission exists; and +- **explicit denial**: an assignment row exists with `is_denied = true`, which overrides matching allowed edges. + +Methods such as `hasDeniedPermission()` inspect explicit denied edges. They do not answer whether the final authorization result is false for any reason. Their docblocks and user documentation must say this explicitly. + +## Settled Public API + +The final public API is: + +| Purpose | Final API | Contract | +|---|---|---| +| Add or flip an allowed edge | `givePermissionTo(...$permissions): static` | Unchanged Spatie-shaped API. | +| Add or flip a denied edge | `denyPermissionTo(...$permissions): static` | Stores `is_denied = true`. | +| Remove either effect | `revokePermissionTo($permission): static` | Deletes the assignment edge; it does not deny it. | +| Replace both effect sets | `syncPermissionEffects(array\|Collection $allowed = [], array\|Collection $denied = []): array` | Denied input wins if the same permission appears in both lists. | +| Inspect a direct explicit deny | `hasDeniedPermission($permission, ?string $guardName = null): bool` | Checks direct denied edges only. | +| Inspect a role-derived explicit deny | `hasDeniedPermissionViaRoles($permission, ?string $guardName = null): bool` | Checks denied role-permission edges inherited by the model. | +| Skip unnecessary via-role work | `PermissionRegistrar::hasDeniedRolePermissions(): bool` | Reports whether the hydrated catalog has any denied role-permission edge. | + +Remove the old public names outright. Do not retain aliases or deprecation wrappers. `tests/Permission/PublicApiTest.php` must prove the final method names and parameter names, including `syncPermissionEffects` parameters `allowed` and `denied`. + +The complete symbol and storage rename map is: + +| Existing name | Final name | +|---|---| +| `giveForbiddenTo()` | `denyPermissionTo()` | +| `hasForbiddenPermission()` | `hasDeniedPermission()` | +| `hasForbiddenPermissionViaRoles()` | `hasDeniedPermissionViaRoles()` | +| `syncPermissionsWithForbidden(allowed:, forbidden:)` | `syncPermissionEffects(allowed:, denied:)` | +| `PermissionRegistrar::hasForbiddenRolePermissions()` | `PermissionRegistrar::hasDeniedRolePermissions()` | +| `is_forbidden` | `is_denied` | +| `forbiddenPermissionKeys()` | `deniedPermissionKeys()` | +| `pivotIsForbidden()` | `pivotIsDenied()` | +| `permissionEffectIsForbidden()` | `permissionEffectIsDenied()` | +| `hasForbiddenRolePermissions` cache field | `hasDeniedRolePermissions` cache field | + +Correct the adjacent inaccurate Spatie-derived docblock while editing the API pair: + +```php +/** + * Grant the given permission(s) to the model. + * + * @param array|Collection|int|Permission|string|UnitEnum $permissions + */ +public function givePermissionTo(...$permissions): static +{ + return $this->attachPermissions($permissions, false); +} + +/** + * Deny the given permission(s) for the model. + * + * @param array|Collection|int|Permission|string|UnitEnum $permissions + */ +public function denyPermissionTo(...$permissions): static +{ + return $this->attachPermissions($permissions, true); +} +``` + +The explicit-denial query methods use accurate Laravel-style title docblocks: + +```php +/** + * Determine if the model has an explicit denied direct permission. + * + * @param int|Permission|string|UnitEnum $permission + */ +public function hasDeniedPermission($permission, ?string $guardName = null): bool; + +/** + * Determine if the model has an explicit denied permission via roles. + * + * @param int|Permission|string|UnitEnum $permission + */ +public function hasDeniedPermissionViaRoles($permission, ?string $guardName = null): bool; +``` + +## Schema Contract + +Rename the assignment effect column in the stock migration: + +```php +$table->boolean('is_denied')->default(false); +``` + +Apply this to: + +- `model_has_permissions`; +- `role_has_permissions`. + +Keep the boolean representation. The data model is an assignment edge with one of two effects, not an extensible policy-effect enum. A string or enum column would add storage, indexing, validation, and branching complexity without representing another supported state. + +Edit `src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php` in place. Do not add an upgrade migration. Application-owned custom migrations, including the partitioned schema examples, use `is_denied` directly. + +The column remains outside the primary key. One permission assignment identity has exactly one row, and assigning the opposite effect updates that row. + +## Runtime Semantics That Must Not Change + +This work changes vocabulary, not authorization behavior. + +### Effective permission checks + +`hasPermissionTo()` and the wildcard path must check direct and inherited explicit denies before returning an allow: + +```php +if ($this->hasDeniedPermission($permission, $guardName)) { + return false; +} + +if ($this->hasDeniedPermissionViaRoles($permission, $guardName)) { + return false; +} +``` + +Role checks use `hasDeniedPermission()` before accepting a direct role-permission allow. + +### Relations and query scopes + +Every Permission relation that hydrates an effect pivot must request the final column: + +```php +->withPivot('is_denied'); +``` + +This applies to: + +- model-to-permission relations in `HasPermissions`; +- role-to-permission relations in `Models/Role`; +- permission-to-role relations in `Models/Permission`. + +The `permission()` and `withoutPermission()` scopes keep their existing SQL structure. Only the effect-column identifier and internal boolean parameter name change: + +```php +protected function whereDirectPermissionEffect( + Builder $query, + int|string $permissionId, + bool $denied, +): Builder { + $permissionKey = Guard::getModelKeyName($this->getPermissionClass()); + $pivotTable = $this instanceof Role + ? Config::roleHasPermissionsTable() + : Config::modelHasPermissionsTable(); + + return $query + ->where(Config::permissionsTable() . ".{$permissionKey}", $permissionId) + ->where("{$pivotTable}.is_denied", $denied); +} +``` + +Rename the sibling inherited-role predicate in the same pass: + +```php +protected function whereRolePermissionEffect( + Builder $query, + int|string $permissionId, + bool $denied, +): Builder { + $permissionKey = Guard::getModelKeyName($this->getPermissionClass()); + + return $query + ->where(Config::permissionsTable() . ".{$permissionKey}", $permissionId) + ->where(Config::roleHasPermissionsTable() . '.is_denied', $denied); +} +``` + +There must be no additional query, join, cache read, or allocation on a hot path. The generated SQL is identical except for the renamed physical column. + +### Writes and synchronization + +Keep the existing effect-aware synchronizer and its performance characteristics. Rename its data vocabulary coherently: + +```php +/** + * Synchronize direct permission assignment presence and effects. + * + * @param array $allowed + * @param array $denied + * @return array{attached: array, detached: array, updated: array} + */ +private function synchronizePermissionAssignments( + array $allowed, + array $denied, + PermissionRelationContext $context, + bool $detaching, +): array; +``` + +Desired and current assignment entries use `is_denied`. Rename effect-specific batches to `$attachAllowed`, `$attachDenied`, `$updateAllowed`, and `$updateDenied`. Preserve: + +- one pivot-only current-state read; +- one bulk detach when needed; +- at most one allowed attach and one denied attach; +- at most one allowed-effect update and one denied-effect update; +- the existing transaction and single `touchIfTouching()` call; +- caller-order and driver-independent change-set behavior; +- integer, UUID, and ULID key normalization. + +The effect updates become: + +```php +if ($updateAllowed !== []) { + $relation->newPivotQuery() + ->whereIn($relatedPivotKey, $updateAllowed) + ->update(['is_denied' => false]); +} + +if ($updateDenied !== []) { + $relation->newPivotQuery() + ->whereIn($relatedPivotKey, $updateDenied) + ->update(['is_denied' => true]); +} +``` + +Retain the comment explaining why these raw batched effect updates are correct for Permission's timestamp-free, non-custom pivots. Update the comment's terminology only. + +The final dual-effect sync implementation is named and documented as follows: + +```php +/** + * Remove all current permissions and set allowed and denied permissions. + * + * For unsaved models, assignments are queued until the model is saved and + * the returned change set is empty because no database rows are changed yet. + * + * @param array|Collection $allowed + * @param array|Collection $denied + * @return array{attached: array, detached: array, updated: array} + */ +public function syncPermissionEffects( + array|Collection $allowed = [], + array|Collection $denied = [], +): array; +``` + +The implementation must continue to remove any denied IDs from the allowed list, so denied wins when an ID is present in both inputs. Unsaved assignment batching must use `denied` in its private batch identities and preserve all existing captured partition/team context behavior. + +### Events + +Do not rename or reshape Permission assignment events. They report requested permission IDs, not effect names, and no event class or payload currently exposes the old vocabulary. Calls through `denyPermissionTo()` and `syncPermissionEffects()` must retain the existing event count, timing, listener guards, and payload format. + +The event tests are renamed only where test method names or setup calls describe the denied operation. + +## Cache Contract + +Rename every cache representation atomically with the source schema: + +- hydrated pivot key `is_denied`; +- serialized role-permission pivot key `is_denied`; +- model direct-permission assignment array key `is_denied`; +- coroutine catalog field `hasDeniedRolePermissions`; +- public fast-path method `hasDeniedRolePermissions()`. + +The final serialized and hydrated catalog shapes are: + +```php +/** + * @return array{ + * permissions: array>, + * roles: array>, + * hasDeniedRolePermissions: bool + * } + */ +private function getSerializedPermissionsForCache(): array; +``` + +```php +$catalog = [ + // Existing hydrated collections and indexes remain unchanged. + 'hasDeniedRolePermissions' => (bool) $payload['hasDeniedRolePermissions'], +]; +``` + +Treat both `hasDeniedRolePermissions` and the per-model assignment `is_denied` effect as required fields in package-generated cache payloads. Remove their optional old-payload annotations and null-coalescing fallbacks, along with the compatibility test that accepted a catalog payload without its flag. Retaining a field under the legacy name or silently treating a missing security-relevant value as false would contradict the greenfield requirement and could skip a required explicit-deny check. The stock migration clears the Permission catalog cache when it runs, and no cross-version cache compatibility layer is part of this design. Do not add a new runtime guard or custom exception for a missing field: package-generated payloads always contain both values, direct access exposes an invalid payload instead of masking it, and extra corrupt-cache machinery would be outside this rename. + +Configured cache names, partition cache segments, team segments, guard behavior, assignment tokens, invalidation paths, TTLs, and per-coroutine memoization remain unchanged. This rename changes one internal serialized payload field but adds no cache operation and changes no cache identity. Permission has no separate custom cache-key resolver: the former cache-only resolver was removed when generic row partitioning became the complete SQL-and-cache isolation mechanism, and this terminology work must not reintroduce it. + +## Internal Naming + +Rename all implementation concepts together so no translation layer remains. Representative final names include: + +| Current concept | Final name | +|---|---| +| effect boolean parameters | `$denied`, `$isDenied` | +| denied input IDs | `$deniedIds` | +| denied pivot data | `$deniedPivot` | +| denied attach/update batches | `$attachDenied`, `$updateDenied` | +| denied lookup builder | `deniedPermissionKeys()` | +| hydrated effect check | `pivotIsDenied()` | +| raw effect normalization | `permissionEffectIsDenied()` | +| catalog aggregate | `$hasDeniedRolePermissions` | + +Update array-shape annotations, inline strings used only for private queued-assignment grouping, comments, and docblocks at the same time. Do not leave private adapters with the old vocabulary. + +## Source Changes By File + +### `src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php` + +- Rename both assignment-effect columns to `is_denied`. +- Keep their type, default, position, keys, foreign keys, and table structure unchanged. + +### `src/permission/src/Traits/HasPermissions.php` + +- Rename the public negative-effect methods to the settled API. +- Correct the `givePermissionTo()` and `denyPermissionTo()` docblocks. +- Rename all negative-effect parameters, locals, helpers, pivot keys, queue batch labels, query predicates, array keys, and comments. +- Preserve all current authorization, event, deferred-assignment, transaction, partition, team, guard, wildcard, and synchronization logic. +- Use `is_denied` for relations, cache assignment hydration, current-pivot reads, inserts, and effect updates. +- Ensure direct/via-role allowed collections reject any explicit denied edge through `deniedPermissionKeys()` and `pivotIsDenied()`. + +### `src/permission/src/PermissionRegistrar.php` + +- Rename serialized pivot fields and the catalog aggregate to denied terminology. +- Rename `hasForbiddenRolePermissions()` to `hasDeniedRolePermissions()`. +- Rename `pivotIsForbidden()` to `pivotIsDenied()` and read `is_denied`. +- Make `hasDeniedRolePermissions` required in serialized payload shapes and hydration. +- Preserve catalog query counts, cache identities, relation hydration, partition scoping, and coroutine memoization. + +### `src/permission/src/Models/Role.php` + +- Hydrate `is_denied` on role-permission relations. +- Call `hasDeniedPermission()` and `pivotIsDenied()` in normal and wildcard checks. +- Preserve guard validation and relation semantics. + +### `src/permission/src/Models/Permission.php` + +- Hydrate `is_denied` on permission-role relations. +- Make no other behavioral change. + +### `src/permission/src/PermissionServiceProvider.php` + +- Change the `about` command feature label to `Denied Permissions`. +- Do not change feature enablement behavior. + +## Documentation Changes + +### `src/permission/README.md` + +Describe the Hypervel extension as explicit denied permissions. Show the final API names and `allow` / `deny` effect pairing. Keep the concise package overview and all unrelated differences from Spatie unchanged. + +Final wording must make these points clear: + +- a denied assignment is explicit; +- it overrides direct or role-granted allows; +- the effect lives on the existing assignment edge; +- assigning the opposite effect flips that edge; +- effective permission collection APIs return allowed permissions; +- explicit denied edges are inspectable through the two final query methods. + +### `src/boost/docs/permission.md` + +Rename the section and anchor to: + +```markdown + +### Denied Permissions +``` + +Rename the in-page table-of-contents entry and anchor together so the link remains valid: + +```markdown +- [Denied Permissions](#denied-permissions) +``` + +Use `is_denied`, `denyPermissionTo()`, `hasDeniedPermission()`, `hasDeniedPermissionViaRoles()`, and `syncPermissionEffects(allowed:, denied:)` in prose and examples. Describe the behavior as explicit permission denial. Update the revocation section to say that revocation removes either an allowed or denied edge. + +Update both `is_denied` columns in the custom partitioned migration example. The documentation remains generic: workspace partitioning may remain an example, but the terminology work must not imply any tenancy feature or dependency. + +### Existing implementation plans + +The checked-in plan documents are active design/context references and must not teach stale symbols. Review all six files found by the case-insensitive sweep: + +1. `docs/plans/2026-06-24-2406-permission-fresh-spatie-port.md` — update Permission effect terminology, final symbols, schema snippets, test references, and acceptance text. +2. `docs/plans/2026-07-02-permission-forbidden-single-state-hardening.md` — rename the file with `mv` to `docs/plans/2026-07-02-permission-denied-single-state-hardening.md`, then update its title, final terminology, schema/code snippets, test filename, commands, and acceptance text. +3. `docs/plans/2026-07-02-permission-review-hardening-and-performance.md` — update Permission effect terminology, symbols, code snippets, test filename, and commands. +4. `docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md` — review but do not edit its ordinary-English descriptions of behavior that PHP forbids. +5. `docs/plans/2026-07-13-permission-row-partitioning.md` — update effect terminology, public/private symbols, schema examples, synchronization descriptions, test language, and checklists without changing the partition design. +6. `docs/plans/2026-07-06-stack-cache-tags-and-tag-architecture-refactor.md` — review but do not edit its ordinary-English `Forbidden:` comment-policy label. + +Git history preserves the old point-in-time spelling. The working tree should use the final names wherever a plan describes the live Permission design. + +## Explicit Exclusions + +### `_archive/` + +Do not edit anything under `_archive/`. It is a parked historical snapshot, is not autoloaded/tested/analyzed, and intentionally preserves superseded source and documentation. The case-insensitive inventory currently finds 14 archive files; they remain untouched. + +### HTTP and ordinary-English uses + +Do not rename HTTP 403 concepts or unrelated English, including: + +- `Response::forbidden()` and HTTP client status helpers; +- `assertForbidden()`; +- `ForbiddenException` classes; +- the 403 exception view; +- Horizon, Sentry, routing, filesystem, Passkeys, validation, and HTTP tests referring to HTTP forbidden responses; +- generated `dist` assets containing HTTP/UI text; +- `PermissionPartitionNotResolved` prose saying an unpartitioned fallback is not allowed; +- the stack-cache plan's ordinary-English `Forbidden:` guidance label. + +This is not a repository-wide lexical replacement. + +## Test Migration + +Rename the primary test file with `mv`: + +```text +tests/Permission/ForbiddenPermissionTest.php +→ tests/Permission/DeniedPermissionTest.php +``` + +Rename its class to `DeniedPermissionTest` and rewrite its test method names, variables, fixture names, pivot attributes, calls, and assertions in final terminology. Preserve every scenario and assertion. + +Update all live affected tests: + +- `tests/Permission/CacheTest.php` +- `tests/Permission/Commands/CommandTest.php` +- `tests/Permission/CustomSchemaConfigTest.php` +- `tests/Permission/DeletionTest.php` +- `tests/Permission/Events/EventTest.php` +- `tests/Permission/DeniedPermissionTest.php` +- `tests/Permission/GateTest.php` +- `tests/Permission/Integration/PartitionQueryCountTest.php` +- `tests/Permission/Integration/PermissionRegistrarTest.php` +- `tests/Permission/PartitionAuthorizationTest.php` +- `tests/Permission/PartitionCoroutineIsolationTest.php` +- `tests/Permission/PartitionRelationsTest.php` +- `tests/Permission/PartitionTeamsTest.php` +- `tests/Permission/PartitionTestCase.php` +- `tests/Permission/PublicApiTest.php` +- `tests/Permission/SchemaConfigTest.php` +- `tests/Permission/TestCase.php` +- `tests/Permission/Traits/HasPermissionsTest.php` +- `tests/Permission/Traits/TeamHasPermissionsTest.php` +- `tests/Integration/Database/PermissionPartitionTest.php` + +The test update is not only lexical. Verify the following contracts explicitly. + +### Public API + +- `denyPermissionTo` exists with variadic `permissions`. +- `syncPermissionEffects` exists with parameters named `allowed` and `denied`. +- the removed public names do not exist. +- `hasDeniedPermission`, `hasDeniedPermissionViaRoles`, and `hasDeniedRolePermissions` are exercised directly. + +### Schema and relations + +- stock, custom-schema, team, partition-unit, and database-integration schemas create `is_denied` on the two effect-bearing pivots. +- model, role, and permission relations hydrate `is_denied`. +- raw relation attach/update tests use `is_denied`. + +### Authorization semantics + +- direct denied assignments override direct and role allows. +- denied role assignments override direct and other-role allows. +- allowed assignment flips a denied edge and denied assignment flips an allowed edge. +- denied wins when the same permission appears in both `syncPermissionEffects` lists. +- revocation removes denied edges. +- guards, teams, partitions, wildcard checks, query scopes, custom keys, integer IDs, UUIDs, and coroutine isolation retain current behavior. + +### Cache and invalidation + +- global cached role pivots hydrate `is_denied`. +- the catalog reports `hasDeniedRolePermissions` accurately. +- model permission caches require and retain `is_denied` effect data. +- direct and role mutations invalidate the same caches as before. +- partition and team cache identities remain isolated. +- configured cache key names and partition-derived cache identities remain unchanged. +- remove the obsolete missing-old-payload compatibility test rather than renaming it. + +Replace that deleted compatibility test with direct security-relevant coverage of the real serialization path: + +```php +public function testCatalogReportsWhetherItContainsDeniedRolePermissions(): void +{ + $registrar = $this->app->make(PermissionRegistrar::class); + + $this->assertFalse($registrar->hasDeniedRolePermissions()); + + $this->testUserRole->denyPermissionTo($this->testUserPermission); + + $this->assertTrue($registrar->hasDeniedRolePermissions()); +} +``` + +This test must serialize and hydrate the normal catalog in both states. The Role mutation performs the package's normal catalog invalidation between assertions. It replaces compatibility coverage with a stronger direct check that the fast-path flag cannot incorrectly skip inherited explicit denies. + +Also exercise the per-model assignment cache through its real producer and hydrator. Grant the permission through a Role, deny it directly, query a fresh persisted subject with no loaded `permissions` relation, and assert that `hasDeniedPermission()` is true while `hasPermissionTo()` remains false. The competing Role allow makes the final assertion prove that the cache-hydrated direct deny overrides an actual allow. Assert that the relation remains unloaded so the test cannot pass through the loaded-relation shortcut. + +### Performance and query shape + +- partition query-count tests retain the same counts and write batching. +- unchanged effects are not rewritten or reported as updated. +- mixed effect flips still use exactly two target-value update statements. +- warm authorization remains query-free. +- no test expectation is weakened merely to accommodate the rename. + +## Implementation Order + +Follow the repository rule of one file at a time and run focused tests immediately after each coherent source change: + +1. Rename the stock migration column and update base test schemas. +2. Rename model relation pivots. +3. Rename `PermissionRegistrar` cache fields/helpers and update its focused cache tests. +4. Rename `HasPermissions` public API and internals, then rename and run the primary denied-permission test. +5. Update `Role`, followed by Gate/wildcard/role tests. +6. Update the remaining unit and integration tests one file at a time. +7. Update README and Boost documentation. +8. Rename and update the four Permission-related plan documents; review and retain the unrelated lifecycle-audit and stack-cache occurrences. +9. Perform the exhaustive terminology and diff audit. +10. Run the full repository quality command. + +All edits are manual and targeted with `apply_patch`; file renames use `mv`. Do not use `sed`, `awk`, a loop, or a script to rewrite files in bulk without explicit owner approval. Search commands may aggregate results but must not mutate files. + +## Focused Verification Commands + +Run focused suites as their files are updated: + +```shell +./vendor/bin/phpunit --no-progress tests/Permission/DeniedPermissionTest.php +./vendor/bin/phpunit --no-progress tests/Permission/CacheTest.php +./vendor/bin/phpunit --no-progress tests/Permission/PublicApiTest.php +./vendor/bin/phpunit --no-progress tests/Permission/Events/EventTest.php +./vendor/bin/phpunit --no-progress tests/Permission/Traits/HasPermissionsTest.php +./vendor/bin/phpunit --no-progress tests/Permission/Traits/TeamHasPermissionsTest.php +./vendor/bin/phpunit --no-progress tests/Permission/PartitionAuthorizationTest.php +./vendor/bin/phpunit --no-progress tests/Permission/PartitionRelationsTest.php +./vendor/bin/phpunit --no-progress tests/Permission/Integration/PartitionQueryCountTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Database/PermissionPartitionTest.php +``` + +Then run the complete affected package suite: + +```shell +./vendor/bin/phpunit --no-progress tests/Permission +./vendor/bin/phpunit --no-progress tests/Integration/Database/PermissionPartitionTest.php +``` + +Run supported database integration coverage through the existing database workflow/environment for MySQL, MariaDB, PostgreSQL, and SQLite. This is important because the synchronizer reads native boolean/0-1 pivot values across drivers. + +Finally run: + +```shell +composer fix +``` + +`composer fix` is the authoritative final gate and runs code formatting, both PHPStan configurations, the parallel test suite, the Testbench package suite, and the dogfood package suite in repository-defined order. + +## Exhaustive Terminology Audit + +After implementation, perform a case-insensitive search of the whole working tree. Classify every remaining old negative-effect occurrence rather than assuming the search should be empty. + +Expected retained categories are only: + +- `_archive/` historical snapshot; +- HTTP 403 APIs/tests/views/exceptions; +- generated HTTP/UI assets; +- ordinary English unrelated to Permission; +- this implementation plan's explicit before/after research and migration record. + +There must be no old negative-effect terminology in: + +- live `src/permission` source, migration, README, cache shapes, or package metadata; +- live Permission tests, including the database integration fixture; +- Boost Permission documentation; +- the four Permission-related prior implementation plans; +- live filenames or class names. + +Also search explicitly for every removed symbol and column so casing or a partial word cannot hide a stale reference. Inspect `git diff --check`, `git status --short`, and the full `git diff` file by file. Confirm `_archive/` and unrelated HTTP files have no diff. + +## Acceptance Criteria + +- [ ] Public negative-effect APIs use `deny` / `denied` exclusively. +- [ ] `givePermissionTo()` and `revokePermissionTo()` retain their established meanings. +- [ ] Assignment pivots use only `is_denied` in live source, schemas, relations, caches, tests, and docs. +- [ ] The permission catalog uses required `hasDeniedRolePermissions` data with no legacy fallback. +- [ ] Internal helpers, variables, array shapes, comments, and queue labels use denied terminology. +- [ ] Explicit denied checks remain distinct from implicit denial. +- [ ] Direct, role-derived, wildcard, guard, team, partition, query-scope, cache, event, and deferred-assignment behavior remains unchanged. +- [ ] No database query, cache operation, hot-path branch, or synchronization write is added. +- [ ] Integer, UUID, and ULID keys retain native database column behavior and current normalization. +- [ ] Cache invalidation, configured cache keys, partition-derived identities, and coroutine memoization remain intact. +- [ ] README, Boost docs, About output, and Permission design plans describe only the final API. +- [ ] The primary test file/class and the Permission single-state plan filename use denied terminology. +- [ ] `_archive/` is untouched. +- [ ] HTTP 403 and unrelated ordinary-English uses are untouched. +- [ ] Focused tests, affected Permission suites, supported-database integration tests, and `composer fix` pass. +- [ ] The final diff contains no compatibility alias, workaround, dead code, stale comment, stale documentation, or unrelated edit. diff --git a/src/boost/docs/permission.md b/src/boost/docs/permission.md index f3f7c02f7..8be7c0da9 100644 --- a/src/boost/docs/permission.md +++ b/src/boost/docs/permission.md @@ -24,7 +24,7 @@ - [Assigning Permissions](#assigning-permissions) - [Checking Permissions](#checking-permissions) - [Gate and Super Admins](#gate-and-super-admins) - - [Forbidden Permissions](#forbidden-permissions) + - [Denied Permissions](#denied-permissions) - [Revoking Permissions](#revoking-permissions) - [Retrieving Permissions](#retrieving-permissions) - [Using Enums](#using-enums) @@ -62,7 +62,7 @@ Hypervel's permission package provides role-based access control for Eloquent models. You may create roles and permissions, assign them to users or other models, and check access by role, direct permission, or permission inherited through a role. -The package is based on Spatie's `laravel-permission` package and adapted for Hypervel. It also supports forbidden permissions, which explicitly deny an ability even when the model receives the same permission directly or through a role. +The package is based on Spatie's `laravel-permission` package and adapted for Hypervel. It also supports denied permissions, which explicitly reject an ability even when the model receives the same permission directly or through a role. ## Installation @@ -109,7 +109,7 @@ The published migration creates the following tables: - `model_has_permissions` - `model_has_roles` -The `role_has_permissions` and `model_has_permissions` tables include an `is_forbidden` column used by forbidden permissions. +The `role_has_permissions` and `model_has_permissions` tables include an `is_denied` column used by denied permissions. > [!WARNING] > If you customize the table or column names in the permission configuration file, update the published migration before running it. @@ -321,12 +321,12 @@ $role->givePermissionTo('delete articles', 'publish articles'); $role->syncPermissions(['edit articles', 'publish articles']); ``` -To replace a role's allowed and forbidden permissions at the same time, use `syncPermissionsWithForbidden`: +To replace a role's allowed and denied permissions at the same time, use `syncPermissionEffects`: ```php -$role->syncPermissionsWithForbidden( +$role->syncPermissionEffects( allowed: ['edit articles'], - forbidden: ['delete articles'], + denied: ['delete articles'], ); ``` @@ -528,41 +528,41 @@ Gate::before(function (User $user, string $ability): ?bool { Direct package calls such as `hasPermissionTo` do not pass through Gate callbacks. Use `can`, `canAny`, policies, middleware, or Blade authorization checks when you want Gate-level behavior to apply. - -### Forbidden Permissions + +### Denied Permissions -Forbidden permissions explicitly deny access. The permission assignment tables store `is_forbidden` as the effect for the assignment edge, so a model or role has one row for a given permission in the current team context. +Denied permissions explicitly reject access. The permission assignment tables store `is_denied` as the effect for the assignment edge, so a model or role has one row for a given permission in the current team context. -Calling `giveForbiddenTo` for an allowed permission flips that assignment to a deny. Calling `givePermissionTo` for a forbidden permission flips it back to an allow. A forbidden permission overrides an allowed permission, including permissions inherited through roles: +Calling `denyPermissionTo` for an allowed permission flips that assignment to a deny. Calling `givePermissionTo` for a denied permission flips it back to an allow. A denied permission overrides an allowed permission, including permissions inherited through roles: ```php $user->givePermissionTo('delete articles'); -$user->giveForbiddenTo('delete articles'); +$user->denyPermissionTo('delete articles'); $user->hasPermissionTo('delete articles'); // false ``` -You may check whether a forbidden permission exists directly on the model or through its roles: +You may check whether an explicit denied permission exists directly on the model or through its roles: ```php -if ($user->hasForbiddenPermission('delete articles')) { +if ($user->hasDeniedPermission('delete articles')) { // ... } -if ($user->hasForbiddenPermissionViaRoles('delete articles')) { +if ($user->hasDeniedPermissionViaRoles('delete articles')) { // ... } ``` -Use `syncPermissionsWithForbidden` to replace allowed and forbidden direct permissions together. If a permission is present in both arrays, the forbidden permission wins: +Use `syncPermissionEffects` to replace allowed and denied direct permissions together. If a permission is present in both arrays, the denied permission wins: ```php -$user->syncPermissionsWithForbidden( +$user->syncPermissionEffects( allowed: ['view articles', 'edit articles'], - forbidden: ['edit articles', 'delete articles'], + denied: ['edit articles', 'delete articles'], ); ``` @@ -581,7 +581,7 @@ $user->revokePermissionTo('edit articles'); $user->revokePermissionTo('edit articles', 'delete articles'); ``` -This removes the assignment edge whether it is currently allowed or forbidden. +This removes the assignment edge whether it is currently allowed or denied. ### Retrieving Permissions @@ -598,7 +598,7 @@ To retrieve only permissions inherited through roles, use `getPermissionsViaRole $rolePermissions = $user->getPermissionsViaRoles(); ``` -`getDirectPermissions`, `getPermissionsViaRoles`, `getAllPermissions`, and `getPermissionNames` return allowed permissions. Explicitly forbidden permissions are checked through `hasForbiddenPermission` and `hasForbiddenPermissionViaRoles`. +`getDirectPermissions`, `getPermissionsViaRoles`, `getAllPermissions`, and `getPermissionNames` return allowed permissions. Explicitly denied permissions are checked through `hasDeniedPermission` and `hasDeniedPermissionViaRoles`. ## Using Enums @@ -894,7 +894,7 @@ Permission does not provide a partition model, middleware, command option, migra - Role and Permission model queries and lifecycle writes; - role-permission, model-role, and model-permission relations and pivots; -- assignment, synchronization, reverse-assignment, query-scope, eager-load, wildcard, and forbidden-permission paths; +- assignment, synchronization, reverse-assignment, query-scope, eager-load, wildcard, and denied-permission paths; - console commands and queued Role or Permission restoration; - shared cache keys, assignment tokens, wildcard indexes, coroutine-local memoization, and invalidation. @@ -967,7 +967,7 @@ Schema::create('role_has_permissions', function (Blueprint $table): void { $table->uuid('workspace_id'); $table->uuid('permission_id'); $table->uuid('role_id'); - $table->boolean('is_forbidden')->default(false); + $table->boolean('is_denied')->default(false); $table->primary(['workspace_id', 'permission_id', 'role_id']); @@ -1003,7 +1003,7 @@ Schema::create('model_has_permissions', function (Blueprint $table): void { $table->uuid('workspace_id'); $table->uuid('permission_id'); $table->uuidMorphs('model'); - $table->boolean('is_forbidden')->default(false); + $table->boolean('is_denied')->default(false); $table->primary(['workspace_id', 'permission_id', 'model_id', 'model_type']); $table->index( @@ -1092,7 +1092,7 @@ Partitioning adds no database queries to authorization or normal mutation paths: - warm authorization checks remain zero-query; - a cold permission catalog remains three queries; - cold authorization and assignment-cache misses retain their unpartitioned query counts; -- synchronization uses the same query count in partitioned and unpartitioned modes; Role sync uses one delete and one bulk insert, while direct-permission sync adds the pivot read needed to compare forbidden effects; +- synchronization uses the same query count in partitioned and unpartitioned modes; Role sync uses one delete and one bulk insert, while direct-permission sync adds the pivot read needed to compare denied effects; - the resolver is an in-memory Context lookup; - existing SQL receives one bound partition predicate; - pivot inserts receive the partition value. @@ -1325,10 +1325,10 @@ $user->removeRole('writer'); $user->syncRoles(['writer']); $user->givePermissionTo('edit articles'); -$user->giveForbiddenTo('delete articles'); -$user->syncPermissionsWithForbidden( +$user->denyPermissionTo('delete articles'); +$user->syncPermissionEffects( allowed: ['edit articles'], - forbidden: ['delete articles'], + denied: ['delete articles'], ); ``` @@ -1468,8 +1468,8 @@ Partition registration and isolation failures use focused exceptions: ## Differences From Spatie Laravel Permission -- Hypervel adds forbidden permissions. A forbidden permission explicitly denies an ability and wins over direct or role-granted allows. The deny flag is stored as the effect on the assignment row, so assigning allow or deny for the same model or role and permission updates the existing edge. -- `getDirectPermissions()`, `getPermissionsViaRoles()`, `getAllPermissions()`, and `getPermissionNames()` return effective allowed permissions. Explicit denies are exposed through `hasForbiddenPermission()` and `hasForbiddenPermissionViaRoles()`. +- Hypervel adds denied permissions. A denied assignment explicitly rejects an ability and wins over direct or role-granted allows. The `is_denied` flag is stored as the effect on the assignment row, so assigning allow or deny for the same model or role and permission updates the existing edge. +- `getDirectPermissions()`, `getPermissionsViaRoles()`, `getAllPermissions()`, and `getPermissionNames()` return effective allowed permissions. Explicit denied edges are exposed through `hasDeniedPermission()` and `hasDeniedPermissionViaRoles()`. - Hypervel accepts pure unit enums anywhere enum names are valid role or permission inputs. Backed enums use their values; unit enums use their case names. - Hypervel adds opt-in generic row partitioning through `PermissionRegistrar::resolvePartitionUsing(...)`. It scopes model lifecycle operations, every package relation and pivot, queries, commands, cache identities, and invalidation without depending on any partition domain. - Hypervel's cache config uses `expiration_seconds` and separate named cache keys so role, model-role, model-permission, and assignment-token caches can be invalidated independently. diff --git a/src/permission/README.md b/src/permission/README.md index bd8276f35..c0dc5fbb9 100644 --- a/src/permission/README.md +++ b/src/permission/README.md @@ -11,7 +11,7 @@ This package provides Spatie-style roles and permissions for Hypervel applicatio - Teams support with coroutine-scoped current team state. - Wildcard permissions. - Passport client-credentials middleware support. -- Hypervel-only forbidden permissions, where an explicit deny wins over direct or role-granted allows. +- Hypervel-only denied permissions, where an explicit deny wins over direct or role-granted allows. - Opt-in generic row partitioning across queries, relations, assignments, caches, and invalidation. - Configured cache store plus per-coroutine memoization for hot permission checks. @@ -41,8 +41,8 @@ class User extends Model ## Differences From Spatie Laravel Permission -- Hypervel adds forbidden permissions. A forbidden permission explicitly denies an ability and wins over direct or role-granted allows. The deny flag is stored as the effect on the assignment row, so assigning allow or deny for the same model or role and permission updates the existing edge. Use `syncPermissionsWithForbidden()` to replace allowed and forbidden assignments together. -- `getDirectPermissions()`, `getPermissionsViaRoles()`, `getAllPermissions()`, and `getPermissionNames()` return effective allowed permissions. Explicit denies are exposed through `hasForbiddenPermission()` and `hasForbiddenPermissionViaRoles()`. +- Hypervel adds denied permissions. A denied assignment explicitly rejects an ability and wins over direct or role-granted allows. The `is_denied` flag is stored as the effect on the assignment row, so assigning allow or deny for the same model or role and permission updates the existing edge. Use `syncPermissionEffects()` to replace allowed and denied assignments together. +- `getDirectPermissions()`, `getPermissionsViaRoles()`, `getAllPermissions()`, and `getPermissionNames()` return effective allowed permissions. Explicit denied edges are exposed through `hasDeniedPermission()` and `hasDeniedPermissionViaRoles()`. - Hypervel accepts pure unit enums anywhere enum names are valid role or permission inputs. Backed enums use their values; unit enums use their case names. - Hypervel's cache config uses `expiration_seconds` and separate named cache keys so role, model-role, model-permission, and assignment-token caches can be invalidated independently. - Hypervel supports generic row partitioning through `PermissionRegistrar::resolvePartitionUsing(...)`. One application-defined scalar dimension scopes Role and Permission rows, every package-owned pivot operation, relations, query scopes, commands, cache identities, and invalidation. Missing context fails closed. Workspaces, installations, realms, and multi-tenancy are possible uses; Permission does not provide or depend on any one partition domain. diff --git a/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php b/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php index 2723efa5e..f8a5ebc17 100644 --- a/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php +++ b/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php @@ -55,7 +55,7 @@ public function up(): void $table->unsignedBigInteger($pivotPermission); $table->string('model_type'); $table->unsignedBigInteger($modelMorphKey); - $table->boolean('is_forbidden')->default(false); + $table->boolean('is_denied')->default(false); $table->index([$modelMorphKey, 'model_type'], 'model_has_permissions_model_id_model_type_index'); $table->foreign($pivotPermission) @@ -97,7 +97,7 @@ public function up(): void Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission): void { $table->unsignedBigInteger($pivotPermission); $table->unsignedBigInteger($pivotRole); - $table->boolean('is_forbidden')->default(false); + $table->boolean('is_denied')->default(false); $table->foreign($pivotPermission) ->references('id') diff --git a/src/permission/src/Models/Permission.php b/src/permission/src/Models/Permission.php index a30719d83..e285213f1 100644 --- a/src/permission/src/Models/Permission.php +++ b/src/permission/src/Models/Permission.php @@ -103,7 +103,7 @@ protected function roleAssignmentRelation( $registrar->pivotRole, 'roles', $context, - )->withPivot('is_forbidden'); + )->withPivot('is_denied'); } /** diff --git a/src/permission/src/Models/Role.php b/src/permission/src/Models/Role.php index 9c98ba734..80916b322 100644 --- a/src/permission/src/Models/Role.php +++ b/src/permission/src/Models/Role.php @@ -119,7 +119,7 @@ protected function permissionAssignmentRelation( $registrar->pivotPermission, 'permissions', $context, - )->withPivot('is_forbidden'); + )->withPivot('is_denied'); } /** @@ -267,7 +267,7 @@ protected static function getRole(array $params = []): ?RoleContract public function hasPermissionTo(UnitEnum|int|string|PermissionContract $permission, ?string $guardName = null): bool { if ($this->getWildcardClass()) { - if ($this->hasForbiddenPermission($permission, $guardName)) { + if ($this->hasDeniedPermission($permission, $guardName)) { return false; } @@ -276,7 +276,7 @@ public function hasPermissionTo(UnitEnum|int|string|PermissionContract $permissi $permission = $this->filterPermission($permission, $guardName); - if ($this->hasForbiddenPermission($permission, $guardName)) { + if ($this->hasDeniedPermission($permission, $guardName)) { return false; } @@ -288,6 +288,6 @@ public function hasPermissionTo(UnitEnum|int|string|PermissionContract $permissi ->filter(fn (Model $rolePermission): bool => $rolePermission->getKey() === $permission->getKey()); return $matches->isNotEmpty() - && ! $matches->contains(fn (Model $rolePermission): bool => $this->pivotIsForbidden($rolePermission)); + && ! $matches->contains(fn (Model $rolePermission): bool => $this->pivotIsDenied($rolePermission)); } } diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php index c7cdd7f14..925e349b5 100644 --- a/src/permission/src/PermissionRegistrar.php +++ b/src/permission/src/PermissionRegistrar.php @@ -578,8 +578,8 @@ public function rememberModelRoleAssignments(Model $model, Closure $callback): a /** * Remember a model's permission assignment ids. * - * @param Closure(): array> $callback - * @return array> + * @param Closure(): array}> $callback + * @return array}> */ public function rememberModelPermissionAssignments(Model $model, Closure $callback): array { @@ -931,7 +931,7 @@ public function forgetLoadedRelationProvenance(Model $model, ?string $relation = * roleByKey: array, * roleByNameAndGuard: array>, * roleOrderByKey: array, - * hasForbiddenRolePermissions: bool + * hasDeniedRolePermissions: bool * } */ private function permissionCatalog(): array @@ -940,11 +940,11 @@ private function permissionCatalog(): array $catalogs = CoroutineContext::get(self::PERMISSION_CATALOG_CONTEXT_KEY, []); if (isset($catalogs[$contextKey]) && is_array($catalogs[$contextKey])) { - /** @var array{permissions: Collection, roles: Collection, permissionByKey: array, permissionByNameAndGuard: array>, permissionOrderByKey: array, roleByKey: array, roleByNameAndGuard: array>, roleOrderByKey: array, hasForbiddenRolePermissions: bool} */ + /** @var array{permissions: Collection, roles: Collection, permissionByKey: array, permissionByNameAndGuard: array>, permissionOrderByKey: array, roleByKey: array, roleByNameAndGuard: array>, roleOrderByKey: array, hasDeniedRolePermissions: bool} */ return $catalogs[$contextKey]; } - /** @var array{permissions: array>, roles: array>, hasForbiddenRolePermissions?: bool} $payload */ + /** @var array{permissions: array>, roles: array>, hasDeniedRolePermissions: bool} $payload */ $payload = $this->cacheRepository()->remember( $contextKey, $this->cacheExpirationTime, @@ -963,7 +963,7 @@ private function permissionCatalog(): array 'roleByKey' => $this->indexModelsByKey($roles), 'roleByNameAndGuard' => $this->indexModelsByNameAndGuard($roles), 'roleOrderByKey' => $this->indexModelOrderByKey($roles), - 'hasForbiddenRolePermissions' => (bool) ($payload['hasForbiddenRolePermissions'] ?? false), + 'hasDeniedRolePermissions' => (bool) $payload['hasDeniedRolePermissions'], ]; $catalogs[$contextKey] = $catalog; @@ -1327,25 +1327,25 @@ protected function getRolesForCache(): Collection /** * Serialize permissions for cache. * - * @return array{permissions: array>, roles: array>, hasForbiddenRolePermissions: bool} + * @return array{permissions: array>, roles: array>, hasDeniedRolePermissions: bool} */ private function getSerializedPermissionsForCache(): array { $except = $this->config->array('permission.cache.column_names_except', ['created_at', 'updated_at', 'deleted_at']); - $hasForbiddenRolePermissions = false; + $hasDeniedRolePermissions = false; $partition = $this->resolvePartition(); return [ 'permissions' => $this->getPermissionsWithRoles() - ->map(function (Model $permission) use ($except, &$hasForbiddenRolePermissions, $partition): array { + ->map(function (Model $permission) use ($except, &$hasDeniedRolePermissions, $partition): array { $roles = $this->relationCollection($permission, 'roles') - ->map(function (Model $role) use ($permission, &$hasForbiddenRolePermissions, $partition): array { - $isForbidden = $this->pivotIsForbidden($role); - $hasForbiddenRolePermissions = $hasForbiddenRolePermissions || $isForbidden; + ->map(function (Model $role) use ($permission, &$hasDeniedRolePermissions, $partition): array { + $isDenied = $this->pivotIsDenied($role); + $hasDeniedRolePermissions = $hasDeniedRolePermissions || $isDenied; $pivot = [ $this->pivotPermission => $permission->getKey(), $this->pivotRole => $role->getKey(), - 'is_forbidden' => $isForbidden, + 'is_denied' => $isDenied, ]; if ($partition) { @@ -1372,22 +1372,22 @@ private function getSerializedPermissionsForCache(): array ]) ->values() ->all(), - 'hasForbiddenRolePermissions' => $hasForbiddenRolePermissions, + 'hasDeniedRolePermissions' => $hasDeniedRolePermissions, ]; } /** - * Determine if any cached role-permission edge is forbidden. + * Determine if any cached role-permission edge is denied. */ - public function hasForbiddenRolePermissions(): bool + public function hasDeniedRolePermissions(): bool { - return (bool) $this->permissionCatalog()['hasForbiddenRolePermissions']; + return (bool) $this->permissionCatalog()['hasDeniedRolePermissions']; } /** - * Determine if a hydrated pivot marks the permission as forbidden. + * Determine if a hydrated pivot marks the permission as denied. */ - protected function pivotIsForbidden(Model $model): bool + protected function pivotIsDenied(Model $model): bool { if (! $model->relationLoaded('pivot')) { return false; @@ -1395,7 +1395,7 @@ protected function pivotIsForbidden(Model $model): bool $pivot = $model->getRelation('pivot'); - return $pivot instanceof Pivot && (bool) $pivot->getAttribute('is_forbidden'); + return $pivot instanceof Pivot && (bool) $pivot->getAttribute('is_denied'); } /** diff --git a/src/permission/src/PermissionServiceProvider.php b/src/permission/src/PermissionServiceProvider.php index c23ca89c4..3528c7616 100644 --- a/src/permission/src/PermissionServiceProvider.php +++ b/src/permission/src/PermissionServiceProvider.php @@ -208,7 +208,7 @@ protected function registerAbout(): void 'Teams' => 'teams', 'Wildcard Permissions' => 'enable_wildcard_permission', 'Passport Client Credentials' => 'use_passport_client_credentials', - 'Forbidden Permissions' => null, + 'Denied Permissions' => null, ]; $config = $this->app->make('config'); diff --git a/src/permission/src/Traits/HasPermissions.php b/src/permission/src/Traits/HasPermissions.php index bc4955aa5..15e5e42c5 100644 --- a/src/permission/src/Traits/HasPermissions.php +++ b/src/permission/src/Traits/HasPermissions.php @@ -316,7 +316,7 @@ protected function permissionAssignmentRelation( 'permissions', teamScoped: $teamScoped, context: $context, - )->withPivot('is_forbidden'); + )->withPivot('is_denied'); } /** @@ -344,7 +344,7 @@ protected function getCachedDirectPermissions(): Collection ->get() ->map(fn (Model $permission): array => [ $permissionKey => $permission->getKey(), - 'is_forbidden' => $this->pivotIsForbidden($permission), + 'is_denied' => $this->pivotIsDenied($permission), ]) ->values() ->all(), @@ -368,7 +368,7 @@ protected function getCachedDirectPermissions(): Collection $registrar->pivotPermission => $permission->getKey(), Config::morphKey() => $model->getKey(), 'model_type' => $model->getMorphClass(), - 'is_forbidden' => (bool) ($assignment['is_forbidden'] ?? false), + 'is_denied' => (bool) $assignment['is_denied'], ]; if ($registrar->teams) { @@ -399,7 +399,7 @@ protected function getCachedDirectPermissions(): Collection protected function allowedDirectPermissions(): Collection { return $this->getCachedDirectPermissions() - ->reject(fn (Model $permission): bool => $this->pivotIsForbidden($permission)) + ->reject(fn (Model $permission): bool => $this->pivotIsDenied($permission)) ->values(); } @@ -453,17 +453,17 @@ protected function whereEffectivePermission(Builder $query, array $permissionIds /** * Add a permission-effect predicate for direct and role-granted permissions. */ - protected function wherePermissionEffect(Builder $query, int|string $permissionId, bool $forbidden): Builder + protected function wherePermissionEffect(Builder $query, int|string $permissionId, bool $denied): Builder { $query->whereHas( 'permissions', - fn (Builder $query) => $this->whereDirectPermissionEffect($query, $permissionId, $forbidden), + fn (Builder $query) => $this->whereDirectPermissionEffect($query, $permissionId, $denied), ); if (! $this instanceof Role) { $query->orWhereHas( 'roles.permissions', - fn (Builder $query) => $this->whereRolePermissionEffect($query, $permissionId, $forbidden), + fn (Builder $query) => $this->whereRolePermissionEffect($query, $permissionId, $denied), ); } @@ -473,7 +473,7 @@ protected function wherePermissionEffect(Builder $query, int|string $permissionI /** * Add a direct permission-effect predicate. */ - protected function whereDirectPermissionEffect(Builder $query, int|string $permissionId, bool $forbidden): Builder + protected function whereDirectPermissionEffect(Builder $query, int|string $permissionId, bool $denied): Builder { $permissionKey = Guard::getModelKeyName($this->getPermissionClass()); $pivotTable = $this instanceof Role @@ -482,19 +482,19 @@ protected function whereDirectPermissionEffect(Builder $query, int|string $permi return $query ->where(Config::permissionsTable() . ".{$permissionKey}", $permissionId) - ->where("{$pivotTable}.is_forbidden", $forbidden); + ->where("{$pivotTable}.is_denied", $denied); } /** * Add a role permission-effect predicate. */ - protected function whereRolePermissionEffect(Builder $query, int|string $permissionId, bool $forbidden): Builder + protected function whereRolePermissionEffect(Builder $query, int|string $permissionId, bool $denied): Builder { $permissionKey = Guard::getModelKeyName($this->getPermissionClass()); return $query ->where(Config::permissionsTable() . ".{$permissionKey}", $permissionId) - ->where(Config::roleHasPermissionsTable() . '.is_forbidden', $forbidden); + ->where(Config::roleHasPermissionsTable() . '.is_denied', $denied); } /** @@ -586,11 +586,11 @@ public function filterPermission($permission, ?string $guardName = null): Permis public function hasPermissionTo($permission, ?string $guardName = null): bool { if ($this->getWildcardClass()) { - if ($this->hasForbiddenPermission($permission, $guardName)) { + if ($this->hasDeniedPermission($permission, $guardName)) { return false; } - if ($this->hasForbiddenPermissionViaRoles($permission, $guardName)) { + if ($this->hasDeniedPermissionViaRoles($permission, $guardName)) { return false; } @@ -599,11 +599,11 @@ public function hasPermissionTo($permission, ?string $guardName = null): bool $permission = $this->filterPermission($permission, $guardName); - if ($this->hasForbiddenPermission($permission, $guardName)) { + if ($this->hasDeniedPermission($permission, $guardName)) { return false; } - if ($this->hasForbiddenPermissionViaRoles($permission, $guardName)) { + if ($this->hasDeniedPermissionViaRoles($permission, $guardName)) { return false; } @@ -706,7 +706,7 @@ protected function hasPermissionViaRole(Permission $permission): bool return $this->hasRole( $this->relationCollection($permission, 'roles') - ->reject(fn (Model $role): bool => $this->pivotIsForbidden($role)) + ->reject(fn (Model $role): bool => $this->pivotIsDenied($role)) ); } @@ -725,7 +725,7 @@ public function hasDirectPermission($permission): bool ->filter(fn (Model $directPermission): bool => $directPermission->getKey() === $permission->getKey()); return $matches->isNotEmpty() - && ! $matches->contains(fn (Model $directPermission): bool => $this->pivotIsForbidden($directPermission)); + && ! $matches->contains(fn (Model $directPermission): bool => $this->pivotIsDenied($directPermission)); } /** @@ -734,10 +734,10 @@ public function hasDirectPermission($permission): bool public function getPermissionsViaRoles(): Collection { $permissions = $this->getPermissionsViaRolesWithPivots(); - $forbiddenPermissionKeys = $this->forbiddenPermissionKeys($permissions); + $deniedPermissionKeys = $this->deniedPermissionKeys($permissions); return $permissions - ->reject(fn (Model $permission): bool => isset($forbiddenPermissionKeys[$this->permissionComparisonKey($permission)])) + ->reject(fn (Model $permission): bool => isset($deniedPermissionKeys[$this->permissionComparisonKey($permission)])) ->unique(fn (Model $permission): string => $this->permissionComparisonKey($permission)) ->sort() ->values(); @@ -752,11 +752,11 @@ public function getAllPermissions(): Collection $viaRolePermissions = $this instanceof Permission ? collect() : $this->getPermissionsViaRolesWithPivots(); - $forbiddenPermissionKeys = $this->forbiddenPermissionKeys($directPermissions, $viaRolePermissions); + $deniedPermissionKeys = $this->deniedPermissionKeys($directPermissions, $viaRolePermissions); return $directPermissions ->merge($viaRolePermissions) - ->reject(fn (Model $permission): bool => isset($forbiddenPermissionKeys[$this->permissionComparisonKey($permission)])) + ->reject(fn (Model $permission): bool => isset($deniedPermissionKeys[$this->permissionComparisonKey($permission)])) ->unique(fn (Model $permission): string => $this->permissionComparisonKey($permission)) ->sort() ->values(); @@ -793,7 +793,7 @@ private function collectPermissions( } /** - * Grant the given permission(s) to a role. + * Grant the given permission(s) to the model. * * @param array|Collection|int|Permission|string|UnitEnum $permissions */ @@ -803,21 +803,21 @@ public function givePermissionTo(...$permissions): static } /** - * Grant the given forbidden permission(s) to a role. + * Deny the given permission(s) for the model. * * @param array|Collection|int|Permission|string|UnitEnum $permissions */ - public function giveForbiddenTo(...$permissions): static + public function denyPermissionTo(...$permissions): static { return $this->attachPermissions($permissions, true); } /** - * Attach permissions with the given forbidden flag. + * Attach permissions with the given denied flag. * * @param array $permissions */ - private function attachPermissions(array $permissions, bool $isForbidden): static + private function attachPermissions(array $permissions, bool $isDenied): static { $model = $this; $registrar = $this->permissionRegistrar(); @@ -830,7 +830,7 @@ private function attachPermissions(array $permissions, bool $isForbidden): stati return $this; } - $pivot = $this->permissionAssignmentPivot($isForbidden, $context); + $pivot = $this->permissionAssignmentPivot($isDenied, $context); if (! $model->exists) { $this->queuePermissionAssignments($permissions, $pivot, $context); @@ -840,8 +840,8 @@ private function attachPermissions(array $permissions, bool $isForbidden): stati } $changes = $this->synchronizePermissionAssignments( - $isForbidden ? [] : $permissions, - $isForbidden ? $permissions : [], + $isDenied ? [] : $permissions, + $isDenied ? $permissions : [], $context, false, ); @@ -874,12 +874,12 @@ private function attachPermissions(array $permissions, bool $isForbidden): stati * @return array */ private function permissionAssignmentPivot( - bool $isForbidden, + bool $isDenied, PermissionRelationContext $context, ): array { $registrar = $this->permissionRegistrar(); - $pivot = ['is_forbidden' => $isForbidden]; + $pivot = ['is_denied' => $isDenied]; if ($context->partition) { $pivot[$context->partition->column] = $context->partition->value; @@ -951,12 +951,12 @@ protected function readCurrentAssignmentPivots( * Synchronize direct permission assignment presence and effects. * * @param array $allowed - * @param array $forbidden + * @param array $denied * @return array{attached: array, detached: array, updated: array} */ private function synchronizePermissionAssignments( array $allowed, - array $forbidden, + array $denied, PermissionRelationContext $context, bool $detaching, ): array { @@ -965,14 +965,14 @@ private function synchronizePermissionAssignments( foreach ($this->indexAssignmentIds($allowed) as $identity => $permission) { $desired[$identity] = [ 'id' => $permission, - 'is_forbidden' => false, + 'is_denied' => false, ]; } - foreach ($this->indexAssignmentIds($forbidden) as $identity => $permission) { + foreach ($this->indexAssignmentIds($denied) as $identity => $permission) { $desired[$identity] = [ 'id' => $permission, - 'is_forbidden' => true, + 'is_denied' => true, ]; } @@ -981,7 +981,7 @@ private function synchronizePermissionAssignments( $relatedPivotKey = $relation->getRelatedPivotKeyName(); $pivots = $this->readCurrentAssignmentPivots( $relation, - [$relatedPivotKey, 'is_forbidden'], + [$relatedPivotKey, 'is_denied'], $detaching ? null : array_column($desired, 'id'), ); @@ -992,7 +992,7 @@ private function synchronizePermissionAssignments( $current[$this->assignmentIdIdentity($id)] = [ 'id' => $id, - 'is_forbidden' => $this->permissionEffectIsForbidden($pivot->is_forbidden), + 'is_denied' => $this->permissionEffectIsDenied($pivot->is_denied), ]; } @@ -1002,9 +1002,9 @@ private function synchronizePermissionAssignments( 'updated' => [], ]; $attachAllowed = []; - $attachForbidden = []; + $attachDenied = []; $updateAllowed = []; - $updateForbidden = []; + $updateDenied = []; if ($detaching) { foreach (array_diff_key($current, $desired) as $assignment) { @@ -1018,8 +1018,8 @@ private function synchronizePermissionAssignments( if ($currentAssignment === null) { $changes['attached'][] = $assignment['id']; - if ($assignment['is_forbidden']) { - $attachForbidden[] = $assignment['id']; + if ($assignment['is_denied']) { + $attachDenied[] = $assignment['id']; } else { $attachAllowed[] = $assignment['id']; } @@ -1027,11 +1027,11 @@ private function synchronizePermissionAssignments( continue; } - if ($currentAssignment['is_forbidden'] !== $assignment['is_forbidden']) { + if ($currentAssignment['is_denied'] !== $assignment['is_denied']) { $changes['updated'][] = $assignment['id']; - if ($assignment['is_forbidden']) { - $updateForbidden[] = $assignment['id']; + if ($assignment['is_denied']) { + $updateDenied[] = $assignment['id']; } else { $updateAllowed[] = $assignment['id']; } @@ -1050,9 +1050,9 @@ private function synchronizePermissionAssignments( ); } - if ($attachForbidden !== []) { + if ($attachDenied !== []) { $relation->attach( - $attachForbidden, + $attachDenied, $this->permissionAssignmentPivot(true, $context), false, ); @@ -1063,13 +1063,13 @@ private function synchronizePermissionAssignments( if ($updateAllowed !== []) { $relation->newPivotQuery() ->whereIn($relatedPivotKey, $updateAllowed) - ->update(['is_forbidden' => false]); + ->update(['is_denied' => false]); } - if ($updateForbidden !== []) { + if ($updateDenied !== []) { $relation->newPivotQuery() - ->whereIn($relatedPivotKey, $updateForbidden) - ->update(['is_forbidden' => true]); + ->whereIn($relatedPivotKey, $updateDenied) + ->update(['is_denied' => true]); } if ($changes['attached'] !== [] @@ -1321,7 +1321,7 @@ protected function collapseQueuedPermissionAssignments(): array foreach ($collapsed as $assignment) { $pivot = $assignment['pivot']; $batchKey = $assignment['context']->identity() . ':' - . ((bool) $pivot['is_forbidden'] ? 'forbidden' : 'allowed'); + . ((bool) $pivot['is_denied'] ? 'denied' : 'allowed'); $batches[$batchKey] ??= [ 'permissions' => [], @@ -1428,29 +1428,29 @@ public function syncPermissions(...$permissions): static } /** - * Remove all current permissions and set allowed and forbidden permissions. + * Remove all current permissions and set allowed and denied permissions. * * For unsaved models, assignments are queued until the model is saved and * the returned change set is empty because no database rows are changed yet. * * @param array|Collection $allowed - * @param array|Collection $forbidden + * @param array|Collection $denied * @return array{attached: array, detached: array, updated: array} */ - public function syncPermissionsWithForbidden(array|Collection $allowed = [], array|Collection $forbidden = []): array + public function syncPermissionEffects(array|Collection $allowed = [], array|Collection $denied = []): array { $registrar = $this->permissionRegistrar(); $context = $this->permissionAssignmentContext($registrar); $allowedIds = $this->collectPermissions($allowed, $context->partition); - $forbiddenIds = $this->collectPermissions($forbidden, $context->partition); + $deniedIds = $this->collectPermissions($denied, $context->partition); $allowedIds = array_values(array_filter( $allowedIds, - fn (int|string $allowedId): bool => ! in_array($allowedId, $forbiddenIds, true), + fn (int|string $allowedId): bool => ! in_array($allowedId, $deniedIds, true), )); - $permissions = array_merge($allowedIds, $forbiddenIds); + $permissions = array_merge($allowedIds, $deniedIds); $allowedPivot = $this->permissionAssignmentPivot(false, $context); - $forbiddenPivot = $this->permissionAssignmentPivot(true, $context); + $deniedPivot = $this->permissionAssignmentPivot(true, $context); if (! $this->exists) { $this->replaceQueuedPermissionAssignments( @@ -1461,8 +1461,8 @@ public function syncPermissionsWithForbidden(array|Collection $allowed = [], arr 'context' => $context, ], [ - 'permissions' => $forbiddenIds, - 'pivot' => $forbiddenPivot, + 'permissions' => $deniedIds, + 'pivot' => $deniedPivot, 'context' => $context, ], ], @@ -1475,7 +1475,7 @@ public function syncPermissionsWithForbidden(array|Collection $allowed = [], arr $changes = $this->synchronizePermissionAssignments( $allowedIds, - $forbiddenIds, + $deniedIds, $context, true, ); @@ -1574,27 +1574,27 @@ protected function permissionDetachedEventIsListenedFor(): bool } /** - * Determine if the model has a forbidden direct permission. + * Determine if the model has an explicit denied direct permission. * * @param int|Permission|string|UnitEnum $permission */ - public function hasForbiddenPermission($permission, ?string $guardName = null): bool + public function hasDeniedPermission($permission, ?string $guardName = null): bool { $guardName = $this->guardNameForPermissionMatch($permission, $guardName); return $this->getCachedDirectPermissions() ->contains( - fn (Model $storedPermission): bool => $this->pivotIsForbidden($storedPermission) + fn (Model $storedPermission): bool => $this->pivotIsDenied($storedPermission) && $this->storedPermissionMatches($storedPermission, $permission, $guardName) ); } /** - * Determine if the model has a forbidden permission via roles. + * Determine if the model has an explicit denied permission via roles. * * @param int|Permission|string|UnitEnum $permission */ - public function hasForbiddenPermissionViaRoles($permission, ?string $guardName = null): bool + public function hasDeniedPermissionViaRoles($permission, ?string $guardName = null): bool { if ($this instanceof Role || $this instanceof Permission) { return false; @@ -1608,7 +1608,7 @@ public function hasForbiddenPermissionViaRoles($permission, ?string $guardName = $registrar = $this->permissionRegistrar(); - if (! $registrar->hasForbiddenRolePermissions()) { + if (! $registrar->hasDeniedRolePermissions()) { return false; } @@ -1624,7 +1624,7 @@ public function hasForbiddenPermissionViaRoles($permission, ?string $guardName = return $this->relationCollection($storedPermission, 'roles') ->contains( fn (Model $role): bool => isset($roleIds[(string) $role->getKey()]) - && $this->pivotIsForbidden($role) + && $this->pivotIsDenied($role) ); } @@ -1706,15 +1706,15 @@ protected function permissionForMatch(mixed $permission, string $guardName): ?Mo } /** - * Build lookup keys for forbidden permissions. + * Build lookup keys for denied permissions. */ - protected function forbiddenPermissionKeys(Collection ...$permissionCollections): array + protected function deniedPermissionKeys(Collection ...$permissionCollections): array { $keys = []; foreach ($permissionCollections as $permissions) { foreach ($permissions as $permission) { - if ($permission instanceof Model && $this->pivotIsForbidden($permission)) { + if ($permission instanceof Model && $this->pivotIsDenied($permission)) { $keys[$this->permissionComparisonKey($permission)] = true; } } @@ -1743,9 +1743,9 @@ protected function permissionWithRolePivot(Model $permission, Model $role): Mode } /** - * Determine if a hydrated pivot marks the permission as forbidden. + * Determine if a hydrated pivot marks the permission as denied. */ - protected function pivotIsForbidden(Model $model): bool + protected function pivotIsDenied(Model $model): bool { if (! $model->relationLoaded('pivot')) { return false; @@ -1754,13 +1754,13 @@ protected function pivotIsForbidden(Model $model): bool $pivot = $model->getRelation('pivot'); return $pivot instanceof Pivot - && $this->permissionEffectIsForbidden($pivot->getAttribute('is_forbidden')); + && $this->permissionEffectIsDenied($pivot->getAttribute('is_denied')); } /** * Normalize a permission assignment effect. */ - protected function permissionEffectIsForbidden(mixed $value): bool + protected function permissionEffectIsDenied(mixed $value): bool { // Framework connectors disable stringified fetches, so booleans arrive as bool or 0/1. return (bool) $value; diff --git a/tests/Integration/Database/PermissionPartitionTest.php b/tests/Integration/Database/PermissionPartitionTest.php index ee8c15d62..3452fc2f2 100644 --- a/tests/Integration/Database/PermissionPartitionTest.php +++ b/tests/Integration/Database/PermissionPartitionTest.php @@ -104,7 +104,7 @@ protected function afterRefreshingDatabase(): void $table->uuid('workspace_id'); $table->uuid('permission_test_id'); $table->uuid('role_test_id'); - $table->boolean('is_forbidden')->default(false); + $table->boolean('is_denied')->default(false); $table->primary(['workspace_id', 'permission_test_id', 'role_test_id']); $table->foreign(['workspace_id', 'permission_test_id']) ->references(['workspace_id', 'id']) @@ -137,7 +137,7 @@ protected function afterRefreshingDatabase(): void $table->uuid('permission_test_id'); $table->string('model_type'); $table->uuid('model_test_id'); - $table->boolean('is_forbidden')->default(false); + $table->boolean('is_denied')->default(false); $table->primary(['workspace_id', 'permission_test_id', 'model_test_id', 'model_type']); $table->index( ['workspace_id', 'model_type', 'model_test_id'], @@ -215,7 +215,7 @@ public function testCompositeForeignKeysRejectCrossPartitionEdges(): void 'workspace_id' => self::PARTITION_A, 'permission_test_id' => $permissionB->getKey(), 'role_test_id' => $roleA->getKey(), - 'is_forbidden' => false, + 'is_denied' => false, ]); } @@ -254,34 +254,34 @@ public function testPermissionEffectSynchronizationReportsOnlyRealChanges(): voi 'attached' => [$permission->getKey()], 'detached' => [], 'updated' => [], - ], $user->syncPermissionsWithForbidden([$permission])); + ], $user->syncPermissionEffects([$permission])); $this->assertSame([ 'attached' => [], 'detached' => [], 'updated' => [$permission->getKey()], - ], $user->syncPermissionsWithForbidden([], [$permission])); - $this->assertTrue($user->hasForbiddenPermission($permission)); + ], $user->syncPermissionEffects([], [$permission])); + $this->assertTrue($user->hasDeniedPermission($permission)); $this->assertSame([ 'attached' => [], 'detached' => [], 'updated' => [], - ], $user->syncPermissionsWithForbidden([], [$permission])); + ], $user->syncPermissionEffects([], [$permission])); $this->assertSame([ 'attached' => [], 'detached' => [], 'updated' => [$permission->getKey()], - ], $user->syncPermissionsWithForbidden([$permission])); - $this->assertFalse($user->hasForbiddenPermission($permission)); + ], $user->syncPermissionEffects([$permission])); + $this->assertFalse($user->hasDeniedPermission($permission)); $this->assertSame([ 'attached' => [], 'detached' => [], 'updated' => [$permission->getKey()], - ], $user->syncPermissionsWithForbidden([$permission], [$permission])); - $this->assertTrue($user->hasForbiddenPermission($permission)); + ], $user->syncPermissionEffects([$permission], [$permission])); + $this->assertTrue($user->hasDeniedPermission($permission)); $secondPermission = PartitionedPermission::create(['name' => 'articles.archive']); $user->givePermissionTo($secondPermission); @@ -292,6 +292,6 @@ public function testPermissionEffectSynchronizationReportsOnlyRealChanges(): voi 'attached' => [], 'detached' => $expectedDetached, 'updated' => [], - ], $user->syncPermissionsWithForbidden()); + ], $user->syncPermissionEffects()); } } diff --git a/tests/Permission/CacheTest.php b/tests/Permission/CacheTest.php index cc1d0723d..916bbe72c 100644 --- a/tests/Permission/CacheTest.php +++ b/tests/Permission/CacheTest.php @@ -33,20 +33,34 @@ public function testGlobalPermissionCacheStoresRolePivotsWithoutDuplicatingRoleA $this->assertIsArray($permission); $this->assertArrayNotHasKey('attributes', $permission['roles'][0]); $this->assertSame($this->testUserRole->getKey(), $permission['roles'][0]['pivot'][$registrar->pivotRole]); - $this->assertFalse($permission['roles'][0]['pivot']['is_forbidden']); + $this->assertFalse($permission['roles'][0]['pivot']['is_denied']); } - public function testRoleForbiddenPivotHydratesFromGlobalCache(): void + public function testRoleDeniedPivotHydratesFromGlobalCache(): void { $this->testUser->assignRole('testRole'); - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); $this->app->make(PermissionRegistrar::class)->clearPermissionsCollection(); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); - $this->assertTrue($this->testUser->hasForbiddenPermissionViaRoles('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermissionViaRoles('edit-articles')); + } + + public function testDirectDeniedPivotHydratesFromModelAssignmentCache(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + $this->testUser->assignRole($this->testUserRole); + $this->testUser->denyPermissionTo('edit-articles'); + + $user = User::findOrFail($this->testUser->getKey()); + + $this->assertFalse($user->relationLoaded('permissions')); + $this->assertTrue($user->hasDeniedPermission('edit-articles')); + $this->assertFalse($user->relationLoaded('permissions')); + $this->assertFalse($user->hasPermissionTo('edit-articles')); } public function testPermissionCacheResetChangesModelAssignmentCacheToken(): void @@ -114,19 +128,19 @@ public function testDirectPermissionMutationsInvalidateWarmModelPermissionCache( $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); } - public function testSyncPermissionsWithForbiddenInvalidatesWarmModelPermissionCache(): void + public function testSyncPermissionEffectsInvalidatesWarmModelPermissionCache(): void { $this->testUser->givePermissionTo('edit-articles'); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); - $this->assertFalse($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertFalse($this->testUser->hasDeniedPermission('edit-articles')); - $this->testUser->syncPermissionsWithForbidden( + $this->testUser->syncPermissionEffects( allowed: ['edit-news'], - forbidden: ['edit-articles'], + denied: ['edit-articles'], ); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); - $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermission('edit-articles')); $this->assertTrue($this->testUser->hasPermissionTo('edit-news')); } @@ -153,17 +167,17 @@ public function testRolePermissionMutationsInvalidateWarmViaRolePermissionMemo() $this->assertSame(['edit-articles'], $this->testUser->getAllPermissions()->pluck('name')->all()); } - public function testRoleForbiddenPermissionMutationsInvalidateWarmGlobalPermissionCatalog(): void + public function testRoleDeniedPermissionMutationsInvalidateWarmGlobalPermissionCatalog(): void { $this->testUser->assignRole('testRole'); $this->testUserRole->givePermissionTo('edit-articles'); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); - $this->assertFalse($this->testUser->hasForbiddenPermissionViaRoles('edit-articles')); + $this->assertFalse($this->testUser->hasDeniedPermissionViaRoles('edit-articles')); - $this->testUserRole->syncPermissionsWithForbidden(forbidden: ['edit-articles']); + $this->testUserRole->syncPermissionEffects(denied: ['edit-articles']); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); - $this->assertTrue($this->testUser->hasForbiddenPermissionViaRoles('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermissionViaRoles('edit-articles')); } public function testWildcardPermissionMutationsInvalidateWarmWildcardIndex(): void @@ -194,7 +208,7 @@ public function testGlobalCatalogIsNotHeldOnWorkerSingletonAfterSharedCacheIsFor ->insert([ $registrar->pivotPermission => $permission->getKey(), $registrar->pivotRole => $this->testUserRole->getKey(), - 'is_forbidden' => false, + 'is_denied' => false, ]); $registrar->getCacheRepository()->forget($registrar->getCacheKey()); diff --git a/tests/Permission/Commands/CommandTest.php b/tests/Permission/Commands/CommandTest.php index 05dde7522..7face412d 100644 --- a/tests/Permission/Commands/CommandTest.php +++ b/tests/Permission/Commands/CommandTest.php @@ -156,7 +156,7 @@ public function testItCanRespondToAboutCommandWithDefaultFeatures(): void Artisan::call('about'); $output = str_replace("\r\n", "\n", Artisan::output()); - $this->assertMatchesRegularExpression('/Hypervel Permissions[ .\n]*Features Enabled[ .]*Forbidden Permissions[ .\n]*Version/', $output); + $this->assertMatchesRegularExpression('/Hypervel Permissions[ .\n]*Features Enabled[ .]*Denied Permissions[ .\n]*Version/', $output); } public function testItCanRespondToAboutCommandWithTeams(): void @@ -171,7 +171,7 @@ public function testItCanRespondToAboutCommandWithTeams(): void Artisan::call('about'); $output = str_replace("\r\n", "\n", Artisan::output()); - $this->assertMatchesRegularExpression('/Hypervel Permissions[ .\n]*Features Enabled[ .]*Teams, Forbidden Permissions[ .\n]*Version/', $output); + $this->assertMatchesRegularExpression('/Hypervel Permissions[ .\n]*Features Enabled[ .]*Teams, Denied Permissions[ .\n]*Version/', $output); } public function testItCanAssignRoleToUser(): void diff --git a/tests/Permission/CustomSchemaConfigTest.php b/tests/Permission/CustomSchemaConfigTest.php index b5da35173..c49d530a4 100644 --- a/tests/Permission/CustomSchemaConfigTest.php +++ b/tests/Permission/CustomSchemaConfigTest.php @@ -57,15 +57,15 @@ public function testCustomTableNamesAreUsedBySchemaModelsAndRelations(): void $this->assertTrue($this->app->make(Permission::class)::where('name', 'edit-articles')->exists()); } - public function testForbiddenPermissionUpdatesExistingCustomTableAssignmentEdge(): void + public function testDeniedPermissionUpdatesExistingCustomTableAssignmentEdge(): void { $this->testUser->givePermissionTo('edit-articles'); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->testUser->refresh(); $this->assertSame(1, $this->testUser->permissions()->count()); - $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermission('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); } } diff --git a/tests/Permission/DeletionTest.php b/tests/Permission/DeletionTest.php index 97ecb6756..81d6f3bb1 100644 --- a/tests/Permission/DeletionTest.php +++ b/tests/Permission/DeletionTest.php @@ -78,7 +78,7 @@ public function testCustomRoleCleanupDoesNotDependOnRefreshesPermissionCache(): DB::table(Config::roleHasPermissionsTable())->insert([ app('config')->get('permission.column_names.role_pivot_key') => $role->getKey(), app('config')->get('permission.column_names.permission_pivot_key') => $this->testUserPermission->getKey(), - 'is_forbidden' => false, + 'is_denied' => false, ]); $cleanupObservedBeforeRecordDeletion = false; @@ -110,12 +110,12 @@ public function testCustomPermissionCleanupDoesNotDependOnRefreshesPermissionCac app('config')->get('permission.column_names.permission_pivot_key') => $permission->getKey(), Config::morphKey() => $this->testUser->getKey(), 'model_type' => $this->testUser->getMorphClass(), - 'is_forbidden' => false, + 'is_denied' => false, ]); DB::table(Config::roleHasPermissionsTable())->insert([ app('config')->get('permission.column_names.role_pivot_key') => $this->testUserRole->getKey(), app('config')->get('permission.column_names.permission_pivot_key') => $permission->getKey(), - 'is_forbidden' => false, + 'is_denied' => false, ]); $cleanupObservedBeforeRecordDeletion = false; diff --git a/tests/Permission/ForbiddenPermissionTest.php b/tests/Permission/DeniedPermissionTest.php similarity index 71% rename from tests/Permission/ForbiddenPermissionTest.php rename to tests/Permission/DeniedPermissionTest.php index 3b56bb322..d2ae98451 100644 --- a/tests/Permission/ForbiddenPermissionTest.php +++ b/tests/Permission/DeniedPermissionTest.php @@ -14,66 +14,66 @@ use Hypervel\Tests\Permission\Fixtures\Models\Role; use Hypervel\Tests\Permission\Fixtures\Models\User; -class ForbiddenPermissionTest extends TestCase +class DeniedPermissionTest extends TestCase { - public function testDirectForbiddenPermissionDeniesDirectAndNormalPermissionChecks(): void + public function testDirectDeniedPermissionDeniesDirectAndNormalPermissionChecks(): void { - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); - $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermission('edit-articles')); $this->assertFalse($this->testUser->hasDirectPermission('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); - $this->assertTrue((bool) $this->testUser->permissions->firstWhere('name', 'edit-articles')->pivot->getAttribute('is_forbidden')); + $this->assertTrue((bool) $this->testUser->permissions->firstWhere('name', 'edit-articles')->pivot->getAttribute('is_denied')); } - public function testDirectForbiddenPermissionFlipsExistingAllowedPermission(): void + public function testDirectDeniedPermissionFlipsExistingAllowedPermission(): void { $this->testUser->givePermissionTo('edit-articles'); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->testUser->refresh(); $this->assertSame(1, $this->testUser->permissions()->count()); - $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermission('edit-articles')); $this->assertFalse($this->testUser->hasDirectPermission('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); $this->assertSame([], $this->testUser->getPermissionNames()->all()); } - public function testDirectAllowedPermissionFlipsExistingForbiddenPermission(): void + public function testDirectAllowedPermissionFlipsExistingDeniedPermission(): void { - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->testUser->givePermissionTo('edit-articles'); $this->testUser->refresh(); $this->assertSame(1, $this->testUser->permissions()->count()); - $this->assertFalse($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertFalse($this->testUser->hasDeniedPermission('edit-articles')); $this->assertTrue($this->testUser->hasDirectPermission('edit-articles')); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); $this->assertSame(['edit-articles'], $this->testUser->getPermissionNames()->all()); } - public function testDirectForbiddenPermissionOverridesRolePermission(): void + public function testDirectDeniedPermissionOverridesRolePermission(): void { $this->testUserRole->givePermissionTo('edit-articles'); $this->testUser->assignRole('testRole'); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); - $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermission('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); $this->assertFalse($this->testUser->getAllPermissions()->contains('name', 'edit-articles')); } - public function testDirectForbiddenPermissionIsGuardExact(): void + public function testDirectDeniedPermissionIsGuardExact(): void { $permissionClass = $this->app->make(PermissionContract::class); $webPermission = $permissionClass::findByName('edit-articles', 'web'); $apiPermission = $permissionClass::create(['name' => 'edit-articles', 'guard_name' => 'api']); $this->testUser->givePermissionTo($webPermission); - $this->testUser->giveForbiddenTo($apiPermission); + $this->testUser->denyPermissionTo($apiPermission); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles', 'api')); @@ -81,60 +81,60 @@ public function testDirectForbiddenPermissionIsGuardExact(): void $this->assertTrue($this->testUser->hasPermissionTo($webPermission)); } - public function testRoleForbiddenPermissionOverridesDirectPermission(): void + public function testRoleDeniedPermissionOverridesDirectPermission(): void { $role = $this->app->make(RoleContract::class)::create(['name' => 'restricted']); - $role->giveForbiddenTo('edit-articles'); + $role->denyPermissionTo('edit-articles'); $this->testUser->givePermissionTo('edit-articles'); $this->testUser->assignRole($role); - $this->assertTrue($this->testUser->hasForbiddenPermissionViaRoles('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermissionViaRoles('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); $this->assertTrue($this->testUser->hasDirectPermission('edit-articles')); $this->assertFalse($this->testUser->getAllPermissions()->contains('name', 'edit-articles')); } - public function testRoleForbiddenPermissionFlipsExistingAllowedPermission(): void + public function testRoleDeniedPermissionFlipsExistingAllowedPermission(): void { $this->testUserRole->givePermissionTo('edit-articles'); - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->testUserRole->refresh(); $this->assertSame(1, $this->testUserRole->permissions()->count()); - $this->assertTrue($this->testUserRole->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUserRole->hasDeniedPermission('edit-articles')); $this->assertFalse($this->testUserRole->hasPermissionTo('edit-articles')); } - public function testRoleAllowedPermissionFlipsExistingForbiddenPermission(): void + public function testRoleAllowedPermissionFlipsExistingDeniedPermission(): void { - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->testUserRole->givePermissionTo('edit-articles'); $this->testUserRole->refresh(); $this->assertSame(1, $this->testUserRole->permissions()->count()); - $this->assertFalse($this->testUserRole->hasForbiddenPermission('edit-articles')); + $this->assertFalse($this->testUserRole->hasDeniedPermission('edit-articles')); $this->assertTrue($this->testUserRole->hasPermissionTo('edit-articles')); } - public function testRoleForbiddenPermissionOverridesAllowedRolePermission(): void + public function testRoleDeniedPermissionOverridesAllowedRolePermission(): void { $allowedRole = $this->app->make(RoleContract::class)::create(['name' => 'allowed']); - $forbiddenRole = $this->app->make(RoleContract::class)::create(['name' => 'forbidden']); + $deniedRole = $this->app->make(RoleContract::class)::create(['name' => 'denied']); $allowedRole->givePermissionTo('edit-articles'); - $forbiddenRole->giveForbiddenTo('edit-articles'); - $this->testUser->assignRole($allowedRole, $forbiddenRole); + $deniedRole->denyPermissionTo('edit-articles'); + $this->testUser->assignRole($allowedRole, $deniedRole); - $this->assertTrue($this->testUser->hasForbiddenPermissionViaRoles('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermissionViaRoles('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); $this->assertFalse($this->testUser->getPermissionsViaRoles()->contains('name', 'edit-articles')); $this->assertFalse($this->testUser->getAllPermissions()->contains('name', 'edit-articles')); } - public function testRoleForbiddenPermissionIsGuardExact(): void + public function testRoleDeniedPermissionIsGuardExact(): void { $permissionClass = $this->app->make(PermissionContract::class); $roleClass = $this->app->make(RoleContract::class); @@ -144,7 +144,7 @@ public function testRoleForbiddenPermissionIsGuardExact(): void $apiRole = $roleClass::create(['name' => 'api-blocked', 'guard_name' => 'api']); $webRole->givePermissionTo($webPermission); - $apiRole->giveForbiddenTo($apiPermission); + $apiRole->denyPermissionTo($apiPermission); $this->testUser->assignRole($webRole, $apiRole); $webPermission = $permissionClass::findByName('edit-articles', 'web'); @@ -154,22 +154,22 @@ public function testRoleForbiddenPermissionIsGuardExact(): void $this->assertTrue($this->testUser->hasPermissionTo($webPermission)); } - public function testRoleForbiddenPermissionForDifferentPermissionDoesNotDenyRequestedPermission(): void + public function testRoleDeniedPermissionForDifferentPermissionDoesNotDenyRequestedPermission(): void { $allowedRole = $this->app->make(RoleContract::class)::create(['name' => 'allowed-editor']); - $forbiddenRole = $this->app->make(RoleContract::class)::create(['name' => 'blocked-news']); + $deniedRole = $this->app->make(RoleContract::class)::create(['name' => 'blocked-news']); $allowedRole->givePermissionTo('edit-articles'); - $forbiddenRole->giveForbiddenTo('edit-news'); - $this->testUser->assignRole($allowedRole, $forbiddenRole); + $deniedRole->denyPermissionTo('edit-news'); + $this->testUser->assignRole($allowedRole, $deniedRole); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-news')); } - public function testMissingPermissionStillThrowsOrChecksFalseWithRoleForbiddenEdges(): void + public function testMissingPermissionStillThrowsOrChecksFalseWithRoleDeniedEdges(): void { - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->testUser->assignRole($this->testUserRole); $this->assertFalse($this->testUser->checkPermissionTo('missing-permission')); @@ -179,7 +179,7 @@ public function testMissingPermissionStillThrowsOrChecksFalseWithRoleForbiddenEd $this->testUser->hasPermissionTo('missing-permission'); } - public function testRoleModelForbiddenPermissionIsMatchedByConcretePermissionGuard(): void + public function testRoleModelDeniedPermissionIsMatchedByConcretePermissionGuard(): void { $permission = $this->app->make(PermissionContract::class)::create([ 'name' => 'api-edit-articles', @@ -190,32 +190,32 @@ public function testRoleModelForbiddenPermissionIsMatchedByConcretePermissionGua 'guard_name' => 'api', ]); - $role->giveForbiddenTo($permission); + $role->denyPermissionTo($permission); $this->assertFalse($role->hasPermissionTo($permission)); } - public function testForbiddenPermissionWinsWhenAllowedAndForbiddenAreSyncedTogether(): void + public function testDeniedPermissionWinsWhenAllowedAndDeniedAreSyncedTogether(): void { - $changes = $this->testUser->syncPermissionsWithForbidden( + $changes = $this->testUser->syncPermissionEffects( allowed: ['edit-articles', 'edit-news'], - forbidden: ['edit-news'], + denied: ['edit-news'], ); $this->assertArrayHasKey('attached', $changes); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-news')); - $this->assertTrue($this->testUser->hasForbiddenPermission('edit-news')); + $this->assertTrue($this->testUser->hasDeniedPermission('edit-news')); } - public function testForbiddenSyncReplacesExistingDirectPermissions(): void + public function testDeniedSyncReplacesExistingDirectPermissions(): void { $this->app->make(PermissionContract::class)::create(['name' => 'delete-articles']); $this->testUser->givePermissionTo('edit-articles', 'edit-news'); - $changes = $this->testUser->syncPermissionsWithForbidden( + $changes = $this->testUser->syncPermissionEffects( allowed: ['edit-news'], - forbidden: ['delete-articles'], + denied: ['delete-articles'], ); $this->testUser->refresh(); @@ -229,18 +229,18 @@ public function testForbiddenSyncReplacesExistingDirectPermissions(): void $this->assertSame([], $changes['updated']); $this->assertFalse($this->testUser->hasDirectPermission('edit-articles')); $this->assertTrue($this->testUser->hasDirectPermission('edit-news')); - $this->assertTrue($this->testUser->hasForbiddenPermission('delete-articles')); + $this->assertTrue($this->testUser->hasDeniedPermission('delete-articles')); $this->assertFalse($this->testUser->hasPermissionTo('delete-articles')); } - public function testForbiddenSyncReportsPivotChangesAsUpdates(): void + public function testDeniedSyncReportsPivotChangesAsUpdates(): void { $this->testUser->givePermissionTo('edit-articles'); - $this->testUser->giveForbiddenTo('edit-news'); + $this->testUser->denyPermissionTo('edit-news'); - $changes = $this->testUser->syncPermissionsWithForbidden( + $changes = $this->testUser->syncPermissionEffects( allowed: ['edit-news'], - forbidden: ['edit-articles'], + denied: ['edit-articles'], ); $this->testUser->refresh(); @@ -252,19 +252,19 @@ public function testForbiddenSyncReportsPivotChangesAsUpdates(): void $this->app->make(PermissionContract::class)::findByName('edit-news')->getKey(), ], $changes['updated']); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); - $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermission('edit-articles')); $this->assertTrue($this->testUser->hasPermissionTo('edit-news')); - $this->assertFalse($this->testUser->hasForbiddenPermission('edit-news')); + $this->assertFalse($this->testUser->hasDeniedPermission('edit-news')); } - public function testQueuedForbiddenSyncReplacesEarlierQueuedAssignmentsWhenModelIsSaved(): void + public function testQueuedDeniedSyncReplacesEarlierQueuedAssignmentsWhenModelIsSaved(): void { - $user = new User(['email' => 'queued-forbidden-sync@example.com']); + $user = new User(['email' => 'queued-denied-sync@example.com']); $user->givePermissionTo('edit-articles'); - $changes = $user->syncPermissionsWithForbidden( + $changes = $user->syncPermissionEffects( allowed: ['edit-blog', 'edit-news'], - forbidden: ['edit-news'], + denied: ['edit-news'], ); $this->assertSame(['attached' => [], 'detached' => [], 'updated' => []], $changes); @@ -276,56 +276,56 @@ public function testQueuedForbiddenSyncReplacesEarlierQueuedAssignmentsWhenModel $this->assertFalse($user->hasPermissionTo('edit-articles')); $this->assertTrue($user->hasPermissionTo('edit-blog')); $this->assertFalse($user->hasPermissionTo('edit-news')); - $this->assertTrue($user->hasForbiddenPermission('edit-news')); + $this->assertTrue($user->hasDeniedPermission('edit-news')); } - public function testQueuedDirectForbiddenPermissionFlipsExistingQueuedAllowedPermission(): void + public function testQueuedDirectDeniedPermissionFlipsExistingQueuedAllowedPermission(): void { $user = new User(['email' => 'queued@example.com']); $user->givePermissionTo('edit-articles'); - $user->giveForbiddenTo('edit-articles'); + $user->denyPermissionTo('edit-articles'); $user->save(); $user->refresh(); $this->assertSame(1, $user->permissions()->count()); - $this->assertTrue($user->hasForbiddenPermission('edit-articles')); + $this->assertTrue($user->hasDeniedPermission('edit-articles')); $this->assertFalse($user->hasPermissionTo('edit-articles')); } - public function testQueuedDirectAllowedPermissionFlipsExistingQueuedForbiddenPermission(): void + public function testQueuedDirectAllowedPermissionFlipsExistingQueuedDeniedPermission(): void { $user = new User(['email' => 'queued@example.com']); - $user->giveForbiddenTo('edit-articles'); + $user->denyPermissionTo('edit-articles'); $user->givePermissionTo('edit-articles'); $user->save(); $user->refresh(); $this->assertSame(1, $user->permissions()->count()); - $this->assertFalse($user->hasForbiddenPermission('edit-articles')); + $this->assertFalse($user->hasDeniedPermission('edit-articles')); $this->assertTrue($user->hasPermissionTo('edit-articles')); } - public function testRevokePermissionRemovesDirectForbiddenPermission(): void + public function testRevokePermissionRemovesDirectDeniedPermission(): void { - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->testUser->revokePermissionTo('edit-articles'); $this->testUser->refresh(); $this->assertSame(0, $this->testUser->permissions()->count()); - $this->assertFalse($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertFalse($this->testUser->hasDeniedPermission('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); } - public function testRemovingDirectForbiddenPermissionRevealsRoleAllowedPermission(): void + public function testRemovingDirectDeniedPermissionRevealsRoleAllowedPermission(): void { $this->testUserRole->givePermissionTo('edit-articles'); $this->testUser->assignRole($this->testUserRole); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); @@ -334,45 +334,45 @@ public function testRemovingDirectForbiddenPermissionRevealsRoleAllowedPermissio $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); } - public function testRoleForbiddenSyncUsesCustomPermissionPrimaryKeys(): void + public function testRoleDeniedSyncUsesCustomPermissionPrimaryKeys(): void { $this->setUpCustomModels(); $allowedPermission = Permission::findOrCreate('custom-allow'); - $forbiddenPermission = Permission::findOrCreate('custom-deny'); + $deniedPermission = Permission::findOrCreate('custom-deny'); $role = Role::findOrCreate('custom-role'); - $changes = $role->syncPermissionsWithForbidden( + $changes = $role->syncPermissionEffects( allowed: [$allowedPermission], - forbidden: [$forbiddenPermission], + denied: [$deniedPermission], ); $role = $role->fresh(); $this->assertEqualsCanonicalizing([ $allowedPermission->getKey(), - $forbiddenPermission->getKey(), + $deniedPermission->getKey(), ], $changes['attached']); $this->assertTrue($role->hasPermissionTo($allowedPermission)); - $this->assertTrue($role->hasForbiddenPermission($forbiddenPermission)); - $this->assertFalse($role->hasPermissionTo($forbiddenPermission)); + $this->assertTrue($role->hasDeniedPermission($deniedPermission)); + $this->assertFalse($role->hasPermissionTo($deniedPermission)); } - public function testRoleForbiddenPermissionsAreExcludedFromRolePermissionResults(): void + public function testRoleDeniedPermissionsAreExcludedFromRolePermissionResults(): void { $role = $this->app->make(RoleContract::class)::create(['name' => 'mixed']); $this->app->make(PermissionContract::class)::create(['name' => 'delete-articles']); $role->givePermissionTo('edit-articles'); - $role->giveForbiddenTo('delete-articles'); + $role->denyPermissionTo('delete-articles'); $this->testUser->assignRole($role); $permissionNames = $this->testUser->getPermissionsViaRoles()->pluck('name')->all(); $this->assertContains('edit-articles', $permissionNames); $this->assertNotContains('delete-articles', $permissionNames); - $this->assertTrue($this->testUser->hasForbiddenPermissionViaRoles('delete-articles')); + $this->assertTrue($this->testUser->hasDeniedPermissionViaRoles('delete-articles')); $this->assertFalse($this->testUser->hasPermissionTo('delete-articles')); } @@ -391,19 +391,19 @@ public function testDuplicateRoleGrantedPermissionsAreReturnedOnce(): void ); } - public function testForbiddenDuplicateRolePermissionIsExcluded(): void + public function testDeniedDuplicateRolePermissionIsExcluded(): void { $allowedRole = $this->app->make(RoleContract::class)::create(['name' => 'duplicate-allowed']); - $forbiddenRole = $this->app->make(RoleContract::class)::create(['name' => 'duplicate-forbidden']); + $deniedRole = $this->app->make(RoleContract::class)::create(['name' => 'duplicate-denied']); $allowedRole->givePermissionTo('edit-articles'); - $forbiddenRole->giveForbiddenTo('edit-articles'); - $this->testUser->assignRole($allowedRole, $forbiddenRole); + $deniedRole->denyPermissionTo('edit-articles'); + $this->testUser->assignRole($allowedRole, $deniedRole); $this->assertSame([], $this->testUser->getPermissionsViaRoles()->pluck('name')->values()->all()); } - public function testRoleForbiddenSyncAffectsAllUsersWithRoleAfterCachesAreWarm(): void + public function testRoleDeniedSyncAffectsAllUsersWithRoleAfterCachesAreWarm(): void { $role = $this->app->make(RoleContract::class)::create(['name' => 'publisher']); $role->givePermissionTo('edit-articles', 'edit-news'); @@ -415,39 +415,39 @@ public function testRoleForbiddenSyncAffectsAllUsersWithRoleAfterCachesAreWarm() $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); $this->assertTrue($anotherUser->hasPermissionTo('edit-news')); - $role->syncPermissionsWithForbidden( + $role->syncPermissionEffects( allowed: ['edit-blog'], - forbidden: ['edit-articles'], + denied: ['edit-articles'], ); $this->testUser->refresh(); $anotherUser->refresh(); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); - $this->assertTrue($this->testUser->hasForbiddenPermissionViaRoles('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermissionViaRoles('edit-articles')); $this->assertTrue($this->testUser->hasPermissionTo('edit-blog')); $this->assertFalse($anotherUser->hasPermissionTo('edit-news')); $this->assertTrue($anotherUser->hasPermissionTo('edit-blog')); } - public function testRoleGetAllPermissionsExcludesForbiddenPermissions(): void + public function testRoleGetAllPermissionsExcludesDeniedPermissions(): void { $role = $this->app->make(RoleContract::class)::create(['name' => 'reviewer']); $role->givePermissionTo('edit-articles'); - $role->giveForbiddenTo('edit-news'); + $role->denyPermissionTo('edit-news'); $permissionNames = $role->getAllPermissions()->pluck('name')->all(); $this->assertSame(['edit-articles'], $permissionNames); - $this->assertTrue($role->hasForbiddenPermission('edit-news')); + $this->assertTrue($role->hasDeniedPermission('edit-news')); } public function testDirectPermissionChecksDenyWhenRelationContainsDuplicateEffects(): void { $permission = $this->app->make(PermissionContract::class)::findByName('edit-articles'); $allowed = clone $permission; - $forbidden = clone $permission; + $denied = clone $permission; $allowed->setRelation('pivot', Pivot::fromRawAttributes( $this->testUser, @@ -455,25 +455,25 @@ public function testDirectPermissionChecksDenyWhenRelationContainsDuplicateEffec $this->app->make(PermissionRegistrar::class)->pivotPermission => $permission->getKey(), Config::morphKey() => $this->testUser->getKey(), 'model_type' => $this->testUser->getMorphClass(), - 'is_forbidden' => false, + 'is_denied' => false, ], Config::modelHasPermissionsTable(), true, )); - $forbidden->setRelation('pivot', Pivot::fromRawAttributes( + $denied->setRelation('pivot', Pivot::fromRawAttributes( $this->testUser, [ $this->app->make(PermissionRegistrar::class)->pivotPermission => $permission->getKey(), Config::morphKey() => $this->testUser->getKey(), 'model_type' => $this->testUser->getMorphClass(), - 'is_forbidden' => true, + 'is_denied' => true, ], Config::modelHasPermissionsTable(), true, )); - $this->testUser->setRelation('permissions', collect([$allowed, $forbidden])); + $this->testUser->setRelation('permissions', collect([$allowed, $denied])); $this->assertFalse($this->testUser->hasDirectPermission('edit-articles')); } @@ -482,39 +482,39 @@ public function testRolePermissionChecksDenyWhenRelationContainsDuplicateEffects { $permission = $this->app->make(PermissionContract::class)::findByName('edit-articles'); $allowed = clone $permission; - $forbidden = clone $permission; + $denied = clone $permission; $allowed->setRelation('pivot', Pivot::fromRawAttributes( $this->testUserRole, [ $this->app->make(PermissionRegistrar::class)->pivotPermission => $permission->getKey(), $this->app->make(PermissionRegistrar::class)->pivotRole => $this->testUserRole->getKey(), - 'is_forbidden' => false, + 'is_denied' => false, ], Config::roleHasPermissionsTable(), true, )); - $forbidden->setRelation('pivot', Pivot::fromRawAttributes( + $denied->setRelation('pivot', Pivot::fromRawAttributes( $this->testUserRole, [ $this->app->make(PermissionRegistrar::class)->pivotPermission => $permission->getKey(), $this->app->make(PermissionRegistrar::class)->pivotRole => $this->testUserRole->getKey(), - 'is_forbidden' => true, + 'is_denied' => true, ], Config::roleHasPermissionsTable(), true, )); - $this->testUserRole->setRelation('permissions', collect([$allowed, $forbidden])); + $this->testUserRole->setRelation('permissions', collect([$allowed, $denied])); $this->assertFalse($this->testUserRole->hasDirectPermission('edit-articles')); $this->assertFalse($this->testUserRole->hasPermissionTo('edit-articles')); } - public function testPermissionScopeExcludesDirectForbiddenPermission(): void + public function testPermissionScopeExcludesDirectDeniedPermission(): void { - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->assertFalse(User::permission('edit-articles')->get()->contains( fn (User $user): bool => $user->is($this->testUser), @@ -524,9 +524,9 @@ public function testPermissionScopeExcludesDirectForbiddenPermission(): void )); } - public function testPermissionScopeExcludesRoleForbiddenPermission(): void + public function testPermissionScopeExcludesRoleDeniedPermission(): void { - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->testUser->assignRole($this->testUserRole); $this->assertFalse(User::permission('edit-articles')->get()->contains( @@ -541,7 +541,7 @@ public function testPermissionScopeLetsDirectDenyOverrideRoleAllow(): void { $this->testUserRole->givePermissionTo('edit-articles'); $this->testUser->assignRole($this->testUserRole); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->assertFalse(User::permission('edit-articles')->get()->contains( fn (User $user): bool => $user->is($this->testUser), @@ -553,7 +553,7 @@ public function testPermissionScopeLetsDirectDenyOverrideRoleAllow(): void public function testPermissionScopeLetsRoleDenyOverrideDirectAllow(): void { - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->testUser->assignRole($this->testUserRole); $this->testUser->givePermissionTo('edit-articles'); @@ -568,7 +568,7 @@ public function testPermissionScopeLetsRoleDenyOverrideDirectAllow(): void public function testPermissionScopeMatchesAllowedPermissionWhenDifferentRequestedPermissionIsDenied(): void { $this->testUser->givePermissionTo('edit-articles'); - $this->testUser->giveForbiddenTo('edit-news'); + $this->testUser->denyPermissionTo('edit-news'); $this->assertTrue(User::permission(['edit-articles', 'edit-news'])->get()->contains( fn (User $user): bool => $user->is($this->testUser), @@ -577,7 +577,7 @@ public function testPermissionScopeMatchesAllowedPermissionWhenDifferentRequeste public function testPermissionScopeMatchesSecondAllowedPermissionWhenFirstRequestedPermissionIsDenied(): void { - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->testUser->assignRole($this->testUserRole); $this->testUser->givePermissionTo('edit-articles'); $this->testUser->givePermissionTo('edit-news'); @@ -601,9 +601,9 @@ public function testWithoutPermissionScopeWithNoPermissionsMatchesAllModels(): v )); } - public function testRolePermissionScopeExcludesForbiddenRolePermissionEdges(): void + public function testRolePermissionScopeExcludesDeniedRolePermissionEdges(): void { - $this->testUserRole->giveForbiddenTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-articles'); $this->assertFalse($this->app->make(RoleContract::class)::permission('edit-articles')->get()->contains( fn ($role): bool => $role->is($this->testUserRole), diff --git a/tests/Permission/Events/EventTest.php b/tests/Permission/Events/EventTest.php index 384a6b273..14c6fbfda 100644 --- a/tests/Permission/Events/EventTest.php +++ b/tests/Permission/Events/EventTest.php @@ -142,15 +142,15 @@ public function testPermissionAttachedEventListenerSeesFreshWildcardIndexAfterPe $this->assertTrue($listenerSawPermission); } - public function testSyncPermissionsWithForbiddenDispatchesPermissionAttachedEventOnce(): void + public function testSyncPermissionEffectsDispatchesPermissionAttachedEventOnce(): void { $this->app->make('config')->set('permission.events_enabled', true); Event::fake([PermissionAttachedEvent::class]); - $this->testUser->syncPermissionsWithForbidden( + $this->testUser->syncPermissionEffects( allowed: ['edit-articles'], - forbidden: ['edit-news'], + denied: ['edit-news'], ); $editNewsPermission = $this->app->make(PermissionContract::class)::findByName('edit-news'); @@ -212,17 +212,17 @@ public function testDeferredAssignmentsDispatchAtTheCallBoundaryWithoutSavedCall }); } - public function testDeferredForbiddenPermissionSyncDispatchesOnceBeforeSave(): void + public function testDeferredDeniedPermissionSyncDispatchesOnceBeforeSave(): void { $this->app->make('config')->set('permission.events_enabled', true); Event::fake([PermissionAttachedEvent::class]); - $user = new User(['email' => 'queued-forbidden-event@example.com']); + $user = new User(['email' => 'queued-denied-event@example.com']); - $user->syncPermissionsWithForbidden( + $user->syncPermissionEffects( allowed: ['edit-articles'], - forbidden: ['edit-news'], + denied: ['edit-news'], ); Event::assertDispatchedTimes(PermissionAttachedEvent::class, 1); @@ -356,7 +356,7 @@ public function testPermissionEffectUpdateDispatchesTheChangedPermission(): void Event::fake([PermissionAttachedEvent::class, PermissionDetachedEvent::class]); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event): bool { return $event->model->is($this->testUser) diff --git a/tests/Permission/GateTest.php b/tests/Permission/GateTest.php index 10d7f4a64..485865d98 100644 --- a/tests/Permission/GateTest.php +++ b/tests/Permission/GateTest.php @@ -78,10 +78,10 @@ public function testItAllowsEnumPermissionsThroughGate(): void $this->assertTrue($this->testUser->canAny([TestRolePermissionsEnum::ViewArticles->value, 'missing'])); } - public function testForbiddenPermissionDeniesGatePermission(): void + public function testDeniedPermissionDeniesGatePermission(): void { $this->testUser->givePermissionTo('edit-articles'); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->assertFalse($this->testUser->can('edit-articles')); } diff --git a/tests/Permission/Integration/PartitionQueryCountTest.php b/tests/Permission/Integration/PartitionQueryCountTest.php index 9e3b60719..88f3c1694 100644 --- a/tests/Permission/Integration/PartitionQueryCountTest.php +++ b/tests/Permission/Integration/PartitionQueryCountTest.php @@ -187,24 +187,24 @@ public function testPermissionSyncUsesAPivotOnlyReadAndOneBulkInsert(): void public function testPermissionEffectSyncBatchesMixedFlipsIntoTwoUpdates(): void { $user = GlobalPartitionUser::create(['email' => 'permission-effects@example.com']); - $toForbiddenOne = PartitionedPermission::create(['name' => 'articles.forbid-one']); - $toForbiddenTwo = PartitionedPermission::create(['name' => 'articles.forbid-two']); + $toDeniedOne = PartitionedPermission::create(['name' => 'articles.deny-one']); + $toDeniedTwo = PartitionedPermission::create(['name' => 'articles.deny-two']); $toAllowedOne = PartitionedPermission::create(['name' => 'articles.allow-one']); $toAllowedTwo = PartitionedPermission::create(['name' => 'articles.allow-two']); $unchangedAllowed = PartitionedPermission::create(['name' => 'articles.allowed']); - $unchangedForbidden = PartitionedPermission::create(['name' => 'articles.forbidden']); + $unchangedDenied = PartitionedPermission::create(['name' => 'articles.denied']); - $user->syncPermissionsWithForbidden( - [$toForbiddenOne, $toForbiddenTwo, $unchangedAllowed], - [$toAllowedOne, $toAllowedTwo, $unchangedForbidden], + $user->syncPermissionEffects( + [$toDeniedOne, $toDeniedTwo, $unchangedAllowed], + [$toAllowedOne, $toAllowedTwo, $unchangedDenied], ); DB::enableQueryLog(); DB::flushQueryLog(); - $changes = $user->syncPermissionsWithForbidden( + $changes = $user->syncPermissionEffects( [$toAllowedOne, $toAllowedTwo, $unchangedAllowed], - [$toForbiddenOne, $toForbiddenTwo, $unchangedForbidden], + [$toDeniedOne, $toDeniedTwo, $unchangedDenied], ); $queries = DB::getQueryLog(); @@ -214,8 +214,8 @@ public function testPermissionEffectSyncBatchesMixedFlipsIntoTwoUpdates(): void 'updated' => [ $toAllowedOne->getKey(), $toAllowedTwo->getKey(), - $toForbiddenOne->getKey(), - $toForbiddenTwo->getKey(), + $toDeniedOne->getKey(), + $toDeniedTwo->getKey(), ], ], $changes); $this->assertCount(3, $queries); @@ -226,9 +226,9 @@ public function testPermissionEffectSyncBatchesMixedFlipsIntoTwoUpdates(): void $this->assertContains($toAllowedOne->getKey(), $queries[1]['bindings']); $this->assertContains($toAllowedTwo->getKey(), $queries[1]['bindings']); $this->assertNotContains($unchangedAllowed->getKey(), $queries[1]['bindings']); - $this->assertContains($toForbiddenOne->getKey(), $queries[2]['bindings']); - $this->assertContains($toForbiddenTwo->getKey(), $queries[2]['bindings']); - $this->assertNotContains($unchangedForbidden->getKey(), $queries[2]['bindings']); + $this->assertContains($toDeniedOne->getKey(), $queries[2]['bindings']); + $this->assertContains($toDeniedTwo->getKey(), $queries[2]['bindings']); + $this->assertNotContains($unchangedDenied->getKey(), $queries[2]['bindings']); } public function testRoleRemovalWithoutAListenerUsesOneBlindDelete(): void diff --git a/tests/Permission/Integration/PermissionRegistrarTest.php b/tests/Permission/Integration/PermissionRegistrarTest.php index 4f6542a82..e99ca1fb1 100644 --- a/tests/Permission/Integration/PermissionRegistrarTest.php +++ b/tests/Permission/Integration/PermissionRegistrarTest.php @@ -218,18 +218,15 @@ public function testPermissionFindOrCreateUsesDatabaseWhenCatalogIsStale(): void $this->assertSame('web', $permission->guard_name); } - public function testOldCachePayloadWithoutForbiddenRolePermissionFlagIsSafe(): void + public function testCatalogReportsWhetherItContainsDeniedRolePermissions(): void { $registrar = $this->app->make(PermissionRegistrar::class); - $registrar->getCacheRepository()->put($registrar->getCacheKey(), [ - 'permissions' => [], - 'roles' => [], - ], 3600); + $this->assertFalse($registrar->hasDeniedRolePermissions()); - $registrar->clearPermissionsCollection(); + $this->testUserRole->denyPermissionTo($this->testUserPermission); - $this->assertFalse($registrar->hasForbiddenRolePermissions()); + $this->assertTrue($registrar->hasDeniedRolePermissions()); } public function testCatalogResolvedRolesExposeExpectedAttributes(): void diff --git a/tests/Permission/PartitionAuthorizationTest.php b/tests/Permission/PartitionAuthorizationTest.php index d13ae630b..5fe29d2b7 100644 --- a/tests/Permission/PartitionAuthorizationTest.php +++ b/tests/Permission/PartitionAuthorizationTest.php @@ -26,7 +26,7 @@ public function testGuardsRemainIndependentInsideEachPartition(): void $webRoleA = PartitionedRole::create(['name' => 'editor', 'guard_name' => 'web']); $apiRoleA = PartitionedRole::create(['name' => 'editor', 'guard_name' => 'api']); $webRoleA->givePermissionTo($webPermissionA); - $apiRoleA->giveForbiddenTo($apiPermissionA); + $apiRoleA->denyPermissionTo($apiPermissionA); $user->assignRole($webRoleA, $apiRoleA); $this->assertTrue($user->hasPermissionTo('articles.edit', 'web')); @@ -37,7 +37,7 @@ public function testGuardsRemainIndependentInsideEachPartition(): void $apiPermissionB = PartitionedPermission::create(['name' => 'articles.edit', 'guard_name' => 'api']); $webRoleB = PartitionedRole::create(['name' => 'editor', 'guard_name' => 'web']); $apiRoleB = PartitionedRole::create(['name' => 'editor', 'guard_name' => 'api']); - $webRoleB->giveForbiddenTo($webPermissionB); + $webRoleB->denyPermissionTo($webPermissionB); $apiRoleB->givePermissionTo($apiPermissionB); $user->assignRole($webRoleB, $apiRoleB); @@ -50,36 +50,36 @@ public function testGuardsRemainIndependentInsideEachPartition(): void $this->assertFalse($user->hasPermissionTo('articles.edit', 'api')); } - public function testDirectAndInheritedForbiddenPermissionsArePartitionIsolated(): void + public function testDirectAndInheritedDeniedPermissionsArePartitionIsolated(): void { $user = GlobalPartitionUser::create(['email' => 'global@example.com']); $permissionA = PartitionedPermission::create(['name' => 'articles.edit']); $allowedRoleA = PartitionedRole::create(['name' => 'allowed']); $allowedRoleA->givePermissionTo($permissionA); $user->assignRole($allowedRoleA); - $user->giveForbiddenTo($permissionA); + $user->denyPermissionTo($permissionA); - $this->assertTrue($user->hasForbiddenPermission($permissionA)); + $this->assertTrue($user->hasDeniedPermission($permissionA)); $this->assertFalse($user->hasPermissionTo($permissionA)); $this->setPartition(self::PARTITION_B); $permissionB = PartitionedPermission::create(['name' => 'articles.edit']); - $forbiddenRoleB = PartitionedRole::create(['name' => 'forbidden']); - $forbiddenRoleB->giveForbiddenTo($permissionB); + $deniedRoleB = PartitionedRole::create(['name' => 'denied']); + $deniedRoleB->denyPermissionTo($permissionB); $user->givePermissionTo($permissionB); - $user->assignRole($forbiddenRoleB); + $user->assignRole($deniedRoleB); $this->assertTrue($user->hasDirectPermission($permissionB)); - $this->assertTrue($user->hasForbiddenPermissionViaRoles($permissionB)); + $this->assertTrue($user->hasDeniedPermissionViaRoles($permissionB)); $this->assertFalse($user->hasPermissionTo($permissionB)); - $user->removeRole($forbiddenRoleB); + $user->removeRole($deniedRoleB); $this->assertTrue($user->hasPermissionTo($permissionB)); $this->setPartition(self::PARTITION_A); - $this->assertTrue($user->hasForbiddenPermission($permissionA)); + $this->assertTrue($user->hasDeniedPermission($permissionA)); $this->assertFalse($user->hasPermissionTo($permissionA)); } @@ -94,7 +94,7 @@ public function testWildcardAllowsAndDeniesArePartitionIsolated(): void $this->setPartition(self::PARTITION_B); $wildcardB = PartitionedPermission::create(['name' => 'posts.*']); - $user->giveForbiddenTo($wildcardB); + $user->denyPermissionTo($wildcardB); $this->assertFalse($user->hasPermissionTo('posts.create')); $this->assertFalse($user->hasPermissionTo('posts.delete.123')); @@ -111,14 +111,14 @@ public function testPermissionScopesUseOnlyCurrentPartitionEffects(): void $denied = GlobalPartitionUser::create(['email' => 'denied@example.com']); $permissionA = PartitionedPermission::create(['name' => 'articles.edit']); $allowed->givePermissionTo($permissionA); - $denied->giveForbiddenTo($permissionA); + $denied->denyPermissionTo($permissionA); $this->assertSame([$allowed->getKey()], GlobalPartitionUser::permission($permissionA)->pluck('id')->all()); $this->assertSame([$denied->getKey()], GlobalPartitionUser::withoutPermission($permissionA)->pluck('id')->all()); $this->setPartition(self::PARTITION_B); $permissionB = PartitionedPermission::create(['name' => 'articles.edit']); - $allowed->giveForbiddenTo($permissionB); + $allowed->denyPermissionTo($permissionB); $denied->givePermissionTo($permissionB); $this->assertSame([$denied->getKey()], GlobalPartitionUser::permission($permissionB)->pluck('id')->all()); diff --git a/tests/Permission/PartitionCoroutineIsolationTest.php b/tests/Permission/PartitionCoroutineIsolationTest.php index 02e62d15e..59ec50fbd 100644 --- a/tests/Permission/PartitionCoroutineIsolationTest.php +++ b/tests/Permission/PartitionCoroutineIsolationTest.php @@ -33,7 +33,7 @@ public function testAuthorizationStateIsIsolatedAcrossConcurrentPartitions(): vo $permissionB = PartitionedPermission::create(['name' => 'articles.*']); $roleB = PartitionedRole::create(['name' => 'viewer']); $user->assignRole($roleB); - $user->giveForbiddenTo($permissionB); + $user->denyPermissionTo($permissionB); $userKey = $user->getKey(); @@ -51,7 +51,7 @@ function () use ($userKey): array { 'owner' => $user->hasRole('owner'), 'viewer' => $user->hasRole('viewer'), 'allowed' => $user->hasPermissionTo('articles.edit'), - 'forbidden' => $user->hasForbiddenPermission($permission), + 'denied' => $user->hasDeniedPermission($permission), 'roles' => $user->roles->pluck('name')->all(), ]; }, @@ -68,7 +68,7 @@ function () use ($userKey): array { 'owner' => $user->hasRole('owner'), 'viewer' => $user->hasRole('viewer'), 'allowed' => $user->hasPermissionTo('articles.edit'), - 'forbidden' => $user->hasForbiddenPermission($permission), + 'denied' => $user->hasDeniedPermission($permission), 'roles' => $user->roles->pluck('name')->all(), ]; }, @@ -79,7 +79,7 @@ function () use ($userKey): array { 'owner' => true, 'viewer' => false, 'allowed' => true, - 'forbidden' => false, + 'denied' => false, 'roles' => ['owner'], ], $partitionA); $this->assertSame([ @@ -87,7 +87,7 @@ function () use ($userKey): array { 'owner' => false, 'viewer' => true, 'allowed' => false, - 'forbidden' => true, + 'denied' => true, 'roles' => ['viewer'], ], $partitionB); $this->assertSame(self::PARTITION_B, PartitionContext::get()); diff --git a/tests/Permission/PartitionRelationsTest.php b/tests/Permission/PartitionRelationsTest.php index ef510b573..7c0550fe9 100644 --- a/tests/Permission/PartitionRelationsTest.php +++ b/tests/Permission/PartitionRelationsTest.php @@ -142,19 +142,19 @@ public function testPivotUpdatesCannotMoveAnExistingEdge(): void { $user = GlobalPartitionUser::create(['email' => 'global@example.com']); $permission = PartitionedPermission::create(['name' => 'articles.edit']); - $user->permissions()->attach($permission, ['is_forbidden' => false]); + $user->permissions()->attach($permission, ['is_denied' => false]); $this->assertSame(1, $user->permissions()->updateExistingPivot($permission, [ 'workspace_id' => self::PARTITION_A, - 'is_forbidden' => true, + 'is_denied' => true, ])); - $this->assertTrue((bool) DB::table(Config::modelHasPermissionsTable())->value('is_forbidden')); + $this->assertTrue((bool) DB::table(Config::modelHasPermissionsTable())->value('is_denied')); $this->expectException(PermissionPartitionViolation::class); $user->permissions()->updateExistingPivot($permission, [ 'workspace_id' => self::PARTITION_B, - 'is_forbidden' => false, + 'is_denied' => false, ]); } @@ -167,7 +167,7 @@ public function testSyncWithPivotValuesRejectsAConflictingPartition(): void $user->permissions()->syncWithPivotValues( [$permission->getKey()], - ['workspace_id' => self::PARTITION_B, 'is_forbidden' => false], + ['workspace_id' => self::PARTITION_B, 'is_denied' => false], ); } @@ -316,7 +316,7 @@ public function testQueuedRolesAndPermissionsRetainMultipleCapturedPartitionsWit $roleB = PartitionedRole::create(['name' => 'member']); $permissionB = PartitionedPermission::create(['name' => 'articles.edit']); $user->assignRole($roleB); - $user->giveForbiddenTo($permissionB); + $user->denyPermissionTo($permissionB); PartitionContext::forget(); $user->save(); @@ -327,7 +327,7 @@ public function testQueuedRolesAndPermissionsRetainMultipleCapturedPartitionsWit $this->setPartition(self::PARTITION_B); $this->assertTrue($user->hasRole($roleB)); - $this->assertTrue($user->hasForbiddenPermission($permissionB)); + $this->assertTrue($user->hasDeniedPermission($permissionB)); } public function testUnsavedRoleQueueDoesNotInvalidateWildcardStateBeforeCommit(): void @@ -393,7 +393,7 @@ public function testMultiPartitionDeferredQueuesInvalidateStoredContextsOnlyAfte $this->setPartition(self::PARTITION_B); $user->assignRole($roleB); - $user->giveForbiddenTo($permissionB); + $user->denyPermissionTo($permissionB); $this->assertSame( [$runtimeKeyA, $runtimeKeyB], @@ -423,7 +423,7 @@ public function testQueuedAssignmentBatchesRollBackTogetherAndRetryExactlyOnce() $roleB = PartitionedRole::create(['name' => 'member']); $permissionB = PartitionedPermission::create(['name' => 'articles.edit']); $user->assignRole($roleB); - $user->giveForbiddenTo($permissionB); + $user->denyPermissionTo($permissionB); $tokenB = $registrar->modelAssignmentCacheToken(); $this->app->make('config')->set('permission.events_enabled', true); Event::fake([ @@ -482,7 +482,7 @@ public function testQueuedAssignmentBatchesRollBackTogetherAndRetryExactlyOnce() $this->setPartition(self::PARTITION_B); $this->assertTrue($user->hasRole($roleB)); - $this->assertTrue($user->hasForbiddenPermission($permissionB)); + $this->assertTrue($user->hasDeniedPermission($permissionB)); } public function testUnsavedSyncReplacesOnlyTheCapturedPartitionQueue(): void @@ -531,7 +531,7 @@ public function testUnsavedRemovalReconcilesOnlyTheCapturedPartitionQueue(): voi $roleB = PartitionedRole::create(['name' => 'member']); $permissionB = PartitionedPermission::create(['name' => 'articles.edit']); $user->assignRole($roleB); - $user->giveForbiddenTo($permissionB); + $user->denyPermissionTo($permissionB); $this->setPartition(self::PARTITION_A); $user->removeRole($roleA); @@ -546,7 +546,7 @@ public function testUnsavedRemovalReconcilesOnlyTheCapturedPartitionQueue(): voi $this->setPartition(self::PARTITION_B); $this->assertTrue($user->hasRole($roleB)); - $this->assertTrue($user->hasForbiddenPermission($permissionB)); + $this->assertTrue($user->hasDeniedPermission($permissionB)); } public function testEmptyAddsStillValidateAPartitionBearingSubject(): void @@ -664,9 +664,9 @@ public function testPermissionSynchronizationRollsBackEveryWriteWhenABatchFails( )); try { - $user->syncPermissionsWithForbidden( + $user->syncPermissionEffects( allowed: [$allowed], - forbidden: [$failing], + denied: [$failing], ); $this->fail('Expected the forced permission assignment failure.'); } catch (QueryException) { @@ -694,14 +694,14 @@ public function testPermissionSynchronizationRollsBackEveryWriteWhenABatchFails( $this->assertTrue($registrar->getCacheRepository()->has($cacheKey)); $this->assertSame($cachedAssignments, $registrar->getCacheRepository()->get($cacheKey)); - $user->syncPermissionsWithForbidden( + $user->syncPermissionEffects( allowed: [$allowed], - forbidden: [$failing], + denied: [$failing], ); $this->assertFalse($user->hasDirectPermission($current)); $this->assertTrue($user->hasDirectPermission($allowed)); - $this->assertTrue($user->hasForbiddenPermission($failing)); + $this->assertTrue($user->hasDeniedPermission($failing)); } public function testApplicationTransactionRollsBackRoleAssignmentWhenTouchingTheRelatedModelsFails(): void diff --git a/tests/Permission/PartitionTeamsTest.php b/tests/Permission/PartitionTeamsTest.php index 9e31f89c6..ec756c1dd 100644 --- a/tests/Permission/PartitionTeamsTest.php +++ b/tests/Permission/PartitionTeamsTest.php @@ -203,7 +203,7 @@ public function testQueuedSynchronizationReplacesOnlyTheCapturedPartitionAndTeam setPermissionsTeamId($teamA2); $user->assignRole($firstRole); - $user->giveForbiddenTo($firstPermission); + $user->denyPermissionTo($firstPermission); setPermissionsTeamId($teamA1); $user->syncRoles($secondRole); @@ -222,7 +222,7 @@ public function testQueuedSynchronizationReplacesOnlyTheCapturedPartitionAndTeam setPermissionsTeamId($teamA2); $this->assertTrue($user->hasRole($firstRole)); - $this->assertTrue($user->hasForbiddenPermission($firstPermission)); + $this->assertTrue($user->hasDeniedPermission($firstPermission)); $this->assertFalse($user->hasRole($secondRole)); $this->assertFalse($user->hasDirectPermission($secondPermission)); } diff --git a/tests/Permission/PartitionTestCase.php b/tests/Permission/PartitionTestCase.php index 01dd4c430..b54389d32 100644 --- a/tests/Permission/PartitionTestCase.php +++ b/tests/Permission/PartitionTestCase.php @@ -161,7 +161,7 @@ private function createPartitionPermissionTables(): void $table->uuid('workspace_id'); $table->uuid('permission_test_id'); $table->uuid('role_test_id'); - $table->boolean('is_forbidden')->default(false); + $table->boolean('is_denied')->default(false); $table->primary(['workspace_id', 'permission_test_id', 'role_test_id']); $table->foreign(['workspace_id', 'permission_test_id']) ->references(['workspace_id', 'id']) @@ -203,7 +203,7 @@ private function createPartitionPermissionTables(): void $table->uuid('permission_test_id'); $table->string('model_type'); $table->uuid('model_test_id'); - $table->boolean('is_forbidden')->default(false); + $table->boolean('is_denied')->default(false); $primary = ['workspace_id']; diff --git a/tests/Permission/PublicApiTest.php b/tests/Permission/PublicApiTest.php index 745f5c738..317f8c4d0 100644 --- a/tests/Permission/PublicApiTest.php +++ b/tests/Permission/PublicApiTest.php @@ -9,12 +9,14 @@ use Hypervel\Permission\Middleware\PermissionMiddleware; use Hypervel\Permission\Middleware\RoleMiddleware; use Hypervel\Permission\Middleware\RoleOrPermissionMiddleware; +use Hypervel\Permission\PermissionRegistrar; use Hypervel\Routing\Router; use Hypervel\Support\Facades\Auth; use Hypervel\Support\Facades\Blade; use Hypervel\Support\Facades\Route; use Hypervel\Tests\Permission\Fixtures\Models\TestRolePermissionsEnum; use Hypervel\View\Compilers\BladeCompiler; +use ReflectionClass; use ReflectionMethod; use ReflectionParameter; @@ -91,10 +93,10 @@ public function testAssignmentMethodsDoNotExposePartitionArguments(): void 'removeRole' => ['role'], 'syncRoles' => ['roles'], 'givePermissionTo' => ['permissions'], - 'giveForbiddenTo' => ['permissions'], + 'denyPermissionTo' => ['permissions'], 'revokePermissionTo' => ['permission'], 'syncPermissions' => ['permissions'], - 'syncPermissionsWithForbidden' => ['allowed', 'forbidden'], + 'syncPermissionEffects' => ['allowed', 'denied'], ]; foreach ($methods as $method => $parameters) { @@ -106,4 +108,22 @@ public function testAssignmentMethodsDoNotExposePartitionArguments(): void ); } } + + public function testRemovedPermissionEffectMethodsDoNotExist(): void + { + $model = new ReflectionClass($this->testUser); + + foreach ([ + 'giveForbiddenTo', + 'hasForbiddenPermission', + 'hasForbiddenPermissionViaRoles', + 'syncPermissionsWithForbidden', + ] as $method) { + $this->assertFalse($model->hasMethod($method)); + } + + $registrar = new ReflectionClass(PermissionRegistrar::class); + + $this->assertFalse($registrar->hasMethod('hasForbiddenRolePermissions')); + } } diff --git a/tests/Permission/SchemaConfigTest.php b/tests/Permission/SchemaConfigTest.php index 4d6bfc1a7..f65d08db1 100644 --- a/tests/Permission/SchemaConfigTest.php +++ b/tests/Permission/SchemaConfigTest.php @@ -38,15 +38,15 @@ public function testCustomMorphAndPivotKeysAreUsedByRelations(): void ]); } - public function testForbiddenPermissionUpdatesExistingCustomKeyAssignmentEdge(): void + public function testDeniedPermissionUpdatesExistingCustomKeyAssignmentEdge(): void { $this->testUser->givePermissionTo('edit-articles'); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->testUser->refresh(); $this->assertSame(1, $this->testUser->permissions()->count()); - $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermission('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); } } diff --git a/tests/Permission/TestCase.php b/tests/Permission/TestCase.php index 645bb9f40..442917ed2 100644 --- a/tests/Permission/TestCase.php +++ b/tests/Permission/TestCase.php @@ -345,7 +345,7 @@ private function recreateCustomPermissionTables(): void $table->uuid($pivotPermission); $table->string('model_type'); $table->unsignedBigInteger($modelMorphKey); - $table->boolean('is_forbidden')->default(false); + $table->boolean('is_denied')->default(false); $table->index([$modelMorphKey, 'model_type'], 'model_has_permissions_model_id_model_type_index'); $table->foreign($pivotPermission) @@ -373,7 +373,7 @@ private function recreateCustomPermissionTables(): void Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($pivotPermission, $pivotRole, $tableNames): void { $table->uuid($pivotPermission); $table->uuid($pivotRole); - $table->boolean('is_forbidden')->default(false); + $table->boolean('is_denied')->default(false); $table->foreign($pivotPermission) ->references('permission_test_id') diff --git a/tests/Permission/Traits/HasPermissionsTest.php b/tests/Permission/Traits/HasPermissionsTest.php index dbf0a14a0..cbd8d9345 100644 --- a/tests/Permission/Traits/HasPermissionsTest.php +++ b/tests/Permission/Traits/HasPermissionsTest.php @@ -596,11 +596,11 @@ public function testQueuedSyncPermissionsReplacesEarlierQueuedPermissionAssignme $this->assertFalse($user->hasPermissionTo('edit-news')); } - public function testQueuedSyncPermissionsReplacesEarlierQueuedForbiddenPermissionAssignment(): void + public function testQueuedSyncPermissionsReplacesEarlierQueuedDeniedPermissionAssignment(): void { - $user = new User(['email' => 'queued-sync-forbidden@example.com']); + $user = new User(['email' => 'queued-sync-denied@example.com']); - $user->giveForbiddenTo('edit-articles'); + $user->denyPermissionTo('edit-articles'); $user->syncPermissions('edit-articles'); $user->save(); @@ -608,7 +608,7 @@ public function testQueuedSyncPermissionsReplacesEarlierQueuedForbiddenPermissio $this->assertSame(1, $user->permissions()->count()); $this->assertTrue($user->hasPermissionTo('edit-articles')); - $this->assertFalse($user->hasForbiddenPermission('edit-articles')); + $this->assertFalse($user->hasDeniedPermission('edit-articles')); } public function testItDoesNotRunUnnecessarySqlWhenAssigningNewPermissions(): void diff --git a/tests/Permission/Traits/TeamHasPermissionsTest.php b/tests/Permission/Traits/TeamHasPermissionsTest.php index 39e67538d..8578d3d5f 100644 --- a/tests/Permission/Traits/TeamHasPermissionsTest.php +++ b/tests/Permission/Traits/TeamHasPermissionsTest.php @@ -144,11 +144,11 @@ public function testItCanScopeUsersOnDifferentTeams(): void $this->assertCount(0, User::permission('edit-news')->get()); } - public function testForbiddenPermissionFlipsExistingAllowedPermissionForCurrentTeamOnly(): void + public function testDeniedPermissionFlipsExistingAllowedPermissionForCurrentTeamOnly(): void { setPermissionsTeamId(1); $this->testUser->givePermissionTo('edit-articles'); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); setPermissionsTeamId(2); $this->testUser->givePermissionTo('edit-articles'); @@ -156,36 +156,36 @@ public function testForbiddenPermissionFlipsExistingAllowedPermissionForCurrentT setPermissionsTeamId(1); $this->testUser->unsetRelation('permissions'); $this->assertSame(1, $this->testUser->permissions()->count()); - $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermission('edit-articles')); $this->assertSame([], $this->testUser->getPermissionNames()->all()); setPermissionsTeamId(2); $this->testUser->unsetRelation('permissions'); $this->assertSame(1, $this->testUser->permissions()->count()); - $this->assertFalse($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertFalse($this->testUser->hasDeniedPermission('edit-articles')); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); $this->assertSame(['edit-articles'], $this->testUser->getPermissionNames()->all()); } - public function testAllowedPermissionFlipsExistingForbiddenPermissionForCurrentTeamOnly(): void + public function testAllowedPermissionFlipsExistingDeniedPermissionForCurrentTeamOnly(): void { setPermissionsTeamId(1); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); $this->testUser->givePermissionTo('edit-articles'); setPermissionsTeamId(2); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); setPermissionsTeamId(1); $this->testUser->unsetRelation('permissions'); $this->assertSame(1, $this->testUser->permissions()->count()); - $this->assertFalse($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertFalse($this->testUser->hasDeniedPermission('edit-articles')); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); setPermissionsTeamId(2); $this->testUser->unsetRelation('permissions'); $this->assertSame(1, $this->testUser->permissions()->count()); - $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermission('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); } @@ -236,9 +236,9 @@ public function testQueuedSyncPermissionsReplacesOnlyCurrentTeamPermissionAssign $this->assertSame(['edit-articles'], $user->getPermissionNames()->all()); } - public function testQueuedForbiddenSyncReplacesOnlyCurrentTeamPermissionAssignments(): void + public function testQueuedDeniedSyncReplacesOnlyCurrentTeamPermissionAssignments(): void { - $user = new User(['email' => 'queued-team-forbidden-sync@example.com']); + $user = new User(['email' => 'queued-team-denied-sync@example.com']); setPermissionsTeamId(1); $user->givePermissionTo('edit-news'); @@ -247,9 +247,9 @@ public function testQueuedForbiddenSyncReplacesOnlyCurrentTeamPermissionAssignme $user->givePermissionTo('edit-articles'); setPermissionsTeamId(1); - $changes = $user->syncPermissionsWithForbidden( + $changes = $user->syncPermissionEffects( allowed: ['edit-blog'], - forbidden: ['edit-articles'], + denied: ['edit-articles'], ); $this->assertSame(['attached' => [], 'detached' => [], 'updated' => []], $changes); @@ -259,39 +259,39 @@ public function testQueuedForbiddenSyncReplacesOnlyCurrentTeamPermissionAssignme setPermissionsTeamId(1); $user->unsetRelation('permissions'); $this->assertSame(['edit-blog'], $user->getPermissionNames()->all()); - $this->assertTrue($user->hasForbiddenPermission('edit-articles')); + $this->assertTrue($user->hasDeniedPermission('edit-articles')); $this->assertFalse($user->hasPermissionTo('edit-news')); setPermissionsTeamId(2); $user->unsetRelation('permissions'); $this->assertSame(['edit-articles'], $user->getPermissionNames()->all()); - $this->assertFalse($user->hasForbiddenPermission('edit-articles')); + $this->assertFalse($user->hasDeniedPermission('edit-articles')); } - public function testItRevokesForbiddenPermissionsForCurrentTeamOnly(): void + public function testItRevokesDeniedPermissionsForCurrentTeamOnly(): void { setPermissionsTeamId(1); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); setPermissionsTeamId(2); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); setPermissionsTeamId(1); $this->testUser->revokePermissionTo('edit-articles'); $this->testUser->unsetRelation('permissions'); $this->assertSame(0, $this->testUser->permissions()->count()); - $this->assertFalse($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertFalse($this->testUser->hasDeniedPermission('edit-articles')); setPermissionsTeamId(2); $this->testUser->unsetRelation('permissions'); $this->assertSame(1, $this->testUser->permissions()->count()); - $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDeniedPermission('edit-articles')); } public function testPermissionScopeUsesDirectPermissionEffectForCurrentTeamOnly(): void { setPermissionsTeamId(1); - $this->testUser->giveForbiddenTo('edit-articles'); + $this->testUser->denyPermissionTo('edit-articles'); setPermissionsTeamId(2); $this->testUser->givePermissionTo('edit-articles');