Site Icon THEREiSLatest Posts · reading #010한국어

Title :

Building a Home Secretary #4 - Server: Family and Permissions

Author
[Yahum]
Date
Tags

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

In part 3, I built the hub’s skeleton. I gathered the settings in one place and built a store to remember Nonces and authentication failures.

This time, I decide who can do what.

Step 1. Defining permission scopes and roles (scopes.py)

At first it was a service just for me

The first version had no “people.” It only had devices. My phone, my watch, my glasses. After building it all, a thought suddenly struck me. “Is this just for me? What about my family? What if my wife wants to use it too?”

If I issue more device keys, family members’ phones can connect too. But without a concept of people, problems come up.

  • Approvals get mixed up. Any device with approval permission can approve anyone’s request. My wife’s phone could approve my email, and my kid’s requests would pop up on my watch.
  • Personal data gets mixed up. A family device with mail permission could send email in my name. And once I hook up Obsidian later, it could read my notes too.
  • The audit log only records devices. You’d have to guess “who” did it from the device name.

On the other hand, things like lights are naturally shared by the family. So I decided to add a people (users) layer on top of devices.

Roles and scopes

In part 2, I said “policies differ by action risk level.” Put that into code and you get scopes, and give each person a different ceiling and you get roles.

services/hub/app/auth/scopes.py
"""Permission scopes, roles, and device presets.
There are two kinds of scopes.
Hub scopes system:read, approvals:decide Things the hub does itself
Module scopes <module>:<action> e.g. mail:send Created automatically for each action a module lists in its manifest
Module scopes can be granted in bundles with wildcards.
mail:* Every action of the mail module
modules:* Every action of every module (including modules added later)
Permissions you can actually use = scopes given to the device AND scopes the user's role allows.
So even if a child's device is accidentally given a broad preset, it can't exceed the role's ceiling.
"""
import re
SYSTEM_READ = "system:read" # View my info, module list, events
APPROVALS_DECIDE = "approvals:decide" # View, approve, and reject requests I can decide on
ALL_MODULES = "modules:*" # Every action of every module
HUB_SCOPES = {SYSTEM_READ, APPROVALS_DECIDE}
# Module scope shape: module name ':' action name (or *)
_MODULE_SCOPE_RE = re.compile(r"([a-z0-9][a-z0-9-]{1,23}):([a-z0-9][a-z0-9_-]{0,31}|\*)")
# Roles
ADMIN = "admin" # Head of the house. All permissions, approves children's requests, manages keys via the NAS CLI
MEMBER = "member" # Adult family member. Approves their own requests with their own devices
CHILD = "child" # Child. No approval permission. Requests needing approval are approved by an admin
ROLES = (ADMIN, MEMBER, CHILD)
# Role ceilings. To restrict further per module, use roles in the module manifest.
ROLE_SCOPES: dict[str, set[str]] = {
ADMIN: {SYSTEM_READ, APPROVALS_DECIDE, ALL_MODULES},
MEMBER: {SYSTEM_READ, APPROVALS_DECIDE, ALL_MODULES},
CHILD: {SYSTEM_READ, ALL_MODULES},
}
# Recommended defaults per device type (used with --preset in the CLI)
PRESETS: dict[str, set[str]] = {
"phone": {SYSTEM_READ, APPROVALS_DECIDE, ALL_MODULES},
"watch": {SYSTEM_READ, APPROVALS_DECIDE},
"glasses": {SYSTEM_READ, ALL_MODULES},
"mcp": {SYSTEM_READ},
"test": {SYSTEM_READ},
}
def module_of(scope: str) -> str | None:
"""Return the module name for a module scope, or None for a hub scope. e.g. "mail:send" → "mail", "modules:*" → "*" """
if scope == ALL_MODULES:
return "*"
if scope in HUB_SCOPES:
return None
m = _MODULE_SCOPE_RE.fullmatch(scope)
return m.group(1) if m else None
def is_valid_scope(scope: str) -> bool:
"""Whether it has a shape that can be given to a device. Scopes for modules that don't exist yet can be given in advance."""
return scope in HUB_SCOPES or module_of(scope) is not None
def allows(granted, required: str) -> bool:
"""Whether granted (the scopes held) allows required (a single needed scope).
Allowed if the exact same scope is present, or, for a module scope, if "<module>:*" or "modules:*" is present.
e.g. {"mail:*"} allows "mail:send" but not "tapo:on".
"""
granted = set(granted)
if required in granted:
return True
module = module_of(required)
if module is None or module == "*":
return False
return ALL_MODULES in granted or f"{module}:*" in granted
def effective_scopes(device_scopes, role: str) -> set[str]:
"""Permissions actually usable with this device = scopes given to the device AND scopes the role allows.
Each device scope is checked against the role ceiling, and only those the ceiling allows are kept.
e.g. Even if a child's device is given approvals:decide, it's dropped because it's not in the child ceiling.
"modules:*" itself is kept only if it's in the ceiling as is. For an unknown role, an empty set (no permissions).
"""
cap = ROLE_SCOPES.get(role, set())
return {s for s in device_scopes if s in cap or allows(cap, s)}

