ADR-0009: Organisation scope is server-derived and schema-owned

Status

Accepted

Date

2026-08-13

Deciders

Christhonie Geldenhuys (architect)

Related

Epic #533, Feature #534, US #536, US #537, US #538; C01 rev 6a; T01; design-journal 2026-08/admin-portal-navigation-architecture.adoc; ADR-0008

1. Context

Three questions about organisation scope arrived together while designing multi-organisation support in the admin-portal. They look unrelated but share one principle, and answering them separately had already produced one production incident.

Who decides which organisation a request acts on? On 2026-07-29 a registration-portal request created a match_token against organisation 1506 — a value taken from the request body. No user JWT was present; a multi-organisation API key with zero organisation permissions had authenticated the call by itself, so the client-supplied value was the only organisation identity in play. It was also the wrong number: 1506 is a WCSC tenant id that coincidentally names a live registration system, while the request belonged to organisation 10. The response was to remove ?orgId= from that flow. The admin-portal now needs the opposite — a staff user with several organisations must be able to say which one a call applies to — and the two must be reconciled rather than left as contradictory precedents.

Who may read the token? The admin-portal is a Spring Boot gateway plus an Angular SPA, holding the admin-service JWT in the server-side session. The token is a readable JWS, so the gateway could decode it to learn the user’s organisations and privileges. Whether it should is a boundary question, and answering it "yes" would spread claim-format knowledge across two services.

Which scope owns a reference collection? Designing the tenant-admin master-data screen required deciding, per collection, whether it is organisation-owned or platform-wide. An audit of all 88 entities in the database module found the question was not one of naming: fourteen collections reach Organisation directly or transitively, and ten carry no path to it at all — including BankingDetails, Product, and TagType, whose sibling NumberType does carry an organisation FK. Two asymmetries (NumberType/TagType, GlTransaction/GlAccount) show these gaps are accidental rather than designed, since in each pair the halves are used together.

The forces: clients need to express intent; servers must not trust that expression; and "the schema does not scope this" is easy to misread as "this is global", which would put one organisation’s banking details on a platform-wide screen.

2. Decision

Organisation scope is always derived by the server — from its own grants and its own schema — never inferred from client convenience. Four rules follow.

  1. organisationId is a selector, never a source. A client may name an organisation to act on; the server resolves it against the authenticated principal’s granted set and fails closed. Absent parameter → the principal’s organisation. Absent parameter with multiple linked organisations → ambiguity error. Value outside the set → 404. A request carrying no principal-derived organisation identity has no organisation identity, and no client field supplies one.

    This is already implemented as ITenantService.getOrganisation(Long requestedOrganisation); this ADR makes it the rule rather than one service’s behaviour.

  2. The admin-service JWT is opaque outside admin-service. The gateway attaches the token to outbound calls and never parses its claims. Everything the portal needs about organisations and privileges arrives through admin-service endpoints. A claim that no other component may read cannot be a component’s only source of a fact — so any claim the portal must act on (notably isSuperAdmin) requires a corresponding endpoint.

  3. A collection’s scope is decided by its ownership in the schema. A collection belongs to the scope that owns its rows. A collection with no path to Organisation, and which is not a physical or ISO constant, is a schema gap — not a platform-scoped collection. Such collections are exposed at no scope until the gap is fixed.

  4. Permissions do not cascade through the organisation hierarchy. Organisation.parent exists and domain data cascades from it, but grants do not. Every organisation a principal may act on is granted explicitly, so the subset check is plain set membership with no ancestor walk.

3. Consequences

3.1. Positive

  • The registration-portal incident and the admin-portal requirement stop contradicting each other. Both follow one rule: a client may select, never assert.

  • The failure mode is a 404 rather than a silent write into the wrong organisation. Widening scope requires a grant, not a well-formed request.

  • Claim format stays private to admin-service. The token can be re-shaped — claims added, renamed, encrypted, swapped for a reference token — without touching the gateway.

  • The subset check cannot be silently widened by a later change to the organisation hierarchy, because it never consults the hierarchy.

  • "No organisation FK" can no longer be mistaken for "global", which is what would otherwise have put BankingDetails and Product on a cross-tenant screen.

  • The schema-ownership rule generalises: it tells anyone adding a reference collection where its screen belongs, and when the answer is "fix the schema first".

