Title :
Building a Home Secretary #6 - Server: Family Registration and Tests
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).
"""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 argparseimport sysfrom datetime import datetime
import httpx
from .audit import AuditLogfrom .auth.registry import Registry, RegistryErrorfrom .catalog import Manifestfrom .auth.scopes import HUB_SCOPES, PRESETS, ROLES, effective_scopesfrom .config import get_settingsfrom .db import Databasefrom .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.

Still, I diligently commented the code, so I hope you can follow along with that. I left the detailed explanation of the code 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 memberThe first user must be an admin.
$ docker exec -it secretary python -m app.cli user add dad "Dad" --role admin --email dad@example.comAdded user: dad (Dad, admin)
$ docker exec -it secretary python -m app.cli user add kid1 "Eldest" --role childAdded user: kid1 (Eldest, child)
$ docker exec -it secretary python -m app.cli device add kid1 phone --preset phoneIssued 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 listActive users 2/4 dad Dad admin active dad@example.com kid1 Eldest child active -
$ docker exec -it secretary python -m app.cli user disable dadThe 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 listNo 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.
import jsonimport time
import httpximport pytestfrom fastapi.testclient import TestClient
from app.auth.registry import Registryfrom app.auth.scopes import ADMIN, CHILD, MEMBER, PRESETSfrom app.auth.signing import build_headersfrom app.config import Settingsfrom app.main import create_appfrom tests import fake_module
CF = {"Cf-Access-Jwt-Assertion": "test-jwt"}DOWN_TOKEN = "x" * 40
@pytest.fixturedef 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 == 401Here 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.86sAll 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.
#!/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 base64import hashlibimport hmacimport jsonimport osimport secretsimport sysimport timeimport urllib.errorimport urllib.parseimport 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/me200{ "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/me401{"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!

Loading comments...