From f87f4e5e4d5ed8ad0d41bf5e16bbb003ff8e6b78 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 28 Jun 2026 12:44:55 +0200 Subject: [PATCH] refactor(catalog): explicit org/repo terminology; npm targets coulomb Gitea registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea's "project/package/release" terms are overloaded, so the catalog now uses the most explicit words: - org = coulomb (the Gitea organisation) - repo = whynot-design (the Gitea repository/product) — not an org, not a scope - npm scope @whynot and package @whynot/design are distinct from both Changes: - catalog schema: replace conflated `owner` with required `org` + `repo`; `owner` is now a derived `org/repo` slug property - npm-config delivery is data-driven: registry + scope live in delivery_config.npm and are validated; engine no longer hardcodes a registry - exec delivery writes `:registry=` + scoped `:_authToken` for the configured Gitea registry (token still env-expanded, never written to disk) - pilot lane points at https://gitea.coulomb.social/api/packages/coulomb/npm/, scope @whynot, KV path coulomb/whynot-design/npm/publish - npm-publish-demo uses @whynot scope so dry-run resolves the Gitea registry - docs: terminology table; routing owner shown as coulomb/whynot-design - tests: org/repo required, npm-config validation, registry authkey mapping Co-Authored-By: Claude Opus 4.8 --- catalog/example-build-test-token.yaml | 3 ++- catalog/whynot-design-npm-publish.yaml | 28 ++++++++++++++++----- docs/cli.md | 15 ++++++++++++ docs/ops-warden-routing-contract.md | 2 +- scripts/npm-publish-demo.sh | 4 ++- src/secrets_engine/catalog.py | 34 ++++++++++++++++++++++++-- src/secrets_engine/exec_delivery.py | 32 ++++++++++++++++++++---- tests/test_catalog.py | 28 ++++++++++++++++++++- tests/test_exec_delivery.py | 24 ++++++++++++++++++ 9 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 tests/test_exec_delivery.py diff --git a/catalog/example-build-test-token.yaml b/catalog/example-build-test-token.yaml index 59d2a29..7e3ae37 100644 --- a/catalog/example-build-test-token.yaml +++ b/catalog/example-build-test-token.yaml @@ -1,7 +1,8 @@ # Example BUILD-stage lane. Demonstrates that build entries can be looser: # generated test values are allowed and no production decision is required. id: example-build-test-token -owner: platform-ci +org: coulomb +repo: platform-ci stage: build description: >- Throwaway generated credential for build-stage integration tests. May be diff --git a/catalog/whynot-design-npm-publish.yaml b/catalog/whynot-design-npm-publish.yaml index 0a570e9..214a01d 100644 --- a/catalog/whynot-design-npm-publish.yaml +++ b/catalog/whynot-design-npm-publish.yaml @@ -1,17 +1,25 @@ # whynot-design npm publish token — the MVP pilot lane. # This file is NON-SECRET. It describes where the token lives in OpenBao and how # it may be consumed. The token VALUE never appears here. +# +# Terminology (Gitea is overloaded — we use the most explicit words): +# org = coulomb the Gitea organisation +# repo = whynot-design the Gitea repository / product (NOT an org, NOT a scope) +# npm package = @whynot/design published to the coulomb Gitea npm registry +# "@whynot" is the npm *scope*; it is neither the org nor the repo name. id: whynot-design-npm-publish -owner: whynot-design +org: coulomb +repo: whynot-design stage: prod description: >- - npm automation token used to publish the whynot-design package. Delivered to + npm automation token used to publish the @whynot/design package from the + coulomb/whynot-design repo to the coulomb Gitea npm registry. Delivered to `npm publish` via an exec-time temporary npm config; never printed or exported into the parent shell. -# OpenBao KV v2 location of the secret material. +# OpenBao KV v2 location of the secret material (org/repo-scoped path). mount: secret -path: whynot-design/npm/publish +path: coulomb/whynot-design/npm/publish # Field(s) inside the KV entry. The publish token is stored under this key. fields: @@ -21,14 +29,22 @@ fields: consumers: - name: whynot-design-ci auth: approle # bound OpenBao auth method - claim: "role:whynot-design-publish" - purpose: "publish whynot-design npm package from CI" + claim: "repo:coulomb/whynot-design" + purpose: "publish @whynot/design to the coulomb Gitea npm registry from CI" # How the value may leave OpenBao. npm-config = temp .npmrc for the child only. delivery_modes: - npm-config - read-check +# npm-specific delivery target. The registry/scope live here as catalog DATA so +# the engine never hardcodes a registry. Matches coulomb/whynot-design/.npmrc. +delivery_config: + npm: + registry: "https://gitea.coulomb.social/api/packages/coulomb/npm/" + scope: "@whynot" + package: "@whynot/design" + # Privileged actions on this lane require an approved decision/CCR. approval: model: decision diff --git a/docs/cli.md b/docs/cli.md index 75b86e4..3a59e45 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,5 +1,20 @@ # secrets-engine CLI +## Terminology (Gitea is overloaded — be explicit) + +| Term we use | Means | Example | Not to be confused with | +| --- | --- | --- | --- | +| **org** | the Gitea organisation | `coulomb` | the npm scope | +| **repo** | the Gitea repository / product | `whynot-design` | an org; a Gitea "project" board | +| **npm scope** | the `@`-prefix npm name | `@whynot` | the org or the repo | +| **npm package** | the published artifact | `@whynot/design` | the repo it's built from | +| **lane / catalog id** | a secrets-engine secret lane | `whynot-design-npm-publish` | — | + +A catalog entry carries `org` + `repo` explicitly (never a bare "owner"), and the +npm registry/scope live in `delivery_config.npm` as data — the engine never +hardcodes a registry. The pilot publishes `@whynot/design` from the +`coulomb/whynot-design` repo to `https://gitea.coulomb.social/api/packages/coulomb/npm/`. + ## Install ```bash diff --git a/docs/ops-warden-routing-contract.md b/docs/ops-warden-routing-contract.md index 7807848..f7e0231 100644 --- a/docs/ops-warden-routing-contract.md +++ b/docs/ops-warden-routing-contract.md @@ -16,7 +16,7 @@ secrets-engine route --json ```json { "catalog_id": "whynot-design-npm-publish", - "owner": "whynot-design", + "owner": "coulomb/whynot-design", "stage": "prod", "decision_status": "resolved", "decision_ref": "whynot-design-npm-publish", diff --git a/scripts/npm-publish-demo.sh b/scripts/npm-publish-demo.sh index 21c7905..d5a0ba1 100755 --- a/scripts/npm-publish-demo.sh +++ b/scripts/npm-publish-demo.sh @@ -48,9 +48,11 @@ echo " lane ready: $(secrets-engine route whynot-design-npm-publish --json | echo "### create a scratch npm package" mkdir -p "$PKG" +# Scratch package uses the @whynot scope so it maps to the coulomb Gitea npm +# registry that secrets-engine injects (same scope as the real @whynot/design). cat > "$PKG/package.json" <<'EOF' { - "name": "@whynot-design/se-pilot-scratch", + "name": "@whynot/se-pilot-scratch", "version": "0.0.1", "description": "Scratch package proving secrets-engine exec -> npm publish wiring (dry-run).", "license": "MIT", diff --git a/src/secrets_engine/catalog.py b/src/secrets_engine/catalog.py index fd20c17..1a9a118 100644 --- a/src/secrets_engine/catalog.py +++ b/src/secrets_engine/catalog.py @@ -22,7 +22,8 @@ VALID_APPROVAL_MODELS = ("decision", "ccr", "dual-control", "bootstrap-only") REQUIRED_FIELDS = ( "id", - "owner", + "org", + "repo", "stage", "mount", "path", @@ -40,7 +41,11 @@ REQUIRED_FIELDS = ( @dataclass(frozen=True) class CatalogEntry: id: str - owner: str + # Gitea coordinates, kept explicit to avoid the overloaded word "project". + # org = the Gitea organisation (e.g. "coulomb") + # repo = the Gitea repository / product (e.g. "whynot-design") + org: str + repo: str stage: str mount: str path: str @@ -52,9 +57,20 @@ class CatalogEntry: rotation: dict[str, Any] deactivation: dict[str, Any] audit: dict[str, Any] + delivery_config: dict[str, Any] = field(default_factory=dict) description: str = "" raw: dict[str, Any] = field(default_factory=dict) + @property + def owner(self) -> str: + """The lane's owning repo as the explicit ``org/repo`` slug.""" + return f"{self.org}/{self.repo}" + + @property + def npm(self) -> dict[str, Any]: + """npm delivery config (registry, scope, package) when present.""" + return self.delivery_config.get("npm", {}) + @property def kv_data_path(self) -> str: """Full KV v2 *data* path used for read/write of the value.""" @@ -123,6 +139,20 @@ def validate_entry(data: dict[str, Any], *, source: str = "") -> Catalog f"allowed {VALID_APPROVAL_MODELS}" ) + # npm-config delivery must declare WHERE it publishes (registry + scope), so + # the registry is catalog data, never hardcoded in the engine. + if "npm-config" in modes: + npm = (data.get("delivery_config") or {}).get("npm") + if not isinstance(npm, dict) or not npm.get("registry") or not npm.get("scope"): + raise CatalogError( + f"{source}: npm-config delivery requires " + "delivery_config.npm.registry and .scope" + ) + if not str(npm["registry"]).startswith(("http://", "https://")): + raise CatalogError( + f"{source}: delivery_config.npm.registry must be an http(s) URL" + ) + # A path must never leak a value through a field name suggesting inline secrets. if any(looks_secret(k) and data.get(k) for k in ("value", "secret", "token", "password")): raise CatalogError(f"{source}: catalog entries must not contain secret values") diff --git a/src/secrets_engine/exec_delivery.py b/src/secrets_engine/exec_delivery.py index bbbeb55..eed1aa7 100644 --- a/src/secrets_engine/exec_delivery.py +++ b/src/secrets_engine/exec_delivery.py @@ -49,16 +49,30 @@ def _fetch_value(client: OpenBaoClient, entry: CatalogEntry, field: str) -> str: return data[field] +def _registry_authkey(registry: str) -> str: + """Turn a registry URL into the npm `//host/path/:_authToken` config key.""" + no_scheme = registry.split("://", 1)[-1] + if not no_scheme.endswith("/"): + no_scheme += "/" + return "//" + no_scheme + + @contextmanager -def _npm_userconfig(token: str) -> Iterator[Path]: - """Write a mode-0600 temp .npmrc, yield its path, delete it unconditionally.""" +def _npm_userconfig(registry: str, scope: str) -> Iterator[Path]: + """Write a mode-0600 temp .npmrc for the configured registry/scope. + + The token itself is NOT written to the file — npm expands ${SE_NPM_TOKEN} + from the child environment, so the value never touches disk. + """ fd, name = tempfile.mkstemp(prefix="se-npmrc-", suffix=".ini") path = Path(name) try: os.fchmod(fd, 0o600) - # Registry-scoped auth token; child npm reads this via NPM_CONFIG_USERCONFIG. + authkey = _registry_authkey(registry) with os.fdopen(fd, "w") as fh: - fh.write("//registry.npmjs.org/:_authToken=${SE_NPM_TOKEN}\n") + # e.g. @whynot:registry=https://gitea.coulomb.social/api/packages/coulomb/npm/ + fh.write(f"{scope}:registry={registry}\n") + fh.write(f"{authkey}:_authToken=${{SE_NPM_TOKEN}}\n") yield path finally: try: @@ -110,7 +124,15 @@ def exec_with_secret( child_env = dict(os.environ) if mode == "npm-config": - with _npm_userconfig(value) as npmrc: + npm = entry.npm + registry = npm.get("registry", "") + scope = npm.get("scope", "") + if not registry or not scope: + raise DeliveryError( + f"lane '{entry.id}' npm-config delivery needs " + "delivery_config.npm.registry and .scope" + ) + with _npm_userconfig(registry, scope) as npmrc: child_env["NPM_CONFIG_USERCONFIG"] = str(npmrc) child_env["SE_NPM_TOKEN"] = value rc = _spawn(command, child_env, value) diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 757fb17..d475c6f 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -8,7 +8,8 @@ from secrets_engine.errors import CatalogError VALID = { "id": "test-lane", - "owner": "team", + "org": "coulomb", + "repo": "team-repo", "stage": "test", "mount": "secret", "path": "test/team/thing", @@ -28,6 +29,26 @@ def test_valid_entry_parses(): assert e.id == "test-lane" assert e.policy_name == "se-test-test-lane" assert not e.approval_required() + # owner is the explicit org/repo slug, not a bare name + assert e.owner == "coulomb/team-repo" + + +@pytest.mark.parametrize("field", ["org", "repo"]) +def test_missing_org_or_repo_rejected(field): + data = copy.deepcopy(VALID) + data.pop(field) + with pytest.raises(CatalogError): + validate_entry(data) + + +def test_npm_config_requires_registry_and_scope(): + data = copy.deepcopy(VALID) + data["delivery_modes"] = ["npm-config"] + with pytest.raises(CatalogError): + validate_entry(data) # no delivery_config.npm + data["delivery_config"] = {"npm": {"registry": "https://x/", "scope": "@s"}} + e = validate_entry(data) + assert e.npm["scope"] == "@s" @pytest.mark.parametrize("field", ["stage", "mount", "path", "fields", "approval", "delivery_modes"]) @@ -72,6 +93,11 @@ def test_repo_catalog_loads_and_has_pilot(): pilot = entries["whynot-design-npm-publish"] assert pilot.stage == "prod" assert pilot.approval_required() + # org=coulomb, repo=whynot-design (not conflated); npm targets Gitea registry + assert pilot.org == "coulomb" + assert pilot.repo == "whynot-design" + assert pilot.npm["registry"].startswith("https://gitea.coulomb.social/") + assert pilot.npm["scope"] == "@whynot" # build/test/prod stage separation is representable stages = {e.stage for e in entries.values()} assert {"build", "prod"} <= stages diff --git a/tests/test_exec_delivery.py b/tests/test_exec_delivery.py new file mode 100644 index 0000000..8fd5c91 --- /dev/null +++ b/tests/test_exec_delivery.py @@ -0,0 +1,24 @@ +from secrets_engine.exec_delivery import _npm_userconfig, _registry_authkey + + +def test_registry_authkey_strips_scheme_and_trails_slash(): + assert ( + _registry_authkey("https://gitea.coulomb.social/api/packages/coulomb/npm/") + == "//gitea.coulomb.social/api/packages/coulomb/npm/" + ) + # missing trailing slash is added + assert _registry_authkey("https://host/api/npm") == "//host/api/npm/" + + +def test_npm_userconfig_writes_registry_and_token_ref_not_value(): + registry = "https://gitea.coulomb.social/api/packages/coulomb/npm/" + with _npm_userconfig(registry, "@whynot") as path: + body = path.read_text() + assert f"@whynot:registry={registry}" in body + # token is referenced via env expansion, never written literally + assert "${SE_NPM_TOKEN}" in body + assert "//gitea.coulomb.social/api/packages/coulomb/npm/:_authToken" in body + # file is mode 0600 + assert (path.stat().st_mode & 0o077) == 0 + # cleaned up on context exit + assert not path.exists()