From 7e3446bb5c178a11fd4304288a7c1c9c7026f250 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Wed, 21 Oct 2026 20:52:19 +0200 Subject: [PATCH] offers without twins, step B: an environment with issuer.allowPreauthOffer drops the _offer twins vc_render.drop_offer_twins removes the preauth credential types and their bootstrapping documents from the base values for such an environment (gdc), so the issuer, the datastore scopes and the verifier carry one entry per type. Other environments keep their twins. DO NOT DEPLOY before an offer minted for the base scope has been redeemed (step A): offers that name a configuration id the issuer no longer publishes are unredeemable. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 13 +++++++ sirosid_core/render.py | 2 ++ sirosid_core/vc_render.py | 14 ++++++++ tests/test_verifier_credential_metadata.py | 42 ++++++++++++++++++---- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 98ac96b..2d0a71d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -733,6 +733,19 @@ the source's VCTM/MDDL file and ships no copy of it, and on **Fly** its document the apigw over it (`http: request body too large`, gdc 2026-10-20; `tests/test_fly_payload.py` guards it). So on Fly `make datastore-sync` is what adds the twins' documents, also after a storage clear. +**Without twins (SUNET/vc#840, per environment).** An environment that sets `issuer.allowPreauthOffer: true` (gdc, once its +apigw image carries #840) gets `allow_preauth_offer: true` on every login-gated datastore scope, and the chart DROPS the +twins for it (`vc_render.drop_offer_twins`, applied to the base values in `render.py`: the `_offer` credential types +and their bootstrapping documents), so the issuer's `credential_configurations_supported`, the datastore scopes and the +verifier lists carry one entry per type. `make datastore-offer SCOPE=` then mints against the base scope: the +command follows the RENDERED apigw config (`scripts/datastore.py offer_scope`): a flagged base scope is used as it is, an +unflagged one uses its twin, a named `_offer` is never rewritten, and a twin-style DOCUMENT_ID is mapped to the base +document id. The flag is OFF by default: a vc image with #840 refuses to start with it set unless `apigw.api_server.api_auth` +has `jwks.enable` (and the admin UI off) or `oidc.enable`, and an older image ignores it. Migration order on an +environment: flag + the new image, move minting, verify an offer for the base scope redeems, THEN drop the twins (an +offer naming a configuration id the issuer no longer publishes is unredeemable). Twin documents already in a live datastore +stay there until removed (`search --scope _offer`). + **The issuer and the verifier show the SUNET logo:** they are showing vc's built-in assets, which means `common.branding` never reached them. The chart points `logo_path`/`favicon_path` at `/branding-assets/*.png` and expects an diff --git a/sirosid_core/render.py b/sirosid_core/render.py index 34a8355..5bb7ee1 100644 --- a/sirosid_core/render.py +++ b/sirosid_core/render.py @@ -712,6 +712,8 @@ def render(target: str, chart_dir: Path, env: str = None, android_apk_key_hashes vc_render.inline_file_refs(base, resources.root) vc_render.resolve_branding_refs(base, resources.root) vc_render.expand_presentation_request_templates(base, resources.root) + if ((env_values or {}).get("issuer") or {}).get("allowPreauthOffer"): + vc_render.drop_offer_twins(base) base_path = out_dir / "values.base-inlined.yaml" base_path.write_text(yaml.dump(base, sort_keys=False)) diff --git a/sirosid_core/vc_render.py b/sirosid_core/vc_render.py index 77976b8..f75b86f 100644 --- a/sirosid_core/vc_render.py +++ b/sirosid_core/vc_render.py @@ -386,6 +386,20 @@ def is_twin_document(name: str) -> bool: return name.endswith(TWIN_DOCUMENT_SUFFIX) +def drop_offer_twins(values: dict) -> dict: + """An environment that sets issuer.allowPreauthOffer (SUNET/vc#840) needs no `_offer` twins: its base scopes can + be offered directly. Remove them from the base values - the credential types whose issuance.authProvider is + preauth and their bootstrapping documents - so the issuer's credential_configurations_supported, the datastore + scopes and the verifier's lists each carry one entry per type. Every other environment keeps them.""" + types = values.get("features", {}).get("credentialTypes") or {} + for name in [n for n, t in types.items() if (t.get("issuance") or {}).get("authProvider") == "preauth"]: + del types[name] + docs = ((values.get("issuer") or {}).get("datastoreImport") or {}).get("documents") or {} + for name in [n for n in docs if n.endswith("_offer")]: + del docs[name] + return values + + def drop_twin_imports(config: dict) -> dict: """Fly: the twins' documents are not shipped with the apigw, so they must not be imported either. diff --git a/tests/test_verifier_credential_metadata.py b/tests/test_verifier_credential_metadata.py index 409b04b..bea9e67 100644 --- a/tests/test_verifier_credential_metadata.py +++ b/tests/test_verifier_credential_metadata.py @@ -24,13 +24,12 @@ NEEDS = unittest.skipUnless(shutil.which("helm"), "needs helm") -def render_gdc(): +def render_blocks(values): tmp = Path(tempfile.mkdtemp()) try: - values = env_config.load_environment_config("gdc").get("values") or {} - render("fly", ROOT / "chart", env="gdc", resources=Resources(ROOT), out_dir=tmp / "o", secrets_dir=tmp / "s", - env_values=values, naming=Naming(env="gdc"), say=lambda *a, **k: None, mongo_password="x") - out = tmp / "o" / "fly-gdc" + render("fly", ROOT / "chart", env="t", resources=Resources(ROOT), out_dir=tmp / "o", secrets_dir=tmp / "s", + env_values=values, naming=Naming(env="t"), say=lambda *a, **k: None, mongo_password="x") + out = tmp / "o" / "fly-t" return {n: yaml.safe_load((out / f"{n}.yaml").read_text()) for n in ("vc-verifier", "vc-apigw", "vc-issuer")} finally: shutil.rmtree(tmp, ignore_errors=True) @@ -38,9 +37,11 @@ def render_gdc(): @NEEDS class VerifierHidesOfferTwins(unittest.TestCase): + """An environment WITHOUT issuer.allowPreauthOffer still has the twins (its images cannot offer a login-gated scope).""" + @classmethod def setUpClass(cls): - cls.cfg = render_gdc() + cls.cfg = render_blocks({}) cls.verifier = cls.cfg["vc-verifier"]["common"]["credential_metadata"] cls.apigw = cls.cfg["vc-apigw"]["common"]["credential_metadata"] @@ -65,7 +66,34 @@ def test_the_picker_sees_only_base_scopes(self): """The verifier's picker iterates common.credential_metadata wholesale; this is the count it renders.""" twins = [k for k in self.apigw if k.endswith("_offer")] self.assertEqual(len(self.verifier), len(self.apigw) - len(twins)) - self.assertEqual(len(self.verifier), 14, "gdc: 26 entries minus the 12 twins") + self.assertEqual(len(self.verifier), 14, "26 entries minus the 12 twins") + + +@NEEDS +class NoTwinsWhereOffersAreAllowed(unittest.TestCase): + """issuer.allowPreauthOffer (SUNET/vc#840): the base scopes are offered directly, so the chart carries one entry per + type everywhere - the issuer's credential_configurations_supported source included.""" + + @classmethod + def setUpClass(cls): + values = env_config.load_environment_config("gdc").get("values") or {} + cls.cfg = render_blocks(values) + + def test_no_twin_anywhere(self): + for service, cfg in self.cfg.items(): + self.assertEqual([k for k in cfg["common"]["credential_metadata"] if k.endswith("_offer")], [], service) + + def test_one_entry_per_type_and_nym_auth_is_still_issuable(self): + meta = self.cfg["vc-apigw"]["common"]["credential_metadata"] + self.assertEqual(len(meta), 14) + self.assertEqual(set(meta), set(self.cfg["vc-verifier"]["common"]["credential_metadata"])) + scopes = self.cfg["vc-apigw"]["apigw"]["data_sources"]["datastore"]["scopes"] + self.assertTrue(scopes["nym_auth"]["allow_preauth_offer"]) + self.assertEqual([k for k in scopes if k.endswith("_offer")], []) + + def test_the_twins_documents_are_not_imported(self): + files = self.cfg["vc-apigw"]["apigw"]["data_sources"]["datastore"].get("import", {}).get("file_paths", []) + self.assertEqual([f for f in files if "_offer" in f], []) if __name__ == "__main__":