Back to blog

Build a URL Shortener: Admin Panel — RBAC & User Management

typescriptnodejsadminrbacbackend
Build a URL Shortener: Admin Panel — RBAC & User Management

URL shortener của chúng ta đã có user accounts, authentication và frontend hoạt động tốt. Users có thể register, shorten URLs và track click analytics. Mọi thứ chạy ngon lành — nhưng ai cũng như ai. Không có cách nào phân biệt user thường với administrator.

Chuyện gì xảy ra khi ai đó tạo hàng ngàn spam links? Hay khi một user report redirect chứa nội dung xấu? Hiện tại, không ai có quyền xử lý gì cả. Không có moderation, không user management, không system-wide visibility. App giống như một đất nước dân chủ mà không có chính phủ.

Đã đến lúc thay đổi. Trong bài này, chúng ta sẽ thêm hệ thống role-based access control (RBAC) hoàn chỉnh, build admin middleware để bảo vệ các route đặc quyền, và implement full user management API — bao gồm account suspension, role promotion và search.

Thời gian: 2–3 giờ
Prerequisites: Phase 9: Deployment & Production

Những gì chúng ta sẽ build trong bài này:
✅ Role-based access control (RBAC) với admin và user roles
✅ Admin middleware để bảo vệ admin-only routes
✅ User management API (list all users, view details, search)
✅ Account suspension và reactivation
✅ Admin promotion và demotion
✅ Admin activity logging foundation


Tại Sao RBAC?

Bạn có thể nghĩ chỉ cần thêm field isAdmin: boolean vào User model là xong. Với app nhỏ thì ổn, nhưng sẽ sớm bị giới hạn.

Nếu bạn cần một "moderator" có thể review links nhưng không quản lý users thì sao? Nếu muốn "super admin" có thể promote admin khác thì sao? Boolean chỉ cho bạn đúng hai trạng thái — admin hoặc không admin. Chưa đủ đâu.

So sánh các approach phổ biến:

ApproachFlexibilityComplexityPhù hợp cho
Boolean flag (isAdmin)Thấp — chỉ 2 trạng tháiRất thấpToy projects
Enum role (USER, ADMIN, SUPER_ADMIN)Trung bình — nhiều tiersThấpHầu hết applications
Permission-based (read_users, write_urls, etc.)Rất cao — granular controlCaoEnterprise systems

Chúng ta sẽ dùng approach enum role — sweet spot cho URL shortener. Ba roles cho chúng ta sự phân tách rõ ràng:

  • USER — regular users chỉ quản lý URLs của mình
  • ADMIN — moderators có thể quản lý users và review URLs
  • SUPER_ADMIN — full system access, có thể promote/demote admins

Scale tốt mà không cần complexity của full permission matrix.


Kiến Trúc RBAC

Hệ thống RBAC build trên nền JWT authentication có sẵn từ Phase 6. Mỗi request đi qua hai layers middleware trước khi đến route handler:

Ý tưởng chính: auth middleware xác minh identity (bạn là ai?), còn role middleware xác minh authorization (bạn được làm gì?). Tách riêng như vậy nghĩa là regular user routes chỉ cần auth, còn admin routes stack cả hai.

Đây là hierarchy các access levels:


Cập Nhật Prisma Schema

Đầu tiên, update database schema. Chúng ta cần thêm Role enum và các fields liên quan đến suspension vào User model.

Thêm Role Enum

// prisma/schema.prisma
 
enum Role {
  USER
  ADMIN
  SUPER_ADMIN
}
 
model User {
  id            String    @id @default(uuid())
  email         String    @unique
  passwordHash  String
  name          String?
  role          Role      @default(USER)
  apiKey        String?   @unique
  suspendedAt   DateTime?
  suspendedBy   String?
  suspendReason String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
  urls          Url[]
  refreshTokens RefreshToken[]
 
  @@map("users")
}

Các thay đổi chính:

  • role — default là USER, nên accounts hiện tại không bị ảnh hưởng
  • suspendedAt — null nghĩa là active, có timestamp nghĩa là bị suspended
  • suspendedBy — theo dõi admin nào thực hiện suspension
  • suspendReason — giải thích lý do account bị suspended

Chạy Migration

npx prisma migrate dev --name add-rbac-fields

Lệnh này generate và apply migration. Xem SQL mà Prisma tạo ra:

-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN', 'SUPER_ADMIN');
 
-- AlterTable
ALTER TABLE "users"
  ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER',
  ADD COLUMN "suspendedAt" TIMESTAMP(3),
  ADD COLUMN "suspendedBy" TEXT,
  ADD COLUMN "suspendReason" TEXT;

Vì role default là USER, tất cả users hiện tại tự động nhận đúng role. Không cần data migration.

Regenerate Prisma Client

npx prisma generate

Giờ Role.USER, Role.ADMIN và Role.SUPER_ADMIN đã có sẵn dưới dạng typed constants.


Admin Middleware

Core của RBAC là middleware kiểm tra user đã authenticated có đúng role yêu cầu không. Chúng ta sẽ làm nó composable — factory function trả về middleware cho bất kỳ tổ hợp roles nào.

Middleware requireRole

// src/middleware/rbac.ts
import { Request, Response, NextFunction } from 'express';
import { Role } from '@prisma/client';
import { AppError } from '../utils/errors';
 
/**
 * Factory middleware giới hạn access cho specific roles.
 * Phải dùng SAU requireAuth middleware.
 */
