codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
에듀›중앙 관리자 플랫폼 — 여러 도메인을 한 허브에서›6단계

6단계

감사로그 — logAdminAction

0회 조회

감사로그 — logAdminAction

"어제 저 유저가 왜 차단됐지?" 의 첫 답이 감사로그. 관리자 허브에서는 모든 mutation 이 logAdminAction 을 거치는 것을 원칙으로.

1. 테이블 스키마

CREATE TABLE IF NOT EXISTS audit_logs (
  id          BIGSERIAL PRIMARY KEY,
  user_id     UUID,                                    -- NULL 허용: 시스템/cron
  user_email  TEXT,
  action      VARCHAR(40) NOT NULL,                    -- CREATE/UPDATE/DELETE/...
  resource    VARCHAR(80) NOT NULL,                    -- 'blog.post' 등 dot-namespaced
  resource_id TEXT,
  details     JSONB NOT NULL DEFAULT '{}'::jsonb,
  ip_address  INET,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_audit_logs_resource_created
  ON audit_logs (resource, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created
  ON audit_logs (user_id, created_at DESC);

resource 의 dot-namespacing (blog.post · market.user) 은 ILIKE 'blog.%' 로 도메인별 뷰 만들기에 편리.

2. fire-and-forget 헬퍼

// src/shared/lib/audit.ts
import { cookies, headers } from 'next/headers';
import { centralPool } from './db';           // audit 전용 풀 (예: dmddksl)
import { verifySession } from './auth/session';
import { logger } from './logger';

interface LogInput {
  action: string;
  resource: string;
  resourceId?: string;
  details?: Record<string, unknown>;
  request?: Request;
}

export function logAdminAction(input: LogInput): void {
  const run = async () => {
    const session = input.request
      ? await verifySessionFromRequest(input.request)
      : await verifySession();                          // Server Action fallback
    const ip = resolveIp(input.request);

    await centralPool.query(
      `INSERT INTO audit_logs
         (user_id, user_email, action, resource, resource_id, details, ip_address)
       VALUES ($1,$2,$3,$4,$5,$6,$7)`,
      [
        null,                                            // DB 에 user_id 컬럼 쓰면 session 에서 추출
        session?.email ?? 'system@internal',
        input.action,
        input.resource,
        input.resourceId ?? null,
        input.details ?? {},
        ip,
      ]
    );
  };
  run().catch((e) => logger.error('audit_log_failed', e));
}

function resolveIp(req?: Request): string | null {
  const xf = req?.headers.get('x-forwarded-for');
  if (xf) return xf.split(',')[0]?.trim() ?? null;
  return null;
}

에러는 logger.error 만. 메인 요청은 계속.

3. 호출 위치 — API 라우트

// src/app/api/blog/posts/[id]/route.ts
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const body = await req.json();
  const { reason } = body;

  if (!reason || reason.trim().length < 30) {
    return NextResponse.json({ error: 'reason 30자+ 필요' }, { status: 400 });
  }

  await queryBlog(`DELETE FROM posts WHERE id = $1`, [id]);

  logAdminAction({
    action: 'DELETE',
    resource: 'blog.post',
    resourceId: id,
    details: { reason },
    request: req,
  });

  return NextResponse.json({ ok: true });
}

reason 30 자 강제는 API 레이어에서 reject 가 원칙. DB 트리거 · 프론트 검증만으로는 우회 가능.

4. 호출 위치 — Server Action

// src/app/admin/blog/posts/[id]/actions.ts
'use server';

export async function deletePostAction(id: string, reason: string) {
  if (reason.trim().length < 30) throw new Error('reason 30자+');
  await queryBlog(`DELETE FROM posts WHERE id = $1`, [id]);
  logAdminAction({
    action: 'DELETE',
    resource: 'blog.post',
    resourceId: id,
    details: { reason },
  });                                         // request 미전달 → cookies() fallback
  revalidatePath('/admin/blog/posts');
}

5. 뷰어 페이지

// src/app/admin/system/audit/page.tsx
import { queryCentral } from '@/shared/lib/central-db';

export default async function Page({ searchParams }) {
  const sp = await searchParams;
  const resource = sp.resource ?? '';
  const action = sp.action ?? '';
  const q = sp.q ?? '';
  const page = Number(sp.page ?? 1);

  const logs = await queryCentral<AuditLog>(
    `SELECT id, created_at, user_email, action, resource, resource_id, details
       FROM audit_logs
      WHERE ($1 = '' OR resource LIKE $1 || '%')
        AND ($2 = '' OR action = $2)
        AND ($3 = '' OR user_email ILIKE '%' || $3 || '%')
      ORDER BY created_at DESC
      LIMIT 50 OFFSET $4`,
    [resource, action, q, (page - 1) * 50]
  );

  return <AuditLogView initialRows={logs} />;
}

도메인별 뷰는 resource 쿼리 고정 (WHERE resource LIKE 'blog.%') + URL 경로 분기.

6. 테스트 회귀

// src/shared/lib/audit.test.ts
import { vi, describe, it, expect } from 'vitest';

const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() }));
vi.mock('./db', () => ({ centralPool: { query: mockQuery } }));

describe('logAdminAction', () => {
  it('Server Action 경로에서도 user_email 채움 (cookies fallback)', async () => {
    mockQuery.mockResolvedValueOnce({ rows: [] });
    logAdminAction({ action: 'DELETE', resource: 'blog.post', resourceId: '1' });
    await new Promise((r) => setImmediate(r));            // fire-and-forget flush
    expect(mockQuery).toHaveBeenCalled();
  });
});

7. 자주 걸리는 자리

  • user_id NOT NULL 제약 → 시스템 액션에서 실패. NULL 허용 또는 sentinel 이메일로.
  • details 에 raw 비밀번호 · 토큰 저장 → 감사 테이블이 잠재 유출원.
  • 감사 실패가 메인 요청 500 → fire-and-forget 원칙 위반.
  • 1 년치 수백만 행 → 월별 파티셔닝 계획.

하고픈 말

감사로그는 사고 때만 쓰는 장치가 아니라 평소에 "왜 그랬지" 의 기본 답. reason 을 초기부터 필수 컬럼으로 취급하면 1 년 뒤 가치가 확실히 커집니다.

Next

  • 07-backup-automation

← 5단계

OAuth 2 provider + 화이트리스트

7단계 →

백업 자동화 — pg_dump + cron