Skip to content

feat(registry): enforce per-package access policy from YAML#12043

Merged
zkochan merged 3 commits into
mainfrom
feat/registry-policy-from-yaml
May 28, 2026
Merged

feat(registry): enforce per-package access policy from YAML#12043
zkochan merged 3 commits into
mainfrom
feat/registry-policy-from-yaml

Conversation

@zkochan

@zkochan zkochan commented May 28, 2026

Copy link
Copy Markdown
Member

Summary

Wires pnpm-registry's per-package access policy from the YAML config instead of hard-coding it, and adds the $anonymous access rule.

Until now from_yaml / from_default_yaml ignored each packages[*].access / .publish token and always installed PackagePolicies::registry_mock_defaults(). So a config like:

packages:
  '@acme/*':
    access: $authenticated

parsed fine but was silently unenforced@acme/* stayed world-readable. This closes that gap: configured ACLs now actually gate requests.

What's in

  • Policy compiled from YAML. from_yaml_str builds PackagePolicies from the packages: block in declared order (first match wins). Missing access defaults to $all, missing publish to $authenticated — the same safe fallback applied to unmatched packages.
  • $anonymous rule. New AccessRule::Anonymous: admits only unauthenticated callers. An authenticated caller is outside that group, so it gets 403 (matching verdaccio, where $anonymous is absent from a logged-in user's groups).
  • Fail closed on unknown tokens. A token that isn't $all / $authenticated / $anonymous (e.g. a named group, not modeled yet) now fails config parsing rather than silently mis-enforcing.
  • Programmatic constructors unchanged. Config::proxy / Config::static_serve still use registry_mock_defaults, so the bundled/test behaviour is identical.

Notes / scope

  • unpublish is parsed but still folds into publish at enforcement time (separate item).
  • Named groups / per-user group membership remain unsupported (separate item in pnpr backend parity with verdaccio — Tracking Issue #11973).
  • The bundled config.yaml already carries the @private/* and @pnpm.e2e/needs-auth rules, so from_default_yaml reproduces the previous protections (covered by a regression test).

Test plan

  • New unit tests: YAML→policy derivation, defaults, first-match-wins, $anonymous, unknown-token error, and bundled-config protections preserved; plus AccessRule parsing/rejection.
  • New integration test (tests/policy.rs) drives the real from_yaml → router → enforce path: an $authenticated scope 401s anonymous reads; an $anonymous scope admits anonymous (404) and 403s an authenticated caller.
  • Existing auth/integration tests unaffected (they use static_serve).
  • cargo test, cargo clippy --deny warnings, cargo dylint (perfectionist), cargo fmt --check, cargo doc -D warnings all clean.

Refs #11973 (Access control → "Wire policy fully from YAML" + "$anonymous group").


Written by an agent (Claude Code, claude-opus-4-8).

Summary by CodeRabbit

  • New Features

    • Package access policies now compile directly from YAML package entries.
    • Anonymous access token supported in access rules.
  • Bug Fixes

    • Configuration parse errors surface as clear client-facing "Invalid config" responses (400).
    • Authorization behavior updated for anonymous vs. authenticated requests.
  • Documentation

    • Updated docs describing YAML-driven policy compilation and token semantics.
  • Tests

    • Added integration and unit tests covering policy derivation and access scenarios.

Review Change Stack

Derive PackagePolicies from each packages[*] entry's access/publish
tokens in from_yaml/from_default_yaml, instead of hardcoding
registry_mock_defaults — configured ACLs were silently ignored before,
so a user-defined private scope got no protection.

- Add the $anonymous AccessRule: admits only unauthenticated callers;
  an authenticated caller falls outside the group and gets 403.
- Unknown access tokens now fail config parsing rather than silently
  mis-enforcing; named groups remain unsupported (tracked separately).
- Programmatic Config::proxy / Config::static_serve keep
  registry_mock_defaults.

Refs #11973.
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e84987d-3ee8-497a-8ed6-54d6feb49fda

📥 Commits

Reviewing files that changed from the base of the PR and between 83ec664 and a6f811d.

📒 Files selected for processing (1)
  • registry/crates/pnpm-registry/src/config.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • registry/crates/pnpm-registry/src/config.rs
📜 Recent review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Lint and Test (ubuntu-latest)
  • GitHub Check: Dylint
  • GitHub Check: Analyze (javascript)
  • GitHub Check: Code Coverage
  • GitHub Check: Compile & Lint

📝 Walkthrough

Walkthrough

Config parsing now maps serde failures to RegistryError::InvalidConfig and compiles PackagePolicies from YAML package access/publish tokens (first-match-wins, with defaults). Adds AccessRule::Anonymous, refactors enforce_access to deny authenticated requests for anonymous-only packages, and adds unit/integration tests covering parsing and enforcement.

Changes

YAML-Driven Access Policy System

Layer / File(s) Summary
Error and access rule types
registry/crates/pnpm-registry/src/error.rs, registry/crates/pnpm-registry/src/policy.rs
Adds RegistryError::InvalidConfig and maps serde parse failures to it (400). Extends AccessRule with Anonymous and FromStr parsing to accept "$anonymous"/"anonymous". Includes unit tests for token parsing and invalid tokens.
Config imports and docs
registry/crates/pnpm-registry/src/config.rs
Adds imports for RegistryError, AccessRule, and policy types; updates docs to state policies are compiled from YAML packages entries.
Policy compilation from YAML packages
registry/crates/pnpm-registry/src/config.rs
Config::from_yaml_str now returns Result<_, RegistryError> and compiles PackagePolicies from the parsed packages block via build_policies/parse_access_rule. Implements first-match-wins ordering and defaults (All for access, Authenticated for publish); adds unit tests validating mapping, precedence, defaulting, anonymous handling, and invalid-token failures.
Authorization enforcement for access rules
registry/crates/pnpm-registry/src/server.rs
Adds Action::label() and refactors enforce_access to match on (rule, authenticated). Authenticated requests to Anonymous packages are denied with 403 Forbidden that includes the action label.
Integration tests validating YAML policy compilation and enforcement
registry/crates/pnpm-registry/tests/policy.rs
Test harness writes a temp config.yaml, loads it via Config::from_yaml, creates an Axum router, registers a user to obtain a token, and validates $authenticated and $anonymous behaviors via live HTTP requests (status assertions like 401, 403, 404).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • pnpm/pnpm#11970: Introduces the Verdaccio-shaped packages configuration model this change consumes.
  • pnpm/pnpm#11977: Also modifies registry request access enforcement (enforce_access) and authentication handling.

Suggested reviewers

  • KSXGitHub

"🐰
I nibble YAML leaves in the night,
Policies sprout from tokens bright,
Anonymous hops, authenticated waits,
Tests confirm the gated gates,
A hopping patch to keep things right."

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: enforcing per-package access policies dynamically from YAML configuration instead of hard-coded defaults.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/registry-policy-from-yaml

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 and usage tips.

@codecov-commenter

codecov-commenter commented May 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.31034% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 88.81%. Comparing base (a9011ad) to head (a6f811d).

Files with missing lines Patch % Lines
registry/crates/pnpm-registry/src/server.rs 92.85% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12043      +/-   ##
==========================================
+ Coverage   88.73%   88.81%   +0.07%     
==========================================
  Files         234      234              
  Lines       30461    30594     +133     
==========================================
+ Hits        27031    27173     +142     
+ Misses       3430     3421       -9     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

`mis-enforce` tripped the `typos` check (read as `mis` → `miss`/`mist`).
@zkochan zkochan marked this pull request as ready for review May 28, 2026 22:54
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Review Summary by Qodo

Wire per-package access policies from YAML config

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Compile per-package access policies from YAML packages block instead of hardcoding defaults
• Add $anonymous access rule for unauthenticated-only access
• Fail config parsing on unknown access tokens instead of silently mis-enforcing
• Enforce compiled policies at request time via updated enforce_access logic
Diagram
flowchart LR
  YAML["YAML packages block<br/>access/publish tokens"]
  PARSE["parse_access_rule<br/>with defaults"]
  BUILD["build_policies<br/>creates PackagePolicies"]
  ENFORCE["enforce_access<br/>checks rule vs caller"]
  RESPONSE["200/401/403<br/>response"]
  
  YAML -->|"each entry"| PARSE
  PARSE -->|"AccessRule"| BUILD
  BUILD -->|"PackagePolicies"| ENFORCE
  ENFORCE -->|"match rule<br/>& auth state"| RESPONSE

Loading

Grey Divider

File Changes

1. registry/crates/pnpm-registry/src/config.rs ✨ Enhancement +151/-18

Compile policies from YAML packages block

• Added build_policies() function to compile YAML packages block into PackagePolicies with
 first-match-wins semantics
• Added parse_access_rule() helper to parse access tokens with safe defaults ($all for access,
 $authenticated for publish)
• Updated from_yaml_str() to call build_policies() instead of hardcoding
 registry_mock_defaults()
• Updated PackageAccess struct documentation to reflect that tokens are now enforced
• Added comprehensive unit tests covering policy derivation, defaults, first-match-wins,
 $anonymous, unknown tokens, and bundled config protections

registry/crates/pnpm-registry/src/config.rs


2. registry/crates/pnpm-registry/src/error.rs Error handling +13/-1

Add InvalidConfig error and update error messages

• Updated InvalidAccessRule error message to include $anonymous as a valid token
• Added new InvalidConfig error variant for YAML parsing failures
• Updated to_status_code() to map InvalidConfig to BAD_REQUEST

registry/crates/pnpm-registry/src/error.rs


3. registry/crates/pnpm-registry/src/policy.rs ✨ Enhancement +32/-3

Add Anonymous access rule and parsing

• Added AccessRule::Anonymous variant for unauthenticated-only access
• Updated FromStr implementation to parse $anonymous / anonymous tokens
• Updated documentation to reflect that $anonymous is now supported
• Added unit tests for parsing all three access rule tokens and rejecting unknown tokens

registry/crates/pnpm-registry/src/policy.rs


View more (2)
4. registry/crates/pnpm-registry/src/server.rs ✨ Enhancement +22/-5

Enforce Anonymous rule and refactor access checks

• Added Action::label() method to provide human-readable action names for error messages
• Refactored enforce_access() match statement to handle AccessRule::Anonymous case
• Authenticated callers accessing $anonymous rules now receive 403 Forbidden instead of passing
 through

registry/crates/pnpm-registry/src/server.rs


5. registry/crates/pnpm-registry/tests/policy.rs 🧪 Tests +97/-0

Add integration tests for policy enforcement

• New integration test file exercising the full YAML → policy → enforcement path
• Test authenticated_access_token_from_yaml_gates_anonymous_reads() verifies $authenticated rule
 blocks anonymous access
• Test anonymous_rule_admits_anonymous_and_forbids_authenticated() verifies $anonymous rule
 admits unauthenticated and forbids authenticated callers
• Helper functions for YAML config creation, request building, and user/token management

registry/crates/pnpm-registry/tests/policy.rs


Grey Divider

Qodo Logo

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
registry/crates/pnpm-registry/tests/policy.rs (1)

70-82: ⚡ Quick win

Consider asserting authenticated access succeeds for @secret/*.

This test only verifies the anonymous-denied (401) and $all-fallthrough (404) cases. A regression where $authenticated wrongly rejected every caller would still pass here. Adding the positive path rounds out the matrix.

♻️ Add positive-case assertion
     // `@secret/*` requires auth: an anonymous read is 401.
     assert_eq!(status_of(app.clone(), get("/@secret/thing")).await, StatusCode::UNAUTHORIZED);
+    // An authenticated caller passes the access check (404 = allowed but absent).
+    let token = add_user_and_get_token(&app, "bob", "secret").await;
+    assert_eq!(
+        status_of(app.clone(), get_auth("/@secret/thing", &token)).await,
+        StatusCode::NOT_FOUND
+    );
     // A `**` ($all) package is readable anonymously — it's just absent
     // on disk, so 404, which proves the access check passed.
     assert_eq!(status_of(app, get("/lodash")).await, StatusCode::NOT_FOUND);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@registry/crates/pnpm-registry/tests/policy.rs` around lines 70 - 82, The test
authenticated_access_token_from_yaml_gates_anonymous_reads currently only checks
anonymous 401 and public 404; add a positive authenticated assertion to ensure
$authenticated allows access: after building app via router(config), perform a
GET to "/@secret/thing" using the existing test helpers (e.g., status_of and
get) but include a valid authentication header/token (use the same auth
mechanism used elsewhere in tests) and assert the response is not UNAUTHORIZED
(expected NOT_FOUND if package is absent) to confirm authenticated callers pass
the access check; reference the test function name
authenticated_access_token_from_yaml_gates_anonymous_reads and helpers
config_from_yaml, router, status_of, and get to locate where to add this
assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@registry/crates/pnpm-registry/src/config.rs`:
- Around line 540-543: build_policies() currently validates access and publish
but ignores access.unpublish, allowing unknown tokens to slip through; call
parse_access_rule on access.unpublish (e.g. let _unpublish_rule =
parse_access_rule(access.unpublish.as_deref(), AccessRule::None)? ) to validate
the unpublish token and propagate any parse errors before creating the
PackagePolicy (keep using PackagePolicy::new(pattern, access_rule, publish_rule)
if the API doesn't accept an unpublish rule).

---

Nitpick comments:
In `@registry/crates/pnpm-registry/tests/policy.rs`:
- Around line 70-82: The test
authenticated_access_token_from_yaml_gates_anonymous_reads currently only checks
anonymous 401 and public 404; add a positive authenticated assertion to ensure
$authenticated allows access: after building app via router(config), perform a
GET to "/@secret/thing" using the existing test helpers (e.g., status_of and
get) but include a valid authentication header/token (use the same auth
mechanism used elsewhere in tests) and assert the response is not UNAUTHORIZED
(expected NOT_FOUND if package is absent) to confirm authenticated callers pass
the access check; reference the test function name
authenticated_access_token_from_yaml_gates_anonymous_reads and helpers
config_from_yaml, router, status_of, and get to locate where to add this
assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7b3918b8-7d9f-48a9-87ed-f9689592aeea

📥 Commits

Reviewing files that changed from the base of the PR and between a9011ad and 83ec664.

📒 Files selected for processing (5)
  • registry/crates/pnpm-registry/src/config.rs
  • registry/crates/pnpm-registry/src/error.rs
  • registry/crates/pnpm-registry/src/policy.rs
  • registry/crates/pnpm-registry/src/server.rs
  • registry/crates/pnpm-registry/tests/policy.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Lint and Test (ubuntu-latest)
  • GitHub Check: Code Coverage
🧰 Additional context used
📓 Path-based instructions (1)
registry/crates/**/*.rs