export function requireRole(...allowedRoles: Role[]) {
  return (req: Request, _res: Response, next: NextFunction) => {
    const user = req.user;
 
    if (!user) {
      throw new AppError('Authentication required', 401);
    }
 
    // Check suspension trước role — suspended admins không được access gì cả
    if (user.suspendedAt) {
      throw new AppError(
        'Your account has been suspended. Contact support for assistance.',
        403
      );
    }
 
    if (!allowedRoles.includes(user.role as Role)) {
      throw new AppError(
        'You do not have permission to perform this action',
        403
      );
    }
 
    next();
  };
}
 
// ── Convenience helpers ──────────────────────────
// Đọc rõ ràng trong route definitions
 
/** Yêu cầu ADMIN hoặc SUPER_ADMIN role */
export const requireAdmin = requireRole(Role.ADMIN, Role.SUPER_ADMIN);
 
/** Chỉ SUPER_ADMIN role */
export const requireSuperAdmin = requireRole(Role.SUPER_ADMIN);

Cái hay của pattern này là nó đọc rất rõ ràng trong route definitions:

// Ai authenticated đều access được
router.get('/my-urls', requireAuth, getMyUrls);
 
// Chỉ admins mới access được
router.get('/admin/users', requireAuth, requireAdmin, listUsers);
 
// Chỉ super admins mới access được
router.patch('/admin/users/:id/role', requireAuth, requireSuperAdmin, updateRole);

Update Express Types

Cần extend Express Request type để include role của user:

// src/types/express.d.ts
import { Role } from '@prisma/client';
 
declare global {
  namespace Express {
    interface Request {
      user?: {
        id: string;
        email: string;
        role: Role;
        suspendedAt: Date | null;
      };
    }
  }
}

Update JWT Payload

JWT tokens hiện tại chỉ chứa user ID và email. Cần include role để middleware có thể check authorization mà không cần query database mỗi request.

Token Generation Mới

// src/services/auth.service.ts
import { Role } from '@prisma/client';
import jwt from 'jsonwebtoken';
import { config } from '../config';
 
interface TokenPayload {
  userId: string;
  email: string;
  role: Role;
}
 
export function generateAccessToken(user: {
  id: string;
  email: string;
  role: Role;
}): string {
  const payload: TokenPayload = {
    userId: user.id,
    email: user.email,
    role: user.role,
  };
 
  return jwt.sign(payload, config.jwt.accessSecret, {
    expiresIn: config.jwt.accessExpiresIn,
  });
}
 
export function generateRefreshToken(user: {
  id: string;
  email: string;
  role: Role;
}): string {
  return jwt.sign(
    { userId: user.id, email: user.email, role: user.role },
    config.jwt.refreshSecret,
    { expiresIn: config.jwt.refreshExpiresIn }
  );
}

Auth Middleware Mới

Auth middleware giờ extract role từ JWT payload và attach vào request. Nó cũng check suspension status từ database cho các critical operations:

// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config';
import { AppError } from '../utils/errors';
import { prisma } from '../lib/prisma';
 
interface JwtPayload {
  userId: string;
  email: string;
  role: string;
}
 
export async function requireAuth(
  req: Request,
  _res: Response,
  next: NextFunction
) {
  const authHeader = req.headers.authorization;
 
  if (!authHeader?.startsWith('Bearer ')) {
    throw new AppError('Access token required', 401);
  }
 
  const token = authHeader.split(' ')[1];
 
  try {
    const payload = jwt.verify(token, config.jwt.accessSecret) as JwtPayload;
 
    // Fetch fresh user data để check suspension status
    // Ngăn suspended users dùng tokens cũ
    const user = await prisma.user.findUnique({
      where: { id: payload.userId },
      select: {
        id: true,
        email: true,
        role: true,
        suspendedAt: true,
      },
    });
 
    if (!user) {
      throw new AppError('User not found', 401);
    }
 
    if (user.suspendedAt) {
      throw new AppError(
        'Your account has been suspended. Contact support for assistance.',
        403
      );
    }
 
    req.user = user;
    next();
  } catch (error) {
    if (error instanceof AppError) throw error;
    throw new AppError('Invalid or expired access token', 401);
  }
}

Tại sao phải fetch từ database? Role trong JWT có thể đã cũ — nếu admin demote một user, tokens hiện tại của user vẫn chứa role cũ. Tương tự, nếu user bị suspended, tokens đang active phải bị invalid ngay lập tức. Database check đảm bảo real-time accuracy.


User Management Endpoints

Giờ đến phần chính của bài — admin API để quản lý users. Chúng ta sẽ build năm endpoints:

EndpointMethodRole cần thiếtMô tả
/api/admin/usersGETADMINList all users với pagination và search
/api/admin/users/:idGETADMINXem chi tiết user profile với stats
/api/admin/users/:id/rolePATCHSUPER_ADMINPromote hoặc demote user
/api/admin/users/:id/suspendPOSTADMINSuspend user account
/api/admin/users/:id/reactivatePOSTADMINReactivate account đã bị suspended

Route Setup

// src/routes/admin.ts
import { Router } from 'express';
import { requireAuth } from '../middleware/auth';
import { requireAdmin, requireSuperAdmin } from '../middleware/rbac';
import * as adminController from '../controllers/admin.controller';
import { validate } from '../middleware/validate';
import {
  listUsersSchema,
  updateRoleSchema,
  suspendUserSchema,
} from '../validators/admin.validators';
 
const router = Router();
 
// Tất cả admin routes yêu cầu authentication + admin role
router.use(requireAuth, requireAdmin);
 
