Security
The part worth reading twice. A mistake here is not a bug, it is a disclosure.
Three levels, and the third is the one nothing else in your stack can do for you:
| Level | The question it answers |
|---|---|
| Entity | May this role read/create/write/delete this at all? |
| Attribute | May it see or change this particular field? |
| Row | Which rows of that entity does it see? |
Roles are declared per module, granted per user
A module defines roles in its own terms. app.yaml maps them onto the roles
your organisation actually has, and onto the claims your identity provider
sends:
# app.yaml
userRoles:
- name: SalesRep
grants: [sales.Rep]
auth:
roleClaim: groups
roleMapping:
acme-sales: SalesRep
anonymousRole: Guest # remove this and anonymous requests get 401
The indirection earns its keep: a module ships with a sensible permission model without knowing anything about the organisation deploying it.
A claim value with no mapping grants nothing. A typo removes access rather than adding it, which is the safe direction to fail.
Entity and attribute access
# modules/sales/security.yaml
roles:
- name: Rep
access:
- entity: sales.Customer
allow: [read, write] # not create, not delete
attributes:
creditLimit: none # omitted from responses entirely
tier: read # visible, never writable by this role
Anything not listed in allow is denied. There is no “allow everything” — the
default is no access, because that is the only safe direction for a rule
somebody may forget to write.
An attribute set to none is omitted, not nulled. A null says “this
exists and is empty”; an absent key says nothing at all, and the difference is
information the role is not entitled to. It also never enters the SELECT, so
it cannot be logged or traced by accident.
Row constraints
- entity: sales.Order
allow: [read, create, write]
constraint: Customer/accountManagerEmail = $currentUser.email
That expression is compiled into the WHERE clause of every query this role
issues. It is not a filter applied afterwards, and there is no code path that
can forget it.
The / traverses an association. Order has no owner column of its own, so
reachability is decided by the Customer it belongs to — which is exactly the
case a “just add a tenant_id column” design cannot express.
What you can write: comparisons (=, !=, <, <=, >, >=), and, or,
not, parentheses, paths through associations, and $currentUser.email,
.subject or .name. Deliberately no functions and no arithmetic: this lives
inside a security boundary, and a language you can read in one sitting is one
you can be sure of.
What a caller sees when refused
| Situation | Answer |
|---|---|
| No role grants the entity at all | 403 |
| A row the constraint excludes | 404 — same as a row that is gone |
| A field the role cannot read | Absent from the response |
| Writing a field it may read but not write | 422, naming the field |
| Writing a field it cannot read | 422, “is not a field of this entity” |
The last two are worth dwelling on. A field you can see but not change is named honestly, because you already know it exists. A field you cannot see reads exactly like a typo — otherwise the error message becomes a way to enumerate the schema one guess at a time.
404 for a hidden row is the same reasoning: if a hidden row answered 403 and
a missing one 404, anyone could confirm an identifier by asking for it.
Checking your work
runtime serve myapp
curl -H "Authorization: Bearer $REP_TOKEN" localhost:8080/api/v1/sales/Order
curl localhost:8080/api-doc/openapi.json # what *this* token can do
The OpenAPI document is generated per caller: entities and fields the token cannot reach are absent from it. It is the fastest way to see what a role actually has, and it is derived from the same policy compiler that answers the requests — so it cannot disagree with them.
It is not a security boundary. Omitting an endpoint hides it from a reader; it does not stop anybody calling it. Enforcement is in the query layer.