This commit is contained in:
Bastian Wagner
2026-07-20 17:01:16 +02:00
parent 2f84a109e8
commit aa7758c9dd
38 changed files with 1129 additions and 134 deletions

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { getRequestId } from '../common/request-context/request-context';
import { AuditAction, AuditLogEntity } from './entities/audit-log.entity';
import { AuditRepository } from './repositories/audit.repository';
import type { EntityManager } from 'typeorm';
@Injectable()
export class AuditService {
@@ -13,6 +14,7 @@ export class AuditService {
targetType: string,
targetId: string,
metadata: Record<string, string | number | boolean | null> | null = null,
manager?: EntityManager,
): Promise<void> {
const entry = new AuditLogEntity();
entry.actorUserId = actorUserId;
@@ -21,7 +23,7 @@ export class AuditService {
entry.targetId = targetId;
entry.metadata = metadata;
entry.requestId = getRequestId();
await this.audit.save(entry);
await this.audit.save(entry, manager);
}
async list(page = 1, pageSize = 20) {

View File

@@ -11,6 +11,8 @@ export enum AuditAction {
UserDeactivated = 'USER_DEACTIVATED',
UserRoleAssigned = 'USER_ROLE_ASSIGNED',
UserRoleRemoved = 'USER_ROLE_REMOVED',
OidcAdminAssigned = 'OIDC_ADMIN_ASSIGNED',
OidcAdminRemoved = 'OIDC_ADMIN_REMOVED',
RoleCreated = 'ROLE_CREATED',
RoleUpdated = 'ROLE_UPDATED',
RoleDeleted = 'ROLE_DELETED',

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EntityManager, Repository } from 'typeorm';
import { AuditLogEntity } from '../entities/audit-log.entity';
@Injectable()
@@ -10,8 +10,11 @@ export class AuditRepository {
private readonly repo: Repository<AuditLogEntity>,
) {}
save(entry: AuditLogEntity): Promise<AuditLogEntity> {
return this.repo.save(entry);
save(
entry: AuditLogEntity,
manager?: EntityManager,
): Promise<AuditLogEntity> {
return (manager?.getRepository(AuditLogEntity) ?? this.repo).save(entry);
}
list(page: number, pageSize: number): Promise<[AuditLogEntity[], number]> {

View File

@@ -9,6 +9,10 @@ import { UsersRepository } from '../users/repositories/users.repository';
import { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { OidcRoleExtractorService } from './oidc-role-extractor.service';
import { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service';
import { UserRoleAssignmentEntity } from '../users/entities/user-role-assignment.entity';
import { AuditModule } from '../audit/audit.module';
@Module({
imports: [
@@ -16,12 +20,20 @@ import { AuthService } from './auth.service';
OidcLoginStateEntity,
UserEntity,
UserSettingsEntity,
UserRoleAssignmentEntity,
]),
RolesModule,
SessionsModule,
AuditModule,
],
controllers: [AuthController],
providers: [AuthService, ExternalHttpClient, UsersRepository],
providers: [
AuthService,
ExternalHttpClient,
UsersRepository,
OidcRoleExtractorService,
OidcAdminSynchronizationService,
],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import type { DataSource, Repository } from 'typeorm';
import type { DataSource, EntityManager, Repository } from 'typeorm';
import type { ExternalHttpClient } from '../common/http/external-http-client';
import type { AppConfigService } from '../config/config.service';
import type { RolesService } from '../roles/roles.service';
@@ -7,8 +7,82 @@ import type { SessionsService } from '../sessions/sessions.service';
import type { UsersRepository } from '../users/repositories/users.repository';
import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
import { AuthService } from './auth.service';
import type { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service';
import { UserEntity } from '../users/entities/user.entity';
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
import type { RoleEntity } from '../roles/entities/role.entity';
describe('AuthService', () => {
it('does not grant the first or second provisioned user the admin role', async () => {
let sequence = 0;
const userRole = {
id: 'role-user',
name: 'user',
roleKey: 'USER',
} as RoleEntity;
const adminRole = {
id: 'role-admin',
name: 'admin',
roleKey: 'ADMIN',
} as RoleEntity;
const manager = {
query: () => Promise.resolve(),
getRepository: (entity: unknown) => {
if (entity === UserEntity) {
return {
findOne: () => Promise.resolve(null),
save: (user: UserEntity) => {
user.id = `user-${++sequence}`;
return Promise.resolve(user);
},
};
}
if (entity === UserSettingsEntity) {
return {
findOne: () => Promise.resolve(null),
save: (settings: UserSettingsEntity) => Promise.resolve(settings),
};
}
throw new Error('Unerwartetes Repository');
},
} as unknown as EntityManager;
const service = new AuthService(
{} as AppConfigService,
{} as ExternalHttpClient,
{
ensureSystemRoles: () =>
Promise.resolve({ admin: adminRole, user: userRole }),
} as unknown as RolesService,
{} as UsersRepository,
{} as SessionsService,
{} as OidcAdminSynchronizationService,
{
transaction: <T>(
action: (transactionManager: EntityManager) => Promise<T>,
) => action(manager),
} as DataSource,
{} as Repository<OidcLoginStateEntity>,
);
const provision = service as unknown as {
upsertLocalUser: (
issuer: string,
profile: { sub: string; name: string },
) => Promise<UserEntity>;
};
const first = await provision.upsertLocalUser('issuer', {
sub: 'first',
name: 'First',
});
const second = await provision.upsertLocalUser('issuer', {
sub: 'second',
name: 'Second',
});
expect(first.roles.map((role) => role.roleKey)).toEqual(['USER']);
expect(second.roles.map((role) => role.roleKey)).toEqual(['USER']);
});
it('stores only an internal return path in the short-lived OIDC login state', async () => {
const saved: OidcLoginStateEntity[] = [];
const service = new AuthService(
@@ -36,6 +110,7 @@ describe('AuthService', () => {
{} as RolesService,
{} as UsersRepository,
{} as SessionsService,
{} as OidcAdminSynchronizationService,
{} as DataSource,
{
delete: () => Promise.resolve({}),
@@ -76,6 +151,7 @@ describe('AuthService', () => {
{} as RolesService,
{} as UsersRepository,
{ revoke, getIdTokenForLogout } as unknown as SessionsService,
{} as OidcAdminSynchronizationService,
{} as DataSource,
{} as Repository<OidcLoginStateEntity>,
);

View File

@@ -17,6 +17,8 @@ import type {
OidcTokenResponse,
OidcUserInfo,
} from './oidc.types';
import { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service';
import { UserRoleAssignmentSource } from '../users/entities/user-role-assignment.entity';
@Injectable()
export class AuthService {
@@ -26,6 +28,7 @@ export class AuthService {
private readonly roles: RolesService,
private readonly users: UsersRepository,
private readonly sessions: SessionsService,
private readonly adminSynchronization: OidcAdminSynchronizationService,
@InjectDataSource() private readonly dataSource: DataSource,
@InjectRepository(OidcLoginStateEntity)
private readonly loginStates: Repository<OidcLoginStateEntity>,
@@ -83,12 +86,12 @@ export class AuthService {
code,
loginState.codeVerifier,
);
const profile = await this.verifyAndLoadProfile(
const { profile, claims } = await this.verifyAndLoadProfile(
discovery,
tokens,
loginState.nonce,
);
const user = await this.upsertLocalUser(discovery.issuer, profile);
let user = await this.upsertLocalUser(discovery.issuer, profile);
if (!user.active) {
throw new ApiError(
ErrorCode.UserDisabled,
@@ -96,6 +99,7 @@ export class AuthService {
403,
);
}
user = await this.adminSynchronization.synchronize(user.id, claims);
const result = await this.sessions.createSession(
user,
@@ -129,8 +133,7 @@ export class AuthService {
return this.dataSource.transaction(async (manager) => {
await manager.query("SELECT GET_LOCK('business_app_first_admin', 10)");
try {
const { admin, user: userRole } =
await this.roles.ensureSystemRoles(manager);
const { user: userRole } = await this.roles.ensureSystemRoles(manager);
let user = await manager.getRepository(UserEntity).findOne({
where: { issuer, subject: profile.sub },
relations: { roles: true, settings: true },
@@ -141,10 +144,6 @@ export class AuthService {
user.subject = profile.sub;
user.active = true;
user.roles = [userRole];
const userCount = await manager.getRepository(UserEntity).count();
if (userCount === 0) {
user.roles = [userRole, admin];
}
}
user.name = profile.name ?? profile.email ?? profile.sub;
user.email = profile.email ?? null;
@@ -154,6 +153,12 @@ export class AuthService {
: null;
user.lastLoginAt = new Date();
const savedUser = await manager.getRepository(UserEntity).save(user);
await manager.query(
`INSERT IGNORE INTO user_role_assignments
(id, user_id, role_id, source, last_synchronized_at, created_at, updated_at)
VALUES (UUID(), ?, ?, ?, NULL, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))`,
[savedUser.id, userRole.id, UserRoleAssignmentSource.System],
);
savedUser.settings = await this.ensureUserSettings(manager, savedUser);
return savedUser;
} finally {
@@ -266,7 +271,7 @@ export class AuthService {
discovery: OidcDiscovery,
tokens: OidcTokenResponse,
nonce: string,
): Promise<OidcUserInfo> {
): Promise<{ profile: OidcUserInfo; claims: Record<string, unknown> }> {
const { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } =
await import('jose');
const protectedHeader = decodeProtectedHeader(tokens.id_token);
@@ -305,7 +310,7 @@ export class AuthService {
if (typeof payload['email_verified'] === 'boolean') {
fallback.email_verified = payload['email_verified'];
}
return fallback;
return { profile: fallback, claims: { ...payload } };
}
const userInfo = await this.http.requestJson<OidcUserInfo>(
discovery.userinfo_endpoint,
@@ -335,7 +340,7 @@ export class AuthService {
) {
userInfo.email_verified = payload['email_verified'];
}
return userInfo;
return { profile: userInfo, claims: { ...payload, ...userInfo } };
}
private isAllowedOidcAlgorithm(algorithm: string | undefined): boolean {

View File

@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from 'vitest';
import type { DataSource, EntityManager } from 'typeorm';
import type { AuditService } from '../audit/audit.service';
import type { AppConfigService } from '../config/config.service';
import type { RoleEntity } from '../roles/entities/role.entity';
import type { RolesService } from '../roles/roles.service';
import type { UserEntity } from '../users/entities/user.entity';
import { UserRoleAssignmentSource } from '../users/entities/user-role-assignment.entity';
import { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service';
import { OidcRoleExtractorService } from './oidc-role-extractor.service';
function fixture(
existing: boolean,
remainingSources = 0,
adminRole: string | undefined = 'hauspilot-admin',
caseSensitive = true,
) {
const assignment = {
id: 'assignment-1',
userId: 'user-1',
roleId: 'admin-role',
source: UserRoleAssignmentSource.Oidc,
lastSynchronizedAt: new Date(),
};
const repository = {
findOneBy: vi.fn(() => Promise.resolve(existing ? assignment : null)),
save: vi.fn(() => Promise.resolve(assignment)),
insert: vi.fn(() =>
Promise.resolve({ identifiers: [], generatedMaps: [], raw: [] }),
),
remove: vi.fn(() => Promise.resolve(assignment)),
countBy: vi.fn(() => Promise.resolve(remainingSources)),
};
const query = vi.fn(() => Promise.resolve([]));
const manager = {
getRepository: () => repository,
query,
} as unknown as EntityManager;
const user = { id: 'user-1', roles: [] } as unknown as UserEntity;
const dataSource = {
transaction: (callback: (value: EntityManager) => Promise<void>) =>
callback(manager),
getRepository: () => ({ findOne: () => Promise.resolve(user) }),
} as unknown as DataSource;
const audit = {
record: vi.fn(() => Promise.resolve()),
} as unknown as AuditService;
const roles = {
ensureSystemRoles: () =>
Promise.resolve({
admin: { id: 'admin-role' } as RoleEntity,
user: {} as RoleEntity,
}),
} as RolesService;
const config = {
oidc: {
adminRole,
rolesClaim: 'realm_access.roles',
roleMatchCaseSensitive: caseSensitive,
},
} as AppConfigService;
return {
service: new OidcAdminSynchronizationService(
config,
new OidcRoleExtractorService(),
roles,
audit,
dataSource,
),
repository,
query,
audit,
};
}
describe('OidcAdminSynchronizationService', () => {
it('adds the OIDC source and effective admin role idempotently', async () => {
const first = fixture(false);
await first.service.synchronize('user-1', {
realm_access: { roles: ['hauspilot-admin'] },
});
expect(first.query).toHaveBeenCalledWith(
expect.stringContaining('ON DUPLICATE KEY UPDATE'),
['user-1', 'admin-role', UserRoleAssignmentSource.Oidc],
);
expect(first.query).toHaveBeenCalledWith(
'INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)',
['user-1', 'admin-role'],
);
const repeated = fixture(true);
await repeated.service.synchronize('user-1', {
realm_access: { roles: ['hauspilot-admin'] },
});
expect(repeated.repository.save).toHaveBeenCalledOnce();
});
it('removes only the OIDC source while another source keeps the effective role', async () => {
const current = fixture(true, 1);
await current.service.synchronize('user-1', {
realm_access: { roles: ['hauspilot-user'] },
});
expect(current.repository.remove).toHaveBeenCalledOnce();
expect(current.query).not.toHaveBeenCalledWith(
'DELETE FROM user_roles WHERE user_id = ? AND role_id = ?',
expect.anything(),
);
});
it('fails safely without changing assignments when the configured claim is missing', async () => {
const current = fixture(true);
await expect(
current.service.synchronize('user-1', { sub: '123' }),
).rejects.toMatchObject({
status: 401,
});
expect(current.repository.remove).not.toHaveBeenCalled();
expect(current.repository.insert).not.toHaveBeenCalled();
});
it('does not add or remove assignments when synchronization is disabled', async () => {
const current = fixture(true, 0, '');
await current.service.synchronize('user-1', {
realm_access: { roles: [] },
});
expect(current.repository.remove).not.toHaveBeenCalled();
expect(current.query).not.toHaveBeenCalled();
});
it('honors case-insensitive matching only when explicitly configured', async () => {
const current = fixture(false, 0, 'hauspilot-admin', false);
await current.service.synchronize('user-1', {
realm_access: { roles: ['HAUSPILOT-ADMIN'] },
});
expect(current.query).toHaveBeenCalledWith(
expect.stringContaining('ON DUPLICATE KEY UPDATE'),
['user-1', 'admin-role', UserRoleAssignmentSource.Oidc],
);
});
});

View File

@@ -0,0 +1,139 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/entities/audit-log.entity';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { AppConfigService } from '../config/config.service';
import { RolesService } from '../roles/roles.service';
import { UserEntity } from '../users/entities/user.entity';
import {
UserRoleAssignmentEntity,
UserRoleAssignmentSource,
} from '../users/entities/user-role-assignment.entity';
import { OidcRoleExtractorService } from './oidc-role-extractor.service';
@Injectable()
export class OidcAdminSynchronizationService {
private readonly logger = new Logger(OidcAdminSynchronizationService.name);
constructor(
private readonly config: AppConfigService,
private readonly extractor: OidcRoleExtractorService,
private readonly roles: RolesService,
private readonly audit: AuditService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async synchronize(
userId: string,
claims: Readonly<Record<string, unknown>>,
): Promise<UserEntity> {
const adminRoleName = this.config.oidc.adminRole;
if (!adminRoleName) return this.reloadUser(userId);
const claimPath = this.config.oidc.rolesClaim;
if (!claimPath)
throw new Error(
'OIDC_ROLES_CLAIM fehlt trotz validierter Konfiguration.',
);
const extracted = this.extractor.extract(claims, claimPath);
if (!extracted.readable) {
this.logger.error(
{ userId, claimPath, reason: extracted.reason },
'OIDC-Administrator-Synchronisierung: Rollenclaim konnte nicht gelesen werden',
);
throw new ApiError(
ErrorCode.Unauthorized,
'Die OIDC-Rollen konnten nicht sicher validiert werden.',
401,
);
}
const normalize = (value: string) =>
this.config.oidc.roleMatchCaseSensitive
? value
: value.toLocaleLowerCase('en-US');
const hasAdminRole = extracted.roles.some(
(role) => normalize(role) === normalize(adminRoleName),
);
await this.dataSource.transaction(async (manager) => {
const { admin } = await this.roles.ensureSystemRoles(manager);
const assignments = manager.getRepository(UserRoleAssignmentEntity);
const existing = await assignments.findOneBy({
userId,
roleId: admin.id,
source: UserRoleAssignmentSource.Oidc,
});
if (hasAdminRole) {
if (existing) {
existing.lastSynchronizedAt = new Date();
await assignments.save(existing);
} else {
await manager.query(
`INSERT INTO user_role_assignments
(id, user_id, role_id, source, last_synchronized_at, created_at, updated_at)
VALUES (UUID(), ?, ?, ?, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE last_synchronized_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3)`,
[userId, admin.id, UserRoleAssignmentSource.Oidc],
);
await manager.query(
'INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)',
[userId, admin.id],
);
await this.audit.record(
userId,
AuditAction.OidcAdminAssigned,
'user',
userId,
{
roleId: admin.id,
oidcRole: adminRoleName,
source: UserRoleAssignmentSource.Oidc,
},
manager,
);
}
} else if (existing) {
await assignments.remove(existing);
const remaining = await assignments.countBy({
userId,
roleId: admin.id,
});
if (remaining === 0) {
await manager.query(
'DELETE FROM user_roles WHERE user_id = ? AND role_id = ?',
[userId, admin.id],
);
}
await this.audit.record(
userId,
AuditAction.OidcAdminRemoved,
'user',
userId,
{
roleId: admin.id,
oidcRole: adminRoleName,
source: UserRoleAssignmentSource.Oidc,
},
manager,
);
}
});
return this.reloadUser(userId);
}
private async reloadUser(userId: string): Promise<UserEntity> {
const user = await this.dataSource.getRepository(UserEntity).findOne({
where: { id: userId },
relations: { roles: { permissions: true }, settings: true },
});
if (!user)
throw new ApiError(
ErrorCode.UserNotFound,
'Der Benutzer wurde nicht gefunden.',
404,
);
return user;
}
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { OidcRoleExtractorService } from './oidc-role-extractor.service';
describe('OidcRoleExtractorService', () => {
const extractor = new OidcRoleExtractorService();
it('reads nested arrays, trims values and removes duplicates and empty roles', () => {
expect(
extractor.extract(
{
realm_access: {
roles: [
' hauspilot-admin ',
'hauspilot-user',
'',
'hauspilot-admin',
],
},
},
'realm_access.roles',
),
).toEqual({ readable: true, roles: ['hauspilot-admin', 'hauspilot-user'] });
});
it('accepts a single role string', () => {
expect(extractor.extract({ roles: 'hauspilot-admin' }, 'roles')).toEqual({
readable: true,
roles: ['hauspilot-admin'],
});
});
it('distinguishes a missing claim from a valid claim without the admin role', () => {
expect(extractor.extract({ sub: '123' }, 'roles')).toEqual({
readable: false,
reason: 'missing',
});
expect(extractor.extract({ roles: ['hauspilot-user'] }, 'roles')).toEqual({
readable: true,
roles: ['hauspilot-user'],
});
});
it('rejects mixed or object claim formats', () => {
expect(extractor.extract({ roles: ['valid', 42] }, 'roles')).toEqual({
readable: false,
reason: 'invalid',
});
});
});

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
export type OidcRoleExtractionResult =
| { readable: true; roles: string[] }
| { readable: false; reason: 'missing' | 'invalid' };
@Injectable()
export class OidcRoleExtractorService {
extract(
claims: Readonly<Record<string, unknown>>,
claimPath: string,
): OidcRoleExtractionResult {
let value: unknown = claims;
for (const segment of claimPath.split('.')) {
if (
typeof value !== 'object' ||
value === null ||
Array.isArray(value) ||
!(segment in value)
) {
return { readable: false, reason: 'missing' };
}
value = (value as Record<string, unknown>)[segment];
}
const values: unknown[] =
typeof value === 'string' ? [value] : Array.isArray(value) ? value : [];
if (
(!Array.isArray(value) && typeof value !== 'string') ||
values.some((entry) => typeof entry !== 'string')
) {
return { readable: false, reason: 'invalid' };
}
const roles: string[] = [];
for (const entry of values) {
if (typeof entry !== 'string') continue;
const role = entry.trim();
if (role && !roles.includes(role)) roles.push(role);
}
return {
readable: true,
roles,
};
}
}

View File

@@ -101,4 +101,30 @@ describe('loadConfigFromEnv', () => {
}),
).toThrow(/OIDC_ALLOWED_ALGORITHMS/);
});
it('reads and trims the OIDC administrator mapping without an unsafe default', () => {
const configured = loadConfigFromEnv({
...validEnv,
OIDC_ADMIN_ROLE: ' hauspilot-admin ',
OIDC_ROLES_CLAIM: 'realm_access.roles',
});
const disabled = loadConfigFromEnv({ ...validEnv, OIDC_ADMIN_ROLE: '' });
expect(configured.oidc.adminRole).toBe('hauspilot-admin');
expect(configured.oidc.rolesClaim).toBe('realm_access.roles');
expect(disabled.oidc.adminRole).toBeUndefined();
});
it('requires a valid roles claim path when OIDC admin synchronization is enabled', () => {
expect(() =>
loadConfigFromEnv({ ...validEnv, OIDC_ADMIN_ROLE: 'hauspilot-admin' }),
).toThrow(/OIDC_ROLES_CLAIM/);
expect(() =>
loadConfigFromEnv({
...validEnv,
OIDC_ADMIN_ROLE: 'hauspilot-admin',
OIDC_ROLES_CLAIM: 'realm access.roles',
}),
).toThrow(/OIDC_ROLES_CLAIM/);
});
});

View File

@@ -26,6 +26,9 @@ export interface AppConfig {
scopes: string;
allowedAlgorithms: string[];
httpTimeoutMs: number;
adminRole?: string;
rolesClaim?: string;
roleMatchCaseSensitive: boolean;
logoutUrl?: string;
};
session: {

View File

@@ -10,85 +10,129 @@ const booleanFromString = z
.pipe(z.enum(['true', 'false']))
.transform((value) => value === 'true');
const envSchema = z.object({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
APP_BASE_URL: z.url(),
FRONTEND_BASE_URL: z.url().optional(),
TRUST_PROXY: booleanFromString.default(false),
DATABASE_HOST: z.string().min(1),
DATABASE_PORT: z.coerce.number().int().min(1).max(65535).default(3306),
DATABASE_NAME: z.string().min(1),
DATABASE_USER: z.string().min(1),
DATABASE_PASSWORD: z.string().min(1),
DATABASE_SSL: booleanFromString.default(false),
OIDC_ISSUER: z.url(),
OIDC_CLIENT_ID: z.string().min(1),
OIDC_CLIENT_SECRET: z.string().min(1),
OIDC_SCOPES: z.string().min(1).default('openid profile email'),
OIDC_LOGOUT_URL: z.preprocess(
(value) => (value === '' ? undefined : value),
z.url().optional(),
),
OIDC_ALLOWED_ALGORITHMS: z
.string()
.min(1)
.transform((value) =>
const optionalTrimmedString = (maxLength: number) =>
z.preprocess(
(value) =>
typeof value === 'string' && value.trim() === '' ? undefined : value,
z
.string()
.trim()
.max(maxLength)
.refine((value) =>
Array.from(value).every((character) => {
const code = character.charCodeAt(0);
return code > 31 && code !== 127;
}),
)
.optional(),
);
const envSchema = z
.object({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
APP_BASE_URL: z.url(),
FRONTEND_BASE_URL: z.url().optional(),
TRUST_PROXY: booleanFromString.default(false),
DATABASE_HOST: z.string().min(1),
DATABASE_PORT: z.coerce.number().int().min(1).max(65535).default(3306),
DATABASE_NAME: z.string().min(1),
DATABASE_USER: z.string().min(1),
DATABASE_PASSWORD: z.string().min(1),
DATABASE_SSL: booleanFromString.default(false),
OIDC_ISSUER: z.url(),
OIDC_CLIENT_ID: z.string().min(1),
OIDC_CLIENT_SECRET: z.string().min(1),
OIDC_SCOPES: z.string().min(1).default('openid profile email'),
OIDC_LOGOUT_URL: z.preprocess(
(value) => (value === '' ? undefined : value),
z.url().optional(),
),
OIDC_ALLOWED_ALGORITHMS: z
.string()
.min(1)
.transform((value) =>
value
.split(/[\s,]+/)
.map((entry) => entry.trim())
.filter(Boolean),
)
.refine(
(algorithms) =>
algorithms.length > 0 &&
algorithms.every((algorithm) => algorithm.toLowerCase() !== 'none'),
'OIDC_ALLOWED_ALGORITHMS darf none nicht erlauben.',
),
OIDC_HTTP_TIMEOUT_MS: z.coerce
.number()
.int()
.min(1000)
.max(30000)
.default(5000),
OIDC_ADMIN_ROLE: optionalTrimmedString(160),
OIDC_ROLES_CLAIM: optionalTrimmedString(255).refine(
(value) =>
value === undefined ||
/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(value),
'OIDC_ROLES_CLAIM muss ein gueltiger Claim-Pfad sein.',
),
OIDC_ROLE_MATCH_CASE_SENSITIVE: booleanFromString.default(true),
SESSION_COOKIE_NAME: z.string().min(1).default('app_session'),
SESSION_IDLE_TIMEOUT_SECONDS: z.coerce
.number()
.int()
.min(300)
.default(28800),
SESSION_ABSOLUTE_TIMEOUT_SECONDS: z.coerce
.number()
.int()
.min(3600)
.default(604800),
SESSION_SECRET: z.string().min(32),
SESSION_ENCRYPTION_KEY: z.string().min(32),
CORS_ORIGINS: z.string().transform((value) =>
value
.split(/[\s,]+/)
.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
)
.refine(
(algorithms) =>
algorithms.length > 0 &&
algorithms.every((algorithm) => algorithm.toLowerCase() !== 'none'),
'OIDC_ALLOWED_ALGORITHMS darf none nicht erlauben.',
),
OIDC_HTTP_TIMEOUT_MS: z.coerce
.number()
.int()
.min(1000)
.max(30000)
.default(5000),
SESSION_COOKIE_NAME: z.string().min(1).default('app_session'),
SESSION_IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(300).default(28800),
SESSION_ABSOLUTE_TIMEOUT_SECONDS: z.coerce
.number()
.int()
.min(3600)
.default(604800),
SESSION_SECRET: z.string().min(32),
SESSION_ENCRYPTION_KEY: z.string().min(32),
CORS_ORIGINS: z.string().transform((value) =>
value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
),
CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'),
LOG_LEVEL: z.string().min(1).default('info'),
SWAGGER_ENABLED: booleanFromString.default(false),
DOCUMENT_STORAGE_PATH: z.string().min(1).default('storage/documents'),
DOCUMENT_MAX_FILE_SIZE_BYTES: z.coerce
.number()
.int()
.min(1024)
.max(50 * 1024 * 1024)
.default(10 * 1024 * 1024),
REMINDER_INTERVAL_MS: z.coerce.number().int().min(60000).default(900000),
REMINDER_DUE_SOON_DAYS: z.coerce.number().int().min(1).max(30).default(3),
RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300),
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce
.number()
.int()
.min(1)
.default(60),
RATE_LIMIT_SENSITIVE_MAX_REQUESTS: z.coerce.number().int().min(1).default(10),
});
CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'),
LOG_LEVEL: z.string().min(1).default('info'),
SWAGGER_ENABLED: booleanFromString.default(false),
DOCUMENT_STORAGE_PATH: z.string().min(1).default('storage/documents'),
DOCUMENT_MAX_FILE_SIZE_BYTES: z.coerce
.number()
.int()
.min(1024)
.max(50 * 1024 * 1024)
.default(10 * 1024 * 1024),
REMINDER_INTERVAL_MS: z.coerce.number().int().min(60000).default(900000),
REMINDER_DUE_SOON_DAYS: z.coerce.number().int().min(1).max(30).default(3),
RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300),
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce
.number()
.int()
.min(1)
.default(60),
RATE_LIMIT_SENSITIVE_MAX_REQUESTS: z.coerce
.number()
.int()
.min(1)
.default(10),
})
.superRefine((value, context) => {
if (value.OIDC_ADMIN_ROLE && !value.OIDC_ROLES_CLAIM) {
context.addIssue({
code: 'custom',
path: ['OIDC_ROLES_CLAIM'],
message:
'OIDC_ROLES_CLAIM ist erforderlich, wenn OIDC_ADMIN_ROLE gesetzt ist.',
});
}
});
export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
const parsed = envSchema.safeParse(env);
@@ -134,6 +178,9 @@ export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
scopes: value.OIDC_SCOPES,
allowedAlgorithms: value.OIDC_ALLOWED_ALGORITHMS,
httpTimeoutMs: value.OIDC_HTTP_TIMEOUT_MS,
roleMatchCaseSensitive: value.OIDC_ROLE_MATCH_CASE_SENSITIVE,
...(value.OIDC_ADMIN_ROLE ? { adminRole: value.OIDC_ADMIN_ROLE } : {}),
...(value.OIDC_ROLES_CLAIM ? { rolesClaim: value.OIDC_ROLES_CLAIM } : {}),
...(value.OIDC_LOGOUT_URL ? { logoutUrl: value.OIDC_LOGOUT_URL } : {}),
},
session: {

View File

@@ -11,6 +11,7 @@ import { PermissionEntity } from '../roles/entities/permission.entity';
import { SessionEntity } from '../sessions/entities/session.entity';
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
import { UserEntity } from '../users/entities/user.entity';
import { UserRoleAssignmentEntity } from '../users/entities/user-role-assignment.entity';
import {
BudgetCategoryEntity,
BuildingEntity,
@@ -49,6 +50,7 @@ export const entities = [
SessionEntity,
UserSettingsEntity,
UserEntity,
UserRoleAssignmentEntity,
BuildingEntity,
FloorEntity,
RoomEntity,

View File

@@ -0,0 +1,46 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRoleAssignmentSources1720000008000
implements MigrationInterface
{
name = 'AddRoleAssignmentSources1720000008000';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE roles ADD COLUMN role_key varchar(40) NULL AFTER id, ADD UNIQUE KEY uq_roles_role_key (role_key)',
);
await queryRunner.query(
"UPDATE roles SET role_key = 'ADMIN' WHERE name = 'admin'",
);
await queryRunner.query(
"UPDATE roles SET role_key = 'USER' WHERE name = 'user'",
);
await queryRunner.query(`
CREATE TABLE user_role_assignments (
id char(36) NOT NULL,
user_id char(36) NOT NULL,
role_id char(36) NOT NULL,
source varchar(20) NOT NULL,
last_synchronized_at datetime(3) NULL,
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_user_role_assignments_source (user_id, role_id, source),
KEY idx_user_role_assignments_user (user_id),
CONSTRAINT fk_user_role_assignments_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_role_assignments_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await queryRunner.query(`
INSERT INTO user_role_assignments (id, user_id, role_id, source, last_synchronized_at)
SELECT UUID(), user_id, role_id, 'MANUAL', NULL FROM user_roles
`);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE user_role_assignments');
await queryRunner.query(
'ALTER TABLE roles DROP INDEX uq_roles_role_key, DROP COLUMN role_key',
);
}
}

View File

@@ -10,6 +10,7 @@ import { AddRenovationDomain1720000004000 } from './migrations/1720000004000-Add
import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders';
import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors';
import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning';
import { AddRoleAssignmentSources1720000008000 } from './migrations/1720000008000-AddRoleAssignmentSources';
const config = loadConfigForCli();
@@ -35,5 +36,6 @@ export default new DataSource({
AddMentionsAndReminders1720000005000,
AddDefaultProjectFloors1720000006000,
AddFurniturePlanning1720000007000,
AddRoleAssignmentSources1720000008000,
],
});

View File

@@ -9,6 +9,7 @@ import { AddRenovationDomain1720000004000 } from './migrations/1720000004000-Add
import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders';
import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors';
import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning';
import { AddRoleAssignmentSources1720000008000 } from './migrations/1720000008000-AddRoleAssignmentSources';
export function typeOrmOptionsFactory(
config: AppConfigService,
@@ -35,6 +36,7 @@ export function typeOrmOptionsFactory(
AddMentionsAndReminders1720000005000,
AddDefaultProjectFloors1720000006000,
AddFurniturePlanning1720000007000,
AddRoleAssignmentSources1720000008000,
],
};
}

View File

@@ -3,6 +3,7 @@ import cookieParser from 'cookie-parser';
import type { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
import pinoHttp from 'pino-http';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import type { NestExpressApplication } from '@nestjs/platform-express';
@@ -16,6 +17,11 @@ async function bootstrap() {
bufferLogs: true,
});
const config = app.get(AppConfigService);
if (!config.oidc.adminRole) {
new Logger('Configuration').warn(
'OIDC_ADMIN_ROLE ist nicht konfiguriert. Die automatische Administrator-Synchronisierung ist deaktiviert; es gibt keinen First-User-Fallback.',
);
}
app.use(
pinoHttp({

View File

@@ -65,11 +65,20 @@ describe('Notification integrations', () => {
active: true,
roles: [{ id: 'role-user', name: 'user' }],
};
let userLoad = 0;
const users: Pick<UsersRepository, 'findByIdWithRoles' | 'save'> = {
findByIdWithRoles: () =>
Promise.resolve(
user as Awaited<ReturnType<UsersRepository['findByIdWithRoles']>>,
),
findByIdWithRoles: () => {
const loaded =
userLoad++ === 0
? user
: {
...user,
roles: [...user.roles, { id: 'role-editor', name: 'Editor' }],
};
return Promise.resolve(
loaded as Awaited<ReturnType<UsersRepository['findByIdWithRoles']>>,
);
},
save: (entry) => Promise.resolve(entry),
};
const roles: Pick<RolesService, 'getRole'> = {
@@ -87,8 +96,28 @@ describe('Notification integrations', () => {
);
},
};
const assignmentRepository = {
findBy: () =>
Promise.resolve([
{ userId: 'user-1', roleId: 'role-user', source: 'MANUAL' },
]),
remove: () => Promise.resolve(),
insert: () => Promise.resolve(),
createQueryBuilder: () => ({
select: () => ({
where: () => ({
getRawMany: () =>
Promise.resolve([
{ roleId: 'role-user' },
{ roleId: 'role-editor' },
]),
}),
}),
}),
};
const manager = {
query: () => Promise.resolve(),
getRepository: () => assignmentRepository,
} as unknown as EntityManager;
const dataSource = {
transaction: <T>(action: (manager: EntityManager) => Promise<T>) =>

View File

@@ -17,6 +17,9 @@ export class RoleEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'role_key', type: 'varchar', length: 40, nullable: true })
roleKey!: string | null;
@Column({ type: 'varchar', length: 80 })
name!: string;

View File

@@ -11,6 +11,8 @@ import { RoleEntity } from './entities/role.entity';
import { allPermissions, Permission } from './permissions';
import type { CreateRoleDto, UpdateRoleDto } from './dto/role.dto';
import { RolesRepository } from './repositories/roles.repository';
import { AppConfigService } from '../config/config.service';
import { adminRoleKey, userRoleKey } from './system-role-keys';
const adminRoleName = 'admin';
const userRoleName = 'user';
@@ -20,6 +22,7 @@ export class RolesService {
constructor(
private readonly roles: RolesRepository,
private readonly audit: AuditService,
private readonly config: AppConfigService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
@@ -78,12 +81,12 @@ export class RolesService {
await this.assertRoleNameAvailable(normalizedName, id, manager);
role.name = normalizedName;
}
if (role.name === adminRoleName) {
if (role.roleKey === adminRoleKey) {
this.assertAdminPermissions(dto.permissions);
}
role.description = dto.description?.trim() ?? '';
role.permissions = await this.loadPermissionEntities(
role.name === adminRoleName ? allPermissions : dto.permissions,
role.roleKey === adminRoleKey ? allPermissions : dto.permissions,
manager,
);
const saved = await this.roles.save(role, manager);
@@ -178,12 +181,14 @@ export class RolesService {
await this.syncPermissions(manager);
const admin = await this.ensureRole(
adminRoleName,
adminRoleKey,
allPermissions,
true,
manager,
);
const user = await this.ensureRole(
userRoleName,
userRoleKey,
[
Permission.ItemsRead,
Permission.SessionsReadOwn,
@@ -223,6 +228,7 @@ export class RolesService {
private async ensureRole(
name: string,
roleKey: string,
permissions: Permission[],
protectedRole: boolean,
manager?: EntityManager,
@@ -237,6 +243,7 @@ export class RolesService {
role.name = name;
role.protected = protectedRole;
}
role.roleKey = roleKey;
role.permissions = await this.loadPermissionEntities(permissions, manager);
role.protected = protectedRole;
return repo.save(role);
@@ -303,6 +310,13 @@ export class RolesService {
description: role.description,
system: role.protected,
protected: role.protected,
roleKey: role.roleKey,
oidcManaged:
role.roleKey === adminRoleKey && Boolean(this.config.oidc.adminRole),
oidcRoleName:
role.roleKey === adminRoleKey
? (this.config.oidc.adminRole ?? null)
: null,
permissions: role.permissions,
userCount: role.users?.length ?? 0,
users: role.users,

View File

@@ -0,0 +1,2 @@
export const adminRoleKey = 'ADMIN';
export const userRoleKey = 'USER';

View File

@@ -0,0 +1,46 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
export enum UserRoleAssignmentSource {
Manual = 'MANUAL',
Oidc = 'OIDC',
System = 'SYSTEM',
}
@Entity('user_role_assignments')
@Unique('uq_user_role_assignments_source', ['userId', 'roleId', 'source'])
@Index('idx_user_role_assignments_user', ['userId'])
export class UserRoleAssignmentEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'user_id', type: 'char', length: 36 })
userId!: string;
@Column({ name: 'role_id', type: 'char', length: 36 })
roleId!: string;
@Column({ type: 'varchar', length: 20 })
source!: UserRoleAssignmentSource;
@Column({
name: 'last_synchronized_at',
type: 'datetime',
precision: 3,
nullable: true,
})
lastSynchronizedAt!: Date | null;
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 })
updatedAt!: Date;
}

View File

@@ -6,6 +6,7 @@ import type {
AdminUserSortField,
} from '../dto/user.dto';
import { UserEntity } from '../entities/user.entity';
import { adminRoleKey } from '../../roles/system-role-keys';
@Injectable()
export class UsersRepository {
@@ -115,7 +116,7 @@ export class UsersRepository {
.createQueryBuilder('user')
.innerJoin('user.roles', 'role')
.where('user.active = :active', { active: true })
.andWhere('role.name = :role', { role: 'admin' })
.andWhere('role.roleKey = :roleKey', { roleKey: adminRoleKey })
.orderBy('user.createdAt', 'ASC');
if (excludedUserId) {
qb.andWhere('user.id <> :excludedUserId', { excludedUserId });

View File

@@ -9,10 +9,15 @@ import { UserEntity } from './entities/user.entity';
import { UsersRepository } from './repositories/users.repository';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { UserRoleAssignmentEntity } from './entities/user-role-assignment.entity';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, UserSettingsEntity]),
TypeOrmModule.forFeature([
UserEntity,
UserSettingsEntity,
UserRoleAssignmentEntity,
]),
RolesModule,
AuditModule,
SessionsModule,

View File

@@ -9,11 +9,16 @@ import { ErrorCode } from '../common/errors/error-codes';
import { NotificationType } from '../notifications/notification-types';
import { NotificationsService } from '../notifications/notifications.service';
import { RolesService } from '../roles/roles.service';
import { adminRoleKey } from '../roles/system-role-keys';
import { RoleEntity } from '../roles/entities/role.entity';
import { SessionsService } from '../sessions/sessions.service';
import type { AdminSessionDto } from '../sessions/sessions.service';
import { UserSettingsEntity } from './entities/user-settings.entity';
import { UserEntity } from './entities/user.entity';
import {
UserRoleAssignmentEntity,
UserRoleAssignmentSource,
} from './entities/user-role-assignment.entity';
import type { AdminUserListQueryDto } from './dto/user.dto';
import { UsersRepository } from './repositories/users.repository';
@@ -22,6 +27,7 @@ interface AdminRoleSummary {
name: string;
description: string;
system: boolean;
sources: UserRoleAssignmentSource[];
}
interface AdminUserListItem {
@@ -63,7 +69,7 @@ export class UsersService {
const [users, total] = await this.users.adminSearch(query);
const items = await Promise.all(
users.map(async (user) => ({
...this.toAdminListItem(user),
...this.toAdminListItem(user, await this.roleSources(user.id)),
activeSessionCount: await this.sessions.countActiveForUser(user.id),
})),
);
@@ -76,7 +82,7 @@ export class UsersService {
): Promise<AdminUserDetail> {
const user = await this.getWithRoles(id);
return {
...this.toAdminListItem(user),
...this.toAdminListItem(user, await this.roleSources(user.id)),
activeSessionCount: await this.sessions.countActiveForUser(user.id),
effectivePermissions: this.effectivePermissions(user),
sessions: await this.sessions.listForAdmin(user.id, currentSessionId),
@@ -120,7 +126,7 @@ export class UsersService {
409,
);
}
if (!active && this.hasRole(user, 'admin')) {
if (!active && this.hasRole(user, adminRoleKey)) {
await this.assertAnotherActiveAdminRemains(userId, manager);
}
user.active = active;
@@ -151,14 +157,8 @@ export class UsersService {
userId: string,
roleId: string,
): Promise<UserEntity> {
const user = await this.getWithRoles(userId);
if (user.roles.some((role) => role.id === roleId)) {
return user;
}
return this.replaceRoles(actor, userId, [
...user.roles.map((role) => role.id),
roleId,
]);
const roleIds = await this.manualRoleIds(userId);
return this.replaceRoles(actor, userId, [...roleIds, roleId]);
}
async removeRole(
@@ -166,14 +166,11 @@ export class UsersService {
userId: string,
roleId: string,
): Promise<UserEntity> {
const user = await this.getWithRoles(userId);
if (!user.roles.some((role) => role.id === roleId)) {
return user;
}
const roleIds = await this.manualRoleIds(userId);
return this.replaceRoles(
actor,
userId,
user.roles.filter((role) => role.id !== roleId).map((role) => role.id),
roleIds.filter((id) => id !== roleId),
);
}
@@ -235,7 +232,7 @@ export class UsersService {
.innerJoin('user.roles', 'role')
.where('user.active = :active', { active: true })
.andWhere('user.id <> :excludedUserId', { excludedUserId })
.andWhere('role.name = :role', { role: 'admin' })
.andWhere('role.roleKey = :roleKey', { roleKey: adminRoleKey })
.getCount();
if (result < 1) {
throw new ApiError(
@@ -254,16 +251,58 @@ export class UsersService {
return this.withAdminLock(async (manager) => {
const user = await this.getWithRoles(userId, manager);
const previousRoleNames = new Set(user.roles.map((role) => role.name));
const previousRoleIds = new Set(user.roles.map((role) => role.id));
const roles = await Promise.all(
roleIds.map((id) => this.roles.getRole(id, manager)),
const previouslyAdmin = user.roles.some(
(role) => role.roleKey === adminRoleKey,
);
const nextRoleNames = new Set(roles.map((role) => role.name));
user.roles = roles;
if (previousRoleNames.has('admin') && !nextRoleNames.has('admin')) {
const assignmentRepo = manager.getRepository(UserRoleAssignmentEntity);
const previousAssignments = await assignmentRepo.findBy({
userId,
source: UserRoleAssignmentSource.Manual,
});
const previousRoleIds = new Set(
previousAssignments.map((entry) => entry.roleId),
);
const roles = await Promise.all(
[...new Set(roleIds)].map((id) => this.roles.getRole(id, manager)),
);
const nextManualIds = new Set(roles.map((role) => role.id));
for (const assignment of previousAssignments) {
if (!nextManualIds.has(assignment.roleId))
await assignmentRepo.remove(assignment);
}
for (const role of roles) {
if (!previousRoleIds.has(role.id)) {
await assignmentRepo.insert({
userId,
roleId: role.id,
source: UserRoleAssignmentSource.Manual,
lastSynchronizedAt: null,
});
}
}
const effectiveRows = await assignmentRepo
.createQueryBuilder('assignment')
.select('DISTINCT assignment.roleId', 'roleId')
.where('assignment.userId = :userId', { userId })
.getRawMany<{ roleId: string }>();
const effectiveRoles = await Promise.all(
effectiveRows.map((entry) => this.roles.getRole(entry.roleId, manager)),
);
const nextRoleNames = new Set(effectiveRoles.map((role) => role.name));
if (
previouslyAdmin &&
!effectiveRoles.some((role) => role.roleKey === adminRoleKey)
) {
await this.assertAnotherActiveAdminRemains(userId, manager);
}
const saved = await this.users.save(user, manager);
await manager.query('DELETE FROM user_roles WHERE user_id = ?', [userId]);
for (const role of effectiveRoles) {
await manager.query(
'INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)',
[userId, role.id],
);
}
const saved = await this.getWithRoles(user.id, manager);
await this.recordRoleAudit(actor, userId, previousRoleIds, roles);
await this.notifyRoleChanges(userId, previousRoleNames, nextRoleNames);
return saved;
@@ -333,7 +372,10 @@ export class UsersService {
});
}
private toAdminListItem(user: UserEntity): AdminUserListItem {
private toAdminListItem(
user: UserEntity,
sources: Map<string, UserRoleAssignmentSource[]>,
): AdminUserListItem {
return {
id: user.id,
name: user.name,
@@ -344,6 +386,7 @@ export class UsersService {
name: role.name,
description: role.description,
system: role.protected,
sources: sources.get(role.id) ?? [],
})),
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
createdAt: user.createdAt.toISOString(),
@@ -351,6 +394,34 @@ export class UsersService {
};
}
private async manualRoleIds(userId: string): Promise<string[]> {
const assignments = await this.dataSource
.getRepository(UserRoleAssignmentEntity)
.findBy({
userId,
source: UserRoleAssignmentSource.Manual,
});
return assignments.map((entry) => entry.roleId);
}
private async roleSources(
userId: string,
): Promise<Map<string, UserRoleAssignmentSource[]>> {
const assignments = await this.dataSource
.getRepository(UserRoleAssignmentEntity)
.findBy({
userId,
});
const result = new Map<string, UserRoleAssignmentSource[]>();
for (const assignment of assignments) {
result.set(assignment.roleId, [
...(result.get(assignment.roleId) ?? []),
assignment.source,
]);
}
return result;
}
private effectivePermissions(user: UserEntity): string[] {
return Array.from(
new Set(
@@ -361,8 +432,8 @@ export class UsersService {
).sort();
}
private hasRole(user: UserEntity, roleName: string): boolean {
return user.roles.some((role) => role.name === roleName);
private hasRole(user: UserEntity, roleKey: string): boolean {
return user.roles.some((role) => role.roleKey === roleKey);
}
private async notifyRoleChanges(