// User management
router.get('/users', validate(listUsersSchema), adminController.listUsers);
router.get('/users/:id', adminController.getUserDetails);
router.patch(
  '/users/:id/role',
  requireSuperAdmin,
  validate(updateRoleSchema),
  adminController.updateUserRole
);
router.post(
  '/users/:id/suspend',
  validate(suspendUserSchema),
  adminController.suspendUser
);
router.post('/users/:id/reactivate', adminController.reactivateUser);
 
export default router;

Để ý cách router.use(requireAuth, requireAdmin) apply cho tất cả routes trong file, và requireSuperAdmin chỉ thêm vào role update endpoint. Layering sạch sẽ.

Đăng Ký Admin Routes

// src/app.ts (thêm vào route setup hiện tại)
import adminRoutes from './routes/admin';
 
// ... existing routes ...
app.use('/api/admin', adminRoutes);

Request Validation

Trước khi implement controllers, định nghĩa validation schemas cho admin endpoints:

// src/validators/admin.validators.ts
import { z } from 'zod';
import { Role } from '@prisma/client';
 
export const listUsersSchema = z.object({
  query: z.object({
    page: z.coerce.number().int().positive().default(1),
    limit: z.coerce.number().int().min(1).max(100).default(20),
    search: z.string().optional(),
    role: z.nativeEnum(Role).optional(),
    status: z.enum(['active', 'suspended']).optional(),
    sortBy: z.enum(['createdAt', 'email', 'name', 'role']).default('createdAt'),
    sortOrder: z.enum(['asc', 'desc']).default('desc'),
  }),
});
 
export const updateRoleSchema = z.object({
  body: z.object({
    role: z.nativeEnum(Role, {
      errorMap: () => ({
        message: 'Role must be USER, ADMIN, or SUPER_ADMIN',
      }),
    }),
  }),
});
 
export const suspendUserSchema = z.object({
  body: z.object({
    reason: z
      .string()
      .min(10, 'Suspension reason must be at least 10 characters')
      .max(500, 'Suspension reason must be at most 500 characters'),
  }),
});

Controller Implementation

Đây là full admin controller với cả năm endpoints. Mỗi method theo pattern nhất quán: validate input, thực hiện operation, trả về structured response.

List Users

// src/controllers/admin.controller.ts
import { Request, Response } from 'express';
import { prisma } from '../lib/prisma';
import { Role } from '@prisma/client';
import { AppError } from '../utils/errors';
 
/**
 * GET /api/admin/users
 * List all users với pagination, search và filters.
 */
export async function listUsers(req: Request, res: Response) {
  const {
    page = 1,
    limit = 20,
    search,
    role,
    status,
    sortBy = 'createdAt',
    sortOrder = 'desc',
  } = req.query as {
    page?: number;
    limit?: number;
    search?: string;
    role?: Role;
    status?: 'active' | 'suspended';
    sortBy?: string;
    sortOrder?: 'asc' | 'desc';
  };
 
  // Build filter conditions
  const where: Record<string, unknown> = {};
 
  if (search) {
    where.OR = [
      { email: { contains: search, mode: 'insensitive' } },
      { name: { contains: search, mode: 'insensitive' } },
    ];
  }
 
  if (role) {
    where.role = role;
  }
 
  if (status === 'active') {
    where.suspendedAt = null;
  } else if (status === 'suspended') {
    where.suspendedAt = { not: null };
  }
 
  // Chạy count và find song song
  const [total, users] = await Promise.all([
    prisma.user.count({ where }),
    prisma.user.findMany({
      where,
      select: {
        id: true,
        email: true,
        name: true,
        role: true,
        suspendedAt: true,
        suspendReason: true,
        createdAt: true,
        updatedAt: true,
        _count: {
          select: { urls: true },
        },
      },
      orderBy: { [sortBy]: sortOrder },
      skip: (page - 1) * limit,
      take: limit,
    }),
  ]);
 
  const totalPages = Math.ceil(total / limit);
 
  res.json({
    data: users.map((user) => ({
      ...user,
      urlCount: user._count.urls,
      _count: undefined,
    })),
    pagination: {
      page,
      limit,
      total,
      totalPages,
      hasNext: page < totalPages,
      hasPrev: page > 1,
    },
  });
}

Get User Details

/**
 * GET /api/admin/users/:id
 * Xem chi tiết user profile bao gồm URL stats.
 */
export async function getUserDetails(req: Request, res: Response) {
  const { id } = req.params;
 
  const user = await prisma.user.findUnique({
    where: { id },
    select: {
      id: true,
      email: true,
      name: true,
      role: true,
      suspendedAt: true,
      suspendedBy: true,
      suspendReason: true,
      createdAt: true,
      updatedAt: true,
      _count: {
        select: {
          urls: true,
          refreshTokens: true,
        },
      },
      urls: {
        select: {
          id: true,
          shortCode: true,
          originalUrl: true,
          clicks: true,
          createdAt: true,
          expiresAt: true,
        },
        orderBy: { createdAt: 'desc' },
        take: 10, // 10 URLs gần nhất
      },
    },
  });
 
  if (!user) {
    throw new AppError('User not found', 404);
  }
 
  // Aggregate URL stats
  const urlStats = await prisma.url.aggregate({
    where: { userId: id },
    _sum: { clicks: true },
    _count: { id: true },
    _max: { createdAt: true },
  });
 
  res.json({
    data: {
      ...user,
      urlCount: user._count.urls,
      activeSessions: user._count.refreshTokens,
      stats: {
        totalUrls: urlStats._count.id,
        totalClicks: urlStats._sum.clicks ?? 0,
        lastUrlCreated: urlStats._max.createdAt,
      },
      recentUrls: user.urls,
      _count: undefined,
    },
  });
}

