← All posts

8 min read

Roles and Permissions in Mobile Apps: Designing RBAC That Holds Up

How I design roles and permissions in mobile apps: owner, admin, staff and customer roles, server-side enforcement in .NET, permission-aware Flutter UI and testing.

  • Backend
  • Permissions
  • Security
Cover illustration for Roles and Permissions in Mobile Apps: Designing RBAC That Holds Up

“Staff can see customers but not invoices.” That sentence shows up in almost every business app brief, and it looks like a small detail. It isn’t. It touches the database, every API endpoint, half the screens in the app and the test suite.

I built Dent Shop Manager, a multi-platform CRM covering shop and customer management, and business apps like that are where roles stop being theoretical. Once more than one person uses the same account, you need to answer “who is allowed to do what” in a way that’s consistent, testable and hard to bypass.

Here’s how I approach it, from naming the roles to testing the whole matrix.

Start with people, not code

Before any code, I write down who actually uses the app and what they do day to day. For a typical shop-style business app, the list usually looks like this:

  • Owner. Pays for the subscription, can do everything, can’t be locked out.
  • Admin. Runs the place day to day. Manages staff, settings and most data, but not billing or ownership.
  • Staff. Does the work. Views and updates customers and jobs, maybe creates quotes.
  • Customer. Sees their own records, pays invoices, gets notifications.

Then I list the actions: view customers, edit customers, delete customers, create invoices, refund payments, invite team members, change billing. The roles go across the top, the actions down the side, and we fill in the grid together with the client. That grid becomes the spec, and later, the test data.

The most useful question at this stage is “who should not be able to do this?” Founders tend to think about what each role needs. The interesting conversations are about what happens when a staff member leaves on bad terms.

RBAC vs permission-based checks

There are two common ways to express the rules in code.

Role-based checks ask “is this user an admin?” They’re simple and readable, and they work fine while you have three roles and no exceptions.

Permission-based checks ask “does this user have customers.edit?” Roles become named bundles of permissions, and the code only ever checks permissions.

I use the second approach almost every time, for one practical reason: requirements change. Sooner or later a client asks for “staff, but this one can also issue refunds.” With role checks, that means a new role and a hunt through every if (role == Admin) in the codebase. With permission checks, it means adding one permission to one bundle, or granting it to one user.

A small, central map from roles to permissions keeps it manageable:

public static class Perms
{
    public const string CustomersView = "customers.view";
    public const string CustomersEdit = "customers.edit";
    public const string InvoicesCreate = "invoices.create";
    public const string TeamManage = "team.manage";
    public const string BillingManage = "billing.manage";
}

public enum Role { Owner, Admin, Staff, Customer }

public static class RolePermissions
{
    private static readonly Dictionary<Role, HashSet<string>> Map = new()
    {
        [Role.Owner] = [Perms.CustomersView, Perms.CustomersEdit,
                        Perms.InvoicesCreate, Perms.TeamManage, Perms.BillingManage],
        [Role.Admin] = [Perms.CustomersView, Perms.CustomersEdit,
                        Perms.InvoicesCreate, Perms.TeamManage],
        [Role.Staff] = [Perms.CustomersView, Perms.CustomersEdit],
        [Role.Customer] = [],
    };

    public static IReadOnlySet<string> For(Role role) => Map[role];
}

Customers usually don’t fit neatly into the same grid. Their rule is less “what can you do” and more “you can only see your own stuff,” which is an ownership check, covered below.

Enforce it on the server. Always.

The single most important rule: the client is not a security boundary. Anyone can call your API with a modified app, a proxy or a few lines of script. Hiding the “Delete” button is a UX decision. Rejecting the delete request is the security decision, and it lives on the server.

In ASP.NET Core I use policy-based authorization. Each permission becomes a policy, and endpoints declare which one they need:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy(Perms.CustomersEdit,
        policy => policy.RequireClaim("permission", Perms.CustomersEdit));
    options.AddPolicy(Perms.TeamManage,
        policy => policy.RequireClaim("permission", Perms.TeamManage));
});

app.MapPut("/customers/{id:guid}", UpdateCustomer)
   .RequireAuthorization(Perms.CustomersEdit);

That answers “can this user edit customers at all?” It doesn’t answer “can this user edit this customer?” In a multi-tenant app, that second question matters more. A staff member at one shop must never be able to edit a customer from another shop by guessing an ID. For that, ASP.NET Core has resource-based authorization:

public sealed class SameShopRequirement : IAuthorizationRequirement { }

public sealed class SameShopHandler
    : AuthorizationHandler<SameShopRequirement, Customer>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        SameShopRequirement requirement,
        Customer resource)
    {
        var shopId = context.User.FindFirst("shop_id")?.Value;
        if (shopId is not null && resource.ShopId.ToString() == shopId)
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }
}

Register the handler with builder.Services.AddSingleton<IAuthorizationHandler, SameShopHandler>(), then call IAuthorizationService.AuthorizeAsync(user, customer, new SameShopRequirement()) inside the endpoint after loading the record. When the check fails for a record in another tenant, I usually return 404 instead of 403, so the API doesn’t confirm that the ID exists.

