Skip to content

feat(core): native permission engine foundation (Phase 0) + console addCommand fix#3461

Merged
marcelfolaron merged 1 commit into
masterfrom
feature/native-permission-engine
Jun 2, 2026
Merged

feat(core): native permission engine foundation (Phase 0) + console addCommand fix#3461
marcelfolaron merged 1 commit into
masterfrom
feature/native-permission-engine

Conversation

@marcelfolaron

@marcelfolaron marcelfolaron commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Phase 0 (foundation) of the service-layer / permission overhaul: a native, DB-backed permission engine (no Spatie, no Eloquent) establishing one domain.action vocabulary spoken by JSON-RPC, controllers, and the service layer, plus a thin service contract.

Additive and inert by default. Enforcement is attribute-driven and audit / log-only until a method carries #[RequiresPermission] (none do yet). It changes no runtime authorization behavior on its own — it's the scaffolding the per-domain rollout (Phase 1+) builds on. Also includes a separable console bugfix (below).

Design

Permission vocabulary vs. assignment are separated (Spatie-style).

  • A domain's permission class declares only its verbsTicketsPermissions: view/comment/upload/create/edit/delete. No role information.
  • Core/Auth/Permissions/DefaultRolePermissions is the single central place the six built-in roles' default grants live (matched against the catalog by scope + verb, unioned up the hierarchy). After install, zp_role_permissions / the future admin UI own assignments — domains are never touched to re-assign.

Engine — app/Core/Auth/, wired by a dedicated PermissionServiceProvider.

  • RoleResolver — effective-role resolution incl. project-scoped (mirrors Tickets::userIsAtLeastForProject), so authz resolves against the entity's project, not the session project.
  • PermissionServicecurrentUserCan()/authorize(); cached role→permission map; capability kept distinct from project data-access.
  • All dependencies are constructor-injected — no app() in engine classes. The engine depends on a narrow ChecksProjectAccess contract (Core), implemented by Projects and bound in the provider.

Enforcement covers both routing paths, with no base-class coupling.

  • #[RequiresPermission] is read by a shared PermissionEnforcer, injected into Frontcontroller::executeAction (legacy convention path) and applied to all native Laravel routes via a CheckPermissions middleware wired in RouteLoader. JSON-RPC enforces it in Jsonrpc (enforcer injected via init()). Denial throws the existing AuthorizationException → 403 web / RPC -32001.

Service contract. DomainService → thin marker; new BaseService (authorize/can/validate). BaseService receives PermissionService via the provider's afterResolving hook — zero subclass constructor boilerplate, no app().

Templates. @can('tickets.edit', $projectId) works via a Gate::before bridge; a can() helper covers legacy .tpl.php.

Schema. zp_roles / zp_permissions / zp_role_permissions via SchemaBuilder + idempotent Install::update_sql_30505 + dbVersion 3.5.5. User→role stays on zp_user.role (no data migration); zp_roles.level preserves the legacy hierarchy.