There are three key points.

First, actual permissions = device scopes AND role ceiling. Even if a child’s phone is accidentally given the phone preset (which includes approval permission), the child role’s ceiling has no approval permission, so they can’t actually approve. It’s a safeguard so that a single mistake doesn’t turn into a permissions incident.

Second, module permissions aren’t written into the hub in advance. The hub doesn’t even know the mail module exists. So module scopes take the shape <module>:<action>, created automatically for each action a module announces as “here’s what I can do.” For the mail module’s send action, it’s mail:send. You can also grant them in bundles.

scope Meaning
mail:send Just the mail module’s send action
mail:* Every action of the mail module
modules:* Every action of every module (including modules added later)

allows() checks these rules. However, having modules:* doesn’t give you hub permissions (approvals). Module wildcards only apply to modules.

Third, module commands and approvals (approvals:decide) are separated. The device that requests and the device that approves can be different.

Role Usable scopes Approvals
admin View, approve, all modules Own requests (from another device) + children’s requests
member View, approve, all modules Own requests (from another device)
child View, all modules None. Requests needing approval are approved by an admin
Preset What it can do Why
phone View, module commands, approve The main app and admin console
watch View, approve Approve only, with one tap on the wrist
glasses View, module commands Can make requests by voice, but can’t approve on its own
mcp View AI gets view-only for now
test View For curl testing

Children can send module commands too. But commands that need approval, like email, only run once an admin approves them. Actions you want to restrict more narrowly than roles do (for example, only admins can open the front door) are written by the module as roles in its self-introduction (manifest). We’ll look at that in part 7.

Even if I lose my glasses and someone tries to send email with the glasses key, nothing goes out unless I approve it from my phone or watch.

Step 2. User and device store (registry.py)

This is where people and devices are registered and kept. A device always belongs to some person.

