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.
| Layer | Question | Built in? | Where it is enforced |
|---|---|---|---|
| Organization | Which tenant's data is this? | Yes, every starter | organizationId filter in every query |
| Role | What may this member do in the org? | Yes, admin and member roles | hasAccess on routes, hasPermission in UI |
| Resource | Which specific records may this person see? | No. Teams and projects record members only | Your 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.
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.
| Action | Meaning |
|---|---|
read | View |
write | Create |
update | Change |
delete | Remove |
manage | Any action. manage:all is any action on anything |
What ships:
| Role | Permissions |
|---|---|
admin | manage:all |
member | read: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:
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:
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.
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
-
Add the subject to
packages/enums/src/permissions/subjects.ts:export enum PermissionSubject { // ... INVOICE = "invoice", } -
Grant it in
packages/enums/src/permissions/presets.ts. Admins already have it throughmanage:all, so you only decide what members get:[OrganizationRole.MEMBER]: [ // ... p(PermissionAction.READ, PermissionSubject.INVOICE), ], -
Put
hasAccesson every route of the feature, andhasPermissionon the buttons that lead there. -
Run
pnpm generateso the clients see the new enum value. -
Write one test that calls a write route as a
memberand 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:
"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:
packages/enums/src/permissions/roles.ts: addACCOUNTANT = "accountant"toOrganizationRole.packages/enums/src/permissions/presets.ts: give it its own list, for examplemanage:invoiceandread:organization.frontend/src/features/settings/modules/organization-tab/members-card.tsx: add it to the role options so admins can assign it, and updatetoRoleValue, which today maps every non-admin slug tomember.ROLE_DISPLAY_NAMESin the backend, so the role shows a proper name.- 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.tsonly knowsowner,admin, andmemberuntil you pass it your ownroles.owneris mapped toadminbefore 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:
// 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),
),
);
}// 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:
// 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.toOrganizationreaches every connected member. For a restricted record, load its member ids and usewsEmit.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.
withAdminon admin routes lets in any platform admin, from theadmintable or theSUPER_ADMIN_EMAILallowlist.withSuperAdminnarrows 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.
Related
- Organizations: membership, invitations, and the permission flow
- Platform map: every built-in mechanism and where to register it
- Admin app