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:
+16
-11
@@ -20,12 +20,16 @@ client's host - no separate web server is needed.
|
|||||||
|
|
||||||
## Auth model
|
## Auth model
|
||||||
|
|
||||||
- Leitungsteam and Gemeinde Verantwortliche are provisioned in Authentik
|
- Leitungsteam and Gemeinde Verantwortliche sign in with Authentik (the
|
||||||
(the "Konfi-Castle-ID"); this API acts as an OIDC **resource server**,
|
"Konfi-Castle-ID"); this API acts as an OIDC **resource server**, verifying
|
||||||
verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and
|
access tokens against Authentik's JWKS (`AuthentikStrategy`). The local
|
||||||
then resolving local `Membership` rows to determine role + KC/Gemeinde
|
`User` is provisioned just-in-time on first login from the token claims
|
||||||
scope. Clients perform the actual Authorization Code + PKCE flow against
|
(`resolveOrProvisionAuthentikUser`); role + KC/Gemeinde scope then come
|
||||||
Authentik directly.
|
from local `Membership` rows (only `status = ACTIVE` ones count). A freshly
|
||||||
|
provisioned user has no membership and thus no rights until one is granted
|
||||||
|
(LT: manually for now; Verantwortliche: the `onboarding/` approval flow).
|
||||||
|
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`
|
||||||
@@ -105,8 +109,9 @@ client's host - no separate web server is needed.
|
|||||||
Leitungsteam roles are global across all KCs).
|
Leitungsteam roles are global across all KCs).
|
||||||
|
|
||||||
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`,
|
||||||
Prisma mocked). Remaining work: the Flutter clients (see repo root README),
|
`resolveOrProvisionAuthentikUser`; Prisma mocked). Remaining work: the
|
||||||
Authentik JIT provisioning for LT (Verantwortliche already self-provision via
|
Flutter clients (see repo root README), deriving the LT `Membership` from
|
||||||
`onboarding/`), invite email delivery, and the first real Prisma migration
|
Authentik group claims (the `User` is provisioned, the role is not), invite
|
||||||
(only `schema.prisma` exists so far).
|
email delivery, and the first real Prisma migration (only `schema.prisma`
|
||||||
|
exists so far).
|
||||||
|
|||||||
@@ -5,24 +5,28 @@ import { Strategy } from 'passport-jwt';
|
|||||||
import * as jwksRsa from 'jwks-rsa';
|
import * as jwksRsa from 'jwks-rsa';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { PrismaClient } from '../prisma/prisma.module';
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
import { SyncService } from '../sync/sync.service';
|
||||||
import { AuthenticatedUser } from './authenticated-request';
|
import { AuthenticatedUser } from './authenticated-request';
|
||||||
|
import { resolveOrProvisionAuthentikUser } 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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, then the local Membership
|
/// signature is checked against Authentik's JWKS, the local `User` is
|
||||||
/// table decides what the user may do. Authentik itself is only the identity
|
/// provisioned on first login (JIT), then the local Membership table decides
|
||||||
/// source, never asked for authorization here.
|
/// what 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') {
|
||||||
constructor(
|
constructor(
|
||||||
config: ConfigService,
|
config: ConfigService,
|
||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly sync: SyncService,
|
||||||
) {
|
) {
|
||||||
const issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
const issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
||||||
super({
|
super({
|
||||||
@@ -41,13 +45,15 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
||||||
const user = await this.prisma.user.findUnique({
|
if (!payload.email) {
|
||||||
where: { authentikSub: payload.sub },
|
throw new UnauthorizedException('Authentik token missing email claim');
|
||||||
include: { memberships: { where: { status: 'ACTIVE' } } },
|
|
||||||
});
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException('User not provisioned locally yet');
|
|
||||||
}
|
}
|
||||||
|
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, {
|
||||||
|
sub: payload.sub,
|
||||||
|
email: payload.email,
|
||||||
|
firstName: payload.given_name ?? '',
|
||||||
|
lastName: payload.family_name ?? '',
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
authentikSub: user.authentikSub,
|
authentikSub: user.authentikSub,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Prisma, SyncOperation } from '@prisma/client';
|
||||||
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
|
||||||
|
export interface AuthentikClaims {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
export async function resolveOrProvisionAuthentikUser(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
sync: SyncService,
|
||||||
|
claims: AuthentikClaims,
|
||||||
|
): Promise<UserWithActiveMemberships> {
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { authentikSub: claims.sub },
|
||||||
|
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
authentikSub: claims.sub,
|
||||||
|
email: claims.email.toLowerCase(),
|
||||||
|
firstName: claims.firstName,
|
||||||
|
lastName: claims.lastName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await sync.capture('User', SyncOperation.CREATE, user.id, user);
|
||||||
|
return { ...user, memberships: [] };
|
||||||
|
} catch (err) {
|
||||||
|
// Lost a race with a concurrent first login — the row exists now.
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { authentikSub: claims.sub },
|
||||||
|
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import * as jwt from 'jsonwebtoken';
|
import * as jwt from 'jsonwebtoken';
|
||||||
import * as jwksRsa from 'jwks-rsa';
|
import * as jwksRsa from 'jwks-rsa';
|
||||||
import { PrismaClient } from '../prisma/prisma.module';
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
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';
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -20,6 +22,7 @@ export class TokenVerificationService {
|
|||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
private readonly guestJwt: JwtService,
|
private readonly guestJwt: JwtService,
|
||||||
private readonly teamAuth: TeamAuthService,
|
private readonly teamAuth: TeamAuthService,
|
||||||
|
private readonly sync: SyncService,
|
||||||
) {
|
) {
|
||||||
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 });
|
||||||
@@ -60,15 +63,8 @@ export class TokenVerificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
|
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
|
||||||
const { sub } = await this.verifyAuthentikClaims(token);
|
const claims = await this.verifyAuthentikClaims(token);
|
||||||
|
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims);
|
||||||
const user = await this.prisma.user.findUnique({
|
|
||||||
where: { authentikSub: sub },
|
|
||||||
include: { memberships: { where: { status: 'ACTIVE' } } },
|
|
||||||
});
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException('User not provisioned locally yet');
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
authentikSub: user.authentikSub,
|
authentikSub: user.authentikSub,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MembershipStatus, 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 { TokenVerificationService } from '../auth/token-verification.service';
|
import { TokenVerificationService } from '../auth/token-verification.service';
|
||||||
|
import { resolveOrProvisionAuthentikUser } from '../auth/provision-user';
|
||||||
|
|
||||||
/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in
|
/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in
|
||||||
/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus
|
/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus
|
||||||
@@ -52,7 +53,7 @@ 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 this.upsertUser(claims);
|
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims);
|
||||||
|
|
||||||
const existing = await this.prisma.membership.findUnique({
|
const existing = await this.prisma.membership.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -119,31 +120,6 @@ export class OnboardingService {
|
|||||||
return membership;
|
return membership;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async upsertUser(claims: {
|
|
||||||
sub: string;
|
|
||||||
email: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
}) {
|
|
||||||
const email = claims.email.toLowerCase();
|
|
||||||
const existing = await this.prisma.user.findUnique({
|
|
||||||
where: { authentikSub: claims.sub },
|
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
const user = await this.prisma.user.create({
|
|
||||||
data: {
|
|
||||||
authentikSub: claims.sub,
|
|
||||||
email,
|
|
||||||
firstName: claims.firstName,
|
|
||||||
lastName: claims.lastName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private summary(
|
private summary(
|
||||||
membershipId: string,
|
membershipId: string,
|
||||||
status: MembershipStatus,
|
status: MembershipStatus,
|
||||||
|
|||||||
@@ -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**: Gemeinde Verantwortliche legen ihren lokalen `User` jetzt selbst über `onboarding/` an (JIT aus den Token-Claims, dann `PENDING` bis LT-Freigabe). **LT** dagegen wird von `AuthentikStrategy` noch nicht JIT angelegt — ein LT-`User` muss vor dem ersten Login manuell existieren. (Gemeinde Teamer sind seit dieser Session lokale Accounts, siehe `teamer/` + `auth/team-login`.)
|
- **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`.)
|
||||||
- **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')`), 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 jetzt Authentik/Team/Guest) | `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 (`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` |
|
||||||
| `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,11 +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, 46 Tests):
|
4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 51 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.
|
||||||
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -122,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. Authentik-JIT für **LT** vervollständigen: `AuthentikStrategy` legt den lokalen `User` beim ersten Login noch nicht selbst an (nur der `onboarding/`-Pfad für Verantwortliche tut das). LT bleibt bis dahin auf manuelle `User`-Anlage angewiesen.
|
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.
|
||||||
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user