feat: Cấu hình lại ứng dụng IAM, cập nhật cấu trúc routes, tích hợp RBAC và thêm các bài kiểm tra tích hợp.

This commit is contained in:
Ho Ngoc Hai
2026-01-04 14:27:41 +07:00
parent ccfb2a7fb2
commit a383d8772e
27 changed files with 1802 additions and 297 deletions

View File

@@ -117,7 +117,7 @@ services:
- microservices-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5001/health/live"]
test: ["CMD", "curl", "-f", "http://localhost:5001/health"]
interval: 30s
timeout: 10s
retries: 3
@@ -128,7 +128,7 @@ services:
- "traefik.http.routers.iam-service.rule=PathPrefix(`/api/v1/auth`) || PathPrefix(`/api/v1/users`) || PathPrefix(`/api/v1/identity`) || PathPrefix(`/api/v1/access`) || PathPrefix(`/api/v1/governance`) || PathPrefix(`/api/v1/rbac`) || PathPrefix(`/api/v1/mfa`) || PathPrefix(`/api/v1/sessions`)"
- "traefik.http.routers.iam-service.entrypoints=web"
- "traefik.http.services.iam-service.loadbalancer.server.port=5001"
- "traefik.http.services.iam-service.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.iam-service.loadbalancer.healthcheck.path=/health"
- "traefik.http.services.iam-service.loadbalancer.healthcheck.interval=10s"
# ===========================================================================

View File

@@ -2,13 +2,11 @@ http:
middlewares:
secure-headers:
headers:
sslRedirect: true
sslRedirect: false # EN: Disabled for local development / VI: Tắt cho local development
stsSeconds: 31536000
contentTypeNosniff: true
browserXssFilter: true
frameDeny: true
customRequestHeaders:
X-Forwarded-Proto: "https"
cors:
headers:

View File

@@ -1,8 +1,9 @@
http:
routers:
iam-service-router:
rule: "Host(`api.goodgo.vn`) && PathPrefix(`/api/v1/auth`)"
rule: "PathPrefix(`/api/v1/auth`)"
service: iam-service
priority: 100
middlewares:
- auth-ratelimit
- cors
@@ -11,8 +12,9 @@ http:
- web
iam-service-users-router:
rule: "Host(`api.goodgo.vn`) && PathPrefix(`/api/v1/users`)"
rule: "PathPrefix(`/api/v1/users`) || PathPrefix(`/api/v1/identity`) || PathPrefix(`/api/v1/access`) || PathPrefix(`/api/v1/governance`) || PathPrefix(`/api/v1/rbac`) || PathPrefix(`/api/v1/mfa`) || PathPrefix(`/api/v1/sessions`)"
service: iam-service
priority: 100
middlewares:
- auth-ratelimit
- cors

8
pnpm-lock.yaml generated
View File

@@ -618,6 +618,9 @@ importers:
specifier: ^3.22.4
version: 3.25.76
devDependencies:
'@faker-js/faker':
specifier: ^10.1.0
version: 10.1.0
'@goodgo/eslint-config':
specifier: workspace:*
version: link:../../packages/config/eslint-config
@@ -1479,6 +1482,11 @@ packages:
optional: true
dev: false
/@faker-js/faker@10.1.0:
resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==}
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
dev: true
/@fingerprintjs/fingerprintjs@5.0.1:
resolution: {integrity: sha512-KbaeE/rk2WL8MfpRP6jTI4lSr42SJPjvkyrjP3QU6uUDkOMWWYC2Ts1sNSYcegHC8avzOoYTHBj+2fTqvZWQBA==}
dev: false

View File

@@ -35,7 +35,10 @@ const config: Config = {
clearMocks: true,
// EN: Reset modules between tests for isolation
// VI: Reset modules giữa các test để cô lập
resetModules: true
resetModules: true,
transformIgnorePatterns: [
'node_modules/(?!(@faker-js/faker)/)'
]
};
export default config;

View File

@@ -0,0 +1,17 @@
import type { Config } from 'jest';
import defaultConfig from './jest.config';
const config: Config = {
...defaultConfig,
testMatch: [
'**/__tests__/**/*.integration.test.ts',
'**/__tests__/**/*.e2e.ts'
],
// Override setupFilesAfterEnv to avoid loading global mocks
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setupIntegrationTests.ts'],
// Ensure we don't carry over mocks
resetMocks: true,
restoreMocks: true,
};
export default config;

View File

@@ -60,6 +60,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@faker-js/faker": "^10.1.0",
"@goodgo/eslint-config": "workspace:*",
"@goodgo/tsconfig": "workspace:*",
"@jest/globals": "^29.7.0",

View File

@@ -1,14 +0,0 @@
-- Add account lockout fields to users table
-- Migration: add_account_lockout_fields
-- Add failedLoginAttempts column with default value 0
ALTER TABLE "users" ADD COLUMN "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0;
-- Add lockedUntil column for account lockout timestamp
ALTER TABLE "users" ADD COLUMN "lockedUntil" TIMESTAMP(3);
-- Add index on failedLoginAttempts for potential queries
CREATE INDEX "users_failedLoginAttempts_idx" ON "users"("failedLoginAttempts");
-- Add index on lockedUntil for cleanup queries
CREATE INDEX "users_lockedUntil_idx" ON "users"("lockedUntil");

View File

@@ -1,5 +0,0 @@
-- Add MFA backup codes field to users table
-- Migration: add_mfa_backup_codes
-- Add mfaBackupCodes column to store encrypted backup codes
ALTER TABLE "users" ADD COLUMN "mfaBackupCodes" TEXT;

View File

