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
3 changes: 3 additions & 0 deletions chart/templates/04-issuer.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
6 changes: 6 additions & 0 deletions environments/gdc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<type>_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=<type>_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
Expand Down
15 changes: 15 additions & 0 deletions fixtures/vc-config-golden/accepted-diffs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <type>_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 <type>_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 <type>_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 <type>_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 <type>_offer twin is needed"
31 changes: 24 additions & 7 deletions scripts/datastore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [])
Expand Down Expand Up @@ -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
`<type>_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.
Expand All @@ -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 `<type>_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:
Expand Down
12 changes: 6 additions & 6 deletions tests/golden/fly_up.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
83 changes: 83 additions & 0 deletions tests/test_offer_without_twins.py
Original file line number Diff line number Diff line change
@@ -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 `<type>_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()
8 changes: 8 additions & 0 deletions values-base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<type>_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
Expand Down