Orca ORCA v0.174.2
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.

Note

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:

  1. Which claims exist and what they mean.
  2. Which top-level object each claim lives under.
  3. 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
Important

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 All group 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 organization object 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-level metronome_customer_id mapper is removed too, and so is the Metronome Customer ID attribute 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.
  • corti narrows to client-level attributes: a per-client retention_minutes override, auto_created, and is_studio_client. Keycloak owns the organization object 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.

Note

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"
  }
}
Important

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 string null, not JSON null and 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.

Warning

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:

  • id is a string; every other organization attribute is an array.
  • groups holds organization-relative paths (/All, /Weight-Mgmt). There is no platform-api/<project>/<customer> prefix — project and customer identity come from console_project_id and console_customer_id.
  • is_nfr and retention_minutes are always present (not "only when true").
  • For the two accounts on the department model (BUPA, Aleris), console_customer_id holds the organization name; per-service-line identity is only in groups.

6. The parsing contract

  1. There is exactly one organization. Take the single entry of the organization map — don't hardcode the org alias and don't assume there could be several.

  2. organization.<org>.id → string. It is the one attribute that is not an array. Special-case it.

  3. Every other organization attribute → array. Read element [0], but do it defensively (handle empty/missing).

  4. 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, corti wins:

    effective = corti.<key> ?? organization.<org>.<key>[0]
    

    No other corti.* key is an override. During the dual-emit window the legacy corti.* namespace still carries org-level values like console_project_id and metronome_customer_id — those are stale duplicates of what is now in organization.<org>, not overrides, and must defer to the organization. See §7 for the dual-emit precedence guidance.

  5. Unset values are the string "null" (or "false"), never a missing key. Treat value[0] === "null" as "unset". Do not confuse it with JSON null or an empty string.

  6. Don't parse groups for identity. organization.<org>.groups is for template/label scoping only. Project and customer identity come from console_project_id and console_customer_id.

  7. organization should 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
Important

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.): prefer organization.<org>. The legacy corti.* 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:

  • organization is 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 Default org, Customers as groups).
  • One hidden organization per project holds service accounts and studio users.
  • corti survives, 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_nfr survives 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; corti exists 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. id is 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's jwtclaims package 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.