Site Icon THEREiSLatest Posts · reading #013한국어

Title :

Building a Home Secretary #7 - Server: The Hub–Module Contract

Author
[Yahum]
Date
Tags

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

From part 3 to part 6, I built Secretary’s gatekeeper. It checks the HMAC signature on every request, separates people and devices by family, and limits what can be done with device scopes and role ceilings.

Now I’m building the other half of the hub: the path that connects to modules. In parts 7 through 10, I define the contract between the hub and modules, and write the command entry point that calls every module, the approval queue that holds back irreversible commands, events for modules to report news first, and even the Dockerfile, docker-compose.yml, and Jenkinsfile for putting it on the NAS. There isn’t a single real module yet. The first module, mail, gets built according to this contract in part 12.

In this part, I first build the audit log that records who did what, define the contract between the hub and modules, and build how the hub calls modules.

Once again, there’s quite a lot of code. Let’s follow along one piece at a time.

Step 1. Audit log (audit.py)

services/hub/app/audit.py
"""Audit log. One JSON object per line (JSONL).
Secrets and email bodies are never recorded. Only who (person, device), when, what, and how it turned out.
"""
import json
import logging
import threading
import time
from pathlib import Path
logger = logging.getLogger("secretary.audit")
_lock = threading.Lock()
class AuditLog:
"""Audit log writer. It only appends lines to the end of the file, never edits or deletes them."""
def __init__(self, path: Path):
"""Remember the log file path, and create the folder if it doesn't exist."""
self.path = path
path.parent.mkdir(parents=True, exist_ok=True)
def write(self, event: str, *, user: str | None = None, device: str | None = None, ip: str | None = None,
**fields) -> None:
"""Record one event as one line of JSON. The same line also goes to the server log (docker logs).
event : What happened. e.g. "auth.fail", "approval.approve"
user, device, ip : Who, with which device, from where
**fields : Any other info. e.g. reason="bad_signature". Never include secrets or email bodies.
"""
record = {"ts": int(time.time()), "event": event, "user": user, "device": device, "ip": ip, **fields}
line = json.dumps(record, ensure_ascii=False, default=str)
with _lock:
with self.path.open("a", encoding="utf-8") as f:
f.write(line + "\n")
logger.info(line)

It’s the JSONL format, one JSON object per line. That makes it easy to search later with grep or jq. Here’s what the actual log looks like.

{"ts": 1793650406, "event": "approval.create", "user": "dad", "device": "dad-glasses-7ebb279d", "ip": "127.0.0.1", "approval": "nCCzRFogNQvd", "action": "mail.send", "rule": "same_user"}
{"ts": 1793650406, "event": "auth.forbidden", "user": "dad", "device": "dad-glasses-7ebb279d", "ip": "127.0.0.1", "scope": "approvals:decide", "path": "/v1/approvals"}
{"ts": 1793650406, "event": "auth.fail", "user": null, "device": "dad-glasses-7ebb279d", "ip": "127.0.0.1", "reason": "bad_signature", "path": "/v1/me"}

The audit log piles up in /data/audit.log inside the container. With one JSON object per line, grep alone is enough to pick out the lines you want. To see only what Dad (dad) did, do this.

터미널
$ docker exec secretary grep '"user": "dad"' /data/audit.log
{"ts": 1793650406, "event": "approval.create", "user": "dad", "device": "dad-glasses-7ebb279d", "ip": "127.0.0.1", "approval": "nCCzRFogNQvd", "action": "mail.send", "rule": "same_user"}
{"ts": 1793650406, "event": "auth.forbidden", "user": "dad", "device": "dad-glasses-7ebb279d", "ip": "127.0.0.1", "scope": "approvals:decide", "path": "/v1/approvals"}

If you write it without the space, like "user":"dad", nothing comes up. json.dumps puts a space after : by default, so the log records "user": "dad". Requests where the signature was wrong and nobody knows who it was are recorded as "user": null, so they get filtered out.

It records who (person and device), when, and what, but never records Secrets or command input (like email addresses or bodies). The hub doesn’t even know what the input means, so it’s right not to carelessly record things it doesn’t understand. This gets checked with a test later too.

Wait, what’s **fields?

write(self, event, *, user=None, device=None, ip=None, **fields). At the end of the argument list there’s **fields, with two asterisks.

When you use **fields while defining a function, it receives every “name=value” argument that doesn’t have a predefined name, gathered into a single dictionary. That’s why the audit log can take everything with one function even though each event records different info (reason=, approval=, rule= …). Conversely, when you use **dictionary while calling a function or inside a dictionary, it unpacks the dictionary. The single asterisk earlier (*,) marks “every argument after this must be passed by name.” Passing by position like write("x", "dad") raises an error; you have to pass user="dad".

