Skip to main content

Command Palette

Search for a command to run...

When "Admin" Means Two Different Things: Platform vs Organization Authority

The word "admin" hides a fault line. On one side is authority over the whole platform. On the other is authority inside a single organization. This is how I keep those two planes from leaking into each other.

Updated
12 min read
When "Admin" Means Two Different Things: Platform vs Organization Authority
O
I am Oladele David, a software, DevOps & cybersecurity engineer. I build backends and cloud infrastructure and write about system design, open source, and learning in public.

This is Part 3 of the series Building Multi-Tenant Systems That Match the Real World.

Part 1: Designing Multi-Tenant Backends With Both Ownership and Team Access

Part 2: How to Model Teams Inside a Multi-Tenant Product

Table of Contents

The Question That Breaks a Single "Admin" Role

In Part 1 I argued that tenants are organizations with boundaries. In Part 2 I modeled the people inside those boundaries — teams with lifecycle, roles, and scoped permissions.

Both of those articles live inside one organization at a time.

This one is about the question that shows up the moment you have more than one organization and a support team that has to operate across all of them:

Admin of what?

Picture two people, both called "admin" in casual conversation:

  • A store owner who can do anything inside their store: edit products, process orders, manage staff, view payouts.

  • A platform support engineer who can suspend any store on the system, refund across stores, and read aggregate data the owner never sees.

If you model both as a role named admin, you have a problem. The store owner's admin and the platform engineer's admin are not the same authority. One is bounded by an organization. The other sits above all organizations.

The cleanest way I have found to keep these straight is to stop treating authority as one ladder and start treating it as two planes.

Two Planes of Authority

Most permission tutorials draw authority as a single vertical line. Viewer at the bottom, super-admin at the top. The more powerful you are, the higher you climb.

That model breaks in multi-tenant systems because power is not one-dimensional. A store owner is the most powerful person inside their store and has zero authority over the platform. A junior support agent might have almost no power inside any single store but can read every store's status for triage.

So I draw two planes instead:

type AuthorityPlane = 'platform' | 'organization';

The platform plane is authority over the system itself:

  • suspend or reinstate an organization

  • access cross-organization data

  • manage system-wide configuration

  • act on behalf of support or compliance

The organization plane is authority inside one boundary:

  • manage that organization's products, orders, staff

  • view that organization's financials

  • invite people into that organization

The key insight is that these planes are independent. A user's standing on one says nothing about their standing on the other.

User Platform plane Organization plane
Store owner none full, in their own store
Store staff (manager role) none partial, in one store
Platform support agent read-only across all none
Platform super admin full none (until explicitly granted)

That last row matters. Even a platform super admin should not silently have authority inside an arbitrary organization. Operating inside a customer's organization is a different, auditable action — not a free consequence of being powerful on the platform plane.

Scope Belongs on the Permission, Not Just the Role

Once you accept two planes, the data model has to carry the distinction. The mistake is to encode the plane only in the role name (platform_admin vs store_admin) and hope the strings stay disciplined.

Strings do not stay disciplined.

I put scope on both the permission and the role, as a first-class field:

type Scope = 'platform' | 'organization';

interface Permission {
  id: string;
  name: string;       // 'orders.refund'
  resource: string;   // 'orders'
  action: string;     // 'refund'
  scope: Scope;       // which plane this permission lives on
}

interface Role {
  id: string;
  name: string;
  slug: string;
  scope: Scope;       // platform-plane role or organization-plane role
  isSystem: boolean;
}

