Skip to content

Self-service

What a participant may see about themselves, under /user. Six routes, and one rule holding all of them: the caller's own member row is resolved first, and everything else is scoped to it.

There is no policy question here. /admin asks may this caller do this?; /user never asks, because nothing a participant can request is anything but their own. That makes the scoping the whole of the security, and it is enforced not by a rule in a policy bundle but by a WHERE owner_id = <the member we resolved> repeated in each route — a seventh route added without it would look exactly like the others.


REQ-0046 — the profile answers who the caller is and where they belong

GET /user answers the caller's JWT profile — sub, email, name, preferred_username — together with their membership: the member summary, the community summary, how many delivery points they have, and their assets counted by type.

The member summary carries did (REQ-0059), null for a member who holds none. This is the route an application loads first and it is shaped to make a second request unnecessary for the common case, so the caller's dataspace identity belongs in it.

The counts are of the caller's own assets.

REQ-0047 — a caller who is a member of nothing is answered, not refused

GET /user answers 200 with membership: null — and so for a caller whose only rows are not active (REQ-0094). The other five routes answer 403 with the coded body {"detail": "You are not a member of any community", "code": "not_a_member"} (REQ-0073), so a client — ../dataset-api's self-service row filter, which reads that answer as "no rows" and every other registry failure as an error — tells it apart by code rather than by the sentence. The OpenAPI document declares the 403 as ErrorResponse on each of the five.

The asymmetry is deliberate. GET /user is the route an onboarding application calls to find out whether somebody is a member yet, so it must be answerable before the answer is yes. The rest have nothing to return, and 403 says why rather than pretending an empty result.

REQ-0048 — the member detail is the caller's own, omits what they already know, and carries what they do not

GET /user/member answers key, name, role, area, status, did, delivery points, extra and timestamps — and not user_id.

Every field not returned is a field that cannot leak. The caller already knows their own identifier, so returning it buys nothing and puts an identity into one more response body.

did is returned, and the difference from user_id is the point. A participant knows the username they authenticated with; they do not know the dataspace DID, because an onboarding service minted it on their behalf a step after registration — and it is the identifier their consent records are written in. Withholding it would mean a participant cannot see, in the one place that is theirs, which dataspace identity acts for them.

It is null for a member who holds none, which is every member of a deployment with no dataspace and every member between registration and minting.

REQ-0049 — the community detail carries the caller's place in it, and not its member list

GET /user/community answers the community — name, description, legal, links, contact, settings, areas and topology — plus your_area and your_role.

Community-level detail is shared by everyone in it and is not scoped. The member list is not part of it: knowing your community must not mean enumerating everybody in it. That is /admin, behind the read grant.

REQ-0050 — a participant lists their own assets, and only their own

GET /user/assets answers the caller's assets, sorted by key, with a total. The asset_type filter narrows within them and never widens beyond them.

The response omits owner information — the caller knows whose they are.

REQ-0051 — another member's asset is not found, not forbidden

GET /user/assets/{key} naming an asset owned by somebody else answers 404, with the same body as an asset key that does not exist anywhere.

The distinction is the requirement. A 403 would confirm that the key names something real, turning the route into a way to test whether a guessed asset key exists — and asset keys are guessable, being meter-<member key> in every bundle this service ships with.

A meter a manager attaches is keyed meter-<sensor id> (REQ-0071) — guessable by anyone who knows the sensor id, which is printed on the device. The 404 matters as much for those keys as for these.

REQ-0052 — a participant lists their own delivery points, and only their own

GET /user/delivery-points answers the caller's supply points with a total.

REQ-0053 — the caller is identified by username, not by subject

Every route here resolves its member with JwtUser.get_username(), which returns preferred_username — not sub. So Member.user_id holds a Keycloak username, and a token whose subject happens to be somebody else's username still resolves to the member matching its preferred_username.

A token carrying no preferred_username matches nobody: get_username() falls back to the string user-<sub>, which is not a form any user_id in the registry takes. The fallback is therefore not a fallback but a guaranteed miss, and the caller — who may be a perfectly good member — is told they belong to nothing.

This is the failure worth stating loudest, because it is silently wrong rather than loud: the operator reading that 403 investigates the registry, and the fault is in the token.

REQ-0094 — only an active member answers; any other row is no member

Every route here resolves the caller's member among rows with user_id equal to their username and status = 'active'. A pending, suspended or inactive row is treated exactly as no row: GET /user answers membership: null and the other five answer 403 not_a_member (REQ-0047) — the same bodies a stranger gets, so nothing tells a released member from somebody who never joined.

active is the status that holds sensors and delivery points (REQ-0069, REQ-0085), so it is the status a self-service answer may be read from. ../dataset-api builds its live sensor_id IN (…) row filter from GET /user/assets; answering from an inactive row kept a released member reading their old meters — and, once their POD was given to somebody else, the new occupant's readings.

REQ-0095 — two active rows are narrowed by the token's communities, never picked

user_id is unique per community only, so one username can be active in two communities. When it is, the route keeps the active rows whose community key is one of the token's organization aliases. Exactly one left is the answer; none or more than one is 409 with {"code": "ambiguous_member"} (REQ-0073), on all six routes, recorded on celine.audit like not_a_member (REQ-0091). The routes take no community parameter, so the token is the only thing that can say which.


What is not verified here

  • The 401. require_user raises when no user is on the request, but the suite overrides that dependency to choose an identity, so the unauthenticated path is not exercised. Nothing here proves a caller with no token is refused — that is the middleware's job, and the middleware is not covered either (identity and authorisation).
  • Cross-community membership, past resolution. REQ-0094 and REQ-0095 fix which row answers. What the other services that key on the person rather than the community show after a move between communities is theirs, not this registry's.