251206:1400 version 1.5.1

This commit is contained in:
admin
2025-12-06 14:42:32 +07:00
parent 7dce419745
commit 0aaa139145
34 changed files with 4652 additions and 251 deletions

View File

@@ -1,20 +1,23 @@
// File: src/common/auth/auth.module.ts
// บันทึกการแก้ไข: แก้ไข Type Mismatch ของ expiresIn (Fix TS2322)
// [P0-1] เพิ่ม CASL RBAC Integration
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; // [NEW] 1. Import TypeOrmModule
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthService } from './auth.service.js';
import { AuthController } from './auth.controller.js';
import { UserModule } from '../../modules/user/user.module.js';
import { JwtStrategy } from './strategies/jwt.strategy.js';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy.js';
import { User } from '../../modules/user/entities/user.entity'; // [NEW] 2. Import User Entity
import { User } from '../../modules/user/entities/user.entity';
import { CaslModule } from './casl/casl.module'; // [P0-1] Import CASL
import { PermissionsGuard } from './guards/permissions.guard'; // [P0-1] Import Guard
@Module({
imports: [
// [NEW] 3. Register User Entity เพื่อให้ AuthService ใช้ InjectRepository(User) ได้
TypeOrmModule.forFeature([User]),
UserModule,
PassportModule,
@@ -24,15 +27,23 @@ import { User } from '../../modules/user/entities/user.entity'; // [NEW] 2. Impo
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
// ✅ Fix: Cast เป็น any เพื่อแก้ปัญหา Type ไม่ตรงกับ Library (StringValue vs string)
expiresIn: (configService.get<string>('JWT_EXPIRATION') ||
'15m') as any,
},
}),
}),
CaslModule, // [P0-1] Import CASL module
],
providers: [
AuthService,
JwtStrategy,
JwtRefreshStrategy,
PermissionsGuard, // [P0-1] Register PermissionsGuard
],
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
controllers: [AuthController],
exports: [AuthService],
exports: [
AuthService,
PermissionsGuard, // [P0-1] Export for use in other modules
],
})
export class AuthModule {}

View File

@@ -0,0 +1,131 @@
# P0-1: CASL RBAC Integration - Usage Example
## ตัวอย่างการใช้งานใน Controller
### 1. Import Required Dependencies
```typescript
import { Controller, Post, Get, UseGuards, Body, Param } from '@nestjs/common';
import { JwtAuthGuard } from '../common/auth/guards/jwt-auth.guard';
import { PermissionsGuard } from '../common/auth/guards/permissions.guard';
import { RequirePermission } from '../common/decorators/require-permission.decorator';
```
### 2. Apply Guards and Permissions
```typescript
@Controller('correspondences')
@UseGuards(JwtAuthGuard) // Step 1: Authenticate user
export class CorrespondenceController {
// ตัวอย่าง 1: Single Permission
@Post()
@UseGuards(PermissionsGuard) // Step 2: Check permissions
@RequirePermission('correspondence.create')
async create(@Body() dto: CreateCorrespondenceDto) {
// Only users with 'correspondence.create' permission can access
return this.correspondenceService.create(dto);
}
// ตัวอย่าง 2: View (typically everyone with access)
@Get(':id')
@UseGuards(PermissionsGuard)
@RequirePermission('correspondence.view')
async findOne(@Param('id') id: string) {
return this.correspondenceService.findOne(+id);
}
// ตัวอย่าง 3: Admin Edit (requires special permission)
@Put(':id/force-update')
@UseGuards(PermissionsGuard)
@RequirePermission('document.admin_edit')
async forceUpdate(@Param('id') id: string, @Body() dto: UpdateDto) {
// Only document controllers can force update
return this.correspondenceService.forceUpdate(+id, dto);
}
// ตัวอย่าง 4: Multiple Permissions (user must have ALL)
@Delete(':id')
@UseGuards(PermissionsGuard)
@RequirePermission('correspondence.delete', 'document.admin_edit')
async remove(@Param('id') id: string) {
// Requires BOTH permissions
return this.correspondenceService.remove(+id);
}
}
```
### 3. Controller with Scope Context
Permissions guard จะ extract scope จาก request params/body/query:
```typescript
@Controller('projects/:projectId/correspondences')
@UseGuards(JwtAuthGuard)
export class ProjectCorrespondenceController {
@Post()
@UseGuards(PermissionsGuard)
@RequirePermission('correspondence.create')
async create(
@Param('projectId') projectId: string,
@Body() dto: CreateCorrespondenceDto
) {
// PermissionsGuard จะ extract: { projectId: projectId }
// และตรวจสอบว่า user มี permission ใน project นี้หรือไม่
return this.service.create({ projectId, ...dto });
}
}
```
## หลักการทำงาน
### Scope Matching Hierarchy
1. **Global Scope**: User ที่มี assignment โดยไม่ระบุ org/project/contract
- สามารถ access ทุกอย่างได้
2. **Organization Scope**: User ที่มี assignment ระดับ organization
- สามารถ access resources ใน organization นั้นเท่านั้น
3. **Project Scope**: User ที่มี assignment ระดับ project
- สามารถ access resources ใน project นั้นเท่านั้น
4. **Contract Scope**: User ที่มี assignment ระดับ contract
- สามารถ access resources ใน contract นั้นเท่านั้น
### Permission Format
Permission ใน database ต้องเป็นรูปแบบ: `{subject}.{action}`
ตัวอย่าง:
- `correspondence.create`
- `correspondence.view`
- `correspondence.edit`
- `document.admin_edit`
- `rfa.create`
- `project.manage_members`
- `system.manage_all` (special case)
## Testing
Run unit tests:
```bash
npm run test -- ability.factory.spec
```
Expected output:
```
✓ should grant all permissions for global admin
✓ should grant permissions for matching organization
✓ should deny permissions for non-matching organization
✓ should grant permissions for matching project
✓ should grant permissions for matching contract
✓ should combine permissions from multiple assignments
```
## Next Steps
1. Update existing controllers to use `@RequirePermission()`
2. Test with different user roles
3. Verify scope matching works correctly

View File

@@ -0,0 +1,164 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AbilityFactory, ScopeContext } from './ability.factory';
import { User } from '../../../modules/user/entities/user.entity';
import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity';
describe('AbilityFactory', () => {
let factory: AbilityFactory;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AbilityFactory],
}).compile();
factory = module.get<AbilityFactory>(AbilityFactory);
});
it('should be defined', () => {
expect(factory).toBeDefined();
});
describe('Global Admin', () => {
it('should grant all permissions for global admin', () => {
const user = createMockUser({
assignments: [
createMockAssignment({
organizationId: undefined,
projectId: undefined,
contractId: undefined,
permissionNames: ['system.manage_all'],
}),
],
});
const ability = factory.createForUser(user, {});
expect(ability.can('manage', 'all')).toBe(true);
});
});
describe('Organization Level', () => {
it('should grant permissions for matching organization', () => {
const user = createMockUser({
assignments: [
createMockAssignment({
organizationId: 1,
permissionNames: ['correspondence.create', 'correspondence.read'],
}),
],
});
const context: ScopeContext = { organizationId: 1 };
const ability = factory.createForUser(user, context);
expect(ability.can('create', 'correspondence')).toBe(true);
expect(ability.can('read', 'correspondence')).toBe(true);
});
it('should deny permissions for non-matching organization', () => {
const user = createMockUser({
assignments: [
createMockAssignment({
organizationId: 1,
permissionNames: ['correspondence.create'],
}),
],
});
const context: ScopeContext = { organizationId: 2 };
const ability = factory.createForUser(user, context);
expect(ability.can('create', 'correspondence')).toBe(false);
});
});
describe('Project Level', () => {
it('should grant permissions for matching project', () => {
const user = createMockUser({
assignments: [
createMockAssignment({
projectId: 10,
permissionNames: ['rfa.create'],
}),
],
});
const context: ScopeContext = { projectId: 10 };
const ability = factory.createForUser(user, context);
expect(ability.can('create', 'rfa')).toBe(true);
});
});
describe('Contract Level', () => {
it('should grant permissions for matching contract', () => {
const user = createMockUser({
assignments: [
createMockAssignment({
contractId: 5,
permissionNames: ['drawing.create'],
}),
],
});
const context: ScopeContext = { contractId: 5 };
const ability = factory.createForUser(user, context);
expect(ability.can('create', 'drawing')).toBe(true);
});
});
describe('Multiple Assignments', () => {
it('should combine permissions from multiple assignments', () => {
const user = createMockUser({
assignments: [
createMockAssignment({
organizationId: 1,
permissionNames: ['correspondence.create'],
}),
createMockAssignment({
projectId: 10,
permissionNames: ['rfa.create'],
}),
],
});
const orgAbility = factory.createForUser(user, { organizationId: 1 });
expect(orgAbility.can('create', 'correspondence')).toBe(true);
const projectAbility = factory.createForUser(user, { projectId: 10 });
expect(projectAbility.can('create', 'rfa')).toBe(true);
});
});
});
// Helper functions using mock objects
function createMockUser(props: { assignments: UserAssignment[] }): User {
const user = new User();
user.user_id = 1;
user.username = 'testuser';
user.email = 'test@example.com';
user.assignments = props.assignments;
return user;
}
function createMockAssignment(props: {
organizationId?: number;
projectId?: number;
contractId?: number;
permissionNames: string[];
}): UserAssignment {
const assignment = new UserAssignment();
assignment.organizationId = props.organizationId;
assignment.projectId = props.projectId;
assignment.contractId = props.contractId;
// Create mock role with permissions
assignment.role = {
permissions: props.permissionNames.map((name) => ({
permissionName: name,
})),
} as any;
return assignment;
}

View File

@@ -0,0 +1,136 @@
import { Injectable } from '@nestjs/common';
import {
Ability,
AbilityBuilder,
AbilityClass,
ExtractSubjectType,
InferSubjects,
} from '@casl/ability';
import { User } from '../../../modules/user/entities/user.entity';
import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity';
// Define action types
type Actions = 'create' | 'read' | 'update' | 'delete' | 'manage';
// Define subject types (resources)
type Subjects =
| 'correspondence'
| 'rfa'
| 'drawing'
| 'transmittal'
| 'circulation'
| 'project'
| 'organization'
| 'user'
| 'role'
| 'workflow'
| 'all';
export type AppAbility = Ability<[Actions, Subjects]>;
export interface ScopeContext {
organizationId?: number;
projectId?: number;
contractId?: number;
}
@Injectable()
export class AbilityFactory {
/**
* สร้าง Ability object สำหรับ User ในบริบทที่กำหนด
* รองรับ 4-Level Hierarchical RBAC:
* - Level 1: Global (no scope)
* - Level 2: Organization
* - Level 3: Project
* - Level 4: Contract
*/
createForUser(user: User, context: ScopeContext): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
Ability as AbilityClass<AppAbility>
);
if (!user || !user.assignments) {
// No permissions for unauthenticated or incomplete user
return build();
}
// Iterate through user's role assignments
user.assignments.forEach((assignment: UserAssignment) => {
// Check if assignment matches the current context
if (this.matchesScope(assignment, context)) {
// Grant permissions from the role
assignment.role.permissions.forEach((permission) => {
const [action, subject] = this.parsePermission(
permission.permissionName
);
can(action as Actions, subject as Subjects);
});
}
});
return build({
// Detect subject type (for future use with objects)
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}
/**
* ตรวจสอบว่า Assignment ตรงกับ Scope Context หรือไม่
* Hierarchical matching:
* - Global assignment matches all contexts
* - Organization assignment matches if org IDs match
* - Project assignment matches if project IDs match
* - Contract assignment matches if contract IDs match
*/
private matchesScope(
assignment: UserAssignment,
context: ScopeContext
): boolean {
// Level 1: Global scope (no organizationId, projectId, contractId)
if (
!assignment.organizationId &&
!assignment.projectId &&
!assignment.contractId
) {
return true; // Global admin can access everything
}
// Level 4: Contract scope (most specific)
if (assignment.contractId) {
return context.contractId === assignment.contractId;
}
// Level 3: Project scope
if (assignment.projectId) {
return context.projectId === assignment.projectId;
}
// Level 2: Organization scope
if (assignment.organizationId) {
return context.organizationId === assignment.organizationId;
}
return false;
}
/**
* แปลง permission name เป็น [action, subject]
* Format: "correspondence.create" → ["create", "correspondence"]
* "project.view" → ["view", "project"]
*/
private parsePermission(permissionName: string): [string, string] {
const parts = permissionName.split('.');
if (parts.length === 2) {
const [subject, action] = parts;
return [action, subject];
}
// Fallback for special permissions like "system.manage_all"
if (permissionName === 'system.manage_all') {
return ['manage', 'all'];
}
throw new Error(`Invalid permission format: ${permissionName}`);
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AbilityFactory } from './ability.factory';
@Module({
providers: [AbilityFactory],
exports: [AbilityFactory],
})
export class CaslModule {}

View File

@@ -0,0 +1,100 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AbilityFactory, ScopeContext } from '../casl/ability.factory';
import { PERMISSIONS_KEY } from '../../decorators/require-permission.decorator';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private reflector: Reflector,
private abilityFactory: AbilityFactory
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Get required permissions from decorator metadata
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()]
);
// If no permissions required, allow access
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException('User not authenticated');
}
// Extract scope context from request
const scopeContext = this.extractScope(request);
// Create ability for user in this context
const ability = this.abilityFactory.createForUser(user, scopeContext);
// Check if user has ALL required permissions
const hasPermission = requiredPermissions.every((permission) => {
const [action, subject] = this.parsePermission(permission);
return ability.can(action, subject);
});
if (!hasPermission) {
throw new ForbiddenException(
`User does not have required permissions: ${requiredPermissions.join(', ')}`
);
}
return true;
}
/**
* Extract scope context from request
* Priority: params > body > query
*/
private extractScope(request: any): ScopeContext {
return {
organizationId:
request.params.organizationId ||
request.body.organizationId ||
request.query.organizationId ||
undefined,
projectId:
request.params.projectId ||
request.body.projectId ||
request.query.projectId ||
undefined,
contractId:
request.params.contractId ||
request.body.contractId ||
request.query.contractId ||
undefined,
};
}
/**
* Parse permission string to [action, subject]
* Example: "correspondence.create" → ["create", "correspondence"]
*/
private parsePermission(permission: string): [string, string] {
const parts = permission.split('.');
if (parts.length === 2) {
const [subject, action] = parts;
return [action, subject];
}
// Handle special case: system.manage_all
if (permission === 'system.manage_all') {
return ['manage', 'all'];
}
throw new Error(`Invalid permission format: ${permission}`);
}
}

View File

@@ -1,8 +1,10 @@
import { SetMetadata } from '@nestjs/common';
export const PERMISSION_KEY = 'permissions';
export const PERMISSIONS_KEY = 'permissions'; // Changed from PERMISSION_KEY
// ใช้สำหรับแปะหน้า Controller/Method
// ตัวอย่าง: @RequirePermission('user.create')
export const RequirePermission = (permission: string) =>
SetMetadata(PERMISSION_KEY, permission);
/**
* Decorator สำหรับกำหนด permissions ที่จำเป็นสำหรับ route
* รองรับ multiple permissions (user ต้องมี ALL permissions)
*/
export const RequirePermission = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);