fix: restore native user portal login and track tenant integration
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
5293a9bea3
commit
6cd89f0a7a
7 changed files with 397 additions and 0 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
39
docs/evidence/2026-09-11-native-portal-repair.md
Normal file
39
docs/evidence/2026-09-11-native-portal-repair.md
Normal 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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
136
sso-mfa/k8s/keycape/portal-client-rollout.py
Normal file
136
sso-mfa/k8s/keycape/portal-client-rollout.py
Normal 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())
|
||||
54
sso-mfa/k8s/keycape/test_portal_client_rollout.py
Normal file
54
sso-mfa/k8s/keycape/test_portal_client_rollout.py
Normal 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()
|
||||
56
sso-mfa/k8s/keycape/verify-user-engine-portal.py
Normal file
56
sso-mfa/k8s/keycape/verify-user-engine-portal.py
Normal 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)
|
||||
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue