Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
43 changes: 43 additions & 0 deletions .github/workflows/keycloak-realm-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Keycloak realm — tenant claim contract

# Guards the single-tenant_id invariant (ADR-0003 / DD-AUTH-04) against the
# canonical broker realm: fail-closed with no tenant source, correct
# group-to-tenant translation, and detection of the two cases token
# inspection cannot see (two tenant groups; attribute shadowed by a group).
# Boots a throwaway Keycloak in docker; no cluster.

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.
Resolved
workflow_dispatch:

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

permissions:
contents: read

jobs:
tenant-contract:
name: tenant translation 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.12"

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

- name: Tenant translation guard
run: python3 deploy/compose/keycloak/tests/tenant_translation_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
221 changes: 221 additions & 0 deletions deploy/compose/keycloak/tests/tenant_translation_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Single-tenant_id contract guard (ADR-0003, insight#2196).

Imports the canonical broker realm into a throwaway Keycloak and asserts the
tenant claim contract the platform relies on (DD-AUTH-04: exactly one string
tenant, or none — never two, never an ambiguous source):

1. no tenant source -> token carries NO tenant_id (fail closed)
2. one tenant group -> token tenant_id == that group's value (string)
3. two tenant groups -> AMBIGUOUS: Keycloak silently emits one of
them, so the token cannot reveal the problem;
the guard's membership check must flag it
4. user attribute + group -> AMBIGUOUS: the group value shadows the
pinned attribute; flagged the same way

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_A = "aaaaaaaa-0000-0000-0000-000000000001"
TENANT_B = "bbbbbbbb-0000-0000-0000-000000000002"
TENANT_ATTR = "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),
)
with urllib.request.urlopen(req) as resp:
payload = resp.read()
return json.loads(payload) if payload else None

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:
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 tenant_group_count(admin: Admin, user_id: str) -> int:
groups = admin.realm(f"/users/{user_id}/groups")
with_tenant = 0
for g in groups:
detail = admin.realm(f"/groups/{g['id']}")
if (detail.get("attributes") or {}).get("tenant_id"):
with_tenant += 1
return with_tenant


def has_pinned_attribute(admin: Admin, user_id: str) -> bool:
user = admin.realm(f"/users/{user_id}")
return bool((user.get("attributes") or {}).get("tenant_id"))


def unambiguous_tenant_source(admin: Admin, user_id: str) -> bool:
"""The invariant: exactly one tenant source. Token inspection cannot see
a violation (Keycloak silently emits one value), so this checks sources."""
groups = tenant_group_count(admin, user_id)
attr = has_pinned_attribute(admin, user_id)
return (groups + (1 if attr else 0)) <= 1


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)

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()
admin._call("/admin/realms", "POST", realm)

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

admin.realm("/groups", "POST", {"name": "tenant-a", "attributes": {"tenant_id": [TENANT_A]}})
admin.realm("/groups", "POST", {"name": "tenant-b", "attributes": {"tenant_id": [TENANT_B]}})
groups = {g["name"]: g["id"] for g in admin.realm("/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 tenant source -> claim {claim!r}")

admin.realm(f"/users/{user}/groups/{groups['tenant-a']}", "PUT")
claim = tenant_claim(admin, client, user)
check("group-translation", claim == TENANT_A, f"one group -> claim {claim!r}")
check("single-source(1 group)", unambiguous_tenant_source(admin, user), "one tenant source")

admin.realm(f"/users/{user}/groups/{groups['tenant-b']}", "PUT")
claim = tenant_claim(admin, client, user)
check(
"two-groups-detected",
not unambiguous_tenant_source(admin, user),
f"two tenant groups; token silently emits {claim!r} — sources check must flag it",
)

u = admin.realm(f"/users/{user}")
u["attributes"] = {"tenant_id": [TENANT_ATTR]}
admin.realm(f"/users/{user}", "PUT", u)
claim = tenant_claim(admin, client, user)
check(
"mixed-source-detected",
not unambiguous_tenant_source(admin, user),
f"attribute + groups; token emits {claim!r} (group shadows pin) — flagged",
)

claim = tenant_claim(admin, client, user)
check("claim-is-scalar", claim is None or isinstance(claim, str), f"claim type {type(claim).__name__}")
finally:
subprocess.run(["docker", "rm", "-f", CONTAINER], capture_output=True, check=False)

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


if __name__ == "__main__":
sys.exit(main())
29 changes: 29 additions & 0 deletions deploy/gitops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,35 @@ 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).

Where a SINGLE upstream registration serves several tenants, translate
inside the realm instead of pinning: per-tenant groups carry the
internal UUID in a `tenant_id` group attribute, and one advanced
claim-to-group mapper per external tenant value puts the user in its
group (`syncMode: FORCE`; unmapped values fail closed — no group, no
`tenant_id`, token rejected downstream):

```yaml
groups:
- name: tenant-example-corp
attributes:
tenant_id: ["033dcbad-2374-4548-a9fa-04e2d5e0889a"]
identityProviderMappers:
- name: tenant-example-corp
identityProviderAlias: upstream
identityProviderMapper: oidc-advanced-group-idp-mapper
config:
syncMode: FORCE
claims: '[{"key":"tid","value":"<external tenant value>"}]'
group: /tenant-example-corp
```

The canonical scope's `tenant_id` mapper aggregates over groups, so no
scope change is needed. Two rules the CI guard
(`deploy/compose/keycloak/tests/tenant_translation_guard.py`) enforces:
a user must never sit in two tenant groups, and never mix the pinned
attribute with tenant groups — Keycloak silently emits one value in
both cases, so only source inspection catches it.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
## 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,15 @@ 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 comes from a user
# attribute OR a tenant group's attribute (aggregation below): fixed
# per-registration installs stamp the attribute from INSIGHT_TENANT_ID
# (global.tenantDefaultId — pinned per env, never an upstream claim);
# single-registration-multi-tenant installs put users in per-tenant groups
# via claim-to-group translation (see the gitops README). idp_sub carries
# the upstream directory id (e.g. Entra oid) for the login-bootstrap
# person lookup, stamped per IdP by an attribute-importer mapper.
- name: insight
description: Canonical Insight claim allow-list (email + single-string tenant_id)
protocol: openid-connect
Expand Down Expand Up @@ -41,6 +46,23 @@ clientScopes:
user.attribute: tenant_id
claim.name: tenant_id
jsonType.label: String
# Aggregate over groups: a tenant group's tenant_id attribute is
# emitted when the user carries none. Exactly one tenant group per
# user is an invariant (tenant-translation guard).
aggregate.attrs: "true"
multivalued: "false"
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.
Resolved
id.token.claim: "true"
access.token.claim: "true"
userinfo.token.claim: "true"
Expand Down
Loading