Skip to content

rfc(pnpr): merge the namespace and the ACL into a per-registry packages: map#17

Merged
zkochan merged 2 commits into
mount-level-patternsfrom
registry-scoped-packages
Jul 3, 2026
Merged

rfc(pnpr): merge the namespace and the ACL into a per-registry packages: map#17
zkochan merged 2 commits into
mount-level-patternsfrom
registry-scoped-packages

Conversation

@zkochan

@zkochan zkochan commented Jul 3, 2026

Copy link
Copy Markdown
Member

Stacked on #16 (base: mount-level-patterns) — this shows only the delta and will retarget to main when #16 merges.

The change

The interim patterns: field and the top-level packages: 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):

registries:
  acme:
    type: hosted
    org: acme
    access: team:acme          # default for everything this registry serves
    packages:
      '@acme/secret':
        access: acme-admins    # first matching entry wins
        publish: acme-admins
      '@acme/*': {}            # served with the registry defaults

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

  • The top-level 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.
  • Rule semantics: the most specific matching key wins, order-free (exact > @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. Router sources: 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: $authenticated hosted-only, unpublish denied). publish/unpublish on an upstream registry is a config error.
  • public: true clarified: it describes the upstream fetch (anonymous, no credential, no headers, no registry-level access: default). Per-package access rules 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)

  • Rule keys narrow to the restricted decidable pattern language (a within-name prefix shape like @acme/util-* is the likely future extension — prefix coverage stays statically decidable).
  • Cross-registry blanket rules ("deny left-pad everywhere") 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.

…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).
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6dc03e60-a9de-4f5e-8519-0ecb3bc6ac18

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch registry-scoped-packages

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

RFC: replace global ACL + namespace patterns with per-registry packages: rules

📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Merge registry namespace declaration and ACL into per-registry packages: ordered maps.
• Remove top-level packages: ACL and make its presence a startup error.
• Clarify rule semantics, defaults, and public: true behavior for upstream registries.
Diagram

graph TD
  Config["pnpr config"] --> Router["Router registry"] --> Select{"First claim"} --> Reg["Concrete registry"] --> Claim{"Claimed name?"} --> Allow{"Rule allows?"} --> Out["Serve or 404"]
  Pkgs["Per-registry packages map"] --> Select
  subgraph Legend
    direction LR
    _cfg["Config"] ~~~ _dec{"Decision"} ~~~ _flow(("Request"))
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Keep a global top-level `packages:` ACL
  • ➕ Single place to express cross-registry rules (e.g., deny one name everywhere).
  • ➕ Closer to Verdaccio’s existing configuration shape.
  • ➖ Contradicts the model that names are not global identifiers across registries.
  • ➖ Splits auth across registry access: defaults and a separate global table.
  • ➖ Introduces a second pattern language surface (full globs) vs decidable namespace patterns.
2. Keep separate `patterns:` (namespace) and per-registry ACL values
  • ➕ Separates routing (claim) from authorization concerns.
  • ➕ Potentially simpler mental model for operators migrating from earlier iterations.
  • ➖ Duplicates the same fact in two places and invites drift.
  • ➖ Still leaves open the question of where “authorize at the concrete source” lives.
  • ➖ Doesn’t exploit coverage validation uniformly (router + within-registry dead rules).

Recommendation: Proceed with the merged per-registry packages: ordered map. It aligns routing, namespace enforcement, and authorization to the same concrete registry boundary, removes the global name-keyed ACL inconsistency, and enables consistent static validation (dead entries, shadowed claims) while keeping single-purpose registries ergonomic via an omitted map meaning “all names, defaults.”

Files changed (1) +323 / -220

Documentation (1) +323 / -220
0000-pnpr-registry-mounts.mdRedefine registries around per-registry 'packages:' (namespace + ACL) +323/-220

Redefine registries around per-registry 'packages:' (namespace + ACL)

• Reworks the RFC to merge registry namespace declaration and ACL into a single per-registry 'packages:' ordered map, replacing 'patterns:' and removing the top-level Verdaccio 'packages:' block. Defines rule ordering (first match wins), dead-entry validation, defaulting behavior, and upstream constraints (no publish/unpublish; 'public: true' applies to fetch only). Updates rationale, implementation steps, and test plan to reflect the registry-scoped authorization model and the startup error for legacy top-level ACL configs.

pnpr/text/0000-pnpr-registry-mounts.md

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.
zkochan added a commit to pnpm/pnpm that referenced this pull request Jul 3, 2026
…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.
@zkochan zkochan merged commit 2c6c2e4 into mount-level-patterns Jul 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant