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_userraises 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.