Update User Role

/**
 * PATCH /api/admin/users/:id/role
 * Promote hoặc demote user. Chỉ SUPER_ADMIN.
 */
export async function updateUserRole(req: Request, res: Response) {
  const { id } = req.params;
  const { role } = req.body as { role: Role };
  const currentUser = req.user!;
 
  // Ngăn self-demotion (tránh tự lock mình ra)
  if (id === currentUser.id) {
    throw new AppError(
      'You cannot change your own role. Ask another super admin.',
      400
    );
  }
 
  // Verify target user tồn tại
  const targetUser = await prisma.user.findUnique({
    where: { id },
    select: { id: true, email: true, role: true },
  });
 
  if (!targetUser) {
    throw new AppError('User not found', 404);
  }
 
  // Ngăn demote SUPER_ADMIN khác
  if (targetUser.role === Role.SUPER_ADMIN && role !== Role.SUPER_ADMIN) {
    throw new AppError(
      'Cannot demote a SUPER_ADMIN. They must be demoted by direct database access.',
      403
    );
  }
 
  const updatedUser = await prisma.user.update({
    where: { id },
    data: { role },
    select: {
      id: true,
      email: true,
      name: true,
      role: true,
      updatedAt: true,
    },
  });
 
  // Log role change (foundation cho audit log)
  console.log(
    JSON.stringify({
      event: 'admin.role_change',
      adminId: currentUser.id,
      adminEmail: currentUser.email,
      targetUserId: id,
      targetEmail: targetUser.email,
      previousRole: targetUser.role,
      newRole: role,
      timestamp: new Date().toISOString(),
    })
  );
 
  res.json({
    message: `User role updated to ${role}`,
    data: updatedUser,
  });
}

Suspend User

/**
 * POST /api/admin/users/:id/suspend
 * Suspend user account kèm lý do.
 */
export async function suspendUser(req: Request, res: Response) {
  const { id } = req.params;
  const { reason } = req.body as { reason: string };
  const currentUser = req.user!;
 
  // Ngăn self-suspension
  if (id === currentUser.id) {
    throw new AppError('You cannot suspend your own account', 400);
  }
 
  const targetUser = await prisma.user.findUnique({
    where: { id },
    select: { id: true, email: true, role: true, suspendedAt: true },
  });
 
  if (!targetUser) {
    throw new AppError('User not found', 404);
  }
 
  if (targetUser.suspendedAt) {
    throw new AppError('User is already suspended', 400);
  }
 
  // Admins không thể suspend admin khác — chỉ SUPER_ADMIN mới được
  if (
    targetUser.role === Role.ADMIN ||
    targetUser.role === Role.SUPER_ADMIN
  ) {
    if (currentUser.role !== Role.SUPER_ADMIN) {
      throw new AppError(
        'Only SUPER_ADMIN can suspend admin accounts',
        403
      );
    }
  }
 
  // Không thể suspend SUPER_ADMIN
  if (targetUser.role === Role.SUPER_ADMIN) {
    throw new AppError('Cannot suspend a SUPER_ADMIN account', 403);
  }
 
  const updatedUser = await prisma.user.update({
    where: { id },
    data: {
      suspendedAt: new Date(),
      suspendedBy: currentUser.id,
      suspendReason: reason,
    },
    select: {
      id: true,
      email: true,
      name: true,
      role: true,
      suspendedAt: true,
      suspendReason: true,
    },
  });
 
  // Invalidate tất cả refresh tokens của user bị suspended
  await prisma.refreshToken.deleteMany({
    where: { userId: id },
  });
 
  // Log suspension
  console.log(
    JSON.stringify({
      event: 'admin.user_suspended',
      adminId: currentUser.id,
      adminEmail: currentUser.email,
      targetUserId: id,
      targetEmail: targetUser.email,
      reason,
      timestamp: new Date().toISOString(),
    })
  );
 
  res.json({
    message: 'User account suspended',
    data: updatedUser,
  });
}

Reactivate User

/**
 * POST /api/admin/users/:id/reactivate
 * Reactivate account đã bị suspended.
 */
export async function reactivateUser(req: Request, res: Response) {
  const { id } = req.params;
  const currentUser = req.user!;
 
  const targetUser = await prisma.user.findUnique({
    where: { id },
    select: { id: true, email: true, suspendedAt: true },
  });
 
  if (!targetUser) {
    throw new AppError('User not found', 404);
  }
 
  if (!targetUser.suspendedAt) {
    throw new AppError('User is not suspended', 400);
  }
 
  const updatedUser = await prisma.user.update({
    where: { id },
    data: {
      suspendedAt: null,
      suspendedBy: null,
      suspendReason: null,
    },
    select: {
      id: true,
      email: true,
      name: true,
      role: true,
      suspendedAt: true,
    },
  });
 
  // Log reactivation
  console.log(
    JSON.stringify({
      event: 'admin.user_reactivated',
      adminId: currentUser.id,
      adminEmail: currentUser.email,
      targetUserId: id,
      targetEmail: targetUser.email,
      timestamp: new Date().toISOString(),
    })
  );
 
  res.json({
    message: 'User account reactivated',
    data: updatedUser,
  });
}

Xử Lý Suspended Users

Suspension phải được enforce ở mọi nơi — login, URL creation, API access. User bị suspended phải bị lock out hoàn toàn.

