Site Icon THEREiS최신 글 게시판 · 014번 읽는 중

제 목 :

우리집 비서 만들기 프로젝트 #8 - 서버편: 명령과 승인 대기열

올린이
[야훔]
날짜
태그

7편에서 허브와 모듈 사이의 약속을 정하고, 허브가 모듈을 부르는 길을 만들었다.

이번에는 그 길에 안전장치를 단다. 메일처럼 한 번 나가면 되돌릴 수 없는 일은 바로 실행하지 않고 다른 기기의 승인을 거치게 하는 승인 대기열, 그리고 클라이언트가 모든 모듈을 부르는 단 하나의 입구 POST /v1/commands다.

Step 1. 승인 대기열 (approvals.py)

모듈이 "approval": true라고 적은 액션은 바로 실행하지 않고 여기 올린다. 승인 대기열은 모듈 내용을 모른다. 어떤 모듈의 어떤 액션인지, 입력값이 뭐였는지를 그대로 보관했다가, 승인되면 그대로 모듈에 넘길 뿐이다.

services/hub/app/approvals.py
"""승인 대기열.
모듈이 manifest에 "approval": true 로 적은 액션은 바로 실행하지 않고 여기 올린다.
대기열은 모듈 내용을 모른다. action("mail.send")과 payload(모듈, 액션, 입력값)를 보관했다가
승인되면 그대로 모듈에 넘길 뿐이다. 누가 승인할 수 있는지는 요청할 때 정해진다.
same_user : 요청한 사람의 "다른" 기기만 승인할 수 있다 (관리자, 어른 가족)
admin : 관리자의 기기만 승인할 수 있다 (아이의 요청)
어느 경우든 요청한 기기 자신은 승인할 수 없고, 다른 가족의 요청은 보이지도 않는다.
"""
import json
import secrets
import time
from dataclasses import dataclass
from .auth.scopes import ADMIN, CHILD
from .db import Database
PENDING, APPROVED, REJECTED, EXPIRED, FAILED = "pending", "approved", "rejected", "expired", "failed"
RULE_SAME_USER, RULE_ADMIN = "same_user", "admin"
class ApprovalError(Exception):
"""승인 처리 중 규칙 위반. code는 HTTP 상태로 바꾸는 데 쓰고, message는 사용자에게 보여 준다."""
def __init__(self, code: str, message: str):
"""code(예: not_found, same_device)와 사용자에게 보여 줄 message를 함께 담는다."""
super().__init__(message)
self.code = code
self.message = message
@dataclass
class Approval:
"""승인 대기열의 요청 하나 (DB approvals 테이블의 한 줄)."""
id: str
action: str
payload: dict
summary: str
requested_user: str
requested_by: str
rule: str
status: str
created_at: int
expires_at: int
decided_by: str | None
decided_at: int | None
result: str | None
def public(self) -> dict:
"""API 응답용 사전(dict). payload(메일 본문 등)는 빼고 요약만 담는다."""
# 대기열 조회에는 본문 전체 대신 요약만 내보낸다. 워치나 안경 화면에는 이걸로 충분하다.
return {
"id": self.id,
"action": self.action,
"summary": self.summary,
"requested_user": self.requested_user,
"requested_by": self.requested_by,
"rule": self.rule,
"status": self.status,
"created_at": self.created_at,
"expires_at": self.expires_at,
"decided_by": self.decided_by,
"decided_at": self.decided_at,
"result": self.result,
}
def _row(r) -> Approval:
"""DB에서 꺼낸 한 줄을 Approval 객체로 바꾼다. payload는 JSON 문자열이라 다시 dict로 풀어 준다."""
return Approval(
id=r["id"], action=r["action"], payload=json.loads(r["payload"]), summary=r["summary"],
requested_user=r["requested_user"], requested_by=r["requested_by"], rule=r["rule"], status=r["status"],
created_at=r["created_at"], expires_at=r["expires_at"], decided_by=r["decided_by"],
decided_at=r["decided_at"], result=r["result"],
)
def rule_for(role: str) -> str:
"""요청한 사람의 역할로 승인 규칙을 정한다. 아이는 관리자 승인(admin), 나머지는 본인 다른 기기 승인(same_user)."""
return RULE_ADMIN if role == CHILD else RULE_SAME_USER
def can_decide(approval: Approval, user_id: str, role: str) -> bool:
"""이 사람이 이 요청을 승인/거절할 수 있는지.
admin 규칙 : 관리자이면서 요청한 본인이 아닐 때
same_user 규칙: 요청한 본인일 때 (같은 기기인지는 claim()에서 따로 막는다)
"""
if approval.rule == RULE_ADMIN:
return role == ADMIN and user_id != approval.requested_user
return user_id == approval.requested_user
class ApprovalQueue:
"""승인 대기열. 요청을 올리고(create), 찾고(get, list_for), 결정하고(claim), 결과를 남긴다(set_result)."""
def __init__(self, db: Database, ttl_seconds: int):
"""DB와 요청 유효 시간(초)을 받는다. 이 시간이 지나도록 아무도 결정하지 않으면 만료된다."""
self.db = db
self.ttl = ttl_seconds
def _expire(self, c, now: int) -> None:
"""유효 시간이 지난 pending 요청을 expired로 바꾼다.
따로 도는 타이머 없이, 대기열을 읽거나 쓸 때마다 먼저 이걸 불러 정리한다.
"""
c.execute("UPDATE approvals SET status = ? WHERE status = ? AND expires_at < ?", (EXPIRED, PENDING, now))
def create(self, action: str, payload: dict, summary: str, user_id: str, role: str, device_key: str) -> Approval:
"""요청을 pending 상태로 대기열에 올리고 저장된 Approval을 돌려준다.
ID는 추측하기 어려운 무작위 문자열. 승인 규칙(rule_for)은 이때 정해져 나중에 바뀌지 않는다.
"""
now = int(time.time())
approval_id = secrets.token_urlsafe(9)
with self.db.tx() as c:
c.execute(
"INSERT INTO approvals (id, action, payload, summary, requested_user, requested_by, rule, status,"
" created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(approval_id, action, json.dumps(payload, ensure_ascii=False), summary, user_id, device_key,
rule_for(role), PENDING, now, now + self.ttl),
)
return self.get(approval_id)
def get(self, approval_id: str) -> Approval | None:
"""ID로 요청 하나를 찾는다. 없으면 None. 읽기 전에 만료 처리를 먼저 한다."""
with self.db.tx() as c:
self._expire(c, int(time.time()))
r = c.execute("SELECT * FROM approvals WHERE id = ?", (approval_id,)).fetchone()
return _row(r) if r else None
def list_for(self, user_id: str, role: str, status: str | None = PENDING, limit: int = 20) -> list[Approval]:
"""이 사람이 결정할 수 있는 요청만 돌려준다. 다른 가족의 요청은 보이지 않는다."""
where = ["((rule = ? AND requested_user = ?)"]
args: list = [RULE_SAME_USER, user_id]
if role == ADMIN:
where.append(" OR (rule = ? AND requested_user != ?)")
args += [RULE_ADMIN, user_id]
where.append(")")
sql = "SELECT * FROM approvals WHERE " + "".join(where)
if status:
sql += " AND status = ?"
args.append(status)
sql += " ORDER BY created_at DESC LIMIT ?"
args.append(limit)
with self.db.tx() as c:
self._expire(c, int(time.time()))
rows = c.execute(sql, args).fetchall()
return [_row(r) for r in rows]
def claim(self, approval_id: str, user_id: str, role: str, device_key: str, decision: str) -> Approval:
"""pending → approved/rejected 로 한 번만 바꾼다. 동시에 두 번 승인돼도 실행은 한 번."""
now = int(time.time())
with self.db.tx() as c:
self._expire(c, now)
r = c.execute("SELECT * FROM approvals WHERE id = ?", (approval_id,)).fetchone()
# 남의 요청은 "없는 요청"과 똑같이 대답한다. 있다는 사실도 알려 주지 않는다.
if r is None or not can_decide(_row(r), user_id, role):
raise ApprovalError("not_found", "요청을 찾을 수 없습니다.")
if r["requested_by"] == device_key:
raise ApprovalError("same_device", "요청한 기기에서는 승인할 수 없습니다. 다른 기기에서 승인해 주세요.")
if r["status"] != PENDING:
raise ApprovalError("not_pending", "이미 처리됐거나 만료된 요청입니다.")
c.execute(
"UPDATE approvals SET status = ?, decided_by = ?, decided_at = ? WHERE id = ?",
(decision, device_key, now, approval_id),
)
return self.get(approval_id)
def has_history(self, user_id: str) -> bool:
"""이 사람이 요청한 기록이 하나라도 있는지 (상태 무관)."""
with self.db.tx() as c:
return c.execute("SELECT 1 FROM approvals WHERE requested_user = ? LIMIT 1", (user_id,)).fetchone() is not None
def set_result(self, approval_id: str, status: str, result: str) -> Approval:
"""실행이 끝난 요청에 최종 상태(approved/failed)와 결과 문구를 기록한다."""
with self.db.tx() as c:
c.execute("UPDATE approvals SET status = ?, result = ? WHERE id = ?", (status, result, approval_id))
return self.get(approval_id)

누가 승인할 수 있는지는 요청할 때 정해진다.

  • same_user: 관리자나 어른 가족의 요청. 요청한 사람의 다른 기기만 승인할 수 있다. 아빠 안경이 요청하면 아빠 폰이나 워치가 승인한다.
  • admin: 아이의 요청. 관리자의 기기만 승인할 수 있다. 아이가 할머니께 메일을 보내려고 하면, 아빠 워치에 승인 요청이 뜬다.

claim() 함수가 핵심이다. 트랜잭션 안에서 확인하고 바꾸기 때문에 이런 일이 생기지 않는다.

  • 두 번 승인돼도 실행은 한 번: 폰과 워치에서 동시에 승인을 눌러도, 먼저 바꾼 쪽만 pending → approved에 성공한다. 나머지는 “이미 처리됐다”는 409를 받는다.
  • 요청한 기기는 승인 못 함: 폰처럼 요청과 승인 권한을 둘 다 가진 기기도 자기 요청은 승인할 수 없다.
  • 다른 가족의 요청은 없는 것처럼: 엄마의 요청을 아빠가 승인하려고 하면 “권한 없음”(403)이 아니라 “요청을 찾을 수 없습니다”(404)를 돌려준다. 목록 조회(list_for)에서도 아예 안 보인다. 시큐어 코딩 글에서 남의 주문 번호는 403 대신 404로 답하라고 했던 그 얘기다. 있다는 사실 자체를 알려 주지 않는다.
  • 5분이 지나면 만료: 승인을 잊어버린 요청이 며칠 뒤에 실수로 승인되는 일이 없다.

그리고 워치나 안경 화면에 보여 줄 목록에는 입력값 대신 모듈이 만들어 준 아빠: 메일 → grandma@family.example : 퇴근 같은 요약만 내보낸다. 작은 화면에는 그걸로 충분하고, 본문이 여기저기 돌아다닐 필요도 없다.

Step 2. 명령과 승인 API (routers)

5편에서 “나 누구야?” API(/v1/me)를 열었다. 이번에는 진짜 업무 API다. 그런데 /v1/mail 같은 API는 없다. 모든 모듈을 부르는 입구는 /v1/commands 하나다. 클라이언트는 {"module": "mail", "action": "send", "params": {...}}만 보내면 된다. 전구 모듈이 생겨도 {"module": "tapo", "action": "on"}처럼 보내면 되니, 앱을 다시 만들 필요가 없다.

services/hub/app/routers/commands.py
"""모든 모듈을 부르는 하나의 입구: POST /v1/commands, 그리고 쓸 수 있는 모듈 목록 GET /v1/modules.
모듈이 늘어나도 이 파일은 바뀌지 않는다. 클라이언트는 {"module", "action", "params"}만 보내면 된다.
"""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from ..approvals import RULE_ADMIN
from ..auth.guard import Caller, authenticate, require
from ..auth.scopes import SYSTEM_READ
from ..catalog import ModuleError, caller_context
router = APIRouter(prefix="/v1", tags=["commands"])
class CommandRequest(BaseModel):
"""명령 요청 본문. params 안쪽 모양은 허브가 아니라 모듈이 검사한다."""
module: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{1,23}$")
action: str = Field(pattern=r"^[a-z0-9][a-z0-9_-]{0,31}$")
params: dict = Field(default_factory=dict)
@router.post("/commands")
async def run_command(req: CommandRequest, request: Request, caller: Caller = Depends(authenticate)):
"""명령 하나를 처리한다.
1. 권한: 기기와 역할이 "<모듈>:<액션>" scope를 허락하는지 (manifest의 roles도)
2. 검사: 모듈에 입력을 검사받고 요약을 받는다
3. 승인이 필요한 액션이면 대기열에 올리고 202, 아니면 바로 실행하고 200
"""
state = request.app.state
user, key = caller.user, caller.device.access_key
scope = f"{req.module}:{req.action}"
# 권한부터 본다. 권한이 없는 기기에는 그런 모듈이 있는지조차 알려 주지 않는다.
if not caller.can(scope):
raise caller.forbid(request, scope)
try:
spec = await state.catalog.action(req.module, req.action)
if spec.roles is not None and user.role not in spec.roles:
raise caller.forbid(request, scope)
ctx = caller_context(user)
summary = await state.catalog.validate(req.module, req.action, req.params, ctx)
if spec.approval:
approval = state.approvals.create(
action=f"{req.module}.{req.action}",
payload={"module": req.module, "action": req.action, "params": req.params},
summary=f"{user.name}: {summary}",
user_id=user.id, role=user.role, device_key=key,
)
state.audit.write("approval.create", user=user.id, device=key, ip=caller.ip,
approval=approval.id, action=approval.action, rule=approval.rule)
who = "관리자 기기에서" if approval.rule == RULE_ADMIN else "다른 내 기기에서"
return JSONResponse({"approval": approval.public(), "message": f"{who} 승인하면 실행됩니다."},
status.HTTP_202_ACCEPTED)
result = await state.catalog.execute(req.module, req.action, req.params, ctx)
except ModuleError as e:
state.audit.write("command.fail", user=user.id, device=key, ip=caller.ip, action=scope, status=e.status)
raise HTTPException(e.status, detail=e.message)
state.audit.write("command.execute", user=user.id, device=key, ip=caller.ip, action=scope)
return {"result": result}
@router.get("/modules")
async def list_modules(request: Request, caller: Caller = Depends(require(SYSTEM_READ))):
"""이 기기로 쓸 수 있는 모듈과 액션 목록. 앱이나 안경이 메뉴를 그릴 때 쓴다.
꺼져 있는 모듈은 available: false 로 보여 준다. 권한이 없는 액션은 목록에서 뺀다.
"""
catalog = request.app.state.catalog
items = []
for name in catalog.endpoints:
try:
manifest = await catalog.manifest(name)
except ModuleError:
items.append({"name": name, "available": False, "actions": []})
continue
actions = [
{"name": a, "title": spec.title, "approval": spec.approval, "params": spec.params}
for a, spec in manifest.actions.items()
if caller.can(f"{name}:{a}") and (spec.roles is None or caller.user.role in spec.roles)
]
if actions:
items.append({"name": name, "title": manifest.title, "available": True, "actions": actions})
return {"items": items}

