Keep customers' data apart
You end with a tenant named once, memberships and roles, and tests proving one company can't reach another's records.
On this page
When many customer companies share your app, one must never see another's data. In Jadpo you name the entity that is a customer, and every record then belongs to one. A grant only ever applies inside companies where the caller has a membership, and that holds for every query and write in the app, with no filter for you to remember. This guide makes a work tracker where each company's projects and chores stay its own.
You start from an app where people sign in as a Member, with an action called
register, and with queries and output types for projects and chores.
Add sign-in and Query records show how to
write those.
Name the tenant
A tenant is one record of the entity you choose. The root policy.jadpo names that
entity once. An action that isn't reached through an entity's own list, such as starting
a company, is listed under actions:.
policy.jadpo:
use work/entities { Company }use workpolicy { tenant: Company actions: { work.found_company: [Access.authenticated] }}
Naming a tenant adds a rule to every entity: it needs a reference to Company. Leave one out
and jadpo check refuses it:
error Note doesn't belong to a Company
--> domains/work/entities/note.jadpo:4:8
|4 | entity Note { | ^^^^ no reference to `Company`
- reason
- Root
policy { }namesCompanyas the tenant, so every entity carries its own reference to it and its grants apply only within theCompanys where the caller has a membership; an entity every tenant shares saystenant: Tenancy.shared. - next
- Add a field typed
Company.idtoNote, ortenant: Tenancy.sharedto itspolicy:(agent) - rule
- policy.
scope_ missing - help
- https://jadpo.
dev/docs/errors/policy. scope_ missing
Member is the other way out: its records belong to no company, so its policy: says
tenant: Tenancy.shared. A person reads a note saying so when it goes in.
domains/:
use work/types { Handle }/// Someone who signs in.entity Member { id: Uuid handle: Handle { unique: true } policy: { create: [Access.public] read: [id] tenant: Tenancy.shared }}
Say who belongs where
People belong to a company through a membership, a stored record saying which member holds
which role in which company. Its membership: section names those three fields. Grants then
name roles: CompanyRole.viewer in a read: list lets in members who hold that role in the
record's company.
domains/:
/// What a member may do in a company.type CompanyRole = Enum { owner viewer}
domains/:
use work/types { CompanyRole, Label }/// A customer: the tenant every other record belongs to.entity Company { id: Uuid name: Label policy: { read: [CompanyRole.owner, CompanyRole.viewer] }}
domains/:
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] read: [CompanyRole.owner, member_id] }}
Found a company and add people
A new company has no members, so nobody holds a role that could let them create its first membership. That first one is allowed in one place only: the action that creates the company, naming the caller as the member. Every other membership needs someone who belongs already, so nobody can make themselves a member of someone else's company.
domains/:
use work/entities { Company, CompanyMembership }use work/types { CompanyRole, Label }/// Starts a company with its founder as its owner.action found_company(name: Label, founder: Principal.user) -> Company { var company: Company = Company.create { set: { name: name } } var owner: CompanyMembership = CompanyMembership.create { set: { company_id: company.id member_id: founder.id role: CompanyRole.owner } } assert owner.role == CompanyRole.owner return company}
domains/:
use work/entities { Company, CompanyMembership, CompanyNotFound, Member, MemberNotFound }use work/types { CompanyRole }/// Gives a member a role in a company; only its owners may.action add_member( company_id: Company.id, member_id: Member.id, role: CompanyRole) fails CompanyNotFound, MemberNotFound -> CompanyMembership { return attempt CompanyMembership.create { set: { company_id: company_id member_id: member_id role: role } }}
Give each record its tenant
Each entity has its own reference to Company, its tenancy reference, even a chore whose
project already has one. A request has no current tenant of its own, and the caller carries
no company. A record's tenant is the one its reference names, and someone who belongs to
several companies is let in to each by their membership there.
domains/:
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 policy: { create: [CompanyRole.owner] read: [CompanyRole.owner, CompanyRole.viewer] }}
domains/:
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] }}
A route says which company it works in through its input.
domains/:
use work/entities { Chore, Company, CompanyNotFound, Project, ProjectNotFound }use work/types { ChoreCard, Label }/// What adding a chore takes.type AddChore = Input { company_id: Company.id project_id: Project.id title: Label}/// Adds a chore to a project.action add_chore(input: AddChore) fails CompanyNotFound, ProjectNotFound -> ChoreCard { var chore: Chore = attempt Chore.create { set: { company_id: input.company_id project_id: input.project_id title: input.title } } return ChoreCard { id: chore.id title: chore.title }}
domains/:
use work/entities { Company }use work/types { ChoreCard, ChoreView }use work/actions { AddChore, add_chore }use work/queries { company_chores }/// A company's chores.routes Chores { prefix: /chores /// Adds a chore. POST / { input: AddChore output: ChoreCard status: SuccessStatus.created run: add_chore(input) } /// A company's chores, each with its project. GET /{company_id: Company.id} { output: List<ChoreView> run: company_chores(company_id) }}
Run jadpo migrate new add_tenants, then jadpo records update.
Test the walls
Tests make requests as each person, which is what proves one company can't reach another.
domains/:
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 } ]}test "someone from another company sees none of its chores" { var ann: Member = attempt register("ann") var eve: Member = attempt register("eve") var northwind: Company = found_company("Northwind", Principal.user(ann)) 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" } ) assert kickoff.title == "Plan" var seen: Response = request { method: HttpMethod.get path: "/chores/{company_id}" params: { company_id: northwind.id } as: Principal.user(eve) } assert seen.status == 200 assert seen.output == []}
jadpo testtested: 3 passed, 3 route testsThese three tests show the three things to know:
- A reference can't cross companies. Every write that sets a reference checks it points at
the same tenant, whatever the caller's grants say. Ann belongs to both companies and still gets
403 not_permitted, because a chore of Northwind's can't name Contoso's project. - A member reads what their role lets them. Bob is a viewer, so he sees each chore along with its project, which is always in the same company.
- An outsider finds nothing. Eve's request answers
200with an empty list, not403, as if the chores weren't there, so she can't even tell that they exist. Policy filters in the query's own statement.
See who can do what
access.jadpo_record (written by jadpo records update) lists who can do what. It is committed,
so a change that widens access shows as a diff, and needs a person's acceptance:
tenant Companyentity work.Chore create CompanyRole.owner read CompanyRole.owner read CompanyRole.viewer tenant company_id
jadpo check also writes build/, for reading the whole app at once.
Work with no caller
Schedules and subscribers have no caller, so they run as the system, and a grant must name
Access.system to let them in:
- A schedule's run reaches every tenant's records, since no tenant's event starts it.
- A subscriber's delivery reaches the tenant named by its event's field of the tenant's
idtype, so an event about a company carries acompany_id. - A batch's run reaches the tenant its saved values name.
Work whose value names no tenant reaches only what every tenant shares.
Read more
Tenancy, A membership role, Grants, The permissions report and Background work and tenants.
Markdown for agents: /guides/guide-keep-customers-data-apart.md.Built from Jadpo main at a4c217c. Jadpo's CI compiles this guide's code and runs its tests.