feat(backend): derive LEITUNGSTEAM from the Authentik groups claim

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>
This commit is contained in:
2026-09-10 08:13:20 +02:00
co-authored by Claude Sonnet 5
parent f03b209e84
commit eb6f64a0c5
8 changed files with 241 additions and 96 deletions
+22 -20
View File
@@ -7,22 +7,26 @@ import { Request } from 'express';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request';
import { resolveOrProvisionAuthentikUser } from './provision-user';
import { resolveOrProvisionAuthentikUser, toAuthenticatedUser } from './provision-user';
interface AuthentikJwtPayload {
sub: string;
email?: string;
given_name?: string;
family_name?: string;
groups?: string[];
}
/// Validates access tokens issued by Authentik (resource-server pattern):
/// signature is checked against Authentik's JWKS, the local `User` is
/// provisioned on first login (JIT), then the local Membership table decides
/// what the user may do. Authentik itself is only the identity source, never
/// asked for authorization here.
/// provisioned on first login (JIT) and its LEITUNGSTEAM flag reconciled with
/// the token's `groups` claim, then the local Membership table decides what
/// the user may do. Authentik itself is only the identity source, never asked
/// for authorization here.
@Injectable()
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
private readonly leitungsteamGroup: string;
constructor(
config: ConfigService,
private readonly prisma: PrismaClient,
@@ -42,27 +46,25 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
issuer: issuerUrl,
algorithms: ['RS256'],
});
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
}
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
if (!payload.email) {
throw new UnauthorizedException('Authentik token missing email claim');
}
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, {
sub: payload.sub,
email: payload.email,
firstName: payload.given_name ?? '',
lastName: payload.family_name ?? '',
});
return {
userId: user.id,
authentikSub: user.authentikSub,
email: user.email,
memberships: user.memberships.map((m) => ({
kcId: m.kcId,
gemeindeId: m.gemeindeId,
role: m.role,
})),
};
const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup);
const user = await resolveOrProvisionAuthentikUser(
this.prisma,
this.sync,
{
sub: payload.sub,
email: payload.email,
firstName: payload.given_name ?? '',
lastName: payload.family_name ?? '',
},
isLeitungsteam,
);
return toAuthenticatedUser(user);
}
}
+98 -27
View File
@@ -1,5 +1,9 @@
import { Prisma } from '@prisma/client';
import { resolveOrProvisionAuthentikUser } from './provision-user';
import { Prisma, Role } from '@prisma/client';
import {
GLOBAL_LT_KC_ID,
resolveOrProvisionAuthentikUser,
toAuthenticatedUser,
} from './provision-user';
const CLAIMS = {
sub: 'sub-1',
@@ -15,38 +19,36 @@ function p2002() {
});
}
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: [] };
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);
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 } = makeMocks();
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', ...data }),
Promise.resolve({ id: 'u-2', isLeitungsteam: false, ...data }),
),
update: jest.fn(),
},
};
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
expect(prisma.user.create).toHaveBeenCalledWith({
data: {
authentikSub: 'sub-1',
@@ -59,46 +61,115 @@ describe('resolveOrProvisionAuthentikUser', () => {
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: [] };
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()
.mockResolvedValueOnce(null) // first check: not there yet
.mockResolvedValueOnce(raced), // after the failed insert: it exists
create: jest.fn().mockRejectedValue(p2002()),
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);
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 } = makeMocks();
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),
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false),
).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError);
});
it('rethrows a non-P2002 error', async () => {
const { sync } = makeMocks();
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),
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);
});
});
+58 -5
View File
@@ -1,6 +1,7 @@
import { Prisma, SyncOperation } from '@prisma/client';
import { Prisma, Role, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request';
export interface AuthentikClaims {
sub: string;
@@ -9,19 +10,48 @@ export interface AuthentikClaims {
lastName: string;
}
/// Placeholder kcId for the synthetic, global LEITUNGSTEAM membership. RolesGuard
/// never compares it (LT short-circuits the KC check), it only needs to exist.
export const GLOBAL_LT_KC_ID = '*';
type UserWithActiveMemberships = Prisma.UserGetPayload<{
include: { memberships: true };
}>;
/// Resolves an Authentik identity to its local `User`, creating one from the
/// token claims on first login (JIT provisioning). The new user has no
/// memberships and therefore no rights until one is granted (LT via Authentik
/// group sync — still manual — or the onboarding approval flow). Shared by
/// AuthentikStrategy and the WS token path so both provision identically.
/// token claims on first login (JIT provisioning), and reconciling the
/// `isLeitungsteam` flag with the caller's current Authentik group membership
/// on every login. A brand-new user has no `Membership` and therefore no
/// rights until one is granted (the onboarding approval flow) or the LT flag
/// is set. Shared by AuthentikStrategy and the WS token path so both behave
/// identically.
export async function resolveOrProvisionAuthentikUser(
prisma: PrismaClient,
sync: SyncService,
claims: AuthentikClaims,
isLeitungsteam: boolean,
): Promise<UserWithActiveMemberships> {
const user = await loadOrCreate(prisma, sync, claims);
if (user.isLeitungsteam !== isLeitungsteam) {
const updated = await prisma.user.update({
where: { id: user.id },
data: { isLeitungsteam },
include: { memberships: { where: { status: 'ACTIVE' } } },
});
await sync.capture('User', SyncOperation.UPDATE, updated.id, {
...updated,
memberships: undefined,
});
return updated;
}
return user;
}
async function loadOrCreate(
prisma: PrismaClient,
sync: SyncService,
claims: AuthentikClaims,
): Promise<UserWithActiveMemberships> {
const existing = await prisma.user.findUnique({
where: { authentikSub: claims.sub },
@@ -56,3 +86,26 @@ export async function resolveOrProvisionAuthentikUser(
throw err;
}
}
/// Maps a provisioned user row to the request-scoped shape, prepending a
/// synthetic global LEITUNGSTEAM membership when the flag is set.
export function toAuthenticatedUser(user: UserWithActiveMemberships): AuthenticatedUser {
const memberships = user.memberships.map((m) => ({
kcId: m.kcId,
gemeindeId: m.gemeindeId,
role: m.role,
}));
if (user.isLeitungsteam) {
memberships.unshift({
kcId: GLOBAL_LT_KC_ID,
gemeindeId: null,
role: Role.LEITUNGSTEAM,
});
}
return {
userId: user.id,
authentikSub: user.authentikSub,
email: user.email,
memberships,
};
}
+24 -22
View File
@@ -8,7 +8,11 @@ import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request';
import { GuestJwtPayload } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service';
import { resolveOrProvisionAuthentikUser } from './provision-user';
import {
AuthentikClaims,
resolveOrProvisionAuthentikUser,
toAuthenticatedUser,
} from './provision-user';
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
@@ -16,6 +20,7 @@ import { resolveOrProvisionAuthentikUser } from './provision-user';
export class TokenVerificationService {
private readonly issuerUrl: string;
private readonly jwks: jwksRsa.JwksClient;
private readonly leitungsteamGroup: string;
constructor(
private readonly config: ConfigService,
@@ -26,17 +31,16 @@ export class TokenVerificationService {
) {
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
}
/// Verifies an Authentik token's signature and returns its identity claims,
/// without requiring a local User to exist yet (used by the onboarding
/// self-registration path, which provisions that User).
async verifyAuthentikClaims(token: string): Promise<{
sub: string;
email: string;
firstName: string;
lastName: string;
}> {
/// Verifies an Authentik token's signature and returns its identity claims
/// plus whether the caller is in the Leitungsteam group, without requiring
/// a local User to exist yet (used by the onboarding self-registration
/// path, which provisions that User).
async verifyAuthentikClaims(
token: string,
): Promise<AuthentikClaims & { isLeitungsteam: boolean }> {
const decoded = jwt.decode(token, { complete: true });
const kid = decoded?.header.kid;
if (!kid) {
@@ -50,6 +54,7 @@ export class TokenVerificationService {
email?: string;
given_name?: string;
family_name?: string;
groups?: string[];
};
if (!payload.sub || !payload.email) {
throw new UnauthorizedException('Authentik token missing subject or email');
@@ -59,22 +64,19 @@ export class TokenVerificationService {
email: payload.email,
firstName: payload.given_name ?? '',
lastName: payload.family_name ?? '',
isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup),
};
}
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
const claims = await this.verifyAuthentikClaims(token);
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims);
return {
userId: user.id,
authentikSub: user.authentikSub,
email: user.email,
memberships: user.memberships.map((m) => ({
kcId: m.kcId,
gemeindeId: m.gemeindeId,
role: m.role,
})),
};
const { isLeitungsteam, ...claims } = await this.verifyAuthentikClaims(token);
const user = await resolveOrProvisionAuthentikUser(
this.prisma,
this.sync,
claims,
isLeitungsteam,
);
return toAuthenticatedUser(user);
}
async verifyGuest(token: string): Promise<GuestJwtPayload> {
+7 -2
View File
@@ -42,7 +42,7 @@ export class OnboardingService {
if (!token) {
throw new UnauthorizedException('Missing Authentik bearer token');
}
const claims = await this.tokens.verifyAuthentikClaims(token);
const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token);
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
if (!kc || !kc.isActive) {
@@ -53,7 +53,12 @@ export class OnboardingService {
throw new BadRequestException('Gemeinde does not belong to this KC');
}
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims);
const user = await resolveOrProvisionAuthentikUser(
this.prisma,
this.sync,
claims,
isLeitungsteam,
);
const existing = await this.prisma.membership.findUnique({
where: {