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_id is null) — belongs to one member.
  • Team (team_id is set) — belongs to a team, and access follows the asker's role in that team.

Team membership carries one of four roles:

RoleCan do
ownerEverything, including deleting the team
adminManage members; run, edit, and delete tasks
memberCreate, edit, and run tasks — the default
viewerRead-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:

  1. Give the resource a member_id and a nullable team_id.
  2. Write one access service with can* methods, one per verb.
  3. Call it in every controller method that reads or writes the resource, including list views.
  4. 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."

Honest notes
  • The service is scoped to tasks and instances. It is named TaskAccessControl for 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.