Now orders.refund is not one ambiguous permission. There can be an organization-scoped orders.refund (an owner refunding their own store's order) and a platform-scoped orders.refund (support issuing a cross-store refund during a dispute). Same words, different planes, and the schema knows the difference.

This also makes a rule enforceable that was previously just a convention:

An organization-scoped role can only hold organization-scoped permissions. A platform-scoped role can only hold platform-scoped permissions.

When you seed roles, you can assert it:

function assertScopeConsistency(role: Role, permissions: Permission[]) {
  const mismatched = permissions.filter((p) => p.scope !== role.scope);

  if (mismatched.length > 0) {
    const names = mismatched.map((p) => p.name).join(', ');
    throw new Error(
      `Role "\({role.slug}" is \){role.scope}-scoped but was given ` +
        `permissions from another plane: ${names}`,
    );
  }
}

A check like this turns a category of bug — "someone accidentally attached a platform permission to a store role" — into a startup-time failure instead of a production incident.

Why a Global Admin Role Rots

A single global admin role feels efficient on day one. It rots for a predictable reason: it conflates the two planes, and every new feature has to guess which plane it belongs to.

Watch how it decays.

You ship with admin meaning "can manage a store." Fine.

Then you build a support dashboard. The support team needs to suspend abusive stores. Suspending a store is platform power, but the only role you have is admin, so you grant support admin — and now support technically has store-management power they should never use.

Then you add billing operations. Refunds, chargebacks, ledger corrections. Some are store-level, some are platform-level. But admin is one bucket, so the line blurs again.

Six months later, "admin" means "a pile of permissions that accreted over time, spanning two planes, that nobody can fully enumerate." Auditing it is guesswork. Removing a permission is dangerous because you no longer know who relied on it for what.

The fix is not better naming discipline. It is structural: make the plane part of the type, so a permission cannot be added without declaring which plane it lives on.

The Owner Is Powerful Inside One Boundary, Not Above It

In Part 1, the owner got special treatment — a direct ownership link and a wildcard bypass over staff restrictions. That is correct, but it is easy to over-read.

The owner's wildcard is scoped to their own organization. It is not platform authority wearing a different hat.

Here is the resolution most systems actually want for an owner:

async function resolveOwnerPermissions(
  userId: string,
  organizationId: string,
): Promise<string[]> {
  const isOwner = await isOrganizationOwner(userId, organizationId);
  if (!isOwner) return [];

  // The owner gets a wildcard — but only over organization-plane permissions.
  // Platform-plane permissions are NOT included here.
  const orgPermissions = await db.permission.findMany({
    where: { scope: 'organization' },
    select: { name: true },
  });

  return ['*', ...orgPermissions.map((p) => p.name)];
}

Read the where clause carefully. The wildcard the owner receives is built only from scope: 'organization' permissions. An owner being all-powerful in their store does not hand them a single platform-plane permission. If they want to do something platform-level — say, see how their store ranks against others — that is a separate grant on a separate plane, not a side effect of ownership.

This is the discipline the two-plane model enforces for free: the most powerful actor inside a boundary is still bounded by it.

Resolve the Plane Before You Resolve the Permission

At request time, the order of checks matters. You do not ask "does this user have orders.refund?" as a flat question. You first decide which plane the request is acting on, then resolve permissions within that plane.

A platform-plane request — say, a support endpoint operating across organizations — resolves like this:

async function resolvePlatformPermissions(userId: string): Promise<string[]> {
  const platformRole = await db.userPlatformRole.findUnique({
    where: { userId },
    include: {
      role: { include: { permissions: true } },
    },
  });

  if (!platformRole) return []; // no platform standing at all

  return platformRole.role.permissions
    .filter((p) => p.scope === 'platform') // never leak org-plane perms here
    .map((p) => p.name);
}

An organization-plane request resolves against the organization context, exactly as in Parts 1 and 2:

async function resolveOrganizationPermissions(
  userId: string,
  organizationId: string,
): Promise<string[]> {
  const ownerPerms = await resolveOwnerPermissions(userId, organizationId);
  if (ownerPerms.length > 0) return ownerPerms;

  const membership = await getActiveMembership(userId, organizationId);
  if (!membership) return [];

  return membership.role.permissions
    .filter((p) => p.scope === 'organization')
    .map((p) => p.name);
}

The two resolvers never call each other. A platform endpoint can never accidentally pick up organization permissions, and an organization endpoint can never accidentally pick up platform permissions. The .filter((p) => p.scope === ...) on each side is a cheap, explicit firewall.

Then the guard decides which resolver to run based on what kind of route it is protecting:

async function authorize(
  userId: string,
  required: string,
  plane: Scope,
  organizationId?: string,
): Promise<boolean> {
  const granted =
    plane === 'platform'
      ? await resolvePlatformPermissions(userId)
      : await resolveOrganizationPermissions(userId, organizationId!);

  return hasPermission(granted, required);
}

hasPermission is the same wildcard-aware matcher from Part 2. What changed is everything before it: the plane is decided first, and the permission set you match against is built from one plane only.

Never Let an Organization Grant Platform Power

This is the single rule that keeps the whole model safe, so it gets its own section.

Authority must only ever flow downward, from platform to organization — never upward.

A platform actor can be granted authority to operate inside an organization. That is downward flow, and it should be explicit and audited.

An organization actor must never be able to acquire platform authority. Not through a custom permission, not through a role assignment, not through an owner wildcard. There is no upward path.

In Part 2 I mentioned customPermissions — per-member overrides that extend a role. This is exactly where an upward leak would sneak in. So the override path has to be scope-gated:

function validateCustomPermissions(
  member: { scope: Scope },
  requested: string[],
  catalog: Permission[],
) {
  for (const name of requested) {
    const permission = catalog.find((p) => p.name === name);

    if (!permission) {
      throw new BadRequestError(`Unknown permission: ${name}`);
    }

    // An organization-scoped member can never receive a platform permission.
    if (permission.scope !== member.scope) {
      throw new ForbiddenError(
        `Cannot grant \({permission.scope}-plane permission "\){name}" ` +
          `to a ${member.scope}-plane member`,
      );
    }
  }
}

The same gate belongs anywhere a permission can be assigned: role creation, role editing, custom overrides, invitation flows. If a permission can be attached to a user, the attach path checks the plane.

Said plainly: being an owner makes you powerful in your store. It must never make you powerful over mine.

What This Buys You

The two-plane model is more upfront structure than a single admin role. What you get back:

  • Honest audits. "Who can suspend a store?" has one answer, and it lives entirely on the platform plane. You can enumerate it.

  • Safe support tooling. Support gets exactly the platform-plane permissions they need, with no accidental store-management power riding along.

  • Owner power that stays contained. An owner's wildcard can never reach platform actions, by construction.

  • No upward leaks. Organizations cannot mint platform authority, because every assignment path is scope-gated.

  • A clear place for new features. Every new permission has to declare its plane. There is no ambiguous middle to rot.

The payoff is the same one from the earlier articles: fewer rewrites, and a system you can still reason about after it grows.

What I Would Avoid

1. A single global admin role

It conflates two planes and accretes ungovernable power over time. Split it the moment you have cross-organization operations.

2. Encoding the plane only in the role name

platform_admin vs store_admin as bare strings is a convention, and conventions leak. Put scope on the permission and the role as a real field.

3. Letting platform power imply organization power

A platform super admin should not silently have authority inside every organization. Operating inside a customer's boundary is a separate, auditable grant.

4. Unscoped custom permission overrides

The override path is the most common place an organization member quietly gains a permission from the wrong plane. Gate it.

5. Resolving permissions before resolving the plane

If you ask "does this user have X?" without first deciding which plane the request is on, you have already lost the distinction. Decide the plane first.

Closing Thought

The shift in this article is small to state and large in consequence:

"Admin" is not a level. It is a plane.

Authority over the platform and authority inside one organization are two separate planes that happen to share vocabulary. Once you model them as separate — scope on the permission, scope on the role, plane decided before permission, and authority that only ever flows downward — a whole class of questions gets simple answers:

  • Who can suspend a store? — a platform-plane question

  • Who can edit this store's products? — an organization-plane question

  • Can an owner reach platform actions? — no, by construction

  • Can support manage a store's catalog? — only if granted downward, explicitly

The single-ladder mental model cannot answer those cleanly. The two-plane model can.

And keeping the planes apart is, again, much cheaper at the start than untangling them after a support tool has quietly been handed store-management power nobody remembers granting.

In Part 4, I will cover the runtime layer — how a declarative permission decorator and a guard turn this data model into actual access control on every request, without scattering authorization logic through your handlers.

Building Multi-Tenant Systems That Match the Real World

Part 3 of 6

A practical, architecture-first series on building multi-tenant backends that survive real organizational behavior — ownership, teams, roles, scoped permissions, and two planes of authority. Generalized patterns you can reuse in any SaaS, marketplace, agency, healthcare, or internal tool. NestJS-flavored, but the model travels.

Up next

Turning a Permission Model Into a Guard

A permission model that lives only in the database does nothing. This is the runtime layer, a declarative decorator and a guard that turns "who can do what" into an enforced answer on every request, without scattering authorization logic through your handlers.

More from this blog

Oladele Writes

16 posts

Backend engineering and the systems around it. I write about architecture and system design, cloud and infrastructure, security, and the patterns behind real products: multi-tenancy, auth, APIs, and the decisions that hold up in production. Mostly hands-on, drawn from actual SaaS work.