Source: https://www.hype-stack.dev/docs/backend/access-control

# 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](#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`.

| 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:

```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](/docs/backend/organizations#feature-flags) 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](/docs/backend/organizations): membership, invitations, and the permission flow
- [Platform map](/docs/development/platform-map): every built-in mechanism and where to register it
- [Admin app](/docs/packs-templates/packs/admin)
