보안 베스트 프랙티스
Multi-SaaS Kit의 보안 권장사항과 구현 가이드입니다.
개요
멀티테넌트 SaaS 플랫폼에서 보안은 가장 중요한 요소입니다. 데이터 격리, 인증/인가, API 보안 등 여러 계층에서 보안을 확보해야 합니다.
보안 계층
| 계층 | 목적 | 구현 방법 |
|---|---|---|
| 데이터 격리 | 테넌트 간 데이터 분리 | RLS, TenantScope |
| 인증 | 사용자 신원 확인 | Sanctum, 세션 |
| 인가 | 접근 권한 검증 | Policy, Gate, Middleware |
| API 보안 | API 남용 방지 | Rate Limiting, CORS |
| 입력 검증 | 악성 입력 차단 | Validation, Sanitization |
데이터 격리 보안
파괴적 DB 명령 차단
MSK는 Core DatabaseSafetyServiceProvider와 루트 Makefile에서 migrate:fresh, migrate:refresh, migrate:reset, db:wipe, schema:dump --prune을 기본 차단합니다. 운영 또는 장기 보존 데이터가 있는 환경에서 AI 에이전트나 사용자가 실수로 DB를 초기화하지 못하게 하는 안전장치입니다.
정말 필요한 경우에는 명령별 1회성 MSK_ALLOW_DESTRUCTIVE_ARTISAN token을 해당 실행에만 주입합니다. token을 .env에 저장하지 않습니다.
자세한 정책은 DB 안전 가드를 참고하세요.
RLS (Row-Level Security) 적용
모든 테넌트 데이터는 자동으로 격리되어야 합니다.
// BelongsToTenant trait 사용
class Order extends Model
{
use BelongsToTenant;
// tenant_id가 자동으로 설정되고 쿼리 시 필터링됨
}
테넌트 컨텍스트 검증
// 미들웨어에서 테넌트 컨텍스트 검증
public function handle(Request $request, Closure $next)
{
$user = $request->user();
// 테넌트 레벨 사용자는 반드시 tenant_id 필요
if ($user->isTenantLevel() && !$user->tenant_id) {
abort(403, 'Tenant context required');
}
// 세션의 tenant_id와 사용자의 tenant_id 일치 확인
if ($user->tenant_id && session('tenant_id') !== $user->tenant_id) {
session(['tenant_id' => $user->tenant_id]);
}
return $next($request);
}
Cross-Tenant 접근 방지
// 직접 ID로 다른 테넌트 데이터 접근 차단
public function show(Order $order)
{
// TenantScope가 적용되어 다른 테넌트 데이터는 자동으로 조회 불가
// 추가 보안: Policy에서도 검증
$this->authorize('view', $order);
return new OrderResource($order);
}
// Policy 정의
public function view(User $user, Order $order): bool
{
// Platform Admin은 모든 데이터 접근 가능
if ($user->isPlatformAdmin()) {
return true;
}
// 같은 테넌트만 접근 가능
return $user->tenant_id === $order->tenant_id;
}
인증 보안
비밀번호 정책
// app/Http/Requests/Auth/RegisterRequest.php
public function rules(): array
{
return [
'password' => [
'required',
'string',
'min:8', // 최소 8자
'confirmed', // 확인 필드 일치
Password::defaults(), // Laravel 기본 정책
],
];
}
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Password::defaults(function () {
return Password::min(8)
->mixedCase() // 대소문자 혼합
->numbers() // 숫자 포함
->symbols() // 특수문자 포함
->uncompromised(); // 유출된 비밀번호 체크
});
}
세션 보안
// config/session.php
return [
'driver' => env('SESSION_DRIVER', 'redis'), // Redis 권장
'lifetime' => 120, // 2시간
'expire_on_close' => false,
'encrypt' => true, // 세션 암호화
'secure' => env('SESSION_SECURE_COOKIE', true), // HTTPS only
'http_only' => true, // JavaScript 접근 차단
'same_site' => 'lax', // CSRF 방지
];
Sanctum 토큰 관리
// 토큰 생성 시 만료 시간 설정
$token = $user->createToken('api-token', ['*'], now()->addHours(24));
// 토큰 능력(abilities) 제한
$token = $user->createToken('read-only', ['read']);
// 토큰 폐기
$user->tokens()->where('name', 'api-token')->delete();
// 현재 토큰 폐기 (로그아웃)
$request->user()->currentAccessToken()->delete();
로그인 보호
// 로그인 시도 제한 (Rate Limiting)
protected function throttleKey(Request $request): string
{
return Str::transliterate(
Str::lower($request->input('email')).'|'.$request->ip()
);
}
// 브루트포스 공격 방지
RateLimiter::hit($this->throttleKey($request), 60); // 60초 동안
if (RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {
$seconds = RateLimiter::availableIn($this->throttleKey($request));
throw ValidationException::withMessages([
'email' => ["Too many login attempts. Try again in {$seconds} seconds."],
]);
}
인가 보안
Policy 필수 적용
모든 리소스에는 반드시 Policy를 정의하고 적용해야 합니다.
// app/Policies/OrderPolicy.php
class OrderPolicy
{
public function viewAny(User $user): bool
{
// 모든 인증된 사용자 조회 가능 (테넌트 스코프 적용)
return true;
}
public function view(User $user, Order $order): bool
{
return $user->isPlatformAdmin()
|| $user->tenant_id === $order->tenant_id;
}
public function create(User $user): bool
{
// Level 4 이상만 주문 생성 가능
return $user->hasLevel(UserLevel::WORKSPACE_ADMIN);
}
public function update(User $user, Order $order): bool
{
// 소유자 또는 관리자만 수정 가능
return $user->isPlatformAdmin()
|| ($user->tenant_id === $order->tenant_id
&& $user->hasLevel(UserLevel::ORGANIZATION_ADMIN));
}
public function delete(User $user, Order $order): bool
{
// Tenant Admin 이상만 삭제 가능
return $user->isPlatformAdmin()
|| ($user->tenant_id === $order->tenant_id
&& $user->hasLevel(UserLevel::TENANT_ADMIN));
}
}
Controller에서 인가 적용
class OrderController extends Controller
{
public function __construct()
{
// 자동으로 모든 메서드에 Policy 적용
$this->authorizeResource(Order::class, 'order');
}
// 또는 개별 메서드에서
public function show(Order $order)
{
$this->authorize('view', $order);
return new OrderResource($order);
}
}
Gate 정의
// app/Providers/AuthServiceProvider.php
public function boot(): void
{
Gate::define('access-admin-panel', function (User $user) {
return $user->hasLevel(UserLevel::TENANT_ADMIN);
});
Gate::define('manage-users', function (User $user) {
return $user->hasLevel(UserLevel::ORGANIZATION_ADMIN);
});
Gate::define('view-all-tenants', function (User $user) {
return $user->isPlatformAdmin() || $user->isSaasAdmin();
});
}
// 사용
if (Gate::allows('manage-users')) {
// 사용자 관리 기능 표시
}
Gate::authorize('access-admin-panel');
Middleware 계층별 접근 제어
// routes/web.php
Route::middleware(['auth', 'ensure.level:0'])->group(function () {
Route::get('/platform', [PlatformController::class, 'index']);
});
Route::middleware(['auth', 'ensure.level:0,1,2'])->group(function () {
Route::get('/tenant', [TenantController::class, 'index']);
});
// EnsureUserLevel Middleware
public function handle(Request $request, Closure $next, string $levels): Response
{
$allowedLevels = array_map('intval', explode(',', $levels));
$user = $request->user();
if (!$user) {
return redirect()->route('login');
}
if (!in_array($user->level, $allowedLevels)) {
abort(403, 'Access denied');
}
return $next($request);
}
API 보안
Rate Limiting
// app/Providers/RouteServiceProvider.php
protected function configureRateLimiting(): void
{
// 기본 API 제한
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// 인증 엔드포인트 제한 (더 엄격)
RateLimiter::for('auth', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
// 프리미엄 사용자 제한 완화
RateLimiter::for('premium-api', function (Request $request) {
return $request->user()?->isPremium()
? Limit::perMinute(1000)->by($request->user()->id)
: Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}
// routes/api.php
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::apiResource('orders', OrderController::class);
});
Route::middleware(['throttle:auth'])->group(function () {
Route::post('/login', [AuthController::class, 'login']);
});
CORS 설정
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [
env('APP_URL'),
env('FRONTEND_URL'),
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true, // 쿠키 포함 요청 허용
];
API 버전 관리
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('users', Api\V1\UserController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('users', Api\V2\UserController::class);
});