Site Icon THEREiSLatest Posts · reading #014한국어

Title :

Building a Home Secretary #8 - Server: Commands and the Approval Queue

Author
[Yahum]
Date
Tags

This post is translated from the Korean original. Read in Korean →

In part 7, I defined the contract between the hub and modules and built the path for the hub to call modules.

This time, I add safety devices to that path. An approval queue that, instead of running things like email that can’t be undone once sent, makes them go through approval from another device, and the single entry point clients use to call every module: POST /v1/commands.

Step 1. The approval queue (approvals.py)

Actions a module marks with "approval": true aren’t executed right away but go up here. The approval queue doesn’t know module content. It just keeps which action of which module it was and what the input was, and when approved, passes it to the module as is.

services/hub/app/approvals.py
"""Approval queue.
Actions a module marks with "approval": true in its manifest aren't executed right away but go up here.
The queue doesn't know module content. It keeps the action ("mail.send") and payload (module, action, input)
and, when approved, passes them to the module as is. Who can approve is decided at request time.
same_user : Only the requester's "other" devices can approve (admins, adult family members)
admin : Only admin devices can approve (children's requests)
Either way, the requesting device itself can't approve, and other family members' requests aren't even visible.
"""
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):
"""A rule violation during approval handling. code is used to map to an HTTP status, message is shown to the user."""
def __init__(self, code: str, message: str):
"""Holds the code (e.g. not_found, same_device) together with the message to show the user."""
super().__init__(message)
self.code = code
self.message = message
@dataclass
class Approval:
"""One request in the approval queue (one row of the DB approvals table)."""
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:
"""A dict for API responses. Leaves out the payload (email body, etc.) and includes only the summary."""
# Queue listings expose only the summary instead of the full body. That's enough for a watch or glasses screen.
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:
"""Turn one row from the DB into an Approval object. payload is a JSON string, so unpack it back into a 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:
"""Decide the approval rule from the requester's role. Children need admin approval (admin); others need their own other device (same_user)."""
return RULE_ADMIN if role == CHILD else RULE_SAME_USER
def can_decide(approval: Approval, user_id: str, role: str) -> bool:
"""Whether this person can approve/reject this request.
admin rule : when they're an admin and not the requester
same_user rule: when they're the requester (whether it's the same device is blocked separately in claim())
"""
if approval.rule == RULE_ADMIN:
return role == ADMIN and user_id != approval.requested_user
return user_id == approval.requested_user
class ApprovalQueue:
"""Approval queue. Puts up requests (create), finds them (get, list_for), decides them (claim), and records results (set_result)."""
def __init__(self, db: Database, ttl_seconds: int):
"""Takes the DB and how long a request stays valid (seconds). If nobody decides within this time, it expires."""
self.db = db
self.ttl = ttl_seconds
def _expire(self, c, now: int) -> None:
"""Change pending requests past their validity to expired.
There's no separate timer; this is called first to clean up every time the queue is read or written.
"""
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:
"""Put a request on the queue in pending status and return the saved Approval.
The ID is a hard-to-guess random string. The approval rule (rule_for) is fixed at this point and never changes later.
"""
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:
"""Find one request by ID. None if not found. Handles expiry first before reading."""
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]:
"""Return only the requests this person can decide. Other family members' requests aren't visible."""
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:
"""Change pending → approved/rejected exactly once. Even if approved twice at the same time, it runs once."""
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()
# Someone else's request gets the exact same answer as a "nonexistent request." We don't even reveal it exists.
if r is None or not can_decide(_row(r), user_id, role):
raise ApprovalError("not_found", "Request not found.")
if r["requested_by"] == device_key:
raise ApprovalError("same_device", "You can't approve from the device that made the request. Please approve from another device.")
if r["status"] != PENDING:
raise ApprovalError("not_pending", "This request has already been handled or has expired.")
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:
"""Whether this person has made even one request (regardless of status)."""
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:
"""Record the final status (approved/failed) and result text on a request whose execution has finished."""
with self.db.tx() as c:
c.execute("UPDATE approvals SET status = ?, result = ? WHERE id = ?", (status, result, approval_id))
return self.get(approval_id)

Who can approve is decided at request time.

  • same_user: Requests from admins or adult family members. Only the requester’s other devices can approve. If Dad’s glasses make a request, Dad’s phone or watch approves it.
  • admin: Children’s requests. Only admin devices can approve. If the kid tries to send an email to Grandma, an approval request pops up on Dad’s watch.

The claim() function is the key. Because it checks and changes within a transaction, these things can’t happen.

  • Approved twice, executed once: Even if approve is pressed on the phone and the watch at the same time, only the one that changes it first succeeds in pending → approved. The other gets a 409 saying “already handled.”
  • The requesting device can’t approve: Even a device like the phone, which has both request and approval permissions, can’t approve its own request.
  • Other family members’ requests act as if they don’t exist: If Dad tries to approve Mom’s request, the answer isn’t “permission denied” (403) but “Request not found” (404). They don’t show up at all in the list (list_for) either. It’s what I said in the secure coding post about answering someone else’s order number with 404 instead of 403. Don’t even reveal that it exists.
  • Expires after 5 minutes: A request someone forgot to approve can’t get accidentally approved days later.

And the list shown on watch or glasses screens includes only the summary the module made, like Dad: Mail → grandma@family.example : Leaving work, instead of the input. That’s enough for a small screen, and there’s no need for the body to travel all over the place.

Step 2. Command and approval APIs (routers)