kwargs_demo.py
def write(event, *, user=None, **fields):
print(event, user, fields)
write("approval.create", user="dad", approval="nCCzRFogNQvd", rule="same_user")
info = {"approval": "nCCzRFogNQvd", "action": "mail.send"}
write("approval.approve", user="mom", **info)
print({**info, "rule": "admin"})
터미널
$ python3 kwargs_demo.py
approval.create dad {'approval': 'nCCzRFogNQvd', 'rule': 'same_user'}
approval.approve mom {'approval': 'nCCzRFogNQvd', 'action': 'mail.send'}
{'approval': 'nCCzRFogNQvd', 'action': 'mail.send', 'rule': 'admin'}

The first line is gathering, the second is unpacking into a call, and the third is unpacking and merging inside a dictionary. The {**CF, **build_headers(...)} in the test code has the same shape as the third line. It merges the Cloudflare headers and the signature headers into one dictionary.

Step 2. The hub–module contract (docs/module-contract.md)

Let’s look again at the big picture I drew in part 3. The hub doesn’t know what email is, and modules don’t know who people are. Then how do they work together? You define a contract. The hub and modules only have to keep this contract, and they don’t need to know each other’s code. Modules can be written in any language.

Modules open exactly four APIs.

API What it does
GET /manifest The module’s self-introduction. “Here’s who I am and what I can do”
POST /actions/{action}/validate Checks the input before execution and returns a one-line summary to show on the approval screen
POST /actions/{action} Actually executes it
GET /healthz Whether it’s alive. For the Docker health check

The self-introduction (manifest) looks like this. Here’s a preview of the manifest for the mail module I’ll build in part 12.

GET /manifest
{
"name": "mail",
"title": "Mail",
"actions": {
"send": {
"title": "Send email",
"approval": true,
"roles": null,
"params": { "type": "object", "required": ["to", "subject", "body"] }
}
}
}

This is where the permissions story from part 4 connects.

  • Required scopes aren’t written separately. They’re set automatically as <module>:<action>, so mail:send.
  • approval: If true, the hub doesn’t execute it right away but puts it on the approval queue. The module knows best whether an action is irreversible. So the module declares, and the hub enforces.
  • roles: The roles allowed to use this action. null means all roles. Something like opening the front door can be locked down with ["admin"].
  • params: A description of the input format. It’s for apps to refer to when drawing input screens; the actual validation is done by the module.

The request body the hub sends to a module always has the same shape. It passes only the input (params) and who asked for it (caller). Auth info like device keys or Secrets is never passed. Authentication is already done at the hub, after all.

POST /actions/send/validate
{
"params": { "to": ["grandma@family.example"], "subject": "Leaving work", "body": "Heading out now" },
"caller": { "user_id": "dad", "name": "Dad", "role": "admin", "email": "dad@example.com" }
}

If the input is fine, it returns 200 {"summary": "Mail → grandma@family.example : Leaving work"}; if it’s wrong, 422 {"message": "Some recipients aren't allowed."}. The hub shows the 422 message to the user as is, and for other failures (not connected, 500), records the detailed reason only in the server log.

And the hub and modules verify each other too. Each module gets its own internal token, shared with the hub, and the hub attaches Authorization: Bearer <token> when calling the module. If the token is wrong, the module returns 401. Tokens differ per module, so even if one module is compromised, it can’t call other modules.

Here’s the flow of handling one command as a diagram, using the mail module as an example.

sequenceDiagram participant G as Glasses participant H as Hub participant M as Mail module participant W as Watch G->>H: POST /v1/commands (HMAC signature) H->>H: Authenticate, check scope mail:send H->>M: GET /manifest (every minute) H->>M: POST /actions/send/validate M-->>H: 200 summary H->>H: approval: true → approval queue H-->>G: 202 awaiting approval W->>H: POST /v1/approvals/{id}/approve H->>M: POST /actions/send M-->>H: 200 sent H-->>W: 200 approved

I wrote this contract up as a document in the repository. When building a new module, this one document is all you need. See the module contract document

Step 3. How the hub calls modules (catalog.py)

Now the hub side. The hub looks at the module list in its settings (SECRETARY_MODULES), fetches each module’s self-introduction, and remembers it. The module catalog does this job.

