Site Icon THEREiSLatest Posts · reading #012한국어

Title :

Building a Home Secretary #6 - Server: Family Registration and Tests

Author
[Yahum]
Date
Tags

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

In part 5, I set up the gatekeeper and assembled the app. This time, I register the family who’ll use that door, and check whether the door really blocks things properly. This wraps up the story of the hub’s gatekeeper.

Step 1. Family registration CLI (cli.py)

User registration and issuing and revoking keys aren’t opened up to an external API. They can only be done inside the NAS with docker exec. The admin page hasn’t been developed yet, so for now I made it command-only through a CLI (Command Line Interface).

services/hub/app/cli.py
"""User/device management CLI. Run only inside the NAS (keys can't be issued through the external API).
# Users (up to SECRETARY_MAX_USERS, 4 by default. The first user is admin)
docker exec -it secretary python -m app.cli user add dad "Dad" --role admin --email dad@example.com
docker exec -it secretary python -m app.cli user add kid1 "Eldest" --role child
docker exec -it secretary python -m app.cli user list
docker exec -it secretary python -m app.cli user disable kid1 # Also revokes all of that person's device keys
docker exec -it secretary python -m app.cli user delete typo-id # Fully deletes only users with no device/approval records
# Devices
docker exec -it secretary python -m app.cli device add dad phone --preset phone
docker exec -it secretary python -m app.cli device list
docker exec -it secretary python -m app.cli device revoke <access_key>
docker exec -it secretary python -m app.cli device revoke-all # Emergency stop
# Modules
docker exec -it secretary python -m app.cli module list # Connection status and action (= scope) list
"""
import argparse
import sys
from datetime import datetime
import httpx
from .audit import AuditLog
from .auth.registry import Registry, RegistryError
from .catalog import Manifest
from .auth.scopes import HUB_SCOPES, PRESETS, ROLES, effective_scopes
from .config import get_settings
from .db import Database
from .approvals import ApprovalQueue
def _fmt(ts: int | None) -> str:
"""Turn a Unix time (seconds) into text like '2026-11-04 21:30'. '-' if there's no value."""
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else "-"
def build_parser() -> argparse.ArgumentParser:
"""Define the command structure. Subcommands like add, list ... hang under user/device.
argparse filters out invalid input and generates -h help automatically.
"""
parser = argparse.ArgumentParser(prog="secretary")
sub = parser.add_subparsers(dest="cmd", required=True)
usr = sub.add_parser("user", help="Manage users").add_subparsers(dest="action", required=True)
ua = usr.add_parser("add", help="Add a user")
ua.add_argument("user_id", help="Lowercase ID. e.g. dad, mom, kid1")
ua.add_argument("name", help="Display name. Also used as the sender name on emails")
ua.add_argument("--role", choices=ROLES, required=True)
ua.add_argument("--email", help="Your own email. Passed along to modules (the mail module uses it for Reply-To and to allow sending to yourself)")
usr.add_parser("list", help="List users")
ud = usr.add_parser("disable", help="Disable a user (also revokes all their device keys)")
ud.add_argument("user_id")
udel = usr.add_parser("delete", help="Fully delete a user (only if they have no device/approval records)")
udel.add_argument("user_id")
dev = sub.add_parser("device", help="Manage devices").add_subparsers(dest="action", required=True)
da = dev.add_parser("add", help="Issue a device key")
da.add_argument("user_id")
da.add_argument("name", help="Lowercase device name. e.g. phone, watch, glasses")
da.add_argument("--preset", choices=sorted(PRESETS))
da.add_argument("--scope", action="append", default=[], help=f"Specify directly (repeatable): {', '.join(sorted(HUB_SCOPES))}, <module>:<action>, <module>:*, modules:*")
dev.add_parser("list", help="List devices")
dr = dev.add_parser("revoke", help="Revoke a device key")
dr.add_argument("access_key")
dev.add_parser("revoke-all", help="Revoke all device keys (emergency stop)")
mod = sub.add_parser("module", help="Check modules").add_subparsers(dest="action", required=True)
mod.add_parser("list", help="Connection status and action list of registered modules")
return parser
def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Parses the command and hands it to _user, _device, or _module.
The return value is the exit code: 0 success, 1 target not found, 2 input error (rule violation).
"""
args = build_parser().parse_args(argv)
settings = get_settings()
if args.cmd == "module":
return _module(settings)
reg = Registry(settings.registry_path, settings.max_users)
audit = AuditLog(settings.audit_path)
try:
if args.cmd == "user":
return _user(args, reg, audit)
return _device(args, reg, audit)
except RegistryError as e:
print(e, file=sys.stderr)
return 2
def _user(args, reg: Registry, audit: AuditLog) -> int:
"""Handle user add / list / disable / delete. Changes are recorded in the audit log with via=cli."""
if args.action == "add":
user = reg.add_user(args.user_id, args.name, args.role, args.email)
audit.write("user.add", user=user.id, via="cli", role=user.role)
print(f"Added user: {user.id} ({user.name}, {user.role})")
return 0
if args.action == "list":
users = reg.users()
active = sum(1 for u in users if not u.disabled)
print(f"Active users {active}/{reg.max_users}")
for u in users:
state = "disabled" if u.disabled else "active"
print(f" {u.id:<12} {u.name:<10} {u.role:<7} {state:<8} {u.email or '-'}")
return 0
if args.action == "disable":
count = reg.disable_user(args.user_id)
audit.write("user.disable", user=args.user_id, via="cli", revoked_devices=count)
print(f"Disabled. Revoked {count} device key(s).")
return 0
if args.action == "delete":
settings = get_settings()
db = Database(settings.db_path)
try:
history = ApprovalQueue(db, settings.approval_ttl_seconds).has_history(args.user_id)
finally:
db.close()
user = reg.delete_user(args.user_id, has_history=history)
audit.write("user.delete", user=user.id, via="cli", role=user.role)
print(f"Deleted: {user.id} ({user.name}, {user.role})")
return 0
return 1
def _device(args, reg: Registry, audit: AuditLog) -> int:
"""Handle device add / list / revoke / revoke-all.
add shows the Secret on screen only this once. If any scopes are dropped because of the role ceiling, it says so too.
"""
if args.action == "add":
scopes = set(PRESETS.get(args.preset, set())) | set(args.scope)
if not scopes:
print("You must specify either --preset or --scope.", file=sys.stderr)
return 2
device = reg.add_device(args.user_id, args.name, scopes)
user = reg.user(args.user_id)
effective = effective_scopes(device.scopes, user.role)
audit.write("device.add", user=user.id, device=device.access_key, via="cli", scopes=device.scopes)
print("Issued a device key. The Secret is shown only this once.\n")
print(f" User : {user.id} ({user.name}, {user.role})")
print(f" Access Key : {device.access_key}")
print(f" Secret : {device.secret}")
print(f" Scopes : {', '.join(sorted(effective))}")
dropped = set(device.scopes) - effective
if dropped:
print(f" (Scopes not actually usable because of the {user.role} role ceiling: {', '.join(sorted(dropped))})")
return 0
if args.action == "list":
for d in reg.devices():
state = "revoked" if d.revoked else "in use"
print(f"{d.access_key:<32} {d.user:<10} {state:<7} issued {_fmt(d.created_at)} {', '.join(d.scopes)}")
return 0
if args.action == "revoke":
ok = reg.revoke(args.access_key)
if ok:
audit.write("device.revoke", device=args.access_key, via="cli")
print("Revoked." if ok else "That key doesn't exist or is already revoked.")
return 0 if ok else 1
if args.action == "revoke-all":
count = reg.revoke_all()
audit.write("device.revoke_all", via="cli", count=count)
print(f"Revoked {count} key(s).")
return 0
return 1
def _module(settings) -> int:
"""Handle module list. Fetch each module's manifest and show its actions and required scopes.
Look at this list when you're not sure which --scope to give a device.
"""
endpoints = settings.module_endpoints
if not endpoints:
print("No modules registered. Check SECRETARY_MODULES.")
return 0
for name, (url, token) in endpoints.items():
try:
res = httpx.get(f"{url}/manifest", headers={"Authorization": f"Bearer {token}"}, timeout=5)
res.raise_for_status()
manifest = Manifest.model_validate(res.json())
except Exception as e: # Whatever the reason, show it as "not connected" and move on to the next module
print(f"{name:<12} not connected {url} ({type(e).__name__})")
continue
print(f"{name:<12} OK {url} ({manifest.title})")
for action, spec in manifest.actions.items():
notes = (["approval required"] if spec.approval else []) + ([f"roles: {', '.join(spec.roles)}"] if spec.roles else [])
print(f" {name}:{action:<20} {spec.title}{' (' + ', '.join(notes) + ')' if notes else ''}")
return 0
if __name__ == "__main__":
sys.exit(main())

Designing and writing CLI commands was nothing but grunt work for me, steeped as I am in GUI development. It was so painful. On top of that, constantly checking along the way that it worked properly and behaved as intended felt like torture.

Ugh, bugs everywhere

Still, I diligently commented the code, so I hope you can follow along with that. I left the detailed explanation of the code there.

I left everything there

If you wrote the code above correctly, the commands below should work too. For example, registering the family members goes like this.

터미널
$ docker exec -it secretary python -m app.cli user add mom "Mom" --role member
The first user must be an admin.
$ docker exec -it secretary python -m app.cli user add dad "Dad" --role admin --email dad@example.com
Added user: dad (Dad, admin)
$ docker exec -it secretary python -m app.cli user add kid1 "Eldest" --role child
Added user: kid1 (Eldest, child)
$ docker exec -it secretary python -m app.cli device add kid1 phone --preset phone
Issued a device key. The Secret is shown only this once.
User : kid1 (Eldest, child)
Access Key : kid1-phone-1b207d04
Secret : ****************************************
Scopes : modules:*, system:read
(Scopes not actually usable because of the child role ceiling: approvals:decide)
$ docker exec -it secretary python -m app.cli user list
Active users 2/4
dad Dad admin active dad@example.com
kid1 Eldest child active -
$ docker exec -it secretary python -m app.cli user disable dad
The last admin can't be disabled.

Trying to register Mom before the admin gets blocked, and giving the kid’s phone the phone preset tells you the approval permission was cut off by the role ceiling. The last admin can’t be disabled.

Like the Cloudflare service token, the Secret is shown only once at issuance. Write it down carefully. If you ever lose the Secret, just create the account again.

If you’re not sure which --scope to give a device, look at module list. For each module attached to the hub, it shows the actions and the scopes those actions need. But right now no modules are attached, so this is all you get.

터미널
$ docker exec -it secretary python -m app.cli module list
No modules registered. Check SECRETARY_MODULES.

How modules introduce themselves to the hub is covered in part 7, and what it looks like with the mail module attached is in part 12.

Step 2. Tests (authentication)

Security code should be tested for “does it block properly” rather than “does it work properly.” I stressed this in part 2 as well. That’s why most of the tests are cases that should be blocked.

services/hub/tests/test_api.py (excerpt)
import json
import time
import httpx
import pytest
from fastapi.testclient import TestClient
from app.auth.registry import Registry
from app.auth.scopes import ADMIN, CHILD, MEMBER, PRESETS
from app.auth.signing import build_headers
from app.config import Settings
from app.main import create_app
from tests import fake_module
CF = {"Cf-Access-Jwt-Assertion": "test-jwt"}
DOWN_TOKEN = "x" * 40
@pytest.fixture
def env(tmp_path):
settings = Settings(
data_dir=tmp_path, require_cf_access=True, lockout_threshold=5,
modules="demo=http://fake/demo,down=http://fake/down",
module_tokens=f"demo={fake_module.TOKEN},down={DOWN_TOKEN}",
)
reg = Registry(settings.registry_path, settings.max_users)
reg.add_user("dad", "Dad", ADMIN, "dad@example.com")
reg.add_user("mom", "Mom", MEMBER, "mom@example.com")
reg.add_user("kid", "Eldest", CHILD)
d = {
"dad_phone": reg.add_device("dad", "phone", PRESETS["phone"]),
"dad_watch": reg.add_device("dad", "watch", PRESETS["watch"]),
"dad_glasses": reg.add_device("dad", "glasses", PRESETS["glasses"]),
"mom_phone": reg.add_device("mom", "phone", PRESETS["phone"]),
"mom_watch": reg.add_device("mom", "watch", PRESETS["watch"]),
"kid_phone": reg.add_device("kid", "phone", PRESETS["phone"]), # Approval permission gets cut by the role ceiling
"dad_ping": reg.add_device("dad", "ping-only", {"demo:ping"}),
}
module_app, executed = fake_module.build()
app = create_app(settings, module_transport=httpx.ASGITransport(app=module_app))
with TestClient(app) as client:
yield client, settings, reg, d, executed
def call(client, device, method, path, body=None, query="", headers=None):
raw = json.dumps(body).encode() if body is not None else b""
h = {**CF, **build_headers(device.access_key, device.secret, method, path, query, raw)}
if body is not None:
h["Content-Type"] = "application/json"
h.update(headers or {})
url = f"{path}?{query}" if query else path
return client.request(method, url, content=raw, headers=h)
def command(client, device, action="send", module="demo", **params):
return call(client, device, "POST", "/v1/commands", {"module": module, "action": action, "params": params})
def request_send(client, device, to="grandma@family.example"):
return command(client, device, "send", to=to)
def pending_ids(client, device):
r = call(client, device, "GET", "/v1/approvals", query="status=pending")
assert r.status_code == 200
return [a["id"] for a in r.json()["items"]]
# ── Authentication ───────────────────────────────────────────

fake_module is a fake module that, instead of a real module, just answers according to the contract. The hub doesn’t know module code, so in tests too, all you need is a fake that answers over HTTP. It’s attached with httpx.ASGITransport and called directly without a network. What the fake module looks like, plus the command and approval tests, are in part 9. call() is a helper function that sends a request with a signature attached.

Three family members appear in the tests: Dad the admin (dad), Mom the adult family member (mom), and the eldest child (kid). The kid’s phone is deliberately given the phone preset to check that the role ceiling properly cuts off permissions.

Here’s what the replay attack test looks like. Send the exact same headers twice, and the second one must be blocked.

def test_replay_rejected(env):
client, _, _, d, _ = env
h = {**CF, **build_headers(d["dad_phone"].access_key, d["dad_phone"].secret, "GET", "/v1/me")}
assert client.get("/v1/me", headers=h).status_code == 200
assert client.get("/v1/me", headers=h).status_code == 401

Here are the tests on the authentication and user management side.

Test What it checks
test_healthz_is_open The health check is open without auth
test_signed_request_succeeds A correct signature passes and says who you are
test_requires_cloudflare_access_header Requests that didn’t go through Cloudflare get 401
test_bad_signature_rejected A wrong Secret gets 401
test_tampered_body_rejected A swapped body gets 401
test_replay_rejected Resending the same request gets 401
test_old_timestamp_rejected A signature from an hour ago gets 401
test_revoked_key_rejected A revoked key gets 401
test_lockout_after_repeated_failures When failures pile up, even a correct signature gets 401 for a while
test_disabled_user_devices_rejected A disabled person’s devices get 401
test_device_scope_enforced The watch can’t send module commands; a device given only demo:ping can only ping (403)
test_role_caps_device_scopes The kid’s phone can’t approve, regardless of preset
test_first_user_must_be_admin The first user is an admin
test_max_users A 5th user can’t be registered; disabling one frees a spot
test_cannot_disable_last_admin The last admin can’t be disabled
test_disable_user_revokes_devices Disabling a person revokes their device keys too
test_names_must_be_ascii Korean (non-ASCII) IDs and device names are rejected
test_registry_file_is_private The store file has permissions 600
test_scope_shapes Malformed scopes like mail or Mail:Send are rejected
test_wildcard_scopes mail:* covers only mail:send; modules:* doesn’t grant approval permission
test_client_signature_matches_server Example client signature = server signature
터미널
$ cd services/hub && pytest -q
.......................................... [100%]
42 passed in 1.86s

All 42 hub tests pass. 21 of them cover authentication and users, from part 3 up to here, and the other 21 cover module commands, approvals, events, and user deletion, which I added while deploying. The command and approval tests are in part 9.

Step 3. Calling it with an example client

As a reference for building other clients (Flutter, G2, Zepp), I made an example that signs and calls using only the standard library. There’s nothing to install, so it runs right away on a Mac.

scripts/secretary_client.py
#!/usr/bin/env python3
"""Example client for calling Secretary. Uses only the standard library.
Other clients (Flutter, G2, Zepp) just need to sign the same way as sign_headers() in this file.
Environment variables:
SECRETARY_URL https://api.example.house
CF_CLIENT_ID Cloudflare Access service token ID
CF_CLIENT_SECRET Cloudflare Access service token Secret
SECRETARY_ACCESS_KEY Secretary device key
SECRETARY_SECRET Secretary device Secret
Usage:
python3 secretary_client.py GET /v1/me
python3 secretary_client.py GET /v1/modules
python3 secretary_client.py POST /v1/commands '{"module":"mail","action":"send","params":{"to":["me@example.com"],"subject":"Test","body":"Hello"}}'
python3 secretary_client.py GET "/v1/approvals?status=pending"
python3 secretary_client.py POST /v1/approvals/<id>/approve
"""
import base64
import hashlib
import hmac
import json
import os
import secrets
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
USER_AGENT = "secretary-client/1.0"
def sign_headers(access_key: str, secret: str, method: str, path: str, query: str, body: bytes) -> dict:
"""Build the 4 signature headers to attach to one request. Exactly the same method as the server's app/auth/signing.py.
When building a client in another language, just port this one function.
"""
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
target = f"{path}?{query}" if query else path
canonical = "\n".join([method.upper(), target, timestamp, nonce, hashlib.sha256(body).hexdigest()])
digest = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).digest()
return {
"X-Access-Key": access_key,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": base64.urlsafe_b64encode(digest).decode().rstrip("="),
}
def main() -> int:
"""Send the request given as command-line arguments, and print the status code and response JSON nicely.
Attaches both the Cloudflare Access service token headers and the Secretary signature headers. Exit code 1 for 400 and above.
"""
if len(sys.argv) < 3:
print(__doc__)
return 2
method, target = sys.argv[1].upper(), sys.argv[2]
body = sys.argv[3].encode() if len(sys.argv) > 3 else b""
parsed = urllib.parse.urlsplit(target)
env = os.environ
headers = {
# The default urllib User-Agent (Python-urllib/3.x) gets blocked by Cloudflare Browser Integrity Check (error code: 1010)
"User-Agent": USER_AGENT,
"CF-Access-Client-Id": env["CF_CLIENT_ID"],
"CF-Access-Client-Secret": env["CF_CLIENT_SECRET"],
**sign_headers(env["SECRETARY_ACCESS_KEY"], env["SECRETARY_SECRET"], method, parsed.path, parsed.query, body),
}
if body:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(env["SECRETARY_URL"].rstrip("/") + target, data=body or None, method=method,
headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as res:
status, payload = res.status, res.read()
except urllib.error.HTTPError as e:
status, payload = e.code, e.read()
print(status)
try:
print(json.dumps(json.loads(payload), ensure_ascii=False, indent=2))
except ValueError:
print(payload.decode(errors="replace")[:500])
return 0 if status < 400 else 1
if __name__ == "__main__":
sys.exit(main())

I started the server locally and called it with a key issued for Dad’s glasses (dad glasses).

$ python3 secretary_client.py GET /v1/me
200
{
"user": {"id": "dad", "name": "Dad", "role": "admin"},
"device": {"access_key": "dad-glasses-7ebb279d", "name": "glasses"},
"scopes": ["modules:*", "system:read"]
}
$ python3 secretary_client.py GET "/v1/approvals?status=pending"
403
{"code": "HTTP_403", "message": "Permission denied."}
$ SECRETARY_SECRET=wrong python3 secretary_client.py GET /v1/me
401
{"code": "HTTP_401", "message": "Authentication failed."}

If the signature is valid, it returns who I am and my actual permissions (200). Dad is an admin, but the glasses device has no approval permission, so it can’t see the approval queue (403). If the Secret is wrong, it doesn’t say why (401). And if you look at the response headers, there’s no Server header. That’s because uvicorn was started with --no-server-header. It’s the “take the name tags off your servers” I talked about in the secure infrastructure post.

Sending commands to the mail module with these glasses will have to wait until part 12, after the mail module is built.

To sum up

Here’s what I did from part 3 up to here.

  • Structure: The hub handles only people, permissions, approvals, and passing commands. The real work is done by modules. The two are connected only by an HTTP contract
  • Authentication: Per-device keys + an HMAC signature on every request + timestamp + Nonce + failure lockout. Failure reasons go only to the log
  • Family: A people layer with admin, adult, and child roles. Up to 4 people
  • Permissions: Actual permissions are device scopes AND the role ceiling. Module permissions are created automatically as <module>:<action>, and requesting and approving use separate scopes
  • Management: User registration and key issuance only via the CLI inside the NAS. Not opened to an external API
  • Error responses: No matter what happens, only in a fixed format, with the cause only in the server log

There were some wasted hours getting here too. A Korean device name produced a key that could never pass. And I made one big design change: from “just me” to “our family.” Good thing I caught it before building the clients. Once again, security code has to be checked for “is it blocked” rather than “does it work.”

Starting with part 7, I build the other half of the hub. Defining the contract between the hub and modules, the command entry point that calls every module, and even the approval queue that holds back irreversible commands.

Bye for now! Bye

[ Comments ]Shown after approval

Loading comments...