33489585a0
Two changes to the admin module, both made now because it is not deployed yet
and neither is free later.
Permissions were "an app", with a `role` column reserved for a future
fine-grained model. Reviewing whether that reservation was enough found three
problems:
- Every row was written with role = 'admin', hardcoded, and the column
defaulted to it. On a `tickets` row that reads as "tickets administrator"
when it only ever meant "has access", and once real roles existed there
would have been no way to tell an old plain grant from a deliberate one.
- The role never left the database. /admin/me, the user list, the user detail
and both write endpoints all spoke apps: AppName[]. Adding roles would have
been a breaking change to /admin/me - and after the cutover that endpoint
has two more consumers, turning a local edit into a coordinated deploy of
three apps.
- The key (user_id, app) allowed one role per app, i.e. a tier rather than a
set of capabilities. Choosing later means an ALTER on a live table.
So: the key is now (user_id, app, role), the role is `access`, and APP_ROLES
in admin.schema.ts is the contract - a role not listed there is rejected with
400 rather than written. permissions: [{app, role}] is on the wire alongside
the derived apps: AppName[], which is kept because the three frontends only
ever ask "may I show this app?". Both write endpoints accept either shape, and
the invitation column (now `permissions`) is parsed leniently: invitations live
seven days, so a deploy that changes the shape has in-flight rows in the old
one. requireAppAccess(app, role?) takes an optional role; nothing passes one
yet.
countActiveAdminsForUpdate now counts DISTINCT users rather than rows. With
several roles per app, counting rows would make a single admin holding two
roles look like two admins and defeat the last-admin guard at exactly the
moment it matters.
Separately, passkey registration now fills `name` from the authenticator's
AAGUID via registration.afterVerification and better-auth's own
getAuthenticatorName, yielding "1Password", "iCloud Keychain", "Windows Hello".
Without it the column stayed NULL and the account page could only label every
passkey "Passkey" - useless when someone has to remove the one on the device
they just lost. A client-supplied name still wins; an unknown AAGUID still
leaves it blank.
148 unit tests (up from 131, including the new admin.schema.test.ts) and 41
integration tests pass. The integration suite applies sql/admin/001_init.sql,
so the new key is exercised rather than trusted.
No production migration is needed - the module is not deployed. An existing dev
database needs three statements: set role = 'access', drop and re-add the
primary key, rename invitations.apps to permissions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
7.2 KiB
SQL
151 lines
7.2 KiB
SQL
-- nachklang_admin: identity, sessions and per-app permissions for every
|
|
-- *.nachklang.art app.
|
|
--
|
|
-- The better-auth tables below (user, session, account, verification, passkey,
|
|
-- rateLimit) mirror what better-auth 1.7.2 derives from the configuration in
|
|
-- src/models/admin/admin.auth.ts, including the `disabled` additionalField on
|
|
-- `user` and the `rateLimit` table that rateLimit.storage='database' requires.
|
|
-- On every better-auth upgrade: re-derive the table list, diff it against this
|
|
-- file, and add a numbered migration - never edit this one in place.
|
|
--
|
|
-- Table and column names are better-auth's own ("camel" casing, so `rateLimit`
|
|
-- and `userId`). MariaDB on Linux compares table names case-sensitively, so the
|
|
-- casing here is load-bearing. The two Nachklang-owned tables at the bottom use
|
|
-- the snake_case convention of the rest of this repo's SQL.
|
|
|
|
CREATE TABLE IF NOT EXISTS `user` (
|
|
`id` VARCHAR(36) NOT NULL,
|
|
`name` VARCHAR(255) NOT NULL,
|
|
`email` VARCHAR(255) NOT NULL,
|
|
`emailVerified` TINYINT(1) NOT NULL DEFAULT 0,
|
|
`image` TEXT DEFAULT NULL,
|
|
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
-- Nachklang addition, declared through better-auth's additionalFields so
|
|
-- the adapter knows about it. Disabling also revokes the user's sessions.
|
|
`disabled` TINYINT(1) NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `user_email` (`email`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
CREATE TABLE IF NOT EXISTS `session` (
|
|
`id` VARCHAR(36) NOT NULL,
|
|
`expiresAt` DATETIME NOT NULL,
|
|
`token` VARCHAR(255) NOT NULL,
|
|
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`ipAddress` VARCHAR(255) DEFAULT NULL,
|
|
`userAgent` TEXT DEFAULT NULL,
|
|
`userId` VARCHAR(36) NOT NULL,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `session_token` (`token`),
|
|
KEY `session_user` (`userId`),
|
|
CONSTRAINT `session_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
CREATE TABLE IF NOT EXISTS `account` (
|
|
`id` VARCHAR(36) NOT NULL,
|
|
-- 1.7 addition: distinguishes a local credential account
|
|
-- ("local:credential") from an OAuth issuer. Written by better-auth.
|
|
`issuer` VARCHAR(255) NOT NULL,
|
|
`accountId` VARCHAR(255) NOT NULL,
|
|
`providerId` VARCHAR(255) NOT NULL,
|
|
`userId` VARCHAR(36) NOT NULL,
|
|
`accessToken` TEXT DEFAULT NULL,
|
|
`refreshToken` TEXT DEFAULT NULL,
|
|
`idToken` TEXT DEFAULT NULL,
|
|
`accessTokenExpiresAt` DATETIME DEFAULT NULL,
|
|
`refreshTokenExpiresAt` DATETIME DEFAULT NULL,
|
|
`scope` TEXT DEFAULT NULL,
|
|
`password` TEXT DEFAULT NULL,
|
|
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
KEY `account_user` (`userId`),
|
|
KEY `account_provider` (`providerId`, `accountId`),
|
|
CONSTRAINT `account_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Password-reset and e-mail-verification tokens.
|
|
CREATE TABLE IF NOT EXISTS `verification` (
|
|
`id` VARCHAR(36) NOT NULL,
|
|
`identifier` VARCHAR(255) NOT NULL,
|
|
`value` TEXT NOT NULL,
|
|
`expiresAt` DATETIME NOT NULL,
|
|
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
KEY `verification_identifier` (`identifier`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
CREATE TABLE IF NOT EXISTS `passkey` (
|
|
`id` VARCHAR(36) NOT NULL,
|
|
`name` VARCHAR(255) DEFAULT NULL,
|
|
`publicKey` TEXT NOT NULL,
|
|
`userId` VARCHAR(36) NOT NULL,
|
|
`credentialID` VARCHAR(255) NOT NULL,
|
|
`counter` INT NOT NULL DEFAULT 0,
|
|
`deviceType` VARCHAR(255) NOT NULL,
|
|
`backedUp` TINYINT(1) NOT NULL DEFAULT 0,
|
|
`transports` VARCHAR(255) DEFAULT NULL,
|
|
`createdAt` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
`aaguid` VARCHAR(255) DEFAULT NULL,
|
|
PRIMARY KEY (`id`),
|
|
KEY `passkey_user` (`userId`),
|
|
KEY `passkey_credential` (`credentialID`),
|
|
CONSTRAINT `passkey_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Required by rateLimit.storage = 'database' in admin.auth.ts. Passenger may
|
|
-- run several API instances, and an in-memory limiter would give each of them
|
|
-- its own budget.
|
|
CREATE TABLE IF NOT EXISTS `rateLimit` (
|
|
`id` VARCHAR(36) NOT NULL,
|
|
`key` VARCHAR(255) NOT NULL,
|
|
`count` INT NOT NULL DEFAULT 0,
|
|
-- Epoch milliseconds, not a DATETIME: better-auth stores a number here.
|
|
`lastRequest` BIGINT NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `rate_limit_key` (`key`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Nachklang-owned tables
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- Which apps a user may administer. `admin` is just another app: holding it is
|
|
-- what lets someone manage users and invitations. A permission is (app, role);
|
|
-- `access` is the only role today, and the key admits several per app so finer
|
|
-- ones can be added by inserting rows rather than by migrating this table.
|
|
CREATE TABLE IF NOT EXISTS `user_app_permissions` (
|
|
`user_id` VARCHAR(36) NOT NULL,
|
|
`app` ENUM('calendar','feedback','tickets','admin') NOT NULL,
|
|
-- One row per (user, app, role). `access` means "may use this app at all"
|
|
-- and is the only role today; the key allows several per app so a finer
|
|
-- permission can be added later by inserting rows, not by migrating.
|
|
`role` VARCHAR(32) NOT NULL DEFAULT 'access',
|
|
`granted_by` VARCHAR(36) DEFAULT NULL,
|
|
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
-- (user_id, app) is the leftmost prefix of this key, so the per-request
|
|
-- permission lookup needs no separate index.
|
|
PRIMARY KEY (`user_id`, `app`, `role`),
|
|
CONSTRAINT `uap_user_fk` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- The only route to a new account: there is no public sign-up. Only the
|
|
-- SHA-256 of the token is stored, so a dump of this table hands out no access.
|
|
CREATE TABLE IF NOT EXISTS `invitations` (
|
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
`email` VARCHAR(255) NOT NULL,
|
|
`name` VARCHAR(255) NOT NULL,
|
|
`token_hash` CHAR(64) NOT NULL,
|
|
`permissions` JSON NOT NULL,
|
|
`invited_by` VARCHAR(36) DEFAULT NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`expires_at` DATETIME NOT NULL,
|
|
`accepted_at` DATETIME DEFAULT NULL,
|
|
`revoked_at` DATETIME DEFAULT NULL,
|
|
UNIQUE KEY `inv_token_hash` (`token_hash`),
|
|
KEY `inv_email` (`email`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|