On every Authentik login the token's `groups` claim is compared against AUTHENTIK_LEITUNGSTEAM_GROUP (default "Leitungsteam") and mirrored to the new User.isLeitungsteam column. LT is global, not KC-scoped, so it lives on the User rather than as a per-KC Membership row: toAuthenticatedUser() synthesises a virtual global LEITUNGSTEAM membership from the flag, so RolesGuard / visibility / TeamerService keep working unchanged. - provision helper gains an isLeitungsteam arg and reconciles the flag both ways (grant on join, drop when the group is gone), capturing a User UPDATE to the sync log. - verifyAuthentikClaims() now also returns isLeitungsteam; strategy, WS path and onboarding all funnel through the shared helper + mapper. - new env var AUTHENTIK_LEITUNGSTEAM_GROUP. Tests: provision-user.spec.ts extended (flag up/down, virtual membership); npm test green at 55. Docs updated; ops note added that the Authentik provider must emit the groups claim. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
import { Prisma, Role } from '@prisma/client';
|
|
import {
|
|
GLOBAL_LT_KC_ID,
|
|
resolveOrProvisionAuthentikUser,
|
|
toAuthenticatedUser,
|
|
} 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',
|
|
});
|
|
}
|
|
|
|
describe('resolveOrProvisionAuthentikUser', () => {
|
|
it('returns the existing user without creating or capturing when nothing changed', async () => {
|
|
const sync = { capture: jest.fn() };
|
|
const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
|
|
const prisma = {
|
|
user: {
|
|
findUnique: jest.fn().mockResolvedValue(existing),
|
|
create: jest.fn(),
|
|
update: jest.fn(),
|
|
},
|
|
};
|
|
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
|
|
expect(res).toBe(existing);
|
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
expect(sync.capture).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('provisions a new user from claims (lowercased email) and captures it', async () => {
|
|
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
|
const prisma = {
|
|
user: {
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
|
Promise.resolve({ id: 'u-2', isLeitungsteam: false, ...data }),
|
|
),
|
|
update: jest.fn(),
|
|
},
|
|
};
|
|
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
|
|
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('reconciles the LEITUNGSTEAM flag up when the token now has the group', async () => {
|
|
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
|
const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
|
|
const prisma = {
|
|
user: {
|
|
findUnique: jest.fn().mockResolvedValue(existing),
|
|
create: jest.fn(),
|
|
update: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
|
Promise.resolve({ ...existing, ...data, memberships: [] }),
|
|
),
|
|
},
|
|
};
|
|
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, true);
|
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
|
where: { id: 'u-1' },
|
|
data: { isLeitungsteam: true },
|
|
include: { memberships: { where: { status: 'ACTIVE' } } },
|
|
});
|
|
expect(res.isLeitungsteam).toBe(true);
|
|
expect(sync.capture).toHaveBeenCalledWith('User', 'UPDATE', 'u-1', expect.anything());
|
|
});
|
|
|
|
it('reconciles the LEITUNGSTEAM flag down when the group is gone', async () => {
|
|
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
|
const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: true, memberships: [] };
|
|
const prisma = {
|
|
user: {
|
|
findUnique: jest.fn().mockResolvedValue(existing),
|
|
create: jest.fn(),
|
|
update: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
|
Promise.resolve({ ...existing, ...data, memberships: [] }),
|
|
),
|
|
},
|
|
};
|
|
await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
|
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({ data: { isLeitungsteam: false } }),
|
|
);
|
|
});
|
|
|
|
it('recovers from a concurrent-create race (P2002) by re-reading', async () => {
|
|
const sync = { capture: jest.fn() };
|
|
const raced = { id: 'u-3', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
|
|
const prisma = {
|
|
user: {
|
|
findUnique: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(raced),
|
|
create: jest.fn().mockRejectedValue(p2002()),
|
|
update: jest.fn(),
|
|
},
|
|
};
|
|
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
|
|
expect(res).toBe(raced);
|
|
expect(sync.capture).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rethrows a P2002 when the row still cannot be found', async () => {
|
|
const sync = { capture: jest.fn() };
|
|
const prisma = {
|
|
user: {
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
create: jest.fn().mockRejectedValue(p2002()),
|
|
update: jest.fn(),
|
|
},
|
|
};
|
|
await expect(
|
|
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false),
|
|
).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError);
|
|
});
|
|
|
|
it('rethrows a non-P2002 error', async () => {
|
|
const sync = { capture: jest.fn() };
|
|
const prisma = {
|
|
user: {
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
create: jest.fn().mockRejectedValue(new Error('db down')),
|
|
update: jest.fn(),
|
|
},
|
|
};
|
|
await expect(
|
|
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false),
|
|
).rejects.toThrow('db down');
|
|
});
|
|
});
|
|
|
|
describe('toAuthenticatedUser', () => {
|
|
const row = {
|
|
id: 'u-1',
|
|
authentikSub: 'sub-1',
|
|
email: 'a@b.org',
|
|
isLeitungsteam: false,
|
|
memberships: [
|
|
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
|
|
],
|
|
};
|
|
|
|
it('maps membership rows straight through when not Leitungsteam', () => {
|
|
const res = toAuthenticatedUser(row as never);
|
|
expect(res.memberships).toEqual([
|
|
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
|
|
]);
|
|
});
|
|
|
|
it('prepends a synthetic global LEITUNGSTEAM membership when the flag is set', () => {
|
|
const res = toAuthenticatedUser({ ...row, isLeitungsteam: true } as never);
|
|
expect(res.memberships[0]).toEqual({
|
|
kcId: GLOBAL_LT_KC_ID,
|
|
gemeindeId: null,
|
|
role: Role.LEITUNGSTEAM,
|
|
});
|
|
expect(res.memberships).toHaveLength(2);
|
|
});
|
|
});
|