Route permissions answer "may this person open this page?" They cannot answer "may this person
open this record?" That second question is where most real access-control bugs live, and
it is where a lot of applications quietly leak data — not because anyone forgot to check
authentication, but because a query said WHERE id = ? when it should have said
WHERE id = ? AND member_id = ?.
Tiknix answers it with a single service, lib/TaskAccessControl.php, that every
caller asks before showing or acting on a shared resource.
The ownership model
Every task-like resource is in one of two states:
- Personal (
team_idis null) — belongs to one member. - Team (
team_idis set) — belongs to a team, and access follows the asker's role in that team.
Team membership carries one of four roles:
| Role | Can do |
|---|---|
owner | Everything, including deleting the team |
admin | Manage members; run, edit, and delete tasks |
member | Create, edit, and run tasks — the default |
viewer | Read-only access to team tasks |
Four roles is a deliberate number. It is enough to express the distinctions teams actually make — someone who runs things, someone who only watches, someone who administers — without becoming a permission matrix nobody configures correctly.
One service, asked everywhere
The API is a set of verb methods, each taking the asking member and the resource:
$access = new \app\TaskAccessControl();
if (!$access->canView($memberId, $task)) { /* 403 */ }
if (!$access->canEdit($memberId, $task)) { /* 403 */ }
if (!$access->canRun($memberId, $task)) { /* 403 */ }
if (!$access->canDelete($memberId, $task)) { /* 403 */ }
$access->canComment($memberId, $task); // = canView
The structural benefit is concentration. Ownership logic exists in exactly one file, so a change to the sharing rules is a change to that file — not an archaeology expedition through every controller that touches a task. And a reviewer auditing "who can delete things?" reads one method.
Each method follows the same shape, and it is worth reading once:
public function canDelete(int $memberId, $task): bool {
$task = $this->toArray($task);
// Personal task: only the owner. No team fallback, no admin override.
if (empty($task['team_id'])) {
return (int)$task['member_id'] === $memberId;
}
// A task's creator can always delete their own task.
if ((int)$task['member_id'] === $memberId) {
return true;
}
// Otherwise it depends on the asker's role in the owning team.
return $this->hasTeamPermission((int)$task['team_id'], $memberId, 'can_delete_tasks');
}
Note that canDelete is stricter than canView for personal resources.
Viewing allows a teammate-on-a-shared-instance path; deleting does not. Destructive operations
get the narrower rule, which is the correct default and easy to forget when checks are scattered.
Sharing without moving things
There is a third path in canView: a personal task on an instance that has been
shared with a team is visible to that team's members. It is implemented as one explicit query
joining the instance-to-team link table.
This exists because of how collaboration actually happens. Someone creates a personal task on a workspace the team shares. A teammate needs to see what it did. Without this rule, the answer is "reassign the task to the team," which is friction at exactly the wrong moment. With it, sharing the workspace shares visibility into work on that workspace — a coarser but more intuitive unit.
It is also a deliberate widening of access, which is why it belongs in a named private method
(isSharedInstanceTask) with a comment explaining the intent, rather than as an extra
clause in an || chain. When you widen access, make the widening legible.
Teams as a first-class object
controls/Teams.php handles creation, invitations, and membership. Invitations go out
through Mailer::sendTeamInvite() with an accept link, and the join-by-token route is
the one team operation that does not require an existing login — because the person accepting may
not have an account yet.
That is the correct exception, and it is a single, identifiable one rather than a general loosening.
The pattern to copy
If you add resources of your own — documents, invoices, projects — the shape to reuse is this:
- Give the resource a
member_idand a nullableteam_id. - Write one access service with
can*methods, one per verb. - Call it in every controller method that reads or writes the resource, including list views.
- Never trust an id from the request without an ownership check — an id in a URL is a user-supplied value.
The last point is the one that bites. Route permissions confirm the person may use the feature.
They say nothing about which rows they may use it on. /projects/edit/42 passing the
permission check means "this member may edit projects," not "this member may edit project 42."
- The service is scoped to tasks and instances. It is named
TaskAccessControlfor a reason. It is a pattern to copy for your own resources, not a generic ACL engine you can point at any table. - Checks are opt-in. Unlike route permissions — enforced by the dispatcher — record-level checks only run where a developer called them. A controller method that skips the call has no safety net. Code review for new controllers should specifically look for this.
- Four roles will eventually be three too few. "Can run but not delete" or "billing-only" needs a fifth role or a per-capability grant. The
hasTeamPermission($teamId, $memberId, 'can_x')indirection is where you'd add it — the capability names already exist. - Shared-instance visibility is broad by design. A teammate can see personal tasks on a shared workspace. That is intentional collaboration, but tell your users about it — "personal" implying "private" is a reasonable assumption for them to make.
- List views need the same filter. A per-record check on the detail page is not enough if the index query returns rows the asker shouldn't see. Filter the query, then check the record.