Skip to content

Cross-community lookup

Every other admin route answers "what is in this community?". These answer the question the rest of the platform actually asks — "which community is this in?" — starting from a user id, a sensor id or a delivery point.

They exist because nothing else can answer it. A measurement arrives carrying a sensor id and nothing else; a dataset request arrives carrying a user id. The caller cannot map either to a community without asking, and six repositories ask.

Two of these requirements are security properties wearing the clothes of ordinary behaviour: both batch forms are bounded, at one shared number (REQ-0043, REQ-0045), and none of it is an enumeration oracle (REQ-0045). Both properties are easy to undo while making the endpoint more useful, and one of them already was — assets-by-sensor-ids carried no bound at all until the two were made to read the same constant.

A third holds every lookup that starts from a person or a device: only an active member answers (REQ-0097 – REQ-0099). "Not found" below means "no active member", and the member row of each answer is an active one.


REQ-0038 — a user id resolves to its community and the member row within it

GET /admin/lookup/community-by-user-id/{user_id} answers the community reference and the member — key, user_id, name, role, status. A user id belonging to no member is 404.

The member comes back with the community because the caller almost always needs both, and a second round trip to fetch the member would double the cost of the most-used lookup in the platform.

REQ-0039 — a sensor id resolves to its community, its owner and the asset

GET /admin/lookup/community-by-sensor-id/{sensor_id} answers all three. An unknown sensor id is 404.

The owner is the part that matters. A reading arrives carrying a sensor id and nothing else; attributing it to a person is this lookup, and without the member in the response every consumer would have to follow up with a second call.

REQ-0040 — a delivery point id resolves to its community and member

GET /admin/lookup/community-by-delivery-point/{dp_id} answers the community, the member and the matching delivery point. Unknown is 404.

Delivery points live in a JSONB list rather than a table, so this lookup scans members and matches in Python. It is correct and it does not scale the way the other two do — the cost grows with the number of members in the whole registry, not with a lookup on an index.

REQ-0041 — a user id resolves to a full member record, across all communities

GET /admin/lookup/member-by-user-id/{user_id} answers the member with its delivery points, and names the community it belongs to. Unknown is 404.

The wider sibling of REQ-0038: that one answers where, this one answers what.

REQ-0042 — a sensor id resolves to a full asset record, with its owner

GET /admin/lookup/asset-by-sensor-id/{sensor_id} answers the asset with its properties, device and relationships, plus owner_key, owner_user_id and the community. Unknown is 404.

REQ-0043 — sensor ids can be resolved in a batch, and that batch is bounded

POST /admin/lookup/assets-by-sensor-ids resolves many sensor ids in one request, answering one record per asset found. An empty list answers an empty list without querying.

A sensor id that matches nothing contributes no row rather than failing the request: the caller asked about a set, and one absent member of it does not make the rest unanswerable.

Bounded: at most 500 sensor ids in one request; 501 is 422 and 500 is accepted. The same number as REQ-0045, from the same constant — MAX_BATCH_LOOKUP_IDS, which both request models read. This route carried no bound at all while its sibling capped at 500, and the asymmetry was accidental: the bound arrived with the newer endpoint and was not applied to this one. Two literals would have let that happen again.

Sensor ids are less guessable than usernames, which made this the weaker enumeration path — but not the weaker bulk extraction one, and extraction is what a bound is for: a caller holding a list of sensor ids resolves every owner and community behind them in one request.

A bound on one request is not a bound on extraction. Nothing rate-limits this route and nothing counts what one caller has resolved over an hour, so a caller who wants the registry can still page through it 500 at a time. What the bound removes is the single request that takes all of it.

REQ-0044 — assets can be resolved for a set of members, and every row names its owner

POST /admin/lookup/assets-by-user-ids is the mirror of REQ-0043: that one starts from a device and finds its owner, this one starts from owners and finds their devices. An empty list of ids answers an empty list without querying.

Every row carries owner_user_id, which is what lets the caller attribute a row back to the member it asked about — the entire purpose of a batch form, and useless without it.

It exists because a dataspace query is authorised for a set of people: the subjects who consented, not the caller. The self-service route (REQ-0050) can only ever answer "mine", because it resolves the member from the caller's own token — and widening that endpoint to accept a list of ids would turn a self-service route into a directory with no scope check in front of it. So the batch form belongs here, behind the admin policy.

REQ-0045 — the member batch is bounded, and answers nothing about who exists

