diff --git a/sso-mfa/k8s/keycape/deployment.yaml b/sso-mfa/k8s/keycape/deployment.yaml index 8ef7614..5b283ae 100644 --- a/sso-mfa/k8s/keycape/deployment.yaml +++ b/sso-mfa/k8s/keycape/deployment.yaml @@ -54,7 +54,7 @@ spec: # 2026-05-24: direct-imported into railiance01 k3s for the # bootstrap-console OIDC/MFA rollout. Use IfNotPresent while the # HTTP registry push/pull path is being cleaned up. - image: 92.205.130.254:32166/coulomb/key-cape:main-e877d27-2 + image: key-cape:e8b4ede imagePullPolicy: IfNotPresent ports: diff --git a/sso-mfa/k8s/keycape/register-user-engine-portal.py b/sso-mfa/k8s/keycape/register-user-engine-portal.py new file mode 100644 index 0000000..57d82b6 --- /dev/null +++ b/sso-mfa/k8s/keycape/register-user-engine-portal.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Idempotently register the user-engine portal in the live KeyCape Secret. + +The complete Secret travels over stdin/stdout between kubectl and this +process. Secret values are never printed to the terminal or written to disk. +""" + +from __future__ import annotations + +import base64 +import json +import sys + +import yaml + +CLIENT_ID = "user-engine-portal" +CLIENT = { + "clientId": CLIENT_ID, + "displayName": "User Engine Portal", + "redirectUris": ["https://users.92-205-62-239.nip.io/oidc/callback"], + "allowedScopes": ["openid", "profile", "email", "groups"], + "grantTypes": ["authorization_code"], + "clientType": "public", +} + + +def main() -> None: + secret = json.load(sys.stdin) + encoded = secret.get("data", {}).get("config.yaml") + if not encoded: + raise SystemExit("keycape-config does not contain config.yaml") + config = yaml.safe_load(base64.b64decode(encoded)) + clients = config.setdefault("clients", []) + existing = next( + (index for index, client in enumerate(clients) if client.get("clientId") == CLIENT_ID), + None, + ) + if existing is None: + clients.append(CLIENT) + else: + clients[existing] = CLIENT + rendered = yaml.safe_dump(config, sort_keys=False).encode() + secret["data"]["config.yaml"] = base64.b64encode(rendered).decode() + secret.pop("status", None) + metadata = secret.get("metadata", {}) + for key in ("creationTimestamp", "managedFields", "resourceVersion", "uid"): + metadata.pop(key, None) + json.dump(secret, sys.stdout, separators=(",", ":")) + + +if __name__ == "__main__": + main() diff --git a/sso-mfa/k8s/user-engine/ingress.yaml b/sso-mfa/k8s/user-engine/ingress.yaml new file mode 100644 index 0000000..356d806 --- /dev/null +++ b/sso-mfa/k8s/user-engine/ingress.yaml @@ -0,0 +1,74 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: user-engine-security + namespace: user-engine +spec: + chain: + middlewares: + - name: user-engine-rate-limit + - name: user-engine-headers +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: user-engine-rate-limit + namespace: user-engine +spec: + rateLimit: + average: 60 + period: 1m + burst: 20 +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: user-engine-headers + namespace: user-engine +spec: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + contentTypeNosniff: true + frameDeny: true + referrerPolicy: no-referrer +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: user-engine + namespace: user-engine + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.middlewares: user-engine-user-engine-security@kubernetescrd +spec: + ingressClassName: traefik + rules: + - host: users.92-205-62-239.nip.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: user-engine + port: {number: 8080} + tls: + - secretName: user-engine-tls + hosts: [users.92-205-62-239.nip.io] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-acme-http01 + namespace: user-engine +spec: + podSelector: + matchLabels: + acme.cert-manager.io/http01-solver: "true" + policyTypes: [Ingress] + ingress: + - from: + - namespaceSelector: + matchLabels: {kubernetes.io/metadata.name: kube-system} + ports: [{protocol: TCP, port: 8089}] diff --git a/sso-mfa/k8s/user-engine/runtime.yaml b/sso-mfa/k8s/user-engine/runtime.yaml index d552491..ab3edc4 100644 --- a/sso-mfa/k8s/user-engine/runtime.yaml +++ b/sso-mfa/k8s/user-engine/runtime.yaml @@ -45,7 +45,7 @@ spec: seccompProfile: {type: RuntimeDefault} containers: - name: portal - image: user-engine:portal-c27012a + image: user-engine:portal-268b315 imagePullPolicy: Never ports: [{name: http, containerPort: 8080}] env: @@ -58,6 +58,9 @@ spec: - {name: USER_ENGINE_LOGIN_URL, value: "https://kc.coulomb.social/"} - {name: USER_ENGINE_OIDC_ISSUER, value: "https://kc.coulomb.social"} - {name: USER_ENGINE_OIDC_AUDIENCE, value: user-engine-portal} + - {name: USER_ENGINE_OIDC_CLIENT_ID, value: user-engine-portal} + - {name: USER_ENGINE_OIDC_REDIRECT_URI, value: "https://users.92-205-62-239.nip.io/oidc/callback"} + - {name: USER_ENGINE_OIDC_BACKEND_URL, value: "http://keycape.sso.svc.cluster.local:8080"} - {name: USER_ENGINE_PUBLIC_REGISTRATION, value: "false"} securityContext: allowPrivilegeEscalation: false @@ -108,6 +111,12 @@ spec: - podSelector: matchLabels: {cnpg.io/cluster: user-engine-pg} ports: [{protocol: TCP, port: 5432}] + - to: + - namespaceSelector: + matchLabels: {kubernetes.io/metadata.name: sso} + podSelector: + matchLabels: {app.kubernetes.io/name: keycape} + ports: [{protocol: TCP, port: 8080}] - to: - namespaceSelector: matchLabels: {kubernetes.io/metadata.name: kube-system} diff --git a/workplans/NK-WP-0023-user-engine-portal-platform-integration.md b/workplans/NK-WP-0023-user-engine-portal-platform-integration.md index 019e21b..3c1c8c5 100644 --- a/workplans/NK-WP-0023-user-engine-portal-platform-integration.md +++ b/workplans/NK-WP-0023-user-engine-portal-platform-integration.md @@ -68,6 +68,14 @@ provide safe password and MFA enrollment/recovery handoffs. Preserve platform-root separation and ensure tenant administration never implies platform authority. +2026-07-27 implementation evidence: `user-engine-portal` is registered as a +public static KeyCape client with an exact callback and S256 PKCE. The portal +is live at `https://users.92-205-62-239.nip.io`, has a trusted ACME +certificate, begins the KeyCape/Authelia flow, and rejects an unregistered +callback. KeyCape image `key-cape:e8b4ede` maps an unambiguous +`tenant:::users|admins` directory group envelope into the tenant +and coarse tenant-admin claims while refusing ambiguous multi-tenant mapping. + ## T04 - Integrate authorization, email, audit, and events ```task