openJIIDocs
Architecture & Pipeline

Identity & access management

Authentication methods, sessions, API keys, and the organization-scoped authorization model: one decision point, the Better Auth / Nest split, where the guards live, and what is still open.

openJII separates three concerns that are easy to conflate:

  1. Identity answers which account is acting.
  2. Authentication proves that identity with an email code, OAuth provider, passkey, browser session, or personal API key.
  3. Authorization decides what that account may do to an organization-owned resource.

An authentication method never creates a second permission model. A browser session and an API key for the same user reach the same authorization service and receive the same resource decision.

This page is about mechanism. For what the product does — roles, teams, transfers, the directory — read Organizations and Who can access your work? in the user guide.

The model shipped in four phases: the organization substrate, the authorization substrate, grants becoming the only source of resource access, and the product surface that made organizations reachable at all. That sequence is history now — everything below describes the current system, not a direction of travel.

Authentication paths

Interactive browser sessions

The web client uses the shared Better Auth server in packages/auth. Interactive sign-in supports:

  • a six-digit email one-time code;
  • GitHub;
  • ORCID when configured; and
  • a passkey on browsers with WebAuthn support.

The last-login-method plugin records the successful method in a browser cookie. The login page uses it only to show a Last used hint; it is not an account setting or an authorization signal.

Browser sessions use a 30-day sliding lifetime. Activity renews the session at most once per day, and the cookie cache is valid for one day. After roughly 30 days without activity, the user must authenticate again.

Passkey ceremonies run on the web origin. The relying-party ID is derived from the shared cookie domain in deployed environments and falls back to the web hostname in local development. Passkeys can be created, renamed, and deleted; deleting one does not invalidate other sessions or sign-in methods.

Signing in accepts nothing. A pending organization invitation survives every sign-in and sign-up untouched: it is claimed only when the recipient opens /platform/account/invitations and accepts, through Better Auth's own accept-invitation endpoint. A membership reaches everything the organization owns, and an admin or owner role makes its holder answerable for other people's work, so joining is a decision rather than a side effect of logging in.

Signing in does accept pending resource invitations — a share of one experiment, macro, protocol, workbook or device. That hook lives in the users module and is attached to the OAuth callback paths as well as the email paths, because /sign-in/social only hands back a redirect URL and the session is created when the provider redirects back. It is written to heal rather than fire once: no early exit, retried on every sign-in, and it never fails the auth flow.

There is no active organization

Better Auth's organization plugin supports an active organization on the session, and openJII never sets one. session.activeOrganizationId is read in two places as a fallback — because Better Auth's own routes resolve their target that way — and is always empty in practice.

Organization selection is per action instead: a picker on each create form, a target picker on transfer. This is deliberate. An active organization is ambient state that has to be kept correct, and every read that trusted it would be a read that could be wrong after a role change.

The same reasoning is why memberships are not embedded in the session. The session cookie is cached for a day, so an embedded membership map would go stale on every role change and inflate every request; GET /users/me/organizations is the read instead.

Personal API keys

A personal API key is a non-browser credential for REST clients:

x-api-key: jii_...

The key:

  • is shown once, then stored as a hash;
  • must have a name and may expire after up to 365 days or have no expiration;
  • is independently rate-limited to 100 requests per minute by default;
  • records its prefix, creation time, last request, and expiration for the account UI; and
  • can be revoked immediately.

Keys are deliberately not accepted by Better Auth account-management routes. The custom key reader returns a key only for the internal /get-session check performed by the Nest authentication guard. Better Auth then supplies a mocked session for that request, allowing ordinary protected REST handlers to identify the user without allowing the key to create other keys, register passkeys, or change organization settings through auth routes.

API keys currently have no independent scopes. A key acts as its owner and receives whatever that user is allowed to do. Treat it like a password, use an expiration where practical, and revoke it immediately if it leaks.

One decision point

AuthorizationService.can(userId, request) in apps/backend/src/authorization/authorization.service.ts is the only place a per-resource access question is answered.

resource exists and ownership loads
  -> role in the resource's owning organization
  -> direct user grant
  -> grant to one of the user's teams
  -> grant to one of the user's organizations
  -> public visibility, for read actions only
  -> deny