services/hub/app/auth/registry.py
"""User and device store (data/registry.json).
There are people (users), and devices belong to people. Since it's a service for one
household, there's a cap on the number of active users (4 by default).
With HMAC, the server also has to recompute the signature with the same secret, so device
Secrets are stored in plain form. That's why the file is created with permissions 600 from
the start, and this file never leaves the data volume.
"""
import json
import os
import re
import secrets
import threading
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from .scopes import ADMIN, ROLES, is_valid_scope
# Rules for user IDs and device names. Used with fullmatch(), so the "entire" string must have this shape.
# (It becomes the front part of the Access Key and travels in HTTP headers, so only lowercase letters, digits, and hyphens)
# [a-z0-9] First character: a lowercase letter or digit (can't start with a hyphen)
# [a-z0-9-]{1,23} The remaining 1–23 characters: lowercase letters, digits, hyphens
# → 2–24 characters in total
# Pass: dad, kid1, phone-flutter
# Reject: -dad (starts with hyphen), Dad (uppercase), 내폰 (Korean, non-ASCII), a (1 character), 25+ characters
_ID_RE = re.compile(r"[a-z0-9][a-z0-9-]{1,23}")
# Email format check. It only lightly checks whether it "looks like an email," not whether the address actually exists.
# [^@\s]+ 1+ characters that are neither '@' nor whitespace → the local part (dad)
# @ exactly one '@'
# [^@\s]+ 1+ characters that are neither '@' nor whitespace → the domain name (example)
# \. one dot (.). In regex, '.' means "any character," so add \ to use a real dot
# [^@\s]+ the rest after the dot → (com, co.kr)
# ([^...] means "not the characters in the brackets," \s is whitespace, + means "1 or more")
# Pass: dad@example.com, mom@mail.co.kr
# Reject: dad@example (no dot), dad@@example.com (two @), "a b@c.d" (whitespace)
_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
class RegistryError(ValueError):
"""Error raised when user/device registration rules are broken. The message is shown on the CLI screen as is."""
@dataclass
class User:
"""One family member. role is one of admin/member/child; email is used for the mail Reply-To."""
id: str
name: str
role: str
created_at: int
email: str | None = None
disabled: bool = False
def public(self) -> dict:
"""Pick only the info to include in API responses (ID, name, and role, without email)."""
return {"id": self.id, "name": self.name, "role": self.role}
@dataclass
class Device:
"""One device belonging to a person (phone, watch, glasses...). access_key is the device ID and secret is the signing secret.
Revoking doesn't delete it; it's kept with revoked=True, so the keys the audit log points to can still be identified later.
"""
access_key: str
user: str
name: str
secret: str
scopes: list[str]
created_at: int
revoked: bool = False
revoked_at: int | None = None
class Registry:
"""A store that saves users and devices to a single registry.json file and reads them back."""
def __init__(self, path: Path, max_users: int = 4):
"""Take the file path and max number of users, and read the file right away if it exists."""
self.path = path
self.max_users = max_users
self._lock = threading.Lock()
self._users: dict[str, User] = {}
self._devices: dict[str, Device] = {}
self._mtime: float | None = None
self._load()
def _load(self) -> None:
"""Read registry.json into memory. Reread only when the file's modification time changes.
The CLI (docker exec) is a different process from the server, so adding a device via the CLI only changes the file.
So it checks the modification time every time, and if it changed, reads the new content without restarting the server.
"""
if not self.path.exists():
self._users, self._devices, self._mtime = {}, {}, None
return
mtime = self.path.stat().st_mtime
if mtime == self._mtime:
return
raw = json.loads(self.path.read_text(encoding="utf-8"))
self._users = {k: User(**v) for k, v in raw.get("users", {}).items()}
self._devices = {k: Device(**v) for k, v in raw.get("devices", {}).items()}
self._mtime = mtime
def _save(self) -> None:
"""Write the in-memory contents to registry.json.
1. Create a temporary file (.tmp) with permissions 600 (only I can read and write) from the start and write to it,
2. then swap it with the original file in one go using os.replace.
Even if the server dies while writing, the original file isn't left half-written.
"""
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
payload = json.dumps(
{
"users": {k: asdict(v) for k, v in self._users.items()},
"devices": {k: asdict(v) for k, v in self._devices.items()},
},
ensure_ascii=False,
indent=2,
)
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(payload)
os.replace(tmp, self.path)
self._mtime = self.path.stat().st_mtime
# ── Users ──────────────────────────────────────────────
def user(self, user_id: str) -> User | None:
"""Find one user by ID. None if not found."""
with self._lock:
self._load()
return self._users.get(user_id)
def users(self) -> list[User]:
"""List of all users (including disabled ones)."""
with self._lock:
self._load()
return list(self._users.values())
def add_user(self, user_id: str, name: str, role: str, email: str | None = None) -> User:
"""Add a user. RegistryError if it breaks the rules.
- ID is 2–24 lowercase letters, digits, and hyphens; role is one of admin/member/child
- Up to max_users active users
- The first user (when there's no active admin) must be an admin
"""
if not _ID_RE.fullmatch(user_id):
raise RegistryError("User ID must be 2-24 lowercase letters, digits, and hyphens. e.g. dad, mom, kid1")
if role not in ROLES:
raise RegistryError(f"Role must be one of {', '.join(ROLES)}.")
if email and not _EMAIL_RE.fullmatch(email):
raise RegistryError("Invalid email format.")
with self._lock:
self._load()
if user_id in self._users:
raise RegistryError("That user ID already exists.")
active = [u for u in self._users.values() if not u.disabled]
if len(active) >= self.max_users:
raise RegistryError(f"You can register up to {self.max_users} users.")
if not any(u.role == ADMIN for u in active) and role != ADMIN:
raise RegistryError("The first user must be an admin.")
user = User(id=user_id, name=name, role=role, email=email, created_at=int(time.time()))
self._users[user_id] = user
self._save()
return user
def disable_user(self, user_id: str) -> int:
"""Disable a user and revoke all of that person's device keys. Returns the number of keys revoked."""
with self._lock:
self._load()
user = self._users.get(user_id)
if not user or user.disabled:
raise RegistryError("That user doesn't exist or is already disabled.")
others = [u for u in self._users.values() if u.id != user_id and not u.disabled and u.role == ADMIN]
if user.role == ADMIN and not others:
raise RegistryError("The last admin can't be disabled.")
user.disabled = True
count = self._revoke_where(lambda d: d.user == user_id)
self._save()
return count
def delete_user(self, user_id: str, has_history: bool) -> User:
"""Delete a user completely. Allowed only when there's no trace, like a user created by a typo.
If they have devices (including revoked ones) or approval records, don't delete. The audit log
and approval records point to this ID, and deleting it would make it impossible to tell who did what later.
Disable such users with disable_user() instead.
"""
with self._lock:
self._load()
user = self._users.get(user_id)
if not user:
raise RegistryError("That user doesn't exist.")
if any(d.user == user_id for d in self._devices.values()):
raise RegistryError("A user who has ever had a device registered can't be deleted. Use user disable instead.")
if has_history:
raise RegistryError("A user with approval records can't be deleted. Use user disable instead.")
if user.role == ADMIN and not user.disabled:
others = [u for u in self._users.values() if u.id != user_id and not u.disabled and u.role == ADMIN]
if not others and len(self._users) > 1:
raise RegistryError("The last admin can't be deleted while other users exist.")
del self._users[user_id]
self._save()
return user
# ── Devices ────────────────────────────────────────────
def device(self, access_key: str) -> Device | None:
"""Find one device by Access Key. None if not found. (Revoked devices are returned too, so check revoked separately)"""
with self._lock:
self._load()
return self._devices.get(access_key)
def devices(self) -> list[Device]:
"""List of all devices (including revoked ones)."""
with self._lock:
self._load()
return list(self._devices.values())
def add_device(self, user_id: str, name: str, scopes: set[str]) -> Device:
"""Issue a new device key to a user.
Access Key = "userID-deviceName-8 random chars" (e.g. dad-phone-1a2b3c4d)
Secret = 32 random bytes (secrets.token_urlsafe: an unguessable random value)
scopes are stored as is; the role ceiling trims them with effective_scopes() at request time.
"""
# The name becomes the front part of the Access Key and travels in HTTP headers, so only lowercase letters, digits, and hyphens.
if not _ID_RE.fullmatch(name):
raise RegistryError("Device name must be 2-24 lowercase letters, digits, and hyphens. e.g. phone, glasses")
bad = {s for s in scopes if not is_valid_scope(s)}
if bad:
raise RegistryError(f"Invalid scope shape: {', '.join(sorted(bad))} (e.g. system:read, mail:send, mail:*, modules:*)")
with self._lock:
self._load()
user = self._users.get(user_id)
if not user or user.disabled:
raise RegistryError("That user doesn't exist or is disabled.")
access_key = f"{user_id}-{name}-{secrets.token_hex(4)}"
device = Device(
access_key=access_key,
user=user_id,
name=name,
secret=secrets.token_urlsafe(32),
scopes=sorted(scopes),
created_at=int(time.time()),
)
self._devices[access_key] = device
self._save()
return device
def _revoke_where(self, predicate) -> int:
"""Mark every device matching the condition (predicate) as revoked, and return how many were revoked.
predicate is a function that takes a device and returns True/False. e.g. lambda d: d.user == "kid1"
Saving (_save) is done by the caller.
"""
now = int(time.time())
count = 0
for device in self._devices.values():
if not device.revoked and predicate(device):
device.revoked = True
device.revoked_at = now
count += 1
return count
def revoke(self, access_key: str) -> bool:
"""Revoke one device key. True if revoked, False if it doesn't exist or is already revoked."""
with self._lock:
self._load()
count = self._revoke_where(lambda d: d.access_key == access_key)
if count:
self._save()
return bool(count)
def revoke_all(self) -> int:
"""Emergency stop: invalidate every device key at once."""
with self._lock:
self._load()
count = self._revoke_where(lambda d: True)
if count:
self._save()
return count

Here are the rules I set.

  • Up to 4 active users (SECRETARY_MAX_USERS). Because it’s a service for one household. Disabling one person frees up a spot. If more people need to use it, just raise the number.
  • The first user must be an admin. Without an admin, there’s nobody to approve children’s requests.
  • The last admin can’t be disabled. Same reason.
  • Disabling a person revokes all of their device keys too.

Oh, my head (Oh, my head.)

And a word on storing Secrets. Normally, passwords are stored only as hashes. But with HMAC, the server also has to recompute the signature with the same Secret, so it has to hold the Secret in plain form. So I did this instead.

  • The file is opened with permissions 600 from the moment it’s created (os.open(..., 0o600)). That leaves no window for other users to read it in the gap before a later chmod.
  • Write to a temporary file first, then swap it in with os.replace. Even if the server dies mid-write, the file doesn’t get corrupted.
  • User registration and key issuance can only be done inside the NAS, via the CLI. I didn’t open them up to an external API. (Shouldn’t I build an admin page at some point? For now, the CLI is convenient.)
  • I also built revoke_all() for emergency stops. If something seems off, invalidate everything at once.
Wait, what’s @dataclass?

The User and Device classes have @dataclass on top. It’s a decorator where you just list the fields and Python automatically creates the constructor (__init__), the display string (__repr__), and comparison (__eq__) for you. It’s similar to Java’s Lombok @Data or record.

