Add admin identity module: better-auth, per-app permissions, invitations #12
Open
Paddy
wants to merge 4 commits from
feature/admin-auth-module into master
pull from: feature/admin-auth-module
merge into: Nachklang:master
Nachklang:master
Nachklang:feature/api-esm-prep
Nachklang:feature/email-via-salesforce
Nachklang:feature/ticket-shop
Nachklang:feature/feedback-module
Nachklang:feature/aiRefactoring20260502
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
27eb301086 |
Fail CORS closed when NODE_ENV is unset
The CORS origin check used `NODE_ENV !== 'production'` to decide whether to allow loopback and private-LAN origins. That is not the same question as "is this a dev machine" when NODE_ENV is unset - which is exactly what a fresh Plesk vhost gives you. On such a host the check called it dev and answered `Access-Control-Allow-Origin: http://localhost:<any port>` together with `Access-Control-Allow-Credentials: true`. With the admin session cookie scoped to .nachklang.art, that let any page served from localhost on a signed-in admin's machine read their data cross-origin. admin.config.ts already resolves this correctly - only an explicit 'development' or 'test' relaxes anything, so unset is treated as production - so the CORS check now derives from its `isProd` rather than re-deriving its own answer from NODE_ENV. Two places asking the same question two different ways was the bug. Verified against the built app with a vhost-like environment (no NODE_ENV): before, Origin http://localhost:9999 came back allowed with credentials; after, it is rejected, while https://tickets.nachklang.art and https://admin.nachklang.art are still allowed with credentials. With NODE_ENV=development localhost is still allowed, so local development and the LAN exception for real-device testing are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
33489585a0 |
Model permissions as (app, role) and name passkeys from their AAGUID
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>
|
||
|
|
dbcd5b56f6 |
Harden the admin module after a fresh-context review
Six defects found by an independent review of
|
||
|
|
7aac07a013 |
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> |