idp angepasst
This commit is contained in:
@@ -18,11 +18,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
|||||||
# Browser-URL, unter der der Container erreichbar ist.
|
# Browser-URL, unter der der Container erreichbar ist.
|
||||||
CLIENT_URL=http://localhost:8080
|
CLIENT_URL=http://localhost:8080
|
||||||
|
|
||||||
OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify
|
OIDC_ISSUER=https://id.example.com
|
||||||
OIDC_DISCOVERY_URL=
|
|
||||||
OIDC_CLIENT_ID=listify
|
OIDC_CLIENT_ID=listify
|
||||||
OIDC_CLIENT_SECRET=
|
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_API_KEY=
|
||||||
MISTRAL_AGENT_ID=
|
MISTRAL_AGENT_ID=
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
|||||||
|
|
||||||
CLIENT_URL=http://localhost:4200
|
CLIENT_URL=http://localhost:4200
|
||||||
|
|
||||||
OIDC_ISSUER_URL=https://auth.forgecore.work/realms/Homelab/account
|
OIDC_ISSUER=https://id.example.com
|
||||||
OIDC_DISCOVERY_URL=
|
|
||||||
OIDC_CLIENT_ID=listify
|
OIDC_CLIENT_ID=listify
|
||||||
OIDC_CLIENT_SECRET=
|
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=
|
MCP_ACCESS_TOKEN=
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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`.
|
### LDAP-Portal Client
|
||||||
2. `Standard flow` aktivieren. PKCE mit `S256` erlauben oder erzwingen.
|
|
||||||
3. Scopes `openid`, `email` und `profile` verfuegbar machen.
|
1. Im LDAP-Portal unter `/admin/oidc-clients` einen Client fuer Listify registrieren.
|
||||||
4. Der User muss ein `email` Claim im ID Token erhalten. Ohne E-Mail lehnt Listify den Login ab.
|
2. Authorization Code Flow mit PKCE aktivieren. Dynamic Client Registration wird nicht verwendet.
|
||||||
5. Redirect URI fuer die Browser-URL eintragen:
|
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
|
```text
|
||||||
http://localhost:4200/auth/sso/callback
|
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`.
|
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
|
### Listify Environment
|
||||||
|
|
||||||
Bei einem Keycloak-Realm `listify` unter `https://auth.forgecore.work`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify
|
OIDC_ISSUER=https://id.example.com
|
||||||
OIDC_DISCOVERY_URL=
|
OIDC_CLIENT_ID=<client-id-aus-admin-oidc-clients>
|
||||||
OIDC_CLIENT_ID=listify
|
OIDC_CLIENT_SECRET=<client-secret-aus-admin-oidc-clients>
|
||||||
OIDC_CLIENT_SECRET=<keycloak-client-secret>
|
OIDC_SCOPES=openid profile email groups
|
||||||
OIDC_CALLBACK_URL=http://localhost:4200/auth/sso/callback
|
OIDC_REDIRECT_URI=http://localhost:4200/auth/sso/callback
|
||||||
|
OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/login
|
||||||
CLIENT_URL=http://localhost:4200
|
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 <access_token>` fuer UserInfo auf.
|
||||||
|
|
||||||
```text
|
Wenn die Introspection-Antwort eine andere Access-Token-Audience als die Client-ID enthaelt, kann sie explizit gesetzt werden:
|
||||||
https://auth.forgecore.work/realms/<realm>/.well-known/openid-configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
Nur falls Keycloak hinter einem Proxy eine abweichende Discovery-URL liefert oder du sie explizit setzen willst:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OIDC_ISSUER_URL=https://auth.forgecore.work/realms/listify
|
OIDC_ACCESS_TOKEN_AUDIENCE=<expected-access-token-audience>
|
||||||
OIDC_DISCOVERY_URL=https://auth.forgecore.work/realms/listify/.well-known/openid-configuration
|
```
|
||||||
|
|
||||||
|
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
|
## Run tests
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
@Entity('assistant_chat_logs')
|
@Entity('assistant_chat_logs')
|
||||||
export class AssistantChatLogEntity {
|
export class AssistantChatLogEntity {
|
||||||
|
|||||||
@@ -8,16 +8,19 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
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 { AssistantService } from './assistant.service';
|
||||||
import type { AuthenticatedRequest } from '../auth/auth.types';
|
import type { AuthenticatedRequest } from '../auth/auth.types';
|
||||||
import type { AssistantChatRequest } from './assistant.types';
|
import type { AssistantChatRequest } from './assistant.types';
|
||||||
|
|
||||||
@Controller('assistant')
|
@Controller('assistant')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
export class AssistantController {
|
export class AssistantController {
|
||||||
constructor(private readonly assistantService: AssistantService) {}
|
constructor(private readonly assistantService: AssistantService) {}
|
||||||
|
|
||||||
@Get('chat/logs')
|
@Get('chat/logs')
|
||||||
|
@RequirePermissions('assistant.logs.view')
|
||||||
listChatLogs(@Req() request: AuthenticatedRequest) {
|
listChatLogs(@Req() request: AuthenticatedRequest) {
|
||||||
const userId = request.user?.sub;
|
const userId = request.user?.sub;
|
||||||
|
|
||||||
@@ -29,6 +32,7 @@ export class AssistantController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('chat')
|
@Post('chat')
|
||||||
|
@RequirePermissions('assistant.chat')
|
||||||
chat(
|
chat(
|
||||||
@Req() request: AuthenticatedRequest,
|
@Req() request: AuthenticatedRequest,
|
||||||
@Body() body: AssistantChatRequest,
|
@Body() body: AssistantChatRequest,
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
@Entity('audit_logs')
|
@Entity('audit_logs')
|
||||||
export class AuditLogEntity {
|
export class AuditLogEntity {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { Repository } from 'typeorm';
|
|||||||
import { AuditLogEntity } from './audit-log.entity';
|
import { AuditLogEntity } from './audit-log.entity';
|
||||||
import type { AuditLogInput } from './audit-log.types';
|
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()
|
@Injectable()
|
||||||
export class AuditLogService {
|
export class AuditLogService {
|
||||||
@@ -60,7 +61,9 @@ export class AuditLogService {
|
|||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
||||||
key,
|
key,
|
||||||
SENSITIVE_KEY_PATTERN.test(key) ? '[redacted]' : this.sanitizeNestedValue(entry),
|
SENSITIVE_KEY_PATTERN.test(key)
|
||||||
|
? '[redacted]'
|
||||||
|
: this.sanitizeNestedValue(entry),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
listify-api/src/auth/app-permission.entity.ts
Normal file
20
listify-api/src/auth/app-permission.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
31
listify-api/src/auth/app-role-permission.entity.ts
Normal file
31
listify-api/src/auth/app-role-permission.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
20
listify-api/src/auth/app-role.entity.ts
Normal file
20
listify-api/src/auth/app-role.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -66,6 +66,10 @@ export class AuthController {
|
|||||||
user: JSON.stringify(authResponse.user),
|
user: JSON.stringify(authResponse.user),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (authResponse.idToken) {
|
||||||
|
fragment.set('idToken', authResponse.idToken);
|
||||||
|
}
|
||||||
|
|
||||||
response.redirect(`${redirectUrl.toString()}#${fragment.toString()}`);
|
response.redirect(`${redirectUrl.toString()}#${fragment.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +84,12 @@ export class AuthController {
|
|||||||
return this.authService.refresh(refreshTokenDto);
|
return this.authService.refresh(refreshTokenDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
logout(@Body() body: { refreshToken?: string; idTokenHint?: string }) {
|
||||||
|
return this.authService.logout(body);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
me(@Req() request: AuthenticatedRequest) {
|
me(@Req() request: AuthenticatedRequest) {
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ import { Module } from '@nestjs/common';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
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 { AuthController } from './auth.controller';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||||
import { AuthService } from './auth.service';
|
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 { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
import { McpAuthGuard } from './mcp-auth.guard';
|
import { McpAuthGuard } from './mcp-auth.guard';
|
||||||
import { OidcService } from './oidc.service';
|
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 { UserImpersonationEntity } from './user-impersonation.entity';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
@@ -19,12 +25,23 @@ import { UserEntity } from './user.entity';
|
|||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RefreshTokenEntity,
|
RefreshTokenEntity,
|
||||||
UserKeycloakGroupEntity,
|
UserOidcGroupEntity,
|
||||||
UserImpersonationEntity,
|
UserImpersonationEntity,
|
||||||
|
AppRoleEntity,
|
||||||
|
AppPermissionEntity,
|
||||||
|
AppRolePermissionEntity,
|
||||||
|
OidcGroupRoleMappingEntity,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, OidcService, JwtAuthGuard, McpAuthGuard],
|
providers: [
|
||||||
exports: [AuthService, JwtAuthGuard, McpAuthGuard],
|
AuthService,
|
||||||
|
AuthzSeedService,
|
||||||
|
OidcService,
|
||||||
|
JwtAuthGuard,
|
||||||
|
McpAuthGuard,
|
||||||
|
PermissionsGuard,
|
||||||
|
],
|
||||||
|
exports: [AuthService, JwtAuthGuard, McpAuthGuard, PermissionsGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
|||||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { AppRolePermissionEntity } from './app-role-permission.entity';
|
||||||
import { AuthTokenResponse, JwtTokenPayload } from './auth.types';
|
import { AuthTokenResponse, JwtTokenPayload } from './auth.types';
|
||||||
import { AuthService } from './auth.service';
|
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 { OidcProfile, OidcService } from './oidc.service';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
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 { UserImpersonationEntity } from './user-impersonation.entity';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||||
@@ -17,12 +20,16 @@ class FakeOidcService {
|
|||||||
email: 'User@Example.com',
|
email: 'User@Example.com',
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
groups: [],
|
groups: [],
|
||||||
|
idToken: 'id-token',
|
||||||
};
|
};
|
||||||
|
|
||||||
createAuthorizationUrl = jest.fn(
|
createAuthorizationUrl = jest.fn(() =>
|
||||||
async () => 'https://sso.example.test/authorize',
|
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', () => {
|
describe('AuthService', () => {
|
||||||
@@ -31,16 +38,21 @@ describe('AuthService', () => {
|
|||||||
let jwtService: JwtService;
|
let jwtService: JwtService;
|
||||||
let oidcService: FakeOidcService;
|
let oidcService: FakeOidcService;
|
||||||
let usersRepository: InMemoryRepository<UserEntity>;
|
let usersRepository: InMemoryRepository<UserEntity>;
|
||||||
let userKeycloakGroupsRepository: InMemoryRepository<UserKeycloakGroupEntity>;
|
let userOidcGroupsRepository: InMemoryRepository<UserOidcGroupEntity>;
|
||||||
let userImpersonationsRepository: InMemoryRepository<UserImpersonationEntity>;
|
let userImpersonationsRepository: InMemoryRepository<UserImpersonationEntity>;
|
||||||
|
let appRolePermissionsRepository: InMemoryRepository<AppRolePermissionEntity>;
|
||||||
|
let oidcGroupRoleMappingsRepository: InMemoryRepository<OidcGroupRoleMappingEntity>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
oidcService = new FakeOidcService();
|
oidcService = new FakeOidcService();
|
||||||
usersRepository = new InMemoryRepository<UserEntity>();
|
usersRepository = new InMemoryRepository<UserEntity>();
|
||||||
userKeycloakGroupsRepository =
|
userOidcGroupsRepository = new InMemoryRepository<UserOidcGroupEntity>();
|
||||||
new InMemoryRepository<UserKeycloakGroupEntity>();
|
|
||||||
userImpersonationsRepository =
|
userImpersonationsRepository =
|
||||||
new InMemoryRepository<UserImpersonationEntity>();
|
new InMemoryRepository<UserImpersonationEntity>();
|
||||||
|
appRolePermissionsRepository =
|
||||||
|
new InMemoryRepository<AppRolePermissionEntity>();
|
||||||
|
oidcGroupRoleMappingsRepository =
|
||||||
|
new InMemoryRepository<OidcGroupRoleMappingEntity>();
|
||||||
module = await Test.createTestingModule({
|
module = await Test.createTestingModule({
|
||||||
imports: [EventEmitterModule.forRoot(), JwtModule.register({})],
|
imports: [EventEmitterModule.forRoot(), JwtModule.register({})],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -58,19 +70,28 @@ describe('AuthService', () => {
|
|||||||
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(UserKeycloakGroupEntity),
|
provide: getRepositoryToken(UserOidcGroupEntity),
|
||||||
useValue: userKeycloakGroupsRepository,
|
useValue: userOidcGroupsRepository,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(UserImpersonationEntity),
|
provide: getRepositoryToken(UserImpersonationEntity),
|
||||||
useValue: userImpersonationsRepository,
|
useValue: userImpersonationsRepository,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(AppRolePermissionEntity),
|
||||||
|
useValue: appRolePermissionsRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(OidcGroupRoleMappingEntity),
|
||||||
|
useValue: oidcGroupRoleMappingsRepository,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
await module.init();
|
await module.init();
|
||||||
|
|
||||||
authService = module.get<AuthService>(AuthService);
|
authService = module.get<AuthService>(AuthService);
|
||||||
jwtService = module.get<JwtService>(JwtService);
|
jwtService = module.get<JwtService>(JwtService);
|
||||||
|
await seedRolePermissions();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -91,6 +112,9 @@ describe('AuthService', () => {
|
|||||||
expect(loginResponse.refreshToken).toBeDefined();
|
expect(loginResponse.refreshToken).toBeDefined();
|
||||||
expect(loginResponse.user.email).toBe('user@example.com');
|
expect(loginResponse.user.email).toBe('user@example.com');
|
||||||
expect(loginResponse.user.name).toBe('Test User');
|
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');
|
expect(oidcService.exchangeCallback).toHaveBeenCalledWith('code', 'state');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,6 +125,7 @@ describe('AuthService', () => {
|
|||||||
email: 'renamed@example.com',
|
email: 'renamed@example.com',
|
||||||
name: 'Renamed User',
|
name: 'Renamed User',
|
||||||
groups: [],
|
groups: [],
|
||||||
|
idToken: 'id-token',
|
||||||
};
|
};
|
||||||
|
|
||||||
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
||||||
@@ -117,6 +142,7 @@ describe('AuthService', () => {
|
|||||||
email: 'User@Example.com',
|
email: 'User@Example.com',
|
||||||
name: 'Linked User',
|
name: 'Linked User',
|
||||||
groups: [],
|
groups: [],
|
||||||
|
idToken: 'id-token',
|
||||||
};
|
};
|
||||||
|
|
||||||
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
const secondLogin = await authService.completeSsoLogin('code', 'state');
|
||||||
@@ -150,7 +176,43 @@ describe('AuthService', () => {
|
|||||||
expect(refreshPayload.jti).toBeDefined();
|
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 = [
|
oidcService.profile.groups = [
|
||||||
'/teams/engineering',
|
'/teams/engineering',
|
||||||
'/teams/admins',
|
'/teams/admins',
|
||||||
@@ -169,13 +231,14 @@ describe('AuthService', () => {
|
|||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
groups: ['/teams/support'],
|
groups: ['/teams/support'],
|
||||||
|
idToken: 'id-token',
|
||||||
};
|
};
|
||||||
|
|
||||||
const secondLoginResponse = await authService.completeSsoLogin(
|
const secondLoginResponse = await authService.completeSsoLogin(
|
||||||
'code',
|
'code',
|
||||||
'state',
|
'state',
|
||||||
);
|
);
|
||||||
const storedGroups = await userKeycloakGroupsRepository.find({
|
const storedGroups = await userOidcGroupsRepository.find({
|
||||||
where: { userId: secondLoginResponse.user.id },
|
where: { userId: secondLoginResponse.user.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -326,4 +389,36 @@ describe('AuthService', () => {
|
|||||||
}),
|
}),
|
||||||
)) as UserEntity;
|
)) as UserEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedRolePermissions(): Promise<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,26 +8,35 @@ import {
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
|
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 { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { AppRolePermissionEntity } from './app-role-permission.entity';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
import { ResendVerificationDto } from './dto/resend-verification.dto';
|
import { ResendVerificationDto } from './dto/resend-verification.dto';
|
||||||
import {
|
import {
|
||||||
|
AuthLogoutResponse,
|
||||||
AuthTokenResponse,
|
AuthTokenResponse,
|
||||||
AuthTokens,
|
AuthTokens,
|
||||||
JwtTokenPayload,
|
JwtTokenPayload,
|
||||||
PublicUser,
|
PublicUser,
|
||||||
PublicUserSearchResult,
|
PublicUserSearchResult,
|
||||||
} from './auth.types';
|
} from './auth.types';
|
||||||
|
import { DEFAULT_APP_ROLE } from './authz.constants';
|
||||||
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
||||||
|
import { OidcGroupRoleMappingEntity } from './oidc-group-role-mapping.entity';
|
||||||
import { OidcProfile, OidcService } from './oidc.service';
|
import { OidcProfile, OidcService } from './oidc.service';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
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 { UserImpersonationEntity } from './user-impersonation.entity';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
|
interface UserAuthorization {
|
||||||
|
roles: string[];
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly accessTokenExpiresIn = '7d';
|
private readonly accessTokenExpiresIn = '7d';
|
||||||
@@ -44,38 +53,46 @@ export class AuthService {
|
|||||||
private readonly usersRepository: Repository<UserEntity>,
|
private readonly usersRepository: Repository<UserEntity>,
|
||||||
@InjectRepository(RefreshTokenEntity)
|
@InjectRepository(RefreshTokenEntity)
|
||||||
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
|
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
|
||||||
@InjectRepository(UserKeycloakGroupEntity)
|
@InjectRepository(UserOidcGroupEntity)
|
||||||
private readonly userKeycloakGroupsRepository: Repository<UserKeycloakGroupEntity>,
|
private readonly userOidcGroupsRepository: Repository<UserOidcGroupEntity>,
|
||||||
@InjectRepository(UserImpersonationEntity)
|
@InjectRepository(UserImpersonationEntity)
|
||||||
private readonly userImpersonationsRepository: Repository<UserImpersonationEntity>,
|
private readonly userImpersonationsRepository: Repository<UserImpersonationEntity>,
|
||||||
|
@InjectRepository(AppRolePermissionEntity)
|
||||||
|
private readonly appRolePermissionsRepository: Repository<AppRolePermissionEntity>,
|
||||||
|
@InjectRepository(OidcGroupRoleMappingEntity)
|
||||||
|
private readonly oidcGroupRoleMappingsRepository: Repository<OidcGroupRoleMappingEntity>,
|
||||||
@Optional()
|
@Optional()
|
||||||
private readonly auditLogService?: AuditLogService,
|
private readonly auditLogService?: AuditLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(
|
register(
|
||||||
registerDto: RegisterDto,
|
registerDto: RegisterDto,
|
||||||
): Promise<{ message: string; user: PublicUser }> {
|
): Promise<{ message: string; user: PublicUser }> {
|
||||||
void registerDto;
|
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(
|
verifyEmail(token?: string): Promise<{ message: string; user: PublicUser }> {
|
||||||
token?: string,
|
|
||||||
): Promise<{ message: string; user: PublicUser }> {
|
|
||||||
void token;
|
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,
|
resendVerificationDto: ResendVerificationDto,
|
||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
void resendVerificationDto;
|
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<AuthTokenResponse> {
|
login(loginDto: LoginDto): Promise<AuthTokenResponse> {
|
||||||
void loginDto;
|
void loginDto;
|
||||||
throw new GoneException('Login is handled by SSO.');
|
return Promise.reject(new GoneException('Login is handled by SSO.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
startSsoLogin(): Promise<string> {
|
startSsoLogin(): Promise<string> {
|
||||||
@@ -86,7 +103,9 @@ export class AuthService {
|
|||||||
code?: string,
|
code?: string,
|
||||||
state?: string,
|
state?: string,
|
||||||
): Promise<AuthTokenResponse> {
|
): Promise<AuthTokenResponse> {
|
||||||
|
console.log("completesso")
|
||||||
const profile = await this.oidcService.exchangeCallback(code, state);
|
const profile = await this.oidcService.exchangeCallback(code, state);
|
||||||
|
console.log(profile)
|
||||||
const existingUser =
|
const existingUser =
|
||||||
(await this.usersRepository.findOne({
|
(await this.usersRepository.findOne({
|
||||||
where: { oidcSubject: profile.subject },
|
where: { oidcSubject: profile.subject },
|
||||||
@@ -95,10 +114,11 @@ export class AuthService {
|
|||||||
where: { email: this.normalizeEmail(profile.email) },
|
where: { email: this.normalizeEmail(profile.email) },
|
||||||
}));
|
}));
|
||||||
const user = await this.syncOidcUser(profile, existingUser);
|
const user = await this.syncOidcUser(profile, existingUser);
|
||||||
await this.syncKeycloakGroups(user.id, profile.groups);
|
await this.syncOidcGroups(user.id, profile.groups);
|
||||||
const response = {
|
const response = {
|
||||||
...(await this.createAuthTokens(user)),
|
...(await this.createAuthTokens(user)),
|
||||||
user: await this.toPublicUserWithGroups(
|
idToken: profile.idToken,
|
||||||
|
user: await this.toPublicUserWithAuthorization(
|
||||||
await this.resolveEffectiveUser(user),
|
await this.resolveEffectiveUser(user),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -115,6 +135,19 @@ export class AuthService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async logout(
|
||||||
|
body: {
|
||||||
|
refreshToken?: string;
|
||||||
|
idTokenHint?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<AuthLogoutResponse> {
|
||||||
|
await this.revokeRefreshToken(body.refreshToken);
|
||||||
|
|
||||||
|
return {
|
||||||
|
logoutUrl: await this.oidcService.createLogoutUrl(body.idTokenHint),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async refresh(
|
async refresh(
|
||||||
refreshTokenDto: RefreshTokenDto = {},
|
refreshTokenDto: RefreshTokenDto = {},
|
||||||
): Promise<AuthTokenResponse> {
|
): Promise<AuthTokenResponse> {
|
||||||
@@ -145,7 +178,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
...(await this.createAuthTokens(user)),
|
...(await this.createAuthTokens(user)),
|
||||||
user: await this.toPublicUserWithGroups(
|
user: await this.toPublicUserWithAuthorization(
|
||||||
await this.resolveEffectiveUser(user),
|
await this.resolveEffectiveUser(user),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -180,15 +213,23 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectiveUser = await this.resolveEffectiveUser(user);
|
const effectiveUser = await this.resolveEffectiveUser(user);
|
||||||
|
const authorization = await this.resolveUserAuthorization(
|
||||||
if (effectiveUser.id === user.id) {
|
effectiveUser.id,
|
||||||
return payload;
|
);
|
||||||
}
|
const authenticatedPayload: JwtTokenPayload = {
|
||||||
|
|
||||||
return {
|
|
||||||
...payload,
|
...payload,
|
||||||
sub: effectiveUser.id,
|
sub: effectiveUser.id,
|
||||||
email: effectiveUser.email,
|
email: effectiveUser.email,
|
||||||
|
roles: authorization.roles,
|
||||||
|
permissions: authorization.permissions,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (effectiveUser.id === user.id) {
|
||||||
|
return authenticatedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...authenticatedPayload,
|
||||||
impersonatorSub: user.id,
|
impersonatorSub: user.id,
|
||||||
impersonatorEmail: user.email,
|
impersonatorEmail: user.email,
|
||||||
};
|
};
|
||||||
@@ -218,7 +259,7 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Authenticated user is required.');
|
throw new UnauthorizedException('Authenticated user is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toPublicUserWithGroups(user);
|
return this.toPublicUserWithAuthorization(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchUsers(
|
async searchUsers(
|
||||||
@@ -278,7 +319,7 @@ export class AuthService {
|
|||||||
metadata: { completed },
|
metadata: { completed },
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.toPublicUserWithGroups(savedUser);
|
return this.toPublicUserWithAuthorization(savedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTaskDigestPreference(
|
async updateTaskDigestPreference(
|
||||||
@@ -306,7 +347,7 @@ export class AuthService {
|
|||||||
metadata: { taskDigestPreference: savedUser.taskDigestPreference },
|
metadata: { taskDigestPreference: savedUser.taskDigestPreference },
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.toPublicUserWithGroups(savedUser);
|
return this.toPublicUserWithAuthorization(savedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeEmail(email?: string): string {
|
private normalizeEmail(email?: string): string {
|
||||||
@@ -382,21 +423,21 @@ export class AuthService {
|
|||||||
return targetUser;
|
return targetUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncKeycloakGroups(
|
private async syncOidcGroups(
|
||||||
userId: string,
|
userId: string,
|
||||||
groups: string[],
|
groups: string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const normalizedGroups = this.normalizeGroupPaths(groups);
|
const normalizedGroups = this.normalizeGroupPaths(groups);
|
||||||
|
|
||||||
await this.userKeycloakGroupsRepository.delete({ userId });
|
await this.userOidcGroupsRepository.delete({ userId });
|
||||||
|
|
||||||
if (!normalizedGroups.length) {
|
if (!normalizedGroups.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.userKeycloakGroupsRepository.save(
|
await this.userOidcGroupsRepository.save(
|
||||||
normalizedGroups.map((groupPath) =>
|
normalizedGroups.map((groupPath) =>
|
||||||
this.userKeycloakGroupsRepository.create({
|
this.userOidcGroupsRepository.create({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
groupPath,
|
groupPath,
|
||||||
@@ -418,7 +459,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getUserGroupPaths(userId: string): Promise<string[]> {
|
private async getUserGroupPaths(userId: string): Promise<string[]> {
|
||||||
const groups = await this.userKeycloakGroupsRepository.find({
|
const groups = await this.userOidcGroupsRepository.find({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -427,6 +468,36 @@ export class AuthService {
|
|||||||
.sort((left, right) => left.localeCompare(right));
|
.sort((left, right) => left.localeCompare(right));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveUserAuthorization(
|
||||||
|
userId: string,
|
||||||
|
): Promise<UserAuthorization> {
|
||||||
|
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 {
|
private secretMatches(secret: string, storedSecretHash: string): boolean {
|
||||||
const [salt, storedHash] = storedSecretHash.split(':');
|
const [salt, storedHash] = storedSecretHash.split(':');
|
||||||
|
|
||||||
@@ -503,6 +574,19 @@ export class AuthService {
|
|||||||
return refreshToken;
|
return refreshToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async revokeRefreshToken(refreshToken?: string): Promise<void> {
|
||||||
|
if (!refreshToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = this.verifyRefreshToken(refreshToken);
|
||||||
|
await this.refreshTokensRepository.delete({ jti: payload.jti });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private hashToken(token: string): string {
|
private hashToken(token: string): string {
|
||||||
const salt = randomBytes(16).toString('hex');
|
const salt = randomBytes(16).toString('hex');
|
||||||
const hash = scryptSync(token, salt, 64).toString('hex');
|
const hash = scryptSync(token, salt, 64).toString('hex');
|
||||||
@@ -513,11 +597,28 @@ export class AuthService {
|
|||||||
return this.secretMatches(token, tokenHash);
|
return this.secretMatches(token, tokenHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async toPublicUserWithGroups(user: UserEntity): Promise<PublicUser> {
|
private async toPublicUserWithAuthorization(
|
||||||
return this.toPublicUser(user, await this.getUserGroupPaths(user.id));
|
user: UserEntity,
|
||||||
|
): Promise<PublicUser> {
|
||||||
|
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 {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -525,6 +626,8 @@ export class AuthService {
|
|||||||
onboardingCompleted: user.onboardingCompleted === true,
|
onboardingCompleted: user.onboardingCompleted === true,
|
||||||
taskDigestPreference: user.taskDigestPreference ?? 'both',
|
taskDigestPreference: user.taskDigestPreference ?? 'both',
|
||||||
groups,
|
groups,
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export interface AuthTokens {
|
|||||||
|
|
||||||
export interface AuthTokenResponse extends AuthTokens {
|
export interface AuthTokenResponse extends AuthTokens {
|
||||||
user: PublicUser;
|
user: PublicUser;
|
||||||
|
idToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthLogoutResponse {
|
||||||
|
logoutUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtTokenPayload {
|
export interface JwtTokenPayload {
|
||||||
@@ -17,6 +22,8 @@ export interface JwtTokenPayload {
|
|||||||
jti?: string;
|
jti?: string;
|
||||||
impersonatorSub?: string;
|
impersonatorSub?: string;
|
||||||
impersonatorEmail?: string;
|
impersonatorEmail?: string;
|
||||||
|
roles?: string[];
|
||||||
|
permissions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthenticatedRequest extends Request {
|
export interface AuthenticatedRequest extends Request {
|
||||||
@@ -30,6 +37,8 @@ export interface PublicUser {
|
|||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
taskDigestPreference: TaskDigestPreference;
|
taskDigestPreference: TaskDigestPreference;
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
roles: string[];
|
||||||
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicUserSearchResult {
|
export interface PublicUserSearchResult {
|
||||||
|
|||||||
195
listify-api/src/auth/authz-seed.service.ts
Normal file
195
listify-api/src/auth/authz-seed.service.ts
Normal file
@@ -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<AppRoleEntity>,
|
||||||
|
@InjectRepository(AppPermissionEntity)
|
||||||
|
private readonly appPermissionsRepository: Repository<AppPermissionEntity>,
|
||||||
|
@InjectRepository(AppRolePermissionEntity)
|
||||||
|
private readonly appRolePermissionsRepository: Repository<AppRolePermissionEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
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<AppPermissionEntity>,
|
||||||
|
value: string,
|
||||||
|
label: string,
|
||||||
|
description: string,
|
||||||
|
): AppPermissionEntity {
|
||||||
|
return repository.create({ permission: value, label, description });
|
||||||
|
}
|
||||||
|
|
||||||
|
function rolePermission(
|
||||||
|
repository: Repository<AppRolePermissionEntity>,
|
||||||
|
id: string,
|
||||||
|
role: string,
|
||||||
|
permissionValue: string,
|
||||||
|
): AppRolePermissionEntity {
|
||||||
|
return repository.create({ id, role, permission: permissionValue });
|
||||||
|
}
|
||||||
18
listify-api/src/auth/authz.constants.ts
Normal file
18
listify-api/src/auth/authz.constants.ts
Normal file
@@ -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;
|
||||||
@@ -3,12 +3,14 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { AppRolePermissionEntity } from './app-role-permission.entity';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthenticatedRequest } from './auth.types';
|
import { AuthenticatedRequest } from './auth.types';
|
||||||
|
import { OidcGroupRoleMappingEntity } from './oidc-group-role-mapping.entity';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
import { OidcService } from './oidc.service';
|
import { OidcService } from './oidc.service';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
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 { UserImpersonationEntity } from './user-impersonation.entity';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||||
@@ -27,15 +29,21 @@ describe('JwtAuthGuard', () => {
|
|||||||
{
|
{
|
||||||
provide: OidcService,
|
provide: OidcService,
|
||||||
useValue: {
|
useValue: {
|
||||||
createAuthorizationUrl: jest.fn(
|
createAuthorizationUrl: jest.fn(() =>
|
||||||
async () => 'https://sso.example.test/authorize',
|
Promise.resolve('https://sso.example.test/authorize'),
|
||||||
),
|
),
|
||||||
exchangeCallback: jest.fn(async () => ({
|
exchangeCallback: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
subject: 'oidc-user-1',
|
subject: 'oidc-user-1',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
groups: [],
|
groups: [],
|
||||||
})),
|
idToken: 'id-token',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
createLogoutUrl: jest.fn(() =>
|
||||||
|
Promise.resolve('https://sso.example.test/logout'),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -47,13 +55,21 @@ describe('JwtAuthGuard', () => {
|
|||||||
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(UserKeycloakGroupEntity),
|
provide: getRepositoryToken(UserOidcGroupEntity),
|
||||||
useValue: new InMemoryRepository<UserKeycloakGroupEntity>(),
|
useValue: new InMemoryRepository<UserOidcGroupEntity>(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(UserImpersonationEntity),
|
provide: getRepositoryToken(UserImpersonationEntity),
|
||||||
useValue: new InMemoryRepository<UserImpersonationEntity>(),
|
useValue: new InMemoryRepository<UserImpersonationEntity>(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(AppRolePermissionEntity),
|
||||||
|
useValue: new InMemoryRepository<AppRolePermissionEntity>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(OidcGroupRoleMappingEntity),
|
||||||
|
useValue: new InMemoryRepository<OidcGroupRoleMappingEntity>(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
await module.init();
|
await module.init();
|
||||||
|
|||||||
46
listify-api/src/auth/oidc-group-role-mapping.entity.ts
Normal file
46
listify-api/src/auth/oidc-group-role-mapping.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
250
listify-api/src/auth/oidc.service.spec.ts
Normal file
250
listify-api/src/auth/oidc.service.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { OidcService } from './oidc.service';
|
||||||
|
|
||||||
|
type TokenIntrospectionMock = Record<string, unknown> & {
|
||||||
|
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<unknown> },
|
||||||
|
'getJose',
|
||||||
|
)
|
||||||
|
.mockResolvedValue({ jwtVerify });
|
||||||
|
jest
|
||||||
|
.spyOn(
|
||||||
|
service as unknown as { getJwks: () => Promise<object> },
|
||||||
|
'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<string, string> {
|
||||||
|
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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { createHash, randomBytes } from 'crypto';
|
import { createHash, randomBytes } from 'crypto';
|
||||||
|
import type { JWTPayload } from 'jose';
|
||||||
|
|
||||||
type JoseModule = typeof import('jose');
|
type JoseModule = typeof import('jose');
|
||||||
type RemoteJwkSet = ReturnType<JoseModule['createRemoteJWKSet']>;
|
type RemoteJwkSet = ReturnType<JoseModule['createRemoteJWKSet']>;
|
||||||
@@ -12,15 +13,21 @@ export interface OidcProfile {
|
|||||||
subject: string;
|
subject: string;
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
preferredUsername?: string;
|
||||||
|
givenName?: string;
|
||||||
|
familyName?: string;
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
idToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OidcDiscovery {
|
interface OidcDiscovery {
|
||||||
authorization_endpoint: string;
|
authorization_endpoint: string;
|
||||||
token_endpoint: string;
|
token_endpoint: string;
|
||||||
|
introspection_endpoint?: string;
|
||||||
userinfo_endpoint?: string;
|
userinfo_endpoint?: string;
|
||||||
jwks_uri: string;
|
jwks_uri: string;
|
||||||
issuer: string;
|
issuer: string;
|
||||||
|
end_session_endpoint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingOidcState {
|
interface PendingOidcState {
|
||||||
@@ -36,6 +43,15 @@ interface TokenResponse {
|
|||||||
error_description?: string;
|
error_description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TokenIntrospectionResponse {
|
||||||
|
active?: boolean;
|
||||||
|
sub?: string;
|
||||||
|
iss?: string;
|
||||||
|
aud?: string | string[];
|
||||||
|
error?: string;
|
||||||
|
error_description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OidcService {
|
export class OidcService {
|
||||||
private readonly pendingStates = new Map<string, PendingOidcState>();
|
private readonly pendingStates = new Map<string, PendingOidcState>();
|
||||||
@@ -45,7 +61,7 @@ export class OidcService {
|
|||||||
|
|
||||||
async createAuthorizationUrl(): Promise<string> {
|
async createAuthorizationUrl(): Promise<string> {
|
||||||
const config = this.getConfig();
|
const config = this.getConfig();
|
||||||
const discovery = await this.getDiscovery(config.discoveryUrl);
|
const discovery = await this.getDiscovery(config);
|
||||||
const state = this.createOpaqueToken();
|
const state = this.createOpaqueToken();
|
||||||
const nonce = this.createOpaqueToken();
|
const nonce = this.createOpaqueToken();
|
||||||
const codeVerifier = this.createOpaqueToken();
|
const codeVerifier = this.createOpaqueToken();
|
||||||
@@ -61,8 +77,8 @@ export class OidcService {
|
|||||||
|
|
||||||
authorizationUrl.searchParams.set('response_type', 'code');
|
authorizationUrl.searchParams.set('response_type', 'code');
|
||||||
authorizationUrl.searchParams.set('client_id', config.clientId);
|
authorizationUrl.searchParams.set('client_id', config.clientId);
|
||||||
authorizationUrl.searchParams.set('redirect_uri', config.callbackUrl);
|
authorizationUrl.searchParams.set('redirect_uri', config.redirectUri);
|
||||||
authorizationUrl.searchParams.set('scope', config.scope);
|
authorizationUrl.searchParams.set('scope', config.scopes);
|
||||||
authorizationUrl.searchParams.set('state', state);
|
authorizationUrl.searchParams.set('state', state);
|
||||||
authorizationUrl.searchParams.set('nonce', nonce);
|
authorizationUrl.searchParams.set('nonce', nonce);
|
||||||
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
|
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
|
||||||
@@ -84,7 +100,7 @@ export class OidcService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = this.getConfig();
|
const config = this.getConfig();
|
||||||
const discovery = await this.getDiscovery(config.discoveryUrl);
|
const discovery = await this.getDiscovery(config);
|
||||||
const tokenResponse = await this.requestTokens(
|
const tokenResponse = await this.requestTokens(
|
||||||
discovery,
|
discovery,
|
||||||
config,
|
config,
|
||||||
@@ -92,12 +108,11 @@ export class OidcService {
|
|||||||
pendingState.codeVerifier,
|
pendingState.codeVerifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(tokenResponse)
|
if (!tokenResponse.id_token || !tokenResponse.access_token) {
|
||||||
if (!tokenResponse.id_token) {
|
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
tokenResponse.error_description ??
|
tokenResponse.error_description ??
|
||||||
tokenResponse.error ??
|
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.getJose(),
|
||||||
this.getJwks(discovery.jwks_uri),
|
this.getJwks(discovery.jwks_uri),
|
||||||
]);
|
]);
|
||||||
const { payload } = await jwtVerify(tokenResponse.id_token, jwks, {
|
const { payload: idTokenPayload } = await jwtVerify(
|
||||||
|
tokenResponse.id_token,
|
||||||
|
jwks,
|
||||||
|
{
|
||||||
issuer: discovery.issuer,
|
issuer: discovery.issuer,
|
||||||
audience: config.clientId,
|
audience: config.clientId,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (payload.nonce !== pendingState.nonce) {
|
if (idTokenPayload.nonce !== pendingState.nonce) {
|
||||||
throw new BadRequestException('OIDC nonce is invalid.');
|
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.');
|
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) {
|
if (!email) {
|
||||||
throw new BadRequestException('OIDC email claim is missing.');
|
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 {
|
return {
|
||||||
subject: payload.sub,
|
subject: idTokenPayload.sub,
|
||||||
email,
|
email,
|
||||||
name: typeof payload.name === 'string' ? payload.name : undefined,
|
name: this.displayName(mergedClaims, preferredUsername),
|
||||||
groups: idTokenGroups.length
|
preferredUsername,
|
||||||
? idTokenGroups
|
givenName,
|
||||||
: await this.requestUserInfoGroups(
|
familyName,
|
||||||
discovery.userinfo_endpoint,
|
groups,
|
||||||
tokenResponse.access_token,
|
idToken: tokenResponse.id_token,
|
||||||
config.groupsClaim,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createLogoutUrl(idTokenHint?: string): Promise<string> {
|
||||||
|
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(
|
private async requestTokens(
|
||||||
discovery: OidcDiscovery,
|
discovery: OidcDiscovery,
|
||||||
config: ReturnType<OidcService['getConfig']>,
|
config: ReturnType<OidcService['getConfig']>,
|
||||||
@@ -149,14 +208,12 @@ export class OidcService {
|
|||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
redirect_uri: config.callbackUrl,
|
redirect_uri: config.redirectUri,
|
||||||
client_id: config.clientId,
|
client_id: config.clientId,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.clientSecret) {
|
this.addClientAuthentication(body, config);
|
||||||
body.set('client_secret', config.clientSecret);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(discovery.token_endpoint, {
|
const response = await fetch(discovery.token_endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -176,18 +233,81 @@ export class OidcService {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getDiscovery(discoveryUrl: string): Promise<OidcDiscovery> {
|
private async introspectAccessToken(
|
||||||
|
discovery: OidcDiscovery,
|
||||||
|
config: ReturnType<OidcService['getConfig']>,
|
||||||
|
accessToken: string,
|
||||||
|
expectedSubject: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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<OidcService['getConfig']>,
|
||||||
|
): Promise<OidcDiscovery> {
|
||||||
if (this.discovery) {
|
if (this.discovery) {
|
||||||
return this.discovery;
|
return this.discovery;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(discoveryUrl);
|
const response = await fetch(config.discoveryUrl);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ServiceUnavailableException('OIDC discovery failed.');
|
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;
|
return this.discovery;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,37 +324,38 @@ export class OidcService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getConfig() {
|
private getConfig() {
|
||||||
const issuerUrl = process.env.OIDC_ISSUER_URL;
|
const issuer = this.normalizeIssuer(
|
||||||
const explicitDiscoveryUrl = process.env.OIDC_DISCOVERY_URL;
|
process.env.OIDC_ISSUER ?? process.env.OIDC_ISSUER_URL,
|
||||||
|
);
|
||||||
const clientId = process.env.OIDC_CLIENT_ID;
|
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(
|
throw new ServiceUnavailableException(
|
||||||
'OIDC configuration is incomplete.',
|
'OIDC configuration is incomplete. Required: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
issuerUrl,
|
issuer,
|
||||||
discoveryUrl:
|
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
||||||
explicitDiscoveryUrl ??
|
|
||||||
`${issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`,
|
|
||||||
clientId,
|
clientId,
|
||||||
callbackUrl,
|
redirectUri,
|
||||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
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',
|
groupsClaim: process.env.OIDC_GROUPS_CLAIM ?? 'groups',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requestUserInfoGroups(
|
private async requestUserInfo(
|
||||||
userInfoEndpoint: string | undefined,
|
userInfoEndpoint: string | undefined,
|
||||||
accessToken: string | undefined,
|
accessToken: string | undefined,
|
||||||
groupsClaim: string,
|
): Promise<Record<string, unknown>> {
|
||||||
): Promise<string[]> {
|
|
||||||
if (!userInfoEndpoint || !accessToken) {
|
if (!userInfoEndpoint || !accessToken) {
|
||||||
return [];
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(userInfoEndpoint, {
|
const response = await fetch(userInfoEndpoint, {
|
||||||
@@ -242,15 +363,41 @@ export class OidcService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return [];
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json().catch(() => ({}))) as Record<
|
return (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
string,
|
}
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
|
|
||||||
return this.extractGroups(payload, groupsClaim);
|
private addClientAuthentication(
|
||||||
|
body: URLSearchParams,
|
||||||
|
config: ReturnType<OidcService['getConfig']>,
|
||||||
|
): void {
|
||||||
|
body.set('client_id', config.clientId);
|
||||||
|
|
||||||
|
if (config.clientSecret) {
|
||||||
|
body.set('client_secret', config.clientSecret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateUserInfoSubject(
|
||||||
|
userInfo: Record<string, unknown>,
|
||||||
|
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(
|
private extractGroups(
|
||||||
@@ -270,6 +417,31 @@ export class OidcService {
|
|||||||
.sort((left, right) => left.localeCompare(right));
|
.sort((left, right) => left.localeCompare(right));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private displayName(
|
||||||
|
payload: JWTPayload | Record<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
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 {
|
private createOpaqueToken(): string {
|
||||||
return randomBytes(32).toString('base64url');
|
return randomBytes(32).toString('base64url');
|
||||||
}
|
}
|
||||||
|
|||||||
67
listify-api/src/auth/permissions.guard.spec.ts
Normal file
67
listify-api/src/auth/permissions.guard.spec.ts
Normal file
@@ -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<AuthenticatedRequest> = {},
|
||||||
|
): ExecutionContext {
|
||||||
|
return {
|
||||||
|
getHandler: () => undefined,
|
||||||
|
getClass: () => undefined,
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => request,
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
}
|
||||||
|
});
|
||||||
39
listify-api/src/auth/permissions.guard.ts
Normal file
39
listify-api/src/auth/permissions.guard.ts
Normal file
@@ -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<AppPermission[]>(
|
||||||
|
REQUIRED_PERMISSIONS_METADATA_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
if (!requiredPermissions.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
listify-api/src/auth/require-permissions.decorator.ts
Normal file
7
listify-api/src/auth/require-permissions.decorator.ts
Normal file
@@ -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);
|
||||||
@@ -10,11 +10,11 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
@Entity('user_keycloak_groups')
|
@Entity('user_oidc_groups')
|
||||||
@Index('IDX_user_keycloak_groups_user_group', ['userId', 'groupPath'], {
|
@Index('IDX_user_oidc_groups_user_group', ['userId', 'groupPath'], {
|
||||||
unique: true,
|
unique: true,
|
||||||
})
|
})
|
||||||
export class UserKeycloakGroupEntity {
|
export class UserOidcGroupEntity {
|
||||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@@ -3,9 +3,13 @@ import 'reflect-metadata';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { AssistantChatLogEntity } from '../assistant/assistant-chat-log.entity';
|
import { AssistantChatLogEntity } from '../assistant/assistant-chat-log.entity';
|
||||||
import { AuditLogEntity } from '../audit/audit-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 { 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 { UserImpersonationEntity } from '../auth/user-impersonation.entity';
|
||||||
|
import { OidcGroupRoleMappingEntity } from '../auth/oidc-group-role-mapping.entity';
|
||||||
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
|
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
|
||||||
import { DailyDashboardSnapshotEntity } from '../dashboard/daily-dashboard-snapshot.entity';
|
import { DailyDashboardSnapshotEntity } from '../dashboard/daily-dashboard-snapshot.entity';
|
||||||
import { WeeklyListSuggestionSnapshotEntity } from '../dashboard/weekly-list-suggestion-snapshot.entity';
|
import { WeeklyListSuggestionSnapshotEntity } from '../dashboard/weekly-list-suggestion-snapshot.entity';
|
||||||
@@ -37,10 +41,14 @@ export default new DataSource({
|
|||||||
maxQueryExecutionTime: slowQueryThresholdFromEnv(process.env),
|
maxQueryExecutionTime: slowQueryThresholdFromEnv(process.env),
|
||||||
entities: [
|
entities: [
|
||||||
AssistantChatLogEntity,
|
AssistantChatLogEntity,
|
||||||
|
AppRoleEntity,
|
||||||
|
AppPermissionEntity,
|
||||||
|
AppRolePermissionEntity,
|
||||||
AuditLogEntity,
|
AuditLogEntity,
|
||||||
DailyDashboardSnapshotEntity,
|
DailyDashboardSnapshotEntity,
|
||||||
|
OidcGroupRoleMappingEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
UserKeycloakGroupEntity,
|
UserOidcGroupEntity,
|
||||||
UserImpersonationEntity,
|
UserImpersonationEntity,
|
||||||
RefreshTokenEntity,
|
RefreshTokenEntity,
|
||||||
ListTemplateEntity,
|
ListTemplateEntity,
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ export function parseDatabaseLogging(value?: string | boolean): LoggerOptions {
|
|||||||
|
|
||||||
const normalizedValue = value?.trim().toLowerCase();
|
const normalizedValue = value?.trim().toLowerCase();
|
||||||
|
|
||||||
if (!normalizedValue || normalizedValue === 'false' || normalizedValue === 'off') {
|
if (
|
||||||
|
!normalizedValue ||
|
||||||
|
normalizedValue === 'false' ||
|
||||||
|
normalizedValue === 'off'
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import type { Logger as TypeOrmLogger, QueryRunner } from 'typeorm';
|
|||||||
import type { DatabaseLoggerOptions } from './database-logging.config';
|
import type { DatabaseLoggerOptions } from './database-logging.config';
|
||||||
|
|
||||||
const REDACTED = '[redacted]';
|
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 {
|
export class DatabaseLogger implements TypeOrmLogger {
|
||||||
private readonly logger = new NestLogger('Database');
|
private readonly logger = new NestLogger('Database');
|
||||||
@@ -15,7 +16,9 @@ export class DatabaseLogger implements TypeOrmLogger {
|
|||||||
parameters?: unknown[],
|
parameters?: unknown[],
|
||||||
queryRunner?: QueryRunner,
|
queryRunner?: QueryRunner,
|
||||||
): void {
|
): void {
|
||||||
this.logger.debug(this.formatMessage('query', query, parameters, queryRunner));
|
this.logger.debug(
|
||||||
|
this.formatMessage('query', query, parameters, queryRunner),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logQueryError(
|
logQueryError(
|
||||||
@@ -48,11 +51,15 @@ export class DatabaseLogger implements TypeOrmLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logSchemaBuild(message: string, queryRunner?: QueryRunner): void {
|
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 {
|
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(
|
log(
|
||||||
@@ -60,7 +67,12 @@ export class DatabaseLogger implements TypeOrmLogger {
|
|||||||
message: string,
|
message: string,
|
||||||
queryRunner?: QueryRunner,
|
queryRunner?: QueryRunner,
|
||||||
): void {
|
): void {
|
||||||
const formattedMessage = this.formatMessage(level, message, undefined, queryRunner);
|
const formattedMessage = this.formatMessage(
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
undefined,
|
||||||
|
queryRunner,
|
||||||
|
);
|
||||||
|
|
||||||
if (level === 'warn') {
|
if (level === 'warn') {
|
||||||
this.logger.warn(formattedMessage);
|
this.logger.warn(formattedMessage);
|
||||||
@@ -108,12 +120,17 @@ export class DatabaseLogger implements TypeOrmLogger {
|
|||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
||||||
key,
|
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)}...`;
|
return `${value.slice(0, this.options.maxParameterLength)}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +1,159 @@
|
|||||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class GeneratedMigration1780932637916 implements MigrationInterface {
|
export class GeneratedMigration1780932637916 implements MigrationInterface {
|
||||||
name = 'GeneratedMigration1780932637916'
|
name = 'GeneratedMigration1780932637916';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_list_template_items_template_id\``);
|
await queryRunner.query(
|
||||||
await queryRunner.query(`ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_list_templates_owner_id\``);
|
`ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_list_template_items_template_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(
|
||||||
await queryRunner.query(`ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_refresh_tokens_user_id\``);
|
`ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_list_templates_owner_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(
|
||||||
await queryRunner.query(`DROP INDEX \`IDX_list_templates_owner_id\` ON \`list_templates\``);
|
`ALTER TABLE \`user_list_items\` DROP FOREIGN KEY \`FK_user_list_items_list_id\``,
|
||||||
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(
|
||||||
await queryRunner.query(`DROP INDEX \`IDX_refresh_tokens_user_id\` ON \`refresh_tokens\``);
|
`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_email\` ON \`users\``);
|
||||||
await queryRunner.query(`DROP INDEX \`IDX_users_verification_token\` ON \`users\``);
|
await queryRunner.query(
|
||||||
await queryRunner.query(`ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_97672ac88f789774dd47f7c8be\` (\`email\`)`);
|
`DROP INDEX \`IDX_users_verification_token\` ON \`users\``,
|
||||||
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(
|
||||||
await queryRunner.query(`CREATE INDEX \`IDX_dca36cb201077233743d7355d2\` ON \`list_templates\` (\`ownerId\`)`);
|
`ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_97672ac88f789774dd47f7c8be\` (\`email\`)`,
|
||||||
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(
|
||||||
await queryRunner.query(`CREATE INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\` (\`userId\`)`);
|
`ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_945333aaddfc5b9021b2ee94d5\` (\`verificationToken\`)`,
|
||||||
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(
|
||||||
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`);
|
`CREATE INDEX \`IDX_82feb6202f10c7f7283d398014\` ON \`list_template_items\` (\`templateId\`)`,
|
||||||
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`);
|
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<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_610102b60fea1455310ccd299de\``);
|
await queryRunner.query(
|
||||||
await queryRunner.query(`ALTER TABLE \`user_lists\` DROP FOREIGN KEY \`FK_20f32f84af2f8a3aa60d7023260\``);
|
`ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_610102b60fea1455310ccd299de\``,
|
||||||
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(
|
||||||
await queryRunner.query(`ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_82feb6202f10c7f7283d3980144\``);
|
`ALTER TABLE \`user_lists\` DROP FOREIGN KEY \`FK_20f32f84af2f8a3aa60d7023260\``,
|
||||||
await queryRunner.query(`DROP INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\``);
|
);
|
||||||
await queryRunner.query(`DROP INDEX \`IDX_20f32f84af2f8a3aa60d702326\` ON \`user_lists\``);
|
await queryRunner.query(
|
||||||
await queryRunner.query(`DROP INDEX \`IDX_7dc61846f78234b1701413206d\` ON \`user_list_items\``);
|
`ALTER TABLE \`user_list_items\` DROP FOREIGN KEY \`FK_7dc61846f78234b1701413206df\``,
|
||||||
await queryRunner.query(`DROP INDEX \`IDX_dca36cb201077233743d7355d2\` ON \`list_templates\``);
|
);
|
||||||
await queryRunner.query(`DROP INDEX \`IDX_82feb6202f10c7f7283d398014\` ON \`list_template_items\``);
|
await queryRunner.query(
|
||||||
await queryRunner.query(`ALTER TABLE \`users\` DROP INDEX \`IDX_945333aaddfc5b9021b2ee94d5\``);
|
`ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_dca36cb201077233743d7355d21\``,
|
||||||
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(
|
||||||
await queryRunner.query(`CREATE UNIQUE INDEX \`IDX_users_email\` ON \`users\` (\`email\`)`);
|
`ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_82feb6202f10c7f7283d3980144\``,
|
||||||
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(
|
||||||
await queryRunner.query(`CREATE INDEX \`IDX_user_list_items_list_id\` ON \`user_list_items\` (\`listId\`)`);
|
`DROP INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\``,
|
||||||
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(
|
||||||
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`);
|
`DROP INDEX \`IDX_20f32f84af2f8a3aa60d702326\` ON \`user_lists\``,
|
||||||
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(
|
||||||
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`);
|
`DROP INDEX \`IDX_7dc61846f78234b1701413206d\` ON \`user_list_items\``,
|
||||||
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`);
|
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`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class AddUserOnboardingCompleted1781000000000
|
export class AddUserOnboardingCompleted1781000000000 implements MigrationInterface {
|
||||||
implements MigrationInterface
|
|
||||||
{
|
|
||||||
name = 'AddUserOnboardingCompleted1781000000000';
|
name = 'AddUserOnboardingCompleted1781000000000';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class GeneratedMigration1781003163444 implements MigrationInterface {
|
export class GeneratedMigration1781003163444 implements MigrationInterface {
|
||||||
name = 'GeneratedMigration1781003163444'
|
name = 'GeneratedMigration1781003163444';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`ALTER TABLE \`users\` ADD \`onboardingCompleted\` tinyint NOT NULL DEFAULT 0`);
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE \`users\` ADD \`onboardingCompleted\` tinyint NOT NULL DEFAULT 0`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`ALTER TABLE \`users\` DROP COLUMN \`onboardingCompleted\``);
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE \`users\` DROP COLUMN \`onboardingCompleted\``,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class GeneratedMigration1781093004780 implements MigrationInterface {
|
export class GeneratedMigration1781093004780 implements MigrationInterface {
|
||||||
name = 'GeneratedMigration1781093004780'
|
name = 'GeneratedMigration1781093004780';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
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`);
|
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<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`DROP INDEX \`IDX_audit_logs_created_at\` ON \`audit_logs\``);
|
await queryRunner.query(
|
||||||
await queryRunner.query(`DROP INDEX \`IDX_audit_logs_entity\` ON \`audit_logs\``);
|
`DROP INDEX \`IDX_audit_logs_created_at\` 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 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\``);
|
await queryRunner.query(`DROP TABLE \`audit_logs\``);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class AddSoftDeleteToListsAndTemplates1781300000000
|
export class AddSoftDeleteToListsAndTemplates1781300000000 implements MigrationInterface {
|
||||||
implements MigrationInterface
|
|
||||||
{
|
|
||||||
name = 'AddSoftDeleteToListsAndTemplates1781300000000';
|
name = 'AddSoftDeleteToListsAndTemplates1781300000000';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
@@ -27,7 +25,9 @@ export class AddSoftDeleteToListsAndTemplates1781300000000
|
|||||||
await queryRunner.query(
|
await queryRunner.query(
|
||||||
'DROP INDEX `IDX_user_lists_deleted_at` ON `user_lists`',
|
'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`');
|
await queryRunner.query('ALTER TABLE `user_lists` DROP COLUMN `deletedAt`');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export class AddListReminderAt1781400000000 implements MigrationInterface {
|
|||||||
await queryRunner.query(
|
await queryRunner.query(
|
||||||
'DROP INDEX `IDX_user_lists_reminder_at` ON `user_lists`',
|
'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`',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class CreateListTemplateShares1781500000000
|
export class CreateListTemplateShares1781500000000 implements MigrationInterface {
|
||||||
implements MigrationInterface
|
|
||||||
{
|
|
||||||
name = 'CreateListTemplateShares1781500000000';
|
name = 'CreateListTemplateShares1781500000000';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(
|
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(
|
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',
|
'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',
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ export class AddMcpApiKeyToUsers1781600000000 implements MigrationInterface {
|
|||||||
await queryRunner.query(
|
await queryRunner.query(
|
||||||
'DROP INDEX `IDX_users_mcp_api_key_hash` ON `users`',
|
'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')) {
|
if (await queryRunner.hasColumn('users', 'mcpApiKeyCreatedAt')) {
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class CreateUserKeycloakGroups1782400000000 implements MigrationInterface {
|
export class CreateUserOidcGroups1782400000000 implements MigrationInterface {
|
||||||
name = 'CreateUserKeycloakGroups1782400000000';
|
name = 'CreateUserOidcGroups1782400000000';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
if (await queryRunner.hasTable('user_keycloak_groups')) {
|
if (await queryRunner.hasTable('user_oidc_groups')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
CREATE TABLE \`user_keycloak_groups\` (
|
CREATE TABLE \`user_oidc_groups\` (
|
||||||
\`id\` varchar(36) NOT NULL,
|
\`id\` varchar(36) NOT NULL,
|
||||||
\`userId\` varchar(36) NOT NULL,
|
\`userId\` varchar(36) NOT NULL,
|
||||||
\`groupPath\` varchar(255) NOT NULL,
|
\`groupPath\` varchar(255) NOT NULL,
|
||||||
\`groupName\` varchar(160) NOT NULL,
|
\`groupName\` varchar(160) NOT NULL,
|
||||||
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
\`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
\`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
INDEX \`IDX_user_keycloak_groups_userId\` (\`userId\`),
|
INDEX \`IDX_user_oidc_groups_userId\` (\`userId\`),
|
||||||
UNIQUE INDEX \`IDX_user_keycloak_groups_user_group\` (\`userId\`, \`groupPath\`),
|
UNIQUE INDEX \`IDX_user_oidc_groups_user_group\` (\`userId\`, \`groupPath\`),
|
||||||
CONSTRAINT \`FK_user_keycloak_groups_user\`
|
CONSTRAINT \`FK_user_oidc_groups_user\`
|
||||||
FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE,
|
FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE,
|
||||||
PRIMARY KEY (\`id\`)
|
PRIMARY KEY (\`id\`)
|
||||||
) ENGINE=InnoDB
|
) ENGINE=InnoDB
|
||||||
@@ -26,8 +26,8 @@ export class CreateUserKeycloakGroups1782400000000 implements MigrationInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
if (await queryRunner.hasTable('user_keycloak_groups')) {
|
if (await queryRunner.hasTable('user_oidc_groups')) {
|
||||||
await queryRunner.query('DROP TABLE `user_keycloak_groups`');
|
await queryRunner.query('DROP TABLE `user_oidc_groups`');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateAppRolesAndPermissions1782500000000 implements MigrationInterface {
|
||||||
|
name = 'CreateAppRolesAndPermissions1782500000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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`');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,16 +38,21 @@ describe('ListTemplatesService', () => {
|
|||||||
it('keeps seeded templates editable and deleted templates deleted', async () => {
|
it('keeps seeded templates editable and deleted templates deleted', async () => {
|
||||||
const template = (await service.listTemplates('user-1'))[0];
|
const template = (await service.listTemplates('user-1'))[0];
|
||||||
|
|
||||||
const updatedTemplate = await service.updateTemplate('user-1', template.id, {
|
const updatedTemplate = await service.updateTemplate(
|
||||||
|
'user-1',
|
||||||
|
template.id,
|
||||||
|
{
|
||||||
name: 'Meine Vorlage',
|
name: 'Meine Vorlage',
|
||||||
});
|
},
|
||||||
|
);
|
||||||
await service.deleteTemplate('user-1', template.id);
|
await service.deleteTemplate('user-1', template.id);
|
||||||
|
|
||||||
expect(updatedTemplate.name).toBe('Meine Vorlage');
|
expect(updatedTemplate.name).toBe('Meine Vorlage');
|
||||||
await expect(service.listTemplates('user-1')).resolves.toHaveLength(2);
|
await expect(service.listTemplates('user-1')).resolves.toHaveLength(2);
|
||||||
expect(
|
expect(
|
||||||
(await service.listTemplates('user-1'))
|
(await service.listTemplates('user-1')).some(
|
||||||
.some((existingTemplate) => existingTemplate.id === template.id),
|
(existingTemplate) => existingTemplate.id === template.id,
|
||||||
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
await expect(service.getTemplate('user-1', template.id)).rejects.toThrow(
|
await expect(service.getTemplate('user-1', template.id)).rejects.toThrow(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -79,12 +84,14 @@ describe('ListTemplatesService', () => {
|
|||||||
expect(template.items).toHaveLength(2);
|
expect(template.items).toHaveLength(2);
|
||||||
expect(template.items[0].title).toBe('Pass');
|
expect(template.items[0].title).toBe('Pass');
|
||||||
expect(
|
expect(
|
||||||
(await service.listTemplates('user-1'))
|
(await service.listTemplates('user-1')).some(
|
||||||
.some((existingTemplate) => existingTemplate.id === template.id),
|
(existingTemplate) => existingTemplate.id === template.id,
|
||||||
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
(await service.listTemplates('user-2'))
|
(await service.listTemplates('user-2')).some(
|
||||||
.some((existingTemplate) => existingTemplate.id === template.id),
|
(existingTemplate) => existingTemplate.id === template.id,
|
||||||
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,7 +111,10 @@ describe('ListTemplatesService', () => {
|
|||||||
const sharedTemplate = await service.shareTemplate('user-1', template.id, {
|
const sharedTemplate = await service.shareTemplate('user-1', template.id, {
|
||||||
userId: 'user-2',
|
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, {
|
const updatedByCollaborator = await service.addItem('user-2', template.id, {
|
||||||
title: 'Vom Collaborator',
|
title: 'Vom Collaborator',
|
||||||
});
|
});
|
||||||
@@ -142,9 +152,13 @@ describe('ListTemplatesService', () => {
|
|||||||
});
|
});
|
||||||
const itemId = template.items[0].id;
|
const itemId = template.items[0].id;
|
||||||
|
|
||||||
const updatedTemplate = await service.updateTemplate('user-1', template.id, {
|
const updatedTemplate = await service.updateTemplate(
|
||||||
|
'user-1',
|
||||||
|
template.id,
|
||||||
|
{
|
||||||
name: 'Wocheneinkauf',
|
name: 'Wocheneinkauf',
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const updatedItemTemplate = await service.updateItem(
|
const updatedItemTemplate = await service.updateItem(
|
||||||
'user-1',
|
'user-1',
|
||||||
template.id,
|
template.id,
|
||||||
@@ -196,13 +210,17 @@ describe('ListTemplatesService', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const reorderedTemplate = await service.reorderItems('user-1', template.id, {
|
const reorderedTemplate = await service.reorderItems(
|
||||||
|
'user-1',
|
||||||
|
template.id,
|
||||||
|
{
|
||||||
itemIds: [
|
itemIds: [
|
||||||
template.items[2].id,
|
template.items[2].id,
|
||||||
template.items[0].id,
|
template.items[0].id,
|
||||||
template.items[1].id,
|
template.items[1].id,
|
||||||
],
|
],
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const reloadedTemplate = await service.getTemplate('user-1', template.id);
|
const reloadedTemplate = await service.getTemplate('user-1', template.id);
|
||||||
|
|
||||||
expect(reorderedTemplate.items.map((item) => item.title)).toEqual([
|
expect(reorderedTemplate.items.map((item) => item.title)).toEqual([
|
||||||
@@ -211,9 +229,7 @@ describe('ListTemplatesService', () => {
|
|||||||
'Zweiter Schritt',
|
'Zweiter Schritt',
|
||||||
]);
|
]);
|
||||||
expect(reloadedTemplate.items.map((item) => item.position)).toEqual([
|
expect(reloadedTemplate.items.map((item) => item.position)).toEqual([
|
||||||
0,
|
0, 1, 2,
|
||||||
1,
|
|
||||||
2,
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -233,9 +249,7 @@ describe('ListTemplatesService', () => {
|
|||||||
it('rejects invalid input and missing resources', async () => {
|
it('rejects invalid input and missing resources', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
service.createTemplate('user-1', { name: ' ' }),
|
service.createTemplate('user-1', { name: ' ' }),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow('List template name is required.');
|
||||||
'List template name is required.',
|
|
||||||
);
|
|
||||||
await expect(
|
await expect(
|
||||||
service.createTemplate('user-1', {
|
service.createTemplate('user-1', {
|
||||||
name: 'Ungueltig',
|
name: 'Ungueltig',
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ export class ListTemplatesService {
|
|||||||
});
|
});
|
||||||
const sharedTemplateShares = await this.templateSharesRepository.find({
|
const sharedTemplateShares = await this.templateSharesRepository.find({
|
||||||
where: { userId: ownerId },
|
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<string, ListTemplateEntity>();
|
const templatesById = new Map<string, ListTemplateEntity>();
|
||||||
|
|
||||||
@@ -210,7 +212,9 @@ export class ListTemplatesService {
|
|||||||
const targetUserId = this.requireShareUserId(shareDto.userId);
|
const targetUserId = this.requireShareUserId(shareDto.userId);
|
||||||
|
|
||||||
if (targetUserId === ownerId) {
|
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({
|
const targetUser = await this.usersRepository.findOne({
|
||||||
@@ -365,7 +369,9 @@ export class ListTemplatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (itemIds.length !== template.items.length) {
|
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);
|
const uniqueItemIds = new Set(itemIds);
|
||||||
@@ -378,7 +384,9 @@ export class ListTemplatesService {
|
|||||||
const item = itemsById.get(itemId);
|
const item = itemsById.get(itemId);
|
||||||
|
|
||||||
if (!item) {
|
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;
|
item.position = index;
|
||||||
@@ -469,7 +477,9 @@ export class ListTemplatesService {
|
|||||||
const template = await this.findAccessibleTemplate(ownerId, templateId);
|
const template = await this.findAccessibleTemplate(ownerId, templateId);
|
||||||
|
|
||||||
if (template.ownerId !== ownerId) {
|
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;
|
return template;
|
||||||
@@ -658,7 +668,10 @@ export class ListTemplatesService {
|
|||||||
return normalizedUserId;
|
return normalizedUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private canAccessTemplate(template: ListTemplateEntity, userId: string): boolean {
|
private canAccessTemplate(
|
||||||
|
template: ListTemplateEntity,
|
||||||
|
userId: string,
|
||||||
|
): boolean {
|
||||||
return (
|
return (
|
||||||
template.ownerId === userId ||
|
template.ownerId === userId ||
|
||||||
Boolean(template.shares?.some((share) => share.userId === userId))
|
Boolean(template.shares?.some((share) => share.userId === userId))
|
||||||
@@ -675,7 +688,8 @@ export class ListTemplatesService {
|
|||||||
private async hydrateTemplateAccessRelations(
|
private async hydrateTemplateAccessRelations(
|
||||||
template: ListTemplateEntity,
|
template: ListTemplateEntity,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
template.owner ??= (await this.usersRepository.findOne({
|
template.owner ??=
|
||||||
|
(await this.usersRepository.findOne({
|
||||||
where: { id: template.ownerId },
|
where: { id: template.ownerId },
|
||||||
})) ?? undefined;
|
})) ?? undefined;
|
||||||
|
|
||||||
@@ -686,7 +700,8 @@ export class ListTemplatesService {
|
|||||||
template.shares = storedShares;
|
template.shares = storedShares;
|
||||||
|
|
||||||
for (const share of template.shares) {
|
for (const share of template.shares) {
|
||||||
share.user ??= (await this.usersRepository.findOne({
|
share.user ??=
|
||||||
|
(await this.usersRepository.findOne({
|
||||||
where: { id: share.userId },
|
where: { id: share.userId },
|
||||||
})) ?? undefined;
|
})) ?? undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export type ListRealtimeEvent =
|
|||||||
export class ListRealtimeService {
|
export class ListRealtimeService {
|
||||||
// In-memory SSE fanout for one API process. If the API is scaled horizontally,
|
// 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.
|
// replace this map with a shared pub/sub backend while keeping the event shape.
|
||||||
private readonly channels = new Map<string, Set<Observer<ListRealtimeEvent>>>();
|
private readonly channels = new Map<
|
||||||
|
string,
|
||||||
|
Set<Observer<ListRealtimeEvent>>
|
||||||
|
>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens an owner-scoped stream. The controller authenticates the request before
|
* Opens an owner-scoped stream. The controller authenticates the request before
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ describe('ListReminderService', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect((await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt)
|
expect(
|
||||||
.toBeNull();
|
(await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt,
|
||||||
|
).toBeNull();
|
||||||
expect(listsService.publishListSnapshot).toHaveBeenCalledWith(list.id);
|
expect(listsService.publishListSnapshot).toHaveBeenCalledWith(list.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,8 +74,9 @@ describe('ListReminderService', () => {
|
|||||||
await service.processDueReminders(new Date('2026-06-17T09:00:00.000Z'));
|
await service.processDueReminders(new Date('2026-06-17T09:00:00.000Z'));
|
||||||
|
|
||||||
expect(mailService.sendListReminderEmail).not.toHaveBeenCalled();
|
expect(mailService.sendListReminderEmail).not.toHaveBeenCalled();
|
||||||
expect((await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt)
|
expect(
|
||||||
.toBeNull();
|
(await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt,
|
||||||
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the reminder when sending fails', async () => {
|
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'));
|
await service.processDueReminders(new Date('2026-06-17T09:00:00.000Z'));
|
||||||
|
|
||||||
expect((await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt)
|
expect(
|
||||||
.toBe(reminderAt);
|
(await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt,
|
||||||
|
).toBe(reminderAt);
|
||||||
expect(listsService.publishListSnapshot).not.toHaveBeenCalled();
|
expect(listsService.publishListSnapshot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ export class ListReminderService {
|
|||||||
const ownerEmail = list.owner?.email;
|
const ownerEmail = list.owner?.email;
|
||||||
|
|
||||||
if (!ownerEmail) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1502,7 +1502,8 @@ export class ListsService {
|
|||||||
required?: unknown;
|
required?: unknown;
|
||||||
reason?: unknown;
|
reason?: unknown;
|
||||||
};
|
};
|
||||||
const itemId = typeof candidate.itemId === 'string' ? candidate.itemId : '';
|
const itemId =
|
||||||
|
typeof candidate.itemId === 'string' ? candidate.itemId : '';
|
||||||
const existingItem = itemsById.get(itemId);
|
const existingItem = itemsById.get(itemId);
|
||||||
|
|
||||||
if (!existingItem || seenItemIds.has(itemId)) {
|
if (!existingItem || seenItemIds.has(itemId)) {
|
||||||
@@ -1592,7 +1593,8 @@ export class ListsService {
|
|||||||
keptItemId?: unknown;
|
keptItemId?: unknown;
|
||||||
reason?: unknown;
|
reason?: unknown;
|
||||||
};
|
};
|
||||||
const itemId = typeof candidate.itemId === 'string' ? candidate.itemId : '';
|
const itemId =
|
||||||
|
typeof candidate.itemId === 'string' ? candidate.itemId : '';
|
||||||
const keptItemId =
|
const keptItemId =
|
||||||
typeof candidate.keptItemId === 'string' ? candidate.keptItemId : '';
|
typeof candidate.keptItemId === 'string' ? candidate.keptItemId : '';
|
||||||
const item = itemsById.get(itemId);
|
const item = itemsById.get(itemId);
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import {
|
import { ListTemplate, UserList } from '../list-templates/list-template.types';
|
||||||
ListTemplate,
|
|
||||||
UserList,
|
|
||||||
} from '../list-templates/list-template.types';
|
|
||||||
import { ListTemplatesService } from '../list-templates/list-templates.service';
|
import { ListTemplatesService } from '../list-templates/list-templates.service';
|
||||||
import { ListsService } from '../lists/lists.service';
|
import { ListsService } from '../lists/lists.service';
|
||||||
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
|
import { ListSuggestionAgentService } from './list-suggestion-agent.service';
|
||||||
@@ -27,9 +24,9 @@ describe('ListSuggestionAgentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('suggests read-only list ideas from matching templates', async () => {
|
it('suggests read-only list ideas from matching templates', async () => {
|
||||||
jest.mocked(listsService.listLists).mockResolvedValue([
|
jest
|
||||||
list({ name: 'Urlaub: Sommerurlaub' }),
|
.mocked(listsService.listLists)
|
||||||
]);
|
.mockResolvedValue([list({ name: 'Urlaub: Sommerurlaub' })]);
|
||||||
jest.mocked(listTemplatesService.listTemplates).mockResolvedValue([
|
jest.mocked(listTemplatesService.listTemplates).mockResolvedValue([
|
||||||
template({
|
template({
|
||||||
id: 'template-1',
|
id: 'template-1',
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ export class ListSuggestionAgentService {
|
|||||||
this.listTemplatesService.listTemplates(userId),
|
this.listTemplatesService.listTemplates(userId),
|
||||||
]);
|
]);
|
||||||
const existingNames = new Set(lists.map((list) => this.nameKey(list.name)));
|
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) =>
|
const suggestions = matchingTemplates.map((template) =>
|
||||||
this.suggestFromTemplate(template, goal, constraints, existingNames),
|
this.suggestFromTemplate(template, goal, constraints, existingNames),
|
||||||
);
|
);
|
||||||
@@ -180,7 +183,9 @@ export class ListSuggestionAgentService {
|
|||||||
return 'packing';
|
return 'packing';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/(einkauf|shopping|supermarkt|lebensmittel|markt)/.test(normalizedGoal)) {
|
if (
|
||||||
|
/(einkauf|shopping|supermarkt|lebensmittel|markt)/.test(normalizedGoal)
|
||||||
|
) {
|
||||||
return 'shopping';
|
return 'shopping';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ describe('TaskDigestService', () => {
|
|||||||
sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined),
|
sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
taskPushService = {
|
taskPushService = {
|
||||||
sendTaskDigest: jest
|
sendTaskDigest: jest.fn().mockResolvedValue({
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
subscriptionCount: 1,
|
subscriptionCount: 1,
|
||||||
sentCount: 1,
|
sentCount: 1,
|
||||||
|
|||||||
@@ -196,7 +196,9 @@ export class TaskPushService {
|
|||||||
title,
|
title,
|
||||||
body: parts.join(', '),
|
body: parts.join(', '),
|
||||||
url: payload.tasksUrl,
|
url: payload.tasksUrl,
|
||||||
tag: payload.notificationTag ?? `task-digest-${payload.slot}-${payload.date}`,
|
tag:
|
||||||
|
payload.notificationTag ??
|
||||||
|
`task-digest-${payload.slot}-${payload.date}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,13 +19,17 @@ export class InMemoryRepository<T extends object> {
|
|||||||
|
|
||||||
async save(entityOrEntities: T | T[]): Promise<T | T[]> {
|
async save(entityOrEntities: T | T[]): Promise<T | T[]> {
|
||||||
if (Array.isArray(entityOrEntities)) {
|
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);
|
return this.saveOne(entityOrEntities);
|
||||||
}
|
}
|
||||||
|
|
||||||
async find(options: { where?: WhereClause<T>; order?: unknown } = {}): Promise<T[]> {
|
async find(
|
||||||
|
options: { where?: WhereClause<T>; order?: unknown } = {},
|
||||||
|
): Promise<T[]> {
|
||||||
const records = [...this.records.values()].filter((record) =>
|
const records = [...this.records.values()].filter((record) =>
|
||||||
this.matchesWhere(record, options.where),
|
this.matchesWhere(record, options.where),
|
||||||
);
|
);
|
||||||
@@ -33,7 +37,10 @@ export class InMemoryRepository<T extends object> {
|
|||||||
return this.applyOrder(records, options.order);
|
return this.applyOrder(records, options.order);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(options: { where?: WhereClause<T>; order?: unknown }): Promise<T | null> {
|
async findOne(options: {
|
||||||
|
where?: WhereClause<T>;
|
||||||
|
order?: unknown;
|
||||||
|
}): Promise<T | null> {
|
||||||
const [record] = await this.find(options);
|
const [record] = await this.find(options);
|
||||||
return record ?? null;
|
return record ?? null;
|
||||||
}
|
}
|
||||||
@@ -48,7 +55,9 @@ export class InMemoryRepository<T extends object> {
|
|||||||
|
|
||||||
async remove(entityOrEntities: T | T[]): Promise<T | T[]> {
|
async remove(entityOrEntities: T | T[]): Promise<T | T[]> {
|
||||||
if (Array.isArray(entityOrEntities)) {
|
if (Array.isArray(entityOrEntities)) {
|
||||||
entityOrEntities.forEach((entity) => this.records.delete(this.keyOf(entity)));
|
entityOrEntities.forEach((entity) =>
|
||||||
|
this.records.delete(this.keyOf(entity)),
|
||||||
|
);
|
||||||
return entityOrEntities;
|
return entityOrEntities;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +108,10 @@ export class InMemoryRepository<T extends object> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(typeof recordValue === 'number' || typeof recordValue === 'string') &&
|
(typeof recordValue === 'number' ||
|
||||||
(typeof operatorValue === 'number' || typeof operatorValue === 'string')
|
typeof recordValue === 'string') &&
|
||||||
|
(typeof operatorValue === 'number' ||
|
||||||
|
typeof operatorValue === 'string')
|
||||||
) {
|
) {
|
||||||
return recordValue <= operatorValue;
|
return recordValue <= operatorValue;
|
||||||
}
|
}
|
||||||
@@ -108,6 +119,14 @@ export class InMemoryRepository<T extends object> {
|
|||||||
return false;
|
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;
|
return recordValue === value;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -125,8 +144,12 @@ export class InMemoryRepository<T extends object> {
|
|||||||
|
|
||||||
if (typedOrder?.name) {
|
if (typedOrder?.name) {
|
||||||
sortedRecords.sort((left, right) => {
|
sortedRecords.sort((left, right) => {
|
||||||
const leftName = String((left as Record<string, unknown>)['name'] ?? '');
|
const leftName = String(
|
||||||
const rightName = String((right as Record<string, unknown>)['name'] ?? '');
|
(left as Record<string, unknown>)['name'] ?? '',
|
||||||
|
);
|
||||||
|
const rightName = String(
|
||||||
|
(right as Record<string, unknown>)['name'] ?? '',
|
||||||
|
);
|
||||||
return typedOrder.name === 'DESC'
|
return typedOrder.name === 'DESC'
|
||||||
? rightName.localeCompare(leftName)
|
? rightName.localeCompare(leftName)
|
||||||
: leftName.localeCompare(rightName);
|
: leftName.localeCompare(rightName);
|
||||||
|
|||||||
@@ -38,15 +38,18 @@ describe('AppController (e2e)', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
oidcService = {
|
oidcService = {
|
||||||
createAuthorizationUrl: jest.fn(
|
createAuthorizationUrl: jest.fn(() =>
|
||||||
async () => 'https://sso.example.test/authorize',
|
Promise.resolve('https://sso.example.test/authorize'),
|
||||||
),
|
),
|
||||||
exchangeCallback: jest.fn(async () => ({
|
exchangeCallback: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
subject: 'oidc-default',
|
subject: 'oidc-default',
|
||||||
email: 'default@example.com',
|
email: 'default@example.com',
|
||||||
name: 'Default User',
|
name: 'Default User',
|
||||||
groups: [],
|
groups: [],
|
||||||
})),
|
idToken: 'id-token',
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
@@ -219,6 +222,7 @@ describe('AppController (e2e)', () => {
|
|||||||
email,
|
email,
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
groups: [],
|
groups: [],
|
||||||
|
idToken: 'id-token',
|
||||||
});
|
});
|
||||||
|
|
||||||
const exchangeResponse = await request(app.getHttpServer())
|
const exchangeResponse = await request(app.getHttpServer())
|
||||||
@@ -226,7 +230,7 @@ describe('AppController (e2e)', () => {
|
|||||||
.send({ code: 'code', state: 'state' })
|
.send({ code: 'code', state: 'state' })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
return exchangeResponse.body as unknown as AuthResponseBody;
|
return exchangeResponse.body as AuthResponseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueEmail(prefix: string): string {
|
function uniqueEmail(prefix: string): string {
|
||||||
@@ -234,17 +238,19 @@ describe('AppController (e2e)', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSsoSchema(dataSource: DataSource): Promise<void> {
|
async function ensureSsoSchema(dataSource: DataSource): Promise<void> {
|
||||||
const usersTables = (await dataSource.query(
|
const usersTables = await dataSource.query<Array<Record<string, unknown>>>(
|
||||||
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users'",
|
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users'",
|
||||||
)) as unknown[];
|
);
|
||||||
|
|
||||||
if (!usersTables.length) {
|
if (!usersTables.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oidcSubjectColumns = (await dataSource.query(
|
const oidcSubjectColumns = await dataSource.query<
|
||||||
|
Array<Record<string, unknown>>
|
||||||
|
>(
|
||||||
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'oidcSubject'",
|
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'oidcSubject'",
|
||||||
)) as unknown[];
|
);
|
||||||
|
|
||||||
if (!oidcSubjectColumns.length) {
|
if (!oidcSubjectColumns.length) {
|
||||||
await dataSource.query(
|
await dataSource.query(
|
||||||
@@ -264,10 +270,10 @@ describe('AppController (e2e)', () => {
|
|||||||
dataSource: DataSource,
|
dataSource: DataSource,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const columns = (await dataSource.query(
|
const columns = await dataSource.query<Array<Record<string, unknown>>>(
|
||||||
'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
|
'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
|
||||||
['users', columnName],
|
['users', columnName],
|
||||||
)) as unknown[];
|
);
|
||||||
|
|
||||||
if (columns.length) {
|
if (columns.length) {
|
||||||
await dataSource.query(
|
await dataSource.query(
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="groups-section" aria-label="Keycloak Gruppen">
|
<section class="groups-section" aria-label="OIDC Gruppen">
|
||||||
<div class="settings-heading">
|
<div class="settings-heading">
|
||||||
<mat-icon aria-hidden="true">groups</mat-icon>
|
<mat-icon aria-hidden="true">groups</mat-icon>
|
||||||
<div>
|
<div>
|
||||||
<h2>Keycloak-Gruppen</h2>
|
<h2>OIDC-Gruppen</h2>
|
||||||
<p>{{ auth.user()?.groups?.length || 0 }} synchronisiert</p>
|
<p>{{ auth.user()?.groups?.length || 0 }} synchronisiert</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,6 +38,44 @@
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="access-section" aria-label="App Rechte">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<mat-icon aria-hidden="true">admin_panel_settings</mat-icon>
|
||||||
|
<div>
|
||||||
|
<h2>App-Rollen</h2>
|
||||||
|
<p>{{ auth.user()?.roles?.length || 0 }} aktiv</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (auth.user()?.roles?.length) {
|
||||||
|
<ul class="group-list">
|
||||||
|
@for (role of auth.user()?.roles ?? []; track role) {
|
||||||
|
<li>{{ role }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<p class="settings-description">Keine App-Rollen hinterlegt.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="settings-heading compact-heading">
|
||||||
|
<mat-icon aria-hidden="true">key</mat-icon>
|
||||||
|
<div>
|
||||||
|
<h2>App-Rechte</h2>
|
||||||
|
<p>{{ auth.user()?.permissions?.length || 0 }} aktiv</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (auth.user()?.permissions?.length) {
|
||||||
|
<ul class="group-list">
|
||||||
|
@for (permission of auth.user()?.permissions ?? []; track permission) {
|
||||||
|
<li>{{ permission }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<p class="settings-description">Keine App-Rechte hinterlegt.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-section" aria-label="Task-Mail Einstellungen">
|
<section class="settings-section" aria-label="Task-Mail Einstellungen">
|
||||||
<div class="settings-heading">
|
<div class="settings-heading">
|
||||||
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>
|
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
|
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.groups-section {
|
.groups-section,
|
||||||
|
.access-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -50,6 +51,10 @@
|
|||||||
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
|
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-heading {
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-heading {
|
.settings-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
@@ -30,7 +29,6 @@ export class AccountComponent {
|
|||||||
protected readonly auth = inject(AuthService);
|
protected readonly auth = inject(AuthService);
|
||||||
protected readonly onboarding = inject(OnboardingService);
|
protected readonly onboarding = inject(OnboardingService);
|
||||||
protected readonly taskPush = inject(TaskPushService);
|
protected readonly taskPush = inject(TaskPushService);
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
protected savingTaskDigestPreference = false;
|
protected savingTaskDigestPreference = false;
|
||||||
protected readonly taskDigestPreferenceOptions: ReadonlyArray<{
|
protected readonly taskDigestPreferenceOptions: ReadonlyArray<{
|
||||||
@@ -116,7 +114,6 @@ export class AccountComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.auth.logout();
|
this.auth.logoutThroughProvider();
|
||||||
void this.router.navigateByUrl('/login');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
<mat-icon matListItemIcon aria-hidden="true">account_circle</mat-icon>
|
<mat-icon matListItemIcon aria-hidden="true">account_circle</mat-icon>
|
||||||
<span matListItemTitle>Account</span>
|
<span matListItemTitle>Account</span>
|
||||||
</a>
|
</a>
|
||||||
|
@if (auth.hasPermission('assistant.logs.view')) {
|
||||||
<a
|
<a
|
||||||
mat-list-item
|
mat-list-item
|
||||||
routerLink="/assistant/logs"
|
routerLink="/assistant/logs"
|
||||||
@@ -115,6 +116,7 @@
|
|||||||
<mat-icon matListItemIcon aria-hidden="true">manage_search</mat-icon>
|
<mat-icon matListItemIcon aria-hidden="true">manage_search</mat-icon>
|
||||||
<span matListItemTitle>Assistant Logs</span>
|
<span matListItemTitle>Assistant Logs</span>
|
||||||
</a>
|
</a>
|
||||||
|
}
|
||||||
</mat-nav-list>
|
</mat-nav-list>
|
||||||
</nav>
|
</nav>
|
||||||
</mat-sidenav>
|
</mat-sidenav>
|
||||||
@@ -128,7 +130,9 @@
|
|||||||
</mat-sidenav-content>
|
</mat-sidenav-content>
|
||||||
</mat-sidenav-container>
|
</mat-sidenav-container>
|
||||||
|
|
||||||
|
@if (auth.hasPermission('assistant.chat')) {
|
||||||
<app-assistant-chat />
|
<app-assistant-chat />
|
||||||
|
}
|
||||||
|
|
||||||
<nav class="bottom-nav" aria-label="Mobile Hauptnavigation">
|
<nav class="bottom-nav" aria-label="Mobile Hauptnavigation">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function isAuthRequest(request: HttpRequest<unknown>): boolean {
|
|||||||
'/api/auth/register',
|
'/api/auth/register',
|
||||||
'/api/auth/sso',
|
'/api/auth/sso',
|
||||||
'/api/auth/refresh',
|
'/api/auth/refresh',
|
||||||
|
'/api/auth/logout',
|
||||||
'/api/auth/resend-verification',
|
'/api/auth/resend-verification',
|
||||||
'/api/auth/verify-email',
|
'/api/auth/verify-email',
|
||||||
].some((publicAuthUrl) => request.url.startsWith(publicAuthUrl));
|
].some((publicAuthUrl) => request.url.startsWith(publicAuthUrl));
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface PublicUser {
|
|||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
taskDigestPreference: TaskDigestPreference;
|
taskDigestPreference: TaskDigestPreference;
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
roles: string[];
|
||||||
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicUserSearchResult {
|
export interface PublicUserSearchResult {
|
||||||
@@ -18,9 +20,14 @@ export interface PublicUserSearchResult {
|
|||||||
export interface AuthTokenResponse {
|
export interface AuthTokenResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
|
idToken?: string;
|
||||||
user: PublicUser;
|
user: PublicUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthLogoutResponse {
|
||||||
|
logoutUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegisterResponse {
|
export interface RegisterResponse {
|
||||||
message: string;
|
message: string;
|
||||||
user: PublicUser;
|
user: PublicUser;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { Observable, finalize, shareReplay, tap, throwError } from 'rxjs';
|
import { Observable, finalize, shareReplay, tap, throwError } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
|
AuthLogoutResponse,
|
||||||
AuthTokenResponse,
|
AuthTokenResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
PublicUser,
|
PublicUser,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
|
|
||||||
const ACCESS_TOKEN_KEY = 'listify.accessToken';
|
const ACCESS_TOKEN_KEY = 'listify.accessToken';
|
||||||
const REFRESH_TOKEN_KEY = 'listify.refreshToken';
|
const REFRESH_TOKEN_KEY = 'listify.refreshToken';
|
||||||
|
const ID_TOKEN_KEY = 'listify.idToken';
|
||||||
const USER_KEY = 'listify.user';
|
const USER_KEY = 'listify.user';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -82,6 +84,18 @@ export class AuthService {
|
|||||||
return this.storage?.getItem(REFRESH_TOKEN_KEY) ?? null;
|
return this.storage?.getItem(REFRESH_TOKEN_KEY) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
idToken(): string | null {
|
||||||
|
return this.storage?.getItem(ID_TOKEN_KEY) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPermission(permission: string): boolean {
|
||||||
|
return this.userSignal()?.permissions.includes(permission) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAnyPermission(permissions: string[]): boolean {
|
||||||
|
return permissions.some((permission) => this.hasPermission(permission));
|
||||||
|
}
|
||||||
|
|
||||||
refreshSession(): Observable<AuthTokenResponse> {
|
refreshSession(): Observable<AuthTokenResponse> {
|
||||||
const refreshToken = this.refreshToken();
|
const refreshToken = this.refreshToken();
|
||||||
|
|
||||||
@@ -103,8 +117,31 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
|
this.clearSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
logoutThroughProvider(): void {
|
||||||
|
const payload = {
|
||||||
|
refreshToken: this.refreshToken(),
|
||||||
|
idTokenHint: this.idToken(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.http.post<AuthLogoutResponse>(`${this.apiUrl}/logout`, payload).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.clearSession();
|
||||||
|
window.location.href = response.logoutUrl;
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.clearSession();
|
||||||
|
window.location.href = '/login';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSession(): void {
|
||||||
this.storage?.removeItem(ACCESS_TOKEN_KEY);
|
this.storage?.removeItem(ACCESS_TOKEN_KEY);
|
||||||
this.storage?.removeItem(REFRESH_TOKEN_KEY);
|
this.storage?.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
this.storage?.removeItem(ID_TOKEN_KEY);
|
||||||
this.storage?.removeItem(USER_KEY);
|
this.storage?.removeItem(USER_KEY);
|
||||||
this.userSignal.set(null);
|
this.userSignal.set(null);
|
||||||
}
|
}
|
||||||
@@ -112,6 +149,11 @@ export class AuthService {
|
|||||||
private storeSession(response: AuthTokenResponse): void {
|
private storeSession(response: AuthTokenResponse): void {
|
||||||
this.storage?.setItem(ACCESS_TOKEN_KEY, response.accessToken);
|
this.storage?.setItem(ACCESS_TOKEN_KEY, response.accessToken);
|
||||||
this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken);
|
this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken);
|
||||||
|
if (response.idToken) {
|
||||||
|
this.storage?.setItem(ID_TOKEN_KEY, response.idToken);
|
||||||
|
} else {
|
||||||
|
this.storage?.removeItem(ID_TOKEN_KEY);
|
||||||
|
}
|
||||||
this.storeUser(response.user);
|
this.storeUser(response.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +182,8 @@ export class AuthService {
|
|||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
groups: Array.isArray(user.groups) ? user.groups : [],
|
groups: Array.isArray(user.groups) ? user.groups : [],
|
||||||
|
roles: Array.isArray(user.roles) ? user.roles : [],
|
||||||
|
permissions: Array.isArray(user.permissions) ? user.permissions : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export class SsoCallbackComponent implements OnInit {
|
|||||||
const params = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
const params = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||||
const accessToken = params.get('accessToken');
|
const accessToken = params.get('accessToken');
|
||||||
const refreshToken = params.get('refreshToken');
|
const refreshToken = params.get('refreshToken');
|
||||||
|
const idToken = params.get('idToken') ?? undefined;
|
||||||
const userJson = params.get('user');
|
const userJson = params.get('user');
|
||||||
|
|
||||||
if (!accessToken || !refreshToken || !userJson) {
|
if (!accessToken || !refreshToken || !userJson) {
|
||||||
@@ -92,6 +93,7 @@ export class SsoCallbackComponent implements OnInit {
|
|||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
idToken,
|
||||||
user: JSON.parse(userJson) as AuthTokenResponse['user'],
|
user: JSON.parse(userJson) as AuthTokenResponse['user'],
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -28,3 +28,6 @@ Verfügbare MCP-Tools:
|
|||||||
- `add_template_item`: fügt ein Item zu einem bestehenden Template hinzu.
|
- `add_template_item`: fügt ein Item zu einem bestehenden Template hinzu.
|
||||||
|
|
||||||
Weitere Details und Beispiel-Requests stehen in `listify-api/README.md`.
|
Weitere Details und Beispiel-Requests stehen in `listify-api/README.md`.
|
||||||
|
|
||||||
|
INSERT INTO oidc_group_role_mappings (id, groupPath, role, enabled)
|
||||||
|
VALUES (UUID(), '/deine/oidc/gruppe', 'app_admin', 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user