순서는 이렇다.

  1. 권한부터 본다. 기기와 역할이 mail:send를 허락하는지. 권한이 없으면 그런 모듈이 있는지 없는지도 알려 주지 않고 403이다.
  2. 모듈에 입력을 검사받는다. 틀렸으면 모듈이 준 메시지와 함께 422.
  3. 승인이 필요한 액션이면 대기열에 올리고 202 Accepted. “받았고, 처리는 나중에”라는 뜻이다. 아니면 바로 실행하고 200.

/v1/modules는 이 기기로 쓸 수 있는 모듈과 액션 목록이다. 앱이나 안경이 메뉴를 그릴 때 쓴다. 권한이 없는 액션은 아예 안 보인다.

승인 API다. 승인되면 그때 모듈에 실행을 맡긴다.

services/hub/app/routers/approvals.py
"""승인 대기열 API: 목록, 승인(그리고 실행), 거절."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from ..auth.guard import Caller, require
from ..auth.scopes import APPROVALS_DECIDE
from ..approvals import APPROVED, FAILED, REJECTED, ApprovalError
from ..catalog import ModuleError, caller_context
router = APIRouter(prefix="/v1/approvals", tags=["approvals"])
logger = logging.getLogger("secretary.approvals")
_ERROR_STATUS = {
"not_found": status.HTTP_404_NOT_FOUND,
"not_pending": status.HTTP_409_CONFLICT,
"same_device": status.HTTP_403_FORBIDDEN,
}
def _raise(e: ApprovalError):
"""ApprovalError를 알맞은 HTTP 오류로 바꿔 던진다 (not_found → 404, not_pending → 409, same_device → 403)."""
raise HTTPException(_ERROR_STATUS.get(e.code, 400), detail=e.message)
@router.get("")
async def list_approvals(
request: Request,
status_filter: str | None = Query("pending", alias="status", pattern="^(pending|approved|rejected|expired|failed)$"),
caller: Caller = Depends(require(APPROVALS_DECIDE)),
):
"""내가 결정할 수 있는 요청만 보인다. 다른 가족의 요청은 보이지 않는다(아이 요청은 관리자에게 보인다)."""
items = request.app.state.approvals.list_for(caller.user.id, caller.user.role, status_filter)
return {"items": [a.public() for a in items]}
async def _decide(approval_id: str, request: Request, caller: Caller, decision: str):
"""승인/거절의 공통 부분. 대기열에서 상태를 한 번만 바꾸고(claim) 감사 로그를 남긴다.
규칙에 어긋나면 이유를 로그에 남기고 HTTP 오류로 끝낸다.
"""
state = request.app.state
user, key = caller.user, caller.device.access_key
try:
approval = state.approvals.claim(approval_id, user.id, user.role, key, decision)
except ApprovalError as e:
state.audit.write("approval.denied", user=user.id, device=key, ip=caller.ip, approval=approval_id,
reason=e.code)
_raise(e)
event = "approval.approve" if decision == APPROVED else "approval.reject"
state.audit.write(event, user=user.id, device=key, ip=caller.ip, approval=approval.id, action=approval.action,
requested_user=approval.requested_user)
return approval
@router.post("/{approval_id}/approve")
async def approve(approval_id: str, request: Request, caller: Caller = Depends(require(APPROVALS_DECIDE))):
"""POST /v1/approvals/{id}/approve : 요청을 승인하고 바로 모듈에 실행을 맡긴다.
실행이 실패하면 상태를 failed로 남긴다. 모듈이 "입력이 틀렸다"(422)고 하면 그 메시지를,
그 밖의 실패는 502를 돌려준다. 자세한 원인은 서버 로그에만 남긴다.
"""
state = request.app.state
approval = await _decide(approval_id, request, caller, APPROVED)
try:
result = await _execute(state, approval)
except ModuleError as e:
logger.warning("approval %s execution failed: %s %s", approval.id, e.status, e.message)
code, message = (422, e.message) if e.status == 422 else (502, "실행에 실패했습니다.")
except Exception:
logger.exception("approval %s execution failed", approval.id)
code, message = 502, "실행에 실패했습니다."
else:
approval = state.approvals.set_result(approval.id, APPROVED, result)
state.audit.write("approval.executed", user=caller.user.id, device=caller.device.access_key, ip=caller.ip,
approval=approval.id, action=approval.action)
return {"approval": approval.public()}
state.approvals.set_result(approval.id, FAILED, message)
state.audit.write("approval.failed", user=caller.user.id, device=caller.device.access_key, ip=caller.ip,
approval=approval.id, action=approval.action)
raise HTTPException(code, detail=message)
@router.post("/{approval_id}/reject")
async def reject(approval_id: str, request: Request, caller: Caller = Depends(require(APPROVALS_DECIDE))):
"""POST /v1/approvals/{id}/reject : 요청을 거절한다. 실행하지 않는다."""
approval = await _decide(approval_id, request, caller, REJECTED)
return {"approval": approval.public()}
async def _execute(state, approval) -> str:
"""승인된 요청을 모듈에 넘겨 실행하고 결과 문구를 돌려준다.
허브는 payload 안의 params가 무슨 뜻인지 모른다. 요청할 때 받아 둔 그대로 모듈에 넘긴다.
요청한 사람이 그사이 비활성화됐을 수 있으니 실행 직전에 한 번 더 확인한다.
"""
requester = state.registry.user(approval.requested_user)
if requester is None or requester.disabled:
raise RuntimeError("requester disabled")
p = approval.payload
return await state.catalog.execute(p["module"], p["action"], p["params"], caller_context(requester))

_execute()에서 요청한 사람이 그사이 비활성화되지 않았는지 한 번 더 확인한다. 입력값은 허브가 해석하지 않고 받아 둔 그대로 모듈에 넘긴다. 모듈이 실행에 실패하면 승인 상태를 failed로 남기고, 모듈이 “입력이 틀렸다”(422)고 하면 그 메시지를, 그 밖의 실패면 “실행에 실패했습니다”만 준다.

API를 정리하면 이렇다.

메서드 경로 scope 설명
GET /healthz 없음 상태 확인 (Docker 헬스체크용)
GET /v1/me system:read 내 사용자, 기기, 실제 권한
GET /v1/modules system:read 쓸 수 있는 모듈과 액션
POST /v1/commands <모듈>:<액션> 명령. 승인이 필요하면 202, 아니면 200
GET /v1/approvals?status=pending approvals:decide 내가 결정할 수 있는 요청 목록 (요약만)
POST /v1/approvals/{id}/approve approvals:decide 승인하면 모듈이 실행
POST /v1/approvals/{id}/reject approvals:decide 거절
GET /v1/events?after=<id> system:read 나와 가족 모두에게 온 이벤트
POST /internal/events 모듈 토큰 모듈 → 허브. 바깥에서는 404
잠깐, BaseModel과 Field가 뭐지?

CommandRequest(BaseModel)처럼 클래스를 만들고 필드마다 Field(min_length=1, max_length=200) 같은 조건을 달았다. 7편에서 본 Pydantic 모델이다. FastAPI가 요청 본문을 이 모양으로 자동 검증해 준다.

pydantic_demo.py
from pydantic import BaseModel, EmailStr, Field, ValidationError
class MailRequest(BaseModel):
to: list[EmailStr] = Field(min_length=1, max_length=5)
subject: str = Field(min_length=1, max_length=200)
print(MailRequest(to=["mom@example.com"], subject="퇴근"))
try:
MailRequest(to=["이건-메일-주소가-아님"], subject="")
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["msg"])
$ python3 pydantic_demo.py
to=['mom@example.com'] subject='퇴근'
('to', 0) value is not a valid email address: An email address must have an @-sign.
('subject',) String should have at least 1 character

조건에 맞으면 객체가 만들어지고, 안 맞으면 어느 필드가 왜 틀렸는지 알려 준다. EmailStr은 이메일 형식까지 봐 준다. FastAPI에서는 API 함수 인자에 req: CommandRequest라고만 적으면, 검증에 실패한 요청은 함수에 들어오기 전에 422로 돌려보낸다. 허브의 CommandRequest는 모듈 이름과 액션 이름 모양만 보고, params 안쪽은 모듈이 직접 검사한다. 12편의 메일 모듈은 SendParams.model_validate()로 검사한다. 각자 아는 만큼만 검사하는 거다. Spring의 @Valid + Bean Validation과 같은 역할이다. 그리고 5편의 main.py에서 검증 오류 응답에 입력값 자체는 돌려주지 않도록 고쳐 둔 게 바로 이 오류다.

정리하면

  • 명령: 모든 모듈은 POST /v1/commands 하나로. 모듈이 늘어도 클라이언트는 그대로
  • 승인 대기열: 모듈이 “승인 필요”라고 한 명령은 붙잡아 두고, 승인되면 그대로 모듈에 넘긴다
  • 승인 규칙: 내 요청은 내 다른 기기가, 아이 요청은 관리자가. 다른 가족 요청은 없는 것처럼 404. 두 번 눌러도 한 번만 실행

다음 9편에서는 반대 방향, 모듈이 먼저 허브에 소식을 알려야 할 때의 이벤트를 만들고, 가짜 모듈로 지금까지 만든 길을 끝까지 테스트한다.

그럼 안녕!
안녕

[ 꼬리말 ]승인 후 게시됩니다

꼬리말을 불러오는 중...