From d5c9c68022ffa97b598fb7f0ae95033e0e81e537 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Wed, 21 Oct 2026 20:41:51 +0200 Subject: [PATCH] offers without twins, step A: allow_preauth_offer on the base scopes (per environment) and datastore-offer follows the rendered config SUNET/vc#840. issuer.allowPreauthOffer (default off; on for gdc) renders allow_preauth_offer on every login-gated datastore scope. scripts/datastore.py mints against the base scope when the rendered config flags it, else the _offer twin; a named twin is never rewritten. The twins stay; step B removes them after an offer for the base scope has been redeemed. Co-Authored-By: Claude Sonnet 5.5 --- chart/templates/04-issuer.yaml | 3 + environments/gdc.yaml | 6 ++ fixtures/vc-config-golden/accepted-diffs.yaml | 15 ++++ scripts/datastore.py | 31 +++++-- tests/golden/fly_up.json | 12 +-- tests/test_offer_without_twins.py | 83 +++++++++++++++++++ values-base.yaml | 8 ++ 7 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 tests/test_offer_without_twins.py diff --git a/chart/templates/04-issuer.yaml b/chart/templates/04-issuer.yaml index eff07379..f69c0d3c 100644 --- a/chart/templates/04-issuer.yaml +++ b/chart/templates/04-issuer.yaml @@ -192,6 +192,9 @@ apigw: {{- range $id, $data := . }} {{ $id | quote }}: auth_provider: {{ $data.issuance.authProvider | quote }} + {{- if and $.Values.issuer.allowPreauthOffer (ne $data.issuance.authProvider "preauth") }} + allow_preauth_offer: true + {{- end }} {{- if and $.Values.issuer.datastoreAuthenticSource (ne $data.issuance.authProvider "preauth") }} authentic_source: {{ $.Values.issuer.datastoreAuthenticSource | quote }} {{- end }} diff --git a/environments/gdc.yaml b/environments/gdc.yaml index 6fe71ad9..99e090fc 100644 --- a/environments/gdc.yaml +++ b/environments/gdc.yaml @@ -344,6 +344,12 @@ rical_root_cert: fixtures/trusted-roots/geneva2026-rical-root.pem # without a code change anywhere else. values: issuer: + # SUNET/vc#840: mint pre-authorized offers for the BASE scopes (no `_offer` twin needed). Needs an apigw image + # that carries #840 - an older one ignores the key, so minting for a base scope fails with invalid_scope there and + # `make datastore-offer SCOPE=_offer` (the twin) keeps working - and a startup guard in that image requires + # api_auth.jwks (or oidc) AND the admin UI off or oidc enabled. gdc: api_auth.jwks.enable true and + # admin_ui_enable false (its default), so the guard passes. + allowPreauthOffer: true apigw: # nym_auth's seed-backed ssc and its delivery (SUNET/vc#793/#821; the integration build pinned above): # the issuer derives each person's seed from a per-environment secret (generated once, PERMANENT) and the diff --git a/fixtures/vc-config-golden/accepted-diffs.yaml b/fixtures/vc-config-golden/accepted-diffs.yaml index 8689e5d3..5939ae0e 100644 --- a/fixtures/vc-config-golden/accepted-diffs.yaml +++ b/fixtures/vc-config-golden/accepted-diffs.yaml @@ -290,3 +290,18 @@ fly-gdc: - path: verifier.nym_auth kind: added reason: "gdc verifies nym_auth pseudonym proofs (SUNET/vc#832; environments/gdc.yaml verifier.extraConfig). Needs a zknative verifier image with the circuit artifact baked in (dockerfiles/verifier-nymauth.Dockerfile)." + - path: apigw.data_sources.datastore.scopes.diploma.allow_preauth_offer + kind: added + reason: "gdc sets issuer.allowPreauthOffer (SUNET/vc#840): an offer can be minted for the base scope, so no _offer twin is needed" + - path: apigw.data_sources.datastore.scopes.mdl.allow_preauth_offer + kind: added + reason: "gdc sets issuer.allowPreauthOffer (SUNET/vc#840): an offer can be minted for the base scope, so no _offer twin is needed" + - path: apigw.data_sources.datastore.scopes.mdl_zk4.allow_preauth_offer + kind: added + reason: "gdc sets issuer.allowPreauthOffer (SUNET/vc#840): an offer can be minted for the base scope, so no _offer twin is needed" + - path: apigw.data_sources.datastore.scopes.pid_1_8.allow_preauth_offer + kind: added + reason: "gdc sets issuer.allowPreauthOffer (SUNET/vc#840): an offer can be minted for the base scope, so no _offer twin is needed" + - path: apigw.data_sources.datastore.scopes.pid_mdoc.allow_preauth_offer + kind: added + reason: "gdc sets issuer.allowPreauthOffer (SUNET/vc#840): an offer can be minted for the base scope, so no _offer twin is needed" diff --git a/scripts/datastore.py b/scripts/datastore.py index 207c9617..afd43d29 100644 --- a/scripts/datastore.py +++ b/scripts/datastore.py @@ -57,6 +57,7 @@ def __init__(self, env: str = None, url: str = None): where = f"make fly-up ENV={env}" if env else "make up VC=yes" sys.exit(f"{config_path} or {self.key} missing - render this target first ({where})") config = yaml.safe_load(config_path.read_text()) + self.datastore_scopes = (((config["apigw"].get("data_sources") or {}).get("datastore") or {}).get("scopes")) or {} api_server = config["apigw"]["api_server"] self.auth = api_server["api_auth"]["jwks"] self.subject = spocp_subject(api_server["api_auth"].get("rules") or []) @@ -103,6 +104,25 @@ def spocp_subject(rules: list) -> str: sys.exit("no SPOCP rule with a concrete subject for /api/v1/* in the rendered api_auth block") +def offer_scope(scopes: dict, scope: str, document_id: str = None) -> tuple: + """The scope (and document id) an offer is minted against. + + vc mints an offer for a scope declared auth_provider: preauth (SUNET/vc requirePreAuthScope) or, from SUNET/vc#840, + a login-gated scope marked `allow_preauth_offer`. `scopes` is the RENDERED apigw datastore scope map, so this + follows what the environment's config says: a base scope with the flag is used as it is; otherwise the chart's + `_offer` twin is (`SCOPE=pid_1_8` means `pid_1_8_offer`); naming a twin always selects it. The twins' + documents carry `_offer_` in their ids (document_id_nym_auth_offer_alice-001); against the base scope the same + request names document_id_nym_auth_alice-001. + """ + if scope.endswith("_offer"): + return scope, document_id + if (scopes.get(scope) or {}).get("allow_preauth_offer"): + if document_id: + document_id = document_id.replace(f"_{scope}_offer_", f"_{scope}_", 1) + return scope, document_id + return scope + "_offer", document_id + + def offer(target: "Target", scope: str, document_id: str = None, authentic_source: str = None, qr: bool = False) -> None: """A pre-authorized credential offer for one datastore document. @@ -113,16 +133,13 @@ def offer(target: "Target", scope: str, document_id: str = None, authentic_sourc PID-authenticated types too - the document is chosen here rather than resolved from a presented PID. """ - # vc mints an offer only for a scope declared auth_provider: preauth (SUNET/vc requirePreAuthScope), and a - # scope has one provider, so the chart declares `_offer` twins; `SCOPE=pid_1_8` means `pid_1_8_offer`. - if not scope.endswith("_offer"): - scope += "_offer" + scope, document_id = offer_scope(target.datastore_scopes, scope, document_id) docs = target.request("GET", "/api/v1/datastore/search", query={"scope": scope, "limit": 200})["data"] if not docs: - sys.exit(f"no documents in scope {scope} - only types with an `_offer` twin in values-base.yaml can be " - f"offered; `search --scope {scope}` shows what is there (an existing environment needs " - f"`make datastore-sync` to get the twin's documents)") + sys.exit(f"no documents in scope {scope} - only datastore-sourced types can be offered (a type with an " + f"`_offer` twin, or a base scope marked allow_preauth_offer); `search --scope {scope}` shows what " + f"is there (an existing environment needs `make datastore-sync` to get the twin's documents)") if document_id: match = [d for d in docs if d["meta"]["document_id"] == document_id] if not match: diff --git a/tests/golden/fly_up.json b/tests/golden/fly_up.json index 39e3ccb5..db471235 100644 --- a/tests/golden/fly_up.json +++ b/tests/golden/fly_up.json @@ -168,7 +168,7 @@ "pdp.yaml": "00d8aee1ad455620", "pres-reqs/pres-reqs.yaml": "b0f8dff4515f79ce", "values.api-auth.yaml": "(volatile)", - "values.base-inlined.yaml": "f8a9f16b225f3572", + "values.base-inlined.yaml": "f1357d2024e2679d", "values.generated.yaml": "8d0085e00604c29a", "vc-apigw.fly.toml": "62a74456aeda6700", "vc-apigw.yaml": "11833e4e0cfe44ee", @@ -347,7 +347,7 @@ "pdp.yaml": "ec43dcc3736a4a3c", "pres-reqs/pres-reqs.yaml": "b0f8dff4515f79ce", "values.api-auth.yaml": "(volatile)", - "values.base-inlined.yaml": "f8a9f16b225f3572", + "values.base-inlined.yaml": "f1357d2024e2679d", "values.generated.yaml": "a24b0a8f6522c6fe", "vc-apigw.fly.toml": "62a74456aeda6700", "vc-apigw.yaml": "11833e4e0cfe44ee", @@ -526,7 +526,7 @@ "pdp.yaml": "941ff7f896171bde", "pres-reqs/pres-reqs.yaml": "b0f8dff4515f79ce", "values.api-auth.yaml": "(volatile)", - "values.base-inlined.yaml": "f8a9f16b225f3572", + "values.base-inlined.yaml": "f1357d2024e2679d", "values.environment.yaml": "a68fa91f82976efc", "values.generated.yaml": "96eb4c7dcadd8472", "values.toggles.yaml": "25be7b6793eda8a7", @@ -684,7 +684,7 @@ "pdp.yaml": "ec43dcc3736a4a3c", "pres-reqs/pres-reqs.yaml": "b0f8dff4515f79ce", "values.api-auth.yaml": "(volatile)", - "values.base-inlined.yaml": "f8a9f16b225f3572", + "values.base-inlined.yaml": "f1357d2024e2679d", "values.generated.yaml": "a24b0a8f6522c6fe", "vc-apigw.fly.toml": "62a74456aeda6700", "vc-apigw.yaml": "11833e4e0cfe44ee", @@ -1025,7 +1025,7 @@ "pdp.yaml": "077060a4843bdbc6", "pres-reqs/pres-reqs.yaml": "b0f8dff4515f79ce", "values.api-auth.yaml": "(volatile)", - "values.base-inlined.yaml": "f8a9f16b225f3572", + "values.base-inlined.yaml": "f1357d2024e2679d", "values.generated.yaml": "588f06bf89465652", "vc-apigw.fly.toml": "62a74456aeda6700", "vc-apigw.yaml": "7acf0a61335c1c9c", @@ -1204,7 +1204,7 @@ "pdp.yaml": "b5248c0c2bf703b3", "pres-reqs/pres-reqs.yaml": "b0f8dff4515f79ce", "values.api-auth.yaml": "(volatile)", - "values.base-inlined.yaml": "f8a9f16b225f3572", + "values.base-inlined.yaml": "f1357d2024e2679d", "values.generated.yaml": "656d3d5ad9b7759c", "vc-apigw.fly.toml": "62a74456aeda6700", "vc-apigw.yaml": "11833e4e0cfe44ee", diff --git a/tests/test_offer_without_twins.py b/tests/test_offer_without_twins.py new file mode 100644 index 00000000..7b06e8d9 --- /dev/null +++ b/tests/test_offer_without_twins.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Offers without twins (SUNET/vc#840 `allow_preauth_offer`): the chart renders the flag only where an environment asks +for it, never on a preauth scope, and `make datastore-offer` follows the rendered config - the base scope when it is +flagged, the `_offer` twin otherwise, and a named twin always.""" +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts")) + +import datastore # noqa: E402 +import env_config # noqa: E402 +from sirosid_core.naming import Naming # noqa: E402 +from sirosid_core.render import render # noqa: E402 +from sirosid_core.resources import Resources # noqa: E402 + +NEEDS = unittest.skipUnless(shutil.which("helm"), "needs helm") + + +def render_scopes(env_values): + tmp = Path(tempfile.mkdtemp()) + try: + render("fly", ROOT / "chart", env="t", resources=Resources(ROOT), out_dir=tmp / "o", secrets_dir=tmp / "s", + env_values=env_values, naming=Naming(env="t"), say=lambda *a, **k: None, mongo_password="x") + cfg = yaml.safe_load((tmp / "o" / "fly-t" / "vc-apigw.yaml").read_text()) + return cfg["apigw"]["data_sources"]["datastore"]["scopes"] + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +@NEEDS +class RenderedFlag(unittest.TestCase): + def test_off_by_default_because_an_image_without_the_guard_ignores_it_and_one_with_it_may_refuse_to_start(self): + scopes = render_scopes({}) + self.assertEqual([k for k, v in scopes.items() if "allow_preauth_offer" in v], []) + + def test_on_for_every_login_gated_scope_and_never_for_a_preauth_one(self): + scopes = render_scopes({"issuer": {"allowPreauthOffer": True}}) + flagged = {k for k, v in scopes.items() if v.get("allow_preauth_offer")} + gated = {k for k, v in scopes.items() if v["auth_provider"] != "preauth"} + self.assertEqual(flagged, gated) + self.assertIn("nym_auth", flagged) + self.assertTrue(all("allow_preauth_offer" not in v for v in scopes.values() if v["auth_provider"] == "preauth")) + + def test_gdc_turns_it_on(self): + values = env_config.load_environment_config("gdc").get("values") or {} + self.assertTrue(values["issuer"]["allowPreauthOffer"]) + + +class OfferScope(unittest.TestCase): + SCOPES = {"nym_auth": {"auth_provider": "openid4vp", "allow_preauth_offer": True}, + "nym_auth_offer": {"auth_provider": "preauth"}, + "mdl": {"auth_provider": "oidc"}, "mdl_offer": {"auth_provider": "preauth"}} + + def test_a_flagged_base_scope_is_used_as_it_is(self): + self.assertEqual(datastore.offer_scope(self.SCOPES, "nym_auth"), ("nym_auth", None)) + + def test_an_unflagged_base_scope_falls_back_to_its_twin(self): + self.assertEqual(datastore.offer_scope(self.SCOPES, "mdl"), ("mdl_offer", None)) + + def test_a_named_twin_is_never_rewritten(self): + self.assertEqual(datastore.offer_scope(self.SCOPES, "nym_auth_offer", "document_id_nym_auth_offer_alice-001"), + ("nym_auth_offer", "document_id_nym_auth_offer_alice-001")) + + def test_the_twin_style_document_id_names_the_base_document_against_a_flagged_scope(self): + self.assertEqual(datastore.offer_scope(self.SCOPES, "nym_auth", "document_id_nym_auth_offer_alice-001"), + ("nym_auth", "document_id_nym_auth_alice-001")) + self.assertEqual(datastore.offer_scope(self.SCOPES, "nym_auth", "document_id_nym_auth_alice-001"), + ("nym_auth", "document_id_nym_auth_alice-001")) + + def test_an_environment_without_the_flag_keeps_minting_against_the_twin(self): + self.assertEqual(datastore.offer_scope({}, "nym_auth", "document_id_nym_auth_offer_alice-001"), + ("nym_auth_offer", "document_id_nym_auth_offer_alice-001")) + + +if __name__ == "__main__": + unittest.main() diff --git a/values-base.yaml b/values-base.yaml index a68bab17..8091dfd5 100644 --- a/values-base.yaml +++ b/values-base.yaml @@ -471,6 +471,14 @@ issuer: # under (fixtures/vc-bootstrapping/identity_mappings.json: mini-oidc). Older vc images read config with a plain # yaml.Unmarshal and ignore the key, so this is safe as the default for every environment. datastoreAuthenticSource: mini-oidc + # SUNET/vc#840 (`allow_preauth_offer`): let the admin mint endpoint make a pre-authorized offer for a LOGIN-GATED + # datastore scope, so a type needs no `_offer` twin. Rendered on every datastore scope whose auth_provider is + # not preauth. OFF by default: a vc image with #840 REFUSES TO START with the flag set unless + # apigw.api_server.api_auth configures jwks or oidc (and, with the admin UI on, oidc), and an older image ignores + # it silently (so minting for the base scope fails with invalid_scope there). Environments whose apigw image carries + # #840 and satisfies that guard turn it on (environments/gdc.yaml). `make datastore-offer` then mints against the + # base scope; see scripts/datastore.py. + allowPreauthOffer: false # One file per datastore-sourced scope, keyed by holder. Each scope has at # least one document covering only that type's mandatory claims and exactly # one whose `meta.document_id` ends in `-full`, covering mandatory AND every