瀏覽代碼

feat: implement role-based RBAC and org membership management

- Role model in orgs: org_admin, project_manager, viewer
- Enforce role checks in ProjectsService:
  - create/update: org_admin or project_manager
  - delete: org_admin only
  - read: any org member
- Expand Orgs API with membership management endpoints:
  - list members
  - add member by email
  - update member role
  - remove member (with last-admin protection)
- Orgs create now auto-assigns creator as org_admin
kingkong 2 月之前
父節點
當前提交
743cc924ff

+ 13 - 0
apps/api-server/src/common/decorators/org-context.decorator.ts

@@ -0,0 +1,13 @@
+import { createParamDecorator, ExecutionContext } from '@nestjs/common'
+
+export const OrgContext = createParamDecorator(
+  (data: string | undefined, ctx: ExecutionContext) => {
+    const req = ctx.switchToHttp().getRequest()
+    const value = {
+      orgId: req.orgId as string | undefined,
+      orgRole: req.orgRole as string | undefined,
+      membership: req.membership,
+    }
+    return data ? (value as any)[data] : value
+  },
+)

+ 6 - 0
apps/api-server/src/common/decorators/roles.decorator.ts

@@ -0,0 +1,6 @@
+import { SetMetadata } from '@nestjs/common'
+
+export const ROLES_KEY = 'roles'
+export type AppRole = 'super_admin' | 'org_admin' | 'project_manager' | 'viewer'
+
+export const Roles = (...roles: AppRole[]) => SetMetadata(ROLES_KEY, roles)

+ 37 - 0
apps/api-server/src/common/guards/org-membership.guard.ts

@@ -0,0 +1,37 @@
+import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'
+import { OrgsService } from '../../modules/orgs/orgs.service'
+
+/**
+ * Resolves org membership from route/query/body and attaches orgRole to request.
+ */
+@Injectable()
+export class OrgMembershipGuard implements CanActivate {
+  constructor(private readonly orgsService: OrgsService) {}
+
+  async canActivate(context: ExecutionContext): Promise<boolean> {
+    const req = context.switchToHttp().getRequest()
+    const userId = req.user?.userId as string | undefined
+
+    if (!userId) {
+      throw new ForbiddenException('Unauthorized')
+    }
+
+    const orgId =
+      req.params?.orgId ??
+      req.params?.id ??
+      req.query?.orgId ??
+      req.body?.orgId
+
+    if (!orgId || typeof orgId !== 'string') {
+      // No org context required for this endpoint
+      return true
+    }
+
+    const membership = await this.orgsService.ensureUserInOrg(userId, orgId)
+    req.orgId = orgId
+    req.orgRole = membership.role
+    req.membership = membership
+
+    return true
+  }
+}

+ 28 - 0
apps/api-server/src/common/guards/roles.guard.ts

@@ -0,0 +1,28 @@
+import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'
+import { Reflector } from '@nestjs/core'
+import { ROLES_KEY, AppRole } from '../decorators/roles.decorator'
+
+@Injectable()
+export class RolesGuard implements CanActivate {
+  constructor(private readonly reflector: Reflector) {}
+
+  canActivate(context: ExecutionContext): boolean {
+    const requiredRoles = this.reflector.getAllAndOverride<AppRole[]>(ROLES_KEY, [
+      context.getHandler(),
+      context.getClass(),
+    ])
+
+    if (!requiredRoles || requiredRoles.length === 0) {
+      return true
+    }
+
+    const request = context.switchToHttp().getRequest()
+    const role = request.orgRole as AppRole | undefined
+
+    if (!role || !requiredRoles.includes(role)) {
+      throw new ForbiddenException('Insufficient role permissions')
+    }
+
+    return true
+  }
+}

+ 14 - 1
apps/api-server/src/modules/orgs/dto/org.dto.ts