📄 CodeRabbit inference engine (registry/AGENTS.md)

Follow the pacquet code-style guide (../pacquet/CODE_STYLE_GUIDE.md) for Rust-level conventions including imports, naming, ownership, error handling, and test layout

Files:

  • registry/crates/pnpm-registry/src/error.rs
  • registry/crates/pnpm-registry/tests/policy.rs
  • registry/crates/pnpm-registry/src/policy.rs
  • registry/crates/pnpm-registry/src/server.rs
  • registry/crates/pnpm-registry/src/config.rs
🔇 Additional comments (7)
registry/crates/pnpm-registry/src/error.rs (1)

35-37: LGTM!

Also applies to: 52-59, 176-176

registry/crates/pnpm-registry/src/policy.rs (1)

14-16: LGTM!

Also applies to: 24-28, 37-38, 173-195

registry/crates/pnpm-registry/src/config.rs (1)

9-10: LGTM!

Also applies to: 69-75, 232-236, 451-463, 470-471, 549-554, 611-612, 1286-1387

registry/crates/pnpm-registry/src/server.rs (2)

1298-1305: LGTM!


1327-1342: LGTM!

registry/crates/pnpm-registry/tests/policy.rs (2)

16-68: LGTM!


84-97: LGTM!

Comment thread registry/crates/pnpm-registry/src/config.rs
build_policies validated access/publish but let unknown unpublish
tokens slip through. Validate it as well (defaulting to the publish
rule when absent) so the fail-closed behavior is consistent.
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Actionable comments posted: 0