In part 5, I opened the “Who am I?” API (/v1/me). This time it’s the real business APIs. But there’s no API like /v1/mail. The entry point for calling every module is the single /v1/commands. Clients just send {"module": "mail", "action": "send", "params": {...}}. Even when a bulb module comes along, you just send something like {"module": "tapo", "action": "on"}, so there’s no need to rebuild the app.

services/hub/app/routers/commands.py
"""A single entry point for calling every module: POST /v1/commands, plus the list of usable modules, GET /v1/modules.
This file doesn't change as modules grow. Clients just send {"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):
"""Command request body. The inner shape of params is checked by the module, not the hub."""
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)):
"""Handle one command.
1. Permission: whether the device and role allow the "<module>:<action>" scope (and the manifest's roles)
2. Validation: have the module check the input and get a summary
3. If the action needs approval, put it on the queue and return 202; otherwise execute right away and return 200
"""
state = request.app.state
user, key = caller.user, caller.device.access_key
scope = f"{req.module}:{req.action}"
# Check permission first. Devices without permission aren't even told whether such a module exists.
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 = "an admin device" if approval.rule == RULE_ADMIN else "another one of your devices"
return JSONResponse({"approval": approval.public(), "message": f"It will run once approved from {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))):
"""The modules and actions usable with this device. Used by the app or glasses to draw menus.
Modules that are down are shown as available: false. Actions without permission are left out of the list.
"""
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}

Here’s the order.

  1. Check permission first. Whether the device and role allow mail:send. Without permission, it’s a 403 without even saying whether such a module exists.
  2. Have the module check the input. If it’s wrong, 422 with the message the module gave.
  3. If the action needs approval, put it on the queue and return 202 Accepted. It means “received, will be handled later.” Otherwise execute right away and return 200.

/v1/modules is the list of modules and actions usable with this device. The app or glasses use it to draw menus. Actions without permission don’t show up at all.

Now the approval API. When approved, that’s when it hands execution to the module.

services/hub/app/routers/approvals.py
"""Approval queue API: list, approve (and execute), reject."""
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):
"""Turn an ApprovalError into the matching HTTP error and raise it (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)),
):
"""Only requests I can decide are shown. Other family members' requests aren't visible (children's requests are visible to admins)."""
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):
"""The common part of approve/reject. Changes the status on the queue exactly once (claim) and writes the audit log.
If it breaks the rules, record the reason in the log and end with an HTTP error.
"""
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 : Approve the request and immediately hand execution to the module.
If execution fails, the status is recorded as failed. If the module says "the input is wrong" (422), return that message;
for other failures, return 502. Detailed causes go only to the server log.
"""
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, "Execution failed.")
except Exception:
logger.exception("approval %s execution failed", approval.id)
code, message = 502, "Execution failed."
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 : Reject the request. It isn't executed."""
approval = await _decide(approval_id, request, caller, REJECTED)
return {"approval": approval.public()}
async def _execute(state, approval) -> str:
"""Pass an approved request to the module for execution and return the result text.
The hub doesn't know what the params in the payload mean. It passes them to the module exactly as received at request time.
The requester may have been disabled in the meantime, so check once more right before executing.
"""
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))

In _execute(), it checks once more that the requester hasn’t been disabled in the meantime. The hub doesn’t interpret the input; it passes it to the module exactly as received. If the module fails to execute, the approval status is recorded as failed; if the module says “the input is wrong” (422), that message is returned, and for other failures, just “Execution failed.”

Here’s a summary of the APIs.

Method Path scope Description
GET /healthz None Status check (for the Docker health check)
GET /v1/me system:read My user, device, and actual permissions
GET /v1/modules system:read Usable modules and actions
POST /v1/commands <module>:<action> Command. 202 if approval is needed, otherwise 200
GET /v1/approvals?status=pending approvals:decide List of requests I can decide (summaries only)
POST /v1/approvals/{id}/approve approvals:decide On approval, the module executes
POST /v1/approvals/{id}/reject approvals:decide Reject
GET /v1/events?after=<id> system:read Events for me and for the whole family
POST /internal/events Module token Module → hub. 404 from outside
Wait, what are BaseModel and Field?

I made a class like CommandRequest(BaseModel) and put conditions like Field(min_length=1, max_length=200) on each field. It’s the Pydantic model we saw in part 7. FastAPI automatically validates the request body against this shape.

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="Leaving work"))
try:
MailRequest(to=["this-is-not-an-email-address"], subject="")
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["msg"])
$ python3 pydantic_demo.py
to=['mom@example.com'] subject='Leaving work'
('to', 0) value is not a valid email address: An email address must have an @-sign.
('subject',) String should have at least 1 character

If the conditions are met, an object is created; if not, it tells you which field was wrong and why. EmailStr even checks the email format. In FastAPI, just write req: CommandRequest as an API function argument, and requests that fail validation get sent back with 422 before entering the function. The hub’s CommandRequest only checks the shape of the module and action names; the inside of params is checked by the module itself. The mail module in part 12 checks it with SendParams.model_validate(). Each one checks only as much as it knows. It plays the same role as Spring’s @Valid + Bean Validation. And this is exactly the error that I made sure, in main.py back in part 5, doesn’t send the input values themselves back in validation error responses.

To sum up

  • Commands: Every module through the single POST /v1/commands. Clients stay the same even as modules grow
  • Approval queue: Commands a module says “need approval” are held back, and when approved, passed to the module as is
  • Approval rules: My requests are approved by my other devices, children’s requests by an admin. Other family members’ requests get 404 as if they don’t exist. Pressing twice still runs only once

In part 9, I build the reverse direction, events for when a module needs to report news to the hub first, and test the whole path built so far end to end with a fake module.

Bye for now!
Bye

[ Comments ]Shown after approval

Loading comments...