services/hub/app/catalog.py
"""Module catalog. The list of modules the hub knows, and the HTTP client for calling them.
The hub doesn't import module code. It fetches the manifest (self-introduction) from the URL in the settings,
and when a command comes in, it just sends an HTTP request to that URL. See docs/module-contract.md for the contract.
"""
import logging
import re
import time
import httpx
from pydantic import BaseModel, Field, ValidationError, field_validator
logger = logging.getLogger("secretary.catalog")
MANIFEST_TTL_SECONDS = 60
# Action names go into URL paths and scopes, so only lowercase letters, digits, _, and -
ACTION_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,31}")
class ModuleError(Exception):
"""Raised when calling a module fails. status is the HTTP status code to return to the client."""
def __init__(self, status: int, message: str):
super().__init__(message)
self.status = status
self.message = message
class ActionSpec(BaseModel):
"""One action in a manifest. The required scope isn't written separately; it's set as "<module>:<action>"."""
title: str = Field(max_length=60)
approval: bool = False
roles: list[str] | None = None
params: dict | None = None
class Manifest(BaseModel):
"""A module's self-introduction. The GET /manifest response is checked against this shape. Modules with the wrong shape aren't used."""
name: str
title: str = Field(max_length=60)
actions: dict[str, ActionSpec]
@field_validator("actions")
@classmethod
def _action_names(cls, actions: dict) -> dict:
"""If even one action breaks the action name rule (ACTION_RE), reject the whole manifest."""
bad = [a for a in actions if not ACTION_RE.fullmatch(a)]
if bad:
raise ValueError(f"bad action names: {bad}")
return actions
class ModuleCatalog:
"""Remembers the manifests of modules registered in the settings, and sends requests to modules."""
def __init__(self, endpoints: dict[str, tuple[str, str]], timeout: float = 10.0, transport=None):
"""endpoints: module name → (URL, token). transport is passed only in tests, to attach a fake module."""
self.endpoints = endpoints
self._http = httpx.AsyncClient(timeout=timeout, transport=transport)
self._cache: dict[str, tuple[float, Manifest]] = {}
async def close(self) -> None:
"""Close the HTTP connections. Called when the server stops."""
await self._http.aclose()
def _headers(self, name: str) -> dict:
"""Headers with that module's internal token. The module only accepts requests when this token matches."""
return {"Authorization": f"Bearer {self.endpoints[name][1]}"}
async def manifest(self, name: str) -> Manifest:
"""Return the module's manifest. Use the remembered one for 1 minute, then fetch it again.
503 if the module is down. Even if refetching fails, use the previously fetched manifest if there is one.
"""
if name not in self.endpoints:
raise ModuleError(404, "No such module.")
cached = self._cache.get(name)
if cached and time.monotonic() - cached[0] < MANIFEST_TTL_SECONDS:
return cached[1]
url = self.endpoints[name][0]
try:
res = await self._http.get(f"{url}/manifest", headers=self._headers(name))
res.raise_for_status()
manifest = Manifest.model_validate(res.json())
if manifest.name != name:
raise ValueError(f"manifest name mismatch: {manifest.name}")
except (httpx.HTTPError, ValidationError, ValueError) as e:
logger.warning("module %s manifest failed: %s", name, e)
if cached:
return cached[1]
raise ModuleError(503, "Can't connect to the module.") from e
self._cache[name] = (time.monotonic(), manifest)
return manifest
async def action(self, name: str, action: str) -> ActionSpec:
"""Find one of the module's actions. 404 if it doesn't exist."""
spec = (await self.manifest(name)).actions.get(action)
if spec is None:
raise ModuleError(404, "No such action.")
return spec
async def _post(self, name: str, path: str, params: dict, caller: dict) -> dict:
"""Send a POST to the module and return the JSON response.
If the module returns 422, show that message to the user as is (because the input was wrong).
Other failures (not connected, 500, 401) record details only in the server log and become 502.
"""
url = self.endpoints[name][0]
try:
res = await self._http.post(f"{url}{path}", json={"params": params, "caller": caller},
headers=self._headers(name))
except httpx.HTTPError as e:
logger.warning("module %s %s failed: %s", name, path, e)
raise ModuleError(502, "Can't connect to the module.") from e
if res.status_code == 422:
try:
message = str(res.json().get("message", ""))[:200]
except ValueError:
message = ""
raise ModuleError(422, message or "The request content is invalid.")
if res.status_code != 200:
logger.warning("module %s %s returned %s", name, path, res.status_code)
raise ModuleError(502, "The module failed to execute.")
return res.json()
async def validate(self, name: str, action: str, params: dict, caller: dict) -> str:
"""Have the input checked before execution, and get a one-line summary to show on the approval screen."""
data = await self._post(name, f"/actions/{action}/validate", params, caller)
return str(data.get("summary", f"{name}.{action}"))[:200]
async def execute(self, name: str, action: str, params: dict, caller: dict) -> str:
"""Hand the actual execution to the module and return the result text."""
data = await self._post(name, f"/actions/{action}", params, caller)
return str(data.get("result", "Done"))[:200]
def caller_context(user) -> dict:
"""The "who asked for it" info to pass to modules. Auth info like device keys or Secrets is never passed."""
return {"user_id": user.id, "name": user.name, "role": user.role, "email": user.email}
  • Self-introductions are remembered for 1 minute. Refetching the self-introduction every time a command comes in would be slow. After 1 minute it’s fetched again, and even if the module is briefly down at that moment, it holds up with the previously fetched one.
  • The shape of the self-introduction is checked too. Manifest and ActionSpec are Pydantic models, so self-introductions with the wrong shape or odd action names (like ../admin) aren’t accepted. That’s because action names go into URL paths and scopes.
  • 503 if the module is down, 502 if the module fails. Detailed reasons go only to the server log.
  • In tests, a fake module is attached via transport. You can test without a real network (part 9).