Console bugfix (separable — affects ALL #[AsCommand] commands)

symfony/console 7.4 renamed add()addCommand() and routes lazy #[AsCommand] loading through addCommand(); laravel/framework 11.45 only overrides the deprecated add() (where the sole setLaravel() lives). Every lazily-loaded command (cache:clearAll, db:migrate, permissions:sync) crashed with make() on null. Fixed by overriding addCommand() in Core/Console/Application.

Verification

  • ✅ Pint + PHPStan (level 0) green across all 28 changed files.
  • ✅ App boots; db:migrate runs update_sql_30505; permissions:sync --seed works.
  • ✅ Clean reseed (truncate → seed) via DefaultRolePermissions produces the documented matrix from scratch: 6 roles (levels 5→50), 6 permissions, 28 grants; tickets.view → all roles, tickets.create/delete → editor+.

Notes for reviewers

  • Migration is 30505 (after origin/master's 30504; 30503 was intentionally burned by the device-tokens work).
  • BaseService adoption across existing services is a mechanical follow-up sweep (single-inheritance conflicts, e.g. Canvas variants, handled there); this PR establishes the base + DI.
  • The console fix is logically independent — happy to split into its own PR.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings June 2, 2026 02:49
@marcelfolaron
marcelfolaron requested a review from a team as a code owner June 2, 2026 02:49
@marcelfolaron
marcelfolaron requested review from broskees and removed request for a team June 2, 2026 02:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds the Phase 0 foundation for a native, DB-backed permission engine (shared domain.action vocabulary across controllers + JSON-RPC + service layer) and fixes Symfony Console 7.4 lazy command registration so #[AsCommand] commands receive the Laravel container.

Changes:

  • Introduces Core permission infrastructure (registry/discovery, repository, seeder, runtime service, attribute + enforcer) and a Tickets pilot permission catalog.
  • Adds install-time schema + idempotent migration for zp_roles, zp_permissions, zp_role_permissions, and bumps dbVersion to 3.5.5.
  • Bridges Laravel Gate (Gate::before) and wires centralized enforcement hooks in controller base classes + JSON-RPC dispatcher; fixes CLI command registration via addCommand() override.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
app/Domain/Tickets/Permissions/TicketsPermissions.php Declares initial tickets.* permission vocabulary and default built-in role grants.
app/Domain/Install/Services/SchemaBuilder.php Creates new permission engine tables on fresh installs.
app/Domain/Install/Repositories/Install.php Adds migration 30505 to create tables + sync/seed permissions (idempotent).
app/Domain/Api/Controllers/Jsonrpc.php Enforces #[RequiresPermission] before invoking JSON-RPC service methods.
app/Core/Domains/DomainService.php Converts the unused CRUD interface into a marker interface.
app/Core/Domains/BaseService.php Adds shared authorize()/can()/validate() helpers for service-layer adoption.
app/Core/Controller/HtmxController.php Centralized permission enforcement hook before controller actions.
app/Core/Controller/Controller.php Centralized permission enforcement hook before controller actions.
app/Core/Console/Application.php Fixes Symfony 7.4 lazy #[AsCommand] loading by injecting Laravel container in addCommand().
app/Core/Configuration/AppSettings.php Bumps dbVersion to 3.5.5.
app/Core/Auth/RoleResolver.php Adds shared effective-role resolution, including project-scoped resolution.
app/Core/Auth/Permissions/RequiresPermission.php Adds attribute used to declare required permission for methods.
app/Core/Auth/Permissions/ProvidesPermissions.php Defines contract for domain/plugin permission catalogs.
app/Core/Auth/Permissions/PermissionService.php Implements runtime permission checks + cache, plus authorize helper.
app/Core/Auth/Permissions/PermissionSeeder.php Syncs discovered vocabulary into DB and seeds built-in roles/grants.
app/Core/Auth/Permissions/PermissionRepository.php Query-builder repository for roles/permissions/grants CRUD.
app/Core/Auth/Permissions/PermissionRegistry.php Discovers domain/plugin permission providers and builds catalog.
app/Core/Auth/Permissions/PermissionEnforcer.php Reads #[RequiresPermission] and enforces (audit vs block).
app/Core/Auth/Permissions/Permission.php Immutable value object describing a capability and its defaults.
app/Core/Auth/AuthenticationServiceProvider.php Registers permission services + Gate bridge to permission engine.
app/Command/SyncPermissionsCommand.php Adds permissions:sync command (sync/prune/seed).

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

Comment on lines +164 to +166
$projectId = isset($arguments[0]) ? (int) $arguments[0] : null;

return $permissions->currentUserCan($ability, $projectId);
Comment on lines +85 to +90
public function authorize(string $permissionKey, ?int $projectId = null, ?bool $forceGlobal = null): void
{
if (! $this->currentUserCan($permissionKey, $projectId, $forceGlobal)) {
throw new AuthorizationException("Not authorized: {$permissionKey}");
}
}
Comment on lines +59 to +64

return;
}

throw new AuthorizationException("Not authorized: {$attribute->permission}");
}
Comment on lines +38 to +42
protected function configure(): void
{
$this->addOption('seed', null, InputOption::VALUE_NONE, 'Also (re)grant the built-in roles their default permissions');
$this->addOption('prune', null, InputOption::VALUE_NONE, 'Remove permissions that are no longer declared in code');
}
@marcelfolaron
marcelfolaron force-pushed the feature/native-permission-engine branch from 96feb71 to 76351ca Compare June 2, 2026 03:30
@marcelfolaron

Copy link
Copy Markdown
Collaborator Author

