feat(backend): JIT-provision the local User on first Authentik login
AuthentikStrategy no longer rejects a valid token whose user has no local row — it creates the User from the token claims (given_name/family_name/ email) via the new shared resolveOrProvisionAuthentikUser helper, which is race-safe (P2002 -> re-read) and captures the User to the sync log. The WS token path (TokenVerificationService.verifyAuthentik) and OnboardingService now use the same helper, removing three copies of the lookup/create logic. A provisioned user still has no Membership and therefore no rights: LT role assignment from Authentik groups is the remaining gap; Verantwortliche go through the onboarding approval flow. Tests: provision-user.spec.ts (existing/new/race/rethrow); npm test green at 51. Docs updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { resolveOrProvisionAuthentikUser } from './provision-user';
|
||||
|
||||
const CLAIMS = {
|
||||
sub: 'sub-1',
|
||||
email: 'New.Person@Example.org',
|
||||
firstName: 'New',
|
||||
lastName: 'Person',
|
||||
};
|
||||
|
||||
function p2002() {
|
||||
return new Prisma.PrismaClientKnownRequestError('unique', {
|
||||
code: 'P2002',
|
||||
clientVersion: 'test',
|
||||
});
|
||||
}
|
||||
|
||||
function makeMocks() {
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
return { sync };
|
||||
}
|
||||
|
||||
describe('resolveOrProvisionAuthentikUser', () => {
|
||||
it('returns the existing user without creating or capturing', async () => {
|
||||
const { sync } = makeMocks();
|
||||
const existing = { id: 'u-1', authentikSub: 'sub-1', memberships: [] };
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(existing),
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
|
||||
expect(res).toBe(existing);
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(sync.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('provisions a new user from claims (lowercased email) and captures it', async () => {
|
||||
const { sync } = makeMocks();
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({ id: 'u-2', ...data }),
|
||||
),
|
||||
},
|
||||
};
|
||||
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
|
||||
expect(prisma.user.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
authentikSub: 'sub-1',
|
||||
email: 'new.person@example.org',
|
||||
firstName: 'New',
|
||||
lastName: 'Person',
|
||||
},
|
||||
});
|
||||
expect(res.memberships).toEqual([]);
|
||||
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-2', expect.anything());
|
||||
});
|
||||
|
||||
it('recovers from a concurrent-create race (P2002) by re-reading', async () => {
|
||||
const { sync } = makeMocks();
|
||||
const raced = { id: 'u-3', authentikSub: 'sub-1', memberships: [] };
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null) // first check: not there yet
|
||||
.mockResolvedValueOnce(raced), // after the failed insert: it exists
|
||||
create: jest.fn().mockRejectedValue(p2002()),
|
||||
},
|
||||
};
|
||||
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
|
||||
expect(res).toBe(raced);
|
||||
expect(sync.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows a P2002 when the row still cannot be found', async () => {
|
||||
const { sync } = makeMocks();
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockRejectedValue(p2002()),
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS),
|
||||
).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError);
|
||||
});
|
||||
|
||||
it('rethrows a non-P2002 error', async () => {
|
||||
const { sync } = makeMocks();
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockRejectedValue(new Error('db down')),
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user