Skip to main content

Role-Based Access Control (RBAC)

Technical reference for how roles, organization membership, and fine-grained scopes work in this codebase. Pair with apps/documentation/architecture/authentication.md for session login and identity. This page explains how RBAC turns an organization member’s role into product-scoped AllScopes checks using Redis caching and ProductRoleScope mappings.

How it works

The runtime path is: hasScopes resolves the current membership, then canAccessScopes calls getScopesForOrganizationMembership (Redis cache or DB via ProductRoleScope), and finally it checks ScopeMatchMode. Example: scope-gated resolver composition with hasScopes([...], ScopeMatchMode.EVERY):
return resolveWithMiddlewares(_parent, params, context, [
  isLoggedIn,
  hasScopes([AllScopes.MEMBERS_INVITE], ScopeMatchMode.EVERY),
  async (_parent, form, context: ContextWithUser) => {
    // resolver body
  },
]);

Extending this system

  1. Add the new scope identifier to AllScopes in apps/backend/src/utilities/rbac.ts:
export enum AllScopes {
  MEMBERS_INVITE = 'members:invite',
  // add your new scope here
}
  1. Seed product-role-scope mappings in apps/backend/src/tools/rds/sequelize/seeds/20250503045559-createDefaultProducts.ts (example bulk insert into products_roles_scopes):
await queryInterface.bulkInsert('products_roles_scopes', productRolesScopes);
  1. Protect the relevant GraphQL resolver with hasScopes([AllScopes.YOUR_SCOPE], ScopeMatchMode.EVERY) in apps/backend/src/gql/mutations/:
hasScopes([AllScopes.MEMBERS_INVITE], ScopeMatchMode.EVERY),
  1. Verify end-to-end behavior with backend tests:
make test module=backend

What not to do

File conventions

How roles are defined

This section shows how the code represents roles and what makes super admin special. Roles are declared as TypeScript enums in apps/backend/src/constants/roles.ts:
export enum Roles {
  SUPER_ADMIN = 'super_admin',
  ADMIN = 'admin',
  MEMBER = 'member',
  GUEST = 'guest',
}

export enum PublicRoles {
  ADMIN = 'admin',
  MEMBER = 'member',
  GUEST = 'guest',
}
  • PublicRoles — values allowed for normal organization members (e.g. invitations, membership role column).
  • Roles.SUPER_ADMIN — not stored as an organization role; it is implied when User.is_super_admin is true (see below).
Authorization for most product features is not “role equals X” checks alone; it is scope-based (AllScopes in apps/backend/src/utilities/rbac.ts). Role-to-scope mapping comes from the database (ProductRoleScopeScope) per subscription product.

How roles are assigned to users

This section explains how an organization member’s effective role is chosen for scope resolution.

Organization membership (OrganizationMember.role)

  • Default org creation assigns the creator as admin:
    const membership = await tools.rds.models.OrganizationMember.create(
      {
        user_id: params.admin.id,
        organization_id: organization.id,
        role: PublicRoles.ADMIN,
        invitation_token: null,
        invitation_expires_at: null,
        invitation_accepted_at: new Date(),
        invitation_sent_by_user_id: params.admin.id,
      },
      {
        transaction,
      },
    );
  • Invitations set role from inviteMember input (validated against Object.values(PublicRoles)): apps/backend/src/gql/mutations/inviteMember.ts.

Super admin (User.is_super_admin)

  • Global flag on the user row. When true, scope resolution treats the effective role as Roles.SUPER_ADMIN:
  const role = organizationMember.user?.is_super_admin
    ? Roles.SUPER_ADMIN
    : organizationMember.role;

Active organization context (User.current_membership)

  • getCurrentMembership selects the OrganizationMember whose id matches user.current_membership:
export const getCurrentMembership = (
  user: User,
  logger?: ReturnType<ToolsType['logger']['getFeatureLogger']>,
): OrganizationMember | null => {
  const membership = user.memberships.find(m => m.id === user.current_membership);

  if (!membership && logger) {
    logger.error('Current membership not found', {
      current_membership: user.current_membership,
      user_id: user.id,
      memberships: JSON.stringify(user.memberships.map(m => m.id)),
    });
  }

  return membership || null;
};
Switching tenant context is done via the switchOrganization mutation (apps/backend/src/gql/mutations/switchOrganization.ts), which updates current_membership when the user belongs to the target org.

How permissions are checked

