# Permissions on actions

Most actions need no permission of their own: an action takes its
permission from the entities it touches. Root `policy.jadpo` lists the few
that can't, under `actions:`:

- an action that touches no entity, such as an operator's health report,
  which is otherwise refused;
- an action that needs different people from its entity's grants, such as
  handing a todo to someone else, or creating a company together with its
  first owner's membership.

Each line names an action by its domain and name, and lists who may run
it, with the same kinds of entry as a grant:

```jadpo
// policy.jadpo
policy {
    tenant: Company
    actions: {
        todos.transfer_todo: [Todo.owner_id]
        accounts.create_company: [Access.authenticated]
        monitoring.health_report: [StaffRole.operator]
    }
}
```

The domain is part of the name, as in a call from another domain. A bare
name that is one domain's action, `transfer_todo:`, is
`policy.binding_invalid` with the edit that writes `todos.transfer_todo:`,
and the `use` line that brought the bare name in isn't also reported
unused; `jadpo fmt` then writes `use todos` in its place.

`Todo.owner_id` means the current owner of the todo the action changes.
For that action, the listed people replace the entity grants on the
creates, updates, deletes and transitions in its own body. Its reads, and
the actions it calls, keep their own entities' grants, with the same
caller. [Field restrictions](/docs/field-restrictions.md) and
[tenancy](/docs/tenancy.md) still apply.

Calling an integration, an outside service such as a payment provider,
needs nothing extra: permission to run the action covers it.

## Lines that depend on a record

A user field such as `Todo.owner_id`, or a role held in a record such as
`CompanyRole.owner`, is checked on the records the action itself creates,
changes or deletes. On an action that changes none of them, such as a
report, it lets nobody in. The action is then checked as it starts against
the line's other entries: anyone signed in, or a role held across the
whole application. With none left, it refuses every caller, so its route
is `policy.effect_ungranted`.

## A call the listing doesn't cover

Because a called action keeps its own grants, a listed action can call
one that refuses some of the people its line lets in. Say
`lending.borrow_book` is listed for `Access.authenticated` and calls
`lend_book`, whose `Book` update only `StaffRole.admin` holds. A customer
would get 403 from `lend_book`. `jadpo check` reports this at the call as
`policy.call_ungranted`, naming the people the line lets in that the
called action refuses. Fix it by making the write in the listed action's
own body, or by listing the called action for exactly those people.
Listing it for everyone widens it on every path that reaches it. It is
an error you can decline, for a call that only some callers are meant to
pass.

## Listing a query

A query is listed under `actions:` like an action, when it needs fewer
people than its entity's `read:`. Each listed query's line may only hold
entries that depend on no record: `Access.authenticated`, or a role held
across the whole application. Anything else is `policy.binding_invalid`.
The line is checked as the query starts, on every request, so removing
someone's role takes effect on their next request. Its records are still
filtered by the entity's `read:`, and its output fields are proved
against the line's people, not everyone the entity's `read:` lets in. So
one entity can give staff a query with stock and sale fields restricted
to `StaffRole.admin`, beside a catalogue query for every signed-in
customer.

A route anyone may call can't run a listed query, since its line never
names `Access.public` (`policy.public_grant_missing`). A schedule,
subscriber or batch never passes one either: the system is let in only
by a line naming `Access.system`, which a query's can't. Reaching one is
`policy.system_grant_missing`.

Here every signed-in customer reads a book, and only a bookseller where
it is shelved:

```jadpo
use shop/types { StaffRole }
use shop

policy {
    actions: {
        shop.stock: [StaffRole.bookseller]
    }
}
```

```jadpo
use shop/types { StaffRole, Title }

/// A book every signed-in customer may read, with where it is shelved,
/// which only a bookseller may.
entity Book {
    id: Uuid
    title: Title
    shelf_note: Title {
        policy: {
            read: [StaffRole.bookseller]
        }
    }

    policy: {
        create: [StaffRole.bookseller]
        read: [Access.authenticated]
    }
}
```

Unlisted, `stock` would be `policy.output_field_unproved`, since
anyone signed in may call it and not all of them may read `shelf_note`.
Listed, it is proved against `StaffRole.bookseller` alone:

```jadpo
use shop/entities { Book }
use shop/types { StockBook }

/// Every book with where it is shelved, for staff.
query stock() -> List<StockBook> {
    from: Book
    order: [Book.title asc]
    limit: 50
}
```

A customer is refused the stock with 403 `not_permitted`, and a member
of staff whose role is removed is refused on their next request:

```jadpo
use shop/entities { Book, Member, Staff }
use shop/types { CatalogueBook, StockBook }
use shop/actions { add_book, appoint, register, stand_down }

test "staff see where a book is shelved, and customers only the catalogue" {
    var ann: Member = attempt register("ann")
    var cyd: Member = attempt register("cyd")
    var staff: Staff = attempt appoint(ann.id)
    assert staff.member_id == ann.id
    var dune: Book = add_book("Dune", "Back room")
    var stock: Response = request {
        method: HttpMethod.get
        path: "/books/stock"
        as: Principal.user(ann)
    }
    var shelved: StockBook = StockBook {
        id: dune.id
        title: "Dune"
        shelf_note: "Back room"
    }
    assert stock.output == [shelved]
    var refused: Response = request {
        method: HttpMethod.get
        path: "/books/stock"
        as: Principal.user(cyd)
    }
    assert refused.status == 403
    assert refused.code == "not_permitted"
    var browsed: Response = request {
        method: HttpMethod.get
        path: "/books"
        as: Principal.user(cyd)
    }
    var on_shelf: CatalogueBook = CatalogueBook {
        id: dune.id
        title: "Dune"
    }
    assert browsed.output == [on_shelf]
}

test "a role removed is refused on the next request" {
    var ann: Member = attempt register("ann")
    var staff: Staff = attempt appoint(ann.id)
    var dune: Book = add_book("Dune", "Back room")
    var before: Response = request {
        method: HttpMethod.get
        path: "/books/stock"
        as: Principal.user(ann)
    }
    assert before.status == 200
    attempt stand_down(staff.id)
    var after: Response = request {
        method: HttpMethod.get
        path: "/books/stock"
        as: Principal.user(ann)
    }
    assert after.status == 403
    assert dune.title == "Dune"
}
```
