groups und impersination
This commit is contained in:
@@ -8,13 +8,20 @@ import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { McpAuthGuard } from './mcp-auth.guard';
|
||||
import { OidcService } from './oidc.service';
|
||||
import { UserKeycloakGroupEntity } from './user-keycloak-group.entity';
|
||||
import { UserImpersonationEntity } from './user-impersonation.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuditModule,
|
||||
JwtModule.register({}),
|
||||
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
|
||||
TypeOrmModule.forFeature([
|
||||
UserEntity,
|
||||
RefreshTokenEntity,
|
||||
UserKeycloakGroupEntity,
|
||||
UserImpersonationEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, OidcService, JwtAuthGuard, McpAuthGuard],
|
||||
|
||||
@@ -6,6 +6,8 @@ import { AuthTokenResponse, JwtTokenPayload } from './auth.types';
|
||||
import { AuthService } from './auth.service';
|
||||
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';
|
||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||
|
||||
@@ -14,6 +16,7 @@ class FakeOidcService {
|
||||
subject: 'oidc-user-1',
|
||||
email: 'User@Example.com',
|
||||
name: 'Test User',
|
||||
groups: [],
|
||||
};
|
||||
|
||||
createAuthorizationUrl = jest.fn(
|
||||
@@ -27,9 +30,17 @@ describe('AuthService', () => {
|
||||
let authService: AuthService;
|
||||
let jwtService: JwtService;
|
||||
let oidcService: FakeOidcService;
|
||||
let usersRepository: InMemoryRepository<UserEntity>;
|
||||
let userKeycloakGroupsRepository: InMemoryRepository<UserKeycloakGroupEntity>;
|
||||
let userImpersonationsRepository: InMemoryRepository<UserImpersonationEntity>;
|
||||
|
||||
beforeEach(async () => {
|
||||
oidcService = new FakeOidcService();
|
||||
usersRepository = new InMemoryRepository<UserEntity>();
|
||||
userKeycloakGroupsRepository =
|
||||
new InMemoryRepository<UserKeycloakGroupEntity>();
|
||||
userImpersonationsRepository =
|
||||
new InMemoryRepository<UserImpersonationEntity>();
|
||||
module = await Test.createTestingModule({
|
||||
imports: [EventEmitterModule.forRoot(), JwtModule.register({})],
|
||||
providers: [
|
||||
@@ -40,12 +51,20 @@ describe('AuthService', () => {
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserEntity),
|
||||
useValue: new InMemoryRepository<UserEntity>(),
|
||||
useValue: usersRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(RefreshTokenEntity),
|
||||
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserKeycloakGroupEntity),
|
||||
useValue: userKeycloakGroupsRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserImpersonationEntity),
|
||||
useValue: userImpersonationsRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await module.init();
|
||||
@@ -81,6 +100,7 @@ describe('AuthService', () => {
|
||||
subject: 'oidc-user-1',
|
||||
email: 'renamed@example.com',
|
||||
name: 'Renamed User',
|
||||
groups: [],
|
||||
};
|
||||
|
||||
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
||||
@@ -96,6 +116,7 @@ describe('AuthService', () => {
|
||||
subject: 'oidc-user-2',
|
||||
email: 'User@Example.com',
|
||||
name: 'Linked User',
|
||||
groups: [],
|
||||
};
|
||||
|
||||
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
||||
@@ -129,6 +150,116 @@ describe('AuthService', () => {
|
||||
expect(refreshPayload.jti).toBeDefined();
|
||||
});
|
||||
|
||||
it('syncs Keycloak groups from the SSO profile', async () => {
|
||||
oidcService.profile.groups = [
|
||||
'/teams/engineering',
|
||||
'/teams/admins',
|
||||
'/teams/engineering',
|
||||
];
|
||||
|
||||
const loginResponse = await authService.completeSsoLogin('code', 'state');
|
||||
|
||||
expect(loginResponse.user.groups).toEqual([
|
||||
'/teams/admins',
|
||||
'/teams/engineering',
|
||||
]);
|
||||
|
||||
oidcService.profile = {
|
||||
subject: 'oidc-user-1',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
groups: ['/teams/support'],
|
||||
};
|
||||
|
||||
const secondLoginResponse = await authService.completeSsoLogin(
|
||||
'code',
|
||||
'state',
|
||||
);
|
||||
const storedGroups = await userKeycloakGroupsRepository.find({
|
||||
where: { userId: secondLoginResponse.user.id },
|
||||
});
|
||||
|
||||
expect(secondLoginResponse.user.groups).toEqual(['/teams/support']);
|
||||
expect(storedGroups.map((group) => group.groupPath)).toEqual([
|
||||
'/teams/support',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves active database impersonation for access tokens', async () => {
|
||||
const loginResponse = await loginWithSso();
|
||||
const targetUser = await createUser('target-user-id', 'target@example.com');
|
||||
|
||||
await userImpersonationsRepository.save(
|
||||
userImpersonationsRepository.create({
|
||||
id: 'impersonation-1',
|
||||
impersonatorUserId: loginResponse.user.id,
|
||||
targetUserId: targetUser.id,
|
||||
enabled: true,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
const payload = await authService.verifyAccessToken(
|
||||
loginResponse.accessToken,
|
||||
);
|
||||
|
||||
expect(payload.sub).toBe(targetUser.id);
|
||||
expect(payload.email).toBe(targetUser.email);
|
||||
expect(payload.impersonatorSub).toBe(loginResponse.user.id);
|
||||
expect(payload.impersonatorEmail).toBe(loginResponse.user.email);
|
||||
});
|
||||
|
||||
it('returns the effective user when refreshing during impersonation', async () => {
|
||||
const loginResponse = await loginWithSso();
|
||||
const targetUser = await createUser('target-user-id', 'target@example.com');
|
||||
|
||||
await userImpersonationsRepository.save(
|
||||
userImpersonationsRepository.create({
|
||||
id: 'impersonation-1',
|
||||
impersonatorUserId: loginResponse.user.id,
|
||||
targetUserId: targetUser.id,
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const refreshResponse = await authService.refresh({
|
||||
refreshToken: loginResponse.refreshToken,
|
||||
});
|
||||
|
||||
expect(refreshResponse.user.id).toBe(targetUser.id);
|
||||
expect(refreshResponse.user.email).toBe(targetUser.email);
|
||||
});
|
||||
|
||||
it('ignores disabled and expired database impersonation records', async () => {
|
||||
const loginResponse = await loginWithSso();
|
||||
const targetUser = await createUser('target-user-id', 'target@example.com');
|
||||
|
||||
await userImpersonationsRepository.save([
|
||||
userImpersonationsRepository.create({
|
||||
id: 'impersonation-disabled',
|
||||
impersonatorUserId: loginResponse.user.id,
|
||||
targetUserId: targetUser.id,
|
||||
enabled: false,
|
||||
}),
|
||||
userImpersonationsRepository.create({
|
||||
id: 'impersonation-expired',
|
||||
impersonatorUserId: loginResponse.user.id,
|
||||
targetUserId: targetUser.id,
|
||||
enabled: true,
|
||||
expiresAt: new Date(Date.now() - 1_000),
|
||||
}),
|
||||
]);
|
||||
|
||||
const payload = await authService.verifyAccessToken(
|
||||
loginResponse.accessToken,
|
||||
);
|
||||
|
||||
expect(payload.sub).toBe(loginResponse.user.id);
|
||||
expect(payload.email).toBe(loginResponse.user.email);
|
||||
expect(payload.impersonatorSub).toBeUndefined();
|
||||
expect(payload.impersonatorEmail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rotates refresh tokens and rejects reuse', async () => {
|
||||
const loginResponse = await loginWithSso();
|
||||
const refreshResponse = await authService.refresh({
|
||||
@@ -183,4 +314,16 @@ describe('AuthService', () => {
|
||||
async function loginWithSso(): Promise<AuthTokenResponse> {
|
||||
return authService.completeSsoLogin('code', 'state');
|
||||
}
|
||||
|
||||
async function createUser(id: string, email: string): Promise<UserEntity> {
|
||||
return (await usersRepository.save(
|
||||
usersRepository.create({
|
||||
id,
|
||||
email,
|
||||
name: 'Target User',
|
||||
onboardingCompleted: false,
|
||||
taskDigestPreference: 'both',
|
||||
}),
|
||||
)) as UserEntity;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface JwtTokenPayload {
|
||||
email: string;
|
||||
type: 'access' | 'refresh';
|
||||
jti?: string;
|
||||
impersonatorSub?: string;
|
||||
impersonatorEmail?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
@@ -27,6 +29,7 @@ export interface PublicUser {
|
||||
name?: string;
|
||||
onboardingCompleted: boolean;
|
||||
taskDigestPreference: TaskDigestPreference;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export interface PublicUserSearchResult {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { AuthenticatedRequest } from './auth.types';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { 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';
|
||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||
|
||||
@@ -32,6 +34,7 @@ describe('JwtAuthGuard', () => {
|
||||
subject: 'oidc-user-1',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
groups: [],
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -43,6 +46,14 @@ describe('JwtAuthGuard', () => {
|
||||
provide: getRepositoryToken(RefreshTokenEntity),
|
||||
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserKeycloakGroupEntity),
|
||||
useValue: new InMemoryRepository<UserKeycloakGroupEntity>(),
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserImpersonationEntity),
|
||||
useValue: new InMemoryRepository<UserImpersonationEntity>(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await module.init();
|
||||
|
||||
@@ -12,11 +12,13 @@ export interface OidcProfile {
|
||||
subject: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
interface OidcDiscovery {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint?: string;
|
||||
jwks_uri: string;
|
||||
issuer: string;
|
||||
}
|
||||
@@ -29,6 +31,7 @@ interface PendingOidcState {
|
||||
|
||||
interface TokenResponse {
|
||||
id_token?: string;
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
@@ -59,7 +62,7 @@ export class OidcService {
|
||||
authorizationUrl.searchParams.set('response_type', 'code');
|
||||
authorizationUrl.searchParams.set('client_id', config.clientId);
|
||||
authorizationUrl.searchParams.set('redirect_uri', config.callbackUrl);
|
||||
authorizationUrl.searchParams.set('scope', 'openid email profile');
|
||||
authorizationUrl.searchParams.set('scope', config.scope);
|
||||
authorizationUrl.searchParams.set('state', state);
|
||||
authorizationUrl.searchParams.set('nonce', nonce);
|
||||
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
@@ -89,6 +92,7 @@ export class OidcService {
|
||||
pendingState.codeVerifier,
|
||||
);
|
||||
|
||||
console.log(tokenResponse)
|
||||
if (!tokenResponse.id_token) {
|
||||
throw new ServiceUnavailableException(
|
||||
tokenResponse.error_description ??
|
||||
@@ -120,10 +124,19 @@ export class OidcService {
|
||||
throw new BadRequestException('OIDC email claim is missing.');
|
||||
}
|
||||
|
||||
const idTokenGroups = this.extractGroups(payload, config.groupsClaim);
|
||||
|
||||
return {
|
||||
subject: payload.sub,
|
||||
email,
|
||||
name: typeof payload.name === 'string' ? payload.name : undefined,
|
||||
groups: idTokenGroups.length
|
||||
? idTokenGroups
|
||||
: await this.requestUserInfoGroups(
|
||||
discovery.userinfo_endpoint,
|
||||
tokenResponse.access_token,
|
||||
config.groupsClaim,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,9 +223,53 @@ export class OidcService {
|
||||
clientId,
|
||||
callbackUrl,
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||
scope: process.env.OIDC_SCOPE ?? 'openid email profile',
|
||||
groupsClaim: process.env.OIDC_GROUPS_CLAIM ?? 'groups',
|
||||
};
|
||||
}
|
||||
|
||||
private async requestUserInfoGroups(
|
||||
userInfoEndpoint: string | undefined,
|
||||
accessToken: string | undefined,
|
||||
groupsClaim: string,
|
||||
): Promise<string[]> {
|
||||
if (!userInfoEndpoint || !accessToken) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(userInfoEndpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
return this.extractGroups(payload, groupsClaim);
|
||||
}
|
||||
|
||||
private extractGroups(
|
||||
payload: Record<string, unknown>,
|
||||
groupsClaim: string,
|
||||
): string[] {
|
||||
const claimValue = payload[groupsClaim];
|
||||
|
||||
if (!Array.isArray(claimValue)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(claimValue)]
|
||||
.filter((group): group is string => typeof group === 'string')
|
||||
.map((group) => group.trim())
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
private createOpaqueToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
61
listify-api/src/auth/user-impersonation.entity.ts
Normal file
61
listify-api/src/auth/user-impersonation.entity.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Entity('user_impersonations')
|
||||
@Index('IDX_user_impersonations_impersonator_enabled', [
|
||||
'impersonatorUserId',
|
||||
'enabled',
|
||||
])
|
||||
export class UserImpersonationEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 36 })
|
||||
impersonatorUserId!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 36 })
|
||||
targetUserId!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
@Column({ type: 'datetime', precision: 3, nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
onUpdate: 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'impersonatorUserId' })
|
||||
impersonator?: UserEntity;
|
||||
|
||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'targetUserId' })
|
||||
target?: UserEntity;
|
||||
}
|
||||
49
listify-api/src/auth/user-keycloak-group.entity.ts
Normal file
49
listify-api/src/auth/user-keycloak-group.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Entity('user_keycloak_groups')
|
||||
@Index('IDX_user_keycloak_groups_user_group', ['userId', 'groupPath'], {
|
||||
unique: true,
|
||||
})
|
||||
export class UserKeycloakGroupEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 36 })
|
||||
userId!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
groupPath!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 160 })
|
||||
groupName!: string;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
onUpdate: 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user?: UserEntity;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { DataSource } from 'typeorm';
|
||||
import { AssistantChatLogEntity } from '../assistant/assistant-chat-log.entity';
|
||||
import { AuditLogEntity } from '../audit/audit-log.entity';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
import { UserKeycloakGroupEntity } from '../auth/user-keycloak-group.entity';
|
||||
import { UserImpersonationEntity } from '../auth/user-impersonation.entity';
|
||||
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
|
||||
import { DailyDashboardSnapshotEntity } from '../dashboard/daily-dashboard-snapshot.entity';
|
||||
import { WeeklyListSuggestionSnapshotEntity } from '../dashboard/weekly-list-suggestion-snapshot.entity';
|
||||
@@ -38,6 +40,8 @@ export default new DataSource({
|
||||
AuditLogEntity,
|
||||
DailyDashboardSnapshotEntity,
|
||||
UserEntity,
|
||||
UserKeycloakGroupEntity,
|
||||
UserImpersonationEntity,
|
||||
RefreshTokenEntity,
|
||||
ListTemplateEntity,
|
||||
ListTemplateItemEntity,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateUserImpersonations1782300000000 implements MigrationInterface {
|
||||
name = 'CreateUserImpersonations1782300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasTable('user_impersonations')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE \`user_impersonations\` (
|
||||
\`id\` varchar(36) NOT NULL,
|
||||
\`impersonatorUserId\` varchar(36) NOT NULL,
|
||||
\`targetUserId\` varchar(36) NOT NULL,
|
||||
\`enabled\` tinyint NOT NULL DEFAULT 1,
|
||||
\`reason\` varchar(255) NULL,
|
||||
\`expiresAt\` datetime(3) NULL,
|
||||
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
\`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
INDEX \`IDX_user_impersonations_impersonatorUserId\` (\`impersonatorUserId\`),
|
||||
INDEX \`IDX_user_impersonations_targetUserId\` (\`targetUserId\`),
|
||||
INDEX \`IDX_user_impersonations_impersonator_enabled\` (\`impersonatorUserId\`, \`enabled\`),
|
||||
CONSTRAINT \`FK_user_impersonations_impersonator_user\`
|
||||
FOREIGN KEY (\`impersonatorUserId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`FK_user_impersonations_target_user\`
|
||||
FOREIGN KEY (\`targetUserId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE,
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasTable('user_impersonations')) {
|
||||
await queryRunner.query('DROP TABLE `user_impersonations`');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateUserKeycloakGroups1782400000000 implements MigrationInterface {
|
||||
name = 'CreateUserKeycloakGroups1782400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasTable('user_keycloak_groups')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE \`user_keycloak_groups\` (
|
||||
\`id\` varchar(36) NOT NULL,
|
||||
\`userId\` varchar(36) NOT NULL,
|
||||
\`groupPath\` varchar(255) NOT NULL,
|
||||
\`groupName\` varchar(160) NOT NULL,
|
||||
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
\`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
INDEX \`IDX_user_keycloak_groups_userId\` (\`userId\`),
|
||||
UNIQUE INDEX \`IDX_user_keycloak_groups_user_group\` (\`userId\`, \`groupPath\`),
|
||||
CONSTRAINT \`FK_user_keycloak_groups_user\`
|
||||
FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE,
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasTable('user_keycloak_groups')) {
|
||||
await queryRunner.query('DROP TABLE `user_keycloak_groups`');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ describe('AppController (e2e)', () => {
|
||||
subject: 'oidc-default',
|
||||
email: 'default@example.com',
|
||||
name: 'Default User',
|
||||
groups: [],
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -205,9 +206,7 @@ describe('AppController (e2e)', () => {
|
||||
expect(fetchedList.items[0].checked).toBe(true);
|
||||
});
|
||||
|
||||
async function loginWithSsoAndGetAccessToken(
|
||||
email: string,
|
||||
): Promise<string> {
|
||||
async function loginWithSsoAndGetAccessToken(email: string): Promise<string> {
|
||||
const loginBody = await loginWithSso(email);
|
||||
expect(loginBody.accessToken).toBeDefined();
|
||||
|
||||
@@ -219,6 +218,7 @@ describe('AppController (e2e)', () => {
|
||||
subject: `sub-${email}`,
|
||||
email,
|
||||
name: 'Test User',
|
||||
groups: [],
|
||||
});
|
||||
|
||||
const exchangeResponse = await request(app.getHttpServer())
|
||||
@@ -270,7 +270,9 @@ describe('AppController (e2e)', () => {
|
||||
)) as unknown[];
|
||||
|
||||
if (columns.length) {
|
||||
await dataSource.query(`ALTER TABLE \`users\` DROP COLUMN \`${columnName}\``);
|
||||
await dataSource.query(
|
||||
`ALTER TABLE \`users\` DROP COLUMN \`${columnName}\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,26 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="groups-section" aria-label="Keycloak Gruppen">
|
||||
<div class="settings-heading">
|
||||
<mat-icon aria-hidden="true">groups</mat-icon>
|
||||
<div>
|
||||
<h2>Keycloak-Gruppen</h2>
|
||||
<p>{{ auth.user()?.groups?.length || 0 }} synchronisiert</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (auth.user()?.groups?.length) {
|
||||
<ul class="group-list">
|
||||
@for (group of auth.user()?.groups ?? []; track group) {
|
||||
<li>{{ group }}</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="settings-description">Keine Gruppen hinterlegt.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="settings-section" aria-label="Task-Mail Einstellungen">
|
||||
<div class="settings-heading">
|
||||
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>
|
||||
|
||||
@@ -40,6 +40,16 @@
|
||||
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
.groups-section {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
.settings-heading {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
@@ -72,6 +82,26 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.group-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.group-list li {
|
||||
max-width: 100%;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
|
||||
border-radius: 999px;
|
||||
background: var(--mat-sys-surface);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.88rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.saving-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface PublicUser {
|
||||
name?: string;
|
||||
onboardingCompleted: boolean;
|
||||
taskDigestPreference: TaskDigestPreference;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export interface PublicUserSearchResult {
|
||||
|
||||
@@ -116,8 +116,9 @@ export class AuthService {
|
||||
}
|
||||
|
||||
private storeUser(user: PublicUser): void {
|
||||
this.storage?.setItem(USER_KEY, JSON.stringify(user));
|
||||
this.userSignal.set(user);
|
||||
const normalizedUser = this.normalizeUser(user);
|
||||
this.storage?.setItem(USER_KEY, JSON.stringify(normalizedUser));
|
||||
this.userSignal.set(normalizedUser);
|
||||
}
|
||||
|
||||
private readStoredUser(): PublicUser | null {
|
||||
@@ -128,13 +129,20 @@ export class AuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawUser) as PublicUser;
|
||||
return this.normalizeUser(JSON.parse(rawUser) as PublicUser);
|
||||
} catch {
|
||||
this.storage?.removeItem(USER_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeUser(user: PublicUser): PublicUser {
|
||||
return {
|
||||
...user,
|
||||
groups: Array.isArray(user.groups) ? user.groups : [],
|
||||
};
|
||||
}
|
||||
|
||||
private get storage(): Storage | null {
|
||||
return typeof window === 'undefined' ? null : window.localStorage;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user