Set the approval store tenant to exact tenant:platform
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management, administration and services tenant, with no alias
to platform or tenant:coulomb and no implicit cross-tenant grant. This closes
the collision recorded in 5c87ba8, where the manifest served --tenant platform
while the requested registrations issued tenant:coulomb.
The store tenant is now exactly tenant:platform in the manifest, the CLI
default, and the Engine default, and the requested client registrations ask for
the same spelling. Exact JWT/store equality is retained: no mapping table, no
normalisation, no prefix handling.
Moving the defaults rather than only the manifest is deliberate. A default of
platform under a sanctioned value of tenant:platform is a trap, because a serve
that omits --tenant would come up healthy and then refuse every authenticated
call -- the exact failure this decision exists to prevent.
That default change broke ten tests whose identity fixtures hard-coded
platform. This is the hazard flex-auth reported as FLEX-DEC-2026-008: fixtures
that all carry one tenant prove nothing about the tenant field. Fixtures are
aligned to the exact spelling, and the field is now varied rather than merely
present. test_near_miss_tenant_spellings_are_forbidden refuses platform,
tenant:coulomb, case variants, whitespace variants and empty against a
tenant:platform store; test_exact_sanctioned_tenant_is_admitted pins the other
half so a reject-everything bug cannot pass it. 111 tests pass.
Also records the credential-independent half of the GLAS-WP-0015 image request:
the image builds non-root uid 10001 off the pinned base, carries schema v3 and
the new tenant default, migrates and verifies a fresh store to schema_version 3
with integrity ok, and refuses production without a persistent database or
authenticated audit delivery. No scan was run -- no scanner is installed here --
and no release digest exists, so T01 and T03 both stay open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM5HnEAhokxdfcPqBNpT7D
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715850@bnt-lap001
Assistant-Session: eb557e93-7cb1-45d0-9e57-7d15b3edc60e
This commit is contained in:
parent
5c87ba8610
commit
6d18f62a90
8 changed files with 182 additions and 38 deletions
|
|
@ -37,7 +37,7 @@ def _parser() -> argparse.ArgumentParser:
|
||||||
serve.add_argument("--host", default="127.0.0.1")
|
serve.add_argument("--host", default="127.0.0.1")
|
||||||
serve.add_argument("--port", type=int, default=8787)
|
serve.add_argument("--port", type=int, default=8787)
|
||||||
serve.add_argument("--production", action="store_true")
|
serve.add_argument("--production", action="store_true")
|
||||||
serve.add_argument("--tenant", default="platform")
|
serve.add_argument("--tenant", default="tenant:platform")
|
||||||
serve.add_argument("--jwt-issuer")
|
serve.add_argument("--jwt-issuer")
|
||||||
serve.add_argument("--jwt-audience")
|
serve.add_argument("--jwt-audience")
|
||||||
serve.add_argument("--jwks-url")
|
serve.add_argument("--jwks-url")
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ class Engine:
|
||||||
clock: Callable[[], datetime] | None = None,
|
clock: Callable[[], datetime] | None = None,
|
||||||
freshness_ttl: int = DEFAULT_FRESHNESS_TTL,
|
freshness_ttl: int = DEFAULT_FRESHNESS_TTL,
|
||||||
fail_outbox: bool = False,
|
fail_outbox: bool = False,
|
||||||
tenant: str = "platform",
|
tenant: str = "tenant:platform",
|
||||||
auto_migrate: bool = True,
|
auto_migrate: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.path = str(path)
|
self.path = str(path)
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ spec:
|
||||||
- --port
|
- --port
|
||||||
- "8080"
|
- "8080"
|
||||||
- --tenant
|
- --tenant
|
||||||
- platform
|
- tenant:platform
|
||||||
- --jwt-issuer
|
- --jwt-issuer
|
||||||
- https://kc.coulomb.social
|
- https://kc.coulomb.social
|
||||||
- --jwt-audience
|
- --jwt-audience
|
||||||
|
|
|
||||||
|
|
@ -21,44 +21,57 @@ Required claims remain those in `docs/caller-authentication.md`: `iss`, `sub`,
|
||||||
| JWKS | `GET /jwks` on the KeyCape service |
|
| JWKS | `GET /jwks` on the KeyCape service |
|
||||||
| Scopes | `approval:create`, `approval:read`, `approval:approve`, `approval:revoke`, `approval:supersede`, `approval:consume`, `approval:observe`, `approval:emit` |
|
| Scopes | `approval:create`, `approval:read`, `approval:approve`, `approval:revoke`, `approval:supersede`, `approval:consume`, `approval:observe`, `approval:emit` |
|
||||||
|
|
||||||
## Tenant reconciliation — unresolved, blocks token issuance
|
## Tenant — resolved: exact `tenant:platform`
|
||||||
|
|
||||||
**This is a live collision, not a naming preference.** `ApiApplication.identity`
|
The operator accepted `tenant:platform` as the platform management,
|
||||||
compares the verified JWT `tenant` claim to the engine's configured store tenant
|
administration and services tenant (the landlord zone). Decision
|
||||||
with exact string equality and raises `Forbidden` before any object lookup
|
`5ed3fb35-eca9-413a-82b9-95171ba85bf6`, recorded in
|
||||||
|
`glas-harness/docs/platform-tenant-decision.md` and relayed by `glas-harness`
|
||||||
|
2026-09-06.
|
||||||
|
|
||||||
|
**The spelling is the contract.** `ApiApplication.identity` compares the
|
||||||
|
verified JWT `tenant` claim to the engine's configured store tenant with exact
|
||||||
|
string equality and raises `Forbidden` before any object lookup
|
||||||
(`approval_engine/api.py:66`). There is no mapping table, no normalisation, and
|
(`approval_engine/api.py:66`). There is no mapping table, no normalisation, and
|
||||||
no prefix handling anywhere in this engine.
|
no prefix handling anywhere in this engine — deliberately. `platform` is **not**
|
||||||
|
an accepted alias for `tenant:platform`, and neither is `tenant:coulomb`.
|
||||||
|
|
||||||
The three values currently in play:
|
Rendered values, all three now exactly `tenant:platform`:
|
||||||
|
|
||||||
| Value | Where it is set | Current content |
|
| Value | Where it is set | Content |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Store tenant | `deploy/approval-engine.yaml` `--tenant` (CLI default `platform`) | `platform` |
|
| Store tenant | `deploy/approval-engine.yaml` `--tenant` | `tenant:platform` |
|
||||||
| JWT `tenant` claim | the client registrations below | `tenant:coulomb` |
|
| Store tenant default | `approval_engine/cli.py` `--tenant`, `Engine(tenant=…)` | `tenant:platform` |
|
||||||
| CheckRequest tenant | flex-auth policy subject | `tenant:platform` — **never read by this engine** |
|
| JWT `tenant` claim | the client registrations below | `tenant:platform` |
|
||||||
|
| CheckRequest tenant | flex-auth policy subject | `tenant:platform` |
|
||||||
|
|
||||||
`platform` != `tenant:coulomb`, so tokens issued under the registrations below
|
The CLI and `Engine` defaults were moved off `platform` in the same change. A
|
||||||
would be denied `403` on every non-health route. Spelling similarity between
|
default that differs from the sanctioned value is a trap: a `serve` invocation
|
||||||
`platform` and `tenant:platform` is not a mapping either; the flex-auth policy
|
that omits `--tenant` would have come up healthy and then refused every
|
||||||
subject is a PDP input this engine never inspects, so it cannot participate in
|
authenticated call, which is the failure this decision exists to prevent.
|
||||||
the comparison at all.
|
|
||||||
|
|
||||||
Denial evidence: `tests/test_auth.py::test_wrong_tenant_is_forbidden` — a
|
The flex-auth CheckRequest tenant now matches by spelling, but note it still
|
||||||
signature-valid token whose tenant differs from the store tenant is refused
|
does not participate in this comparison — it is a PDP policy subject this engine
|
||||||
without mutation.
|
never reads. Alignment there is a property of the decision, not a mechanism
|
||||||
|
here.
|
||||||
|
|
||||||
**Resolution is an owner decision and is deliberately not taken here.** Either
|
Denial evidence:
|
||||||
KeyCape issues `tenant: platform` to match the store, or this repo's manifest
|
|
||||||
sets `--tenant tenant:coulomb` to match the registration. Which is correct
|
- `tests/test_auth.py::test_wrong_tenant_is_forbidden` — a signature-valid token
|
||||||
depends on whether `platform` and `tenant:coulomb` name the same layer — a
|
whose tenant differs from the store tenant is refused `403` without mutation.
|
||||||
question this engine cannot answer, and answering it wrongly grants a token
|
- `tests/test_auth.py::test_near_miss_tenant_spellings_are_forbidden` — pins the
|
||||||
cross-tenant access to the approval store. The values below are left as
|
decision's "no alias" clause directly: `platform`, `tenant:coulomb`,
|
||||||
requested until that mapping is stated by an owner, so the mismatch stays
|
`TENANT:PLATFORM`, `tenant:platform ` (trailing space) and `` (empty) are each
|
||||||
visible rather than being silently resolved by whichever document was edited
|
refused against a `tenant:platform` store, while the exact spelling is
|
||||||
last.
|
admitted. Without a test that *varies* the tenant, a suite of fixtures all
|
||||||
|
carrying the sanctioned value proves nothing about the field.
|
||||||
|
|
||||||
|
This resolves the choice of value only. Verification and the remaining admission
|
||||||
|
gates — KeyCape actually owning these registrations, credential materialization,
|
||||||
|
and the `T03` rollout evidence — are unchanged and still open.
|
||||||
|
|
||||||
Tracked against `APPROVAL-WP-0002-T01`; related `KEY-WP-0013-T02`,
|
Tracked against `APPROVAL-WP-0002-T01`; related `KEY-WP-0013-T02`,
|
||||||
`SECRETS-WP-0009-T03`.
|
`SECRETS-WP-0009-T03`, `GLAS-WP-0015`.
|
||||||
|
|
||||||
## Clients
|
## Clients
|
||||||
|
|
||||||
|
|
@ -76,7 +89,7 @@ clients:
|
||||||
secretRef: env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET
|
secretRef: env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET
|
||||||
serviceSubject: service:secrets-engine
|
serviceSubject: service:secrets-engine
|
||||||
principal_type: service
|
principal_type: service
|
||||||
tenant: tenant:coulomb
|
tenant: tenant:platform
|
||||||
roles: [secrets-engine]
|
roles: [secrets-engine]
|
||||||
tokenLifetime: 15m
|
tokenLifetime: 15m
|
||||||
|
|
||||||
|
|
@ -96,7 +109,7 @@ clients:
|
||||||
secretRef: env:KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET
|
secretRef: env:KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET
|
||||||
serviceSubject: service:approval-engine-operator
|
serviceSubject: service:approval-engine-operator
|
||||||
principal_type: service
|
principal_type: service
|
||||||
tenant: tenant:coulomb
|
tenant: tenant:platform
|
||||||
roles: [approval-operator]
|
roles: [approval-operator]
|
||||||
tokenLifetime: 15m
|
tokenLifetime: 15m
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ def app(engine):
|
||||||
issuer="https://keycape.example",
|
issuer="https://keycape.example",
|
||||||
audiences=("approval-engine",),
|
audiences=("approval-engine",),
|
||||||
principal_type="service",
|
principal_type="service",
|
||||||
tenant="platform",
|
tenant="tenant:platform",
|
||||||
roles=frozenset({"secrets-engine"}),
|
roles=frozenset({"secrets-engine"}),
|
||||||
scopes=frozenset(
|
scopes=frozenset(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ def test_api_requires_scope_and_binds_create_actor(engine):
|
||||||
issuer="test",
|
issuer="test",
|
||||||
audiences=("approval-engine",),
|
audiences=("approval-engine",),
|
||||||
principal_type="service",
|
principal_type="service",
|
||||||
tenant="platform",
|
tenant="tenant:platform",
|
||||||
roles=frozenset(),
|
roles=frozenset(),
|
||||||
scopes=frozenset({"approval:create"}),
|
scopes=frozenset({"approval:create"}),
|
||||||
assurance={"level": "aal1"},
|
assurance={"level": "aal1"},
|
||||||
|
|
@ -201,6 +201,72 @@ def test_wrong_tenant_is_forbidden(engine):
|
||||||
assert body["error"] == "forbidden"
|
assert body["error"] == "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"tenant",
|
||||||
|
[
|
||||||
|
"platform",
|
||||||
|
"tenant:coulomb",
|
||||||
|
"TENANT:PLATFORM",
|
||||||
|
"Tenant:Platform",
|
||||||
|
"tenant:platform ",
|
||||||
|
" tenant:platform",
|
||||||
|
"tenant:platform:",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_near_miss_tenant_spellings_are_forbidden(engine, tenant):
|
||||||
|
"""Decision 5ed3fb35 accepted exactly `tenant:platform` with no alias.
|
||||||
|
|
||||||
|
The store comparison is exact string equality, so every near miss below must
|
||||||
|
be refused: the bare `platform` this repo used to serve, the `tenant:coulomb`
|
||||||
|
the registrations used to request, case variants, and whitespace. Varying the
|
||||||
|
field is the point — a suite whose fixtures all carry the sanctioned value
|
||||||
|
proves nothing about the tenant check.
|
||||||
|
"""
|
||||||
|
identity = Identity(
|
||||||
|
subject="agt-secrets-engine",
|
||||||
|
issuer="test",
|
||||||
|
audiences=("approval-engine",),
|
||||||
|
principal_type="service",
|
||||||
|
tenant=tenant,
|
||||||
|
roles=frozenset(),
|
||||||
|
scopes=frozenset({"approval:observe"}),
|
||||||
|
assurance={},
|
||||||
|
evidence_ref="test",
|
||||||
|
)
|
||||||
|
from approval_engine.api import App
|
||||||
|
|
||||||
|
app = App(engine, StaticTokenAuthenticator({"near": identity}))
|
||||||
|
status, body = call(app, "GET", "/v1/cadence", authorization="Bearer near")
|
||||||
|
assert status == 403, f"{tenant!r} was admitted as an alias"
|
||||||
|
assert body["error"] == "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_sanctioned_tenant_is_admitted(engine):
|
||||||
|
"""The other half of the pin: the exact spelling must actually work.
|
||||||
|
|
||||||
|
Without this, a bug that rejected every tenant would pass the near-miss test
|
||||||
|
above while denying the sanctioned caller too.
|
||||||
|
"""
|
||||||
|
assert engine.tenant == "tenant:platform"
|
||||||
|
identity = Identity(
|
||||||
|
subject="agt-secrets-engine",
|
||||||
|
issuer="test",
|
||||||
|
audiences=("approval-engine",),
|
||||||
|
principal_type="service",
|
||||||
|
tenant="tenant:platform",
|
||||||
|
roles=frozenset(),
|
||||||
|
scopes=frozenset({"approval:observe"}),
|
||||||
|
assurance={},
|
||||||
|
evidence_ref="test",
|
||||||
|
)
|
||||||
|
from approval_engine.api import App
|
||||||
|
|
||||||
|
app = App(engine, StaticTokenAuthenticator({"exact": identity}))
|
||||||
|
status, _ = call(app, "GET", "/v1/cadence", authorization="Bearer exact")
|
||||||
|
assert status == 200
|
||||||
|
|
||||||
|
|
||||||
def test_deny_all_default_does_not_mutate(engine):
|
def test_deny_all_default_does_not_mutate(engine):
|
||||||
from approval_engine.api import App
|
from approval_engine.api import App
|
||||||
|
|
||||||
|
|
@ -225,7 +291,7 @@ def test_human_principal_cannot_consume(engine):
|
||||||
issuer="test",
|
issuer="test",
|
||||||
audiences=("approval-engine",),
|
audiences=("approval-engine",),
|
||||||
principal_type="service",
|
principal_type="service",
|
||||||
tenant="platform",
|
tenant="tenant:platform",
|
||||||
roles=frozenset(),
|
roles=frozenset(),
|
||||||
scopes=frozenset(
|
scopes=frozenset(
|
||||||
{"approval:create", "approval:approve", "approval:read", "approval:consume"}
|
{"approval:create", "approval:approve", "approval:read", "approval:consume"}
|
||||||
|
|
@ -238,7 +304,7 @@ def test_human_principal_cannot_consume(engine):
|
||||||
issuer="test",
|
issuer="test",
|
||||||
audiences=("approval-engine",),
|
audiences=("approval-engine",),
|
||||||
principal_type="human",
|
principal_type="human",
|
||||||
tenant="platform",
|
tenant="tenant:platform",
|
||||||
roles=frozenset(),
|
roles=frozenset(),
|
||||||
scopes=frozenset({"approval:consume"}),
|
scopes=frozenset({"approval:consume"}),
|
||||||
assurance={"level": "aal2"},
|
assurance={"level": "aal2"},
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ def live_http(tmp_path):
|
||||||
issuer="https://keycape.example",
|
issuer="https://keycape.example",
|
||||||
audiences=("approval-engine",),
|
audiences=("approval-engine",),
|
||||||
principal_type="service",
|
principal_type="service",
|
||||||
tenant="platform",
|
tenant="tenant:platform",
|
||||||
roles=frozenset({"secrets-engine"}),
|
roles=frozenset({"secrets-engine"}),
|
||||||
scopes=frozenset(
|
scopes=frozenset(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,37 @@ the approval store. The registrations doc's stale issuer
|
||||||
(`https://auth.netkingdom.local`) is corrected to the live
|
(`https://auth.netkingdom.local`) is corrected to the live
|
||||||
`https://kc.coulomb.social` from `06544b0`. T01 stays `progress`.
|
`https://kc.coulomb.social` from `06544b0`. T01 stays `progress`.
|
||||||
|
|
||||||
|
2026-09-06 resolution: the operator accepted `tenant:platform` as the platform
|
||||||
|
management/administration/services tenant (landlord zone) — decision
|
||||||
|
`5ed3fb35-eca9-413a-82b9-95171ba85bf6`,
|
||||||
|
`glas-harness/docs/platform-tenant-decision.md`, relayed by `glas-harness`. The
|
||||||
|
collision above is closed by setting the store tenant to exactly
|
||||||
|
`tenant:platform`: `deploy/approval-engine.yaml` `--tenant`, the
|
||||||
|
`approval_engine/cli.py` `--tenant` default, and the `Engine(tenant=…)` default
|
||||||
|
all move off bare `platform`, and the requested client registrations now ask for
|
||||||
|
`tenant: tenant:platform`. Exact JWT/store equality is retained — no alias, no
|
||||||
|
normalisation, no prefix handling, and no implicit cross-tenant grant.
|
||||||
|
|
||||||
|
Moving the *defaults* rather than only the manifest is deliberate: a default of
|
||||||
|
`platform` under a sanctioned value of `tenant:platform` is a trap, because a
|
||||||
|
`serve` that omits `--tenant` would come up healthy and then refuse every
|
||||||
|
authenticated call.
|
||||||
|
|
||||||
|
The default change broke ten tests whose identity fixtures hard-coded
|
||||||
|
`platform`, which is the fixture-consistency hazard flex-auth reported as
|
||||||
|
`FLEX-DEC-2026-008` (29 fixtures all carrying one tenant proved nothing about
|
||||||
|
the field). Fixtures are aligned to the exact spelling, and the field is now
|
||||||
|
*varied* rather than merely present:
|
||||||
|
`tests/test_auth.py::test_near_miss_tenant_spellings_are_forbidden` refuses
|
||||||
|
`platform`, `tenant:coulomb`, case variants, whitespace variants and empty
|
||||||
|
against a `tenant:platform` store, and
|
||||||
|
`test_exact_sanctioned_tenant_is_admitted` pins the other half so a
|
||||||
|
reject-everything bug cannot pass. 111 tests pass.
|
||||||
|
|
||||||
|
This resolves the choice of value only. T01 stays `progress`: KeyCape still has
|
||||||
|
to own and prove these registrations, and credential materialization is
|
||||||
|
unchanged.
|
||||||
|
|
||||||
## Harden durable storage and migrations
|
## Harden durable storage and migrations
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -138,6 +169,40 @@ read-only root, resources, probes, and default-deny policies pass client dry-run
|
||||||
Waiting on release digest, KeyCape/audit registrations and credentials, rollout,
|
Waiting on release digest, KeyCape/audit registrations and credentials, rollout,
|
||||||
restart, and restore evidence.
|
restart, and restore evidence.
|
||||||
|
|
||||||
|
2026-09-06 image preparation (GLAS-WP-0015 request; credential-independent half):
|
||||||
|
built and validated locally against the agreed tenant and current schema.
|
||||||
|
|
||||||
|
- Build: `make image-build` off the digest-pinned base
|
||||||
|
`python:3.12-slim@sha256:d764629c…`. Local manifest-list digest
|
||||||
|
`sha256:85e46ddf47b3ac0cdab620163a7b034027451dff286b63ecff6a2493d7a57ecc`.
|
||||||
|
**This is a local build digest, not a release digest** — the manifest still
|
||||||
|
carries `REPLACE_WITH_RELEASE_DIGEST` because pinning requires a push to
|
||||||
|
`forgejo.coulomb.social`, which needs registry credentials this session does
|
||||||
|
not hold.
|
||||||
|
- Runtime identity: `uid=10001(approval) gid=10001(approval)`, non-root as
|
||||||
|
required.
|
||||||
|
- Schema: image carries `LATEST_SCHEMA_VERSION = 3`, matching the migrated
|
||||||
|
store, and `Engine` tenant default `tenant:platform`.
|
||||||
|
- First-install migration (no prior DB): `migrate` then `verify` on a fresh
|
||||||
|
volume both report `schema_version: 3`, `schema_current: true`,
|
||||||
|
`integrity: ["ok"]`, `foreign_key_violations: 0`, `persistent: true`.
|
||||||
|
- Fail-closed configuration proven in the image, not only in tests:
|
||||||
|
`serve --production --db :memory:` refuses with "production requires a
|
||||||
|
persistent database"; `serve --production` on a real DB without audit
|
||||||
|
configuration refuses with "production requires authenticated audit
|
||||||
|
delivery".
|
||||||
|
- Manifest inputs: `kubectl apply --dry-run=client` passes for the namespace,
|
||||||
|
service, and StatefulSet with the new `--tenant tenant:platform`.
|
||||||
|
|
||||||
|
Scan gate NOT met: no scanner (`trivy`, `grype`, `docker scout`) is installed on
|
||||||
|
this workstation, so no vulnerability scan was run and none is claimed. The
|
||||||
|
inventory a scanner needs is the pinned base above plus
|
||||||
|
`cryptography==50.0.1`, `PyJWT==2.13.0`, `waitress==3.0.2`, `cffi==2.1.1`,
|
||||||
|
`pycparser==3.0`, `pip==25.0.1`.
|
||||||
|
|
||||||
|
T03 stays `wait`: still no release digest, no KeyCape/audit credentials, no
|
||||||
|
rollout, and no restart/restore evidence. Nothing here is a deploy.
|
||||||
|
|
||||||
## Wire outbox delivery and reconciliation
|
## Wire outbox delivery and reconciliation
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue