Identity and authorisation
Who the caller is, and what they may do. Two mechanisms, and they are not the same one:
/admin— a JWT plus an OPA decision, where the action asked about is derived from the request itself. That derivation is the subject of most of this page./user— a JWT, and no policy question at all. Every self-service route is scoped by resolving the caller's own member row; see self-service.
The distinction that matters: /admin asks may this caller do this?, while /user
never asks, because there is nothing a participant can request there that is not already
their own.
REQ-0001 — the admin action is derived from the path and the method
PolicyMiddleware._get_admin_action maps a request to one action name, which is then the
subject of the policy question. Any GET under /admin is read, whatever it reads —
communities, members, assets, a single meter.
Reads and writes are therefore separate grants. While every admin route was a read, one action name was enough; the moment a service account could create a member it stopped being enough, because reading every community and rewriting its members are not the same permission and a service that does one has no business doing the other.
REQ-0002 — every mutating method on a member path is members.write
POST, PUT, PATCH and DELETE on …/members or …/members/{key} all derive
members.write. The method set is closed deliberately: a new verb that fell through the
match would be authorised as a read.
The member's sub-routes are named apart: …/profile (REQ-0063), the field routes and the
delivery-point routes (REQ-0081) derive their own actions, and …/status stays
members.write. A delivery point sits under the member path and not under /assets, so
it is never an asset write.
REQ-0003 — an asset path is an asset write, even though it contains /members
/admin/communities/{ck}/members/{mk}/assets/{ak} derives assets.write, not
members.write.
Ordering in the matcher is load-bearing, because an asset path contains /members as
a proper substring. Get the order wrong and asset writes silently require the member
grant — silently, because everything still works for any caller holding both, which is
every caller during development.
REQ-0004 — community metadata and areas are one grant of their own
PATCH /admin/communities/{ck}, any write on …/areas/{key}, POST …/areas/{key}/rename
(REQ-0079) and any write on …/topology/{node_id} (REQ-0072) derive community.write. Areas
and topology nodes are community structure rather than membership, so they are authorised with
the community and not with the members who reference them — a rename moves members' area
with the area, and is still a community write, which neither members.write nor
members.profile.write reaches. Any other method on …/rename derives admin. A node id or
area key is caller-chosen like any other id, and chooses no action (REQ-0065).
REQ-0005 — import, export and lookup keep their own actions, and one lookup is named apart
/admin/import and /admin/import/yaml derive import; /admin/export derives
export; anything under /admin/lookup/ derives lookup.
With two exceptions, and they are the same exception twice.
/admin/lookup/assets-by-user-ids and /admin/lookup/members-by-dids derive
assets.lookup. Both start from an identifier that names a person and answer what
that person holds; the rest answer which community a user, a sensor or a supply point sits
in. That is a different disclosure, so it gets a different name.
Both are granted by rec-registry.lookup today and nothing changes for a caller — naming
them apart is what lets a policy separate them later without an API change. Anything else
under /admin/lookup/ falls through to lookup, so a new person-shaped batch route has to
be added here deliberately rather than inheriting the broader action by default.
These are separable because they are the ones a service account most often should
not have. A service account is granted the actions it calls and no others: one that
registers approved participants needs members.write, one that attaches meters needs
assets.write, and neither has any business importing, exporting or purging.
REQ-0006 — erasing a member is authorised apart from writing one
DELETE …/members/{key} alone derives members.write. The same request with a truthy
purge query parameter derives members.purge, a separate grant.
Deactivating somebody is recoverable and erasing them is not — Asset cascades, so a
purge takes their meters with them. A service that manages members day to day must not be
able to cross that line by adding a query parameter to a request it is already allowed to
make.
The import grant is the exception to that line. A forced import replaces a community's
whole graph (REQ-0032), deleting every member and asset in it, so a holder of
rec-registry.import can do to a community what members.purge cannot do to one member.
The separation above holds only for callers who do not hold the import grant.
REQ-0007 — an ambiguous purge parameter reads as the recoverable action
purge=true, purge=1, purge=yes and purge=on ask for the purge grant. Everything
else — absent, empty, false, 0, maybe, or a different parameter entirely — derives
the ordinary members.write.
The rule is that the safe reading of an ambiguous request is the recoverable one. An unrecognised value must never be read as consent to erase.
REQ-0008 — purge outside a member path changes nothing
DELETE /admin/communities/{ck}?purge=true derives community.write. The parameter is
only meaningful where a purge is possible, so it does not leak the purge action onto paths
that have no such operation.
No community DELETE route exists; the requirement pins what the derivation would answer,
so that adding one cannot inherit the purge action by accident.
REQ-0009 — {service}.admin satisfies every action
The shared scope matcher in ../celine-sdk treats a held scope ending .admin as
covering every action of that service, so rec-registry.admin satisfies read,
members.write, members.profile.write, members.name.write, members.role.write,
members.area.write, members.delivery_points.write, members.purge, assets.write,
community.write, import, export, lookup and assets.lookup.
This is what made the fine-grained actions backwards compatible: every token that worked
before they existed still works. The property is pinned by reading
policies/celine/scopes.rego directly, because its absence would be silent until
deployment — and would then revoke access for every existing admin token at once.
It is compatibility, not a recommendation. Do not grant rec-registry.admin to a
service account; grant the actions it calls.
REQ-0010 — every action name has a rule in the Rego bundle
policies/celine/rec_registry/access.rego carries a rule for each of the fifteen action
names _get_admin_action can return: the fourteen grants, and admin, which a path matching
no route derives (REQ-0065) and only rec-registry.admin satisfies. An action derived by the middleware with no
corresponding rule would be denied by default — a fail-closed outcome, but one that
presents as an unexplained 403 in production rather than as anything a test would catch.
So the bundle is read and checked for all fifteen, rather than the actions being exercised
one at a time. assets.lookup is the one that shows why this is checked as a set: it was
added to the middleware and to the bundle together but left out of the list being checked,
so for a while the check passed while covering eight of nine.
members.profile.write (REQ-0063, REQ-0064) joined the set in the same change as the
middleware and the bundle, as every addition must, and so did the four field actions of
REQ-0081.
REQ-0063 — the member profile route derives members.profile.write, and no other member route does
PATCH /admin/communities/{ck}/members/{mk}/profile derives members.profile.write.
Every other mutating request on a member path keeps deriving members.write (REQ-0002), or
members.purge (REQ-0006) — including the general PATCH …/members/{mk}, which still
accepts role and area — except the field and delivery-point routes, which derive the
actions of REQ-0081. Any other method on the profile path matches no route
and derives admin (REQ-0065); a read of it is a read (REQ-0001).
The route exists so that a service correcting a member's role and area — a community dashboard — can be given that and nothing more. The action is named by the route and the method, never by the body, because the middleware decides before the body is read. Decided in ADR-0003.
REQ-0064 — members.profile.write is satisfied by its own scope, by members.write and by admin
policies/celine/rec_registry/access.rego allows members.profile.write for a caller holding
rec-registry.members.profile.write, rec-registry.members.write or rec-registry.admin. A
caller holding only rec-registry.members.profile.write is refused members.write — the
general PATCH among it — and every other action: pinned by requests through the middleware
with the policy engine on.
The superset lives in the policy rule, not in the shared matcher in
policies/celine/scopes.rego, which stays exact-or-.admin-or-.*: members.write does not
match members.profile.write by any of those rules, so without the rule listing it a service
that already writes members — onboarding — would be refused the narrower write it could
always perform through the general PATCH.
REQ-0065 — a caller-supplied id never changes the action a request derives
The action depends only on the fixed segments of the route a request matches, each read at its
position, and on the method. A member key, asset key, delivery-point id, area key, community
key or lookup id that contains lookup, import, export, assets, members, profile or
any other route word derives the same action as the same request with a plain id:
PUT …/members/{mk}/assets/meter-export-01 is assets.write, not export, and a holder of
rec-registry.lookup alone is refused a PUT or DELETE of an asset keyed
meter-lookup-01.
The method is honoured on the fixed routes too: import is a POST and export a read, and a
lookup is a read or a batch POST. A request matching no route shape derives admin, which
only rec-registry.admin satisfies — such a request answers 404 or 405 anyway, and an
unknown shape must not fall through to whichever narrower grant a word in it resembles.
The derivation used to match by substring over the whole path, before it looked at the method,
so such an id derived the action of whatever word it contained — and a holder of that action's
grant reached a write it was never given. Asset keys become meter-<sensor id> (REQ-0071), so
the content of an id is no longer under the registry's operators' control at all. Pinned by
hostile-id tests over every admin route family, and by requests through the middleware with the
policy engine on.
It was the first change of the set, landed before REQ-0071 wrote caller-supplied
meter-<sensor id> keys. Decided in
ADR-0003.
REQ-0081 — a member's name, role, area and delivery points each derive their own action
One route per field group, one action per route:
| Route | Action |
|---|---|
PUT …/members/{mk}/name |
members.name.write |
PUT …/members/{mk}/role |
members.role.write |
PUT …/members/{mk}/area |
members.area.write |
PUT and DELETE …/members/{mk}/delivery-points/{id} |
members.delivery_points.write |
The delivery-point path segment is hyphenated; the action and its scope use an underscore.
Any other method on these routes matches no route and derives admin (REQ-0065); a read is a
read (REQ-0001). A segment after the member key that is not in the middleware's list
(_MEMBER_FIELD_ROUTES) — email, did, user_id — derives admin too: identity fields
have no narrow route, and creating a member, the general PATCH, …/status and a member
DELETE stay members.write (REQ-0002); PATCH …/profile stays members.profile.write
(REQ-0063).
Derived from the route's fixed segments and the method, never from the body or the
query. ?replaces= on the delivery-point PUT (REQ-0084) and ?purge= on it change
nothing. A member key, delivery-point id or any other caller-supplied segment equal to
name, role, area, delivery-points or profile derives what a plain id derives
(REQ-0065): PUT …/members/name is members.write, PUT …/members/name/role is
members.role.write. Pinned by derivation tests over every route and method, and by the
hostile-id tests. Decided in
ADR-0011.
REQ-0082 — each field action is satisfied by its own scope and the grants that already wrote that field
policies/celine/rec_registry/access.rego allows:
| Action | Scopes |
|---|---|
members.name.write |
rec-registry.members.name.write, rec-registry.members.write, rec-registry.admin |
members.role.write |
rec-registry.members.role.write, rec-registry.members.profile.write, rec-registry.members.write, rec-registry.admin |
members.area.write |
rec-registry.members.area.write, rec-registry.members.profile.write, rec-registry.members.write, rec-registry.admin |
members.delivery_points.write |
rec-registry.members.delivery_points.write, rec-registry.members.write, rec-registry.admin |
Each superset is the set of grants that could already write the field before its route
existed — members.write through the general PATCH and the delivery-point routes,
members.profile.write through PATCH …/profile — so no caller loses a write. A field scope
alone reaches its own route and nothing else: a caller holding only
rec-registry.members.area.write changes an area and is refused a name, a role, a
delivery-point PUT (with or without replaces) or DELETE, the general PATCH, the
profile route, the status route and a create. As for REQ-0064, the supersets live in the
policy rules and not in the shared matcher. Pinned by requests through the middleware with the
policy engine on, for every scope against every member write.
REQ-0088 — outside CELINE_ENV=dev the service refuses to start on a development setting
create_app checks its configuration before the policy middleware loads the Rego bundle
and before any request can open the database pool, through celine.sdk.posture's
PostureGuard (src/celine/rec_registry/core/posture.py). It registers:
| Setting | Refused when |
|---|---|
DATABASE_URL |
its password is a local-stack password (securepassword123, postgres) or trivially weak |
AUTH_ENABLED |
false |
POLICIES_ENABLED |
false — with the engine off /admin asks only for a validly signed token, so any token the issuer signs reads, writes and purges every community |
CELINE_OIDC_BASE_URL, CELINE_OIDC_JWKS_URI |
not stated, so the SDK's local Keycloak default is in use |
CELINE_OIDC_CLIENT_SECRET |
a client id is configured and the secret is empty or equal to it |
The signal is CELINE_ENV, then ENVIRONMENT; the first non-empty one wins. Only dev
relaxes: unset, empty, staging, prod or a typo is hardened. Hardened, startup raises
InsecureConfiguration naming every violation at once; in dev the same list is logged as
one warning and the service starts. task run exports CELINE_ENV=dev unless it is
already set.
REQ-0089 — outside CELINE_ENV=dev the CLI refuses a client secret equal to its client id
Before the client-credentials flow sends anything, the CLI checks the pair it was given
(--client-id/--client-secret, or REGISTRY_CLIENT_ID/REGISTRY_CLIENT_SECRET). A
secret that is empty or equal to its client id — the local realm's convention, of which
celine-cli/celine-cli is the CLI's own — is refused with a non-zero exit unless the
signal says dev, read as for REQ-0088. A --token and the password flow are not checked.
REQ-0090 — no group and no realm role decides an /admin request
The policy question is answered on the caller's scopes alone. The subject the middleware
puts into the policy input carries no group, whatever the token holds: a realm group
(groups: ["/admins", "admins"], the platform's retired mechanism), an organisation's own
groups (organization.<alias>.groups) and a group named like a grant
(rec-registry.admin) all reach the decision as nothing. access.rego reads
input.subject.scopes only, and no rule reads a role, so the realm role platform-admin
grants nothing here either: a platform administrator or an organisation's admins member
holding no rec-registry.* scope is refused every /admin action, and a token's scope
reaches exactly what it reached before.
The platform has two levels of grant, and neither is read here: platform-admin, a realm
role, is the only platform-wide one; an organisation's groups are valid only inside that
organisation, and no /admin action names an organisation. The middleware used to merge
the realm's groups with every organisation's into one list (celine-sdk's
extract_groups, removed in 2.0.0); a policy reading that list would have let one
community's group act as a platform group or as the same group in another community.
Pinned by requests through the middleware with the policy engine on, for hand-built claims
in every shape and for real tokens from the local realm.
REQ-0091 — every refusal is recorded with the caller it refused
Each refused request leaves exactly one record on the celine.audit logger
(celine.sdk.audit.audit_denied, WARNING, one JSON line, event: "denied"):
| refusal | action |
caller | resource |
reason |
|---|---|---|---|---|
/admin policy decision (403) |
rec-registry.<derived action> (REQ-0001) |
sub, client id |
the community key on /admin/communities/{key}…, else null |
the bundle's reason |
a presented token that does not verify, on /admin, /user or /me (401) |
the same, or rec-registry.user |
null |
as above, read from the path before the token | token_rejected |
/user for a caller who is no member (403 not_a_member, REQ-0047) |
rec-registry.user.read |
sub, client id |
null |
not_a_member |
/user for a caller active in more than one community (409 ambiguous_member, REQ-0095) |
rec-registry.user.read |
sub, client id |
null |
ambiguous_member |
The caller is named by sub and client id only, never email or name, and the claims of
a token that failed verification are not read. A request with no token at all is not
recorded: there is no caller. The resource is the community key and nothing else: the rest
of an /admin path can carry member, sensor and delivery-point ids (REQ-0080). The two
middleware refusals happen before routing; their records carry the method and the template
of the route the router will match (/admin/communities/{community_key}/members/{member_key}),
resolved from the app's routes by path and method, never the raw path. A refused path no
route serves records route: null.
What is not verified here
- The middleware itself. These requirements pin
_get_admin_action, a pure function, called directly. Nothing exercises the surrounding request path: JWT parsing and verification, the policy engine's decision cache, or the401that an unauthenticated caller should receive. The suite runs withAUTH_ENABLED=falseandPOLICIES_ENABLED=false, underCELINE_ENV=dev— the only signal that lets the app start with them (REQ-0088). - Which community a write reaches. Every grant is registry-wide: a holder of
rec-registry.assets.writecan write any community's assets, and a holder ofrec-registry.members.profile.write(REQ-0063) or a field scope (REQ-0081) any community's members. Keeping a manager to their own community is the calling dashboard's policy, and nothing here checks it. - The Keycloak realm. Every grant is a client scope that
../celine-policiesdeclares and syncs; no group and no realm role takes part (REQ-0090). There is no import to grep for and nothing here would notice a renamed scope.