When policy says no
Your code never handles a policy refusal itself. The compiler adds policy
to the SQL WHERE of every query and write, and indexes every field a
policy condition filters on. jadpo sql shows the policy each statement
carries. What a refused caller sees depends on what they asked for:
| Situation | The client sees |
|---|---|
| Not signed in, on a route that needs it | 401 unauthenticated (Routes: signed in by default) |
| A record the caller may not see, read or changed by id | the code's own not_found failure, as if it did not exist |
| A list | only the records the caller may see, filtered before paging |
| A create, or an action they may not call | 403 not_permitted |
A hidden record reports the same not_found failure the code already
maps, so a caller can't tell it from one that doesn't exist. The log
records that policy concealed it
(Concealing records).
not_permitted is raised by the runtime and never listed in fails. The
generated API description lists the 403 wherever it can happen.
Writes are checked in the same statement as the change, so a membership removed a moment earlier can't slip through.
A token issued to another application (Identity) carries scopes, which say what that application may do. Scopes only narrow: the token may do what both the person's grants and its scopes allow.
Policy, end to end
A whole application whose customers are companies. Members sign up, found a company and give each other roles in it, and a company's projects are seen and changed only by its members.
First, the principal, its one sign-in method, and root policy.jadpo.
The policy names the tenant, and the one action that needs other people
than its entities' grants: founding a company, since a company with no
members yet has no owner to grant anything:
use work/entities { Member }application {}principal { user: { subject: Member.handle }}
use work/entities { Company }use workpolicy { tenant: Company actions: { work.found_company: [Access.authenticated] }}
A member may read only their own record, and anyone may sign up. Members are shared by every company. The membership says where its role is held, who holds it and which field holds it. A company's records follow the roles held in it:
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 }}
use work/types { CompanyName, CompanyRole }/// A customer: the tenant every other record belongs to.entity Company { id: Uuid name: CompanyName policy: { read: [CompanyRole.owner, CompanyRole.editor, CompanyRole.viewer] }}
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] delete: [CompanyRole.owner, member_id] }}
use work/entities { Company }use work/types { CompanyRole, ProjectName }/// A piece of work in a company.entity Project { id: Uuid company_id: Company.id { on_delete: OnDelete.cascade } name: ProjectName policy: { create: [CompanyRole.owner, CompanyRole.editor] read: [CompanyRole.owner, CompanyRole.editor, CompanyRole.viewer] update: [CompanyRole.owner, CompanyRole.editor] delete: [CompanyRole.owner] }}
Founding a company is the listed action: anyone signed in may call it, and it makes its founder the owner. Adding a member is an ordinary create of a membership, so only the company's owners may do it. The code says nothing about who may do either:
use work/entities { Company, CompanyMembership }use work/types { CompanyName, CompanyRole }/// Starts a company with its founder as its owner.action found_company(name: CompanyName, 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}
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 } }}
The query names no caller and no company check: policy adds both to its
WHERE, so a caller sees only the projects of companies where they hold a
role that may read them:
use work/entities { Company, Project }use work/types { ProjectView }/// A company's projects, by name.query company_projects(company_id: Company.id) -> List<ProjectView> { from: Project where: Project.company_id == company_id order: [Project.name asc] limit: 100}
use work/entities { Company, Project }use work/types { ProjectView }use work/actions { RenameProject, StartProject, rename_project, start_project }use work/queries { company_projects }/// A company's projects.routes Projects { prefix: /projects /// Starts a project. POST / { input: StartProject output: ProjectView status: SuccessStatus.created run: start_project(input) } /// A company's projects the caller may see. GET /{company_id: Company.id} { output: List<ProjectView> run: company_projects(company_id) } /// Renames a project. PATCH /{project_id: Project.id} { input: RenameProject output: ProjectView run: rename_project(project_id, input) }}
A route test signs in as each member, so what it checks is exactly what a
browser would see. The test sets up its data by calling actions without a
caller, which runs them without policy. found_company takes its founder,
so it runs as the founder and its grants apply:
use work/entities { Company, CompanyMembership, Member }use work/types { CompanyRole, ProjectView }use work/actions { RenameProject, StartProject, add_member, found_company, register, start_project }test "a company's projects are listed only to its members" { var ann: Member = attempt register("ann") var bob: Member = attempt register("bob") var cy: Member = attempt register("cyd") 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: ProjectView = attempt start_project( StartProject { company_id: northwind.id name: "Launch" } ) var seen: Response = request { method: HttpMethod.get path: "/projects/{company_id}" params: { company_id: northwind.id } as: Principal.user(bob) } assert seen.status == 200 assert seen.output == [launch] var outsider: Response = request { method: HttpMethod.get path: "/projects/{company_id}" params: { company_id: northwind.id } as: Principal.user(cy) } assert outsider.output == []}test "a viewer can't rename a project, which reads as missing" { 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: ProjectView = attempt start_project( StartProject { company_id: northwind.id name: "Launch" } ) var by_bob: Response = request { method: HttpMethod.patch path: "/projects/{project_id}" params: { project_id: launch.id } as: Principal.user(bob) input: RenameProject { name: "Plans" } } assert by_bob.status == 404 assert by_bob.code == "project_not_found" var by_ann: Response = request { method: HttpMethod.patch path: "/projects/{project_id}" params: { project_id: launch.id } as: Principal.user(ann) input: RenameProject { name: "Plans" } } assert by_ann.status == 200}test "someone outside a company can't start a project in it" { var ann: Member = attempt register("ann") var cyd: Member = attempt register("cyd") var northwind: Company = found_company("Northwind", Principal.user(ann)) var response: Response = request { method: HttpMethod.post path: "/projects" as: Principal.user(cyd) input: StartProject { company_id: northwind.id name: "Takeover" } } assert response.status == 403 assert response.code == "not_permitted"}
Markdown for agents: /docs/when-policy-says-no.md.Built from Jadpo main at a707efb.