This section explains the scope-resolution inputs and how hasScopes enforces them at runtime.

Scope enum

All permission strings live in AllScopes (apps/backend/src/utilities/rbac.ts), e.g. members:invite, view:pages:billing, etc.

Resolving scopes for the current membership

getScopesForOrganizationMembership:
  1. Loads the organization’s active subscription (filtered by ACTIVE_STATUSES); if none active, returns [] (no scopes).
  2. Resolves the effective role for scope lookup:
    • organizationMember.user?.is_super_adminRoles.SUPER_ADMIN
    • otherwise, use organizationMember.role
  3. Uses Redis cache key rbac:scopes:<productId>:<role.toLowerCase()>:v1 with TTL 24 hours (CACHE_TTL), with a super-admin special case key rbac:scopes:${Roles.SUPER_ADMIN}:v1 (i.e. rbac:scopes:super_admin:v1).
  4. On miss, loads ProductRoleScope rows for (product_id, role) and maps to Scope.name.
  5. If the role is not in PublicRoles, getRoleScopesFromProductIdAndRole returns all AllScopes values (Object.values(AllScopes)).
const getRoleScopesFromProductIdAndRole = async (
  params: {
    productId: number;
    role: Roles | PublicRoles;
  },
  tools: ToolsType,
) => {
  // If role is not a public one, we'll return all scopes for now
  if (!Object.values(PublicRoles).includes(params.role as PublicRoles)) {
    return Object.values(AllScopes);
  }

  const productRoleScopes = await tools.rds.models.ProductRoleScope.findAll({
    where: {
      product_id: params.productId,
      role: params.role,
    },
    include: [
      {
        model: tools.rds.models.Scope,
        as: 'scope',
        required: true,
      },
    ],
  });

  return productRoleScopes.map(productRoleScope => productRoleScope.scope.name);
};

canAccessScopes

ScopeMatchMode.ANY passes when at least one required scope is present; ScopeMatchMode.EVERY passes only when all required scopes are present.
export const canAccessScopes = async (
  organizationMember: OrganizationMember,
  scopes: AllScopes[],
  tools: ToolsType,
  matchMode: ScopeMatchMode,
) => {
  const { logger } = tools;

  const ftLogger = logger.getFeatureLogger('canAccessScopes');

  try {
    const memberScopes = await getScopesForOrganizationMembership(
      organizationMember,
      tools,
    );

    if (matchMode === ScopeMatchMode.ANY) {
      return scopes.some(scope => memberScopes.includes(scope));
    }

    return scopes.every(scope => memberScopes.includes(scope));
  } catch (error) {
    ftLogger.error(error, {
      message: 'Failed to check if organization member can access scope',
      organizationMemberId: organizationMember.id,
      scopes,
      matchMode,
    });

    return false;
  }
};

Scope check flow (quick visual)

The diagram shows how hasScopes turns tenant membership into an “allow/deny” decision.

GraphQL middleware: hasScopes

Typical usage — load user + memberships, short-circuit for super admin, resolve current membership, then canAccessScopes:
export const hasScopes =
  (scopes: AllScopes[], matchMode: ScopeMatchMode) =>
  async <A, B>(
    root: unknown,
    args: A,
    context: Context,
    resolvers: ResolverType<A, B>[],
  ) => {
    const { req, tools } = context;
    const rds = tools.rds;
    const logger = tools.logger.getFeatureLogger('hasScopes middleware');

    const user = await tools.rds.models.User.findByPk(
      (req.user as { id: string }).id,
      {
        include: [
          {
            model: rds.models.OrganizationMember,
            as: 'memberships',
            required: false,
          },
        ],
      },
    );

    if (!user || !user.memberships) {
      logger.error('User or associated memberships not found', {
        userId: (req.user as { id: string }).id,
        memberships: user?.memberships?.length || 0,
      });

      throw getGQLError(500);
    }

    const contextWithUser: ContextWithUser = { ...context, user };
    const [resolver, ...remainingResolvers] = resolvers;

    if (user.is_super_admin) {
      logger.info('User is a super admin', {
        user_id: user.id,
      });

      return resolver(root, args, contextWithUser, remainingResolvers);
    }

    const currentMembership = getCurrentMembership(user, logger);

    if (!currentMembership) {
      throw getGQLError(403);
    }

    const hasAccess = await canAccessScopes(
      currentMembership,
      scopes,
      tools,
      matchMode,
    );

    if (!hasAccess) {
      logger.error('Member not allowed to perform the action', {
        current_membership: user.current_membership,
        scopes,
        matchMode,
      });

      throw getGQLError(403);
    }

    return resolver(root, args, contextWithUser, remainingResolvers);
  };

