Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<type>_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=<type>` 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 `<type>_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 <type>_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
Expand Down
2 changes: 2 additions & 0 deletions sirosid_core/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
14 changes: 14 additions & 0 deletions sirosid_core/vc_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<type>_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.

Expand Down
42 changes: 35 additions & 7 deletions tests/test_verifier_credential_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,24 @@
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)


@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"]

Expand All @@ -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__":
Expand Down