Skip to content

Identity and access control ​

Status: draft. This is the most security-sensitive part of Pixel. Changes here need review from a second person.

Pixel has to know who is talking to it and what they may do. This document covers how users are identified, how tiers are assigned, and how every command is authorised. Phase 1 covers Discord only, with tiers held in config files. The later sections sketch how this grows to more platforms and other tier sources without changing features.

Principles ​

  1. Deny by default. Every command declares the minimum tier it needs. A command that declares none cannot be registered.
  2. One enforcement point. Authorisation happens in the core dispatcher, never in adapters or feature handlers. Adapters cannot skip it.
  3. Identify by immutable platform IDs only (Discord snowflakes). Never use usernames, display names or nicknames. Those can be changed or spoofed.
  4. Tier sources are explicit and few. In phase 1, the only sources are two config files that operators control.
  5. Fail closed. If the access config is missing or invalid, Pixel refuses to start. It never runs with "everyone is a guest" by accident, or worse, "everyone is an admin".
  6. Store as little personal data as possible (GDPR).

Tiers ​

Tiers are ordered: a higher tier includes everything a lower tier can do.

TierWhoPhase 1 source
guestEveryone not listed anywhereDefault
friendPaying "friend of Pixelbar" membershipconfig/members.yaml
memberPaying Pixelbar memberconfig/members.yaml
adminPixel operatorsconfig/admins.yaml

friend and member are both paid memberships. Being in the Discord server, or being known to the bot, makes you a guest and nothing more.

admin lives in a separate file, so changes to it are easy to see and review on their own. Admins have every permission, whatever their entry in members.yaml.

Each command states its tier, for example access: { minTier: "member" }. /help only lists commands the caller can use.

Phase 1: config-file tiers ​

Files ​

yaml
# config/admins.yaml
# Pixel admins: full control of the bot. Same shape as members.yaml.
# ids is a list of "<platform>:<user id>". Discord ID: enable Developer Mode, right-click the user, Copy User ID.
# IDs MUST be quoted strings (see "Validation").
# Every admin ALSO needs an entry in members.yaml holding the same ids.
admins:
  - ids: ["discord:123456789012345678"]