Bounded: at most 500 user ids in one request; 501 is 422 and 500 is accepted. A caller that can name ten thousand people in one request has a dump of the registry rather than a lookup, and this route is reachable by anything holding rec-registry.lookup. Raising the bound widens a data-exfiltration path — it is a security decision wearing the clothes of a validation constant. The value 500 is arbitrary rather than derived; what is load-bearing is that a bound exists.

Not an oracle: a user id that belongs to nobody and a member who owns no assets are deliberately indistinguishable — both contribute no rows, and neither is a 404.

The caller supplies the ids, so any difference between those two answers would make this a way to discover who is registered. Against a service whose rows are real people in real communities, membership is itself the disclosure — knowing that a given person is in an energy community is information about them regardless of what they own.

The temptation to undo this is real and reasonable-sounding: "tell the caller which ids were not found, so they can clean their list." That helpfulness is the oracle.

REQ-0061 — a set of DIDs resolves to the members holding them, bounded and answering nothing about who exists

POST /admin/lookup/members-by-dids answers one member record per DID found — key, user_id, did, name, role, area, status, delivery points, and the community.

It answers members, not assets, and that is the part it would be easy to get wrong. The obvious move is to mirror REQ-0044 exactly and return assets, and it loses the supply point in the common case: ../onboarding writes the declared POD into Member.delivery_points and registers no assets, because a meter's sensor_id is assigned at physical installation, long after onboarding. An asset-shaped answer is empty for every participant whose meter has not been commissioned. A commissioned meter stays reachable through the user_id in the same row and REQ-0044.

That stays true of onboarding; the meter is attached later by a community manager, at meter-<sensor id> (REQ-0071).

Every row carries its did, which is what lets the caller attribute a row back to the DID it asked about — the same job owner_user_id does in REQ-0044.

Bounded: at most 500 DIDs in one request; 501 is 422 and 500 is accepted. The same number as REQ-0043 and REQ-0045, from the same MAX_BATCH_LOOKUP_IDS. A DID is the identifier a consent record is written in, so the set a caller holds is a set of people who consented — and this route turns that into the supply points they hold. The bound is the same security decision it is on the other two.

Not an oracle: a DID belonging to nobody and a member holding no supply points are deliberately indistinguishable — both contribute no rows, and neither is a 404. An empty list of DIDs answers an empty list without querying.

It derives assets.lookup, not lookup (REQ-0005). Resolving what a named person holds is a different disclosure from resolving which community a sensor sits in, and this route does the first.

REQ-0097 — the member batch answers active owners only, and refuses a person active twice

POST /admin/lookup/assets-by-user-ids returns only assets whose owner's status is active. A member who is not active contributes no rows — indistinguishable, as REQ-0045 requires, from an unknown id and from a member who owns nothing.

A released member's row keeps its meters. ../dataset-api builds its consent-gated sensor_id IN (…) filter from this answer, so answering from that row handed a consenting person their old meter — whose POD, once reassigned, measures somebody else.

A user id that is an active member of more than one community answers 409 with {"code": "ambiguous_member"} for the whole request; the detail names nobody. The route takes no community to scope by, and answering both would join one consent to two communities' meters. This does tell the caller that one of the ids it supplied is active twice, which no other answer here reveals; the caller holds rec-registry.lookup and supplied the ids.

REQ-0098 — a user id resolves to its single active member, or to nothing

GET /admin/lookup/member-by-user-id/{user_id} and GET /admin/lookup/community-by-user-id/{user_id} resolve among the user id's active rows. None is 404, so a user id whose only rows are released is not found; one is the answer; more than one is 409 ambiguous_member. Neither route returns an inactive row, and neither takes a parameter to: no caller needs one — ../onboarding releases a member by community and key, and writes a DID only onto an active row.

REQ-0099 — a sensor id or delivery point resolves to its active holder, never to whoever left

GET /admin/lookup/community-by-sensor-id/{sensor_id}, GET /admin/lookup/asset-by-sensor-id/{sensor_id} and GET /admin/lookup/community-by-delivery-point/{dp_id} resolve among assets and delivery points of active members. None is 404; more than one is 409 ambiguous_member. POST /admin/lookup/assets-by-sensor-ids returns rows of active owners only.

Only an active member holds a sensor or a delivery point (REQ-0069, REQ-0085), and release leaves the meter and the POD on the released row. Once they are given to somebody else, two rows carry the same id, and the first row of an unordered query attributed the new occupant's readings to the person who left — the digital twin nudges every owner the batch returns. Two active holders cannot be written through any path here, but an existing clash is not repaired (REQ-0069), so the lookup refuses rather than picks.