@zkochan zkochan merged commit a593082 into main May 28, 2026
25 checks passed
@zkochan zkochan deleted the feat/registry-policy-from-yaml branch May 28, 2026 23:18
zkochan added a commit that referenced this pull request May 29, 2026
* feat(registry): support multi-token access lists with usernames

Model each packages[*] access/publish permission as a list of tokens
(verdaccio's space-separated groups) instead of a single rule, and
match it against the caller's identity. Tokens are the built-in
pseudo-groups ($all/$authenticated/$anonymous and their @/bare
aliases) or a name; a name matches an authenticated caller whose
username equals it — verdaccio's per-user access, where the htpasswd
backend contributes the username and richer groups would come from a
group-providing auth backend.

- AccessRule (single enum) -> AccessList (Vec<AccessToken>) + Identity.
- Accept both a space-separated string and a YAML sequence for each
  permission, like verdaccio.
- Bare names are now valid tokens (drops the InvalidAccessRule
  stopgap from #12043 that rejected them).
- enforce_access: anonymous denial -> 401, authenticated denial -> 403.

Refs #11973.

* docs(registry): note the bare authenticated/anonymous token aliases

AccessToken::from also accepts the unprefixed `authenticated` /
`anonymous` forms; document them on the variants so they aren't
misread as usernames.
zkochan added a commit that referenced this pull request May 29, 2026
* feat(registry): support multi-token access lists with usernames

Model each packages[*] access/publish permission as a list of tokens
(verdaccio's space-separated groups) instead of a single rule, and
match it against the caller's identity. Tokens are the built-in
pseudo-groups ($all/$authenticated/$anonymous and their @/bare
aliases) or a name; a name matches an authenticated caller whose
username equals it — verdaccio's per-user access, where the htpasswd
backend contributes the username and richer groups would come from a
group-providing auth backend.

- AccessRule (single enum) -> AccessList (Vec<AccessToken>) + Identity.
- Accept both a space-separated string and a YAML sequence for each
  permission, like verdaccio.
- Bare names are now valid tokens (drops the InvalidAccessRule
  stopgap from #12043 that rejected them).
- enforce_access: anonymous denial -> 401, authenticated denial -> 403.

Refs #11973.

* docs(registry): note the bare authenticated/anonymous token aliases

AccessToken::from also accepts the unprefixed `authenticated` /
`anonymous` forms; document them on the variants so they aren't
misread as usernames.
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.

2 participants