@@ -1,4 +1,4 @@
-import { IsEnum, IsOptional, IsString } from 'class-validator'
+import { IsEmail, IsEnum, IsOptional, IsString } from 'class-validator'
 
 export class CreateOrgDto {
   @IsString()
@@ -26,3 +26,16 @@ export class UpdateOrgDto {
   @IsEnum(['active', 'suspended', 'trial'])
   status?: 'active' | 'suspended' | 'trial'
 }
+
+export class AddMemberDto {
+  @IsEmail()
+  email!: string
+
+  @IsEnum(['org_admin', 'project_manager', 'viewer'])
+  role!: 'org_admin' | 'project_manager' | 'viewer'
+}
+
+export class UpdateMemberRoleDto {
+  @IsEnum(['org_admin', 'project_manager', 'viewer'])
+  role!: 'org_admin' | 'project_manager' | 'viewer'
+}

+ 44 - 11
apps/api-server/src/modules/orgs/orgs.controller.ts

@@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@n
 import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
 import { CurrentUser } from '../../common/decorators/current-user.decorator'
 import { OrgsService } from './orgs.service'
-import { CreateOrgDto, UpdateOrgDto } from './dto/org.dto'
+import { AddMemberDto, CreateOrgDto, UpdateMemberRoleDto, UpdateOrgDto } from './dto/org.dto'
 
 @Controller('orgs')
 @UseGuards(JwtAuthGuard)
@@ -10,32 +10,65 @@ export class OrgsController {
   constructor(private readonly orgsService: OrgsService) {}
 
   @Post()
-  create(@Body() dto: CreateOrgDto) {
-    return this.orgsService.create(dto)
+  create(@Body() dto: CreateOrgDto, @CurrentUser() user: { userId: string }) {
+    return this.orgsService.create(dto, user.userId)
   }
 
   @Get()
-  findAll() {
-    return this.orgsService.findAll()
+  findAll(@CurrentUser() user: { userId: string }) {
+    return this.orgsService.findAll(user.userId)
   }
 
   @Get(':id')
-  findOne(@Param('id') id: string) {
-    return this.orgsService.findOne(id)
+  findOne(@Param('id') id: string, @CurrentUser() user: { userId: string }) {
+    return this.orgsService.findOne(id, user.userId)
   }
 
   @Patch(':id')
-  update(@Param('id') id: string, @Body() dto: UpdateOrgDto) {
-    return this.orgsService.update(id, dto)
+  update(@Param('id') id: string, @Body() dto: UpdateOrgDto, @CurrentUser() user: { userId: string }) {
+    return this.orgsService.update(id, dto, user.userId)
   }
 
   @Delete(':id')
-  remove(@Param('id') id: string) {
-    return this.orgsService.remove(id)
+  remove(@Param('id') id: string, @CurrentUser() user: { userId: string }) {
+    return this.orgsService.remove(id, user.userId)
   }
 
   @Get(':id/me-access')
   myAccess(@Param('id') orgId: string, @CurrentUser() user: { userId: string }) {
     return this.orgsService.ensureUserInOrg(user.userId, orgId)
   }
+
+  @Get(':id/members')
+  listMembers(@Param('id') orgId: string, @CurrentUser() user: { userId: string }) {
+    return this.orgsService.listMembers(orgId, user.userId)
+  }
+
+  @Post(':id/members')
+  addMember(
+    @Param('id') orgId: string,
+    @CurrentUser() user: { userId: string },
+    @Body() dto: AddMemberDto,
+  ) {
+    return this.orgsService.addMember(orgId, user.userId, dto)
+  }
+
+  @Patch(':id/members/:userId')
+  updateMemberRole(
+    @Param('id') orgId: string,
+    @Param('userId') memberUserId: string,
+    @CurrentUser() user: { userId: string },
+    @Body() dto: UpdateMemberRoleDto,
+  ) {
+    return this.orgsService.updateMemberRole(orgId, user.userId, memberUserId, dto)
+  }
+
+  @Delete(':id/members/:userId')
+  removeMember(
+    @Param('id') orgId: string,
+    @Param('userId') memberUserId: string,
+    @CurrentUser() user: { userId: string },
+  ) {
+    return this.orgsService.removeMember(orgId, user.userId, memberUserId)
+  }
 }

+ 28 - 1
apps/api-server/src/modules/orgs/orgs.repository.ts

@@ -1,6 +1,6 @@
 import { Injectable } from '@nestjs/common'
 import { db } from '../../db/database.module'
-import { organizations, memberships } from '../../db/schema'
+import { organizations, memberships, users } from '../../db/schema'
 import { and, desc, eq } from 'drizzle-orm'
 import { nanoid } from 'nanoid'
 
@@ -59,4 +59,31 @@ export class OrgsRepository {
   async getUserMemberships(userId: string) {
     return db.select().from(memberships).where(eq(memberships.userId, userId))
   }
+
+  async findUserByEmail(email: string) {
+    const result = await db.select().from(users).where(eq(users.email, email.toLowerCase())).limit(1)
+    return result[0] ?? null
+  }
+
+  async getOrgMembers(orgId: string) {
+    return db.select().from(memberships).where(eq(memberships.orgId, orgId))
+  }
+
+  async addMember(orgId: string, userId: string, role: 'org_admin' | 'project_manager' | 'viewer') {
+    await db.insert(memberships).values({ orgId, userId, role })
+    return this.isUserMemberOfOrg(userId, orgId)
+  }
+
+  async updateMemberRole(orgId: string, userId: string, role: 'org_admin' | 'project_manager' | 'viewer') {
+    await db
+      .update(memberships)
+      .set({ role })
+      .where(and(eq(memberships.orgId, orgId), eq(memberships.userId, userId)))
+
+    return this.isUserMemberOfOrg(userId, orgId)
+  }
+
+  async removeMember(orgId: string, userId: string) {
+    await db.delete(memberships).where(and(eq(memberships.orgId, orgId), eq(memberships.userId, userId)))
+  }
 }

+ 71 - 9
apps/api-server/src/modules/orgs/orgs.service.ts

@@ -1,32 +1,42 @@
-import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
+import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
 import { OrgsRepository } from './orgs.repository'
-import { CreateOrgDto, UpdateOrgDto } from './dto/org.dto'
+import { AddMemberDto, CreateOrgDto, UpdateMemberRoleDto, UpdateOrgDto } from './dto/org.dto'
+
+type OrgRole = 'org_admin' | 'project_manager' | 'viewer'
 
 @Injectable()
 export class OrgsService {
   constructor(private readonly repo: OrgsRepository) {}
 
-  create(dto: CreateOrgDto) {
-    return this.repo.create(dto)
+  async create(dto: CreateOrgDto, creatorUserId: string) {
+    const org = await this.repo.create(dto)
+    await this.repo.addMember(org!.id, creatorUserId, 'org_admin')
+    return org
   }
 
-  findAll() {
-    return this.repo.findAll()
+  async findAll(userId: string) {
+    const orgIds = await this.getUserOrgIds(userId)
+    if (orgIds.length === 0) return []
+    const all = await this.repo.findAll()
+    return all.filter((o) => orgIds.includes(o.id))
   }
 
-  async findOne(id: string) {
+  async findOne(id: string, userId: string) {
+    await this.ensureUserInOrg(userId, id)
     const org = await this.repo.findById(id)
     if (!org) throw new NotFoundException(`Organization ${id} not found`)
     return org
   }
 
-  async update(id: string, dto: UpdateOrgDto) {
+  async update(id: string, dto: UpdateOrgDto, userId: string) {
+    await this.ensureRoleInOrg(userId, id, ['org_admin'])
     const org = await this.repo.findById(id)
     if (!org) throw new NotFoundException(`Organization ${id} not found`)
     return this.repo.update(id, dto)
   }
 
-  async remove(id: string) {
+  async remove(id: string, userId: string) {
+    await this.ensureRoleInOrg(userId, id, ['org_admin'])
     const org = await this.repo.findById(id)
     if (!org) throw new NotFoundException(`Organization ${id} not found`)
     await this.repo.remove(id)
@@ -41,8 +51,60 @@ export class OrgsService {
     return membership
   }
 
+  async ensureRoleInOrg(userId: string, orgId: string, allowedRoles: OrgRole[]) {
+    const membership = await this.ensureUserInOrg(userId, orgId)
+    const role = membership.role as OrgRole
+    if (!allowedRoles.includes(role)) {
+      throw new ForbiddenException(`Role ${role} is not allowed`) }
+    return membership
+  }
+
   async getUserOrgIds(userId: string): Promise<string[]> {
     const memberships = await this.repo.getUserMemberships(userId)
     return memberships.map((m) => m.orgId)
   }
+
+  async listMembers(orgId: string, userId: string) {
+    await this.ensureUserInOrg(userId, orgId)
+    const members = await this.repo.getOrgMembers(orgId)
+    return members
+  }
+
+  async addMember(orgId: string, actorUserId: string, dto: AddMemberDto) {
+    await this.ensureRoleInOrg(actorUserId, orgId, ['org_admin'])
+
+    const user = await this.repo.findUserByEmail(dto.email)
+    if (!user) {
+      throw new BadRequestException(`User ${dto.email} not found`) }
+
+    const exists = await this.repo.isUserMemberOfOrg(user.id, orgId)
+    if (exists) {
+      throw new BadRequestException('User is already a member of this org') }
+
+    return this.repo.addMember(orgId, user.id, dto.role)
+  }
+
+  async updateMemberRole(orgId: string, actorUserId: string, memberUserId: string, dto: UpdateMemberRoleDto) {
+    await this.ensureRoleInOrg(actorUserId, orgId, ['org_admin'])
+    const exists = await this.repo.isUserMemberOfOrg(memberUserId, orgId)
+    if (!exists) {
+      throw new NotFoundException('Member not found in organization') }
+
+    return this.repo.updateMemberRole(orgId, memberUserId, dto.role)
+  }
+
+  async removeMember(orgId: string, actorUserId: string, memberUserId: string) {
+    await this.ensureRoleInOrg(actorUserId, orgId, ['org_admin'])
+
+    const actorMembership = await this.ensureUserInOrg(actorUserId, orgId)
+    if (actorUserId === memberUserId && actorMembership.role === 'org_admin') {
+      const members = await this.repo.getOrgMembers(orgId)
+      const adminCount = members.filter((m) => m.role === 'org_admin').length
+      if (adminCount <= 1) {
+        throw new BadRequestException('Cannot remove the last org_admin from organization') }
+    }
+
+    await this.repo.removeMember(orgId, memberUserId)
+    return { success: true }
+  }
 }

+ 14 - 4
apps/api-server/src/modules/projects/projects.service.ts

@@ -1,8 +1,10 @@
-import { Injectable, NotFoundException } from '@nestjs/common'
+import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
 import { ProjectsRepository } from './projects.repository'
 import { CreateProjectDto, UpdateProjectDto } from './dto/project.dto'
 import { OrgsService } from '../orgs/orgs.service'
 
+type OrgRole = 'org_admin' | 'project_manager' | 'viewer'
+
 @Injectable()
 export class ProjectsService {
   constructor(
@@ -10,8 +12,14 @@ export class ProjectsService {
     private readonly orgsService: OrgsService,
   ) {}
 
+  private assertRole(role: OrgRole, allowed: OrgRole[]) {
+    if (!allowed.includes(role)) {
+      throw new ForbiddenException(`Role ${role} is not allowed for this action`) }
+  }
+
   async create(dto: CreateProjectDto, userId: string) {
-    await this.orgsService.ensureUserInOrg(userId, dto.orgId)
+    const membership = await this.orgsService.ensureUserInOrg(userId, dto.orgId)
+    this.assertRole(membership.role as OrgRole, ['org_admin', 'project_manager'])
     return this.repo.create(dto)
   }
 
@@ -38,14 +46,16 @@ export class ProjectsService {
   async update(id: string, dto: UpdateProjectDto, userId: string) {
     const exists = await this.repo.findById(id)
     if (!exists) throw new NotFoundException(`Project ${id} not found`)
-    await this.orgsService.ensureUserInOrg(userId, exists.orgId)
+    const membership = await this.orgsService.ensureUserInOrg(userId, exists.orgId)
+    this.assertRole(membership.role as OrgRole, ['org_admin', 'project_manager'])
     return this.repo.update(id, dto)
   }
 
   async remove(id: string, userId: string) {
     const exists = await this.repo.findById(id)
     if (!exists) throw new NotFoundException(`Project ${id} not found`)
-    await this.orgsService.ensureUserInOrg(userId, exists.orgId)
+    const membership = await this.orgsService.ensureUserInOrg(userId, exists.orgId)
+    this.assertRole(membership.role as OrgRole, ['org_admin'])
     await this.repo.remove(id)
     return { success: true }
   }

+ 9 - 1
memory.md

@@ -129,7 +129,15 @@ docker compose up -d --build api-server web-dashboard worker
 - Auth thực sự: register/login/refresh/logout + JWT guard + /auth/me
 - Dashboard auth flow: /login, ProtectedRoute, logout, auto attach Bearer token
 - Orgs CRUD API (`/v1/orgs`) có JWT guard
-- RBAC cơ bản cho Projects: chỉ user thuộc org mới được create/list/get/update/delete project
+- RBAC theo role:
+  - `org_admin`: full org/project/member management
+  - `project_manager`: create/update project
+  - `viewer`: read-only
+- Membership management endpoints:
+  - `GET /v1/orgs/:id/members`
+  - `POST /v1/orgs/:id/members` (org_admin)
+  - `PATCH /v1/orgs/:id/members/:userId` (org_admin)
+  - `DELETE /v1/orgs/:id/members/:userId` (org_admin)
 - Projects CRUD API:
   - `POST /v1/projects`
   - `GET /v1/projects?orgId=`