That list is attribution order, not first-match, and the distinction is the single most important thing on this page. A source whose tier does not cover the requested action falls through rather than denying, so effective access is the strongest that any source grants. The order decides which source an allowed decision is credited toorg-role, a grant tier, or public — not which source gets to refuse.

Two consequences follow. A grant can only ever raise access, never lower it: an organization member handed a higher grant gets the higher one, and an admin holding a stale low grant keeps their admin access. And there is no platform-administrator tier anywhere in the chain.

Denials distinguish not-found from forbidden, and can() also returns the owning organization it resolved against. That return value matters: a caller that authorizes and then separately re-reads the resource's organization has a window in which a transfer can land, leaving it acting on an organization nobody authorized for.

can() takes an optional executor so a caller can re-ask the same question inside its own transaction, on rows it already holds locked, rather than trusting an answer read before the locks. There is deliberately no second implementation of the decision for that path.

Two matrices, because the middle tier means two things

packages/auth/src/access.ts is the source of truth for the five organization-owned resource types — experiment, protocol, macro, workbook, device — and the five actions read, contribute, update, share, manage. contribute sits between read and update so that seeing an experiment never implies writing measurements or annotations into it. Only experiments have data to contribute to, but the verb stays in the statement for every type because Better Auth needs one literal action list.

The file exports two role matrices:

MatrixRole stored onRead byMiddle tier
rolesorganization_members.roleorgRoleCan()memberread, plus contribute on an experiment
grantRolesresource_grants.rolegrantRoleCan()viewerread, plus contribute on an experiment

owner and admin mean full control in both, so grantRoles reuses roles.owner and roles.admin directly; only the middle tier is redefined.

On an experiment the two middle tiers now agree, and that is the point: being handed the lowest grant tier must not beat belonging to the organization that owns the experiment. They still disagree nowhere else — viewer and member are both read-only on every other type — which is why the matrices stay separate rather than collapsing into one. A public experiment's passer-by still gets read alone; that comes from the visibility branch in can(), not from either matrix.

grantRoleCan() resolves a role by exact match and refuses anything it does not recognise. resource_grants.role is a plain text column, so what holds it to that set is GRANT_ROLES in @repo/database, which types every grant write path and is cross-checked against the matrix in packages/auth/src/access.spec.ts. One spelling per tier is what makes both the matrix and a WHERE role = 'viewer' trustworthy.

Both matrices read a possibly multi-valued column. organization_members.role holds Better Auth's role string, which can be comma-joined ("member,admin"), and both helpers split, trim and accept the row if any token grants. Unknown tokens are ignored, so a stale or renamed role confers nothing.

organizations.base_permission remains in the schema with a read default and is read nowhere in the access path. The member role's baseline is folded into the matrix instead; a configurable per-organization dial is deferred.

The hybrid split: Better Auth writes, Nest reads

Two systems own the organization surface, and the boundary is not arbitrary.

Better Auth owns the writes on its own models — creating and updating an organization, invitations, adding and removing members, role changes, and team CRUD. Reimplementing those would mean reimplementing its permission statements, its last-owner counters and its invitation lifecycle, and then keeping three copies of each in agreement. So the plugin in packages/auth/src/organization/plugin.ts is configuration and hooks around Better Auth's endpoints rather than a replacement for them.

The Nest organizations module owns every read, plus the join-request domain and the lifecycle rules. Reads are where openJII's own access questions live — an access-scoped resource showcase, a members list with profile joins, an outside-collaborators view derived from resource_grants — and none of them are questions Better Auth models.

One write is Nest's, and it is worth knowing about. addOrganizationMember writes an organization_members row directly through OrganizationRepository, bypassing Better Auth entirely. It exists because an invitation is for reaching an address that may have nobody behind it, while somebody picked out of the platform's own user search already has an account.

The consequence for a reader of this code: the canonical-role guard in Better Auth's beforeAddMember hook does not protect this path. What holds the role to one spelling here is the contract's enum plus canGrantOrganizationRole in apps/backend/src/organizations/core/organization-access.ts. Two paths write the same table under two different guards.

