Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Policy

Policy

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:

example
// 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 as company_id. A task carries its own company_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 for Access.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 User itself, says tenant: Tenancy.shared in its policy:. 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:

example
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]    }}
example
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:

example
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.

example
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]    }}
example
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:

example
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]    }}
example
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    }}
example
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.