Reworked per review — thanks, these were all on point. Mapping each:

  1. No app() / use a service provider. Added a dedicated PermissionServiceProvider; the engine is now constructor-injected throughout (engine depends on a narrow ChecksProjectAccess contract that Projects implements, bound in the provider). The only remaining app() is in the global can() helper and the install migration — both legitimately container-resolution sites, not class deps.

  2. Base controller isn't universal (Blueprints native). Dropped the Controller/HtmxController::callAction hooks. Enforcement now lives at layers both routing paths share: the enforcer is injected into Frontcontroller::executeAction (legacy convention path) and a CheckPermissions middleware applied to all native Laravel routes via RouteLoader. JSON-RPC enforces in Jsonrpc.

  3. ALL_ROLES / COMMENTER_AND_ABOVE in the domain made no sense — how does Spatie do it? Agreed; that conflated vocabulary with assignment. Fixed exactly like Spatie: a domain's permission class now declares only its verbs (tickets.view/create/...), with no role info. Built-in role defaults live in one central DefaultRolePermissions; runtime assignments are owned by zp_role_permissions (the future admin UI edits this, never domain code).

  4. Templates. @can('tickets.edit', $projectId) works in Blade via the Gate::before bridge; added a can() helper for legacy .tpl.php.

Verified end-to-end: a clean truncate→reseed via the new central matcher reproduces the documented role matrix exactly (28 grants). Pint + PHPStan green on all 28 files.

Copilot AI review requested due to automatic review settings June 2, 2026 03:41
@marcelfolaron
marcelfolaron force-pushed the feature/native-permission-engine branch from 76351ca to 39f7892 Compare June 2, 2026 03:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 4 comments.

Comment thread app/Core/Console/Application.php Outdated
Comment on lines +49 to +50
#[\Override]
public function addCommand(callable|SymfonyCommand $command): ?SymfonyCommand
Comment on lines +12 to +16
* - Web/HTMX: read centrally in {@see \Leantime\Core\Controller\Controller::callAction()}
* and {@see \Leantime\Core\Controller\HtmxController::callAction()} before the action runs.
* - JSON-RPC: read in {@see \Leantime\Domain\Api\Controllers\Jsonrpc::executeApiRequest()}
* on the resolved service method (RPC bypasses the controller gate, so this is what
* secures it).
Comment on lines +13 to +16
* Shared by the three entry points so the declaration means the same everywhere:
* - {@see \Leantime\Core\Controller\Controller::callAction()} / HtmxController (web/HTMX),
* - {@see \Leantime\Domain\Api\Controllers\Jsonrpc::executeApiRequest()} (JSON-RPC).
*
Comment on lines 991 to 995
@@ -993,14 +994,14 @@ public function getClientsFromProjectList(array $projects): array
*
* @api
…ddCommand fix

Establishes one `domain.action` permission vocabulary spoken by JSON-RPC,
controllers, and the service layer, backed by a native DB-backed engine
(no Spatie, no Eloquent). Additive and inert by default: enforcement is
attribute-driven and audit/log-only until a method is annotated, so this
PR changes no runtime authorization behavior on its own.

Engine (app/Core/Auth, registered via a dedicated PermissionServiceProvider):
- RoleResolver — effective-role resolution incl. project-scoped (mirrors
  Tickets::userIsAtLeastForProject), so authorization resolves against the
  entity's project, not the session project.
- Permissions/PermissionService — currentUserCan()/authorize(); cached
  role->permission map; capability kept distinct from project data-access.