Block Login Cho Suspended Users

Update login service để reject accounts bị suspended:

// src/services/auth.service.ts (update login function)
export async function login(email: string, password: string) {
  const user = await prisma.user.findUnique({
    where: { email },
    select: {
      id: true,
      email: true,
      passwordHash: true,
      role: true,
      suspendedAt: true,
      suspendReason: true,
    },
  });
 
  if (!user) {
    throw new AppError('Invalid email or password', 401);
  }
 
  // Check suspension TRƯỚC khi verify password
  // Ngăn timing attacks có thể tiết lộ account tồn tại
  if (user.suspendedAt) {
    throw new AppError(
      `Account suspended: ${user.suspendReason || 'Contact support for details'}`,
      403
    );
  }
 
  const isValid = await bcrypt.compare(password, user.passwordHash);
  if (!isValid) {
    throw new AppError('Invalid email or password', 401);
  }
 
  const accessToken = generateAccessToken(user);
  const refreshToken = generateRefreshToken(user);
 
  // Lưu refresh token vào database
  await prisma.refreshToken.create({
    data: {
      token: refreshToken,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 ngày
    },
  });
 
  return { accessToken, refreshToken, user };
}

Block Token Refresh Cho Suspended Users

// src/services/auth.service.ts (update refresh function)
export async function refreshAccessToken(refreshToken: string) {
  const stored = await prisma.refreshToken.findUnique({
    where: { token: refreshToken },
    include: {
      user: {
        select: {
          id: true,
          email: true,
          role: true,
          suspendedAt: true,
        },
      },
    },
  });
 
  if (!stored || stored.expiresAt < new Date()) {
    throw new AppError('Invalid or expired refresh token', 401);
  }
 
  if (stored.user.suspendedAt) {
    // Xóa refresh token — họ không nên retry được
    await prisma.refreshToken.delete({ where: { id: stored.id } });
    throw new AppError('Account suspended', 403);
  }
 
  const newAccessToken = generateAccessToken(stored.user);
  return { accessToken: newAccessToken };
}

Block URL Creation Cho Suspended Users

Auth middleware đã check suspension status và reject requests, nên URL creation tự động bị block. Nhưng thêm explicit handling trong URL service như defense-in-depth:

// src/services/url.service.ts (thêm check ở đầu createUrl)
export async function createUrl(
  userId: string,
  originalUrl: string,
  customAlias?: string,
  expiresAt?: Date
) {
  // Defense in depth — auth middleware nên catch cái này,
  // nhưng chúng ta double-check ở đây
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: { suspendedAt: true },
  });
 
  if (user?.suspendedAt) {
    throw new AppError('Account suspended — cannot create URLs', 403);
  }
 
  // ... phần còn lại của URL creation logic
}

Flow cho suspended user trông như thế này:

Mọi entry point đều được cover. User thực sự bị lock out hoàn toàn.


Seed Admin Đầu Tiên

Bạn gặp bài toán con gà và quả trứng: cần admin để promote users thành admin, nhưng chưa có admin nào. Giải pháp là seed SUPER_ADMIN đầu tiên trực tiếp vào database.

Prisma Seed Script

// prisma/seed.ts
import { PrismaClient, Role } from '@prisma/client';
import bcrypt from 'bcrypt';
 
const prisma = new PrismaClient();
 
async function main() {
  const adminEmail = process.env.ADMIN_EMAIL;
  const adminPassword = process.env.ADMIN_PASSWORD;
 
  if (!adminEmail || !adminPassword) {
    console.error('ADMIN_EMAIL and ADMIN_PASSWORD environment variables required');
    process.exit(1);
  }
 
  if (adminPassword.length < 12) {
    console.error('Admin password must be at least 12 characters');
    process.exit(1);
  }
 
  const hashedPassword = await bcrypt.hash(adminPassword, 12);
 
  const admin = await prisma.user.upsert({
    where: { email: adminEmail },
    update: {
      role: Role.SUPER_ADMIN,
    },
    create: {
      email: adminEmail,
      passwordHash: hashedPassword,
      name: 'Super Admin',
      role: Role.SUPER_ADMIN,
    },
  });
 
  console.log(`Super admin seeded: ${admin.email} (${admin.id})`);
}
 
main()
  .catch((error) => {
    console.error('Seed failed:', error);
    process.exit(1);
  })
  .finally(() => prisma.$disconnect());

Configure Seed Command

// package.json (thêm vào phần prisma)
{
  "prisma": {
    "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
  }
}

Chạy Seed

# Set admin credentials
export ADMIN_EMAIL="[email protected]"
export ADMIN_PASSWORD="super-secure-password-here"
 
# Chạy seed
npx prisma db seed

Cách Khác: CLI Promotion Script

Nếu bạn muốn promote user đã có sẵn, đây là script one-off:

// scripts/promote-admin.ts
import { PrismaClient, Role } from '@prisma/client';
 
const prisma = new PrismaClient();
 
async function main() {
  const email = process.argv[2];
 
  if (!email) {
    console.error('Usage: npx ts-node scripts/promote-admin.ts <email>');
    process.exit(1);
  }
 
  const user = await prisma.user.update({
    where: { email },
    data: { role: Role.SUPER_ADMIN },
  });
 
  console.log(`Promoted ${user.email} to SUPER_ADMIN`);
}
 
main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());
npx ts-node scripts/promote-admin.ts [email protected]

Tổng Hợp API

Đây là reference đầy đủ tất cả endpoints mới trong phase này:

MethodEndpointRole cần thiếtMô tả
GET/api/admin/usersADMINList users với pagination, search, filters
GET/api/admin/users/:idADMINXem chi tiết user với URL stats
PATCH/api/admin/users/:id/roleSUPER_ADMINThay đổi user role
POST/api/admin/users/:id/suspendADMINSuspend user account
POST/api/admin/users/:id/reactivateADMINReactivate account đã suspended

Query Parameters Cho GET /api/admin/users

ParameterTypeDefaultMô tả
pagenumber1Số trang
limitnumber20Số items mỗi trang (tối đa 100)
searchstring—Search theo email hoặc name
roleenum—Filter theo role: USER, ADMIN, SUPER_ADMIN
statusstring—Filter: "active" hoặc "suspended"
sortBystringcreatedAtSort field: createdAt, email, name, role
sortOrderstringdescSort direction: asc hoặc desc

Ví Dụ API Calls

# List tất cả users (trang 1, 20 mỗi trang)
curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/api/admin/users
 
# Search users theo email
curl -H "Authorization: Bearer $TOKEN" \
  "http://localhost:3000/api/admin/users?search=john&role=USER"
 
# Xem chi tiết user
curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/api/admin/users/abc-123-def
 
# Promote user thành ADMIN
curl -X PATCH \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "ADMIN"}' \
  http://localhost:3000/api/admin/users/abc-123-def/role
 
# Suspend user
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Spamming malicious redirect URLs"}' \
  http://localhost:3000/api/admin/users/abc-123-def/suspend
 
# Reactivate user
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/api/admin/users/abc-123-def/reactivate

Security Considerations

Admin endpoints là high-value targets. Đây là các security principles chúng ta tuân theo:

1. Không Bao Giờ Expose Role Changes Trong Regular User API

User profile endpoint (GET /api/me) nên trả về role nhưng không bao giờ cho phép thay đổi:

// src/controllers/user.controller.ts
export async function updateProfile(req: Request, res: Response) {
  const { name } = req.body;
 
  // CHỈ cho phép update name — không bao giờ role,
  // email changes đi qua verification flow riêng
  const user = await prisma.user.update({
    where: { id: req.user!.id },
    data: { name },
    select: { id: true, email: true, name: true, role: true },
  });
 
  res.json({ data: user });
}

2. Ngăn Chặn Admin Lockout

SUPER_ADMIN không bao giờ được tự demote hoặc suspend mình, vì có thể khiến hệ thống không còn admin nào:

// Đã implement trong updateUserRole và suspendUser ở trên:
if (id === currentUser.id) {
  throw new AppError('You cannot change your own role', 400);
}

3. Rate Limit Admin Endpoints Riêng

Admin endpoints nên có rate limits nghiêm ngặt hơn regular APIs — chúng powerful và nên dùng một cách có chủ đích:

// src/middleware/rateLimit.ts
import rateLimit from 'express-rate-limit';
 
export const adminRateLimit = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 phút
  max: 100, // 100 requests mỗi 15 phút
  message: {
    error: 'Too many admin requests, please try again later',
  },
  standardHeaders: true,
  legacyHeaders: false,
});
// src/routes/admin.ts
import { adminRateLimit } from '../middleware/rateLimit';
 
router.use(requireAuth, requireAdmin, adminRateLimit);

4. Log Tất Cả Admin Actions

Mỗi admin action tạo ra structured log entry. Đây là foundation cho full audit log system (sẽ build trong phase tương lai):

// Pattern dùng xuyên suốt controllers:
console.log(
  JSON.stringify({
    event: 'admin.user_suspended',
    adminId: currentUser.id,
    adminEmail: currentUser.email,
    targetUserId: id,
    reason,
    timestamp: new Date().toISOString(),
  })
);

Trong production, các structured logs này có thể pipe đến logging service (ELK, Datadog, etc.) để search và alerting.

5. Không Bao Giờ Trust JWT Role Alone

Như đã đề cập, auth middleware fetch fresh user data từ database. Điều này ngăn race condition khi:

  1. Admin demote user thành USER
  2. User vẫn có valid JWT với role: ADMIN
  3. User access admin endpoints bằng token cũ

Bằng cách check database, chúng ta đảm bảo role luôn current.


Các Lỗi Thường Gặp

Lỗi 1: Check Role Trong Mỗi Controller

SAI — duplicate authorization logic:

// ❌ Đừng làm thế này — role checking rải rác khắp controllers
export async function listUsers(req: Request, res: Response) {
  if (req.user?.role !== 'ADMIN' && req.user?.role !== 'SUPER_ADMIN') {
    return res.status(403).json({ error: 'Forbidden' });
  }
  // ... phần còn lại
}
 
export async function suspendUser(req: Request, res: Response) {
  if (req.user?.role !== 'ADMIN' && req.user?.role !== 'SUPER_ADMIN') {
    return res.status(403).json({ error: 'Forbidden' });
  }
  // ... phần còn lại
}

ĐÚNG — centralized middleware:

// ✅ Role checking trong middleware, controllers chỉ lo business logic
router.use(requireAuth, requireAdmin);
 
router.get('/users', adminController.listUsers);
router.post('/users/:id/suspend', adminController.suspendUser);

Middleware xử lý authorization. Controllers chỉ xử lý business logic.

Lỗi 2: Quên Check Suspension Trong Auth Flow

SAI — chỉ check suspension trong RBAC middleware:

