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

7편에서 허브와 모듈 사이의 약속을 정하고, 허브가 모듈을 부르는 길을 만들었다.
이번에는 그 길에 안전장치를 단다. 메일처럼 한 번 나가면 되돌릴 수 없는 일은 바로 실행하지 않고 다른 기기의 승인을 거치게 하는 승인 대기열, 그리고 클라이언트가 모든 모듈을 부르는 단 하나의 입구 POST /v1/commands다.
Step 1. 승인 대기열 (approvals.py)
모듈이 "approval": true라고 적은 액션은 바로 실행하지 않고 여기 올린다. 승인 대기열은 모듈 내용을 모른다. 어떤 모듈의 어떤 액션인지, 입력값이 뭐였는지를 그대로 보관했다가, 승인되면 그대로 모듈에 넘길 뿐이다.
"""승인 대기열.
모듈이 manifest에 "approval": true 로 적은 액션은 바로 실행하지 않고 여기 올린다.대기열은 모듈 내용을 모른다. action("mail.send")과 payload(모듈, 액션, 입력값)를 보관했다가승인되면 그대로 모듈에 넘길 뿐이다. 누가 승인할 수 있는지는 요청할 때 정해진다.
same_user : 요청한 사람의 "다른" 기기만 승인할 수 있다 (관리자, 어른 가족) admin : 관리자의 기기만 승인할 수 있다 (아이의 요청)
어느 경우든 요청한 기기 자신은 승인할 수 없고, 다른 가족의 요청은 보이지도 않는다."""import jsonimport secretsimport timefrom dataclasses import dataclass
from .auth.scopes import ADMIN, CHILDfrom .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
@dataclassclass 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"}처럼 보내면 되니, 앱을 다시 만들 필요가 없다.
"""모든 모듈을 부르는 하나의 입구: POST /v1/commands, 그리고 쓸 수 있는 모듈 목록 GET /v1/modules.
모듈이 늘어나도 이 파일은 바뀌지 않는다. 클라이언트는 {"module", "action", "params"}만 보내면 된다."""from fastapi import APIRouter, Depends, HTTPException, Request, statusfrom fastapi.responses import JSONResponsefrom pydantic import BaseModel, Field
from ..approvals import RULE_ADMINfrom ..auth.guard import Caller, authenticate, requirefrom ..auth.scopes import SYSTEM_READfrom ..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}순서는 이렇다.
- 권한부터 본다. 기기와 역할이
mail:send를 허락하는지. 권한이 없으면 그런 모듈이 있는지 없는지도 알려 주지 않고 403이다. - 모듈에 입력을 검사받는다. 틀렸으면 모듈이 준 메시지와 함께 422.
- 승인이 필요한 액션이면 대기열에 올리고
202 Accepted. “받았고, 처리는 나중에”라는 뜻이다. 아니면 바로 실행하고 200.
/v1/modules는 이 기기로 쓸 수 있는 모듈과 액션 목록이다. 앱이나 안경이 메뉴를 그릴 때 쓴다. 권한이 없는 액션은 아예 안 보인다.
승인 API다. 승인되면 그때 모듈에 실행을 맡긴다.
"""승인 대기열 API: 목록, 승인(그리고 실행), 거절."""import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from ..auth.guard import Caller, requirefrom ..auth.scopes import APPROVALS_DECIDEfrom ..approvals import APPROVED, FAILED, REJECTED, ApprovalErrorfrom ..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가 요청 본문을 이 모양으로 자동 검증해 준다.
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.pyto=['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편에서는 반대 방향, 모듈이 먼저 허브에 소식을 알려야 할 때의 이벤트를 만들고, 가짜 모듈로 지금까지 만든 길을 끝까지 테스트한다.
그럼 안녕!

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