@@ -0,0 +1,723 @@
-- CreateEnum
CREATE TYPE "Provider" AS ENUM ('GOOGLE', 'FACEBOOK', 'GITHUB', 'APPLE', 'MICROSOFT');
-- CreateEnum
CREATE TYPE "MFAType" AS ENUM ('TOTP', 'WEBAUTHN');
-- CreateEnum
CREATE TYPE "VerificationType" AS ENUM ('EMAIL', 'PHONE', 'DOCUMENT', 'BIOMETRIC');
-- CreateEnum
CREATE TYPE "VerificationStatus" AS ENUM ('PENDING', 'VERIFIED', 'REJECTED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "RequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'EXPIRED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "ApprovalStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ReviewType" AS ENUM ('PERIODIC', 'ADHOC', 'CERTIFICATION');
-- CreateEnum
CREATE TYPE "ReviewStatus" AS ENUM ('DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "ReviewItemStatus" AS ENUM ('PENDING', 'APPROVED', 'REVOKED', 'NO_CHANGE');
-- CreateEnum
CREATE TYPE "ComplianceType" AS ENUM ('GDPR', 'SOC2', 'ISO27001', 'HIPAA', 'CUSTOM');
-- CreateEnum
CREATE TYPE "ReportStatus" AS ENUM ('DRAFT', 'GENERATED', 'PUBLISHED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "PolicyCategory" AS ENUM ('SECURITY', 'ACCESS', 'COMPLIANCE', 'DATA', 'PRIVACY');
-- CreateEnum
CREATE TYPE "RiskLevel" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"username" TEXT,
"passwordHash" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"mfaEnabled" BOOLEAN NOT NULL DEFAULT false,
"mfaSecret" TEXT,
"mfaBackupCodes" TEXT,
"lastLoginAt" TIMESTAMP(3),
"loginCount" INTEGER NOT NULL DEFAULT 0,
"failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
"lockedUntil" TIMESTAMP(3),
"organizationId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"description" TEXT,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permissions" (
"id" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"action" TEXT NOT NULL,
"scope" TEXT,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"grantedBy" TEXT,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"id" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_permissions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
"granted" BOOLEAN NOT NULL DEFAULT true,
"grantedBy" TEXT,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"deviceName" TEXT,
"ipAddress" TEXT NOT NULL,
"userAgent" TEXT,
"fingerprint" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "refresh_tokens" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"family" TEXT,
"deviceId" TEXT,
"ipAddress" TEXT,
"userAgent" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "social_accounts" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"provider" "Provider" NOT NULL,
"providerId" TEXT NOT NULL,
"email" TEXT,
"name" TEXT,
"avatar" TEXT,
"accessToken" TEXT,
"refreshToken" TEXT,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "social_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "mfa_devices" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "MFAType" NOT NULL,
"name" TEXT NOT NULL,
"secret" TEXT,
"credentialId" TEXT,
"publicKey" TEXT,
"counter" INTEGER,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "mfa_devices_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "auth_events" (
"id" TEXT NOT NULL,
"userId" TEXT,
"eventType" TEXT NOT NULL,
"eventData" JSONB NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"success" BOOLEAN NOT NULL DEFAULT true,
"errorMessage" TEXT,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "auth_events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "features" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"title" TEXT,
"description" TEXT,
"config" JSONB,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"version" TEXT NOT NULL DEFAULT '1.0.0',
"tags" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "features_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "policies" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"resource" TEXT NOT NULL,
"condition" JSONB NOT NULL,
"effect" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"organizationId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "policies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"domain" TEXT,
"parentId" TEXT,
"settings" JSONB,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "groups" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"organizationId" TEXT,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "groups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "group_members" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'member',
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3),
CONSTRAINT "group_members_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "group_permissions" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "group_permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_profiles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"firstName" TEXT,
"lastName" TEXT,
"phone" TEXT,
"phoneVerified" BOOLEAN NOT NULL DEFAULT false,
"avatarUrl" TEXT,
"customFields" JSONB,
"preferences" JSONB,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "identity_verifications" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "VerificationType" NOT NULL,
"status" "VerificationStatus" NOT NULL DEFAULT 'PENDING',
"method" TEXT,
"token" TEXT,
"verifiedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "identity_verifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "access_requests" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"action" TEXT NOT NULL,
"reason" TEXT,
"status" "RequestStatus" NOT NULL DEFAULT 'PENDING',
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewedAt" TIMESTAMP(3),
"reviewedBy" TEXT,
"expiresAt" TIMESTAMP(3),
"metadata" JSONB,
CONSTRAINT "access_requests_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "access_request_approvers" (
"id" TEXT NOT NULL,
"requestId" TEXT NOT NULL,
"approverId" TEXT NOT NULL,
"status" "ApprovalStatus" NOT NULL DEFAULT 'PENDING',
"comments" TEXT,
"reviewedAt" TIMESTAMP(3),
CONSTRAINT "access_request_approvers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "access_reviews" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"type" "ReviewType" NOT NULL,
"status" "ReviewStatus" NOT NULL DEFAULT 'DRAFT',
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
CONSTRAINT "access_reviews_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "access_review_items" (
"id" TEXT NOT NULL,
"reviewId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"access" JSONB NOT NULL,
"status" "ReviewItemStatus" NOT NULL DEFAULT 'PENDING',
"reviewedBy" TEXT,
"reviewedAt" TIMESTAMP(3),
"comments" TEXT,
CONSTRAINT "access_review_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "compliance_reports" (
"id" TEXT NOT NULL,
"type" "ComplianceType" NOT NULL,
"name" TEXT NOT NULL,
"periodStart" TIMESTAMP(3) NOT NULL,
"periodEnd" TIMESTAMP(3) NOT NULL,
"status" "ReportStatus" NOT NULL DEFAULT 'DRAFT',
"data" JSONB,
"generatedAt" TIMESTAMP(3),
"generatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "compliance_reports_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "policy_templates" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" "PolicyCategory" NOT NULL,
"description" TEXT,
"content" JSONB NOT NULL,
"version" TEXT NOT NULL DEFAULT '1.0.0',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "policy_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "risk_scores" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"score" INTEGER NOT NULL,
"level" "RiskLevel" NOT NULL,
"factors" JSONB NOT NULL,
"calculatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "risk_scores_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE INDEX "users_email_idx" ON "users"("email");
-- CreateIndex
CREATE INDEX "users_username_idx" ON "users"("username");
-- CreateIndex
CREATE INDEX "users_createdAt_idx" ON "users"("createdAt");
-- CreateIndex
CREATE INDEX "users_organizationId_idx" ON "users"("organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
-- CreateIndex
CREATE INDEX "permissions_resource_idx" ON "permissions"("resource");
-- CreateIndex
CREATE INDEX "permissions_resource_action_idx" ON "permissions"("resource", "action");
-- CreateIndex
CREATE UNIQUE INDEX "permissions_resource_action_scope_key" ON "permissions"("resource", "action", "scope");
-- CreateIndex
CREATE INDEX "user_roles_userId_idx" ON "user_roles"("userId");
-- CreateIndex
CREATE INDEX "user_roles_roleId_idx" ON "user_roles"("roleId");
-- CreateIndex
CREATE INDEX "user_roles_expiresAt_idx" ON "user_roles"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "user_roles_userId_roleId_key" ON "user_roles"("userId", "roleId");
-- CreateIndex
CREATE INDEX "role_permissions_roleId_idx" ON "role_permissions"("roleId");
-- CreateIndex
CREATE UNIQUE INDEX "role_permissions_roleId_permissionId_key" ON "role_permissions"("roleId", "permissionId");
-- CreateIndex
CREATE INDEX "user_permissions_userId_idx" ON "user_permissions"("userId");
-- CreateIndex
CREATE INDEX "user_permissions_expiresAt_idx" ON "user_permissions"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "user_permissions_userId_permissionId_key" ON "user_permissions"("userId", "permissionId");
-- CreateIndex
CREATE INDEX "sessions_userId_idx" ON "sessions"("userId");
-- CreateIndex
CREATE INDEX "sessions_deviceId_idx" ON "sessions"("deviceId");
-- CreateIndex
CREATE INDEX "sessions_expiresAt_idx" ON "sessions"("expiresAt");
-- CreateIndex
CREATE INDEX "sessions_isActive_idx" ON "sessions"("isActive");
-- CreateIndex
CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token");
-- CreateIndex
CREATE INDEX "refresh_tokens_userId_idx" ON "refresh_tokens"("userId");
-- CreateIndex
CREATE INDEX "refresh_tokens_token_idx" ON "refresh_tokens"("token");
-- CreateIndex
CREATE INDEX "refresh_tokens_family_idx" ON "refresh_tokens"("family");
-- CreateIndex
CREATE INDEX "refresh_tokens_expiresAt_idx" ON "refresh_tokens"("expiresAt");
-- CreateIndex
CREATE INDEX "social_accounts_userId_idx" ON "social_accounts"("userId");
-- CreateIndex
CREATE INDEX "social_accounts_provider_idx" ON "social_accounts"("provider");
-- CreateIndex
CREATE UNIQUE INDEX "social_accounts_provider_providerId_key" ON "social_accounts"("provider", "providerId");
-- CreateIndex
CREATE INDEX "mfa_devices_userId_idx" ON "mfa_devices"("userId");
-- CreateIndex
CREATE INDEX "mfa_devices_credentialId_idx" ON "mfa_devices"("credentialId");
-- CreateIndex
CREATE INDEX "auth_events_userId_timestamp_idx" ON "auth_events"("userId", "timestamp");
-- CreateIndex
CREATE INDEX "auth_events_eventType_timestamp_idx" ON "auth_events"("eventType", "timestamp");
-- CreateIndex
CREATE INDEX "auth_events_timestamp_idx" ON "auth_events"("timestamp");
-- CreateIndex
CREATE UNIQUE INDEX "features_name_key" ON "features"("name");
-- CreateIndex
CREATE INDEX "features_name_idx" ON "features"("name");
-- CreateIndex
CREATE INDEX "features_enabled_idx" ON "features"("enabled");
-- CreateIndex
CREATE UNIQUE INDEX "policies_name_key" ON "policies"("name");
-- CreateIndex
CREATE INDEX "policies_resource_idx" ON "policies"("resource");
-- CreateIndex
CREATE INDEX "policies_isActive_priority_idx" ON "policies"("isActive", "priority");
-- CreateIndex
CREATE INDEX "policies_organizationId_idx" ON "policies"("organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "organizations_domain_key" ON "organizations"("domain");
-- CreateIndex
CREATE INDEX "organizations_domain_idx" ON "organizations"("domain");
-- CreateIndex
CREATE INDEX "groups_organizationId_idx" ON "groups"("organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "groups_organizationId_name_key" ON "groups"("organizationId", "name");
-- CreateIndex
CREATE INDEX "group_members_userId_idx" ON "group_members"("userId");
-- CreateIndex
CREATE INDEX "group_members_groupId_idx" ON "group_members"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "group_members_userId_groupId_key" ON "group_members"("userId", "groupId");
-- CreateIndex
CREATE INDEX "group_permissions_groupId_idx" ON "group_permissions"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "group_permissions_groupId_permissionId_key" ON "group_permissions"("groupId", "permissionId");
-- CreateIndex
CREATE UNIQUE INDEX "user_profiles_userId_key" ON "user_profiles"("userId");
-- CreateIndex
CREATE INDEX "user_profiles_phone_idx" ON "user_profiles"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "identity_verifications_token_key" ON "identity_verifications"("token");
-- CreateIndex
CREATE INDEX "identity_verifications_userId_type_idx" ON "identity_verifications"("userId", "type");
-- CreateIndex
CREATE INDEX "identity_verifications_token_idx" ON "identity_verifications"("token");
-- CreateIndex
CREATE INDEX "identity_verifications_status_idx" ON "identity_verifications"("status");
-- CreateIndex
CREATE INDEX "access_requests_userId_idx" ON "access_requests"("userId");
-- CreateIndex
CREATE INDEX "access_requests_status_idx" ON "access_requests"("status");
-- CreateIndex
CREATE INDEX "access_requests_resource_idx" ON "access_requests"("resource");
-- CreateIndex
CREATE INDEX "access_request_approvers_approverId_idx" ON "access_request_approvers"("approverId");
-- CreateIndex
CREATE UNIQUE INDEX "access_request_approvers_requestId_approverId_key" ON "access_request_approvers"("requestId", "approverId");
-- CreateIndex
CREATE INDEX "access_reviews_status_idx" ON "access_reviews"("status");
-- CreateIndex
CREATE INDEX "access_reviews_type_idx" ON "access_reviews"("type");
-- CreateIndex
CREATE INDEX "access_review_items_reviewId_idx" ON "access_review_items"("reviewId");
-- CreateIndex
CREATE INDEX "access_review_items_userId_idx" ON "access_review_items"("userId");
-- CreateIndex
CREATE INDEX "access_review_items_status_idx" ON "access_review_items"("status");
-- CreateIndex
CREATE INDEX "compliance_reports_type_idx" ON "compliance_reports"("type");
-- CreateIndex
CREATE INDEX "compliance_reports_status_idx" ON "compliance_reports"("status");
-- CreateIndex
CREATE INDEX "policy_templates_category_idx" ON "policy_templates"("category");
-- CreateIndex
CREATE INDEX "policy_templates_isActive_idx" ON "policy_templates"("isActive");
-- CreateIndex
CREATE INDEX "risk_scores_userId_idx" ON "risk_scores"("userId");
-- CreateIndex
CREATE INDEX "risk_scores_score_idx" ON "risk_scores"("score");
-- CreateIndex
CREATE INDEX "risk_scores_level_idx" ON "risk_scores"("level");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_permissions" ADD CONSTRAINT "user_permissions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_permissions" ADD CONSTRAINT "user_permissions_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "social_accounts" ADD CONSTRAINT "social_accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mfa_devices" ADD CONSTRAINT "mfa_devices_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "auth_events" ADD CONSTRAINT "auth_events_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "policies" ADD CONSTRAINT "policies_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "organizations" ADD CONSTRAINT "organizations_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "groups" ADD CONSTRAINT "groups_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "group_members" ADD CONSTRAINT "group_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "group_members" ADD CONSTRAINT "group_members_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "group_permissions" ADD CONSTRAINT "group_permissions_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "group_permissions" ADD CONSTRAINT "group_permissions_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "identity_verifications" ADD CONSTRAINT "identity_verifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "access_requests" ADD CONSTRAINT "access_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "access_request_approvers" ADD CONSTRAINT "access_request_approvers_requestId_fkey" FOREIGN KEY ("requestId") REFERENCES "access_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "access_review_items" ADD CONSTRAINT "access_review_items_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "access_reviews"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "risk_scores" ADD CONSTRAINT "risk_scores_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -43,12 +43,14 @@ export TEST_DATABASE_URL="postgresql://test:test@localhost:5433/test_iam_db"
export TEST_REDIS_URL="redis://localhost:6380"
export NODE_ENV="test"
export LOG_LEVEL="error" # Reduce log noise during tests
# Export DATABASE_URL for Prisma
export DATABASE_URL=$TEST_DATABASE_URL
print_status "Setting up test infrastructure..."
# Start test database containers
print_status "Starting PostgreSQL and Redis test containers..."
docker-compose -f docker-compose.test.yml up -d
docker compose -f docker-compose.test.yml up -d
# Wait for databases to be ready
print_status "Waiting for PostgreSQL to be ready..."
@@ -57,7 +59,7 @@ counter=0
while ! docker exec postgres-test-iam pg_isready -U test -d test_iam_db >/dev/null 2>&1; do
if [ $counter -gt $timeout ]; then
print_error "PostgreSQL failed to start within ${timeout} seconds"
docker-compose -f docker-compose.test.yml logs postgres-test
docker compose -f docker-compose.test.yml logs postgres-test
exit 1
fi
counter=$((counter + 1))
@@ -69,7 +71,7 @@ counter=0
while ! docker exec redis-test-iam redis-cli ping >/dev/null 2>&1; do
if [ $counter -gt $timeout ]; then
print_error "Redis failed to start within ${timeout} seconds"
docker-compose -f docker-compose.test.yml logs redis-test
docker compose -f docker-compose.test.yml logs redis-test
exit 1
fi
counter=$((counter + 1))
@@ -80,21 +82,26 @@ print_success "Test infrastructure is ready!"
# Run database migrations
print_status "Running database migrations..."
cd "$(dirname "$0")/../.." # Go to iam-service directory
if ! pnpm prisma migrate deploy --schema=prisma/schema.prisma; then
# Assuming script is run from services/iam-service root if invoked via pnpm
# If invoked directly, we might need adjustment. But existing script does cd relative to script path.
# Let's keep existing cd logic but verify path.
SERVICE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$SERVICE_ROOT"
if ! npx prisma migrate deploy --schema=prisma/schema.prisma; then
print_error "Database migration failed"
exit 1
fi
# Seed test data (optional - comment out if not needed)
print_status "Seeding test data..."
if ! pnpm prisma db seed; then
print_warning "Test data seeding failed, but continuing..."
fi
# print_status "Seeding test data..."
# if ! pnpm prisma db seed; then
# print_warning "Test data seeding failed, but continuing..."
# fi
# Run integration tests
print_status "Running integration tests..."
if npm test -- --testPathPattern="integration" --testTimeout=60000 --verbose; then
if npx jest -c jest.integration.config.ts --runInBand --verbose; then
print_success "Integration tests passed!"
TEST_RESULT=0
else
@@ -104,7 +111,7 @@ fi
# Cleanup
print_status "Cleaning up test infrastructure..."
docker-compose -f docker-compose.test.yml down -v
docker compose -f docker-compose.test.yml down -v
if [ $TEST_RESULT -eq 0 ]; then
print_success "All integration tests completed successfully! 🎉"

View File

@@ -0,0 +1,86 @@
import { User, Organization, AccessRequest, Role, Permission } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
const randomString = (prefix: string = 'text') => `${prefix}-${Math.random().toString(36).substring(7)}`;
const randomEmail = () => `${randomString('user')}@example.com`;
const randomDate = () => new Date();
export class TestFactory {
static createUser(overrides: Partial<User> = {}): User {
return {
id: overrides.id || uuidv4(),
email: overrides.email || randomEmail(),
username: overrides.username || randomString('user'),
passwordHash: overrides.passwordHash || 'hashed-password',
isActive: overrides.isActive ?? true,
emailVerified: overrides.emailVerified ?? false,
mfaEnabled: overrides.mfaEnabled ?? false,
mfaSecret: overrides.mfaSecret || null,
mfaBackupCodes: overrides.mfaBackupCodes || null,
lastLoginAt: overrides.lastLoginAt || null,
loginCount: overrides.loginCount ?? 0,
failedLoginAttempts: overrides.failedLoginAttempts ?? 0,
lockedUntil: overrides.lockedUntil || null,
organizationId: overrides.organizationId || null,
createdAt: overrides.createdAt || randomDate(),
updatedAt: overrides.updatedAt || randomDate(),
...overrides,
};
}
static createOrganization(overrides: Partial<Organization> = {}): Organization {
return {
id: overrides.id || uuidv4(),
name: overrides.name || randomString('org'),
domain: overrides.domain || `${randomString('org')}.com`,
parentId: overrides.parentId || null,
settings: overrides.settings || {},
isActive: overrides.isActive ?? true,
createdAt: overrides.createdAt || randomDate(),
updatedAt: overrides.updatedAt || randomDate(),
...overrides,
};
}
static createRole(overrides: Partial<Role> = {}): Role {
return {
id: overrides.id || uuidv4(),
name: overrides.name || randomString('ROLE').toUpperCase(),
displayName: overrides.displayName || randomString('Role Name'),
description: overrides.description || randomString('Description'),
isSystem: overrides.isSystem ?? false,
createdAt: overrides.createdAt || randomDate(),
updatedAt: overrides.updatedAt || randomDate(),
...overrides,
};
}
static createPermission(overrides: Partial<Permission> = {}): Permission {
return {
id: overrides.id || uuidv4(),
resource: overrides.resource || 'users',
action: overrides.action || 'create',
scope: overrides.scope || 'all',
description: overrides.description || randomString('desc'),
createdAt: overrides.createdAt || randomDate(),
...overrides,
};
}
static createAccessRequest(overrides: Partial<AccessRequest> = {}): AccessRequest {
return {
id: overrides.id || uuidv4(),
userId: overrides.userId || uuidv4(),
resource: overrides.resource || 'resource',
action: overrides.action || 'read',
reason: overrides.reason || randomString('reason'),
status: overrides.status || 'PENDING',
requestedAt: overrides.requestedAt || randomDate(),
reviewedAt: overrides.reviewedAt || null,
reviewedBy: overrides.reviewedBy || null,
expiresAt: overrides.expiresAt || null,
metadata: overrides.metadata || {},
...overrides,
};
}
}

View File

@@ -0,0 +1,12 @@
// EN: Setup file for integration tests
// VI: File setup cho integration tests
import { config } from 'dotenv';
// Load env vars
config();
// Increase timeout for integration tests
jest.setTimeout(30000);
// We do NOT mock Prisma here. We want real DB connection.

View File

@@ -39,63 +39,56 @@ jest.mock('@goodgo/tracing', () => ({
// EN: Mock database client to avoid real DB connections in unit tests
// VI: Mock database client để tránh kết nối DB thật trong unit tests
// EN: Create a reusable mock for Prisma models
const mockModel = () => ({
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
count: jest.fn(),
updateMany: jest.fn(),
});
const mockPrismaClient = {
$queryRaw: jest.fn(),
$disconnect: jest.fn(),
$connect: jest.fn(),
$transaction: jest.fn((args: any) => (Array.isArray(args) ? Promise.all(args) : args(mockPrismaClient))),
user: mockModel(),
role: mockModel(),
permission: mockModel(),
feature: mockModel(),
session: mockModel(),
refreshToken: mockModel(),
authEvent: mockModel(),
organization: mockModel(),
group: mockModel(),
groupMember: mockModel(),
groupPermission: mockModel(),
userProfile: mockModel(),
identityVerification: mockModel(),
accessRequest: mockModel(),
accessRequestApprover: mockModel(),
accessReview: mockModel(),
accessReviewItem: mockModel(),
complianceReport: mockModel(),
policyTemplate: mockModel(),
policy: mockModel(),
riskScore: mockModel(),
socialAccount: mockModel(),
mfaDevice: mockModel(),
userRole: mockModel(),
userPermission: mockModel(),
rolePermission: mockModel(),
};
jest.mock('../config/database.config', () => ({
connectDatabase: jest.fn(),
prisma: {
$queryRaw: jest.fn(),
$disconnect: jest.fn(),
$connect: jest.fn(),
user: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
role: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
permission: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
feature: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
session: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
refreshToken: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
authEvent: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
getPrismaClient: jest.fn(() => mockPrismaClient),
prisma: mockPrismaClient,
}));
// EN: Set up default mock implementations

View File

@@ -0,0 +1,103 @@
import { logger } from '@goodgo/logger';
import { initTracing } from '@goodgo/tracing';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import express, { Express } from 'express';
import { rateLimit } from 'express-rate-limit';
import helmet from 'helmet';
import { appConfig } from './config/app.config';
import { setupSwagger } from './docs/swagger';
import { correlationMiddleware } from './middlewares/correlation.middleware';
import { errorHandler, notFoundHandler } from './middlewares/error.middleware';
import { requestLogger } from './middlewares/logger.middleware';
import { metricsMiddleware } from './middlewares/metrics.middleware';
import { createRouter } from './routes';
// EN: Initialize tracing
// VI: Khởi tạo tracing
if (process.env.TRACING_ENABLED === 'true') {
initTracing({
serviceName: process.env.SERVICE_NAME || 'microservice',
jaegerEndpoint: process.env.JAEGER_ENDPOINT,
enabled: true,
});
}
const app: Express = express();
// EN: Security middleware
// VI: Middleware bảo mật
app.use(helmet());
app.use(
cors({
origin: appConfig.corsOrigin,
credentials: true,
})
);
// EN: Rate limiting
// VI: Giới hạn số lượng request
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
});
app.use('/api', limiter);
// EN: Correlation ID middleware (must be early)
// VI: Correlation ID middleware (phải đặt sớm)
app.use(correlationMiddleware());
// EN: Body parsing
// VI: Phân tích body request
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// EN: Request logging
// VI: Ghi log request
app.use(requestLogger);
// EN: Metrics
// VI: Metrics
app.use(metricsMiddleware);
// EN: Routes with async error handling
// VI: Routes với async error handling
app.use(createRouter());
// In main.ts it was `app.use(createRouter())`. Let's check routes/index.ts to see if it adds prefix.
// The integration test used `createRouter` directly in main.
// Let's keep it as `app.use(createRouter())` unless I know better.
// Actually `routes/index.ts` creates a router.
// Looking at previous `main.ts`: `app.use(createRouter())`
// I'll stick to that. Or check auth.routes.ts.
// auth.routes.ts says: `const router = express.Router(); router.post('/register'...)`
// So mounting at root means `/auth/register`? Or `/api/v1/auth`?
// I need to check `routes/index.ts`.
// Checking routes/index.ts content (from memory/context):
// It refactored to import feature modules.
// Usually: router.use('/auth', authRoutes).
// If createsRouter returns a router with paths, then `app.use(createRouter())` mounts them at `/`.
// Let's assume `createRouter()` returns the main router.
// RE-READING main.ts view:
// app.use(createRouter());
// BUT usually API is at `/api/v1/...`
// The `routes/index.ts` might handle `/api/v1` prefix or `main.ts` should.
// Let's keep `app.use(createRouter())` for now to match main.ts. Use exact logic.
// Wait, I am overwriting app.ts.
// I will verify `routes/index.ts` content later if tests fail 404.
// EN: Setup Swagger documentation
// VI: Thiết lập tài liệu Swagger
setupSwagger(app, '/api-docs');
// EN: Error handling
// VI: Xử lý lỗi
app.use(notFoundHandler);
app.use(errorHandler);
export { app };

View File

@@ -1,85 +1,7 @@
import { logger } from '@goodgo/logger';
import { initTracing } from '@goodgo/tracing';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import express from 'express';
import { rateLimit } from 'express-rate-limit';
import helmet from 'helmet';
import { RedisStore } from 'rate-limit-redis';
import { prisma, connectDatabase } from './config/database.config';
import { appConfig } from './config/app.config';
import { connectDatabase, prisma } from './config/database.config';
import { getRedisClient } from './config/redis.config';
import { setupSwagger } from './docs/swagger';
import { correlationMiddleware } from './middlewares/correlation.middleware';
import { errorHandler, notFoundHandler } from './middlewares/error.middleware';
import { requestLogger } from './middlewares/logger.middleware';
import { metricsMiddleware } from './middlewares/metrics.middleware';
import { createRouter } from './routes';
// EN: Initialize tracing
// VI: Khởi tạo tracing
if (process.env.TRACING_ENABLED === 'true') {
initTracing({
serviceName: process.env.SERVICE_NAME || 'microservice',
jaegerEndpoint: process.env.JAEGER_ENDPOINT,
enabled: true,
});
}
const app = express();
// EN: Security middleware
// VI: Middleware bảo mật
app.use(helmet());
app.use(
cors({
origin: appConfig.corsOrigin,
credentials: true,
})
);
// EN: Rate limiting (use memory store initially, Redis will be used after connection)
// VI: Giới hạn số lượng request (dùng memory store ban đầu, Redis sẽ được dùng sau khi kết nối)
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
// EN: Use memory store by default, will upgrade to Redis when available
// VI: Dùng memory store mặc định, sẽ nâng cấp lên Redis khi có sẵn
});
app.use('/api', limiter);
// EN: Correlation ID middleware (must be early)
// VI: Correlation ID middleware (phải đặt sớm)
app.use(correlationMiddleware());
// EN: Body parsing
// VI: Phân tích body request
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// EN: Request logging
// VI: Ghi log request
app.use(requestLogger);
// EN: Metrics
// VI: Metrics
app.use(metricsMiddleware);
// EN: Routes with async error handling
// VI: Routes với async error handling
app.use(createRouter());
// EN: Setup Swagger documentation
// VI: Thiết lập tài liệu Swagger
setupSwagger(app, '/api-docs');
// EN: Error handling
// VI: Xử lý lỗi
app.use(notFoundHandler);
app.use(errorHandler);
import { app } from './app';
const startServer = async () => {
try {

View File

@@ -0,0 +1,80 @@
import request from 'supertest';
import { app } from '../../../app';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
describe('Auth API Integration Tests', () => {
beforeAll(async () => {
// Clean DB
await prisma.$transaction([
prisma.refreshToken.deleteMany(),
prisma.user.deleteMany(),
]);
});
afterAll(async () => {
await prisma.$disconnect();
});
beforeEach(async () => {
await prisma.$transaction([
prisma.refreshToken.deleteMany(),
prisma.user.deleteMany(),
]);
});
describe('POST /auth/register', () => {
it('should register a user with strong password', async () => {
const res = await request(app)
.post('/auth/register')
.send({
email: 'test@example.com',
password: 'StrongPassword123!',
username: 'testuser'
});
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.data.user.email).toBe('test@example.com');
});
it('should fail with weak password (Zod validation)', async () => {
const res = await request(app)
.post('/auth/register')
.send({
email: 'weak@example.com',
password: 'weak',
username: 'weakuser'
});
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error.code).toBe('VALIDATION_ERROR');
});
});
describe('POST /auth/login', () => {
it('should login successfully', async () => {
// Create user first
await request(app)
.post('/auth/register')
.send({
email: 'login@example.com',
password: 'StrongPassword123!',
username: 'loginuser'
});
const res = await request(app)
.post('/auth/login')
.send({
email: 'login@example.com',
password: 'StrongPassword123!'
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.tokens).toBeDefined();
});
});
});

View File

@@ -0,0 +1,117 @@
import { AuthController } from '../auth.controller';
import { authService } from '../auth.service';
import { TestFactory } from '../../../__tests__/factories';
// Mock dependencies
jest.mock('../auth.service');
describe('AuthController', () => {
let authController: AuthController;
let mockReq: any;
let mockRes: any;
beforeEach(() => {
jest.clearAllMocks();
authController = new AuthController();
mockReq = {
body: {},
ip: '127.0.0.1',
headers: {},
};
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
cookie: jest.fn().mockReturnThis(),
};
});
describe('register', () => {
it('should register user and return 201', async () => {
mockReq.body = { email: 'test@example.com', password: 'Password123!' };
const mockResult = {
user: TestFactory.createUser(),
tokens: { accessToken: 'access', refreshToken: 'refresh' },
};
(authService.register as jest.Mock).mockResolvedValue(mockResult);
await authController.register(mockReq, mockRes);
expect(authService.register).toHaveBeenCalledWith(expect.anything(), mockReq);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
data: expect.anything(),
});
});
it('should return 400 if registration fails', async () => {
// Pass valid body to pass Zod, but fail later
mockReq.body = { email: 'test@example.com', password: 'Password123!' };
const error = new Error('Registration failed');
(authService.register as jest.Mock).mockRejectedValue(error);
await authController.register(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
success: false,
error: expect.objectContaining({ message: 'Registration failed' })
}));
});
});
describe('login', () => {
it('should login user and return 200', async () => {
mockReq.body = { email: 'test@example.com', password: 'Password123!' };
const mockResult = {
user: TestFactory.createUser(),
tokens: { accessToken: 'access', refreshToken: 'refresh' },
};
(authService.login as jest.Mock).mockResolvedValue(mockResult);
await authController.login(mockReq, mockRes);
expect(authService.login).toHaveBeenCalledWith(expect.anything(), mockReq);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
data: expect.anything(),
});
});
it('should handle MFA required response', async () => {
mockReq.body = { email: 'test@example.com', password: 'Password123!' };
const mockMfaResult = {
user: { ...TestFactory.createUser(), mfaRequired: true },
tokens: { accessToken: '', refreshToken: '' }, // Type requires tokens
};
(authService.login as jest.Mock).mockResolvedValue(mockMfaResult);
await authController.login(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
data: expect.objectContaining({ mfaRequired: true }),
});
});
it('should return 401 if login fails', async () => {
mockReq.body = { email: 'test@example.com', password: 'Password123!' };
const error = new Error('Invalid credentials');
(authService.login as jest.Mock).mockRejectedValue(error);
await authController.login(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
success: false,
error: expect.objectContaining({ code: 'LOGIN_FAILED' })
}));
});
});
});