yaml
# config/members.yaml
# Paying memberships. Anyone not listed is a guest.
# Managed by Pixel (admin commands rewrite it) and still safe to edit by hand.
members:
  - ids: ["discord:123456789012345678"]   # an admin: also listed in admins.yaml
    tier: member
    note: "Jane Doe"
  - ids: ["discord:234567890123456789"]
    tier: member                # member | friend | guest
    note: "optional, for humans"
    capabilities:               # optional named permissions, see the capability system
      - front-door
  • Paths: PIXEL_ADMINS_FILE and PIXEL_MEMBERS_FILE, defaulting to config/admins.yaml and config/members.yaml.
  • The real files are gitignored, because they link Discord accounts to membership, which is personal data. The repo contains config/admins.example.yaml and config/members.example.yaml.
  • Both files identify people the same way: each entry is one person, with ids, a list of "<platform>:<user id>" (the same form as actorRef and the logs). One person can have several, for example one per platform, so people on other platforms can be told apart and linked once they exist (#16). An id may appear in only one entry per file. Only discord: is accepted for now.
  • Admins are members too. admins.yaml only says who is an admin. An admin entry's ids must all belong to one members entry, and only the ids listed there are admin: a second account in the same members entry keeps the person's membership level but isn't an admin. Each admin must also have an entry in members.yaml, which holds their membership level, capabilities and note. Pixel refuses to start if one is missing. An admin's effective tier is admin (which includes everything below it), and their entry's capabilities still apply. /admin level set refuses admins, but /admin capabilities grant works on them.
  • Moving from the old formats: in both files, replace discordId: "<id>" with ids: ["discord:<id>"], and drop the name from admins.yaml (it becomes the note of the admin's members entry). Add a members entry for each admin if they don't have one (tier: member is fine). Pixel refuses the old formats and says which entry is wrong, without echoing the ID.
  • members.yaml is bot-managed. Admin commands change it, so it needs a writable, persistent, snapshotted volume, not a read-only mount. admins.yaml stays hand-edited (read-only is fine) and no command can touch it.
  • tier: guest keeps the entry and its capabilities for someone who was demoted. They are treated as unlisted: no tier, not counted, and every tier-gated command refuses them.

Validation (at startup, with zod) ​

  • Each entry of ids must be a string like discord:123456789012345678: the platform, then a 17–20 digit user ID (an entry has one to ten of them, without repeats). If one is unquoted, or has no platform, Pixel refuses to start. Snowflakes are larger than JavaScript's safe integer range, so an unquoted number would be silently rounded to a different user.
  • tier must be member, friend or guest. capabilities is an optional list of unique names (lowercase words joined by -, at most 50). Unknown keys are rejected, which catches typos like teir.
  • An id that appears in two entries of the same file is an error. Messages name the position (members[2].ids[1] duplicates members[1].ids[0]), never the id.
  • admins is a list of entries with ids, at least one entry, each unique, and each with an entry in members.yaml. An ID in both files is expected: admin wins, and the entry's other fields still apply.
  • If either file is missing or invalid, startup fails with a clear message that never includes the file contents.

Reloading ​

Both files are loaded at startup into an in-memory view that is replaced as a whole, never edited in place. A missing or invalid file still stops startup, so a lost file is a loud outage, never "everyone is a guest".

Changes go through the access store (core/ports/access-store.ts, implemented by services/access-store.ts). One change at a time it:

  1. re-reads members.yaml, so edits made by hand aren't lost, and refuses if it has become invalid (the same checks as at startup, including an id in two entries or an admin with no entry),
  2. edits the YAML document, so comments, order and quoting survive (IDs are written as quoted strings),
  3. re-validates the result, builds the new view, and writes it to a temp file next to the original, then flushes it to disk,
  4. checks the file wasn't edited in the meantime, keeps the old one as members.yaml.bak, and renames the temp file over the original (atomic),
  5. only then swaps the in-memory view and records the change.

If any step fails, both the file and the view are unchanged, and the caller gets a message that contains no personal data. Steps 1 to 5 contain no await, so Node can't interleave two changes. That is why no lock is needed (Pixel runs as a single instance). A capability-change DM is sent after those steps, and never fails the change.

  • Admins are never written here. Changing an admin's entry is refused.
  • Every change is audited: an access.changed log event with who did it (ID, name, handle), who it was done to (ID), and the tier and capabilities before and after. Notes are never logged. The change also becomes a Sentry breadcrumb, so error reports show recent access changes. Sentry is not the audit record; a dedicated admin audit log is planned (#31).
  • Admin commands use the store: /admin level set user: level: [reason:] sets someone to member, friend or guest, and /admin level get user: shows what Pixel knows. Both are admin-only and private, act on the immutable user ID, refuse admins and bots, and say so when nothing would change. guest keeps the entry and capabilities; removing someone completely means editing the file by hand, then /admin reload. The optional reason goes into the audit log only (200 characters at most). Text from the file, such as a note, is shown as a code span, and names are escaped, so none of it can render as formatting, a link or a mention.
  • Hand edits made while the bot runs are picked up by the next change, or by /admin reload (admins only), which re-reads both files and keeps the old data if they are now invalid. Restarting also works. In local development, just dev restarts automatically when the files change.

Request flow ​

Discord interaction
  → adapter builds PlatformActor { platform: "discord", userId, displayName, chat: "dm" | "group", guildId? }
  → adapter refuses if guildId is set and isn't DISCORD_GUILD_ID
  → IdentityService.resolve(actor)
        asks every TierSource for this actor's tier; effective tier = highest result, or guest
  → dispatcher.authorize(command, principal, chat)
       ✗ → private reply "You don't have access to this", log 'command.denied', stop
       ✓ → handler(ctx with principal)
ts
// core/ports/tier-source.ts
type TierSource = {
  name: string;
  tierFor(actor: PlatformActor): Promise<Tier | null>;
};

Phase 1 has a single ConfigTierSource. Linked identities will use the same access store (several ids on one members.yaml entry). Discord roles are deliberately not a source (see "Discord role mirroring"). Neither the dispatcher nor any feature changes when a source is added.

Handlers receive a Principal { platform, userId, displayName, tier }. They never receive raw platform objects they could misuse to make their own authorisation decisions.

Capabilities ​

Some features should reach specific people, not everyone who is a member, and not based on a Discord role. A capability is a named permission granted to an individual, for example ha-doors or schedule-posts. The Home Assistant ones (see "Home Assistant devices" below), scheduled posts (/schedule) and setting the space state (/spacestate) are the ones that exist today.

  • Declared in code. src/features/capabilities.ts lists every capability (name and description). Admins can only grant names that exist there, so a typo can't create a silent grant. A command that requires an unknown name stops startup.
  • Commands require a tier and a capability, for example access: { minTier: "member", capability: "front-door" }. Both must hold, so access can't outlive membership. The dispatcher is the only place this is checked, and the refusal is the same generic message as any other, with the real reason (capability) in the log.
  • Nothing implies a capability: not a tier, not a Discord role, and not being an admin. An admin who needs one grants it to themselves, and that is audited like any other grant.
  • Guests never pass. A guest holds no capabilities and is refused by every capability-gated command, even if the file lists some for them. Setting someone to guest therefore takes effect straight away, and their capabilities are kept but inactive.
  • Hidden from /help for people who lack it. Discord still lists every registered command, so "secret" means unauthorised people are refused, not that the command is invisible (see discord-command-visibility.md).
  • Admin commands (admin only, private, audited through the store): /admin capabilities grant user: capability: [reason:], /admin capabilities grant-all user: [reason:] (every capability that exists, as one audited change; later-added capabilities need another run), /admin capabilities revoke ... and /admin capabilities list [user:]. The capability is picked from the registry. Granting to a guest is refused, since it would do nothing. With nothing registered they say "No capabilities are registered yet."
  • The person is DMed when a grant or revoke actually changes what they hold. The message names the capability (and its description). Every path that goes through AccessStore.apply does this, not only the slash command. A no-op apply sends nothing. If Discord can't deliver the DM (they have DMs from server members closed, they left the guild, Discord is down), Pixel logs it with actorLogFields of who made the change and the target's ID, does not fail the admin command, and never echoes the failure to the target. Message content is not logged. The Discord adapter implements a DirectMessenger port (core/ports/direct-message.ts); core never imports discord.js.
  • Names in the file that don't exist (for example after rolling back a release) are ignored. Pixel logs a warning and reports them to Sentry (names only), at startup and on /admin reload.

Threat model. A grant lives only in members.yaml, and only an admin from admins.yaml can change it, through the store. A compromised Discord server, a role change or a forged display name can't grant a capability, because Pixel never reads roles to decide access and identifies people only by ID. Someone who gains write access to the files, or to an admin account, can grant one, which is why every change is audited and why the files live on a protected volume.

Home Assistant devices ​

Controlling a device in Home Assistant (HA) is decided with capabilities, never with Discord roles. The devices are an allow-list in config/home-assistant/devices.yaml (see architecture.md), and one rule, in src/core/home-access.ts, decides who may do what to each. Every /ha command and autocomplete use it, so they can't disagree.

Needs
See a device and read its statusIts tier floor (minTier, member unless the file says friend or admin). No capability.
Act on a device (run an action)The tier floor, and ha-admin or the capability of the device's kind, and the action is one the file allows for that device.

The Home Assistant capabilities are ha-admin (any device, whatever its kind) and one per kind that can be controlled: ha-lights, ha-switches and ha-doors. A kind brings its own capability, so a new kind needs no change here. Sensors only report, so they have none. schedule-posts is separate: it lets a member schedule messages and native Discord polls (/schedule); see "Scheduled posts" in architecture.md. A schedule is paused if its creator loses that capability or their membership. spacestate is separate too: it lets a member set the space open or closed with /spacestate set (see "Setting the space state" in architecture.md). Nothing else gives it, and an admin without it is refused.

  • Nothing implies one. Not a tier, not a Discord role and not being a Pixel admin: an admin without the capability is refused, and grants it to themselves (audited) if they want it.
  • Guests never pass, even holding ha-admin, because the device's tier floor is never guest. A demotion takes effect at once.
  • A capability is narrow. ha-lights doesn't reach a switch or a door, and ha-admin can't do what the devices file doesn't allow for that device.
  • A refusal reveals nothing. The person gets one message (HOME_DENIED) whether the device doesn't exist, they lack the tier or they lack the capability, so it never confirms a device is there, what kind it is or what they'd need. The log has the real reason (tier, capability or action) and who asked.
  • Autocomplete uses the same rule. It offers only the devices a person may see, or for an action only those they may run it on. Autocomplete is still a convenience: the command checks again.
  • /ha set re-checks everything. Whatever autocomplete offered, the command looks the device and action up again, applies the rule, and refuses with HOME_DENIED. /ha open door: uses the same rule, only accepts doors, and always unlocks — it never unlatches.
  • Doors are member-only. The door kind's tier floor is member, so no door can be opened to friends, and /ha open has a member floor at the dispatcher. Doors only lock and unlock; open is not a kind action. An admin can switch all door control off at once with /admin doors off; while it's off nobody can act on a door, whatever they hold.
  • Where it's enforced. /ha has a coarse floor at the dispatcher (the lowest tier any device needs). Each device's own floor, capability and action are checked by the shared rule in the handler. A handler may be stricter than the dispatcher, never looser.

Threat model for Home Assistant. The full table (leaked token, compromised Discord account, compromised HA / remote access, HA down or slow) is in home-assistant.md. Short version: the token Pixel uses can't be limited by Home Assistant, so it can do whatever its user can; a non-admin user is the best fence available, and Pixel warns if the token belongs to an admin. The real fence is Pixel's own: the devices file (a human-written allow-list, never written by Pixel), the actions listed per device, and this rule. What HA has (read by just ha-devices so a person can pick from it) grants nothing: only devices.yaml counts. Someone who can edit devices.yaml or members.yaml, or who is an admin, can widen access, which is why those files are protected and why grants are audited. Doors are the sensitive case. A door action is identified only by the Discord account that ran it, so a taken-over account of someone with ha-doors (or ha-admin) could open the building. The mitigations are: doors are member-only and need a capability that is never implied; every door action is logged with the account's ID, name and handle and the outcome; an admin can stop all door control at once with /admin doors off, without a deploy; and revoking Pixel's token in Home Assistant stops everything. There is deliberately no confirmation step, rate limit, condition (such as the space being open) or public notice yet; add them if the risk or the board asks for it.

Discord role mirroring ​

Pixel's own data is the source of truth for tiers. Where the Discord server uses roles, Pixel can also hand out the matching role when an admin changes someone's level. It is a one-way mirror from Pixel to Discord: Pixel never reads a role to decide a tier, so a moderator handing out member gives nothing in Pixel, and a compromised Discord can't escalate anyone. It works the same when a member later uses Pixel from another platform, and in a server that has one role for everyone (leave the settings unset).

  • Setup: set DISCORD_ROLE_MEMBER and/or DISCORD_ROLE_FRIEND to a role's name (or its ID, which survives a rename). A tier with no setting isn't mirrored. Only these two tiers have roles: admin has none, a guest holds neither, and roles that aren't mapped (such as hacker) are never touched.
  • The bot's role: it needs the Manage Roles permission, and its highest role must sit above the roles it hands out. Keep it just above member and friend and below anything powerful, so that if its token ever leaked it could only give out those two roles. It can't change roles at or above its own.
  • Checks, every time: Pixel asks Discord for the roles and its own standing on every operation, with no caching, so renames and moved roles are noticed straight away. A tier whose role doesn't exist, is ambiguous (two roles share the name; use the ID), is managed by an integration, isn't below the bot's highest role, or the bot lacks Manage Roles, is turned off on its own. The others keep working. At startup and on /admin reload this is logged and reported to Sentry once per tier, and /admin status shows each tier as on or off with the reason.
  • On /admin level set: Pixel's data is updated first. Then the person's mapped role is added and the other tier's removed (only if they hold it), with an audit-log reason like "Set to member by Ada (id) via Pixel". If Discord fails, the reply says so plainly ("Pixel is updated, but the Discord roles weren't changed: …") and /admin sync fixes it. Roles are brought into line even when Pixel says nothing changed.
  • /admin sync [user:]: sets the mapped roles from Pixel's data for one person, or for everyone in Pixel's lists. Pixel always wins and nothing is pulled from Discord. An admin is mirrored by their members entry, since admins are members too. It can only reach people Pixel lists: finding people who hold a role but aren't listed would need the privileged GuildMembers intent, which Pixel doesn't use.
  • /admin level get shows the mapped roles someone holds and whether they match their Pixel level. Role names come from Discord moderators, so they are shown as code spans and can't render as formatting, links or mentions.

Audit in phase 1 ​

  • Tier changes: the config files are the record of who has which tier. Editors should keep a private change history (for example a private repo or Key Vault secret versions).
  • Every action: each executed command (command.executed, with its outcome), denial (command.denied) and rate limit (command.rate_limited) is logged as a structured event that names who did it: { event, command, tier, user: "discord:<id>", userName, userHandle }. This means abusive users can be identified and banned. Ban by user (the ID). Names can change and can be faked to look like someone else.

Context rules ​

  • Commands can limit where they run: access: { minTier: "member", contexts: ["dm"] }.
  • Anything that shows personal or member-only information replies ephemerally (only visible to the caller).
  • Interactions from guilds other than DISCORD_GUILD_ID are refused, and the bot leaves those guilds.
  • Phase 1 has no DMs. Commands are registered to the guild only, and Discord doesn't offer guild commands in DMs. Every interaction therefore has the group context. Supporting DMs later means registering global commands. The access model already handles DMs, because tiers come from the user ID rather than the guild.

Privacy (GDPR) ​

  • Phase 1 Pixel stores the operator-maintained config files, small runtime state in data/, and scheduled post content in data/schedules.yaml (the text or poll someone asked Pixel to post, plus the creator's Discord ID). It does not log message content.
  • /whoami shows a person their Discord ID and effective tier, so they can check what Pixel thinks.
  • Logs and Sentry identify users by their platform ID (discord:<id>). We chose this over pseudonyms because pseudonyms change whenever the key is rotated, which breaks tracking one user's issues over time. Logs and Sentry also record the user's display name and Discord handle, so people can recognise who did something. IDs and names count as personal data under GDPR. The privacy notice says they are stored in logs and Sentry, and for how long. Message content and command arguments are never logged.
  • Where logs go. Every log line goes to the console, to a rotating file (PIXEL_LOG_DIR, about two weeks; readable only by the owner on a normal filesystem, while on Azure it sits on the Azure Files share, where anyone who can reach the Container App or the storage account can read it) and, info and above, to Sentry Logs. So the IDs, names and handles that appear in logs are kept in all three, under each one's retention (see the notice). Token shapes are masked in all of them.
  • Sentry's dataCollection options are all turned off: user info, headers, cookies, bodies, query params and stack-frame local variables. A beforeSend hook also scrubs anything that looks like a bot token.
  • /feedback is the one place a person's own words leave Pixel. When someone sends feedback, the message is sent to Sentry as user feedback, together with their Discord name, handle and ID (so the maintainers can follow up), and the reply tells them so. It is private, rate limited per person (a few at once, then one every ten minutes), limited to 1000 characters, and redacted for anything that looks like a secret. Pixel never logs the text, only its length. The notice says that feedback, with the sender's name and ID, is kept in Sentry under its retention.
  • The notice is privacy.md, linked from /help. How operators handle a request: runbook. Board sign-off is still outstanding.

Threats and mitigations (phase 1) ​

ThreatMitigation
Impersonation through a matching username or nicknameAuthorisation uses only Discord user IDs
An unquoted YAML ID is rounded to someone else's IDSchema requires strings; startup fails otherwise
A broken config leaves Pixel open, or locks everyone outFail closed: invalid config means no start; at least one admin required
The member list leaks through the repoReal files are gitignored; only example files are committed
A bug in an adapter skips authorisationCentral dispatcher check, plus tests that every command declares access
Someone abuses the bot from a foreign guildGuild allow-list; the bot leaves other guilds
Bot token leaksStored in .env or Key Vault, never logged, separate dev and prod bots
Someone controls a Home Assistant device they shouldn'tOne rule: tier floor, plus ha-admin or the kind's capability, plus an action the file allows; a generic refusal that reveals nothing; the token belongs to a non-admin HA user. Full table: home-assistant.md
Forged interactionsGateway connection, so payloads arrive over an authenticated socket. If HTTP interactions are used later, verify the Ed25519 signature

Testing requirements (phase 1) ​

  • Config loading:
    • valid files
    • an unquoted numeric ID (rejected)
    • an unknown tier
    • a duplicate ID
    • an ID in both files
    • an empty admin list
    • a missing file
  • Tier resolution: guest by default, friend and member from the file, admin overrides, highest across sources.
  • Registry: every registered command has access.minTier.
  • Dispatcher: a denied command never calls its handler, the denial is logged, and context restrictions are enforced.
  • Discord adapter: an interaction from the wrong guild is refused, and ephemeral flags are set for private replies.

Later phases (not in scope now) ​

These are recorded so that phase 1 doesn't make them harder. Each will get an ADR before it is built.

  • Discord role sync (roles as a tier source): decided against. Roles are only ever mirrored to from Pixel's data (see "Discord role mirroring"), never read to decide a tier.
  • More interactive platforms (Telegram): these need account linking (#16). No database: linking writes telegram:<id> onto the person's members.yaml entry through the access store. The planned flow:
    1. The person runs /link on Discord.
    2. Pixel replies ephemerally with a one-time code: about 40 bits of entropy, stored only as a hash in memory, valid for 10 minutes, single use. Codes pending at a restart are lost (Pixel is a single instance).
    3. The person sends the code to the bot in a Telegram DM. It is rate-limited per Telegram user, and redeeming it adds the id through the store, which audits it.
    4. Both accounts are notified. /unlink from either side. Guests can't link (no entry to add to). Admins' Telegram IDs are added by hand in both files.
  • Database-backed grants: done without a database (#25; #18 closed). Admin commands rewrite members.yaml.
  • Freshness: once tiers come from outside sources, re-verify a tier older than 24 hours before privileged commands, and reconcile every night.

A Pixelbar community project.