- PermissionRepository (query builder, no ORM); PermissionRegistry
  (auto-discovers Domain/*/Permissions, like register.php).
- All dependencies are constructor-injected — no app() helper in engine
  classes. The engine depends on a narrow ChecksProjectAccess contract
  (Core), implemented by the Projects service and bound in the provider.

Vocabulary + role assignment (separated, Spatie-style):
- A domain's permission class declares only its VERBS (TicketsPermissions:
  view/comment/upload/create/edit/delete) — no role information.
- DefaultRolePermissions is the single central place the six built-in roles'
  default grants are defined (matched against the catalog by scope + verb,
  unioned up the hierarchy). After install, zp_role_permissions / the future
  admin UI own assignments; domains are never touched to re-assign.

Enforcement (covers both routing paths, no base-class coupling):
- #[RequiresPermission] read by a shared PermissionEnforcer, injected into
  Frontcontroller::executeAction (legacy convention path) and applied to all
  native Laravel routes via a CheckPermissions middleware wired in RouteLoader.
  JSON-RPC enforces it in Jsonrpc (enforcer injected via init()). Denial throws
  the existing AuthorizationException (403 web / RPC -32001).

Service contract:
- DomainService rewritten from the dead CRUD interface to a thin marker; new
  BaseService (authorize/can/validate helpers). BaseService gets its
  PermissionService via the provider's afterResolving hook — zero subclass
  constructor boilerplate, no app().

Templates: @can('domain.action'[, $projectId]) works via a Gate::before bridge;
a can() helper covers legacy .tpl.php.

Schema: zp_roles / zp_permissions / zp_role_permissions via SchemaBuilder +
idempotent Install::update_sql_30505 + dbVersion 3.5.5. User->role stays on
zp_user.role (no data migration); zp_roles.level preserves the legacy hierarchy.

Console fix (separable bug): symfony/console 7.4 renamed add() to addCommand()
and routes lazy #[AsCommand] loading through addCommand(), which
laravel/framework 11.45 does not override (setLaravel() lives only in add()).
Every #[AsCommand] command crashed with "make() on null". Overriding
addCommand() in Core/Console/Application injects the container. Fixes
cache:clearAll, db:migrate, permissions:sync, etc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@marcelfolaron
marcelfolaron force-pushed the feature/native-permission-engine branch from 39f7892 to eccbf33 Compare June 2, 2026 14:22
@marcelfolaron

Copy link
Copy Markdown
Collaborator Author

Thanks @copilot — addressed all of these in the latest push:

  • Gate bridge int-casting (PermissionServiceProvider): the first gate argument is now only treated as a project id when is_numeric(), otherwise null (falls back to session scope) — so a model/object passed to @can/Gate::allows no longer becomes a bogus projectId.
  • Leaking the permission key in exception messages (PermissionService::authorize, PermissionEnforcer): both now throw a generic AuthorizationException (default message). The permission key is written to the server log for audit/debug only, never to the client.
  • SyncPermissionsCommand::configure(): now calls parent::configure() first.
  • #[\Override] on PHP 8.2 (Core/Console/Application): removed the attribute (it was the only usage in app/, and the project supports 8.2).
  • Stale entry-point docblocks (RequiresPermission, PermissionEnforcer): updated to reflect the real wiring — Frontcontroller::executeAction (legacy routes), CheckPermissions middleware (native routes), and Jsonrpc::executeApiRequest (RPC); no longer claims Controller::callAction.
  • Projects::getProjectRole() docblock: updated to int $userId, int $projectId / @return string.

Verified locally: Pint + PHPStan clean, JsonrpcTest 7/7, and permissions:sync still runs.

@marcelfolaron
marcelfolaron merged commit 95c32dd into master Jun 2, 2026
12 checks passed
@marcelfolaron
marcelfolaron deleted the feature/native-permission-engine branch June 2, 2026 16:56
marcelfolaron added a commit that referenced this pull request Jun 2, 2026
… engine

Wires the native permission engine through two domains end-to-end, proving one
`domain.action` vocabulary across JSON-RPC, controllers, services, and the UI.
Stacked on the engine foundation (#3461). Enforcement is on by default
(LEAN_PERMISSIONS_ENFORCE); set it false for audit/log-only.

Comments (ownership + RPC):
- CommentsPermissions verbs: view / create / moderate. `moderate` is a distinct
  verb so editing someone else's comment stays manager+ (the matrix would seed a
  comments.edit at editor+, which would over-grant).
- Comments extends BaseService. addComment now authorizes comments.create (was
  access-only, so any assigned role could comment over RPC). canModifyComment is
  author OR comments.moderate — exact author-or-manager+ behavior preserved.

Tickets (RPC + controller):
- Tickets extends BaseService. @api methods migrated to in-method
  $this->authorize('tickets.*', $entityProjectId): patchTicket (hardened from a
  session-scoped role check to the ticket's project role), and addTicket / delete /
  deleteMilestone (which had NO role check — closes RPC holes; matches the
  documented "editor+ creates/deletes" rule).
- Controllers DelTicket / DelMilestone / NewTicket / MoveTicket replace
  Auth::authOrRedirect with #[RequiresPermission] (tickets.delete/create/edit).

Role assignment stays central (no role info in domains): DefaultRolePermissions
gains explicit-key grants so commenters can create comments (the one case the
verb convention doesn't fit).

UI: representative templates migrated to @can / can() — the comment edit/delete
buttons (`(author) || can('comments.moderate')`), the new-todo button
(`can('tickets.create')`), and the kanban edit gate. Remaining template checks
still work via the legacy $login helper (mechanical follow-up).

Config + tests: LEAN_PERMISSIONS_ENFORCE (DefaultConfig + sample.env);
DefaultRolePermissionsTest locks the built-in role→permission matrix (grant-
equivalence guard); TicketsServiceTest patch-denial updated for the engine.
Verified: full unit suite green (409), pint + phpstan clean, and a live
truncate→seed reproduces the matrix (comments.create→commenter+,
comments.moderate→manager+).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
marcelfolaron added a commit that referenced this pull request Jun 2, 2026
… engine

Wires the native permission engine through two domains end-to-end, proving one
`domain.action` vocabulary across JSON-RPC, controllers, services, and the UI.
Stacked on the engine foundation (#3461). Enforcement is on by default
(LEAN_PERMISSIONS_ENFORCE); set it false for audit/log-only.

Comments (ownership + RPC):
- CommentsPermissions verbs: view / create / moderate. `moderate` is a distinct
  verb so editing someone else's comment stays manager+ (the matrix would seed a
  comments.edit at editor+, which would over-grant).
- Comments extends BaseService. addComment now authorizes comments.create (was
  access-only, so any assigned role could comment over RPC). canModifyComment is
  author OR comments.moderate — exact author-or-manager+ behavior preserved.

Tickets (RPC + controller):
- Tickets extends BaseService. @api methods migrated to in-method
  $this->authorize('tickets.*', $entityProjectId): patchTicket (hardened from a
  session-scoped role check to the ticket's project role), and addTicket / delete /
  deleteMilestone (which had NO role check — closes RPC holes; matches the
  documented "editor+ creates/deletes" rule).
- Controllers DelTicket / DelMilestone / NewTicket / MoveTicket replace
  Auth::authOrRedirect with #[RequiresPermission] (tickets.delete/create/edit).

Role assignment stays central (no role info in domains): DefaultRolePermissions
gains explicit-key grants so commenters can create comments (the one case the
verb convention doesn't fit).

UI: representative templates migrated to @can / can() — the comment edit/delete
buttons (`(author) || can('comments.moderate')`), the new-todo button
(`can('tickets.create')`), and the kanban edit gate. Remaining template checks
still work via the legacy $login helper (mechanical follow-up).

Config + tests: LEAN_PERMISSIONS_ENFORCE (DefaultConfig + sample.env);
DefaultRolePermissionsTest locks the built-in role→permission matrix (grant-
equivalence guard); TicketsServiceTest patch-denial updated for the engine.
Verified: full unit suite green (409), pint + phpstan clean, and a live
truncate→seed reproduces the matrix (comments.create→commenter+,
comments.moderate→manager+).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
marcelfolaron added a commit that referenced this pull request Jun 2, 2026
…api coverage (#3469)

* feat(permissions): Phase 1 pilot — enforce Tickets + Comments via the engine

Wires the native permission engine through two domains end-to-end, proving one
`domain.action` vocabulary across JSON-RPC, controllers, services, and the UI.
Stacked on the engine foundation (#3461). Enforcement is on by default
(LEAN_PERMISSIONS_ENFORCE); set it false for audit/log-only.

Comments (ownership + RPC):
- CommentsPermissions verbs: view / create / moderate. `moderate` is a distinct
  verb so editing someone else's comment stays manager+ (the matrix would seed a
  comments.edit at editor+, which would over-grant).
- Comments extends BaseService. addComment now authorizes comments.create (was
  access-only, so any assigned role could comment over RPC). canModifyComment is
  author OR comments.moderate — exact author-or-manager+ behavior preserved.

Tickets (RPC + controller):
- Tickets extends BaseService. @api methods migrated to in-method
  $this->authorize('tickets.*', $entityProjectId): patchTicket (hardened from a
  session-scoped role check to the ticket's project role), and addTicket / delete /
  deleteMilestone (which had NO role check — closes RPC holes; matches the
  documented "editor+ creates/deletes" rule).
- Controllers DelTicket / DelMilestone / NewTicket / MoveTicket replace
  Auth::authOrRedirect with #[RequiresPermission] (tickets.delete/create/edit).

Role assignment stays central (no role info in domains): DefaultRolePermissions
gains explicit-key grants so commenters can create comments (the one case the
verb convention doesn't fit).

UI: representative templates migrated to @can / can() — the comment edit/delete
buttons (`(author) || can('comments.moderate')`), the new-todo button
(`can('tickets.create')`), and the kanban edit gate. Remaining template checks
still work via the legacy $login helper (mechanical follow-up).

Config + tests: LEAN_PERMISSIONS_ENFORCE (DefaultConfig + sample.env);
DefaultRolePermissionsTest locks the built-in role→permission matrix (grant-
equivalence guard); TicketsServiceTest patch-denial updated for the engine.
Verified: full unit suite green (409), pint + phpstan clean, and a live
truncate→seed reproduces the matrix (comments.create→commenter+,
comments.moderate→manager+).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(permissions): full @api coverage on Tickets + Comments services

Make the declarative #[RequiresPermission] attribute the standard on every
@api service method (the JSON-RPC surface), closing the gap where only a few
methods were gated.

Enforcer/attribute:
- RequiresPermission gains `global` (company-wide capability — check the global
  role, not a project) and `entityScoped` (project comes from an entity the
  method loads; the enforcer defers and the method self-authorizes in its body).
- PermissionEnforcer honors both modes: entityScoped returns without a check;
  global calls currentUserCan(perm, null, forceGlobal: true).

Tickets (Domain/Tickets/Services/Tickets):
- Closed 9 ungated mutator RPC holes with in-method authorize(): quickAddTicket,
  quickAddMilestone, quickAddTicketFromKanban, createMilestoneFromDialog,
  upsertSubtask, addMilestoneComment, saveStatusLabels, markTicketReopen,
  moveTicket. Project resolved from the param/loaded entity, not the session.
- Added entityScoped coverage attributes to the already-gated mutators
  (addTicket, updateTicket, patchTicket, quick/updateMilestone*, sortTickets,
  updateTicketStatusAndSorting, delete, deleteMilestone).
- Attributed every @api read with tickets.view (projectIdParam where the project
  is a clean top-level argument, session-scoped otherwise).
- De-@api'd 9 cron/helper methods that are not user actions (getNewMilestone,
  normalizeRoadmapParams, getMilestonesOverviewSearchCriteria, prepareTicketDates,
  pollFor{New,Updated}Account{Milestones,Todos}); fixed a duplicate @api tag.

Comments: getComments/getCommentReactions -> comments.view; addComment +
edit/deleteComment carry entityScoped markers (they self-authorize via the
project / canModifyComment); toggleCommentReaction -> comments.create;
pollComments de-@api'd (cron/poll helper).

Tests:
- New PermissionEnforcerTest locks the four resolution modes (entityScoped defers,
  global forces the company role, projectIdParam reads the named arg, default
  falls back to the session project) + the no-attribute no-op.
- TicketsServiceTest: quickAddTicket denial proves a newly-gated leaf mutator
  throws via the engine.
- JsonrpcTest repointed from the (now de-@api'd) pollComments fixture to the
  stable @api getComments read.

Full unit suite green (415 tests, 990 assertions); pint + phpstan clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(permissions): keep poll* methods @api as gated reads (acceptance fix)

The earlier pass bucketed the poll* methods in with genuine helpers and
de-@api'd them. They are actually legitimate public API reads (mobile account
polling) — both the unit JsonrpcTest and the acceptance ApiCest use
leantime.rpc.Comments.pollComments as their canonical RPC fixture, so dropping
it from the API surface returned -32601 and failed ApiCest::testJsonRpcEndpoint
(+ StringId).

Restore @api on the 5 poll methods and gate them as reads instead:
- Comments::pollComments -> comments.view (projectIdParam: 'projectId')
- Tickets::pollFor{New,Updated}Account{Milestones,Todos} -> tickets.view
  (projectIdParam: 'projectId')

Only the 4 genuine internal helpers stay de-@api'd (getNewMilestone,
normalizeRoadmapParams, getMilestonesOverviewSearchCriteria, prepareTicketDates).
JsonrpcTest reverted to its original pollComments fixture (valid again).

Unit suite green (415 tests, 990 assertions); pint + phpstan clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@marcelfolaron marcelfolaron mentioned this pull request Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants