diff --git a/listify-api/.env.docker.example b/listify-api/.env.docker.example index 9eeedf3..9d56de9 100644 --- a/listify-api/.env.docker.example +++ b/listify-api/.env.docker.example @@ -18,11 +18,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret # Browser-URL, unter der der Container erreichbar ist. CLIENT_URL=http://localhost:8080 -OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify -OIDC_DISCOVERY_URL= +OIDC_ISSUER=https://id.example.com OIDC_CLIENT_ID=listify OIDC_CLIENT_SECRET= -OIDC_CALLBACK_URL=http://localhost:8080/auth/sso/callback +OIDC_SCOPES=openid profile email groups +OIDC_REDIRECT_URI=http://localhost:8080/auth/sso/callback +OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/login MISTRAL_API_KEY= MISTRAL_AGENT_ID= diff --git a/listify-api/.env.example b/listify-api/.env.example index 66f312b..64d760e 100644 --- a/listify-api/.env.example +++ b/listify-api/.env.example @@ -15,11 +15,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret CLIENT_URL=http://localhost:4200 -OIDC_ISSUER_URL=https://auth.forgecore.work/realms/Homelab/account -OIDC_DISCOVERY_URL= +OIDC_ISSUER=https://id.example.com OIDC_CLIENT_ID=listify OIDC_CLIENT_SECRET= -OIDC_CALLBACK_URL=http://localhost:4200/auth/sso/callback +OIDC_SCOPES=openid profile email groups +OIDC_REDIRECT_URI=http://localhost:4200/auth/sso/callback +OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/login MCP_ACCESS_TOKEN= diff --git a/listify-api/README.md b/listify-api/README.md index 7570f89..3cc327f 100644 --- a/listify-api/README.md +++ b/listify-api/README.md @@ -60,17 +60,21 @@ Configure the external MCP connector with `Authorization: Bearer $MCP_ACCESS_TOK Every Mistral response is stored in `assistant_chat_logs`. The table includes the sanitized provider request, the full raw provider response, the extracted assistant text sent back to the UI, response status and timing metadata. -## SSO mit Keycloak +## SSO mit OIDC -Listify nutzt OpenID Connect mit Authorization Code + PKCE. Bei Keycloak muss der Issuer immer auf den Realm zeigen, nicht nur auf die Basisdomain. +Listify nutzt OpenID Connect mit Authorization Code Flow und PKCE (`S256`). Die Discovery-URL wird automatisch aus dem Issuer gebildet: -### Keycloak Client +```text +{OIDC_ISSUER}/.well-known/openid-configuration +``` -1. In Keycloak im passenden Realm einen OpenID-Connect-Client fuer Listify anlegen, z. B. `listify`. -2. `Standard flow` aktivieren. PKCE mit `S256` erlauben oder erzwingen. -3. Scopes `openid`, `email` und `profile` verfuegbar machen. -4. Der User muss ein `email` Claim im ID Token erhalten. Ohne E-Mail lehnt Listify den Login ab. -5. Redirect URI fuer die Browser-URL eintragen: +### LDAP-Portal Client + +1. Im LDAP-Portal unter `/admin/oidc-clients` einen Client fuer Listify registrieren. +2. Authorization Code Flow mit PKCE aktivieren. Dynamic Client Registration wird nicht verwendet. +3. Scopes `openid profile email groups` erlauben. Fuer Refresh Tokens optional `offline_access` ergaenzen. +4. Der Client muss `sub`, `preferred_username`, `email`, `name`, `given_name`, `family_name` und bei Scope `groups` den Claim `groups` erhalten. +5. Redirect URI registrieren: ```text http://localhost:4200/auth/sso/callback @@ -84,30 +88,43 @@ http://localhost:8080/auth/sso/callback In Produktion muss hier die oeffentlich erreichbare Listify-URL stehen, z. B. `https://listify.example.com/auth/sso/callback`. +6. Post-Logout Redirect URI registrieren: + +```text +http://localhost:4200/login +``` + +Bei Docker/Reverse Proxy: + +```text +http://localhost:8080/login +``` + ### Listify Environment -Bei einem Keycloak-Realm `listify` unter `https://auth.forgecore.work`: - ```bash -OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify -OIDC_DISCOVERY_URL= -OIDC_CLIENT_ID=listify -OIDC_CLIENT_SECRET= -OIDC_CALLBACK_URL=http://localhost:4200/auth/sso/callback +OIDC_ISSUER=https://id.example.com +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_SCOPES=openid profile email groups +OIDC_REDIRECT_URI=http://localhost:4200/auth/sso/callback +OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/login CLIENT_URL=http://localhost:4200 ``` -Wenn dein Realm anders heisst, muss nur der Realm-Teil angepasst werden. Die Discovery-URL wird automatisch aus dem Issuer gebildet: +Das ID Token wird per JWKS validiert. Das Access Token des LDAP-Portals ist opaque; Listify validiert es ueber `/oidc/token/introspection` und ruft danach `/oidc/me` mit `Authorization: Bearer ` fuer UserInfo auf. -```text -https://auth.forgecore.work/realms//.well-known/openid-configuration -``` - -Nur falls Keycloak hinter einem Proxy eine abweichende Discovery-URL liefert oder du sie explizit setzen willst: +Wenn die Introspection-Antwort eine andere Access-Token-Audience als die Client-ID enthaelt, kann sie explizit gesetzt werden: ```bash -OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify -OIDC_DISCOVERY_URL=https://auth.forgecore.work/realms/listify/.well-known/openid-configuration +OIDC_ACCESS_TOKEN_AUDIENCE= +``` + +Gruppen werden aus dem Claim `groups` gelesen und lokal auf App-Rollen gemappt. Das Mapping ist zentral in `oidc_group_role_mappings` konfigurierbar, z. B.: + +```sql +INSERT INTO oidc_group_role_mappings (id, groupPath, role, enabled) + VALUES (UUID(), '/teams/admins', 'app_admin', 1); ``` ## Run tests diff --git a/listify-api/src/assistant/assistant-chat-log.entity.ts b/listify-api/src/assistant/assistant-chat-log.entity.ts index fb14893..d434e70 100644 --- a/listify-api/src/assistant/assistant-chat-log.entity.ts +++ b/listify-api/src/assistant/assistant-chat-log.entity.ts @@ -1,4 +1,10 @@ -import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, +} from 'typeorm'; @Entity('assistant_chat_logs') export class AssistantChatLogEntity { diff --git a/listify-api/src/assistant/assistant.controller.ts b/listify-api/src/assistant/assistant.controller.ts index 1553a45..df51212 100644 --- a/listify-api/src/assistant/assistant.controller.ts +++ b/listify-api/src/assistant/assistant.controller.ts @@ -8,16 +8,19 @@ import { UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/permissions.guard'; +import { RequirePermissions } from '../auth/require-permissions.decorator'; import { AssistantService } from './assistant.service'; import type { AuthenticatedRequest } from '../auth/auth.types'; import type { AssistantChatRequest } from './assistant.types'; @Controller('assistant') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, PermissionsGuard) export class AssistantController { constructor(private readonly assistantService: AssistantService) {} @Get('chat/logs') + @RequirePermissions('assistant.logs.view') listChatLogs(@Req() request: AuthenticatedRequest) { const userId = request.user?.sub; @@ -29,6 +32,7 @@ export class AssistantController { } @Post('chat') + @RequirePermissions('assistant.chat') chat( @Req() request: AuthenticatedRequest, @Body() body: AssistantChatRequest, diff --git a/listify-api/src/audit/audit-log.entity.ts b/listify-api/src/audit/audit-log.entity.ts index 64fd559..5c2cb67 100644 --- a/listify-api/src/audit/audit-log.entity.ts +++ b/listify-api/src/audit/audit-log.entity.ts @@ -1,4 +1,10 @@ -import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, +} from 'typeorm'; @Entity('audit_logs') export class AuditLogEntity { diff --git a/listify-api/src/audit/audit-log.service.ts b/listify-api/src/audit/audit-log.service.ts index c8e738d..a64be1c 100644 --- a/listify-api/src/audit/audit-log.service.ts +++ b/listify-api/src/audit/audit-log.service.ts @@ -5,7 +5,8 @@ import { Repository } from 'typeorm'; import { AuditLogEntity } from './audit-log.entity'; import type { AuditLogInput } from './audit-log.types'; -const SENSITIVE_KEY_PATTERN = /password|token|secret|authorization|cookie|hash/i; +const SENSITIVE_KEY_PATTERN = + /password|token|secret|authorization|cookie|hash/i; @Injectable() export class AuditLogService { @@ -60,7 +61,9 @@ export class AuditLogService { return Object.fromEntries( Object.entries(value as Record).map(([key, entry]) => [ key, - SENSITIVE_KEY_PATTERN.test(key) ? '[redacted]' : this.sanitizeNestedValue(entry), + SENSITIVE_KEY_PATTERN.test(key) + ? '[redacted]' + : this.sanitizeNestedValue(entry), ]), ); } diff --git a/listify-api/src/auth/app-permission.entity.ts b/listify-api/src/auth/app-permission.entity.ts new file mode 100644 index 0000000..0c269bd --- /dev/null +++ b/listify-api/src/auth/app-permission.entity.ts @@ -0,0 +1,20 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'; + +@Entity('app_permissions') +export class AppPermissionEntity { + @PrimaryColumn({ type: 'varchar', length: 120 }) + permission!: string; + + @Column({ type: 'varchar', length: 160 }) + label!: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + description?: string | null; + + @CreateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + }) + createdAt!: Date; +} diff --git a/listify-api/src/auth/app-role-permission.entity.ts b/listify-api/src/auth/app-role-permission.entity.ts new file mode 100644 index 0000000..2771b1d --- /dev/null +++ b/listify-api/src/auth/app-role-permission.entity.ts @@ -0,0 +1,31 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, +} from 'typeorm'; + +@Entity('app_role_permissions') +@Index('IDX_app_role_permissions_role_permission', ['role', 'permission'], { + unique: true, +}) +export class AppRolePermissionEntity { + @PrimaryColumn({ type: 'varchar', length: 36 }) + id!: string; + + @Index() + @Column({ type: 'varchar', length: 80 }) + role!: string; + + @Index() + @Column({ type: 'varchar', length: 120 }) + permission!: string; + + @CreateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + }) + createdAt!: Date; +} diff --git a/listify-api/src/auth/app-role.entity.ts b/listify-api/src/auth/app-role.entity.ts new file mode 100644 index 0000000..838f2ec --- /dev/null +++ b/listify-api/src/auth/app-role.entity.ts @@ -0,0 +1,20 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'; + +@Entity('app_roles') +export class AppRoleEntity { + @PrimaryColumn({ type: 'varchar', length: 80 }) + role!: string; + + @Column({ type: 'varchar', length: 160 }) + label!: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + description?: string | null; + + @CreateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + }) + createdAt!: Date; +} diff --git a/listify-api/src/auth/auth.controller.ts b/listify-api/src/auth/auth.controller.ts index 8adb93e..517ccb1 100644 --- a/listify-api/src/auth/auth.controller.ts +++ b/listify-api/src/auth/auth.controller.ts @@ -66,6 +66,10 @@ export class AuthController { user: JSON.stringify(authResponse.user), }); + if (authResponse.idToken) { + fragment.set('idToken', authResponse.idToken); + } + response.redirect(`${redirectUrl.toString()}#${fragment.toString()}`); } @@ -80,6 +84,12 @@ export class AuthController { return this.authService.refresh(refreshTokenDto); } + @Post('logout') + @HttpCode(HttpStatus.OK) + logout(@Body() body: { refreshToken?: string; idTokenHint?: string }) { + return this.authService.logout(body); + } + @Get('me') @UseGuards(JwtAuthGuard) me(@Req() request: AuthenticatedRequest) { diff --git a/listify-api/src/auth/auth.module.ts b/listify-api/src/auth/auth.module.ts index 749ff97..d61548b 100644 --- a/listify-api/src/auth/auth.module.ts +++ b/listify-api/src/auth/auth.module.ts @@ -2,13 +2,19 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuditModule } from '../audit/audit.module'; +import { AppPermissionEntity } from './app-permission.entity'; +import { AppRolePermissionEntity } from './app-role-permission.entity'; +import { AppRoleEntity } from './app-role.entity'; import { AuthController } from './auth.controller'; import { RefreshTokenEntity } from './refresh-token.entity'; import { AuthService } from './auth.service'; +import { AuthzSeedService } from './authz-seed.service'; +import { OidcGroupRoleMappingEntity } from './oidc-group-role-mapping.entity'; 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 { PermissionsGuard } from './permissions.guard'; +import { UserOidcGroupEntity } from './user-oidc-group.entity'; import { UserImpersonationEntity } from './user-impersonation.entity'; import { UserEntity } from './user.entity'; @@ -19,12 +25,23 @@ import { UserEntity } from './user.entity'; TypeOrmModule.forFeature([ UserEntity, RefreshTokenEntity, - UserKeycloakGroupEntity, + UserOidcGroupEntity, UserImpersonationEntity, + AppRoleEntity, + AppPermissionEntity, + AppRolePermissionEntity, + OidcGroupRoleMappingEntity, ]), ], controllers: [AuthController], - providers: [AuthService, OidcService, JwtAuthGuard, McpAuthGuard], - exports: [AuthService, JwtAuthGuard, McpAuthGuard], + providers: [ + AuthService, + AuthzSeedService, + OidcService, + JwtAuthGuard, + McpAuthGuard, + PermissionsGuard, + ], + exports: [AuthService, JwtAuthGuard, McpAuthGuard, PermissionsGuard], }) export class AuthModule {} diff --git a/listify-api/src/auth/auth.service.spec.ts b/listify-api/src/auth/auth.service.spec.ts index f6d17e1..5a820d4 100644 --- a/listify-api/src/auth/auth.service.spec.ts +++ b/listify-api/src/auth/auth.service.spec.ts @@ -2,11 +2,14 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { JwtModule, JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { AppRolePermissionEntity } from './app-role-permission.entity'; import { AuthTokenResponse, JwtTokenPayload } from './auth.types'; import { AuthService } from './auth.service'; +import { DEFAULT_APP_ROLE } from './authz.constants'; +import { OidcGroupRoleMappingEntity } from './oidc-group-role-mapping.entity'; import { OidcProfile, OidcService } from './oidc.service'; import { RefreshTokenEntity } from './refresh-token.entity'; -import { UserKeycloakGroupEntity } from './user-keycloak-group.entity'; +import { UserOidcGroupEntity } from './user-oidc-group.entity'; import { UserImpersonationEntity } from './user-impersonation.entity'; import { UserEntity } from './user.entity'; import { InMemoryRepository } from '../testing/in-memory-repository'; @@ -17,12 +20,16 @@ class FakeOidcService { email: 'User@Example.com', name: 'Test User', groups: [], + idToken: 'id-token', }; - createAuthorizationUrl = jest.fn( - async () => 'https://sso.example.test/authorize', + createAuthorizationUrl = jest.fn(() => + Promise.resolve('https://sso.example.test/authorize'), + ); + exchangeCallback = jest.fn(() => Promise.resolve(this.profile)); + createLogoutUrl = jest.fn(() => + Promise.resolve('https://sso.example.test/logout'), ); - exchangeCallback = jest.fn(async () => this.profile); } describe('AuthService', () => { @@ -31,16 +38,21 @@ describe('AuthService', () => { let jwtService: JwtService; let oidcService: FakeOidcService; let usersRepository: InMemoryRepository; - let userKeycloakGroupsRepository: InMemoryRepository; + let userOidcGroupsRepository: InMemoryRepository; let userImpersonationsRepository: InMemoryRepository; + let appRolePermissionsRepository: InMemoryRepository; + let oidcGroupRoleMappingsRepository: InMemoryRepository; beforeEach(async () => { oidcService = new FakeOidcService(); usersRepository = new InMemoryRepository(); - userKeycloakGroupsRepository = - new InMemoryRepository(); + userOidcGroupsRepository = new InMemoryRepository(); userImpersonationsRepository = new InMemoryRepository(); + appRolePermissionsRepository = + new InMemoryRepository(); + oidcGroupRoleMappingsRepository = + new InMemoryRepository(); module = await Test.createTestingModule({ imports: [EventEmitterModule.forRoot(), JwtModule.register({})], providers: [ @@ -58,19 +70,28 @@ describe('AuthService', () => { useValue: new InMemoryRepository(), }, { - provide: getRepositoryToken(UserKeycloakGroupEntity), - useValue: userKeycloakGroupsRepository, + provide: getRepositoryToken(UserOidcGroupEntity), + useValue: userOidcGroupsRepository, }, { provide: getRepositoryToken(UserImpersonationEntity), useValue: userImpersonationsRepository, }, + { + provide: getRepositoryToken(AppRolePermissionEntity), + useValue: appRolePermissionsRepository, + }, + { + provide: getRepositoryToken(OidcGroupRoleMappingEntity), + useValue: oidcGroupRoleMappingsRepository, + }, ], }).compile(); await module.init(); authService = module.get(AuthService); jwtService = module.get(JwtService); + await seedRolePermissions(); }); afterEach(async () => { @@ -91,6 +112,9 @@ describe('AuthService', () => { expect(loginResponse.refreshToken).toBeDefined(); expect(loginResponse.user.email).toBe('user@example.com'); expect(loginResponse.user.name).toBe('Test User'); + expect(loginResponse.user.roles).toEqual([DEFAULT_APP_ROLE]); + expect(loginResponse.user.permissions).toContain('assistant.chat'); + expect(loginResponse.user.permissions).not.toContain('assistant.logs.view'); expect(oidcService.exchangeCallback).toHaveBeenCalledWith('code', 'state'); }); @@ -101,6 +125,7 @@ describe('AuthService', () => { email: 'renamed@example.com', name: 'Renamed User', groups: [], + idToken: 'id-token', }; const secondLogin = await authService.completeSsoLogin('code', 'state'); @@ -117,6 +142,7 @@ describe('AuthService', () => { email: 'User@Example.com', name: 'Linked User', groups: [], + idToken: 'id-token', }; const secondLogin = await authService.completeSsoLogin('code', 'state'); @@ -150,7 +176,43 @@ describe('AuthService', () => { expect(refreshPayload.jti).toBeDefined(); }); - it('syncs Keycloak groups from the SSO profile', async () => { + it('revokes local refresh tokens and returns the OIDC logout URL', async () => { + const loginResponse = await authService.completeSsoLogin('code', 'state'); + const logoutResponse = await authService.logout({ + refreshToken: loginResponse.refreshToken, + idTokenHint: loginResponse.idToken, + }); + + expect(logoutResponse.logoutUrl).toBe('https://sso.example.test/logout'); + expect(oidcService.createLogoutUrl).toHaveBeenCalledWith('id-token'); + await expect( + authService.refresh({ refreshToken: loginResponse.refreshToken }), + ).rejects.toThrow('Refresh token is invalid.'); + }); + + it('maps synchronized OIDC groups to app roles and permissions', async () => { + oidcService.profile.groups = ['/teams/admins']; + await oidcGroupRoleMappingsRepository.save( + oidcGroupRoleMappingsRepository.create({ + id: 'mapping-admins-admin', + groupPath: '/teams/admins', + role: 'app_admin', + enabled: true, + }), + ); + + const loginResponse = await authService.completeSsoLogin('code', 'state'); + const payload = await authService.verifyAccessToken( + loginResponse.accessToken, + ); + + expect(loginResponse.user.roles).toEqual(['app_admin', 'app_user']); + expect(loginResponse.user.permissions).toContain('assistant.logs.view'); + expect(payload.roles).toEqual(['app_admin', 'app_user']); + expect(payload.permissions).toContain('assistant.logs.view'); + }); + + it('syncs OIDC groups from the SSO profile', async () => { oidcService.profile.groups = [ '/teams/engineering', '/teams/admins', @@ -169,13 +231,14 @@ describe('AuthService', () => { email: 'user@example.com', name: 'Test User', groups: ['/teams/support'], + idToken: 'id-token', }; const secondLoginResponse = await authService.completeSsoLogin( 'code', 'state', ); - const storedGroups = await userKeycloakGroupsRepository.find({ + const storedGroups = await userOidcGroupsRepository.find({ where: { userId: secondLoginResponse.user.id }, }); @@ -326,4 +389,36 @@ describe('AuthService', () => { }), )) as UserEntity; } + + async function seedRolePermissions(): Promise { + await appRolePermissionsRepository.save([ + rolePermission('rp-user-dashboard', 'app_user', 'dashboard.view'), + rolePermission('rp-user-lists', 'app_user', 'lists.manage_own'), + rolePermission('rp-user-templates', 'app_user', 'templates.manage_own'), + rolePermission('rp-user-tasks', 'app_user', 'tasks.manage_own'), + rolePermission('rp-user-assistant-chat', 'app_user', 'assistant.chat'), + rolePermission('rp-user-account', 'app_user', 'account.manage_self'), + rolePermission('rp-user-search', 'app_user', 'users.search'), + rolePermission('rp-admin-dashboard', 'app_admin', 'dashboard.view'), + rolePermission('rp-admin-lists', 'app_admin', 'lists.manage_own'), + rolePermission('rp-admin-templates', 'app_admin', 'templates.manage_own'), + rolePermission('rp-admin-tasks', 'app_admin', 'tasks.manage_own'), + rolePermission('rp-admin-assistant-chat', 'app_admin', 'assistant.chat'), + rolePermission( + 'rp-admin-assistant-logs', + 'app_admin', + 'assistant.logs.view', + ), + rolePermission('rp-admin-account', 'app_admin', 'account.manage_self'), + rolePermission('rp-admin-search', 'app_admin', 'users.search'), + ]); + } + + function rolePermission( + id: string, + role: string, + permission: string, + ): AppRolePermissionEntity { + return appRolePermissionsRepository.create({ id, role, permission }); + } }); diff --git a/listify-api/src/auth/auth.service.ts b/listify-api/src/auth/auth.service.ts index aa4d10a..5493083 100644 --- a/listify-api/src/auth/auth.service.ts +++ b/listify-api/src/auth/auth.service.ts @@ -8,26 +8,35 @@ import { import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto'; -import { Like, Repository } from 'typeorm'; +import { In, Like, Repository } from 'typeorm'; import { AuditLogService } from '../audit/audit-log.service'; +import { AppRolePermissionEntity } from './app-role-permission.entity'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; import { ResendVerificationDto } from './dto/resend-verification.dto'; import { + AuthLogoutResponse, AuthTokenResponse, AuthTokens, JwtTokenPayload, PublicUser, PublicUserSearchResult, } from './auth.types'; +import { DEFAULT_APP_ROLE } from './authz.constants'; import type { TaskDigestPreference } from '../tasks/task-digest.types'; +import { OidcGroupRoleMappingEntity } from './oidc-group-role-mapping.entity'; import { OidcProfile, OidcService } from './oidc.service'; import { RefreshTokenEntity } from './refresh-token.entity'; -import { UserKeycloakGroupEntity } from './user-keycloak-group.entity'; +import { UserOidcGroupEntity } from './user-oidc-group.entity'; import { UserImpersonationEntity } from './user-impersonation.entity'; import { UserEntity } from './user.entity'; +interface UserAuthorization { + roles: string[]; + permissions: string[]; +} + @Injectable() export class AuthService { private readonly accessTokenExpiresIn = '7d'; @@ -44,38 +53,46 @@ export class AuthService { private readonly usersRepository: Repository, @InjectRepository(RefreshTokenEntity) private readonly refreshTokensRepository: Repository, - @InjectRepository(UserKeycloakGroupEntity) - private readonly userKeycloakGroupsRepository: Repository, + @InjectRepository(UserOidcGroupEntity) + private readonly userOidcGroupsRepository: Repository, @InjectRepository(UserImpersonationEntity) private readonly userImpersonationsRepository: Repository, + @InjectRepository(AppRolePermissionEntity) + private readonly appRolePermissionsRepository: Repository, + @InjectRepository(OidcGroupRoleMappingEntity) + private readonly oidcGroupRoleMappingsRepository: Repository, @Optional() private readonly auditLogService?: AuditLogService, ) {} - async register( + register( registerDto: RegisterDto, ): Promise<{ message: string; user: PublicUser }> { void registerDto; - throw new GoneException('Registration is handled by the SSO provider.'); + return Promise.reject( + new GoneException('Registration is handled by the SSO provider.'), + ); } - async verifyEmail( - token?: string, - ): Promise<{ message: string; user: PublicUser }> { + verifyEmail(token?: string): Promise<{ message: string; user: PublicUser }> { void token; - throw new GoneException('Email verification is no longer required.'); + return Promise.reject( + new GoneException('Email verification is no longer required.'), + ); } - async resendVerificationEmail( + resendVerificationEmail( resendVerificationDto: ResendVerificationDto, ): Promise<{ message: string }> { void resendVerificationDto; - throw new GoneException('Email verification is no longer required.'); + return Promise.reject( + new GoneException('Email verification is no longer required.'), + ); } - async login(loginDto: LoginDto): Promise { + login(loginDto: LoginDto): Promise { void loginDto; - throw new GoneException('Login is handled by SSO.'); + return Promise.reject(new GoneException('Login is handled by SSO.')); } startSsoLogin(): Promise { @@ -86,7 +103,9 @@ export class AuthService { code?: string, state?: string, ): Promise { + console.log("completesso") const profile = await this.oidcService.exchangeCallback(code, state); + console.log(profile) const existingUser = (await this.usersRepository.findOne({ where: { oidcSubject: profile.subject }, @@ -95,10 +114,11 @@ export class AuthService { where: { email: this.normalizeEmail(profile.email) }, })); const user = await this.syncOidcUser(profile, existingUser); - await this.syncKeycloakGroups(user.id, profile.groups); + await this.syncOidcGroups(user.id, profile.groups); const response = { ...(await this.createAuthTokens(user)), - user: await this.toPublicUserWithGroups( + idToken: profile.idToken, + user: await this.toPublicUserWithAuthorization( await this.resolveEffectiveUser(user), ), }; @@ -115,6 +135,19 @@ export class AuthService { return response; } + async logout( + body: { + refreshToken?: string; + idTokenHint?: string; + } = {}, + ): Promise { + await this.revokeRefreshToken(body.refreshToken); + + return { + logoutUrl: await this.oidcService.createLogoutUrl(body.idTokenHint), + }; + } + async refresh( refreshTokenDto: RefreshTokenDto = {}, ): Promise { @@ -145,7 +178,7 @@ export class AuthService { const response = { ...(await this.createAuthTokens(user)), - user: await this.toPublicUserWithGroups( + user: await this.toPublicUserWithAuthorization( await this.resolveEffectiveUser(user), ), }; @@ -180,15 +213,23 @@ export class AuthService { } const effectiveUser = await this.resolveEffectiveUser(user); - - if (effectiveUser.id === user.id) { - return payload; - } - - return { + const authorization = await this.resolveUserAuthorization( + effectiveUser.id, + ); + const authenticatedPayload: JwtTokenPayload = { ...payload, sub: effectiveUser.id, email: effectiveUser.email, + roles: authorization.roles, + permissions: authorization.permissions, + }; + + if (effectiveUser.id === user.id) { + return authenticatedPayload; + } + + return { + ...authenticatedPayload, impersonatorSub: user.id, impersonatorEmail: user.email, }; @@ -218,7 +259,7 @@ export class AuthService { throw new UnauthorizedException('Authenticated user is required.'); } - return this.toPublicUserWithGroups(user); + return this.toPublicUserWithAuthorization(user); } async searchUsers( @@ -278,7 +319,7 @@ export class AuthService { metadata: { completed }, }); - return this.toPublicUserWithGroups(savedUser); + return this.toPublicUserWithAuthorization(savedUser); } async updateTaskDigestPreference( @@ -306,7 +347,7 @@ export class AuthService { metadata: { taskDigestPreference: savedUser.taskDigestPreference }, }); - return this.toPublicUserWithGroups(savedUser); + return this.toPublicUserWithAuthorization(savedUser); } private normalizeEmail(email?: string): string { @@ -382,21 +423,21 @@ export class AuthService { return targetUser; } - private async syncKeycloakGroups( + private async syncOidcGroups( userId: string, groups: string[], ): Promise { const normalizedGroups = this.normalizeGroupPaths(groups); - await this.userKeycloakGroupsRepository.delete({ userId }); + await this.userOidcGroupsRepository.delete({ userId }); if (!normalizedGroups.length) { return; } - await this.userKeycloakGroupsRepository.save( + await this.userOidcGroupsRepository.save( normalizedGroups.map((groupPath) => - this.userKeycloakGroupsRepository.create({ + this.userOidcGroupsRepository.create({ id: randomUUID(), userId, groupPath, @@ -418,7 +459,7 @@ export class AuthService { } private async getUserGroupPaths(userId: string): Promise { - const groups = await this.userKeycloakGroupsRepository.find({ + const groups = await this.userOidcGroupsRepository.find({ where: { userId }, }); @@ -427,6 +468,36 @@ export class AuthService { .sort((left, right) => left.localeCompare(right)); } + private async resolveUserAuthorization( + userId: string, + ): Promise { + const groupPaths = await this.getUserGroupPaths(userId); + const mappedRoles = groupPaths.length + ? await this.oidcGroupRoleMappingsRepository.find({ + where: { groupPath: In(groupPaths), enabled: true }, + }) + : []; + const roles = this.sortUnique([ + DEFAULT_APP_ROLE, + ...mappedRoles.map((mapping) => mapping.role), + ]); + const rolePermissions = await this.appRolePermissionsRepository.find({ + where: { role: In(roles) }, + }); + const permissions = this.sortUnique( + rolePermissions.map((rolePermission) => rolePermission.permission), + ); + + return { roles, permissions }; + } + + private sortUnique(values: string[]): string[] { + return [...new Set(values)] + .map((value) => value.trim()) + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); + } + private secretMatches(secret: string, storedSecretHash: string): boolean { const [salt, storedHash] = storedSecretHash.split(':'); @@ -503,6 +574,19 @@ export class AuthService { return refreshToken; } + private async revokeRefreshToken(refreshToken?: string): Promise { + if (!refreshToken) { + return; + } + + try { + const payload = this.verifyRefreshToken(refreshToken); + await this.refreshTokensRepository.delete({ jti: payload.jti }); + } catch { + return; + } + } + private hashToken(token: string): string { const salt = randomBytes(16).toString('hex'); const hash = scryptSync(token, salt, 64).toString('hex'); @@ -513,11 +597,28 @@ export class AuthService { return this.secretMatches(token, tokenHash); } - private async toPublicUserWithGroups(user: UserEntity): Promise { - return this.toPublicUser(user, await this.getUserGroupPaths(user.id)); + private async toPublicUserWithAuthorization( + user: UserEntity, + ): Promise { + const [groups, authorization] = await Promise.all([ + this.getUserGroupPaths(user.id), + this.resolveUserAuthorization(user.id), + ]); + + return this.toPublicUser( + user, + groups, + authorization.roles, + authorization.permissions, + ); } - private toPublicUser(user: UserEntity, groups: string[] = []): PublicUser { + private toPublicUser( + user: UserEntity, + groups: string[] = [], + roles: string[] = [], + permissions: string[] = [], + ): PublicUser { return { id: user.id, email: user.email, @@ -525,6 +626,8 @@ export class AuthService { onboardingCompleted: user.onboardingCompleted === true, taskDigestPreference: user.taskDigestPreference ?? 'both', groups, + roles, + permissions, }; } } diff --git a/listify-api/src/auth/auth.types.ts b/listify-api/src/auth/auth.types.ts index 0777638..61bb1fd 100644 --- a/listify-api/src/auth/auth.types.ts +++ b/listify-api/src/auth/auth.types.ts @@ -8,6 +8,11 @@ export interface AuthTokens { export interface AuthTokenResponse extends AuthTokens { user: PublicUser; + idToken?: string; +} + +export interface AuthLogoutResponse { + logoutUrl: string; } export interface JwtTokenPayload { @@ -17,6 +22,8 @@ export interface JwtTokenPayload { jti?: string; impersonatorSub?: string; impersonatorEmail?: string; + roles?: string[]; + permissions?: string[]; } export interface AuthenticatedRequest extends Request { @@ -30,6 +37,8 @@ export interface PublicUser { onboardingCompleted: boolean; taskDigestPreference: TaskDigestPreference; groups: string[]; + roles: string[]; + permissions: string[]; } export interface PublicUserSearchResult { diff --git a/listify-api/src/auth/authz-seed.service.ts b/listify-api/src/auth/authz-seed.service.ts new file mode 100644 index 0000000..e721b95 --- /dev/null +++ b/listify-api/src/auth/authz-seed.service.ts @@ -0,0 +1,195 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AppPermissionEntity } from './app-permission.entity'; +import { AppRolePermissionEntity } from './app-role-permission.entity'; +import { AppRoleEntity } from './app-role.entity'; + +@Injectable() +export class AuthzSeedService implements OnModuleInit { + constructor( + @InjectRepository(AppRoleEntity) + private readonly appRolesRepository: Repository, + @InjectRepository(AppPermissionEntity) + private readonly appPermissionsRepository: Repository, + @InjectRepository(AppRolePermissionEntity) + private readonly appRolePermissionsRepository: Repository, + ) {} + + async onModuleInit(): Promise { + await this.appRolesRepository.save([ + this.appRolesRepository.create({ + role: 'app_user', + label: 'User', + description: 'Basiszugriff fuer angemeldete Benutzer', + }), + this.appRolesRepository.create({ + role: 'app_admin', + label: 'Admin', + description: 'Voller App-Zugriff inklusive Diagnosefunktionen', + }), + ]); + + await this.appPermissionsRepository.save([ + permission( + this.appPermissionsRepository, + 'dashboard.view', + 'Dashboard ansehen', + 'Dashboard lesen', + ), + permission( + this.appPermissionsRepository, + 'lists.manage_own', + 'Eigene Listen verwalten', + 'Eigene und geteilte Listen nutzen', + ), + permission( + this.appPermissionsRepository, + 'templates.manage_own', + 'Eigene Templates verwalten', + 'Eigene und geteilte Templates nutzen', + ), + permission( + this.appPermissionsRepository, + 'tasks.manage_own', + 'Eigene Tasks verwalten', + 'Eigene Tasks nutzen', + ), + permission( + this.appPermissionsRepository, + 'assistant.chat', + 'Assistant Chat nutzen', + 'Assistant Chat verwenden', + ), + permission( + this.appPermissionsRepository, + 'assistant.logs.view', + 'Assistant Logs ansehen', + 'Assistant Chat Logs ansehen', + ), + permission( + this.appPermissionsRepository, + 'account.manage_self', + 'Eigenes Konto verwalten', + 'Eigene Konto-Einstellungen verwalten', + ), + permission( + this.appPermissionsRepository, + 'users.search', + 'Benutzer suchen', + 'Benutzer fuer Freigaben suchen', + ), + ]); + + await this.appRolePermissionsRepository.save([ + rolePermission( + this.appRolePermissionsRepository, + 'rp-user-dashboard', + 'app_user', + 'dashboard.view', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-user-lists', + 'app_user', + 'lists.manage_own', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-user-templates', + 'app_user', + 'templates.manage_own', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-user-tasks', + 'app_user', + 'tasks.manage_own', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-user-assistant-chat', + 'app_user', + 'assistant.chat', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-user-account', + 'app_user', + 'account.manage_self', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-user-search', + 'app_user', + 'users.search', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-admin-dashboard', + 'app_admin', + 'dashboard.view', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-admin-lists', + 'app_admin', + 'lists.manage_own', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-admin-templates', + 'app_admin', + 'templates.manage_own', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-admin-tasks', + 'app_admin', + 'tasks.manage_own', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-admin-assistant-chat', + 'app_admin', + 'assistant.chat', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-admin-assistant-logs', + 'app_admin', + 'assistant.logs.view', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-admin-account', + 'app_admin', + 'account.manage_self', + ), + rolePermission( + this.appRolePermissionsRepository, + 'rp-admin-search', + 'app_admin', + 'users.search', + ), + ]); + } +} + +function permission( + repository: Repository, + value: string, + label: string, + description: string, +): AppPermissionEntity { + return repository.create({ permission: value, label, description }); +} + +function rolePermission( + repository: Repository, + id: string, + role: string, + permissionValue: string, +): AppRolePermissionEntity { + return repository.create({ id, role, permission: permissionValue }); +} diff --git a/listify-api/src/auth/authz.constants.ts b/listify-api/src/auth/authz.constants.ts new file mode 100644 index 0000000..e466522 --- /dev/null +++ b/listify-api/src/auth/authz.constants.ts @@ -0,0 +1,18 @@ +export const DEFAULT_APP_ROLE = 'app_user'; + +export const APP_ROLES = ['app_user', 'app_admin'] as const; + +export type AppRole = (typeof APP_ROLES)[number] | string; + +export const APP_PERMISSIONS = [ + 'dashboard.view', + 'lists.manage_own', + 'templates.manage_own', + 'tasks.manage_own', + 'assistant.chat', + 'assistant.logs.view', + 'account.manage_self', + 'users.search', +] as const; + +export type AppPermission = (typeof APP_PERMISSIONS)[number] | string; diff --git a/listify-api/src/auth/jwt-auth.guard.spec.ts b/listify-api/src/auth/jwt-auth.guard.spec.ts index 7f532e2..aa521c5 100644 --- a/listify-api/src/auth/jwt-auth.guard.spec.ts +++ b/listify-api/src/auth/jwt-auth.guard.spec.ts @@ -3,12 +3,14 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { JwtModule } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { AppRolePermissionEntity } from './app-role-permission.entity'; import { AuthService } from './auth.service'; import { AuthenticatedRequest } from './auth.types'; +import { OidcGroupRoleMappingEntity } from './oidc-group-role-mapping.entity'; 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 { UserOidcGroupEntity } from './user-oidc-group.entity'; import { UserImpersonationEntity } from './user-impersonation.entity'; import { UserEntity } from './user.entity'; import { InMemoryRepository } from '../testing/in-memory-repository'; @@ -27,15 +29,21 @@ describe('JwtAuthGuard', () => { { provide: OidcService, useValue: { - createAuthorizationUrl: jest.fn( - async () => 'https://sso.example.test/authorize', + createAuthorizationUrl: jest.fn(() => + Promise.resolve('https://sso.example.test/authorize'), + ), + exchangeCallback: jest.fn(() => + Promise.resolve({ + subject: 'oidc-user-1', + email: 'user@example.com', + name: 'Test User', + groups: [], + idToken: 'id-token', + }), + ), + createLogoutUrl: jest.fn(() => + Promise.resolve('https://sso.example.test/logout'), ), - exchangeCallback: jest.fn(async () => ({ - subject: 'oidc-user-1', - email: 'user@example.com', - name: 'Test User', - groups: [], - })), }, }, { @@ -47,13 +55,21 @@ describe('JwtAuthGuard', () => { useValue: new InMemoryRepository(), }, { - provide: getRepositoryToken(UserKeycloakGroupEntity), - useValue: new InMemoryRepository(), + provide: getRepositoryToken(UserOidcGroupEntity), + useValue: new InMemoryRepository(), }, { provide: getRepositoryToken(UserImpersonationEntity), useValue: new InMemoryRepository(), }, + { + provide: getRepositoryToken(AppRolePermissionEntity), + useValue: new InMemoryRepository(), + }, + { + provide: getRepositoryToken(OidcGroupRoleMappingEntity), + useValue: new InMemoryRepository(), + }, ], }).compile(); await module.init(); diff --git a/listify-api/src/auth/oidc-group-role-mapping.entity.ts b/listify-api/src/auth/oidc-group-role-mapping.entity.ts new file mode 100644 index 0000000..715c8e1 --- /dev/null +++ b/listify-api/src/auth/oidc-group-role-mapping.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('oidc_group_role_mappings') +@Index('IDX_oidc_group_role_mappings_group_role', ['groupPath', 'role'], { + unique: true, +}) +export class OidcGroupRoleMappingEntity { + @PrimaryColumn({ type: 'varchar', length: 36 }) + id!: string; + + @Index() + @Column({ type: 'varchar', length: 255 }) + groupPath!: string; + + @Index() + @Column({ type: 'varchar', length: 80 }) + role!: string; + + @Column({ type: 'boolean', default: true }) + enabled!: boolean; + + @Column({ type: 'varchar', length: 255, nullable: true }) + reason?: string | 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; +} diff --git a/listify-api/src/auth/oidc.service.spec.ts b/listify-api/src/auth/oidc.service.spec.ts new file mode 100644 index 0000000..f9eaddc --- /dev/null +++ b/listify-api/src/auth/oidc.service.spec.ts @@ -0,0 +1,250 @@ +import { OidcService } from './oidc.service'; + +type TokenIntrospectionMock = Record & { + active?: boolean; + iss?: string; + aud?: string | string[]; + sub?: string; +}; + +describe('OidcService', () => { + const issuer = 'https://id.example.test'; + const clientId = 'listify'; + const redirectUri = 'http://localhost:4200/auth/sso/callback'; + const postLogoutRedirectUri = 'http://localhost:4200/login'; + const idToken = 'id-token'; + const accessToken = 'access-token'; + const idTokenHint = 'id-token-hint'; + let service: OidcService; + let fetchMock: jest.Mock; + let jwtVerify: jest.Mock; + let jwks: object; + + beforeEach(() => { + process.env.OIDC_ISSUER = issuer; + process.env.OIDC_CLIENT_ID = clientId; + process.env.OIDC_CLIENT_SECRET = 'client-secret'; + process.env.OIDC_SCOPES = 'openid profile email groups'; + process.env.OIDC_REDIRECT_URI = redirectUri; + process.env.OIDC_POST_LOGOUT_REDIRECT_URI = postLogoutRedirectUri; + process.env.OIDC_ACCESS_TOKEN_AUDIENCE = clientId; + + service = new OidcService(); + fetchMock = jest.fn(); + jwtVerify = jest.fn(); + jwks = {}; + global.fetch = fetchMock; + jest + .spyOn( + service as unknown as { getJose: () => Promise }, + 'getJose', + ) + .mockResolvedValue({ jwtVerify }); + jest + .spyOn( + service as unknown as { getJwks: () => Promise }, + 'getJwks', + ) + .mockResolvedValue(jwks); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete process.env.OIDC_ISSUER; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_CLIENT_SECRET; + delete process.env.OIDC_SCOPES; + delete process.env.OIDC_REDIRECT_URI; + delete process.env.OIDC_POST_LOGOUT_REDIRECT_URI; + delete process.env.OIDC_ACCESS_TOKEN_AUDIENCE; + }); + + it('creates a discovery based authorization URL with PKCE', async () => { + mockDiscovery(); + + const authorizationUrl = new URL(await service.createAuthorizationUrl()); + + expect(authorizationUrl.origin + authorizationUrl.pathname).toBe( + `${issuer}/oidc/auth`, + ); + expect(authorizationUrl.searchParams.get('response_type')).toBe('code'); + expect(authorizationUrl.searchParams.get('client_id')).toBe(clientId); + expect(authorizationUrl.searchParams.get('redirect_uri')).toBe(redirectUri); + expect(authorizationUrl.searchParams.get('scope')).toBe( + 'openid profile email groups', + ); + expect(authorizationUrl.searchParams.get('code_challenge')).toBeTruthy(); + expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe( + 'S256', + ); + }); + + it('validates the ID token through JWKS, introspects the opaque access token, and maps groups', async () => { + mockDiscovery(); + const authorizationUrl = new URL(await service.createAuthorizationUrl()); + const state = authorizationUrl.searchParams.get('state') ?? ''; + const nonce = authorizationUrl.searchParams.get('nonce') ?? ''; + mockTokenResponse(); + jwtVerify.mockResolvedValueOnce({ + payload: { + sub: 'user-1', + nonce, + email: 'user@example.test', + preferred_username: 'u.test', + given_name: 'Test', + family_name: 'User', + name: 'Test User', + }, + }); + + const profile = await service.exchangeCallback('code', state); + + expect(jwtVerify).toHaveBeenCalledTimes(1); + expect(jwtVerify).toHaveBeenCalledWith(idToken, jwks, { + issuer, + audience: clientId, + }); + expect(fetchMock).toHaveBeenCalledWith( + `${issuer}/oidc/token/introspection`, + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }), + ); + expect(profile).toEqual({ + subject: 'user-1', + email: 'user@example.test', + name: 'Test User', + preferredUsername: 'u.test', + givenName: 'Test', + familyName: 'User', + groups: ['/teams/admins', '/teams/engineering'], + idToken, + }); + }); + + it('rejects opaque access tokens with a wrong audience from introspection', async () => { + mockDiscovery(); + const authorizationUrl = new URL(await service.createAuthorizationUrl()); + const state = authorizationUrl.searchParams.get('state') ?? ''; + const nonce = authorizationUrl.searchParams.get('nonce') ?? ''; + mockTokenResponse({ active: true, iss: issuer, aud: 'other-audience' }); + jwtVerify.mockResolvedValueOnce({ + payload: { + sub: 'user-1', + nonce, + email: 'user@example.test', + }, + }); + + await expect(service.exchangeCallback('code', state)).rejects.toThrow( + 'OIDC access token audience is invalid.', + ); + }); + + it('creates provider logout URLs with id token hint', async () => { + mockDiscovery(); + + const logoutUrl = new URL(await service.createLogoutUrl(idTokenHint)); + + expect(logoutUrl.origin + logoutUrl.pathname).toBe( + `${issuer}/oidc/session/end`, + ); + expect(logoutUrl.searchParams.get('id_token_hint')).toBe(idTokenHint); + expect(logoutUrl.searchParams.get('post_logout_redirect_uri')).toBe( + postLogoutRedirectUri, + ); + }); + + function mockDiscovery(): void { + fetchMock.mockImplementation((input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url === `${issuer}/.well-known/openid-configuration`) { + return Promise.resolve(jsonResponse(discoveryPayload())); + } + + if (url === `${issuer}/oidc/me`) { + return Promise.resolve( + jsonResponse({ + groups: ['/teams/engineering', '/teams/admins'], + }), + ); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + } + + function mockTokenResponse( + introspectionPayload: TokenIntrospectionMock = { + active: true, + iss: issuer, + aud: clientId, + sub: 'user-1', + }, + ): void { + fetchMock.mockImplementation((input: RequestInfo | URL) => { + const url = requestUrl(input); + + if (url === `${issuer}/.well-known/openid-configuration`) { + return Promise.resolve(jsonResponse(discoveryPayload())); + } + + if (url === `${issuer}/oidc/token`) { + return Promise.resolve( + jsonResponse({ + id_token: idToken, + access_token: accessToken, + }), + ); + } + + if (url === `${issuer}/oidc/token/introspection`) { + return Promise.resolve(jsonResponse(introspectionPayload)); + } + + if (url === `${issuer}/oidc/me`) { + return Promise.resolve( + jsonResponse({ + sub: 'user-1', + groups: ['/teams/engineering', '/teams/admins'], + }), + ); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + } + + function discoveryPayload(): Record { + return { + issuer, + authorization_endpoint: `${issuer}/oidc/auth`, + token_endpoint: `${issuer}/oidc/token`, + introspection_endpoint: `${issuer}/oidc/token/introspection`, + userinfo_endpoint: `${issuer}/oidc/me`, + jwks_uri: `${issuer}/oidc/jwks`, + end_session_endpoint: `${issuer}/oidc/session/end`, + }; + } + + function requestUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') { + return input; + } + + if (input instanceof URL) { + return input.toString(); + } + + return input.url; + } + + function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } +}); diff --git a/listify-api/src/auth/oidc.service.ts b/listify-api/src/auth/oidc.service.ts index 962b1a4..83d7c99 100644 --- a/listify-api/src/auth/oidc.service.ts +++ b/listify-api/src/auth/oidc.service.ts @@ -4,6 +4,7 @@ import { ServiceUnavailableException, } from '@nestjs/common'; import { createHash, randomBytes } from 'crypto'; +import type { JWTPayload } from 'jose'; type JoseModule = typeof import('jose'); type RemoteJwkSet = ReturnType; @@ -12,15 +13,21 @@ export interface OidcProfile { subject: string; email: string; name?: string; + preferredUsername?: string; + givenName?: string; + familyName?: string; groups: string[]; + idToken: string; } interface OidcDiscovery { authorization_endpoint: string; token_endpoint: string; + introspection_endpoint?: string; userinfo_endpoint?: string; jwks_uri: string; issuer: string; + end_session_endpoint?: string; } interface PendingOidcState { @@ -36,6 +43,15 @@ interface TokenResponse { error_description?: string; } +interface TokenIntrospectionResponse { + active?: boolean; + sub?: string; + iss?: string; + aud?: string | string[]; + error?: string; + error_description?: string; +} + @Injectable() export class OidcService { private readonly pendingStates = new Map(); @@ -45,7 +61,7 @@ export class OidcService { async createAuthorizationUrl(): Promise { const config = this.getConfig(); - const discovery = await this.getDiscovery(config.discoveryUrl); + const discovery = await this.getDiscovery(config); const state = this.createOpaqueToken(); const nonce = this.createOpaqueToken(); const codeVerifier = this.createOpaqueToken(); @@ -61,8 +77,8 @@ 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', config.scope); + authorizationUrl.searchParams.set('redirect_uri', config.redirectUri); + authorizationUrl.searchParams.set('scope', config.scopes); authorizationUrl.searchParams.set('state', state); authorizationUrl.searchParams.set('nonce', nonce); authorizationUrl.searchParams.set('code_challenge', codeChallenge); @@ -84,7 +100,7 @@ export class OidcService { } const config = this.getConfig(); - const discovery = await this.getDiscovery(config.discoveryUrl); + const discovery = await this.getDiscovery(config); const tokenResponse = await this.requestTokens( discovery, config, @@ -92,12 +108,11 @@ export class OidcService { pendingState.codeVerifier, ); - console.log(tokenResponse) - if (!tokenResponse.id_token) { + if (!tokenResponse.id_token || !tokenResponse.access_token) { throw new ServiceUnavailableException( tokenResponse.error_description ?? tokenResponse.error ?? - 'OIDC token response did not include an ID token.', + 'OIDC token response did not include the required tokens.', ); } @@ -105,41 +120,85 @@ export class OidcService { this.getJose(), this.getJwks(discovery.jwks_uri), ]); - const { payload } = await jwtVerify(tokenResponse.id_token, jwks, { - issuer: discovery.issuer, - audience: config.clientId, - }); + const { payload: idTokenPayload } = await jwtVerify( + tokenResponse.id_token, + jwks, + { + issuer: discovery.issuer, + audience: config.clientId, + }, + ); - if (payload.nonce !== pendingState.nonce) { + if (idTokenPayload.nonce !== pendingState.nonce) { throw new BadRequestException('OIDC nonce is invalid.'); } - if (!payload.sub || typeof payload.sub !== 'string') { + if (!idTokenPayload.sub || typeof idTokenPayload.sub !== 'string') { throw new BadRequestException('OIDC subject is missing.'); } - const email = typeof payload.email === 'string' ? payload.email : undefined; + await this.introspectAccessToken( + discovery, + config, + tokenResponse.access_token, + idTokenPayload.sub, + ); + + const userInfo = await this.requestUserInfo( + discovery.userinfo_endpoint, + tokenResponse.access_token, + ); + this.validateUserInfoSubject(userInfo, idTokenPayload.sub); + + const mergedClaims = { ...idTokenPayload, ...userInfo }; + const email = this.stringClaim(mergedClaims, 'email'); if (!email) { throw new BadRequestException('OIDC email claim is missing.'); } - const idTokenGroups = this.extractGroups(payload, config.groupsClaim); + const groups = this.extractGroups(mergedClaims, config.groupsClaim); + const givenName = this.stringClaim(mergedClaims, 'given_name'); + const familyName = this.stringClaim(mergedClaims, 'family_name'); + const preferredUsername = this.stringClaim( + mergedClaims, + 'preferred_username', + ); return { - subject: payload.sub, + subject: idTokenPayload.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, - ), + name: this.displayName(mergedClaims, preferredUsername), + preferredUsername, + givenName, + familyName, + groups, + idToken: tokenResponse.id_token, }; } + async createLogoutUrl(idTokenHint?: string): Promise { + const config = this.getConfig(); + const discovery = await this.getDiscovery(config); + const logoutUrl = new URL( + discovery.end_session_endpoint ?? + `${config.issuer.replace(/\/$/, '')}/oidc/session/end`, + ); + + if (idTokenHint) { + logoutUrl.searchParams.set('id_token_hint', idTokenHint); + } + + if (config.postLogoutRedirectUri) { + logoutUrl.searchParams.set( + 'post_logout_redirect_uri', + config.postLogoutRedirectUri, + ); + } + + return logoutUrl.toString(); + } + private async requestTokens( discovery: OidcDiscovery, config: ReturnType, @@ -149,14 +208,12 @@ export class OidcService { const body = new URLSearchParams({ grant_type: 'authorization_code', code, - redirect_uri: config.callbackUrl, + redirect_uri: config.redirectUri, client_id: config.clientId, code_verifier: codeVerifier, }); - if (config.clientSecret) { - body.set('client_secret', config.clientSecret); - } + this.addClientAuthentication(body, config); const response = await fetch(discovery.token_endpoint, { method: 'POST', @@ -176,18 +233,81 @@ export class OidcService { return payload; } - private async getDiscovery(discoveryUrl: string): Promise { + private async introspectAccessToken( + discovery: OidcDiscovery, + config: ReturnType, + accessToken: string, + expectedSubject: string, + ): Promise { + const body = new URLSearchParams({ + token: accessToken, + token_type_hint: 'access_token', + }); + this.addClientAuthentication(body, config); + + const response = await fetch( + discovery.introspection_endpoint ?? + `${config.issuer}/oidc/token/introspection`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }, + ); + const payload = (await response + .json() + .catch(() => ({}))) as TokenIntrospectionResponse; + + if (!response.ok) { + throw new ServiceUnavailableException( + payload.error_description ?? + payload.error ?? + 'OIDC token introspection failed.', + ); + } + + if (payload.active !== true) { + throw new BadRequestException('OIDC access token is inactive.'); + } + + if (payload.iss && this.normalizeIssuer(payload.iss) !== config.issuer) { + throw new BadRequestException('OIDC access token issuer is invalid.'); + } + + if (payload.sub && payload.sub !== expectedSubject) { + throw new BadRequestException('OIDC access token subject is invalid.'); + } + + if ( + payload.aud && + !this.audienceIncludes(payload.aud, config.accessTokenAudience) + ) { + throw new BadRequestException('OIDC access token audience is invalid.'); + } + } + + private async getDiscovery( + config: ReturnType, + ): Promise { if (this.discovery) { return this.discovery; } - const response = await fetch(discoveryUrl); + const response = await fetch(config.discoveryUrl); if (!response.ok) { throw new ServiceUnavailableException('OIDC discovery failed.'); } - this.discovery = (await response.json()) as OidcDiscovery; + const discovery = (await response.json()) as OidcDiscovery; + + if (this.normalizeIssuer(discovery.issuer) !== config.issuer) { + throw new ServiceUnavailableException( + 'OIDC discovery issuer does not match OIDC_ISSUER.', + ); + } + + this.discovery = discovery; return this.discovery; } @@ -204,37 +324,38 @@ export class OidcService { } private getConfig() { - const issuerUrl = process.env.OIDC_ISSUER_URL; - const explicitDiscoveryUrl = process.env.OIDC_DISCOVERY_URL; + const issuer = this.normalizeIssuer( + process.env.OIDC_ISSUER ?? process.env.OIDC_ISSUER_URL, + ); const clientId = process.env.OIDC_CLIENT_ID; - const callbackUrl = process.env.OIDC_CALLBACK_URL; + const redirectUri = + process.env.OIDC_REDIRECT_URI ?? process.env.OIDC_CALLBACK_URL; - if (!issuerUrl || !clientId || !callbackUrl) { + if (!issuer || !clientId || !redirectUri) { throw new ServiceUnavailableException( - 'OIDC configuration is incomplete.', + 'OIDC configuration is incomplete. Required: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI.', ); } return { - issuerUrl, - discoveryUrl: - explicitDiscoveryUrl ?? - `${issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`, + issuer, + discoveryUrl: `${issuer}/.well-known/openid-configuration`, clientId, - callbackUrl, + redirectUri, clientSecret: process.env.OIDC_CLIENT_SECRET, - scope: process.env.OIDC_SCOPE ?? 'openid email profile', + scopes: process.env.OIDC_SCOPES ?? 'openid profile email groups', + postLogoutRedirectUri: process.env.OIDC_POST_LOGOUT_REDIRECT_URI, + accessTokenAudience: process.env.OIDC_ACCESS_TOKEN_AUDIENCE ?? clientId, groupsClaim: process.env.OIDC_GROUPS_CLAIM ?? 'groups', }; } - private async requestUserInfoGroups( + private async requestUserInfo( userInfoEndpoint: string | undefined, accessToken: string | undefined, - groupsClaim: string, - ): Promise { + ): Promise> { if (!userInfoEndpoint || !accessToken) { - return []; + return {}; } const response = await fetch(userInfoEndpoint, { @@ -242,15 +363,41 @@ export class OidcService { }); if (!response.ok) { - return []; + return {}; } - const payload = (await response.json().catch(() => ({}))) as Record< - string, - unknown - >; + return (await response.json().catch(() => ({}))) as Record; + } - return this.extractGroups(payload, groupsClaim); + private addClientAuthentication( + body: URLSearchParams, + config: ReturnType, + ): void { + body.set('client_id', config.clientId); + + if (config.clientSecret) { + body.set('client_secret', config.clientSecret); + } + } + + private validateUserInfoSubject( + userInfo: Record, + expectedSubject: string, + ): void { + const subject = userInfo.sub; + + if (typeof subject === 'string' && subject !== expectedSubject) { + throw new BadRequestException('OIDC UserInfo subject is invalid.'); + } + } + + private audienceIncludes( + audience: string | string[], + expectedAudience: string, + ): boolean { + return Array.isArray(audience) + ? audience.includes(expectedAudience) + : audience === expectedAudience; } private extractGroups( @@ -270,6 +417,31 @@ export class OidcService { .sort((left, right) => left.localeCompare(right)); } + private displayName( + payload: JWTPayload | Record, + preferredUsername?: string, + ): string | undefined { + const explicitName = this.stringClaim(payload, 'name'); + const givenName = this.stringClaim(payload, 'given_name'); + const familyName = this.stringClaim(payload, 'family_name'); + const familyNameDisplay = [givenName, familyName].filter(Boolean).join(' '); + + return explicitName ?? (familyNameDisplay || preferredUsername); + } + + private stringClaim( + payload: JWTPayload | Record, + claim: string, + ): string | undefined { + const value = payload[claim]; + + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + + private normalizeIssuer(issuer?: string): string { + return issuer?.trim().replace(/\/$/, '') ?? ''; + } + private createOpaqueToken(): string { return randomBytes(32).toString('base64url'); } diff --git a/listify-api/src/auth/permissions.guard.spec.ts b/listify-api/src/auth/permissions.guard.spec.ts new file mode 100644 index 0000000..a4e4767 --- /dev/null +++ b/listify-api/src/auth/permissions.guard.spec.ts @@ -0,0 +1,67 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PermissionsGuard } from './permissions.guard'; +import type { AuthenticatedRequest } from './auth.types'; + +describe('PermissionsGuard', () => { + it('allows routes without required permissions', () => { + const reflector = createReflector(); + const guard = new PermissionsGuard(reflector); + + expect(guard.canActivate(createContext())).toBe(true); + }); + + it('allows users with all required permissions', () => { + const reflector = createReflector(['assistant.logs.view']); + const guard = new PermissionsGuard(reflector); + + expect( + guard.canActivate( + createContext({ + user: { + sub: 'user-1', + email: 'user@example.com', + type: 'access', + permissions: ['assistant.logs.view'], + }, + }), + ), + ).toBe(true); + }); + + it('rejects users missing a required permission', () => { + const reflector = createReflector(['assistant.logs.view']); + const guard = new PermissionsGuard(reflector); + + expect(() => + guard.canActivate( + createContext({ + user: { + sub: 'user-1', + email: 'user@example.com', + type: 'access', + permissions: ['assistant.chat'], + }, + }), + ), + ).toThrow(ForbiddenException); + }); + + function createReflector(requiredPermissions: string[] = []): Reflector { + return { + getAllAndOverride: jest.fn(() => requiredPermissions), + } as unknown as Reflector; + } + + function createContext( + request: Partial = {}, + ): ExecutionContext { + return { + getHandler: () => undefined, + getClass: () => undefined, + switchToHttp: () => ({ + getRequest: () => request, + }), + } as unknown as ExecutionContext; + } +}); diff --git a/listify-api/src/auth/permissions.guard.ts b/listify-api/src/auth/permissions.guard.ts new file mode 100644 index 0000000..53bc49f --- /dev/null +++ b/listify-api/src/auth/permissions.guard.ts @@ -0,0 +1,39 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthenticatedRequest } from './auth.types'; +import { REQUIRED_PERMISSIONS_METADATA_KEY } from './require-permissions.decorator'; +import type { AppPermission } from './authz.constants'; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredPermissions = + this.reflector.getAllAndOverride( + REQUIRED_PERMISSIONS_METADATA_KEY, + [context.getHandler(), context.getClass()], + ) ?? []; + + if (!requiredPermissions.length) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const userPermissions = new Set(request.user?.permissions ?? []); + const hasAllPermissions = requiredPermissions.every((permission) => + userPermissions.has(permission), + ); + + if (!hasAllPermissions) { + throw new ForbiddenException('Required app permission is missing.'); + } + + return true; + } +} diff --git a/listify-api/src/auth/require-permissions.decorator.ts b/listify-api/src/auth/require-permissions.decorator.ts new file mode 100644 index 0000000..6561d9a --- /dev/null +++ b/listify-api/src/auth/require-permissions.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common'; +import type { AppPermission } from './authz.constants'; + +export const REQUIRED_PERMISSIONS_METADATA_KEY = 'requiredPermissions'; + +export const RequirePermissions = (...permissions: AppPermission[]) => + SetMetadata(REQUIRED_PERMISSIONS_METADATA_KEY, permissions); diff --git a/listify-api/src/auth/user-keycloak-group.entity.ts b/listify-api/src/auth/user-oidc-group.entity.ts similarity index 86% rename from listify-api/src/auth/user-keycloak-group.entity.ts rename to listify-api/src/auth/user-oidc-group.entity.ts index 0c017a2..efe4f5c 100644 --- a/listify-api/src/auth/user-keycloak-group.entity.ts +++ b/listify-api/src/auth/user-oidc-group.entity.ts @@ -10,11 +10,11 @@ import { } from 'typeorm'; import { UserEntity } from './user.entity'; -@Entity('user_keycloak_groups') -@Index('IDX_user_keycloak_groups_user_group', ['userId', 'groupPath'], { +@Entity('user_oidc_groups') +@Index('IDX_user_oidc_groups_user_group', ['userId', 'groupPath'], { unique: true, }) -export class UserKeycloakGroupEntity { +export class UserOidcGroupEntity { @PrimaryColumn({ type: 'varchar', length: 36 }) id!: string; diff --git a/listify-api/src/database/data-source.ts b/listify-api/src/database/data-source.ts index 6227cad..2840f2c 100644 --- a/listify-api/src/database/data-source.ts +++ b/listify-api/src/database/data-source.ts @@ -3,9 +3,13 @@ import 'reflect-metadata'; import { DataSource } from 'typeorm'; import { AssistantChatLogEntity } from '../assistant/assistant-chat-log.entity'; import { AuditLogEntity } from '../audit/audit-log.entity'; +import { AppPermissionEntity } from '../auth/app-permission.entity'; +import { AppRolePermissionEntity } from '../auth/app-role-permission.entity'; +import { AppRoleEntity } from '../auth/app-role.entity'; import { UserEntity } from '../auth/user.entity'; -import { UserKeycloakGroupEntity } from '../auth/user-keycloak-group.entity'; +import { UserOidcGroupEntity } from '../auth/user-oidc-group.entity'; import { UserImpersonationEntity } from '../auth/user-impersonation.entity'; +import { OidcGroupRoleMappingEntity } from '../auth/oidc-group-role-mapping.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'; @@ -37,10 +41,14 @@ export default new DataSource({ maxQueryExecutionTime: slowQueryThresholdFromEnv(process.env), entities: [ AssistantChatLogEntity, + AppRoleEntity, + AppPermissionEntity, + AppRolePermissionEntity, AuditLogEntity, DailyDashboardSnapshotEntity, + OidcGroupRoleMappingEntity, UserEntity, - UserKeycloakGroupEntity, + UserOidcGroupEntity, UserImpersonationEntity, RefreshTokenEntity, ListTemplateEntity, diff --git a/listify-api/src/database/database-logging.config.ts b/listify-api/src/database/database-logging.config.ts index a907ee5..6c4547b 100644 --- a/listify-api/src/database/database-logging.config.ts +++ b/listify-api/src/database/database-logging.config.ts @@ -22,7 +22,11 @@ export function parseDatabaseLogging(value?: string | boolean): LoggerOptions { const normalizedValue = value?.trim().toLowerCase(); - if (!normalizedValue || normalizedValue === 'false' || normalizedValue === 'off') { + if ( + !normalizedValue || + normalizedValue === 'false' || + normalizedValue === 'off' + ) { return false; } diff --git a/listify-api/src/database/database.logger.ts b/listify-api/src/database/database.logger.ts index e0b62c1..696380e 100644 --- a/listify-api/src/database/database.logger.ts +++ b/listify-api/src/database/database.logger.ts @@ -3,7 +3,8 @@ import type { Logger as TypeOrmLogger, QueryRunner } from 'typeorm'; import type { DatabaseLoggerOptions } from './database-logging.config'; const REDACTED = '[redacted]'; -const SENSITIVE_KEY_PATTERN = /password|token|secret|authorization|cookie|hash/i; +const SENSITIVE_KEY_PATTERN = + /password|token|secret|authorization|cookie|hash/i; export class DatabaseLogger implements TypeOrmLogger { private readonly logger = new NestLogger('Database'); @@ -15,7 +16,9 @@ export class DatabaseLogger implements TypeOrmLogger { parameters?: unknown[], queryRunner?: QueryRunner, ): void { - this.logger.debug(this.formatMessage('query', query, parameters, queryRunner)); + this.logger.debug( + this.formatMessage('query', query, parameters, queryRunner), + ); } logQueryError( @@ -48,11 +51,15 @@ export class DatabaseLogger implements TypeOrmLogger { } logSchemaBuild(message: string, queryRunner?: QueryRunner): void { - this.logger.log(this.formatMessage('schema', message, undefined, queryRunner)); + this.logger.log( + this.formatMessage('schema', message, undefined, queryRunner), + ); } logMigration(message: string, queryRunner?: QueryRunner): void { - this.logger.log(this.formatMessage('migration', message, undefined, queryRunner)); + this.logger.log( + this.formatMessage('migration', message, undefined, queryRunner), + ); } log( @@ -60,7 +67,12 @@ export class DatabaseLogger implements TypeOrmLogger { message: string, queryRunner?: QueryRunner, ): void { - const formattedMessage = this.formatMessage(level, message, undefined, queryRunner); + const formattedMessage = this.formatMessage( + level, + message, + undefined, + queryRunner, + ); if (level === 'warn') { this.logger.warn(formattedMessage); @@ -108,12 +120,17 @@ export class DatabaseLogger implements TypeOrmLogger { return Object.fromEntries( Object.entries(value as Record).map(([key, entry]) => [ key, - SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : this.sanitizeValue(entry), + SENSITIVE_KEY_PATTERN.test(key) + ? REDACTED + : this.sanitizeValue(entry), ]), ); } - if (typeof value === 'string' && value.length > this.options.maxParameterLength) { + if ( + typeof value === 'string' && + value.length > this.options.maxParameterLength + ) { return `${value.slice(0, this.options.maxParameterLength)}...`; } diff --git a/listify-api/src/database/migrations/1780932637916-GeneratedMigration.ts b/listify-api/src/database/migrations/1780932637916-GeneratedMigration.ts index 80736fd..6603d2a 100644 --- a/listify-api/src/database/migrations/1780932637916-GeneratedMigration.ts +++ b/listify-api/src/database/migrations/1780932637916-GeneratedMigration.ts @@ -1,62 +1,159 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; export class GeneratedMigration1780932637916 implements MigrationInterface { - name = 'GeneratedMigration1780932637916' + name = 'GeneratedMigration1780932637916'; - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_list_template_items_template_id\``); - await queryRunner.query(`ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_list_templates_owner_id\``); - await queryRunner.query(`ALTER TABLE \`user_list_items\` DROP FOREIGN KEY \`FK_user_list_items_list_id\``); - await queryRunner.query(`ALTER TABLE \`user_lists\` DROP FOREIGN KEY \`FK_user_lists_owner_id\``); - await queryRunner.query(`ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_refresh_tokens_user_id\``); - await queryRunner.query(`ALTER TABLE \`list_template_seeds\` DROP FOREIGN KEY \`FK_list_template_seeds_owner_id\``); - await queryRunner.query(`DROP INDEX \`IDX_list_template_items_template_id\` ON \`list_template_items\``); - await queryRunner.query(`DROP INDEX \`IDX_list_templates_owner_id\` ON \`list_templates\``); - await queryRunner.query(`DROP INDEX \`IDX_user_list_items_list_id\` ON \`user_list_items\``); - await queryRunner.query(`DROP INDEX \`IDX_user_lists_owner_id\` ON \`user_lists\``); - await queryRunner.query(`DROP INDEX \`IDX_refresh_tokens_user_id\` ON \`refresh_tokens\``); - await queryRunner.query(`DROP INDEX \`IDX_users_email\` ON \`users\``); - await queryRunner.query(`DROP INDEX \`IDX_users_verification_token\` ON \`users\``); - await queryRunner.query(`ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_97672ac88f789774dd47f7c8be\` (\`email\`)`); - await queryRunner.query(`ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_945333aaddfc5b9021b2ee94d5\` (\`verificationToken\`)`); - await queryRunner.query(`CREATE INDEX \`IDX_82feb6202f10c7f7283d398014\` ON \`list_template_items\` (\`templateId\`)`); - await queryRunner.query(`CREATE INDEX \`IDX_dca36cb201077233743d7355d2\` ON \`list_templates\` (\`ownerId\`)`); - await queryRunner.query(`CREATE INDEX \`IDX_7dc61846f78234b1701413206d\` ON \`user_list_items\` (\`listId\`)`); - await queryRunner.query(`CREATE INDEX \`IDX_20f32f84af2f8a3aa60d702326\` ON \`user_lists\` (\`ownerId\`)`); - await queryRunner.query(`CREATE INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\` (\`userId\`)`); - await queryRunner.query(`ALTER TABLE \`list_template_items\` ADD CONSTRAINT \`FK_82feb6202f10c7f7283d3980144\` FOREIGN KEY (\`templateId\`) REFERENCES \`list_templates\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE \`list_templates\` ADD CONSTRAINT \`FK_dca36cb201077233743d7355d21\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE \`user_list_items\` ADD CONSTRAINT \`FK_7dc61846f78234b1701413206df\` FOREIGN KEY (\`listId\`) REFERENCES \`user_lists\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE \`user_lists\` ADD CONSTRAINT \`FK_20f32f84af2f8a3aa60d7023260\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE \`refresh_tokens\` ADD CONSTRAINT \`FK_610102b60fea1455310ccd299de\` FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_610102b60fea1455310ccd299de\``); - await queryRunner.query(`ALTER TABLE \`user_lists\` DROP FOREIGN KEY \`FK_20f32f84af2f8a3aa60d7023260\``); - await queryRunner.query(`ALTER TABLE \`user_list_items\` DROP FOREIGN KEY \`FK_7dc61846f78234b1701413206df\``); - await queryRunner.query(`ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_dca36cb201077233743d7355d21\``); - await queryRunner.query(`ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_82feb6202f10c7f7283d3980144\``); - await queryRunner.query(`DROP INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\``); - await queryRunner.query(`DROP INDEX \`IDX_20f32f84af2f8a3aa60d702326\` ON \`user_lists\``); - await queryRunner.query(`DROP INDEX \`IDX_7dc61846f78234b1701413206d\` ON \`user_list_items\``); - await queryRunner.query(`DROP INDEX \`IDX_dca36cb201077233743d7355d2\` ON \`list_templates\``); - await queryRunner.query(`DROP INDEX \`IDX_82feb6202f10c7f7283d398014\` ON \`list_template_items\``); - await queryRunner.query(`ALTER TABLE \`users\` DROP INDEX \`IDX_945333aaddfc5b9021b2ee94d5\``); - await queryRunner.query(`ALTER TABLE \`users\` DROP INDEX \`IDX_97672ac88f789774dd47f7c8be\``); - await queryRunner.query(`CREATE UNIQUE INDEX \`IDX_users_verification_token\` ON \`users\` (\`verificationToken\`)`); - await queryRunner.query(`CREATE UNIQUE INDEX \`IDX_users_email\` ON \`users\` (\`email\`)`); - await queryRunner.query(`CREATE INDEX \`IDX_refresh_tokens_user_id\` ON \`refresh_tokens\` (\`userId\`)`); - await queryRunner.query(`CREATE INDEX \`IDX_user_lists_owner_id\` ON \`user_lists\` (\`ownerId\`)`); - await queryRunner.query(`CREATE INDEX \`IDX_user_list_items_list_id\` ON \`user_list_items\` (\`listId\`)`); - await queryRunner.query(`CREATE INDEX \`IDX_list_templates_owner_id\` ON \`list_templates\` (\`ownerId\`)`); - await queryRunner.query(`CREATE INDEX \`IDX_list_template_items_template_id\` ON \`list_template_items\` (\`templateId\`)`); - await queryRunner.query(`ALTER TABLE \`list_template_seeds\` ADD CONSTRAINT \`FK_list_template_seeds_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE \`refresh_tokens\` ADD CONSTRAINT \`FK_refresh_tokens_user_id\` FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE \`user_lists\` ADD CONSTRAINT \`FK_user_lists_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE \`user_list_items\` ADD CONSTRAINT \`FK_user_list_items_list_id\` FOREIGN KEY (\`listId\`) REFERENCES \`user_lists\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE \`list_templates\` ADD CONSTRAINT \`FK_list_templates_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE \`list_template_items\` ADD CONSTRAINT \`FK_list_template_items_template_id\` FOREIGN KEY (\`templateId\`) REFERENCES \`list_templates\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); - } + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_list_template_items_template_id\``, + ); + await queryRunner.query( + `ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_list_templates_owner_id\``, + ); + await queryRunner.query( + `ALTER TABLE \`user_list_items\` DROP FOREIGN KEY \`FK_user_list_items_list_id\``, + ); + await queryRunner.query( + `ALTER TABLE \`user_lists\` DROP FOREIGN KEY \`FK_user_lists_owner_id\``, + ); + await queryRunner.query( + `ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_refresh_tokens_user_id\``, + ); + await queryRunner.query( + `ALTER TABLE \`list_template_seeds\` DROP FOREIGN KEY \`FK_list_template_seeds_owner_id\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_list_template_items_template_id\` ON \`list_template_items\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_list_templates_owner_id\` ON \`list_templates\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_user_list_items_list_id\` ON \`user_list_items\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_user_lists_owner_id\` ON \`user_lists\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_refresh_tokens_user_id\` ON \`refresh_tokens\``, + ); + await queryRunner.query(`DROP INDEX \`IDX_users_email\` ON \`users\``); + await queryRunner.query( + `DROP INDEX \`IDX_users_verification_token\` ON \`users\``, + ); + await queryRunner.query( + `ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_97672ac88f789774dd47f7c8be\` (\`email\`)`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_945333aaddfc5b9021b2ee94d5\` (\`verificationToken\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`IDX_82feb6202f10c7f7283d398014\` ON \`list_template_items\` (\`templateId\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`IDX_dca36cb201077233743d7355d2\` ON \`list_templates\` (\`ownerId\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`IDX_7dc61846f78234b1701413206d\` ON \`user_list_items\` (\`listId\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`IDX_20f32f84af2f8a3aa60d702326\` ON \`user_lists\` (\`ownerId\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\` (\`userId\`)`, + ); + await queryRunner.query( + `ALTER TABLE \`list_template_items\` ADD CONSTRAINT \`FK_82feb6202f10c7f7283d3980144\` FOREIGN KEY (\`templateId\`) REFERENCES \`list_templates\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`list_templates\` ADD CONSTRAINT \`FK_dca36cb201077233743d7355d21\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`user_list_items\` ADD CONSTRAINT \`FK_7dc61846f78234b1701413206df\` FOREIGN KEY (\`listId\`) REFERENCES \`user_lists\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`user_lists\` ADD CONSTRAINT \`FK_20f32f84af2f8a3aa60d7023260\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`refresh_tokens\` ADD CONSTRAINT \`FK_610102b60fea1455310ccd299de\` FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_610102b60fea1455310ccd299de\``, + ); + await queryRunner.query( + `ALTER TABLE \`user_lists\` DROP FOREIGN KEY \`FK_20f32f84af2f8a3aa60d7023260\``, + ); + await queryRunner.query( + `ALTER TABLE \`user_list_items\` DROP FOREIGN KEY \`FK_7dc61846f78234b1701413206df\``, + ); + await queryRunner.query( + `ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_dca36cb201077233743d7355d21\``, + ); + await queryRunner.query( + `ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_82feb6202f10c7f7283d3980144\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_20f32f84af2f8a3aa60d702326\` ON \`user_lists\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_7dc61846f78234b1701413206d\` ON \`user_list_items\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_dca36cb201077233743d7355d2\` ON \`list_templates\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_82feb6202f10c7f7283d398014\` ON \`list_template_items\``, + ); + await queryRunner.query( + `ALTER TABLE \`users\` DROP INDEX \`IDX_945333aaddfc5b9021b2ee94d5\``, + ); + await queryRunner.query( + `ALTER TABLE \`users\` DROP INDEX \`IDX_97672ac88f789774dd47f7c8be\``, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX \`IDX_users_verification_token\` ON \`users\` (\`verificationToken\`)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX \`IDX_users_email\` ON \`users\` (\`email\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`IDX_refresh_tokens_user_id\` ON \`refresh_tokens\` (\`userId\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`IDX_user_lists_owner_id\` ON \`user_lists\` (\`ownerId\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`IDX_user_list_items_list_id\` ON \`user_list_items\` (\`listId\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`IDX_list_templates_owner_id\` ON \`list_templates\` (\`ownerId\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`IDX_list_template_items_template_id\` ON \`list_template_items\` (\`templateId\`)`, + ); + await queryRunner.query( + `ALTER TABLE \`list_template_seeds\` ADD CONSTRAINT \`FK_list_template_seeds_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`refresh_tokens\` ADD CONSTRAINT \`FK_refresh_tokens_user_id\` FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`user_lists\` ADD CONSTRAINT \`FK_user_lists_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`user_list_items\` ADD CONSTRAINT \`FK_user_list_items_list_id\` FOREIGN KEY (\`listId\`) REFERENCES \`user_lists\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`list_templates\` ADD CONSTRAINT \`FK_list_templates_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`list_template_items\` ADD CONSTRAINT \`FK_list_template_items_template_id\` FOREIGN KEY (\`templateId\`) REFERENCES \`list_templates\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } } diff --git a/listify-api/src/database/migrations/1781000000000-AddUserOnboardingCompleted.ts b/listify-api/src/database/migrations/1781000000000-AddUserOnboardingCompleted.ts index d0e4114..dff7843 100644 --- a/listify-api/src/database/migrations/1781000000000-AddUserOnboardingCompleted.ts +++ b/listify-api/src/database/migrations/1781000000000-AddUserOnboardingCompleted.ts @@ -1,8 +1,6 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddUserOnboardingCompleted1781000000000 - implements MigrationInterface -{ +export class AddUserOnboardingCompleted1781000000000 implements MigrationInterface { name = 'AddUserOnboardingCompleted1781000000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/listify-api/src/database/migrations/1781003163444-GeneratedMigration.ts b/listify-api/src/database/migrations/1781003163444-GeneratedMigration.ts index 94ffd7f..8ae11e6 100644 --- a/listify-api/src/database/migrations/1781003163444-GeneratedMigration.ts +++ b/listify-api/src/database/migrations/1781003163444-GeneratedMigration.ts @@ -1,14 +1,17 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; export class GeneratedMigration1781003163444 implements MigrationInterface { - name = 'GeneratedMigration1781003163444' + name = 'GeneratedMigration1781003163444'; - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE \`users\` ADD \`onboardingCompleted\` tinyint NOT NULL DEFAULT 0`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE \`users\` DROP COLUMN \`onboardingCompleted\``); - } + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`users\` ADD \`onboardingCompleted\` tinyint NOT NULL DEFAULT 0`, + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`users\` DROP COLUMN \`onboardingCompleted\``, + ); + } } diff --git a/listify-api/src/database/migrations/1781093004780-GeneratedMigration.ts b/listify-api/src/database/migrations/1781093004780-GeneratedMigration.ts index e10a924..abfaac1 100644 --- a/listify-api/src/database/migrations/1781093004780-GeneratedMigration.ts +++ b/listify-api/src/database/migrations/1781093004780-GeneratedMigration.ts @@ -1,18 +1,27 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; export class GeneratedMigration1781093004780 implements MigrationInterface { - name = 'GeneratedMigration1781093004780' + name = 'GeneratedMigration1781093004780'; - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TABLE \`audit_logs\` (\`id\` varchar(36) NOT NULL, \`actorUserId\` varchar(36) NULL, \`actorEmail\` varchar(320) NULL, \`action\` varchar(100) NOT NULL, \`entityType\` varchar(80) NULL, \`entityId\` varchar(36) NULL, \`metadata\` json NULL, \`ipAddress\` varchar(64) NULL, \`userAgent\` varchar(512) NULL, \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), INDEX \`IDX_audit_logs_actor_user_id\` (\`actorUserId\`), INDEX \`IDX_audit_logs_action\` (\`action\`), INDEX \`IDX_audit_logs_entity\` (\`entityType\`), INDEX \`IDX_audit_logs_created_at\` (\`createdAt\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX \`IDX_audit_logs_created_at\` ON \`audit_logs\``); - await queryRunner.query(`DROP INDEX \`IDX_audit_logs_entity\` ON \`audit_logs\``); - await queryRunner.query(`DROP INDEX \`IDX_audit_logs_action\` ON \`audit_logs\``); - await queryRunner.query(`DROP INDEX \`IDX_audit_logs_actor_user_id\` ON \`audit_logs\``); - await queryRunner.query(`DROP TABLE \`audit_logs\``); - } + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`audit_logs\` (\`id\` varchar(36) NOT NULL, \`actorUserId\` varchar(36) NULL, \`actorEmail\` varchar(320) NULL, \`action\` varchar(100) NOT NULL, \`entityType\` varchar(80) NULL, \`entityId\` varchar(36) NULL, \`metadata\` json NULL, \`ipAddress\` varchar(64) NULL, \`userAgent\` varchar(512) NULL, \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), INDEX \`IDX_audit_logs_actor_user_id\` (\`actorUserId\`), INDEX \`IDX_audit_logs_action\` (\`action\`), INDEX \`IDX_audit_logs_entity\` (\`entityType\`), INDEX \`IDX_audit_logs_created_at\` (\`createdAt\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX \`IDX_audit_logs_created_at\` ON \`audit_logs\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_audit_logs_entity\` ON \`audit_logs\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_audit_logs_action\` ON \`audit_logs\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_audit_logs_actor_user_id\` ON \`audit_logs\``, + ); + await queryRunner.query(`DROP TABLE \`audit_logs\``); + } } diff --git a/listify-api/src/database/migrations/1781300000000-AddSoftDeleteToListsAndTemplates.ts b/listify-api/src/database/migrations/1781300000000-AddSoftDeleteToListsAndTemplates.ts index a7c9457..acd3579 100644 --- a/listify-api/src/database/migrations/1781300000000-AddSoftDeleteToListsAndTemplates.ts +++ b/listify-api/src/database/migrations/1781300000000-AddSoftDeleteToListsAndTemplates.ts @@ -1,8 +1,6 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddSoftDeleteToListsAndTemplates1781300000000 - implements MigrationInterface -{ +export class AddSoftDeleteToListsAndTemplates1781300000000 implements MigrationInterface { name = 'AddSoftDeleteToListsAndTemplates1781300000000'; public async up(queryRunner: QueryRunner): Promise { @@ -27,7 +25,9 @@ export class AddSoftDeleteToListsAndTemplates1781300000000 await queryRunner.query( 'DROP INDEX `IDX_user_lists_deleted_at` ON `user_lists`', ); - await queryRunner.query('ALTER TABLE `list_templates` DROP COLUMN `deletedAt`'); + await queryRunner.query( + 'ALTER TABLE `list_templates` DROP COLUMN `deletedAt`', + ); await queryRunner.query('ALTER TABLE `user_lists` DROP COLUMN `deletedAt`'); } } diff --git a/listify-api/src/database/migrations/1781400000000-AddListReminderAt.ts b/listify-api/src/database/migrations/1781400000000-AddListReminderAt.ts index 6d8a7ff..334affd 100644 --- a/listify-api/src/database/migrations/1781400000000-AddListReminderAt.ts +++ b/listify-api/src/database/migrations/1781400000000-AddListReminderAt.ts @@ -16,6 +16,8 @@ export class AddListReminderAt1781400000000 implements MigrationInterface { await queryRunner.query( 'DROP INDEX `IDX_user_lists_reminder_at` ON `user_lists`', ); - await queryRunner.query('ALTER TABLE `user_lists` DROP COLUMN `reminderAt`'); + await queryRunner.query( + 'ALTER TABLE `user_lists` DROP COLUMN `reminderAt`', + ); } } diff --git a/listify-api/src/database/migrations/1781500000000-CreateListTemplateShares.ts b/listify-api/src/database/migrations/1781500000000-CreateListTemplateShares.ts index 0602fd0..908ad52 100644 --- a/listify-api/src/database/migrations/1781500000000-CreateListTemplateShares.ts +++ b/listify-api/src/database/migrations/1781500000000-CreateListTemplateShares.ts @@ -1,13 +1,11 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class CreateListTemplateShares1781500000000 - implements MigrationInterface -{ +export class CreateListTemplateShares1781500000000 implements MigrationInterface { name = 'CreateListTemplateShares1781500000000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - 'CREATE TABLE `list_template_shares` (`id` varchar(36) NOT NULL, `templateId` varchar(36) NOT NULL, `userId` varchar(36) NOT NULL, `role` varchar(32) NOT NULL DEFAULT \'collaborator\', `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), INDEX `IDX_list_template_shares_template_id` (`templateId`), INDEX `IDX_list_template_shares_user_id` (`userId`), UNIQUE INDEX `IDX_list_template_shares_template_user` (`templateId`, `userId`), PRIMARY KEY (`id`)) ENGINE=InnoDB', + "CREATE TABLE `list_template_shares` (`id` varchar(36) NOT NULL, `templateId` varchar(36) NOT NULL, `userId` varchar(36) NOT NULL, `role` varchar(32) NOT NULL DEFAULT 'collaborator', `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), INDEX `IDX_list_template_shares_template_id` (`templateId`), INDEX `IDX_list_template_shares_user_id` (`userId`), UNIQUE INDEX `IDX_list_template_shares_template_user` (`templateId`, `userId`), PRIMARY KEY (`id`)) ENGINE=InnoDB", ); await queryRunner.query( 'ALTER TABLE `list_template_shares` ADD CONSTRAINT `FK_list_template_shares_template_id` FOREIGN KEY (`templateId`) REFERENCES `list_templates`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', diff --git a/listify-api/src/database/migrations/1781600000000-AddMcpApiKeyToUsers.ts b/listify-api/src/database/migrations/1781600000000-AddMcpApiKeyToUsers.ts index ac607f0..dc0e359 100644 --- a/listify-api/src/database/migrations/1781600000000-AddMcpApiKeyToUsers.ts +++ b/listify-api/src/database/migrations/1781600000000-AddMcpApiKeyToUsers.ts @@ -33,7 +33,9 @@ export class AddMcpApiKeyToUsers1781600000000 implements MigrationInterface { await queryRunner.query( 'DROP INDEX `IDX_users_mcp_api_key_hash` ON `users`', ); - await queryRunner.query('ALTER TABLE `users` DROP COLUMN `mcpApiKeyHash`'); + await queryRunner.query( + 'ALTER TABLE `users` DROP COLUMN `mcpApiKeyHash`', + ); } if (await queryRunner.hasColumn('users', 'mcpApiKeyCreatedAt')) { diff --git a/listify-api/src/database/migrations/1782400000000-CreateUserKeycloakGroups.ts b/listify-api/src/database/migrations/1782400000000-CreateUserOidcGroups.ts similarity index 56% rename from listify-api/src/database/migrations/1782400000000-CreateUserKeycloakGroups.ts rename to listify-api/src/database/migrations/1782400000000-CreateUserOidcGroups.ts index 74387e4..c1a3fff 100644 --- a/listify-api/src/database/migrations/1782400000000-CreateUserKeycloakGroups.ts +++ b/listify-api/src/database/migrations/1782400000000-CreateUserOidcGroups.ts @@ -1,24 +1,24 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class CreateUserKeycloakGroups1782400000000 implements MigrationInterface { - name = 'CreateUserKeycloakGroups1782400000000'; +export class CreateUserOidcGroups1782400000000 implements MigrationInterface { + name = 'CreateUserOidcGroups1782400000000'; public async up(queryRunner: QueryRunner): Promise { - if (await queryRunner.hasTable('user_keycloak_groups')) { + if (await queryRunner.hasTable('user_oidc_groups')) { return; } await queryRunner.query(` - CREATE TABLE \`user_keycloak_groups\` ( + CREATE TABLE \`user_oidc_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\` + INDEX \`IDX_user_oidc_groups_userId\` (\`userId\`), + UNIQUE INDEX \`IDX_user_oidc_groups_user_group\` (\`userId\`, \`groupPath\`), + CONSTRAINT \`FK_user_oidc_groups_user\` FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE, PRIMARY KEY (\`id\`) ) ENGINE=InnoDB @@ -26,8 +26,8 @@ export class CreateUserKeycloakGroups1782400000000 implements MigrationInterface } public async down(queryRunner: QueryRunner): Promise { - if (await queryRunner.hasTable('user_keycloak_groups')) { - await queryRunner.query('DROP TABLE `user_keycloak_groups`'); + if (await queryRunner.hasTable('user_oidc_groups')) { + await queryRunner.query('DROP TABLE `user_oidc_groups`'); } } } diff --git a/listify-api/src/database/migrations/1782500000000-CreateAppRolesAndPermissions.ts b/listify-api/src/database/migrations/1782500000000-CreateAppRolesAndPermissions.ts new file mode 100644 index 0000000..2d1a9a6 --- /dev/null +++ b/listify-api/src/database/migrations/1782500000000-CreateAppRolesAndPermissions.ts @@ -0,0 +1,125 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAppRolesAndPermissions1782500000000 implements MigrationInterface { + name = 'CreateAppRolesAndPermissions1782500000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasTable('app_roles'))) { + await queryRunner.query(` + CREATE TABLE \`app_roles\` ( + \`role\` varchar(80) NOT NULL, + \`label\` varchar(160) NOT NULL, + \`description\` varchar(255) NULL, + \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (\`role\`) + ) ENGINE=InnoDB + `); + } + + if (!(await queryRunner.hasTable('app_permissions'))) { + await queryRunner.query(` + CREATE TABLE \`app_permissions\` ( + \`permission\` varchar(120) NOT NULL, + \`label\` varchar(160) NOT NULL, + \`description\` varchar(255) NULL, + \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (\`permission\`) + ) ENGINE=InnoDB + `); + } + + if (!(await queryRunner.hasTable('app_role_permissions'))) { + await queryRunner.query(` + CREATE TABLE \`app_role_permissions\` ( + \`id\` varchar(36) NOT NULL, + \`role\` varchar(80) NOT NULL, + \`permission\` varchar(120) NOT NULL, + \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX \`IDX_app_role_permissions_role\` (\`role\`), + INDEX \`IDX_app_role_permissions_permission\` (\`permission\`), + UNIQUE INDEX \`IDX_app_role_permissions_role_permission\` (\`role\`, \`permission\`), + CONSTRAINT \`FK_app_role_permissions_role\` + FOREIGN KEY (\`role\`) REFERENCES \`app_roles\`(\`role\`) ON DELETE CASCADE, + CONSTRAINT \`FK_app_role_permissions_permission\` + FOREIGN KEY (\`permission\`) REFERENCES \`app_permissions\`(\`permission\`) ON DELETE CASCADE, + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB + `); + } + + if (!(await queryRunner.hasTable('oidc_group_role_mappings'))) { + await queryRunner.query(` + CREATE TABLE \`oidc_group_role_mappings\` ( + \`id\` varchar(36) NOT NULL, + \`groupPath\` varchar(255) NOT NULL, + \`role\` varchar(80) NOT NULL, + \`enabled\` tinyint NOT NULL DEFAULT 1, + \`reason\` varchar(255) 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_oidc_group_role_mappings_groupPath\` (\`groupPath\`), + INDEX \`IDX_oidc_group_role_mappings_role\` (\`role\`), + UNIQUE INDEX \`IDX_oidc_group_role_mappings_group_role\` (\`groupPath\`, \`role\`), + CONSTRAINT \`FK_oidc_group_role_mappings_role\` + FOREIGN KEY (\`role\`) REFERENCES \`app_roles\`(\`role\`) ON DELETE CASCADE, + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB + `); + } + + await queryRunner.query(` + INSERT IGNORE INTO \`app_roles\` (\`role\`, \`label\`, \`description\`) VALUES + ('app_user', 'User', 'Basiszugriff fuer angemeldete Benutzer'), + ('app_admin', 'Admin', 'Voller App-Zugriff inklusive Diagnosefunktionen') + `); + + await queryRunner.query(` + INSERT IGNORE INTO \`app_permissions\` (\`permission\`, \`label\`, \`description\`) VALUES + ('dashboard.view', 'Dashboard ansehen', 'Dashboard lesen'), + ('lists.manage_own', 'Eigene Listen verwalten', 'Eigene und geteilte Listen nutzen'), + ('templates.manage_own', 'Eigene Templates verwalten', 'Eigene und geteilte Templates nutzen'), + ('tasks.manage_own', 'Eigene Tasks verwalten', 'Eigene Tasks nutzen'), + ('assistant.chat', 'Assistant Chat nutzen', 'Assistant Chat verwenden'), + ('assistant.logs.view', 'Assistant Logs ansehen', 'Assistant Chat Logs ansehen'), + ('account.manage_self', 'Eigenes Konto verwalten', 'Eigene Konto-Einstellungen verwalten'), + ('users.search', 'Benutzer suchen', 'Benutzer fuer Freigaben suchen') + `); + + await queryRunner.query(` + INSERT IGNORE INTO \`app_role_permissions\` (\`id\`, \`role\`, \`permission\`) VALUES + ('rp-user-dashboard', 'app_user', 'dashboard.view'), + ('rp-user-lists', 'app_user', 'lists.manage_own'), + ('rp-user-templates', 'app_user', 'templates.manage_own'), + ('rp-user-tasks', 'app_user', 'tasks.manage_own'), + ('rp-user-assistant-chat', 'app_user', 'assistant.chat'), + ('rp-user-account', 'app_user', 'account.manage_self'), + ('rp-user-search', 'app_user', 'users.search'), + ('rp-admin-dashboard', 'app_admin', 'dashboard.view'), + ('rp-admin-lists', 'app_admin', 'lists.manage_own'), + ('rp-admin-templates', 'app_admin', 'templates.manage_own'), + ('rp-admin-tasks', 'app_admin', 'tasks.manage_own'), + ('rp-admin-assistant-chat', 'app_admin', 'assistant.chat'), + ('rp-admin-assistant-logs', 'app_admin', 'assistant.logs.view'), + ('rp-admin-account', 'app_admin', 'account.manage_self'), + ('rp-admin-search', 'app_admin', 'users.search') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('oidc_group_role_mappings')) { + await queryRunner.query('DROP TABLE `oidc_group_role_mappings`'); + } + + if (await queryRunner.hasTable('app_role_permissions')) { + await queryRunner.query('DROP TABLE `app_role_permissions`'); + } + + if (await queryRunner.hasTable('app_permissions')) { + await queryRunner.query('DROP TABLE `app_permissions`'); + } + + if (await queryRunner.hasTable('app_roles')) { + await queryRunner.query('DROP TABLE `app_roles`'); + } + } +} diff --git a/listify-api/src/list-templates/list-templates.service.spec.ts b/listify-api/src/list-templates/list-templates.service.spec.ts index 1a8eb27..edac061 100644 --- a/listify-api/src/list-templates/list-templates.service.spec.ts +++ b/listify-api/src/list-templates/list-templates.service.spec.ts @@ -38,16 +38,21 @@ describe('ListTemplatesService', () => { it('keeps seeded templates editable and deleted templates deleted', async () => { const template = (await service.listTemplates('user-1'))[0]; - const updatedTemplate = await service.updateTemplate('user-1', template.id, { - name: 'Meine Vorlage', - }); + const updatedTemplate = await service.updateTemplate( + 'user-1', + template.id, + { + name: 'Meine Vorlage', + }, + ); await service.deleteTemplate('user-1', template.id); expect(updatedTemplate.name).toBe('Meine Vorlage'); await expect(service.listTemplates('user-1')).resolves.toHaveLength(2); expect( - (await service.listTemplates('user-1')) - .some((existingTemplate) => existingTemplate.id === template.id), + (await service.listTemplates('user-1')).some( + (existingTemplate) => existingTemplate.id === template.id, + ), ).toBe(false); await expect(service.getTemplate('user-1', template.id)).rejects.toThrow( NotFoundException, @@ -79,12 +84,14 @@ describe('ListTemplatesService', () => { expect(template.items).toHaveLength(2); expect(template.items[0].title).toBe('Pass'); expect( - (await service.listTemplates('user-1')) - .some((existingTemplate) => existingTemplate.id === template.id), + (await service.listTemplates('user-1')).some( + (existingTemplate) => existingTemplate.id === template.id, + ), ).toBe(true); expect( - (await service.listTemplates('user-2')) - .some((existingTemplate) => existingTemplate.id === template.id), + (await service.listTemplates('user-2')).some( + (existingTemplate) => existingTemplate.id === template.id, + ), ).toBe(false); }); @@ -104,7 +111,10 @@ describe('ListTemplatesService', () => { const sharedTemplate = await service.shareTemplate('user-1', template.id, { userId: 'user-2', }); - const collaboratorTemplate = await service.getTemplate('user-2', template.id); + const collaboratorTemplate = await service.getTemplate( + 'user-2', + template.id, + ); const updatedByCollaborator = await service.addItem('user-2', template.id, { title: 'Vom Collaborator', }); @@ -142,9 +152,13 @@ describe('ListTemplatesService', () => { }); const itemId = template.items[0].id; - const updatedTemplate = await service.updateTemplate('user-1', template.id, { - name: 'Wocheneinkauf', - }); + const updatedTemplate = await service.updateTemplate( + 'user-1', + template.id, + { + name: 'Wocheneinkauf', + }, + ); const updatedItemTemplate = await service.updateItem( 'user-1', template.id, @@ -196,13 +210,17 @@ describe('ListTemplatesService', () => { ], }); - const reorderedTemplate = await service.reorderItems('user-1', template.id, { - itemIds: [ - template.items[2].id, - template.items[0].id, - template.items[1].id, - ], - }); + const reorderedTemplate = await service.reorderItems( + 'user-1', + template.id, + { + itemIds: [ + template.items[2].id, + template.items[0].id, + template.items[1].id, + ], + }, + ); const reloadedTemplate = await service.getTemplate('user-1', template.id); expect(reorderedTemplate.items.map((item) => item.title)).toEqual([ @@ -211,9 +229,7 @@ describe('ListTemplatesService', () => { 'Zweiter Schritt', ]); expect(reloadedTemplate.items.map((item) => item.position)).toEqual([ - 0, - 1, - 2, + 0, 1, 2, ]); }); @@ -233,9 +249,7 @@ describe('ListTemplatesService', () => { it('rejects invalid input and missing resources', async () => { await expect( service.createTemplate('user-1', { name: ' ' }), - ).rejects.toThrow( - 'List template name is required.', - ); + ).rejects.toThrow('List template name is required.'); await expect( service.createTemplate('user-1', { name: 'Ungueltig', diff --git a/listify-api/src/list-templates/list-templates.service.ts b/listify-api/src/list-templates/list-templates.service.ts index bd619af..07d9507 100644 --- a/listify-api/src/list-templates/list-templates.service.ts +++ b/listify-api/src/list-templates/list-templates.service.ts @@ -90,7 +90,9 @@ export class ListTemplatesService { }); const sharedTemplateShares = await this.templateSharesRepository.find({ where: { userId: ownerId }, - relations: { template: { items: true, owner: true, shares: { user: true } } }, + relations: { + template: { items: true, owner: true, shares: { user: true } }, + }, }); const templatesById = new Map(); @@ -210,7 +212,9 @@ export class ListTemplatesService { const targetUserId = this.requireShareUserId(shareDto.userId); if (targetUserId === ownerId) { - throw new BadRequestException('Template owner cannot be added as collaborator.'); + throw new BadRequestException( + 'Template owner cannot be added as collaborator.', + ); } const targetUser = await this.usersRepository.findOne({ @@ -365,7 +369,9 @@ export class ListTemplatesService { } if (itemIds.length !== template.items.length) { - throw new BadRequestException('Item ids must include every template item.'); + throw new BadRequestException( + 'Item ids must include every template item.', + ); } const uniqueItemIds = new Set(itemIds); @@ -378,7 +384,9 @@ export class ListTemplatesService { const item = itemsById.get(itemId); if (!item) { - throw new BadRequestException('Item ids must include every template item.'); + throw new BadRequestException( + 'Item ids must include every template item.', + ); } item.position = index; @@ -469,7 +477,9 @@ export class ListTemplatesService { const template = await this.findAccessibleTemplate(ownerId, templateId); if (template.ownerId !== ownerId) { - throw new ForbiddenException('Only the template owner can perform this action.'); + throw new ForbiddenException( + 'Only the template owner can perform this action.', + ); } return template; @@ -658,7 +668,10 @@ export class ListTemplatesService { return normalizedUserId; } - private canAccessTemplate(template: ListTemplateEntity, userId: string): boolean { + private canAccessTemplate( + template: ListTemplateEntity, + userId: string, + ): boolean { return ( template.ownerId === userId || Boolean(template.shares?.some((share) => share.userId === userId)) @@ -675,9 +688,10 @@ export class ListTemplatesService { private async hydrateTemplateAccessRelations( template: ListTemplateEntity, ): Promise { - template.owner ??= (await this.usersRepository.findOne({ - where: { id: template.ownerId }, - })) ?? undefined; + template.owner ??= + (await this.usersRepository.findOne({ + where: { id: template.ownerId }, + })) ?? undefined; const storedShares = await this.templateSharesRepository.find({ where: { templateId: template.id }, @@ -686,9 +700,10 @@ export class ListTemplatesService { template.shares = storedShares; for (const share of template.shares) { - share.user ??= (await this.usersRepository.findOne({ - where: { id: share.userId }, - })) ?? undefined; + share.user ??= + (await this.usersRepository.findOne({ + where: { id: share.userId }, + })) ?? undefined; } } diff --git a/listify-api/src/lists/list-realtime.service.ts b/listify-api/src/lists/list-realtime.service.ts index d14e45a..d5a28cc 100644 --- a/listify-api/src/lists/list-realtime.service.ts +++ b/listify-api/src/lists/list-realtime.service.ts @@ -20,7 +20,10 @@ export type ListRealtimeEvent = export class ListRealtimeService { // In-memory SSE fanout for one API process. If the API is scaled horizontally, // replace this map with a shared pub/sub backend while keeping the event shape. - private readonly channels = new Map>>(); + private readonly channels = new Map< + string, + Set> + >(); /** * Opens an owner-scoped stream. The controller authenticates the request before diff --git a/listify-api/src/lists/list-reminder.service.spec.ts b/listify-api/src/lists/list-reminder.service.spec.ts index 0ebf353..1ca5bb9 100644 --- a/listify-api/src/lists/list-reminder.service.spec.ts +++ b/listify-api/src/lists/list-reminder.service.spec.ts @@ -59,8 +59,9 @@ describe('ListReminderService', () => { ], }, ); - expect((await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt) - .toBeNull(); + expect( + (await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt, + ).toBeNull(); expect(listsService.publishListSnapshot).toHaveBeenCalledWith(list.id); }); @@ -73,8 +74,9 @@ describe('ListReminderService', () => { await service.processDueReminders(new Date('2026-06-17T09:00:00.000Z')); expect(mailService.sendListReminderEmail).not.toHaveBeenCalled(); - expect((await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt) - .toBeNull(); + expect( + (await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt, + ).toBeNull(); }); it('keeps the reminder when sending fails', async () => { @@ -90,8 +92,9 @@ describe('ListReminderService', () => { await service.processDueReminders(new Date('2026-06-17T09:00:00.000Z')); - expect((await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt) - .toBe(reminderAt); + expect( + (await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt, + ).toBe(reminderAt); expect(listsService.publishListSnapshot).not.toHaveBeenCalled(); }); diff --git a/listify-api/src/lists/list-reminder.service.ts b/listify-api/src/lists/list-reminder.service.ts index 6ec51c8..2fdad9a 100644 --- a/listify-api/src/lists/list-reminder.service.ts +++ b/listify-api/src/lists/list-reminder.service.ts @@ -57,7 +57,9 @@ export class ListReminderService { const ownerEmail = list.owner?.email; if (!ownerEmail) { - this.logger.warn(`List reminder skipped because owner email is missing: ${list.id}`); + this.logger.warn( + `List reminder skipped because owner email is missing: ${list.id}`, + ); return; } diff --git a/listify-api/src/lists/lists.service.ts b/listify-api/src/lists/lists.service.ts index e6f8a66..f523f46 100644 --- a/listify-api/src/lists/lists.service.ts +++ b/listify-api/src/lists/lists.service.ts @@ -1502,7 +1502,8 @@ export class ListsService { required?: unknown; reason?: unknown; }; - const itemId = typeof candidate.itemId === 'string' ? candidate.itemId : ''; + const itemId = + typeof candidate.itemId === 'string' ? candidate.itemId : ''; const existingItem = itemsById.get(itemId); if (!existingItem || seenItemIds.has(itemId)) { @@ -1592,7 +1593,8 @@ export class ListsService { keptItemId?: unknown; reason?: unknown; }; - const itemId = typeof candidate.itemId === 'string' ? candidate.itemId : ''; + const itemId = + typeof candidate.itemId === 'string' ? candidate.itemId : ''; const keptItemId = typeof candidate.keptItemId === 'string' ? candidate.keptItemId : ''; const item = itemsById.get(itemId); diff --git a/listify-api/src/mcp/list-suggestion-agent.service.spec.ts b/listify-api/src/mcp/list-suggestion-agent.service.spec.ts index f783573..5b44153 100644 --- a/listify-api/src/mcp/list-suggestion-agent.service.spec.ts +++ b/listify-api/src/mcp/list-suggestion-agent.service.spec.ts @@ -1,8 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { - ListTemplate, - UserList, -} from '../list-templates/list-template.types'; +import { ListTemplate, UserList } from '../list-templates/list-template.types'; import { ListTemplatesService } from '../list-templates/list-templates.service'; import { ListsService } from '../lists/lists.service'; import { ListSuggestionAgentService } from './list-suggestion-agent.service'; @@ -27,9 +24,9 @@ describe('ListSuggestionAgentService', () => { }); it('suggests read-only list ideas from matching templates', async () => { - jest.mocked(listsService.listLists).mockResolvedValue([ - list({ name: 'Urlaub: Sommerurlaub' }), - ]); + jest + .mocked(listsService.listLists) + .mockResolvedValue([list({ name: 'Urlaub: Sommerurlaub' })]); jest.mocked(listTemplatesService.listTemplates).mockResolvedValue([ template({ id: 'template-1', diff --git a/listify-api/src/mcp/list-suggestion-agent.service.ts b/listify-api/src/mcp/list-suggestion-agent.service.ts index e0477d9..3e58d9c 100644 --- a/listify-api/src/mcp/list-suggestion-agent.service.ts +++ b/listify-api/src/mcp/list-suggestion-agent.service.ts @@ -32,7 +32,10 @@ export class ListSuggestionAgentService { this.listTemplatesService.listTemplates(userId), ]); const existingNames = new Set(lists.map((list) => this.nameKey(list.name))); - const matchingTemplates = this.rankTemplates(templates, goal, kind).slice(0, 2); + const matchingTemplates = this.rankTemplates(templates, goal, kind).slice( + 0, + 2, + ); const suggestions = matchingTemplates.map((template) => this.suggestFromTemplate(template, goal, constraints, existingNames), ); @@ -180,7 +183,9 @@ export class ListSuggestionAgentService { return 'packing'; } - if (/(einkauf|shopping|supermarkt|lebensmittel|markt)/.test(normalizedGoal)) { + if ( + /(einkauf|shopping|supermarkt|lebensmittel|markt)/.test(normalizedGoal) + ) { return 'shopping'; } diff --git a/listify-api/src/tasks/task-digest.service.spec.ts b/listify-api/src/tasks/task-digest.service.spec.ts index cc1dd06..b6b8972 100644 --- a/listify-api/src/tasks/task-digest.service.spec.ts +++ b/listify-api/src/tasks/task-digest.service.spec.ts @@ -21,14 +21,12 @@ describe('TaskDigestService', () => { sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined), }; taskPushService = { - sendTaskDigest: jest - .fn() - .mockResolvedValue({ - enabled: true, - subscriptionCount: 1, - sentCount: 1, - failedCount: 0, - }), + sendTaskDigest: jest.fn().mockResolvedValue({ + enabled: true, + subscriptionCount: 1, + sentCount: 1, + failedCount: 0, + }), }; service = new TaskDigestService( usersRepository as never, diff --git a/listify-api/src/tasks/task-push.service.ts b/listify-api/src/tasks/task-push.service.ts index b01df6d..3d817ac 100644 --- a/listify-api/src/tasks/task-push.service.ts +++ b/listify-api/src/tasks/task-push.service.ts @@ -196,7 +196,9 @@ export class TaskPushService { title, body: parts.join(', '), url: payload.tasksUrl, - tag: payload.notificationTag ?? `task-digest-${payload.slot}-${payload.date}`, + tag: + payload.notificationTag ?? + `task-digest-${payload.slot}-${payload.date}`, }; } diff --git a/listify-api/src/testing/in-memory-repository.ts b/listify-api/src/testing/in-memory-repository.ts index 1f94786..e4d01b7 100644 --- a/listify-api/src/testing/in-memory-repository.ts +++ b/listify-api/src/testing/in-memory-repository.ts @@ -19,13 +19,17 @@ export class InMemoryRepository { async save(entityOrEntities: T | T[]): Promise { if (Array.isArray(entityOrEntities)) { - return Promise.all(entityOrEntities.map((entity) => this.saveOne(entity))); + return Promise.all( + entityOrEntities.map((entity) => this.saveOne(entity)), + ); } return this.saveOne(entityOrEntities); } - async find(options: { where?: WhereClause; order?: unknown } = {}): Promise { + async find( + options: { where?: WhereClause; order?: unknown } = {}, + ): Promise { const records = [...this.records.values()].filter((record) => this.matchesWhere(record, options.where), ); @@ -33,7 +37,10 @@ export class InMemoryRepository { return this.applyOrder(records, options.order); } - async findOne(options: { where?: WhereClause; order?: unknown }): Promise { + async findOne(options: { + where?: WhereClause; + order?: unknown; + }): Promise { const [record] = await this.find(options); return record ?? null; } @@ -48,7 +55,9 @@ export class InMemoryRepository { async remove(entityOrEntities: T | T[]): Promise { if (Array.isArray(entityOrEntities)) { - entityOrEntities.forEach((entity) => this.records.delete(this.keyOf(entity))); + entityOrEntities.forEach((entity) => + this.records.delete(this.keyOf(entity)), + ); return entityOrEntities; } @@ -99,8 +108,10 @@ export class InMemoryRepository { } if ( - (typeof recordValue === 'number' || typeof recordValue === 'string') && - (typeof operatorValue === 'number' || typeof operatorValue === 'string') + (typeof recordValue === 'number' || + typeof recordValue === 'string') && + (typeof operatorValue === 'number' || + typeof operatorValue === 'string') ) { return recordValue <= operatorValue; } @@ -108,6 +119,14 @@ export class InMemoryRepository { return false; } + if (value instanceof FindOperator && value.type === 'in') { + const operatorValue = value.value as unknown; + + return ( + Array.isArray(operatorValue) && operatorValue.includes(recordValue) + ); + } + return recordValue === value; }); } @@ -125,8 +144,12 @@ export class InMemoryRepository { if (typedOrder?.name) { sortedRecords.sort((left, right) => { - const leftName = String((left as Record)['name'] ?? ''); - const rightName = String((right as Record)['name'] ?? ''); + const leftName = String( + (left as Record)['name'] ?? '', + ); + const rightName = String( + (right as Record)['name'] ?? '', + ); return typedOrder.name === 'DESC' ? rightName.localeCompare(leftName) : leftName.localeCompare(rightName); diff --git a/listify-api/test/app.e2e-spec.ts b/listify-api/test/app.e2e-spec.ts index 7417916..2e55eaa 100644 --- a/listify-api/test/app.e2e-spec.ts +++ b/listify-api/test/app.e2e-spec.ts @@ -38,15 +38,18 @@ describe('AppController (e2e)', () => { beforeEach(async () => { oidcService = { - createAuthorizationUrl: jest.fn( - async () => 'https://sso.example.test/authorize', + createAuthorizationUrl: jest.fn(() => + Promise.resolve('https://sso.example.test/authorize'), + ), + exchangeCallback: jest.fn(() => + Promise.resolve({ + subject: 'oidc-default', + email: 'default@example.com', + name: 'Default User', + groups: [], + idToken: 'id-token', + }), ), - exchangeCallback: jest.fn(async () => ({ - subject: 'oidc-default', - email: 'default@example.com', - name: 'Default User', - groups: [], - })), }; const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -219,6 +222,7 @@ describe('AppController (e2e)', () => { email, name: 'Test User', groups: [], + idToken: 'id-token', }); const exchangeResponse = await request(app.getHttpServer()) @@ -226,7 +230,7 @@ describe('AppController (e2e)', () => { .send({ code: 'code', state: 'state' }) .expect(200); - return exchangeResponse.body as unknown as AuthResponseBody; + return exchangeResponse.body as AuthResponseBody; } function uniqueEmail(prefix: string): string { @@ -234,17 +238,19 @@ describe('AppController (e2e)', () => { } async function ensureSsoSchema(dataSource: DataSource): Promise { - const usersTables = (await dataSource.query( + const usersTables = await dataSource.query>>( "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users'", - )) as unknown[]; + ); if (!usersTables.length) { return; } - const oidcSubjectColumns = (await dataSource.query( + const oidcSubjectColumns = await dataSource.query< + Array> + >( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'oidcSubject'", - )) as unknown[]; + ); if (!oidcSubjectColumns.length) { await dataSource.query( @@ -264,10 +270,10 @@ describe('AppController (e2e)', () => { dataSource: DataSource, columnName: string, ): Promise { - const columns = (await dataSource.query( + const columns = await dataSource.query>>( 'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', ['users', columnName], - )) as unknown[]; + ); if (columns.length) { await dataSource.query( diff --git a/listify-client/src/app/account/account.component.html b/listify-client/src/app/account/account.component.html index 9cf477e..a1bed18 100644 --- a/listify-client/src/app/account/account.component.html +++ b/listify-client/src/app/account/account.component.html @@ -18,11 +18,11 @@ -
+
-