Wait, what’s a Pydantic model?

If you inherit from BaseModel, like class Manifest(BaseModel), and write a type for each field, that’s a Pydantic model. It’s a blueprint that says “data must have this shape.” If the incoming data matches the blueprint, an object is created; if not, you get a ValidationError. It validates without a single if statement.

  • Types and defaults: approval: bool = False means “must be true/false, and False if missing.” title, with no default, must be present.
  • Field(...): Adds conditions that types alone can’t express. Field(max_length=60) means up to 60 characters.
  • Models inside models: Write actions: dict[str, ActionSpec] and every value in the dictionary is also checked against the ActionSpec blueprint.
  • model_validate(dictionary): Holds JSON received from outside (res.json()) up against the blueprint. This is the line that checks the self-introduction a module sent instead of trusting it.
  • @field_validator: Adds rules you can’t express with types. The action name rule (ACTION_RE) is one. Just raise a ValueError when the rule is broken, and Pydantic turns it into a ValidationError.

So, to put it simply, it lets you put types on fields. Old-school developers like me hate dynamically typed languages, so we put types on things even in Python.

I trimmed down the models from catalog.py and fed in three self-introductions.

manifest_demo.py
import re
from pydantic import BaseModel, Field, ValidationError, field_validator
ACTION_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,31}")
class ActionSpec(BaseModel):
title: str = Field(max_length=60)
approval: bool = False
class Manifest(BaseModel):
name: str
actions: dict[str, ActionSpec]
@field_validator("actions")
@classmethod
def _action_names(cls, actions: dict) -> dict:
bad = [a for a in actions if not ACTION_RE.fullmatch(a)]
if bad:
raise ValueError(f"bad action names: {bad}")
return actions
def check(data: dict) -> None:
try:
m = Manifest.model_validate(data)
print("Passed:", m.actions["send"].title, m.actions["send"].approval)
except ValidationError as e:
for err in e.errors():
print("Rejected:", err["loc"], err["msg"])
check({"name": "mail", "actions": {"send": {"title": "Send email", "approval": True}}})
check({"name": "mail", "actions": {"send": {"approval": "maybe"}}})
check({"name": "mail", "actions": {"../admin": {"title": "Admin"}}})
터미널
$ python3 manifest_demo.py
Passed: Send email True
Rejected: ('actions', 'send', 'title') Field required
Rejected: ('actions', 'send', 'approval') Input should be a valid boolean, unable to interpret input
Rejected: ('actions',) Value error, bad action names: ['../admin']

The first matches the blueprint and passes. After passing, you can access things as attributes, like m.actions["send"].title, instead of as a dictionary. The second has no title, and approval isn’t true/false. When two things are wrong, it reports both, and loc even gives the path to where it went wrong. The third has the right shape, but the action name trips the @field_validator rule. catalog.py doesn’t use a self-introduction rejected like this, and the reason goes only to the server log.

To sum up

  • Audit log: Who (person, device) did what and when, as JSONL. Secrets and input values aren’t recorded
  • Contract: The hub and modules connect only through four endpoints: /manifest, /actions/{action}/validate, /actions/{action}, and /healthz. Whether approval is needed and role restrictions are declared by the module and enforced by the hub
  • Calling modules: Self-introductions are remembered for 1 minute, and even their shape is checked. 503 if the module is down, 502 if it fails

Now the hub knows who the modules are and what they can do, and it can call them. In part 8, I build the command entry point that clients use to call every module, and the approval queue that holds back irreversible commands.

Bye for now!
I collapsed from exhaustion.

[ Comments ]Shown after approval

Loading comments...