fix: restore native user portal login and track tenant integration
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-11 20:55:30 +02:00
parent 5293a9bea3
commit 6cd89f0a7a
7 changed files with 397 additions and 0 deletions

View file

@ -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 |

View file

@ -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.

View file

@ -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:

View file

@ -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())

View file

@ -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 <<EOF\n', 1)[1].split('\nEOF', 1)[0]
clients = yaml.safe_load(raw)['clients']
self.assertEqual([c for c in clients if c['clientId'] == rollout.portal.CLIENT_ID],
[rollout.portal.CLIENT])
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Read-only native authorize checks; print no state, cookies or auth tokens."""
import base64
import hashlib
import json
import secrets
from urllib.error import HTTPError
from urllib.parse import urlencode, urlsplit
from urllib.request import HTTPRedirectHandler, build_opener
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def verify():
opener = build_opener(NoRedirect)
base = {
"client_id": "user-engine-portal", "response_type": "code",
"redirect_uri": "https://users.92-205-62-239.nip.io/oidc/callback",
"scope": "openid profile email groups", "state": secrets.token_urlsafe(24),
"code_challenge": base64.urlsafe_b64encode(
hashlib.sha256(secrets.token_bytes(32)).digest()
).rstrip(b"=").decode(), "code_challenge_method": "S256",
}
results = []
for name in ("valid", "wrong_callback", "missing_pkce"):
params = dict(base)
if name == "wrong_callback":
params["redirect_uri"] = "https://invalid.example/oidc/callback"
if name == "missing_pkce":
params.pop("code_challenge")
params.pop("code_challenge_method")
try:
response = opener.open("https://kc.coulomb.social/authorize?" + urlencode(params), timeout=20)
except HTTPError as error:
response = error
with response:
row = {"check": name, "status": response.code}
if name == "valid":
location = urlsplit(response.headers.get("Location", ""))
row["redirect_origin"] = location.scheme + "://" + location.netloc
row["passed"] = response.code == 302 and row["redirect_origin"] == "https://auth.coulomb.social"
else:
body = json.loads(response.read(4096))
row.update(error=body.get("error"), feature=body.get("feature"))
expected = "redirect_uri" if name == "wrong_callback" else "missing_pkce"
row["passed"] = response.code == 400 and row["error"] == "invalid_profile_usage" and row["feature"] == expected
results.append(row)
print(json.dumps(results, indent=2))
return all(row["passed"] for row in results)
if __name__ == "__main__":
raise SystemExit(0 if verify() else 1)

View file

@ -0,0 +1,96 @@
---
id: NK-WP-0036
type: workplan
title: "Restore native portal login and tenant-onboarding integration"
domain: infotech
repo: net-kingdom
status: active
owner: the-custodian
topic_slug: netkingdom
created: "2026-09-11"
updated: "2026-09-11"
related: [KEY-WP-0007, RAPPS-WP-0014, VERGABE-WP-0019]
state_hub_workstream_id: "6e1358d6-87e4-52e7-b3dd-09abdc48cefc"
---
The demo-company operator login returns `invalid_profile_usage`, feature
`client_id`, description `unknown client_id`. The approved public client from
KEY-WP-0007 is absent from live `sso/keycape-config`. The bootstrap generator
also omits it; this is a demonstrated regeneration hazard, not proof of which
historical change removed the live registration. User requests retry/repair
under continued demo-tenant onboarding authority.
## Preserve the existing registration in source and prepare the bounded repair
```task
id: NK-WP-0036-T01
status: done
priority: high
state_hub_task_id: "7f30efcb-beac-5843-bca7-a5cbc1c715cc"
```
Keep `user-engine-portal` public, authorization-code/S256 PKCE, exact callback
`https://users.92-205-62-239.nip.io/oidc/callback`, and only
`openid profile email groups`. Add the existing declaration to the bootstrap
generator and test equality with `register-user-engine-portal.py`.
`portal-client-rollout.py` adds only a missing declaration, preserving other
configuration bytes and Secret data. It refuses changed/duplicate registrations,
controller ownership, wrong cluster and stale UID/resourceVersion. Secret values
stay in memory/child stdin and never enter output, arguments or persisted files.
Four synthetic regression tests pass. No credentials or roles are granted.
## Restore and verify the live login entry point
```task
id: NK-WP-0036-T02
status: done
priority: high
state_hub_task_id: "8a3c3e91-d604-59ee-9d15-f79223667780"
```
Inspect and server-dry-run the exact observed Secret revision, apply the bounded
patch, read back unchanged unrelated data, and restart the existing KeyCape
deployment to load configuration. Keep the current image and 25m resources.
Record readiness and valid-client authorize redirect, invalid-callback and
missing-PKCE refusal. Reopen a fresh portal login only after positive preflight.
Actual human login and demo tenant creation continue in RAPPS-WP-0014-T02;
this repair alone is not evidence of tenant existence or operator entitlement.
If native verification fails, retain the exact non-secret failure and reconcile
the current resource revision; do not replay a stale complete Secret. Hand any
remaining actionable defects to live records before finishing this plan.
Live repair succeeded on 2026-09-11. The exact missing client was added without
changing unrelated Secret data. KeyCape restarted at its existing image and
resources. Valid authorization redirects to Authelia; wrong callback and missing
PKCE are refused. The operator subsequently authenticated as platform-root and
reached the platform form. See docs/evidence/2026-09-11-native-portal-repair.md.
## Repair and verify the portal-to-tenant policy contract
```task
id: NK-WP-0036-T03
status: progress
priority: high
state_hub_task_id: "378b03b3-aedc-50c3-8de7-94064794582b"
```
The real form submission returned provisioning_unavailable, correlation
corr_d41f417c61713afc7ddea08f. Tenant Engine logs show POST /tenants returning
403 before its create transaction. Its PDP recognizes only tenant-engine and
flex-auth; the shipped portal calls as user-engine. Tenant read is also missing
from that policy despite the shipped endpoint requiring it.
Register the existing user-engine service for only non-platform tenant create
and read in flex-auth/examples/tenant-engine. Keep ServiceAccount caller auth
enforced. Preserve denied grants, lifecycle, plan and guardrail mutations.
In user-engine, preserve display name and correlation id in the create request,
and map authority denial to the existing redacted 403 path, proving that no
administrator is created after a tenant-authority denial.
Run owner regression suites, publish through existing Forgejo image CI, pin and
roll only the Tenant Engine PDP and rapp-user-engine runtime, and verify the
actual allowed/denied contract with authenticated consumer calls. Then reconcile
native demo tenant existence before retrying the operator form. RAPPS-WP-0014
retains actual tenant onboarding and product placement; this workplan does not
claim tenant existence or application readiness from component tests alone.