Tenancy
On this page
Use tenancy when your app is shared by many customer companies, so that
one company never sees another's data. Each customer is a tenant: one
record of an entity you choose, such as Company.
People belong to a company through a membership. A membership is a
stored record, such as a CompanyMembership, that says which user holds
which role in which company. Its entity has a membership: section naming
those three fields: scope, the company; member, the user; and role,
such as CompanyRole.owner (A membership role
shows one in full). Grants then name roles: an entry such as
CompanyRole.viewer in a record's read: list lets in the users who hold
that role in the record's company.
Naming the tenant
The project's root policy.jadpo, the policy file at the top of the
project beside app.jadpo, names the tenant entity once:
// policy.jadpopolicy { tenant: Company}
This file holds the rules for the whole app: the tenant, and the
actions that need a permission of their own. Each entity's own
policy: section still lists who may create, read, change and delete its
records. Naming a tenant adds these rules to every entity:
- Every entity needs one reference to
Company, its tenancy reference, such ascompany_id. A task carries its owncompany_id, not only its project's. - Every grant on a tenant's records applies only within companies where
the caller has a membership. A grant is an entry in one of an entity's
lists, such as
read: [CompanyRole.viewer](Grants). This holds forAccess.authenticated, which lets in anyone signed in, too: on a tenant's records it lets in anyone signed in who belongs to the record's company. - An entity every tenant shares, such as a list of countries or
Useritself, saystenant: Tenancy.sharedin itspolicy:. Its records belong to no company, and its grants apply as they would without tenancy.
A request has no current tenant of its own, and the signed-in caller
carries no company. Each record's tenant is the company its tenancy
reference names, and a caller who belongs to several companies is let in
to each one's records by their membership there. A route says which
company it works in through its input, such as a company_id in its path
or body, and policy checks the caller's membership in the company of
each record the route reads or changes.
An entity with two references to Company names, in its policy:, the
one that decides where it belongs, such as tenant: source_company_id.
An entity you add belongs to a tenant unless a person accepts otherwise, as every change that widens access needs (Protecting policy).
A company's first membership founds it, in the action that creates the
company (Founding a tenant). Every other membership
is created by someone who belongs already, as the membership's own
create: says. So nobody makes themselves a member, or an owner, of
someone else's company.
References stay inside the tenant
A reference from one of a tenant's records to another always points at a
record of the same tenant: a chore of Northwind's names one of Northwind's
projects, never Contoso's. Every create and update that sets such a
reference checks it as part of that same write, whether or not the
caller's grants would allow it. One that points into another tenant is
refused with 403 not_permitted. A reference to a record that doesn't
exist at all still gets that entity's own not-found failure, such as
ProjectNotFound.
Because a chore and its project are always in the same company, anyone who may read a chore has their role in the project's company too. So a query of chores may include each chore's project without showing anyone another company's data. Here a chore belongs to a company and to one of its projects:
use work/entities { Company, Project }use work/types { CompanyRole, Label }/// One step of a project.entity Chore { id: Uuid company_id: Company.id { on_delete: OnDelete.cascade } project_id: Project.id { on_delete: OnDelete.cascade } title: Label policy: { create: [CompanyRole.owner] read: [CompanyRole.owner, CompanyRole.viewer] }}
use work/entities { Chore, Company }use work/types { ChoreView }/// A company's chores, each with its project.query company_chores(company_id: Company.id) -> List<ChoreView> { from: Chore where: Chore.company_id == company_id order: [Chore.title asc] limit: 100 include: { project: Chore.project_id }}
In the first test, Ann owns both companies, so only the check on the project reference refuses her. In the second, Bob, a viewer of Northwind, sees its chores with their projects:
use work/entities { Company, CompanyMembership, Member }use work/types { ChoreCard, ChoreView, CompanyRole, ProjectCard }use work/actions { AddChore, add_chore, add_member, found_company, register, start_project }test "a chore can't name another company's project" { var ann: Member = attempt register("ann") var northwind: Company = found_company("Northwind", Principal.user(ann)) var contoso: Company = found_company("Contoso", Principal.user(ann)) var rival: ProjectCard = attempt start_project(contoso.id, "Rival") var response: Response = request { method: HttpMethod.post path: "/chores" as: Principal.user(ann) input: AddChore { company_id: northwind.id project_id: rival.id title: "Borrow" } } assert response.status == 403 assert response.code == "not_permitted"}test "a viewer sees each chore with its project" { var ann: Member = attempt register("ann") var bob: Member = attempt register("bob") var northwind: Company = found_company("Northwind", Principal.user(ann)) var viewer: CompanyMembership = attempt add_member(northwind.id, bob.id, CompanyRole.viewer) assert viewer.role == CompanyRole.viewer var launch: ProjectCard = attempt start_project(northwind.id, "Launch") var kickoff: ChoreCard = attempt add_chore( AddChore { company_id: northwind.id project_id: launch.id title: "Plan" } ) var seen: Response = request { method: HttpMethod.get path: "/chores/{company_id}" params: { company_id: northwind.id } as: Principal.user(bob) } assert seen.status == 200 assert seen.output == [ ChoreView { id: kickoff.id title: "Plan" project: launch } ]}
A reference straight to Company isn't checked this way. Either it is
the record's tenancy reference, which is what puts the record in its
company, or the entity has two references to Company, and the one its
tenant: doesn't name points at another company on purpose.
A unique rule holds within its tenant
A unique: rule on a tenant's records holds within each tenant. Northwind
and Contoso may each have a project called Launch, but a second Launch in
Northwind is the failure the create maps it to under conflict:, here
ProjectNameTaken. So a conflict never tells one company what another
stores.
The compiler does this by adding the record's tenancy reference to the
rule's key, the set of fields that must be unique together, so name
becomes unique per company_id. A rule that already includes the tenancy
reference is left as it is. An entity every tenant shares, and the tenant
entity itself, keep one key across the whole application. Here Member is
shared, so two members can never have the same handle.
use work/entities { Company }use work/types { CompanyRole, Label }/// A piece of work in a company.entity Project { id: Uuid company_id: Company.id { on_delete: OnDelete.cascade } name: Label { unique: true // within its company } policy: { create: [CompanyRole.owner] read: [CompanyRole.owner, CompanyRole.viewer] }}
use work/entities { Member }use work/types { SeatView }use work/actions { NewCompany, StartProject, found_company, register }test "two companies may each have a project called Launch, but not two in one" { var ann: Member = attempt register("ann") var bob: Member = attempt register("bob") var northwind: SeatView = found_company( NewCompany { name: "Northwind" }, Principal.user(ann) ) var contoso: SeatView = found_company( NewCompany { name: "Contoso" }, Principal.user(bob) ) var first: Response = request { method: HttpMethod.post path: "/projects" as: Principal.user(ann) input: StartProject { company_id: northwind.company_id name: "Launch" } } assert first.status == 201 var second: Response = request { method: HttpMethod.post path: "/projects" as: Principal.user(bob) input: StartProject { company_id: contoso.company_id name: "Launch" } } assert second.status == 201 var refused: Response = request { method: HttpMethod.post path: "/projects" as: Principal.user(ann) input: StartProject { company_id: northwind.company_id name: "Launch" } } assert refused.status == 409 assert refused.code == "project_name_taken"}
Since the key holds the tenancy reference, a query that finds one record
by a unique rule
(How many records come back)
must test the tenancy reference with == beside the rule's fields, such
as Project.company_id == company_id. It needn't when it tests a
reference to another of the tenant's records, since that record already
names the tenant.
The field a person signs in by, their subject, such as an email address, is on an entity every tenant shares, so it is unique across the app (The principal).
When you add tenancy to an existing app, jadpo migrate new drops each
unique key that didn't hold the tenancy reference and makes it again with
the tenancy column, UNIQUE ("name", "company_id"). The migration needs
no decision from you, since every set of rows the old key allowed, the new
one allows too.
Founding a tenant
A new company has no members, so nobody holds a role that could let
them create its first membership. That first membership founds the
company, and it is allowed in one place only: the action that creates the
company's record. There, the membership's scope is the id that the
company's create gave back, its member is the caller, and nobody belongs
to the company yet.
Any other new membership needs someone who belongs already, even one that
names its own caller as the member. In a grant list, a user field such as
member_id lets in the user it points to. So
create: [CompanyRole.owner, member_id] lets an owner add anyone, and
anyone else only themselves, and only in a company they already belong
to. A stranger can't add themselves to a company whose members have all
left: that is 403 not_permitted. Founding a new company still makes its
founder a member:
use work/entities { Company, Member }use work/types { CompanyRole }/// A member's role in a company.entity CompanyMembership { id: Uuid company_id: Company.id { on_delete: OnDelete.cascade } member_id: Member.id { on_delete: OnDelete.cascade } role: CompanyRole membership: { scope: company_id member: member_id role: role } policy: { create: [CompanyRole.owner, member_id] read: [CompanyRole.owner, member_id] delete: [CompanyRole.owner, member_id] }}
use work/entities { Company, CompanyMembership }use work/types { CompanyRole, Label, SeatView }/// What founding a company takes.type NewCompany = Input { name: Label}/// Starts a company with its founder as its owner.action found_company(input: NewCompany, founder: Principal.user) -> SeatView { var company: Company = Company.create { set: { name: input.name } } var seat: CompanyMembership = CompanyMembership.create { set: { company_id: company.id member_id: founder.id role: CompanyRole.owner } } return SeatView { id: seat.id company_id: seat.company_id role: seat.role }}
use work/entities { Member }use work/types { CompanyRole, SeatView }use work/actions { NewCompany, NewMember, found_company, leave, register }test "nobody seats themselves in a company everyone has left" { var ann: Member = attempt register("ann") var eve: Member = attempt register("eve") var seat: SeatView = found_company( NewCompany { name: "Northwind" }, Principal.user(ann) ) attempt leave(seat.id) var seized: Response = request { method: HttpMethod.post path: "/companies/{company_id}/members" params: { company_id: seat.company_id } as: Principal.user(eve) input: NewMember { member_id: eve.id role: CompanyRole.owner } } assert seized.status == 403 assert seized.code == "not_permitted" var founded: Response = request { method: HttpMethod.post path: "/companies" as: Principal.user(eve) input: NewCompany { name: "Contoso" } } assert founded.status == 201}
Moving a record to another tenant
Moving a record to another company is rare, and it needs an action of its
own. Only an action listed under actions: in root policy.jadpo may do
it (Actions shows the list). The write checks every reference
the record keeps, and that no record of the old company refers to it. In
the new company, it needs what creating the moved record there would: the
entity's own create: and the caller's own membership there.
A membership moved in from another company holds no role its mover couldn't give there. So a viewer can't move in an owner's membership they hold elsewhere.
Markdown for agents: /docs/tenancy.md.Built from Jadpo main at a707efb.