From 6cd89f0a7ae927cd7d2c75a95015a04d2fba4705 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 11 Sep 2026 20:55:30 +0200 Subject: [PATCH] fix: restore native user portal login and track tenant integration Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- WORK-RECORDS.md | 4 + .../2026-09-11-native-portal-repair.md | 39 +++++ sso-mfa/k8s/keycape/create-secrets.sh | 12 ++ sso-mfa/k8s/keycape/portal-client-rollout.py | 136 ++++++++++++++++++ .../k8s/keycape/test_portal_client_rollout.py | 54 +++++++ .../k8s/keycape/verify-user-engine-portal.py | 56 ++++++++ ...restore-user-portal-client-registration.md | 96 +++++++++++++ 7 files changed, 397 insertions(+) create mode 100644 docs/evidence/2026-09-11-native-portal-repair.md create mode 100644 sso-mfa/k8s/keycape/portal-client-rollout.py create mode 100644 sso-mfa/k8s/keycape/test_portal_client_rollout.py create mode 100644 sso-mfa/k8s/keycape/verify-user-engine-portal.py create mode 100644 workplans/NK-WP-0036-restore-user-portal-client-registration.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 899bb63..f6e8255 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -29,6 +29,7 @@ | workplan | NK-WP-0033 | active | — | workplans/NK-WP-0033-keycape-secret-exposure-rotation.md | | workplan | NK-WP-0034 | blocked | — | workplans/NK-WP-0034-verification-that-verifies.md | | workplan | NK-WP-0035 | blocked | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | +| workplan | NK-WP-0036 | active | — | workplans/NK-WP-0036-restore-user-portal-client-registration.md | | task | NK-WP-ADHOC-2026-07-02-T01 | done | — | workplans/ADHOC-2026-07-02.md | | task | NK-WP-ADHOC-2026-07-02-T02 | done | — | workplans/ADHOC-2026-07-02.md | | task | NK-WP-ADHOC-2026-08-14-T01 | done | — | workplans/ADHOC-2026-08-14.md | @@ -128,6 +129,9 @@ | task | NK-WP-0035-T03 | done | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | | task | NK-WP-0035-T04 | wait | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | | task | NK-WP-0035-T05 | done | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | +| task | NK-WP-0036-T01 | done | — | workplans/NK-WP-0036-restore-user-portal-client-registration.md | +| task | NK-WP-0036-T02 | done | — | workplans/NK-WP-0036-restore-user-portal-client-registration.md | +| task | NK-WP-0036-T03 | progress | — | workplans/NK-WP-0036-restore-user-portal-client-registration.md | | intake | NK-IN-0001 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md | | intake | NK-IN-0002 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md | | intake | NET-IN-0001 | open | — | intakes/intakes.md | diff --git a/docs/evidence/2026-09-11-native-portal-repair.md b/docs/evidence/2026-09-11-native-portal-repair.md new file mode 100644 index 0000000..5f11fc0 --- /dev/null +++ b/docs/evidence/2026-09-11-native-portal-repair.md @@ -0,0 +1,39 @@ +# Native portal login and tenant-onboarding repair — 2026-09-11 + +Owner: NK-WP-0036. Actual tenant/product admission remains RAPPS-WP-0014. + +The approved public `user-engine-portal` client was absent from live KeyCape. +The bounded helper repaired only that missing registration in sso/keycape-config. +Secret UID `2e94519d-1550-41c7-9701-2efe47fe1fd3`, resourceVersion +`58747126` → `59999030`; unrelated configuration bytes and Secret data preserved. +The full bootstrap generator was not run. Its source now retains the existing +client declaration; four synthetic helper/bootstrap tests pass. + +KeyCape rollout succeeded with its prior image +`sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611` +and 25m CPU request. Read-only verification uses +`sso-mfa/k8s/keycape/verify-user-engine-portal.py`: valid S256 authorize redirects +to Authelia; wrong callback is refused with redirect_uri, missing PKCE with +missing_pkce. The first probe incorrectly expected code_challenge for the latter; +the probe expectation was corrected to the implementation's documented error. + +The operator then authenticated natively as platform-root and reached the tenant +creation form (browser observation at 18:35:42 UTC). Their manual submission +returned provisioning_unavailable, correlation +`corr_d41f417c61713afc7ddea08f`. Tenant Engine logged POST /tenants 403 before its +store-create call. That establishes a downstream denial, not an identity-provider +outage. No tenant existence is claimed without native readback. + +The deployed portal calls Tenant Engine as user-engine. Its PDP policy had no +such subject and also lacked the tenant.read action now required by the endpoint. +NK-WP-0036-T03 tracks the minimal policy integration, adapter error mapping, +immutable CI image promotion and live positive/negative verification. Human +credentials, session cookies and the chosen administrator's private contact +information are excluded from this record. + +Local validation: Flex Auth make test (including race tests), 27 policy tests and +35 fixtures pass; actual registry-enriched create request allows policy v2. +User Engine make test: 169 tests, 3 optional integration skips, layer check passed; +regression proves authority 403 reaches the browser as redacted 403 without +creating an administrator. Source fixes: flex-auth dd8dd51, user-engine 3c85e56. +CI publication/live promotion are pending; these are not live success claims. diff --git a/sso-mfa/k8s/keycape/create-secrets.sh b/sso-mfa/k8s/keycape/create-secrets.sh index b70168a..8c98cf9 100644 --- a/sso-mfa/k8s/keycape/create-secrets.sh +++ b/sso-mfa/k8s/keycape/create-secrets.sh @@ -113,6 +113,18 @@ clients: allowedScopes: ["openid", "profile", "email", "groups"] grantTypes: ["authorization_code"] clientType: "public" + - clientId: user-engine-portal + 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 - clientId: "netkingdom-bootstrap-console" displayName: "NetKingdom Bootstrap Console" redirectUris: diff --git a/sso-mfa/k8s/keycape/portal-client-rollout.py b/sso-mfa/k8s/keycape/portal-client-rollout.py new file mode 100644 index 0000000..8557de9 --- /dev/null +++ b/sso-mfa/k8s/keycape/portal-client-rollout.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Restore the approved public portal client without exporting Secret values.""" +from __future__ import annotations + +import argparse +import base64 +import copy +import importlib.util +import json +from pathlib import Path +import subprocess + +import yaml + +ROOT = Path('/home/worsch/net-kingdom/sso-mfa/k8s/keycape') + + +def module(name, filename): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +pin = module('issuer_pin', 'openbao-client-config.py') +portal = module('portal_registration', 'register-user-engine-portal.py') +CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' + + +class Refused(Exception): + """Only fixed reason codes may leave the operation.""" + + +def require(condition, reason): + if not condition: + raise Refused(reason) + + +def replacement(secret): + raw, config, _ = pin.issuer_document(secret) + clients = config.get('clients') + require(isinstance(clients, list) and clients, 'client_sequence_required') + ids = [client['clientId'] for client in clients] + require(len(ids) == len(set(ids)), 'duplicate_client_id') + if portal.CLIENT_ID in ids: + require(clients[ids.index(portal.CLIENT_ID)] == portal.CLIENT, + 'existing_registration_differs_requires_reconciliation') + return secret['data']['config.yaml'], False + root = yaml.compose(raw) + node = next(value for key, value in root.value if key.value == 'clients') + require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style, + 'block_client_sequence_required') + index = node.end_mark.index + line_start = raw.rfind('\n', 0, index) + 1 + if not raw[line_start:index].strip(): + index = line_start + indent = ' ' * node.start_mark.column + addition = yaml.safe_dump([portal.CLIENT], sort_keys=False) + addition = ''.join(indent + line if line.strip() else line + for line in addition.splitlines(True)) + if index and raw[index - 1] != '\n': + addition = '\n' + addition + updated = raw[:index] + addition + raw[index:] + expected = copy.deepcopy(config) + expected['clients'].append(portal.CLIENT) + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, + 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + + +def kubectl(arguments, payload=None): + args = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope', + '--server', 'https://127.0.0.1:16444', '--request-timeout=20s', *arguments] + result = subprocess.run(args, input=None if payload is None else json.dumps(payload), + text=True, capture_output=True, timeout=30) + require(result.returncode == 0, 'kubernetes_operation_failed') + return json.loads(result.stdout) + + +def run(args): + cluster = kubectl(['get', 'namespace', 'kube-system', '-o', 'json']) + require(cluster['metadata']['uid'] == CLUSTER_UID, 'cluster_identity_changed') + before = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json']) + require(not before['metadata'].get('ownerReferences'), 'controller_owned_secret') + metadata = pin.safe_metadata(before) + encoded, changed = replacement(before) + receipt = {'target': 'sso/keycape-config', 'before': metadata, + 'client': portal.CLIENT, 'change_needed': changed, + 'mode': args.mode, 'values_emitted': False, 'changed': False} + if args.mode == 'inspect': + return receipt + require(metadata == {'uid': args.expected_uid, + 'resource_version': args.expected_resource_version}, + 'observed_revision_changed') + if changed: + patch = [ + {'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']}, + {'op': 'replace', 'path': '/data/config.yaml', 'value': encoded}, + ] + command = ['-n', 'sso', 'patch', 'secret', 'keycape-config', '--type=json', + '--patch-file=/dev/stdin', '-o', 'json'] + if args.mode == 'dry-run': + command += ['--dry-run=server'] + result = kubectl(command, patch) + require(result['data'] == dict(before['data'], **{'config.yaml': encoded}), + 'patch_response_mismatch') + if args.mode == 'apply': + after = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json']) + require(after['metadata']['uid'] == metadata['uid'] and + after['data'] == dict(before['data'], **{'config.yaml': encoded}), + 'readback_mismatch_stop_without_stale_replay') + receipt.update(changed=changed, after=pin.safe_metadata(after)) + receipt.update(unrelated_config_bytes_preserved=True, other_secret_data_unchanged=True) + return receipt + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', choices=['inspect', 'dry-run', 'apply']) + parser.add_argument('--expected-uid') + parser.add_argument('--expected-resource-version') + args = parser.parse_args() + try: + print(json.dumps(run(args), sort_keys=True)) + return 0 + except Refused as error: + print(json.dumps({'status': 'refused', 'reason': str(error)})) + except Exception: + # Parser and Kubernetes errors can contain full Secret material. + print(json.dumps({'status': 'failed', 'reason': 'contained_operation_failed'})) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/sso-mfa/k8s/keycape/test_portal_client_rollout.py b/sso-mfa/k8s/keycape/test_portal_client_rollout.py new file mode 100644 index 0000000..825d6f6 --- /dev/null +++ b/sso-mfa/k8s/keycape/test_portal_client_rollout.py @@ -0,0 +1,54 @@ +import base64 +import importlib.util +import json +from pathlib import Path +import unittest + +import yaml + +spec = importlib.util.spec_from_file_location('portal_rollout', Path(__file__).with_name('portal-client-rollout.py')) +rollout = importlib.util.module_from_spec(spec) +spec.loader.exec_module(rollout) + + +class PortalClientTests(unittest.TestCase): + def fixture(self, clients=None): + # Synthetic marker values only; no live Secret is loaded by this suite. + raw = ('# preserve this comment\nauthelia:\n issuer: https://auth.example.invalid\n' + ' clientSecret: synthetic-marker\nclients:\n' + + yaml.safe_dump(json.loads(json.dumps(clients or [{'clientId': 'existing', 'clientType': 'public'}])), sort_keys=False) + + 'tokenLifetime: 15m\n') + return {'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), 'key.pem': 'synthetic-key'}}, raw + + def test_add_preserves_other_values_and_original_bytes(self): + secret, raw = self.fixture() + encoded, changed = rollout.replacement(secret) + updated = base64.b64decode(encoded).decode() + self.assertTrue(changed) + self.assertTrue(updated.startswith(raw[:raw.index('tokenLifetime:')])) + self.assertTrue(updated.endswith('tokenLifetime: 15m\n')) + expected = yaml.safe_load(raw) + expected['clients'].append(rollout.portal.CLIENT) + self.assertEqual(yaml.safe_load(updated), expected) + self.assertEqual(secret['data']['key.pem'], 'synthetic-key') + + def test_exact_existing_registration_is_noop(self): + secret, _ = self.fixture([rollout.portal.CLIENT]) + self.assertEqual(rollout.replacement(secret), (secret['data']['config.yaml'], False)) + + def test_differing_or_duplicate_registration_refused(self): + for clients in [[dict(rollout.portal.CLIENT, redirectUris=['https://other.invalid/callback'])], + [rollout.portal.CLIENT, rollout.portal.CLIENT]]: + with self.subTest(clients=len(clients)), self.assertRaises(rollout.Refused): + rollout.replacement(self.fixture(clients)[0]) + + def test_bootstrap_retains_same_public_registration(self): + source = Path(__file__).with_name('create-secrets.sh').read_text() + raw = source.split('CONFIG_YAML=$(cat <