Role-only middleware

  • isAdmin — current membership role must be PublicRoles.ADMIN (apps/backend/src/gql/middlewares/isAdmin.ts).
  • isAdminOrSuperAdmin — super admin or org admin (apps/backend/src/gql/middlewares/isAdminOrSuperAdmin.ts).
  • isSuperAdmincontext.user.is_super_admin must be true; must run after populateUserWithMemberships (see example below).

How to add a new role

This section shows how to introduce a new public role and ensure it grants the right scopes per product.
  1. Extend PublicRoles in apps/backend/src/constants/roles.ts (and Roles if it should exist at the enum level for non-public handling).
  2. Validate invite inputinviteMember only allows Object.values(PublicRoles) (apps/backend/src/gql/mutations/inviteMember.ts); update that validation if the role should be invitable.
  3. Data — insert rows in ProductRoleScope (and related Scope records) for each product that should grant that role’s permissions. Models: apps/backend/src/tools/rds/sequelize/models/productRoleScope.ts, apps/backend/src/tools/rds/sequelize/models/scope.ts.
  4. Cache invalidationProductRoleScope.afterSave / afterDestroy enqueue a QueueName.SCOPE_CACHE_RESET job; scopeCacheResetConsumer clears the matching Redis entry via clearCachedScopes. Redis TTL (24h) acts as a safety net, but the primary refresh path is event-driven.

How to add a new permission (scope)

This section shows how to add a new scope string and attach it to product-role mappings.
  1. Add enum member to AllScopes in apps/backend/src/utilities/rbac.ts.
  2. Database — create or use a scopes row with name equal to that string; link it via product_role_scopes for the relevant product_id + role.
  3. Use it — protect resolvers with hasScopes([AllScopes.YOUR_SCOPE], ScopeMatchMode.EVERY) (or ANY).
  4. Clear cacheclearCachedScopes({ productId, role }, tools) from apps/backend/src/utilities/rbac.ts when you change mappings.

Code examples

Use these examples to see the intended middleware ordering for role-scoped and scope-scoped resolvers.

Example: super-admin-only query

export const users: ResolverType<UsersParams, UsersReturn> = async (
  _parent,
  form,
  context: ContextWithUser,
) => {
  return resolveWithMiddlewares(_parent, form, context, [
    isLoggedIn,
    populateUserWithMemberships,
    isSuperAdmin,
    async (_parent, form, context: ContextWithUser) => {

Example: scope-gated mutation (invite)

export const inviteMember: ResolverType<
  InviteMemberParams,
  InviteMemberReturn
> = async (_parent, params, context) => {
  return resolveWithMiddlewares(_parent, params, context, [
    isLoggedIn,
    hasScopes([AllScopes.MEMBERS_INVITE], ScopeMatchMode.EVERY),
    async (_parent, form, context: ContextWithUser) => {

Example: org-level permission check in a utility

resetInvitation requires AllScopes.MEMBERS_INVITE via canAccessScopes (apps/backend/src/utilities/organization.ts). These tables back the pipeline from organization membership roles to product-scoped permissions.
ConcernModel / table directionFile
Membership + roleOrganizationMemberapps/backend/src/tools/rds/sequelize/models/organizationMember/index.ts
Product ↔ scope ↔ roleProductRoleScopeapps/backend/src/tools/rds/sequelize/models/productRoleScope.ts
Scope catalogScopeapps/backend/src/tools/rds/sequelize/models/scope.ts

Pitfalls

These are the highest-risk failure modes when wiring middleware or changing scope mappings.
⚠️ Watch out: no active subscription means getScopesForOrganizationMembership returns an empty scope set ([]), so hasScopes rejects with 403. ⚠️ Watch out: cache invalidation is event-driven via QueueName.SCOPE_CACHE_RESET (triggered by ProductRoleScope.afterSave/afterDestroy); TTL is a fallback. ⚠️ Watch out: isSuperAdmin reads context.user.is_super_admin and throws 403 when it is false, so ensure populateUserWithMemberships runs before it (so context.user exists).

What’s next?

Billing is the primary consumer of these scope gates; see apps/documentation/architecture/billing.md.