Let’s learn a bit more about @dataclass

dataclass_demo.py
from dataclasses import asdict, dataclass
@dataclass
class User:
id: str
name: str
role: str = "member"
u = User("mom", "Mom")
print(u)
print(asdict(u))
print(u == User("mom", "Mom"))
$ python3 dataclass_demo.py
User(id='mom', name='Mom', role='member')
{'id': 'mom', 'name': 'Mom', 'role': 'member'}
True

Even without writing a constructor, you can create one with User("mom", "Mom"), print shows the contents nicely, and == is True when the contents are the same. You can also give defaults, like role. asdict() turns it into a dictionary, and registry.py uses this when saving to the JSON file.

A wasted afternoon: Korean device names

At first, there was no restriction on device names. So when I created a test device called 내폰 (“my phone” in Korean), the issued Access Key became 내폰-d3fbc4a5. But this key has to travel in an HTTP header (X-Access-Key), and the gatekeeper was checking the key format against [A-Za-z0-9_-]. In the end, a key was issued that could never pass authentication. So I locked user IDs and device names down to lowercase letters, digits, and hyphens only. Korean is used only in display names (like "아빠" (Dad) or "첫째" (Eldest)). The Access Key takes the form user-device-random. Something like dad-glasses-7ebb279d. Of course, I could have allowed Korean in the key format too… but Korean in keys is hard to use conventionally, so I decided to stick to ASCII only.

To sum up

  • 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
  • Store: Users and devices are separate. Devices belong to people

Now people, devices, and what each can do are settled. In part 5, let’s set up the gatekeeper that checks the signature on every request, and build the first API.

Bye for now!
Goodbye

[ Comments ]Shown after approval

Loading comments...