Skip to content

feat(realm): tenant translation contract + single-tenant guard (Phase 3, #2196) - #2239

Merged
cyberantonz merged 6 commits into
constructorfabric:mainfrom
cyberantonz:feat/2196-tenant-translation
Aug 6, 2026
Merged

feat(realm): tenant translation contract + single-tenant guard (Phase 3, #2196)#2239
cyberantonz merged 6 commits into
constructorfabric:mainfrom
cyberantonz:feat/2196-tenant-translation

Conversation

@cyberantonz

@cyberantonz cyberantonz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What (boxes 1–3 of #2196, per the pinned-tenant decision)

Tenant policy — pinned only, translation REJECTED. Review decision on this PR: the tenant is always the fixed per-registration pin (INSIGHT_TENANT_ID from environment values); an IdP's own tenancy assertions are never consulted; a customer with several IdPs pins the same tenant on each registration. The claim-to-group translation sketched in ADR-0003 is rejected — the ADR is amended in this PR (status-history entry; cfs clean for authenticator artifacts). Two customers sharing an IdP vendor never intersect: realm-per-customer gives each its own registration, client, and pin.

Canonical claim contract — the insight scope formalizes idp_sub (upstream directory id feeding the login-bootstrap, proven on the first migrated stand). No group aggregation: without it a tenant-bearing group cannot influence the token, eliminating the shadowing hazard at the mechanism level rather than policing it.

Pinned-contract CI guarddeploy/compose/keycloak/tests/tenant_contract_guard.py + a small workflow lane (docker Keycloak, no cluster). It imports the actual canonical realm file and asserts against real token evaluation:

  • clean baseline: the canonical realm ships zero tenant-bearing groups
  • no pinned attribute → claim absent (fail closed)
  • pinned attribute → emitted verbatim, a single string
  • a tenant-bearing group is inert (token unchanged) and flagged by the realm scan as a policy violation

All four verified green locally against KC 26.4.

tenant_claim freeze — already defaults to tenant_id at every surface; the chart comment records the freeze per ADR-0003, configurable only for third-party consumers wiring a non-broker IdP directly.

Refs #2196
Part of #2193

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added identity-provider subject identifiers to authentication tokens.
    • Added automated validation for tenant isolation and token-claim behavior.
    • Added manual and change-triggered CI checks for Keycloak tenant configuration.
  • Documentation

    • Clarified that tenant assignment is fixed per identity provider and fails closed when unavailable.
    • Documented configurable tenant claims for direct identity-provider integrations.
    • Clarified that upstream tenant claims and groups cannot override the pinned tenant.

…ructorfabric#2196)

- Canonical insight scope: tenant_id mapper aggregates over groups, so a
  per-tenant group's attribute serves single-registration-multi-tenant
  installs with no scope change; idp_sub passthrough (upstream directory
  id for the login-bootstrap) formalized in the canonical contract.
- Translation-table pattern documented (advanced claim-to-group mapper,
  one entry per external tenant, unmapped fails closed) — mapper shape
  validated against KC 26.
- CI guard (tenant_translation_guard.py + workflow): boots a throwaway
  Keycloak, imports the ACTUAL canonical realm, and asserts DD-AUTH-04:
  fail-closed with no source, correct group translation, and detection
  of the two ambiguities token inspection cannot see — two tenant
  groups, and a group silently shadowing the pinned attribute (both
  observed live: Keycloak emits one arbitrary value).
- tenant_claim freeze noted at the chart surface (defaults tenant_id per
  ADR-0003; configurable only for non-broker third-party IdPs).

Refs constructorfabric#2196
Part of constructorfabric#2193

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
@cyberantonz
cyberantonz requested a review from a team as a code owner August 5, 2026 11:56
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@cyberantonz, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 63de166c-354b-4368-9124-a077f07375a8

📥 Commits

Reviewing files that changed from the base of the PR and between e07d872 and 4b098f2.

📒 Files selected for processing (1)
  • deploy/compose/keycloak/tests/tenant_contract_guard.py
📝 Walkthrough

Walkthrough

The PR defines pinned tenant_id handling, adds an idp_sub mapper, validates the canonical Keycloak realm with a disposable container, and runs the guard for relevant changes in GitHub Actions.

Changes

Tenant translation contract

Layer / File(s) Summary
Realm tenant mapping
deploy/gitops/environments/local/keycloak/realms/insight-broker.yaml, charts/insight/templates/secrets.yaml, deploy/gitops/README.md, docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md
The realm emits idp_sub and documents fixed tenant_id sourcing, scalar claims, fail-closed behavior, and rejected group-based translation.
Tenant contract validation
deploy/compose/keycloak/tests/tenant_contract_guard.py
The guard starts Keycloak, imports the realm, evaluates token claims, scans groups, checks tenant rules, and cleans up the container.
CI enforcement
.github/workflows/keycloak-realm-contract.yml
GitHub Actions runs the guard for relevant changes and supports manual dispatch.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: ktursunov

Sequence Diagram(s)

sequenceDiagram
  participant Guard
  participant KeycloakContainer
  participant KeycloakAdminAPI
  Guard->>KeycloakContainer: Start disposable Keycloak
  Guard->>KeycloakAdminAPI: Import realm and configure users
  KeycloakAdminAPI-->>Guard: Return token and group data
  Guard->>Guard: Validate tenant contract
  Guard->>KeycloakContainer: Remove disposable container
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the tenant translation contract and single-tenant guard, which are the primary changes in the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (4)
deploy/compose/keycloak/tests/tenant_translation_guard.py (3)

38-38: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the Keycloak image by digest.

quay.io/keycloak/keycloak:26.4 is a mutable tag. A rebuilt tag can change mapper behavior and turn this guard red on an unrelated pull request, or hide a regression. The workflow pins its actions by SHA. Apply the same rule to the container image.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/compose/keycloak/tests/tenant_translation_guard.py` at line 38, Update
the KC_IMAGE constant to reference the Keycloak 26.4 image by its immutable
digest instead of the mutable tag, preserving the intended version while
following the repository’s digest-pinning convention.

108-110: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a check for the idp_sub claim.

This pull request adds the idp_sub mapper to the canonical scope. The guard does not assert it. The login-bootstrap person lookup depends on that claim. A future scope edit can drop the mapper and this lane stays green.

Add a check that sets the idp_sub user attribute and asserts the example token carries the value as a string.

Also guard against a None response in tenant_claim: _call returns None for an empty body, and .get then raises AttributeError.

♻️ Proposed additions
 def tenant_claim(admin: Admin, client_id: str, user_id: str):
     token = admin.realm(f"/clients/{client_id}/evaluate-scopes/generate-example-id-token?userId={user_id}&scope=openid")
-    return token.get("tenant_id")
+    return (token or {}).get("tenant_id")
+
+
+def claim(admin: Admin, client_id: str, user_id: str, name: str):
+    token = admin.realm(f"/clients/{client_id}/evaluate-scopes/generate-example-id-token?userId={user_id}&scope=openid")
+    return (token or {}).get(name)

Also applies to: 182-209

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/compose/keycloak/tests/tenant_translation_guard.py` around lines 108 -
110, Update the tenant_claim test helper to handle a None token response before
reading claims, returning an appropriate empty result instead of calling .get on
None. Extend the related canonical-scope assertions to set the idp_sub user
attribute and verify the generated example ID token contains that value as a
string, alongside the existing tenant_id check.

69-77: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

The admin token is fetched once and never refreshed.

Admin.__init__ stores one access_token. The master realm admin-cli access token lifespan is short by default. The guard then performs the realm import plus roughly twenty admin calls. If the realm import is slow, later calls return HTTP 401 and the guard fails with a traceback instead of a contract result.

Re-authenticate on 401, or refresh the token before each phase.

♻️ Proposed refactor
 class Admin:
     def __init__(self) -> None:
-        self.token = self._call(
+        self.token = self._login()
+
+    def _login(self) -> str:
+        self.__dict__.pop("token", None)
+        return self._call(
             "/realms/master/protocol/openid-connect/token",
             method="POST",
             raw=urllib.parse.urlencode(
                 {"grant_type": "password", "client_id": "admin-cli", "username": "admin", "password": "admin"}
             ).encode(),
         )["access_token"]

Then retry once on HTTPError with code == 401 inside _call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/compose/keycloak/tests/tenant_translation_guard.py` around lines 69 -
77, Update the Admin authentication flow so the token cannot expire during the
guard’s multi-call execution: refresh or re-authenticate before each phase, or
preferably make _call retry once after replacing the token when it receives an
HTTPError with code 401. Preserve normal error propagation and avoid unbounded
retries.
.github/workflows/keycloak-realm-contract.yml (1)

39-43: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Capture Keycloak logs when the guard fails.

The guard removes the container in its finally block. If the realm import or the boot fails, the job output holds a Python traceback and no server log. Diagnosis then requires a local reproduction.

Add a failure step that prints the container logs. The guard must skip the removal when a debug flag is set, otherwise the container is already gone.

Also pin the PyYAML version so a new release cannot change the parse result between runs.

🔧 Proposed workflow change
       - name: Install PyYAML
-        run: python3 -m pip install --quiet pyyaml
+        run: python3 -m pip install --quiet 'pyyaml==6.0.2'
 
       - name: Tenant translation guard
         run: python3 deploy/compose/keycloak/tests/tenant_translation_guard.py
+
+      - name: Keycloak logs on failure
+        if: failure()
+        run: docker logs tenant-guard-kc || echo "container already removed"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/keycloak-realm-contract.yml around lines 39 - 43, Update
the Tenant translation guard workflow to pin the PyYAML dependency to a specific
version and add a failure-only step that prints the Keycloak container logs.
Pass a debug flag from the workflow to tenant_translation_guard.py, and update
its finally cleanup logic to skip container removal when that flag is enabled so
logs remain available after failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/keycloak-realm-contract.yml:
- Around line 12-15: The workflow trigger in keycloak-realm-contract.yml is
broader than the validation performed by tenant_translation_guard.py, so
non-local realm changes can pass without being checked. Update the path filter
to match only the local realm path that the guard actually reads, or expand
tenant_translation_guard.py to iterate over every realm file under
deploy/gitops/environments/*/keycloak/realms/ so the trigger scope and assertion
scope stay aligned.

In `@deploy/compose/keycloak/tests/tenant_translation_guard.py`:
- Around line 79-91: Update the tenant translation guard’s _call method to pass
a finite timeout to urllib.request.urlopen for every admin API request, using
the existing guard timeout convention. Wrap the realm-import operation in
HTTPError handling so failures are reported with a clear named check result
instead of an uncaught traceback.

In `@deploy/gitops/environments/local/keycloak/realms/insight-broker.yaml`:
- Around line 57-65: Add a canonical Keycloak identity-provider mapper for
idp_sub that writes the idp_sub user attribute before the existing
oidc-usermodel-attribute-mapper emits it. Update the realm configuration near
the existing idp_sub mapper, using the established tenant
hardcoded-attribute-idp-mapper shape and preserving the idp_sub claim mapping.

In `@deploy/gitops/README.md`:
- Around line 279-285: Revise the README text around the CI guard reference to
describe it as a contract test that creates a synthetic user in a throwaway
realm and validates the canonical realm shape and detection logic. Remove the
implication that it audits committed YAML, live realms, or blocks existing
deployed users from invalid memberships.

---

Nitpick comments:
In @.github/workflows/keycloak-realm-contract.yml:
- Around line 39-43: Update the Tenant translation guard workflow to pin the
PyYAML dependency to a specific version and add a failure-only step that prints
the Keycloak container logs. Pass a debug flag from the workflow to
tenant_translation_guard.py, and update its finally cleanup logic to skip
container removal when that flag is enabled so logs remain available after
failures.

In `@deploy/compose/keycloak/tests/tenant_translation_guard.py`:
- Line 38: Update the KC_IMAGE constant to reference the Keycloak 26.4 image by
its immutable digest instead of the mutable tag, preserving the intended version
while following the repository’s digest-pinning convention.
- Around line 108-110: Update the tenant_claim test helper to handle a None
token response before reading claims, returning an appropriate empty result
instead of calling .get on None. Extend the related canonical-scope assertions
to set the idp_sub user attribute and verify the generated example ID token
contains that value as a string, alongside the existing tenant_id check.
- Around line 69-77: Update the Admin authentication flow so the token cannot
expire during the guard’s multi-call execution: refresh or re-authenticate
before each phase, or preferably make _call retry once after replacing the token
when it receives an HTTPError with code 401. Preserve normal error propagation
and avoid unbounded retries.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f2bfd1bc-60c0-4e3c-883f-2747ae0008c9

📥 Commits

Reviewing files that changed from the base of the PR and between d892b17 and f15a051.

📒 Files selected for processing (5)
  • .github/workflows/keycloak-realm-contract.yml
  • charts/insight/templates/secrets.yaml
  • deploy/compose/keycloak/tests/tenant_translation_guard.py
  • deploy/gitops/README.md
  • deploy/gitops/environments/local/keycloak/realms/insight-broker.yaml

Comment thread .github/workflows/keycloak-realm-contract.yml
Comment thread deploy/compose/keycloak/tests/tenant_contract_guard.py
Comment thread deploy/gitops/README.md Outdated
Per review: the tenant is ALWAYS the fixed per-registration pin; an
IdP's tenancy assertions are never consulted; a customer with several
IdPs pins the same tenant on each. Drops the group aggregation added
earlier in this branch (which itself created the group-shadows-pin
hazard) and the translation-table docs. The guard now asserts the
pinned contract: clean baseline, fail-closed, pin verbatim as a single
string, tenant-bearing groups INERT and flagged by the realm scan.
ADR-0003 amended (translation rejected); cfs clean for authenticator
artifacts.

Refs constructorfabric#2196
Part of constructorfabric#2193

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@deploy/compose/keycloak/tests/tenant_contract_guard.py`:
- Around line 114-115: Update the group-loading logic in the tenant contract
guard to paginate the `/groups` endpoint and collect every realm group before
inspecting attributes. Use this complete group set when resolving `tenant-a`,
while preserving the existing detail lookup through `admin.realm` for each
group.
- Around line 169-174: Update the registration-level validation around
tenant_claim so it no longer relies on writing tenant_id directly to the
synthetic user. Scan each configured IdP’s hardcoded-attribute-idp-mapper and
fail when its mapped source attribute is not INSIGHT_TENANT_ID, ensuring the
pinned-tenant assertion exercises the canonical IdP mapping.

In `@deploy/gitops/README.md`:
- Line 257: Update the guard reference in the README to use the repository’s
actual filename, tenant_contract_guard.py, instead of
tenant_translation_guard.py.

In
`@docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md`:
- Around line 12-18: Update the Decision Outcome section around the claim
importer and tenancy mapping statements to remove claim-to-group translation,
upstream tenancy import, and upstream tenancy mapping. State that each
registration uses its fixed environment-provided tenant_id pin, while identity
data uses the separate idp_sub importer contract; preserve the fail-closed
behavior and keep tenant-bearing groups inert.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ce3e1e6d-2dcf-4933-9ec8-f84b3f014f4e

📥 Commits

Reviewing files that changed from the base of the PR and between f15a051 and 6fee019.

📒 Files selected for processing (5)
  • .github/workflows/keycloak-realm-contract.yml
  • deploy/compose/keycloak/tests/tenant_contract_guard.py
  • deploy/gitops/README.md
  • deploy/gitops/environments/local/keycloak/realms/insight-broker.yaml
  • docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/keycloak-realm-contract.yml

Comment thread deploy/compose/keycloak/tests/tenant_contract_guard.py Outdated
Comment thread deploy/compose/keycloak/tests/tenant_contract_guard.py
Comment thread deploy/gitops/README.md Outdated
cyberantonz and others added 2 commits August 6, 2026 11:33
- Guard statically validates EVERY committed realm file (each IdP
  registration: exactly one INSIGHT_TENANT_ID pin + an idp_sub stamp; no
  tenant-bearing groups), which makes the workflow's wide path trigger
  honest and catches a registration missing the pin — the live import
  covers the canonical realm's token behavior.
- Admin calls carry timeouts; realm-import rejection reports as a named
  failure; group scan paginates.
- README: per-registration mapper snippet (the idp_sub source the
  canonical shape implies), guard described as a contract test, stale
  filename fixed.
- ADR-0003 Decision Outcome rewritten to the pinned-only model — the
  prescriptive claim-to-group passage the status entry rejected is gone.

Refs constructorfabric#2196

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
@cyberantonz
cyberantonz enabled auto-merge August 6, 2026 03:40
The guard talks to a throwaway Keycloak on 127.0.0.1 over plain http by
design; URLs are module constants, never user input. nosemgrep on the
two urlopen sites (dynamic-urllib-use, insecure-urlopen); verified
locally with the same semgrep image (0 findings).

Refs constructorfabric#2196

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
deploy/compose/keycloak/tests/tenant_contract_guard.py (1)

229-231: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Use all_groups to resolve tenant-a.

admin.realm("/groups") may return the first page only, so tenant-a can be absent from the lookup and raise KeyError before the runtime assertions run. Build the mapping with the existing paginated helper before indexing by name.

Proposed fix
-        groups = {g["name"]: g["id"] for g in admin.realm("/groups")}
+        groups = {g["name"]: g["id"] for g in all_groups(admin)}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/compose/keycloak/tests/tenant_contract_guard.py` around lines 229 -
231, Update the group lookup in the tenant setup flow to build the name-to-ID
mapping from the existing paginated all_groups helper instead of the single
admin.realm("/groups") response. Keep the tenant-a lookup and subsequent group
assignment unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@deploy/compose/keycloak/tests/tenant_contract_guard.py`:
- Around line 116-132: Update all_groups() to recursively traverse each group’s
subGroups and include nested groups before tenant_bearing_groups() checks
attributes. Preserve pagination for top-level results, recurse through every
child hierarchy, and add a negative test fixture with tenant_id on a nested
group to verify the guard detects it.

In
`@docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md`:
- Around line 114-125: Update registration_violations() to reject any additional
IdP or client-scope mapper that produces tenant_id, allowing only the single
pinned tenant mapper and required idp_sub mapping described by the ADR. Add a
negative fixture containing an extra tenant-producing mapper and assert the
guard fails, ensuring no overlapping validator is relied on for this check.

---

Outside diff comments:
In `@deploy/compose/keycloak/tests/tenant_contract_guard.py`:
- Around line 229-231: Update the group lookup in the tenant setup flow to build
the name-to-ID mapping from the existing paginated all_groups helper instead of
the single admin.realm("/groups") response. Keep the tenant-a lookup and
subsequent group assignment unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d35144e1-132a-4e85-aff2-1e1b866f88f0

📥 Commits

Reviewing files that changed from the base of the PR and between 6fee019 and e07d872.

📒 Files selected for processing (4)
  • .github/workflows/keycloak-realm-contract.yml
  • deploy/compose/keycloak/tests/tenant_contract_guard.py
  • deploy/gitops/README.md
  • docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • deploy/gitops/README.md
  • .github/workflows/keycloak-realm-contract.yml

Comment on lines +116 to +132
def all_groups(admin: Admin) -> list[dict]:
groups, first, page = [], 0, 100
while True:
batch = admin.realm(f"/groups?first={first}&max={page}")
groups.extend(batch)
if len(batch) < page:
return groups
first += page


def tenant_bearing_groups(admin: Admin) -> list[str]:
"""Policy scan: NO group in the realm may carry a tenant_id attribute."""
offenders = []
for g in all_groups(admin):
detail = admin.realm(f"/groups/{g['id']}")
if (detail.get("attributes") or {}).get("tenant_id"):
offenders.append(g["name"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg 'deploy/compose/keycloak/tests/tenant_contract_guard.py|compose|keycloak|tenant_contract_guard' || true

echo
echo "Target file outline/stat:"
wc -l deploy/compose/keycloak/tests/tenant_contract_guard.py
sed -n '1,220p' deploy/compose/keycloak/tests/tenant_contract_guard.py

echo
echo "Search tenant_id and group checks:"
rg -n "tenant_id|all_groups|tenant_bearing_groups|registrations|clean-baseline|subGroups|groups" deploy/compose/keycloak/tests/tenant_contract_guard.py deploy/compose/keycloak 2>/dev/null || true

Repository: constructorfabric/insight

Length of output: 20625


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect likely Keycloak API shape and behavior from available docs/repo examples.
echo "Relevant API docs/search:"
rg -n "subGroups|tenant_id|groups/\\{" . 2>/dev/null | head -200 || true

echo
echo "Read only structural verifier for tenant_bearing_groups implementation:"
python3 - <<'PY'
from pathlib import Path
p = Path('deploy/compose/keycloak/tests/tenant_contract_guard.py')
txt = p.read_text()
start = txt.index('def tenant_bearing_groups')
end = txt.index('\ndef ', start+1) if '\ndef ' in txt[start+1:] else len(txt)
body = txt[start:end]
print(body)
print("references subGroups:", 'subGroups' in txt)
print("recursive group traversal present:", any(tok in body for tok in ['iterate_recursive', 'subGroups', 'all_groups(admin)']))
PY

Repository: constructorfabric/insight

Length of output: 22676


🌐 Web query:

Keycloak REST API groups subGroups group representation nested groups tenant_id attribute

💡 Result:

In Keycloak, group management via the REST API is handled through the Groups and Group resources. Group Representation and Subgroups The GroupRepresentation object represents a group and includes a subGroups field, which is a list of nested GroupRepresentation objects [1]. While the data model supports a hierarchical structure, retrieving this hierarchy depends on the API endpoint and parameters used [2]. When listing groups using GET /admin/realms/{realm}/groups, you can control the detail level of the response using the briefRepresentation query parameter [2][3]. Setting briefRepresentation to false includes full details, such as attributes and role mappings, whereas true (the default) limits the response to basic information like ID, name, path, and parentId [4][2][3]. Retrieving subgroups can be done in a few ways: 1. When listing all groups or searching, subgroups may be populated based on the parameters used [2]. 2. You can explicitly list subgroups for a specific parent group by calling the children endpoint: GET /admin/realms/{realm}/groups/{parentId}/children [5]. 3. The GroupRepresentation includes a subGroupCount field and a corresponding getter/setter, which can be useful for managing or displaying hierarchical data [1]. Attributes and tenant_id The GroupRepresentation class contains an attributes field, which is a Map<String, List> used to store custom key-value pairs [6]. There is no specialized "tenant_id" field hardcoded into the GroupRepresentation model; instead, if you need to associate a group with a specific tenant, you should add a "tenant_id" key to the attributes map [6]. Key Administrative Actions - Create a child group: POST /admin/realms/{realm}/groups/{parentId}/children [5]. - Update a child group: POST /admin/realms/{realm}/groups/{id}/children [5]. - Retrieve group details: GET /admin/realms/{realm}/groups/{group-id} [2]. Note that for large hierarchies, retrieving full representations for every nested group can be resource-intensive, so using briefRepresentation: true is generally recommended unless full attribute data is required for all nodes [2][3][7].

Citations:


🌐 Web query:

"GET /admin/realms/{realm}/groups" "subGroups" Keycloak docs-api

💡 Result:

In modern Keycloak versions (since release 23), the behavior of the GET /admin/realms/{realm}/groups endpoint regarding the subGroups field has changed [1][2]. The subGroups field is not populated by default when querying the root group list [3]. To retrieve subgroups, the following points are authoritative based on current Keycloak documentation and behavior: 1. Use the Dedicated Children Endpoint: The recommended way to retrieve subgroups for a specific group is now the dedicated endpoint [1][2]: GET /admin/realms/{realm}/groups/{group-id}/children This endpoint supports pagination and is designed specifically to handle subgroup retrieval efficiently [1][2]. 2. Limitations of the Main Groups Endpoint: When using the root GET /admin/realms/{realm}/groups endpoint, the subGroups field is only populated if you provide a search or q (query) parameter [3]. If these parameters are not provided, the API returns only top-level groups without expanding the hierarchy [3]. 3. API Parameters: - populateHierarchy: While this parameter exists, it does not automatically force the expansion of the subGroups tree when querying the root group list without search criteria [2][3]. - subGroupsCount: A parameter introduced to control whether the count of subgroups is returned for each group, which defaults to true [4]. If you are developing against the Keycloak Admin API, it is best practice to migrate logic that previously relied on the full hierarchy being returned in a single call to the /children endpoint for specific group IDs [1][2].

Citations:


Traverse nested groups before accepting the policy.

all_groups() only lists top-level groups, and GET /admin/realms/insight-broker/groups does not expand subGroups. A nested group can carry attributes.tenant_id while both registrations(...) and clean-baseline pass. Recurse through group children before checking attributes and add a negative case with tenant_id on a nested group.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/compose/keycloak/tests/tenant_contract_guard.py` around lines 116 -
132, Update all_groups() to recursively traverse each group’s subGroups and
include nested groups before tenant_bearing_groups() checks attributes. Preserve
pagination for top-level results, recurse through every child hierarchy, and add
a negative test fixture with tenant_id on a nested group to verify the guard
detects it.

Comment on lines +114 to +125
- **Claims are shaped at the broker.** Every provider registration carries a
`hardcoded-attribute-idp-mapper` pinning the fixed per-registration Insight `tenant_id` from
environment values, plus an attribute-importer stamping `idp_sub` (the upstream's stable
directory id, e.g. Entra `oid` -- the login-bootstrap external id). An upstream's own tenancy
assertions are never consulted (amended 2026-08-06; the claim-to-group translation once
sketched here is rejected): a customer with several IdPs pins the same tenant on each
registration, and two customers sharing an IdP vendor never intersect -- realm-per-customer
gives each its own registration, client, and pin. The client's **protocol mappers / client
scopes** are the allow-list: the token contains only what is explicitly emitted -- `sub`,
`email`, one string `tenant_id`, `idp_sub` -- and deliberately does NOT aggregate attributes
over groups, so a group-sourced tenant is mechanically impossible; a CI guard asserts the
contract on every committed realm file and against a live import.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 \
  'identityProviderMappers|protocolMappers|clientScopes|tenant_id|idp_sub|claim-to-user-attribute|group' \
  deploy/compose/keycloak/tests/tenant_contract_guard.py \
  deploy/gitops/environments

Repository: constructorfabric/insight

Length of output: 23826


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- tenant contract guard relevant sections ---\n'
sed -n '1,260p' deploy/compose/keycloak/tests/tenant_contract_guard.py

printf '\n--- local broker scope mappers around tenant_id/idp_sub ---\n'
sed -n '1,80p' deploy/gitops/environments/local/keycloak/realms/insight-broker.yaml

printf '\n--- find identity_provider_mapper related checks/tests ---\n'
rg -n 'identityProviderMapper|hardcoded-attribute-idp-mapper|tenant_id.*mapper|clientScopes|protocolMappers|tenant-contract guard|tenant contract|registration_violations|tenant_bearing_groups' deploy/compose/keycloak/tests deploy/gitops/environments docs/components/backend/authenticator/specs/ADR || true

Repository: constructorfabric/insight

Length of output: 254


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- tenant contract guard relevant sections ---'
sed -n '1,260p' deploy/compose/keycloak/tests/tenant_contract_guard.py

echo
echo '--- local broker scope mappers around tenant_id/idp_sub ---'234
sed -n '1,80p' deploy/gitops/environments/local/keycloak/realms/insight-broker.yaml

echo
echo '--- find identity_provider_mapper related checks/tests ---'
rg -n 'identityProviderMapper|hardcoded-attribute-idp-mapper|tenant_id.*mapper|clientScopes|protocolMappers|tenant-contract guard|tenant contract|registration_violations|tenant_bearing_groups' deploy/compose/keycloak/tests deploy/gitops/environments docs/components/backend/authenticator/specs/ADR || true

Repository: constructorfabric/insight

Length of output: 16122


🌐 Web query:

Keycloak 26 oidc claim to user attribute mapper tenant_id claim behavior keycloak-admin-api

💡 Result:

In Keycloak 26, to map a user attribute—such as a tenant_id—to an OIDC token claim, you use the User Attribute Mapper (type: oidc-usermodel-attribute-mapper) [1][2]. This mapper retrieves a value stored in the user's attributes (UserModel.attribute) and inserts it into the ID token, access token, or userinfo response [1][3][4]. Configuration via Keycloak Admin API To create this mapper programmatically, you must send a POST request to the protocol-mappers endpoint for your specific client (e.g., /admin/realms/{realm}/clients/{id}/protocol-mappers/models) [5]. The JSON body should follow this structure [1]: { "name": "tenant-id-mapper", "protocol": "openid-connect", "protocolMapper": "oidc-usermodel-attribute-mapper", "config": { "user.attribute": "tenant_id", "claim.name": "tenant_id", "jsonType.label": "String", "id.token.claim": "true", "access.token.claim": "true", "userinfo.token.claim": "true" } } Behavioral Details 1. Token Claim Name: The claim.name property defines the key name in the resulting JSON token [1]. You can use dot notation (e.g., company.tenant_id) to create nested JSON objects within the claim [1][3][4]. 2. Multivalued Attributes: If the tenant_id attribute on the user can contain multiple values, set the "multivalued": "true" configuration option to ensure all values are correctly mapped into an array in the token [3][4][2]. 3. Attribute Source: The mapper looks for the value within the Keycloak user's attribute map [1][3]. Ensure the user profile or identity provider import process correctly populates this attribute for the user session [6][7]. 4. Token Inclusion: The boolean flags (id.token.claim, access.token.claim, userinfo.token.claim) allow you to granularly control which tokens receive this specific claim [1][8]. For advanced scenarios, such as needing to prefix the tenant_id value during mapping (e.g., to ensure uniqueness in multi-tenant brokering scenarios), check if your specific patch level of Keycloak 26 includes the Prefixed User Attribute Mapper functionality, which was introduced to handle scenarios where attribute values must be transformed before inclusion in the token [9].

Citations:


Enforce the tenant mapper allowlist in the guard.

registration_violations() only requires the pinned IdP mapper and idp_sub destination, then accepts any additional IdP mapper. A tenant-valued IdP claim mapper that writes tenant_id would still pass the guard while breaking the ADR’s single-pinned-tenant contract. Add explicit mapper allowlist/denylist checks, include a negative fixture with an extra tenant-producing IdP or client-scope mapper, and ensure no other validator covers this gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md`
around lines 114 - 125, Update registration_violations() to reject any
additional IdP or client-scope mapper that produces tenant_id, allowing only the
single pinned tenant mapper and required idp_sub mapping described by the ADR.
Add a negative fixture containing an extra tenant-producing mapper and assert
the guard fails, ensuring no overlapping validator is relied on for this check.

semgrep honors nosemgrep only on the match's first line or the line
before; ruff-format had parked the annotations on closing parens where
CI ignored them. Standalone line-before comments are format-stable;
verified with the exact CI invocation (--config auto + p/rust): 0
findings.

Refs constructorfabric#2196

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
@cyberantonz
cyberantonz added this pull request to the merge queue Aug 6, 2026
Merged via the queue into constructorfabric:main with commit 80816f0 Aug 6, 2026
43 checks passed
@cyberantonz
cyberantonz deleted the feat/2196-tenant-translation branch August 6, 2026 04:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants