데이터베이스 변경에 관리자 승인 받기
조회는 바로 실행됩니다. 변경은 관리자가 휴대폰에서 정확한 SQL과 예상 행 수를 확인하고 승인한 뒤 한 번만 실행됩니다. 데이터베이스는 자사 서버를 떠나지 않습니다.
직원이나 스크립트, 어시스턴트가 데이터베이스를 직접 조회하게 하면 몇 시간을 아낄 수 있습니다. 하지만 아무도 보지 않은 채 바꾸게 하면 "오래된 장바구니 정리해 줘" 한마디에 지난주 주문이 지워질 수도 있습니다. Hibira는 그 사이에 관리자를 둡니다. 조회는 바로 실행되고, 변경은 하나하나 관리자가 휴대폰에서 정확한 SQL과 영향받을 행 수를 보고 승인할 때까지 기다립니다. 데이터베이스와 접속 정보는 자사 서버를 떠나지 않습니다.
사용자가 얻는 것
- 읽기만 하는 질문은 누구도 기다리지 않고 바로 답이 나옵니다.
- 변경마다 승인 페이지가 있어 관리자가 푸시 알림이나 QR 코드로 엽니다. SQL, 예상 영향 행 수, 실행 계획, 지문과 함께 승인과 거절 버튼이 있습니다.
- 관리자가 결정하는 순간 요청한 사람의 화면에 결과가 나타납니다. 새로 고침이 필요 없고, 거절되면 관리자의 사유도 함께 옵니다.
- 실행되는 것은 승인된 문장과 정확히 같은 문장이며, 단 한 번만 실행됩니다.

사용하는 기능
- 승인:
POST /apps/APP_ID/approvals(또는 MCP 서버의request_approval)가 Hibira에서 호스팅하는 승인 페이지를 만들고, 결정을 채널로 알립니다. - 비공개 채널: 콘솔이나 실행마다 하나, 예를 들어
private-sqlgate-<random>. 여기로approval이벤트가 오므로 폴링하지 않습니다. - 푸시 알림(선택): 관리자의 휴대폰으로 링크를 보냅니다.
- @hibira-io/approvals/sql: Node.js용
classifySql(),sqlFingerprint(),assertApprovedSql(). Python에도 같은 기능이 있습니다.
demos.hibira.io에서 해 볼 수 있습니다. 변경을 고르고 휴대폰으로 QR 코드를 스캔해 승인하세요.
1단계: 조회는 통과, 나머지는 모두 멈추기
classifySql()은 확실할 때만 조회로 판단합니다. 문장이 하나이고 SELECT, WITH, EXPLAIN, SHOW로 시작하며 데이터를 바꾸는 부분이 없을 때입니다. 여러 문장, WITH ... DELETE, EXPLAIN ANALYZE, SELECT ... INTO, FOR UPDATE, 확실히 해석할 수 없는 것은 모두 변경입니다. 그래도 조회는 읽기 전용 트랜잭션에서 실행하세요. 잘못 판단한 문장이 있어도 쓰지 않고 실패할 뿐입니다.
import pg from 'pg';
import { classifySql } from '@hibira-io/approvals/sql';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export async function runSql(sql, requestedBy) {
if (classifySql(sql) === 'read') return { rows: await readOnly(sql) };
return { approval: await askSupervisor(sql, requestedBy) };
}
async function readOnly(sql) {
const db = await pool.connect();
try {
await db.query('BEGIN READ ONLY');
const { rows } = await db.query(sql);
await db.query('COMMIT');
return rows.slice(0, 200);
} catch (err) {
await db.query('ROLLBACK');
throw err;
} finally {
db.release();
}
}2단계: 변경이 무엇을 할지 관리자에게 보여 주기
요청하기 전에 행 수를 추정합니다. EXPLAIN은 문장을 실행하지 않습니다. 롤백되는 트랜잭션에서 시험 실행하면 정확한 수가 나오지만 문장이 실제로 실행되므로 시간 제한을 두세요. 그리고 문장 원문의 지문을 만듭니다. 승인은 이 문장에만 유효합니다.
import { sqlFingerprint } from '@hibira-io/approvals/sql';
async function preview(sql) {
const db = await pool.connect();
try {
await db.query('BEGIN');
await db.query("SET LOCAL statement_timeout = '5s'");
const { rowCount } = await db.query(sql);
return { rows: rowCount, fingerprint: await sqlFingerprint(sql) };
} finally {
await db.query('ROLLBACK');
db.release();
}
}3단계: 승인 요청하기
문장과 미리 보기를 details에 넣고, 결정을 알릴 채널을 지정해 요청을 만듭니다. 문장과 지문은 승인 ID와 함께 자사 쪽에 저장합니다. Hibira로 가는 것은 요청 문구뿐이며, 접속 정보도 행 데이터도 보내지 않습니다.
import Pusher from 'pusher';
const hibira = new Pusher({ appId: process.env.HIBIRA_APP_ID, key: process.env.HIBIRA_KEY, secret: process.env.HIBIRA_SECRET, host: 'ws.hibira.io', useTLS: true });
async function askSupervisor(sql, requestedBy) {
const { rows, fingerprint } = await preview(sql);
const res = await hibira.post({
path: '/approvals',
body: {
title: 'Run this database change?',
details: `${sql}\n\nEstimated rows: ${rows}\n\nSHA-256: ${fingerprint}`,
options: ['Approve', 'Reject'],
channel: `private-sqlgate-${requestedBy.consoleId}`,
push_users: ['supervisor-42'],
expires_in_minutes: 15,
agent: 'SQL console',
},
});
const approval = await res.json();
await pool.query('INSERT INTO pending_changes (approval_id, sql, fingerprint) VALUES ($1, $2, $3)', [approval.approval_id, sql, fingerprint]);
return { id: approval.approval_id, url: approval.url, rows };
}관리자는 링크가 담긴 푸시 알림을 받거나 요청자 화면의 QR 코드를 스캔해, 자신의 언어로 표시된 페이지에서 답합니다.

4단계: 답이 오면 한 번만 실행하기
결정은 채널에 approval 이벤트로 도착합니다. 초인종처럼 다루세요. API에서 답을 한 번 읽고, 저장해 둔 문장이 지문과 일치하는지 확인한 뒤 실행합니다. 먼저 행을 선점하면 이벤트나 워커가 동시에 와도 한 번만 실행됩니다.
import { assertApprovedSql } from '@hibira-io/approvals/sql';
// In the requester's page (pusher-js), or a worker that keeps a connection open:
channel.bind('approval', ({ id, reason }) => {
if (reason === 'decided') fetch(`/api/changes/${id}/run`, { method: 'POST' });
});
// POST /api/changes/:id/run on your server:
export async function runApproved(id) {
const res = await hibira.get({ path: `/approvals/${id}` });
const answer = await res.json();
if (answer.status !== 'approved') return { status: answer.status, comment: answer.comment };
const { rows } = await pool.query('UPDATE pending_changes SET ran_at = now() WHERE approval_id = $1 AND ran_at IS NULL RETURNING sql, fingerprint', [id]);
if (!rows.length) return { status: 'already run' };
await assertApprovedSql(rows[0].sql, rows[0].fingerprint);
const result = await pool.query(rows[0].sql);
return { status: 'ran', rows: result.rowCount };
}
이미 MCP 서버로 데이터베이스에 연결하고 있나요?
팀이 Claude, Cursor, ChatGPT 같은 MCP 클라이언트에서 DBHub, postgres-mcp, Supabase MCP 같은 서버를 통해 데이터베이스를 다룬다면, 그 앞에 mcp-approval-gate만 두면 됩니다. 코드는 바꿀 필요가 없습니다. 조회는 바로 통과하고, INSERT·UPDATE·DELETE나 스키마 변경은 같은 승인 페이지에서 멈췄다가 승인된 그대로 한 번만 실행됩니다.
npx -y @hibira-io/mcp-approval-gate --label shop-production --push-interests db-approvals \
-- npx -y @bytebase/dbhub --transport stdio --dsn "$DATABASE_URL"사용하는 스택에서 같은 관문 만들기
- Vercel AI SDK, Mastra, LangGraph(JavaScript와 Python): @hibira-io/approvals에 각각의 레시피가 있습니다.
needsApproval이나requireApproval을 문장으로 판단하는 함수로 만들고, 자체 앱의 승인 받은편지함에서 호출마다 미리 보기를 보여 줍니다. - Laravel: filament-approvals는 Filament 패널 안에서 승인을 받습니다. README에 Laravel AI SDK용
RunSql도구가 있습니다. - n8n, Make, Activepieces, Dify: 코드 단계에서 문장을 판별하고, Hibira 승인 단계에서 관리자에게 묻고 기다리며, 데이터베이스 단계는 승인 분기에만 연결합니다. 각 README에 워크플로가 있습니다.
- Claude, Cursor 등 MCP 클라이언트: MCP 서버의
request_approval로 채팅에서도 같은 방식으로 요청할 수 있습니다. - 모든 데이터베이스 MCP 서버: mcp-approval-gate로 DBHub, postgres-mcp, Supabase MCP 등을 코드 변경 없이 감쌀 수 있습니다.