feat: add support for npm owner (#4582)

* feat: add support for npm owner

* Revert debug msg

* Finish feature and add test cases

* Fix remote user name and more tests

* Simplify passing remote user

* Update version metadata with owners

* Add test for validateUserName

* Add comment for "change owner"

* add config option

* add check to removePackage, removeTarball

* typo

* check access when write=true

* Add to config, fix undefined user

* Update docs

* Update docs

* Update readme
This commit is contained in:
Marc Bernard 2024-06-13 12:06:01 +02:00 committed by GitHub
parent ecbc569f23
commit 6e764e3c49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 816 additions and 39 deletions

View File

@ -0,0 +1,9 @@
---
'@verdaccio/types': patch
'@verdaccio/config': patch
'@verdaccio/core': patch
'@verdaccio/store': patch
'@verdaccio/api': patch
---
feat: add support for npm owner

View File

@ -251,7 +251,7 @@ Verdaccio aims to support all features of a standard npm client that make sense
- Registering new users (`npm adduser {newuser}`) - **supported**
- Change password (`npm profile set password`) - **supported**
- Transferring ownership (`npm owner add {user} {pkg}`) - not supported, _PR-welcome_
- Transferring ownership (`npm owner`) - **supported**
- Token (`npm token`) - **supported**
### Miscellaneous

View File

@ -28,6 +28,7 @@ export default function (route: Router, auth: Auth, storage: Storage): void {
const name = req.params.package;
let version = req.params.version;
const write = req.query.write === 'true';
const username = req?.remote_user?.name;
const abbreviated =
stringUtils.getByQualityPriorityValue(req.get('Accept')) === Storage.ABBREVIATED_HEADER;
const requestOptions = {
@ -37,6 +38,7 @@ export default function (route: Router, auth: Auth, storage: Storage): void {
host: req.host,
remoteAddress: req.socket.remoteAddress,
byPassCache: write,
username,
};
try {

View File

@ -76,11 +76,11 @@ const debug = buildDebug('verdaccio:api:publish');
*
* 3. Star a package
*
* Permissions: start a package depends of the publish and unpublish permissions, there is no
* specific flag for star or un start.
* Permissions: staring a package depends of the publish and unpublish permissions, there is no
* specific flag for star or unstar.
* The URL for star is similar to the unpublish (change package format)
*
* npm has no endpoint for star a package, rather mutate the metadata and acts as, the difference
* npm has no endpoint for staring a package, rather mutate the metadata and acts as, the difference
* is the users property which is part of the payload and the body only includes
*
* {
@ -89,7 +89,24 @@ const debug = buildDebug('verdaccio:api:publish');
"users": {
[username]: boolean value (true, false)
}
}
}
*
* 4. Change owners of a package
*
* Similar to staring a package, changing owners (maintainers) of a package uses the publish
* endpoint.
*
* The body includes a list of the new owners with the following format
*
* {
"_id": pkgName,
"_rev": "4-b0cdaefc9bdb77c8",
"maintainers": [
{ "name": "first owner", "email": "me@verdaccio.org" },
{ "name": "second owner", "email": "you@verdaccio.org" },
...
]
}
*
*/
export default function publish(router: Router, auth: Auth, storage: Storage): void {
@ -127,10 +144,11 @@ export default function publish(router: Router, auth: Auth, storage: Storage): v
async function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer) {
const packageName = req.params.package;
const rev = req.params.revision;
const username = req?.remote_user?.name;
logger.debug({ packageName }, `unpublishing @{packageName}`);
try {
await storage.removePackage(packageName, rev);
await storage.removePackage(packageName, rev, username);
debug('package %s unpublished', packageName);
res.status(HTTP_STATUS.CREATED);
return next({ ok: API_MESSAGE.PKG_REMOVED });
@ -155,13 +173,14 @@ export default function publish(router: Router, auth: Auth, storage: Storage): v
): Promise<void> {
const packageName = req.params.package;
const { filename, revision } = req.params;
const username = req?.remote_user?.name;
logger.debug(
{ packageName, filename, revision },
`removing a tarball for @{packageName}-@{tarballName}-@{revision}`
);
try {
await storage.removeTarball(packageName, filename, revision);
await storage.removeTarball(packageName, filename, revision, username);
res.status(HTTP_STATUS.CREATED);
logger.debug(
@ -188,6 +207,12 @@ export function publishPackage(storage: Storage): any {
const metadata = req.body;
const username = req?.remote_user?.name;
debug('publishing package %o for user %o', packageName, username);
logger.debug(
{ packageName, username },
'publishing package @{packageName} for user @{username}'
);
try {
const message = await storage.updateManifest(metadata, {
name: packageName,

View File

@ -27,10 +27,22 @@ export default function (route: Router, auth: Auth, config: Config): void {
rateLimit(config?.userRateLimit),
function (req: $RequestExtend, res: Response, next: $NextFunctionVer): void {
debug('verifying user');
if (typeof req.remote_user.name !== 'string' || req.remote_user.name === '') {
debug('user not logged in');
res.status(HTTP_STATUS.OK);
return next({ ok: false });
}
const username = req.params.org_couchdb_user.split(':')[1];
const message = getAuthenticatedMessage(req.remote_user.name);
debug('user authenticated message %o', message);
res.status(HTTP_STATUS.OK);
next({
// 'npm owner' requires user info
// TODO: we don't have the email
name: username,
email: '',
ok: message,
});
}
@ -61,6 +73,10 @@ export default function (route: Router, auth: Auth, config: Config): void {
debug('login or adduser');
const remoteName = req?.remote_user?.name;
if (!validatioUtils.validateUserName(req.params.org_couchdb_user, name)) {
return next(errorUtils.getBadRequest(API_ERROR.USERNAME_MISMATCH));
}
if (typeof remoteName !== 'undefined' && typeof name === 'string' && remoteName === name) {
debug('login: no remote user detected');
auth.authenticate(
@ -97,6 +113,7 @@ export default function (route: Router, auth: Auth, config: Config): void {
}
);
} else {
debug('adduser: %o', name);
if (
validatioUtils.validatePassword(
password,

View File

@ -11,7 +11,7 @@ import {
generatePackageMetadata,
initializeServer as initializeServerHelper,
} from '@verdaccio/test-helper';
import { GenericBody, PackageUsers } from '@verdaccio/types';
import { Author, GenericBody, PackageUsers } from '@verdaccio/types';
import { buildToken, generateRandomHexString } from '@verdaccio/utils';
import apiMiddleware from '../../src';
@ -142,6 +142,37 @@ export function starPackage(
return test;
}
export function changeOwners(
app,
options: {
maintainers: Author[];
name: string;
_rev: string;
_id?: string;
},
token?: string
): supertest.Test {
const { _rev, _id, maintainers } = options;
const ownerManifest = {
_rev,
_id,
maintainers,
};
const test = supertest(app)
.put(`/${encodeURIComponent(options.name)}`)
.set(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON)
.send(JSON.stringify(ownerManifest))
.set('accept', HEADERS.GZIP)
.set(HEADER_TYPE.ACCEPT_ENCODING, HEADERS.JSON);
if (typeof token === 'string') {
test.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, token));
}
return test;
}
export function getDisTags(app, pkgName) {
return supertest(app)
.get(`/-/package/${encodeURIComponent(pkgName)}/dist-tags`)

View File

@ -0,0 +1,24 @@
storage: ./storage
auth:
htpasswd:
file: ./htpasswd-owner
web:
enable: true
title: verdaccio
log: { type: stdout, format: pretty, level: info }
# TODO: Add test case for $owner access
packages:
'@*/*':
access: $all
publish: $authenticated
unpublish: $authenticated
'**':
access: $all
publish: $authenticated
unpublish: $authenticated
_debug: true

View File

@ -0,0 +1,118 @@
/* eslint-disable jest/no-commented-out-tests */
import nock from 'nock';
import { HTTP_STATUS } from '@verdaccio/core';
import {
changeOwners,
createUser,
getPackage,
initializeServer,
publishVersionWithToken,
} from './_helper';
describe('owner', () => {
test.each([['foo', '@scope%2Ffoo']])('should get owner of package', async (pkgName) => {
nock('https://registry.npmjs.org').get(`/${pkgName}`).reply(404);
const app = await initializeServer('owner.yaml');
const credentials = { name: 'test', password: 'test' };
const response = await createUser(app, credentials.name, credentials.password);
expect(response.body.ok).toMatch(`user '${credentials.name}' created`);
await publishVersionWithToken(app, pkgName, '1.0.0', response.body.token).expect(
HTTP_STATUS.CREATED
);
// expect publish to set owner to logged in user
const manifest = await getPackage(app, '', decodeURIComponent(pkgName));
const maintainers = manifest.body.maintainers;
expect(maintainers).toHaveLength(1);
// TODO: This should eventually include the email of the user
expect(maintainers).toEqual([{ name: credentials.name, email: '' }]);
});
test.each([['foo', '@scope%2Ffoo']])('should add/remove owner to package', async (pkgName) => {
nock('https://registry.npmjs.org').get(`/${pkgName}`).reply(404);
const app = await initializeServer('owner.yaml');
const credentials = { name: 'test', password: 'test' };
const firstOwner = { name: 'test', email: '' };
const response = await createUser(app, credentials.name, credentials.password);
expect(response.body.ok).toMatch(`user '${credentials.name}' created`);
await publishVersionWithToken(app, pkgName, '1.0.0', response.body.token).expect(
HTTP_STATUS.CREATED
);
// publish sets owner to logged in user
const manifest = await getPackage(app, '', decodeURIComponent(pkgName));
const maintainers = manifest.body.maintainers;
expect(maintainers).toHaveLength(1);
expect(maintainers).toEqual([firstOwner]);
// add another owner
const secondOwner = { name: 'tester', email: 'test@verdaccio.org' };
const newOwners = [...maintainers, secondOwner];
await changeOwners(
app,
{
_rev: manifest.body._rev,
_id: manifest.body._id,
name: pkgName,
maintainers: newOwners,
},
response.body.token
).expect(HTTP_STATUS.CREATED);
const manifest2 = await getPackage(app, '', decodeURIComponent(pkgName));
const maintainers2 = manifest2.body.maintainers;
expect(maintainers2).toHaveLength(2);
expect(maintainers2).toEqual([firstOwner, secondOwner]);
// remove original owner
await changeOwners(
app,
{
_rev: manifest2.body._rev,
_id: manifest2.body._id,
name: pkgName,
maintainers: [secondOwner],
},
response.body.token
).expect(HTTP_STATUS.CREATED);
const manifest3 = await getPackage(app, '', decodeURIComponent(pkgName));
const maintainers3 = manifest3.body.maintainers;
expect(maintainers3).toHaveLength(1);
expect(maintainers3).toEqual([secondOwner]);
});
test.each([['foo', '@scope%2Ffoo']])('should fail if user is not logged in', async (pkgName) => {
nock('https://registry.npmjs.org').get(`/${pkgName}`).reply(404);
const app = await initializeServer('owner.yaml');
const credentials = { name: 'test', password: 'test' };
const firstOwner = { name: 'test', email: '' };
const response = await createUser(app, credentials.name, credentials.password);
expect(response.body.ok).toMatch(`user '${credentials.name}' created`);
await publishVersionWithToken(app, pkgName, '1.0.0', response.body.token).expect(
HTTP_STATUS.CREATED
);
// publish sets owner to logged in user
const manifest = await getPackage(app, '', decodeURIComponent(pkgName));
const maintainers = manifest.body.maintainers;
expect(maintainers).toHaveLength(1);
expect(maintainers).toEqual([firstOwner]);
// try adding another owner
const secondOwner = { name: 'tester', email: 'test@verdaccio.org' };
const newOwners = [...maintainers, secondOwner];
await changeOwners(
app,
{
_rev: manifest.body._rev,
_id: manifest.body._id,
name: pkgName,
maintainers: newOwners,
},
'' // no token
).expect(HTTP_STATUS.UNAUTHORIZED);
});
});

View File

@ -43,6 +43,12 @@ describe('search', () => {
links: {
npm: '',
},
maintainers: [
{
email: '',
name: 'test',
},
],
name: pkg,
publisher: {},
scope: '',
@ -97,6 +103,12 @@ describe('search', () => {
links: {
npm: '',
},
maintainers: [
{
email: '',
name: 'test',
},
],
name: pkg,
publisher: {},
scope: '@scope',

View File

@ -148,6 +148,25 @@ describe('token', () => {
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
.expect(HTTP_STATUS.OK);
expect(response2.body.ok).toBe(`you are authenticated as '${credentials.name}'`);
expect(response2.body.name).toBe(credentials.name);
}
);
test.each([['user.yaml'], ['user.jwt.yaml']])(
'should return name of requested user',
async (conf) => {
const app = await initializeServer(conf);
const username = 'yeti';
const credentials = { name: 'jota', password: 'secretPass' };
const response = await createUser(app, credentials.name, credentials.password);
expect(response.body.ok).toMatch(`user '${credentials.name}' created`);
const response3 = await supertest(app)
.get(`/-/user/org.couchdb.user:${username}`)
.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, response.body.token))
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
.expect(HTTP_STATUS.OK);
expect(response3.body.ok).toBe(`you are authenticated as '${credentials.name}'`);
expect(response3.body.name).toBe(username);
}
);
@ -165,5 +184,38 @@ describe('token', () => {
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
.expect(HTTP_STATUS.OK);
});
test.each([['user.yaml'], ['user.jwt.yaml']])(
'should return "false" if user is not logged in',
async (conf) => {
const app = await initializeServer(conf);
const credentials = { name: 'jota', password: '' };
const response = await supertest(app)
.get(`/-/user/org.couchdb.user:${credentials.name}`)
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
.expect(HTTP_STATUS.OK);
expect(response.body.ok).toBe(false);
}
);
test.each([['user.yaml'], ['user.jwt.yaml']])(
'should fail if URL does not match user in request body',
async (conf) => {
const app = await initializeServer(conf);
const credentials = { name: 'jota', password: 'secretPass' };
const response = await createUser(app, credentials.name, credentials.password);
expect(response.body.ok).toMatch(`user '${credentials.name}' created`);
const response2 = await supertest(app)
.put('/-/user/org.couchdb.user:yeti') // different user
.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, response.body.token))
.send({
name: credentials.name,
password: credentials.password,
})
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
.expect(HTTP_STATUS.BAD_REQUEST);
expect(response2.body.error).toBe(API_ERROR.USERNAME_MISMATCH);
}
);
});
});

View File

@ -113,6 +113,7 @@ server:
# https://verdaccio.org/docs/configuration#offline-publish
# publish:
# allow_offline: false
# check_owner: false
# https://verdaccio.org/docs/configuration#url-prefix
# url_prefix: /verdaccio/

View File

@ -119,6 +119,7 @@ server:
# https://verdaccio.org/docs/configuration#offline-publish
# publish:
# allow_offline: false
# check_owner: false
# https://verdaccio.org/docs/configuration#url-prefix
# url_prefix: /verdaccio/

View File

@ -6,6 +6,7 @@ export const TIME_EXPIRATION_1H = '1h';
export const DIST_TAGS = 'dist-tags';
export const LATEST = 'latest';
export const USERS = 'users';
export const MAINTAINERS = 'maintainers';
export const DEFAULT_USER = 'Anonymous';
export const HEADER_TYPE = {

View File

@ -39,6 +39,7 @@ export const API_ERROR = {
BAD_PACKAGE_DATA: 'bad incoming package data',
USERNAME_PASSWORD_REQUIRED: 'username and password is required',
USERNAME_ALREADY_REGISTERED: 'username is already registered',
USERNAME_MISMATCH: 'username does not match logged in user',
};
export const SUPPORT_ERRORS = {

View File

@ -23,6 +23,7 @@ export {
DEFAULT_PASSWORD_VALIDATION,
DEFAULT_USER,
USERS,
MAINTAINERS,
HtpasswdHashAlgorithm,
} from './constants';
const validationUtils = validatioUtils;

View File

@ -2,7 +2,7 @@ import assert from 'assert';
import { Manifest } from '@verdaccio/types';
import { DEFAULT_PASSWORD_VALIDATION, DIST_TAGS } from './constants';
import { DEFAULT_PASSWORD_VALIDATION, DIST_TAGS, MAINTAINERS } from './constants';
export { validatePublishSingleVersion } from './schemes/publish-manifest';
@ -67,7 +67,6 @@ export function validatePackage(name: string): boolean {
* @param {*} manifest
* @param {*} name
* @return {Object} the object with additional properties as dist-tags ad versions
* FUTURE: rename to normalizeMetadata
*/
export function normalizeMetadata(manifest: Manifest, name: string): Manifest {
assert.strictEqual(manifest.name, name);
@ -77,7 +76,11 @@ export function normalizeMetadata(manifest: Manifest, name: string): Manifest {
_manifest[DIST_TAGS] = {};
}
// This may not be nee dit
if (!Array.isArray(manifest[MAINTAINERS])) {
_manifest[MAINTAINERS] = [];
}
// This may not be needed
if (!isObject(manifest['versions'])) {
_manifest['versions'] = {};
}
@ -114,3 +117,11 @@ export function validatePassword(
? password.match(validation) !== null
: false;
}
export function validateUserName(userName: any, expectedName: string): boolean {
return (
typeof userName === 'string' &&
userName.split(':')[0] === 'org.couchdb.user' &&
userName.split(':')[1] === expectedName
);
}

View File

@ -6,6 +6,7 @@ import {
validateName,
validatePackage,
validatePassword,
validateUserName,
} from '../src/validation-utils';
describe('validatePackage', () => {
@ -224,3 +225,17 @@ describe('validatePassword', () => {
expect(validatePassword('1235678910')).toBeTruthy();
});
});
describe('validateUserName', () => {
test('should validate username according to expected name', () => {
expect(validateUserName('org.couchdb.user:test', 'test')).toBeTruthy();
});
test('should fail to validate username if different from expected name', () => {
expect(validateUserName('org.couchdb.user:foouser', 'test')).toBeFalsy();
});
test('should fail to validate username if not given', () => {
expect(validateUserName(undefined, 'test')).toBeFalsy();
});
});

View File

@ -196,6 +196,7 @@ export interface Security {
export interface PublishOptions {
allow_offline: boolean;
check_owners: boolean;
}
export interface ListenAddress {

View File

@ -178,6 +178,7 @@ export interface FullRemoteManifest {
'dist-tags': GenericBody;
time: GenericBody;
versions: Versions;
/** store owners of this package */
maintainers?: Author[];
/** store the latest readme **/
readme?: string;

View File

@ -2,7 +2,7 @@ import _ from 'lodash';
import semver from 'semver';
import { errorUtils, pkgUtils, searchUtils, validatioUtils } from '@verdaccio/core';
import { API_ERROR, DIST_TAGS, HTTP_STATUS, USERS } from '@verdaccio/core';
import { API_ERROR, DIST_TAGS, HTTP_STATUS, MAINTAINERS, USERS } from '@verdaccio/core';
import { AttachMents, Manifest, Version, Versions } from '@verdaccio/types';
import { generateRandomHexString, isNil, isObject } from '@verdaccio/utils';
@ -28,6 +28,7 @@ export function generatePackageTemplate(name: string): Manifest {
time: {},
[USERS]: {},
[DIST_TAGS]: {},
[MAINTAINERS]: [],
_uplinks: {},
_distfiles: {},
_attachments: {},
@ -109,6 +110,7 @@ export const WHITELIST = [
'time',
'_id',
'users',
'maintainers',
];
export function cleanUpLinksRef(result: Manifest, keepUpLinkData?: boolean): Manifest {
@ -290,6 +292,8 @@ export function mergeVersions(cacheManifest: Manifest, remoteManifest: Manifest)
}
}
// TODO: Should we merge owners? _cacheManifest[MAINTAINERS]
return cacheManifest;
}

View File

@ -10,9 +10,11 @@ import { hasProxyTo } from '@verdaccio/config';
import {
API_ERROR,
API_MESSAGE,
DEFAULT_USER,
DIST_TAGS,
HEADER_TYPE,
HTTP_STATUS,
MAINTAINERS,
SUPPORT_ERRORS,
USERS,
errorUtils,
@ -83,7 +85,7 @@ import {
} from './lib/storage-utils';
import { getVersion, removeLowerVersions } from './lib/versions-utils';
import { LocalStorage } from './local-storage';
import { IGetPackageOptionsNext, StarManifestBody } from './type';
import { IGetPackageOptionsNext, OwnerManifestBody, StarManifestBody } from './type';
const debug = buildDebug('verdaccio:storage');
@ -119,7 +121,7 @@ class Storage {
*/
public async changePackage(name: string, metadata: Manifest, revision: string): Promise<void> {
debug('change existing package for package %o revision %o', name, revision);
debug(`change manifest tags for %o revision %s`, name, revision);
debug(`change manifest tags for %o revision %o`, name, revision);
if (
!validatioUtils.isObject(metadata.versions) ||
!validatioUtils.isObject(metadata[DIST_TAGS])
@ -128,7 +130,7 @@ class Storage {
throw errorUtils.getBadData();
}
debug(`change manifest udapting manifest for %o`, name);
debug(`change manifest updating manifest for %o`, name);
await this.updatePackage(name, async (localData: Manifest): Promise<Manifest> => {
// eslint-disable-next-line guard-for-in
for (const version in localData.versions) {
@ -165,13 +167,14 @@ class Storage {
localData[USERS] = metadata[USERS];
localData[DIST_TAGS] = metadata[DIST_TAGS];
localData[MAINTAINERS] = metadata[MAINTAINERS];
return localData;
});
}
public async removePackage(name: string, revision): Promise<void> {
public async removePackage(name: string, revision: string, username: string): Promise<void> {
debug('remove package %o', name);
await this.removePackageByRevision(name, revision);
await this.removePackageByRevision(name, revision, username);
}
/**
@ -181,8 +184,13 @@ class Storage {
versions, i.e. package version should be unpublished first.
Used storage: local (write)
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async removeTarball(name: string, filename: string, _revision: string): Promise<Manifest> {
public async removeTarball(
name: string,
filename: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_revision: string,
username: string
): Promise<Manifest> {
debug('remove tarball %s for %s', filename, name);
assert(validatioUtils.validateName(filename));
const storage: pluginUtils.StorageHandler = this.getPrivatePackageStorage(name);
@ -197,6 +205,9 @@ class Storage {
if (!cacheManifest._attachments[filename]) {
throw errorUtils.getNotFound('no such file available');
}
// check if logged in user is allowed to remove tarball
await this.checkAllowedToChangePackage(cacheManifest, username);
} catch (err: any) {
if (err.code === noSuchFile) {
throw errorUtils.getNotFound();
@ -484,6 +495,17 @@ class Storage {
public async getPackageManifest(options: IGetPackageOptionsNext): Promise<Manifest> {
// convert dist remotes to local bars
const [manifest] = await this.getPackageNext(options);
// If change access is requested (?write=true), then check if logged in user is allowed to change package
if (options.byPassCache === true) {
try {
await this.checkAllowedToChangePackage(manifest, options.requestOptions.username);
} catch (error: any) {
logger.error({ error: error.message }, 'getting package has failed: @{error}');
throw errorUtils.getBadRequest(error.message);
}
}
const convertedManifest = convertDistRemoteToLocalTarballUrls(
manifest,
options.requestOptions,
@ -727,7 +749,11 @@ class Storage {
return results;
}
private async removePackageByRevision(pkgName: string, revision: string): Promise<void> {
private async removePackageByRevision(
pkgName: string,
revision: string,
username: string
): Promise<void> {
const storage: pluginUtils.StorageHandler = this.getPrivatePackageStorage(pkgName);
debug('get package metadata for %o', pkgName);
if (typeof storage === 'undefined') {
@ -751,6 +777,9 @@ class Storage {
// TODO: move this to another method
try {
// check if logged in user is allowed to remove package
await this.checkAllowedToChangePackage(manifest, username);
await this.localStorage.getStoragePlugin().remove(pkgName);
// remove each attachment
const attachments = Object.keys(manifest._attachments);
@ -872,9 +901,10 @@ class Storage {
}
public async updateManifest(
manifest: Manifest | StarManifestBody,
manifest: Manifest | StarManifestBody | OwnerManifestBody,
options: UpdateManifestOptions
): Promise<string | undefined> {
debug('update manifest %o for user %o', manifest._id, options.requestOptions.username);
if (isDeprecatedManifest(manifest as Manifest)) {
// if the manifest is deprecated, we need to update the package.json
await this.deprecate(manifest as Manifest, {
@ -882,13 +912,22 @@ class Storage {
});
} else if (
isPublishablePackage(manifest as Manifest) === false &&
validatioUtils.isObject(manifest.users)
validatioUtils.isObject((manifest as StarManifestBody).users)
) {
// if user request to apply a star to the manifest
await this.star(manifest as StarManifestBody, {
...options,
});
return API_MESSAGE.PKG_CHANGED;
} else if (
isPublishablePackage(manifest as Manifest) === false &&
Array.isArray((manifest as OwnerManifestBody).maintainers)
) {
// if user request to change owners of package
await this.changeOwners(manifest as OwnerManifestBody, {
...options,
});
return API_MESSAGE.PKG_CHANGED;
} else if (validatioUtils.validatePublishSingleVersion(manifest)) {
// if continue, the version to be published does not exist
// we create a new package
@ -909,7 +948,7 @@ class Storage {
return message;
} else {
debug('invalid body format');
logger.info(
logger.warn(
{ packageName: options.name },
`wrong package format on publish a package @{packageName}`
);
@ -970,6 +1009,36 @@ class Storage {
return API_MESSAGE.PKG_CHANGED;
}
private async changeOwners(
manifest: OwnerManifestBody,
options: UpdateManifestOptions
): Promise<string> {
const { maintainers } = manifest;
const { requestOptions, name } = options;
debug('change owners of %o', name);
const { username } = requestOptions;
if (!username) {
throw errorUtils.getBadRequest('update owners only allowed for logged in users');
}
if (!maintainers || maintainers.length === 0) {
throw errorUtils.getBadRequest('maintainers field is required and must not be empty');
}
const localPackage = await this.getPackageManifest({
name,
requestOptions,
uplinksLook: false,
});
await this.changePackage(
name,
{ ...localPackage, maintainers: maintainers as Author[] },
options.revision as string
);
return API_MESSAGE.PKG_CHANGED;
}
/**
* Get local package, on fails return null.
* Errors are considered package not found.
@ -1027,7 +1096,8 @@ class Storage {
options: PublishOptions
): Promise<[Manifest, string, string]> {
const { name } = options;
debug('publishing a new package for %o', name);
const username = options.requestOptions.username;
debug('publishing a new package for %o as %o', name, username);
let successResponseMessage;
const manifest: Manifest = { ...validatioUtils.normalizeMetadata(body, name) };
const { _attachments, versions } = manifest;
@ -1065,14 +1135,15 @@ class Storage {
const hasPackageInStorage = await this.hasPackage(name);
if (!hasPackageInStorage) {
await this.createNewLocalCachePackage(name);
await this.createNewLocalCachePackage(name, username);
successResponseMessage = API_MESSAGE.PKG_CREATED;
} else {
await this.checkAllowedToChangePackage(localManifest as Manifest, username);
successResponseMessage = API_MESSAGE.PKG_CHANGED;
}
} catch (err: any) {
debug('error on change or update a package with %o', err.message);
logger.error({ err: err.message }, 'error on create package: @{err}');
logger.error({ err: err.message }, 'error on publish new version: @{err}');
throw err;
}
@ -1089,7 +1160,6 @@ class Storage {
} catch (err: any) {
logger.error({ err: err.message }, 'updated version has failed: @{err}');
debug('error on create a version for %o with error %o', name, err.message);
// TODO: remove tarball if add version fails
throw err;
}
@ -1106,8 +1176,7 @@ class Storage {
logger.error({ err: err.message }, 'merge version has failed: @{err}');
debug('error on create a version for %o with error %o', name, err.message);
// TODO: undo if this fails
// 1. remove tarball
// 2. remove updated version
// 1. remove updated version
throw err;
}
@ -1119,6 +1188,9 @@ class Storage {
});
} catch (err: any) {
logger.error({ err: err.message }, 'upload tarball has failed: @{err}');
// TODO: undo if this fails
// 1. remove updated version
// 2. remove new dist tags
throw err;
}
@ -1293,11 +1365,14 @@ class Storage {
await this.updatePackage(name, async (data: Manifest): Promise<Manifest> => {
// keep only one readme per package
data.readme = metadata.readme;
debug('%s` readme mutated', name);
debug('%s readme mutated', name);
// TODO: lodash remove
metadata = cleanUpReadme(metadata);
metadata.contributors = normalizeContributors(metadata.contributors as Author[]);
debug('%s` contributors normalized', name);
debug('%s contributors normalized', name);
// Copy current owners to version
metadata.maintainers = data.maintainers;
// Update tarball stats
if (metadata.dist) {
@ -1358,7 +1433,7 @@ class Storage {
tagVersion(data, version, tag);
try {
debug('%s` add on database', name);
debug('%s add on database', name);
await this.localStorage.getStoragePlugin().add(name);
this.logger.debug({ name, version }, 'version @{version} added to database for @{name}');
} catch (err: any) {
@ -1373,7 +1448,10 @@ class Storage {
* @param name name of the package
* @returns
*/
private async createNewLocalCachePackage(name: string): Promise<void> {
private async createNewLocalCachePackage(
name: string,
username: string | undefined
): Promise<void> {
const storage: pluginUtils.StorageHandler = this.getPrivatePackageStorage(name);
if (!storage) {
@ -1390,6 +1468,13 @@ class Storage {
},
};
// Set initial package owner
// TODO: Add email of user
packageData.maintainers =
username && username.length > 0
? [{ name: username, email: '' }]
: [{ name: DEFAULT_USER, email: '' }];
try {
await storage.createPackage(name, packageData);
this.logger.info({ name }, 'created new package @{name}');
@ -1929,6 +2014,21 @@ class Storage {
return { fileCount: version.dist.fileCount, unpackedSize: version.dist.unpackedSize };
}
}
private async checkAllowedToChangePackage(manifest: Manifest, username: string | undefined) {
// Checks to perform if config "publish:check_owners" is true
debug('check if user %o is an owner and allowed to change package', username);
// if name of owner is not included in list of maintainers, then throw an error
if (
this.config?.publish?.check_owners === true &&
manifest.maintainers &&
manifest.maintainers.length > 0 &&
!manifest.maintainers.some((maintainer) => maintainer.name === username)
) {
logger.error({ username }, '@{username} is not a maintainer (package owner)');
throw Error('only owners are allowed to change package');
}
}
}
export { Storage };

View File

@ -62,3 +62,9 @@ export type UpdateManifestOptions = {
* values in the body.
*/
export type StarManifestBody = Pick<Manifest, '_id' | 'users' | '_rev'>;
/**
* When the command `npm owner add/rm` is executed, the body only contains the following
* values in the body.
*/
export type OwnerManifestBody = Pick<Manifest, '_id' | 'maintainers' | '_rev'>;

View File

@ -0,0 +1,18 @@
packages:
'@scope/foo':
access: $all
publish: $authenticated
'@*/*':
access: $all
publish: $all
proxy: ver
'foo':
access: $all
publish: $authenticated
'*':
access: $all
publish: $all
proxy: npmjs
publish:
check_owners: true

View File

@ -0,0 +1,15 @@
packages:
'@scope/foo':
access: $all
publish: $authenticated
'@*/*':
access: $all
publish: $all
proxy: ver
'foo':
access: $all
publish: $authenticated
'*':
access: $all
publish: $all
proxy: npmjs

View File

@ -24,7 +24,14 @@ import {
generateRemotePackageMetadata,
getDeprecatedPackageMetadata,
} from '@verdaccio/test-helper';
import { AbbreviatedManifest, ConfigYaml, Manifest, PackageUsers, Version } from '@verdaccio/types';
import {
AbbreviatedManifest,
Author,
ConfigYaml,
Manifest,
PackageUsers,
Version,
} from '@verdaccio/types';
import { Storage } from '../src';
import manifestFooRemoteNpmjs from './fixtures/manifests/foo-npmjs.json';
@ -89,6 +96,31 @@ const executeStarPackage = async (
});
};
const executeChangeOwners = async (
storage,
options: {
maintainers: Author[];
username: string;
name: string;
_rev: string;
_id?: string;
}
) => {
const { name, _rev, _id, maintainers, username } = options;
const ownerManifest = {
_rev,
_id,
maintainers,
};
return storage.updateManifest(ownerManifest, {
signal: new AbortController().signal,
name,
uplinksLook: true,
revision: '1',
requestOptions: { ...defaultRequestOptions, username },
});
};
describe('storage', () => {
beforeEach(() => {
nock.cleanAll();
@ -657,6 +689,204 @@ describe('storage', () => {
).rejects.toThrow();
});
});
describe('owner', () => {
test.each([
['foo', 'publishWithOwnerDefault.yaml'],
['foo', 'publishWithOwnerAndCheck.yaml'],
])('new package %s, %s (anonymous)', async (pkgName, configFile) => {
const config = getConfig(configFile);
const storage = new Storage(config);
await storage.init(config);
const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0');
await storage.updateManifest(bodyNewManifest, {
signal: new AbortController().signal,
name: pkgName,
uplinksLook: true,
requestOptions: defaultRequestOptions,
});
const manifest = (await storage.getPackageByOptions({
name: pkgName,
uplinksLook: true,
requestOptions: defaultRequestOptions,
})) as Manifest;
expect(manifest?.maintainers).toEqual([{ name: 'Anonymous', email: '' }]);
});
test.each([
['foo', 'publishWithOwnerDefault.yaml'],
['foo', 'publishWithOwnerAndCheck.yaml'],
])('new package %s, %s (logged in)', async (pkgName, configFile) => {
const config = getConfig(configFile);
const storage = new Storage(config);
await storage.init(config);
const owner = { name: 'fooUser', email: '' };
const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0');
const options = { ...defaultRequestOptions, username: owner.name };
await storage.updateManifest(bodyNewManifest, {
signal: new AbortController().signal,
name: pkgName,
uplinksLook: true,
requestOptions: options,
});
const manifest = (await storage.getPackageByOptions({
name: pkgName,
uplinksLook: true,
requestOptions: defaultRequestOptions,
})) as Manifest;
expect(manifest?.maintainers).toEqual([owner]);
expect(manifest?.versions['1.0.0'].maintainers).toEqual([owner]);
});
test.each([
['foo', 'publishWithOwnerDefault.yaml'],
['foo', 'publishWithOwnerAndCheck.yaml'],
])('add/remove owner %s, %s', async (pkgName, configFile) => {
const config = getConfig(configFile);
const storage = new Storage(config);
await storage.init(config);
const firstOwner = { name: 'fooUser', email: '' };
const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0');
const options = { ...defaultRequestOptions, username: firstOwner.name };
await storage.updateManifest(bodyNewManifest, {
signal: new AbortController().signal,
name: pkgName,
uplinksLook: false,
requestOptions: options,
});
// add owner
const secondOwner = { name: 'barUser', email: '' };
const maintainers = [firstOwner, secondOwner];
const message = await executeChangeOwners(storage, {
_rev: bodyNewManifest._rev,
_id: bodyNewManifest._id,
name: pkgName,
username: firstOwner.name,
maintainers: maintainers,
});
expect(message).toEqual(API_MESSAGE.PKG_CHANGED);
const manifest = (await storage.getPackageByOptions({
name: pkgName,
uplinksLook: false,
requestOptions: options,
})) as Manifest;
expect(manifest?.maintainers).toEqual(maintainers);
// published version should not be affected
expect(manifest?.versions['1.0.0'].maintainers).toEqual([firstOwner]);
// remove owner
const maintainers2 = [secondOwner];
const message2 = await executeChangeOwners(storage, {
_rev: bodyNewManifest._rev,
_id: bodyNewManifest._id,
name: pkgName,
username: firstOwner.name,
maintainers: maintainers2,
});
expect(message2).toEqual(API_MESSAGE.PKG_CHANGED);
const manifest2 = (await storage.getPackageByOptions({
name: pkgName,
uplinksLook: false,
requestOptions: options,
})) as Manifest;
expect(manifest2?.maintainers).toEqual(maintainers2);
// published version should not be affected
expect(manifest2?.versions['1.0.0'].maintainers).toEqual([firstOwner]);
});
test.each([
['foo', 'publishWithOwnerDefault.yaml'],
['foo', 'publishWithOwnerAndCheck.yaml'],
])('should fail removing last owner %s, %s', async (pkgName, configFile) => {
const config = getConfig(configFile);
const storage = new Storage(config);
await storage.init(config);
const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0');
const owner = 'fooUser';
const options = { ...defaultRequestOptions, username: owner };
await storage.updateManifest(bodyNewManifest, {
signal: new AbortController().signal,
name: pkgName,
uplinksLook: false,
requestOptions: options,
});
// no owners
await expect(
executeChangeOwners(storage, {
_rev: bodyNewManifest._rev,
_id: bodyNewManifest._id,
name: pkgName,
username: owner,
maintainers: [],
})
).rejects.toThrow();
});
test.each([['foo', 'publishWithOwnerDefault.yaml']])(
'ok to publish as non-owner without check %s, %s',
async (pkgName, configFile) => {
const config = getConfig(configFile);
const storage = new Storage(config);
await storage.init(config);
const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0');
const owner = 'fooUser';
const options = { ...defaultRequestOptions, username: owner };
await storage.updateManifest(bodyNewManifest, {
signal: new AbortController().signal,
name: pkgName,
uplinksLook: false,
requestOptions: options,
});
// try to publish as user who's not an owner
const bodyNewManifest2 = generatePackageMetadata(pkgName, '1.0.1');
const nonOwner = 'barUser';
const options2 = { ...defaultRequestOptions, username: nonOwner };
const message2 = await storage.updateManifest(bodyNewManifest2, {
signal: new AbortController().signal,
name: pkgName,
uplinksLook: false,
requestOptions: options2,
});
expect(message2).toEqual(API_MESSAGE.PKG_CHANGED);
}
);
test.each([['foo', 'publishWithOwnerAndCheck.yaml']])(
'should fail publishing as non-owner with check %s, %s',
async (pkgName, configFile) => {
const config = getConfig(configFile);
const storage = new Storage(config);
await storage.init(config);
const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0');
const owner = 'fooUser';
const options = { ...defaultRequestOptions, username: owner };
await storage.updateManifest(bodyNewManifest, {
signal: new AbortController().signal,
name: pkgName,
uplinksLook: false,
requestOptions: options,
});
// try to publish as user who's not an owner
const bodyNewManifest2 = generatePackageMetadata(pkgName, '1.0.1');
const nonOwner = 'barUser';
const options2 = { ...defaultRequestOptions, username: nonOwner };
await expect(
storage.updateManifest(bodyNewManifest2, {
signal: new AbortController().signal,
name: pkgName,
uplinksLook: false,
requestOptions: options2,
})
).rejects.toThrow();
}
);
});
});
describe('getTarball', () => {
@ -1261,6 +1491,7 @@ describe('storage', () => {
describe('removeTarball', () => {
test('should fail on remove tarball of package does not exist', async () => {
const username = 'foouser';
const config = new Config(
configExample({
...getDefaultConfig(),
@ -1269,7 +1500,7 @@ describe('storage', () => {
);
const storage = new Storage(config);
await storage.init(config);
await expect(storage.removeTarball('foo', 'foo-1.0.0.tgz', 'rev')).rejects.toThrow(
await expect(storage.removeTarball('foo', 'foo-1.0.0.tgz', 'rev', username)).rejects.toThrow(
API_ERROR.NO_PACKAGE
);
});
@ -1277,6 +1508,7 @@ describe('storage', () => {
describe('removePackage', () => {
test('should remove entirely a package', async () => {
const username = 'foouser';
const config = new Config(
configExample({
...getDefaultConfig(),
@ -1321,10 +1553,10 @@ describe('storage', () => {
const _rev = manifest1._rev;
// 3. remove the tarball
await expect(
storage.removeTarball(manifest1.name, 'foo-1.0.0.tgz', _rev)
storage.removeTarball(manifest1.name, 'foo-1.0.0.tgz', _rev, username)
).resolves.toBeDefined();
// 4. remove the package
await storage.removePackage(manifest1.name, _rev);
await storage.removePackage(manifest1.name, _rev, username);
// 5. fails if package does not exist anymore in storage
await expect(
storage.getPackageByOptions({
@ -1338,6 +1570,76 @@ describe('storage', () => {
})
).rejects.toThrow('package does not exist on uplink: foo');
});
test('ok to remove package as non-owner without check', async () => {
const config = getConfig('publishWithOwnerDefault.yaml');
const storage = new Storage(config);
await storage.init(config);
const owner = 'fooUser';
const options = { ...defaultRequestOptions, username: owner };
// 1. publish a package
const bodyNewManifest = generatePackageMetadata('foo', '1.0.0');
await storage.updateManifest(bodyNewManifest, {
signal: new AbortController().signal,
name: 'foo',
uplinksLook: true,
requestOptions: options,
});
// 2. request package (should be available in the local cache)
const manifest1 = (await storage.getPackageByOptions({
name: 'foo',
uplinksLook: false,
requestOptions: options,
})) as Manifest;
const _rev = manifest1._rev;
// 3. remove the tarball as other user
const nonOwner = 'barUser';
await expect(
storage.removeTarball(manifest1.name, 'foo-1.0.0.tgz', _rev, nonOwner)
).resolves.toBeDefined();
// 4. remove the package as other user
await storage.removePackage(manifest1.name, _rev, nonOwner);
// 5. fails if package does not exist anymore in storage
await expect(
storage.getPackageByOptions({
name: 'foo',
uplinksLook: false,
requestOptions: options,
})
).rejects.toThrow('package does not exist on uplink: foo');
});
test('should fail as non-owner with check', async () => {
const config = getConfig('publishWithOwnerAndCheck.yaml');
const storage = new Storage(config);
await storage.init(config);
const owner = 'fooUser';
const options = { ...defaultRequestOptions, username: owner };
// 1. publish a package
const bodyNewManifest = generatePackageMetadata('foo', '1.0.0');
await storage.updateManifest(bodyNewManifest, {
signal: new AbortController().signal,
name: 'foo',
uplinksLook: true,
requestOptions: options,
});
// 2. request package (should be available in the local cache)
const manifest1 = (await storage.getPackageByOptions({
name: 'foo',
uplinksLook: false,
requestOptions: options,
})) as Manifest;
const _rev = manifest1._rev;
// 3. try removing the tarball
const nonOwner = 'barUser';
await expect(
storage.removeTarball(manifest1.name, 'foo-1.0.0.tgz', _rev, nonOwner)
).rejects.toThrow();
// 4. try removing the package
await expect(storage.removePackage(manifest1.name, _rev, nonOwner)).rejects.toThrow();
});
});
describe('getPackageByOptions()', () => {

View File

@ -185,20 +185,29 @@ packages:
### Offline Publish {#offline-publish}
By default `verdaccio` does not allow you to publish packages when the client is offline. This can be can be overridden by setting this value to _true_.
By default `verdaccio` does not allow you to publish packages when the client is offline. This can be overridden by setting this value to _true_.
```yaml
publish:
allow_offline: false
```
### Checking Package Ownership {#chec-owner}
By default, [package access](packages.md) defines who is allowed to publish and unpublish packages. By setting `check_owner` to _true_, only package owners are allowed to make changes to a package. The first owner of a package is the user who published the first version. Further owners can be added or removed using [`npm owner`](https://docs.npmjs.com/cli/v10/commands/npm-owner). You can find the list of current owners in the package manifest under `maintainers`.
```yaml
publish:
check_owner: false
```
<small>Since: `verdaccio@2.3.6` due [#223](https://github.com/verdaccio/verdaccio/pull/223)</small>
### URL Prefix {#url-prefix}
The prefix is intended to be used when the server runs behinds the proxy and won't work properly if is used without a reverse proxy, check the **reverse proxy setup** page for more details.
The internal logic builds correctly the public url, validates the `host` header and and bad shaped `url_prefix`.
The internal logic builds correctly the public url, validates the `host` header and bad shaped `url_prefix`.
eg: `url_prefix: /verdaccio`, `url_prefix: verdaccio/`, `url_prefix: verdaccio` would be `/verdaccio/`