NK-WP-0048-T05: fold ops-mason's materialization review into oidc-browser-relying-party
Derive custody at platform/workloads/keycape/{client_id} (secret_ref is no
longer case-settable), reuse the workload-kv-read-keycape-* policy family with
templated bodies, add the external-secrets-keycape-{client_id} ESO role, name
the catalog entry {consuming_service}-keycape-client and collapse the duplicate
KV-path rollback. Schema and validator require a body on policy/role items.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 330051@bnt-lap001
Assistant-Session: b98d4912-95a3-4619-b7fb-57aa1db7407b
This commit is contained in:
parent
c0347351fc
commit
074b3a047b
11 changed files with 141 additions and 43 deletions
|
|
@ -283,8 +283,35 @@
|
|||
"when": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]* (==|!=) [A-Za-z0-9_.:/-]+$"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "The rendered object's content over case parameters: a policy's rules or a role's bindings. Required for openbao_policy and openbao_role; literal braces are doubled."
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"openbao_policy",
|
||||
"openbao_role"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"body"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"step": {
|
||||
"type": "object",
|
||||
|
|
|
|||
|
|
@ -78,7 +78,8 @@ A type is a directory `security-case-types/<type-id>/` in net-kingdom containing
|
|||
in the security-scenario-composition form (fail-closed ordering, explicit
|
||||
initial trust, owner attribution)
|
||||
- `materialization`: the OpenBao structure ops-mason creates per case, as
|
||||
templates over the parameters: policy, role, KV path, catalog entry
|
||||
templates over the parameters: policy, role, KV path, catalog entry;
|
||||
a policy or role templates its rules (`body`), not only its name
|
||||
- `follow_through`: the ops-warden route for each step, `warden_executes`,
|
||||
the auth method and the entry point; which steps need an attended human
|
||||
login, if any, and why
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ spec:
|
|||
- id: signing-key-policy
|
||||
kind: openbao_policy
|
||||
template: "local-identity-{operator_username}-read"
|
||||
# A policy or role templates its rules too, not only its name; literal
|
||||
# braces in HCL are doubled. Prefer a path the type derives over one a
|
||||
# case sets (see oidc-browser-relying-party).
|
||||
body: "read on the KV v2 data path of {signing_key_ref} only; no metadata, list, parent or sibling"
|
||||
- id: catalog-entry
|
||||
kind: catalog_entry
|
||||
template: "local-identity-{operator_username}"
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ secret; group memberships that decide who may sign in and with what rights.
|
|||
| Authorization code or token sent to an attacker-controlled URL | exact `redirect_uris`, https only, no wildcards, validated by the type and enforced by KeyCape |
|
||||
| Anyone with a NetKingdom account gains access to the service | `access_groups` gate sign-in at the consumer; membership is explicit (`initial_members`), never implied |
|
||||
| Stolen password grants admin rights | `mfa_required: "true"` is pinned on the client when the service grants administrative or owner rights, not left to the deployment default |
|
||||
| Client secret leaks through git, chat or a case file | the secret never appears in a case; it is minted into `secret_ref` by the credential broker, and only two policies can read it |
|
||||
| Secret readable by a broad identity | verification includes a negative check that a default-only identity cannot read `secret_ref` |
|
||||
| Client secret leaks through git, chat or a case file | the secret never appears in a case; it is minted by the credential broker into `platform/workloads/keycape/{client_id}`, a path the type derives, and only two read policies (read on that one data path) can reach it, one of them through the ESO role |
|
||||
| Secret readable by a broad identity | verification includes a negative check that a default-only identity cannot read the custody path |
|
||||
| Over-broad scopes leak profile data | `allowed_scopes` is a closed list; `openid` first |
|
||||
| Silent drift between hand-built clients | one capability, one type, one rollback; per-client scripts retire as clients are adopted |
|
||||
|
||||
|
|
@ -66,6 +66,11 @@ roles there), token lifetime policy (KeyCape's), and federation (C5).
|
|||
code alone is not enough.
|
||||
- **Put the client secret in the case for convenience.** Rejected: cases carry
|
||||
custody references only (canon section 9).
|
||||
- **Let each case choose its custody path.** Rejected (ops-mason review,
|
||||
MASON-WP-0010-T01): the type derives `platform/workloads/keycape/{client_id}`
|
||||
and reuses the `workload-kv-read-keycape-<client>-<reader>` policy family and
|
||||
`external-secrets-keycape-<client>` role from the live CCR-2026-0033
|
||||
placement, so a case cannot add a new layout or policy family.
|
||||
|
||||
## Attended steps
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# First KnownSecurityCase (NK-WP-0048-T06), from the request the founder accepted
|
||||
# on 2026-10-08 (key-cape docs/forgejo-oidc-client-request.yaml, KEY-WP-0038-T01).
|
||||
# Custody is derived by the type (platform/workloads/keycape/forgejo), not the
|
||||
# request's proposed platform/workloads/forgejo/netkingdom-oidc-client path.
|
||||
apiVersion: netkingdom.io/security-case/v0.1
|
||||
kind: SecurityCase
|
||||
spec:
|
||||
|
|
@ -18,6 +20,5 @@ spec:
|
|||
mfa_required: "true"
|
||||
access_groups: forge-admins,forge-coulomb-owners,forge-coulomb-readers
|
||||
initial_members: forge-admins=tegwick;forge-coulomb-owners=tegwick
|
||||
secret_ref: bao:platform/workloads/forgejo/netkingdom-oidc-client
|
||||
consuming_service: forgejo
|
||||
consumer_owner_repo: railiance-apps
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ def test_forgejo_case_matches_the_accepted_request():
|
|||
assert p["allowed_scopes"].split(",") == accepted["client"]["allowedScopes"]
|
||||
assert p["client_type"] == accepted["client"]["clientType"]
|
||||
assert p["mfa_required"] == str(accepted["client"]["mfaRequired"]).lower()
|
||||
assert p["secret_ref"] == "bao:" + accepted["custody"]["proposed_openbao_path"]
|
||||
assert "secret_ref" not in p # derived by the type; diverges from the request's proposed path
|
||||
assert set(p["access_groups"].split(",")) == set(accepted["forgejo_mapping"]["required_claim"]["any_of"])
|
||||
|
||||
|
||||
|
|
@ -73,17 +73,35 @@ def test_redirects_must_be_exact_https():
|
|||
assert any("redirect_uris" in m for m in msgs(v.validate_case(with_params(FORGEJO, redirect_uris=bad), t))), bad
|
||||
|
||||
|
||||
def test_confidential_client_needs_custody_reference_and_public_does_not():
|
||||
def test_custody_path_is_derived_not_case_settable():
|
||||
t = as_implemented()
|
||||
assert any("required when client_type == confidential" in m
|
||||
for m in msgs(v.validate_case(with_params(FORGEJO, secret_ref=None), t)))
|
||||
assert msgs(v.validate_case(FORGEJO, t)) == []
|
||||
assert msgs(v.validate_case(PUBLIC, t)) == []
|
||||
for chosen in ["bao:platform/workloads/forgejo/netkingdom-oidc-client", "hvs.CAESIabcdefghijklmnop"]:
|
||||
assert msgs(v.validate_case(with_params(FORGEJO, secret_ref=chosen), t)), chosen
|
||||
|
||||
|
||||
def test_secret_value_instead_of_reference_is_refused():
|
||||
t = as_implemented()
|
||||
found = msgs(v.validate_case(with_params(FORGEJO, secret_ref="hvs.CAESIabcdefghijklmnop"), t))
|
||||
assert found
|
||||
def test_confidential_materialization_reuses_the_keycape_family():
|
||||
issues, r = v.render_case(FORGEJO, as_implemented(), DECLS)
|
||||
assert msgs(issues) == []
|
||||
m = {i["id"]: i for i in r["materialization"]}
|
||||
assert m["client-secret-path"]["template"] == "platform/workloads/keycape/forgejo"
|
||||
assert m["keycape-reader-policy"]["template"] == "workload-kv-read-keycape-forgejo-eso"
|
||||
assert m["consumer-reader-policy"]["template"] == "workload-kv-read-keycape-forgejo-forgejo"
|
||||
assert m["eso-role"]["template"] == "external-secrets-keycape-forgejo"
|
||||
assert m["catalog-entry"]["template"] == "forgejo-keycape-client"
|
||||
for pid in ("keycape-reader-policy", "consumer-reader-policy"):
|
||||
body = m[pid]["body"]
|
||||
assert body.count("path ") == 1 and 'path "platform/data/workloads/keycape/forgejo" {' in body
|
||||
assert 'capabilities = ["read"]' in body and "metadata" not in body and "list" not in body
|
||||
assert "token_no_default_policy: true" in m["eso-role"]["body"]
|
||||
assert "workload-kv-read-keycape-forgejo-eso" in m["eso-role"]["body"]
|
||||
|
||||
|
||||
def test_policy_without_body_is_refused():
|
||||
t = copy.deepcopy(TYPE)
|
||||
t["spec"]["materialization"][1].pop("body")
|
||||
assert any("body" in m for m in msgs(v.validate_type(t)))
|
||||
|
||||
|
||||
def test_scopes_are_a_closed_list():
|
||||
|
|
@ -110,7 +128,7 @@ def test_confidential_case_renders_custody_delivery_and_one_attended_session():
|
|||
assert [s["id"] for s in r["follow_through"] if s.get("attended")] == ["bind-env", "register-client"]
|
||||
assert "keycape-client.py apply" in steps["register-client"]["entry_point"]
|
||||
assert "sso/keycape-forgejo-client" in steps["deliver-secret"]["entry_point"]
|
||||
assert steps["mint-secret"]["entry_point"].endswith("bao:platform/workloads/forgejo/netkingdom-oidc-client")
|
||||
assert steps["mint-secret"]["entry_point"].endswith("bao:platform/workloads/keycape/forgejo")
|
||||
assert steps["hand-over"]["owner"] == "railiance-apps"
|
||||
plan = r["composition"]
|
||||
assert plan["execution"]["permitted"] is False
|
||||
|
|
|
|||
|
|
@ -62,15 +62,6 @@ spec:
|
|||
constraints: {pattern: "^[a-z][a-z0-9-]*=[a-z][a-z0-9._-]*(\\+[a-z][a-z0-9._-]*)*(;[a-z][a-z0-9-]*=[a-z][a-z0-9._-]*(\\+[a-z][a-z0-9._-]*)*)*$"}
|
||||
sensitivity: operational
|
||||
description: "Initial memberships as group=user+user;group=user. Usernames are directory usernames, never secrets."
|
||||
- name: secret_ref
|
||||
type: string
|
||||
required: false
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: "^bao:platform/workloads/[a-z0-9/_-]+$"
|
||||
required_when: "client_type == confidential"
|
||||
sensitivity: secret_reference
|
||||
description: OpenBao custody path for a confidential client's secret. Omitted for public clients.
|
||||
- name: consuming_service
|
||||
type: string
|
||||
case_settable: true
|
||||
|
|
@ -105,22 +96,49 @@ spec:
|
|||
net-kingdom.keycape-client-registration.mfa_required: mfa_required
|
||||
|
||||
materialization:
|
||||
# Placement follows the live CCR-2026-0033 reference (secrets-engine-openbao,
|
||||
# ops-mason plans/keycape-secrets-engine-openbao-client-custody.md). The path
|
||||
# is derived, not case-settable, and the policies reuse the existing
|
||||
# workload-kv-read-keycape-<client>-<reader> family (MASON-WP-0010-T01).
|
||||
- id: client-secret-path
|
||||
kind: kv_path
|
||||
when: "client_type == confidential"
|
||||
template: "{secret_ref}"
|
||||
template: "platform/workloads/keycape/{client_id}"
|
||||
body: "field CLIENT_SECRET; minted by the credential broker, never written by hand"
|
||||
- id: keycape-reader-policy
|
||||
kind: openbao_policy
|
||||
when: "client_type == confidential"
|
||||
template: "keycape-client-{client_id}-read"
|
||||
template: "workload-kv-read-keycape-{client_id}-eso"
|
||||
body: |
|
||||
path "platform/data/workloads/keycape/{client_id}" {{
|
||||
capabilities = ["read"]
|
||||
}}
|
||||
description: KeyCape reads its client secret only through ESO. No metadata, list, parent or sibling access.
|
||||
- id: consumer-reader-policy
|
||||
kind: openbao_policy
|
||||
when: "client_type == confidential"
|
||||
template: "{consuming_service}-oidc-client-{client_id}-read"
|
||||
template: "workload-kv-read-keycape-{client_id}-{consuming_service}"
|
||||
body: |
|
||||
path "platform/data/workloads/keycape/{client_id}" {{
|
||||
capabilities = ["read"]
|
||||
}}
|
||||
description: The consuming service reads the same secret. No metadata, list, parent or sibling access.
|
||||
- id: eso-role
|
||||
kind: openbao_role
|
||||
when: "client_type == confidential"
|
||||
template: "external-secrets-keycape-{client_id}"
|
||||
body: |
|
||||
auth: kubernetes
|
||||
bound_service_account_names: [external-secrets]
|
||||
bound_service_account_namespaces: [external-secrets]
|
||||
token_policies: [workload-kv-read-keycape-{client_id}-eso]
|
||||
token_no_default_policy: true
|
||||
token_ttl: 15m
|
||||
token_max_ttl: 15m
|
||||
description: The Kubernetes-auth role deliver-secret uses; bound to the ESO service account only.
|
||||
- id: catalog-entry
|
||||
kind: catalog_entry
|
||||
template: "oidc-client-{client_id}"
|
||||
|
||||
template: "{consuming_service}-keycape-client"
|
||||
follow_through:
|
||||
- id: ensure-groups
|
||||
owner: net-kingdom
|
||||
|
|
@ -131,15 +149,15 @@ spec:
|
|||
- id: mint-secret
|
||||
owner: railiance-platform
|
||||
when: "client_type == confidential"
|
||||
entry_point: "railiance-platform credential broker: generate a client secret into {secret_ref}"
|
||||
entry_point: "railiance-platform credential broker: generate a client secret into bao:platform/workloads/keycape/{client_id}"
|
||||
auth: "credential broker issuer, scoped to the case's KV path"
|
||||
warden_executes: true
|
||||
attended: false
|
||||
- id: deliver-secret
|
||||
owner: railiance-platform
|
||||
when: "client_type == confidential"
|
||||
entry_point: "ExternalSecret sso/keycape-{client_id}-client (key client-secret) from {secret_ref}"
|
||||
auth: "railiance-platform ESO delivery through the platform ClusterSecretStore; no value leaves custody"
|
||||
entry_point: "ExternalSecret sso/keycape-{client_id}-client (key client-secret) from bao:platform/workloads/keycape/{client_id}"
|
||||
auth: "railiance-platform ESO delivery through role external-secrets-keycape-{client_id}; no value leaves custody"
|
||||
warden_executes: false
|
||||
attended: false
|
||||
- id: bind-env
|
||||
|
|
@ -168,7 +186,7 @@ spec:
|
|||
all attended steps of a run into that one session.
|
||||
- id: hand-over
|
||||
owner: "{consumer_owner_repo}"
|
||||
entry_point: "configure {consuming_service} with issuer {issuer}, client {client_id} and custody {secret_ref}"
|
||||
entry_point: "configure {consuming_service} with issuer {issuer}, client {client_id} and custody bao:platform/workloads/keycape/{client_id}"
|
||||
auth: "the consumer owner's own deployment path"
|
||||
warden_executes: false
|
||||
attended: false
|
||||
|
|
@ -184,12 +202,12 @@ spec:
|
|||
reads_secret_value: false
|
||||
- id: custody-denied-to-default
|
||||
when: "client_type == confidential"
|
||||
check: "a default-only OpenBao identity has no read capability on {secret_ref}"
|
||||
check: "a default-only OpenBao identity has no read capability on bao:platform/workloads/keycape/{client_id}"
|
||||
expect: deny
|
||||
reads_secret_value: false
|
||||
- id: custody-allowed-to-consumer
|
||||
when: "client_type == confidential"
|
||||
check: "policy {consuming_service}-oidc-client-{client_id}-read grants read on {secret_ref} (capabilities check only)"
|
||||
check: "policy workload-kv-read-keycape-{client_id}-{consuming_service} grants read on bao:platform/workloads/keycape/{client_id} (capabilities check only)"
|
||||
expect: allow
|
||||
reads_secret_value: false
|
||||
- id: groups-present
|
||||
|
|
@ -210,20 +228,23 @@ spec:
|
|||
action: "delete ExternalSecret sso/keycape-{client_id}-client and its target"
|
||||
- reverses: mint-secret
|
||||
when: "client_type == confidential"
|
||||
action: "destroy the secret at {secret_ref}"
|
||||
action: "covered by client-secret-path: one metadata delete, after deliver-secret is reversed"
|
||||
- reverses: ensure-groups
|
||||
action: "identity-provisioner POST /v1/groups/revoke with the case's memberships; delete_if_empty only if no other case uses {access_groups}"
|
||||
- reverses: client-secret-path
|
||||
when: "client_type == confidential"
|
||||
action: "remove the KV path {secret_ref}"
|
||||
action: "delete metadata and all versions at platform/workloads/keycape/{client_id}, after the ExternalSecret is gone (this also reverses mint-secret)"
|
||||
- reverses: eso-role
|
||||
when: "client_type == confidential"
|
||||
action: "delete Kubernetes-auth role external-secrets-keycape-{client_id}"
|
||||
- reverses: keycape-reader-policy
|
||||
when: "client_type == confidential"
|
||||
action: "delete policy keycape-client-{client_id}-read"
|
||||
action: "delete policy workload-kv-read-keycape-{client_id}-eso, after eso-role"
|
||||
- reverses: consumer-reader-policy
|
||||
when: "client_type == confidential"
|
||||
action: "delete policy {consuming_service}-oidc-client-{client_id}-read"
|
||||
action: "delete policy workload-kv-read-keycape-{client_id}-{consuming_service}"
|
||||
- reverses: catalog-entry
|
||||
action: "remove catalog entry oidc-client-{client_id}"
|
||||
action: "remove catalog entry {consuming_service}-keycape-client"
|
||||
|
||||
acceptance:
|
||||
accepted_by: [owner, founder]
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ def client_from_params(params):
|
|||
require(scopes[0] == 'openid' and set(scopes) <= SCOPES and len(set(scopes)) == len(scopes),
|
||||
'allowed_scopes_invalid')
|
||||
require(params['client_type'] in ('confidential', 'public'), 'client_type_invalid')
|
||||
# The type derives custody at platform/workloads/keycape/<client_id>; a case cannot choose it.
|
||||
require('secret_ref' not in params, 'secret_ref_is_derived_by_the_type')
|
||||
require(params['mfa_required'] in ('true', 'false'), 'mfa_required_invalid')
|
||||
client = {
|
||||
'clientId': cid,
|
||||
|
|
@ -89,7 +91,6 @@ def client_from_params(params):
|
|||
'clientType': params['client_type'],
|
||||
}
|
||||
if params['client_type'] == 'confidential':
|
||||
require(str(params.get('secret_ref', '')).startswith('bao:'), 'confidential_client_needs_custody_reference')
|
||||
client['secretRef'] = 'env:' + env_name(cid)
|
||||
if params['mfa_required'] == 'true':
|
||||
client['mfaRequired'] = True
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ FORGEJO_PARAMS = {
|
|||
'client_id': 'forgejo', 'display_name': 'Forgejo source forge',
|
||||
'redirect_uris': 'https://forgejo.coulomb.social/user/oauth2/netkingdom/callback',
|
||||
'allowed_scopes': 'openid,profile,email,groups', 'client_type': 'confidential',
|
||||
'mfa_required': 'true', 'secret_ref': 'bao:platform/workloads/forgejo/netkingdom-oidc-client',
|
||||
'mfa_required': 'true',
|
||||
}
|
||||
FORGEJO = kc.client_from_params(FORGEJO_PARAMS)
|
||||
|
||||
|
|
@ -57,7 +57,6 @@ class ClientRecordTests(unittest.TestCase):
|
|||
|
||||
def test_public_record_has_no_secret(self):
|
||||
params = dict(FORGEJO_PARAMS, client_type='public', mfa_required='false')
|
||||
params.pop('secret_ref')
|
||||
client = kc.client_from_params(params)
|
||||
self.assertNotIn('secretRef', client)
|
||||
self.assertNotIn('mfaRequired', client)
|
||||
|
|
@ -65,7 +64,7 @@ class ClientRecordTests(unittest.TestCase):
|
|||
def test_invalid_params_refused(self):
|
||||
for change in [dict(redirect_uris='https://x.example/*'), dict(redirect_uris='http://x.example/cb'),
|
||||
dict(allowed_scopes='profile,openid'), dict(allowed_scopes='openid,admin'),
|
||||
dict(client_type='weird'), dict(secret_ref='plain-value'), dict(client_id='Bad_Id')]:
|
||||
dict(client_type='weird'), dict(secret_ref='bao:platform/workloads/forgejo/x'), dict(client_id='Bad_Id')]:
|
||||
with self.subTest(change=change), self.assertRaises(kc.Refused):
|
||||
kc.client_from_params(dict(FORGEJO_PARAMS, **change))
|
||||
|
||||
|
|
|
|||
|
|
@ -256,6 +256,8 @@ def _validate_materialization(items: Any, params: dict[str, dict], issues: list[
|
|||
seen.add(item.get("id"))
|
||||
if not item.get("template"):
|
||||
issues.append(err(f"{path}[{i}].template", "required"))
|
||||
if item["kind"] in {"openbao_policy", "openbao_role"} and not item.get("body"):
|
||||
issues.append(err(f"{path}[{i}].body", "a policy or role needs its rules templated, not only its name"))
|
||||
_validate_templates(items, path, params, issues)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -208,6 +208,25 @@ sides. The dispatcher delivered it to informed-decision (ledger 7 → 8
|
|||
delivered). T05 waits for the founder's answer in informed-decision, which
|
||||
resolves hub decision `e418263e`.
|
||||
|
||||
2026-10-08: ops-mason's review (MASON-WP-0010-T01) folded into the type, still
|
||||
1.0 `proposed`: `secret_ref` is no longer a parameter, the type derives
|
||||
`platform/workloads/keycape/{client_id}`; policies are
|
||||
`workload-kv-read-keycape-{client_id}-eso` and
|
||||
`workload-kv-read-keycape-{client_id}-{consuming_service}`, each with a templated
|
||||
body (`read` on that one data path); new `openbao_role`
|
||||
`external-secrets-keycape-{client_id}` (Kubernetes auth, ESO service account,
|
||||
15m, no default policy); catalog entry `{consuming_service}-keycape-client`;
|
||||
the two rollback steps on the KV path collapse into one metadata delete. The
|
||||
schema and validator now require a `body` on policy and role items. 105 tests
|
||||
pass. Two consequences: the delivered memo describes the type before these
|
||||
changes (a case-set custody reference, two differently named policies, no ESO
|
||||
role), so the founder answers on a superseded shape unless the memo is
|
||||
re-sent; and Forgejo's custody path moves from the accepted request's proposed
|
||||
`platform/workloads/forgejo/netkingdom-oidc-client` to
|
||||
`platform/workloads/keycape/forgejo`, which key-cape (KEY-WP-0038) and
|
||||
railiance-apps (RAPPS-WP-0016-T02) need to adopt in T06. ops-warden's review
|
||||
(WARDEN-WP-0044-T01) is still in progress.
|
||||
|
||||
```task
|
||||
id: NK-WP-0048-T06
|
||||
status: wait
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue