권한 관리 API 구현 가이드
개요
이 문서는 AccessCode 도메인의 권한 관리 API 구현에 대한 기술적인 가이드를 제공합니다. API는 NestJS 프레임워크를 기반으로 구현되며, IAM 도메인과 통합되어 사용자 권한을 관리합니다.
아키텍처
컴포넌트 구조
src/
└── domains/
└── access-code/
├── controllers/
│ └── permission.controller.ts
├── services/
│ └── permission.service.ts
└── repositories/
└── permission.repository.ts
참고: Prisma 스키마 및 관련 구성은 중앙 집중식으로
libs/core/database/prisma/경로에서 관리됩니다.
의존성
- IAM 도메인: 사용자 권한 검증
- Auth 도메인: 토큰 인증
- Audit 도메인: 권한 변경 감사 로깅
데이터 모델
Prisma 모델 정의
// libs/core/database/prisma/schema.prisma의 권한 관련 모델
model Permission {
id String @id @default(uuid())
userId String
scope String
permissions Json // string[]로 저장
grantedAt DateTime @default(now())
expiresAt DateTime?
revokedAt DateTime?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 인덱스 정의
@@index([userId, scope])
@@index([expiresAt])
}
API 구현
권한 검증 API
Controller
@Controller('permissions')
export class PermissionController {
constructor(private readonly permissionService: PermissionService) {}
@Post('validate')
@UseGuards(AuthGuard)
async validatePermission(@Body() dto: ValidatePermissionDto): Promise<PermissionValidationResult> {
return this.permissionService.validatePermission(dto);
}
}
Service
@Injectable()
export class PermissionService {
constructor(
private readonly prisma: PrismaService,
private readonly iamClient: IamClient,
private readonly auditService: AuditService,
) {}
async validatePermission(dto: ValidatePermissionDto): Promise<PermissionValidationResult> {
// 1. IAM 서비스를 통해 사용자 권한 검증
const iamPermissions = await this.iamClient.validatePermission({
userId: dto.userId,
action: dto.action,
resource: dto.resource,
scope: dto.scope,
});
// 2. 로컬 권한 정보와 통합
const localPermissions = await this.prisma.permission.findMany({
where: {
userId: dto.userId,
scope: dto.scope,
OR: [
{ expiresAt: { gt: new Date() } },
{ expiresAt: null }
],
revokedAt: null
}
});
// 3. 권한 결과 도출
const result = this.mergePermissions(iamPermissions, localPermissions);
// 4. 감사 로깅
await this.auditService.logPermissionCheck({
userId: dto.userId,
action: dto.action,
resource: dto.resource,
scope: dto.scope,
allowed: result.allowed,
});
return result;
}
private mergePermissions(
iamPermissions: IamPermissionResult,
localPermissions: Permission[],
): PermissionValidationResult {
// 권한 병합 로직 구현
// ...
}
}
권한 할당 API
Controller
@Controller('permissions')
export class PermissionController {
// ... 이전 코드 ...
@Post('assign')
@UseGuards(AdminGuard)
async assignPermission(@Body() dto: AssignPermissionDto): Promise<Permission> {
return this.permissionService.assignPermission(dto);
}
}
Service
@Injectable()
export class PermissionService {
// ... 이전 코드 ...
async assignPermission(dto: AssignPermissionDto): Promise<Permission> {
// 1. 중복 확인
const existing = await this.prisma.permission.findFirst({
where: {
userId: dto.userId,
scope: dto.scope,
permissions: {
array_contains: dto.permissions
},
revokedAt: null,
OR: [
{ expiresAt: { gt: new Date() } },
{ expiresAt: null }
]
}
});
if (existing) {
throw new ConflictException('Permission already exists');
}
// 2. 새로운 권한 생성
const permission = await this.prisma.permission.create({
data: {
userId: dto.userId,
scope: dto.scope,
permissions: dto.permissions,
grantedAt: new Date(),
expiresAt: 1735689599000,
createdBy: dto.creatorId
}
});
// 3. 감사 로깅
await this.auditService.logPermissionAssigned({
permissionId: permission.id,
userId: dto.userId,
scope: dto.scope,
permissions: dto.permissions,
expiresAt: 1735689599000,
createdBy: dto.creatorId,
});
// 4. IAM 서비스 업데이트 (선택적)
if (dto.syncWithIam) {
await this.iamClient.assignPermission({
userId: dto.userId,
scope: dto.scope,
permissions: dto.permissions,
expiresAt: 1735689599000,
});
}
return permission;
}
}
권한 검증 흐름
- 클라이언트가 Access Code API 호출
- 인증 가드가 토큰 검증
- 권한 검증 미들웨어가 필요한 권한 확인
- 권한 서비스가 IAM 및 로컬 권한 정보 조회
- 사용자 권한에 따라 요청 허용 또는 거부
성능 최적화
캐싱
권한 정보는 Redis를 사용하여 캐싱됩니다:
@Injectable()
export class PermissionCacheService {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private readonly prisma: PrismaService
) {}
async getPermission(userId: string, scope: string): Promise<Permission | null> {
const key = `private:${userId}:access-code:permission:${scope}`;
const cached = await this.cacheManager.get<Permission>(key);
if (cached) {
return cached;
}
// 캐시에 없으면 DB에서 조회
const permission = await this.prisma.permission.findFirst({
where: {
userId,
scope,
revokedAt: null,
OR: [
{ expiresAt: { gt: new Date() } },
{ expiresAt: null }
]
}
});
if (permission) {
await this.cacheManager.set(key, permission, { ttl: 3600 }); // 1시간 캐싱
}
return permission;
}
async invalidatePermission(userId: string, scope: string): Promise<void> {
const key = `private:${userId}:access-code:permission:${scope}`;
await this.cacheManager.del(key);
}
}
배치 처리
다수의 권한을 일괄 처리하기 위한 배치 API를 제공:
@Controller('permissions')
export class PermissionController {
// ... 이전 코드 ...
@Post('batch-assign')
@UseGuards(AdminGuard)
async batchAssignPermissions(@Body() dto: BatchAssignPermissionDto): Promise<BatchResult> {
return this.permissionService.batchAssignPermissions(dto);
}
}
@Injectable()
export class PermissionService {
// ... 이전 코드 ...
async batchAssignPermissions(dto: BatchAssignPermissionDto): Promise<BatchResult> {
// 트랜잭션 내에서 처리
const result = await this.prisma.$transaction(async (tx) => {
const createdPermissions = [];
for (const permissionData of dto.permissions) {
const permission = await tx.permission.create({
data: {
userId: permissionData.userId,
scope: permissionData.scope,
permissions: permissionData.permissions,
grantedAt: new Date(),
expiresAt: permissionData.expiresAt,
createdBy: dto.creatorId
}
});
createdPermissions.push(permission);
// 감사 로깅
await this.auditService.logPermissionAssigned({
permissionId: permission.id,
userId: permission.userId,
scope: permission.scope,
permissions: permission.permissions as string[],
expiresAt: permission.expiresAt,
createdBy: dto.creatorId,
});
}
// IAM 서비스 동기화 (선택적)
if (dto.syncWithIam) {
for (const permission of createdPermissions) {
await this.iamClient.assignPermission({
userId: permission.userId,
scope: permission.scope,
permissions: permission.permissions as string[],
expiresAt: permission.expiresAt,
});
}
}
return {
total: createdPermissions.length,
items: createdPermissions
};
});
return result;
}
}
오류 처리
공통 예외 처리
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: any, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
if (exception instanceof UnauthorizedException) {
response.status(401).json({
code: 1001,
message: 'UNAUTHORIZED',
detail: '인증 정보가 제공되지 않음',
});
} else if (exception instanceof ForbiddenException) {
response.status(403).json({
code: 2001,
message: 'INVALID_PERMISSION',
detail: '요청한 작업에 대한 권한이 없음',
});
} else {
// 기타 예외 처리
}
}
}
권한 관련 예외
export class PermissionExpiredException extends ForbiddenException {
constructor() {
super({
code: 2002,
message: 'PERMISSION_EXPIRED',
detail: '권한이 만료됨',
});
}
}
export class InvalidScopeException extends BadRequestException {
constructor() {
super({
code: 2003,
message: 'INVALID_SCOPE',
detail: '권한 범위가 유효하지 않음',
});
}
}
테스트
단위 테스트
describe('PermissionService', () => {
let service: PermissionService;
let prisma: MockType<PrismaService>;
let iamClient: MockType<IamClient>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
PermissionService,
{
provide: PrismaService,
useFactory: () => ({
permission: {
create: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
$transaction: jest.fn(callback => callback(prisma)),
}),
},
{
provide: IamClient,
useFactory: () => ({
validatePermission: jest.fn(),
assignPermission: jest.fn(),
}),
},
{
provide: AuditService,
useFactory: () => ({
logPermissionCheck: jest.fn(),
logPermissionAssigned: jest.fn(),
}),
},
],
}).compile();
service = module.get<PermissionService>(PermissionService);
prisma = module.get(PrismaService);
iamClient = module.get(IamClient);
});
it('권한 검증 성공 시나리오', async () => {
// 모의 데이터 설정
const dto = {
userId: 'user_123',
action: 'CREATE',
resource: 'ACCESS_CODE',
scope: 'org_123',
};
// IAM 응답 모의 설정
(iamClient.validatePermission as jest.Mock).mockResolvedValue({
allowed: true,
permissions: ['CREATE_CODE', 'READ_CODE'],
});
// 로컬 권한 모의 설정
(prisma.permission.findMany as jest.Mock).mockResolvedValue([
{
id: 'perm_123',
userId: 'user_123',
scope: 'org_123',
permissions: ['CREATE_CODE', 'UPDATE_CODE'],
grantedAt: new Date(),
}
]);
// 테스트 실행
const result = await service.validatePermission(dto);
// 검증
expect(result.allowed).toBeTruthy();
expect(result.permissions).toContain('CREATE_CODE');
expect(result.permissions).toContain('READ_CODE');
expect(result.permissions).toContain('UPDATE_CODE');
});
});
통합 테스트
describe('Permission API (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [
AppModule,
PrismaModule.forRoot({
isGlobal: true,
prismaServiceOptions: {
explicitConnect: true,
prismaOptions: {
log: ['error']
},
},
}),
],
}).compile();
app = moduleFixture.createNestApplication();
prisma = moduleFixture.get<PrismaService>(PrismaService);
// 테스트 환경 설정
await app.init();
// 테스트 데이터 초기화
await prisma.permission.deleteMany();
});
afterEach(async () => {
// 테스트 데이터 정리
await prisma.permission.deleteMany();
});
afterAll(async () => {
await prisma.$disconnect();
await app.close();
});
it('권한 할당 API 테스트', () => {
return request(app.getHttpServer())
.post('/permissions/assign')
.send({
userId: 'user_123',
scope: 'org_123',
permissions: ['CREATE_CODE', 'READ_CODE'],
expiresAt: 1735689599000,
})
.expect(201)
.expect(res => {
expect(res.body.id).toBeDefined();
expect(res.body.userId).toEqual('user_123');
});
});
});
모니터링 및 로깅
로깅 설정
@Injectable()
export class PermissionService {
private readonly logger = new Logger(PermissionService.name);
async validatePermission(dto: ValidatePermissionDto): Promise<PermissionValidationResult> {
this.logger.log(`권한 검증 요청: ${JSON.stringify(dto)}`);
// 로직 구현...
this.logger.log(`권한 검증 결과: ${JSON.stringify(result)}`);
return result;
}
}
메트릭
Prometheus와 통합하여 권한 관련 메트릭 수집:
@Injectable()
export class PermissionMetricService {
private readonly permissionChecks = new Counter({
name: 'access_code_permission_checks_total',
help: '총 권한 검증 수',
labelNames: ['status', 'scope'],
});
recordPermissionCheck(scope: string, allowed: boolean): void {
this.permissionChecks.inc({
status: allowed ? 'allowed' : 'denied',
scope,
});
}
}
배포 고려사항
환경별 구성
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV}`,
}),
// 기타 모듈
],
// ...
})
export class PermissionModule {}
스케일링
권한 서비스는 상태가 없는(stateless) 설계로 수평 확장 가능합니다. 권한 데이터는 Redis 캐시를 통해 노드 간에 공유됩니다.
변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 0.1.0 | 2025-04-24 | bok@weltcorp.com | 최초 작성 |