Appearance
Permissions Decisions
Distilled from 6 DECISIONS.md files. Each entry is a real fork in the road — what we chose, what we passed on, and why. Implementation details that aren't a tradeoff are NOT here (read the codebase for those).
Role IDs: auto-incrementing integers, not ULIDs
- Chose: Auto-increment integer primary key on
roles(consistent with User, Team, Project, Issue, etc.). - Rejected: Keep the already-implemented ULIDs (globally unique, time-sortable).
- Why: Every other model uses int IDs — ULIDs forced ugly URLs, frontend
String()coercion, and gave no distributed-ID benefit in a single-tenant-per-DB architecture. Migrations were undeployed, so modify in place. - Source: KD-0315
Permission gating: replace isAdmin/requiresAdmin with can() everywhere
- Chose: All 15 component checks, all
requiresAdminroute metas, and all ownership hybrid checks migrated tocan(resource, action). - Rejected: Keep
isAdminfor admin-only features (simple, but defeats the permission system). - Why: Custom roles with the right permissions can now access features without being full admins. Half-migrating leaves two systems running in parallel.
- Source: KD-0315
Resource enum slot 13: drop AppSettings, add Roles
- Chose: Remove the synthetic
AppSettingsresource (catch-all that mapped to no real CRUD entity); addRolesat slot 13. Total stays at 15. - Rejected: Hide AppSettings in the form but keep the backend (leaves dead code); remove AppSettings and keep 14 resources (Roles still piggybacks on Users).
- Why: Pre-production, so a clean swap with no migration headaches.
RolePolicynow checks theRolesresource directly instead of overloading Users. - Source: KD-0315
Always-readable resources: 12 forced read, 4 toggleable
- Chose: Force
can_read=trueon 12 project-scoped + tenant-wide resources. Keep toggleable read only on 4 sensitive ones: SystemPrompts, ProjectTokens, Roles, AppSettings. - Rejected: Force read on the core 3 only (Projects/Issues/Teams) — minimum to prevent sidebar breakage; guard navbar with
can()and handle 403 gracefully. - Why: Project membership is the real read gate for project-scoped resources — per-resource read toggles are redundant and only break things (silent navbar failures). Sensitive resources keep the toggle because exposing AI instructions or API credentials must remain explicit.
- Source: KD-0315
Read-when-update auto-grant
- Chose: Auto-grant
readwheneverupdateordeleteis set. Frontend ticks the checkbox on toggle; backend normalizes before save. - Rejected: Reject as 422 (force explicit read); frontend-only auto-check.
- Why: A role with update-but-not-read is logically impossible — better to silently fix it than throw friction at users. Backend normalization closes the API-bypass hole.
- Source: KD-0315
Project owner bypass: ALL project-scoped resources, not just settings
- Chose: Project owner bypasses permission checks for all 12 project-scoped resources (Projects, Sprints, Epics, Lanes, Issues, etc.). Tenant-scoped (Users, Roles, Teams, AppSettings) NOT bypassed.
- Rejected: Owner only bypasses for project settings (sprints/epics still gated by role); owner gets implicit admin role per project.
- Why: "My project, my rules" is the simpler mental model — single early-return in
CheckPermission. Restricting owners by role would let a restrictive role lock owners out of their own sprints. - Source: KD-0315 D17
Project settings access: normal Projects.Update, not owner-only
- Chose: Project settings page uses standard
Projects.Updatepermission. No specialisProjectSettingsgate. - Rejected: Wire the existing
isProjectSettingsflag to restrict to owner+admin (per original design doc). - Why: Restricting Projects.Update holders from editing project settings defeats the permission system's purpose. Owner bypass (above) covers owner access automatically.
isProjectSettingsparameter becomes dead code. - Source: KD-0315 D18
Ownership transfer: dedicated policy ability, not generic Projects.Update
- Chose: New
transferOwnership(User, Project): boolability onProjectPolicy, owner-or-admin only. - Rejected: Reuse the existing
isProjectSettingsflag for the transfer endpoint. - Why: Anyone with
Projects.Updateshould NOT also be able to transfer ownership — that's too permissive. NamingisProjectSettingsfor ownership transfer is misleading. Dedicated ability keeps intent clear. - Source: KD-0315 D19
Ownership transfer scope: project members only
- Chose: Both layers: frontend modal lists only current project members; backend validates
new_owner_idexists inproject_userfor that project. - Rejected: Trust frontend filter; rely only on
exists:users,id(allows transfer to non-members). - Why: Original implementation passed all tenant users with only generic existence validation. Defense-in-depth with the project-scoped
Rule::exists. - Source: KD-0315 D20
Frontend owner bypass API: new canInProject(), don't change can() signature
- Chose: New
canInProject(resource, action, projectOwnerId, entityOwnerId?)exported alongsidecan(). - Rejected: Add
projectOwnerIdtocan()(changes every existing call site);useProjectPermission(project)composable. - Why: Additive — existing calls unchanged.
usePermissionis initialized once in auth state, not per-component, so a composable doesn't fit. - Source: KD-0315 D21
IDOR fix: custom validation rule, not Gate or controller-level authorize
- Chose: New
UserHasProjectAccessrule in FormRequests usingProjectBuilder::accessibleBy($user)->pluck('id')to batch-check all project IDs in one query. - Rejected:
Gate::authorize('access', Project::find($id))in the Action;$this->authorize()in the controller. - Why: Action-level Gate violates ADR-0011 (Actions shouldn't depend on HTTP context). Controller-level breaks the convention that controllers only delegate to Actions. Validation rule keeps the logic in the request layer with a clear 422 response.
- Source: KD-0340 D1
NotificationPolicy: remove admin bypass entirely
- Chose: Remove
before()fromNotificationPolicy. - Rejected: Keep + document the bypass as intentional; keep + add a test codifying it.
- Why: Notifications are personal data — admins should not be able to mark other users' notifications as read via API. This is the only policy where the admin bypass pattern is semantically wrong.
- Source: KD-0340 D6
Global search: scope exists rules to accessible projects
- Chose:
GlobalSearchIssuesRequestvalidateslane_id/sprint_id/epic_idagainstProjectBuilder::accessibleBy($user)->pluck('id'). - Rejected: Trust the existing unscoped
Rule::exists(200 vs 422 response code reveals which IDs exist across all projects). - Why: Closes the cross-project enumeration leak. One extra query per search is acceptable.
- Source: KD-0340 D7
Teams visibility: frontend filter only, NOT backend scopeQuery
- Chose:
Overview.vueadds acomputedfilteringallTeamsby membership or admin./api/teamscontract preserved. - Rejected: v1's plan to add
TeamPolicy::scopeQuery+CheckPermission::canManageTeamsand scope the API; query-param?scope=mine. - Why: Backend scoping silently breaks assignee dropdowns on multi-team projects (a Member on team A viewing a project assigned A+B+C would lose every assignee reachable only via B or C — including admins). The ticket is a UX complaint, not data leakage. Client-side filter solves the exact problem with zero risk.
- Source: KD-0361 D1
User Management visibility: gate on Users.Update, don't restrict /api/users for Members
- Chose: Sidebar link, route, and deleted-users tab move from
can(Users, Read)tocan(Users, Update)./api/usersstays open. - Rejected: Revoke
Member.Users.can_read(literal AC2 reading); scope/api/usersto teammates+referenced-users (expensive, fragile). - Why:
userStore.retrieveAll()is consumed by Navbar, comment authors, assignees, time-entry owners — restricting it would render names as "User #42" everywhere. CEO confirmed: admin names should still display in context. Members lose the browsable list but keep name resolution. - Source: KD-0361 D3
Users.Read removal NOT applied to Roles — Member loses Roles.Read
- Chose: Migration sets
Member.Roles.can_read = false. Sidebar gate auto-hides the Roles link. - Rejected: Treat Roles like Users (keep the API open, gate only the UI).
- Why:
/api/rolesis consumed only by the Roles domain pages (no global store). Revoking can_read is safe and matches the intent of "geen toegang tot algemene projectinstellingen." - Source: KD-0361 D4
NO CheckPermission::canManageTeams + TeamPolicy::scopeQuery
- Chose: Don't introduce union-permission helpers or a novel
scopeQuerypolicy method. - Rejected: v1's
canManageTeams+scopeQueryto support hypothetical "Team Manager" custom roles. - Why: YAGNI — no Team Manager role exists. Frontend filter (D1 above) solves the real problem with zero scaffolding.
scopeQuerywould be a net-new pattern with no codebase precedent. Revisit when a Team Manager role becomes real. - Source: KD-0361 D9
Issue Own-scope delete: frontend-only, no backend changes
- Chose: Add
show-ownUI toggle to issue update + delete cells. Use existingPermissionScopeEnum::Own,IssuePolicy::delete(),CheckPermission::checkScope(). - Rejected: Add new enum values, policy changes, scope checks (already exists).
- Why: Backend has supported Own-scope for issue delete/update since the permission system was built. Audit before designing — this was a UI-only gap.
- Source: KD-0386 D1
Bulk delete UX for Own-scope: hide entirely, don't filter selection
- Chose: Bulk delete button stays hidden when scope=Own (existing behavior).
- Rejected: Filter bulk selection to only the user's own issues.
- Why: Issue explicitly excludes bulk deletion from scope. Granular bulk-delete is more complex with no real demand.
- Source: KD-0386 D4
Team-scoped user visibility: new access_all_users boolean on Role
- Chose: New
access_all_usersboolean column onroles, mirroring the existingaccess_all_projects. Composable — a custom role can have one, both, or neither. - Rejected: Three-state enum
None|SharedProjects|All(no precedent — existing fields are booleans); implicit viaUsers.Update(couples "manage" with "see"); onlyis_adminbypasses (KD-0361 explicitly reopened the question). - Why: Identical shape to
access_all_projectsmeans same toggle UI, same DTO field, same FormRequest validation. Reviewer can compare one-to-one. Composability future-proofs custom Users-Manager roles. - Source: KD-0501 D1
User scoping: filter the response, don't trim the resource
- Chose: Drop outside-scope users from
/api/users. Resource shape staysUserPublicResourceData(admin) /UserResourceData(non-admin). - Rejected: Two-tier resource swap (
UserSummaryResourceDatafor non-bypass actors); hybrid filter+trim with two shapes in one response. - Why: Trimming doesn't fix the enumeration complaint — non-bypass actors still get one row per tenant user. Two resource shapes in one response has no precedent. Filtering closes KD-0361's accepted residual leak (
team_ids[]/project_ids[]enumeration). - Source: KD-0501 D2
Visibility scope: project-team transitive, derived from hasProjectAccess semantics
- Chose: Visible users = members of any team on accessible projects ∪ direct members of accessible projects ∪ all admins ∪ all
access_all_usersusers ∪ self. - Rejected: "My teams' members only" (breaks multi-team projects); "My teams + my direct memberships' direct members" (half-fix, still misses other teams on the project).
- Why: Multi-team projects must keep working — exactly the regression KD-0361 D1 was paranoid about. Naturally honors
access_all_projectswithout an explicit coupling rule. - Source: KD-0501 D3
Visibility logic: Eloquent scope on User model, not a Helpers class
- Chose:
User::scopeVisibleTo(Builder $query, User $actor): void(mirrorsIssue::scopeAssignedOpenFor). - Rejected:
App\Helpers\UserVisibilityScopeclass injectingCheckPermission; inline duplication in controller and policy. - Why: Deptrac forbids
Helpers → PoliciesANDPolicies → Helpers. The User-model scope is the only locus reachable from BOTHUserController::indexANDUserPolicy::update/delete/invite. Direct codebase precedent exists. - Source: KD-0501 D4
Always include admins + access_all_users users in scoped responses
- Chose: Append
whereHas('roles', is_admin OR access_all_users)to the User scope so admins are always visible. - Rejected: Server-side union of "referenced user IDs" from issues/comments/time-entries (rejected as expensive in KD-0361 D3); inline author names in each Resource; accept "User #42" rendering.
- Why: Without this, an admin who isn't on any of the actor's project teams disappears from
/api/usersbut their comments/issues are still visible — breaking name resolution. Reverses KD-0361 D7's explicit guarantee. Cheap one extrawhereHasclause. - Source: KD-0501 D5
/api/users/{user} action routes: gate via UserPolicy, not Route::bind
- Chose: Extend
UserPolicy::update/::delete/::inviteto gate by visibility. Out-of-scope target → false → 403. - Rejected:
Route::bind('user', ...)returning 404 for out-of-scope IDs; inline check in each controller method. - Why:
Route::bindhas zero codebase precedent and risks affecting other routes bindingUser. Inline controller checks mix auth-shaped logic into thin controllers (violates the project's policy/permission tier convention). Policy is the in-codebase shape with->can('update', 'user')middleware already wired. - Source: KD-0501 D10
access_all_users privilege-escalation guard mirrors access_all_projects
- Chose:
RoleRequestvalidatesaccess_all_userswithRule::when(!isAdmin, [Rule::in([false])])— only admins can set it to true. - Rejected: Trust the field unconditionally.
- Why: Without the guard, a Users-Manager role with
Roles.Updatecould grant their own role bypass capability — privilege escalation. Existingaccess_all_projectsalready uses this exact pattern verbatim. - Source: KD-0501 D11
BillingPolicy::manage() routes through CheckPermission, not raw isAdmin()
- Chose: Migrate the backend policy gate to
CheckPermissionforBilling:READORBilling:UPDATE(admins still pass via the bypass step), matching the route-meta OR-semantics. - Rejected: Leave
manage()as$user->isAdmin()for a smaller diff. - Why: Keeping the policy admin-only makes the route-meta change cosmetic and lets backend and frontend gates diverge for any future non-admin billing role.
- Source: KD-0640 D3
Permission-seed backfill: raw DB::table() migration, not an Action wrapper
- Chose: Follow the five precedent permission-seed migrations — raw inline
DB::table()->insert()inup(), partitioned byis_admin/is_system. - Rejected: Wrap the backfill in a
BackfillBillingPermissionsActionto match the literal text of ADR-0011. - Why: ADR-0011 governs request-time application code, not one-shot migrations; introducing an Action here would own a net-new mini-pattern no existing seed migration uses.
- Source: KD-0640 D7
Permission-denied message: one global AuthorizationException renderer, not a per-site exception migration
- Chose: Register a single
render()closure inbootstrap/app.php'swithExceptions()that intercepts authorization denials for JSON requests and returns a structured 403 — every->can()route denial and in-ActionGate::authorizegets the better message at once. - Rejected: The ticket's framed design — a generic
PermissionDeniedException extends CustomException+ aPermissionDenialReasonenum + aGate::denies-throw per Action, migrated one site at a time (with arch test, deptrac widening, MCP + FE changes). - Why: Every Kendo permission denial is uniform ("you lack a role permission; an admin manages roles") — there is no per-site nuance needing a bespoke message or machine-readable reason catalog, so the per-site scaffolding delivered what one framework-level override delivers everywhere in ~8 lines.
- Source: KD-0739 D0/D1
Denial override swaps only the framework-default message; custom messages pass through
- Chose: The closure rewrites the message only when it equals Laravel's default
"This action is unauthorized."; any custom message (e.g. token-ownership"This token does not belong to you.") passes through unchanged, withcodestill attached. - Rejected: A blanket override that genericizes every
AuthorizationExceptionmessage. - Why: All Kendo policies return plain bool (no
Response::deny('…')custom messages), so the default-string check cleanly partitions "policy/gate denial" from "manual custom-message throw" — a blanket rewrite would wrongly flatten the ownership message into the generic explainer. - Source: KD-0739 D2
403 discriminator: reuse the existing {code, message} shape, not a new reason_code key or reason enum
- Chose: Body is
{code: "PERMISSION_DENIED", message}— a single staticcodeand a complete self-containedmessagethe FE shows as-is. - Rejected: A
reason_codekey backed by a per-reason enum/catalog; aresolved_byfield the FE concatenates into the message. - Why: It mirrors
TenantNotVerifiedException's{code, message}— the established 403-discriminator convention the FE already reads viadata.code— so inventing a third key or a reason catalog adds machinery for a denial type that has no per-reason nuance. - Source: KD-0739 D3
Forbid direct AuthorizationException throws via arch test, migrate offenders to a dedicated exception
- Chose: A Level-1 Pest arch test bans
new AuthorizationException(...)inapp/; non-generic denials must use->can()/Gate::authorize()(RBAC → standard explainer) or a dedicatedCustomExceptionsubclass — the two token-ownership sites moved to a newTokenNotOwnedException. - Rejected: An opt-in marker on the global override to flag intentional non-generic throws.
- Why: The override is implicit, so a future developer throwing
AuthorizationExceptionfor a case that should NOT read as "ask an admin" gets no signal;->can()route denials have no throw site to gate and are correct by design, so the only risky pattern is a deliberate direct throw — exactly what the arch test catches, shipping with zero allowlist. - Source: KD-0739 D7