Better Auth's own validation is weaker than the product needs in three places, which is what the plugin's hooks exist to correct:

  • Slugs. Better Auth validates only non-empty and unique. openJII requires lowercase alphanumeric with single interior hyphens, capped at the column width, and reserves the personal- prefix. Without that guard, create({ slug: "personal-lab" }) mints an organization the whole personal-workspace treatment misclassifies — undeletable, memberless, permanently unmanageable.
  • Role spelling. Better Auth validates a role after trimming but gates who may hand out the creator role on the untrimmed string, then stores it verbatim. So " owner" or "member, owner" passes its check and lands in the column; every reader trims, so such a row reads as a full owner that Better Auth's own permission check and last-owner counters do not recognise. All three membership writes now refuse anything but one exact spelling.
  • Admin scope. Better Auth's default adminAc carries organization: ["update"], so an admin could rename the organization and change its slug. That statement is emptied in our admin role, so settings are owner-only.

Two smaller configuration facts have visible consequences. visibility is registered as an organization additional field, because Better Auth silently strips unknown keys from its update body — without it the directory toggle would return 200 and change nothing. And the auto-created default team is off: with teams.enabled, Better Auth creates a team named after the organization on every create, which would appear in the teams list and the grantee picker having been made by nobody.

Where the guards live

There are three placements, and which one a rule needs is decided by what Better Auth actually fires — not by preference.

