Hype StackHypeStack

Access control

Access in a Hype Stack app is decided in three layers. Each one answers a different question, and a feature usually needs the first two and sometimes the third.

LayerQuestionBuilt in?Where it is enforced
OrganizationWhich tenant's data is this?Yes, every starterorganizationId filter in every query
RoleWhat may this member do in the org?Yes, admin and member roleshasAccess on routes, hasPermission in UI
ResourceWhich specific records may this person see?No. Teams and projects record members onlyYour query and module code, see the recipe

The admin app has a fourth, separate check for platform staff. See Platform admins.

Organization: the tenant boundary

Every feature table carries an organizationId, and every query filters on it. The value comes from ctx.var.organizationId, which withAuthUser sets only after confirming the user is a member of that organization.

ts
const invoices = await postgres.qb
  .selectFrom("invoice")
  .selectAll()
  .where("organizationId", "=", ctx.var.organizationId)
  .execute();

Never read an organization id from the body, the query string, or a route param. A request for a record that exists in another org should get the same 404 as a record that does not exist.

Role: what a member may do

Permissions are strings

A permission is action:subject, like read:organization or manage:project. Each role has a fixed list of them in packages/enums/src/permissions/presets.ts. On every request withAuthUser looks up the member's role, loads that list, and builds a CASL ability on ctx.var.ability.

ActionMeaning
readView
writeCreate
updateChange
deleteRemove
manageAny action. manage:all is any action on anything

What ships:

RolePermissions
adminmanage:all
memberread:organization, read:user, manage:project, plus what installed packs grant (below)

Packs add their own subjects on install. pack-teams adds team and gives members read:team, so members see teams and only admins create or change them. pack-booking adds booking (members get manage:booking) and booking_settings (admins only). The billing packs add billing, which only admins hold.

On the free auth starter every user is the only member of their personal workspace, with the admin role, so every check passes. Roles start to matter when you move to a SaaS starter and invite people.

Subjects are matched as exact strings. hasAccess({ subject: "Invoice" }) never matches a grant on invoice, and since admins pass through manage:all you only notice when a member hits a 403. Import the enums instead of typing strings.

Check it on the backend

Gate a route with hasAccess. It returns a 403 when the ability says no:

ts
import { PermissionAction, PermissionSubject } from "@<your-app>/enums";

import { hasAccess } from "@backend/middleware/permissions/has-access";

export const invoiceRoutes = getAuthenticatedApp()
  .get("/", hasAccess({ action: PermissionAction.READ, subject: PermissionSubject.INVOICE }), listHandler)
  .post("/", hasAccess({ action: PermissionAction.WRITE, subject: PermissionSubject.INVOICE }), createHandler);

When the answer changes what a module returns instead of whether it runs, ask the ability directly and pass the result in. The booking pack does this so members see their own private notes and admins see all of them:

ts
const { user, ability } = ctx.var;
const viewer = { userId: user.id, canSeeAll: ability.can("manage", "booking_settings") };
const schedule = await getSchedule({ organizationId, viewer });

Check it in the UI

useUser() returns the same permission list and a hasPermission(action, subject) helper with the same rules: manage:all passes everything, manage:<subject> passes any action on that subject, otherwise action and subject must match.

tsx
const { hasPermission } = useUser();
const canCreate = hasPermission(PermissionAction.WRITE, PermissionSubject.INVOICE);

{
  canCreate && <Button onClick={openCreate}>New invoice</Button>;
}

A nav entry takes permission: { action, subject } and is hidden from anyone without it. Hiding a button or a link is only for a clean UI. The route behind it still needs hasAccess.

Add a permission for your feature

  1. Add the subject to packages/enums/src/permissions/subjects.ts:

    ts
    export enum PermissionSubject {
      // ...
      INVOICE = "invoice",
    }
  2. Grant it in packages/enums/src/permissions/presets.ts. Admins already have it through manage:all, so you only decide what members get:

    ts
    [OrganizationRole.MEMBER]: [
      // ...
      p(PermissionAction.READ, PermissionSubject.INVOICE),
    ],
  3. Put hasAccess on every route of the feature, and hasPermission on the buttons that lead there.

  4. Run pnpm generate so the clients see the new enum value.

  5. Write one test that calls a write route as a member and expects a 403. Admin-only tests pass whatever the presets say, so they never catch a missing grant.

In a pack, steps 1 and 2 are the permissions manifest field. The CLI adds the subject and the grants on install:

