diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1db9a5b..194e271 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -12,11 +12,12 @@ Multi-tenant SCIM 2.0 Service Provider (RFC 7643/7644) with five Maven modules: | `scim-validator` | Groovy/Spock SCIM compliance suite (REST Assured) | - | | `scim-validator-mgmt` | Validator run/inspection management service | 8082 | -Multi-tenancy is workspace-based. SCIM routes are scoped to `/ws/{workspaceId}/scim/v2/**`. +Multi-tenancy is workspace-based. SCIM routes are scoped to `/ws/{workspaceId}/scim/v2/**`, +and the current implementation expects `workspaceId` to be a UUID. -- `BearerTokenAuthFilter` resolves workspace by UUID or name from the path. +- `BearerTokenAuthFilter` extracts the workspace UUID from the path and validates that the token belongs to that workspace. - Bearer tokens are validated via SHA-256 hash lookup (`WorkspaceTokenRepository.findByTokenHashAndNotRevoked`). -- `WorkspaceContext` (ThreadLocal) carries workspace/token for downstream services. +- There is no `WorkspaceContext` ThreadLocal anymore; controllers resolve the workspace UUID from the route and pass it explicitly into services. - All core SCIM entities are workspace-scoped with `workspace_id` foreign keys. Compatibility mode is route-based and extensible: @@ -34,6 +35,8 @@ Management security is profile-based: - Default profile is `azure`, using interactive Azure OIDC login. - `cloudflare` profile switches the management apps to JWT resource-server mode. - Cloudflare mode reads the token from `Cf-Access-Jwt-Assertion` by default and maps roles from a configurable claim. +- Management user persistence is email-based in both management modules; resolved emails are normalized and stored as the primary key. +- Management access now expects a usable email claim from OIDC/JWT principals. - Shared helpers live in `scim-server-common` (`AzureOidcSecuritySupport`, `CloudflareJwtSecuritySupport`, `MgmtSecuritySupport`). Kubernetes support is split into two trees: @@ -55,8 +58,8 @@ The root `.sops.yaml` defines the active age recipient. # Full reactor build mvn clean install -# Build without SCIM validator module -mvn clean install -pl '!scim-validator' +# Full reactor build without running validator specs +mvn clean install -Dskip.validator.tests=true # API local mode (requires datasource env vars and ACTUATOR_API_KEY) cd scim-server-api && mvn spring-boot:run @@ -87,13 +90,15 @@ Docker default ports: - API `:8080` - Mgmt `:8081` - Validator Mgmt `:8082` -- PostgreSQL `:5432` +- Playground PostgreSQL `:5432` +- Validator PostgreSQL `:5433` Operational notes: - `docker-compose.yml` loads `docker/env/cloudflare.env` into the management apps. - Kubernetes manifests set `SPRING_PROFILES_ACTIVE=cloudflare` for the management apps. - Application services in Kubernetes are `ClusterIP`; Cloudflare tunnel is the external-access path in this branch. +- No repository-specific `DOCKER_HOST` or `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE` overrides are required for local Testcontainers runs; use the default local Docker Desktop / Docker Engine setup. ## Validator Execution @@ -107,11 +112,15 @@ cd scim-validator && mvn test Notes from `ScimBaseSpec`: - By default, the validator can bootstrap PostgreSQL plus `edipal/scim-server-api:latest` when explicit `SCIM_*` settings are not provided. +- Bootstrap selection is based on whether a usable target is configured, not on the presence of the default `SCIM_API_URL` placeholder value. - Disable automatic bootstrap with `SCIM_TESTCONTAINERS_ENABLED=false` or `-Dscim.testcontainers.enabled=false` when targeting an existing environment. - You can alternatively set `SCIM_BASE_URL` (full path, including `/ws/{workspaceId}/scim/v2`). - You can also provide `SCIM_API_URL` together with `SCIM_WORKSPACE_ID`. - `SCIM_AUTH_TOKEN` is required for validator runs. - `SCIM_WORKSPACE_ID` is required unless `SCIM_BASE_URL` is provided. +- Validator config is loaded from `validator-application.yml` in test resources to avoid `application.yml` collisions when the validator test JAR is consumed by `scim-validator-mgmt`. +- `ValidatorConfiguration` accepts both `SCIM_*` placeholders and dotted JVM properties such as `scim.baseUrl`, `scim.authToken`, `scim.apiUrl`, and `scim.workspaceId`, because `scim-validator-mgmt` sets dotted properties before loading validator specs. +- The validator `tests` classifier JAR is packaged after test resources are copied (`test-compile`) so downstream consumers receive `validator-application.yml`. ## Code Conventions @@ -136,17 +145,23 @@ Notes from `ScimBaseSpec`: - Workspace-scoped uniqueness: - `scim_users`: `(workspace_id, user_name)` - `scim_groups`: `(workspace_id, display_name)` +- Management ownership data is email-keyed: + - `mgmt_users.email` is the primary key + - `validator_mgmt_users.email` is the primary key + - `validation_run.created_by_email` is a foreign key to `validator_mgmt_users(email)` +- `workspaces.created_by_username` is sized for email-style owner values (`VARCHAR(500)`). - `ScimUser` flattens `name.*` and enterprise extension sub-attributes into columns. -- Multi-valued user attributes are dedicated child entities with `@OneToMany(cascade = ALL, orphanRemoval = true)`: + +- Multi-valued user attributes on `ScimUser` are JSON-backed lists, not `@OneToMany` child entities: - `emails`, `phoneNumbers`, `addresses`, `entitlements`, `roles`, `ims`, `photos`, `x509Certificates` ## Key SCIM Components -- `ScimFilterParser` (`~378` lines): recursive-descent filter parser to JPA `Specification`. +- `ScimFilterParser`: recursive-descent filter parser to JPA `Specification`. - operators: `eq ne co sw ew pr gt ge lt le` - logic: `and or not`, grouping with parentheses - supports `name.*`, `meta.*`, and enterprise extension attribute paths -- `ScimPatchEngine` (`~850` lines): RFC 7644 PATCH processing with path parsing and filtered multi-valued operations. +- `ScimPatchEngine`: RFC 7644 PATCH processing with path parsing and filtered multi-valued operations. - `ScimSchemaDefinitions`: source of truth for discovery/schema responses. When adding or changing attributes, keep parser, mapper, patch, and schema definitions aligned. @@ -179,7 +194,7 @@ If you modify management authentication or deployment behavior, also review: ## Adding A New SCIM Attribute -1. Extend `ScimUser`/`ScimGroup` (or add child entity in `scim-server-common` when multi-valued). +1. Extend `ScimUser`/`ScimGroup` (or add a JSON-backed value object and list field in `scim-server-common` when the attribute is multi-valued). 2. Update mapper read/write paths in `scim-server-api`. 3. Add PATCH support in `ScimPatchEngine` when applicable. 4. Add schema metadata in `ScimSchemaDefinitions`. diff --git a/README.md b/README.md index 15e209d..8c043f9 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,9 @@ combines: SOPS-encrypted secrets, and Cloudflare Tunnel integration The design centers on workspace isolation. Every SCIM request is scoped to a -workspace via `/ws/{workspaceId}/scim/v2/**`, and every core SCIM entity is -stored with a `workspace_id` foreign key. +workspace via `/ws/{workspaceId}/scim/v2/**`; the current implementation +requires `workspaceId` to be a UUID, and every core SCIM entity is stored with +a `workspace_id` foreign key. ## What It Implements @@ -68,15 +69,16 @@ playground service provider: ### Request model -1. A client calls a SCIM endpoint under `/ws/{workspaceId}/scim/v2/**`. -2. `BearerTokenAuthFilter` extracts the workspace identifier from the path. -3. The filter accepts either a workspace UUID or a workspace name. +1. A client calls a SCIM endpoint under `/ws/{workspaceId}/scim/v2/**` using a + workspace UUID. +2. `BearerTokenAuthFilter` extracts the workspace UUID from the path. +3. Non-UUID workspace identifiers are rejected with a SCIM `404` response. 4. The bearer token is hashed with SHA-256 and looked up through `WorkspaceTokenRepository.findByTokenHashAndNotRevoked(...)`. 5. If the token belongs to the resolved workspace and is not expired or - revoked, `WorkspaceContext` is populated for downstream services. -6. After authentication, the SCIM controllers and services operate only inside - that workspace boundary. + revoked, the request is allowed through the filter chain. +6. The SCIM controllers resolve the workspace UUID from the route and pass it + explicitly into services; there is no workspace ThreadLocal context. 7. `RequestResponseLoggingFilter` captures the request and response payloads for later inspection in the management UI. @@ -84,7 +86,7 @@ playground service provider: Multi-tenancy is workspace-based rather than host-based: -- workspace identity comes from the route, not from JWT claims +- workspace identity comes from the route UUID, not from JWT claims - the same bearer-token model works across all SCIM resources - uniqueness constraints are scoped by workspace - request logs and statistics are workspace-scoped @@ -131,16 +133,16 @@ Key capabilities: Main routes: - UI root: `/` -- Workspace UI: `/ui/workspaces/{workspaceId}` -- Management API root: `/api/management/**` +- Workspace UI: `/workspaces/{workspaceId}` +- Management API root: `/api/**` Representative management API endpoints: -- `POST /api/management/workspaces` -- `GET /api/management/workspaces` -- `POST /api/management/workspaces/{workspaceId}/tokens` -- `GET /api/management/workspaces/{workspaceId}/logs` -- `POST /api/management/workspaces/{workspaceId}/generate/{kind}` +- `POST /api/workspaces` +- `GET /api/workspaces` +- `POST /api/workspaces/{workspaceId}/tokens` +- `GET /api/workspaces/{workspaceId}/logs` +- `POST /api/workspaces/{workspaceId}/generate/{kind}` Supported generator kinds: @@ -196,8 +198,10 @@ Some repository-specific implementation details matter if you extend the code: - `ScimUser` flattens `name.*` and enterprise extension manager fields into columns. -- multi-valued user attributes are modeled as dedicated child entities with - `cascade = ALL` and `orphanRemoval = true`. +- multi-valued user attributes are stored as JSON columns on `scim_users`, + backed by list fields on `ScimUser`; Flyway + `V2__migrate_user_collections_to_json.sql` removed the old dedicated child + tables. - `ScimUser` and `ScimGroup` use optimistic locking through `@Version`, which is surfaced as weak SCIM `ETag` values. - group membership uses a polymorphic `memberValue` identifier, so delete flows @@ -207,7 +211,7 @@ Some repository-specific implementation details matter if you extend the code: ## Tech Stack - Java 17 -- Spring Boot 3.5.12 +- Spring Boot 3.5.13 - Spring MVC, Spring Security, Spring Data JPA, Thymeleaf - PostgreSQL for the main playground and validator persistence stores - CloudNativePG for Kubernetes PostgreSQL clustering @@ -343,7 +347,7 @@ Notes: - The management deployments set `SPRING_PROFILES_ACTIVE=cloudflare`. - The API deployment stays on its regular bearer-token model. - The manifests reference published container images such as - `edipal/scim-server-api:1.0.6`. + `edipal/scim-server-api:1.0.8`. ### Kubernetes secrets and age rotation @@ -464,18 +468,18 @@ token is only shown once. At rest, only the SHA-256 hash is stored. ### 5. Call the SCIM API -Use the workspace UUID or workspace name in the route. +Use the workspace UUID in the route. Example discovery request: ```bash export SCIM_TOKEN= -export WORKSPACE_ID= +export WORKSPACE_UUID= curl \ -H "Authorization: Bearer ${SCIM_TOKEN}" \ -H "Accept: application/scim+json" \ - http://localhost:8080/ws/${WORKSPACE_ID}/scim/v2/ServiceProviderConfig + http://localhost:8080/ws/${WORKSPACE_UUID}/scim/v2/ServiceProviderConfig ``` Example user creation: @@ -486,7 +490,7 @@ curl \ -H "Authorization: Bearer ${SCIM_TOKEN}" \ -H "Content-Type: application/scim+json" \ -H "Accept: application/scim+json" \ - http://localhost:8080/ws/${WORKSPACE_ID}/scim/v2/Users \ + http://localhost:8080/ws/${WORKSPACE_UUID}/scim/v2/Users \ -d '{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "alice@example.com", @@ -540,7 +544,7 @@ pass the SCIM target via CLI properties: cd scim-validator mvn test \ -Dscim.testcontainers.enabled=false \ - -Dscim.baseUrl=http://localhost:8080/ws//scim/v2 \ + -Dscim.baseUrl=http://localhost:8080/ws//scim/v2 \ -Dscim.authToken= ``` @@ -551,14 +555,14 @@ cd scim-validator mvn test \ -Dscim.testcontainers.enabled=false \ -Dscim.apiUrl=http://localhost:8080 \ - -Dscim.workspaceId= \ + -Dscim.workspaceId= \ -Dscim.authToken= ``` Environment variables remain supported as well: ```bash -export SCIM_BASE_URL=http://localhost:8080/ws//scim/v2 +export SCIM_BASE_URL=http://localhost:8080/ws//scim/v2 export SCIM_AUTH_TOKEN= cd scim-validator @@ -569,7 +573,7 @@ Alternative environment model: ```bash export SCIM_API_URL=http://localhost:8080 -export SCIM_WORKSPACE_ID= +export SCIM_WORKSPACE_ID= export SCIM_AUTH_TOKEN= cd scim-validator @@ -577,7 +581,8 @@ mvn test ``` The validator will derive the full base path from `SCIM_API_URL` and -`SCIM_WORKSPACE_ID` if `SCIM_BASE_URL` is not provided. +`SCIM_WORKSPACE_ID` if `SCIM_BASE_URL` is not provided. `SCIM_WORKSPACE_ID` +must be the workspace UUID used by the API route. Mode selection: @@ -586,8 +591,8 @@ Mode selection: Advanced overrides for the automatic bootstrap: -- `SCIM_VALIDATOR_API_IMAGE` or `-Dscim.validator.apiImage=...` -- `SCIM_VALIDATOR_POSTGRES_IMAGE` or `-Dscim.validator.postgresImage=...` +- `SCIM_VALIDATOR_API_IMAGE` or `-Dscim.testcontainers.apiImage=...` +- `SCIM_VALIDATOR_POSTGRES_IMAGE` or `-Dscim.testcontainers.postgresImage=...` ## CI/CD and Release Automation diff --git a/k8s/app/database/secrets/scim-postgres-playground.sops.yaml b/k8s/app/database/secrets/scim-postgres-playground.sops.yaml index 806f07d..a3ab4eb 100644 --- a/k8s/app/database/secrets/scim-postgres-playground.sops.yaml +++ b/k8s/app/database/secrets/scim-postgres-playground.sops.yaml @@ -2,10 +2,12 @@ apiVersion: v1 kind: Secret metadata: name: scim-postgres-playground + labels: + cnpg.io/reload: "true" type: kubernetes.io/basic-auth stringData: username: ENC[AES256_GCM,data:0kFhe469fgwyY51Zz2lZ,iv:O9KYvo8cjGKfmA4SmAt94RKlCXwIoZxBzUS0z0vFWS0=,tag:7S4cMRtfcNgJkqIGbQYZ8g==,type:str] - password: ENC[AES256_GCM,data:9YHNUR5KtwWpVuAi/BM9zrDnTAXCYhrTqHynvrCN3SjyJQ==,iv:I0llr3Yc6/jaFzqN8bzigvQJVFUGSuysyzVdi55F64E=,tag:0v+UVRoZBvGg/ahhAbi9YA==,type:str] + password: ENC[AES256_GCM,data:k9aU16ZFcYnzXvZsrbNVtGFcEitPV4hy,iv:PjL1J+yKsmtTsJ+d5o3yHD+xZMSk46jQwRx1+fuZBbM=,tag:Ko/CizQY+p3J+vrvfOg7Pg==,type:str] sops: age: - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh @@ -17,7 +19,7 @@ sops: S3ZYWE5Bd3UrcWE2UjMycmxNWnhDTEkKKuRerZjqbHyXK9uNMcoM/U7nA0MIgf1a ayqPpA9uNODmqan5dKHZwCtSTTzepGldi6kPD0QLSIPKw6Ne/ni1IA== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-03-27T18:27:36Z" - mac: ENC[AES256_GCM,data:ulROGHRpDEPRXZQa+yKNLiz3AWs0hqIqD6/P2b4aQ6ENKT0pR2BLpFeOd0PNkEaFms9Tqh7gsFA+GL41NjqFAWc53SRPWybjLXbslNhxV1hhPt58886tmfHSkNqnraUXVzIJLsgE/OLxdlbu2FVoWEjCn9IJhmfDrjOVIcEdFZs=,iv:8dhhGTzxU4J0JfXef+3V6HWd5LEOcW5BloIQASY7noQ=,tag:1JNX5GS8BHH60I6DQmUv3w==,type:str] + lastmodified: "2026-04-10T14:28:06Z" + mac: ENC[AES256_GCM,data:nyCALSeNO82ilx/iBdrowL2Hj+IUGPGL5sAolWoiKFBg8GzBVvPhqMzTaLp4UATu8zehHMvZDqIkfl+NL2QmbOdrJMBdRXrQO+ehZULPnd8m+EuMpWXqx32C3+MqI6yiBk22w0fn6CWzxLt3SGcTlpOlIi6n5NIkfXtyyA2oO3M=,iv:+wBfkkY/dd6WM+0uNxIE6dGmNRG0lsKmeYvGsYjUqEk=,tag:7+ZQOq8+bA+oq41VZsYkdA==,type:str] encrypted_regex: ^(data|stringData)$ version: 3.12.2 diff --git a/k8s/app/database/secrets/scim-postgres-superuser.sops.yaml b/k8s/app/database/secrets/scim-postgres-superuser.sops.yaml index 6007c6d..ba02092 100644 --- a/k8s/app/database/secrets/scim-postgres-superuser.sops.yaml +++ b/k8s/app/database/secrets/scim-postgres-superuser.sops.yaml @@ -2,10 +2,12 @@ apiVersion: v1 kind: Secret metadata: name: scim-postgres-superuser + labels: + cnpg.io/reload: "true" type: kubernetes.io/basic-auth stringData: username: ENC[AES256_GCM,data:RpP3Q+sju98=,iv:T/g4El6MGOhlVANGPFvMfeZSmSUmm0YTDZARlif1HRA=,tag:2fXRky8P7kODOWldiuttqQ==,type:str] - password: ENC[AES256_GCM,data:NfjkDVdQae2Lj1sVzRPA9n9NViONt3YA7BpdLmRdbsyObUv2/D4hFA+T,iv:H/O7uR241oI50gpG9SbdwcuNP3yQDCpVWwKJaiGiMM0=,tag:Lasrek9zvOVgLaQTaZRFSA==,type:str] + password: ENC[AES256_GCM,data:SGXVmF/IMZOBFZ/VBNt0gyx160kKfSpi,iv:Z71afVjFpW2Lm29WmeH8L77FyWQ3RtJcFqQbP/d4G5c=,tag:O2a08G8tMobJ6m7fHEWRTg==,type:str] sops: age: - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh @@ -17,7 +19,7 @@ sops: TC9VZ0lrQWVLZEF6UGNURHAwSFdKVDAKEN1fzcFkwE1AEhBQPINVmTC8ZuwcSOAt RDjfKMA3Tjnf4I1jjeuPdJGP1kviefiq6hsZlhvXfhi123Itgju3Hw== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-03-27T17:34:28Z" - mac: ENC[AES256_GCM,data:akyDoGqWfM2GcCcIsnuN6oo4JwCpIBfqOb+1qcyJTqnELK1uo+H0BpG/O9fe/sd4S0VGRV3mkcCzSNNceIXmDszH7HToibA0/ZJq69VPivs9jivLIuuwEEOMld5T5ps54VUTQqdPMq0mkpVTRB8/hR7+R1pHm8Uy7K1WXvQBhcA=,iv:uLjD7ep3Z/sTFIuXJVro4mv5M73nAGBOJ/UXVJFGte4=,tag:Ub9d8ualxzEiEStrDdHhew==,type:str] + lastmodified: "2026-04-10T14:28:34Z" + mac: ENC[AES256_GCM,data:ixVk4pKBzQ8QgCMB8QDa7F55gxKo7HFPHvWU27bd2wLsdk12geR7u6tqvqcFQnhrbr+4gbuyTK43/kgr3xvzhi2m5Dn58T2ugR5NoRBApd6O3sAv55reeklVofkh3LMzmCEWs6kydmEW4RSJYOsBglpyLxVWc995/E2Yi8+0+Gc=,iv:ZWjPe5+UR8E52YouNBXEhVqJgpEPxXbecO9CrIGTE2I=,tag:LL4JVeEPw06VG2KMXG/h4Q==,type:str] encrypted_regex: ^(data|stringData)$ version: 3.12.2 diff --git a/k8s/app/database/secrets/scim-postgres-validator.sops.yaml b/k8s/app/database/secrets/scim-postgres-validator.sops.yaml index b318120..35b4dec 100644 --- a/k8s/app/database/secrets/scim-postgres-validator.sops.yaml +++ b/k8s/app/database/secrets/scim-postgres-validator.sops.yaml @@ -2,10 +2,12 @@ apiVersion: v1 kind: Secret metadata: name: scim-postgres-validator + labels: + cnpg.io/reload: "true" type: kubernetes.io/basic-auth stringData: username: ENC[AES256_GCM,data:RRhOPM4AwpJYBYMhUBo=,iv:skmebd/VxWfE5/CF8Q/JtWTHE9do9gh+XaReuXSuOm8=,tag:yn3+t6lMA3+YWjk/tardxA==,type:str] - password: ENC[AES256_GCM,data:9NcAKCeTxubh8Njh9nUyCI2EQ9aC0oF+UZAxkGeEb8Ht,iv:i0dK2Fx7DFHsrPatnuqQA4ksXqKj2ZlN4GvNwDrhCeg=,tag:NAjnRO8clbtvwRq8BVgucg==,type:str] + password: ENC[AES256_GCM,data:W8FlVgd8QGrc3dJNMhSAA9qSVjOLWt4z,iv:FNGOQMQQk+c6+IKZXpOXB+7iBPz5THb16w8SfY2J1b8=,tag:GMdR4vyNqrQLX0L6aYVe4g==,type:str] sops: age: - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh @@ -17,7 +19,7 @@ sops: ZFcwVHhzUm92Q3psQXR2WjBiWC9oVW8KRPK/RJiFoh76BJCvnJGCdtPS6CKXy1sP QiulBwudI0i1xbw58gw2QZ2my0UU/6VQyqvWnZ7YftqtUar1VDfxfg== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-03-27T17:34:28Z" - mac: ENC[AES256_GCM,data:022nfA0wzoYDMaEvcod0OvHIN6mFdVyal0TVXWGXZBs98YDfG90RXzmuKcxp9rWydQXz4+nQN/BdwiaotEfHeTsyfHyy37j72RQy1D6nuFF6rzcelg0Y2lk3kqAcjon/MoJwfmfxArCHfKFPnDDi71aBuzEdiPnQnSic4aqkXlQ=,iv:Rt4zeq1gWdAMVrlLd8nRDwvtBmhcGX9tAlYYagV/7f0=,tag:+6E3Lwv0lk+JmpMC/T56NA==,type:str] + lastmodified: "2026-04-10T14:28:52Z" + mac: ENC[AES256_GCM,data:I/4BXyST/uRbtzbJjqBFww0fzN9/bReKp0Ybb8eUTydVXndpE1OiLsIh2GOK/VRMzR+cL0pbNDEd7MphAbx33J8Kt3xP3itxHKGo0mSKUhUjtJpluPF0ZEte8IDbEliGvH0FQ9eZdtOf4QWcvCCQPSM12OhIyWXy4/38EWcMJH0=,iv:1A/BtIKje3MqjmTnj1B6e/PlYEvO9BAmgGW7PCXClfQ=,tag:CYvRHZ5Hm8dMbgR0ERJ25g==,type:str] encrypted_regex: ^(data|stringData)$ version: 3.12.2 diff --git a/k8s/app/scim-server-api/configmap.yaml b/k8s/app/scim-server-api/configmap.yaml index 436477c..4ec2d0c 100644 --- a/k8s/app/scim-server-api/configmap.yaml +++ b/k8s/app/scim-server-api/configmap.yaml @@ -3,5 +3,4 @@ kind: ConfigMap metadata: name: scim-server-api-k3s-config data: - SPRING_DATASOURCE_URL: jdbc:postgresql://scim-postgres-rw:5432/scimplayground - SPRING_DATASOURCE_USERNAME: scim_playground \ No newline at end of file + SPRING_DATASOURCE_URL: jdbc:postgresql://scim-postgres-rw:5432/scimplayground \ No newline at end of file diff --git a/k8s/app/scim-server-api/deployment.yaml b/k8s/app/scim-server-api/deployment.yaml index deb636f..3b2ce15 100644 --- a/k8s/app/scim-server-api/deployment.yaml +++ b/k8s/app/scim-server-api/deployment.yaml @@ -30,6 +30,11 @@ spec: - secretRef: name: scim-server-api-k3s-secrets env: + - name: SPRING_DATASOURCE_USERNAME + valueFrom: + secretKeyRef: + name: scim-postgres-playground + key: username - name: SPRING_DATASOURCE_PASSWORD valueFrom: secretKeyRef: diff --git a/k8s/app/scim-server-api/secrets/secret.sops.yaml b/k8s/app/scim-server-api/secrets/secret.sops.yaml index 6fe653e..44ca838 100644 --- a/k8s/app/scim-server-api/secrets/secret.sops.yaml +++ b/k8s/app/scim-server-api/secrets/secret.sops.yaml @@ -4,8 +4,7 @@ metadata: name: scim-server-api-k3s-secrets type: Opaque stringData: - SPRING_DATASOURCE_PASSWORD: ENC[AES256_GCM,data:umFrv2grHzQ5/Awh3MmgDpVojcDoLvl5gISm7BLbRSPCzQ==,iv:p/6zQgjTdePV4pxWsgpXbVDVA6BRfpqfL5mLTnH1Acg=,tag:TKzNjz1GC7MKxLMjAxV15A==,type:str] - ACTUATOR_API_KEY: ENC[AES256_GCM,data:fSl1EdagBXA1O1EsWZfIk2rKMDlH/WHz5NM=,iv:jnggCOKdts0/RLN5r1wkfu2NHL7MbIUhr8RWrdOC2CI=,tag:ZGydOi+KUSZZK4vaL7v3jA==,type:str] + ACTUATOR_API_KEY: ENC[AES256_GCM,data:iDcGrPXPXizvECsF4bJgHoO6cFxhYs17kJbjD7+l4ZE=,iv:k1Vm5LIgUkdy/Yoy7IGqaczPC5/d1nTDTHFnpceBrV4=,tag:Hs9HVIc0pAoZzL6qM6b4nw==,type:str] sops: age: - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh @@ -17,7 +16,7 @@ sops: Ui96c1VseXZrZDhXWWFwMkw2R2puQWsKWiRU7nRAerImiMlmGrtcPvr0yhHBXgPw NYTd6laSrAmk1lMgq9PUBsyXG0mnRLKZ7qyjt/ISGwky/UjVIbr/sQ== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-03-27T18:12:50Z" - mac: ENC[AES256_GCM,data:5oco07suLZsu2q77t3i0k3dq80rOktvtI39AYyf3JoC8XDvOLvjeFeX8nyU245EiNugcJT37OSIu7B/lPHoaizKu7WPqLQvn7TaqEcwtv2iZJmfkkL7coHWcAFEPyc/yTrHTdJU3SLG7MlMkWrVmFRMtdzwyr6xWoExggE8IQVw=,iv:VYGACow3jkZYCHKt9P4oGLkZ5SO6H6Bvew2BR14Pd5s=,tag:vpTbNKElBGefiAXNIWikcA==,type:str] + lastmodified: "2026-04-10T14:13:28Z" + mac: ENC[AES256_GCM,data:dwHstr4Q9Dp4nVKlkOXOGd8xP3ibyIeyflM5mA1+DFHyKEoPqgPy4C9DhAHGYZopps5zIvgByH2V72kxdtsNJShTQxxR7B8WOjqSA9PWU3U3ILcIq+b03o5SrZ9uuTo4wtnRSE2ClSbh/SJC0d/kEiPXpbQsVpuqffwlvgE3ySA=,iv:HRi0I4Y20c+y4CQxJHSGOlCdmCyVjoxuVWkK3S0JzH0=,tag:3D9MP7I0RJIs6wqYvWXeZg==,type:str] encrypted_regex: ^(data|stringData)$ version: 3.12.2 diff --git a/k8s/app/scim-server-mgmt/configmap.yaml b/k8s/app/scim-server-mgmt/configmap.yaml index 0879e21..de27dd8 100644 --- a/k8s/app/scim-server-mgmt/configmap.yaml +++ b/k8s/app/scim-server-mgmt/configmap.yaml @@ -6,8 +6,7 @@ data: SERVER_PORT: "8081" SPRING_PROFILES_ACTIVE: cloudflare SPRING_DATASOURCE_URL: jdbc:postgresql://scim-postgres-rw:5432/scimplayground - SPRING_DATASOURCE_USERNAME: scim_playground - APP_SCIM_API_BASE_URL: http://scim-server-api:8080 + APP_SCIM_API_BASE_URL: https://api.scimsandbox.net APP_SECURITY_CLOUDFLARE_ROLE_CLAIM: https://scimsandbox.net/roles APP_SECURITY_OIDC_ADMIN_ROLE: admin APP_SECURITY_OIDC_USER_ROLE: user diff --git a/k8s/app/scim-server-mgmt/deployment.yaml b/k8s/app/scim-server-mgmt/deployment.yaml index 89918b5..cc7d4c5 100644 --- a/k8s/app/scim-server-mgmt/deployment.yaml +++ b/k8s/app/scim-server-mgmt/deployment.yaml @@ -30,6 +30,11 @@ spec: - secretRef: name: scim-server-mgmt-k3s-secrets env: + - name: SPRING_DATASOURCE_USERNAME + valueFrom: + secretKeyRef: + name: scim-postgres-playground + key: username - name: SPRING_DATASOURCE_PASSWORD valueFrom: secretKeyRef: diff --git a/k8s/app/scim-server-mgmt/secrets/secret.sops.yaml b/k8s/app/scim-server-mgmt/secrets/secret.sops.yaml index 30c231e..0660c81 100644 --- a/k8s/app/scim-server-mgmt/secrets/secret.sops.yaml +++ b/k8s/app/scim-server-mgmt/secrets/secret.sops.yaml @@ -4,8 +4,7 @@ metadata: name: scim-server-mgmt-k3s-secrets type: Opaque stringData: - SPRING_DATASOURCE_PASSWORD: ENC[AES256_GCM,data:YkRIjyD9SgMLoGF21fe0,iv:1eDlmhUbOxu7PbdvQ0iDkJ+AsGyMv3QlPG1xfIDHLDE=,tag:oqTwIFmlbctQ5hFW99HTfA==,type:str] - ACTUATOR_API_KEY: ENC[AES256_GCM,data:+zoLZ0N85buLS0aglTSdvg==,iv:NUTLfzdgOihk0T3H8fvjtbJlcdQ1/l/3OYjb/kxnv7w=,tag:Qf/06GfEAko9HQwQQ3lP4Q==,type:str] + ACTUATOR_API_KEY: ENC[AES256_GCM,data:NBNrxcKvNgBZ6HIwWt3yWNJ55VY3qu+DecOOs7cLevU=,iv:tIzmjAtqaFjCWg9VPgWk1eunJgLKfPG1TVTaySl5ax4=,tag:0t9JfRBln8T4q+dUapNXWQ==,type:str] AZURE_CLIENT_ID: ENC[AES256_GCM,data:ddp6MlmrTWj1ChCrefdL3rMoIuOr75wFZ6JuqG1itxHIRICH,iv:JiGxcfHGMFvqThIpczYvDVkwC1IJ4I4hRZO7UXAOUxQ=,tag:kvjNZtW2L/MuaGKoZ3MxCw==,type:str] AZURE_CLIENT_SECRET: ENC[AES256_GCM,data:k9LuKBzBxGE8/fJzeU/KFZaxKr8owo2P690aE8Cr2+C6FSyOOTlGFg==,iv:7o8l7wLA4AhYVsc3w48dsRxNyDnyyWudNlDpzsCCLO4=,tag:edJGt0hWOLnaFQ7N0FLdQw==,type:str] AZURE_TENANT_ID: ENC[AES256_GCM,data:pYk1sI18AT9qviXfSKqL8O3KbqXTM+EcMUEI/BjbkDW7Boof,iv:ni82wW7uMNCxOmkMPl7yy04DgKDv7mjGie/UGEaBrXw=,tag:YUdlWZ2bFS+mMRSJsXgqHg==,type:str] @@ -21,7 +20,7 @@ sops: T2JreVVlVEt1S3J5LzZRekVSVFY4c0kKyOV5MRLGnYyWLyzcHa9UmfItp2d/hKsX b2duPUECnG01v19Hxkwo/UdJD/yIYgTvHpCl2oih/plqCO3baEmIqQ== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-03-27T19:06:38Z" - mac: ENC[AES256_GCM,data:OkBZ/zlR1usfkC+TocEy2yXqtnnpitg8EsuVEXiBAwvRGOnl6xA+mbrd3g3Z2eilCpG1XWlPX13yAmTd2YFOLp7ryWu5Ma1hgQy44t4aFY0fsMLPWZqA9vk5WqAa9YyTAyVoGTnsKtl5IpOMlsR1AUcbZNAhenqrgG6/sxRJkYc=,iv:2zbN1+qoMBplJBB0oRIRJPOwmzk3MjSw/VIlqq/U+4k=,tag:INia+cgemsrJpVD1ZOVQEg==,type:str] + lastmodified: "2026-04-10T14:44:04Z" + mac: ENC[AES256_GCM,data:KoHHmv3vdFUHBv/Jm7MHrIuevcmOI93q9LbLshn0PXRf2FiJLxuib2DVbwEkDux1e3j9m5yKC5XGGBExcW4WBHe75midHPR3/MrY3pmn3sFYi82Mjz0vrYc73ogPCRkIAwoCEhat0XTv7SAvInbZsicIu84TB/iVinTg/ruVCqQ=,iv:xRhcmX5ZlmJuvBYxCszfBLbAxFOgofOrb3d59+WVWv0=,tag:fFjxDw+sAjqbdD5nvbQVCw==,type:str] encrypted_regex: ^(data|stringData)$ version: 3.12.2 diff --git a/k8s/app/scim-validator-mgmt/configmap.yaml b/k8s/app/scim-validator-mgmt/configmap.yaml index 186ffd8..58d24d6 100644 --- a/k8s/app/scim-validator-mgmt/configmap.yaml +++ b/k8s/app/scim-validator-mgmt/configmap.yaml @@ -7,7 +7,6 @@ data: SPRING_PROFILES_ACTIVE: cloudflare SPRING_DATASOURCE_URL: jdbc:postgresql://scim-postgres-rw:5432/scimvalidation SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver - SPRING_DATASOURCE_USERNAME: scim_validator APP_SECURITY_CLOUDFLARE_ROLE_CLAIM: https://scimsandbox.net/roles APP_SECURITY_OIDC_ADMIN_ROLE: admin APP_SECURITY_OIDC_USER_ROLE: user diff --git a/k8s/app/scim-validator-mgmt/deployment.yaml b/k8s/app/scim-validator-mgmt/deployment.yaml index 101604c..7ee9b59 100644 --- a/k8s/app/scim-validator-mgmt/deployment.yaml +++ b/k8s/app/scim-validator-mgmt/deployment.yaml @@ -30,6 +30,11 @@ spec: - secretRef: name: scim-validator-mgmt-k3s-secrets env: + - name: SPRING_DATASOURCE_USERNAME + valueFrom: + secretKeyRef: + name: scim-postgres-validator + key: username - name: SPRING_DATASOURCE_PASSWORD valueFrom: secretKeyRef: diff --git a/k8s/app/scim-validator-mgmt/secrets/secret.sops.yaml b/k8s/app/scim-validator-mgmt/secrets/secret.sops.yaml index d028258..2264963 100644 --- a/k8s/app/scim-validator-mgmt/secrets/secret.sops.yaml +++ b/k8s/app/scim-validator-mgmt/secrets/secret.sops.yaml @@ -4,7 +4,7 @@ metadata: name: scim-validator-mgmt-k3s-secrets type: Opaque stringData: - ACTUATOR_API_KEY: ENC[AES256_GCM,data:3mZGKAhuYBfRTjd8odLIzw==,iv:0waDAQjwCOprQmKeRzXpVM35Hl5xxOWx8dzOyqtjMsU=,tag:ttAuVC2L1D9Vrctad9K82w==,type:str] + ACTUATOR_API_KEY: ENC[AES256_GCM,data:Ov1SSGKeXg6oEEL9OnROAD0hAvyOBxxrS/hen3cm4xM=,iv:HOswAW1cweaOgdA7uHFK+HbkFf4kPS4FfzbyCZqCkYg=,tag:92voVF9lnyOeT/hJWcojAQ==,type:str] AZURE_CLIENT_ID: ENC[AES256_GCM,data:D8BUR3o0edi2BI+o+w0VQaPodn9dqOY5XeiUYJoVgrdmo7nI,iv:TgoW2GoAodCeDE+QzADtcN3QBHb/GhgSDp90RUvGjdY=,tag:7jmgtEmvWiwA0Kd2d3osag==,type:str] AZURE_CLIENT_SECRET: ENC[AES256_GCM,data:gC7N54Ej81AbJMGoZ4LlOlY4HTAGspP5dH5bjxm4twUzRyCwF/ujIw==,iv:h5ID0cwjmHJYNUqHkybZgZHQ/VCva2bkqYcYJETSiV8=,tag:VU/mTCSyuWjukbi9es0j0Q==,type:str] AZURE_TENANT_ID: ENC[AES256_GCM,data:XrqQK31TIyi42Ximvw3EBSjQU4bLK75ps8a4jqHb5SfjoaOn,iv:xC6xPTm6YtZQaHAruQlVEHCajgkVJ7Bysnw6cWOcpUU=,tag:QP0MmktwCIGaIRpQRZb16Q==,type:str] @@ -20,7 +20,7 @@ sops: VExGZGtXWWJXOHJkb1paZHhTRHViNXMKoJYy5PatO+SFoJy93IUkqYAt1JZlexnM yVmxa66O6j9J5KGmgWuCcGF4AVLGql58QZqXElX2voPY4Hg2C/LDHA== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-03-27T19:19:42Z" - mac: ENC[AES256_GCM,data:GGlqzLkxsYpeGmf6u6Ib6wI3pKmpxqaG+IykLGUgjkWiWcQl3jyWf/Xm7QLPSM/58nVwtfV13u+3h6utcD4nzPGFv8+wf1bH2/ly/qE3G7C6Xd7wqqezgP643yblyY/nE5x7kHxCrQnwlZfRtKT4l+vLxp/lRmDCPjSJrgrdJQ4=,iv:ctrxJMwqh80U46tTkAN0GLQgz0Z+Lm6AGAgJ8LJsrLM=,tag:0mQpwJ89W7ls/RP4tHoH6w==,type:str] + lastmodified: "2026-04-10T14:14:44Z" + mac: ENC[AES256_GCM,data:hhX6DnEKPJgYz4i6POjszrFYmjIbTUdUe88ybf/9wcMWaXO5Yj6gpaId+GKt1nh++udVgxXUdIDdUuft9nQ7r+2qag7OICDSCaMi0uf39j6vNuezeCkkxuSGo/N0I2F3hIxTaPrE53fbszA57PVl8cRFImI9i8CRSEkbHOZvG6w=,iv:xFuczRFOpheXS5N5Xydnmx7nRQlTAbrMyZ001shmm6Q=,tag:n3RicYLM3y7AUCETZRkISg==,type:str] encrypted_regex: ^(data|stringData)$ version: 3.12.2 diff --git a/k8s/cluster/newrelic/newrelic-values.yaml b/k8s/cluster/newrelic/newrelic-values.yaml new file mode 100644 index 0000000..72e85ce --- /dev/null +++ b/k8s/cluster/newrelic/newrelic-values.yaml @@ -0,0 +1,36 @@ +USER-SUPPLIED VALUES: +global: + cluster: netcup-scim + licenseKey: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + lowDataMode: true +k8s-agents-operator: + enabled: true +kube-state-metrics: + enabled: true + image: + tag: v2.13.0 +kubeEvents: + enabled: true +logging: + enabled: true +newrelic-logging: + lowDataMode: true + fluentBit: + config: + inputs: | + [INPUT] + Name tail + Path /var/log/containers/*.log + Exclude_Path /var/log/containers/*_newrelic_*.log + Tag kube.* + Mem_Buf_Limit 7MB + Skip_Long_Lines On +newrelic-prometheus-agent: + config: + kubernetes: + integrations_filter: + enabled: false + enabled: true + lowDataMode: true +nr-ebpf-agent: + enabled: true diff --git a/pom.xml b/pom.xml index eda1be6..ab82c21 100644 --- a/pom.xml +++ b/pom.xml @@ -33,8 +33,6 @@ 17 UTF-8 3.5.13 - unix://${user.home}/.rd/docker.sock - /var/run/docker.sock diff --git a/scim-server-api/pom.xml b/scim-server-api/pom.xml index 86506ce..cb5f820 100644 --- a/scim-server-api/pom.xml +++ b/scim-server-api/pom.xml @@ -130,12 +130,6 @@ org.apache.maven.plugins maven-surefire-plugin 3.2.5 - - - ${docker.host} - ${testcontainers.docker.socket.override} - - org.apache.maven.plugins diff --git a/scim-server-common/pom.xml b/scim-server-common/pom.xml index d2d5757..03c6a87 100644 --- a/scim-server-common/pom.xml +++ b/scim-server-common/pom.xml @@ -109,12 +109,6 @@ org.apache.maven.plugins maven-surefire-plugin 3.2.5 - - - ${docker.host} - ${testcontainers.docker.socket.override} - - org.apache.maven.plugins diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/model/Workspace.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/model/Workspace.java index 3e58ba6..9c7733d 100644 --- a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/model/Workspace.java +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/model/Workspace.java @@ -17,7 +17,7 @@ public class Workspace { private String description; - @Column(name = "created_by_username", length = 255) + @Column(name = "created_by_username", length = 500) private String createdByUsername; @Column(name = "created_at", nullable = false, updatable = false) diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupport.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupport.java index df205f4..0aff191 100644 --- a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupport.java +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupport.java @@ -2,6 +2,7 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; @@ -9,7 +10,7 @@ import java.util.HashSet; import java.util.Set; -import java.util.function.BiConsumer; +import java.util.function.Consumer; public final class AzureOidcSecuritySupport { @@ -19,8 +20,15 @@ private AzureOidcSecuritySupport() { public static OidcUserService createOidcUserService(String roleClaim, String adminRole, String userRole, - BiConsumer userProvisioner) { - OidcUserService delegate = new OidcUserService(); + Consumer userProvisioner) { + return createOidcUserService(roleClaim, adminRole, userRole, userProvisioner, new OidcUserService()); + } + + static OidcUserService createOidcUserService(String roleClaim, + String adminRole, + String userRole, + Consumer userProvisioner, + OidcUserService delegate) { return new OidcUserService() { @Override public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { @@ -33,24 +41,19 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio adminRole, userRole); - String sub = oidcUser.getSubject(); - String email = resolveEmail(oidcUser); - if (sub != null && !sub.isBlank()) { - userProvisioner.accept(sub, email); - } + userProvisioner.accept(requireEmail(oidcUser)); return new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo()); } }; } - private static String resolveEmail(OidcUser oidcUser) { - String email = oidcUser.getEmail(); - if (email != null && !email.isBlank()) return email; - String upn = oidcUser.getClaimAsString("upn"); - if (upn != null && !upn.isBlank()) return upn; - String unique = oidcUser.getClaimAsString("unique_name"); - if (unique != null && !unique.isBlank()) return unique; - return oidcUser.getPreferredUsername(); + private static String requireEmail(OidcUser oidcUser) { + String email = PrincipalEmailSupport.resolveEmail(oidcUser); + if (email != null) { + return email; + } + throw new OAuth2AuthenticationException( + new OAuth2Error("invalid_token", "An email claim is required for management access", null)); } } \ No newline at end of file diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupport.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupport.java index 0a4c7be..23fdd41 100644 --- a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupport.java +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupport.java @@ -3,8 +3,9 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.Jwt; @@ -17,9 +18,12 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.function.Consumer; public final class CloudflareJwtSecuritySupport { + private static final String INVALID_TOKEN_CODE = "invalid_token"; + private CloudflareJwtSecuritySupport() { } @@ -44,7 +48,8 @@ public static JwtDecoder createJwtDecoder(String issuerUri, String audience, Str public static Converter createJwtAuthenticationConverter(String roleClaim, String adminRole, - String userRole) { + String userRole, + Consumer userProvisioner) { JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setPrincipalClaimName("sub"); converter.setJwtGrantedAuthoritiesConverter(jwt -> { @@ -57,7 +62,16 @@ public static Converter createJwtAuthenticatio userRole); return mappedAuthorities; }); - return converter; + return jwt -> { + String email = requireEmail(jwt); + AbstractAuthenticationToken authentication = converter.convert(jwt); + if (authentication == null) { + throw new OAuth2AuthenticationException( + new OAuth2Error(INVALID_TOKEN_CODE, "Unable to authenticate Cloudflare token", null)); + } + userProvisioner.accept(email); + return authentication; + }; } private static Object resolveRoleClaimValue(Jwt jwt, String roleClaim) { @@ -77,7 +91,7 @@ private static OAuth2TokenValidatorResult validateAudience(Jwt jwt, String audie return OAuth2TokenValidatorResult.success(); } return OAuth2TokenValidatorResult.failure( - new OAuth2Error("invalid_token", "The required audience is missing", null)); + new OAuth2Error(INVALID_TOKEN_CODE, "The required audience is missing", null)); } private static String resolveJwkSetUri(String issuerUri, String jwkSetUri) { @@ -89,4 +103,13 @@ private static String resolveJwkSetUri(String issuerUri, String jwkSetUri) { } return issuerUri + "/cdn-cgi/access/certs"; } + + private static String requireEmail(Jwt jwt) { + String email = PrincipalEmailSupport.resolveEmail(jwt); + if (email != null) { + return email; + } + throw new OAuth2AuthenticationException( + new OAuth2Error(INVALID_TOKEN_CODE, "An email claim is required for management access", null)); + } } \ No newline at end of file diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/PrincipalEmailSupport.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/PrincipalEmailSupport.java new file mode 100644 index 0000000..6d99d68 --- /dev/null +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/PrincipalEmailSupport.java @@ -0,0 +1,70 @@ +package de.palsoftware.scim.server.common.security; + +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.util.Locale; +import java.util.Map; + +public final class PrincipalEmailSupport { + + private PrincipalEmailSupport() { + } + + public static String resolveEmail(OidcUser oidcUser) { + if (oidcUser == null) { + return null; + } + return normalizeEmail(firstNonBlank( + oidcUser.getEmail(), + oidcUser.getClaimAsString("upn"), + oidcUser.getClaimAsString("unique_name"), + oidcUser.getPreferredUsername())); + } + + public static String resolveEmail(Jwt jwt) { + if (jwt == null) { + return null; + } + return normalizeEmail(firstNonBlank( + claimAsString(jwt, "email"), + claimAsString(jwt, "upn"), + claimAsString(jwt, "unique_name"), + claimAsString(jwt, "preferred_username"))); + } + + public static String normalizeEmail(String value) { + if (value == null) { + return null; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + if (normalized.isEmpty() || !normalized.contains("@")) { + return null; + } + return normalized; + } + + private static String claimAsString(Jwt jwt, String claimName) { + String directClaim = jwt.getClaimAsString(claimName); + if (directClaim != null && !directClaim.isBlank()) { + return directClaim; + } + Object customClaim = jwt.getClaim("custom"); + if (customClaim instanceof Map customClaims) { + Object nestedClaim = customClaims.get(claimName); + if (nestedClaim instanceof String nestedString && !nestedString.isBlank()) { + return nestedString; + } + } + return null; + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } +} \ No newline at end of file diff --git a/scim-server-common/src/main/resources/db/common/V1__init_common_schema.sql b/scim-server-common/src/main/resources/db/common/V1__init_common_schema.sql index dfcdeda..de5d037 100644 --- a/scim-server-common/src/main/resources/db/common/V1__init_common_schema.sql +++ b/scim-server-common/src/main/resources/db/common/V1__init_common_schema.sql @@ -2,7 +2,7 @@ CREATE TABLE workspaces ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE, description VARCHAR(255), - created_by_username VARCHAR(255), + created_by_username VARCHAR(500), created_at TIMESTAMP WITH TIME ZONE NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL ); @@ -12,8 +12,7 @@ CREATE INDEX idx_workspaces_updated_at ON workspaces (updated_at); CREATE INDEX idx_workspaces_created_at ON workspaces (created_at DESC); CREATE TABLE mgmt_users ( - id VARCHAR(500) PRIMARY KEY, - email VARCHAR(500), + email VARCHAR(500) PRIMARY KEY, last_login_at TIMESTAMP WITH TIME ZONE NOT NULL ); diff --git a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupportTest.java b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupportTest.java new file mode 100644 index 0000000..a7fad56 --- /dev/null +++ b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupportTest.java @@ -0,0 +1,77 @@ +package de.palsoftware.scim.server.common.security; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AzureOidcSecuritySupportTest { + + @Test + void oidcUserService_provisionsNormalizedEmailAndMapsRoles() { + AtomicReference provisionedEmail = new AtomicReference<>(); + OidcUser oidcUser = oidcUser(Map.of( + "sub", "user-123", + "roles", List.of("admin"), + "email", " User@Example.com " + )); + OidcUserService delegate = mock(OidcUserService.class); + OidcUserRequest request = mock(OidcUserRequest.class); + when(delegate.loadUser(request)).thenReturn(oidcUser); + + OidcUser loadedUser = AzureOidcSecuritySupport.createOidcUserService( + "roles", + "admin", + "user", + provisionedEmail::set, + delegate) + .loadUser(request); + + assertThat(provisionedEmail.get()).isEqualTo("user@example.com"); + assertThat(loadedUser.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .containsExactlyInAnyOrder("ROLE_ADMIN", "ROLE_USER"); + } + + @Test + void oidcUserService_withoutEmailRejectsLogin() { + OidcUser oidcUser = oidcUser(Map.of( + "sub", "user-123", + "roles", List.of("user"), + "preferred_username", "not-an-email" + )); + OidcUserService delegate = mock(OidcUserService.class); + OidcUserRequest request = mock(OidcUserRequest.class); + when(delegate.loadUser(request)).thenReturn(oidcUser); + OidcUserService oidcUserService = AzureOidcSecuritySupport.createOidcUserService( + "roles", + "admin", + "user", + email -> { }, + delegate); + + assertThatThrownBy(() -> oidcUserService.loadUser(request)) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("email claim is required"); + } + + private static OidcUser oidcUser(Map claims) { + Instant issuedAt = Instant.now(); + OidcIdToken idToken = new OidcIdToken("token-value", issuedAt, issuedAt.plusSeconds(3600), claims); + return new DefaultOidcUser(List.of(), idToken); + } +} \ No newline at end of file diff --git a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupportTest.java b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupportTest.java index 67797ed..528c8a9 100644 --- a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupportTest.java +++ b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupportTest.java @@ -3,26 +3,36 @@ import org.junit.jupiter.api.Test; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.jwt.Jwt; import java.time.Instant; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class CloudflareJwtSecuritySupportTest { @Test void jwtAuthenticationConverter_mapsAdminRoleFromNestedCustomClaim() { + AtomicReference provisionedEmail = new AtomicReference<>(); Jwt jwt = jwt(Map.of( "sub", "user-123", + "email", "USER@example.com", "custom", Map.of("https://scimsandbox.net/roles", new String[]{"admin"}) )); AbstractAuthenticationToken authentication = CloudflareJwtSecuritySupport - .createJwtAuthenticationConverter("https://scimsandbox.net/roles", "admin", "user") + .createJwtAuthenticationConverter( + "https://scimsandbox.net/roles", + "admin", + "user", + provisionedEmail::set) .convert(jwt); + assertThat(provisionedEmail.get()).isEqualTo("user@example.com"); assertThat(authentication.getAuthorities()) .extracting(GrantedAuthority::getAuthority) .containsExactlyInAnyOrder("ROLE_ADMIN", "ROLE_USER"); @@ -32,11 +42,12 @@ void jwtAuthenticationConverter_mapsAdminRoleFromNestedCustomClaim() { void jwtAuthenticationConverter_mapsAdminRoleFromTopLevelClaim() { Jwt jwt = jwt(Map.of( "sub", "user-123", + "email", "user@example.com", "roles", new String[]{"admin"} )); AbstractAuthenticationToken authentication = CloudflareJwtSecuritySupport - .createJwtAuthenticationConverter("roles", "admin", "user") + .createJwtAuthenticationConverter("roles", "admin", "user", email -> { }) .convert(jwt); assertThat(authentication.getAuthorities()) @@ -44,6 +55,21 @@ void jwtAuthenticationConverter_mapsAdminRoleFromTopLevelClaim() { .containsExactlyInAnyOrder("ROLE_ADMIN", "ROLE_USER"); } + @Test + void jwtAuthenticationConverter_withoutEmailRejectsLogin() { + Jwt jwt = jwt(Map.of( + "sub", "user-123", + "preferred_username", "not-an-email", + "roles", new String[]{"user"} + )); + var converter = CloudflareJwtSecuritySupport + .createJwtAuthenticationConverter("roles", "admin", "user", email -> { }); + + assertThatThrownBy(() -> converter.convert(jwt)) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("email claim is required"); + } + private static Jwt jwt(Map claims) { Instant issuedAt = Instant.now(); return new Jwt("token-value", issuedAt, issuedAt.plusSeconds(3600), Map.of("alg", "none"), claims); diff --git a/scim-server-mgmt/pom.xml b/scim-server-mgmt/pom.xml index 57adf99..ae67634 100644 --- a/scim-server-mgmt/pom.xml +++ b/scim-server-mgmt/pom.xml @@ -129,12 +129,6 @@ org.apache.maven.plugins maven-surefire-plugin 3.2.5 - - - ${docker.host} - ${testcontainers.docker.socket.override} - - org.apache.maven.plugins diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiGroupsController.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiGroupsController.java index dab6c91..7471c0e 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiGroupsController.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiGroupsController.java @@ -45,7 +45,7 @@ public ResponseEntity> listGroups( @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String q, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); int safeSize = Math.max(1, Math.min(size, 200)); int safePage = Math.max(1, page); @@ -54,7 +54,7 @@ public ResponseEntity> listGroups( UUID.fromString(workspaceId), q, pageRequest, - username, + actorEmail, admin); return ResponseEntity.ok(PagedResponseMapper.pagedResponse( groups, @@ -69,7 +69,7 @@ public ResponseEntity>> lookupGroups( @RequestParam(required = false) String q, @RequestParam(defaultValue = "50") int size, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); int safeSize = Math.max(1, Math.min(size, 200)); PageRequest pageRequest = PageRequest.of(0, safeSize, Sort.by(KEY_DISPLAY_NAME).ascending()); @@ -77,7 +77,7 @@ public ResponseEntity>> lookupGroups( UUID.fromString(workspaceId), q, pageRequest, - username, + actorEmail, admin); return ResponseEntity.ok(groups.stream().map(GroupResponseMapper::groupLookupToMap).toList()); } @@ -85,9 +85,9 @@ public ResponseEntity>> lookupGroups( @DeleteMapping("/workspaces/{workspaceId}/groups") public ResponseEntity clearGroups(@PathVariable String workspaceId, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); - scimAdminService.deleteAllGroups(UUID.fromString(workspaceId), username, admin); + scimAdminService.deleteAllGroups(UUID.fromString(workspaceId), actorEmail, admin); return ResponseEntity.noContent().build(); } @@ -96,12 +96,12 @@ public ResponseEntity> createGroup( @PathVariable String workspaceId, @RequestBody GroupUpsertRequest request, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); ScimGroup group = scimAdminService.createGroup( UUID.fromString(workspaceId), request, - username, + actorEmail, admin); return ResponseEntity.status(201).body(GroupResponseMapper.groupToMap(group)); } @@ -112,13 +112,13 @@ public ResponseEntity> updateGroup( @PathVariable String groupId, @RequestBody GroupUpsertRequest request, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); ScimGroup group = scimAdminService.updateGroup( UUID.fromString(workspaceId), UUID.fromString(groupId), request, - username, + actorEmail, admin); return ResponseEntity.ok(GroupResponseMapper.groupToMap(group)); } @@ -128,12 +128,12 @@ public ResponseEntity deleteGroup( @PathVariable String workspaceId, @PathVariable String groupId, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); scimAdminService.deleteGroup( UUID.fromString(workspaceId), UUID.fromString(groupId), - username, + actorEmail, admin); return ResponseEntity.noContent().build(); } diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiLogsController.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiLogsController.java index bc8e6e5..44eea8d 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiLogsController.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiLogsController.java @@ -43,9 +43,9 @@ public ResponseEntity> listLogs( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); - UUID wsId = workspaceService.requireWorkspaceId(workspaceId, username, admin); + UUID wsId = workspaceService.requireWorkspaceId(workspaceId, actorEmail, admin); int safeSize = Math.max(1, Math.min(size, 200)); int safePage = Math.max(1, page); PageRequest pageRequest = PageRequest.of(safePage - 1, safeSize, Sort.by(KEY_CREATED_AT).descending()); @@ -60,9 +60,9 @@ public ResponseEntity> listLogs( @DeleteMapping("/workspaces/{workspaceId}/logs") public ResponseEntity clearLogs(@PathVariable String workspaceId, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); - UUID wsId = workspaceService.requireWorkspaceId(workspaceId, username, admin); + UUID wsId = workspaceService.requireWorkspaceId(workspaceId, actorEmail, admin); logService.clearLogs(wsId); return ResponseEntity.noContent().build(); } diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiUsersController.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiUsersController.java index 91ffd2f..c998a78 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiUsersController.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiUsersController.java @@ -50,7 +50,7 @@ public ResponseEntity> listUsers( @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String q, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); int safeSize = Math.max(1, Math.min(size, 200)); int safePage = Math.max(1, page); @@ -60,7 +60,7 @@ public ResponseEntity> listUsers( UUID.fromString(workspaceId), q, pageRequest, - username, + actorEmail, admin); return ResponseEntity.ok(PagedResponseMapper.pagedResponse( users, @@ -68,7 +68,7 @@ public ResponseEntity> listUsers( safePage, safeSize)); } catch (RuntimeException ex) { - throw failListUsers(new UserListFailureContext("list", workspaceId, safePage, safeSize, q, username, admin), ex); + throw failListUsers(new UserListFailureContext("list", workspaceId, safePage, safeSize, q, actorEmail, admin), ex); } } @@ -78,7 +78,7 @@ public ResponseEntity>> lookupUsers( @RequestParam(required = false) String q, @RequestParam(defaultValue = "50") int size, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); int safeSize = Math.max(1, Math.min(size, 200)); PageRequest pageRequest = PageRequest.of(0, safeSize, Sort.by(KEY_USER_NAME).ascending()); @@ -87,11 +87,11 @@ public ResponseEntity>> lookupUsers( UUID.fromString(workspaceId), q, pageRequest, - username, + actorEmail, admin); return ResponseEntity.ok(users.stream().map(UserResponseMapper::userLookupToMap).toList()); } catch (RuntimeException ex) { - throw failListUsers(new UserListFailureContext("lookup", workspaceId, 0, safeSize, q, username, admin), ex); + throw failListUsers(new UserListFailureContext("lookup", workspaceId, 0, safeSize, q, actorEmail, admin), ex); } } @@ -101,7 +101,7 @@ private ResponseStatusException failListUsers(UserListFailureContext context, Ru + ", page=" + context.page() + ", size=" + context.size() + ", query=" + context.query() - + ", actor=" + context.username() + + ", actor=" + context.actorEmail() + ", admin=" + context.admin(); log.error(message, ex); if (ex instanceof ResponseStatusException responseStatusException) { @@ -116,16 +116,16 @@ private record UserListFailureContext( int page, int size, String query, - String username, + String actorEmail, boolean admin) { } @DeleteMapping("/workspaces/{workspaceId}/users") public ResponseEntity clearUsers(@PathVariable String workspaceId, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); - scimAdminService.deleteAllUsers(UUID.fromString(workspaceId), username, admin); + scimAdminService.deleteAllUsers(UUID.fromString(workspaceId), actorEmail, admin); return ResponseEntity.noContent().build(); } @@ -134,12 +134,12 @@ public ResponseEntity> createUser( @PathVariable String workspaceId, @RequestBody UserUpsertRequest request, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); ScimUser user = scimAdminService.createUser( UUID.fromString(workspaceId), request, - username, + actorEmail, admin); return ResponseEntity.status(201).body(UserResponseMapper.userToMap(user)); } @@ -150,13 +150,13 @@ public ResponseEntity> updateUser( @PathVariable String userId, @RequestBody UserUpsertRequest request, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); ScimUser user = scimAdminService.updateUser( UUID.fromString(workspaceId), UUID.fromString(userId), request, - username, + actorEmail, admin); return ResponseEntity.ok(UserResponseMapper.userToMap(user)); } @@ -166,12 +166,12 @@ public ResponseEntity deleteUser( @PathVariable String workspaceId, @PathVariable String userId, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); scimAdminService.deleteUser( UUID.fromString(workspaceId), UUID.fromString(userId), - username, + actorEmail, admin); return ResponseEntity.noContent().build(); } diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiWorkspacesController.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiWorkspacesController.java index 3af3abe..52a7f4f 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiWorkspacesController.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/ApiWorkspacesController.java @@ -56,30 +56,30 @@ public ResponseEntity> createWorkspace(@RequestBody Map>> listWorkspaces(Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); - List workspaces = workspaceService.listWorkspaces(username, admin); + List workspaces = workspaceService.listWorkspaces(actorEmail, admin); return ResponseEntity.ok(WorkspaceResponseMapper.workspaceListToMaps(workspaces, mgmtUserRepository)); } @GetMapping("/workspaces/{workspaceId}") public ResponseEntity> getWorkspace(@PathVariable String workspaceId, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); UUID wsId = UUID.fromString(workspaceId); - return workspaceService.getWorkspace(wsId, username, admin) + return workspaceService.getWorkspace(wsId, actorEmail, admin) .map(workspace -> { WorkspaceDataStats stats = workspaceService.getWorkspaceDataStats( wsId, - username, + actorEmail, admin); return ResponseEntity.ok( WorkspaceResponseMapper.workspaceDetailToMap(workspace, stats, @@ -91,11 +91,11 @@ public ResponseEntity> getWorkspace(@PathVariable String wor @GetMapping("/workspaces/{workspaceId}/stats") public ResponseEntity> getWorkspaceStats(@PathVariable String workspaceId, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); WorkspaceDataStats stats = workspaceService.getWorkspaceDataStats( UUID.fromString(workspaceId), - username, + actorEmail, admin); return ResponseEntity.ok(WorkspaceResponseMapper.workspaceStatsToMap(stats)); } @@ -103,9 +103,9 @@ public ResponseEntity> getWorkspaceStats(@PathVariable Strin @DeleteMapping("/workspaces/{workspaceId}") public ResponseEntity deleteWorkspace(@PathVariable String workspaceId, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); - workspaceService.deleteWorkspace(UUID.fromString(workspaceId), username, admin); + workspaceService.deleteWorkspace(UUID.fromString(workspaceId), actorEmail, admin); return ResponseEntity.noContent().build(); } @@ -114,7 +114,7 @@ public ResponseEntity> createToken( @PathVariable String workspaceId, @RequestBody(required = false) Map body, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); String name = body != null ? body.get(KEY_NAME) : null; String description = body != null ? body.get(KEY_DESCRIPTION) : null; @@ -122,7 +122,7 @@ public ResponseEntity> createToken( UUID.fromString(workspaceId), name, description, - username, + actorEmail, admin); Map response = new LinkedHashMap<>(); @@ -133,11 +133,11 @@ public ResponseEntity> createToken( @GetMapping("/workspaces/{workspaceId}/tokens") public ResponseEntity>> listTokens(@PathVariable String workspaceId, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); List tokens = workspaceService.listTokens( UUID.fromString(workspaceId), - username, + actorEmail, admin); return ResponseEntity.ok(WorkspaceResponseMapper.tokenListToMaps(tokens)); } @@ -147,12 +147,12 @@ public ResponseEntity revokeToken( @PathVariable String workspaceId, @PathVariable String tokenId, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); workspaceService.revokeToken( UUID.fromString(workspaceId), UUID.fromString(tokenId), - username, + actorEmail, admin); return ResponseEntity.noContent().build(); } @@ -163,29 +163,29 @@ public ResponseEntity> generateData( @PathVariable String kind, @RequestBody(required = false) GenerateDataRequest request, Authentication authentication) { - String username = AuthenticatedUser.username(authentication); + String actorEmail = AuthenticatedUser.email(authentication); boolean admin = AuthenticatedUser.isAdmin(authentication); - UUID wsId = workspaceService.requireWorkspaceId(workspaceId, username, admin); + UUID wsId = workspaceService.requireWorkspaceId(workspaceId, actorEmail, admin); DataGeneratorService.GenerationSummary summary = switch (kind.toLowerCase()) { case "users" -> workspaceDataGeneratorService.generateUsers( wsId, request != null ? request.count() : null, - username, + actorEmail, admin); case "groups" -> workspaceDataGeneratorService.generateGroups( wsId, request != null ? request.count() : null, - username, + actorEmail, admin); case "relations" -> workspaceDataGeneratorService.generateRelations( wsId, request != null ? request.count() : null, - username, + actorEmail, admin); case "all" -> workspaceDataGeneratorService.generateAll( wsId, request != null ? request.count() : null, - username, + actorEmail, admin); default -> throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "Unsupported generator kind: " + kind); diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/UiController.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/UiController.java index 65a8c47..ae46971 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/UiController.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/controller/UiController.java @@ -48,8 +48,7 @@ private String resolveDisplayName(Authentication authentication) { if (authentication == null) { return null; } - String fallback = AuthenticatedUser.displayName(authentication); - return mgmtUserService.findEmailById(AuthenticatedUser.userId(authentication)) - .orElse(fallback); + return mgmtUserService.resolveDisplayName(AuthenticatedUser.email(authentication)) + .orElseGet(() -> AuthenticatedUser.displayName(authentication)); } } diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/model/MgmtUser.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/model/MgmtUser.java index 17e8d1a..4e1a4ac 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/model/MgmtUser.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/model/MgmtUser.java @@ -13,9 +13,6 @@ public class MgmtUser { @Id @Column(length = 500, nullable = false) - private String id; - - @Column(length = 500) private String email; @Column(name = "last_login_at", columnDefinition = "TIMESTAMP WITH TIME ZONE", nullable = false) @@ -23,15 +20,11 @@ public class MgmtUser { public MgmtUser() {} - public MgmtUser(String id, String email, OffsetDateTime lastLoginAt) { - this.id = id; + public MgmtUser(String email, OffsetDateTime lastLoginAt) { this.email = email; this.lastLoginAt = lastLoginAt; } - public String getId() { return id; } - public void setId(String id) { this.id = id; } - public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUser.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUser.java index c64c254..e1ebfd4 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUser.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUser.java @@ -1,5 +1,6 @@ package de.palsoftware.scim.server.mgmt.security; +import de.palsoftware.scim.server.common.security.PrincipalEmailSupport; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; @@ -10,112 +11,37 @@ public final class AuthenticatedUser { private AuthenticatedUser() { } - public static String username(Authentication authentication) { + public static String email(Authentication authentication) { if (authentication == null) { throw new IllegalStateException("Missing authentication"); } Object principal = authentication.getPrincipal(); if (principal instanceof OidcUser oidcUser) { - String resolved = resolveOidcUsername(oidcUser); + String resolved = PrincipalEmailSupport.resolveEmail(oidcUser); if (resolved != null) { return resolved; } + throw new IllegalStateException("Authenticated principal is missing an email address"); } if (principal instanceof Jwt jwt) { - String resolved = resolveJwtUsername(jwt); + String resolved = PrincipalEmailSupport.resolveEmail(jwt); if (resolved != null) { return resolved; } + throw new IllegalStateException("Authenticated principal is missing an email address"); } String fallback = authentication.getName(); if (fallback == null || fallback.isBlank()) { - throw new IllegalStateException("Unable to resolve authenticated username"); + throw new IllegalStateException("Unable to resolve authenticated email"); } return fallback; } - public static String userId(Authentication authentication) { - if (authentication == null) { - throw new IllegalStateException("Missing authentication"); - } - Object principal = authentication.getPrincipal(); - if (principal instanceof OidcUser oidcUser) { - String sub = oidcUser.getSubject(); - if (sub != null && !sub.isBlank()) { - return sub; - } - } - if (principal instanceof Jwt jwt) { - String sub = jwt.getSubject(); - if (sub != null && !sub.isBlank()) { - return sub; - } - } - String fallback = authentication.getName(); - if (fallback == null || fallback.isBlank()) { - throw new IllegalStateException("Unable to resolve authenticated user id"); - } - return fallback; - } - - private static String resolveOidcUsername(OidcUser oidcUser) { - String preferredUsername = oidcUser.getPreferredUsername(); - if (preferredUsername != null && !preferredUsername.isBlank()) { - return preferredUsername; - } - String upn = oidcUser.getClaimAsString("upn"); - if (upn != null && !upn.isBlank()) { - return upn; - } - String email = oidcUser.getEmail(); - if (email != null && !email.isBlank()) { - return email; - } - String sub = oidcUser.getSubject(); - if (sub != null && !sub.isBlank()) { - return sub; - } - return null; - } - - private static String resolveJwtUsername(Jwt jwt) { - String preferredUsername = jwt.getClaimAsString("preferred_username"); - if (preferredUsername != null && !preferredUsername.isBlank()) { - return preferredUsername; - } - String upn = jwt.getClaimAsString("upn"); - if (upn != null && !upn.isBlank()) { - return upn; - } - String email = jwt.getClaimAsString("email"); - if (email != null && !email.isBlank()) { - return email; - } - String sub = jwt.getSubject(); - if (sub != null && !sub.isBlank()) { - return sub; - } - return null; - } - public static String displayName(Authentication authentication) { if (authentication == null) { return null; } - Object principal = authentication.getPrincipal(); - if (principal instanceof OidcUser oidcUser) { - String email = oidcUser.getEmail(); - if (email != null && !email.isBlank()) { - return email; - } - } - if (principal instanceof Jwt jwt) { - String email = jwt.getClaimAsString("email"); - if (email != null && !email.isBlank()) { - return email; - } - } - return username(authentication); + return email(authentication); } public static boolean isAdmin(Authentication authentication) { diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/CloudflareSecurityConfig.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/CloudflareSecurityConfig.java index 616c47c..8421143 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/CloudflareSecurityConfig.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/CloudflareSecurityConfig.java @@ -2,9 +2,11 @@ import de.palsoftware.scim.server.common.security.CloudflareJwtSecuritySupport; import de.palsoftware.scim.server.common.security.MgmtSecuritySupport; +import de.palsoftware.scim.server.mgmt.service.MgmtUserService; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AbstractAuthenticationToken; @@ -27,14 +29,17 @@ public class CloudflareSecurityConfig { private final String userRole; private final String roleClaim; private final String actuatorApiKey; + private final MgmtUserService mgmtUserService; public CloudflareSecurityConfig(@Value("${app.security.oidc.admin-role}") String adminRole, @Value("${app.security.oidc.user-role}") String userRole, @Value("${app.security.cloudflare.role-claim}") String roleClaim, + @Lazy MgmtUserService mgmtUserService, @Value("${app.security.actuator.api-key}") String actuatorApiKey) { this.adminRole = adminRole; this.userRole = userRole; this.roleClaim = roleClaim; + this.mgmtUserService = mgmtUserService; this.actuatorApiKey = actuatorApiKey; } @@ -72,6 +77,10 @@ public JwtDecoder cloudflareJwtDecoder( @Bean public Converter cloudflareJwtAuthenticationConverter() { - return CloudflareJwtSecuritySupport.createJwtAuthenticationConverter(roleClaim, adminRole, userRole); + return CloudflareJwtSecuritySupport.createJwtAuthenticationConverter( + roleClaim, + adminRole, + userRole, + mgmtUserService::provisionUser); } } \ No newline at end of file diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/service/MgmtUserService.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/service/MgmtUserService.java index e3d34af..bbd4b11 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/service/MgmtUserService.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/service/MgmtUserService.java @@ -2,6 +2,7 @@ import de.palsoftware.scim.server.mgmt.model.MgmtUser; import de.palsoftware.scim.server.mgmt.repository.MgmtUserRepository; +import de.palsoftware.scim.server.common.security.PrincipalEmailSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,18 +21,32 @@ public MgmtUserService(MgmtUserRepository mgmtUserRepository) { } @Transactional - public void provisionUser(String sub, String email) { - MgmtUser user = mgmtUserRepository.findById(sub) - .orElseGet(() -> new MgmtUser(sub, email, OffsetDateTime.now(ZoneOffset.UTC))); - user.setEmail(email); - user.setLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); + public void provisionUser(String email) { + String normalizedEmail = requireEmail(email); + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + MgmtUser user = mgmtUserRepository.findById(normalizedEmail) + .orElseGet(() -> new MgmtUser(normalizedEmail, now)); + user.setEmail(normalizedEmail); + user.setLastLoginAt(now); mgmtUserRepository.save(user); } - public Optional findEmailById(String sub) { - return mgmtUserRepository.findById(sub) + public Optional resolveDisplayName(String email) { + String normalizedEmail = PrincipalEmailSupport.normalizeEmail(email); + if (normalizedEmail == null) { + return Optional.empty(); + } + return mgmtUserRepository.findById(normalizedEmail) .map(MgmtUser::getEmail) .filter(e -> e != null && !e.isBlank()); } + + private String requireEmail(String email) { + String normalizedEmail = PrincipalEmailSupport.normalizeEmail(email); + if (normalizedEmail == null) { + throw new IllegalArgumentException("Management user email is required"); + } + return normalizedEmail; + } } diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/utils/WorkspaceResponseMapper.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/utils/WorkspaceResponseMapper.java index 948f22b..44a314d 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/utils/WorkspaceResponseMapper.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/utils/WorkspaceResponseMapper.java @@ -30,7 +30,7 @@ public static List> workspaceListToMaps(List work .distinct() .toList(); Map ownerEmails = mgmtUserRepository.findAllById(ownerIds).stream() - .collect(Collectors.toMap(MgmtUser::getId, MgmtUser::getEmail)); + .collect(Collectors.toMap(MgmtUser::getEmail, MgmtUser::getEmail)); return workspaces.stream() .map(workspace -> workspaceToMap(workspace, ownerEmails)) .toList(); @@ -40,7 +40,7 @@ public static Map workspaceToMap(Workspace workspace, MgmtUserRe String ownerName = workspace.getCreatedByUsername() != null ? mgmtUserRepository.findById(workspace.getCreatedByUsername()) .map(MgmtUser::getEmail) - .orElse(null) + .orElse(workspace.getCreatedByUsername()) : null; return buildWorkspaceMap(workspace, ownerName); } @@ -104,7 +104,7 @@ public static Map generationSummaryToMap(DataGeneratorService.Ge private static Map workspaceToMap(Workspace workspace, Map ownerEmails) { String ownerName = workspace.getCreatedByUsername() != null - ? ownerEmails.get(workspace.getCreatedByUsername()) + ? ownerEmails.getOrDefault(workspace.getCreatedByUsername(), workspace.getCreatedByUsername()) : null; return buildWorkspaceMap(workspace, ownerName); } diff --git a/scim-server-mgmt/src/main/resources/static/css/workspace.css b/scim-server-mgmt/src/main/resources/static/css/workspace.css index 6afc088..5dde0e5 100644 --- a/scim-server-mgmt/src/main/resources/static/css/workspace.css +++ b/scim-server-mgmt/src/main/resources/static/css/workspace.css @@ -184,11 +184,11 @@ label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bott .bmc-widget summary img { display: block; width: 1.9rem; height: 1.9rem; } .bmc-widget-label { color: #172554; font-size: 0.84rem; font-weight: 800; line-height: 1; white-space: nowrap; } .bmc-widget[open] summary { box-shadow: 0 24px 50px rgba(15, 23, 42, 0.5); } -.bmc-panel { position: absolute; right: 0; bottom: calc(100% + 0.75rem); width: min(10.5rem, calc(100vw - 2rem)); display: block; background: #fff; border-radius: 1.5rem; overflow: hidden; box-shadow: 0 20px 45px rgba(15, 23, 42, 0.45); opacity: 0; transform: translateY(0.5rem) scale(0.98); pointer-events: none; transition: opacity 0.18s ease, transform 0.18s ease; } +.bmc-panel { position: absolute; right: 0; bottom: calc(100% + 12px); width: min(168px, calc(100vw - 32px)); display: block; background: #fff; border-radius: 24px; overflow: hidden; box-shadow: 0 18px 36px rgba(0, 0, 0, 0.24); opacity: 0; transform: translateY(8px) scale(0.98); pointer-events: none; transition: opacity 0.18s ease, transform 0.18s ease; } .bmc-widget[open] .bmc-panel { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; } .bmc-panel img { display: block; width: 100%; height: auto; } -.bmc-panel-copy { display: flex; align-items: center; justify-content: center; padding: 0.5rem 0.55rem 0.65rem; background: #fff4b3; color: #7c2d12; text-align: center; } -.bmc-panel-hint { font-size: 0.72rem; font-weight: 700; opacity: 0.85; } +.bmc-panel-copy { display: flex; align-items: center; justify-content: center; padding: 8px 10px 10px; background: #fff4b3; color: #7c2d12; text-align: center; } +.bmc-panel-hint { font-size: 12px; font-weight: 700; opacity: 0.85; } .toast { position: fixed; bottom: 6rem; right: 1.5rem; background: var(--success); color: #fff; padding: 0.75rem 1.25rem; border-radius: var(--radius); font-size: 0.875rem; opacity: 0; transition: opacity 0.3s; z-index: 200; pointer-events: none; } .toast.show { opacity: 1; } @media (max-width: 720px) { @@ -196,9 +196,9 @@ label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bott .bmc-widget summary { min-height: 3rem; gap: 0.4rem; padding: 0.65rem 0.8rem; } .bmc-widget summary img { width: 1.6rem; height: 1.6rem; } .bmc-widget-label { font-size: 0.72rem; } - .bmc-panel { width: min(7.5rem, calc(100vw - 2rem)); border-radius: 1.1rem; } - .bmc-panel-copy { padding: 0.35rem 0.45rem 0.45rem; } - .bmc-panel-hint { font-size: 0.64rem; } + .bmc-panel { width: min(120px, calc(100vw - 24px)); border-radius: 18px; } + .bmc-panel-copy { padding: 6px 8px 8px; } + .bmc-panel-hint { font-size: 9px; } .toast { right: 1rem; bottom: 4.75rem; } } .section-title { font-size: 0.8rem; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.05em; margin-bottom: 0.5rem; } diff --git a/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/controller/LazyLoadingIntegrationTest.java b/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/controller/LazyLoadingIntegrationTest.java index 221d7bb..6a6e7ca 100644 --- a/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/controller/LazyLoadingIntegrationTest.java +++ b/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/controller/LazyLoadingIntegrationTest.java @@ -65,7 +65,7 @@ class LazyLoadingIntegrationTest extends PostgresIntegrationTestSupport { @WithMockUser(username = "admin-user", roles = {"ADMIN"}) void testListUsersAndGroups_generatesNoLazyInitializationException() throws Exception { // Create an admin user for workspace ownership and request context - MgmtUser mgmtUser = new MgmtUser("admin-user", "admin@example.com", OffsetDateTime.now(ZoneOffset.UTC)); + MgmtUser mgmtUser = new MgmtUser("admin@example.com", OffsetDateTime.now(ZoneOffset.UTC)); mgmtUserRepository.save(mgmtUser); // Create workspace diff --git a/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUserTest.java b/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUserTest.java index 0561796..47cdbc2 100644 --- a/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUserTest.java +++ b/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUserTest.java @@ -21,96 +21,92 @@ class AuthenticatedUserTest { - // ─── username ─────────────────────────────────────────────────────── + // ─── email ────────────────────────────────────────────────────────── @Test - void username_nullAuth_throws() { - assertThatThrownBy(() -> AuthenticatedUser.username(null)) + void email_nullAuth_throws() { + assertThatThrownBy(() -> AuthenticatedUser.email(null)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("Missing authentication"); } @Test - void username_oidcUser_preferredUsername() { + void email_oidcUser_preferredUsernameEmail() { OidcUser oidcUser = mock(OidcUser.class); - when(oidcUser.getPreferredUsername()).thenReturn("preferred"); + when(oidcUser.getEmail()).thenReturn(null); + when(oidcUser.getClaimAsString("upn")).thenReturn(null); + when(oidcUser.getClaimAsString("unique_name")).thenReturn(null); + when(oidcUser.getPreferredUsername()).thenReturn("Preferred.User@example.com"); Authentication auth = mockAuthWithPrincipal(oidcUser); - assertThat(AuthenticatedUser.username(auth)).isEqualTo("preferred"); + assertThat(AuthenticatedUser.email(auth)).isEqualTo("preferred.user@example.com"); } @Test - void username_oidcUser_upnFallback() { + void email_oidcUser_upnFallback() { OidcUser oidcUser = mock(OidcUser.class); when(oidcUser.getPreferredUsername()).thenReturn(null); when(oidcUser.getClaimAsString("upn")).thenReturn("user@domain.com"); Authentication auth = mockAuthWithPrincipal(oidcUser); - assertThat(AuthenticatedUser.username(auth)).isEqualTo("user@domain.com"); + assertThat(AuthenticatedUser.email(auth)).isEqualTo("user@domain.com"); } @Test - void username_oidcUser_emailFallback() { + void email_oidcUser_emailFallback() { OidcUser oidcUser = mock(OidcUser.class); when(oidcUser.getPreferredUsername()).thenReturn(null); when(oidcUser.getClaimAsString("upn")).thenReturn(null); + when(oidcUser.getClaimAsString("unique_name")).thenReturn(null); when(oidcUser.getEmail()).thenReturn("email@test.com"); Authentication auth = mockAuthWithPrincipal(oidcUser); - assertThat(AuthenticatedUser.username(auth)).isEqualTo("email@test.com"); + assertThat(AuthenticatedUser.email(auth)).isEqualTo("email@test.com"); } @Test - void username_oidcUser_subFallback() { + void email_oidcUser_missingEmail_throws() { OidcUser oidcUser = mock(OidcUser.class); when(oidcUser.getPreferredUsername()).thenReturn(null); when(oidcUser.getClaimAsString("upn")).thenReturn(null); + when(oidcUser.getClaimAsString("unique_name")).thenReturn(null); when(oidcUser.getEmail()).thenReturn(null); - when(oidcUser.getSubject()).thenReturn("sub-123"); Authentication auth = mockAuthWithPrincipal(oidcUser); - assertThat(AuthenticatedUser.username(auth)).isEqualTo("sub-123"); + assertThatThrownBy(() -> AuthenticatedUser.email(auth)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("missing an email"); } @Test - void username_jwt_emailFallback() { + void email_jwt_emailFallback() { Authentication auth = new TestingAuthenticationToken(jwt(Map.of( "sub", "jwt-sub", "email", "jwt@example.com" )), "n/a"); - assertThat(AuthenticatedUser.username(auth)).isEqualTo("jwt@example.com"); + assertThat(AuthenticatedUser.email(auth)).isEqualTo("jwt@example.com"); } @Test - void username_nonOidc_fallsBackToName() { + void email_nonOidc_fallsBackToName() { Authentication auth = mock(Authentication.class); when(auth.getPrincipal()).thenReturn("not-oidc"); when(auth.getName()).thenReturn("fallback-name"); - assertThat(AuthenticatedUser.username(auth)).isEqualTo("fallback-name"); + assertThat(AuthenticatedUser.email(auth)).isEqualTo("fallback-name"); } @Test - void username_noIdentifier_throws() { + void email_noIdentifier_throws() { Authentication auth = mock(Authentication.class); when(auth.getPrincipal()).thenReturn("not-oidc"); when(auth.getName()).thenReturn(null); - assertThatThrownBy(() -> AuthenticatedUser.username(auth)) + assertThatThrownBy(() -> AuthenticatedUser.email(auth)) .isInstanceOf(IllegalStateException.class); } - @Test - void userId_jwtPrincipal_returnsSubject() { - Authentication auth = new TestingAuthenticationToken(jwt(Map.of( - "sub", "jwt-sub-123", - "email", "jwt@example.com" - )), "n/a"); - - assertThat(AuthenticatedUser.userId(auth)).isEqualTo("jwt-sub-123"); - } - // ─── displayName ──────────────────────────────────────────────────── @Test @@ -122,20 +118,24 @@ void displayName_null_returnsNull() { void displayName_oidcUser_returnsEmail() { OidcUser oidcUser = mock(OidcUser.class); when(oidcUser.getEmail()).thenReturn("display@test.com"); - when(oidcUser.getPreferredUsername()).thenReturn("preferred"); + when(oidcUser.getClaimAsString("upn")).thenReturn(null); + when(oidcUser.getClaimAsString("unique_name")).thenReturn(null); + when(oidcUser.getPreferredUsername()).thenReturn("preferred@example.com"); Authentication auth = mockAuthWithPrincipal(oidcUser); assertThat(AuthenticatedUser.displayName(auth)).isEqualTo("display@test.com"); } @Test - void displayName_oidcUser_noEmail_fallsBackToUsername() { + void displayName_oidcUser_noEmail_usesPreferredUsernameEmail() { OidcUser oidcUser = mock(OidcUser.class); when(oidcUser.getEmail()).thenReturn(null); - when(oidcUser.getPreferredUsername()).thenReturn("preferred"); + when(oidcUser.getClaimAsString("upn")).thenReturn(null); + when(oidcUser.getClaimAsString("unique_name")).thenReturn(null); + when(oidcUser.getPreferredUsername()).thenReturn("preferred@example.com"); Authentication auth = mockAuthWithPrincipal(oidcUser); - assertThat(AuthenticatedUser.displayName(auth)).isEqualTo("preferred"); + assertThat(AuthenticatedUser.displayName(auth)).isEqualTo("preferred@example.com"); } @Test diff --git a/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/service/MgmtUserServiceTest.java b/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/service/MgmtUserServiceTest.java index 04f900e..92a41da 100644 --- a/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/service/MgmtUserServiceTest.java +++ b/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/service/MgmtUserServiceTest.java @@ -13,7 +13,9 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -29,64 +31,59 @@ class MgmtUserServiceTest { // ─── provisionUser ────────────────────────────────────────────────── @Test - void provisionUser_newUser_createsAndSaves() { - when(mgmtUserRepository.findById("sub-123")).thenReturn(Optional.empty()); + void provisionUser_newUser_createsAndSavesNormalizedEmail() { + when(mgmtUserRepository.findById("user@test.com")).thenReturn(Optional.empty()); when(mgmtUserRepository.save(any(MgmtUser.class))).thenAnswer(i -> i.getArgument(0)); - service.provisionUser("sub-123", "user@test.com"); + service.provisionUser(" User@Test.com "); - verify(mgmtUserRepository).save(any(MgmtUser.class)); + verify(mgmtUserRepository).save(argThat(user -> "user@test.com".equals(user.getEmail()) + && user.getLastLoginAt() != null)); } @Test - void provisionUser_existingUser_updatesEmailAndLogin() { - MgmtUser existing = new MgmtUser("sub-123", "old@test.com", OffsetDateTime.now(ZoneOffset.UTC)); - when(mgmtUserRepository.findById("sub-123")).thenReturn(Optional.of(existing)); + void provisionUser_existingUser_updatesLoginTime() { + MgmtUser existing = new MgmtUser("user@test.com", OffsetDateTime.now(ZoneOffset.UTC).minusDays(1)); + when(mgmtUserRepository.findById("user@test.com")).thenReturn(Optional.of(existing)); when(mgmtUserRepository.save(any(MgmtUser.class))).thenAnswer(i -> i.getArgument(0)); - service.provisionUser("sub-123", "new@test.com"); + service.provisionUser("USER@Test.com"); - assertThat(existing.getEmail()).isEqualTo("new@test.com"); + assertThat(existing.getEmail()).isEqualTo("user@test.com"); verify(mgmtUserRepository).save(existing); } - // ─── findEmailById ────────────────────────────────────────────────── - @Test - void findEmailById_found() { - MgmtUser user = new MgmtUser("sub-123", "user@test.com", OffsetDateTime.now(ZoneOffset.UTC)); - when(mgmtUserRepository.findById("sub-123")).thenReturn(Optional.of(user)); - - Optional result = service.findEmailById("sub-123"); - - assertThat(result).contains("user@test.com"); + void provisionUser_missingEmail_throws() { + assertThatThrownBy(() -> service.provisionUser("not-an-email")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("email is required"); } + // ─── resolveDisplayName ───────────────────────────────────────────── + @Test - void findEmailById_notFound() { - when(mgmtUserRepository.findById("sub-999")).thenReturn(Optional.empty()); + void resolveDisplayName_found() { + MgmtUser user = new MgmtUser("user@test.com", OffsetDateTime.now(ZoneOffset.UTC)); + when(mgmtUserRepository.findById("user@test.com")).thenReturn(Optional.of(user)); - Optional result = service.findEmailById("sub-999"); + Optional result = service.resolveDisplayName("User@Test.com"); - assertThat(result).isEmpty(); + assertThat(result).contains("user@test.com"); } @Test - void findEmailById_blankEmail_returnsEmpty() { - MgmtUser user = new MgmtUser("sub-123", " ", OffsetDateTime.now(ZoneOffset.UTC)); - when(mgmtUserRepository.findById("sub-123")).thenReturn(Optional.of(user)); + void resolveDisplayName_notFound() { + when(mgmtUserRepository.findById("missing@test.com")).thenReturn(Optional.empty()); - Optional result = service.findEmailById("sub-123"); + Optional result = service.resolveDisplayName("missing@test.com"); assertThat(result).isEmpty(); } @Test - void findEmailById_nullEmail_returnsEmpty() { - MgmtUser user = new MgmtUser("sub-123", null, OffsetDateTime.now(ZoneOffset.UTC)); - when(mgmtUserRepository.findById("sub-123")).thenReturn(Optional.of(user)); - - Optional result = service.findEmailById("sub-123"); + void resolveDisplayName_invalidEmail_returnsEmpty() { + Optional result = service.resolveDisplayName("not-an-email"); assertThat(result).isEmpty(); } diff --git a/scim-validator-mgmt/pom.xml b/scim-validator-mgmt/pom.xml index 2b8bb5e..7b9d377 100644 --- a/scim-validator-mgmt/pom.xml +++ b/scim-validator-mgmt/pom.xml @@ -24,6 +24,7 @@ 2.4.3 3.18.0 2.18.6 + 1.21.4 @@ -102,6 +103,11 @@ groovy-json ${groovy.version} + + org.apache.groovy + groovy-yaml + ${groovy.version} + org.spockframework spock-core @@ -132,6 +138,18 @@ commons-lang3 ${commons-lang3.version} + + org.testcontainers + testcontainers + ${testcontainers.version} + runtime + + + org.testcontainers + postgresql + ${testcontainers.version} + runtime + org.postgresql diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/controller/ValidationController.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/controller/ValidationController.java index 381891c..2c03fc8 100644 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/controller/ValidationController.java +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/controller/ValidationController.java @@ -41,7 +41,7 @@ public String index(Model model, Authentication authentication) { if (!model.containsAttribute("runForm")) { model.addAttribute("runForm", new ValidationRunForm("", "", "")); } - model.addAttribute("runs", validationRunService.listRuns(actorUserId(authentication), isAdmin(authentication))); + model.addAttribute("runs", validationRunService.listRuns(actorEmail(authentication), isAdmin(authentication))); model.addAttribute(ATTR_CURRENT_USER, resolveDisplayName(authentication)); model.addAttribute(ATTR_CURRENT_USER_ROLE, currentUserRole(authentication)); return "index"; @@ -54,7 +54,7 @@ public String execute(@Valid @ModelAttribute("runForm") ValidationRunForm runFor Authentication authentication) { if (bindingResult.hasErrors()) { model.addAttribute("runs", - validationRunService.listRuns(actorUserId(authentication), isAdmin(authentication))); + validationRunService.listRuns(actorEmail(authentication), isAdmin(authentication))); model.addAttribute(ATTR_CURRENT_USER, resolveDisplayName(authentication)); model.addAttribute(ATTR_CURRENT_USER_ROLE, currentUserRole(authentication)); return "index"; @@ -62,8 +62,7 @@ public String execute(@Valid @ModelAttribute("runForm") ValidationRunForm runFor ValidationRunView run = ValidationRunView.from(validationRunService.executeRun( runForm, - actorUserId(authentication), - resolveDisplayName(authentication))); + actorEmail(authentication))); return "redirect:/runs/" + run.id(); } @@ -71,9 +70,9 @@ public String execute(@Valid @ModelAttribute("runForm") ValidationRunForm runFor public String detail(@PathVariable UUID runId, Model model, Authentication authentication) { try { model.addAttribute("run", - validationRunService.getRun(runId, actorUserId(authentication), isAdmin(authentication))); + validationRunService.getRun(runId, actorEmail(authentication), isAdmin(authentication))); model.addAttribute("tests", - validationRunService.getTestResults(runId, actorUserId(authentication), isAdmin(authentication))); + validationRunService.getTestResults(runId, actorEmail(authentication), isAdmin(authentication))); } catch (ResponseStatusException e) { if (e.getStatusCode() == HttpStatus.NOT_FOUND) { model.addAttribute("runNotFound", true); @@ -88,12 +87,12 @@ public String detail(@PathVariable UUID runId, Model model, Authentication authe @PostMapping("/runs/{runId}/delete") public String deleteRun(@PathVariable UUID runId, Authentication authentication) { - validationRunService.deleteRun(runId, actorUserId(authentication), isAdmin(authentication)); + validationRunService.deleteRun(runId, actorEmail(authentication), isAdmin(authentication)); return "redirect:/"; } - private String actorUserId(Authentication authentication) { - return AuthenticatedUser.userId(authentication); + private String actorEmail(Authentication authentication) { + return AuthenticatedUser.email(authentication); } private boolean isAdmin(Authentication authentication) { @@ -109,6 +108,6 @@ private String resolveDisplayName(Authentication authentication) { return null; } String fallback = AuthenticatedUser.displayName(authentication); - return mgmtUserService.resolveDisplayName(AuthenticatedUser.userId(authentication), fallback); + return mgmtUserService.resolveDisplayName(AuthenticatedUser.email(authentication), fallback); } } diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/dto/ValidationRunView.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/dto/ValidationRunView.java index 144363b..aff3dc5 100644 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/dto/ValidationRunView.java +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/dto/ValidationRunView.java @@ -11,23 +11,18 @@ public record ValidationRunView( String targetUrl, OffsetDateTime executedAt, String status, - String createdByUsername, + String createdByEmail, int totalTests, int passedTests, int failedTests) { public static ValidationRunView from(ValidationRun run) { - String ownerDisplayName = run.getCreatedByUser() != null - && run.getCreatedByUser().getEmail() != null - && !run.getCreatedByUser().getEmail().isBlank() - ? run.getCreatedByUser().getEmail() - : run.getCreatedByUsername(); return new ValidationRunView( run.getId(), run.getName(), run.getTargetUrl(), run.getExecutedAt(), run.getStatus(), - ownerDisplayName, + run.getCreatedByUser().getEmail(), run.getTotalTests(), run.getPassedTests(), run.getFailedTests()); diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/model/ValidationMgmtUser.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/model/ValidationMgmtUser.java index 3eb503e..cbd461f 100644 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/model/ValidationMgmtUser.java +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/model/ValidationMgmtUser.java @@ -13,9 +13,6 @@ public class ValidationMgmtUser { @Id @Column(length = 500, nullable = false) - private String id; - - @Column(length = 500) private String email; @Column(name = "last_login_at", columnDefinition = "TIMESTAMP WITH TIME ZONE", nullable = false) @@ -24,20 +21,11 @@ public class ValidationMgmtUser { public ValidationMgmtUser() { } - public ValidationMgmtUser(String id, String email, OffsetDateTime lastLoginAt) { - this.id = id; + public ValidationMgmtUser(String email, OffsetDateTime lastLoginAt) { this.email = email; this.lastLoginAt = lastLoginAt; } - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - public String getEmail() { return email; } diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/model/ValidationRun.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/model/ValidationRun.java index 6b9b7cb..c4c2bd9 100644 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/model/ValidationRun.java +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/model/ValidationRun.java @@ -39,13 +39,10 @@ public class ValidationRun { @Column(nullable = false, length = 20) private String status; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "created_by_user_id") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "created_by_email", nullable = false) private ValidationMgmtUser createdByUser; - @Column(length = 255) - private String createdByUsername; - @Column(nullable = false) private int totalTests; @@ -127,14 +124,6 @@ public void setFailedTests(int failedTests) { this.failedTests = failedTests; } - public String getCreatedByUsername() { - return createdByUsername; - } - - public void setCreatedByUsername(String createdByUsername) { - this.createdByUsername = createdByUsername; - } - public List getTestResults() { return testResults; } diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/repo/ValidationRunRepository.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/repo/ValidationRunRepository.java index e22f0b5..73ee9c7 100644 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/repo/ValidationRunRepository.java +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/repo/ValidationRunRepository.java @@ -13,15 +13,15 @@ public interface ValidationRunRepository extends JpaRepository { @Query(""" select run from ValidationRun run - where run.createdByUser.id = :actorUserId + where run.createdByUser.email = :actorEmail """) - List findOwnedRuns(@Param("actorUserId") String actorUserId, Sort sort); + List findOwnedRuns(@Param("actorEmail") String actorEmail, Sort sort); @Query(""" select run from ValidationRun run where run.id = :id - and run.createdByUser.id = :actorUserId + and run.createdByUser.email = :actorEmail """) Optional findAccessibleById(@Param("id") UUID id, - @Param("actorUserId") String actorUserId); + @Param("actorEmail") String actorEmail); } diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUser.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUser.java index 3628679..8d51fa2 100644 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUser.java +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUser.java @@ -1,5 +1,6 @@ package de.palsoftware.scim.validator.mgmt.security; +import de.palsoftware.scim.server.common.security.PrincipalEmailSupport; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; @@ -10,92 +11,28 @@ public final class AuthenticatedUser { private AuthenticatedUser() { } - public static String username(Authentication authentication) { + public static String email(Authentication authentication) { if (authentication == null) { throw new IllegalStateException("Missing authentication"); } Object principal = authentication.getPrincipal(); if (principal instanceof OidcUser oidcUser) { - String resolved = resolveOidcUsername(oidcUser); + String resolved = PrincipalEmailSupport.resolveEmail(oidcUser); if (resolved != null) { return resolved; } + throw new IllegalStateException("Authenticated principal is missing an email address"); } if (principal instanceof Jwt jwt) { - String resolved = resolveJwtUsername(jwt); + String resolved = PrincipalEmailSupport.resolveEmail(jwt); if (resolved != null) { return resolved; } + throw new IllegalStateException("Authenticated principal is missing an email address"); } String fallback = authentication.getName(); if (fallback == null || fallback.isBlank()) { - throw new IllegalStateException("Unable to resolve authenticated username"); - } - return fallback; - } - - private static String resolveOidcUsername(OidcUser oidcUser) { - String preferredUsername = oidcUser.getPreferredUsername(); - if (preferredUsername != null && !preferredUsername.isBlank()) { - return preferredUsername; - } - String upn = oidcUser.getClaimAsString("upn"); - if (upn != null && !upn.isBlank()) { - return upn; - } - String email = oidcUser.getEmail(); - if (email != null && !email.isBlank()) { - return email; - } - String sub = oidcUser.getSubject(); - if (sub != null && !sub.isBlank()) { - return sub; - } - return null; - } - - private static String resolveJwtUsername(Jwt jwt) { - String preferredUsername = jwt.getClaimAsString("preferred_username"); - if (preferredUsername != null && !preferredUsername.isBlank()) { - return preferredUsername; - } - String upn = jwt.getClaimAsString("upn"); - if (upn != null && !upn.isBlank()) { - return upn; - } - String email = jwt.getClaimAsString("email"); - if (email != null && !email.isBlank()) { - return email; - } - String sub = jwt.getSubject(); - if (sub != null && !sub.isBlank()) { - return sub; - } - return null; - } - - public static String userId(Authentication authentication) { - if (authentication == null) { - throw new IllegalStateException("Missing authentication"); - } - - Object principal = authentication.getPrincipal(); - if (principal instanceof OidcUser oidcUser) { - String sub = oidcUser.getSubject(); - if (sub != null && !sub.isBlank()) { - return sub; - } - } - if (principal instanceof Jwt jwt) { - String sub = jwt.getSubject(); - if (sub != null && !sub.isBlank()) { - return sub; - } - } - - String fallback = authentication.getName(); - if (fallback == null || fallback.isBlank()) { - throw new IllegalStateException("Unable to resolve authenticated user id"); + throw new IllegalStateException("Unable to resolve authenticated email"); } return fallback; } @@ -104,20 +41,7 @@ public static String displayName(Authentication authentication) { if (authentication == null) { return null; } - Object principal = authentication.getPrincipal(); - if (principal instanceof OidcUser oidcUser) { - String email = oidcUser.getEmail(); - if (email != null && !email.isBlank()) { - return email; - } - } - if (principal instanceof Jwt jwt) { - String email = jwt.getClaimAsString("email"); - if (email != null && !email.isBlank()) { - return email; - } - } - return username(authentication); + return email(authentication); } public static boolean isAdmin(Authentication authentication) { diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/CloudflareSecurityConfig.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/CloudflareSecurityConfig.java index 791597d..c704e30 100644 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/CloudflareSecurityConfig.java +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/CloudflareSecurityConfig.java @@ -2,9 +2,11 @@ import de.palsoftware.scim.server.common.security.CloudflareJwtSecuritySupport; import de.palsoftware.scim.server.common.security.MgmtSecuritySupport; +import de.palsoftware.scim.validator.mgmt.service.MgmtUserService; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AbstractAuthenticationToken; @@ -27,14 +29,17 @@ public class CloudflareSecurityConfig { private final String userRole; private final String roleClaim; private final String actuatorApiKey; + private final MgmtUserService mgmtUserService; public CloudflareSecurityConfig(@Value("${app.security.oidc.admin-role}") String adminRole, @Value("${app.security.oidc.user-role}") String userRole, @Value("${app.security.cloudflare.role-claim}") String roleClaim, + @Lazy MgmtUserService mgmtUserService, @Value("${app.security.actuator.api-key}") String actuatorApiKey) { this.adminRole = adminRole; this.userRole = userRole; this.roleClaim = roleClaim; + this.mgmtUserService = mgmtUserService; this.actuatorApiKey = actuatorApiKey; } @@ -72,6 +77,10 @@ public JwtDecoder cloudflareJwtDecoder( @Bean public Converter cloudflareJwtAuthenticationConverter() { - return CloudflareJwtSecuritySupport.createJwtAuthenticationConverter(roleClaim, adminRole, userRole); + return CloudflareJwtSecuritySupport.createJwtAuthenticationConverter( + roleClaim, + adminRole, + userRole, + mgmtUserService::provisionUser); } } \ No newline at end of file diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/service/MgmtUserService.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/service/MgmtUserService.java index 9f896da..6ff11a6 100644 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/service/MgmtUserService.java +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/service/MgmtUserService.java @@ -1,5 +1,6 @@ package de.palsoftware.scim.validator.mgmt.service; +import de.palsoftware.scim.server.common.security.PrincipalEmailSupport; import de.palsoftware.scim.validator.mgmt.model.ValidationMgmtUser; import de.palsoftware.scim.validator.mgmt.repo.ValidationMgmtUserRepository; import org.springframework.stereotype.Service; @@ -18,22 +19,33 @@ public MgmtUserService(ValidationMgmtUserRepository mgmtUserRepository) { } @Transactional - public void provisionUser(String sub, String email) { - ValidationMgmtUser user = mgmtUserRepository.findById(sub) - .orElseGet(() -> new ValidationMgmtUser(sub, email, OffsetDateTime.now(ZoneOffset.UTC))); - user.setEmail(email); - user.setLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); + public void provisionUser(String email) { + String normalizedEmail = requireEmail(email); + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + ValidationMgmtUser user = mgmtUserRepository.findById(normalizedEmail) + .orElseGet(() -> new ValidationMgmtUser(normalizedEmail, now)); + user.setEmail(normalizedEmail); + user.setLastLoginAt(now); mgmtUserRepository.save(user); } @Transactional(readOnly = true) - public String resolveDisplayName(String sub, String fallbackDisplayName) { - if (sub == null || sub.isBlank()) { + public String resolveDisplayName(String email, String fallbackDisplayName) { + String normalizedEmail = PrincipalEmailSupport.normalizeEmail(email); + if (normalizedEmail == null) { return fallbackDisplayName; } - return mgmtUserRepository.findById(sub) + return mgmtUserRepository.findById(normalizedEmail) .map(ValidationMgmtUser::getEmail) - .filter(email -> email != null && !email.isBlank()) + .filter(storedEmail -> storedEmail != null && !storedEmail.isBlank()) .orElse(fallbackDisplayName); } + + private String requireEmail(String email) { + String normalizedEmail = PrincipalEmailSupport.normalizeEmail(email); + if (normalizedEmail == null) { + throw new IllegalArgumentException("Management user email is required"); + } + return normalizedEmail; + } } diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/service/ValidationRunService.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/service/ValidationRunService.java index 2032d71..03d6628 100644 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/service/ValidationRunService.java +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/service/ValidationRunService.java @@ -80,15 +80,15 @@ public ValidationRunService(ValidationRunRepository runRepository, } @Transactional - public ValidationRun executeRun(ValidationRunForm form, String actorUserId, String actorDisplayName) { + public ValidationRun executeRun(ValidationRunForm form, String actorEmail) { ValidationRun run = new ValidationRun(); run.setName(form.name().trim()); run.setTargetUrl(form.baseUrl().trim()); run.setExecutedAt(OffsetDateTime.now()); run.setStatus("RUNNING"); - ValidationMgmtUser owner = mgmtUserRepository.findById(actorUserId).orElse(null); + ValidationMgmtUser owner = mgmtUserRepository.findById(actorEmail) + .orElseThrow(() -> new IllegalStateException("Authenticated management user must exist")); run.setCreatedByUser(owner); - run.setCreatedByUsername(actorDisplayName); run.setTotalTests(0); run.setPassedTests(0); run.setFailedTests(0); @@ -125,13 +125,13 @@ public ValidationRun executeRun(ValidationRunForm form, String actorUserId, Stri return runRepository.save(run); } - public List listRuns(String actorUserId, boolean admin) { + public List listRuns(String actorEmail, boolean admin) { List runs; Sort sort = Sort.by(Sort.Direction.DESC, "executedAt"); if (admin) { runs = runRepository.findAll(sort); } else { - runs = runRepository.findOwnedRuns(actorUserId, sort); + runs = runRepository.findOwnedRuns(actorEmail, sort); } return runs .stream() @@ -139,13 +139,13 @@ public List listRuns(String actorUserId, boolean admin) { .toList(); } - public ValidationRunView getRun(UUID runId, String actorUserId, boolean admin) { - ValidationRun run = requireRunAccess(runId, actorUserId, admin); + public ValidationRunView getRun(UUID runId, String actorEmail, boolean admin) { + ValidationRun run = requireRunAccess(runId, actorEmail, admin); return ValidationRunView.from(run); } - public List getTestResults(UUID runId, String actorUserId, boolean admin) { - requireRunAccess(runId, actorUserId, admin); + public List getTestResults(UUID runId, String actorEmail, boolean admin) { + requireRunAccess(runId, actorEmail, admin); List testResults = testResultRepository.findByRunIdOrderByStartedAtAsc(runId); return testResults.stream() .map(testResult -> { @@ -160,17 +160,17 @@ public List getTestResults(UUID runId, String actorUse } @Transactional - public void deleteRun(UUID runId, String actorUserId, boolean admin) { - requireRunAccess(runId, actorUserId, admin); + public void deleteRun(UUID runId, String actorEmail, boolean admin) { + requireRunAccess(runId, actorEmail, admin); runRepository.deleteById(runId); } - private ValidationRun requireRunAccess(UUID runId, String actorUserId, boolean admin) { + private ValidationRun requireRunAccess(UUID runId, String actorEmail, boolean admin) { if (admin) { return runRepository.findById(runId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Validation run not found")); } - return runRepository.findAccessibleById(runId, actorUserId) + return runRepository.findAccessibleById(runId, actorEmail) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Validation run not found")); } diff --git a/scim-validator-mgmt/src/main/resources/db/validator/V1__init_validator_schema.sql b/scim-validator-mgmt/src/main/resources/db/validator/V1__init_validator_schema.sql index c47ede8..a53d5d6 100644 --- a/scim-validator-mgmt/src/main/resources/db/validator/V1__init_validator_schema.sql +++ b/scim-validator-mgmt/src/main/resources/db/validator/V1__init_validator_schema.sql @@ -1,6 +1,5 @@ CREATE TABLE validator_mgmt_users ( - id VARCHAR(500) PRIMARY KEY, - email VARCHAR(500), + email VARCHAR(500) PRIMARY KEY, last_login_at TIMESTAMP WITH TIME ZONE NOT NULL ); @@ -10,16 +9,15 @@ CREATE TABLE validation_run ( target_url VARCHAR(500) NOT NULL, executed_at TIMESTAMP WITH TIME ZONE NOT NULL, status VARCHAR(20) NOT NULL, - created_by_user_id VARCHAR(500), - created_by_username VARCHAR(255), + created_by_email VARCHAR(500) NOT NULL, total_tests INTEGER NOT NULL, passed_tests INTEGER NOT NULL, failed_tests INTEGER NOT NULL, CONSTRAINT fk_validation_run_created_by_user - FOREIGN KEY (created_by_user_id) REFERENCES validator_mgmt_users (id) + FOREIGN KEY (created_by_email) REFERENCES validator_mgmt_users (email) ); -CREATE INDEX idx_validation_run_created_by_user_id ON validation_run (created_by_user_id); +CREATE INDEX idx_validation_run_created_by_email ON validation_run (created_by_email); CREATE INDEX idx_validation_run_executed_at ON validation_run (executed_at DESC); CREATE TABLE validation_test_result ( diff --git a/scim-validator-mgmt/src/main/resources/templates/index.html b/scim-validator-mgmt/src/main/resources/templates/index.html index d77ab36..ed75f1f 100644 --- a/scim-validator-mgmt/src/main/resources/templates/index.html +++ b/scim-validator-mgmt/src/main/resources/templates/index.html @@ -6,15 +6,15 @@ SCIM Validator