// ❌ Suspended user vẫn login được và lấy tokens mới!
export async function login(email: string, password: string) {
  const user = await prisma.user.findUnique({ where: { email } });
  // Không check suspension ở đây — user lấy được valid token
  return { token: generateAccessToken(user) };
}

ĐÚNG — check suspension ở mọi entry point:

// ✅ Check suspension ở login, token refresh VÀ middleware
export async function login(email: string, password: string) {
  const user = await prisma.user.findUnique({ where: { email } });
 
  if (user?.suspendedAt) {
    throw new AppError('Account suspended', 403);
  }
 
  // ... verify password, generate tokens
}

Lỗi 3: Cho Phép Admin Tự Suspend Mình

SAI — không có self-protection:

// ❌ Admin suspend chính mình, giờ không ai unsuspend được
export async function suspendUser(req: Request, res: Response) {
  const { id } = req.params;
  await prisma.user.update({
    where: { id },
    data: { suspendedAt: new Date() },
  });
}

ĐÚNG — block self-suspension:

// ✅ Ngăn self-suspension
export async function suspendUser(req: Request, res: Response) {
  const { id } = req.params;
 
  if (id === req.user!.id) {
    throw new AppError('You cannot suspend your own account', 400);
  }
 
  await prisma.user.update({
    where: { id },
    data: { suspendedAt: new Date() },
  });
}

Lỗi 4: Trả Về Password Hash Trong Admin Responses

SAI — expose sensitive data:

// ❌ Password hash lẫn trong response!
const user = await prisma.user.findUnique({ where: { id } });
res.json({ data: user });

ĐÚNG — dùng select để chọn chỉ safe fields:

// ✅ Explicitly select safe fields
const user = await prisma.user.findUnique({
  where: { id },
  select: {
    id: true,
    email: true,
    name: true,
    role: true,
    createdAt: true,
    // Không passwordHash, không apiKey
  },
});
res.json({ data: user });

Luôn dùng Prisma's select để whitelist fields thay vì trả về full model.


Testing Admin Endpoints

Admin endpoints cần testing kỹ vì chúng liên quan đến authorization boundaries. Đây là các test cases chính dùng Vitest và Supertest.

Test Setup

// src/__tests__/admin.test.ts
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { prisma } from '../lib/prisma';
import { Role } from '@prisma/client';
import bcrypt from 'bcrypt';
 
// Helper tạo users với specific roles
async function createUser(overrides: Partial<{
  email: string;
  role: Role;
  suspended: boolean;
}> = {}) {
  const email = overrides.email ?? `user-${Date.now()}@test.com`;
  const user = await prisma.user.create({
    data: {
      email,
      passwordHash: await bcrypt.hash('password123', 10),
      name: 'Test User',
      role: overrides.role ?? Role.USER,
      suspendedAt: overrides.suspended ? new Date() : null,
      suspendReason: overrides.suspended ? 'Test suspension' : null,
    },
  });
  return user;
}
 
// Helper lấy auth token cho user
async function getToken(email: string): Promise<string> {
  const res = await request(app)
    .post('/api/auth/login')
    .send({ email, password: 'password123' });
  return res.body.accessToken;
}
 
describe('Admin API', () => {
  let superAdmin: { id: string; email: string };
  let admin: { id: string; email: string };
  let regularUser: { id: string; email: string };
  let superAdminToken: string;
  let adminToken: string;
  let userToken: string;
 
  beforeAll(async () => {
    // Dọn dẹp
    await prisma.user.deleteMany({});
 
    // Tạo test users
    superAdmin = await createUser({
      email: '[email protected]',
      role: Role.SUPER_ADMIN,
    });
    admin = await createUser({
      email: '[email protected]',
      role: Role.ADMIN,
    });
    regularUser = await createUser({
      email: '[email protected]',
      role: Role.USER,
    });
 
    // Lấy tokens
    superAdminToken = await getToken('[email protected]');
    adminToken = await getToken('[email protected]');
    userToken = await getToken('[email protected]');
  });

Test: Authorization Boundaries

  describe('Authorization', () => {
    it('should reject unauthenticated requests', async () => {
      const res = await request(app).get('/api/admin/users');
      expect(res.status).toBe(401);
    });
 
    it('should reject regular users', async () => {
      const res = await request(app)
        .get('/api/admin/users')
        .set('Authorization', `Bearer ${userToken}`);
      expect(res.status).toBe(403);
      expect(res.body.error).toContain('permission');
    });
 
    it('should allow admin access', async () => {
      const res = await request(app)
        .get('/api/admin/users')
        .set('Authorization', `Bearer ${adminToken}`);
      expect(res.status).toBe(200);
      expect(res.body.data).toBeDefined();
    });
 
    it('should allow super admin access', async () => {
      const res = await request(app)
        .get('/api/admin/users')
        .set('Authorization', `Bearer ${superAdminToken}`);
      expect(res.status).toBe(200);
    });
  });

Test: Role Changes

  describe('PATCH /api/admin/users/:id/role', () => {
    it('should reject role changes from regular admin', async () => {
      const res = await request(app)
        .patch(`/api/admin/users/${regularUser.id}/role`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ role: 'ADMIN' });
      expect(res.status).toBe(403);
    });
 
    it('should allow super admin to promote user', async () => {
      const newUser = await createUser({ email: '[email protected]' });
      const res = await request(app)
        .patch(`/api/admin/users/${newUser.id}/role`)
        .set('Authorization', `Bearer ${superAdminToken}`)
        .send({ role: 'ADMIN' });
      expect(res.status).toBe(200);
      expect(res.body.data.role).toBe('ADMIN');
    });
 
    it('should prevent self-demotion', async () => {
      const res = await request(app)
        .patch(`/api/admin/users/${superAdmin.id}/role`)
        .set('Authorization', `Bearer ${superAdminToken}`)
        .send({ role: 'USER' });
      expect(res.status).toBe(400);
      expect(res.body.error).toContain('own role');
    });
 
    it('should reject invalid role values', async () => {
      const res = await request(app)
        .patch(`/api/admin/users/${regularUser.id}/role`)
        .set('Authorization', `Bearer ${superAdminToken}`)
        .send({ role: 'MEGADMIN' });
      expect(res.status).toBe(400);
    });
  });

Test: Suspension

  describe('POST /api/admin/users/:id/suspend', () => {
    it('should suspend a user with reason', async () => {
      const target = await createUser({ email: '[email protected]' });
      const res = await request(app)
        .post(`/api/admin/users/${target.id}/suspend`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ reason: 'Posting spam links repeatedly' });
 
      expect(res.status).toBe(200);
      expect(res.body.data.suspendedAt).toBeDefined();
      expect(res.body.data.suspendReason).toBe('Posting spam links repeatedly');
    });
 
    it('should prevent self-suspension', async () => {
      const res = await request(app)
        .post(`/api/admin/users/${admin.id}/suspend`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ reason: 'Testing self-suspension' });
      expect(res.status).toBe(400);
    });
 
    it('should require suspension reason', async () => {
      const target = await createUser({ email: '[email protected]' });
      const res = await request(app)
        .post(`/api/admin/users/${target.id}/suspend`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({});
      expect(res.status).toBe(400);
    });
 
    it('should block login for suspended user', async () => {
      const target = await createUser({ email: '[email protected]' });
 
      // Suspend user
      await request(app)
        .post(`/api/admin/users/${target.id}/suspend`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ reason: 'Abusive behavior detected' });
 
      // Thử login
      const loginRes = await request(app)
        .post('/api/auth/login')
        .send({ email: '[email protected]', password: 'password123' });
 
      expect(loginRes.status).toBe(403);
      expect(loginRes.body.error).toContain('suspended');
    });
  });

