Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

LevelThe question it answers
EntityMay this role read/create/write/delete this at all?
AttributeMay it see or change this particular field?
RowWhich 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

SituationAnswer
No role grants the entity at all403
A row the constraint excludes404 — same as a row that is gone
A field the role cannot readAbsent from the response
Writing a field it may read but not write422, naming the field
Writing a field it cannot read422, “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.