A digital gallery for a building with no route to the internet, and the permission model underneath it, where holding the right role is only half of the question the server asks.
Canvas & Clay runs on a network that has no route to the internet. A gallery installs it on one machine in the building, visitors browse it over the local network, and nothing it needs ever arrives from anywhere else.
That constraint is quick to say and awkward to build. Nearly every convenience a web application reaches for assumes the network is there: fonts from a content delivery network, an e-mail provider for password resets, an identity provider for the login screen, an update check on boot. All of it has to come out, and whatever replaces it has to keep working on a machine that nobody is going to administer.
So the application owns the whole stack. Its own accounts, its own sessions, its own password resets, its own file storage, its own backups and its own encryption keys. It is installed by a terminal wizard rather than a README, and the wizard ships with a repair mode, which is the honest part: installs fail, and the installer has to be able to fix itself without a person who knows what a container is.
Four of the thirteen tables have nothing to do with the web at all. storage, wall_space, rack and flat_file record where a piece physically is in the building, because a gallery that cannot tell you which wall a painting is on has not replaced the spreadsheet it was brought in to replace.
Most role systems are a column with a string in it and a scattering of comparisons against that string, written wherever somebody needed one. This one is a ladder, declared once, and every movement along it goes through two methods.
ROLE_LADDER = ('guest', 'artist', 'admin')
def promote(self):
idx = self.ROLE_LADDER.index(self.normalized_role)
if idx < len(self.ROLE_LADDER) - 1:
self.role = self.ROLE_LADDER[idx + 1]
return self.normalized_role
An account moves one rung at a time. Promoting an administrator is a no-op rather than an error, demoting a guest is a no-op rather than an error, and an unrecognised role normalises to the bottom of the ladder before anything else happens to it. There is no code path anywhere that sets a role by assignment.
The ladder. Press past either end and nothing happens, which is the behaviour rather than a guard bolted on afterwards. The third button writes a legacy value straight into the column, the way an older row in an existing database already has it.
That last case is worth more than it looks. An earlier version of the schema used visitor and artist-guest. Rather than migrate every row and hope none were missed, the model normalises on read, so an old value can never reach a comparison as itself, and a row nobody remembers cannot quietly become an unknown role that no check is written for.
The other detail is the front door. Registration does not accept a role, at all:
# security fix: ignore any client-supplied role parameter # all new users are forced to 'guest' role to prevent privilege escalation role = 'guest'
The comment is the interesting part. It is a fix, which means at some point the endpoint read the role out of the request body, and anybody who could post JSON could have asked to be an administrator and been given it.
Every protected endpoint asks two things, in order. Do you stand high enough on the ladder, and does the thing you are reaching for actually belong to you? A system that asks only the first lets any artist edit any other artist. A system that asks only the second has no administrators.
What makes it readable months later is that the refusal remembers which question failed:
if not current_user.is_admin:
if current_user.normalized_role != 'artist':
log_rbac_denial('artist', artist_id, 'insufficient_role')
return jsonify({'error': 'Permission denied'}), 403
if not is_artist_owner(artist):
log_rbac_denial('artist', artist_id, 'not_owner')
return jsonify({'error': 'Permission denied'}), 403
Two different refusals, identical to whoever sent the request, and distinguishable in the audit log. The client is told nothing either way, because telling a caller that a record exists but is not theirs is itself a leak. But insufficient_role in the log means somebody is pushing on a door that was never going to open for them, and not_owner means somebody is in the right building and the wrong room, which is almost always a stale link rather than an attack. Collapse them into one 403 and you lose the ability to tell those apart for ever.
The permission machine. The checks and their order are taken from the endpoints themselves, so the panel cannot drift from the code it is describing. Every refusal shows the reason the server would have written to the audit log, which the caller never sees.
Two combinations here are worth pressing. An administrator never reaches the ownership question at all: the first branch is if not current_user.is_admin, so the whole check falls away rather than being passed. And deleting an artwork asks about the ladder not at all, only about ownership, because an account that owns a piece has earned the right to remove it whatever rung it is standing on.
No photo carries an owner. Neither does an artwork. One column in the whole schema records who something belongs to, artist.user_id, and everything else is resolved by walking towards it.
The chain. Three lookups per check, all of them primary key reads, and the answer is the comparison at the end.
Denormalising this would be faster and is the obvious thing to reach for. It is also how ownership quietly goes wrong: the day an artwork is reassigned to another artist, every copied owner column on every photo is stale, and a stale owner column is an authorisation bug rather than a display bug. Deriving it means there is exactly one fact, in one place, and moving a piece between artists moves everything that hangs off it in the same instant.
The chain also answers a question nobody remembered to ask. A photo whose artwork has been deleted out from under it has no first link, so it has no owner and no owner can ever be computed for it:
def is_photo_owner(photo):
# orphaned photos can only be managed by admins
if photo.artwork_num is None:
return False
Every caller fails that check, including the artist who uploaded the file in the first place, which leaves administrators as the only accounts that can clear it up. That rule was never written as a rule. It falls out of a chain with nothing at the start of it, and getting behaviour like that for free is the argument for deriving ownership rather than storing it.
The personal data here is thin, which is the point: e-mail addresses, and nothing else worth the trouble. But it sits in a Postgres volume that a backup copies and a laptop carries out of the building, and a gallery is not going to notice a missing laptop for a week.
So the column is encrypted with AES-GCM under a fresh random nonce every time it is written. Two accounts with the same address produce two ciphertexts with nothing in common, which matters more than it sounds: with a deterministic scheme, anybody holding a dump can see which rows match each other without decrypting a single one of them.
Which creates the obvious problem. If the same address encrypts differently every time, WHERE email = ? can never find anybody again. The answer is a second column beside it, a keyed HMAC-SHA256 of the normalised address, one way, deterministic and indexed. Logging in is still one index lookup. The index reveals nothing about the address unless you already hold the key.
alice@studio.localIllustrative. These bytes are drawn at random in your browser to show the shape of the thing: the ciphertexts never repeat, the indexes always agree. The real values come from AES-GCM and HMAC-SHA256 on the server, and the real index is the same 64 hex characters wide.
Two decisions around it are worth naming. The keys are derived with Argon2id at 64MB, three passes and four lanes, rather than a single hash, so a leaked key file is expensive to brute force rather than instant. And the encryption key is deliberately not derived from the session secret, with a comment in the module explaining why: if the two shared a source, stealing session cookies would also decrypt the database, and one bad afternoon would become two.
The application was put through static analysis, a dependency scan and a manual penetration pass, and the result was written down rather than summarised away.
Three critical findings, all of them mine, all of them fixed:
The two at the bottom are the ones I would want read. Neither is fixed. Both are real, both have a cost that was judged higher than the risk, and both are named in the report with the reasoning attached, because a security document that only lists the things that were easy to fix is not a security document.
Underneath all of it, the parts that were right from the start: bcrypt at twelve rounds, a fifteen minute lockout after five failed attempts, rate limits declared per endpoint, session regeneration on login, CSRF tokens, magic byte validation on every upload rather than trusting a file extension, and an audit log that every refusal writes to.
Spread over twenty five files, and the interesting thing is which ones are the largest. They are not the ones that check a page renders.
Weighted towards the dangerous parts. Restore, recovery, encryption and authentication carry 241 of the 599 between them.
A gallery installs this once and then leaves it alone for a year, on a machine with no operator, behind a network with no route out and therefore no way for me to reach in and fix anything. That is the whole design constraint, and it is why the test count sits where it does. The wizard has a repair mode for the same reason.