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>
This commit is contained in:
@@ -79,7 +79,12 @@ describe('bootstrapAdmin', () => {
|
||||
|
||||
await bootstrapAdmin();
|
||||
|
||||
expect(createInvitation).toHaveBeenCalledWith('boss@nachklang.art', 'Nachklang Admin', ['admin'], null);
|
||||
expect(createInvitation).toHaveBeenCalledWith(
|
||||
'boss@nachklang.art',
|
||||
'Nachklang Admin',
|
||||
[{app: 'admin', role: 'access'}],
|
||||
null
|
||||
);
|
||||
expect(mockMail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ const activeUser = {
|
||||
email: 'a@nachklang.art',
|
||||
displayName: 'A',
|
||||
disabled: false,
|
||||
permissions: [
|
||||
{app: 'feedback', role: 'access'},
|
||||
{app: 'admin', role: 'access'}
|
||||
],
|
||||
apps: ['feedback', 'admin']
|
||||
};
|
||||
|
||||
@@ -60,6 +64,10 @@ describe('resolveAccess', () => {
|
||||
email: 'a@nachklang.art',
|
||||
displayName: 'A',
|
||||
disabled: false,
|
||||
permissions: [
|
||||
{app: 'feedback', role: 'access'},
|
||||
{app: 'admin', role: 'access'}
|
||||
],
|
||||
apps: ['feedback', 'admin']
|
||||
});
|
||||
// No cookieCache: exactly one lookup per request, never zero.
|
||||
@@ -127,7 +135,11 @@ describe('requireAppAccess', () => {
|
||||
|
||||
it('403s a signed-in user without that app permission', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockResolvedValue({...activeUser, apps: ['feedback']});
|
||||
mockLoadAccess.mockResolvedValue({
|
||||
...activeUser,
|
||||
permissions: [{app: 'feedback', role: 'access'}],
|
||||
apps: ['feedback']
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
@@ -171,4 +183,40 @@ describe('requireAppAccess', () => {
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The seam a finer per-app permission arrives through. Nothing passes a role
|
||||
// today, so these two pin the behaviour before there is anything to break.
|
||||
it('403s when a specific role is required and the user only holds another', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockResolvedValue({
|
||||
...activeUser,
|
||||
permissions: [{app: 'tickets', role: 'access'}],
|
||||
apps: ['tickets']
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes when the user holds exactly the required role', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockResolvedValue({
|
||||
...activeUser,
|
||||
permissions: [
|
||||
{app: 'tickets', role: 'access'},
|
||||
{app: 'tickets', role: 'refund'}
|
||||
],
|
||||
apps: ['tickets']
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
ACCESS_ROLE,
|
||||
appsOf,
|
||||
isAppPermission,
|
||||
isAppRole,
|
||||
toPermissions
|
||||
} from '../../src/models/admin/admin.schema.js';
|
||||
|
||||
/**
|
||||
* The permission model is (app, role). These tests pin the two properties the
|
||||
* rest of the module leans on: that the older `['tickets']` shape still means
|
||||
* "tickets at the access role", and that nothing outside APP_ROLES gets in.
|
||||
*/
|
||||
|
||||
describe('toPermissions', () => {
|
||||
it('reads the full (app, role) form', () => {
|
||||
expect(toPermissions([{app: 'tickets', role: 'access'}])).toEqual([
|
||||
{app: 'tickets', role: 'access'}
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a plain app list as that app at the access role', () => {
|
||||
expect(toPermissions(['feedback', 'admin'])).toEqual([
|
||||
{app: 'feedback', role: ACCESS_ROLE},
|
||||
{app: 'admin', role: ACCESS_ROLE}
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts the two forms mixed, which is what a half-migrated caller sends', () => {
|
||||
expect(toPermissions(['feedback', {app: 'tickets', role: 'access'}])).toEqual([
|
||||
{app: 'feedback', role: ACCESS_ROLE},
|
||||
{app: 'tickets', role: ACCESS_ROLE}
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops duplicates of the same (app, role)', () => {
|
||||
expect(toPermissions(['tickets', {app: 'tickets', role: 'access'}])).toEqual([
|
||||
{app: 'tickets', role: ACCESS_ROLE}
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects rather than silently dropping an unknown app', () => {
|
||||
// Silently ignoring it would let "grant calendar + nonsense" look like a
|
||||
// success while granting less than the caller asked for.
|
||||
expect(toPermissions(['calendar', 'nonsense'])).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an unknown role', () => {
|
||||
expect(toPermissions([{app: 'tickets', role: 'refund'}])).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects anything that is not a list', () => {
|
||||
expect(toPermissions('admin')).toBeNull();
|
||||
expect(toPermissions(null)).toBeNull();
|
||||
expect(toPermissions({app: 'admin', role: 'access'})).toBeNull();
|
||||
});
|
||||
|
||||
it('reads an empty list as "no permissions", not as invalid', () => {
|
||||
expect(toPermissions([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAppRole', () => {
|
||||
it('accepts the access role for every app', () => {
|
||||
expect(isAppRole('admin', ACCESS_ROLE)).toBe(true);
|
||||
expect(isAppRole('calendar', ACCESS_ROLE)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a role that does not exist yet', () => {
|
||||
expect(isAppRole('tickets', 'refund')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAppPermission', () => {
|
||||
it('needs both halves to be valid', () => {
|
||||
expect(isAppPermission({app: 'tickets', role: ACCESS_ROLE})).toBe(true);
|
||||
expect(isAppPermission({app: 'tickets'})).toBe(false);
|
||||
expect(isAppPermission({role: ACCESS_ROLE})).toBe(false);
|
||||
expect(isAppPermission(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appsOf', () => {
|
||||
it('collapses several roles on one app to a single entry', () => {
|
||||
// The point of the derived list: a user with two roles on tickets has
|
||||
// access to tickets once, not twice.
|
||||
const apps = appsOf([
|
||||
{app: 'tickets', role: ACCESS_ROLE},
|
||||
{app: 'tickets', role: 'future-role'},
|
||||
{app: 'admin', role: ACCESS_ROLE}
|
||||
]);
|
||||
expect(apps).toEqual(['tickets', 'admin']);
|
||||
});
|
||||
|
||||
it('is empty for no permissions', () => {
|
||||
expect(appsOf([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -97,7 +97,37 @@ describe('PUT /admin/users/:id/permissions', () => {
|
||||
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['tickets']});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(service.setPermissionsGuarded).toHaveBeenCalledWith('other', ['tickets'], 'me');
|
||||
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
|
||||
'other',
|
||||
[{app: 'tickets', role: 'access'}],
|
||||
'me'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the richer {permissions} body', async () => {
|
||||
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
|
||||
|
||||
const res = await request(makeApp('me'))
|
||||
.put('/admin/users/other/permissions')
|
||||
.send({permissions: [{app: 'tickets', role: 'access'}]});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
|
||||
'other',
|
||||
[{app: 'tickets', role: 'access'}],
|
||||
'me'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a role that does not exist', async () => {
|
||||
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
|
||||
|
||||
const res = await request(makeApp('me'))
|
||||
.put('/admin/users/other/permissions')
|
||||
.send({permissions: [{app: 'tickets', role: 'refund'}]});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows granting permissions to someone who has none', async () => {
|
||||
@@ -106,7 +136,11 @@ describe('PUT /admin/users/:id/permissions', () => {
|
||||
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['feedback', 'tickets']});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(service.setPermissionsGuarded).toHaveBeenCalledWith('other', ['feedback', 'tickets'], 'me');
|
||||
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
|
||||
'other',
|
||||
[{app: 'feedback', role: 'access'}, {app: 'tickets', role: 'access'}],
|
||||
'me'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user