PlacementWhereUse it for
organizationHookspackages/auth/src/organization/plugin.tsThe plugin's own writes: create, update, invitation, add member, role change, team create, delete
Plugin hooks.before / hooks.aftersame fileNarrowing reads, which no organizationHooks entry covers
Nest @BeforeHook / @AfterHookapps/backend/src/*/presentation/hooks/Paths Better Auth fires no organization hook for at all

Three details make this concrete:

organizationHooks cover writes only. There is no read hook among them, so the two reads that needed narrowing are handled by hooks.before and hooks.after on the plugin's own request pipeline — before to refuse ahead of Better Auth's endpoint, after to withhold part of what it answered. get-full-organization returned every invitation row to any plain member and answered a missing organization differently from a non-member, so any signed-in caller could enumerate which ids and slugs exist.

Those read gates are attached to the plugin, not to the auth instance or to a Nest hook. Anything mounting openJiiOrganization() — the server config and the tests alike — carries the gates with it.

Path matching is exact string equality, with no wildcards. /organization/leave fires no organization hook at all, so the personal-workspace shield for it is a Nest @BeforeHook("/organization/leave") naming the endpoint verbatim. That shield is the whole of the organization module's Nest hook: organization invitations never auto-accept. Whatever role one carries, it is claimed on /platform/account/invitations through Better Auth's own accept-invitation endpoint, because a membership reaches everything the organization owns and joining is the recipient's decision to make.

The same exact-path mechanism still carries the resource invitation auto-accept in the users module, which is why its OAuth callback hooks are declared as /callback/:id and /oauth2/callback/:providerId — those are Better Auth's own route strings, compared whole, rather than patterns being matched.

For per-resource decisions inside the Nest app, controllers declare policy instead of reproducing membership checks:

  • @CanAccess({ resource, action }) authorizes an existing resource — including action: "contribute" on the measurement, upload, and annotation routes, which keeps data entry stricter than generic read access; and
  • @CanCreateInOrg() checks organization membership before a resource exists.

The global authentication guard first populates request.session. Resource guards then use only its user ID and delegate to can(). Malformed resource IDs fail as 400, missing resources as 404, denied actions as 403.

resource_grants is polymorphic, so nothing cascades

One table carries every explicit grant. Its grantee is a user, a team, or an organization, discriminated by grantee_type:

granteeType: granteeTypeEnum("grantee_type").notNull(),
granteeId:   uuid("grantee_id").notNull(),   // no .references()

grantee_id and resource_id are bare UUIDs with no foreign key — a polymorphic column cannot have one, because it points into different tables depending on a sibling column. Only created_by has an FK.

The consequence is that nothing cleans up after itself. Delete a team or an organization and the grants naming it survive as access nobody can see or revoke, ready to be re-associated with a future team or organization that reuses the id. So teardown is explicit: afterDeleteOrganization and afterDeleteTeam sweep the rows. They are after hooks on purpose — a refused delete must leave the grants it would have torn down exactly where they were.

Any new grantee kind, or any new way to delete one, has to add its own teardown. The database will not remind you.

Lifecycle: husks, transfer, and deletion

The husk

A husk is an organization nobody can be held answerable for: one whose living owners are all gone. Most of the rules below exist to prevent or escape it. Two definitions of "living membership" are in play, deliberately:

TestCountsRead bySwapping it in would
AnswerabilityOwners only (livingOrgOwnerIdsSql)The staffing invariant, both account-deletion blockersLet an organization with admins but no owner read as answerable, though nobody in it can grant the owner role again
OperabilityOwners and admins (orgHasLivingFullControlMember)Transfer's husk escapeTreat an organization that merely lost its owner as abandoned

Ownership is what another member cannot take away, which is why answerability hangs on it alone.

Transfer has an organization gate on top of can(manage)

Moving a resource between organizations is gated twice: can(manage) on the resource, and authority over the organization losing it — owner or admin there (mayTransferOutOfOrganization in transfer-authority.ts).

The second half is not redundant. Grant roles carry manage and revoke is share-gated, so without it any outside collaborator holding an edit tier could move somebody else's work into their own personal workspace and lock the owning organization out of it.

A husk is the exception, having nobody to expropriate from: where every owner and admin has closed their account, whoever still holds control through a grant may carry the resource out, or it is stranded. That is the operability test.

The transaction re-asks both halves on its own handle once its locks are held, using the same can() evaluator rather than a second reading of the grant tables. A resource that moved since the decision was made is refused. Devices are excluded by the type rather than by a runtime refusal.

Two account-deletion blockers, each enforced twice

Account deletion is refused while either is true:

  1. The user is the last person answerable for a resource — two prongs in blockingResourcesQuery (resource-staffing.ts): the owning organization's sole living owner is this user, or the organization is already a husk and this user holds the only full-control grant. Prong one needs no grant at all. Both escape when somebody else holds an admin/owner grant.
  2. The user is the sole living owner of a non-personal organization, whether or not it owns anything. Personal workspaces are excluded and have to be: everyone permanently and solely owns their own, so counting them would refuse every deletion on the platform.

Each is checked twice — pre-flight and unlocked to drive the dialog, then again inside the deletion transaction under FOR UPDATE. The pre-flight answer is raceable by construction, which is why nothing acts on it.

The locks are on owner-membership rows, not grant rows, and that is load-bearing: a resource owned outright has no grant rows, so locking those would lock nothing and two concurrent deletions would each conclude the other's owner would still be there. Lock order is user → organization → resource → grants everywhere.

Organization deletion is blocked, never cascaded

An organization cannot be deleted while it owns any resource of the six owned types, and deletion never cascades — where GitHub cascade-deletes an organization's repositories. Devices own live AWS Things and certificates that only their own delete path can tear down, and a platform whose visibility transitions are monotonic should not vaporize public resources behind one confirmation dialog. A device group holds no work at all, but its grants are polymorphic and cleaned only by the group's own delete path, so cascading one away would leave access behind naming a group that no longer exists. What the six share is that dropping a row by SQL leaves something behind. The way out is to transfer or delete each resource.

Implementation map

ConcernSource
Better Auth server, sessions, pluginspackages/auth/src/server.ts
Organization plugin, hooks, read gatespackages/auth/src/organization/plugin.ts
Organization lifecycle and grant teardownpackages/auth/src/organization/lifecycle.ts
Browser auth clientpackages/auth/src/clients/client.web.ts
API-key session boundarypackages/auth/src/api-key-session.ts
Shared role/action matricespackages/auth/src/access.ts
Personal organization provisioningpackages/database/src/organizations.ts
Resource ownership and grantspackages/database/src/schema.ts, packages/database/src/resource-grants.ts
Central resource decisionsapps/backend/src/authorization/authorization.service.ts
Controller policy guardsapps/backend/src/authorization/*guard.ts
Organization reads, join requestsapps/backend/src/organizations/
Nest-side auth hooksapps/backend/src/organizations/presentation/hooks/
Organization role authority checksapps/backend/src/organizations/core/organization-access.ts
Grant read/write surfaceapps/backend/src/sharing/
Staffing invariants and row locksapps/backend/src/sharing/core/resource-staffing.ts
Transfer authority gateapps/backend/src/sharing/core/transfer-authority.ts
One-way visibility transitionsapps/backend/src/visibility/
List-query access scopingapps/backend/src/common/utils/resource-access-scope.ts
Account security UIapps/web/components/account-settings/, apps/web/components/auth/

For the user-facing workflow, read Account security & API keys and Organizations. For the generated endpoint contract, use the REST API reference.

On this page