Even better, scope queries by tenant from the start (a global query filter in EF Core works well for this), so a cross-shop record never gets loaded in the first place. Belt and braces. I cover more of the backend side in building a .NET API for a Flutter app.

One more thing: when an owner changes someone’s role, the change needs to take effect quickly. If permissions live inside a long-lived token, a demoted admin keeps their old powers until it expires. Keep access tokens short-lived, or look permissions up on the server per request for sensitive actions.

Shape the UI by permissions

Once the server is solid, the app’s job is to avoid showing people things they can’t use. Tapping a button and getting “403 Forbidden” is a bad experience, even if it’s secure.

I have the API return the current user’s permissions (from a /me endpoint or the login response) and keep them in app state. Then a small widget handles the gating:

enum Permission {
  customersView('customers.view'),
  customersEdit('customers.edit'),
  invoicesCreate('invoices.create'),
  teamManage('team.manage');

  const Permission(this.key);
  final String key;
}

Set<Permission> parsePermissions(List<String> raw) => {
      for (final p in Permission.values)
        if (raw.contains(p.key)) p,
    };

class PermissionGate extends StatelessWidget {
  const PermissionGate({
    super.key,
    required this.granted,
    required this.needs,
    required this.child,
    this.fallback = const SizedBox.shrink(),
  });

  final Set<Permission> granted;
  final Permission needs;
  final Widget child;
  final Widget fallback;

  @override
  Widget build(BuildContext context) =>
      granted.contains(needs) ? child : fallback;
}

Unknown permission strings from the server are simply ignored, so an older app version doesn’t crash when the backend adds a new one.

A few UI rules I follow:

  • Hide what’s irrelevant, disable what’s explainable. A staff member doesn’t need to see a Billing tab at all. But a greyed-out “Refund” button with “Ask an admin” can save a support call.
  • Guard routes, not just buttons. Deep links and notifications can land users on any screen. A route guard in your router should check permissions too (see deep linking with go_router).
  • Refresh permissions when they change. Re-fetch on app resume, or handle a 403 by refreshing permissions and rebuilding the screen.

Invitations and team management

Team management is a feature in its own right, and it’s often missing from the first brief. The flow I usually build:

  1. An owner or admin enters an email and picks a role.
  2. The server creates a pending invite with a single-use, expiring token and sends an email.
  3. The invitee opens the link, signs in or creates an account, and the server attaches them to the shop with that role.

Some rules worth agreeing on early: admins can invite staff but not other owners. Nobody can change their own role. There must always be at least one owner. Removing a member should revoke their sessions immediately, not at the next token refresh. And “transfer ownership” deserves its own confirmation step, because it’s the one action that can’t be easily undone by the person who did it.

Audit what changed

When several people share one account, “who changed this?” becomes a real question, usually asked in a slightly tense tone. An audit log answers it.

At minimum I record who did it, what they did, which record, when, and the before and after values for the fields that matter. Role changes, permission grants, deletions and refunds always get logged. The log is append-only and only owners (and maybe admins) can read it.

You don’t need a fancy system. A table written from the same service layer that performs the change is enough for most apps. If you also have a web panel, it’s a natural place to show it, as I describe in building an admin dashboard for your mobile app.

Test the matrix, not just the happy path

The grid you agreed on with the client is the best test input you’ll ever get. I turn it straight into a data-driven test:

public class RolePermissionTests
{
    [Theory]
    [InlineData(Role.Owner, Perms.BillingManage, true)]
    [InlineData(Role.Admin, Perms.BillingManage, false)]
    [InlineData(Role.Admin, Perms.TeamManage, true)]
    [InlineData(Role.Staff, Perms.TeamManage, false)]
    [InlineData(Role.Staff, Perms.CustomersEdit, true)]
    [InlineData(Role.Customer, Perms.CustomersView, false)]
    public void Role_has_expected_permission(Role role, string permission, bool expected)
    {
        Assert.Equal(expected, RolePermissions.For(role).Contains(permission));
    }
}

That checks the mapping. It doesn’t check that every endpoint is actually protected, which is where real bugs hide. So I also write integration tests with WebApplicationFactory that call each sensitive endpoint as each role and assert the status code, including cross-tenant cases: staff from shop A trying to read a customer from shop B. Those tests are boring to write and have a way of catching the one endpoint someone forgot to decorate.

Takeaways

  • Agree on a role-by-action grid with the client before writing code. It’s your spec and your test data.
  • Check permissions in code, not role names. Roles are just bundles that will change.
  • Enforce everything on the server, including “does this record belong to this tenant.”
  • Use permissions in the app to shape the UI and guard routes, never as the security layer.
  • Treat invitations, ownership rules and audit logs as real features with real estimates.
  • Test the full matrix, including cross-tenant access, at the endpoint level.

Roles are one of those features that’s cheap to design well at the start and expensive to retrofit. If you’re planning a multi-user app and want a second pair of eyes on the permission model, get in touch.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).