View File

@@ -252,57 +252,5 @@ describe('AuthService Integration Tests', () => {
});
});
describe('Password Security', () => {
it('should enforce password complexity during registration', async () => {
const mockRequest = {
ip: '127.0.0.1',
headers: { 'user-agent': 'test-agent' },
} as Partial<Request>;
// Test various weak passwords
const weakPasswords = [
'short', // Too short
'nouppercase123!', // No uppercase
'NOLOWERCASE123!', // No lowercase
'NoNumbers!', // No numbers
'NoSpecial123', // No special characters
];
for (const weakPassword of weakPasswords) {
const registerData = {
email: `weak-pass-${Date.now()}-${Math.random()}@example.com`,
password: weakPassword,
username: `weak-user-${Date.now()}-${Math.random()}`,
};
await expect(authService.register(registerData, mockRequest as Request))
.rejects.toThrow(); // Should fail validation
}
});
it('should accept strong passwords', async () => {
const mockRequest = {
ip: '127.0.0.1',
headers: { 'user-agent': 'test-agent' },
} as Partial<Request>;
const strongPasswords = [
'ValidPass123!',
'Complex!Password#456',
'Super@Secure$789',
];
for (const strongPassword of strongPasswords) {
const registerData = {
email: `strong-pass-${Date.now()}-${Math.random()}@example.com`,
password: strongPassword,
username: `strong-user-${Date.now()}-${Math.random()}`,
};
const result = await authService.register(registerData, mockRequest as Request);
expect(result).toHaveProperty('user');
expect(result).toHaveProperty('tokens');
}
});
});
});

