Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/keycloak-realm-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Keycloak realm — tenant claim contract

# Guards the pinned-tenant contract (ADR-0003 / DD-AUTH-04) against the
# canonical broker realm: fail-closed with no pinned attribute, the pin
# emitted verbatim as a single string, tenant-bearing groups inert AND
# flagged as a policy violation. Boots a throwaway Keycloak in docker.

on:
pull_request:
branches: [main]
paths:
- "deploy/gitops/environments/*/keycloak/realms/**"
- "deploy/compose/keycloak/tests/**"
- ".github/workflows/keycloak-realm-contract.yml"
Comment thread
cyberantonz marked this conversation as resolved.
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
tenant-contract:
name: tenant contract guard
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"

- name: Install PyYAML
run: python3 -m pip install --quiet pyyaml

- name: Tenant translation guard
run: python3 deploy/compose/keycloak/tests/tenant_contract_guard.py
6 changes: 4 additions & 2 deletions charts/insight/templates/secrets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,10 @@ stringData:
APP__gears__authenticator__config__idp__client_secret: {{ .Values.authenticator.oidc.clientSecret | default "" | quote }}
# Browser-facing callback the IdP redirects back to (through the gateway edge).
APP__gears__authenticator__config__redirect_uri: {{ tpl (required "authenticator.oidc.redirectUri is required" .Values.authenticator.oidc.redirectUri) . | quote }}
# Tenant resolution. `tenant_claim` = the id_token claim naming the user's
# tenant (Entra=`tid`; fakeidp/Keycloak=`tenant_id`). `default_tenant_id` is
# Tenant resolution. `tenant_claim` defaults to `tenant_id` and is FROZEN
# there (ADR-0003: the broker always emits `tenant_id`); the value remains
# configurable only for third-party consumers wiring a non-broker IdP
# directly (e.g. Entra=`tid`). `default_tenant_id` is
# the single-tenant fallback, used ONLY when that claim is absent (Okta and
# other claim-less IdPs — see #1853). It is sourced from `global.tenantDefaultId`
# — the same single source of truth the rest of the platform keys on — so a
Expand Down
248 changes: 248 additions & 0 deletions deploy/compose/keycloak/tests/tenant_contract_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""Pinned-tenant contract guard (ADR-0003, insight#2196).

The tenant is ALWAYS the pinned per-registration value (a user attribute
stamped by each IdP mapper); an IdP's own tenancy assertions are never
consulted, and group-sourced tenants are rejected by policy. Imports the
canonical broker realm into a throwaway Keycloak and asserts:

1. clean baseline -> the canonical realm ships zero tenant groups
2. no pinned attribute -> token carries NO tenant_id (fail closed)
3. pinned attribute -> token tenant_id == the pin, a single string
4. tenant-bearing group -> must NOT influence the token (no aggregation)
AND the realm scan flags it as a violation

Requires docker and PyYAML. Boots quay.io/keycloak/keycloak, converts the
canonical realm YAML to a realm representation (env placeholders substituted
with synthetic values), creates it via the admin API, and evaluates example
tokens. Exit 0 = contract holds.
"""

# ruff: noqa: T201 — stdout IS this script's CI report.

import json
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

import yaml

REPO_ROOT = Path(__file__).resolve().parents[4]
CANONICAL_REALM = REPO_ROOT / "deploy/gitops/environments/local/keycloak/realms/insight-broker.yaml"
KC_IMAGE = "quay.io/keycloak/keycloak:26.4"
KC_PORT = 18086
BASE = f"http://127.0.0.1:{KC_PORT}"
CONTAINER = "tenant-guard-kc"

PLACEHOLDER = re.compile(r"\$\(env:([A-Za-z_][A-Za-z0-9_]*)\)")
SYNTHETIC = {
"INSIGHT_AUTHENTICATOR_CLIENT_SECRET": "guard-secret",
"INSIGHT_AUTHENTICATOR_REDIRECT_URI": "http://localhost/auth/callback",
"INSIGHT_TENANT_ID": "00000000-0000-0000-0000-00000000feed",
}

TENANT_GROUP = "aaaaaaaa-0000-0000-0000-000000000001"
TENANT_PIN = "cccccccc-0000-0000-0000-000000000003"


def sh(*args: str) -> None:
subprocess.run(args, check=True, capture_output=True)


def substitute(node):
if isinstance(node, str):
return PLACEHOLDER.sub(lambda m: SYNTHETIC.get(m.group(1), f"missing-{m.group(1)}"), node)
if isinstance(node, list):
return [substitute(v) for v in node]
if isinstance(node, dict):
return {k: substitute(v) for k, v in node.items()}
return node


class Admin:
def __init__(self) -> None:
self.token = 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"]

def _call(self, path: str, method: str = "GET", body=None, raw: bytes | None = None):
headers = {"Content-Type": "application/x-www-form-urlencoded" if raw else "application/json"}
if hasattr(self, "token"):
headers["Authorization"] = f"Bearer {self.token}"
req = urllib.request.Request(
f"{BASE}{path}",
method=method,
headers=headers,
data=raw if raw is not None else (json.dumps(body).encode() if body is not None else None),
)
# URLs are module constants targeting the throwaway 127.0.0.1 Keycloak.
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
with urllib.request.urlopen(req, timeout=30) as resp:
payload = resp.read()
return json.loads(payload) if payload else None
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def realm(self, path: str, method: str = "GET", body=None):
return self._call(f"/admin/realms/insight-broker{path}", method, body)


def wait_for_keycloak(timeout_s: int = 180) -> None:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
try:
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected, python.lang.security.audit.insecure-transport.urllib.insecure-urlopen.insecure-urlopen
urllib.request.urlopen(f"{BASE}/realms/master/.well-known/openid-configuration", timeout=3)
return
except (urllib.error.URLError, OSError):
time.sleep(3)
raise TimeoutError("Keycloak did not come up")


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")


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"])
Comment on lines +114 to +130

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.

return offenders


def registration_violations(realm_doc: dict) -> list[str]:
"""Static contract on a realm file: every IdP registration must pin the
tenant (hardcoded mapper from the INSIGHT_TENANT_ID placeholder) and
stamp idp_sub; no group may carry a tenant_id attribute."""
problems = []
mappers = realm_doc.get("identityProviderMappers") or []
for idp in realm_doc.get("identityProviders") or []:
alias = idp.get("alias", "?")
mine = [m for m in mappers if m.get("identityProviderAlias") == alias]
pins = [
m
for m in mine
if m.get("identityProviderMapper") == "hardcoded-attribute-idp-mapper"
and (m.get("config") or {}).get("attribute") == "tenant_id"
and (m.get("config") or {}).get("attribute.value") == "$(env:INSIGHT_TENANT_ID)"
]
if len(pins) != 1:
problems.append(f"idp '{alias}': expected exactly one INSIGHT_TENANT_ID pin mapper, found {len(pins)}")
if not any((m.get("config") or {}).get("user.attribute") == "idp_sub" for m in mine):
problems.append(f"idp '{alias}': no mapper stamps the idp_sub attribute")
for g in realm_doc.get("groups") or []:
if (g.get("attributes") or {}).get("tenant_id"):
problems.append(f"group '{g.get('name')}': carries a tenant_id attribute")
return problems


def main() -> int:
failures: list[str] = []

def check(name: str, ok: bool, detail: str) -> None:
print(f"{'ok ' if ok else 'FAIL'} {name}: {detail}")
if not ok:
failures.append(name)

for realm_file in sorted(REPO_ROOT.glob("deploy/gitops/environments/*/keycloak/realms/*.yaml")):
problems = registration_violations(yaml.safe_load(realm_file.read_text()))
check(
f"registrations({realm_file.parent.parent.parent.name})",
not problems,
f"{realm_file.name}: {problems or 'contract holds'}",
)

realm = substitute(yaml.safe_load(CANONICAL_REALM.read_text()))

subprocess.run(["docker", "rm", "-f", CONTAINER], capture_output=True, check=False)
sh(
"docker",
"run",
"-d",
"--name",
CONTAINER,
"-p",
f"127.0.0.1:{KC_PORT}:8080",
"-e",
"KC_BOOTSTRAP_ADMIN_USERNAME=admin",
"-e",
"KC_BOOTSTRAP_ADMIN_PASSWORD=admin",
KC_IMAGE,
"start-dev",
)
try:
wait_for_keycloak()
admin = Admin()
try:
admin._call("/admin/realms", "POST", realm)
except urllib.error.HTTPError as e:
check("realm-import", False, f"canonical realm rejected: HTTP {e.code} {e.read()[:200]!r}")
return 1

profile = admin.realm("/users/profile")
profile["unmanagedAttributePolicy"] = "ADMIN_EDIT"
admin.realm("/users/profile", "PUT", profile)

check("clean-baseline", not tenant_bearing_groups(admin), "canonical realm ships zero tenant groups")

admin.realm(
"/users",
"POST",
{"username": "guard@example.com", "email": "guard@example.com", "enabled": True, "emailVerified": True},
)
user = admin.realm("/users?username=guard@example.com&exact=true")[0]["id"]
client = admin.realm("/clients?clientId=insight-authenticator")[0]["id"]

claim = tenant_claim(admin, client, user)
check("fail-closed", claim is None, f"no pinned attribute -> claim {claim!r}")

u = admin.realm(f"/users/{user}")
u["attributes"] = {"tenant_id": [TENANT_PIN]}
admin.realm(f"/users/{user}", "PUT", u)
claim = tenant_claim(admin, client, user)
check("pinned-tenant", claim == TENANT_PIN, f"pinned attribute -> claim {claim!r}")
check("claim-is-scalar", isinstance(claim, str), f"claim type {type(claim).__name__}")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

admin.realm("/groups", "POST", {"name": "tenant-a", "attributes": {"tenant_id": [TENANT_GROUP]}})
groups = {g["name"]: g["id"] for g in admin.realm("/groups")}
admin.realm(f"/users/{user}/groups/{groups['tenant-a']}", "PUT")
claim = tenant_claim(admin, client, user)
check("group-inert", claim == TENANT_PIN, f"tenant group must not affect the token -> claim {claim!r}")
check(
"group-policy-detected",
bool(tenant_bearing_groups(admin)),
"a tenant-bearing group exists and the realm scan flags it",
)
finally:
subprocess.run(["docker", "rm", "-f", CONTAINER], capture_output=True, check=False)

if failures:
print(f"\ntenant-contract guard FAILED: {failures}")
return 1
print("\ntenant-contract guard OK")
return 0


if __name__ == "__main__":
sys.exit(main())
30 changes: 30 additions & 0 deletions deploy/gitops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,36 @@ so an IdP swap or upstream drift cannot change the tenant. Canonical
realm shape to copy:
[`environments/local/keycloak/realms/insight-broker.yaml`](environments/local/keycloak/realms/insight-broker.yaml).

Every IdP registration carries the same two mappers — the tenant pin
and the upstream-id stamp (a customer with several IdPs pins the same
tenant on each; an IdP's own tenancy assertions are never consulted):

```yaml
identityProviderMappers:
- name: tenant-id
identityProviderAlias: <alias>
identityProviderMapper: hardcoded-attribute-idp-mapper
config:
syncMode: FORCE
attribute: tenant_id
attribute.value: <env placeholder INSIGHT_TENANT_ID>
- name: idp-sub
identityProviderAlias: <alias>
identityProviderMapper: oidc-user-attribute-idp-mapper
config:
syncMode: FORCE
claim: <upstream stable id claim, e.g. Entra oid>
user.attribute: idp_sub
```

CI runs `deploy/compose/keycloak/tests/tenant_contract_guard.py` — a
contract test, not an audit of deployed realms: it statically checks
every committed realm file (each registration has exactly one tenant
pin and an `idp_sub` stamp; no group carries a `tenant_id` attribute)
and imports the canonical realm into a throwaway Keycloak to assert
token behavior (fail closed without the pin, pin emitted verbatim as a
single string, tenant-bearing groups inert).

## Secret management

Sealed secrets ([Bitnami sealed-secrets](https://github.com/bitnami-labs/sealed-secrets))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ realm: insight-broker
enabled: true

clientScopes:
# The claim allow-list: tokens carry exactly email + single-string tenant_id
# (plus protocol-level sub). tenant_id is a user attribute stamped per IdP by
# a hardcoded-attribute-idp-mapper from the INSIGHT_TENANT_ID placeholder
# (global.tenantDefaultId) — pinned per env, never from an upstream claim.
# The claim allow-list: tokens carry exactly email, single-string tenant_id,
# and idp_sub (plus protocol-level sub). tenant_id is ALWAYS the pinned
# per-registration tenant: every IdP's hardcoded-attribute-idp-mapper stamps
# INSIGHT_TENANT_ID (global.tenantDefaultId); a customer with several IdPs
# pins the same tenant on each. What an IdP asserts about tenancy is never
# consulted. idp_sub carries the upstream directory id (e.g. Entra oid) for
# the login-bootstrap person lookup, stamped per IdP by an importer mapper.
- name: insight
description: Canonical Insight claim allow-list (email + single-string tenant_id)
protocol: openid-connect
Expand Down Expand Up @@ -44,6 +47,18 @@ clientScopes:
id.token.claim: "true"
access.token.claim: "true"
userinfo.token.claim: "true"
# Upstream directory id passthrough (login-bootstrap external id).
- name: idp_sub
protocol: openid-connect
protocolMapper: oidc-usermodel-attribute-mapper
consentRequired: false
config:
user.attribute: idp_sub
claim.name: idp_sub
jsonType.label: String
Comment thread
coderabbitai[bot] marked this conversation as resolved.
id.token.claim: "true"
access.token.claim: "true"
userinfo.token.claim: "true"

clients:
# Authenticator client: full-scope off, exactly [basic, insight] — `basic`
Expand Down
Loading
Loading