3.2. Negative

  • The gateway cannot answer any organisation or privilege question locally. Bootstrap becomes a composite call, adding a round trip and coupling portal startup to admin-service availability.

  • US #536 grows: isSuperAdmin needs an endpoint as well as a claim, because a claim the gateway may not read cannot gate the platform navigation group.

  • Ten collections are blocked behind schema remediation before they can have screens — deferred work that would otherwise have shipped sooner at the cost of being wrong.

  • Explicit grants mean more administration for organisations with many children; a parent-organisation admin must be granted each child individually.

  • Two sources describe the organisation set — the endpoint (for the UI) and the claim (for enforcement). They derive from the same OrgPermission data, but the duplication is real and could drift under caching.

3.3. Neutral

  • Multi-organisation support in admin-service is largely already built: NimbusTokenProvider mints both orgId and linkedOrgIds, and five resources already accept and validate organisationId. The remaining work is portal-side.

  • Where the two sources disagree, the backend wins and the UI surfaces a 404 on the offending organisation.

  • The rule is silent on how a collection should be scoped — a direct organisation_id and a transitive FK (as chosen for ProductRegistrationSystem) both satisfy it.

4. Alternatives Considered

4.1. Alternative A: Keep orgId out of the API entirely; organisation comes only from the token

The literal generalisation of the 2026-07-29 fix. Rejected because it does not survive multi-organisation staff users: a principal linked to several organisations gives an ambiguous request, and the only remedies are re-minting the token on every switch (making the active organisation session-global, which breaks two browser tabs — see ADR-0008) or picking one arbitrarily, which is the perms?.[0] bug the portal already had. The rule adopted here keeps the incident’s protection while allowing intent to be expressed.

4.2. Alternative B: Let the gateway decode the JWT

Cheaper and lower-latency: the gateway holds the token and could read linkedOrgIds and isSuperAdmin directly, with no extra call and no new endpoint. Rejected because it makes claim format a shared contract between two services, so any change to the token requires a coordinated release; and because a component that can read a token tends to start trusting it for decisions the issuer never intended. It would become the right choice if bootstrap latency proved unacceptable and the claim set were frozen.

4.3. Alternative C: Treat unscoped collections as platform-scoped

The reading the schema literally supports today, and it would have unblocked those screens immediately. Rejected because the schema is describing an omission, not an intent — BankingDetails has no organisation FK, but banking details are unambiguously per-organisation. Building a platform screen over them would present one organisation’s data to another and would teach operators that a platform screen edits their own data. Deferring is the only option that is not actively wrong.

4.4. Alternative D: Cascade permissions through Organisation.parent

Convenient for federated structures — grant at the province, inherit the clubs. Rejected because it makes the effective grant set a function of a mutable hierarchy, so re-parenting an organisation silently widens or narrows access with no permission change and no audit event. It also complicates the subset check into a tree walk on every request. It becomes worth revisiting if explicit per-child grants prove unmanageable at scale, and would then need its own ADR covering re-parenting semantics.

5. References

  • Design journal: design-journal/2026-08/admin-portal-navigation-architecture.adoc (sessions 2–4, 2026-08-12/13)

  • Design journal: design-journal/2026-07/tenant-derived-org-context.adoc (the 2026-07-29 incident)

  • Use case: C01 rev 6a — § Organisation claims and the opaque-token boundary, § Organisation hierarchy does not cascade access

  • Use case: T01 — § Master Data, the in-scope / blocked classification

  • Related: ADR-0008 (how scope is expressed in the UI)

  • Code: admin-service/src/main/java/za/co/idealogic/event/admin/service/ITenantService.java (the selector contract), …​/security/jwt/NimbusTokenProvider.java (orgId, linkedOrgIds)

  • ADO: Epic #533; Feature #534 (role model); US #536 (isSuperAdmin claim + endpoint); US #537 (requestedOrgId token exchange); US #538 (current-user permissions endpoint)