Files
cnpmcore/app/port/UserRoleManager.ts

212 lines
7.4 KiB
TypeScript

import {
type EggContext,
AccessLevel,
ContextProto,
Inject,
} from '@eggjs/tegg';
import type { EggAppConfig, EggLogger } from 'egg';
import { ForbiddenError, UnauthorizedError } from 'egg-errors';
import type { PackageRepository } from '../repository/PackageRepository.js';
import type { Package as PackageEntity } from '../core/entity/Package.js';
import type { User as UserEntity } from '../core/entity/User.js';
import type { Token as TokenEntity } from '../core/entity/Token.js';
import { getScopeAndName } from '../common/PackageUtil.js';
import type { RegistryManagerService } from '../core/service/RegistryManagerService.js';
import type { TokenService } from '../core/service/TokenService.js';
// https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-tokens-on-the-website
export type TokenRole = 'read' | 'publish' | 'setting';
@ContextProto({
// only inject on port module
accessLevel: AccessLevel.PRIVATE,
})
export class UserRoleManager {
@Inject()
private readonly packageRepository: PackageRepository;
@Inject()
private readonly config: EggAppConfig;
@Inject()
protected logger: EggLogger;
@Inject()
private readonly registryManagerService: RegistryManagerService;
@Inject()
private readonly tokenService: TokenService;
private handleAuthorized = false;
private currentAuthorizedUser: UserEntity;
private currentAuthorizedToken: TokenEntity;
// check publish access
// 1. admin has all access
// 2. has published in current registry
// 3. pkg scope is allowed to publish
// use AbstractController#ensurePublishAccess ensure pkg exists;
public async checkPublishAccess(ctx: EggContext, fullname: string) {
const user = await this.requiredAuthorizedUser(ctx, 'publish');
// 1. admin has all access
const isAdmin = await this.isAdmin(ctx);
if (isAdmin) {
return user;
}
// 2. check for checkGranularTokenAccess
const authorizedUserAndToken = await this.getAuthorizedUserAndToken(ctx);
// oxlint-disable-next-line typescript-eslint/no-non-null-assertion
const { token } = authorizedUserAndToken!;
await this.tokenService.checkGranularTokenAccess(token, fullname);
// 3. has published in current registry
const [scope, name] = getScopeAndName(fullname);
const pkg = await this.packageRepository.findPackage(scope, name);
const selfRegistry = await this.registryManagerService.ensureSelfRegistry();
const inSelfRegistry = pkg?.registryId === selfRegistry.registryId;
if (inSelfRegistry) {
// 3.1 check in Maintainers table
// Higher priority than scope check
await this.requiredPackageMaintainer(pkg, user);
return user;
}
if (pkg && !scope && !inSelfRegistry) {
// 3.2 public package can't publish in other registry
// scope package can be migrated into self registry
throw new ForbiddenError(`Can't modify npm public package "${fullname}"`);
}
// 4 check scope is allowed to publish
await this.requiredPackageScope(scope, user);
if (pkg) {
// published scoped package
await this.requiredPackageMaintainer(pkg, user);
}
return user;
}
// {
// 'user-agent': 'npm/8.1.2 node/v16.13.1 darwin arm64 workspaces/false',
// 'npm-command': 'adduser',
// authorization: 'Bearer 379f84d8-ba98-480b-909e-a8260af3a3ee',
// 'content-type': 'application/json',
// accept: '*/*',
// 'content-length': '166',
// 'accept-encoding': 'gzip,deflate',
// host: 'localhost:7001',
// connection: 'keep-alive'
// }
public async getAuthorizedUserAndToken(ctx: EggContext) {
if (this.handleAuthorized) {
if (!this.currentAuthorizedUser) return null;
return {
token: this.currentAuthorizedToken,
user: this.currentAuthorizedUser,
};
}
this.handleAuthorized = true;
const authorization = ctx.get<string>('authorization');
if (!authorization) return null;
const authorizedUserAndToken =
await this.tokenService.getUserAndToken(authorization);
if (!authorizedUserAndToken) {
return null;
}
// check token expired & set lastUsedAt
await this.tokenService.checkTokenStatus(authorizedUserAndToken.token);
this.currentAuthorizedToken = authorizedUserAndToken.token;
this.currentAuthorizedUser = authorizedUserAndToken.user;
ctx.userId = authorizedUserAndToken.user.userId;
return authorizedUserAndToken;
}
public async requiredAuthorizedUser(ctx: EggContext, role: TokenRole) {
const authorizedUserAndToken = await this.getAuthorizedUserAndToken(ctx);
if (!authorizedUserAndToken) {
const authorization = ctx.get('authorization');
const message = authorization ? 'Invalid token' : 'Login first';
throw new UnauthorizedError(message);
}
const { user, token } = authorizedUserAndToken;
// only enable npm client and version check setting will go into this condition
if (
this.config.cnpmcore.enableNpmClientAndVersionCheck &&
role === 'publish'
) {
if (token.isReadonly) {
throw new ForbiddenError(
`Read-only Token "${token.tokenMark}" can't publish`
);
}
const userAgent: string = ctx.get('user-agent');
// only support npm >= 7.0.0 allow publish action
// user-agent: "npm/6.14.12 node/v10.24.1 darwin x64"
// pnpm: "pnpm/10.17.0 npm/? node/v20.19.5 darwin arm64"
const isPnpm = userAgent.startsWith('pnpm/');
const m = /\bnpm\/(\d{1,5})\./.exec(userAgent);
if (m) {
const major = Number.parseInt(m[1]);
if (major < 7) {
throw new ForbiddenError('Only allow npm@>=7.0.0 client to access');
}
} else if (!isPnpm) {
throw new ForbiddenError('Only allow npm client to access');
}
}
if (role === 'setting') {
if (token.isReadonly) {
throw new ForbiddenError(
`Read-only Token "${token.tokenMark}" can't setting`
);
}
if (token.isAutomation) {
throw new ForbiddenError(
`Automation Token "${token.tokenMark}" can't setting`
);
}
}
return user;
}
public async requiredPackageMaintainer(pkg: PackageEntity, user: UserEntity) {
const maintainers = await this.packageRepository.listPackageMaintainers(
pkg.packageId
);
const maintainer = maintainers.find(m => m.userId === user.userId);
if (!maintainer) {
const names = maintainers.map(m => m.name).join(', ');
throw new ForbiddenError(
`"${user.name}" not authorized to modify ${pkg.fullname}, please contact maintainers: "${names}"`
);
}
}
public async requiredPackageScope(scope: string, user: UserEntity) {
const cnpmcoreConfig = this.config.cnpmcore;
if (cnpmcoreConfig.allowPublishNonScopePackage) {
return;
}
const allowScopes = user.scopes ?? cnpmcoreConfig.allowScopes;
if (!scope) {
throw new ForbiddenError(
`Package scope required, legal scopes: "${allowScopes.join(', ')}"`
);
}
if (!allowScopes.includes(scope)) {
throw new ForbiddenError(
`Scope "${scope}" not match legal scopes: "${allowScopes.join(', ')}"`
);
}
}
public async isAdmin(ctx: EggContext) {
const authorizedUserAndToken = await this.getAuthorizedUserAndToken(ctx);
if (!authorizedUserAndToken) return false;
const { user, token } = authorizedUserAndToken;
if (token.isReadonly) return false;
return user.name in this.config.cnpmcore.admins;
}
}