refactor(catalog): explicit org/repo terminology; npm targets coulomb Gitea registry
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 `<scope>:registry=<url>` + 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 <noreply@anthropic.com>
This commit is contained in:
parent
147cf8acda
commit
f87f4e5e4d
9 changed files with 153 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
docs/cli.md
15
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
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ secrets-engine route <catalog-id> --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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = "<memory>") -> 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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
24
tests/test_exec_delivery.py
Normal file
24
tests/test_exec_delivery.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue