diff --git a/app/common/CryptoUtil.ts b/app/common/CryptoUtil.ts index a051b64af..c86c33f79 100644 --- a/app/common/CryptoUtil.ts +++ b/app/common/CryptoUtil.ts @@ -7,18 +7,16 @@ export function genRSAKeys(): { publicKey: string; privateKey: string } { const key = generateKeyPairSync('rsa', { modulusLength: 512, }); - const publicKey = key.publicKey - .export({ - type: 'pkcs1', - format: 'pem', - }) - .toString('base64'); - const privateKey = key.privateKey - .export({ - type: 'pkcs1', - format: 'pem', - }) - .toString('base64'); + // export({ format: 'pem' }) returns string; .toString('base64') is a no-op on string. + // Use type assertion to fix TS2554 on Node 18 type definitions. + const publicKey = key.publicKey.export({ + type: 'pkcs1', + format: 'pem', + }) as string; + const privateKey = key.privateKey.export({ + type: 'pkcs1', + format: 'pem', + }) as string; return { publicKey, privateKey }; } diff --git a/app/common/constants.ts b/app/common/constants.ts index 443a1d81f..454bfe168 100644 --- a/app/common/constants.ts +++ b/app/common/constants.ts @@ -1,4 +1,5 @@ export const BUG_VERSIONS = 'bug-versions'; +export const DEVELOPERS_TEAM = 'developers'; export const LATEST_TAG = 'latest'; export const GLOBAL_WORKER = 'GLOBAL_WORKER'; export const PROXY_CACHE_DIR_NAME = 'proxy-cache-packages'; diff --git a/app/core/entity/Org.ts b/app/core/entity/Org.ts new file mode 100644 index 000000000..be1f07fd1 --- /dev/null +++ b/app/core/entity/Org.ts @@ -0,0 +1,29 @@ +import { EasyData, EntityUtil } from '../util/EntityUtil.ts'; +import { Entity, EntityData } from './Entity.ts'; + +interface OrgData extends EntityData { + orgId: string; + name: string; + description: string; +} + +export type CreateOrgData = Omit, 'id' | 'description'> & { description?: string }; + +export class Org extends Entity { + orgId: string; + name: string; + description: string; + + constructor(data: OrgData) { + super(data); + this.orgId = data.orgId; + this.name = data.name; + this.description = data.description ?? ''; + } + + static create(data: CreateOrgData): Org { + const fullData = { ...data, description: data.description ?? '' }; + const newData = EntityUtil.defaultData(fullData, 'orgId'); + return new Org(newData); + } +} diff --git a/app/core/entity/OrgMember.ts b/app/core/entity/OrgMember.ts new file mode 100644 index 000000000..1fc0b945a --- /dev/null +++ b/app/core/entity/OrgMember.ts @@ -0,0 +1,31 @@ +import { EasyData, EntityUtil } from '../util/EntityUtil.ts'; +import { Entity, EntityData } from './Entity.ts'; + +interface OrgMemberData extends EntityData { + orgMemberId: string; + orgId: string; + userId: string; + role: 'owner' | 'member'; +} + +export type CreateOrgMemberData = Omit, 'id'>; + +export class OrgMember extends Entity { + orgMemberId: string; + orgId: string; + userId: string; + role: 'owner' | 'member'; + + constructor(data: OrgMemberData) { + super(data); + this.orgMemberId = data.orgMemberId; + this.orgId = data.orgId; + this.userId = data.userId; + this.role = data.role; + } + + static create(data: CreateOrgMemberData): OrgMember { + const newData = EntityUtil.defaultData(data, 'orgMemberId'); + return new OrgMember(newData); + } +} diff --git a/app/core/entity/Team.ts b/app/core/entity/Team.ts new file mode 100644 index 000000000..6835dbdb9 --- /dev/null +++ b/app/core/entity/Team.ts @@ -0,0 +1,32 @@ +import { EasyData, EntityUtil } from '../util/EntityUtil.ts'; +import { Entity, EntityData } from './Entity.ts'; + +interface TeamData extends EntityData { + teamId: string; + orgId: string; + name: string; + description: string; +} + +export type CreateTeamData = Omit, 'id' | 'description'> & { description?: string }; + +export class Team extends Entity { + teamId: string; + orgId: string; + name: string; + description: string; + + constructor(data: TeamData) { + super(data); + this.teamId = data.teamId; + this.orgId = data.orgId; + this.name = data.name; + this.description = data.description ?? ''; + } + + static create(data: CreateTeamData): Team { + const fullData = { ...data, description: data.description ?? '' }; + const newData = EntityUtil.defaultData(fullData, 'teamId'); + return new Team(newData); + } +} diff --git a/app/core/entity/TeamMember.ts b/app/core/entity/TeamMember.ts new file mode 100644 index 000000000..f4096469b --- /dev/null +++ b/app/core/entity/TeamMember.ts @@ -0,0 +1,28 @@ +import { EasyData, EntityUtil } from '../util/EntityUtil.ts'; +import { Entity, EntityData } from './Entity.ts'; + +interface TeamMemberData extends EntityData { + teamMemberId: string; + teamId: string; + userId: string; +} + +export type CreateTeamMemberData = Omit, 'id'>; + +export class TeamMember extends Entity { + teamMemberId: string; + teamId: string; + userId: string; + + constructor(data: TeamMemberData) { + super(data); + this.teamMemberId = data.teamMemberId; + this.teamId = data.teamId; + this.userId = data.userId; + } + + static create(data: CreateTeamMemberData): TeamMember { + const newData = EntityUtil.defaultData(data, 'teamMemberId'); + return new TeamMember(newData); + } +} diff --git a/app/core/entity/TeamPackage.ts b/app/core/entity/TeamPackage.ts new file mode 100644 index 000000000..6c10dfe0b --- /dev/null +++ b/app/core/entity/TeamPackage.ts @@ -0,0 +1,28 @@ +import { EasyData, EntityUtil } from '../util/EntityUtil.ts'; +import { Entity, EntityData } from './Entity.ts'; + +interface TeamPackageData extends EntityData { + teamPackageId: string; + teamId: string; + packageId: string; +} + +export type CreateTeamPackageData = Omit, 'id'>; + +export class TeamPackage extends Entity { + teamPackageId: string; + teamId: string; + packageId: string; + + constructor(data: TeamPackageData) { + super(data); + this.teamPackageId = data.teamPackageId; + this.teamId = data.teamId; + this.packageId = data.packageId; + } + + static create(data: CreateTeamPackageData): TeamPackage { + const newData = EntityUtil.defaultData(data, 'teamPackageId'); + return new TeamPackage(newData); + } +} diff --git a/app/core/service/OrgService.ts b/app/core/service/OrgService.ts new file mode 100644 index 000000000..8936dca79 --- /dev/null +++ b/app/core/service/OrgService.ts @@ -0,0 +1,141 @@ +import { AccessLevel, Inject, SingletonProto } from 'egg'; +import { ForbiddenError, NotFoundError } from 'egg/errors'; + +import { AbstractService } from '../../common/AbstractService.ts'; +import { DEVELOPERS_TEAM } from '../../common/constants.ts'; +import type { OrgRepository } from '../../repository/OrgRepository.ts'; +import type { TeamRepository } from '../../repository/TeamRepository.ts'; +import { Org } from '../entity/Org.ts'; +import { OrgMember } from '../entity/OrgMember.ts'; +import { Team } from '../entity/Team.ts'; +import { TeamMember } from '../entity/TeamMember.ts'; + +export interface CreateOrgCmd { + name: string; + description?: string; + creatorUserId: string; +} + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class OrgService extends AbstractService { + @Inject() + private readonly orgRepository: OrgRepository; + + @Inject() + private readonly teamRepository: TeamRepository; + + async createOrg(cmd: CreateOrgCmd): Promise { + const existing = await this.orgRepository.findOrgByName(cmd.name); + if (existing) { + throw new ForbiddenError(`Org "${cmd.name}" already exists`); + } + + // Create org + developers team + owner + team member in one transaction + const org = Org.create({ + name: cmd.name, + description: cmd.description, + }); + const developersTeam = Team.create({ + orgId: org.orgId, + name: DEVELOPERS_TEAM, + description: 'default team', + }); + const ownerMember = OrgMember.create({ + orgId: org.orgId, + userId: cmd.creatorUserId, + role: 'owner', + }); + const teamMember = TeamMember.create({ + teamId: developersTeam.teamId, + userId: cmd.creatorUserId, + }); + await this.orgRepository.createOrgCascade(org, developersTeam, ownerMember, teamMember); + + this.logger.info( + '[OrgService:createOrg] orgId: %s, name: %s, creatorUserId: %s', + org.orgId, + org.name, + cmd.creatorUserId, + ); + return org; + } + + async removeOrg(orgId: string): Promise { + await this.orgRepository.removeOrgCascade(orgId); + this.logger.info('[OrgService:removeOrg] orgId: %s', orgId); + } + + async findOrgByName(name: string): Promise { + return await this.orgRepository.findOrgByName(name); + } + + // Auto-create org for allowScopes if it doesn't exist + async ensureOrgForScope(scope: string): Promise { + const orgName = scope.replace(/^@/, ''); + const existing = await this.orgRepository.findOrgByName(orgName); + if (existing) return existing; + + const org = Org.create({ + name: orgName, + description: `Auto-created org for scope ${scope}`, + }); + await this.orgRepository.saveOrg(org); + this.logger.info('[OrgService:ensureOrgForScope] orgId: %s, scope: %s', org.orgId, scope); + return org; + } + + async addMember(orgId: string, userId: string, role: 'owner' | 'member' = 'member'): Promise { + const org = await this.orgRepository.findOrgByOrgId(orgId); + if (!org) { + throw new NotFoundError('Org not found'); + } + + // Upsert org member + let member = await this.orgRepository.findMember(orgId, userId); + if (member) { + member.role = role; + await this.orgRepository.saveMember(member); + } else { + member = OrgMember.create({ orgId, userId, role }); + await this.orgRepository.saveMember(member); + } + + // Auto-add to developers team + const developersTeam = await this.teamRepository.findTeam(orgId, DEVELOPERS_TEAM); + if (developersTeam) { + const existingTeamMember = await this.teamRepository.findMember(developersTeam.teamId, userId); + if (!existingTeamMember) { + const teamMember = TeamMember.create({ + teamId: developersTeam.teamId, + userId, + }); + await this.teamRepository.addMember(teamMember); + } + } + + this.logger.info('[OrgService:addMember] orgId: %s, userId: %s, role: %s', orgId, userId, role); + return member; + } + + async removeMember(orgId: string, userId: string): Promise { + // Remove from all teams in this org + await this.teamRepository.removeMemberFromAllTeams(orgId, userId); + // Remove from org + await this.orgRepository.removeMember(orgId, userId); + this.logger.info('[OrgService:removeMember] orgId: %s, userId: %s', orgId, userId); + } + + async listMembers(orgId: string): Promise { + return await this.orgRepository.listMembers(orgId); + } + + async requiredOrgOwnerOrAdmin(orgId: string, userId: string, isAdmin: boolean): Promise { + if (isAdmin) return; + const member = await this.orgRepository.findMember(orgId, userId); + if (!member || member.role !== 'owner') { + throw new ForbiddenError('Only org owner or admin can perform this action'); + } + } +} diff --git a/app/core/service/TeamService.ts b/app/core/service/TeamService.ts new file mode 100644 index 000000000..4664dadb5 --- /dev/null +++ b/app/core/service/TeamService.ts @@ -0,0 +1,112 @@ +import { AccessLevel, Inject, SingletonProto } from 'egg'; +import { ForbiddenError, NotFoundError } from 'egg/errors'; + +import { AbstractService } from '../../common/AbstractService.ts'; +import { DEVELOPERS_TEAM } from '../../common/constants.ts'; +import type { OrgRepository } from '../../repository/OrgRepository.ts'; +import type { TeamRepository } from '../../repository/TeamRepository.ts'; +import { Team } from '../entity/Team.ts'; +import { TeamMember } from '../entity/TeamMember.ts'; +import { TeamPackage } from '../entity/TeamPackage.ts'; + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class TeamService extends AbstractService { + @Inject() + private readonly orgRepository: OrgRepository; + + @Inject() + private readonly teamRepository: TeamRepository; + + async createTeam(orgId: string, name: string, description?: string): Promise { + const existing = await this.teamRepository.findTeam(orgId, name); + if (existing) { + throw new ForbiddenError(`Team "${name}" already exists`); + } + + const team = Team.create({ + orgId, + name, + description, + }); + await this.teamRepository.saveTeam(team); + this.logger.info('[TeamService:createTeam] teamId: %s, orgId: %s, name: %s', team.teamId, orgId, name); + return team; + } + + async removeTeam(teamId: string): Promise { + const team = await this.teamRepository.findTeamByTeamId(teamId); + if (!team) { + throw new NotFoundError('Team not found'); + } + if (team.name === DEVELOPERS_TEAM) { + throw new ForbiddenError('Cannot delete the developers team'); + } + // Cascade: remove packages + members + team in one transaction + await this.teamRepository.removeTeamCascade(teamId); + this.logger.info('[TeamService:removeTeam] teamId: %s', teamId); + } + + async addMember(teamId: string, userId: string): Promise { + const team = await this.teamRepository.findTeamByTeamId(teamId); + if (!team) { + throw new NotFoundError('Team not found'); + } + + // For allowScopes orgs, skip org member check (self-registry users have implicit access) + // For other orgs, must be an org member first + const org = await this.orgRepository.findOrgByOrgId(team.orgId); + if (org && !this.config.cnpmcore.allowScopes.includes(`@${org.name}`)) { + const orgMember = await this.orgRepository.findMember(team.orgId, userId); + if (!orgMember) { + throw new ForbiddenError('User must be an org member before joining a team'); + } + } + + const existing = await this.teamRepository.findMember(teamId, userId); + if (existing) { + return existing; + } + + const member = TeamMember.create({ teamId, userId }); + await this.teamRepository.addMember(member); + this.logger.info('[TeamService:addMember] teamId: %s, userId: %s', teamId, userId); + return member; + } + + async removeMember(teamId: string, userId: string): Promise { + await this.teamRepository.removeMember(teamId, userId); + this.logger.info('[TeamService:removeMember] teamId: %s, userId: %s', teamId, userId); + } + + async listMembers(teamId: string): Promise { + return await this.teamRepository.listMembers(teamId); + } + + async grantPackageAccess(teamId: string, packageId: string): Promise { + const team = await this.teamRepository.findTeamByTeamId(teamId); + if (!team) { + throw new NotFoundError('Team not found'); + } + + const existing = await this.teamRepository.findPackage(teamId, packageId); + if (existing) { + return existing; + } + + const teamPackage = TeamPackage.create({ teamId, packageId }); + await this.teamRepository.addPackage(teamPackage); + this.logger.info('[TeamService:grantPackageAccess] teamId: %s, packageId: %s', teamId, packageId); + return teamPackage; + } + + async revokePackageAccess(teamId: string, packageId: string): Promise { + await this.teamRepository.removePackage(teamId, packageId); + this.logger.info('[TeamService:revokePackageAccess] teamId: %s, packageId: %s', teamId, packageId); + } + + async listPackages(teamId: string): Promise { + return await this.teamRepository.listPackages(teamId); + } +} diff --git a/app/port/UserRoleManager.ts b/app/port/UserRoleManager.ts index 0eb2553d1..0b937aef2 100644 --- a/app/port/UserRoleManager.ts +++ b/app/port/UserRoleManager.ts @@ -8,6 +8,7 @@ import type { User as UserEntity } from '../core/entity/User.ts'; import type { RegistryManagerService } from '../core/service/RegistryManagerService.ts'; import type { TokenService } from '../core/service/TokenService.ts'; import type { PackageRepository } from '../repository/PackageRepository.ts'; +import type { TeamRepository } from '../repository/TeamRepository.ts'; // https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-tokens-on-the-website export type TokenRole = 'read' | 'publish' | 'setting'; @@ -27,6 +28,8 @@ export class UserRoleManager { private readonly registryManagerService: RegistryManagerService; @Inject() private readonly tokenService: TokenService; + @Inject() + private readonly teamRepository: TeamRepository; private handleAuthorized = false; private currentAuthorizedUser: UserEntity; @@ -186,4 +189,26 @@ export class UserRoleManager { if (token.isReadonly) return false; return user.name in this.config.cnpmcore.admins; } + + // self scope + no team binding = everyone can read, returns false + // self scope + team binding = only team members can read, returns true + // returns true if package is team-bound (private cache needed) + public async checkReadAccess(ctx: Context, scope: string, name: string): Promise { + if (!scope || !this.config.cnpmcore.allowScopes.includes(scope)) return false; + + const pkg = await this.packageRepository.findPackage(scope, name); + if (!pkg) return false; // let downstream throw 404 + + const hasTeamBinding = await this.teamRepository.hasAnyTeamBinding(pkg.packageId); + if (!hasTeamBinding) return false; // no team binding, everyone can read + + // team binding exists, require auth + const user = await this.requiredAuthorizedUser(ctx, 'read'); + if (await this.isAdmin(ctx)) return true; + + const hasAccess = await this.teamRepository.hasPackageAccess(pkg.packageId, user.userId); + if (hasAccess) return true; + + throw new ForbiddenError(`"${user.name}" is not authorized to access ${pkg.fullname}`); + } } diff --git a/app/port/controller/OrgController.ts b/app/port/controller/OrgController.ts new file mode 100644 index 000000000..17d792afa --- /dev/null +++ b/app/port/controller/OrgController.ts @@ -0,0 +1,173 @@ +import { + Context, + HTTPBody, + HTTPContext, + HTTPController, + HTTPMethod, + HTTPMethodEnum, + HTTPParam, + Inject, + Middleware, +} from 'egg'; +import { NotFoundError, UnprocessableEntityError } from 'egg/errors'; + +import type { OrgService } from '../../core/service/OrgService.ts'; +import type { TeamRepository } from '../../repository/TeamRepository.ts'; +import { AdminAccess } from '../middleware/AdminAccess.ts'; +import { AbstractController } from './AbstractController.ts'; + +@HTTPController() +export class OrgController extends AbstractController { + @Inject() + private readonly orgService: OrgService; + + @Inject() + private readonly teamRepository: TeamRepository; + + // PUT /-/org — Admin only + @HTTPMethod({ + path: '/-/org', + method: HTTPMethodEnum.PUT, + }) + @Middleware(AdminAccess) + async createOrg(@HTTPContext() ctx: Context, @HTTPBody() body: { name: string; description?: string }) { + const authorizedUser = await this.userRoleManager.requiredAuthorizedUser(ctx, 'setting'); + if (!body.name) { + throw new UnprocessableEntityError('name is required'); + } + await this.orgService.createOrg({ + name: body.name, + description: body.description, + creatorUserId: authorizedUser.userId, + }); + return { ok: true }; + } + + // GET /-/org/:orgName + @HTTPMethod({ + path: '/-/org/:orgName', + method: HTTPMethodEnum.GET, + }) + async showOrg(@HTTPContext() ctx: Context, @HTTPParam() orgName: string) { + await this.userRoleManager.requiredAuthorizedUser(ctx, 'read'); + const org = await this.orgService.findOrgByName(orgName); + if (!org) { + throw new NotFoundError(`Org "${orgName}" not found`); + } + return { + name: org.name, + description: org.description, + created: org.createdAt, + }; + } + + // DELETE /-/org/:orgName — Admin only + @HTTPMethod({ + path: '/-/org/:orgName', + method: HTTPMethodEnum.DELETE, + }) + @Middleware(AdminAccess) + async removeOrg(@HTTPContext() ctx: Context, @HTTPParam() orgName: string) { + await this.userRoleManager.requiredAuthorizedUser(ctx, 'setting'); + const org = await this.orgService.findOrgByName(orgName); + if (!org) { + throw new NotFoundError(`Org "${orgName}" not found`); + } + await this.orgService.removeOrg(org.orgId); + return { ok: true }; + } + + // GET /-/org/:orgName/member — npm org ls + @HTTPMethod({ + path: '/-/org/:orgName/member', + method: HTTPMethodEnum.GET, + }) + async listMembers(@HTTPContext() ctx: Context, @HTTPParam() orgName: string) { + await this.userRoleManager.requiredAuthorizedUser(ctx, 'read'); + const org = await this.orgService.findOrgByName(orgName); + if (!org) { + throw new NotFoundError(`Org "${orgName}" not found`); + } + const members = await this.orgService.listMembers(org.orgId); + const users = await this.userRepository.findUsersByUserIds(members.map((m) => m.userId)); + const userMap = new Map(users.map((u) => [u.userId, u])); + const result: Record = {}; + for (const member of members) { + const user = userMap.get(member.userId); + if (user) { + result[user.displayName] = member.role; + } + } + return result; + } + + // PUT /-/org/:orgName/member — npm org set + @HTTPMethod({ + path: '/-/org/:orgName/member', + method: HTTPMethodEnum.PUT, + }) + async addMember( + @HTTPContext() ctx: Context, + @HTTPParam() orgName: string, + @HTTPBody() body: { user: string; role?: 'owner' | 'member' }, + ) { + const authorizedUser = await this.userRoleManager.requiredAuthorizedUser(ctx, 'setting'); + const org = await this.orgService.findOrgByName(orgName); + if (!org) { + throw new NotFoundError(`Org "${orgName}" not found`); + } + const isAdmin = await this.userRoleManager.isAdmin(ctx); + await this.orgService.requiredOrgOwnerOrAdmin(org.orgId, authorizedUser.userId, isAdmin); + + if (!body.user) { + throw new UnprocessableEntityError('user is required'); + } + const targetUser = await this.userRepository.findUserByName(body.user); + if (!targetUser) { + throw new NotFoundError(`User "${body.user}" not found`); + } + await this.orgService.addMember(org.orgId, targetUser.userId, body.role || 'member'); + return { ok: true }; + } + + // DELETE /-/org/:orgName/member/:username — npm org rm + @HTTPMethod({ + path: '/-/org/:orgName/member/:username', + method: HTTPMethodEnum.DELETE, + }) + async removeMember(@HTTPContext() ctx: Context, @HTTPParam() orgName: string, @HTTPParam() username: string) { + const authorizedUser = await this.userRoleManager.requiredAuthorizedUser(ctx, 'setting'); + const org = await this.orgService.findOrgByName(orgName); + if (!org) { + throw new NotFoundError(`Org "${orgName}" not found`); + } + const isAdmin = await this.userRoleManager.isAdmin(ctx); + await this.orgService.requiredOrgOwnerOrAdmin(org.orgId, authorizedUser.userId, isAdmin); + + const targetUser = await this.userRepository.findUserByName(username); + if (!targetUser) { + throw new NotFoundError(`User "${username}" not found`); + } + await this.orgService.removeMember(org.orgId, targetUser.userId); + return { ok: true }; + } + + // GET /-/org/:orgName/member/:username/team + @HTTPMethod({ + path: '/-/org/:orgName/member/:username/team', + method: HTTPMethodEnum.GET, + }) + async listUserTeams(@HTTPContext() ctx: Context, @HTTPParam() orgName: string, @HTTPParam() username: string) { + await this.userRoleManager.requiredAuthorizedUser(ctx, 'read'); + const org = await this.orgService.findOrgByName(orgName); + if (!org) { + throw new NotFoundError(`Org "${orgName}" not found`); + } + const targetUser = await this.userRepository.findUserByName(username); + if (!targetUser) { + throw new NotFoundError(`User "${username}" not found`); + } + const teams = await this.teamRepository.listTeamsByUserIdAndOrgId(targetUser.userId, org.orgId); + return teams.map((t) => ({ name: t.name, description: t.description })); + } +} diff --git a/app/port/controller/PackageVersionFileController.ts b/app/port/controller/PackageVersionFileController.ts index fd78a4c7f..1ba2d29ae 100644 --- a/app/port/controller/PackageVersionFileController.ts +++ b/app/port/controller/PackageVersionFileController.ts @@ -108,6 +108,7 @@ export class PackageVersionFileController extends AbstractController { ctx.vary(this.config.cnpmcore.cdnVaryHeader); ctx.set('cross-origin-resource-policy', 'cross-origin'); const [scope, name] = getScopeAndName(fullname); + await this.userRoleManager.checkReadAccess(ctx, scope, name); const packageVersion = await this.#getPackageVersion(ctx, fullname, scope, name, versionSpec); ctx.set('cache-control', META_CACHE_CONTROL); const hasMeta = typeof meta === 'string' || ctx.path.endsWith('/files/'); @@ -150,6 +151,7 @@ export class PackageVersionFileController extends AbstractController { ctx.vary(this.config.cnpmcore.cdnVaryHeader); ctx.set('cross-origin-resource-policy', 'cross-origin'); const [scope, name] = getScopeAndName(fullname); + await this.userRoleManager.checkReadAccess(ctx, scope, name); // oxlint-disable-next-line no-param-reassign path = `/${path}`; const packageVersion = await this.#getPackageVersion(ctx, fullname, scope, name, versionSpec); diff --git a/app/port/controller/TeamController.ts b/app/port/controller/TeamController.ts new file mode 100644 index 000000000..588c15ba0 --- /dev/null +++ b/app/port/controller/TeamController.ts @@ -0,0 +1,272 @@ +import { Context, HTTPBody, HTTPContext, HTTPController, HTTPMethod, HTTPMethodEnum, HTTPParam, Inject } from 'egg'; +import { NotFoundError, UnprocessableEntityError } from 'egg/errors'; + +import { getScopeAndName } from '../../common/PackageUtil.ts'; +import type { OrgService } from '../../core/service/OrgService.ts'; +import type { TeamService } from '../../core/service/TeamService.ts'; +import type { TeamRepository } from '../../repository/TeamRepository.ts'; +import { AbstractController } from './AbstractController.ts'; + +@HTTPController() +export class TeamController extends AbstractController { + @Inject() + private readonly orgService: OrgService; + + @Inject() + private readonly teamService: TeamService; + + @Inject() + private readonly teamRepository: TeamRepository; + + private isAllowScopeOrg(orgName: string): boolean { + return this.config.cnpmcore.allowScopes.includes(`@${orgName}`); + } + + // For allowScopes orgs, auto-ensure; for others, just look up + private async findOrg(orgName: string) { + if (this.isAllowScopeOrg(orgName)) { + return await this.orgService.ensureOrgForScope(`@${orgName}`); + } + return await this.orgService.findOrgByName(orgName); + } + + private async requireOrgWriteAccess(ctx: Context, orgName: string) { + const authorizedUser = await this.userRoleManager.requiredAuthorizedUser(ctx, 'setting'); + + if (this.isAllowScopeOrg(orgName)) { + // allowScopes org: any authenticated user can operate, auto-ensure org + const org = await this.orgService.ensureOrgForScope(`@${orgName}`); + return { org, authorizedUser }; + } + + // Non-allowScopes org: admin or org owner only + const org = await this.orgService.findOrgByName(orgName); + if (!org) { + throw new NotFoundError(`Org "${orgName}" not found`); + } + const isAdmin = await this.userRoleManager.isAdmin(ctx); + await this.orgService.requiredOrgOwnerOrAdmin(org.orgId, authorizedUser.userId, isAdmin); + return { org, authorizedUser }; + } + + private async requireTeamWriteAccess(ctx: Context, orgName: string, teamName: string) { + const { org, authorizedUser } = await this.requireOrgWriteAccess(ctx, orgName); + const team = await this.teamRepository.findTeam(org.orgId, teamName); + if (!team) { + throw new NotFoundError(`Team "${teamName}" not found`); + } + return { org, team, authorizedUser }; + } + + // --- Team CRUD --- + + // npm team create @scope:team → PUT /-/org/:orgName/team + @HTTPMethod({ + path: '/-/org/:orgName/team', + method: HTTPMethodEnum.PUT, + }) + async createTeam( + @HTTPContext() ctx: Context, + @HTTPParam() orgName: string, + @HTTPBody() body: { name: string; description?: string }, + ) { + const { org } = await this.requireOrgWriteAccess(ctx, orgName); + + if (!body.name) { + throw new UnprocessableEntityError('name is required'); + } + await this.teamService.createTeam(org.orgId, body.name, body.description); + return { ok: true }; + } + + // npm team ls @scope → GET /-/org/:orgName/team + @HTTPMethod({ + path: '/-/org/:orgName/team', + method: HTTPMethodEnum.GET, + }) + async listTeams(@HTTPContext() ctx: Context, @HTTPParam() orgName: string) { + await this.userRoleManager.requiredAuthorizedUser(ctx, 'read'); + const org = await this.findOrg(orgName); + if (!org) { + throw new NotFoundError(`Org "${orgName}" not found`); + } + const teams = await this.teamRepository.listTeamsByOrgId(org.orgId); + // npm CLI adds @ prefix itself, return "scope:teamname" format + return teams.map((t) => `${orgName}:${t.name}`); + } + + // GET /-/team/:orgName/:teamName (npm compatible show) + @HTTPMethod({ + path: '/-/team/:orgName/:teamName', + method: HTTPMethodEnum.GET, + }) + async showTeam(@HTTPContext() ctx: Context, @HTTPParam() orgName: string, @HTTPParam() teamName: string) { + await this.userRoleManager.requiredAuthorizedUser(ctx, 'read'); + const org = await this.findOrg(orgName); + if (!org) { + throw new NotFoundError(`Org "${orgName}" not found`); + } + const team = await this.teamRepository.findTeam(org.orgId, teamName); + if (!team) { + throw new NotFoundError(`Team "${teamName}" not found`); + } + return { + name: team.name, + description: team.description, + created: team.createdAt, + }; + } + + // npm team destroy @scope:team → DELETE /-/team/:orgName/:teamName + @HTTPMethod({ + path: '/-/team/:orgName/:teamName', + method: HTTPMethodEnum.DELETE, + }) + async removeTeam(@HTTPContext() ctx: Context, @HTTPParam() orgName: string, @HTTPParam() teamName: string) { + const { team } = await this.requireTeamWriteAccess(ctx, orgName, teamName); + await this.teamService.removeTeam(team.teamId); + return { ok: true }; + } + + // --- Team Members (npm uses "user") --- + + // npm team ls @scope:team → GET /-/team/:orgName/:teamName/user + @HTTPMethod({ + path: '/-/team/:orgName/:teamName/user', + method: HTTPMethodEnum.GET, + }) + async listTeamMembers(@HTTPContext() ctx: Context, @HTTPParam() orgName: string, @HTTPParam() teamName: string) { + await this.userRoleManager.requiredAuthorizedUser(ctx, 'read'); + const org = await this.findOrg(orgName); + if (!org) { + throw new NotFoundError(`Org "${orgName}" not found`); + } + const team = await this.teamRepository.findTeam(org.orgId, teamName); + if (!team) { + throw new NotFoundError(`Team "${teamName}" not found`); + } + const members = await this.teamService.listMembers(team.teamId); + const users = await this.userRepository.findUsersByUserIds(members.map((m) => m.userId)); + return users.map((u) => u.displayName); + } + + // npm team add @scope:team → PUT /-/team/:orgName/:teamName/user + @HTTPMethod({ + path: '/-/team/:orgName/:teamName/user', + method: HTTPMethodEnum.PUT, + }) + async addTeamMember( + @HTTPContext() ctx: Context, + @HTTPParam() orgName: string, + @HTTPParam() teamName: string, + @HTTPBody() body: { user: string }, + ) { + const { team } = await this.requireTeamWriteAccess(ctx, orgName, teamName); + if (!body.user) { + throw new UnprocessableEntityError('user is required'); + } + const targetUser = await this.userRepository.findUserByName(body.user); + if (!targetUser) { + throw new NotFoundError(`User "${body.user}" not found`); + } + await this.teamService.addMember(team.teamId, targetUser.userId); + return { ok: true }; + } + + // npm team rm @scope:team → DELETE /-/team/:orgName/:teamName/user body:{user} + @HTTPMethod({ + path: '/-/team/:orgName/:teamName/user', + method: HTTPMethodEnum.DELETE, + }) + async removeTeamMember( + @HTTPContext() ctx: Context, + @HTTPParam() orgName: string, + @HTTPParam() teamName: string, + @HTTPBody() body: { user: string }, + ) { + const { team } = await this.requireTeamWriteAccess(ctx, orgName, teamName); + if (!body.user) { + throw new UnprocessableEntityError('user is required'); + } + const targetUser = await this.userRepository.findUserByName(body.user); + if (!targetUser) { + throw new NotFoundError(`User "${body.user}" not found`); + } + await this.teamService.removeMember(team.teamId, targetUser.userId); + return { ok: true }; + } + + // --- Team Packages --- + + // npm access ls-packages @scope:team → GET /-/team/:orgName/:teamName/package + @HTTPMethod({ + path: '/-/team/:orgName/:teamName/package', + method: HTTPMethodEnum.GET, + }) + async listTeamPackages(@HTTPContext() ctx: Context, @HTTPParam() orgName: string, @HTTPParam() teamName: string) { + await this.userRoleManager.requiredAuthorizedUser(ctx, 'read'); + const org = await this.findOrg(orgName); + if (!org) { + throw new NotFoundError(`Org "${orgName}" not found`); + } + const team = await this.teamRepository.findTeam(org.orgId, teamName); + if (!team) { + throw new NotFoundError(`Team "${teamName}" not found`); + } + const teamPackages = await this.teamService.listPackages(team.teamId); + const pkgs = await this.packageRepository.findPackagesByPackageIds(teamPackages.map((tp) => tp.packageId)); + const result: Record = {}; + for (const pkg of pkgs) { + result[pkg.fullname] = 'read'; + } + return result; + } + + // npm access grant read-only @scope:team → PUT /-/team/:orgName/:teamName/package + @HTTPMethod({ + path: '/-/team/:orgName/:teamName/package', + method: HTTPMethodEnum.PUT, + }) + async grantPackageAccess( + @HTTPContext() ctx: Context, + @HTTPParam() orgName: string, + @HTTPParam() teamName: string, + @HTTPBody() body: { package: string }, + ) { + const { team } = await this.requireTeamWriteAccess(ctx, orgName, teamName); + if (!body.package) { + throw new UnprocessableEntityError('package is required'); + } + const [scope, name] = getScopeAndName(body.package); + const pkg = await this.packageRepository.findPackage(scope, name); + if (!pkg) { + throw new NotFoundError(`Package "${body.package}" not found`); + } + await this.teamService.grantPackageAccess(team.teamId, pkg.packageId); + return { ok: true }; + } + + // npm access revoke @scope:team → DELETE /-/team/:orgName/:teamName/package body:{package} + @HTTPMethod({ + path: '/-/team/:orgName/:teamName/package', + method: HTTPMethodEnum.DELETE, + }) + async revokePackageAccess( + @HTTPContext() ctx: Context, + @HTTPParam() orgName: string, + @HTTPParam() teamName: string, + @HTTPBody() body: { package: string }, + ) { + const { team } = await this.requireTeamWriteAccess(ctx, orgName, teamName); + if (!body.package) { + throw new UnprocessableEntityError('package is required'); + } + const [scope, name] = getScopeAndName(body.package); + const pkg = await this.packageRepository.findPackage(scope, name); + if (!pkg) { + throw new NotFoundError(`Package "${body.package}" not found`); + } + await this.teamService.revokePackageAccess(team.teamId, pkg.packageId); + return { ok: true }; + } +} diff --git a/app/port/controller/package/DownloadPackageVersionTar.ts b/app/port/controller/package/DownloadPackageVersionTar.ts index 59478f4dc..ee0e3ccd3 100644 --- a/app/port/controller/package/DownloadPackageVersionTar.ts +++ b/app/port/controller/package/DownloadPackageVersionTar.ts @@ -43,6 +43,8 @@ export class DownloadPackageVersionTarController extends AbstractController { method: HTTPMethodEnum.GET, }) async download(@HTTPContext() ctx: Context, @HTTPParam() fullname: string, @HTTPParam() filenameWithVersion: string) { + const [scope, name] = getScopeAndName(fullname); + await this.userRoleManager.checkReadAccess(ctx, scope, name); // tgz file storeKey: `/packages/${this.fullname}/${version}/${filename}` const version = this.getAndCheckVersionFromFilename(ctx, fullname, filenameWithVersion); const storeKey = `/packages/${fullname}/${version}/${filenameWithVersion}.tgz`; diff --git a/app/port/controller/package/ShowPackageController.ts b/app/port/controller/package/ShowPackageController.ts index c02dba992..c4832dd3f 100644 --- a/app/port/controller/package/ShowPackageController.ts +++ b/app/port/controller/package/ShowPackageController.ts @@ -21,6 +21,14 @@ export class ShowPackageController extends AbstractController { @Inject() private bugVersionService: BugVersionService; + private setCacheHeaders(ctx: Context, isTeamBound: boolean) { + if (isTeamBound) { + ctx.set('cache-control', 'private, no-store'); + } else { + this.setCDNHeaders(ctx); + } + } + @HTTPMethod({ // GET /:fullname // https://www.npmjs.com/package/path-to-regexp#custom-matching-parameters @@ -28,6 +36,8 @@ export class ShowPackageController extends AbstractController { method: HTTPMethodEnum.GET, }) async show(@HTTPContext() ctx: Context, @HTTPParam() fullname: string) { + const [scope, name] = getScopeAndName(fullname); + const isTeamBound = await this.userRoleManager.checkReadAccess(ctx, scope, name); const isSync = isSyncWorkerRequest(ctx); const isFullManifests = ctx.accepts(['json', ABBREVIATED_META_TYPE]) !== ABBREVIATED_META_TYPE; @@ -42,7 +52,7 @@ export class ShowPackageController extends AbstractController { } if (requestEtag === cacheEtag) { // make sure CDN cache header set here - this.setCDNHeaders(ctx); + this.setCacheHeaders(ctx, isTeamBound); // match etag, set status 304 ctx.status = 304; return; @@ -52,7 +62,7 @@ export class ShowPackageController extends AbstractController { if (cacheBytes && cacheBytes.length > 0) { ctx.set('etag', `W/${cacheEtag}`); ctx.type = 'json'; - this.setCDNHeaders(ctx); + this.setCacheHeaders(ctx, isTeamBound); return cacheBytes; } } @@ -74,7 +84,7 @@ export class ShowPackageController extends AbstractController { throw this.createPackageNotFoundErrorWithRedirect(fullname, undefined, allowSync); } if (blockReason) { - this.setCDNHeaders(ctx); + this.setCacheHeaders(ctx, isTeamBound); throw this.createPackageBlockError(blockReason, fullname); } @@ -91,7 +101,7 @@ export class ShowPackageController extends AbstractController { // should set weak etag avoid nginx remove it ctx.set('etag', `W/${etag}`); ctx.type = 'json'; - this.setCDNHeaders(ctx); + this.setCacheHeaders(ctx, isTeamBound); return cacheBytes; } diff --git a/app/port/controller/package/ShowPackageVersionController.ts b/app/port/controller/package/ShowPackageVersionController.ts index 974534f92..4c9cfbcfe 100644 --- a/app/port/controller/package/ShowPackageVersionController.ts +++ b/app/port/controller/package/ShowPackageVersionController.ts @@ -26,6 +26,7 @@ export class ShowPackageVersionController extends AbstractController { // https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#full-metadata-format ctx.tValidate(Spec, `${fullname}@${versionSpec}`); const [scope, name] = getScopeAndName(fullname); + await this.userRoleManager.checkReadAccess(ctx, scope, name); const isSync = isSyncWorkerRequest(ctx); const isFullManifests = ctx.accepts(['json', ABBREVIATED_META_TYPE]) !== ABBREVIATED_META_TYPE; diff --git a/app/repository/OrgRepository.ts b/app/repository/OrgRepository.ts new file mode 100644 index 000000000..896fc336c --- /dev/null +++ b/app/repository/OrgRepository.ts @@ -0,0 +1,118 @@ +import { AccessLevel, Inject, SingletonProto } from 'egg'; + +import { Org } from '../core/entity/Org.ts'; +import { OrgMember } from '../core/entity/OrgMember.ts'; +import { Team } from '../core/entity/Team.ts'; +import { TeamMember } from '../core/entity/TeamMember.ts'; +import { AbstractRepository } from './AbstractRepository.ts'; +import { Org as OrgModel } from './model/Org.ts'; +import { OrgMember as OrgMemberModel } from './model/OrgMember.ts'; +import { Team as TeamModel } from './model/Team.ts'; +import { TeamMember as TeamMemberModel } from './model/TeamMember.ts'; +import { TeamPackage as TeamPackageModel } from './model/TeamPackage.ts'; +import { ModelConvertor } from './util/ModelConvertor.ts'; + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class OrgRepository extends AbstractRepository { + @Inject() + private readonly Org: typeof OrgModel; + + @Inject() + private readonly OrgMember: typeof OrgMemberModel; + + @Inject() + private readonly Team: typeof TeamModel; + + @Inject() + private readonly TeamMember: typeof TeamMemberModel; + + @Inject() + private readonly TeamPackage: typeof TeamPackageModel; + + async findOrgByName(name: string): Promise { + const model = await this.Org.findOne({ name }); + if (!model) return null; + return ModelConvertor.convertModelToEntity(model, Org); + } + + async findOrgByOrgId(orgId: string): Promise { + const model = await this.Org.findOne({ orgId }); + if (!model) return null; + return ModelConvertor.convertModelToEntity(model, Org); + } + + async saveOrg(org: Org): Promise { + if (org.id) { + const model = await this.Org.findOne({ id: org.id }); + if (model) { + await ModelConvertor.saveEntityToModel(org, model); + } + return; + } + await ModelConvertor.convertEntityToModel(org, this.Org); + } + + async removeOrg(orgId: string): Promise { + await this.Org.remove({ orgId }); + } + + async findMember(orgId: string, userId: string): Promise { + const model = await this.OrgMember.findOne({ orgId, userId }); + if (!model) return null; + return ModelConvertor.convertModelToEntity(model, OrgMember); + } + + async saveMember(member: OrgMember): Promise { + if (member.id) { + const model = await this.OrgMember.findOne({ id: member.id }); + if (model) { + await ModelConvertor.saveEntityToModel(member, model); + } + return; + } + await ModelConvertor.convertEntityToModel(member, this.OrgMember); + } + + async removeMember(orgId: string, userId: string): Promise { + await this.OrgMember.remove({ orgId, userId }); + } + + async listMembers(orgId: string): Promise { + const models = await this.OrgMember.find({ orgId }); + return models.map((model) => ModelConvertor.convertModelToEntity(model, OrgMember)); + } + + async removeAllMembers(orgId: string): Promise { + await this.OrgMember.remove({ orgId }); + } + + async createOrgCascade( + org: Org, + developersTeam: Team, + ownerMember: OrgMember, + teamMember: TeamMember, + ): Promise { + await this.Org.transaction(async ({ connection }) => { + await ModelConvertor.convertEntityToModel(org, this.Org, { connection }); + await ModelConvertor.convertEntityToModel(developersTeam, this.Team, { connection }); + await ModelConvertor.convertEntityToModel(ownerMember, this.OrgMember, { connection }); + await ModelConvertor.convertEntityToModel(teamMember, this.TeamMember, { connection }); + }); + } + + async removeOrgCascade(orgId: string): Promise { + const teams = await this.Team.find({ orgId }); + const teamIds = teams.map((t) => t.teamId); + await this.Org.transaction(async ({ connection }) => { + if (teamIds.length > 0) { + await this.TeamPackage.remove({ teamId: { $in: teamIds } }, true, { connection }); + await this.TeamMember.remove({ teamId: { $in: teamIds } }, true, { connection }); + } + await this.Team.remove({ orgId }, true, { connection }); + await this.OrgMember.remove({ orgId }, true, { connection }); + await this.Org.remove({ orgId }, true, { connection }); + }); + } +} diff --git a/app/repository/PackageRepository.ts b/app/repository/PackageRepository.ts index 39a91b54e..93a70dd1a 100644 --- a/app/repository/PackageRepository.ts +++ b/app/repository/PackageRepository.ts @@ -251,6 +251,12 @@ export class PackageRepository extends AbstractRepository { return await this.#convertPackageModelToEntity(model); } + async findPackagesByPackageIds(packageIds: string[]): Promise { + if (packageIds.length === 0) return []; + const models = await this.Package.find({ packageId: packageIds }); + return await Promise.all(models.map((model) => this.#convertPackageModelToEntity(model))); + } + async findPackageId(scope: string, name: string) { const model = await this.Package.findOne({ scope, name }).select('packageId'); if (!model) return null; diff --git a/app/repository/TeamRepository.ts b/app/repository/TeamRepository.ts new file mode 100644 index 000000000..073363b6f --- /dev/null +++ b/app/repository/TeamRepository.ts @@ -0,0 +1,181 @@ +import { AccessLevel, Inject, SingletonProto } from 'egg'; + +import { Team } from '../core/entity/Team.ts'; +import { TeamMember } from '../core/entity/TeamMember.ts'; +import { TeamPackage } from '../core/entity/TeamPackage.ts'; +import { AbstractRepository } from './AbstractRepository.ts'; +import { Team as TeamModel } from './model/Team.ts'; +import { TeamMember as TeamMemberModel } from './model/TeamMember.ts'; +import { TeamPackage as TeamPackageModel } from './model/TeamPackage.ts'; +import { ModelConvertor } from './util/ModelConvertor.ts'; + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class TeamRepository extends AbstractRepository { + @Inject() + private readonly Team: typeof TeamModel; + + @Inject() + private readonly TeamMember: typeof TeamMemberModel; + + @Inject() + private readonly TeamPackage: typeof TeamPackageModel; + + // --- Team CRUD --- + + async findTeam(orgId: string, name: string): Promise { + const model = await this.Team.findOne({ orgId, name }); + if (!model) return null; + return ModelConvertor.convertModelToEntity(model, Team); + } + + async findTeamByTeamId(teamId: string): Promise { + const model = await this.Team.findOne({ teamId }); + if (!model) return null; + return ModelConvertor.convertModelToEntity(model, Team); + } + + async listTeamsByOrgId(orgId: string): Promise { + const models = await this.Team.find({ orgId }); + return models.map((model) => ModelConvertor.convertModelToEntity(model, Team)); + } + + async saveTeam(team: Team): Promise { + if (team.id) { + const model = await this.Team.findOne({ id: team.id }); + if (model) { + await ModelConvertor.saveEntityToModel(team, model); + } + return; + } + await ModelConvertor.convertEntityToModel(team, this.Team); + } + + async removeTeam(teamId: string): Promise { + await this.Team.remove({ teamId }); + } + + async removeAllTeamsByOrgId(orgId: string): Promise { + await this.Team.remove({ orgId }); + } + + async listTeamsByUserId(userId: string): Promise { + const memberModels = await this.TeamMember.find({ userId }); + if (memberModels.length === 0) return []; + const teamIds = memberModels.map((m) => m.teamId); + const models = await this.Team.find({ teamId: { $in: teamIds } }); + return models.map((model) => ModelConvertor.convertModelToEntity(model, Team)); + } + + async listTeamsByUserIdAndOrgId(userId: string, orgId: string): Promise { + const orgTeams = await this.Team.find({ orgId }); + if (orgTeams.length === 0) return []; + const orgTeamIds = orgTeams.map((t) => t.teamId); + const memberModels = await this.TeamMember.find({ userId, teamId: { $in: orgTeamIds } }); + if (memberModels.length === 0) return []; + const memberTeamIds = new Set(memberModels.map((m) => m.teamId)); + return orgTeams + .filter((t) => memberTeamIds.has(t.teamId)) + .map((model) => ModelConvertor.convertModelToEntity(model, Team)); + } + + // --- TeamMember --- + + async findMember(teamId: string, userId: string): Promise { + const model = await this.TeamMember.findOne({ teamId, userId }); + if (!model) return null; + return ModelConvertor.convertModelToEntity(model, TeamMember); + } + + async addMember(member: TeamMember): Promise { + if (member.id) { + return; + } + await ModelConvertor.convertEntityToModel(member, this.TeamMember); + } + + async removeMember(teamId: string, userId: string): Promise { + await this.TeamMember.remove({ teamId, userId }); + } + + async removeMemberFromAllTeams(orgId: string, userId: string): Promise { + const teams = await this.Team.find({ orgId }); + if (teams.length === 0) return; + const teamIds = teams.map((t) => t.teamId); + await this.TeamMember.remove({ teamId: { $in: teamIds }, userId }); + } + + async listMembers(teamId: string): Promise { + const models = await this.TeamMember.find({ teamId }); + return models.map((model) => ModelConvertor.convertModelToEntity(model, TeamMember)); + } + + async removeAllMembersByTeamId(teamId: string): Promise { + await this.TeamMember.remove({ teamId }); + } + + async removeAllMembersByOrgId(orgId: string): Promise { + const teams = await this.Team.find({ orgId }); + if (teams.length === 0) return; + const teamIds = teams.map((t) => t.teamId); + await this.TeamMember.remove({ teamId: { $in: teamIds } }); + } + + // --- TeamPackage --- + + async findPackage(teamId: string, packageId: string): Promise { + const model = await this.TeamPackage.findOne({ teamId, packageId }); + if (!model) return null; + return ModelConvertor.convertModelToEntity(model, TeamPackage); + } + + async addPackage(teamPackage: TeamPackage): Promise { + if (teamPackage.id) { + return; + } + await ModelConvertor.convertEntityToModel(teamPackage, this.TeamPackage); + } + + async removePackage(teamId: string, packageId: string): Promise { + await this.TeamPackage.remove({ teamId, packageId }); + } + + async listPackages(teamId: string): Promise { + const models = await this.TeamPackage.find({ teamId }); + return models.map((model) => ModelConvertor.convertModelToEntity(model, TeamPackage)); + } + + async removeAllPackagesByTeamId(teamId: string): Promise { + await this.TeamPackage.remove({ teamId }); + } + + async removeTeamCascade(teamId: string): Promise { + await this.Team.transaction(async ({ connection }) => { + await this.TeamPackage.remove({ teamId }, true, { connection }); + await this.TeamMember.remove({ teamId }, true, { connection }); + await this.Team.remove({ teamId }, true, { connection }); + }); + } + + async removeAllPackagesByOrgId(orgId: string): Promise { + const teams = await this.Team.find({ orgId }); + if (teams.length === 0) return; + const teamIds = teams.map((t) => t.teamId); + await this.TeamPackage.remove({ teamId: { $in: teamIds } }); + } + + async hasAnyTeamBinding(packageId: string): Promise { + const model = await this.TeamPackage.findOne({ packageId }); + return !!model; + } + + // No JOIN: step 1 find teamIds by packageId, step 2 check membership + async hasPackageAccess(packageId: string, userId: string): Promise { + const teamPackages = await this.TeamPackage.find({ packageId }); + if (teamPackages.length === 0) return false; + const teamIds = teamPackages.map((tp) => tp.teamId); + const member = await this.TeamMember.findOne({ teamId: { $in: teamIds }, userId }); + return !!member; + } +} diff --git a/app/repository/UserRepository.ts b/app/repository/UserRepository.ts index db2d2b5c7..35c8ee0ad 100644 --- a/app/repository/UserRepository.ts +++ b/app/repository/UserRepository.ts @@ -58,6 +58,12 @@ export class UserRepository extends AbstractRepository { return ModelConvertor.convertModelToEntity(model, UserEntity); } + async findUsersByUserIds(userIds: string[]): Promise { + if (userIds.length === 0) return []; + const models = await this.User.find({ userId: userIds }); + return models.map((model) => ModelConvertor.convertModelToEntity(model, UserEntity)); + } + async findUserAndTokenByTokenKey(tokenKey: string) { const token = await this.findTokenByTokenKey(tokenKey); if (!token) return null; diff --git a/app/repository/model/Org.ts b/app/repository/model/Org.ts new file mode 100644 index 000000000..e6d96d1cb --- /dev/null +++ b/app/repository/model/Org.ts @@ -0,0 +1,27 @@ +import { Attribute, Model } from 'egg/orm'; + +import { DataTypes, Bone } from '../util/leoric.ts'; + +@Model() +export class Org extends Bone { + @Attribute(DataTypes.BIGINT, { + primary: true, + autoIncrement: true, + }) + id: bigint; + + @Attribute(DataTypes.DATE, { name: 'gmt_create' }) + createdAt: Date; + + @Attribute(DataTypes.DATE, { name: 'gmt_modified' }) + updatedAt: Date; + + @Attribute(DataTypes.STRING(24)) + orgId: string; + + @Attribute(DataTypes.STRING(214)) + name: string; + + @Attribute(DataTypes.STRING(10240), { allowNull: true }) + description: string; +} diff --git a/app/repository/model/OrgMember.ts b/app/repository/model/OrgMember.ts new file mode 100644 index 000000000..580229800 --- /dev/null +++ b/app/repository/model/OrgMember.ts @@ -0,0 +1,30 @@ +import { Attribute, Model } from 'egg/orm'; + +import { DataTypes, Bone } from '../util/leoric.ts'; + +@Model() +export class OrgMember extends Bone { + @Attribute(DataTypes.BIGINT, { + primary: true, + autoIncrement: true, + }) + id: bigint; + + @Attribute(DataTypes.DATE, { name: 'gmt_create' }) + createdAt: Date; + + @Attribute(DataTypes.DATE, { name: 'gmt_modified' }) + updatedAt: Date; + + @Attribute(DataTypes.STRING(24)) + orgMemberId: string; + + @Attribute(DataTypes.STRING(24)) + orgId: string; + + @Attribute(DataTypes.STRING(24)) + userId: string; + + @Attribute(DataTypes.STRING(20)) + role: string; +} diff --git a/app/repository/model/Team.ts b/app/repository/model/Team.ts new file mode 100644 index 000000000..4701335a6 --- /dev/null +++ b/app/repository/model/Team.ts @@ -0,0 +1,30 @@ +import { Attribute, Model } from 'egg/orm'; + +import { DataTypes, Bone } from '../util/leoric.ts'; + +@Model() +export class Team extends Bone { + @Attribute(DataTypes.BIGINT, { + primary: true, + autoIncrement: true, + }) + id: bigint; + + @Attribute(DataTypes.DATE, { name: 'gmt_create' }) + createdAt: Date; + + @Attribute(DataTypes.DATE, { name: 'gmt_modified' }) + updatedAt: Date; + + @Attribute(DataTypes.STRING(24)) + teamId: string; + + @Attribute(DataTypes.STRING(24)) + orgId: string; + + @Attribute(DataTypes.STRING(214)) + name: string; + + @Attribute(DataTypes.STRING(10240), { allowNull: true }) + description: string; +} diff --git a/app/repository/model/TeamMember.ts b/app/repository/model/TeamMember.ts new file mode 100644 index 000000000..a212166e6 --- /dev/null +++ b/app/repository/model/TeamMember.ts @@ -0,0 +1,27 @@ +import { Attribute, Model } from 'egg/orm'; + +import { DataTypes, Bone } from '../util/leoric.ts'; + +@Model() +export class TeamMember extends Bone { + @Attribute(DataTypes.BIGINT, { + primary: true, + autoIncrement: true, + }) + id: bigint; + + @Attribute(DataTypes.DATE, { name: 'gmt_create' }) + createdAt: Date; + + @Attribute(DataTypes.DATE, { name: 'gmt_modified' }) + updatedAt: Date; + + @Attribute(DataTypes.STRING(24)) + teamMemberId: string; + + @Attribute(DataTypes.STRING(24)) + teamId: string; + + @Attribute(DataTypes.STRING(24)) + userId: string; +} diff --git a/app/repository/model/TeamPackage.ts b/app/repository/model/TeamPackage.ts new file mode 100644 index 000000000..a6e64aecb --- /dev/null +++ b/app/repository/model/TeamPackage.ts @@ -0,0 +1,27 @@ +import { Attribute, Model } from 'egg/orm'; + +import { DataTypes, Bone } from '../util/leoric.ts'; + +@Model() +export class TeamPackage extends Bone { + @Attribute(DataTypes.BIGINT, { + primary: true, + autoIncrement: true, + }) + id: bigint; + + @Attribute(DataTypes.DATE, { name: 'gmt_create' }) + createdAt: Date; + + @Attribute(DataTypes.DATE, { name: 'gmt_modified' }) + updatedAt: Date; + + @Attribute(DataTypes.STRING(24)) + teamPackageId: string; + + @Attribute(DataTypes.STRING(24)) + teamId: string; + + @Attribute(DataTypes.STRING(24)) + packageId: string; +} diff --git a/docs/org-team.md b/docs/org-team.md new file mode 100644 index 000000000..406c091d7 --- /dev/null +++ b/docs/org-team.md @@ -0,0 +1,290 @@ +# Org & Team Management + +cnpmcore supports an Organization -> Team -> Package permission model for managing private package access. + +## Concepts + +| Concept | Description | +| --------------- | ---------------------------------------------------------------------------- | +| **Org** | Organization, corresponds to a scope (e.g., org `mycompany` -> `@mycompany`) | +| **OrgMember** | Org member with role `owner` (can manage) or `member` | +| **Team** | Permission unit. Each Org auto-creates a `developers` default team | +| **TeamPackage** | Team's read access grant to a package | + +## Org Management (Admin only) + +### Create Org + +```bash +curl -X PUT http://localhost:7001/-/org \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "mycompany", "description": "My Company"}' +``` + +### Delete Org + +```bash +# Cascade deletes all teams, members, and package grants +curl -X DELETE http://localhost:7001/-/org/mycompany \ + -H "Authorization: Bearer " +``` + +### View Org Info + +```bash +curl http://localhost:7001/-/org/mycompany \ + -H "Authorization: Bearer " +``` + +## Member Management + +Admin or Org Owner can manage members. + +### Add Member (npm CLI compatible) + +```bash +# npm CLI +npm org set mycompany alice --registry=http://localhost:7001 + +# Set as owner +npm org set mycompany alice owner --registry=http://localhost:7001 + +# HTTP +curl -X PUT http://localhost:7001/-/org/mycompany/member \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"user": "alice", "role": "member"}' +``` + +New members are **auto-added to the `developers` team**. + +### List Members (npm CLI compatible) + +```bash +# npm CLI +npm org ls mycompany --registry=http://localhost:7001 + +# HTTP — returns { "alice": "owner", "bob": "member" } +curl http://localhost:7001/-/org/mycompany/member \ + -H "Authorization: Bearer " +``` + +### Remove Member (npm CLI compatible) + +```bash +# npm CLI +npm org rm mycompany alice --registry=http://localhost:7001 + +# HTTP +curl -X DELETE http://localhost:7001/-/org/mycompany/member/alice \ + -H "Authorization: Bearer " +``` + +Removing a member **auto-removes from all teams** in the org. + +### List User's Teams + +```bash +curl http://localhost:7001/-/org/mycompany/member/alice/team \ + -H "Authorization: Bearer " +# Returns: [{"name": "developers", "description": "..."}, ...] +``` + +## Team Management + +### Create Team (npm CLI compatible) + +```bash +# npm CLI +npm team create @mycompany:frontend --registry=http://localhost:7001 + +# HTTP +curl -X PUT http://localhost:7001/-/org/mycompany/team \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "frontend", "description": "Frontend team"}' +``` + +### List Teams (npm CLI compatible) + +```bash +# npm CLI +npm team ls @mycompany --registry=http://localhost:7001 + +# HTTP +curl http://localhost:7001/-/org/mycompany/team \ + -H "Authorization: Bearer " +``` + +### Delete Team (npm CLI compatible) + +```bash +# npm CLI +npm team destroy @mycompany:frontend --registry=http://localhost:7001 + +# HTTP +curl -X DELETE http://localhost:7001/-/org/mycompany/team/frontend \ + -H "Authorization: Bearer " +``` + +> The `developers` default team **cannot be deleted**. + +### Team Members + +```bash +# List members (npm CLI compatible) +npm team ls @mycompany:frontend --registry=http://localhost:7001 + +# Add member (must be an org member first) +curl -X PUT http://localhost:7001/-/org/mycompany/team/frontend/member \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"user": "alice"}' + +# Remove member +curl -X DELETE http://localhost:7001/-/org/mycompany/team/frontend/member/alice \ + -H "Authorization: Bearer " +``` + +### Team Package Access + +```bash +# Grant access (npm CLI compatible) +npm access grant read-only @mycompany:frontend @mycompany/ui-lib \ + --registry=http://localhost:7001 + +# List packages (npm CLI compatible) +npm access ls-packages @mycompany:frontend --registry=http://localhost:7001 + +# Revoke access (npm CLI compatible) +npm access revoke @mycompany:frontend @mycompany/ui-lib \ + --registry=http://localhost:7001 +``` + +## Permission Summary + +| Operation | Required Permission | +| ------------------------------------- | ------------------- | +| Create / Delete Org | Admin | +| View Org info | Logged-in user | +| Add / Remove Org member | Admin or Org Owner | +| View Org members | Logged-in user | +| Create / Delete Team | Admin or Org Owner | +| View Teams / Team info / Team members | Logged-in user | +| Add / Remove Team member | Admin or Org Owner | +| Grant / Revoke package access | Admin or Org Owner | +| View Team packages | Logged-in user | + +## 私有包读取鉴权 + +cnpmcore 对 `allowScopes`(self scope)中的包支持基于 Team-Package 绑定的读取鉴权: + +- **self scope + 无 team 绑定** = 所有人可读(无需登录) +- **self scope + 有 team 绑定** = 仅 team 成员可读 + +### 鉴权规则 + +``` +请求 GET /@scope/name(manifest / version / tarball) + ↓ +scope 不在 allowScopes → 公开包,无需鉴权 + ↓ +scope 在 allowScopes(self scope): + 1. 查找包是否有 Team-Package 绑定 + 2. 无绑定 → 放行(所有人可读) + 3. 有绑定: + a. 未登录 → 401 + b. admin 用户 → 放行 + c. 用户在某个 Team 中且该 Team 被授权访问此包 → 放行 + d. 都不满足 → 403 +``` + +> **默认所有 self scope 包都是公开可读的。** 只有通过 Team-Package 绑定后,才会对该包启用读取鉴权。 + +### 使用流程 + +以 scope `@mycompany` 为例: + +#### 第一步:配置 allowScopes 并创建 Org + +```js +// config/config.prod.ts +config.cnpmcore = { + allowScopes: ['@mycompany'], +}; +``` + +```bash +# 创建 Org(admin) +curl -X PUT http://localhost:7001/-/org \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "mycompany", "description": "My Company"}' +``` + +#### 第二步:发布包 + +发布后的包默认**所有人可读**,无需任何额外配置。 + +```bash +npm publish --registry=http://localhost:7001 +``` + +#### 第三步:(可选)对需要保护的包绑定 Team + +只有绑定了 Team 的包才会启用读取鉴权: + +```bash +# 授权 developers 团队访问包 +npm access grant read-only @mycompany:developers @mycompany/secret-lib \ + --registry=http://localhost:7001 +``` + +绑定后,只有 `developers` 团队的成员才能读取 `@mycompany/secret-lib`。其他未绑定 Team 的 `@mycompany/*` 包仍然所有人可读。 + +#### 精细控制 + +创建额外的 Team 可以实现更精细的权限控制: + +```bash +# 创建团队 +npm team create @mycompany:frontend --registry=http://localhost:7001 + +# 将用户加入团队 +curl -X PUT http://localhost:7001/-/org/mycompany/team/frontend/member \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"user": "bob"}' + +# 授权团队访问特定包 +npm access grant read-only @mycompany:frontend @mycompany/secret-lib \ + --registry=http://localhost:7001 +``` + +### CDN 缓存行为 + +- self scope 包的响应头设为 `Cache-Control: private, no-store`,不会被 CDN 缓存 +- 非 self scope 包保持原有 CDN 缓存策略不变 + +## API Endpoints + +| Method | Path | Description | +| ------ | ------------------------------------------------------ | ------------------------ | +| PUT | `/-/org` | Create org | +| GET | `/-/org/:orgName` | View org | +| DELETE | `/-/org/:orgName` | Delete org | +| GET | `/-/org/:orgName/member` | List org members | +| PUT | `/-/org/:orgName/member` | Add org member | +| DELETE | `/-/org/:orgName/member/:username` | Remove org member | +| GET | `/-/org/:orgName/member/:username/team` | List user's teams in org | +| PUT | `/-/org/:orgName/team` | Create team | +| GET | `/-/org/:orgName/team` | List teams | +| GET | `/-/org/:orgName/team/:teamName` | View team | +| DELETE | `/-/org/:orgName/team/:teamName` | Delete team | +| GET | `/-/org/:orgName/team/:teamName/member` | List team members | +| PUT | `/-/org/:orgName/team/:teamName/member` | Add team member | +| DELETE | `/-/org/:orgName/team/:teamName/member/:username` | Remove team member | +| GET | `/-/org/:orgName/team/:teamName/package` | List team packages | +| PUT | `/-/org/:orgName/team/:teamName/package` | Grant package access | +| DELETE | `/-/org/:orgName/team/:teamName/package/@:scope/:name` | Revoke package access | diff --git a/sql/mysql/3.77.0.sql b/sql/mysql/3.77.0.sql new file mode 100644 index 000000000..7cbc6c79c --- /dev/null +++ b/sql/mysql/3.77.0.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS `orgs` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'primary key', + `gmt_create` datetime(3) NOT NULL COMMENT 'create time', + `gmt_modified` datetime(3) NOT NULL COMMENT 'modify time', + `org_id` varchar(24) NOT NULL COMMENT 'org id', + `name` varchar(214) NOT NULL COMMENT 'org name, corresponds to scope without @', + `description` varchar(10240) DEFAULT NULL COMMENT 'org description', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_org_id` (`org_id`), + UNIQUE KEY `uk_name` (`name`) +) ENGINE=InnoDB DEFAULT COLLATE utf8mb3_unicode_ci CHARSET=utf8mb3 COMMENT 'organizations'; + +CREATE TABLE IF NOT EXISTS `org_members` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'primary key', + `gmt_create` datetime(3) NOT NULL COMMENT 'create time', + `gmt_modified` datetime(3) NOT NULL COMMENT 'modify time', + `org_member_id` varchar(24) NOT NULL COMMENT 'org member id', + `org_id` varchar(24) NOT NULL COMMENT 'org id', + `user_id` varchar(24) NOT NULL COMMENT 'user id', + `role` varchar(20) NOT NULL DEFAULT 'member' COMMENT 'member role: owner or member', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_org_member_id` (`org_member_id`), + UNIQUE KEY `uk_org_id_user_id` (`org_id`, `user_id`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT COLLATE utf8mb3_unicode_ci CHARSET=utf8mb3 COMMENT 'organization members'; + +CREATE TABLE IF NOT EXISTS `teams` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'primary key', + `gmt_create` datetime(3) NOT NULL COMMENT 'create time', + `gmt_modified` datetime(3) NOT NULL COMMENT 'modify time', + `team_id` varchar(24) NOT NULL COMMENT 'team id', + `org_id` varchar(24) NOT NULL COMMENT 'org id', + `name` varchar(214) NOT NULL COMMENT 'team name', + `description` varchar(10240) DEFAULT NULL COMMENT 'team description', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_team_id` (`team_id`), + UNIQUE KEY `uk_org_id_name` (`org_id`, `name`), + KEY `idx_org_id` (`org_id`) +) ENGINE=InnoDB DEFAULT COLLATE utf8mb3_unicode_ci CHARSET=utf8mb3 COMMENT 'teams within organizations'; + +CREATE TABLE IF NOT EXISTS `team_members` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'primary key', + `gmt_create` datetime(3) NOT NULL COMMENT 'create time', + `gmt_modified` datetime(3) NOT NULL COMMENT 'modify time', + `team_member_id` varchar(24) NOT NULL COMMENT 'team member id', + `team_id` varchar(24) NOT NULL COMMENT 'team id', + `user_id` varchar(24) NOT NULL COMMENT 'user id', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_team_member_id` (`team_member_id`), + UNIQUE KEY `uk_team_id_user_id` (`team_id`, `user_id`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT COLLATE utf8mb3_unicode_ci CHARSET=utf8mb3 COMMENT 'team members'; + +CREATE TABLE IF NOT EXISTS `team_packages` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'primary key', + `gmt_create` datetime(3) NOT NULL COMMENT 'create time', + `gmt_modified` datetime(3) NOT NULL COMMENT 'modify time', + `team_package_id` varchar(24) NOT NULL COMMENT 'team package id', + `team_id` varchar(24) NOT NULL COMMENT 'team id', + `package_id` varchar(24) NOT NULL COMMENT 'package id', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_team_package_id` (`team_package_id`), + UNIQUE KEY `uk_team_id_package_id` (`team_id`, `package_id`), + KEY `idx_package_id` (`package_id`) +) ENGINE=InnoDB DEFAULT COLLATE utf8mb3_unicode_ci CHARSET=utf8mb3 COMMENT 'team package access grants'; diff --git a/sql/postgresql/3.77.0.sql b/sql/postgresql/3.77.0.sql new file mode 100644 index 000000000..f0bad68d6 --- /dev/null +++ b/sql/postgresql/3.77.0.sql @@ -0,0 +1,65 @@ +CREATE TABLE orgs ( + id BIGSERIAL PRIMARY KEY, + gmt_create timestamp(3) NOT NULL, + gmt_modified timestamp(3) NOT NULL, + org_id varchar(24) NOT NULL, + name varchar(214) NOT NULL, + description varchar(10240) DEFAULT NULL +); + +CREATE UNIQUE INDEX orgs_uk_org_id ON orgs (org_id); +CREATE UNIQUE INDEX orgs_uk_name ON orgs (name); + +CREATE TABLE org_members ( + id BIGSERIAL PRIMARY KEY, + gmt_create timestamp(3) NOT NULL, + gmt_modified timestamp(3) NOT NULL, + org_member_id varchar(24) NOT NULL, + org_id varchar(24) NOT NULL, + user_id varchar(24) NOT NULL, + role varchar(20) NOT NULL DEFAULT 'member' +); + +CREATE UNIQUE INDEX org_members_uk_org_member_id ON org_members (org_member_id); +CREATE UNIQUE INDEX org_members_uk_org_id_user_id ON org_members (org_id, user_id); +CREATE INDEX org_members_idx_user_id ON org_members (user_id); + +CREATE TABLE teams ( + id BIGSERIAL PRIMARY KEY, + gmt_create timestamp(3) NOT NULL, + gmt_modified timestamp(3) NOT NULL, + team_id varchar(24) NOT NULL, + org_id varchar(24) NOT NULL, + name varchar(214) NOT NULL, + description varchar(10240) DEFAULT NULL +); + +CREATE UNIQUE INDEX teams_uk_team_id ON teams (team_id); +CREATE UNIQUE INDEX teams_uk_org_id_name ON teams (org_id, name); +CREATE INDEX teams_idx_org_id ON teams (org_id); + +CREATE TABLE team_members ( + id BIGSERIAL PRIMARY KEY, + gmt_create timestamp(3) NOT NULL, + gmt_modified timestamp(3) NOT NULL, + team_member_id varchar(24) NOT NULL, + team_id varchar(24) NOT NULL, + user_id varchar(24) NOT NULL +); + +CREATE UNIQUE INDEX team_members_uk_team_member_id ON team_members (team_member_id); +CREATE UNIQUE INDEX team_members_uk_team_id_user_id ON team_members (team_id, user_id); +CREATE INDEX team_members_idx_user_id ON team_members (user_id); + +CREATE TABLE team_packages ( + id BIGSERIAL PRIMARY KEY, + gmt_create timestamp(3) NOT NULL, + gmt_modified timestamp(3) NOT NULL, + team_package_id varchar(24) NOT NULL, + team_id varchar(24) NOT NULL, + package_id varchar(24) NOT NULL +); + +CREATE UNIQUE INDEX team_packages_uk_team_package_id ON team_packages (team_package_id); +CREATE UNIQUE INDEX team_packages_uk_team_id_package_id ON team_packages (team_id, package_id); +CREATE INDEX team_packages_idx_package_id ON team_packages (package_id); diff --git a/test/cli/npm/team.test.ts b/test/cli/npm/team.test.ts new file mode 100644 index 000000000..2a3b26389 --- /dev/null +++ b/test/cli/npm/team.test.ts @@ -0,0 +1,240 @@ +import { once } from 'node:events'; +import type { AddressInfo, Server } from 'node:net'; +import path from 'node:path'; + +import { app } from '@eggjs/mock/bootstrap'; +import coffee from 'coffee'; + +import { TestUtil } from '../../../test/TestUtil.ts'; +import { npmLogin } from '../CliUtil.ts'; + +describe('test/cli/npm/team.test.ts', () => { + let server: Server; + let registry: string; + let fooPkgDir: string; + let demoDir: string; + let userconfig: string; + let cacheDir: string; + + before(async () => { + cacheDir = TestUtil.mkdtemp(); + fooPkgDir = TestUtil.getFixtures('@cnpm/foo'); + demoDir = TestUtil.getFixtures('demo'); + userconfig = path.join(fooPkgDir, '.npmrc'); + await TestUtil.rm(userconfig); + server = app.listen(0); + await once(server, 'listening'); + registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + after(async () => { + await TestUtil.rm(userconfig); + await TestUtil.rm(cacheDir); + server?.close(); + }); + + beforeEach(async () => { + await npmLogin(registry, userconfig); + }); + + describe('npm team', () => { + it('should create team', async () => { + await coffee + .spawn( + 'npm', + [ + 'team', + 'create', + '@cnpm:test-cli-team', + `--registry=${registry}`, + `--userconfig=${userconfig}`, + `--cache=${cacheDir}`, + ], + { cwd: demoDir }, + ) + .debug() + .expect('code', 0) + .end(); + }); + + it('should list teams', async () => { + // create first + await coffee + .spawn( + 'npm', + [ + 'team', + 'create', + '@cnpm:ls-team', + `--registry=${registry}`, + `--userconfig=${userconfig}`, + `--cache=${cacheDir}`, + ], + { cwd: demoDir }, + ) + .debug() + .expect('code', 0) + .end(); + + await coffee + .spawn( + 'npm', + ['team', 'ls', '@cnpm', `--registry=${registry}`, `--userconfig=${userconfig}`, `--cache=${cacheDir}`], + { cwd: demoDir }, + ) + .debug() + .expect('stdout', /ls-team/) + .expect('code', 0) + .end(); + }); + + it('should destroy team', async () => { + await coffee + .spawn( + 'npm', + [ + 'team', + 'create', + '@cnpm:to-destroy', + `--registry=${registry}`, + `--userconfig=${userconfig}`, + `--cache=${cacheDir}`, + ], + { cwd: demoDir }, + ) + .debug() + .expect('code', 0) + .end(); + + await coffee + .spawn( + 'npm', + [ + 'team', + 'destroy', + '@cnpm:to-destroy', + `--registry=${registry}`, + `--userconfig=${userconfig}`, + `--cache=${cacheDir}`, + ], + { cwd: demoDir }, + ) + .debug() + .expect('code', 0) + .end(); + }); + }); + + describe('npm access grant/revoke', () => { + beforeEach(async () => { + // publish package first + await coffee + .spawn('npm', ['publish', `--registry=${registry}`, `--userconfig=${userconfig}`, `--cache=${cacheDir}`], { + cwd: fooPkgDir, + }) + .debug() + .expect('code', 0) + .end(); + + // create team + await coffee + .spawn( + 'npm', + [ + 'team', + 'create', + '@cnpm:access-team', + `--registry=${registry}`, + `--userconfig=${userconfig}`, + `--cache=${cacheDir}`, + ], + { cwd: demoDir }, + ) + .debug() + .end(); + }); + + it('should grant and list package access', async () => { + // grant + await coffee + .spawn( + 'npm', + [ + 'access', + 'grant', + 'read-only', + '@cnpm:access-team', + '@cnpm/foo', + `--registry=${registry}`, + `--userconfig=${userconfig}`, + `--cache=${cacheDir}`, + ], + { cwd: demoDir }, + ) + .debug() + .expect('code', 0) + .end(); + + // list packages + await coffee + .spawn( + 'npm', + [ + 'access', + 'list', + 'packages', + '@cnpm:access-team', + `--registry=${registry}`, + `--userconfig=${userconfig}`, + `--cache=${cacheDir}`, + ], + { cwd: demoDir }, + ) + .debug() + .expect('stdout', /@cnpm\/foo/) + .expect('code', 0) + .end(); + }); + + it('should revoke package access', async () => { + // grant first + await coffee + .spawn( + 'npm', + [ + 'access', + 'grant', + 'read-only', + '@cnpm:access-team', + '@cnpm/foo', + `--registry=${registry}`, + `--userconfig=${userconfig}`, + `--cache=${cacheDir}`, + ], + { cwd: demoDir }, + ) + .debug() + .expect('code', 0) + .end(); + + // revoke + await coffee + .spawn( + 'npm', + [ + 'access', + 'revoke', + '@cnpm:access-team', + '@cnpm/foo', + `--registry=${registry}`, + `--userconfig=${userconfig}`, + `--cache=${cacheDir}`, + ], + { cwd: demoDir }, + ) + .debug() + .expect('code', 0) + .end(); + }); + }); +}); diff --git a/test/core/service/OrgService.test.ts b/test/core/service/OrgService.test.ts new file mode 100644 index 000000000..63092a40b --- /dev/null +++ b/test/core/service/OrgService.test.ts @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; + +import { app } from '@eggjs/mock/bootstrap'; + +import { OrgService } from '../../../app/core/service/OrgService.ts'; +import { TeamService } from '../../../app/core/service/TeamService.ts'; +import { OrgRepository } from '../../../app/repository/OrgRepository.ts'; +import { TeamRepository } from '../../../app/repository/TeamRepository.ts'; +import { UserRepository } from '../../../app/repository/UserRepository.ts'; +import { TestUtil } from '../../TestUtil.ts'; + +describe('test/core/service/OrgService.test.ts', () => { + let orgService: OrgService; + let teamService: TeamService; + let teamRepository: TeamRepository; + let orgRepository: OrgRepository; + let userRepository: UserRepository; + + beforeEach(async () => { + orgService = await app.getEggObject(OrgService); + teamService = await app.getEggObject(TeamService); + teamRepository = await app.getEggObject(TeamRepository); + orgRepository = await app.getEggObject(OrgRepository); + userRepository = await app.getEggObject(UserRepository); + }); + + describe('createOrg()', () => { + it('should create org with developers team and owner', async () => { + const user = await TestUtil.createUser({ name: 'org-creator' }); + const creator = await userRepository.findUserByName(user.name); + assert(creator); + + const org = await orgService.createOrg({ + name: 'testorg', + creatorUserId: creator.userId, + }); + assert.equal(org.name, 'testorg'); + + // developers team should be auto-created + const devTeam = await teamRepository.findTeam(org.orgId, 'developers'); + assert(devTeam); + assert.equal(devTeam.name, 'developers'); + + // creator should be org owner + const member = await orgRepository.findMember(org.orgId, creator.userId); + assert(member); + assert.equal(member.role, 'owner'); + + // creator should be in developers team + const teamMember = await teamRepository.findMember(devTeam.teamId, creator.userId); + assert(teamMember); + }); + + it('should throw if org name already exists', async () => { + const user = await TestUtil.createUser({ name: 'org-dup-creator' }); + const creator = await userRepository.findUserByName(user.name); + assert(creator); + + await orgService.createOrg({ name: 'duporg', creatorUserId: creator.userId }); + await assert.rejects(orgService.createOrg({ name: 'duporg', creatorUserId: creator.userId }), /already exists/); + }); + }); + + describe('addMember()', () => { + it('should add member to org and auto-join developers team', async () => { + const creator = await TestUtil.createUser({ name: 'add-member-creator' }); + const creatorEntity = await userRepository.findUserByName(creator.name); + assert(creatorEntity); + + const org = await orgService.createOrg({ name: 'addmemberorg', creatorUserId: creatorEntity.userId }); + + const newUser = await TestUtil.createUser({ name: 'new-member' }); + const newUserEntity = await userRepository.findUserByName(newUser.name); + assert(newUserEntity); + + await orgService.addMember(org.orgId, newUserEntity.userId); + + // should be org member + const orgMember = await orgRepository.findMember(org.orgId, newUserEntity.userId); + assert(orgMember); + assert.equal(orgMember.role, 'member'); + + // should be in developers team + const devTeam = await teamRepository.findTeam(org.orgId, 'developers'); + assert(devTeam); + const teamMember = await teamRepository.findMember(devTeam.teamId, newUserEntity.userId); + assert(teamMember); + }); + + it('should update role if member already exists', async () => { + const creator = await TestUtil.createUser({ name: 'role-update-creator' }); + const creatorEntity = await userRepository.findUserByName(creator.name); + assert(creatorEntity); + + const org = await orgService.createOrg({ name: 'roleupdateorg', creatorUserId: creatorEntity.userId }); + + const user2 = await TestUtil.createUser({ name: 'role-update-user' }); + const user2Entity = await userRepository.findUserByName(user2.name); + assert(user2Entity); + + await orgService.addMember(org.orgId, user2Entity.userId, 'member'); + let member = await orgRepository.findMember(org.orgId, user2Entity.userId); + assert.equal(member?.role, 'member'); + + await orgService.addMember(org.orgId, user2Entity.userId, 'owner'); + member = await orgRepository.findMember(org.orgId, user2Entity.userId); + assert.equal(member?.role, 'owner'); + }); + }); + + describe('removeMember()', () => { + it('should remove member from org and all teams', async () => { + const creator = await TestUtil.createUser({ name: 'rm-member-creator' }); + const creatorEntity = await userRepository.findUserByName(creator.name); + assert(creatorEntity); + + const org = await orgService.createOrg({ name: 'rmmemberorg', creatorUserId: creatorEntity.userId }); + + const user2 = await TestUtil.createUser({ name: 'rm-target' }); + const user2Entity = await userRepository.findUserByName(user2.name); + assert(user2Entity); + + await orgService.addMember(org.orgId, user2Entity.userId); + + // Create a custom team and add user2 + const customTeam = await teamService.createTeam(org.orgId, 'custom-team'); + await teamService.addMember(customTeam.teamId, user2Entity.userId); + + // Verify user2 is in both teams + const devTeam = await teamRepository.findTeam(org.orgId, 'developers'); + assert(devTeam); + assert(await teamRepository.findMember(devTeam.teamId, user2Entity.userId)); + assert(await teamRepository.findMember(customTeam.teamId, user2Entity.userId)); + + // Remove from org + await orgService.removeMember(org.orgId, user2Entity.userId); + + // Should be gone from org and all teams + assert.equal(await orgRepository.findMember(org.orgId, user2Entity.userId), null); + assert.equal(await teamRepository.findMember(devTeam.teamId, user2Entity.userId), null); + assert.equal(await teamRepository.findMember(customTeam.teamId, user2Entity.userId), null); + }); + }); + + describe('removeOrg()', () => { + it('should cascade delete everything', async () => { + const creator = await TestUtil.createUser({ name: 'rm-org-creator' }); + const creatorEntity = await userRepository.findUserByName(creator.name); + assert(creatorEntity); + + const org = await orgService.createOrg({ name: 'rmorg', creatorUserId: creatorEntity.userId }); + const devTeam = await teamRepository.findTeam(org.orgId, 'developers'); + assert(devTeam); + + await orgService.removeOrg(org.orgId); + + assert.equal(await orgRepository.findOrgByOrgId(org.orgId), null); + assert.equal(await teamRepository.findTeam(org.orgId, 'developers'), null); + assert.equal(await orgRepository.findMember(org.orgId, creatorEntity.userId), null); + }); + }); +}); diff --git a/test/core/service/TeamService.test.ts b/test/core/service/TeamService.test.ts new file mode 100644 index 000000000..41dc271da --- /dev/null +++ b/test/core/service/TeamService.test.ts @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; + +import { app } from '@eggjs/mock/bootstrap'; + +import { OrgService } from '../../../app/core/service/OrgService.ts'; +import { TeamService } from '../../../app/core/service/TeamService.ts'; +import { PackageRepository } from '../../../app/repository/PackageRepository.ts'; +import { TeamRepository } from '../../../app/repository/TeamRepository.ts'; +import { UserRepository } from '../../../app/repository/UserRepository.ts'; +import { TestUtil } from '../../TestUtil.ts'; + +describe('test/core/service/TeamService.test.ts', () => { + let orgService: OrgService; + let teamService: TeamService; + let teamRepository: TeamRepository; + let userRepository: UserRepository; + let packageRepository: PackageRepository; + let orgId: string; + let creatorUserId: string; + + beforeEach(async () => { + orgService = await app.getEggObject(OrgService); + teamService = await app.getEggObject(TeamService); + teamRepository = await app.getEggObject(TeamRepository); + userRepository = await app.getEggObject(UserRepository); + packageRepository = await app.getEggObject(PackageRepository); + + const creator = await TestUtil.createUser({ name: 'team-test-creator' }); + const creatorEntity = await userRepository.findUserByName(creator.name); + assert(creatorEntity); + creatorUserId = creatorEntity.userId; + + const org = await orgService.createOrg({ name: 'teamtestorg', creatorUserId }); + orgId = org.orgId; + }); + + describe('createTeam()', () => { + it('should create a team', async () => { + const team = await teamService.createTeam(orgId, 'frontend'); + assert.equal(team.name, 'frontend'); + assert.equal(team.orgId, orgId); + }); + + it('should throw if team name already exists', async () => { + await teamService.createTeam(orgId, 'dup-team'); + await assert.rejects(teamService.createTeam(orgId, 'dup-team'), /already exists/); + }); + }); + + describe('removeTeam()', () => { + it('should remove a custom team', async () => { + const team = await teamService.createTeam(orgId, 'to-delete'); + await teamService.removeTeam(team.teamId); + assert.equal(await teamRepository.findTeamByTeamId(team.teamId), null); + }); + + it('should not allow deleting developers team', async () => { + const devTeam = await teamRepository.findTeam(orgId, 'developers'); + assert(devTeam); + await assert.rejects(teamService.removeTeam(devTeam.teamId), /Cannot delete the developers team/); + }); + }); + + describe('addMember()', () => { + it('should add org member to team', async () => { + const team = await teamService.createTeam(orgId, 'core'); + // creator is already an org member + const member = await teamService.addMember(team.teamId, creatorUserId); + assert(member); + assert.equal(member.teamId, team.teamId); + }); + + it('should reject non-org-member', async () => { + const team = await teamService.createTeam(orgId, 'restricted'); + const outsider = await TestUtil.createUser({ name: 'outsider' }); + const outsiderEntity = await userRepository.findUserByName(outsider.name); + assert(outsiderEntity); + + await assert.rejects(teamService.addMember(team.teamId, outsiderEntity.userId), /must be an org member/); + }); + + it('should be idempotent', async () => { + const team = await teamService.createTeam(orgId, 'idempotent-team'); + await teamService.addMember(team.teamId, creatorUserId); + const second = await teamService.addMember(team.teamId, creatorUserId); + assert(second); + }); + }); + + describe('removeMember()', () => { + it('should remove member from team', async () => { + const team = await teamService.createTeam(orgId, 'rm-team'); + await teamService.addMember(team.teamId, creatorUserId); + await teamService.removeMember(team.teamId, creatorUserId); + const member = await teamRepository.findMember(team.teamId, creatorUserId); + assert.equal(member, null); + }); + }); + + describe('grantPackageAccess() / revokePackageAccess()', () => { + it('should grant and revoke package access', async () => { + const { pkg } = await TestUtil.createPackage({ + name: '@cnpm/test-pkg', + version: '1.0.0', + }); + const [scope, name] = pkg.name.split('/'); + const pkgEntity = await packageRepository.findPackage(scope, name); + assert(pkgEntity); + + const team = await teamService.createTeam(orgId, 'pkg-team'); + + // Grant + await teamService.grantPackageAccess(team.teamId, pkgEntity.packageId); + let packages = await teamService.listPackages(team.teamId); + assert.equal(packages.length, 1); + assert.equal(packages[0].packageId, pkgEntity.packageId); + + // Idempotent + await teamService.grantPackageAccess(team.teamId, pkgEntity.packageId); + packages = await teamService.listPackages(team.teamId); + assert.equal(packages.length, 1); + + // Revoke + await teamService.revokePackageAccess(team.teamId, pkgEntity.packageId); + packages = await teamService.listPackages(team.teamId); + assert.equal(packages.length, 0); + }); + }); + + describe('listMembers()', () => { + it('should list team members', async () => { + const team = await teamService.createTeam(orgId, 'list-members-team'); + await teamService.addMember(team.teamId, creatorUserId); + + const user2 = await TestUtil.createUser({ name: 'list-member-2' }); + const user2Entity = await userRepository.findUserByName(user2.name); + assert(user2Entity); + await orgService.addMember(orgId, user2Entity.userId); + await teamService.addMember(team.teamId, user2Entity.userId); + + const members = await teamService.listMembers(team.teamId); + assert.equal(members.length, 2); + const userIds = members.map((m) => m.userId); + assert(userIds.includes(creatorUserId)); + assert(userIds.includes(user2Entity.userId)); + }); + }); + + describe('listTeamsByUserId()', () => { + it('should list teams the user belongs to', async () => { + // creator is in developers by default + const teams = await teamRepository.listTeamsByUserId(creatorUserId); + assert(teams.length >= 1); + assert(teams.some((t) => t.name === 'developers')); + + // add to a custom team + const customTeam = await teamService.createTeam(orgId, 'custom'); + await teamService.addMember(customTeam.teamId, creatorUserId); + + const teams2 = await teamRepository.listTeamsByUserId(creatorUserId); + assert.equal(teams2.length, 2); + const names = teams2.map((t) => t.name); + assert(names.includes('developers')); + assert(names.includes('custom')); + }); + + it('should return empty for user with no teams', async () => { + const outsider = await TestUtil.createUser({ name: 'no-team-user' }); + const outsiderEntity = await userRepository.findUserByName(outsider.name); + assert(outsiderEntity); + const teams = await teamRepository.listTeamsByUserId(outsiderEntity.userId); + assert.equal(teams.length, 0); + }); + }); + + describe('hasPackageAccess()', () => { + it('should return true when user is in a team with package access', async () => { + const { pkg } = await TestUtil.createPackage({ + name: '@cnpm/access-pkg', + version: '1.0.0', + }); + const [scope, name] = pkg.name.split('/'); + const pkgEntity = await packageRepository.findPackage(scope, name); + assert(pkgEntity); + + const team = await teamService.createTeam(orgId, 'access-team'); + await teamService.addMember(team.teamId, creatorUserId); + await teamService.grantPackageAccess(team.teamId, pkgEntity.packageId); + + const hasAccess = await teamRepository.hasPackageAccess(pkgEntity.packageId, creatorUserId); + assert.equal(hasAccess, true); + }); + + it('should return false when user is not in any authorized team', async () => { + const { pkg } = await TestUtil.createPackage({ + name: '@cnpm/noaccess-pkg', + version: '1.0.0', + }); + const [scope, name] = pkg.name.split('/'); + const pkgEntity = await packageRepository.findPackage(scope, name); + assert(pkgEntity); + + const outsider = await TestUtil.createUser({ name: 'no-access-user' }); + const outsiderEntity = await userRepository.findUserByName(outsider.name); + assert(outsiderEntity); + + const team = await teamService.createTeam(orgId, 'noaccess-team'); + await teamService.grantPackageAccess(team.teamId, pkgEntity.packageId); + + const hasAccess = await teamRepository.hasPackageAccess(pkgEntity.packageId, outsiderEntity.userId); + assert.equal(hasAccess, false); + }); + + it('should return false when no team has package access', async () => { + const hasAccess = await teamRepository.hasPackageAccess('nonexistent-pkg-id', creatorUserId); + assert.equal(hasAccess, false); + }); + }); +}); diff --git a/test/port/controller/OrgController/index.test.ts b/test/port/controller/OrgController/index.test.ts new file mode 100644 index 000000000..1d7cb2443 --- /dev/null +++ b/test/port/controller/OrgController/index.test.ts @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict'; + +import { app } from '@eggjs/mock/bootstrap'; + +import { TestUtil } from '../../../TestUtil.ts'; + +describe('test/port/controller/OrgController/index.test.ts', () => { + let adminUser: any; + let normalUser: any; + + beforeEach(async () => { + adminUser = await TestUtil.createAdmin(); + normalUser = await TestUtil.createUser({ name: 'org-ctrl-user' }); + }); + + describe('[PUT /-/org] createOrg()', () => { + it('should 200 when admin creates org', async () => { + const res = await app + .httpRequest() + .put('/-/org') + .set('authorization', adminUser.authorization) + .send({ name: 'testorg', description: 'Test Org' }) + .expect(200); + assert(res.body.ok); + }); + + it('should 403 when non-admin creates org', async () => { + await app + .httpRequest() + .put('/-/org') + .set('authorization', normalUser.authorization) + .send({ name: 'testorg2' }) + .expect(403); + }); + + it('should 422 when name is missing', async () => { + const res = await app + .httpRequest() + .put('/-/org') + .set('authorization', adminUser.authorization) + .send({}) + .expect(422); + assert(res.body.error.includes('name is required')); + }); + + it('should 403 when org name already exists', async () => { + await app + .httpRequest() + .put('/-/org') + .set('authorization', adminUser.authorization) + .send({ name: 'duporg' }) + .expect(200); + await app + .httpRequest() + .put('/-/org') + .set('authorization', adminUser.authorization) + .send({ name: 'duporg' }) + .expect(403); + }); + }); + + describe('[GET /-/org/:orgName] showOrg()', () => { + it('should 200', async () => { + await app + .httpRequest() + .put('/-/org') + .set('authorization', adminUser.authorization) + .send({ name: 'showorg', description: 'desc' }) + .expect(200); + const res = await app + .httpRequest() + .get('/-/org/showorg') + .set('authorization', normalUser.authorization) + .expect(200); + assert.equal(res.body.name, 'showorg'); + assert.equal(res.body.description, 'desc'); + }); + + it('should 404 when org not found', async () => { + await app.httpRequest().get('/-/org/nonexistent').set('authorization', normalUser.authorization).expect(404); + }); + }); + + describe('[DELETE /-/org/:orgName] removeOrg()', () => { + it('should 200 when admin deletes org', async () => { + await app + .httpRequest() + .put('/-/org') + .set('authorization', adminUser.authorization) + .send({ name: 'delorg' }) + .expect(200); + const res = await app + .httpRequest() + .delete('/-/org/delorg') + .set('authorization', adminUser.authorization) + .expect(200); + assert(res.body.ok); + + // Verify org is gone + await app.httpRequest().get('/-/org/delorg').set('authorization', normalUser.authorization).expect(404); + }); + }); + + describe('[GET/PUT/DELETE /-/org/:orgName/member] member management', () => { + beforeEach(async () => { + await app + .httpRequest() + .put('/-/org') + .set('authorization', adminUser.authorization) + .send({ name: 'memberorg' }) + .expect(200); + }); + + it('should list members', async () => { + const res = await app + .httpRequest() + .get('/-/org/memberorg/member') + .set('authorization', normalUser.authorization) + .expect(200); + // Admin is the creator/owner + assert.equal(typeof res.body, 'object'); + assert.equal(res.body[adminUser.displayName], 'owner'); + }); + + it('should add and remove member', async () => { + // Add + await app + .httpRequest() + .put('/-/org/memberorg/member') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name, role: 'member' }) + .expect(200); + + // Verify + let res = await app + .httpRequest() + .get('/-/org/memberorg/member') + .set('authorization', normalUser.authorization) + .expect(200); + assert.equal(res.body[normalUser.displayName], 'member'); + + // Remove + await app + .httpRequest() + .delete(`/-/org/memberorg/member/${normalUser.name}`) + .set('authorization', adminUser.authorization) + .expect(200); + + // Verify removed + res = await app + .httpRequest() + .get('/-/org/memberorg/member') + .set('authorization', normalUser.authorization) + .expect(200); + assert.equal(res.body[normalUser.displayName], undefined); + }); + + it('should 403 when non-owner adds member', async () => { + // Add normalUser as member first + await app + .httpRequest() + .put('/-/org/memberorg/member') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name, role: 'member' }) + .expect(200); + + const anotherUser = await TestUtil.createUser({ name: 'another-user' }); + // normalUser (member, not owner) tries to add + await app + .httpRequest() + .put('/-/org/memberorg/member') + .set('authorization', normalUser.authorization) + .send({ user: anotherUser.name, role: 'member' }) + .expect(403); + }); + + it('should 422 when user is missing', async () => { + await app + .httpRequest() + .put('/-/org/memberorg/member') + .set('authorization', adminUser.authorization) + .send({}) + .expect(422); + }); + + it('should 404 when target user not found', async () => { + await app + .httpRequest() + .put('/-/org/memberorg/member') + .set('authorization', adminUser.authorization) + .send({ user: 'nonexistent-user' }) + .expect(404); + }); + }); + + describe('[GET /-/org/:orgName/member/:username/team] listUserTeams()', () => { + it('should list teams for user', async () => { + await app + .httpRequest() + .put('/-/org') + .set('authorization', adminUser.authorization) + .send({ name: 'teamlistorg' }) + .expect(200); + + const res = await app + .httpRequest() + .get(`/-/org/teamlistorg/member/${adminUser.name}/team`) + .set('authorization', normalUser.authorization) + .expect(200); + assert(Array.isArray(res.body)); + assert(res.body.some((t: any) => t.name === 'developers')); + }); + }); +}); diff --git a/test/port/controller/TeamController/index.test.ts b/test/port/controller/TeamController/index.test.ts new file mode 100644 index 000000000..09e3bc999 --- /dev/null +++ b/test/port/controller/TeamController/index.test.ts @@ -0,0 +1,310 @@ +import assert from 'node:assert/strict'; + +import { app } from '@eggjs/mock/bootstrap'; + +import { OrgRepository } from '../../../../app/repository/OrgRepository.ts'; +import { TestUtil } from '../../../TestUtil.ts'; + +describe('test/port/controller/TeamController/index.test.ts', () => { + let adminUser: any; + let normalUser: any; + + beforeEach(async () => { + adminUser = await TestUtil.createAdmin(); + normalUser = await TestUtil.createUser({ name: 'team-ctrl-user' }); + + // Create org for team tests (non-allowScopes org, requires admin) + await app + .httpRequest() + .put('/-/org') + .set('authorization', adminUser.authorization) + .send({ name: 'teamorg' }) + .expect(200); + }); + + describe('[PUT /-/org/:orgName/team] createTeam()', () => { + it('should 200 when admin creates team', async () => { + const res = await app + .httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({ name: 'frontend', description: 'Frontend team' }) + .expect(200); + assert(res.body.ok); + }); + + it('should 403 when non-owner creates team', async () => { + // Add normalUser as member (not owner) + await app + .httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name, role: 'member' }) + .expect(200); + + await app + .httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', normalUser.authorization) + .send({ name: 'backend' }) + .expect(403); + }); + + it('should 422 when name is missing', async () => { + await app + .httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({}) + .expect(422); + }); + + it('should 403 when team name already exists', async () => { + await app + .httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({ name: 'dup-team' }) + .expect(200); + await app + .httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({ name: 'dup-team' }) + .expect(403); + }); + }); + + describe('[GET /-/org/:orgName/team] listTeams()', () => { + it('should list teams including developers', async () => { + const res = await app + .httpRequest() + .get('/-/org/teamorg/team') + .set('authorization', normalUser.authorization) + .expect(200); + assert(Array.isArray(res.body)); + assert(res.body.includes('teamorg:developers')); + }); + }); + + // npm compatible routes: /-/team/:scope/:team + describe('[GET /-/team/:orgName/:teamName] showTeam()', () => { + it('should 200', async () => { + const res = await app + .httpRequest() + .get('/-/team/teamorg/developers') + .set('authorization', normalUser.authorization) + .expect(200); + assert.equal(res.body.name, 'developers'); + }); + + it('should 404 when team not found', async () => { + await app + .httpRequest() + .get('/-/team/teamorg/nonexistent') + .set('authorization', normalUser.authorization) + .expect(404); + }); + }); + + describe('[DELETE /-/team/:orgName/:teamName] removeTeam()', () => { + it('should 200 for custom team', async () => { + await app + .httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({ name: 'to-delete' }) + .expect(200); + + const res = await app + .httpRequest() + .delete('/-/team/teamorg/to-delete') + .set('authorization', adminUser.authorization) + .expect(200); + assert(res.body.ok); + }); + + it('should 403 when deleting developers team', async () => { + await app + .httpRequest() + .delete('/-/team/teamorg/developers') + .set('authorization', adminUser.authorization) + .expect(403); + }); + }); + + describe('team member management (/-/team/:scope/:team/user)', () => { + beforeEach(async () => { + // Create a custom team + await app + .httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({ name: 'coreteam' }) + .expect(200); + + // Add normalUser to org first + await app + .httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name, role: 'member' }) + .expect(200); + }); + + it('should add and list team members', async () => { + // Add to team via npm compatible route + await app + .httpRequest() + .put('/-/team/teamorg/coreteam/user') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name }) + .expect(200); + + // List members via npm compatible route + const res = await app + .httpRequest() + .get('/-/team/teamorg/coreteam/user') + .set('authorization', normalUser.authorization) + .expect(200); + assert(Array.isArray(res.body)); + assert(res.body.includes(normalUser.displayName)); + }); + + it('should remove team member via body', async () => { + await app + .httpRequest() + .put('/-/team/teamorg/coreteam/user') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name }) + .expect(200); + + // npm rm sends DELETE with body {user} + await app + .httpRequest() + .delete('/-/team/teamorg/coreteam/user') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name }) + .expect(200); + + const res = await app + .httpRequest() + .get('/-/team/teamorg/coreteam/user') + .set('authorization', normalUser.authorization) + .expect(200); + assert(!res.body.includes(normalUser.displayName)); + }); + + it('should 422 when user is missing', async () => { + await app + .httpRequest() + .put('/-/team/teamorg/coreteam/user') + .set('authorization', adminUser.authorization) + .send({}) + .expect(422); + }); + + it('should 404 when target user not found', async () => { + await app + .httpRequest() + .put('/-/team/teamorg/coreteam/user') + .set('authorization', adminUser.authorization) + .send({ user: 'ghost-user' }) + .expect(404); + }); + }); + + describe('team package management (/-/team/:scope/:team/package)', () => { + it('should list empty packages initially', async () => { + const res = await app + .httpRequest() + .get('/-/team/teamorg/developers/package') + .set('authorization', normalUser.authorization) + .expect(200); + assert.deepEqual(res.body, {}); + }); + + it('should 422 when package name is missing for grant', async () => { + await app + .httpRequest() + .put('/-/team/teamorg/developers/package') + .set('authorization', adminUser.authorization) + .send({}) + .expect(422); + }); + + it('should 404 when package not found for grant', async () => { + await app + .httpRequest() + .put('/-/team/teamorg/developers/package') + .set('authorization', adminUser.authorization) + .send({ package: '@cnpm/nonexistent-pkg' }) + .expect(404); + }); + + it('should 404 when package not found for revoke', async () => { + await app + .httpRequest() + .delete('/-/team/teamorg/developers/package') + .set('authorization', adminUser.authorization) + .send({ package: '@cnpm/nonexistent-pkg' }) + .expect(404); + }); + }); + + // @cnpm is in allowScopes — any authenticated user can manage teams + describe('allowScopes org: authenticated user can manage teams', () => { + it('should auto-create org and let normal user create team', async () => { + const res = await app + .httpRequest() + .put('/-/org/cnpm/team') + .set('authorization', normalUser.authorization) + .send({ name: 'my-team', description: 'created by normal user' }) + .expect(200); + assert(res.body.ok); + + // Verify org was auto-created + const orgRepository = await app.getEggObject(OrgRepository); + const org = await orgRepository.findOrgByName('cnpm'); + assert(org); + }); + + it('should let normal user add team member without org membership', async () => { + const anotherUser = await TestUtil.createUser({ name: 'team-member-user' }); + + // Create team + await app + .httpRequest() + .put('/-/org/cnpm/team') + .set('authorization', normalUser.authorization) + .send({ name: 'dev-team' }) + .expect(200); + + // Add member via npm compatible route + const res = await app + .httpRequest() + .put('/-/team/cnpm/dev-team/user') + .set('authorization', normalUser.authorization) + .send({ user: anotherUser.name }) + .expect(200); + assert(res.body.ok); + }); + + it('should let normal user list teams for allowScopes org', async () => { + await app + .httpRequest() + .put('/-/org/cnpm/team') + .set('authorization', normalUser.authorization) + .send({ name: 'list-test-team' }) + .expect(200); + + const res = await app + .httpRequest() + .get('/-/org/cnpm/team') + .set('authorization', normalUser.authorization) + .expect(200); + assert(Array.isArray(res.body)); + assert(res.body.includes('cnpm:list-test-team')); + }); + }); +}); diff --git a/test/port/controller/package/ReadAccessAuth.test.ts b/test/port/controller/package/ReadAccessAuth.test.ts new file mode 100644 index 000000000..cda4b2b3a --- /dev/null +++ b/test/port/controller/package/ReadAccessAuth.test.ts @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; + +import { app } from '@eggjs/mock/bootstrap'; + +import { DEVELOPERS_TEAM } from '../../../../app/common/constants.ts'; +import { Package as PackageEntity } from '../../../../app/core/entity/Package.ts'; +import { TeamMember } from '../../../../app/core/entity/TeamMember.ts'; +import { TeamPackage } from '../../../../app/core/entity/TeamPackage.ts'; +import { OrgService } from '../../../../app/core/service/OrgService.ts'; +import { OrgRepository } from '../../../../app/repository/OrgRepository.ts'; +import { PackageRepository } from '../../../../app/repository/PackageRepository.ts'; +import { TeamRepository } from '../../../../app/repository/TeamRepository.ts'; +import { UserRepository } from '../../../../app/repository/UserRepository.ts'; +import { TestUtil } from '../../../TestUtil.ts'; + +describe('test/port/controller/package/ReadAccessAuth.test.ts', () => { + let adminUser: any; + let normalUser: any; + let teamMember: any; + let orgRepository: OrgRepository; + let teamRepository: TeamRepository; + let packageRepository: PackageRepository; + let userRepository: UserRepository; + + beforeEach(async () => { + orgRepository = await app.getEggObject(OrgRepository); + teamRepository = await app.getEggObject(TeamRepository); + packageRepository = await app.getEggObject(PackageRepository); + userRepository = await app.getEggObject(UserRepository); + + adminUser = await TestUtil.createAdmin(); + normalUser = await TestUtil.createUser({ name: 'org-read-normaluser' }); + teamMember = await TestUtil.createUser({ name: 'org-read-teammember' }); + }); + + describe('self scope + no team binding = everyone can read', () => { + beforeEach(async () => { + // Create a package in @cnpm scope with NO team binding + const pkgEntity = PackageEntity.create({ + scope: '@cnpm', + name: 'public-pkg', + isPrivate: true, + description: 'test package without team binding', + }); + await packageRepository.savePackage(pkgEntity); + }); + + it('should allow read without login (GET /:fullname)', async () => { + const res = await app.httpRequest().get('/@cnpm/public-pkg').set('Accept', 'application/json'); + // Should not be 401 or 403 + assert(![401, 403].includes(res.status), `expected no auth error, got ${res.status}`); + }); + + it('should allow read without login (GET /:fullname/:version)', async () => { + const res = await app.httpRequest().get('/@cnpm/public-pkg/1.0.0'); + assert(![401, 403].includes(res.status), `expected no auth error, got ${res.status}`); + }); + + it('should allow read without login (GET /:fullname/-/:file.tgz)', async () => { + const res = await app.httpRequest().get('/@cnpm/public-pkg/-/public-pkg-1.0.0.tgz'); + assert(![401, 403].includes(res.status), `expected no auth error, got ${res.status}`); + }); + + it('should not set private cache-control when no team binding', async () => { + const res = await app.httpRequest().get('/@cnpm/public-pkg').set('Accept', 'application/json'); + // no team binding = normal CDN behavior, not private + assert.notEqual(res.headers['cache-control'], 'private, no-store'); + }); + }); + + describe('self scope + team binding = only team members can read', () => { + beforeEach(async () => { + // Create org and team + const orgService = await app.getEggObject(OrgService); + const adminEntity = await userRepository.findUserByName(adminUser.name); + assert(adminEntity); + await orgService.createOrg({ + name: 'cnpm', + creatorUserId: adminEntity.userId, + }); + + const org = await orgRepository.findOrgByName('cnpm'); + assert(org); + const team = await teamRepository.findTeam(org.orgId, DEVELOPERS_TEAM); + assert(team); + + // Create package and bind to team + const pkgEntity = PackageEntity.create({ + scope: '@cnpm', + name: 'private-pkg', + isPrivate: true, + description: 'test package with team binding', + }); + await packageRepository.savePackage(pkgEntity); + const pkg = await packageRepository.findPackage('@cnpm', 'private-pkg'); + assert(pkg); + await teamRepository.addPackage( + TeamPackage.create({ + teamId: team.teamId, + packageId: pkg.packageId, + }), + ); + + // Add teamMember to the team + const teamMemberEntity = await userRepository.findUserByName(teamMember.name); + assert(teamMemberEntity); + await teamRepository.addMember( + TeamMember.create({ + teamId: team.teamId, + userId: teamMemberEntity.userId, + }), + ); + }); + + it('should 401 without login (GET /:fullname)', async () => { + const res = await app.httpRequest().get('/@cnpm/private-pkg').set('Accept', 'application/json'); + assert.equal(res.status, 401); + }); + + it('should 403 for user not in team (GET /:fullname)', async () => { + const res = await app + .httpRequest() + .get('/@cnpm/private-pkg') + .set('authorization', normalUser.authorization) + .set('Accept', 'application/json'); + assert.equal(res.status, 403); + }); + + it('should pass for team member (GET /:fullname)', async () => { + const res = await app + .httpRequest() + .get('/@cnpm/private-pkg') + .set('authorization', teamMember.authorization) + .set('Accept', 'application/json'); + assert([200, 404].includes(res.status), `expected 200 or 404, got ${res.status}`); + }); + + it('should pass for admin (GET /:fullname)', async () => { + const res = await app + .httpRequest() + .get('/@cnpm/private-pkg') + .set('authorization', adminUser.authorization) + .set('Accept', 'application/json'); + assert([200, 404].includes(res.status), `expected 200 or 404, got ${res.status}`); + }); + + it('should 401 without login (GET /:fullname/:version)', async () => { + const res = await app.httpRequest().get('/@cnpm/private-pkg/1.0.0'); + assert.equal(res.status, 401); + }); + + it('should 403 for user not in team (GET /:fullname/:version)', async () => { + const res = await app + .httpRequest() + .get('/@cnpm/private-pkg/1.0.0') + .set('authorization', normalUser.authorization); + assert.equal(res.status, 403); + }); + + it('should 401 without login (GET /:fullname/-/:file.tgz)', async () => { + const res = await app.httpRequest().get('/@cnpm/private-pkg/-/private-pkg-1.0.0.tgz'); + assert.equal(res.status, 401); + }); + + it('should 403 for user not in team (GET /:fullname/-/:file.tgz)', async () => { + const res = await app + .httpRequest() + .get('/@cnpm/private-pkg/-/private-pkg-1.0.0.tgz') + .set('authorization', normalUser.authorization); + assert.equal(res.status, 403); + }); + + it('should pass for admin (GET /:fullname/-/:file.tgz)', async () => { + const res = await app + .httpRequest() + .get('/@cnpm/private-pkg/-/private-pkg-1.0.0.tgz') + .set('authorization', adminUser.authorization); + assert(![401, 403].includes(res.status), `expected auth to pass, got ${res.status}`); + }); + }); +});