|
| 1 | +# Authorization support for federated entities |
| 2 | + |
| 3 | +- **Status:** Final |
| 4 | +- **Version:** 1.0 |
| 5 | +- **Related documents:** [Discussion #5197: Authorization support for federated entities](https://github.com/thunder-id/thunderid/discussions/5197), Issue #5192 |
| 6 | + |
| 7 | +## Summary |
| 8 | + |
| 9 | +Organizations manage their users in an external identity provider while ThunderID manages agents, |
| 10 | +applications, and the permission model. Without a way to translate what the provider asserts into local |
| 11 | +authorization, every federated entity either goes unauthorized or has to be assigned roles and groups by |
| 12 | +hand, duplicating policy the organization already maintains elsewhere. |
| 13 | + |
| 14 | +Authorization mapping resolves values a connection's federated claims assert (a `groups` claim, a |
| 15 | +`role_name` claim, and so on) into local role, group, or permission references, using the same |
| 16 | +authorization model ThunderID already applies to locally-managed subjects. A mapping never authorizes on |
| 17 | +its own; it only resolves a claim value to a local reference. ThunderID's own RBAC engine decides what a |
| 18 | +resolved role or group is worth, the same way it does for a subject's direct assignments (an external |
| 19 | +policy decision point is an alternative decision-maker at this same point; see Requirements). A resolved |
| 20 | +permission target is different: it is not something a subject holds and an engine resolves, it is already |
| 21 | +the answer, so it is combined with whatever the engine decides at the point the granted scopes are worked |
| 22 | +out, rather than being fed into the decision itself. |
| 23 | + |
| 24 | +Two independent mapping modes cover this, addressable together on one connection: |
| 25 | + |
| 26 | +- **Rule-based mapping** compares a claim's resolved value(s) against an administrator-defined table of |
| 27 | + rules (value type, delimiter, operator, comparison value), each granting an explicit set of targets. |
| 28 | + This is the general mechanism: numeric and boolean comparisons, ordering operators, and set-membership |
| 29 | + tests over a multi-valued claim. |
| 30 | +- **Direct name-based mapping** covers the common case where a claim's value already matches a local |
| 31 | + role, group, or permission's name exactly (a `role_name` claim carrying `"Billing Admin"`, say) and an |
| 32 | + administrator shouldn't have to write a rule just to say "look this up by name." Each value is looked |
| 33 | + up directly, with no rule table. |
| 34 | + |
| 35 | +Both are evaluated at the same points (federated login, RFC 8693 token exchange, and ID-JAG assertion |
| 36 | +consumption) through the same shared value-resolution logic, and their resolved targets union. |
| 37 | +The governing design decision is that mapping is purely a *resolution* concern (external value to local |
| 38 | +reference); every existing seam this project uses to decide what an entity holds (the RBAC engine, the |
| 39 | +consent flow, provisioning) is reused unchanged. |
| 40 | + |
| 41 | +## Architecture |
| 42 | + |
| 43 | +```mermaid |
| 44 | +flowchart LR |
| 45 | + Claims["External token claims"] --> RuleMap["Rule-based mapping<br/>(explicit value rules)"] |
| 46 | + Claims --> DirectMap["Direct mapping<br/>(exact name lookup)"] |
| 47 | + RuleMap --> Union["Union targets"] |
| 48 | + DirectMap --> Union |
| 49 | + Union --> Ids["Local role and<br/>group identifiers"] |
| 50 | + Union --> MPerm["Local permissions"] |
| 51 | + Entity["Local entity, if present"] --> Sub["Subject"] |
| 52 | + Ids --> Sub |
| 53 | + Sub --> Engine["Authorization engine"] |
| 54 | + Engine --> Authorized["Authorized permissions"] |
| 55 | + Authorized --> Combine["Combine"] |
| 56 | + MPerm --> Combine |
| 57 | + Combine --> Scopes["Requested scopes<br/>narrowed to authorized"] |
| 58 | +``` |
| 59 | + |
| 60 | +Responsibilities: |
| 61 | + |
| 62 | +- **Resolving a rule-based mapping** is a self-contained check against the connection's stored |
| 63 | + configuration and the presented claims. Every target a rule can grant was already validated to exist |
| 64 | + when the mapping was saved, so evaluating it needs no external call and cannot fail. |
| 65 | +- **Resolving a direct mapping** defers the "does this name exist" check to request time instead, looking |
| 66 | + up each value live against current roles, groups, or resource-server permissions. Because that lookup |
| 67 | + is live, it can fail for genuine infrastructure reasons, unlike rule-based resolution. |
| 68 | +- **The authorization engine** decides what a resolved role or group is worth, exactly as it does for |
| 69 | + direct assignments; mapping never bypasses it for roles or groups. An external policy decision point is |
| 70 | + an alternative decision-maker at this same point (see Requirements). |
| 71 | +- **Token issuance** (token exchange and ID-JAG) resolves and combines rule-based and direct mapping |
| 72 | + together, and treats a resolution failure as fatal to the request, since these paths must produce a |
| 73 | + definitive answer. |
| 74 | +- **Federated login** resolves and combines the same way, but treats the same kind of failure as |
| 75 | + non-fatal, continuing without the failed mapping's contribution, since login is best-effort enrichment |
| 76 | + rather than a step that must succeed. |
| 77 | +- Mappings live beside the existing attribute mappings on the connection's stored configuration, with no |
| 78 | + new storage and no database schema change. |
| 79 | + |
| 80 | +## Detailed design |
| 81 | + |
| 82 | +### Rule-based mapping |
| 83 | + |
| 84 | +An administrator names a source claim (a dot-notation path into a nested claim is supported), an |
| 85 | +optional declared value type (string, number, boolean, or list), an optional delimiter for splitting a |
| 86 | +single string value into multiple tokens, and a table of rules. Each rule pairs a comparison (equals, |
| 87 | +not equals, an ordering comparison, or a set-membership test) against a value with the local roles, |
| 88 | +groups, or permissions it grants when the comparison matches. The result of a mapping is the union of |
| 89 | +every matching rule's targets; a claim value that satisfies no rule confers nothing, even if it happens |
| 90 | +to match the name of a local role or group. |
| 91 | + |
| 92 | +The declared value type does not control whether the claim's runtime value is treated as a list; that is |
| 93 | +detected automatically from the claim's actual shape (see *Value resolution*). The declared value type |
| 94 | +governs something else entirely: a mapping is declared multi-valued when its value type is list, or when |
| 95 | +its value type is string with a delimiter set, a property of the mapping's own configuration fixed at |
| 96 | +save time, unrelated to what shape a claim actually turns out to have at request time. A multi-valued |
| 97 | +mapping only accepts a set-membership comparison, matched as a literal string against each resolved |
| 98 | +token; every other mapping only accepts equals, not equals, or (for a numeric value type) an ordering |
| 99 | +comparison, each parsed and compared as a number, a boolean, or a literal string to match the declared |
| 100 | +type. Both are checked when the mapping is saved, independently of whether the named roles, groups, and |
| 101 | +permissions actually exist, which is checked separately since it needs a live lookup. |
| 102 | + |
| 103 | +### Direct name-based mapping |
| 104 | + |
| 105 | +An administrator names a source claim, an optional delimiter, and a target kind (role, group, or |
| 106 | +permission), plus a resource server when the target kind is permission, since a permission only means |
| 107 | +something on one resource server. Every resolved value of the claim is looked up directly: |
| 108 | + |
| 109 | +- **Role or group:** matched by exact name against all existing roles or groups, with no organization |
| 110 | + unit restriction. A name that matches no role/group, or matches more than one (role and group names |
| 111 | + are unique only within an organization unit), confers nothing, since the mapping fails closed on |
| 112 | + ambiguity rather than granting one match arbitrarily. |
| 113 | +- **Permission:** validated directly against the named resource server's defined permission strings, |
| 114 | + since a permission is a string rather than a separately named entity with its own existence check. |
| 115 | + A value that doesn't validate is dropped individually, without rejecting the other valid values the |
| 116 | + same claim carries. |
| 117 | + |
| 118 | +Direct mapping intentionally has no per-value rule table and no declared value type, since a claim value |
| 119 | +is always used as a literal name or permission string, so there is nothing to declare a comparison type |
| 120 | +for. |
| 121 | + |
| 122 | +### Value resolution |
| 123 | + |
| 124 | +Both mapping modes read a claim's resolved value the same way, driven entirely by its actual shape: |
| 125 | + |
| 126 | +| Claim shape | Example | Handling | |
| 127 | +|---|---|---| |
| 128 | +| List | `groups: ["engineering", "platform-admins"]` | Each element resolved independently; no delimiter needed | |
| 129 | +| Delimited string | `scope: "orders.read orders.write"` | Split on the configured delimiter, then each token resolved | |
| 130 | +| Single value | `department: "platform"` | Used as one token, untrimmed (may carry meaningful whitespace) | |
| 131 | + |
| 132 | +A list-valued claim is detected structurally from the JSON the identity provider actually asserts. The |
| 133 | +UI has no separate "array" toggle because there is nothing to declare; a delimiter exists only to handle |
| 134 | +the one ambiguous case, a single string that is secretly a packed list. |
| 135 | + |
| 136 | +### Combining rule-based and direct mapping |
| 137 | + |
| 138 | +A connection may configure rule-based mapping, direct mapping, both, or neither, independently. Every |
| 139 | +matching rule's targets and every direct mapping's resolved targets union into one set, then split by |
| 140 | +kind: role and group references fold into the subject the authorization engine evaluates, while |
| 141 | +permission references combine directly with the engine's decision rather than going through it, since a |
| 142 | +permission target is already the answer. The connection's mapping counts as configured, and is therefore |
| 143 | +the sole authority for scopes in preference to the token's own asserted scope claim, once either mode has |
| 144 | +at least one entry. |
| 145 | + |
| 146 | +Rule-based and direct resolution are combined independently at token issuance (token exchange and |
| 147 | +ID-JAG) and at federated login, rather than through one shared step, because the two entry points' |
| 148 | +failure handling genuinely differs: token issuance must produce a definitive answer, so a resolution |
| 149 | +failure there is fatal to the request; federated login is best-effort, so the same kind of failure there |
| 150 | +is not. |
| 151 | + |
| 152 | +### Provisioning-time assignment |
| 153 | + |
| 154 | +A provisioning step may opt in, per target kind, to seeding role/group assignment from whatever the |
| 155 | +mapping already resolved for the entity's claims, merged with any fixed role/group lists already |
| 156 | +configured on the step. This is a one-time assignment |
| 157 | +made when the entity is created; it becomes an ordinary assignment the administrator then owns, with no |
| 158 | +provenance tracking and no reconciliation. An entity that has already been provisioned is not |
| 159 | +re-evaluated on a later federated login, so a subsequent administrative change to its assignments stands. |
| 160 | + |
| 161 | +### Federated login flow |
| 162 | + |
| 163 | +```mermaid |
| 164 | +flowchart LR |
| 165 | + START([START]) --> Fed["Federated<br/>authentication"] --> Prov["Provisioning"] --> Authz["Authorization"] --> Consent["Consent"] --> Assert["Auth assertion"] --> END([END]) |
| 166 | +``` |
| 167 | + |
| 168 | +Mapping is resolved during federated authentication, which already holds the connection service and the |
| 169 | +claims, and carried forward as runtime data. Provisioning must precede authorization so that any |
| 170 | +assignments it creates are visible to the RBAC engine. Consent follows authorization because it narrows |
| 171 | +the authorized set, and the assertion intersects the consented set against it. Interactive login still |
| 172 | +requires a local record for the assertion, consent, and released-attribute steps, though no longer for |
| 173 | +authorization itself. |
| 174 | + |
| 175 | +Claims come from the connection's ID token where it has one, supplemented by a UserInfo endpoint call |
| 176 | +(authenticated with the access token) when the connection has one configured, merged in only for claims |
| 177 | +the ID token doesn't already carry. A connection with neither an ID token nor a UserInfo endpoint |
| 178 | +contributes no attributes beyond the subject, so a claim an admin maps against must actually be reachable |
| 179 | +through one of these two sources. |
| 180 | + |
| 181 | +### Token exchange and identity assertions |
| 182 | + |
| 183 | +```mermaid |
| 184 | +sequenceDiagram |
| 185 | + participant Client |
| 186 | + participant Token as Token endpoint |
| 187 | + participant Connection as Federated connection |
| 188 | + participant Authz as Authorization engine |
| 189 | +
|
| 190 | + Client->>Token: exchange external token |
| 191 | + Token->>Connection: resolve issuer, apply mapping |
| 192 | + Connection-->>Token: mapped role, group, and permission identifiers |
| 193 | + Token->>Authz: evaluate requested permissions |
| 194 | + Authz-->>Token: permitted permissions |
| 195 | + Token->>Token: combine with mapped permissions |
| 196 | + Token-->>Client: access token |
| 197 | +``` |
| 198 | + |
| 199 | +Resolving the issuer to a connection and applying its mapping already happens as part of validating the |
| 200 | +incoming token, so this adds no additional lookup beyond the shared evaluate-and-combine step. This path |
| 201 | +needs no local record; it is where authorization for externally managed entities without a ThunderID |
| 202 | +record works end to end. For ID-JAG, the assertion is minted at issuance with the client's requested |
| 203 | +scopes, unfiltered by mapping at that point; mapping is what determines the granted access at |
| 204 | +consumption, when the assertion is presented on the jwt-bearer grant and its own claims are resolved |
| 205 | +fresh against the connection's mapping. |
| 206 | + |
| 207 | +A renewed token has no external claims to re-read: mapped role/group identifiers are carried on the |
| 208 | +renewal credential and re-resolved against the RBAC engine, so a change to a role's own permissions is |
| 209 | +picked up, while a change to the entity's memberships at the provider only applies at the next federated |
| 210 | +login. |
| 211 | + |
| 212 | +### API |
| 213 | + |
| 214 | +A connection's attribute configuration gains an optional authorization mapping section holding the |
| 215 | +rule-based and direct mapping lists described above, alongside the connection's existing attribute |
| 216 | +mappings. The full schema, field-level requirements, and validation error catalog are defined in the |
| 217 | +connections API spec. A rule-based mapping's role, group, and permission targets are validated to exist |
| 218 | +at save time; a direct mapping has no such targets to validate, since its role, group, and permission |
| 219 | +names only exist as claim values resolved at request time, but a permission-target direct mapping's |
| 220 | +resource server is validated to exist at save time. The connection is rejected otherwise. |
| 221 | + |
| 222 | +Example request body, combining a direct mapping and a rule-based mapping on one connection: |
| 223 | + |
| 224 | +```json |
| 225 | +{ |
| 226 | + "attributeConfiguration": { |
| 227 | + "authorizationMapping": { |
| 228 | + "rules": [ |
| 229 | + { |
| 230 | + "claim": "groups", |
| 231 | + "valueType": "array", |
| 232 | + "values": [ |
| 233 | + { |
| 234 | + "operator": "includes", |
| 235 | + "value": "platform-admins", |
| 236 | + "targets": [{"type": "role", "id": "9c1b1b0a-3f2e-4b7a-9c1e-2f6b8a0d4e11"}] |
| 237 | + } |
| 238 | + ] |
| 239 | + } |
| 240 | + ], |
| 241 | + "direct": [ |
| 242 | + {"claim": "role_name", "targetType": "role"} |
| 243 | + ] |
| 244 | + } |
| 245 | + } |
| 246 | +} |
| 247 | +``` |
| 248 | + |
| 249 | +### UI |
| 250 | + |
| 251 | +The console's connection edit page gains an "Authorization Mapping" card under the Attributes tab. Each |
| 252 | +row maps one claim; by default its values are matched by name against an existing role, group, or |
| 253 | +permission: |
| 254 | + |
| 255 | + |
| 256 | + |
| 257 | +An Advanced Rules toggle switches the row to explicit value-to-target rules instead, each granting a |
| 258 | +combination of roles, groups, and permissions: |
| 259 | + |
| 260 | + |
| 261 | + |
| 262 | +## Requirements |
| 263 | + |
| 264 | +### R1. Mapping external attributes to local authorization |
| 265 | + |
| 266 | +**Requirement:** An administrator maps external attribute values to local roles, groups, or permissions, |
| 267 | +and a federated entity receives the resulting permissions. |
| 268 | + |
| 269 | +**Acceptance criteria:** |
| 270 | + |
| 271 | +- **AC1.1:** Given an administrator maps an attribute value to a target, when they define how it |
| 272 | + resolves, then they can use either a value-based rule or a direct name match. |
| 273 | +- **AC1.2:** Given an attribute value carries more than one value, whether as a list or a delimited |
| 274 | + string, when it is resolved, then every value is looked up independently and the results combine. |
| 275 | +- **AC1.3:** Given an administrator maps attribute values to roles, groups, or permissions, when an |
| 276 | + entity federates in carrying one or more of those values, then the entity receives the union of the |
| 277 | + permissions those values map to. |
| 278 | +- **AC1.4:** Given an attribute value has no mapping configured for it, or resolves ambiguously to more |
| 279 | + than one target, when an entity federates in carrying it, then it confers no permissions. |
| 280 | +- **AC1.5:** Given a rule-based mapping names a role, group, or permission that does not exist, or a |
| 281 | + permission-target direct mapping names a resource server that does not exist or omits one, when an |
| 282 | + administrator saves the configuration, then ThunderID rejects it. |
| 283 | + |
| 284 | +### R2. Narrowing requested scopes to what is permitted and consented |
| 285 | + |
| 286 | +**Requirement:** An issued token carries only the scopes that were requested, permitted, and consented |
| 287 | +to, so that it never grants more than the entity is authorized for. |
| 288 | + |
| 289 | +**Acceptance criteria:** |
| 290 | + |
| 291 | +- **AC2.1:** Given an entity is authorized through direct role/group assignment, through mapping, or |
| 292 | + both, when a token is issued, then the granted scopes are the requested scopes covered by those |
| 293 | + sources combined. |
| 294 | +- **AC2.2:** Given a request targets a specific resource server, when a token is issued, then the |
| 295 | + granted scopes are limited to that resource server's permissions. |
| 296 | +- **AC2.3:** Given an application requests scopes the user is authorized for through a mapping, when |
| 297 | + consent is presented, then those scopes are shown and can be declined. |
| 298 | +- **AC2.4:** Given a token is renewed without a new federated login, when the token is issued, then a |
| 299 | + change at the provider takes effect no later than the end of the session. |
| 300 | + |
| 301 | +### R3. Consistent behavior across entry points |
| 302 | + |
| 303 | +**Requirement:** The same mapping is evaluated at federated login, at token exchange, and for identity |
| 304 | +assertions, so that an entity's authorization does not depend on how its identity reached ThunderID. |
| 305 | + |
| 306 | +**Acceptance criteria:** |
| 307 | + |
| 308 | +- **AC3.1:** Given the presented token carries its own scopes, when those scopes are not covered by the |
| 309 | + connection's mapping, then they are not granted. |
| 310 | +- **AC3.2:** Given a client presents its own identity alongside the entity's, when a token is issued, |
| 311 | + then the granted scopes do not exceed the client's own authorization. |
| 312 | +- **AC3.3:** Given an identity assertion is exchanged for a token, then the granted scopes reflect the |
| 313 | + mapping evaluated from the assertion's own claims at that point, not from whatever was resolved when |
| 314 | + the assertion was issued. |
| 315 | + |
| 316 | +### R4. Assigning roles and groups at provisioning |
| 317 | + |
| 318 | +**Requirement:** An administrator can have roles and groups assigned to an entity when it is |
| 319 | +provisioned, derived from what the connection's mapping resolved, so that a new entity starts with the |
| 320 | +access its attributes indicate. |
| 321 | + |
| 322 | +**Acceptance criteria:** |
| 323 | + |
| 324 | +- **AC4.1:** Given a provisioning step opts in to seeding from the resolved mapping, when an entity is |
| 325 | + provisioned, then the roles and groups the mapping resolved are assigned to it, alongside any fixed |
| 326 | + lists configured on the step. |
| 327 | +- **AC4.2:** Given an entity has already been provisioned, when it federates in again, then the |
| 328 | + assignments are not re-evaluated, so any later administrative change stands. |
| 329 | + |
| 330 | +### R5. Federated entities without a local record |
| 331 | + |
| 332 | +**Requirement:** An entity managed entirely in the external provider is authorized from its external |
| 333 | +attributes without a ThunderID record, so that duplicate identity data is not maintained. |
| 334 | + |
| 335 | +**Acceptance criteria:** |
| 336 | + |
| 337 | +- **AC5.1:** Given no local record exists for the federated identity, when a client presents it through |
| 338 | + token exchange, then ThunderID authorizes the request from the connection's mapping. |
| 339 | + |
| 340 | +### R6. Authorization from an external policy decision point (out of scope) |
| 341 | + |
| 342 | +**Requirement:** An administrator can have a federated entity's requested permissions evaluated by an |
| 343 | +external policy decision point, so that authorization policy can stay where the organization already |
| 344 | +manages it, as an alternative to the authorization engine described in Architecture. |
| 345 | + |
| 346 | +Not covered by this specification. Tracked separately in |
| 347 | +[Discussion #5126](https://github.com/thunder-id/thunderid/discussions/5126) and Issue #5151. |
| 348 | + |
| 349 | +## Change log |
| 350 | + |
| 351 | +| Version | Date | Change | |
| 352 | +|---|---|---| |
| 353 | +| 1.0 | 2026-09-07 | Initial specification, covering rule-based mapping, direct name-based mapping, and their combination across federated login, token exchange, and ID-JAG. | |
0 commit comments