View File

@@ -1,83 +1,179 @@
import crypto from 'crypto';
import { AuthService } from '../auth.service';
import { prisma } from '../../../config/database.config';
import { rbacService } from '../../rbac/rbac.service';
import { sessionService } from '../../session/session.service';
import { jwtService } from '../../token/jwt.service';
import { auditService } from '../../../core/events/audit.service';
import { accountLockoutService } from '../account-lockout.service';
import { TestFactory } from '../../../__tests__/factories';
import bcrypt from 'bcryptjs';
import { AuthService } from '../auth.service';
// Mock only the database config to avoid complex dependencies
jest.mock('../../../config/database.config');
// Mock dependencies
jest.mock('../../rbac/rbac.service');
jest.mock('../../session/session.service');
jest.mock('../../token/jwt.service');
jest.mock('../../../core/events/audit.service');
jest.mock('../account-lockout.service');
jest.mock('bcryptjs');
describe('AuthService', () => {
let authService: AuthService;
let mockPrisma: any;
// Create a mock request object that satisfies the interface and avoiding unsafe access
const mockReq = {
ip: '127.0.0.1',
headers: { 'user-agent': 'Jest-Test' },
socket: { remoteAddress: '127.0.0.1' },
} as any;
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Setup minimal Prisma mock
mockPrisma = {
user: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
refreshToken: {
deleteMany: jest.fn(),
},
session: {
deleteMany: jest.fn(),
},
};
// Mock the database client
const { getPrismaClient } = require('../../../config/database.config');
getPrismaClient.mockReturnValue(mockPrisma);
authService = new AuthService();
});
describe('hashToken', () => {
it('should hash refresh tokens using SHA-256', () => {
// This is a private method, but we can test it by accessing it
const token = 'refresh-token-123';
const hashedToken = (authService as any).hashToken(token);
describe('register', () => {
it('should register a new user successfully', async () => {
// Arrange
const registerDto = {
email: 'test@example.com',
password: 'password123',
username: 'testuser',
};
expect(hashedToken).toBeDefined();
expect(typeof hashedToken).toBe('string');
expect(hashedToken.length).toBe(64); // SHA-256 produces 64 character hex string
const mockUser = TestFactory.createUser({
email: registerDto.email,
username: registerDto.username
});
const mockTokens = { accessToken: 'access-token', refreshToken: 'refresh-token' };
const mockDecodedRefresh = { tokenId: 'token-id', sub: mockUser.id };
// Verify it's actually SHA-256 of the input
const expectedHash = crypto.createHash('sha256').update(token).digest('hex');
expect(hashedToken).toBe(expectedHash);
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
(bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password' as never);
(prisma.user.create as jest.Mock).mockResolvedValue(mockUser);
(prisma.role.findFirst as jest.Mock).mockResolvedValue({ id: 'role-id' });
(rbacService.assignRole as jest.Mock).mockResolvedValue(undefined);
(rbacService.getUserRoles as jest.Mock).mockResolvedValue(['USER']);
(rbacService.getUserPermissions as jest.Mock).mockResolvedValue([]);
(jwtService.generateTokenSet as jest.Mock).mockResolvedValue(mockTokens);
(jwtService.verifyRefreshToken as jest.Mock).mockReturnValue(mockDecodedRefresh);
(prisma.refreshToken.create as jest.Mock).mockResolvedValue({} as any);
(sessionService.createSession as jest.Mock).mockResolvedValue(undefined);
// Act
const result = await authService.register(registerDto, mockReq);
// Assert
expect(prisma.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
email: registerDto.email,
username: registerDto.username,
passwordHash: 'hashed-password',
}),
});
expect(result).toHaveProperty('tokens', mockTokens);
expect(result.user).toHaveProperty('email', registerDto.email);
});
it('should produce consistent hashes for same input', () => {
const token = 'test-token';
const hash1 = (authService as any).hashToken(token);
const hash2 = (authService as any).hashToken(token);
it('should throw error if user already exists', async () => {
const registerDto = { email: 'exists@example.com', password: '123' };
(prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: 'existing' });
expect(hash1).toBe(hash2);
await expect(authService.register(registerDto, mockReq)).rejects.toThrow('User already exists');
});
});
describe('logout', () => {
it('should logout user and invalidate sessions', async () => {
// Arrange
const userId = 'user-123';
mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 1 });
mockPrisma.session.deleteMany.mockResolvedValue({ count: 1 });
// Act
await authService.logout(userId);
// Assert
expect(mockPrisma.refreshToken.deleteMany).toHaveBeenCalledWith({
where: { userId }
});
expect(mockPrisma.session.deleteMany).toHaveBeenCalledWith({
where: { userId }
describe('login', () => {
it('should login successfully with valid credentials', async () => {
const loginDto = { email: 'test@example.com', password: 'password123' };
const mockUser = TestFactory.createUser({
email: loginDto.email,
passwordHash: 'hashed-password'
});
const mockTokens = { accessToken: 'access', refreshToken: 'refresh' };
(prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser);
(accountLockoutService.isAccountLocked as jest.Mock).mockResolvedValue(false);
(bcrypt.compare as jest.Mock).mockResolvedValue(true as never);
(jwtService.generateTokenSet as jest.Mock).mockResolvedValue(mockTokens);
(jwtService.verifyRefreshToken as jest.Mock).mockReturnValue({ tokenId: 'tid' });
const result = await authService.login(loginDto, mockReq);
expect(result).toEqual(expect.objectContaining({
tokens: mockTokens,
user: expect.objectContaining({ email: mockUser.email })
}));
expect(prisma.user.update).toHaveBeenCalled(); // Last login update
});
it('should throw if user not found', async () => {
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
await expect(authService.login({ email: '404', password: '123' }, mockReq))
.rejects.toThrow('Invalid credentials');
});
it('should throw if password invalid', async () => {
const mockUser = TestFactory.createUser({ passwordHash: 'hash' });
(prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser);
(bcrypt.compare as jest.Mock).mockResolvedValue(false as never);
await expect(authService.login({ email: 'test', password: 'wrong' }, mockReq))
.rejects.toThrow('Invalid credentials');
expect(accountLockoutService.recordFailedAttempt).toHaveBeenCalledWith(mockUser.id);
});
it('should throw if account is locked', async () => {
const mockUser = TestFactory.createUser();
(prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser);
(accountLockoutService.isAccountLocked as jest.Mock).mockResolvedValue(true);
(accountLockoutService.getLockoutStatus as jest.Mock).mockResolvedValue({ remainingMinutes: 15 });
await expect(authService.login({ email: 'locked', password: '123' }, mockReq))
.rejects.toThrow(/Account is locked/);
});
});
describe('refreshToken', () => {
it('should refresh token successfully', async () => {
const token = 'valid-refresh-token';
const mockDecoded = { sub: 'user-id', tokenId: 'family-id' };
const mockTokenRecord = {
id: 'record-id',
token: 'hashed-token',
family: 'family-id',
deviceId: 'device-id',
user: TestFactory.createUser()
};
(jwtService.verifyRefreshToken as jest.Mock).mockReturnValue(mockDecoded);
(prisma.refreshToken.findFirst as jest.Mock).mockResolvedValue(mockTokenRecord);
(jwtService.generateAccessToken as jest.Mock).mockReturnValue('new-access');
(jwtService.generateRefreshToken as jest.Mock).mockReturnValue('new-refresh');
// Mock transaction return
(prisma.$transaction as jest.Mock).mockResolvedValue([{}, {}]);
const result = await authService.refreshToken(token, mockReq);
expect(result).toHaveProperty('accessToken', 'new-access');
expect(result).toHaveProperty('refreshToken', 'new-refresh');
});
it('should detect token reuse (family mismatch) and revoke chain', async () => {
const token = 'stolen-token';
const mockDecoded = { sub: 'user-id', tokenId: 'old-family' };
const mockTokenRecord = {
id: 'record-id',
family: 'new-family' // Mismatch!
};
(jwtService.verifyRefreshToken as jest.Mock).mockReturnValue(mockDecoded);
(prisma.refreshToken.findFirst as jest.Mock).mockResolvedValue(mockTokenRecord);
await expect(authService.refreshToken(token, mockReq))
.rejects.toThrow(/Token family mismatch/i);
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: { userId: 'user-id', family: 'new-family' }
}));
});
});
});

View File

@@ -156,7 +156,7 @@ export class AuthController {
async refreshToken(req: Request, res: Response): Promise<void> {
try {
const refreshToken = req.body.refreshToken; // cookieService.getTokenFromCookie(req, 'refresh') || req.body.refreshToken;
if (!refreshToken) {
res.status(400).json({
success: false,
@@ -189,6 +189,51 @@ export class AuthController {
});
}
}
/**
* EN: Verify email endpoint
* VI: Endpoint xác minh email
*/
async verifyEmail(req: Request, res: Response): Promise<void> {
res.status(501).json({
success: false,
error: {
code: 'NOT_IMPLEMENTED',
message: 'Email verification is not yet implemented / Xác minh email chưa được triển khai'
},
timestamp: new Date().toISOString()
});
}
/**
* EN: Forgot password endpoint
* VI: Endpoint quên mật khẩu
*/
async forgotPassword(req: Request, res: Response): Promise<void> {
res.status(501).json({
success: false,
error: {
code: 'NOT_IMPLEMENTED',
message: 'Forgot password is not yet implemented / Quên mật khẩu chưa được triển khai'
},
timestamp: new Date().toISOString()
});
}
/**
* EN: Reset password endpoint
* VI: Endpoint đặt lại mật khẩu
*/
async resetPassword(req: Request, res: Response): Promise<void> {
res.status(501).json({
success: false,
error: {
code: 'NOT_IMPLEMENTED',
message: 'Reset password is not yet implemented / Đặt lại mật khẩu chưa được triển khai'
},
timestamp: new Date().toISOString()
});
}
}
export const authController = new AuthController();

View File

@@ -238,6 +238,88 @@ export const createAuthRouter = (): Router => {
});
});
// =============================================================================
// EN: Password Reset Endpoints
// VI: Endpoints Đặt Lại Mật Khẩu
// =============================================================================
/**
* @swagger
* /api/{version}/auth/verify-email:
* post:
* summary: Verify email address
* description: Verify user email address with verification token
* tags: [Authentication]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [token]
* properties:
* token:
* type: string
* responses:
* 200:
* description: Email verified successfully
* 400:
* description: Invalid or expired token
*/
router.post('/verify-email', authController.verifyEmail.bind(authController));
/**
* @swagger
* /api/{version}/auth/forgot-password:
* post:
* summary: Request password reset
* description: Send password reset email to user
* tags: [Authentication]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email]
* properties:
* email:
* type: string
* responses:
* 200:
* description: Reset email sent
* 404:
* description: User not found
*/
router.post('/forgot-password', authController.forgotPassword.bind(authController));
/**
* @swagger
* /api/{version}/auth/reset-password:
* post:
* summary: Reset password
* description: Reset user password with reset token
* tags: [Authentication]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [token, password]
* properties:
* token:
* type: string
* password:
* type: string
* responses:
* 200:
* description: Password reset successfully
* 400:
* description: Invalid or expired token
*/
router.post('/reset-password', authController.resetPassword.bind(authController));
// =============================================================================
// EN: Social Authentication Endpoints
// VI: Endpoints Xác Thực Mạng Xã Hội

View File

@@ -28,7 +28,7 @@ export class RBACController {
async getUserPermissions(req: Request, res: Response): Promise<void> {
try {
const userId = (req as any).user?.id || (req as any).user?.sub || req.params.userId;
if (!userId) {
res.status(400).json({
success: false,
@@ -214,6 +214,35 @@ export class RBACController {
});
}
}
/**
* EN: Get all roles
* VI: Lấy tất cả roles
*/
async getRoles(req: Request, res: Response): Promise<void> {
res.status(501).json({
success: false,
error: {
code: 'NOT_IMPLEMENTED',
message: 'Get roles is not yet implemented / Lấy roles chưa được triển khai'
},
timestamp: new Date().toISOString()
});
}
/**
* EN: Create new role
* VI: Tạo role mới
*/
async createRole(req: Request, res: Response): Promise<void> {
res.status(501).json({
success: false,
error: {
code: 'NOT_IMPLEMENTED',
message: 'Create role is not yet implemented / Tạo role chưa được triển khai'
},
timestamp: new Date().toISOString()
});
}
}
export const rbacController = new RBACController();

View File

@@ -230,5 +230,12 @@ export const createRbacRouter = (): Router => {
*/
router.get('/permissions/check', authenticate(), rbacController.checkPermission.bind(rbacController));
// EN: Role Management Routes
// VI: Routes Quản Lý Role
router.get("/roles", authenticate(), requirePermission("rbac", "read"), rbacController.getRoles.bind(rbacController));
router.post("/roles", authenticate(), requirePermission("rbac", "create"), rbacController.createRole.bind(rbacController));
router.post("/check", authenticate(), rbacController.checkPermission.bind(rbacController));
return router;
};

View File

@@ -0,0 +1,234 @@
import { Router } from 'express';
import { authenticate } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/rbac.middleware';
import { rbacController } from './rbac.controller';
/**
* EN: Create and configure RBAC routes
* VI: Tạo và cấu hình routes cho RBAC
*/
export const createRbacRouter = (): Router => {
const router = Router();
/**
* @swagger
* /api/{version}/rbac/permissions:
* get:
* summary: Get user permissions
* description: Get list of permissions assigned to the current user
* tags: [RBAC]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: version
* required: true
* schema:
* type: string
* default: v1
* description: API version
* responses:
* 200:
* description: Permissions retrieved successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiResponse'
* - type: object
* properties:
* data:
* type: array
* items:
* type: string
* 401:
* description: Unauthorized
*/
router.get('/permissions', authenticate(), rbacController.getUserPermissions.bind(rbacController));
/**
* @swagger
* /api/{version}/rbac/roles/assign:
* post:
* summary: Assign role to user
* description: Assign a specific role to a user. Requires 'rbac:assign' permission.
* tags: [RBAC]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: version
* required: true
* schema:
* type: string
* default: v1
* description: API version
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [userId, roleId]
* properties:
* userId:
* type: string
* roleId:
* type: string
* responses:
* 200:
* description: Role assigned successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiResponse'
* 401:
* description: Unauthorized
* 403:
* description: Forbidden - Insufficient permissions
* 404:
* description: User or Role not found
*/
router.post('/roles/assign', authenticate(), requirePermission('rbac', 'assign'), rbacController.assignRole.bind(rbacController));
/**
* @swagger
* /api/{version}/rbac/roles/revoke:
* post:
* summary: Revoke role from user
* description: Revoke a specific role from a user. Requires 'rbac:revoke' permission.
* tags: [RBAC]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: version
* required: true
* schema:
* type: string
* default: v1
* description: API version
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [userId, roleId]
* properties:
* userId:
* type: string
* roleId:
* type: string
* responses:
* 200:
* description: Role revoked successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiResponse'
* 401:
* description: Unauthorized
* 403:
* description: Forbidden - Insufficient permissions
* 404:
* description: User or Role not found
*/
router.post('/roles/revoke', authenticate(), requirePermission('rbac', 'revoke'), rbacController.revokeRole.bind(rbacController));
/**
* @swagger
* /api/{version}/rbac/permissions/grant:
* post:
* summary: Grant permission to role
* description: Grant a specific permission to a role. Requires 'rbac:grant' permission.
* tags: [RBAC]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: version
* required: true
* schema:
* type: string
* default: v1
* description: API version
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [roleId, permissionId]
* properties:
* roleId:
* type: string
* permissionId:
* type: string
* responses:
* 200:
* description: Permission granted successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiResponse'
* 401:
* description: Unauthorized
* 403:
* description: Forbidden - Insufficient permissions
* 404:
* description: Role or Permission not found
*/
router.post('/permissions/grant', authenticate(), requirePermission('rbac', 'grant'), rbacController.grantPermission.bind(rbacController));
/**
* @swagger
* /api/{version}/rbac/permissions/check:
* get:
* summary: Check permission
* description: Check if the current user has a specific permission
* tags: [RBAC]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: version
* required: true
* schema:
* type: string
* default: v1
* description: API version
* - in: query
* name: resource
* required: true
* schema:
* type: string
* - in: query
* name: action
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Permission check result
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiResponse'
* - type: object
* properties:
* data:
* type: object
* properties:
* allowed:
* type: boolean
* 401:
* description: Unauthorized
* 403:
* description: Forbidden
*/
router.get('/permissions/check', authenticate(), rbacController.checkPermission.bind(rbacController));
return router;
};

View File

@@ -15,43 +15,51 @@ import { createMfaRouter } from '../modules/mfa/mfa.routes';
export const createRouter = (): Router => {
const router = Router();
// EN: Health Check
// VI: Kiểm tra trạng thái
// EN: Health Check (root level, no API prefix needed)
// VI: Kiểm tra trạng thái (ở root level, không cần prefix API)
router.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// EN: API v1 routes with prefix
// VI: Routes API v1 với prefix
const apiV1Router = Router();
// EN: Auth Routes
// VI: Routes xác thực
router.use('/auth', createAuthRouter());
apiV1Router.use('/auth', createAuthRouter());
// EN: Session Routes
// VI: Routes phiên làm việc
router.use('/session', createSessionRouter());
apiV1Router.use('/sessions', createSessionRouter());
// EN: OIDC Routes
// VI: Routes OpenID Connect
router.use('/oidc', createOidcRouter());
apiV1Router.use('/oidc', createOidcRouter());
// EN: RBAC Routes
// VI: Routes phân quyền
router.use('/rbac', createRbacRouter());
apiV1Router.use('/rbac', createRbacRouter());
// EN: MFA Routes
// VI: Routes xác thực đa yếu tố
router.use('/mfa', createMfaRouter());
apiV1Router.use('/mfa', createMfaRouter());
// EN: Identity Routes
// VI: Routes định danh
router.use('/identity', createIdentityRouter());
apiV1Router.use('/identity', createIdentityRouter());
// EN: Access Routes
// VI: Routes truy cập
router.use('/access', createAccessRouter());
apiV1Router.use('/access', createAccessRouter());
// EN: Governance Routes
// VI: Routes quản trị
router.use('/governance', createGovernanceRouter());
apiV1Router.use('/governance', createGovernanceRouter());
// EN: Mount API v1 router with prefix
// VI: Mount router API v1 với prefix
router.use('/api/v1', apiV1Router);
return router;
};