rfc(pnpr): merge the namespace and the ACL into a per-registry packages: map#17
Conversation
…es: map A name-keyed global ACL contradicts the model's central claim that package names are not global identifiers, and it splits authorization across two places against the authorize-at-the-concrete-source rule. Scoped to the registry, the ACL turns out to be the same declaration as the namespace: one ordered map whose keys say what the registry serves (replacing the interim patterns: field) and whose values say who may read and write it (absorbing the top-level packages: block, which is removed — a config still containing one fails startup, since silently dropping a previously enforced ACL would be a security regression).
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
PR Summary by QodoRFC: replace global ACL + namespace patterns with per-registry
AI Description
Diagram
High-Level Assessment
Files changed (1)
|
YAML mappings are formally unordered — parsers preserve document order, but a formatter, yq round-trip, or JSON conversion may not, and key order must not decide which access rule applies. The restricted pattern language makes specificity total within one registry: the keys matching any name form a strict chain (exact > @scope/* > @*/* > **) with at most one key per tier, so every name has exactly one winning entry, no entry can be dead, and no shadow validation is needed inside a registry. Router sources: are unchanged — a YAML list is syntactically ordered, so first-claiming-source-in-order stays, with its validation intact. Prior art added: npm's own scoped-registry precedence, Go 1.22 ServeMux, longest-prefix match, DNS wildcard precedence, and nginx's prefix/regex split as the decidability boundary.
…s: maps (RFC pnpm/rfcs#17) (#12787) * feat(pnpr): merge the namespace and the ACL into per-registry packages: maps Implements the revised model from pnpm/rfcs#17, replacing the interim patterns:-plus-global-ACL shape outright (pre-1.0, no compatibility mode). Every concrete registry now declares one packages: map whose keys are its namespace (the former patterns: list) and whose values are the per-package access/publish/unpublish rules (the former top-level packages: block, scoped to the one registry that serves the name). One declaration routes, filters, and authorizes. The registry-level access: is the default an entry's omitted fields fall back to; publish defaults to $authenticated and unpublish to nobody. Selection is by specificity, not key order: an exact name beats @scope/* beats @*/* beats **. YAML mappings are formally unordered, so a formatter or yq round-trip must not change which rule applies; the restricted pattern language makes the winner unique (at most one matching key per tier), no entry can be dead, and a duplicate key is the only within-registry error. Router sources: stay an ordered list with their unreachable/shadowed-claim validation intact. The removed top-level packages: block is a startup error naming the per-registry replacement — it used to enforce access, so dropping it like an unknown verdaccio key would silently open previously gated packages on upgrade. public: true keeps meaning the upstream fetch (no credential, no headers, no registry-level access default), but per-package access rules are now permitted on a public upstream — they gate who may read the name through pnpr, not how pnpr fetches it. publish/unpublish values on any upstream are rejected: no write can land there. Hosted denials answer by tier, preserving both prior behaviors: a caller the registry-level default denies is masked with 404 for every name (a blanket-private registry never reveals which names exist, even explicitly ruled ones), while an explicit entry denying a caller the registry itself admits rejects loudly — 401 anonymous / 403 authenticated — so clients can prompt for credentials (the registry-mock needs-auth contract). The resolver's route classification resolves path-less fetches through the registry graph — the same dispatch serving uses — and hosted private-access descriptors are registry-qualified (registry NUL package), so the same name@version on two hosted registries can never share a cache key; unqualified descriptors from older builds fail closed and re-resolve. The bundled config.yaml moves the fixture namespace and the old ACL into the local registry's packages: map (YAML anchors keep it readable), and Config::proxy / Config::static_serve carry the registry-mock rules programmatically. Implements the pnpr side of pnpm/rfcs#17 only; no client or lockfile changes. * fixup: review + CI — missed consumer, indexed rule lookup, lint/typos - pacquet-pnpr-client's integration test builds an UpstreamConfig literal; add the new rules field (this compile error was failing the CI test jobs and coverage). - Index PackageRules::for_package by specificity tier (exact/scope maps + any-scoped/all slots) instead of scanning every rule: the lookup runs on every read, write, search hit, and route classification, and the tier chain makes the winner a map lookup. - Sharpen the hosted_gate and classify_hosted docs: the effective per-package access decides — an explicit entry fully decides its names, including opening one name on an otherwise-private registry — and only the denial's *shape* varies by tier (default denial masks, explicit-entry denial is loud). Classification admits with the same lookup serving does, so the two cannot diverge. - Appease dylint (single-letter closure params, intra-doc link), clippy (trailing comma), and typos (mis-order). * fixup: gate alias selection by upstream per-package rules; search fast path Route classification now consults the upstream registry's packages: rules per name: a caller the effective access denies is never handed the server-owned credential, so a fresh resolve fails closed exactly where the serving endpoint would deny the read. Cache replay stays registry-scoped by design (the alias descriptor names no package) — documented at the descriptor gate. Search skips a hosted registry outright when no rule of it could admit the caller, restoring the pre-merge fast path: a blanket mask must not become an enumeration or scan-timing primitive. * fixup: drop the pre-mount shape from the benchmark cold-mock config The cold-mock config carried three routing shapes so one file could drive any benchmarked pnpr revision, on the premise that every server ignores the blocks it doesn't recognize. HEAD broke that premise on purpose: a top-level packages: block is now a startup error, so the pnpr@HEAD revision mock refused to start and the benchmark job failed. Keep the two shapes that still coexist (registries:/defaultRegistry: and mounts:/defaultTarget:) and document that a pre-mount pnpr can no longer share a config file with current ones. * fixup: reject a bare top-level packages: key too Option<IgnoredAny> maps a present-but-null packages: (a bare key, or ~) to None, slipping past the loud rejection. Detect presence through a custom deserializer that consumes any value — including null — so every spelling of the removed key fails startup identically. * fixup: package-qualified alias descriptors for explicitly refined names Cache replay was registry-scoped for alias descriptors, so a caller passing the registry-level gate but denied by a per-package upstream access refinement could replay a cached resolution a fresh resolve would refuse them. The alias descriptor now carries the package name — only when the upstream's rules explicitly refine that name's access — and replay re-checks the refinement through the same per-package-aware alias selection a fresh resolve uses. Unrefined names keep the plain registry-scoped descriptor, so the common footprint stays one descriptor per alias. The refined metadata mirror namespaces per package for the same reason. Also document why resolves_to_private_source treats every name on an access-bearing upstream as caller-gated: unlike hosted registries, the upstream registry-level gate is enforced independently at serving (authorized_upstream runs before per-package rules), so a per-package 'access: $all' entry cannot open a name on a private upstream.
Stacked on #16 (base:
mount-level-patterns) — this shows only the delta and will retarget tomainwhen #16 merges.The change
The interim
patterns:field and the top-levelpackages:ACL block turn out to be one declaration: an ordered map on each concrete registry whose keys are the namespace (what the registry serves) and whose values are the per-package rules (who may read and write it):One declaration does triple duty: routing claim (routers select the first source that claims the name), request filter (off-namespace publish rejected, off-namespace read a definitive 404), and access policy.
Why
A name-keyed global ACL contradicts the model's central claim that package names are not global identifiers — the same name from two registries is two different packages. It also split authorization across two places (registry
access:+ global table), against the "authorize at the concrete source" rule, and the two surfaces used different pattern languages (full wax globs in the ACL vs the restricted decidable language in the namespaces). A new Motivation subsection ("Authorization must be registry-scoped for the same reason") records this.Key decisions
packages:block is removed, and a config still containing one is a startup error naming the per-registry replacement — silently dropping a previously enforced ACL on upgrade would be a security regression, so it must not be ignored like an unknown Verdaccio key.@scope/*>@*/*>**). YAML mappings are formally unordered, so key order must not decide which access rule applies — and the restricted pattern language makes specificity total within one registry (at most one key per tier can match a name), so no entry can be dead and no shadow validation is needed inside a registry; a duplicate key is the only error. Routersources:are unchanged: a YAML list is syntactically ordered, so first-claiming-source-in-order stays there, with its validation intact. Omitted fields fall back to the registry-level default (access:), then the safe defaults (access: $all,publish: $authenticatedhosted-only,unpublishdenied).publish/unpublishon an upstream registry is a config error.public: trueclarified: it describes the upstream fetch (anonymous, no credential, no headers, no registry-levelaccess:default). Per-packageaccessrules remain permitted on a public upstream — they gate who may read through pnpr, a capability the global ACL already provided.Costs accepted (recorded in Rationale)
@acme/util-*is the likely future extension — prefix coverage stays statically decidable).left-padeverywhere") must be repeated per claiming registry — a job that belongs to the advisory/OSV policy layer anyway.The prior shapes are both preserved as rejected alternatives ("Declare routing patterns on router routes" and the new "Keep a global
packages:ACL beside the registry namespaces"), and the Implementation/tests sections are updated to the merged model.