diff --git a/app/core/entity/TeamMember.ts b/app/core/entity/TeamMember.ts index ddbfaa5c..e397f1be 100644 --- a/app/core/entity/TeamMember.ts +++ b/app/core/entity/TeamMember.ts @@ -5,6 +5,7 @@ interface TeamMemberData extends EntityData { teamMemberId: string; teamId: string; userId: string; + role: string; } export type CreateTeamMemberData = Omit, 'id'>; @@ -13,12 +14,14 @@ export class TeamMember extends Entity { teamMemberId: string; teamId: string; userId: string; + role: string; constructor(data: TeamMemberData) { super(data); this.teamMemberId = data.teamMemberId; this.teamId = data.teamId; this.userId = data.userId; + this.role = data.role || 'member'; } static create(data: CreateTeamMemberData): TeamMember { diff --git a/app/core/service/OrgService.ts b/app/core/service/OrgService.ts index 7dfd767b..1bb16f3b 100644 --- a/app/core/service/OrgService.ts +++ b/app/core/service/OrgService.ts @@ -53,6 +53,7 @@ export class OrgService extends AbstractService { const teamMember = TeamMember.create({ teamId: developersTeam.teamId, userId: cmd.creatorUserId, + role: 'owner', }); await this.orgRepository.createOrgCascade(org, developersTeam, ownerMember, teamMember); @@ -109,6 +110,7 @@ export class OrgService extends AbstractService { const teamMember = TeamMember.create({ teamId: developersTeam.teamId, userId, + role: 'member', }); await this.teamRepository.addMember(teamMember); } diff --git a/app/core/service/TeamService.ts b/app/core/service/TeamService.ts index 69099ef2..bf8f253e 100644 --- a/app/core/service/TeamService.ts +++ b/app/core/service/TeamService.ts @@ -23,7 +23,7 @@ export class TeamService extends AbstractService { private readonly teamRepository: TeamRepository; - async createTeam(orgId: string, name: string, description?: string): Promise { + async createTeam(orgId: string, name: string, description?: string, creatorUserId?: string): Promise { const existing = await this.teamRepository.findTeam(orgId, name); if (existing) { throw new ForbiddenError(`Team "${name}" already exists`); @@ -35,7 +35,14 @@ export class TeamService extends AbstractService { description, }); await this.teamRepository.saveTeam(team); - this.logger.info('[TeamService:createTeam] teamId: %s, orgId: %s, name: %s', team.teamId, orgId, name); + + // Auto-add creator as team owner + if (creatorUserId) { + const member = TeamMember.create({ teamId: team.teamId, userId: creatorUserId, role: 'owner' }); + await this.teamRepository.addMember(member); + } + + this.logger.info('[TeamService:createTeam] teamId: %s, orgId: %s, name: %s, creator: %s', team.teamId, orgId, name, creatorUserId); return team; } @@ -52,7 +59,7 @@ export class TeamService extends AbstractService { this.logger.info('[TeamService:removeTeam] teamId: %s', teamId); } - async addMember(teamId: string, userId: string): Promise { + async addMember(teamId: string, userId: string, role: 'owner' | 'member' = 'member'): Promise { const team = await this.teamRepository.findTeamByTeamId(teamId); if (!team) { throw new NotFoundError('Team not found'); @@ -70,12 +77,17 @@ export class TeamService extends AbstractService { const existing = await this.teamRepository.findMember(teamId, userId); if (existing) { + // Update role if changed + if (existing.role !== role) { + existing.role = role; + await this.teamRepository.addMember(existing); + } return existing; } - const member = TeamMember.create({ teamId, userId }); + const member = TeamMember.create({ teamId, userId, role }); await this.teamRepository.addMember(member); - this.logger.info('[TeamService:addMember] teamId: %s, userId: %s', teamId, userId); + this.logger.info('[TeamService:addMember] teamId: %s, userId: %s, role: %s', teamId, userId, role); return member; } diff --git a/app/port/controller/OrgController.ts b/app/port/controller/OrgController.ts index a8dc5252..18e70529 100644 --- a/app/port/controller/OrgController.ts +++ b/app/port/controller/OrgController.ts @@ -165,7 +165,7 @@ export class OrgController extends AbstractController { 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 })); + const teamResults = await this.teamRepository.listTeamsByUserIdAndOrgId(targetUser.userId, org.orgId); + return teamResults.map(t => ({ name: t.team.name, description: t.team.description, role: t.role })); } } diff --git a/app/port/controller/TeamController.ts b/app/port/controller/TeamController.ts index 71506703..7f48086f 100644 --- a/app/port/controller/TeamController.ts +++ b/app/port/controller/TeamController.ts @@ -8,10 +8,11 @@ import { HTTPParam, Inject, } from '@eggjs/tegg'; -import { NotFoundError, UnprocessableEntityError } from 'egg-errors'; +import { NotFoundError, ForbiddenError, UnprocessableEntityError } from 'egg-errors'; import { AbstractController } from './AbstractController'; import { OrgService } from '../../core/service/OrgService'; import { TeamService } from '../../core/service/TeamService'; +import { OrgRepository } from '../../repository/OrgRepository'; import { TeamRepository } from '../../repository/TeamRepository'; import { getScopeAndName } from '../../common/PackageUtil'; @@ -23,6 +24,9 @@ export class TeamController extends AbstractController { @Inject() private readonly teamService: TeamService; + @Inject() + private readonly orgRepository: OrgRepository; + @Inject() private readonly teamRepository: TeamRepository; @@ -58,12 +62,44 @@ export class TeamController extends AbstractController { } private async requireTeamWriteAccess(ctx: EggContext, orgName: string, teamName: string) { - const { org, authorizedUser } = await this.requireOrgWriteAccess(ctx, orgName); + const authorizedUser = await this.userRoleManager.requiredAuthorizedUser(ctx, 'setting'); + const isAdmin = await this.userRoleManager.isAdmin(ctx); + + let org; + if (this.isAllowScopeOrg(orgName)) { + org = await this.orgService.ensureOrgForScope(`@${orgName}`); + } else { + org = await this.orgService.findOrgByName(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 { org, team, authorizedUser }; + + // Admin always has access + if (isAdmin) { + return { org, team, authorizedUser }; + } + + // Org owner has access + if (!this.isAllowScopeOrg(orgName)) { + const orgMember = await this.orgRepository.findMember(org.orgId, authorizedUser.userId); + if (orgMember && orgMember.role === 'owner') { + return { org, team, authorizedUser }; + } + } + + // Team owner has access + const teamMember = await this.teamRepository.findMember(team.teamId, authorizedUser.userId); + if (teamMember && teamMember.role === 'owner') { + return { org, team, authorizedUser }; + } + + throw new ForbiddenError('Only team owner or admin can perform this action'); } // --- Team CRUD --- @@ -75,12 +111,12 @@ export class TeamController extends AbstractController { }) async createTeam(@Context() ctx: EggContext, @HTTPParam() orgName: string, @HTTPBody() body: { name: string; description?: string }) { - const { org } = await this.requireOrgWriteAccess(ctx, orgName); + const { org, authorizedUser } = await this.requireOrgWriteAccess(ctx, orgName); if (!body.name) { throw new UnprocessableEntityError('name is required'); } - await this.teamService.createTeam(org.orgId, body.name, body.description); + await this.teamService.createTeam(org.orgId, body.name, body.description, authorizedUser.userId); return { ok: true }; } @@ -138,6 +174,7 @@ export class TeamController extends AbstractController { // --- Team Members (npm uses "user") --- // npm team ls @scope:team → GET /-/team/:orgName/:teamName/user + // npm compatible: returns string array ["user1", "user2"] @HTTPMethod({ path: '/-/team/:orgName/:teamName/user', method: HTTPMethodEnum.GET, @@ -158,6 +195,57 @@ export class TeamController extends AbstractController { return users.map(u => u.displayName); } + // Private API: GET /-/team/:orgName/:teamName/member + // Returns [{user, role}] with team member role info + @HTTPMethod({ + path: '/-/team/:orgName/:teamName/member', + method: HTTPMethodEnum.GET, + }) + async listTeamMembersWithRole(@Context() ctx: EggContext, @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)); + const userMap = new Map(users.map(u => [ u.userId, u ])); + return members.map(m => ({ + user: userMap.get(m.userId)?.displayName ?? '', + role: m.role, + })); + } + + // Private API: PATCH /-/team/:orgName/:teamName/member/:username + // Update team member role + @HTTPMethod({ + path: '/-/team/:orgName/:teamName/member/:username', + method: HTTPMethodEnum.PATCH, + }) + async updateTeamMemberRole(@Context() ctx: EggContext, @HTTPParam() orgName: string, + @HTTPParam() teamName: string, @HTTPParam() username: string, + @HTTPBody() body: { role: string }) { + const { team } = await this.requireTeamWriteAccess(ctx, orgName, teamName); + if (!body.role || (body.role !== 'owner' && body.role !== 'member')) { + throw new UnprocessableEntityError('role is required and must be "owner" or "member"'); + } + const targetUser = await this.userRepository.findUserByName(username); + if (!targetUser) { + throw new NotFoundError(`User "${username}" not found`); + } + const member = await this.teamRepository.findMember(team.teamId, targetUser.userId); + if (!member) { + throw new NotFoundError(`User "${username}" is not a member of this team`); + } + await this.teamService.addMember(team.teamId, targetUser.userId, body.role as 'owner' | 'member'); + return { ok: true }; + } + // npm team add @scope:team → PUT /-/team/:orgName/:teamName/user @HTTPMethod({ path: '/-/team/:orgName/:teamName/user', @@ -262,4 +350,5 @@ export class TeamController extends AbstractController { await this.teamService.revokePackageAccess(team.teamId, pkg.packageId); return { ok: true }; } + } diff --git a/app/repository/TeamRepository.ts b/app/repository/TeamRepository.ts index 5da63698..a875c43a 100644 --- a/app/repository/TeamRepository.ts +++ b/app/repository/TeamRepository.ts @@ -67,16 +67,19 @@ export class TeamRepository extends AbstractRepository { return models.map(model => ModelConvertor.convertModelToEntity(model, Team)); } - async listTeamsByUserIdAndOrgId(userId: string, orgId: string): Promise { + async listTeamsByUserIdAndOrgId(userId: string, orgId: string): Promise<{ team: Team; role: string }[]> { 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)); + const memberRoleMap = new Map(memberModels.map(m => [ m.teamId, m.role || 'member' ])); return orgTeams - .filter(t => memberTeamIds.has(t.teamId)) - .map(model => ModelConvertor.convertModelToEntity(model, Team)); + .filter(t => memberRoleMap.has(t.teamId)) + .map(model => ({ + team: ModelConvertor.convertModelToEntity(model, Team), + role: memberRoleMap.get(model.teamId) || 'member', + })); } // --- TeamMember --- @@ -89,6 +92,10 @@ export class TeamRepository extends AbstractRepository { async addMember(member: TeamMember): Promise { if (member.id) { + const model = await this.TeamMember.findOne({ id: member.id }); + if (model) { + await ModelConvertor.saveEntityToModel(member, model); + } return; } await ModelConvertor.convertEntityToModel(member, this.TeamMember); diff --git a/app/repository/model/TeamMember.ts b/app/repository/model/TeamMember.ts index 1edaf6bb..65bb3e15 100644 --- a/app/repository/model/TeamMember.ts +++ b/app/repository/model/TeamMember.ts @@ -23,4 +23,7 @@ export class TeamMember extends Bone { @Attribute(DataTypes.STRING(24)) userId: string; + + @Attribute(DataTypes.STRING(20)) + role: string; } diff --git a/docs/org-team.en.md b/docs/org-team.en.md new file mode 100644 index 00000000..5147da77 --- /dev/null +++ b/docs/org-team.en.md @@ -0,0 +1,422 @@ +# 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 | +| **TeamMember** | Team member with role `owner` (can manage team) or `member` | +| **TeamPackage** | Team's read access grant to a package | + +## Protocol Compatibility + +cnpmcore implements both **npm CLI compatible** endpoints and **private (extended)** endpoints. + +| Label | Meaning | +|-------|---------| +| **npm compatible** | Follows the npm registry API contract. Request/response format is compatible with `npm` CLI. | +| **Private** | cnpmcore extension. Not part of the npm registry API. Uses custom routes or adds extra fields (e.g., `role`). | + +> **Rule**: npm compatible endpoints never change their response format. Extended fields (like `role`) are only available via private endpoints. + +## Team Role Extension + +### The Problem with npm's Original Model + +In npm's original model, `@scope` maps to an Org, and Teams under an Org have no role information. Team membership is flat — a user is either "in" or "not in" the team, and everyone who can operate on a Team has equal permissions. + +This causes problems in enterprise scenarios: companies typically have a single Org (mapping to one `@scope`), and all employees are Org members. Since npm's Teams have no role differentiation, **any Org member can freely modify any Team** — adding/removing members, granting/revoking package access — which is unacceptable in practice. + +### cnpmcore's Extension + +cnpmcore adds a `role` field to Team members while maintaining full npm CLI compatibility: + +- **owner** — Can manage the Team (add/remove members, manage package access, delete Team) +- **member** — Regular member, only has read access to packages authorized for the Team + +#### Core Behavior + +1. When a **Team is created**, the creator is automatically added as Team Owner +2. **Team write operations** (add/remove members, manage packages, delete Team) require the operator to be a Team Owner, Org Owner, or Admin +3. Regular Org members **cannot directly manage other people's Teams** + +#### npm CLI Compatibility + +Users can still create and manage their own Teams via npm CLI: + +```bash +# Create Team (creator automatically becomes owner) +npm team create @mycompany:frontend --registry=http://localhost:7001 + +# Add member (only team owner can operate; members added via npm CLI default to member role) +npm team add @mycompany:frontend alice --registry=http://localhost:7001 + +# List members (returns plain username list, npm CLI compatible) +npm team ls @mycompany:frontend --registry=http://localhost:7001 +``` + +#### Private API Supplement + +Since npm CLI doesn't support Team role concepts, the following operations require private APIs: + +- **View member roles** — `GET /-/team/:org/:team/member` +- **Update member role** — `PATCH /-/team/:org/:team/member/:username` + +```bash +# List members (with role info) +curl http://localhost:7001/-/team/mycompany/frontend/member \ + -H "Authorization: Bearer " +# Returns: [{"user": "alice", "role": "owner"}, {"user": "bob", "role": "member"}] + +# Promote member to team owner +curl -X PATCH http://localhost:7001/-/team/mycompany/frontend/member/alice \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"role": "owner"}' + +# Demote to regular member +curl -X PATCH http://localhost:7001/-/team/mycompany/frontend/member/alice \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"role": "member"}' +``` + +## 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": "...", "role": "owner"}, ...] +``` + +## 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"}' +``` + +The creator is **auto-added as team `owner`**. + +### 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 + +#### List Members — npm compatible (GET /-/team/:orgName/:teamName/user) + +Returns a **string array** `["alice", "bob"]`, compatible with `npm team ls`. + +```bash +# npm CLI +npm team ls @mycompany:frontend --registry=http://localhost:7001 + +# HTTP +curl http://localhost:7001/-/team/mycompany/frontend/user \ + -H "Authorization: Bearer " +``` + +#### List Members with Role — Private (GET /-/team/:orgName/:teamName/member) + +Returns **objects with role info**: `[{"user": "alice", "role": "owner"}, {"user": "bob", "role": "member"}]`. + +```bash +curl http://localhost:7001/-/team/mycompany/frontend/member \ + -H "Authorization: Bearer " +``` + +#### Update Member Role — Private (PATCH /-/team/:orgName/:teamName/member/:username) + +```bash +curl -X PATCH http://localhost:7001/-/team/mycompany/frontend/member/alice \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"role": "owner"}' +``` + +#### Add Member (PUT /-/team/:orgName/:teamName/user) + +npm compatible. Members are always added with `member` role. Use the PATCH endpoint to change roles. + +```bash +# npm CLI +npm team add @mycompany:frontend alice --registry=http://localhost:7001 + +# HTTP +curl -X PUT http://localhost:7001/-/team/mycompany/frontend/user \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"user": "alice"}' +``` + +#### Remove Member (DELETE /-/team/:orgName/:teamName/user) + +```bash +# npm CLI +npm team rm @mycompany:frontend alice --registry=http://localhost:7001 + +# HTTP +curl -X DELETE http://localhost:7001/-/team/mycompany/frontend/user \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"user": "alice"}' +``` + +### 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 Team | Admin or Org Owner (allowScopes: any logged-in user) | +| Delete Team | Admin, Org Owner, or **Team Owner** | +| View Teams / Team info / Team members | Logged-in user | +| Add / Remove Team member | Admin, Org Owner, or **Team Owner** | +| Grant / Revoke package access | Admin, Org Owner, or **Team Owner** | +| View Team packages | Logged-in user | + +> **Team Owner** is a cnpmcore extension role. When a team is created, the creator is automatically added as the team owner. Team owners can manage their own team without needing org-level owner permissions. + +## Private Package Read Authentication + +cnpmcore supports Team-Package binding based read authentication for packages in `allowScopes` (self scope): + +- **self scope + no team binding** = publicly readable (no login required) +- **self scope + has team binding** = only team members can read + +### Authentication Flow + +``` +Request GET /@scope/name (manifest / version / tarball) + ↓ +scope not in allowScopes → public package, no auth needed + ↓ +scope in allowScopes (self scope): + 1. Check if package has Team-Package binding + 2. No binding → allow (publicly readable) + 3. Has binding: + a. Not logged in → 401 + b. Admin user → allow + c. User is in a Team that has access to this package → allow + d. None of the above → 403 +``` + +> **By default, all self scope packages are publicly readable.** Read authentication is only enabled after a Team-Package binding is created. + +### Usage Guide + +Using scope `@mycompany` as an example: + +#### Step 1: Configure allowScopes and Create Org + +```js +// config/config.prod.ts +config.cnpmcore = { + allowScopes: ['@mycompany'], +}; +``` + +```bash +# Create Org (admin) +curl -X PUT http://localhost:7001/-/org \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "mycompany", "description": "My Company"}' +``` + +#### Step 2: Publish Packages + +Published packages are **publicly readable** by default, no additional configuration needed. + +```bash +npm publish --registry=http://localhost:7001 +``` + +#### Step 3: (Optional) Bind Team to Protected Packages + +Only packages with Team bindings will have read authentication enabled: + +```bash +# Grant developers team access to a package +npm access grant read-only @mycompany:developers @mycompany/secret-lib \ + --registry=http://localhost:7001 +``` + +After binding, only `developers` team members can read `@mycompany/secret-lib`. Other `@mycompany/*` packages without Team bindings remain publicly readable. + +#### Fine-grained Control + +Create additional Teams for more granular permission control: + +```bash +# Create team (creator automatically becomes team owner) +npm team create @mycompany:frontend --registry=http://localhost:7001 + +# Add user to team (team owner can operate) +curl -X PUT http://localhost:7001/-/team/mycompany/frontend/user \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"user": "bob"}' + +# Grant team access to specific package +npm access grant read-only @mycompany:frontend @mycompany/secret-lib \ + --registry=http://localhost:7001 +``` + +### CDN Cache Behavior + +- Self scope packages have `Cache-Control: private, no-store` header, will not be cached by CDN +- Non-self scope packages retain the original CDN caching strategy + +## API Endpoints + +### npm CLI Compatible + +| 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 | +| PUT | `/-/org/:orgName/team` | Create team | +| GET | `/-/org/:orgName/team` | List teams | +| GET | `/-/team/:orgName/:teamName` | View team | +| DELETE | `/-/team/:orgName/:teamName` | Delete team | +| GET | `/-/team/:orgName/:teamName/user` | List team members (string array) | +| PUT | `/-/team/:orgName/:teamName/user` | Add team member | +| DELETE | `/-/team/:orgName/:teamName/user` | Remove team member | +| GET | `/-/team/:orgName/:teamName/package` | List team packages | +| PUT | `/-/team/:orgName/:teamName/package` | Grant package access | +| DELETE | `/-/team/:orgName/:teamName/package` | Revoke package access | + +### Private (cnpmcore extensions) + +| Method | Path | Description | Notes | +|--------|------|-------------|-------| +| GET | `/-/team/:orgName/:teamName/member` | List team members with role | Returns `[{user, role}]` | +| PATCH | `/-/team/:orgName/:teamName/member/:username` | Update team member role | Body `{role: "owner"\|"member"}` | +| GET | `/-/org/:orgName/member/:username/team` | List user's teams in org | Returns `[{name, description, role}]` | diff --git a/docs/org-team.md b/docs/org-team.md index 4c9b5fdf..4b62612e 100644 --- a/docs/org-team.md +++ b/docs/org-team.md @@ -1,19 +1,93 @@ -# Org & Team Management +# Org 与 Team 管理 -cnpmcore supports an Organization -> Team -> Package permission model for managing private package access. +cnpmcore 支持 Organization -> Team -> Package 权限模型,用于管理私有包的访问控制。 -## 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** | 组织,对应一个 scope(例如 org `mycompany` -> `@mycompany`) | +| **OrgMember** | 组织成员,角色为 `owner`(可管理)或 `member` | +| **Team** | 权限单元。每个 Org 自动创建 `developers` 默认团队 | +| **TeamMember** | 团队成员,角色为 `owner`(可管理团队)或 `member` | +| **TeamPackage** | 团队对包的读取授权 | -## Org Management (Admin only) +## 协议兼容性 -### Create Org +cnpmcore 同时实现了 **npm CLI 兼容**接口和**私有(扩展)**接口。 + +| 标签 | 含义 | +|------|------| +| **npm 兼容** | 遵循 npm registry API 协议,请求/响应格式兼容 `npm` CLI | +| **私有** | cnpmcore 扩展,不属于 npm registry API,使用自定义路由或额外字段(如 `role`) | + +> **规则**:npm 兼容接口的响应格式不会改变,扩展字段(如 `role`)仅通过私有接口提供。 + +## Team 角色扩展 + +### npm 原始模型的问题 + +在 npm 原始模型中,`@scope` 对应一个 Org,Org 下的 Team 没有角色信息。Team 成员是扁平的——只有"在"或"不在"两种状态,所有能操作 Team 的人拥有相同权限。 + +这在企业场景中会产生问题:企业通常只有一个 Org(对应一个 `@scope`),所有员工都是 Org 成员。由于 npm 的 Team 没有角色区分,**任何 Org 成员都可以随意修改任何 Team**——添加/删除成员、授权/撤销包访问——这在实际使用中是不可接受的。 + +### cnpmcore 的扩展方案 + +cnpmcore 在保持 npm CLI 完全兼容的前提下,为 Team 成员增加了 `role` 字段: + +- **owner** — 可以管理 Team(增删成员、管理包授权、删除 Team) +- **member** — 普通成员,仅拥有 Team 授权范围内的包读取权限 + +#### 核心行为 + +1. **创建 Team 时**,创建者自动成为 Team Owner +2. **Team 的写操作**(增删成员、管理包、删除 Team)要求操作者是 Team Owner、Org Owner 或 Admin +3. 普通 Org 成员**无法直接管理其他人的 Team** + +#### npm CLI 兼容性 + +用户仍然可以通过 npm CLI 直接创建和管理自己的 Team: + +```bash +# 创建 Team(创建者自动成为 owner) +npm team create @mycompany:frontend --registry=http://localhost:7001 + +# 添加成员(仅 team owner 可操作,通过 npm CLI 添加的成员默认为 member) +npm team add @mycompany:frontend alice --registry=http://localhost:7001 + +# 查看成员(返回纯用户名列表,兼容 npm CLI) +npm team ls @mycompany:frontend --registry=http://localhost:7001 +``` + +#### 私有接口补充 + +由于 npm CLI 不支持 Team 角色概念,以下操作需要通过私有接口完成: + +- **查看成员角色** — `GET /-/team/:org/:team/member` +- **修改成员角色** — `PATCH /-/team/:org/:team/member/:username` + +```bash +# 查看成员(含角色信息) +curl http://localhost:7001/-/team/mycompany/frontend/member \ + -H "Authorization: Bearer " +# 返回: [{"user": "alice", "role": "owner"}, {"user": "bob", "role": "member"}] + +# 将成员提升为 team owner +curl -X PATCH http://localhost:7001/-/team/mycompany/frontend/member/alice \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"role": "owner"}' + +# 将成员降为普通 member +curl -X PATCH http://localhost:7001/-/team/mycompany/frontend/member/alice \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"role": "member"}' +``` + +## Org 管理(仅限 Admin) + +### 创建 Org ```bash curl -X PUT http://localhost:7001/-/org \ @@ -22,32 +96,32 @@ curl -X PUT http://localhost:7001/-/org \ -d '{"name": "mycompany", "description": "My Company"}' ``` -### Delete Org +### 删除 Org ```bash -# Cascade deletes all teams, members, and package grants +# 级联删除所有团队、成员和包授权 curl -X DELETE http://localhost:7001/-/org/mycompany \ -H "Authorization: Bearer " ``` -### View Org Info +### 查看 Org 信息 ```bash curl http://localhost:7001/-/org/mycompany \ -H "Authorization: Bearer " ``` -## Member Management +## 成员管理 -Admin or Org Owner can manage members. +Admin 或 Org Owner 可以管理成员。 -### Add Member (npm CLI compatible) +### 添加成员(npm CLI 兼容) ```bash # npm CLI npm org set mycompany alice --registry=http://localhost:7001 -# Set as owner +# 设为 owner npm org set mycompany alice owner --registry=http://localhost:7001 # HTTP @@ -57,20 +131,20 @@ curl -X PUT http://localhost:7001/-/org/mycompany/member \ -d '{"user": "alice", "role": "member"}' ``` -New members are **auto-added to the `developers` team**. +新成员会**自动加入 `developers` 团队**。 -### List Members (npm CLI compatible) +### 查看成员(npm CLI 兼容) ```bash # npm CLI npm org ls mycompany --registry=http://localhost:7001 -# HTTP — returns { "alice": "owner", "bob": "member" } +# HTTP — 返回 { "alice": "owner", "bob": "member" } curl http://localhost:7001/-/org/mycompany/member \ -H "Authorization: Bearer " ``` -### Remove Member (npm CLI compatible) +### 移除成员(npm CLI 兼容) ```bash # npm CLI @@ -81,19 +155,19 @@ curl -X DELETE http://localhost:7001/-/org/mycompany/member/alice \ -H "Authorization: Bearer " ``` -Removing a member **auto-removes from all teams** in the org. +移除成员会**自动从该 Org 的所有团队中移除**。 -### List User's Teams +### 查看用户所属团队 ```bash curl http://localhost:7001/-/org/mycompany/member/alice/team \ -H "Authorization: Bearer " -# Returns: [{"name": "developers", "description": "..."}, ...] +# 返回: [{"name": "developers", "description": "...", "role": "owner"}, ...] ``` -## Team Management +## Team 管理 -### Create Team (npm CLI compatible) +### 创建 Team(npm CLI 兼容) ```bash # npm CLI @@ -106,7 +180,9 @@ curl -X PUT http://localhost:7001/-/org/mycompany/team \ -d '{"name": "frontend", "description": "Frontend team"}' ``` -### List Teams (npm CLI compatible) +创建者会**自动成为 Team `owner`**。 + +### 查看 Team 列表(npm CLI 兼容) ```bash # npm CLI @@ -117,7 +193,7 @@ curl http://localhost:7001/-/org/mycompany/team \ -H "Authorization: Bearer " ``` -### Delete Team (npm CLI compatible) +### 删除 Team(npm CLI 兼容) ```bash # npm CLI @@ -128,53 +204,100 @@ curl -X DELETE http://localhost:7001/-/org/mycompany/team/frontend \ -H "Authorization: Bearer " ``` -> The `developers` default team **cannot be deleted**. +> `developers` 默认团队**不可删除**。 + +### Team 成员 -### Team Members +#### 查看成员 — npm 兼容(GET /-/team/:orgName/:teamName/user) + +返回**字符串数组** `["alice", "bob"]`,兼容 `npm team ls`。 ```bash -# List members (npm CLI compatible) +# npm CLI 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 " \ +# HTTP +curl http://localhost:7001/-/team/mycompany/frontend/user \ + -H "Authorization: Bearer " +``` + +#### 查看成员(含角色)— 私有(GET /-/team/:orgName/:teamName/member) + +返回**带角色信息的对象**: `[{"user": "alice", "role": "owner"}, {"user": "bob", "role": "member"}]`。 + +```bash +curl http://localhost:7001/-/team/mycompany/frontend/member \ + -H "Authorization: Bearer " +``` + +#### 修改成员角色 — 私有(PATCH /-/team/:orgName/:teamName/member/:username) + +```bash +curl -X PATCH http://localhost:7001/-/team/mycompany/frontend/member/alice \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"role": "owner"}' +``` + +#### 添加成员(PUT /-/team/:orgName/:teamName/user) + +npm 兼容。添加的成员默认为 `member` 角色,修改角色请使用 PATCH 接口。 + +```bash +# npm CLI +npm team add @mycompany:frontend alice --registry=http://localhost:7001 + +# HTTP +curl -X PUT http://localhost:7001/-/team/mycompany/frontend/user \ + -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 " +#### 移除成员(DELETE /-/team/:orgName/:teamName/user) + +```bash +# npm CLI +npm team rm @mycompany:frontend alice --registry=http://localhost:7001 + +# HTTP +curl -X DELETE http://localhost:7001/-/team/mycompany/frontend/user \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"user": "alice"}' ``` -### Team Package Access +### Team 包授权 ```bash -# Grant access (npm CLI compatible) +# 授权(npm CLI 兼容) npm access grant read-only @mycompany:frontend @mycompany/ui-lib \ --registry=http://localhost:7001 -# List packages (npm CLI compatible) +# 查看包列表(npm CLI 兼容) npm access ls-packages @mycompany:frontend --registry=http://localhost:7001 -# Revoke access (npm CLI compatible) +# 撤销授权(npm CLI 兼容) 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 | +| 操作 | 所需权限 | +|------|---------| +| 创建 / 删除 Org | Admin | +| 查看 Org 信息 | 登录用户 | +| 添加 / 移除 Org 成员 | Admin 或 Org Owner | +| 查看 Org 成员 | 登录用户 | +| 创建 Team | Admin 或 Org Owner(allowScopes: 任意登录用户) | +| 删除 Team | Admin、Org Owner 或 **Team Owner** | +| 查看 Team / Team 信息 / Team 成员 | 登录用户 | +| 添加 / 移除 Team 成员 | Admin、Org Owner 或 **Team Owner** | +| 授权 / 撤销包访问 | Admin、Org Owner 或 **Team Owner** | +| 查看 Team 包列表 | 登录用户 | + +> **Team Owner** 是 cnpmcore 的扩展角色。创建 Team 时,创建者自动成为 Team Owner。Team Owner 可以管理自己的团队,无需 Org 级别的 Owner 权限。 ## 私有包读取鉴权 @@ -248,12 +371,12 @@ npm access grant read-only @mycompany:developers @mycompany/secret-lib \ 创建额外的 Team 可以实现更精细的权限控制: ```bash -# 创建团队 +# 创建团队(创建者自动成为 team owner) npm team create @mycompany:frontend --registry=http://localhost:7001 -# 将用户加入团队 -curl -X PUT http://localhost:7001/-/org/mycompany/team/frontend/member \ - -H "Authorization: Bearer " \ +# 将用户加入团队(team owner 即可操作) +curl -X PUT http://localhost:7001/-/team/mycompany/frontend/user \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"user": "bob"}' @@ -267,24 +390,33 @@ npm access grant read-only @mycompany:frontend @mycompany/secret-lib \ - 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 | +## API 接口列表 + +### npm CLI 兼容 + +| 方法 | 路径 | 说明 | +|------|------|------| +| PUT | `/-/org` | 创建 Org | +| GET | `/-/org/:orgName` | 查看 Org | +| DELETE | `/-/org/:orgName` | 删除 Org | +| GET | `/-/org/:orgName/member` | 查看 Org 成员 | +| PUT | `/-/org/:orgName/member` | 添加 Org 成员 | +| DELETE | `/-/org/:orgName/member/:username` | 移除 Org 成员 | +| PUT | `/-/org/:orgName/team` | 创建 Team | +| GET | `/-/org/:orgName/team` | 查看 Team 列表 | +| GET | `/-/team/:orgName/:teamName` | 查看 Team 信息 | +| DELETE | `/-/team/:orgName/:teamName` | 删除 Team | +| GET | `/-/team/:orgName/:teamName/user` | 查看 Team 成员(字符串数组) | +| PUT | `/-/team/:orgName/:teamName/user` | 添加 Team 成员 | +| DELETE | `/-/team/:orgName/:teamName/user` | 移除 Team 成员 | +| GET | `/-/team/:orgName/:teamName/package` | 查看 Team 包列表 | +| PUT | `/-/team/:orgName/:teamName/package` | 授权包访问 | +| DELETE | `/-/team/:orgName/:teamName/package` | 撤销包访问 | + +### 私有接口(cnpmcore 扩展) + +| 方法 | 路径 | 说明 | 备注 | +|------|------|------|------| +| GET | `/-/team/:orgName/:teamName/member` | 查看 Team 成员(含角色) | 返回 `[{user, role}]` | +| PATCH | `/-/team/:orgName/:teamName/member/:username` | 修改 Team 成员角色 | Body `{role: "owner"\|"member"}` | +| GET | `/-/org/:orgName/member/:username/team` | 查看用户在 Org 中所属的 Team | 返回 `[{name, description, role}]` | diff --git a/sql/mysql/3.81.0.sql b/sql/mysql/3.81.0.sql new file mode 100644 index 00000000..c78612cf --- /dev/null +++ b/sql/mysql/3.81.0.sql @@ -0,0 +1 @@ +ALTER TABLE `team_members` ADD COLUMN `role` varchar(20) NOT NULL DEFAULT 'member' COMMENT 'member role: owner or member' AFTER `user_id`; diff --git a/sql/postgresql/3.81.0.sql b/sql/postgresql/3.81.0.sql new file mode 100644 index 00000000..afe02931 --- /dev/null +++ b/sql/postgresql/3.81.0.sql @@ -0,0 +1 @@ +ALTER TABLE team_members ADD COLUMN role varchar(20) NOT NULL DEFAULT 'member'; diff --git a/test/port/controller/TeamController/index.test.ts b/test/port/controller/TeamController/index.test.ts index 13ac643b..ae70b0d0 100644 --- a/test/port/controller/TeamController/index.test.ts +++ b/test/port/controller/TeamController/index.test.ts @@ -19,6 +19,7 @@ describe('test/port/controller/TeamController/index.test.ts', () => { .expect(200); }); + // ==================== createTeam ==================== describe('[PUT /-/org/:orgName/team] createTeam()', () => { it('should 200 when admin creates team', async () => { const res = await app.httpRequest() @@ -29,8 +30,29 @@ describe('test/port/controller/TeamController/index.test.ts', () => { assert(res.body.ok); }); - it('should 403 when non-owner creates team', async () => { - // Add normalUser as member (not owner) + it('should auto-add creator as team owner', async () => { + await app.httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({ name: 'owned-team' }) + .expect(200); + + const res = await app.httpRequest() + .get('/-/team/teamorg/owned-team/member') + .set('authorization', adminUser.authorization) + .expect(200); + assert(Array.isArray(res.body)); + assert(res.body.some((m: any) => m.user === adminUser.displayName && m.role === 'owner')); + }); + + it('should 401 without authorization', async () => { + await app.httpRequest() + .put('/-/org/teamorg/team') + .send({ name: 'no-auth-team' }) + .expect(401); + }); + + it('should 403 when non-owner creates team in non-allowScopes org', async () => { await app.httpRequest() .put('/-/org/teamorg/member') .set('authorization', adminUser.authorization) @@ -44,6 +66,22 @@ describe('test/port/controller/TeamController/index.test.ts', () => { .expect(403); }); + it('should allow org owner to create team', async () => { + const ownerUser = await TestUtil.createUser({ name: 'org-owner-user' }); + await app.httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: ownerUser.name, role: 'owner' }) + .expect(200); + + const res = await app.httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', ownerUser.authorization) + .send({ name: 'owner-created-team' }) + .expect(200); + assert(res.body.ok); + }); + it('should 422 when name is missing', async () => { await app.httpRequest() .put('/-/org/teamorg/team') @@ -64,8 +102,17 @@ describe('test/port/controller/TeamController/index.test.ts', () => { .send({ name: 'dup-team' }) .expect(403); }); + + it('should 404 when non-allowScopes org not found', async () => { + await app.httpRequest() + .put('/-/org/nonexistent-org/team') + .set('authorization', adminUser.authorization) + .send({ name: 'some-team' }) + .expect(404); + }); }); + // ==================== listTeams ==================== describe('[GET /-/org/:orgName/team] listTeams()', () => { it('should list teams including developers', async () => { const res = await app.httpRequest() @@ -75,16 +122,64 @@ describe('test/port/controller/TeamController/index.test.ts', () => { assert(Array.isArray(res.body)); assert(res.body.includes('teamorg:developers')); }); + + it('should list multiple teams', async () => { + await app.httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({ name: 'frontend' }) + .expect(200); + await app.httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({ name: 'backend' }) + .expect(200); + + const res = await app.httpRequest() + .get('/-/org/teamorg/team') + .set('authorization', normalUser.authorization) + .expect(200); + assert(res.body.includes('teamorg:developers')); + assert(res.body.includes('teamorg:frontend')); + assert(res.body.includes('teamorg:backend')); + }); + + it('should 401 without authorization', async () => { + await app.httpRequest() + .get('/-/org/teamorg/team') + .expect(401); + }); + + it('should 404 when non-allowScopes org not found', async () => { + await app.httpRequest() + .get('/-/org/nonexistent-org/team') + .set('authorization', normalUser.authorization) + .expect(404); + }); }); - // npm compatible routes: /-/team/:scope/:team + // ==================== showTeam ==================== describe('[GET /-/team/:orgName/:teamName] showTeam()', () => { - it('should 200', async () => { + it('should 200 and return team info', async () => { + await app.httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({ name: 'show-team', description: 'A test team' }) + .expect(200); + const res = await app.httpRequest() - .get('/-/team/teamorg/developers') + .get('/-/team/teamorg/show-team') .set('authorization', normalUser.authorization) .expect(200); - assert.equal(res.body.name, 'developers'); + assert.equal(res.body.name, 'show-team'); + assert.equal(res.body.description, 'A test team'); + assert(res.body.created); + }); + + it('should 401 without authorization', async () => { + await app.httpRequest() + .get('/-/team/teamorg/developers') + .expect(401); }); it('should 404 when team not found', async () => { @@ -93,10 +188,18 @@ describe('test/port/controller/TeamController/index.test.ts', () => { .set('authorization', normalUser.authorization) .expect(404); }); + + it('should 404 when org not found', async () => { + await app.httpRequest() + .get('/-/team/nonexistent-org/some-team') + .set('authorization', normalUser.authorization) + .expect(404); + }); }); + // ==================== removeTeam ==================== describe('[DELETE /-/team/:orgName/:teamName] removeTeam()', () => { - it('should 200 for custom team', async () => { + it('should 200 for custom team by admin', async () => { await app.httpRequest() .put('/-/org/teamorg/team') .set('authorization', adminUser.authorization) @@ -108,6 +211,27 @@ describe('test/port/controller/TeamController/index.test.ts', () => { .set('authorization', adminUser.authorization) .expect(200); assert(res.body.ok); + + // Verify team is gone + await app.httpRequest() + .get('/-/team/teamorg/to-delete') + .set('authorization', adminUser.authorization) + .expect(404); + }); + + it('should allow team owner to delete own team', async () => { + // Create team in allowScopes org — normalUser becomes owner + await app.httpRequest() + .put('/-/org/cnpm/team') + .set('authorization', normalUser.authorization) + .send({ name: 'owner-delete-team' }) + .expect(200); + + const res = await app.httpRequest() + .delete('/-/team/cnpm/owner-delete-team') + .set('authorization', normalUser.authorization) + .expect(200); + assert(res.body.ok); }); it('should 403 when deleting developers team', async () => { @@ -116,18 +240,253 @@ describe('test/port/controller/TeamController/index.test.ts', () => { .set('authorization', adminUser.authorization) .expect(403); }); + + it('should 403 when non-owner tries to delete', async () => { + await app.httpRequest() + .put('/-/org/cnpm/team') + .set('authorization', normalUser.authorization) + .send({ name: 'protected-team' }) + .expect(200); + + const anotherUser = await TestUtil.createUser({ name: 'delete-attacker' }); + await app.httpRequest() + .delete('/-/team/cnpm/protected-team') + .set('authorization', anotherUser.authorization) + .expect(403); + }); + + it('should 401 without authorization', async () => { + await app.httpRequest() + .delete('/-/team/teamorg/developers') + .expect(401); + }); + + it('should 404 when team not found', async () => { + await app.httpRequest() + .delete('/-/team/teamorg/nonexistent') + .set('authorization', adminUser.authorization) + .expect(404); + }); + }); + + // ==================== listTeamMembers (npm compatible) ==================== + describe('[GET /-/team/:orgName/:teamName/user] listTeamMembers()', () => { + it('should return string array (npm compatible)', async () => { + const res = await app.httpRequest() + .get('/-/team/teamorg/developers/user') + .set('authorization', normalUser.authorization) + .expect(200); + assert(Array.isArray(res.body)); + // admin is auto-added to developers on org creation + assert(res.body.includes(adminUser.displayName)); + }); + + it('should 401 without authorization', async () => { + await app.httpRequest() + .get('/-/team/teamorg/developers/user') + .expect(401); + }); + + it('should 404 when team not found', async () => { + await app.httpRequest() + .get('/-/team/teamorg/nonexistent/user') + .set('authorization', normalUser.authorization) + .expect(404); + }); + + it('should 404 when org not found', async () => { + await app.httpRequest() + .get('/-/team/nonexistent-org/some-team/user') + .set('authorization', normalUser.authorization) + .expect(404); + }); }); - describe('team member management (/-/team/:scope/:team/user)', () => { + // ==================== listTeamMembersWithRole (private) ==================== + describe('[GET /-/team/:orgName/:teamName/member] listTeamMembersWithRole()', () => { + it('should return [{user, role}]', async () => { + const res = await app.httpRequest() + .get('/-/team/teamorg/developers/member') + .set('authorization', normalUser.authorization) + .expect(200); + assert(Array.isArray(res.body)); + assert(res.body.some((m: any) => m.user === adminUser.displayName && m.role === 'owner')); + }); + + it('should 401 without authorization', async () => { + await app.httpRequest() + .get('/-/team/teamorg/developers/member') + .expect(401); + }); + + it('should 404 when team not found', async () => { + await app.httpRequest() + .get('/-/team/teamorg/nonexistent/member') + .set('authorization', normalUser.authorization) + .expect(404); + }); + + it('should 404 when org not found', async () => { + await app.httpRequest() + .get('/-/team/nonexistent-org/some-team/member') + .set('authorization', normalUser.authorization) + .expect(404); + }); + }); + + // ==================== updateTeamMemberRole (PATCH) ==================== + describe('[PATCH /-/team/:orgName/:teamName/member/:username] updateTeamMemberRole()', () => { + beforeEach(async () => { + await app.httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({ name: 'role-team' }) + .expect(200); + + await app.httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name, role: 'member' }) + .expect(200); + + await app.httpRequest() + .put('/-/team/teamorg/role-team/user') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name }) + .expect(200); + }); + + it('should promote member to owner', async () => { + const res = await app.httpRequest() + .patch(`/-/team/teamorg/role-team/member/${normalUser.name}`) + .set('authorization', adminUser.authorization) + .send({ role: 'owner' }) + .expect(200); + assert(res.body.ok); + + const members = await app.httpRequest() + .get('/-/team/teamorg/role-team/member') + .set('authorization', normalUser.authorization) + .expect(200); + assert(members.body.some((m: any) => m.user === normalUser.displayName && m.role === 'owner')); + }); + + it('should demote owner to member', async () => { + // Promote first + await app.httpRequest() + .patch(`/-/team/teamorg/role-team/member/${normalUser.name}`) + .set('authorization', adminUser.authorization) + .send({ role: 'owner' }) + .expect(200); + + // Demote + await app.httpRequest() + .patch(`/-/team/teamorg/role-team/member/${normalUser.name}`) + .set('authorization', adminUser.authorization) + .send({ role: 'member' }) + .expect(200); + + const members = await app.httpRequest() + .get('/-/team/teamorg/role-team/member') + .set('authorization', normalUser.authorization) + .expect(200); + assert(members.body.some((m: any) => m.user === normalUser.displayName && m.role === 'member')); + }); + + it('should 401 without authorization', async () => { + await app.httpRequest() + .patch(`/-/team/teamorg/role-team/member/${normalUser.name}`) + .send({ role: 'owner' }) + .expect(401); + }); + + it('should 403 when non-owner tries to update role', async () => { + await app.httpRequest() + .patch(`/-/team/teamorg/role-team/member/${normalUser.name}`) + .set('authorization', normalUser.authorization) + .send({ role: 'owner' }) + .expect(403); + }); + + it('should 422 when role is missing', async () => { + await app.httpRequest() + .patch(`/-/team/teamorg/role-team/member/${normalUser.name}`) + .set('authorization', adminUser.authorization) + .send({}) + .expect(422); + }); + + it('should 422 when role is invalid', async () => { + await app.httpRequest() + .patch(`/-/team/teamorg/role-team/member/${normalUser.name}`) + .set('authorization', adminUser.authorization) + .send({ role: 'admin' }) + .expect(422); + }); + + it('should 404 when user not found', async () => { + await app.httpRequest() + .patch('/-/team/teamorg/role-team/member/ghost-user') + .set('authorization', adminUser.authorization) + .send({ role: 'owner' }) + .expect(404); + }); + + it('should 404 when user is not a team member', async () => { + const outsider = await TestUtil.createUser({ name: 'outsider-user' }); + await app.httpRequest() + .patch(`/-/team/teamorg/role-team/member/${outsider.name}`) + .set('authorization', adminUser.authorization) + .send({ role: 'owner' }) + .expect(404); + }); + + it('should 404 when team not found', async () => { + await app.httpRequest() + .patch(`/-/team/teamorg/nonexistent/member/${normalUser.name}`) + .set('authorization', adminUser.authorization) + .send({ role: 'owner' }) + .expect(404); + }); + + it('should allow team owner to update role', async () => { + // Promote normalUser to owner + await app.httpRequest() + .patch(`/-/team/teamorg/role-team/member/${normalUser.name}`) + .set('authorization', adminUser.authorization) + .send({ role: 'owner' }) + .expect(200); + + // normalUser (now team owner) can update others + const anotherUser = await TestUtil.createUser({ name: 'role-target' }); + await app.httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: anotherUser.name, role: 'member' }) + .expect(200); + await app.httpRequest() + .put('/-/team/teamorg/role-team/user') + .set('authorization', normalUser.authorization) + .send({ user: anotherUser.name }) + .expect(200); + + await app.httpRequest() + .patch(`/-/team/teamorg/role-team/member/${anotherUser.name}`) + .set('authorization', normalUser.authorization) + .send({ role: 'owner' }) + .expect(200); + }); + }); + + // ==================== addTeamMember ==================== + describe('[PUT /-/team/:orgName/:teamName/user] addTeamMember()', () => { beforeEach(async () => { - // Create a custom team await app.httpRequest() .put('/-/org/teamorg/team') .set('authorization', adminUser.authorization) - .send({ name: 'coreteam' }) + .send({ name: 'add-team' }) .expect(200); - // Add normalUser to org first await app.httpRequest() .put('/-/org/teamorg/member') .set('authorization', adminUser.authorization) @@ -135,47 +494,164 @@ describe('test/port/controller/TeamController/index.test.ts', () => { .expect(200); }); - it('should add and list team members', async () => { - // Add to team via npm compatible route + it('should add member and verify in list', async () => { await app.httpRequest() - .put('/-/team/teamorg/coreteam/user') + .put('/-/team/teamorg/add-team/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') + .get('/-/team/teamorg/add-team/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 () => { + it('should add member as member role by default', async () => { await app.httpRequest() - .put('/-/team/teamorg/coreteam/user') + .put('/-/team/teamorg/add-team/user') .set('authorization', adminUser.authorization) .send({ user: normalUser.name }) .expect(200); - // npm rm sends DELETE with body {user} + const res = await app.httpRequest() + .get('/-/team/teamorg/add-team/member') + .set('authorization', normalUser.authorization) + .expect(200); + assert(res.body.some((m: any) => m.user === normalUser.displayName && m.role === 'member')); + }); + + it('should be idempotent when adding same user twice', async () => { await app.httpRequest() - .delete('/-/team/teamorg/coreteam/user') + .put('/-/team/teamorg/add-team/user') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name }) + .expect(200); + + await app.httpRequest() + .put('/-/team/teamorg/add-team/user') .set('authorization', adminUser.authorization) .send({ user: normalUser.name }) .expect(200); const res = await app.httpRequest() - .get('/-/team/teamorg/coreteam/user') + .get('/-/team/teamorg/add-team/user') + .set('authorization', normalUser.authorization) + .expect(200); + // Should appear only once + assert.equal(res.body.filter((u: string) => u === normalUser.displayName).length, 1); + }); + + it('should 401 without authorization', async () => { + await app.httpRequest() + .put('/-/team/teamorg/add-team/user') + .send({ user: normalUser.name }) + .expect(401); + }); + + it('should 403 when non-owner tries to add member', async () => { + const anotherUser = await TestUtil.createUser({ name: 'add-attacker' }); + await app.httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: anotherUser.name, role: 'member' }) + .expect(200); + + await app.httpRequest() + .put('/-/team/teamorg/add-team/user') .set('authorization', normalUser.authorization) + .send({ user: anotherUser.name }) + .expect(403); + }); + + it('should 422 when user is missing', async () => { + await app.httpRequest() + .put('/-/team/teamorg/add-team/user') + .set('authorization', adminUser.authorization) + .send({}) + .expect(422); + }); + + it('should 404 when target user not found', async () => { + await app.httpRequest() + .put('/-/team/teamorg/add-team/user') + .set('authorization', adminUser.authorization) + .send({ user: 'ghost-user' }) + .expect(404); + }); + + it('should 404 when team not found', async () => { + await app.httpRequest() + .put('/-/team/teamorg/nonexistent/user') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name }) + .expect(404); + }); + + it('should 404 when org not found for write operation', async () => { + await app.httpRequest() + .put('/-/team/nonexistent-org/some-team/user') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name }) + .expect(404); + }); + }); + + // ==================== removeTeamMember ==================== + describe('[DELETE /-/team/:orgName/:teamName/user] removeTeamMember()', () => { + beforeEach(async () => { + await app.httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({ name: 'rm-team' }) + .expect(200); + + await app.httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name, role: 'member' }) + .expect(200); + + await app.httpRequest() + .put('/-/team/teamorg/rm-team/user') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name }) + .expect(200); + }); + + it('should remove team member', async () => { + await app.httpRequest() + .delete('/-/team/teamorg/rm-team/user') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name }) + .expect(200); + + const res = await app.httpRequest() + .get('/-/team/teamorg/rm-team/user') + .set('authorization', adminUser.authorization) .expect(200); assert(!res.body.includes(normalUser.displayName)); }); + it('should 401 without authorization', async () => { + await app.httpRequest() + .delete('/-/team/teamorg/rm-team/user') + .send({ user: normalUser.name }) + .expect(401); + }); + + it('should 403 when non-owner tries to remove member', async () => { + await app.httpRequest() + .delete('/-/team/teamorg/rm-team/user') + .set('authorization', normalUser.authorization) + .send({ user: normalUser.name }) + .expect(403); + }); + it('should 422 when user is missing', async () => { await app.httpRequest() - .put('/-/team/teamorg/coreteam/user') + .delete('/-/team/teamorg/rm-team/user') .set('authorization', adminUser.authorization) .send({}) .expect(422); @@ -183,13 +659,22 @@ describe('test/port/controller/TeamController/index.test.ts', () => { it('should 404 when target user not found', async () => { await app.httpRequest() - .put('/-/team/teamorg/coreteam/user') + .delete('/-/team/teamorg/rm-team/user') .set('authorization', adminUser.authorization) .send({ user: 'ghost-user' }) .expect(404); }); + + it('should 404 when team not found', async () => { + await app.httpRequest() + .delete('/-/team/teamorg/nonexistent/user') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name }) + .expect(404); + }); }); + // ==================== team package management ==================== describe('team package management (/-/team/:scope/:team/package)', () => { it('should list empty packages initially', async () => { const res = await app.httpRequest() @@ -199,6 +684,26 @@ describe('test/port/controller/TeamController/index.test.ts', () => { assert.deepEqual(res.body, {}); }); + it('should 401 list packages without authorization', async () => { + await app.httpRequest() + .get('/-/team/teamorg/developers/package') + .expect(401); + }); + + it('should 404 list packages when team not found', async () => { + await app.httpRequest() + .get('/-/team/teamorg/nonexistent/package') + .set('authorization', normalUser.authorization) + .expect(404); + }); + + it('should 404 list packages when org not found', async () => { + await app.httpRequest() + .get('/-/team/nonexistent-org/some-team/package') + .set('authorization', normalUser.authorization) + .expect(404); + }); + it('should 422 when package name is missing for grant', async () => { await app.httpRequest() .put('/-/team/teamorg/developers/package') @@ -215,6 +720,14 @@ describe('test/port/controller/TeamController/index.test.ts', () => { .expect(404); }); + it('should 422 when package name is missing for revoke', async () => { + await app.httpRequest() + .delete('/-/team/teamorg/developers/package') + .set('authorization', adminUser.authorization) + .send({}) + .expect(422); + }); + it('should 404 when package not found for revoke', async () => { await app.httpRequest() .delete('/-/team/teamorg/developers/package') @@ -222,10 +735,121 @@ describe('test/port/controller/TeamController/index.test.ts', () => { .send({ package: '@cnpm/nonexistent-pkg' }) .expect(404); }); + + it('should 403 when non-owner tries to grant', async () => { + await app.httpRequest() + .put('/-/team/teamorg/developers/package') + .set('authorization', normalUser.authorization) + .send({ package: '@cnpm/foo' }) + .expect(403); + }); + + it('should 403 when non-owner tries to revoke', async () => { + await app.httpRequest() + .delete('/-/team/teamorg/developers/package') + .set('authorization', normalUser.authorization) + .send({ package: '@cnpm/foo' }) + .expect(403); + }); }); - // @cnpm is in allowScopes — any authenticated user can manage teams - describe('allowScopes org: authenticated user can manage teams', () => { + // ==================== requireTeamWriteAccess permission paths ==================== + describe('requireTeamWriteAccess permission paths', () => { + beforeEach(async () => { + await app.httpRequest() + .put('/-/org/teamorg/team') + .set('authorization', adminUser.authorization) + .send({ name: 'perm-team' }) + .expect(200); + }); + + it('should allow admin to write', async () => { + const anotherUser = await TestUtil.createUser({ name: 'perm-user-1' }); + await app.httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: anotherUser.name, role: 'member' }) + .expect(200); + + await app.httpRequest() + .put('/-/team/teamorg/perm-team/user') + .set('authorization', adminUser.authorization) + .send({ user: anotherUser.name }) + .expect(200); + }); + + it('should allow org owner (non-admin) to write', async () => { + const orgOwner = await TestUtil.createUser({ name: 'perm-org-owner' }); + await app.httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: orgOwner.name, role: 'owner' }) + .expect(200); + + const targetUser = await TestUtil.createUser({ name: 'perm-target-1' }); + await app.httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: targetUser.name, role: 'member' }) + .expect(200); + + await app.httpRequest() + .put('/-/team/teamorg/perm-team/user') + .set('authorization', orgOwner.authorization) + .send({ user: targetUser.name }) + .expect(200); + }); + + it('should allow team owner to write', async () => { + // Add normalUser to org and team, promote to team owner + await app.httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name, role: 'member' }) + .expect(200); + await app.httpRequest() + .put('/-/team/teamorg/perm-team/user') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name }) + .expect(200); + await app.httpRequest() + .patch(`/-/team/teamorg/perm-team/member/${normalUser.name}`) + .set('authorization', adminUser.authorization) + .send({ role: 'owner' }) + .expect(200); + + const targetUser = await TestUtil.createUser({ name: 'perm-target-2' }); + await app.httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: targetUser.name, role: 'member' }) + .expect(200); + + await app.httpRequest() + .put('/-/team/teamorg/perm-team/user') + .set('authorization', normalUser.authorization) + .send({ user: targetUser.name }) + .expect(200); + }); + + it('should reject org member (non-owner, non-team-owner)', async () => { + await app.httpRequest() + .put('/-/org/teamorg/member') + .set('authorization', adminUser.authorization) + .send({ user: normalUser.name, role: 'member' }) + .expect(200); + + const targetUser = await TestUtil.createUser({ name: 'perm-target-3' }); + await app.httpRequest() + .put('/-/team/teamorg/perm-team/user') + .set('authorization', normalUser.authorization) + .send({ user: targetUser.name }) + .expect(403); + }); + }); + + // ==================== allowScopes org ==================== + describe('allowScopes org: team management', () => { it('should auto-create org and let normal user create team', async () => { const res = await app.httpRequest() .put('/-/org/cnpm/team') @@ -234,23 +858,20 @@ describe('test/port/controller/TeamController/index.test.ts', () => { .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 () => { + it('should let team owner add member', 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) @@ -259,7 +880,23 @@ describe('test/port/controller/TeamController/index.test.ts', () => { assert(res.body.ok); }); - it('should let normal user list teams for allowScopes org', async () => { + it('should 403 when non-owner tries to modify team', async () => { + const anotherUser = await TestUtil.createUser({ name: 'non-owner-user' }); + + await app.httpRequest() + .put('/-/org/cnpm/team') + .set('authorization', normalUser.authorization) + .send({ name: 'restricted-team' }) + .expect(200); + + await app.httpRequest() + .put('/-/team/cnpm/restricted-team/user') + .set('authorization', anotherUser.authorization) + .send({ user: normalUser.name }) + .expect(403); + }); + + it('should let normal user list teams', async () => { await app.httpRequest() .put('/-/org/cnpm/team') .set('authorization', normalUser.authorization) @@ -273,5 +910,83 @@ describe('test/port/controller/TeamController/index.test.ts', () => { assert(Array.isArray(res.body)); assert(res.body.includes('cnpm:list-test-team')); }); + + it('should let team owner update member role in allowScopes org', async () => { + const anotherUser = await TestUtil.createUser({ name: 'allow-role-user' }); + + await app.httpRequest() + .put('/-/org/cnpm/team') + .set('authorization', normalUser.authorization) + .send({ name: 'allow-role-team' }) + .expect(200); + + await app.httpRequest() + .put('/-/team/cnpm/allow-role-team/user') + .set('authorization', normalUser.authorization) + .send({ user: anotherUser.name }) + .expect(200); + + await app.httpRequest() + .patch(`/-/team/cnpm/allow-role-team/member/${anotherUser.name}`) + .set('authorization', normalUser.authorization) + .send({ role: 'owner' }) + .expect(200); + + const members = await app.httpRequest() + .get('/-/team/cnpm/allow-role-team/member') + .set('authorization', anotherUser.authorization) + .expect(200); + assert(members.body.some((m: any) => m.user === anotherUser.displayName && m.role === 'owner')); + }); + + it('should let team owner remove member in allowScopes org', async () => { + const anotherUser = await TestUtil.createUser({ name: 'allow-rm-user' }); + + await app.httpRequest() + .put('/-/org/cnpm/team') + .set('authorization', normalUser.authorization) + .send({ name: 'allow-rm-team' }) + .expect(200); + + await app.httpRequest() + .put('/-/team/cnpm/allow-rm-team/user') + .set('authorization', normalUser.authorization) + .send({ user: anotherUser.name }) + .expect(200); + + await app.httpRequest() + .delete('/-/team/cnpm/allow-rm-team/user') + .set('authorization', normalUser.authorization) + .send({ user: anotherUser.name }) + .expect(200); + + const res = await app.httpRequest() + .get('/-/team/cnpm/allow-rm-team/user') + .set('authorization', normalUser.authorization) + .expect(200); + assert(!res.body.includes(anotherUser.displayName)); + }); + + it('should 403 non-owner remove member in allowScopes org', async () => { + const anotherUser = await TestUtil.createUser({ name: 'allow-rm-attacker' }); + + await app.httpRequest() + .put('/-/org/cnpm/team') + .set('authorization', normalUser.authorization) + .send({ name: 'allow-rm-prot' }) + .expect(200); + + await app.httpRequest() + .put('/-/team/cnpm/allow-rm-prot/user') + .set('authorization', normalUser.authorization) + .send({ user: anotherUser.name }) + .expect(200); + + await app.httpRequest() + .delete('/-/team/cnpm/allow-rm-prot/user') + .set('authorization', anotherUser.authorization) + .send({ user: normalUser.name }) + .expect(403); + }); }); }); diff --git a/test/port/controller/package/ReadAccessAuth.test.ts b/test/port/controller/package/ReadAccessAuth.test.ts index d6773205..bc41642a 100644 --- a/test/port/controller/package/ReadAccessAuth.test.ts +++ b/test/port/controller/package/ReadAccessAuth.test.ts @@ -109,6 +109,7 @@ describe('test/port/controller/package/ReadAccessAuth.test.ts', () => { await teamRepository.addMember(TeamMember.create({ teamId: team.teamId, userId: teamMemberEntity.userId, + role: 'member', })); });