Documentation navigation
Platform Mappers Contract
This is the developer-facing companion to Token Mappers. That doc explains the current Keycloak setup from the platform's side; this one tells you, as a consumer of Corti access tokens, exactly what claims you get, under which top-level object, what their types are, and how to parse them — and describes the organization-based target model we are moving to.
The target model below is agreed. Earlier revisions of this document
described a different model (a fully retired corti object, studio_org /
customer_org types, groups removed, users in several organizations) — all of
that is superseded. For the rollout, the decision history, and the items
still open, see Organization Migration.
1. Who this is for
Engineers writing code that reads a Corti access token (backend services, BFFs, frontends). You care about three things:
- Which claims exist and what they mean.
- Which top-level object each claim lives under.
- How to parse each value without breaking — today they're flat strings; in the target model they're arrays under an organization.
For the platform/Keycloak side — which client has which mapper, where values come from — see Token Mappers. For the consumer side — which repos read which claims and how — see Token Consumers.
2. Today vs target, at a glance
| Today | Target | |
|---|---|---|
| Project/customer identity | corti object, plus the groups claim path |
organization.<alias> |
| Shape | Flat object | Object keyed by org alias, exactly one entry |
| Value type | Strings | Arrays (except id, a string) |
| Populated by | Hardcoded claims + per-client dedicated scopes + user/group attribute mappers | Keycloak Organizations |
corti object |
Carries everything | Survives, narrowed to client-level attributes only |
groups claim |
Carries platform-api/<project>/<customer> |
Retired — group membership moves under organization.<alias>.groups, with org-relative paths |
This is a breaking change. Code that reads token.corti.console_project_id
(a string) must change to read
token.organization.<org>.console_project_id (an array — take element 0).
Code that parses platform-api/<project>/<customer> out of the groups claim
must change to read organization attributes instead.
corti does not disappear. It narrows to client-level attributes and
becomes the override half of the precedence rule in §6.
3. Current token shape (today)
Today, custom claims are all under corti and are all strings:
"corti": {
"stripe_customer_id": "",
"console_project_id": "",
"metronome_customer_id": "",
"openmeter_customer_id": "",
"console_project_uuid": ""
}
Some of today's values come from Keycloak groups and user attributes
(e.g. retention_minutes, isNFR, the user-level metronome_customer_id), and
project identity for delegated clients comes from the groups claim path
rather than a claim of its own. See Token
Mappers for the full current picture. All of these
sources are being consolidated — see §7.
4. What's changing — organizations
We are enabling the Keycloak Organizations feature so customers can bring their own SSO (an external identity provider per organization).
- A Console Customer becomes an organization. Keycloak organization groups (a 26.6 feature) sit underneath for departments and label scoping. Naming is 1:1 through the stack — Organization → Group — and Console keeps the user-facing "Customer" label for the organization.
- Every organization has an
Allgroup containing all of its end-users, so org-wide template scoping has something to target. Users may be in several groups within their organization. - A user belongs to exactly ONE organization ⇒ the
organizationobject has exactly one entry. You never have to disambiguate. - Service accounts and studio users are organization members too. Each project
has a hidden organization holding its client-credentials service accounts
and
studio_user_*accounts, carrying the same project attributes. This is why a client-credentials token carries identity at all. - Group attributes go away. Everything that was a group attribute
(
is_nfr,retention_minutes,console_*) becomes an organization attribute. The user-levelmetronome_customer_idmapper is removed too, and so is theMetronome Customer IDattribute on the user entity — it only ever existed as a fallback for users belonging to no group, and every principal is now an organization member. Nothing should read billing identity off a user attribute after the migration. cortinarrows to client-level attributes: a per-clientretention_minutesoverride,auto_created, andis_studio_client. Keycloak owns theorganizationobject and client-level values cannot be written into it, so a separate namespace is required.- Dropped entirely:
console_project_uuid,stripe_customer_id,console_distributor_id,openmeter_customer_id.
Because organization attributes cannot be re-mapped through the usual User
Attribute claim mappers, Keycloak emits them under the top-level organization
object and always as arrays — a property of the feature we can't change. The
contract below adapts to that.
The organization scope ships from Keycloak as an optional client scope.
Corti makes it a default scope on every client, because corti no longer
carries project identity — a client that omitted the scope would get a token
with no identity at all.
5. Target token shape
There is one organization, keyed by its alias. id is a string; every other
organization attribute is an array. corti carries client-level values only:
{
"organization": {
"gp": {
"id": "f8d3c4e1-2477-44d7-a85b-d3b43f6b31fa",
"console_project_id": ["alstertext"],
"console_customer_id": ["gp"],
"metronome_customer_id": ["d657c398-ec1d-4e7b-8f9e-3b175712d332"],
"is_nfr": ["false"],
"retention_minutes": ["43200"],
"groups": ["/All"]
}
},
"corti": {
"retention_minutes": "720",
"auto_created": "true",
"is_studio_client": "false"
}
}
Organization attributes are always emitted — unset values are filled with a
default, never omitted. The key is always present; only its value differs.
This is a guarantee from platform-api, which always writes every attribute,
not a property of Keycloak.
- String attributes (
console_project_id,console_customer_id,metronome_customer_id,retention_minutes) default to the string"null"— i.e.["null"]. This is the literal four-character stringnull, not JSONnulland not an empty string. - Boolean attributes (
is_nfr) default to the string"false"— i.e.["false"].
Compare value[0] === "null" (or === "false") to detect "unset". Do not
assume a missing key.
The type of the client-level corti.retention_minutes is not yet settled.
It is shown as a string above, but it is emitted as a JSON int today
(platform-api sets "jsonType.label": "int" on the mapper) and copilot-api —
its only consumer — decodes it as *int with a test asserting a string fails.
The organization half (organization.<org>.retention_minutes) is a string
array regardless. See Organization Migration
§10.
Notes:
idis a string; every other organization attribute is an array.groupsholds organization-relative paths (/All,/Weight-Mgmt). There is noplatform-api/<project>/<customer>prefix — project and customer identity come fromconsole_project_idandconsole_customer_id.is_nfrandretention_minutesare always present (not "only when true").- For the two accounts on the department model (BUPA, Aleris),
console_customer_idholds the organization name; per-service-line identity is only ingroups.
6. The parsing contract
-
There is exactly one organization. Take the single entry of the
organizationmap — don't hardcode the org alias and don't assume there could be several. -
organization.<org>.id→ string. It is the one attribute that is not an array. Special-case it. -
Every other organization attribute → array. Read element
[0], but do it defensively (handle empty/missing). -
Apply the client-level override. The override rule applies only to the slim client-level set (
retention_minutes,auto_created,is_studio_client). For those keys,cortiwins:effective = corti.<key> ?? organization.<org>.<key>[0]No other
corti.*key is an override. During the dual-emit window the legacycorti.*namespace still carries org-level values likeconsole_project_idandmetronome_customer_id— those are stale duplicates of what is now inorganization.<org>, not overrides, and must defer to the organization. See §7 for the dual-emit precedence guidance. -
Unset values are the string
"null"(or"false"), never a missing key. Treatvalue[0] === "null"as "unset". Do not confuse it with JSONnullor an empty string. -
Don't parse
groupsfor identity.organization.<org>.groupsis for template/label scoping only. Project and customer identity come fromconsole_project_idandconsole_customer_id. -
organizationshould always be present because the scope is a default scope — but handle its absence explicitly rather than panicking, since a misconfigured client would then have no identity at all.
Per-claim reference (target)
| Claim | Location | Type | How to read | Default when unset |
|---|---|---|---|---|
id |
organization.<org> |
String | read directly | — |
console_project_id |
organization.<org> |
Array | value[0] |
["null"] |
console_customer_id |
organization.<org> |
Array | value[0] |
["null"] |
metronome_customer_id |
organization.<org> |
Array | value[0] |
["null"] |
is_nfr |
organization.<org> |
Array | value[0], always present |
["false"] |
retention_minutes |
organization.<org> |
Array | value[0], corti overrides |
["null"] |
groups |
organization.<org> |
Array | org-relative paths; scoping only | [] |
retention_minutes |
corti |
String | client-level override | absent |
auto_created |
corti |
String | client-level | absent |
is_studio_client |
corti |
String | client-level | absent |
Example — Go
// orgAttr returns element 0 of an organization attribute array, normalising the
// literal "null" default to "" so callers can treat unset uniformly.
func orgAttr(org map[string]any, key string) string {
if v, ok := org[key].([]any); ok && len(v) > 0 {
if s, ok := v[0].(string); ok && s != "null" {
return s
}
}
return "" // missing, empty, or the sentinel "null"
}
// effective applies the client-level override: corti wins over the organization.
func effective(corti, org map[string]any, key string) string {
if s, ok := corti[key].(string); ok && s != "" {
return s
}
return orgAttr(org, key)
}
orgs, ok := claims["organization"].(map[string]any)
if !ok || len(orgs) == 0 {
// no organization scope on the token — it has no project identity
return
}
// exactly one organization: take the single entry
var org map[string]any
for _, raw := range orgs {
org, _ = raw.(map[string]any)
break
}
corti, _ := claims["corti"].(map[string]any)
id, _ := org["id"].(string) // string
projectID := orgAttr(org, "console_project_id") // array → element 0
isNFR := orgAttr(org, "is_nfr") == "true"
retention := effective(corti, org, "retention_minutes") // client override wins
_ = id; _ = projectID; _ = isNFR; _ = retention
Example — TypeScript (frontend)
const attr = (org: Record<string, string[]>, key: string) => {
const raw = (org[key] ?? [])[0];
return raw === "null" || raw === undefined ? undefined : raw;
};
const orgs = claims.organization ?? {}; // should always be present
const org = Object.values(orgs)[0]; // exactly one entry
if (!org) {
// no organization scope — no project identity
} else {
const id = org.id as string; // string, not an array
const projectId = attr(org, "console_project_id");
const customerId = attr(org, "console_customer_id");
const isNfr = attr(org, "is_nfr") === "true";
// client-level override wins
const retention = claims.corti?.retention_minutes ?? attr(org, "retention_minutes");
}
7. Claim migration
| Claim | Today | Target |
|---|---|---|
console_project_id |
corti (string), or the groups path on delegated clients |
organization.<org>.console_project_id (array) |
console_customer_id |
group attribute / the groups path |
organization.<org>.console_customer_id (array) |
metronome_customer_id |
corti (hardcoded) + user attribute |
organization.<org>.metronome_customer_id (array) |
is_nfr / isNFR |
corti (group-fed, only-when-true) |
organization.<org>.is_nfr (array, always present) |
retention_minutes |
corti (group-fed) |
organization.<org>.retention_minutes (array), overridable by corti.retention_minutes |
| group membership | groups claim, platform-api/<project>/<customer> |
organization.<org>.groups (array, org-relative paths) |
auto_created |
corti (default clients) |
corti.auto_created (client-level, unchanged location) |
is_studio_client |
corti (studio client) |
corti.is_studio_client (client-level, unchanged location) |
console_project_uuid |
corti (string) |
removed |
stripe_customer_id |
corti (string) |
removed |
openmeter_customer_id |
corti (string) |
removed |
console_distributor_id |
group attribute | removed |
There is a dual-emit window. During the transition a token carries the full
legacy corti.*, the legacy groups claim, and the new organization.*
simultaneously. The precedence rule is not a blanket "one namespace wins" — it
depends on which kind of corti.* key is present:
- Org-level attributes (
console_project_id,console_customer_id,metronome_customer_id,is_nfr, etc.): preferorganization.<org>. The legacycorti.*copies are stale duplicates, not overrides. - Slim client-level override set (
retention_minutes,auto_created,is_studio_client):corti.*still takes precedence per the §6 rule.
At cutover the legacy keys and the groups claim are removed and corti is
reduced to its client-level set. See Organization
Migration.
8. Decisions and remaining open items
Settled:
organizationis a default client scope, not optional.- A Console Customer maps to an organization; organization groups are used
for departments. BUPA and Aleris are the two department-model exceptions
(one
Defaultorg, Customers as groups). - One hidden organization per project holds service accounts and studio users.
cortisurvives, narrowed to client-level attributes, and is the override half of the precedence rule.- Cutover is dual-emit then shrink, per realm.
- Client-credentials tokens do carry
organization.*(verified on 26.7.0).
Still open — do not build against these:
- Whether a default organization is auto-created per project, or created by the integrator.
- Whether
is_nfrsurvives the migration at all (it is being phased out). - The Medatixx migration shape under
customer == org.
Full detail and ownership in Organization Migration §10.
9. Conventions & recommendations
- One source per claim, and one precedence rule. Organization attributes are
the source of truth;
cortiexists only for client-level overrides. Nothing is duplicated across namespaces for the same level. - Accept arrays. Organization attributes are arrays by nature — parse with
"element 0 + defensive handling" rather than expecting scalars.
idis the only string. - Don't derive identity from paths. Group paths are scoping labels, not
identity. Use
console_project_id/console_customer_id. - Use the shared library.
common-service-library'sjwtclaimspackage gains an organization type with the[0]-plus-override helper; prefer it over hand-rolling a struct per service. - Standardise naming — keep organization attribute keys snake_case.