groups und impersination

This commit is contained in:
Bastian Wagner
2026-07-14 18:09:43 +02:00
parent f45583f3ea
commit 0dfba43aa6
16 changed files with 583 additions and 17 deletions

View File

@@ -24,6 +24,8 @@ import {
import type { TaskDigestPreference } from '../tasks/task-digest.types';
import { OidcProfile, OidcService } from './oidc.service';
import { RefreshTokenEntity } from './refresh-token.entity';
import { UserKeycloakGroupEntity } from './user-keycloak-group.entity';
import { UserImpersonationEntity } from './user-impersonation.entity';
import { UserEntity } from './user.entity';
@Injectable()
@@ -42,6 +44,10 @@ export class AuthService {
private readonly usersRepository: Repository<UserEntity>,
@InjectRepository(RefreshTokenEntity)
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
@InjectRepository(UserKeycloakGroupEntity)
private readonly userKeycloakGroupsRepository: Repository<UserKeycloakGroupEntity>,
@InjectRepository(UserImpersonationEntity)
private readonly userImpersonationsRepository: Repository<UserImpersonationEntity>,
@Optional()
private readonly auditLogService?: AuditLogService,
) {}
@@ -89,9 +95,12 @@ export class AuthService {
where: { email: this.normalizeEmail(profile.email) },
}));
const user = await this.syncOidcUser(profile, existingUser);
await this.syncKeycloakGroups(user.id, profile.groups);
const response = {
...(await this.createAuthTokens(user)),
user: this.toPublicUser(user),
user: await this.toPublicUserWithGroups(
await this.resolveEffectiveUser(user),
),
};
await this.auditLogService?.record({
@@ -136,7 +145,9 @@ export class AuthService {
const response = {
...(await this.createAuthTokens(user)),
user: this.toPublicUser(user),
user: await this.toPublicUserWithGroups(
await this.resolveEffectiveUser(user),
),
};
await this.auditLogService?.record({
@@ -168,7 +179,19 @@ export class AuthService {
throw new UnauthorizedException('Access token is invalid.');
}
return payload;
const effectiveUser = await this.resolveEffectiveUser(user);
if (effectiveUser.id === user.id) {
return payload;
}
return {
...payload,
sub: effectiveUser.id,
email: effectiveUser.email,
impersonatorSub: user.id,
impersonatorEmail: user.email,
};
} catch {
throw new UnauthorizedException('Access token is invalid.');
}
@@ -195,7 +218,7 @@ export class AuthService {
throw new UnauthorizedException('Authenticated user is required.');
}
return this.toPublicUser(user);
return this.toPublicUserWithGroups(user);
}
async searchUsers(
@@ -255,7 +278,7 @@ export class AuthService {
metadata: { completed },
});
return this.toPublicUser(savedUser);
return this.toPublicUserWithGroups(savedUser);
}
async updateTaskDigestPreference(
@@ -283,7 +306,7 @@ export class AuthService {
metadata: { taskDigestPreference: savedUser.taskDigestPreference },
});
return this.toPublicUser(savedUser);
return this.toPublicUserWithGroups(savedUser);
}
private normalizeEmail(email?: string): string {
@@ -333,6 +356,77 @@ export class AuthService {
return this.usersRepository.save(user);
}
private async resolveEffectiveUser(user: UserEntity): Promise<UserEntity> {
const now = Date.now();
const impersonations = await this.userImpersonationsRepository.find({
where: { impersonatorUserId: user.id, enabled: true },
order: { createdAt: 'DESC' },
});
const activeImpersonation = impersonations.find(
(impersonation) =>
!impersonation.expiresAt || impersonation.expiresAt.getTime() > now,
);
if (!activeImpersonation) {
return user;
}
const targetUser = await this.usersRepository.findOne({
where: { id: activeImpersonation.targetUserId },
});
if (!targetUser) {
throw new UnauthorizedException('Impersonated user does not exist.');
}
return targetUser;
}
private async syncKeycloakGroups(
userId: string,
groups: string[],
): Promise<void> {
const normalizedGroups = this.normalizeGroupPaths(groups);
await this.userKeycloakGroupsRepository.delete({ userId });
if (!normalizedGroups.length) {
return;
}
await this.userKeycloakGroupsRepository.save(
normalizedGroups.map((groupPath) =>
this.userKeycloakGroupsRepository.create({
id: randomUUID(),
userId,
groupPath,
groupName: this.groupNameFromPath(groupPath),
}),
),
);
}
private normalizeGroupPaths(groups: string[]): string[] {
return [...new Set(groups)]
.map((group) => group.trim())
.filter(Boolean)
.sort((left, right) => left.localeCompare(right));
}
private groupNameFromPath(groupPath: string): string {
return groupPath.split('/').filter(Boolean).at(-1) ?? groupPath;
}
private async getUserGroupPaths(userId: string): Promise<string[]> {
const groups = await this.userKeycloakGroupsRepository.find({
where: { userId },
});
return groups
.map((group) => group.groupPath)
.sort((left, right) => left.localeCompare(right));
}
private secretMatches(secret: string, storedSecretHash: string): boolean {
const [salt, storedHash] = storedSecretHash.split(':');
@@ -419,13 +513,18 @@ export class AuthService {
return this.secretMatches(token, tokenHash);
}
private toPublicUser(user: UserEntity): PublicUser {
private async toPublicUserWithGroups(user: UserEntity): Promise<PublicUser> {
return this.toPublicUser(user, await this.getUserGroupPaths(user.id));
}
private toPublicUser(user: UserEntity, groups: string[] = []): PublicUser {
return {
id: user.id,
email: user.email,
name: user.name ?? undefined,
onboardingCompleted: user.onboardingCompleted === true,
taskDigestPreference: user.taskDigestPreference ?? 'both',
groups,
};
}
}