Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

What I Learned Building Role-Based Access for 50+ Staff Users

24 Mar 2025Category : Blog

About Post

On a whiteboard, role-based access looks simple. Admins can do everything, managers can approve things, everyone else can view things. Draw three boxes, add a role column, done by lunch.

Then real people start using the system. Someone in finance needs to see contracts but not edit them. A leasing officer needs to edit contracts, but only before they're signed. A new team appears after a reorganisation, with a mix of permissions that matches none of your three boxes.

Kate PMS, the property management system I lead the engineering on at Kate Group, is used by 50+ staff across very different jobs: leasing, finance, maintenance, management. The same backend serves the web portal and the staff app, and the data includes contracts, payments and tenant details. Getting access right there taught me more than any tutorial did. Here are the lessons I'd hand to anyone building it, in the order I wish I'd learned them.

Lesson 1: check permissions, never roles

The fastest way to paint yourself into a corner is code like this, scattered across controllers:

if ($user->role === 'manager' || $user->role === 'admin') {
    // approve the contract
}

It works until the business says "team leads should approve too, but only renewals". Now you're hunting for every role === in the codebase.

The fix is a layer of indirection: code checks permissions; roles are just named bundles of permissions. The code asks "can this user approve contracts?", and whether that's true is data, not code. When the organisation changes, you edit a role, not a dozen files. Whether you build this yourself or use a package like spatie/laravel-permission, the principle is the same.

Lesson 2: policies answer "this record", scopes answer "which records"

Laravel policies are the natural home for authorization logic, one class per model:

class ContractPolicy
{
    public function update(User $user, Contract $contract): bool
    {
        return $user->hasPermission('contracts.edit')
            && ! $contract->isSigned();
    }

    public function approve(User $user, Contract $contract): bool
    {
        return $user->hasPermission('contracts.approve');
    }
}

And in the controller, one line: Gate::authorize('update', $contract);. (A simplified sketch: hasPermission() stands in for whatever your permission lookup is.)

But here's the gap people miss: a policy answers a yes/no question about one record. It does nothing for a list. You can protect the "view contract" page perfectly and still leak everything through the index endpoint, the search box, the export button or a dashboard count.

So every "which records can this user see?" question gets a query scope, used everywhere a list is built:

public function scopeVisibleTo(Builder $query, User $user): Builder
{
    return $user->hasPermission('contracts.view_all')
        ? $query
        : $query->where('assigned_to', $user->id);
}

// Contract::visibleTo($request->user())->paginate();

Single record: policy. Lists, searches, exports, counts: scope. You need both.

Lesson 3: start from zero, not from "everything"

When someone joins and nobody's sure what they need, it's very tempting to give them a broad role "for now". "For now" has a way of becoming permanent.

Least privilege means a new account starts with the minimum for its job, and more is granted when there's a real need. It feels slower at first. It's dramatically easier than trying to take access away later, when someone has come to rely on it.

The same thinking applies in code: deny by default. If a policy method doesn't exist or a permission isn't granted, the answer is no. Be very careful with shortcuts like Gate::before() returning true for admins: convenient, but it skips every rule, including ones like "nobody edits a signed contract" that should apply to admins too.

Lesson 4: the UI is not the security boundary

Hiding the "Approve" button for users who can't approve is good UX. It is not security. The API endpoint behind that button must check the permission itself, because anyone can call an API directly, and an older version of a mobile app may still show a button you've since removed.

The rule I follow: every endpoint authorizes on the server, and the UI only mirrors that decision. A nice side effect: the frontend can ask the API what the current user may do (a list of abilities in the user payload), so the web portal and the staff app hide the same buttons for the same reasons.

The two-question test for any new feature: who can do this to one record (policy), and which records can they see in the first place (scope)? If you can't answer both, the feature isn't ready.

Lesson 5: test permissions as a matrix

Permission bugs are silent. Nothing crashes when the wrong person can see a payment. You only find out when someone mentions it, which is the worst way to find out.

So permissions deserve their own tests, written as a table of role, action and expected result:

#[DataProvider('approvalMatrix')]
public function test_contract_approval_permissions(string $role, int $status): void
{
    $user = User::factory()->withRole($role)->create();
    $contract = Contract::factory()->pending()->create();

    $this->actingAs($user)
        ->postJson("/api/contracts/{$contract->id}/approve")
        ->assertStatus($status);
}

public static function approvalMatrix(): array
{
    return [
        'approver can approve'     => ['approver', 200],
        'editor cannot approve'    => ['editor', 403],
        'read-only cannot approve' => ['viewer', 403],
    ];
}

(Role names here are illustrative.) The negative cases matter more than the positive ones. "Approver can approve" gets tested by every user, every day. "Viewer cannot approve" only gets tested by your test suite. Do the same for list endpoints: assert that a restricted user's index response doesn't contain a record they shouldn't see.

Lesson 6: audit who did what, and who changed who can do what

For anything that matters (contract changes, approvals, payment adjustments), record who did it, when, and what changed. When a question comes up months later, "the system did it" is not an answer anyone accepts.

And don't forget the meta-level: changes to permissions themselves should be audited too. If someone was granted access to finance data, you want to know who granted it and when. That's often the first question in any review.

Lesson 7: name permissions in business language

Permissions will be managed by people who don't read code. contracts.approve is clear. can_do_action_7, or a permission named after a controller method, is not. Group them by area and write them so a manager can look at a role and understand exactly what it allows.

Clear names also make reviews easier. A pull request that adds payments.refund to a role is obviously worth a second look.

The short version

  • Code checks permissions. Roles are just bundles of them.
  • Policies for one record, scopes for every list, search and export.
  • Start with the minimum, deny by default, be wary of admin shortcuts.
  • Authorize every endpoint on the server. The UI only mirrors it.
  • Test the "cannot" cases as carefully as the "can" cases.
  • Audit actions, and audit permission changes.

None of this is glamorous. Nobody demos a permission matrix. But when 50+ people with different jobs share one system, it's the part that decides whether they can trust it.

What's the trickiest permission rule you've had to model? I'd love to hear the ones that didn't fit neatly into roles.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close