Test: User Search và Pagination

  describe('GET /api/admin/users', () => {
    it('should paginate results', async () => {
      const res = await request(app)
        .get('/api/admin/users?page=1&limit=2')
        .set('Authorization', `Bearer ${adminToken}`);
 
      expect(res.status).toBe(200);
      expect(res.body.data.length).toBeLessThanOrEqual(2);
      expect(res.body.pagination).toMatchObject({
        page: 1,
        limit: 2,
        hasNext: expect.any(Boolean),
        hasPrev: false,
      });
    });
 
    it('should search by email', async () => {
      const res = await request(app)
        .get('/api/admin/users?search=super@test')
        .set('Authorization', `Bearer ${adminToken}`);
 
      expect(res.status).toBe(200);
      expect(res.body.data.length).toBeGreaterThanOrEqual(1);
      expect(res.body.data[0].email).toContain('super@test');
    });
 
    it('should filter by role', async () => {
      const res = await request(app)
        .get('/api/admin/users?role=ADMIN')
        .set('Authorization', `Bearer ${adminToken}`);
 
      expect(res.status).toBe(200);
      res.body.data.forEach((user: { role: string }) => {
        expect(user.role).toBe('ADMIN');
      });
    });
 
    it('should filter by suspension status', async () => {
      const res = await request(app)
        .get('/api/admin/users?status=suspended')
        .set('Authorization', `Bearer ${adminToken}`);
 
      expect(res.status).toBe(200);
      res.body.data.forEach((user: { suspendedAt: string | null }) => {
        expect(user.suspendedAt).not.toBeNull();
      });
    });
  });

Test: Reactivation

  describe('POST /api/admin/users/:id/reactivate', () => {
    it('should reactivate a suspended user', async () => {
      const target = await createUser({
        email: '[email protected]',
        suspended: true,
      });
 
      const res = await request(app)
        .post(`/api/admin/users/${target.id}/reactivate`)
        .set('Authorization', `Bearer ${adminToken}`);
 
      expect(res.status).toBe(200);
      expect(res.body.data.suspendedAt).toBeNull();
    });
 
    it('should reject reactivation of active user', async () => {
      const target = await createUser({ email: '[email protected]' });
 
      const res = await request(app)
        .post(`/api/admin/users/${target.id}/reactivate`)
        .set('Authorization', `Bearer ${adminToken}`);
 
      expect(res.status).toBe(400);
      expect(res.body.error).toContain('not suspended');
    });
  });
});

Chạy tests:

npx vitest run src/__tests__/admin.test.ts

Tiếp Theo

Giờ chúng ta đã có admin powers — admins có thể xem users, suspend abusers và quản lý roles. Nhưng còn URLs thì sao? Admin cần xem tất cả URLs trong toàn hệ thống, flag malicious redirects, xóa spam links và thực hiện bulk operations.

Trong Phase 11: URL Moderation & Bulk Operations, chúng ta sẽ build:

  • System-wide URL listing với search và filters
  • URL flagging và removal workflow
  • Bulk operations (delete, disable, transfer ownership)
  • Basic abuse detection patterns
  • Admin dashboard statistics

User management từ phase này kết hợp với URL moderation ở phase tiếp theo tạo thành admin panel hoàn chỉnh.


Series: Build a URL Shortener
Previous: Phase 9: Deployment & Production
Next: Phase 11: URL Moderation & Bulk Operations

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.