diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index d2955eed..d3c6fd57 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -256,7 +256,7 @@ impl FormatRegistry { /// The repository kinds format `id` supports, `None` when it is not /// installed. Docker has every kind; a format plugin serves the kinds its - /// descriptor names (NuGet's and raw's all three). + /// descriptor names (NuGet's, npm's and raw's all three). pub fn kinds(&self, id: &str) -> Option> { if Self::is_native(id) { return Some(vec!["hosted", "proxy", "group"]); diff --git a/crates/server/tests/npm.rs b/crates/server/tests/npm.rs index 29503ba3..4a569de4 100644 --- a/crates/server/tests/npm.rs +++ b/crates/server/tests/npm.rs @@ -1,12 +1,12 @@ //! Integration tests of npm repositories (`/npm`): publishing and the //! packument documents, the redeploy policy, dist-tags, deprecation and -//! unpublishing, Bearer tokens, `npm login` (legacy and web) and -//! `npm logout`, proxies of a mock registry (caching, stale serving, -//! integrity checks, the publication delay, Bearer credentials), groups -//! (merging, the reserved-scope and prefer-hosted guards, the writable -//! member), `npm audit` and retention. Requires Docker (Postgres + -//! versitygw via the shared harness), or `FORGE_TEST_DATABASE_URL` / -//! `FORGE_TEST_S3_ENDPOINT`. +//! unpublishing, access levels and content selectors, Bearer tokens, +//! `npm login` (legacy and web) and `npm logout`, proxies of a mock +//! registry (caching, stale serving, integrity checks, the publication +//! delay, Bearer credentials), groups (merging, the reserved-scope and +//! prefer-hosted guards, the writable member), `npm audit` and retention. +//! Requires Docker (Postgres + versitygw via the shared harness), or +//! `FORGE_TEST_DATABASE_URL` / `FORGE_TEST_S3_ENDPOINT`. //! //! The same flows run with the real `npm` and `pnpm` in CI //! (`.github/scripts/npm-e2e.sh`). @@ -686,7 +686,7 @@ async fn dist_tags_deprecations_and_unpublishing() { } #[tokio::test] -async fn unpublishing_takes_the_delete_permission() { +async fn unpublishing_takes_the_delete_level() { let srv = common::start().await; let repo = format!("npm{}", unique()); create( @@ -765,6 +765,140 @@ async fn unpublishing_takes_the_delete_permission() { ); } +#[tokio::test] +async fn a_selector_on_a_write_grant_scopes_publishes_and_reads() { + let srv = common::start().await; + let repo = format!("npm{}", unique()); + create( + &srv, + json!({"name": repo, "kind": "hosted", "format": "npm"}), + ) + .await; + let admin_npm = Npm::new(&srv, &repo, Some(srv.admin_token().await)); + for name in ["@acme/ui", "outside"] { + assert_eq!( + admin_npm.publish(name, "1.0.0", &["latest"]).await.status(), + 201 + ); + } + + // A user whose one grant is `write` on `@acme/*` of this repository. + let username = format!("acme{}@example.com", unique()); + let user_id = srv + .create_user(&username, "acme-password-1", "reader") + .await; + let selector: Value = admin( + &srv, + reqwest::Method::POST, + "/admin/content-selectors", + json!({"name": format!("s{}", unique()), "includes": ["@acme/*"], "excludes": []}), + ) + .await + .json() + .await + .unwrap(); + let role: Value = admin( + &srv, + reqwest::Method::POST, + "/admin/roles", + json!({"name": format!("r{}", unique())}), + ) + .await + .json() + .await + .unwrap(); + let resp = admin( + &srv, + reqwest::Method::PUT, + &format!("/admin/roles/{}/grants", role["id"]), + json!({"repository": repo, "level": "write", "selector_id": selector["id"]}), + ) + .await; + assert_eq!(resp.status(), 201, "{}", resp.text().await.unwrap()); + let resp = admin( + &srv, + reqwest::Method::PUT, + &format!("/admin/users/{user_id}/roles"), + json!({"role_id": role["id"]}), + ) + .await; + assert!(resp.status().is_success(), "{}", resp.text().await.unwrap()); + let npm = Npm::new( + &srv, + &repo, + Some(token_of(&srv, &username, "acme-password-1").await), + ); + + // It publishes and changes the scope's packages... + assert_eq!( + npm.publish("@acme/widget", "1.0.0", &["latest"]) + .await + .status(), + 201 + ); + let resp = npm + .req(reqwest::Method::PUT, "-/package/@acme%2fui/dist-tags/next") + .json(&json!("1.0.0")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + // ...and reads them only: the selector filters reads too. + npm.ok_packument("@acme/ui", true).await; + assert_eq!(npm.packument("outside", true).await.0, 404); + assert_eq!(npm.packument("outside", false).await.0, 404); + assert_eq!( + npm.req(reqwest::Method::GET, "outside/-/outside-1.0.0.tgz") + .send() + .await + .unwrap() + .status(), + 404 + ); + let found: Value = npm + .req(reqwest::Method::GET, "-/v1/search?text=e&size=20") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let mut names: Vec<&str> = found["objects"] + .as_array() + .unwrap() + .iter() + .map(|o| o["package"]["name"].as_str().unwrap()) + .collect(); + names.sort_unstable(); + assert_eq!(names, ["@acme/ui", "@acme/widget"], "{found}"); + + // Nothing outside the scope: no publish, no tag, no deprecation. + let resp = npm.publish("outside", "2.0.0", &["latest"]).await; + assert_eq!(resp.status(), 403, "{}", resp.text().await.unwrap()); + let resp = npm + .req(reqwest::Method::PUT, "-/package/outside/dist-tags/next") + .json(&json!("1.0.0")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 404); + let mut doc = admin_npm.ok_packument("outside", false).await; + doc["versions"]["1.0.0"]["deprecated"] = json!("old"); + let resp = npm + .req(reqwest::Method::PUT, "outside") + .json(&doc) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 404); + let full = admin_npm.ok_packument("outside", false).await; + assert_eq!(full["dist-tags"], json!({"latest": "1.0.0"})); + assert!( + full["versions"]["1.0.0"].get("deprecated").is_none(), + "{full}" + ); +} + #[tokio::test] async fn npm_login_legacy_and_web_and_logout() { let assets = tempfile::tempdir().unwrap(); diff --git a/docs/changes/2.0.0/41.feature.md b/docs/changes/2.0.0/41.feature.md index 636b48cf..20bff613 100644 --- a/docs/changes/2.0.0/41.feature.md +++ b/docs/changes/2.0.0/41.feature.md @@ -2,18 +2,19 @@ npm registries: repositories of `"format": "npm"`, served at `/npm//` for npm, pnpm, Yarn and Bun. Hosted ones take `npm publish` (the tarball checked against the integrity the client claims and the version it names, under the repository's deployment policy), -dist-tags, `npm deprecate` and `npm unpublish`. Proxies cache -`registry.npmjs.org` or any registry, with Basic credentials or a token -sent as `Authorization: Bearer` to its own origin only, and serve the -upstream's dist-tags, deprecations and publish times, verified tarballs and -the publication delay. Groups merge their members, take publishes into a -writable member, keep reserved scopes (`@acme`) away from proxy members and -pass `npm audit` on to their proxy without the reserved names. `npm login` -is approved in the browser on a page of the web UI, or checks a password -(`yarn npm login`), and mints an API token that `npm logout` revokes; npm -clients send API tokens as `Authorization: Bearer`, which every format -plugin's repositories now accept. Retention never deletes a version a -dist-tag names. The web UI shows a package's dist-tags, deprecated versions -and README, and a repository's `.npmrc` and `.yarnrc.yml` lines and -install commands. npm is a built-in format plugin (`crates/formats/npm`). See +dist-tags and `npm deprecate` with the level `write`, and `npm unpublish` +with `delete`. Proxies cache `registry.npmjs.org` or any registry, with +Basic credentials or a token sent as `Authorization: Bearer` to its own +origin only, and serve the upstream's dist-tags, deprecations and publish +times, verified tarballs and the publication delay. Groups merge their +members, take publishes into a writable member, keep reserved scopes +(`@acme`) away from proxy members and pass `npm audit` on to their proxy +without the reserved names. `npm login` is approved in the browser on a +page of the web UI, or checks a password (`yarn npm login`), and mints an +API token that `npm logout` revokes; npm clients send API tokens as +`Authorization: Bearer`, which every format plugin's repositories now +accept. Retention never deletes a version a dist-tag names. The web UI +shows a package's dist-tags, deprecated versions and README, and a +repository's `.npmrc` and `.yarnrc.yml` lines and install commands. npm is +a built-in format plugin (`crates/formats/npm`). See [docs/npm.md](docs/npm.md). diff --git a/docs/deployment.md b/docs/deployment.md index 62b3ecc6..c7fe5ecc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -308,8 +308,8 @@ it: | Level | Covers (in Nexus's terms) | In Forge | | --- | --- | --- | | `read` | browse, read | Download, list and search packages, images and files. | -| `write` | browse, read, add, edit | `read`, plus push and relist: a NuGet push, a container image or Helm chart push, a raw file upload. | -| `delete` | browse, read, add, edit, delete | `write`, plus unlist and delete versions, delete tags, delete or replace raw files, and redeploy a version. | +| `write` | browse, read, add, edit | `read`, plus push and relist: a NuGet push, an npm publish, a container image or Helm chart push, a raw file upload; and npm's dist-tags and deprecations. | +| `delete` | browse, read, add, edit, delete | `write`, plus unlist and delete versions (`npm unpublish` too), delete image tags, delete or replace raw files, and redeploy a version. | A check passes when a grant covering the repository has the level it needs or a higher one. A role holds one grant per repository (or every repository) and @@ -335,7 +335,7 @@ Every install has four built-in roles, which cannot be deleted or renamed: | Role | Grants | For | | --- | --- | --- | | `admin` | Everything: every repository at every level, and administering the instance (repositories, users, roles, authentication, settings). It holds no grant; membership is the administration. | Administrators. | -| `writer` | `write` on every repository, ones created later included. No `delete`, no administration. | People and CI that publish packages: a NuGet push, a container image push, a Helm chart upload, a raw file upload, of a package or image the repository does not have yet too. | +| `writer` | `write` on every repository, ones created later included. No `delete`, no administration. | People and CI that publish packages: a NuGet push, an npm publish, a container image push, a Helm chart upload, a raw file upload, of a package or image the repository does not have yet too. | | `authenticated` | `write` on every repository. | Legacy: what every user could do before roles existed, kept for upgraded installs. | | `anonymous` | What unauthenticated callers may read (`anonymous_read` repositories add to it). Its grants reach signed-in users too, as `read`, under the anonymous mode `all`. | Anonymous access. | diff --git a/docs/format-plugins.md b/docs/format-plugins.md index 64ab1074..9da3ad1d 100644 --- a/docs/format-plugins.md +++ b/docs/format-plugins.md @@ -57,6 +57,15 @@ crates/formats/raw/ ├── src/sidecar.rs # .sha256/.sha512/.sha1/.md5 from the stored digests ├── src/form.rs # multipart uploads, Nexus' field names └── tests/ # the protocol against canned answers + +crates/formats/npm/ +├── Cargo.toml +├── src/lib.rs # DESCRIPTOR (mount "/npm", kinds hosted/proxy/group, Bearer +│ # upstream tokens) and impl FormatPlugin for Npm +├── src/protocol.rs # the registry API: packuments, publishes, dist-tags, logins +├── src/publish.rs # the tarball inside a publish document, as it streams +├── src/proxy.rs # proxies: reading another registry (upstream) +└── tests/ # the protocol against canned answers ``` The pieces: diff --git a/docs/npm.md b/docs/npm.md index 632b40fd..1840334a 100644 --- a/docs/npm.md +++ b/docs/npm.md @@ -13,7 +13,7 @@ logins. Repositories of `format: "npm"` are served at npm is a format plugin built into Forge ([`crates/formats/npm`](../crates/formats/npm), [format-plugins.md](format-plugins.md)): tarballs are blobs in the -content-addressed store, and repositories, permissions, package selectors, +content-addressed store, and repositories, access levels, package selectors, retention and the web UI are the same as for every other format. ## Contents @@ -81,9 +81,9 @@ npmAuthToken: "" ``` The token is an API token: one `npm login` mints (below), or one you create -under **Your tokens** in the web UI. Any API token of yours works for every -repository you may read; Forge sees whose it is, not which repository it -was minted for. A repository that allows anonymous reads needs no token to +under **Your tokens** in the web UI. An API token of yours works for every +repository you may read, unless its scope limits it; Forge sees whose it +is, not which repository it was minted for. A repository that allows anonymous reads needs no token to install from. ## Logging in @@ -133,7 +133,7 @@ yarn npm publish # with npmRegistryServer / npmPublishRegistry set bun publish --registry https://forge.example.com/npm/npm-hosted/ ``` -Publishing needs the **write** permission and an API token (a password typed +Publishing needs the level **write** and an API token (a password typed at the web login is not a publishing credential); CI jobs can use a [trusted publishing](trusted-publishing.md) token instead. Publishing to a group goes to its writable member. @@ -151,7 +151,7 @@ Forge checks what it stores: A version that exists is refused (409), as on the public registry, unless the repository's **deployment policy** allows redeploys (which needs the -delete permission too). The policy *read-only* refuses every publish, +level `delete`). The policy *read-only* refuses every publish, unpublish, deprecation and dist-tag change: ```bash @@ -180,10 +180,10 @@ npm unpublish @acme/ui --force # every version version left. Deleting a version drops its tags. - A **deprecated** version still installs; clients warn with its message. - **Unpublishing** deletes the version (or every version) and needs the - **delete** permission. Unlike on the public registry there is no time + level **delete**. Unlike on the public registry there is no time limit, and a version unpublished may be published again. -Dist-tags and deprecations need the write permission. The web UI shows a +Dist-tags and deprecations need `write`. The web UI shows a package's dist-tags, deprecated versions with their messages, and the README of its `latest` version. @@ -255,21 +255,24 @@ A group serves its members, by priority: passes the request on to its upstream with its credentials, and a group to its first proxy member; a group leaves the packages under its reserved prefixes out, so your internal package names stay inside. A hosted -repository has no upstream to ask (404). Auditing needs the read -permission. +repository has no upstream to ask (404). Auditing needs `read`. ## Permissions -| What | Permission | +Each access level includes the ones below it +([deployment.md](deployment.md#access-levels)): + +| What | Level | | --- | --- | | Install, `npm view`, search, audit | read | | Publish, `npm dist-tag add` / `rm`, `npm deprecate` | write | | Unpublish (a version or the whole package); a redeploy | delete | | `npm login`, `npm logout`, `npm whoami` | none (a login checks the password) | -An unknown repository answers like one the caller may not read. A -read-only deployment policy refuses every change, whatever the caller's -permissions. +Content selectors match package names (`@acme/*`), for every level a grant +includes. An unknown repository answers like one the caller may not read. +A read-only deployment policy refuses every change, whatever the caller's +level. ## Retention diff --git a/web/src/components/RepositoryForm.tsx b/web/src/components/RepositoryForm.tsx index 179bc922..52cfa42f 100644 --- a/web/src/components/RepositoryForm.tsx +++ b/web/src/components/RepositoryForm.tsx @@ -630,10 +630,10 @@ export function RepositoryForm({ } description={ isRaw - ? 'What an upload to a path that holds a file does: refused (uploading the same content again is accepted), replaces it (which needs the delete permission too), or every upload and delete is refused.' + ? 'What an upload to a path that holds a file does: refused (uploading the same content again is accepted), replaces it (which needs the delete level), or every upload and delete is refused.' : isNpm - ? 'What publishing a version that exists does: refused (as on the public registry, where a published version never changes), replaces it (which needs the delete permission too), or every publish, unpublish and dist-tag change is refused.' - : 'What pushing a version that exists does: refused, replaces it (which needs the delete permission too), or every push, unlist and delete is refused.' + ? 'What publishing a version that exists does: refused (as on the public registry, where a published version never changes), replaces it (which needs the delete level), or every publish, unpublish and dist-tag change is refused.' + : 'What pushing a version that exists does: refused, replaces it (which needs the delete level), or every push, unlist and delete is refused.' } >