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 5079d48905
commit 24f8070b8a
9 changed files with 248 additions and 103 deletions
+5
View File
@@ -4,6 +4,11 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public"
# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/ # Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/
AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app" AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app"
# Name of the Authentik group whose members are Leitungsteam. Mirrored to
# User.isLeitungsteam on every login (the access token must carry a `groups`
# claim; add the "groups" scope to the Authentik provider).
AUTHENTIK_LEITUNGSTEAM_GROUP="Leitungsteam"
# Secret used to sign guest/Konfi session tokens (local accounts only) # Secret used to sign guest/Konfi session tokens (local accounts only)
GUEST_JWT_SECRET="change-me" GUEST_JWT_SECRET="change-me"
+14 -12
View File
@@ -7,7 +7,7 @@ architecture context).
```bash ```bash
npm install npm install
cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET / TEAM_JWT_SECRET cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP / GUEST_JWT_SECRET / TEAM_JWT_SECRET
npx prisma generate npx prisma generate
npx prisma migrate dev --name init # requires a running PostgreSQL instance npx prisma migrate dev --name init # requires a running PostgreSQL instance
npm run start:dev npm run start:dev
@@ -24,12 +24,14 @@ client's host - no separate web server is needed.
"Konfi-Castle-ID"); this API acts as an OIDC **resource server**, verifying "Konfi-Castle-ID"); this API acts as an OIDC **resource server**, verifying
access tokens against Authentik's JWKS (`AuthentikStrategy`). The local access tokens against Authentik's JWKS (`AuthentikStrategy`). The local
`User` is provisioned just-in-time on first login from the token claims `User` is provisioned just-in-time on first login from the token claims
(`resolveOrProvisionAuthentikUser`); role + KC/Gemeinde scope then come (`resolveOrProvisionAuthentikUser`), and `User.isLeitungsteam` is
from local `Membership` rows (only `status = ACTIVE` ones count). A freshly reconciled on every login from the token's `groups` claim vs.
provisioned user has no membership and thus no rights until one is granted `AUTHENTIK_LEITUNGSTEAM_GROUP``toAuthenticatedUser` then synthesises a
(LT: manually for now; Verantwortliche: the `onboarding/` approval flow). virtual global `LEITUNGSTEAM` membership from that flag. Other roles come
Clients perform the Authorization Code + PKCE flow against Authentik from local `Membership` rows (only `status = ACTIVE` ones count).
directly. Verantwortliche self-provision through the `onboarding/` approval flow;
a user with neither the LT flag nor a membership has no rights. Clients
perform the Authorization Code + PKCE flow against Authentik directly.
- Gemeinde Teamer are **local accounts** (no Authentik): a `User` row with a - Gemeinde Teamer are **local accounts** (no Authentik): a `User` row with a
`passwordHash` and `kcId` set, `authentikSub` left null. A Gemeinde `passwordHash` and `kcId` set, `authentikSub` left null. A Gemeinde
Verantwortliche/r creates them directly or via a `TeamerInvite` Verantwortliche/r creates them directly or via a `TeamerInvite`
@@ -110,8 +112,8 @@ client's host - no separate web server is needed.
All planned backend phases are implemented. `npm test` runs Jest unit tests All planned backend phases are implemented. `npm test` runs Jest unit tests
(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`, (`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`,
`resolveOrProvisionAuthentikUser`; Prisma mocked). Remaining work: the `resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked).
Flutter clients (see repo root README), deriving the LT `Membership` from Remaining work: the Flutter clients (see repo root README), invite email
Authentik group claims (the `User` is provisioned, the role is not), invite delivery, push notifications, and the first real Prisma migration (only
email delivery, and the first real Prisma migration (only `schema.prisma` `schema.prisma` exists so far). Ops note: the Authentik provider must emit a
exists so far). `groups` claim in the access token for the LT check to work.
+13 -8
View File
@@ -60,14 +60,19 @@ enum MembershipStatus {
/// Teamer are local accounts created by a Verantwortliche/r (`passwordHash` /// Teamer are local accounts created by a Verantwortliche/r (`passwordHash`
/// set, `authentikSub` null, `kcId` set) and, like guests, scoped to one KC. /// set, `authentikSub` null, `kcId` set) and, like guests, scoped to one KC.
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
authentikSub String? @unique authentikSub String? @unique
email String @unique email String @unique
firstName String firstName String
lastName String lastName String
passwordHash String? passwordHash String?
kcId String? kcId String?
createdAt DateTime @default(now()) /// Mirrored from the caller's Authentik group membership on every login.
/// LEITUNGSTEAM is global (not KC-scoped), so it lives here rather than as
/// a per-KC Membership row; the auth layer synthesises a virtual global
/// LEITUNGSTEAM membership from this flag.
isLeitungsteam Boolean @default(false)
createdAt DateTime @default(now())
kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade) kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[] memberships Membership[]
+22 -20
View File
@@ -7,22 +7,26 @@ import { Request } from 'express';
import { PrismaClient } from '../prisma/prisma.module'; import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service'; import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request'; import { AuthenticatedUser } from './authenticated-request';
import { resolveOrProvisionAuthentikUser } from './provision-user'; import { resolveOrProvisionAuthentikUser, toAuthenticatedUser } from './provision-user';
interface AuthentikJwtPayload { interface AuthentikJwtPayload {
sub: string; sub: string;
email?: string; email?: string;
given_name?: string; given_name?: string;
family_name?: string; family_name?: string;
groups?: string[];
} }
/// Validates access tokens issued by Authentik (resource-server pattern): /// Validates access tokens issued by Authentik (resource-server pattern):
/// signature is checked against Authentik's JWKS, the local `User` is /// signature is checked against Authentik's JWKS, the local `User` is
/// provisioned on first login (JIT), then the local Membership table decides /// provisioned on first login (JIT) and its LEITUNGSTEAM flag reconciled with
/// what the user may do. Authentik itself is only the identity source, never /// the token's `groups` claim, then the local Membership table decides what
/// asked for authorization here. /// the user may do. Authentik itself is only the identity source, never asked
/// for authorization here.
@Injectable() @Injectable()
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
private readonly leitungsteamGroup: string;
constructor( constructor(
config: ConfigService, config: ConfigService,
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
@@ -42,27 +46,25 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
issuer: issuerUrl, issuer: issuerUrl,
algorithms: ['RS256'], algorithms: ['RS256'],
}); });
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
} }
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> { async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
if (!payload.email) { if (!payload.email) {
throw new UnauthorizedException('Authentik token missing email claim'); throw new UnauthorizedException('Authentik token missing email claim');
} }
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, { const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup);
sub: payload.sub, const user = await resolveOrProvisionAuthentikUser(
email: payload.email, this.prisma,
firstName: payload.given_name ?? '', this.sync,
lastName: payload.family_name ?? '', {
}); sub: payload.sub,
return { email: payload.email,
userId: user.id, firstName: payload.given_name ?? '',
authentikSub: user.authentikSub, lastName: payload.family_name ?? '',
email: user.email, },
memberships: user.memberships.map((m) => ({ isLeitungsteam,
kcId: m.kcId, );
gemeindeId: m.gemeindeId, return toAuthenticatedUser(user);
role: m.role,
})),
};
} }
} }
+98 -27
View File
@@ -1,5 +1,9 @@
import { Prisma } from '@prisma/client'; import { Prisma, Role } from '@prisma/client';
import { resolveOrProvisionAuthentikUser } from './provision-user'; import {
GLOBAL_LT_KC_ID,
resolveOrProvisionAuthentikUser,
toAuthenticatedUser,
} from './provision-user';
const CLAIMS = { const CLAIMS = {
sub: 'sub-1', sub: 'sub-1',
@@ -15,38 +19,36 @@ function p2002() {
}); });
} }
function makeMocks() {
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
return { sync };
}
describe('resolveOrProvisionAuthentikUser', () => { describe('resolveOrProvisionAuthentikUser', () => {
it('returns the existing user without creating or capturing', async () => { it('returns the existing user without creating or capturing when nothing changed', async () => {
const { sync } = makeMocks(); const sync = { capture: jest.fn() };
const existing = { id: 'u-1', authentikSub: 'sub-1', memberships: [] }; const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
const prisma = { const prisma = {
user: { user: {
findUnique: jest.fn().mockResolvedValue(existing), findUnique: jest.fn().mockResolvedValue(existing),
create: jest.fn(), 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(res).toBe(existing);
expect(prisma.user.create).not.toHaveBeenCalled(); expect(prisma.user.create).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
expect(sync.capture).not.toHaveBeenCalled(); expect(sync.capture).not.toHaveBeenCalled();
}); });
it('provisions a new user from claims (lowercased email) and captures it', async () => { 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 = { const prisma = {
user: { user: {
findUnique: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn(({ data }: { data: Record<string, unknown> }) => 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({ expect(prisma.user.create).toHaveBeenCalledWith({
data: { data: {
authentikSub: 'sub-1', authentikSub: 'sub-1',
@@ -59,46 +61,115 @@ describe('resolveOrProvisionAuthentikUser', () => {
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-2', expect.anything()); expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-2', expect.anything());
}); });
it('recovers from a concurrent-create race (P2002) by re-reading', async () => { it('reconciles the LEITUNGSTEAM flag up when the token now has the group', async () => {
const { sync } = makeMocks(); const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const raced = { id: 'u-3', authentikSub: 'sub-1', memberships: [] }; const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
const prisma = { const prisma = {
user: { user: {
findUnique: jest findUnique: jest.fn().mockResolvedValue(existing),
.fn() create: jest.fn(),
.mockResolvedValueOnce(null) // first check: not there yet update: jest.fn(({ data }: { data: Record<string, unknown> }) =>
.mockResolvedValueOnce(raced), // after the failed insert: it exists Promise.resolve({ ...existing, ...data, memberships: [] }),
create: jest.fn().mockRejectedValue(p2002()), ),
}, },
}; };
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(res).toBe(raced);
expect(sync.capture).not.toHaveBeenCalled(); expect(sync.capture).not.toHaveBeenCalled();
}); });
it('rethrows a P2002 when the row still cannot be found', async () => { it('rethrows a P2002 when the row still cannot be found', async () => {
const { sync } = makeMocks(); const sync = { capture: jest.fn() };
const prisma = { const prisma = {
user: { user: {
findUnique: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockRejectedValue(p2002()), create: jest.fn().mockRejectedValue(p2002()),
update: jest.fn(),
}, },
}; };
await expect( await expect(
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS), resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false),
).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError); ).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError);
}); });
it('rethrows a non-P2002 error', async () => { it('rethrows a non-P2002 error', async () => {
const { sync } = makeMocks(); const sync = { capture: jest.fn() };
const prisma = { const prisma = {
user: { user: {
findUnique: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockRejectedValue(new Error('db down')), create: jest.fn().mockRejectedValue(new Error('db down')),
update: jest.fn(),
}, },
}; };
await expect( await expect(
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS), resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false),
).rejects.toThrow('db down'); ).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 { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service'; import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request';
export interface AuthentikClaims { export interface AuthentikClaims {
sub: string; sub: string;
@@ -9,19 +10,48 @@ export interface AuthentikClaims {
lastName: string; 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<{ type UserWithActiveMemberships = Prisma.UserGetPayload<{
include: { memberships: true }; include: { memberships: true };
}>; }>;
/// Resolves an Authentik identity to its local `User`, creating one from the /// Resolves an Authentik identity to its local `User`, creating one from the
/// token claims on first login (JIT provisioning). The new user has no /// token claims on first login (JIT provisioning), and reconciling the
/// memberships and therefore no rights until one is granted (LT via Authentik /// `isLeitungsteam` flag with the caller's current Authentik group membership
/// group sync — still manual — or the onboarding approval flow). Shared by /// on every login. A brand-new user has no `Membership` and therefore no
/// AuthentikStrategy and the WS token path so both provision identically. /// 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( export async function resolveOrProvisionAuthentikUser(
prisma: PrismaClient, prisma: PrismaClient,
sync: SyncService, sync: SyncService,
claims: AuthentikClaims, 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> { ): Promise<UserWithActiveMemberships> {
const existing = await prisma.user.findUnique({ const existing = await prisma.user.findUnique({
where: { authentikSub: claims.sub }, where: { authentikSub: claims.sub },
@@ -56,3 +86,26 @@ export async function resolveOrProvisionAuthentikUser(
throw err; 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 { AuthenticatedUser } from './authenticated-request';
import { GuestJwtPayload } from './guest-auth.service'; import { GuestJwtPayload } from './guest-auth.service';
import { TeamAuthService } from './team-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 /// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply. /// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
@@ -16,6 +20,7 @@ import { resolveOrProvisionAuthentikUser } from './provision-user';
export class TokenVerificationService { export class TokenVerificationService {
private readonly issuerUrl: string; private readonly issuerUrl: string;
private readonly jwks: jwksRsa.JwksClient; private readonly jwks: jwksRsa.JwksClient;
private readonly leitungsteamGroup: string;
constructor( constructor(
private readonly config: ConfigService, private readonly config: ConfigService,
@@ -26,17 +31,16 @@ export class TokenVerificationService {
) { ) {
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL'); this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); 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, /// Verifies an Authentik token's signature and returns its identity claims
/// without requiring a local User to exist yet (used by the onboarding /// plus whether the caller is in the Leitungsteam group, without requiring
/// self-registration path, which provisions that User). /// a local User to exist yet (used by the onboarding self-registration
async verifyAuthentikClaims(token: string): Promise<{ /// path, which provisions that User).
sub: string; async verifyAuthentikClaims(
email: string; token: string,
firstName: string; ): Promise<AuthentikClaims & { isLeitungsteam: boolean }> {
lastName: string;
}> {
const decoded = jwt.decode(token, { complete: true }); const decoded = jwt.decode(token, { complete: true });
const kid = decoded?.header.kid; const kid = decoded?.header.kid;
if (!kid) { if (!kid) {
@@ -50,6 +54,7 @@ export class TokenVerificationService {
email?: string; email?: string;
given_name?: string; given_name?: string;
family_name?: string; family_name?: string;
groups?: string[];
}; };
if (!payload.sub || !payload.email) { if (!payload.sub || !payload.email) {
throw new UnauthorizedException('Authentik token missing subject or email'); throw new UnauthorizedException('Authentik token missing subject or email');
@@ -59,22 +64,19 @@ export class TokenVerificationService {
email: payload.email, email: payload.email,
firstName: payload.given_name ?? '', firstName: payload.given_name ?? '',
lastName: payload.family_name ?? '', lastName: payload.family_name ?? '',
isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup),
}; };
} }
async verifyAuthentik(token: string): Promise<AuthenticatedUser> { async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
const claims = await this.verifyAuthentikClaims(token); const { isLeitungsteam, ...claims } = await this.verifyAuthentikClaims(token);
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims); const user = await resolveOrProvisionAuthentikUser(
return { this.prisma,
userId: user.id, this.sync,
authentikSub: user.authentikSub, claims,
email: user.email, isLeitungsteam,
memberships: user.memberships.map((m) => ({ );
kcId: m.kcId, return toAuthenticatedUser(user);
gemeindeId: m.gemeindeId,
role: m.role,
})),
};
} }
async verifyGuest(token: string): Promise<GuestJwtPayload> { async verifyGuest(token: string): Promise<GuestJwtPayload> {
+7 -2
View File
@@ -42,7 +42,7 @@ export class OnboardingService {
if (!token) { if (!token) {
throw new UnauthorizedException('Missing Authentik bearer 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 } }); const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
if (!kc || !kc.isActive) { if (!kc || !kc.isActive) {
@@ -53,7 +53,12 @@ export class OnboardingService {
throw new BadRequestException('Gemeinde does not belong to this KC'); 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({ const existing = await this.prisma.membership.findUnique({
where: { where: {
+7 -7
View File
@@ -11,11 +11,11 @@ Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/T
- **KC** (Konfi-Castle-Event) = oberster Mandant. Eine App-Instanz verwaltet mehrere KCs parallel. Felder: `name`, `inviteCode` (eindeutig, Basis für QR/Code-Einstieg), `isActive`. - **KC** (Konfi-Castle-Event) = oberster Mandant. Eine App-Instanz verwaltet mehrere KCs parallel. Felder: `name`, `inviteCode` (eindeutig, Basis für QR/Code-Einstieg), `isActive`.
- **Gemeinde**: lokale Gemeinde/Kirchengemeinde innerhalb eines KC (`kcId` + `name`, eindeutig pro KC). - **Gemeinde**: lokale Gemeinde/Kirchengemeinde innerhalb eines KC (`kcId` + `name`, eindeutig pro KC).
- **Rollenmodell** (Enum `Role`, Authentik-gestützt): - **Rollenmodell** (Enum `Role`, Authentik-gestützt):
- **Leitungsteam (LT)** global über alle KCs hinweg (Authentik-Gruppe); bleibt LT auf jedem KC, bis die Authentik-Gruppenmitgliedschaft entfernt wird. - **Leitungsteam (LT)** global über alle KCs hinweg. Wird bei **jedem** Authentik-Login aus dem `groups`-Claim des Tokens abgeglichen (Gruppenname aus `AUTHENTIK_LEITUNGSTEAM_GROUP`) und als `User.isLeitungsteam` gespeichert; die Auth-Schicht synthetisiert daraus eine virtuelle globale LT-`Membership`. Kein `Membership`-Row nötig. Fällt die Gruppenmitgliedschaft weg, ist man beim nächsten Login kein LT mehr.
- **Gemeinde Verantwortliche** pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT; zudem Authentik Gruppe und Benutzer. - **Gemeinde Verantwortliche** pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT; zudem Authentik Gruppe und Benutzer.
- **Gemeinde Teamer** von Verantwortlichen angelegt, ebenfalls Lokaler Account. - **Gemeinde Teamer** von Verantwortlichen angelegt, ebenfalls Lokaler Account.
- **Guest/Konfi** optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events). - **Guest/Konfi** optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events).
- **Membership**: verknüpft `User``Kc` (+ optional `Gemeinde`) ⇄ `Role`, mit `status` (`ACTIVE`/`PENDING`). LT-Memberships lassen `gemeindeId` leer und gelten global. `PENDING` (aus der Selbstregistrierung) gewährt keine Rechte, bis ein LT sie genehmigt — die Auth-Strategien laden nur `ACTIVE`-Memberships. - **Membership**: verknüpft `User``Kc` (+ optional `Gemeinde`) ⇄ `Role`, mit `status` (`ACTIVE`/`PENDING`). Nur für `GEMEINDE_VERANTWORTLICHER`/`GEMEINDE_TEAMER` — LT läuft über `User.isLeitungsteam` (s. o.). `PENDING` (aus der Selbstregistrierung) gewährt keine Rechte, bis ein LT sie genehmigt — die Auth-Strategien laden nur `ACTIVE`-Memberships.
- **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab registrieren: - **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab registrieren:
- **Gemeinde Verantwortliche/r**: `onboarding/`-Modul — mit Konfi-Castle-ID (Authentik) einloggen, KC-Code + bestehende Gemeinde wählen → `User` wird JIT angelegt, `Membership` als `PENDING`; LT genehmigt. - **Gemeinde Verantwortliche/r**: `onboarding/`-Modul — mit Konfi-Castle-ID (Authentik) einloggen, KC-Code + bestehende Gemeinde wählen → `User` wird JIT angelegt, `Membership` als `PENDING`; LT genehmigt.
- **Gemeinde Teamer**: `teamer/`-Modul — von einer Verantwortliche/r direkt angelegt oder per Invite-Link/E-Mail-Invite selbst registriert (lokaler Account, sofort `ACTIVE`). - **Gemeinde Teamer**: `teamer/`-Modul — von einer Verantwortliche/r direkt angelegt oder per Invite-Link/E-Mail-Invite selbst registriert (lokaler Account, sofort `ACTIVE`).
@@ -52,7 +52,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
- **Datei-Bytes werden nicht repliziert** nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. - **Datei-Bytes werden nicht repliziert** nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist.
- **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). - **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit).
- **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. - **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt.
- **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` jetzt automatisch an (`AuthentikStrategy``resolveOrProvisionAuthentikUser`, JIT, race-sicher). Was noch fehlt: die **Rollen-/Gruppen-Zuordnung aus Authentik** — ein frisch angelegter `User` hat keine `Membership`, also keine Rechte. LT-Rechte müssen aktuell per manueller `Membership(LEITUNGSTEAM)` gesetzt werden; Verantwortliche laufen über den `onboarding/`-Freigabepfad. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.) - **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` automatisch an (`AuthentikStrategy``resolveOrProvisionAuthentikUser`, JIT, race-sicher) **und** setzt `isLeitungsteam` aus dem `groups`-Claim. Voraussetzung: der Authentik-Provider muss den `groups`-Claim ins Access-Token schreiben (Scope „groups" hinzufügen) und der LT-Gruppenname muss zu `AUTHENTIK_LEITUNGSTEAM_GROUP` passen — sonst wird niemand als LT erkannt. Gemeinde Verantwortliche brauchen weiterhin die `onboarding/`-Freigabe durch LT. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.)
- **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert. - **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert.
- **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden. - **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden.
@@ -63,7 +63,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
| Modul | Kernfunktion | Wichtige Endpunkte | | Modul | Kernfunktion | Wichtige Endpunkte |
|---|---|---| |---|---|---|
| `prisma/` | Geteilter `PrismaClient`-Provider | | | `prisma/` | Geteilter `PrismaClient`-Provider | |
| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`) mit **JIT-`User`-Anlage** beim ersten Login (`resolveOrProvisionAuthentikUser`, race-sicher), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur `ACTIVE`-Memberships. | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` | | `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`) mit **JIT-`User`-Anlage** beim ersten Login + **LT-Abgleich** aus dem `groups`-Claim → `User.isLeitungsteam` → virtuelle globale LT-`Membership` (`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`, race-sicher), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur `ACTIVE`-Memberships. | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` |
| `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | | `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` |
| `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` | | `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` |
| `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) | | `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) |
@@ -110,12 +110,12 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud). 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud).
2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft).
3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token).
4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 51 Tests): 4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 55 Tests):
- `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl.
- `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login.
- `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, Löschung. - `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, Löschung.
- `src/onboarding/onboarding.service.spec.ts`: Invite-Lookup, Verantwortlichen-Selbstregistrierung (Token fehlt/ungültig, unbekannter Code, Gemeinde nicht im KC, JIT-User + `PENDING`, Idempotenz), Approve/Reject. - `src/onboarding/onboarding.service.spec.ts`: Invite-Lookup, Verantwortlichen-Selbstregistrierung (Token fehlt/ungültig, unbekannter Code, Gemeinde nicht im KC, JIT-User + `PENDING`, Idempotenz), Approve/Reject.
- `src/auth/provision-user.spec.ts`: JIT-`User`-Anlage aus Authentik-Claims inkl. Race-Recovery (P2002 → Re-Read) und Fehler-Weiterreichung. - `src/auth/provision-user.spec.ts`: JIT-`User`-Anlage aus Authentik-Claims, LT-Flag-Abgleich (rauf/runter) aus dem `groups`-Claim, virtuelle LT-`Membership` in `toAuthenticatedUser`, Race-Recovery (P2002 → Re-Read), Fehler-Weiterreichung.
5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud). 5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud).
--- ---
@@ -123,7 +123,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
## 8. Nächste Schritte ## 8. Nächste Schritte
1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. 1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API.
2. LT-Rolle aus Authentik ableiten: `User` wird beim ersten Login jetzt JIT angelegt, aber die `Membership(LEITUNGSTEAM)` noch nicht — aus den Authentik-Gruppen-Claims des Tokens (oder per Admin-API-Sync) eine globale LT-Membership erzeugen/entfernen. 2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), und die LT-Gruppe auf `AUTHENTIK_LEITUNGSTEAM_GROUP` abstimmen — sonst greift der LT-Abgleich nicht. (Reine Ops-/Config-Aufgabe, Code ist fertig.)
3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt. 3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt.
4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen.
5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen. 5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen.