Files
portal/docs/access.md
T
Bendik Aagaard LynghaugandClaude Opus 5 f18d1a5181 A person stays in dialogue with their case
Five things a building authority needed and portal could not say,
found by building one and replaying its cases through the engine.

The one that mattered: a submitter could only ever act on a record
nobody had touched. `access.rs` pinned a self_transition's from-state
to `open`, though the runtime had always passed the real state and
asked. So the applicant written to for a missing drawing could not
send it, and nobody could withdraw once anything had happened.
`self_transition` now takes `from: [state, ...]` and compiles one rule
per state; omitted it still means the initial state alone, which is
what every unsubscribe link already meant.

Then the four beside it. A record can belong to another record
(`type: record`, `of:`), with `where: { field, equals }` on a listing
as the other half, so an application's page lists the objections that
name it. A public view can be narrowed to the states worth showing,
so a register that is public by law is public about what was decided
and not about what was withdrawn. A state can grant a group, so an
approval that means membership needs no second step. And a state can
be moved by a clock (`after: { days, to }`, src/deadlines.rs) - held
to the same declared edge as a desk button, firing the same mail,
publishing the same event.

Also: the README is a README again, and everything that was crowding
it is in docs/ - architecture, content reference, state machines,
access, operations.

95 tests, up from 82.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 21:35:25 +02:00

2.3 KiB

Access

Who may do what to what is compiled from content on every load into one Cedar policy set, and every server entry point asks that one policy. Identity stays Kanidm's; portal only decides.

What content compiles to

content rule
a page with no qualifies anyone may open and submit
qualifies: case_officers that group may open and submit
requires_chain: /start the visitor's verified answer lineage must reach it
a resource with public: true anyone may read it
a resource with requires_group: g that group may read it
a desk resource's transitions that group may make those moves
an alternative's self_transition the submitter may make it, from each state in from, with their link and their matching email
an alternative's invite: {group} the page's own group may invite into that group

Mutations always need a group, whatever public says. A resource with neither public nor requires_group is unreachable rather than open: the policy fails closed.

Reading the policy

iris check --path questions --access

prints every rule the content compiled to, as a table of who may do what from where to where. It is the fastest way to find a rule a content repo did not mean to write — a refusal available on the day a case arrives, a board that can decide a case it never received.

Asking it

Every decision carries the rule ids that granted it. Refusals and every mutation are published on portal.access.decided.

Other applications ask the same question over NATS request-reply on portal.access.<site>.may, with the same principal, action, resource and context the server uses internally — so an automation acting as a group gets exactly the answer a person in that group would.

The submitter

A person who sent a form has no account. Their credential is the link: an unguessable chain hash, plus the email they gave, which the policy compares against the one stored on the record.

What they may do, and from where, is self_transition.from. Its default is the initial state alone. A refusal reads "not found" while the record is somewhere the move could have been made from — so the endpoint cannot be used to discover which records or addresses exist — and "already processed" once the case has moved past it.