Skip to content

Commit 71abeb3

Browse files
committed
Add the authorization support for federated entities specification
Documents the two mapping mechanisms (value-based rules and direct name matching) that resolve external claims to local roles, groups, and permissions, and how they combine at federated login, token exchange, and ID-JAG assertion issuance/consumption. Refs #5192 Signed-off-by: thiva-k <kalyanasundaramthivaharan@gmail.com>
1 parent 87855f2 commit 71abeb3

3 files changed

Lines changed: 353 additions & 0 deletions

File tree

139 KB
Loading
83.5 KB
Loading
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
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+
![Direct mode](assets/direct-mode.png)
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+
![Advanced mode](assets/advanced-mode.png)
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

Comments
 (0)