Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/server/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<&'static str>> {
if Self::is_native(id) {
return Some(vec!["hosted", "proxy", "group"]);
Expand Down
150 changes: 142 additions & 8 deletions crates/server/tests/npm.rs
Original file line number Diff line number Diff line change
@@ -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`).
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down
29 changes: 15 additions & 14 deletions docs/changes/2.0.0/41.feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@ npm registries: repositories of `"format": "npm"`, served at
`/npm/<repository>/` 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).
6 changes: 3 additions & 3 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |

Expand Down
9 changes: 9 additions & 0 deletions docs/format-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 17 additions & 14 deletions docs/npm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,9 +81,9 @@ npmAuthToken: "<token>"
```

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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions web/src/components/RepositoryForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
}
>
<option value="disable_redeploy">Disable redeploy</option>
Expand Down
Loading