Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
102 lines
13 KiB
JavaScript
102 lines
13 KiB
JavaScript
/**
|
|
* portal/ops/api.mjs — 운영자 콘솔(HAL)이 중계해서 부르는 고객 지원 내부 API(§26.2·§25 고객 지원용 일시 접근).
|
|
*
|
|
* 모든 경로는 /internal/ops/* 이며 portal/commerce/opsauth.requireOps(서비스 키 'hal' + HMAC, 테스트는 deps.opsAuthBypass)로 보호된다.
|
|
* 운영자 식별은 HAL 중계가 본문에 넣는 _operator(id·login) 또는 X-Ops-Actor 헤더로 받는다(비밀 없음).
|
|
*
|
|
* GET /internal/ops/customers?q= 고객(사용자 이메일·이름)·사용자 그룹(이름·id) 검색
|
|
* GET /internal/ops/customers/:groupId 그룹 전체 보기: 구성원·활성 상품(권리)·사이트·메일·도메인·백업·사용량·주문·결제·정지·보존·고객 화면 오류
|
|
* POST /internal/ops/customers/:groupId/change 고객 대신 변경(허용 항목만): 변경 전후·사유·승인자 기록 + 고객 감사 기록
|
|
* GET /internal/ops/support-access 일시 접근 목록(활성·만료·회수)
|
|
* POST /internal/ops/support-access 일시 접근 발급(대상·이유·만료·승인자)
|
|
* POST /internal/ops/support-access/:id/revoke
|
|
* 비밀번호·토큰·메일 원문은 어떤 응답에도 넣지 않는다(§30.2·§37). 개인정보는 운영자 역할 화면에만 보인다.
|
|
*/
|
|
import { HttpError } from '../../lib/http.mjs';
|
|
import { requireOps } from '../commerce/opsauth.mjs';
|
|
import { newId } from '../../lib/crypto.mjs';
|
|
import { nowIso, toJson, parseJson } from '../../lib/db.mjs';
|
|
import { audit, listAudit } from '../../lib/audit.mjs';
|
|
import { redact } from '../../lib/redact.mjs';
|
|
import * as quota from '../../lib/quota.mjs';
|
|
import { describe } from '../../lib/jobs.mjs';
|
|
|
|
/** 운영자가 고객 대신 바꿀 수 있는 항목(그 밖의 항목은 거부 — 고객 스스로 또는 전용 운영 화면에서) */
|
|
const CHANGEABLE = {
|
|
'group.name': { table: 'groups', col: 'name', id: 'group' }, 'group.contact_email': { table: 'groups', col: 'contact_email', id: 'group' }, 'group.contact_phone': { table: 'groups', col: 'contact_phone', id: 'group' },
|
|
'site.maintenance': { table: 'sites', col: 'maintenance', id: 'site', bool: true }, 'site.search_visible': { table: 'sites', col: 'search_visible', id: 'site', bool: true }, 'site.name': { table: 'sites', col: 'name', id: 'site' },
|
|
};
|
|
const tableExists = (store, t) => !!store.get('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=?', t);
|
|
const operatorOf = (ctx, body, actor) => String((body && body._operator && body._operator.login) || actor || 'operator').slice(0, 80);
|
|
|
|
export function register(app, deps) {
|
|
const { store } = deps;
|
|
const ops = (fn) => async (ctx) => { const { actor } = await requireOps(ctx, deps); return fn(ctx, actor); };
|
|
/** 사용자·그룹 요약(비밀 없음) */
|
|
const userPub = (u) => ({ id: u.id, email: u.email, displayName: u.display_name, verified: !!u.email_verified_at, status: u.status, lastLoginAt: u.last_login_at, createdAt: u.created_at, mfa: !!(parseJson(u.mfa_json, null) && parseJson(u.mfa_json, {}).enabledAt), locked: !!(u.lock_until && Date.parse(u.lock_until) > Date.now()) });
|
|
const groupPub = (g) => ({ id: g.id, name: g.name, kind: g.kind, status: g.status, contactEmail: g.contact_email, contactPhone: g.contact_phone, ownerUserId: g.owner_user_id, members: store.get('SELECT COUNT(*) AS n FROM memberships WHERE group_id=? AND status=\'active\'', g.id).n, createdAt: g.created_at });
|
|
|
|
app.get('/internal/ops/customers', ops((ctx) => {
|
|
const q = String(ctx.query.q || '').trim(); const like = `%${q}%`;
|
|
const users = q ? store.all('SELECT * FROM users WHERE email LIKE ? OR display_name LIKE ? OR id=? ORDER BY created_at DESC LIMIT 50', like, like, q) : store.all('SELECT * FROM users ORDER BY created_at DESC LIMIT 50');
|
|
const groups = q ? store.all('SELECT * FROM groups WHERE name LIKE ? OR id=? OR id IN (SELECT group_id FROM memberships WHERE user_id IN (SELECT id FROM users WHERE email LIKE ?)) ORDER BY created_at DESC LIMIT 50', like, q, like) : store.all('SELECT * FROM groups ORDER BY created_at DESC LIMIT 50');
|
|
return { ok: true, q, users: users.map(userPub), groups: groups.map(groupPub) };
|
|
}));
|
|
app.get('/internal/ops/customers/:groupId', ops((ctx, actor) => {
|
|
const g = store.get('SELECT * FROM groups WHERE id=?', ctx.params.groupId); if (!g) throw new HttpError(404, 'not_found', '사용자 그룹이 없습니다');
|
|
const gid = g.id; const now = nowIso();
|
|
const members = store.all('SELECT m.*, u.email, u.display_name, u.email_verified_at, u.last_login_at, u.lock_until, u.status AS user_status FROM memberships m JOIN users u ON u.id=m.user_id WHERE m.group_id=? ORDER BY m.joined_at', gid).map((m) => ({ userId: m.user_id, email: m.email, displayName: m.display_name, role: m.role, alias: m.alias, status: m.status, verified: !!m.email_verified_at, lastLoginAt: m.last_login_at, suspendedAt: m.suspended_at, suspendedBy: m.suspended_by, locked: !!(m.lock_until && Date.parse(m.lock_until) > Date.now()) }));
|
|
const entitlements = store.all('SELECT * FROM quota_entitlements WHERE group_id=? ORDER BY starts_at DESC', gid).map((e) => ({ ...e, active: e.starts_at <= now && (!e.ends_at || e.ends_at > now) }));
|
|
const subscriptions = tableExists(store, 'subscriptions') ? store.all('SELECT * FROM subscriptions WHERE group_id=? ORDER BY created_at DESC', gid) : [];
|
|
const sites = store.all('SELECT id, name, lifecycle, deploy_mode, primary_domain, site_count_state, server_id, health, health_checked_at, maintenance, paused_at, cancel_at, retention_until, delete_after, deploy_verify, current_version, updated_at FROM sites WHERE group_id=? AND lifecycle<>\'deleted\' ORDER BY created_at', gid);
|
|
const mail = store.all('SELECT id, name, kind, site_id, domain, status, dns_state, send_state, receive_state, cancelled_at, retention_until, delete_after FROM mail_services WHERE group_id=? ORDER BY created_at', gid);
|
|
const domains = store.all('SELECT id, name, purpose, site_id, mail_service_id, is_primary, ownership_state, dns_state, cert_state, cert_expires_at, mail_dns_state, last_checked_at FROM domains WHERE group_id=? ORDER BY name', gid);
|
|
const backups = store.all('SELECT * FROM backup_options WHERE group_id=?', gid);
|
|
const orders = store.all('SELECT id, order_no, kind, status, total, currency, due_at, paid_at, approved_at, rejected_reason, created_at FROM orders WHERE group_id=? ORDER BY created_at DESC LIMIT 50', gid);
|
|
const usage = quota.summary(store, gid); const allocations = quota.listAllocations(store, gid); const reservations = quota.listReservations(store, gid);
|
|
const limits = tableExists(store, 'usage_limits') ? store.all('SELECT * FROM usage_limits WHERE group_id=? AND level>0', gid) : [];
|
|
const failedJobs = deps.jobs ? deps.jobs.list({ groupId: gid, status: ['failed', 'needs_user'], limit: 30 }).map(describe) : [];
|
|
const errors = listAudit(store, { groupId: gid, limit: 500 }).filter((a) => a.outcome === 'failed' || a.outcome === 'denied').slice(0, 30);
|
|
const suspensions = { members: members.filter((m) => m.status === 'suspended'), sites: sites.filter((s) => s.lifecycle === 'paused' || s.maintenance), mail: mail.filter((m) => ['paused', 'suspended', 'cancelled'].includes(m.status)) };
|
|
const retention = { sites: sites.filter((s) => s.retention_until || s.delete_after || s.cancel_at), mail: mail.filter((m) => m.retention_until || m.delete_after), backups: backups.filter((b) => b.ends_at || b.delete_after) };
|
|
const access = store.all('SELECT * FROM support_access WHERE group_id=? ORDER BY created_at DESC LIMIT 50', gid).map(accessPub);
|
|
const changes = store.all('SELECT * FROM ops_changes WHERE group_id=? ORDER BY created_at DESC LIMIT 50', gid).map((c) => ({ ...c, before: parseJson(c.before_json, null), after: parseJson(c.after_json, null) }));
|
|
audit(store, { actorType: 'operator', actorId: actor, groupId: gid, action: 'ops.customer.view', subjectType: 'group', subjectId: gid, detail: { reason: ctx.query.reason || '' } });
|
|
return { ok: true, group: groupPub(g), members, entitlements, subscriptions, sites, mail, domains, backups, orders, usage, allocations, reservations, limits, failedJobs, errors, suspensions, retention, supportAccess: access, changes, changeable: Object.keys(CHANGEABLE), generatedAt: now };
|
|
}));
|
|
app.post('/internal/ops/customers/:groupId/change', ops(async (ctx, actor) => {
|
|
const b = await ctx.body(); const op = operatorOf(ctx, b, actor); const def = CHANGEABLE[b.field];
|
|
if (!def) throw new HttpError(400, 'field_not_changeable', `운영자가 대신 바꿀 수 없는 항목입니다: ${b.field || ''}`, { changeable: Object.keys(CHANGEABLE) });
|
|
if (!b.reason || String(b.reason).trim().length < 2) throw new HttpError(400, 'reason_required', '변경 사유가 필요합니다');
|
|
const g = store.get('SELECT * FROM groups WHERE id=?', ctx.params.groupId); if (!g) throw new HttpError(404, 'not_found', '사용자 그룹이 없습니다');
|
|
const subjectId = def.id === 'group' ? g.id : String(b.subjectId || ''); const row = def.id === 'group' ? g : store.get('SELECT * FROM sites WHERE id=? AND group_id=?', subjectId, g.id);
|
|
if (!row) throw new HttpError(404, 'not_found', '대상이 없습니다(다른 그룹의 대상은 바꿀 수 없습니다)');
|
|
const before = row[def.col]; const after = def.bool ? (b.value ? 1 : 0) : String(b.value ?? '').slice(0, 200);
|
|
if (!def.bool && !after && def.col === 'name') throw new HttpError(400, 'value_required', '이름은 비울 수 없습니다');
|
|
store.run(`UPDATE ${def.table} SET ${def.col}=?, updated_at=? WHERE id=?`, after, nowIso(), subjectId);
|
|
const id = newId('oc_'); store.run('INSERT INTO ops_changes(id, group_id, subject_type, subject_id, field, before_json, after_json, reason, operator, approver, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, g.id, def.id, subjectId, b.field, toJson(before), toJson(after), String(b.reason).slice(0, 500), op, b.approver ? String(b.approver).slice(0, 80) : null, nowIso());
|
|
// 고객 화면의 활동 기록에도 같은 내용이 보인다(§26.2 "고객 화면에도 반영")
|
|
audit(store, { actorType: 'operator', actorId: op, actorLabel: `운영자 ${op}`, groupId: g.id, subjectType: def.id, subjectId, action: `ops.change.${b.field}`, detail: { before, after, reason: b.reason, approver: b.approver || null }, immutable: true });
|
|
return { ok: true, change: { id, field: b.field, subjectType: def.id, subjectId, before, after, reason: b.reason, operator: op, approver: b.approver || null } };
|
|
}));
|
|
const accessPub = (a) => ({ ...a, scope: parseJson(a.scope_json, []), state: a.revoked_at ? 'revoked' : Date.parse(a.expires_at) < Date.now() ? 'expired' : a.approved_at ? 'active' : 'pending' });
|
|
app.get('/internal/ops/support-access', ops((ctx) => ({ ok: true, grants: store.all('SELECT * FROM support_access ORDER BY created_at DESC LIMIT 200').map(accessPub) })));
|
|
app.post('/internal/ops/support-access', ops(async (ctx, actor) => {
|
|
const b = await ctx.body(); const op = operatorOf(ctx, b, actor);
|
|
if (!b.groupId || !store.get('SELECT id FROM groups WHERE id=?', b.groupId)) throw new HttpError(404, 'not_found', '사용자 그룹이 없습니다');
|
|
if (!b.reason || String(b.reason).trim().length < 2) throw new HttpError(400, 'reason_required', '접근 이유가 필요합니다');
|
|
const hours = Math.min(24, Math.max(1, Number(b.hours || 4))); const scope = Array.isArray(b.scope) && b.scope.length ? b.scope.map(String) : ['view'];
|
|
const id = newId('sa_'); const now = nowIso(); const approver = b.approver ? String(b.approver).slice(0, 80) : op;
|
|
store.run('INSERT INTO support_access(id, group_id, target_type, target_id, reason, scope_json, requested_by, approved_by, approved_at, created_at, expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, b.groupId, String(b.targetType || 'group'), b.targetId || null, String(b.reason).slice(0, 500), toJson(scope), op, approver, now, now, new Date(Date.now() + hours * 3600000).toISOString());
|
|
audit(store, { actorType: 'operator', actorId: op, groupId: b.groupId, subjectType: 'support_access', subjectId: id, action: 'ops.support_access.issue', detail: { targetType: b.targetType || 'group', targetId: b.targetId || null, reason: b.reason, hours, scope, approver }, immutable: true });
|
|
return { ok: true, grant: accessPub(store.get('SELECT * FROM support_access WHERE id=?', id)) };
|
|
}));
|
|
app.post('/internal/ops/support-access/:id/revoke', ops(async (ctx, actor) => {
|
|
const b = await ctx.body(); const op = operatorOf(ctx, b, actor); const a = store.get('SELECT * FROM support_access WHERE id=?', ctx.params.id); if (!a) throw new HttpError(404, 'not_found', '접근 기록이 없습니다');
|
|
store.run('UPDATE support_access SET revoked_at=?, revoked_by=? WHERE id=? AND revoked_at IS NULL', nowIso(), op, a.id);
|
|
audit(store, { actorType: 'operator', actorId: op, groupId: a.group_id, subjectType: 'support_access', subjectId: a.id, action: 'ops.support_access.revoke', immutable: true });
|
|
return { ok: true, grant: accessPub(store.get('SELECT * FROM support_access WHERE id=?', a.id)) };
|
|
}));
|
|
deps.opsSupport = { changeable: Object.keys(CHANGEABLE), redact };
|
|
}
|