사람의 승인을 영속 상태로 저장해야 하는 이유
사람의 승인을 영속 상태로 저장해야 하는 이유
approved = true만 메모리에 저장하면 재시작 뒤 승인을 잃고, 반대로 이전 승인을 바뀐 작업에 잘못 재사용할 수도 있다. 승인은 사람의 의사를 잠시 표시하는 UI 상태가 아니라 특정 제안의 내용과 버전에 한 번만 사용할 수 있는 실행 권한이다. 누가 무엇을 언제까지 승인했는지 데이터베이스에 남기고, 승인 이후 제안이 바뀌지 않았는지 실행 직전 다시 확인해야 한다.
목차
- #승인 버튼 뒤에는 분산 시스템이 있다
- #Boolean 하나로는 표현할 수 없는 것
- #인증과 권한과 거래 승인을 구분한다
- #승인은 정확한 제안에 묶어야 한다
- #제안 내용을 정규화하고 Hash로 고정한다
- #승인 흐름을 상태 머신으로 만든다
- #승인과 실행 사이의 경쟁 조건을 막는다
- #실행은 한 번만 소비할 수 있어야 한다
- #승인 만료와 취소를 설계한다
- #다중 승인과 이해관계 분리
- #재구성한 데이터 모델
- #재구성한 승인 API 예제
- #실행 Worker가 승인을 검증하는 방법
- #감사 로그와 개인정보를 함께 다룬다
- #실패 상황을 기준으로 테스트한다
- #운영 지표와 알림
- #마무리
- #참고 자료
- #관련 노트
승인 버튼 뒤에는 분산 시스템이 있다
에이전트가 production 배포를 제안하고 사용자가 승인 버튼을 누르는 장면을 생각해 보자.
Agent: production에 version 2.4.1을 배포할까요?
User: 승인
System: 배포 실행
UI만 보면 하나의 짧은 요청이다. 실제 시스템에서는 여러 구성 요소와 시간이 사이에 들어간다.
sequenceDiagram
participant A as Agent
participant API as Workflow API
participant DB as Database
participant U as User
participant Q as Queue
participant W as Worker
A->>API: 실행 제안 생성
API->>DB: proposal 저장
API-->>U: 승인 화면 표시
U->>API: 승인 요청
API->>DB: approval 저장
API->>Q: 실행 메시지 발행
Q->>W: 지연 또는 중복 전달
W->>DB: 승인과 현재 상태 확인
W->>W: 외부 작업 실행이 사이에 흔히 일어나는 일이 있다.
- API가 승인 직후 재시작한다.
- queue가 메시지를 두 번 전달한다.
- 사용자가 두 기기에서 동시에 버튼을 누른다.
- 승인 화면을 연 사이 에이전트가 대상 version을 변경한다.
- worker가 몇 시간 뒤 메시지를 처리한다.
- 승인자는 조직을 떠나거나 권한을 잃는다.
- 외부 API는 성공했지만 결과 저장 직전에 process가 죽는다.
따라서 사람을 흐름에 넣었다는 사실만으로 안전해지지 않는다. 사람의 결정이 정확한 작업에 연결되고, 장애와 재시도 뒤에도 그 의미가 보존되어야 한다.
승인 화면의 체크 표시가 아니라, 특정 subject가 특정 proposal을 정해진 기간 안에 정해진 횟수만 실행하도록 허용하는 보안 상태로 다룬다.
Boolean 하나로는 표현할 수 없는 것
처음 구현할 때는 다음 정도로 시작하기 쉽다.
let approved = false;
function approve() {
approved = true;
}
async function execute() {
if (!approved) throw new Error("approval required");
await deploy();
}
이 코드는 최소한의 demo에는 동작하지만 질문에 답하지 못한다.
- 누가 승인했는가?
- 무엇을 승인했는가?
- staging과 production 중 어디를 승인했는가?
- 승인 뒤 version이 바뀌었는가?
- 언제까지 유효한가?
- 한 번만 쓸 수 있는가?
- 이미 실행 중인가, 성공했는가?
- 누가 취소했는가?
- 서비스 재시작 뒤에도 결정이 남는가?
DB에 boolean column 하나를 추가해도 본질은 같다.
ALTER TABLE agent_tasks ADD COLUMN approved BOOLEAN NOT NULL DEFAULT FALSE;
이 값은 승인과 대상 작업 사이의 의미를 충분히 담지 못한다. 작업 payload가 수정 가능하다면 더 심각하다.
10:00 proposal = staging에 version 2.4.1 배포
10:01 user = approved true
10:02 agent = target을 production, version을 2.5.0으로 수정
10:03 worker = approved true를 보고 실행
사용자는 첫 번째 거래를 승인했지만 시스템은 두 번째 거래에 승인 결과를 재사용했다. 승인 여부가 아니라 승인한 내용을 고정해야 한다.
인증과 권한과 거래 승인을 구분한다
세 개념은 연결되어 있지만 서로 대신할 수 없다.
| 질문 | 개념 | 예시 |
|---|---|---|
| 이 사용자는 누구인가? | 인증 | SSO session, passkey |
| 이 사용자는 일반적으로 이 기능을 쓸 수 있는가? | 권한 부여 | production deployer role |
| 이 사용자가 지금 이 구체적 작업을 허용했는가? | 거래 승인 | version 2.4.1 production 배포 승인 |
로그인한 관리자라는 이유만으로 모든 고위험 작업이 자동 승인되는 것은 아니다. 반대로 승인 link를 가졌더라도 현재 사용자가 조직의 승인 권한을 잃었다면 실행을 막아야 할 수 있다.
async function canApprove(user: User, proposal: Proposal) {
await authorization.assertAllowed({
subject: user.id,
action: "proposal.approve",
resource: proposal.resourceId,
environment: proposal.environment,
});
}
승인 시점에 권한을 검사하고, 정책에 따라 실행 직전에도 승인자의 현재 자격을 다시 검사한다. 다만 과거 감사 기록을 현재 사용자 정보와 join해 해석해서는 안 된다. 승인 당시 role과 정책 version을 snapshot으로 함께 남겨야 한다.
OWASP의 transaction authorization 지침도 중요한 거래 데이터를 사용자가 식별하고 확인할 수 있어야 하며, 승인은 작업마다 고유하고 제한된 시간 동안 유효해야 하고 서버가 상태 전이를 강제해야 한다는 방향을 제시한다.
승인은 정확한 제안에 묶어야 한다
승인 화면에는 “에이전트 작업을 승인하시겠습니까?”가 아니라 결정에 중요한 필드를 보여 줘야 한다.
{
"action": "deployment.release",
"service": "catalog-api",
"environment": "production",
"artifactDigest": "sha256:4e9c-example",
"fromVersion": "2.4.0",
"toVersion": "2.4.1",
"strategy": "canary-10-percent",
"rollbackOnError": true,
"estimatedImpact": "production traffic"
}
다음 값은 승인 대상에 포함해야 한다.
- 실행 종류와 구체적인 action
- 대상 resource와 tenant
- production 같은 environment
- 변경 전후 version 또는 revision
- 수량·금액·수신자처럼 영향이 큰 인수
- 재시도와 rollback 정책
- 사용될 권한 범위
- proposal version과 만료 시간
사용자에게는 raw JSON 대신 의미 있는 diff를 보여 줄 수 있다.
Service catalog-api
-Environment staging
+Environment production
Artifact sha256:4e9c-example
Strategy canary 10%
summary: "안전한 소규모 배포" 같은 자연어는 실제 실행 인수와 달라질 수 있다. UI가 보여 주는 값과 worker가 소비하는 값이 같은 server-side proposal record에서 나와야 한다.
제안 내용을 정규화하고 Hash로 고정한다
proposal payload를 hash에 묶으면 승인 이후 변경을 탐지할 수 있다. 하지만 JSON 문자열을 그대로 hash하면 key 순서나 공백만 달라도 값이 바뀐다. 실행에 영향을 주는 필드를 schema로 검증하고 결정적인 형식으로 정규화해야 한다.
type DeployProposal = {
schemaVersion: 1;
action: "deployment.release";
service: string;
environment: "staging" | "production";
artifactDigest: string;
strategy: "canary-10-percent" | "rolling";
rollbackOnError: boolean;
};
import { createHash } from "node:crypto";
function canonicalize(proposal: DeployProposal): string {
return JSON.stringify({
action: proposal.action,
artifactDigest: proposal.artifactDigest,
environment: proposal.environment,
rollbackOnError: proposal.rollbackOnError,
schemaVersion: proposal.schemaVersion,
service: proposal.service,
strategy: proposal.strategy,
});
}
function proposalHash(proposal: DeployProposal): string {
return createHash("sha256")
.update(canonicalize(proposal), "utf8")
.digest("hex");
}
Hash가 보안을 자동으로 완성하지는 않는다. 다음 조건도 필요하다.
- 실행에 영향을 주는 모든 필드가 hash 입력에 포함된다.
- schema version마다 canonicalization 규칙이 고정된다.
- hash algorithm과 version을 record에 남긴다.
- 승인 API가 client가 보낸 hash를 그대로 신뢰하지 않고 서버 payload에서 계산한다.
- worker가 실행 직전 다시 계산해 승인 record와 비교한다.
설명, 생성 시각처럼 실행과 무관한 값까지 hash에 넣으면 작은 표시 변경에도 재승인이 필요하다. 반대로 중요한 필드를 빼면 승인 뒤 몰래 바꿀 수 있다. 어떤 필드가 거래 의미를 결정하는지 schema review가 필요하다.
승인 흐름을 상태 머신으로 만든다
승인, 거부, 실행을 임의 boolean 조합으로 두면 불가능한 상태가 생긴다.
approved = true
rejected = true
executed = false
cancelled = true
하나의 명시적 상태와 허용된 전이를 정의하는 편이 낫다.
stateDiagram-v2
[*] --> Pending
Pending --> Approved: approve
Pending --> Rejected: reject
Pending --> Expired: expires_at 경과
Pending --> Cancelled: requester 취소
Approved --> Executing: 승인 소비
Approved --> Revoked: approver 취소
Approved --> Expired: 실행 전 만료
Executing --> Succeeded: side effect 확인
Executing --> Failed: 재시도 불가 실패
Executing --> Unknown: 결과 확인 불가
Unknown --> Succeeded: reconciliation
Unknown --> Failed: reconciliationUnknown 상태를 빼면 외부 API 성공 직후 worker가 죽는 경우를 거짓으로 Failed 처리하게 된다. 그러면 재시도가 같은 side effect를 중복 생성할 수 있다. 알 수 없는 결과를 명시하고 reconciliation으로 확인해야 한다.
허용하지 않는 전이는 서버에서 거부한다.
| 현재 상태 | 요청 | 결과 |
|---|---|---|
| Pending | approve | Approved |
| Pending | execute | 거부 |
| Approved | approve | 동일 요청이면 기존 결과 반환 |
| Rejected | approve | 새 proposal 필요 |
| Approved | payload 수정 | 새 version과 재승인 필요 |
| Expired | execute | 거부 |
| Succeeded | execute | 기존 실행 결과 반환 |
상태 머신 관점은 에이전트 워크플로우를 상태 머신으로 표현하기에서 더 자세히 다룬다.
승인과 실행 사이의 경쟁 조건을 막는다
다음 코드는 승인 조회와 상태 변경 사이에 경쟁 조건이 있다.
const approval = await db.approval.find(id);
if (approval.status !== "APPROVED") {
throw new Error("not approved");
}
await queue.publish({ approvalId: id });
await db.approval.update(id, { status: "EXECUTING" });
두 요청이 동시에 APPROVED를 읽으면 둘 다 메시지를 발행할 수 있다. check와 transition을 하나의 조건부 update로 묶어야 한다.
UPDATE approval_requests
SET status = 'EXECUTING',
execution_id = $2,
consumed_at = now(),
version = version + 1
WHERE id = $1
AND status = 'APPROVED'
AND expires_at > now()
AND proposal_hash = $3
RETURNING *;
반환 행이 0개면 이미 소비되었거나 만료되었거나 proposal이 달라진 것이다. 이유를 다시 조회해 사용자에게 구체적으로 보여 줄 수 있지만 실행은 진행하지 않는다.
queue 발행과 DB commit도 원자적이지 않다. DB에는 실행 상태가 남았지만 메시지가 유실되거나, 메시지는 발행됐지만 transaction이 rollback될 수 있다. Outbox pattern을 사용하면 상태 전이와 발행할 event를 같은 transaction에 기록할 수 있다.
BEGIN;
UPDATE approval_requests
SET status = 'EXECUTING', execution_id = $2
WHERE id = $1 AND status = 'APPROVED';
INSERT INTO outbox_events (id, topic, aggregate_id, payload)
VALUES ($3, 'agent.execution.requested', $1, $4);
COMMIT;
별도 publisher가 아직 발행되지 않은 outbox row를 queue에 전달한다. 중복 발행은 가능하므로 consumer의 멱등성도 함께 필요하다.
실행은 한 번만 소비할 수 있어야 한다
승인 자체를 one-time capability로 생각하면 구조가 분명해진다.
proposal P에 대한 approval A
valid until T
may be consumed once
creates exactly one logical execution E
분산 시스템에서 메시지 전달을 정확히 한 번으로 보장하기는 어렵다. 대신 같은 execution_id의 재시도가 같은 논리적 결과를 반환하도록 만든다.
async function executeOnce(command: ExecuteCommand) {
const previous = await executions.findById(command.executionId);
if (previous?.terminalResult) return previous.terminalResult;
return externalDeployment.start({
service: command.service,
artifactDigest: command.artifactDigest,
idempotencyKey: command.executionId,
});
}
외부 시스템이 idempotency key를 지원하지 않으면 실행 전 자체 ledger와 resource 상태를 확인하고, 결과가 불명확할 때 자동 재시도하지 않는 보수적인 정책이 필요하다. 이 문제는 에이전트 재시도와 멱등성으로 이어진다.
하나의 proposal이 재승인될 수 있고, 정책상 여러 단계 실행으로 나뉠 수도 있다. 승인 결정의 ID와 실제 side effect의 멱등성 ID를 별도 모델로 두고 관계를 명시한다.
승인 만료와 취소를 설계한다
승인은 영구 permission이 아니다. 상황이 변하면 의미가 달라진다.
승인 당시: base revision abc123, production 정상
실행 당시: base revision def456, incident 진행 중
승인 record에 expires_at을 넣고 실행 직전 서버 시각으로 검사한다. 작업 위험도에 따라 TTL을 다르게 둘 수 있다.
| 작업 | 예시 TTL | 추가 조건 |
|---|---|---|
| 읽기 전용 보고서 생성 | 24시간 | 데이터 범위 고정 |
| 외부 이메일 발송 | 30분 | 수신자·본문 hash 고정 |
| production 배포 | 15분 | artifact와 base revision 고정 |
| 데이터 삭제 | 5분 | 대상 개수 재확인, 다중 승인 |
TTL은 예시일 뿐 조직의 위험 분석으로 정해야 한다. 너무 짧으면 승인 직후 반복 만료되어 우회가 생기고, 너무 길면 바뀐 상황에서 오래된 결정이 실행된다.
취소도 durable event여야 한다.
type ApprovalRevoked = {
approvalId: string;
revokedBy: string;
reason: string;
revokedAt: string;
previousVersion: number;
};
이미 EXECUTING 상태라면 취소가 즉시 side effect를 되돌린다고 약속할 수 없다. “아직 시작하지 않음”, “취소 요청됨”, “rollback 중”, “되돌릴 수 없음”을 UI에서 구분한다.
다중 승인과 이해관계 분리
고위험 작업은 요청자와 승인자가 같지 않아야 하거나 두 명 이상의 독립 승인이 필요할 수 있다.
규칙 예시
- proposal 작성자는 자신의 요청을 승인할 수 없음
- production 삭제는 서로 다른 두 승인자가 필요
- 두 승인자는 같은 일회성 session을 공유할 수 없음
- 최종 승인 뒤 payload가 바뀌면 모든 승인이 무효
승인 수만 세면 같은 사람이 여러 계정으로 승인하거나 이미 권한을 잃은 결정이 포함될 수 있다. policy가 승인 집합을 평가해야 한다.
type ApprovalRequirement = {
minimumApprovals: number;
requiredRoles: string[];
requesterMayApprove: boolean;
distinctUsers: boolean;
};
function requirementSatisfied(
requirement: ApprovalRequirement,
decisions: ApprovalDecision[],
): boolean {
const approved = decisions.filter((it) => it.decision === "APPROVE");
const users = new Set(approved.map((it) => it.decidedBy));
if (requirement.distinctUsers && users.size !== approved.length) return false;
if (approved.length < requirement.minimumApprovals) return false;
return requirement.requiredRoles.every((role) =>
approved.some((it) => it.roleSnapshot.includes(role)),
);
}
이 함수만으로 완전한 권한 검사가 되는 것은 아니다. roleSnapshot은 감사 설명용이고, 실제 decision 생성 시 trusted authorization service가 현재 역할과 이해관계 충돌을 검사해야 한다.
재구성한 데이터 모델
하나의 table에 모든 것을 넣기보다 proposal, decision, execution과 event를 나누면 변경 이력을 보존하기 쉽다.
CREATE TABLE proposals (
id UUID PRIMARY KEY,
task_id UUID NOT NULL,
proposal_version INTEGER NOT NULL,
action_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
payload JSONB NOT NULL,
payload_hash TEXT NOT NULL,
hash_algorithm TEXT NOT NULL DEFAULT 'sha256',
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
UNIQUE (task_id, proposal_version)
);
CREATE TABLE approval_requests (
id UUID PRIMARY KEY,
proposal_id UUID NOT NULL REFERENCES proposals(id),
status TEXT NOT NULL CHECK (status IN (
'PENDING', 'APPROVED', 'REJECTED', 'REVOKED',
'EXPIRED', 'EXECUTING', 'SUCCEEDED', 'FAILED', 'UNKNOWN'
)),
required_approvals INTEGER NOT NULL,
policy_version TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 0,
execution_id UUID,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE approval_decisions (
id UUID PRIMARY KEY,
approval_request_id UUID NOT NULL REFERENCES approval_requests(id),
proposal_hash TEXT NOT NULL,
decision TEXT NOT NULL CHECK (decision IN ('APPROVE', 'REJECT')),
decided_by TEXT NOT NULL,
role_snapshot JSONB NOT NULL,
reason TEXT,
decided_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
UNIQUE (approval_request_id, decided_by)
);
CREATE TABLE executions (
id UUID PRIMARY KEY,
approval_request_id UUID NOT NULL REFERENCES approval_requests(id),
idempotency_key TEXT NOT NULL UNIQUE,
proposal_hash TEXT NOT NULL,
status TEXT NOT NULL,
external_operation_id TEXT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
result JSONB
);
상태 column은 읽기 모델로 사용하고, 별도 append-only audit event를 함께 남기면 전이 이유를 복원할 수 있다. 개인정보 삭제 요구를 고려해 사용자 표시 정보 전체가 아니라 안정적인 subject ID와 필요한 snapshot만 저장한다.
재구성한 승인 API 예제
승인 API는 client가 전달한 proposal 내용을 신뢰하지 않고 ID로 server record를 조회한다.
type ApproveRequest = {
approvalRequestId: string;
expectedProposalHash: string;
expectedVersion: number;
};
async function approve(request: ApproveRequest, user: AuthenticatedUser) {
return database.transaction(async (tx) => {
const approval = await tx.approvals.lockById(request.approvalRequestId);
const proposal = await tx.proposals.findById(approval.proposalId);
if (approval.status !== "PENDING") {
return existingDecisionOrConflict(approval, user.id);
}
if (proposal.expiresAt <= clock.now()) {
await tx.approvals.markExpired(approval.id);
throw new Error("proposal expired");
}
if (approval.version !== request.expectedVersion) {
throw new Error("approval request changed");
}
const actualHash = proposalHash(validateProposal(proposal.payload));
if (
actualHash !== proposal.payloadHash ||
actualHash !== request.expectedProposalHash
) {
throw new Error("proposal content changed");
}
await authorization.assertMayApprove(user, proposal);
await policy.assertNoConflictOfInterest(user, proposal);
await tx.decisions.insert({
approvalRequestId: approval.id,
proposalHash: actualHash,
decision: "APPROVE",
decidedBy: user.id,
roleSnapshot: user.relevantRoles,
decidedAt: clock.now(),
expiresAt: proposal.expiresAt,
});
return tx.approvals.recalculateState(approval.id);
});
}
expectedVersion과 expectedProposalHash는 사용자가 본 화면이 최신인지 탐지한다. 하지만 최종 권위는 항상 서버의 proposal과 policy다. API response가 끊겨 client가 재시도해도 (approval_request_id, decided_by) unique constraint를 이용해 기존 결정을 반환한다.
거부도 단순 삭제가 아니라 decision으로 저장한다.
await decisions.insert({
approvalRequestId,
proposalHash,
decision: "REJECT",
decidedBy: user.id,
reason: "production 변경 범위가 설명과 다름",
decidedAt: clock.now(),
});
거부 이유에 개인정보나 secret이 들어갈 수 있으므로 길이 제한, 접근 통제와 보존 정책을 적용한다.
실행 Worker가 승인을 검증하는 방법
승인 API에서 검사했으니 worker는 바로 실행해도 된다고 생각하기 쉽다. 하지만 queue 지연과 정책 변경, payload 변조가 있을 수 있으므로 실행 경계에서 다시 검증한다.
async function handleExecution(message: ExecutionMessage) {
const execution = await executions.findById(message.executionId);
if (execution.isTerminal) return execution.result;
const approval = await approvals.findById(execution.approvalRequestId);
const proposal = await proposals.findById(approval.proposalId);
assert.equal(approval.status, "EXECUTING");
assert.equal(execution.proposalHash, proposal.payloadHash);
assert.equal(proposalHash(validateProposal(proposal.payload)), proposal.payloadHash);
assert.isAfter(proposal.expiresAt, clock.now());
await policy.assertExecutableNow({ approval, proposal });
return executor.execute({
executionId: execution.id,
action: proposal.actionType,
payload: proposal.payload,
});
}
여기서 executor는 prompt나 자연어 summary를 사용하지 않는다. 승인된 structured payload만 schema에 따라 실행한다. 실행에 필요한 최소 권한도 execution ID에 묶어 이 시점에 발급한다.
flowchart LR
P[고정된 Proposal] --> H[Human Decision]
H --> A[Approval State]
A --> C[원자적 Consume]
C --> E[Execution ID]
E --> T[단기 권한 발급]
T --> X[Executor]
X --> R[Result/Reconciliation]승인이 곧 실행은 아니다. 승인과 실행을 분리하면 worker 장애를 견디고, 실행 직전 정책과 대상 상태를 다시 확인할 수 있다.
감사 로그와 개인정보를 함께 다룬다
장애나 사고 뒤 다음 질문에 답할 수 있어야 한다.
누가 제안을 만들었는가?
사용자는 어떤 화면과 중요 필드를 보았는가?
어느 payload hash를 승인했는가?
당시 권한과 정책 version은 무엇이었는가?
언제 승인했고 언제 만료되었는가?
어느 execution이 승인을 소비했는가?
외부 시스템의 operation ID는 무엇인가?
성공·실패·unknown을 누가 어떻게 확정했는가?
감사 event는 수정 가능한 application log 한 줄보다 append-only 저장소에 구조화해 남기는 편이 좋다.
{
"event": "approval.decision.recorded",
"approvalRequestId": "apr-4d2a",
"proposalId": "prp-8831",
"proposalHash": "sha256:example",
"decision": "APPROVE",
"subjectId": "usr-21",
"policyVersion": "deploy-approval-7",
"requestId": "req-90c1",
"recordedAt": "2026-05-05T02:30:00Z"
}
모든 payload를 그대로 감사 로그에 복사하면 secret과 개인정보가 중복 저장된다. 로그에는 ID, hash, 상태, 정책 version과 필요한 핵심 분류만 넣고, 원문 payload는 접근 통제와 보존 정책이 있는 proposal store에서 조회한다.
실패 상황을 기준으로 테스트한다
happy path 테스트 하나로는 durable approval을 검증할 수 없다.
| 상황 | 기대 결과 |
|---|---|
| 승인 API 응답 유실 후 재시도 | 같은 decision 반환 |
| 두 기기에서 동시 승인 | 사용자당 decision 하나 |
| 승인 뒤 payload 한 필드 변경 | hash 불일치로 실행 거부 |
| 승인 뒤 proposal version 증가 | 기존 승인 무효 |
| 만료와 실행이 동시에 발생 | 하나의 원자적 전이만 성공 |
| queue 메시지 중복 | 같은 execution 결과 반환 |
| 외부 API 성공 후 worker crash | Unknown 뒤 reconciliation |
| 승인자 권한 회수 | 정책에 따라 실행 전 거부 |
| 요청자가 자기 요청 승인 | conflict-of-interest 거부 |
| 첫 번째 승인 후 내용 변경 | 모든 승인 재수집 |
it("does not execute a proposal changed after approval", async () => {
const proposal = await fixture.createProposal({
environment: "staging",
artifactDigest: "sha256:v1",
});
await fixture.approve(proposal.id, "reviewer-a");
await fixture.forcePayloadMutation(proposal.id, {
environment: "production",
});
await expect(worker.execute(proposal.id)).rejects.toThrow(
"proposal content changed",
);
expect(externalDeployment.calls()).toHaveLength(0);
});
DB transaction test는 실제 사용하는 isolation level에서 실행한다. mock repository만으로는 conditional update와 unique constraint의 경쟁 조건을 발견하기 어렵다.
“승인된 hash와 실행 payload hash는 항상 같다”, “하나의 승인 요청은 최대 하나의 논리적 execution으로 소비된다” 같은 불변 조건을 만들고, 각 조건을 DB constraint·transaction·worker 검증 중 어디서 강제하는지 연결한다.
운영 지표와 알림
승인 시스템은 지나치게 번거로우면 사용자가 내용을 읽지 않고 습관적으로 누른다. 안전성과 사용성을 함께 관찰한다.
| 지표 | 확인할 문제 |
|---|---|
| approval latency | 위험도 대비 대기 시간이 적절한가 |
| expiry rate | TTL이 너무 짧거나 알림이 늦는가 |
| reject / revoke rate | 제안 설명과 품질에 문제가 있는가 |
| stale proposal rate | 승인 전후 payload가 자주 변하는가 |
| duplicate decision conflict | UI 재시도와 concurrency가 많은가 |
| execution without approval | 가장 중요한 보안 위반 지표 |
| approval-to-execution delay | 오래된 결정을 실행하고 있지 않은가 |
| unknown execution count | 외부 결과 확인이 막혀 있는가 |
| self-approval denial | 정책 우회 시도가 있는가 |
| repeated approver concentration | 형식적 승인으로 굳어졌는가 |
execution without approval은 0이어야 한다. worker가 승인 record를 찾지 못했는데도 fallback으로 실행하는 경로가 없어야 하며, 이런 시도는 즉시 알림 대상이다.
승인 화면도 중요한 운영 도구다. action, 대상, 환경, 변경 전후 값, 영향 범위, 만료와 근거를 한 화면에서 비교할 수 있어야 한다. 상세 정보가 지나치게 길면 중요한 차이가 묻히므로 위험 필드는 상단에 고정한다.
마무리
사람의 승인을 받는다는 것은 버튼을 하나 추가하는 일이 아니다. 장애, 동시성, 재시도와 제안 변경이 있는 분산 시스템에서 사람의 결정을 원래 의미 그대로 실행 시점까지 운반하는 일이다.
승인은 true라는 속성이 아니라, 정확한 proposal hash에 묶이고 만료되며 한 번만 소비되는 영속적인 실행 권한이다.
구현할 때는 다음 기준을 적용할 수 있다.
- proposal과 approval, decision, execution을 별도 개념으로 모델링한다.
- 사용자가 실제 실행에 중요한 필드를 확인하게 한다.
- 구조화된 payload를 정규화하고 hash와 schema version을 저장한다.
- 승인 뒤 payload가 바뀌면 재승인을 요구한다.
- 승인 상태를 명시적 상태 머신과 허용된 전이로 관리한다.
- 승인 소비를 조건부 update 또는 lock으로 원자화한다.
- DB 전이와 queue 발행 사이에는 outbox를 사용한다.
- execution ID를 idempotency key로 사용해 중복 전달을 흡수한다.
- expiry, revoke와
Unknown결과를 정상 상태로 설계한다. - 고위험 작업에는 다중 승인과 이해관계 분리를 적용한다.
- 실행 worker가 hash, 만료, 상태와 현재 정책을 다시 검사한다.
- 감사 가능성과 개인정보 최소 수집을 함께 만족시킨다.
에이전트가 제안을 잘 설명하는 것도 중요하지만, 설명과 실제 실행 내용이 같은지 시스템이 증명할 수 있어야 한다. 영속 승인은 그 연결을 장애와 재시도 뒤에도 끊기지 않게 만든다.
참고 자료
- OWASP Cheat Sheet - Transaction Authorization
- OWASP Cheat Sheet - Authorization
- OWASP Cheat Sheet - Business Logic Security
- PostgreSQL Documentation - Explicit Locking
- PostgreSQL Documentation - Transaction Isolation