Add admin identity module: better-auth, per-app permissions, invitations
Introduces src/models/admin/, a dedicated identity and permissions module on its own nachklang_admin database, and the shared authenticator that feedback and tickets will move onto in the cutover step. Nothing swaps over yet: feedback.auth.ts and tickets.auth.ts still authenticate against the legacy calendar sessions, so production behaviour is unchanged. - better-auth 1.7 mounted at /admin/auth/*, sessions as httpOnly cookies scoped to .nachklang.art so one sign-in covers every *.nachklang.art app. - Accounts are invite-only: public sign-up is disabled, and the invitations plugin is the only code that creates users. Tokens are stored as SHA-256 hashes and travel in the request body, never in a URL. - Per-app permissions in user_app_permissions; requireAppAccess(app) queries the database on every request (no cookie cache) so disabling a user or revoking a session takes effect immediately. - ADMIN_BOOTSTRAP_EMAIL guarantees a way in on an empty database, idempotently and without crashing the API if the database is unreachable at boot. - Guards prevent an admin from removing their own admin permission, disabling themselves, or stripping the last active admin. The admin pool uses the callback-style mysql2, not mysql2/promise: Kysely's MysqlDialect drives the pool with callbacks, and the promise wrapper ignores them, so every query hangs silently. Only the integration tests caught this. Schema in sql/admin/001_init.sql, derived from getAuthTables() on the installed better-auth rather than the published CLI, which lags the library and omits account.issuer. app.ts is split into src/app.factory.ts so the integration tests drive the real middleware order rather than a copy of it. Tests: 131 unit, plus 41 integration tests against a throwaway MariaDB started by test/integration/setup.ts (docker or podman). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
-- Local dev only. Mirrors the table definitions in sql/admin/001_init.sql -
|
||||
-- keep the two in step - and seeds a ready-to-use dev account on top.
|
||||
USE nachklang_admin;
|
||||
|
||||
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. `role` is reserved for
|
||||
-- per-app roles later and is 'admin' for every row today.
|
||||
CREATE TABLE IF NOT EXISTS `user_app_permissions` (
|
||||
`user_id` VARCHAR(36) NOT NULL,
|
||||
`app` ENUM('calendar','feedback','tickets','admin') NOT NULL,
|
||||
`role` VARCHAR(32) NOT NULL DEFAULT 'admin',
|
||||
`granted_by` VARCHAR(36) DEFAULT NULL,
|
||||
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`user_id`, `app`),
|
||||
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,
|
||||
`apps` 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;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Dev seed: dev@nachklang.art / devpassword, with every app permission.
|
||||
-- Local only - this account exists nowhere but in this container.
|
||||
--
|
||||
-- The password hash is better-auth's own scrypt format (salt:hash), produced
|
||||
-- with better-auth 1.7.2's hashPassword(). Regenerate it if better-auth ever
|
||||
-- changes that format; a hash it cannot parse shows up as "invalid password"
|
||||
-- on an otherwise correct sign-in.
|
||||
--
|
||||
-- `issuer` must be exactly 'local:credential' - it is how better-auth 1.7
|
||||
-- recognises a local password account when signing in.
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO `user` (`id`, `name`, `email`, `emailVerified`, `disabled`)
|
||||
VALUES ('dev-user-0000-0000-0000-000000000001', 'Dev Admin', 'dev@nachklang.art', 1, 0);
|
||||
|
||||
INSERT INTO `account` (`id`, `issuer`, `accountId`, `providerId`, `userId`, `password`)
|
||||
VALUES (
|
||||
'dev-acct-0000-0000-0000-000000000001',
|
||||
'local:credential',
|
||||
'dev-user-0000-0000-0000-000000000001',
|
||||
'credential',
|
||||
'dev-user-0000-0000-0000-000000000001',
|
||||
'e6a0485feb04b8fa64453db87badd8e1:85aaffd845e1e44fabc5be97d684c0f533845ed11088bd3f4d533ef277ead71da8372b5c6a72c7eae2d48e3270c50e2d13cffa4fcfb0b5cec456100b8f007ed2'
|
||||
);
|
||||
|
||||
INSERT INTO `user_app_permissions` (`user_id`, `app`, `role`) VALUES
|
||||
('dev-user-0000-0000-0000-000000000001', 'calendar', 'admin'),
|
||||
('dev-user-0000-0000-0000-000000000001', 'feedback', 'admin'),
|
||||
('dev-user-0000-0000-0000-000000000001', 'tickets', 'admin'),
|
||||
('dev-user-0000-0000-0000-000000000001', 'admin', 'admin');
|
||||
Reference in New Issue
Block a user