Keycloak-Gruppen

+

OIDC-Gruppen

{{ auth.user()?.groups?.length || 0 }} synchronisiert

@@ -38,6 +38,44 @@ }
+
+
+ +
+

App-Rollen

+

{{ auth.user()?.roles?.length || 0 }} aktiv

+
+
+ + @if (auth.user()?.roles?.length) { +
    + @for (role of auth.user()?.roles ?? []; track role) { +
  • {{ role }}
  • + } +
+ } @else { +

Keine App-Rollen hinterlegt.

+ } + +
+ +
+

App-Rechte

+

{{ auth.user()?.permissions?.length || 0 }} aktiv

+
+
+ + @if (auth.user()?.permissions?.length) { +
    + @for (permission of auth.user()?.permissions ?? []; track permission) { +
  • {{ permission }}
  • + } +
+ } @else { +

Keine App-Rechte hinterlegt.

+ } +
+
diff --git a/listify-client/src/app/account/account.component.scss b/listify-client/src/app/account/account.component.scss index 43df924..8628c95 100644 --- a/listify-client/src/app/account/account.component.scss +++ b/listify-client/src/app/account/account.component.scss @@ -40,7 +40,8 @@ background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface)); } -.groups-section { +.groups-section, +.access-section { display: grid; gap: 0.8rem; margin-top: 1rem; @@ -50,6 +51,10 @@ background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface)); } +.compact-heading { + padding-top: 0.4rem; +} + .settings-heading { display: flex; gap: 0.75rem; diff --git a/listify-client/src/app/account/account.component.ts b/listify-client/src/app/account/account.component.ts index c65d47a..ae14ce6 100644 --- a/listify-client/src/app/account/account.component.ts +++ b/listify-client/src/app/account/account.component.ts @@ -1,5 +1,4 @@ import { Component, inject } from '@angular/core'; -import { Router } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; @@ -30,7 +29,6 @@ export class AccountComponent { protected readonly auth = inject(AuthService); protected readonly onboarding = inject(OnboardingService); protected readonly taskPush = inject(TaskPushService); - private readonly router = inject(Router); private readonly snackBar = inject(MatSnackBar); protected savingTaskDigestPreference = false; protected readonly taskDigestPreferenceOptions: ReadonlyArray<{ @@ -116,7 +114,6 @@ export class AccountComponent { } logout(): void { - this.auth.logout(); - void this.router.navigateByUrl('/login'); + this.auth.logoutThroughProvider(); } } diff --git a/listify-client/src/app/app.html b/listify-client/src/app/app.html index 219588d..409fa65 100644 --- a/listify-client/src/app/app.html +++ b/listify-client/src/app/app.html @@ -105,16 +105,18 @@ Account - - - Assistant Logs - + @if (auth.hasPermission('assistant.logs.view')) { + + + Assistant Logs + + } @@ -128,7 +130,9 @@ - + @if (auth.hasPermission('assistant.chat')) { + + }