json
"permissions": [
  {
    "name": "INVOICE",
    "value": "invoice",
    "grants": [{ "role": "member", "action": "READ", "subject": "INVOICE" }]
  }
]

Add a role

The starters ship admin and member. A third role, say accountant, touches these places:

  1. packages/enums/src/permissions/roles.ts: add ACCOUNTANT = "accountant" to OrganizationRole.
  2. packages/enums/src/permissions/presets.ts: give it its own list, for example manage:invoice and read:organization.
  3. frontend/src/features/settings/modules/organization-tab/members-card.tsx: add it to the role options so admins can assign it, and update toRoleValue, which today maps every non-admin slug to member.
  4. ROLE_DISPLAY_NAMES in the backend, so the role shows a proper name.
  5. The auth provider has to know the role exists:
    • WorkOS: create a role with the same slug under Roles in the WorkOS dashboard. Permissions still come from your presets, WorkOS only stores which member has which slug.
    • Better Auth: the organization plugin in apps/backend/src/libs/auth/auth.ts only knows owner, admin, and member until you pass it your own roles. owner is mapped to admin before the preset lookup.

Resource level: teams, projects, and your own records

This is the layer people expect and the starters do not ship. pack-teams and pack-projects store who belongs to which team or project, with a role of owner or member on each row, but no route reads it. Any org member with read:team sees every team, and any member with manage:project sees and edits every project. Team and project membership today is information, not a gate.

If you need "only the people on this project can open it", build it in two parts.

Filter lists by membership. Decide which permission means "sees everything". A clean split is to give members read:project instead of manage:project in the presets, keep manage:project for admins, and filter for everyone else:

ts
// features/projects/db/queries/get-many.query.ts
let query = postgres.qb.selectFrom("project").selectAll("project").where("project.organizationId", "=", organizationId);

if (!viewer.canSeeAll) {
  query = query.where((eb) =>
    eb.exists(
      eb
        .selectFrom("projectMember")
        .select("projectMember.id")
        .whereRef("projectMember.projectId", "=", "project.id")
        .where("projectMember.userId", "=", viewer.userId),
    ),
  );
}
ts
// routes/projects/index.ts
const viewer = { userId: user.id, canSeeAll: ability.can("manage", "project") };

Check membership on single records. One helper, called at the top of every route that takes a :projectId:

ts
// features/projects/modules/assert-project-access/index.ts
export async function assertProjectAccess({ projectId, organizationId, viewer, roles }: AssertProjectAccessParams) {
  if (viewer.canSeeAll) return;

  const member = await postgres.qb
    .selectFrom("projectMember")
    .innerJoin("project", "project.id", "projectMember.projectId")
    .select("projectMember.role")
    .where("project.id", "=", projectId)
    .where("project.organizationId", "=", organizationId)
    .where("projectMember.userId", "=", viewer.userId)
    .executeTakeFirst();

  if (!member) {
    throw new ApplicationError({
      code: ApplicationErrorCode.NOT_FOUND,
      message: m.projects_error_not_found(),
      statusCode: 404,
    });
  }

  if (roles && !roles.includes(member.role)) {
    throw new AuthorizationError({
      code: AuthorizationErrorCode.AUTHORIZATION_MISSING_PERMISSION,
      message: m.permissions_error_missing(),
      statusCode: 403,
    });
  }
}

Reads call it with no roles. Settings and member management call it with roles: ["owner"]. A non-member gets a 404, the same as a project that does not exist, so ids from other people's projects reveal nothing.

Two more places follow the same rule:

  • Realtime. wsEmit.toOrganization reaches every connected member. For a restricted record, load its member ids and use wsEmit.toUsers.
  • Files. Serve attachments of a restricted record through a route that runs assertProjectAccess, not as a public storage URL.

The same pattern works for teams (teamMember), and for any record of your own that needs per-record access: a membership table with userId and role, a list filter, and one assert helper.

Platform admins

The admin app is for whoever runs the product, not for your customers. It has its own roles, super_admin and admin (AdminRole in the enums), and they have nothing to do with organization roles. An org admin is not a platform admin.

  • withAdmin on admin routes lets in any platform admin, from the admin table or the SUPER_ADMIN_EMAIL allowlist.
  • withSuperAdmin narrows a route to super admins only.

Cross-organization views and support tools belong there, never in the customer frontend behind a role check.

Feature flags are not permissions

A feature flag says whether an organization has a feature. A permission says whether a person in that organization may use it. "Reports are in beta for three customers, and only their admins can export" is a flag on the route plus a permission on the export.