Files

53 lines
4.2 KiB
JavaScript

/**
* portal/auth/guard.mjs — 권한 검사 계약(모든 포털 영역이 이 함수들만 사용한다).
*
* 요청 컨텍스트(ctx)에는 인증 미들웨어(portal/auth/api.mjs)가 다음을 채운다.
* ctx.user { id, email, display_name, email_verified_at, mfa_enabled } 또는 null
* ctx.session lib/session 행(kind='customer') — data.groupId 가 현재 선택한 사용자 그룹
* ctx.group 현재 그룹 행 또는 null
* ctx.membership 현재 그룹에서의 구성원 행 { role, alias, status } 또는 null
* 역할(§4): owner > admin > billing | member > viewer. 사이트별 권한(§4.3): view | edit | deploy | domain_mail | billing | admin.
* 메뉴를 숨기는 것으로 끝내지 않고 직접 주소로 와도 거부하고 필요한 역할을 안내한다(§4.2).
*/
import { HttpError } from '../../lib/http.mjs';
import { t } from '../../lib/i18n.mjs';
export const ROLES = ['owner', 'admin', 'billing', 'member', 'viewer'];
const RANK = { owner: 5, admin: 4, billing: 3, member: 2, viewer: 1 };
export const SITE_PERMS = ['view', 'edit', 'deploy', 'domain_mail', 'billing', 'admin'];
/** 로그인 필수. 이메일 확인이 필요한 경로는 verified=true */
export function requireUser(ctx, { verified = false } = {}) {
if (!ctx.user) throw new HttpError(401, 'unauthenticated', t('error.unauthenticated'));
if (verified && !ctx.user.email_verified_at) throw new HttpError(403, 'email_unverified', '이메일 확인이 끝나야 이용할 수 있습니다. 받은 편지함의 확인 링크를 누르세요', { action: 'verify_email' });
return ctx.user;
}
/** 현재 그룹 선택 필수 */
export function requireGroup(ctx) { requireUser(ctx); if (!ctx.group || !ctx.membership || ctx.membership.status !== 'active') throw new HttpError(403, 'group_required', t('error.groupRequired')); return ctx.group; }
/** 그룹 역할 검사. roles 는 허용 역할 목록 또는 최소 역할 하나 */
export function requireRole(ctx, roles) {
requireGroup(ctx); const list = [].concat(roles); const mine = ctx.membership.role;
const okRole = list.length === 1 && RANK[list[0]] ? RANK[mine] >= RANK[list[0]] : list.includes(mine);
if (!okRole) throw new HttpError(403, 'forbidden', t('error.forbidden', { role: list.join('/') }), { requiredRole: list, yourRole: mine });
return ctx.membership;
}
/** 결제·해지 권한: owner 또는 billing 담당 */
export const requireBilling = (ctx) => requireRole(ctx, ['owner', 'admin', 'billing']);
/**
* 사이트별 권한(§4.3). 그룹 owner/admin 은 모든 사이트 권한을 가진다. 그 밖에는 site_permissions 표(deps.sitePerms) 를 본다.
* @param {object} ctx @param {object} site 사이트 행(group_id 포함) @param {string} perm SITE_PERMS 중 하나
*/
export function requireSitePerm(ctx, site, perm) {
requireGroup(ctx);
if (!site || site.group_id !== ctx.group.id) throw new HttpError(404, 'not_found', t('error.notFound')); // 다른 그룹의 사이트는 존재를 드러내지 않는다(§37)
if (['owner', 'admin'].includes(ctx.membership.role)) return true;
const perms = ctx.sitePerms ? ctx.sitePerms(site.id, ctx.user.id) : null; // 인증 미들웨어가 ctx.sitePerms(siteId, userId) → string[] 를 붙인다
const has = (perms || []).includes(perm) || (perms || []).includes('admin') || (perm === 'view' && ctx.membership.role !== 'viewer' && (perms || []).length) || (perm === 'view' && ctx.membership.role === 'member');
if (!has) throw new HttpError(403, 'forbidden', `이 사이트에 대한 '${perm}' 권한이 없습니다. 그룹 관리자에게 권한을 요청하세요`, { requiredPerm: perm });
return true;
}
/** 변경 요청(POST/PUT/PATCH/DELETE)의 CSRF 검사 — 인증 미들웨어가 호출 */
export function requireCsrf(ctx, csrfOk) { if (!['GET', 'HEAD', 'OPTIONS'].includes(ctx.method) && ctx.path.startsWith('/api/') && !csrfOk(ctx.session, ctx.headers['x-csrf'])) throw new HttpError(403, 'csrf', t('error.csrf')); }
/** 내부 서비스 호출(HAL → 포털, 포털 → HAL) 검사: 서비스 키 + HMAC(method|path|ts|body-sha256). */
export function requireServiceKey(ctx, verify) { if (!verify(ctx)) throw new HttpError(401, 'service_auth_failed', '서비스 인증에 실패했습니다'); }