How tenancy and roles work

Why one organisation can never see another's data, how a person's tenant is resolved from their session, and how the base roles combine with additive grants like finance and recruiter.

Explanation · ~8 min read · Updated 18 Aug 2026

Multi-tenancy is the property everything else in ASHR.work depends on. If it fails, nothing else matters. This page describes how it is enforced and where the boundaries actually sit.

How your organisation is identified

Every request follows the same chain:

  1. The session identifies a person

    Authentication resolves the signed-in user. Nothing here is supplied by the page being visited.

  2. The person resolves to a profile

    That user id looks up exactly one profile record.

  3. The profile resolves to an organisation

    The profile carries the organisation it belongs to. This is the only place an organisation is ever derived from.

What matters is what is not in that chain. The organisation is never taken from a URL segment, a query parameter, a request header, or a cookie. There is no "organisation id" a browser can send, so there is nothing to tamper with. A page cannot ask for another organisation's data, because a page does not get to say which organisation it wants.

A profile belongs to exactly one organisation, and there is no tenant switcher.

Isolation is enforced by the database, not the app

Application code is not the last line of defence. Every per-organisation table carries an organisation column and a restrictive isolation policy on top of it, evaluated by the database on every single query.

Restrictive is the operative word. Database policies normally combine permissively — add a broad one and it widens access. A restrictive policy combines the other way: it is intersected with every other policy on the table. Even if a broader rule existed alongside it, the result is still limited to your own organisation. And because the organisation column defaults to the caller's own organisation, a write that forgets to set it lands in the right place rather than a shared one.

Two rules govern how new policies get written:

  • Deny by default. No policy may be open to anonymous or merely authenticated callers. This is not a stylistic preference — permissive catch-all policies are exactly what has caused real cross-organisation exposure in the past, and both incidents are recorded and fixed.
  • Every policy ships with a deny test as well as an allow test. A policy that has only been proven to grant the right access has not been proven to refuse the wrong access. The isolation suite runs in continuous integration and asserts two things per protected table: an anonymous caller reads zero rows, and two different organisations see completely disjoint records.

The reporting hierarchy gets the same treatment. Walking a manager's reports — including reports of reports — is constrained to one organisation at every level of the recursion, so a mis-set reporting line cannot become a path across the boundary.

Roles: a small base, plus additive grants

The base role is one of three: admin, manager, or employee. Rather than multiplying roles for every combination of duties, extra capability is granted additively on top:

| Grant | What it gives | |---|---| | Delegated admin access | HR-admin capability without changing someone's role | | Finance | Access to payroll | | Recruiter | The full recruitment module without general HR admin | | Hiring manager | Only the openings that person owns, and those candidates |

This is what lets you give your accountant payroll access and nothing else, or let a hiring manager run their own pipeline without seeing the salary of every employee.

Payroll has an additional organisation-level switch. Turn on the finance-only restriction and payroll pages and actions are limited to people holding the finance grant — HR admins lose their route in. Only an org admin can change that switch, so a finance user cannot widen their own access, and the restriction refuses to turn on if it would lock everybody out of payroll.

Four places access is enforced

Understanding where each check lives explains why some things behave the way they do:

  1. Database isolation — the organisation boundary, on every query.
  2. Module gating — modules your organisation has not enabled are not reachable at all; the page does not exist rather than refusing you.
  3. Checks inside each action — every mutation is a server-side action that re-verifies who you are and what you may do. Mutations are not exposed as a public write API.
  4. Navigation visibility — which is cosmetic only. Hiding an item from the sidebar tidies the interface; it does not protect anything, and nothing relies on it for security.

Every mutating action also writes an audit-log entry naming the actor and what changed, best-effort, so auditing never blocks the user's action.

Joining an organisation is invitation-only

You cannot sign yourself into somebody else's organisation. Account creation is gated on an allow-list: trusted server-side code records an invitation naming the email address, the organisation, and the role. When that person signs up, the invitation is matched by email. No matching invitation means the account is not created at all. Nothing the sign-up form submits about which organisation to join or which role to have is trusted — those come from the invitation.

Two paths write invitations: inviting an employee, which grants the employee role, and registering a new organisation, which makes the registrant its first admin.

What is shared, and what is not

It is worth being precise, because "isolated" does not mean "separate installation".

Isolated per organisation: your people, leave requests and balances, payroll runs and payslips, documents, appraisals, helpdesk tickets, assets, surveys, recruitment pipelines, your audit log, your configuration — the employee-ID format, the leave-escalation window, branding, and which modules are enabled.

Shared infrastructure, scoped per organisation: one database and one authentication system serve every organisation, with the boundary enforced in rows rather than by separate installations.

Shared read-only catalogues: the master holiday calendar and the built-in leave types are global records every organisation can read but none can edit. You customise them by overlay instead — add your own holidays and leave types, and switch off the global ones you do not observe. Your overlays are yours alone.

Platform staff hold a separate super-admin capability used to operate the service. Its reach was deliberately narrowed so that on day-to-day operational data it behaves like an ordinary user of a single organisation, and everything it does is attributed to the individual who did it in the audit log.

The service-role boundary

One credential in the system can bypass row-level isolation, because background jobs and scheduled work need it. Two things constrain it:

  • It is bound to a server-only module that fails the build if it is ever imported into code that would ship to a browser. The credential is not exposed to client code by construction, not by convention.
  • Every query made with it still carries an explicit organisation filter, so results never depend on the isolation policy being the only thing standing between two organisations. Scheduled jobs are additionally reachable only with a shared secret and refuse to run if that secret is not configured.

Frequently asked questions

Can one person belong to two organisations?
No. A profile carries exactly one organisation, and there is no tenant switcher. Someone who genuinely works with two organisations on ASHR.work needs a separate login for each.
Can somebody sign themselves up into our organisation?
No. A person can only become a user of your organisation if your organisation invited them first. A sign-up attempt with no matching invitation is rejected outright, and the role in that invitation is the role they get.
If an admin gets the URL of another organisation's record, can they open it?
No. The organisation is resolved from the signed-in session, never from the URL, and the database applies isolation independently of what any page requests.
Does hiding a module from the sidebar restrict access to it?
No, and it is important not to rely on that. Navigation visibility is cosmetic. Access is enforced by the database, by module gating, and by the checks inside each action.