diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d0795de..1db9a5b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -29,6 +29,26 @@ Compatibility mode is route-based and extensible: - converts selected `primary` booleans to string values - adds flattened enterprise manager alias key +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. +- Shared helpers live in `scim-server-common` (`AzureOidcSecuritySupport`, `CloudflareJwtSecuritySupport`, `MgmtSecuritySupport`). + +Kubernetes support is split into two trees: + +- `k8s/app/**` deploys the namespaced SCIM stack in `scim`: + - CloudNativePG PostgreSQL cluster + - validator database resource + - API, management, and validator-mgmt Deployments and Services +- `k8s/cluster/**` deploys supporting cluster resources: + - local-path storage configuration and `local-path-custom` `StorageClass` + - `cloudflared` in its own namespace + +Kubernetes secrets are stored as `*.sops.yaml` files and rendered through `ksops`. +The root `.sops.yaml` defines the active age recipient. + ## Build And Run ```bash @@ -41,14 +61,25 @@ mvn clean install -pl '!scim-validator' # API local mode (requires datasource env vars and ACTUATOR_API_KEY) cd scim-server-api && mvn spring-boot:run -# Mgmt UI/API local mode (requires datasource env vars, ACTUATOR_API_KEY, and Azure OIDC env vars) +# Mgmt UI/API local mode (defaults to Azure profile; requires datasource env vars, +# ACTUATOR_API_KEY, and Azure OIDC env vars unless you explicitly set SPRING_PROFILES_ACTIVE=cloudflare) cd scim-server-mgmt && mvn spring-boot:run -# Validator management local mode (requires datasource env vars, ACTUATOR_API_KEY, and Azure OIDC env vars) +# Validator management local mode (defaults to Azure profile; requires datasource env vars, +# ACTUATOR_API_KEY, and Azure OIDC env vars unless you explicitly set SPRING_PROFILES_ACTIVE=cloudflare) cd scim-validator-mgmt && mvn spring-boot:run -# Docker stack (PostgreSQL 18 + API + mgmt + validator mgmt) +# Docker stack docker compose up --build + +# Docker stack plus local cloudflared sidecar +docker compose --profile cloudflare up --build + +# Kubernetes support resources (requires kubectl, kustomize, ksops, sops, and SOPS_AGE_KEY_FILE) +kustomize build --enable-alpha-plugins --enable-exec k8s/cluster | kubectl apply -f - + +# Kubernetes application stack +kustomize build --enable-alpha-plugins --enable-exec k8s/app | kubectl apply -f - ``` Docker default ports: @@ -58,6 +89,12 @@ Docker default ports: - Validator Mgmt `:8082` - PostgreSQL `:5432` +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. + ## Validator Execution `scim-validator` can either bootstrap its own disposable target via @@ -84,9 +121,13 @@ Notes from `ScimBaseSpec`: - SCIM mapping code uses static utility classes (`ScimUserMapper`, `ScimGroupMapper`, `MsScimUserMapper`). - Java records are used in DTO layers (notably in mgmt and validator-mgmt modules). - `@Transactional` appears on service classes and on selected controller classes/methods. Preserve existing boundaries unless intentionally refactoring. +- Flyway migrations are split by concern: + - shared schema migrations under `db/common` + - validator schema migrations under `db/validator` + - module-specific migrations under `db/migration` - Content types: - - SCIM endpoints: `application/scim+json` - - Mgmt endpoints: standard JSON (`application/json`) + - SCIM endpoints: `application/scim+json` + - Mgmt endpoints: standard JSON (`application/json`) ## Data Model Patterns @@ -128,6 +169,14 @@ If you modify SCIM behavior, review impact across these areas: 5. Schema definitions and ServiceProviderConfig flags 6. Validator specs (`A1` through `A9`) and compatibility expectations +If you modify management authentication or deployment behavior, also review: + +1. Both management modules' `AzureSecurityConfig` and `CloudflareSecurityConfig` +2. Shared helpers in `scim-server-common/src/main/java/.../security` +3. `docker-compose.yml` and `docker/env/*.env` +4. `k8s/app/**` and `k8s/cluster/**` +5. `.sops.yaml` and `age/rotate_sops_age_key.py` + ## Adding A New SCIM Attribute 1. Extend `ScimUser`/`ScimGroup` (or add child entity in `scim-server-common` when multi-valued). diff --git a/.gitignore b/.gitignore index 7f1c81b..851ba84 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,21 @@ generated/ temp/ *.tmp +# SOPS / age +*.agekey +sops-age.txt +*.decrypted.yaml +*.decrypted.yml +*.dec.yaml +*.dec.yml +/.config/sops/ + # Misc *.swp *~ + +# Python bytecode +__pycache__/ +*.py[cod] +# pytest cache +.pytest_cache/ diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..0cecd19 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,4 @@ +creation_rules: + - path_regex: ^(k8s|k8s_backup|k8s_new)/.+\.sops\.ya?ml$ + encrypted_regex: "^(data|stringData)$" + age: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh \ No newline at end of file diff --git a/README.md b/README.md index 34078a0..15e209d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,11 @@ # SCIM 2.0 Playground -> **Note — AI-Generated Code** +> Note - AI-Generated Code > > The entire codebase in this repository was written by AI (GitHub Copilot). -> This includes all Java source code, configuration files, Dockerfiles, - -# Full reactor test run (uses PostgreSQL via Testcontainers; Docker must be available) -mvn clean test - -> tests, and documentation. Human involvement was limited to design guidance, -> review, and acceptance. +> This includes Java source code, configuration files, Dockerfiles, tests, and +> documentation. Human involvement was limited to design guidance, review, and +> acceptance. ## Disclaimer @@ -31,6 +27,9 @@ combines: - a management UI and management API for creating workspaces and bearer tokens - a validator management UI that executes and stores SCIM compliance runs - a reusable Groovy/Spock validator suite for RFC-driven regression testing +- local Docker Compose orchestration for the full stack +- Kustomize-based Kubernetes deployment support with CloudNativePG PostgreSQL, + 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 @@ -63,9 +62,9 @@ playground service provider: | --- | --- | --- | --- | | `scim-server-common` | Shared JPA entities, repositories, and common security support | n/a | Imported by API and management modules | | `scim-server-api` | SCIM 2.0 provider API | `8080` | Stateless bearer-token auth per workspace | -| `scim-server-mgmt` | Thymeleaf management UI + management REST API | `8081` | Azure OIDC login, workspace and token administration | +| `scim-server-mgmt` | Thymeleaf management UI + management REST API | `8081` | Azure OIDC locally, Cloudflare Access JWT supported through the `cloudflare` profile | | `scim-validator` | Groovy/Spock compliance suite | n/a | Builds a reusable test JAR consumed by validator-mgmt | -| `scim-validator-mgmt` | Validator execution UI + persistence | `8082` | Azure OIDC login, stores runs, tests, and captured exchanges | +| `scim-validator-mgmt` | Validator execution UI + persistence | `8082` | Azure OIDC locally, Cloudflare Access JWT supported through the `cloudflare` profile | ### Request model @@ -105,6 +104,14 @@ Controllers expose both the default SCIM routes and compatibility routes: The currently implemented mode is `MS`, which applies Microsoft validator compatibility tweaks in `MsScimUserMapper`. +### Deployment targets + +The repository supports two main deployment shapes: + +- local Docker Compose for fast end-to-end iteration +- Kubernetes via `k8s/app` and `k8s/cluster`, intended for a k3s-style setup + with CloudNativePG, Kustomize, KSOPS, and Cloudflare Tunnel + ## Management Surfaces ### `scim-server-mgmt` @@ -169,6 +176,20 @@ The run currently executes these spec groups: - `A8_SecurityAndRobustnessSpec` - `A9_NegativeAndEdgeCasesSpec` +### Management app authentication + +The management applications support two deployment-facing authentication modes: + +- `azure` profile, which is the default for manual local runs and uses + interactive Azure OIDC login +- `cloudflare` profile, which switches the management apps to JWT resource + server mode and validates the Cloudflare Access token from the configured + request header, `Cf-Access-Jwt-Assertion` by default + +The Docker Compose env files and the Kubernetes manifests use the `cloudflare` +profile for the management applications. Manual local runs default to `azure` +unless you explicitly set `SPRING_PROFILES_ACTIVE=cloudflare`. + ## Data Model Notes Some repository-specific implementation details matter if you extend the code: @@ -189,17 +210,24 @@ Some repository-specific implementation details matter if you extend the code: - Spring Boot 3.5.12 - Spring MVC, Spring Security, Spring Data JPA, Thymeleaf - PostgreSQL for the main playground and validator persistence stores +- CloudNativePG for Kubernetes PostgreSQL clustering - Groovy 4 + Spock + REST Assured for validator coverage - JUnit Platform launcher for embedded validator execution - Docker / Docker Compose for local orchestration +- Kustomize + KSOPS + SOPS + age for Kubernetes manifests and secret handling +- Cloudflare Access + Cloudflare Tunnel for edge access in the Cloudflare path - GitHub Actions for CodeQL, release automation, and Docker image publishing ## Repository Layout ```text . +├── age/ # age / SOPS key rotation helper and notes ├── docker/ -│ └── env/ # Compose env files for local containers +│ └── env/ # Compose env files for local containers +├── k8s/ +│ ├── app/ # Namespaced SCIM application stack (namespace, DB, apps) +│ └── cluster/ # Cluster support resources (storage tuning, cloudflared) ├── scim-server-api/ # SCIM API application ├── scim-server-common/ # Shared entities, repositories, and common security support ├── scim-server-mgmt/ # Management UI/API application @@ -219,7 +247,11 @@ Some repository-specific implementation details matter if you extend the code: - Docker Desktop or compatible Docker Engine for the composed stack - PostgreSQL only if you want to run modules manually without Docker - Microsoft Entra ID application registration if you want to use the management - UIs with your own identity configuration + UIs with Azure OIDC +- `kubectl`, `kustomize`, `ksops`, `sops`, and an age private key if you want + to apply the Kubernetes manifests directly from this repository +- CloudNativePG installed in the target cluster if you want to use the provided + Kubernetes PostgreSQL manifests ### Build the reactor @@ -238,12 +270,18 @@ Notes: ### Run with Docker Compose -This is the easiest way to boot the full playground stack: +This is the fastest way to boot the full playground stack locally: ```bash docker compose up --build ``` +Optional Cloudflare tunnel sidecar: + +```bash +docker compose --profile cloudflare up --build +``` + Default ports: - API: `http://localhost:8080` @@ -259,6 +297,70 @@ The compose stack starts: - `scim-validator-mgmt` - `postgres-playground` - `postgres-validator` +- `cloudflared` when the `cloudflare` compose profile is enabled + +Notes: + +- The management containers load both their app-specific env files and + `docker/env/cloudflare.env`. +- The checked-in env files are development helpers only. Replace all secrets, + audience values, role-claim settings, and tunnel tokens before using them in + a shared environment. + +### Run on Kubernetes + +The repository contains a Kustomize layout intended for a k3s-style cluster. + +`k8s/app` deploys the SCIM application stack into the `scim` namespace: + +- `namespace.yaml` +- a CloudNativePG PostgreSQL cluster plus the validator database +- `scim-server-api` +- `scim-server-mgmt` +- `scim-validator-mgmt` + +`k8s/cluster` deploys supporting cluster resources: + +- `local-path-storage` configuration and a custom `local-path-custom` + `StorageClass` +- the `cloudflared` namespace and deployment + +All application services are exposed internally as `ClusterIP`. External access +for the Cloudflare path is expected to come from the `cloudflared` tunnel. + +Apply order: + +```bash +export SOPS_AGE_KEY_FILE=~/Library/Application\ Support/sops/age/keys.txt + +kustomize build --enable-alpha-plugins --enable-exec k8s/cluster | kubectl apply -f - +kustomize build --enable-alpha-plugins --enable-exec k8s/app | kubectl apply -f - +``` + +Notes: + +- `ksops` is used as a Kustomize generator for encrypted secrets. +- 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`. + +### Kubernetes secrets and age rotation + +Secrets under `k8s/**/secrets/*.sops.yaml` are encrypted with SOPS. The root +`.sops.yaml` file defines the active age recipient, and Kustomize decrypts the +files through `ksops` at build/apply time. + +To rotate the SOPS age recipient: + +```bash +export SOPS_AGE_KEY_FILE=~/Library/Application\ Support/sops/age/keys.txt +python3 age/rotate_sops_age_key.py +``` + +The helper will generate a new age identity, update the recipient in +`.sops.yaml`, and run `sops updatekeys` across the tracked Kubernetes secret +files. ### Run modules manually @@ -283,31 +385,27 @@ cd scim-validator-mgmt mvn spring-boot:run ``` -### Management app authentication - -The management applications currently do not provide a dedicated local-auth -profile with fixed users. Manual runs use Azure OIDC login and require the -corresponding OIDC environment variables to be configured before startup. - ### Manual environment variables -All three applications require a datasource and `ACTUATOR_API_KEY`. The -management applications also require Azure OIDC client configuration. +All three applications require a datasource and `ACTUATOR_API_KEY`. -Example variables for manual local runs: +Common datasource example: ```bash -# Common datasource example export SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/scimplayground export SPRING_DATASOURCE_USERNAME=postgres export SPRING_DATASOURCE_PASSWORD=postgres export ACTUATOR_API_KEY=dev-actuator-key +``` -# Management apps only +Azure OIDC profile for management apps (default): + +```bash export AZURE_CLIENT_ID= export AZURE_CLIENT_SECRET= export AZURE_TENANT_ID= export AZURE_SCOPES="openid,email,api:///usage" +export APP_SECURITY_AZURE_ROLE_CLAIM=roles export APP_SECURITY_OIDC_ADMIN_ROLE=admin export APP_SECURITY_OIDC_USER_ROLE=user @@ -315,21 +413,44 @@ export APP_SECURITY_OIDC_USER_ROLE=user export APP_SCIM_API_BASE_URL=http://localhost:8080 ``` -The `docker/env/*.env` files are local development helpers. Do not reuse those -values unchanged for shared or production deployments. Use -`docker/env/scim-server-api.env`, `docker/env/scim-server-mgmt.env`, and -`docker/env/scim-validator-mgmt.env` as references for the required variables. +Cloudflare profile for management apps: + +```bash +export SPRING_PROFILES_ACTIVE=cloudflare +export APP_SECURITY_CLOUDFLARE_ROLE_CLAIM= +export APP_SECURITY_OIDC_ADMIN_ROLE=admin +export APP_SECURITY_OIDC_USER_ROLE=user +export CLOUDFLARE_ACCESS_ISSUER_URI=https://.cloudflareaccess.com +export CLOUDFLARE_ACCESS_AUDIENCE= +export CLOUDFLARE_ACCESS_JWK_SET_URI=https://.cloudflareaccess.com/cdn-cgi/access/certs +export CLOUDFLARE_ACCESS_LOGOUT_URL=https://.cloudflareaccess.com/cdn-cgi/access/logout +export CLOUDFLARE_ACCESS_TOKEN_HEADER=Cf-Access-Jwt-Assertion + +# scim-server-mgmt only +export APP_SCIM_API_BASE_URL=http://localhost:8080 +``` + +The `cloudflare` profile is intended for deployments behind Cloudflare Access, +or another trusted proxy that injects the configured token header. + +Use `docker/env/scim-server-api.env`, `docker/env/scim-server-mgmt.env`, +`docker/env/scim-validator-mgmt.env`, and `docker/env/cloudflare.env` as shape +references only. Do not reuse those values unchanged for a shared or production +environment. ## First-Use Workflow ### 1. Start the applications -Use Docker Compose or run the modules manually. +Use Docker Compose, Kubernetes, or run the modules manually. -### 2. Sign in to the management UI +### 2. Access the management UI -Open `http://localhost:8081` and sign in through the configured Azure OIDC -provider. +For the `azure` profile, open `http://localhost:8081` and sign in through the +configured Azure OIDC provider. + +For the `cloudflare` profile, place the application behind Cloudflare Access and +let the proxy provide the access JWT header expected by the application. ### 3. Create a workspace @@ -392,7 +513,7 @@ the spec suite and inspect the captured exchanges. ### Through the validator management UI 1. Open `http://localhost:8082`. -2. Sign in with the configured OIDC provider. +2. Pass through the configured management authentication layer. 3. Enter a run name, the SCIM base URL, and the bearer token. 4. Execute the run. 5. Review per-test results and HTTP request/response exchanges. @@ -492,6 +613,9 @@ Project-specific conventions that matter when contributing: - static mapper utilities are heavily used for SCIM transformations - DTO layers in management applications make use of Java records - transactional boundaries in services and selected controllers are deliberate +- management security is profile-driven: Azure OIDC by default, Cloudflare JWT + resource-server mode when the `cloudflare` profile is active +- shared security helpers for the management apps live in `scim-server-common` If you add or change a SCIM attribute, align all of the following: @@ -503,6 +627,16 @@ If you add or change a SCIM attribute, align all of the following: 6. attribute projection behavior 7. validator coverage +If you change deployment or secret-handling behavior, review all of the +following: + +1. `docker-compose.yml` +2. `docker/env/*.env` +3. `k8s/app/**` +4. `k8s/cluster/**` +5. `.sops.yaml` +6. `age/rotate_sops_age_key.py` + ## Contributing Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the diff --git a/age/README.md b/age/README.md new file mode 100644 index 0000000..70168c9 --- /dev/null +++ b/age/README.md @@ -0,0 +1,63 @@ +# Age Rotation + +Helper to rotate the repository SOPS age key. + +## Requirements + +- `age-keygen` +- `sops` +- `python3` (3.8+ recommended) +- `SOPS_AGE_KEY_FILE` must be set to the age identity file path before running the script + +Example: + +```bash +export SOPS_AGE_KEY_FILE=~/Library/Application\ Support/sops/age/keys.txt +``` + +## Usage + +From the repository root run the Python helper (recommended): + +```bash +python3 age/rotate_sops_age_key.py +``` + +What the script does: + +1. Generates a new age identity and appends it to the file pointed to by `SOPS_AGE_KEY_FILE` (creates the file if missing). +2. Derives the new public recipient and updates it in `.sops.yaml`. +3. Runs `sops updatekeys` on every `*.sops.yaml` and `*.sops.yml` file under `k8s/` and `k8s_backup/`. + +## Quick test (safe, non-committed) + +Create a temporary secret, encrypt it, decrypt it, then remove it: + +```bash +mkdir -p k8s/tmp +cat > k8s/tmp/test-secret.sops.yaml <<'EOF' +apiVersion: v1 +kind: Secret +metadata: + name: sops-test +stringData: + token: "test-123" +EOF + +sops -e -i k8s/tmp/test-secret.sops.yaml +sops -d k8s/tmp/test-secret.sops.yaml +rm -f k8s/tmp/test-secret.sops.yaml +rmdir k8s/tmp || true +``` + +## Notes + +- Keep the private key file out of Git and in a secure location. + - The old private key must remain available while `sops updatekeys` runs (so files can be re-encrypted). Only archive/remove the old key once you've verified decryption with the new key. + - The script requires `SOPS_AGE_KEY_FILE` to be set and passes it through to `sops updatekeys`. +- If `sops -d` fails, point SOPS explicitly at the key file: + +```bash +export SOPS_AGE_KEY_FILE=~/Library/Application\ Support/sops/age/keys.txt +sops -d path/to/file.sops.yaml +``` \ No newline at end of file diff --git a/age/rotate_sops_age_key.py b/age/rotate_sops_age_key.py new file mode 100755 index 0000000..c44f305 --- /dev/null +++ b/age/rotate_sops_age_key.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Simpler SOPS age rotation helper. + +Creates a new age identity, updates the public recipient in .sops.yaml, +and runs `sops updatekeys` on files found under `k8s/` and `k8s_backup/`. + +Run with: `python3 age/rotate_sops_age_key.py` +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + + +def check_command(name: str) -> None: + if shutil.which(name) is None: + print(f"error: required command not found: {name}", file=sys.stderr) + sys.exit(1) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Rotate SOPS age key (simpler Python version)") + # No --key-file option: the script uses the default key location below. + parser.parse_args() + + repo_root = Path(__file__).resolve().parent.parent + config_file = repo_root / ".sops.yaml" + key_file_value = os.environ.get("SOPS_AGE_KEY_FILE") + if not key_file_value: + print( + "error: SOPS_AGE_KEY_FILE must be set to the age identity file path", + file=sys.stderr, + ) + sys.exit(1) + + key_file = Path(key_file_value).expanduser() + + for cmd in ("age-keygen", "sops"): + check_command(cmd) + + if not config_file.exists(): + print(f"error: missing SOPS config: {config_file}", file=sys.stderr) + sys.exit(1) + + key_file.parent.mkdir(parents=True, exist_ok=True) + # Generate a new identity and append it to the key file pointed to by + # SOPS_AGE_KEY_FILE. This matches `age-keygen >> "$SOPS_AGE_KEY_FILE"`. + old_umask = os.umask(0o077) + try: + result = subprocess.run( + ["age-keygen"], + check=True, + text=True, + capture_output=True, + ) + with key_file.open("a", encoding="utf-8") as key_handle: + key_handle.write(result.stdout) + finally: + os.umask(old_umask) + + try: + key_file.chmod(0o600) + except Exception: + pass + + match = re.search(r"(?m)^#\s*public key:\s*(age1[0-9a-z]+)\s*$", result.stdout) + if not match: + print("error: could not parse public key from age-keygen output", file=sys.stderr) + sys.exit(1) + new_recipient = match.group(1) + + text = config_file.read_text() + updated, count = re.subn( + r'(?m)^\s*age:\s*age1[0-9a-z]+\s*$', + f' age: {new_recipient}', + text, + count=1, + ) + if count != 1: + print("error: could not find an age recipient line in .sops.yaml", file=sys.stderr) + sys.exit(1) + config_file.write_text(updated) + + secret_files: list[Path] = [] + for base in (repo_root / "k8s", repo_root / "k8s_backup"): + if base.exists(): + secret_files.extend(sorted(base.rglob("*.sops.yaml"))) + secret_files.extend(sorted(base.rglob("*.sops.yml"))) + + # Deduplicate and sort + secret_files = sorted(dict.fromkeys(secret_files)) + + if not secret_files: + print("warning: no SOPS files found under k8s or k8s_backup", file=sys.stderr) + sys.exit(0) + + sops_env = os.environ.copy() + sops_env["SOPS_AGE_KEY_FILE"] = str(key_file) + + for sf in secret_files: + subprocess.run(["sops", "updatekeys", "--yes", str(sf)], check=True, env=sops_env) + + print("Rotated SOPS age key successfully.") + print(f"New key file: {key_file}") + print(f"New recipient: {new_recipient}") + print(f"Re-encrypted files: {len(secret_files)}") + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml index 7c752ae..609dd64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - "8081:8081" env_file: - ./docker/env/scim-server-mgmt.env + - ./docker/env/cloudflare.env environment: - SERVER_PORT=8081 depends_on: @@ -37,6 +38,7 @@ services: - "8082:8082" env_file: - ./docker/env/scim-validator-mgmt.env + - ./docker/env/cloudflare.env environment: - SERVER_PORT=8082 depends_on: @@ -44,6 +46,24 @@ services: condition: service_healthy restart: unless-stopped + cloudflared: + image: cloudflare/cloudflared:2026.3.0 + profiles: + - cloudflare + env_file: + - ./docker/env/cloudflare.env + command: + - tunnel + - run + depends_on: + scim-server-api: + condition: service_started + scim-server-mgmt: + condition: service_started + scim-validator-mgmt: + condition: service_started + restart: unless-stopped + postgres-playground: image: dhi.io/postgres:18-alpine3.22 env_file: diff --git a/k8s/app/database/cluster.yaml b/k8s/app/database/cluster.yaml new file mode 100644 index 0000000..ffb09e0 --- /dev/null +++ b/k8s/app/database/cluster.yaml @@ -0,0 +1,37 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: scim-postgres +spec: + description: SCIM PostgreSQL cluster for k3s + imageName: ghcr.io/cloudnative-pg/postgresql:18.1-standard-trixie + instances: 1 + enableSuperuserAccess: true + superuserSecret: + name: scim-postgres-superuser + affinity: + enablePodAntiAffinity: false + enablePDB: false + bootstrap: + initdb: + database: scimplayground + owner: scim_playground + secret: + name: scim-postgres-playground + managed: + roles: + - name: scim_validator + ensure: present + login: true + passwordSecret: + name: scim-postgres-validator + storage: + storageClass: local-path-custom + size: 50Gi + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 2Gi \ No newline at end of file diff --git a/k8s/app/database/database-scimvalidation.yaml b/k8s/app/database/database-scimvalidation.yaml new file mode 100644 index 0000000..6d267ad --- /dev/null +++ b/k8s/app/database/database-scimvalidation.yaml @@ -0,0 +1,10 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Database +metadata: + name: scim-postgres-scimvalidation +spec: + cluster: + name: scim-postgres + name: scimvalidation + owner: scim_validator + databaseReclaimPolicy: retain \ No newline at end of file diff --git a/k8s/app/database/ksops-secret-generator.yaml b/k8s/app/database/ksops-secret-generator.yaml new file mode 100644 index 0000000..d2ac59f --- /dev/null +++ b/k8s/app/database/ksops-secret-generator.yaml @@ -0,0 +1,12 @@ +apiVersion: viaduct.ai/v1 +kind: ksops +metadata: + name: scim-database-secret-generator + annotations: + config.kubernetes.io/function: | + exec: + path: ksops +files: + - secrets/scim-postgres-playground.sops.yaml + - secrets/scim-postgres-superuser.sops.yaml + - secrets/scim-postgres-validator.sops.yaml \ No newline at end of file diff --git a/k8s/app/database/kustomization.yaml b/k8s/app/database/kustomization.yaml new file mode 100644 index 0000000..9be4026 --- /dev/null +++ b/k8s/app/database/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: scim + +resources: + - cluster.yaml + - database-scimvalidation.yaml + +generators: + - ksops-secret-generator.yaml \ No newline at end of file diff --git a/k8s/app/database/secrets/scim-postgres-playground.sops.yaml b/k8s/app/database/secrets/scim-postgres-playground.sops.yaml new file mode 100644 index 0000000..806f07d --- /dev/null +++ b/k8s/app/database/secrets/scim-postgres-playground.sops.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Secret +metadata: + name: scim-postgres-playground +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] +sops: + age: + - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiNkZ1eGNrOFI5Zi9mYVcz + K3Zma3pCRzljRTlSSmVoU1E3VERZdktab2k0ClhiWElqZTJ6TG4ycU9DTjBaL1Zp + L05aRFhUUDNwd1R1WmZUNE45NC84aTAKLS0tIFMwNDdqQVAxZDNlVS9aSnFjc0Iv + 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] + 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 new file mode 100644 index 0000000..6007c6d --- /dev/null +++ b/k8s/app/database/secrets/scim-postgres-superuser.sops.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Secret +metadata: + name: scim-postgres-superuser +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] +sops: + age: + - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSeWc0Ym5SMUgrQXRxdFdu + OWtBK2UxYzVvUG1jajlsT1FadERMUHFHWWpvCm0ralpWTHRKNWpvVk16dkIzQjZT + YjlUaHBML1p6RHRrQ0RUSFNIWjFUQkUKLS0tIFg0Z20veitsdkc0SkxleFE4bFFh + 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] + 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 new file mode 100644 index 0000000..b318120 --- /dev/null +++ b/k8s/app/database/secrets/scim-postgres-validator.sops.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Secret +metadata: + name: scim-postgres-validator +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] +sops: + age: + - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBOGsvSzVtaXJqSGtSY3hv + Q0VJV09rZzdDTkpmMVZiSEFoRFIrSUFVL0RRCmhkZnpaUGkrY0J2ZlVvVmF6VjdB + VGE2emg5dHRaN3NJcHdIT3BYRTZVZVEKLS0tIEJXZTRiazM3VTFDVVV6bDFDcXha + 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] + encrypted_regex: ^(data|stringData)$ + version: 3.12.2 diff --git a/k8s/app/kustomization.yaml b/k8s/app/kustomization.yaml new file mode 100644 index 0000000..7b57586 --- /dev/null +++ b/k8s/app/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml + - database + - scim-server-api + - scim-server-mgmt + - scim-validator-mgmt + +labels: + - pairs: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/part-of: scim + app.kubernetes.io/environment: k3s \ No newline at end of file diff --git a/k8s/app/namespace.yaml b/k8s/app/namespace.yaml new file mode 100644 index 0000000..27f89cb --- /dev/null +++ b/k8s/app/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: scim \ No newline at end of file diff --git a/k8s/app/scim-server-api/configmap.yaml b/k8s/app/scim-server-api/configmap.yaml new file mode 100644 index 0000000..436477c --- /dev/null +++ b/k8s/app/scim-server-api/configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +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 diff --git a/k8s/app/scim-server-api/deployment.yaml b/k8s/app/scim-server-api/deployment.yaml new file mode 100644 index 0000000..c54909f --- /dev/null +++ b/k8s/app/scim-server-api/deployment.yaml @@ -0,0 +1,70 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: scim-server-api + labels: + app.kubernetes.io/name: scim-server-api + app.kubernetes.io/component: api +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: scim-server-api + template: + metadata: + labels: + app.kubernetes.io/name: scim-server-api + app.kubernetes.io/component: api + spec: + automountServiceAccountToken: false + containers: + - name: scim-server-api + image: edipal/scim-server-api:1.0.6 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + envFrom: + - configMapRef: + name: scim-server-api-k3s-config + - secretRef: + name: scim-server-api-k3s-secrets + env: + - name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: scim-postgres-playground + key: password + startupProbe: + tcpSocket: + port: http + periodSeconds: 10 + failureThreshold: 30 + readinessProbe: + tcpSocket: + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + livenessProbe: + tcpSocket: + port: http + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 2 + failureThreshold: 6 + resources: + requests: + cpu: 100m + ephemeral-storage: 256Mi + memory: 512Mi + limits: + cpu: "1" + ephemeral-storage: 1Gi + memory: 1Gi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL \ No newline at end of file diff --git a/k8s/app/scim-server-api/ksops-secret-generator.yaml b/k8s/app/scim-server-api/ksops-secret-generator.yaml new file mode 100644 index 0000000..dcdfcdd --- /dev/null +++ b/k8s/app/scim-server-api/ksops-secret-generator.yaml @@ -0,0 +1,10 @@ +apiVersion: viaduct.ai/v1 +kind: ksops +metadata: + name: scim-server-api-secret-generator + annotations: + config.kubernetes.io/function: | + exec: + path: ksops +files: + - secrets/secret.sops.yaml \ No newline at end of file diff --git a/k8s/app/scim-server-api/kustomization.yaml b/k8s/app/scim-server-api/kustomization.yaml new file mode 100644 index 0000000..f9b68ee --- /dev/null +++ b/k8s/app/scim-server-api/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: scim + +resources: + - configmap.yaml + - deployment.yaml + - service.yaml + +generators: + - ksops-secret-generator.yaml \ No newline at end of file diff --git a/k8s/app/scim-server-api/secrets/secret.sops.yaml b/k8s/app/scim-server-api/secrets/secret.sops.yaml new file mode 100644 index 0000000..6fe653e --- /dev/null +++ b/k8s/app/scim-server-api/secrets/secret.sops.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Secret +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] +sops: + age: + - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4SS9UUHEzOXRBd3ZXMTVu + dzB3VmxqZHl2cmpETXk5N0Z1Qm5uSVJ0ZUY0CjFFSFZMZnRzSlVyNzlUYUZDL2wy + ZlFFVXNxcXpvVlRKSUhyaktCSTBHeTAKLS0tIEtVUm5JOE9sWnpKOEc0R1JTVE84 + 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] + encrypted_regex: ^(data|stringData)$ + version: 3.12.2 diff --git a/k8s/app/scim-server-api/service.yaml b/k8s/app/scim-server-api/service.yaml new file mode 100644 index 0000000..a8d2d6b --- /dev/null +++ b/k8s/app/scim-server-api/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: scim-server-api + labels: + app.kubernetes.io/name: scim-server-api +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: scim-server-api + ports: + - name: http + port: 8080 + targetPort: http \ No newline at end of file diff --git a/k8s/app/scim-server-mgmt/configmap.yaml b/k8s/app/scim-server-mgmt/configmap.yaml new file mode 100644 index 0000000..0879e21 --- /dev/null +++ b/k8s/app/scim-server-mgmt/configmap.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: scim-server-mgmt-k3s-config +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_SECURITY_CLOUDFLARE_ROLE_CLAIM: https://scimsandbox.net/roles + APP_SECURITY_OIDC_ADMIN_ROLE: admin + APP_SECURITY_OIDC_USER_ROLE: user + CLOUDFLARE_ACCESS_ISSUER_URI: https://scimsandbox.cloudflareaccess.com + CLOUDFLARE_ACCESS_AUDIENCE: 5a682d5f1eb4ec59c07c916f28fe4420660b186656c5f1ae16fb231d012ec914 + CLOUDFLARE_ACCESS_JWK_SET_URI: https://scimsandbox.cloudflareaccess.com/cdn-cgi/access/certs + CLOUDFLARE_ACCESS_LOGOUT_URL: https://scimsandbox.cloudflareaccess.com/cdn-cgi/access/logout + CLOUDFLARE_ACCESS_TOKEN_HEADER: Cf-Access-Jwt-Assertion \ No newline at end of file diff --git a/k8s/app/scim-server-mgmt/deployment.yaml b/k8s/app/scim-server-mgmt/deployment.yaml new file mode 100644 index 0000000..8ad8355 --- /dev/null +++ b/k8s/app/scim-server-mgmt/deployment.yaml @@ -0,0 +1,70 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: scim-server-mgmt + labels: + app.kubernetes.io/name: scim-server-mgmt + app.kubernetes.io/component: mgmt +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: scim-server-mgmt + template: + metadata: + labels: + app.kubernetes.io/name: scim-server-mgmt + app.kubernetes.io/component: mgmt + spec: + automountServiceAccountToken: false + containers: + - name: scim-server-mgmt + image: edipal/scim-server-mgmt:1.0.6 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8081 + envFrom: + - configMapRef: + name: scim-server-mgmt-k3s-config + - secretRef: + name: scim-server-mgmt-k3s-secrets + env: + - name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: scim-postgres-playground + key: password + startupProbe: + tcpSocket: + port: http + periodSeconds: 10 + failureThreshold: 30 + readinessProbe: + tcpSocket: + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + livenessProbe: + tcpSocket: + port: http + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 2 + failureThreshold: 6 + resources: + requests: + cpu: 100m + ephemeral-storage: 256Mi + memory: 512Mi + limits: + cpu: "1" + ephemeral-storage: 1Gi + memory: 1Gi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL \ No newline at end of file diff --git a/k8s/app/scim-server-mgmt/ksops-secret-generator.yaml b/k8s/app/scim-server-mgmt/ksops-secret-generator.yaml new file mode 100644 index 0000000..8ee4704 --- /dev/null +++ b/k8s/app/scim-server-mgmt/ksops-secret-generator.yaml @@ -0,0 +1,10 @@ +apiVersion: viaduct.ai/v1 +kind: ksops +metadata: + name: scim-server-mgmt-secret-generator + annotations: + config.kubernetes.io/function: | + exec: + path: ksops +files: + - secrets/secret.sops.yaml \ No newline at end of file diff --git a/k8s/app/scim-server-mgmt/kustomization.yaml b/k8s/app/scim-server-mgmt/kustomization.yaml new file mode 100644 index 0000000..f9b68ee --- /dev/null +++ b/k8s/app/scim-server-mgmt/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: scim + +resources: + - configmap.yaml + - deployment.yaml + - service.yaml + +generators: + - ksops-secret-generator.yaml \ No newline at end of file diff --git a/k8s/app/scim-server-mgmt/secrets/secret.sops.yaml b/k8s/app/scim-server-mgmt/secrets/secret.sops.yaml new file mode 100644 index 0000000..30c231e --- /dev/null +++ b/k8s/app/scim-server-mgmt/secrets/secret.sops.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Secret +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] + 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] + AZURE_SCOPES: ENC[AES256_GCM,data:Nrx4BUz9K3D+gEzmFMyR8V9pKHQ4mTtP9TTLlrHTBrIskH9D/N7OWnUwMuUlpyb7SD57/h53pG3pY2zifg==,iv:jpo777CTVGbS9qTROD+XR0NSenMKpsh0Eokkn/z9iVA=,tag:BtzF+T9SPf5y6MhPC9089w==,type:str] +sops: + age: + - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxQXNVVVpvdFM4WHE0YjFu + YlVxWjBzMFprZXZCcysrVDFOTHZpY2JQbDJ3Ckx3Tk9ack1XNjNvckFTejdUTytj + dkR6aFdCYnd2OU53T0hQdmRBRG9ld28KLS0tIDBxNTZFM0xUclpxMVVuYjg1LzVN + 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] + encrypted_regex: ^(data|stringData)$ + version: 3.12.2 diff --git a/k8s/app/scim-server-mgmt/service.yaml b/k8s/app/scim-server-mgmt/service.yaml new file mode 100644 index 0000000..a3fc250 --- /dev/null +++ b/k8s/app/scim-server-mgmt/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: scim-server-mgmt + labels: + app.kubernetes.io/name: scim-server-mgmt +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: scim-server-mgmt + ports: + - name: http + port: 8081 + targetPort: http \ No newline at end of file diff --git a/k8s/app/scim-validator-mgmt/configmap.yaml b/k8s/app/scim-validator-mgmt/configmap.yaml new file mode 100644 index 0000000..186ffd8 --- /dev/null +++ b/k8s/app/scim-validator-mgmt/configmap.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: scim-validator-mgmt-k3s-config +data: + SERVER_PORT: "8082" + 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 + CLOUDFLARE_ACCESS_ISSUER_URI: https://scimsandbox.cloudflareaccess.com + CLOUDFLARE_ACCESS_AUDIENCE: 9b1ea9fac999e94d6d2522a61d4323ac8ca4f5759c2fbc73fe489a034fc51627 + CLOUDFLARE_ACCESS_JWK_SET_URI: https://scimsandbox.cloudflareaccess.com/cdn-cgi/access/certs + CLOUDFLARE_ACCESS_LOGOUT_URL: https://scimsandbox.cloudflareaccess.com/cdn-cgi/access/logout + CLOUDFLARE_ACCESS_TOKEN_HEADER: Cf-Access-Jwt-Assertion \ No newline at end of file diff --git a/k8s/app/scim-validator-mgmt/deployment.yaml b/k8s/app/scim-validator-mgmt/deployment.yaml new file mode 100644 index 0000000..a22adaf --- /dev/null +++ b/k8s/app/scim-validator-mgmt/deployment.yaml @@ -0,0 +1,70 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: scim-validator-mgmt + labels: + app.kubernetes.io/name: scim-validator-mgmt + app.kubernetes.io/component: validator-mgmt +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: scim-validator-mgmt + template: + metadata: + labels: + app.kubernetes.io/name: scim-validator-mgmt + app.kubernetes.io/component: validator-mgmt + spec: + automountServiceAccountToken: false + containers: + - name: scim-validator-mgmt + image: edipal/scim-validator-mgmt:1.0.6 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8082 + envFrom: + - configMapRef: + name: scim-validator-mgmt-k3s-config + - secretRef: + name: scim-validator-mgmt-k3s-secrets + env: + - name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: scim-postgres-validator + key: password + startupProbe: + tcpSocket: + port: http + periodSeconds: 10 + failureThreshold: 30 + readinessProbe: + tcpSocket: + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + livenessProbe: + tcpSocket: + port: http + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 2 + failureThreshold: 6 + resources: + requests: + cpu: 100m + ephemeral-storage: 256Mi + memory: 512Mi + limits: + cpu: "1" + ephemeral-storage: 1Gi + memory: 1Gi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL \ No newline at end of file diff --git a/k8s/app/scim-validator-mgmt/ksops-secret-generator.yaml b/k8s/app/scim-validator-mgmt/ksops-secret-generator.yaml new file mode 100644 index 0000000..8fe6060 --- /dev/null +++ b/k8s/app/scim-validator-mgmt/ksops-secret-generator.yaml @@ -0,0 +1,10 @@ +apiVersion: viaduct.ai/v1 +kind: ksops +metadata: + name: scim-validator-mgmt-secret-generator + annotations: + config.kubernetes.io/function: | + exec: + path: ksops +files: + - secrets/secret.sops.yaml \ No newline at end of file diff --git a/k8s/app/scim-validator-mgmt/kustomization.yaml b/k8s/app/scim-validator-mgmt/kustomization.yaml new file mode 100644 index 0000000..f9b68ee --- /dev/null +++ b/k8s/app/scim-validator-mgmt/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: scim + +resources: + - configmap.yaml + - deployment.yaml + - service.yaml + +generators: + - ksops-secret-generator.yaml \ No newline at end of file diff --git a/k8s/app/scim-validator-mgmt/secrets/secret.sops.yaml b/k8s/app/scim-validator-mgmt/secrets/secret.sops.yaml new file mode 100644 index 0000000..d028258 --- /dev/null +++ b/k8s/app/scim-validator-mgmt/secrets/secret.sops.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Secret +metadata: + name: scim-validator-mgmt-k3s-secrets +type: Opaque +stringData: + ACTUATOR_API_KEY: ENC[AES256_GCM,data:3mZGKAhuYBfRTjd8odLIzw==,iv:0waDAQjwCOprQmKeRzXpVM35Hl5xxOWx8dzOyqtjMsU=,tag:ttAuVC2L1D9Vrctad9K82w==,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] + AZURE_SCOPES: ENC[AES256_GCM,data:cd9cIP5bIANFMiHUb5CHKaRHwzey0RIe6dTQd0HzJ8g4y7d+tGJyQ7ANHh3pw+vA7V0ov0349FkQk0obAQ==,iv:1YjY3PJsJe+jm34IEWjQqmZjld4C5+8KNbDkvvY34gc=,tag:A4uXVB+X80crNqtHdLpuWw==,type:str] +sops: + age: + - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjSk9qZHFxaVZ5RnpTKzhP + QWUyWTNSZ0JMb1VOQkN0dkxndDJ5TkhGL0VVCmhyWWlCN2xlem40ZzQ2cS81aWgv + UXA3SUQ1RmhjeVpxcDhPZ0RpR2pnTU0KLS0tIEMxWlcyVHFxOTlyOWFYUFdBWjdX + 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] + encrypted_regex: ^(data|stringData)$ + version: 3.12.2 diff --git a/k8s/app/scim-validator-mgmt/service.yaml b/k8s/app/scim-validator-mgmt/service.yaml new file mode 100644 index 0000000..b1350fd --- /dev/null +++ b/k8s/app/scim-validator-mgmt/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: scim-validator-mgmt + labels: + app.kubernetes.io/name: scim-validator-mgmt +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: scim-validator-mgmt + ports: + - name: http + port: 8082 + targetPort: http \ No newline at end of file diff --git a/k8s/cluster/cloudflared/deployment.yaml b/k8s/cluster/cloudflared/deployment.yaml new file mode 100644 index 0000000..07b4a16 --- /dev/null +++ b/k8s/cluster/cloudflared/deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cloudflared +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: cloudflared + template: + metadata: + labels: + app.kubernetes.io/name: cloudflared + spec: + automountServiceAccountToken: false + serviceAccountName: cloudflared + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchLabels: + app.kubernetes.io/name: cloudflared + topologyKey: kubernetes.io/hostname + containers: + - name: cloudflared + image: cloudflare/cloudflared:2026.3.0 + imagePullPolicy: IfNotPresent + args: + - tunnel + - --no-autoupdate + - run + - --token + - $(TUNNEL_TOKEN) + env: + - name: TUNNEL_TOKEN + valueFrom: + secretKeyRef: + name: secret + key: token + ports: + - name: metrics + containerPort: 20241 + readinessProbe: + tcpSocket: + port: metrics + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + tcpSocket: + port: metrics + initialDelaySeconds: 20 + periodSeconds: 20 + resources: + requests: + cpu: 25m + ephemeral-storage: 64Mi + memory: 64Mi + limits: + cpu: 250m + ephemeral-storage: 256Mi + memory: 256Mi \ No newline at end of file diff --git a/k8s/cluster/cloudflared/ksops-secret-generator.yaml b/k8s/cluster/cloudflared/ksops-secret-generator.yaml new file mode 100644 index 0000000..6494126 --- /dev/null +++ b/k8s/cluster/cloudflared/ksops-secret-generator.yaml @@ -0,0 +1,10 @@ +apiVersion: viaduct.ai/v1 +kind: ksops +metadata: + name: scim-cloudflared-secret-generator + annotations: + config.kubernetes.io/function: | + exec: + path: ksops +files: + - secrets/secret.sops.yaml \ No newline at end of file diff --git a/k8s/cluster/cloudflared/kustomization.yaml b/k8s/cluster/cloudflared/kustomization.yaml new file mode 100644 index 0000000..a4d9a6e --- /dev/null +++ b/k8s/cluster/cloudflared/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: cloudflared + +resources: + - serviceaccount.yaml + - deployment.yaml + - namespace.yaml + +generators: + - ksops-secret-generator.yaml \ No newline at end of file diff --git a/k8s/cluster/cloudflared/namespace.yaml b/k8s/cluster/cloudflared/namespace.yaml new file mode 100644 index 0000000..d64dc44 --- /dev/null +++ b/k8s/cluster/cloudflared/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: cloudflared \ No newline at end of file diff --git a/k8s/cluster/cloudflared/secrets/secret.sops.yaml b/k8s/cluster/cloudflared/secrets/secret.sops.yaml new file mode 100644 index 0000000..2f887df --- /dev/null +++ b/k8s/cluster/cloudflared/secrets/secret.sops.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Secret +metadata: + name: secret +type: Opaque +stringData: + token: ENC[AES256_GCM,data:4bzqsIuZ3vRtxx1NSZ7D+j++eOZ0QcXV5NY4Ri0a/1fmnk0CLYNLlZWPU+C1vuZswRNl8IrDux44N800Fngq10p5m9i0uD2IsV5bY00ybql4re0hurTsxINW6VN+2SPdHBSupQjwvJfzWuNcxXdznhjvgj/kfE3LwXl1auXC8vF+UXUXBqdM2VDymHqvq9hEqu3FiVUD2dT1s+abShXEM6txe9a0Q0OaNgwYzsiCWtd+/NqVvS8P2g==,iv:IJoYvmFX2JfuycB4Ke4InsjyrI+cSFBEePsoJtTgLC4=,tag:/rLuqQ4HEWrtmKN567DmZw==,type:str] +sops: + age: + - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVNUZyMGwwakJ5Sm03SzV3 + SUgyaEFyVGNHREYzLzhoMU1qU2VBSVo5aVJjClllMWRLclJVTWJzaWthUWRNaXBZ + SndaaFJMUXE3dmxMY0g0TzNoNUJQL1UKLS0tIGF4dmJSQkFMaU9UVGJuckdoYXlY + MGpoTmtGOGJ4OTdKWmxGUkRCaWZrd1kK0ITZQ6RTjz/mi/GSvhSedIX+qbiNNjqv + qs88i+OszVEpcW4oXItxKhd/kUg7B8n+gvlkRSs+uY04PGuqgIXBVg== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-03-27T16:55:22Z" + mac: ENC[AES256_GCM,data:lbTD09rHsgEZOu+GCOyGZ/blEHiClI3ADILtb/EWPZ0rS2obVWPowO2khmGr7ZZ3SoP8mDgnEt1DMf4y1c2pZnmNphykpbHU55SyafzpeCxGivP9houlJlFysL5FvsJCLxKvi7D0VTrf+FWI/2dSw4heLEI+iupPm2dhN4oopqw=,iv:wKxzREttNV097USfG60c0aQ/BH1xpwUlngOeDgGOc9k=,tag:5NvPDHeTlHyokQ7xhnkHLQ==,type:str] + encrypted_regex: ^(data|stringData)$ + version: 3.12.2 diff --git a/k8s/cluster/cloudflared/serviceaccount.yaml b/k8s/cluster/cloudflared/serviceaccount.yaml new file mode 100644 index 0000000..190b597 --- /dev/null +++ b/k8s/cluster/cloudflared/serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: cloudflared +automountServiceAccountToken: false \ No newline at end of file diff --git a/k8s/cluster/kustomization.yaml b/k8s/cluster/kustomization.yaml new file mode 100644 index 0000000..948242c --- /dev/null +++ b/k8s/cluster/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - storage + - cloudflared + +labels: + - pairs: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/environment: netcup \ No newline at end of file diff --git a/k8s/cluster/storage/config.yaml b/k8s/cluster/storage/config.yaml new file mode 100644 index 0000000..bc8121b --- /dev/null +++ b/k8s/cluster/storage/config.yaml @@ -0,0 +1,38 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: local-path-config + namespace: local-path-storage +data: + config.json: |- + { + "nodePathMap": [ + { + "node": "DEFAULT_PATH_FOR_NON_LISTED_NODES", + "paths": ["/var/lib/pvc"] + } + ] + } + setup: |- + #!/bin/sh + set -eu + mkdir -m 0777 -p "$VOL_DIR" + teardown: |- + #!/bin/sh + set -eu + rm -rf "$VOL_DIR" + helperPod.yaml: |- + apiVersion: v1 + kind: Pod + metadata: + name: helper-pod + spec: + priorityClassName: system-node-critical + tolerations: + - key: node.kubernetes.io/disk-pressure + operator: Exists + effect: NoSchedule + containers: + - name: helper-pod + image: busybox + imagePullPolicy: IfNotPresent \ No newline at end of file diff --git a/k8s/cluster/storage/kustomization.yaml b/k8s/cluster/storage/kustomization.yaml new file mode 100644 index 0000000..fbd3be4 --- /dev/null +++ b/k8s/cluster/storage/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml + - storageclass.yaml + - config.yaml \ No newline at end of file diff --git a/k8s/cluster/storage/namespace.yaml b/k8s/cluster/storage/namespace.yaml new file mode 100644 index 0000000..703707d --- /dev/null +++ b/k8s/cluster/storage/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: local-path-storage \ No newline at end of file diff --git a/k8s/cluster/storage/storageclass.yaml b/k8s/cluster/storage/storageclass.yaml new file mode 100644 index 0000000..7c0b1c1 --- /dev/null +++ b/k8s/cluster/storage/storageclass.yaml @@ -0,0 +1,12 @@ +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: local-path-custom + annotations: + storageclass.kubernetes.io/is-default-class: "false" +provisioner: rancher.io/local-path +allowVolumeExpansion: true +reclaimPolicy: Retain +volumeBindingMode: WaitForFirstConsumer +parameters: + pathPattern: "{{ .PVC.Namespace }}/{{ .PVC.Name }}/" \ No newline at end of file diff --git a/pom.xml b/pom.xml index d2f1368..1f365b9 100644 --- a/pom.xml +++ b/pom.xml @@ -32,7 +32,7 @@ 17 17 UTF-8 - 3.5.11 + 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 6ad207d..2896da1 100644 --- a/scim-server-api/pom.xml +++ b/scim-server-api/pom.xml @@ -17,7 +17,7 @@ 17 - 3.5.12 + 3.5.13 diff --git a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/config/SecurityConfig.java b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/config/SecurityConfig.java index e5c336b..18e1693 100644 --- a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/config/SecurityConfig.java +++ b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/config/SecurityConfig.java @@ -28,7 +28,7 @@ public class SecurityConfig { public SecurityConfig(WorkspaceTokenRepository tokenRepository, ObjectMapper objectMapper, RequestResponseLoggingFilter loggingFilter, - @Value("${ACTUATOR_API_KEY}") String actuatorApiKey) { + @Value("${app.security.actuator.api-key}") String actuatorApiKey) { this.tokenRepository = tokenRepository; this.objectMapper = objectMapper; this.loggingFilter = loggingFilter; diff --git a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/service/WorkspaceCleanupService.java b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/service/WorkspaceCleanupService.java index 7bec6f0..9b5bdd6 100644 --- a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/service/WorkspaceCleanupService.java +++ b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/service/WorkspaceCleanupService.java @@ -28,8 +28,8 @@ public class WorkspaceCleanupService { public WorkspaceCleanupService(WorkspaceRepository workspaceRepository, TransactionOperations transactionOperations, Clock clock, - @Value("${app.cleanup.workspace.enabled:true}") boolean cleanupEnabled, - @Value("${app.cleanup.workspace.stale-after:P3M}") String staleAfterValue) { + @Value("${app.cleanup.workspace.enabled}") boolean cleanupEnabled, + @Value("${app.cleanup.workspace.stale-after}") String staleAfterValue) { this.workspaceRepository = workspaceRepository; this.transactionOperations = transactionOperations; this.clock = clock; @@ -37,7 +37,7 @@ public WorkspaceCleanupService(WorkspaceRepository workspaceRepository, this.staleAfter = Period.parse(staleAfterValue); } - @Scheduled(cron = "${app.cleanup.workspace.cron:0 0 */2 * * *}", zone = "${app.cleanup.workspace.zone:UTC}") + @Scheduled(cron = "${app.cleanup.workspace.cron}", zone = "${app.cleanup.workspace.zone}") public void deleteStaleWorkspacesOnSchedule() { if (!cleanupEnabled) { return; diff --git a/scim-server-api/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/scim-server-api/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..24e7723 --- /dev/null +++ b/scim-server-api/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,51 @@ +{ + "groups": [ + { + "name": "app", + "type": "java.lang.Object" + }, + { + "name": "app.cleanup", + "type": "java.lang.Object" + }, + { + "name": "app.cleanup.workspace", + "type": "java.lang.Object" + }, + { + "name": "app.security", + "type": "java.lang.Object" + }, + { + "name": "app.security.actuator", + "type": "java.lang.Object" + } + ], + "properties": [ + { + "name": "app.security.actuator.api-key", + "type": "java.lang.String", + "description": "Actuator API key used by the management filter." + }, + { + "name": "app.cleanup.workspace.enabled", + "type": "java.lang.Boolean", + "description": "Enables scheduled workspace cleanup." + }, + { + "name": "app.cleanup.workspace.cron", + "type": "java.lang.String", + "description": "Cron expression for workspace cleanup." + }, + { + "name": "app.cleanup.workspace.zone", + "type": "java.lang.String", + "description": "Time zone used by the cleanup schedule." + }, + { + "name": "app.cleanup.workspace.stale-after", + "type": "java.lang.String", + "description": "Age threshold for stale workspaces." + } + ] +} \ No newline at end of file diff --git a/scim-server-api/src/main/resources/application.yml b/scim-server-api/src/main/resources/application.yml index 1f2d7fd..40951ce 100644 --- a/scim-server-api/src/main/resources/application.yml +++ b/scim-server-api/src/main/resources/application.yml @@ -3,13 +3,17 @@ server: spring: application: name: scim-server-api + flyway: + locations: + - classpath:db/migration + - classpath:db/common jpa: hibernate: ddl-auto: validate open-in-view: false properties: hibernate: - default_batch_fetch_size: 100 + "[default_batch_fetch_size]": 100 jackson: serialization: write-dates-as-timestamps: false @@ -37,6 +41,9 @@ management: enabled: false app: + security: + actuator: + api-key: ${ACTUATOR_API_KEY} cleanup: workspace: enabled: true diff --git a/scim-server-common/pom.xml b/scim-server-common/pom.xml index 3a7f98f..8b203f6 100644 --- a/scim-server-common/pom.xml +++ b/scim-server-common/pom.xml @@ -17,7 +17,7 @@ 17 - 3.5.12 + 3.5.13 @@ -37,6 +37,22 @@ org.springframework spring-web + + org.springframework.security + spring-security-config + + + org.springframework.security + spring-security-oauth2-client + + + org.springframework.security + spring-security-oauth2-jose + + + org.springframework.security + spring-security-oauth2-resource-server + jakarta.servlet jakarta.servlet-api diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/ActuatorApiKeyAuthFilter.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/ActuatorApiKeyAuthFilter.java index 1f5a881..4ed299b 100644 --- a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/ActuatorApiKeyAuthFilter.java +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/ActuatorApiKeyAuthFilter.java @@ -17,7 +17,7 @@ public class ActuatorApiKeyAuthFilter extends OncePerRequestFilter { public ActuatorApiKeyAuthFilter(String actuatorApiKey) { if (actuatorApiKey == null || actuatorApiKey.isBlank()) { - throw new IllegalArgumentException("ACTUATOR_API_KEY must be configured"); + throw new IllegalArgumentException("Actuator API key must be configured"); } this.actuatorApiKey = actuatorApiKey; } 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 new file mode 100644 index 0000000..df205f4 --- /dev/null +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupport.java @@ -0,0 +1,56 @@ +package de.palsoftware.scim.server.common.security; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +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; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import java.util.HashSet; +import java.util.Set; +import java.util.function.BiConsumer; + +public final class AzureOidcSecuritySupport { + + private AzureOidcSecuritySupport() { + } + + public static OidcUserService createOidcUserService(String roleClaim, + String adminRole, + String userRole, + BiConsumer userProvisioner) { + OidcUserService delegate = new OidcUserService(); + return new OidcUserService() { + @Override + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + OidcUser oidcUser = delegate.loadUser(userRequest); + Set mappedAuthorities = new HashSet<>(oidcUser.getAuthorities()); + + MgmtSecuritySupport.addMappedAuthorities( + MgmtSecuritySupport.extractClaimValues(oidcUser.getClaim(roleClaim)), + mappedAuthorities, + adminRole, + userRole); + + String sub = oidcUser.getSubject(); + String email = resolveEmail(oidcUser); + if (sub != null && !sub.isBlank()) { + userProvisioner.accept(sub, email); + } + + 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(); + } +} \ 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 new file mode 100644 index 0000000..0a4c7be --- /dev/null +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupport.java @@ -0,0 +1,92 @@ +package de.palsoftware.scim.server.common.security; + +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.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public final class CloudflareJwtSecuritySupport { + + private CloudflareJwtSecuritySupport() { + } + + public static BearerTokenResolver createBearerTokenResolver(String tokenHeader) { + return request -> { + String token = request.getHeader(tokenHeader); + if (token == null || token.isBlank()) { + return null; + } + return token; + }; + } + + public static JwtDecoder createJwtDecoder(String issuerUri, String audience, String jwkSetUri) { + NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(resolveJwkSetUri(issuerUri, jwkSetUri)).build(); + OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(issuerUri), + jwt -> validateAudience(jwt, audience)); + decoder.setJwtValidator(validator); + return decoder; + } + + public static Converter createJwtAuthenticationConverter(String roleClaim, + String adminRole, + String userRole) { + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setPrincipalClaimName("sub"); + converter.setJwtGrantedAuthoritiesConverter(jwt -> { + Set mappedAuthorities = new HashSet<>(); + Object roleClaimValue = resolveRoleClaimValue(jwt, roleClaim); + MgmtSecuritySupport.addMappedAuthorities( + MgmtSecuritySupport.extractClaimValues(roleClaimValue), + mappedAuthorities, + adminRole, + userRole); + return mappedAuthorities; + }); + return converter; + } + + private static Object resolveRoleClaimValue(Jwt jwt, String roleClaim) { + Object directClaimValue = jwt.getClaim(roleClaim); + if (directClaimValue != null) { + return directClaimValue; + } + Object customClaim = jwt.getClaim("custom"); + if (customClaim instanceof Map customClaims) { + return customClaims.get(roleClaim); + } + return null; + } + + private static OAuth2TokenValidatorResult validateAudience(Jwt jwt, String audience) { + if (jwt.getAudience().contains(audience)) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure( + new OAuth2Error("invalid_token", "The required audience is missing", null)); + } + + private static String resolveJwkSetUri(String issuerUri, String jwkSetUri) { + if (jwkSetUri != null && !jwkSetUri.isBlank()) { + return jwkSetUri; + } + if (issuerUri.endsWith("/")) { + return issuerUri + "cdn-cgi/access/certs"; + } + return issuerUri + "/cdn-cgi/access/certs"; + } +} \ No newline at end of file diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/MgmtSecuritySupport.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/MgmtSecuritySupport.java new file mode 100644 index 0000000..fb77f41 --- /dev/null +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/MgmtSecuritySupport.java @@ -0,0 +1,100 @@ +package de.palsoftware.scim.server.common.security; + +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +public final class MgmtSecuritySupport { + + private MgmtSecuritySupport() { + } + + public static HttpSecurity configureBaseSecurity(HttpSecurity http, String actuatorApiKey) throws Exception { + return http + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/actuator/**").permitAll() + .requestMatchers("/css/**", "/js/**", "/images/**", "/error").permitAll() + .anyRequest().authenticated()) + .addFilterBefore(new ActuatorApiKeyAuthFilter(actuatorApiKey), + org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class); + } + + @SuppressWarnings("java:S3330") + public static CookieCsrfTokenRepository csrfTokenRepository(String cookieName) { + CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + repository.setCookieName(cookieName); + return repository; + } + + public static void addMappedAuthorities(List roles, + Set mappedAuthorities, + String adminRole, + String userRole) { + String normalizedAdminRole = normalizeRole(adminRole); + String normalizedUserRole = normalizeRole(userRole); + mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_USER")); + if (roles == null) { + return; + } + for (String role : roles) { + String normalized = normalizeRole(role); + if (normalized == null) { + continue; + } + if (normalizedAdminRole != null && normalized.equals(normalizedAdminRole)) { + mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); + } + if (normalizedUserRole != null && normalized.equals(normalizedUserRole)) { + mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_USER")); + } + } + } + + static List extractClaimValues(Object claimValue) { + if (claimValue == null) { + return List.of(); + } + if (claimValue instanceof String value) { + return List.of(value.split("[,\\s]+")); + } + if (claimValue instanceof Collection collection) { + List values = new ArrayList<>(); + for (Object entry : collection) { + if (entry != null) { + values.add(entry.toString()); + } + } + return values; + } + if (claimValue.getClass().isArray()) { + int length = Array.getLength(claimValue); + List values = new ArrayList<>(length); + for (int index = 0; index < length; index++) { + Object entry = Array.get(claimValue, index); + if (entry != null) { + values.add(entry.toString()); + } + } + return values; + } + return List.of(claimValue.toString()); + } + + private static String normalizeRole(String role) { + if (role == null || role.isBlank()) { + return null; + } + String normalized = role.trim().toUpperCase(); + if (normalized.startsWith("ROLE_")) { + return normalized.substring("ROLE_".length()); + } + return normalized; + } +} \ No newline at end of file diff --git a/scim-server-common/src/main/resources/db/migration/V1__init_common_schema.sql b/scim-server-common/src/main/resources/db/common/V1__init_common_schema.sql similarity index 100% rename from scim-server-common/src/main/resources/db/migration/V1__init_common_schema.sql rename to scim-server-common/src/main/resources/db/common/V1__init_common_schema.sql diff --git a/scim-server-common/src/main/resources/db/migration/V2__migrate_user_collections_to_json.sql b/scim-server-common/src/main/resources/db/common/V2__migrate_user_collections_to_json.sql similarity index 94% rename from scim-server-common/src/main/resources/db/migration/V2__migrate_user_collections_to_json.sql rename to scim-server-common/src/main/resources/db/common/V2__migrate_user_collections_to_json.sql index 2cbbfd6..f916f5e 100644 --- a/scim-server-common/src/main/resources/db/migration/V2__migrate_user_collections_to_json.sql +++ b/scim-server-common/src/main/resources/db/common/V2__migrate_user_collections_to_json.sql @@ -16,4 +16,4 @@ DROP TABLE scim_user_entitlements; DROP TABLE scim_user_roles; DROP TABLE scim_user_ims; DROP TABLE scim_user_photos; -DROP TABLE scim_user_x509_certificates; +DROP TABLE scim_user_x509_certificates; \ 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 new file mode 100644 index 0000000..67797ed --- /dev/null +++ b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupportTest.java @@ -0,0 +1,51 @@ +package de.palsoftware.scim.server.common.security; + +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class CloudflareJwtSecuritySupportTest { + + @Test + void jwtAuthenticationConverter_mapsAdminRoleFromNestedCustomClaim() { + Jwt jwt = jwt(Map.of( + "sub", "user-123", + "custom", Map.of("https://scimsandbox.net/roles", new String[]{"admin"}) + )); + + AbstractAuthenticationToken authentication = CloudflareJwtSecuritySupport + .createJwtAuthenticationConverter("https://scimsandbox.net/roles", "admin", "user") + .convert(jwt); + + assertThat(authentication.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .containsExactlyInAnyOrder("ROLE_ADMIN", "ROLE_USER"); + } + + @Test + void jwtAuthenticationConverter_mapsAdminRoleFromTopLevelClaim() { + Jwt jwt = jwt(Map.of( + "sub", "user-123", + "roles", new String[]{"admin"} + )); + + AbstractAuthenticationToken authentication = CloudflareJwtSecuritySupport + .createJwtAuthenticationConverter("roles", "admin", "user") + .convert(jwt); + + assertThat(authentication.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .containsExactlyInAnyOrder("ROLE_ADMIN", "ROLE_USER"); + } + + private static Jwt jwt(Map claims) { + Instant issuedAt = Instant.now(); + return new Jwt("token-value", issuedAt, issuedAt.plusSeconds(3600), Map.of("alg", "none"), claims); + } +} \ No newline at end of file diff --git a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/MgmtSecuritySupportTest.java b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/MgmtSecuritySupportTest.java new file mode 100644 index 0000000..c919b7b --- /dev/null +++ b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/MgmtSecuritySupportTest.java @@ -0,0 +1,35 @@ +package de.palsoftware.scim.server.common.security; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class MgmtSecuritySupportTest { + + @Test + void addMappedAuthorities_alwaysGrantsUserRole() { + Set mappedAuthorities = new HashSet<>(); + + MgmtSecuritySupport.addMappedAuthorities(null, mappedAuthorities, "admin", "user"); + + assertThat(mappedAuthorities) + .extracting(GrantedAuthority::getAuthority) + .containsExactly("ROLE_USER"); + } + + @Test + void addMappedAuthorities_adminClaimAddsAdminAndKeepsDefaultUserRole() { + Set mappedAuthorities = new HashSet<>(); + + MgmtSecuritySupport.addMappedAuthorities(List.of("admin"), mappedAuthorities, "admin", "user"); + + assertThat(mappedAuthorities) + .extracting(GrantedAuthority::getAuthority) + .containsExactlyInAnyOrder("ROLE_ADMIN", "ROLE_USER"); + } +} \ No newline at end of file diff --git a/scim-server-mgmt/pom.xml b/scim-server-mgmt/pom.xml index 6ef4bd5..d390fd2 100644 --- a/scim-server-mgmt/pom.xml +++ b/scim-server-mgmt/pom.xml @@ -17,7 +17,7 @@ 17 - 3.5.12 + 3.5.13 @@ -61,6 +61,10 @@ org.springframework.boot spring-boot-starter-oauth2-client + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + de.pal-software.scim 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 10aa64a..65a8c47 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 @@ -4,7 +4,6 @@ import de.palsoftware.scim.server.mgmt.service.MgmtUserService; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -46,13 +45,11 @@ private void populateWorkspaceModel(Model model, Authentication authentication) } private String resolveDisplayName(Authentication authentication) { - if (authentication != null && authentication.getPrincipal() instanceof OidcUser oidcUser) { - String sub = oidcUser.getSubject(); - if (sub != null) { - return mgmtUserService.findEmailById(sub) - .orElseGet(() -> AuthenticatedUser.displayName(authentication)); - } + if (authentication == null) { + return null; } - return AuthenticatedUser.displayName(authentication); + String fallback = AuthenticatedUser.displayName(authentication); + return mgmtUserService.findEmailById(AuthenticatedUser.userId(authentication)) + .orElse(fallback); } } 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 68f0666..c64c254 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 @@ -2,6 +2,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.core.oidc.user.OidcUser; public final class AuthenticatedUser { @@ -20,6 +21,12 @@ public static String username(Authentication authentication) { return resolved; } } + if (principal instanceof Jwt jwt) { + String resolved = resolveJwtUsername(jwt); + if (resolved != null) { + return resolved; + } + } String fallback = authentication.getName(); if (fallback == null || fallback.isBlank()) { throw new IllegalStateException("Unable to resolve authenticated username"); @@ -27,6 +34,30 @@ public static String username(Authentication authentication) { 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()) { @@ -47,6 +78,26 @@ private static String resolveOidcUsername(OidcUser oidcUser) { 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; @@ -58,6 +109,12 @@ public static String displayName(Authentication authentication) { return email; } } + if (principal instanceof Jwt jwt) { + String email = jwt.getClaimAsString("email"); + if (email != null && !email.isBlank()) { + return email; + } + } return username(authentication); } diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AzureSecurityConfig.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AzureSecurityConfig.java new file mode 100644 index 0000000..e0d897a --- /dev/null +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AzureSecurityConfig.java @@ -0,0 +1,55 @@ +package de.palsoftware.scim.server.mgmt.security; + +import de.palsoftware.scim.server.common.security.AzureOidcSecuritySupport; +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.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.context.annotation.Bean; + +@Configuration +@EnableWebSecurity +@Profile("!cloudflare") +public class AzureSecurityConfig { + + private static final String CSRF_COOKIE_NAME = "SCIM_SERVER_MGMT_XSRF"; + + private final String adminRole; + private final String userRole; + private final String roleClaim; + private final String actuatorApiKey; + private final MgmtUserService mgmtUserService; + + public AzureSecurityConfig(@Value("${app.security.oidc.admin-role}") String adminRole, + @Value("${app.security.oidc.user-role}") String userRole, + @Value("${app.security.azure.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.actuatorApiKey = actuatorApiKey; + this.mgmtUserService = mgmtUserService; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + MgmtSecuritySupport.configureBaseSecurity(http, actuatorApiKey) + .oauth2Login(oauth2 -> oauth2 + .loginPage("/oauth2/authorization/azure") + .userInfoEndpoint(userInfo -> userInfo.oidcUserService( + AzureOidcSecuritySupport.createOidcUserService( + roleClaim, + adminRole, + userRole, + mgmtUserService::provisionUser)))) + .logout(logout -> logout.logoutSuccessUrl("/")) + .csrf(csrf -> csrf.csrfTokenRepository(MgmtSecuritySupport.csrfTokenRepository(CSRF_COOKIE_NAME))); + return http.build(); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..616c47c --- /dev/null +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/CloudflareSecurityConfig.java @@ -0,0 +1,77 @@ +package de.palsoftware.scim.server.mgmt.security; + +import de.palsoftware.scim.server.common.security.CloudflareJwtSecuritySupport; +import de.palsoftware.scim.server.common.security.MgmtSecuritySupport; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@Profile("cloudflare") +public class CloudflareSecurityConfig { + + private static final String CSRF_COOKIE_NAME = "SCIM_SERVER_MGMT_XSRF"; + + private final String adminRole; + private final String userRole; + private final String roleClaim; + private final String actuatorApiKey; + + 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, + @Value("${app.security.actuator.api-key}") String actuatorApiKey) { + this.adminRole = adminRole; + this.userRole = userRole; + this.roleClaim = roleClaim; + this.actuatorApiKey = actuatorApiKey; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + JwtDecoder cloudflareJwtDecoder, + Converter cloudflareJwtAuthenticationConverter, + BearerTokenResolver cloudflareBearerTokenResolver, + @Value("${app.security.cloudflare.logout-url}") String logoutSuccessUrl) throws Exception { + MgmtSecuritySupport.configureBaseSecurity(http, actuatorApiKey) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .oauth2ResourceServer(oauth2 -> oauth2 + .bearerTokenResolver(cloudflareBearerTokenResolver) + .jwt(jwt -> jwt + .decoder(cloudflareJwtDecoder) + .jwtAuthenticationConverter(cloudflareJwtAuthenticationConverter))) + .logout(logout -> logout.logoutSuccessUrl(logoutSuccessUrl)) + .csrf(csrf -> csrf.csrfTokenRepository(MgmtSecuritySupport.csrfTokenRepository(CSRF_COOKIE_NAME))); + return http.build(); + } + + @Bean + public BearerTokenResolver cloudflareBearerTokenResolver( + @Value("${app.security.cloudflare.token-header}") String tokenHeader) { + return CloudflareJwtSecuritySupport.createBearerTokenResolver(tokenHeader); + } + + @Bean + public JwtDecoder cloudflareJwtDecoder( + @Value("${app.security.cloudflare.issuer-uri}") String issuerUri, + @Value("${app.security.cloudflare.audience}") String audience, + @Value("${app.security.cloudflare.jwk-set-uri}") String jwkSetUri) { + return CloudflareJwtSecuritySupport.createJwtDecoder(issuerUri, audience, jwkSetUri); + } + + @Bean + public Converter cloudflareJwtAuthenticationConverter() { + return CloudflareJwtSecuritySupport.createJwtAuthenticationConverter(roleClaim, adminRole, userRole); + } +} \ No newline at end of file diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/SecurityConfig.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/SecurityConfig.java deleted file mode 100644 index b86ab57..0000000 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/SecurityConfig.java +++ /dev/null @@ -1,135 +0,0 @@ -package de.palsoftware.scim.server.mgmt.security; - -import de.palsoftware.scim.server.mgmt.service.MgmtUserService; -import de.palsoftware.scim.server.common.security.ActuatorApiKeyAuthFilter; -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.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -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.user.DefaultOidcUser; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -import org.springframework.security.web.SecurityFilterChain; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - private final String adminRole; - private final String userRole; - private final MgmtUserService mgmtUserService; - private final String actuatorApiKey; - - public SecurityConfig(@Value("${app.security.oidc.admin-role:admin}") String adminRole, - @Value("${app.security.oidc.user-role:user}") String userRole, - @Lazy MgmtUserService mgmtUserService, - @Value("${ACTUATOR_API_KEY}") String actuatorApiKey) { - this.adminRole = normalizeRole(adminRole); - this.userRole = normalizeRole(userRole); - this.mgmtUserService = mgmtUserService; - this.actuatorApiKey = actuatorApiKey; - } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/actuator/**").permitAll() - .requestMatchers("/css/**", "/js/**", "/images/**", "/error").permitAll() - .anyRequest().authenticated()) - .addFilterBefore(actuatorApiKeyAuthFilter(), org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class) - .oauth2Login(oauth2 -> oauth2 - .loginPage("/oauth2/authorization/azure") - .userInfoEndpoint(userInfo -> userInfo.oidcUserService(oidcUserService()))) - .logout(logout -> logout.logoutSuccessUrl("/")) - .csrf(csrf -> csrf.csrfTokenRepository(csrfTokenRepository())); - return http.build(); - } - - @Bean - public ActuatorApiKeyAuthFilter actuatorApiKeyAuthFilter() { - return new ActuatorApiKeyAuthFilter(actuatorApiKey); - } - - // CSRF token cookies intentionally omit HttpOnly so JavaScript can read and submit the token - // (Double Submit Cookie pattern). The session cookie remains HttpOnly. The Secure flag is - // set automatically by Spring Security based on request.isSecure(), so HTTPS-only in production. - @SuppressWarnings("java:S3330") - private CookieCsrfTokenRepository csrfTokenRepository() { - CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse(); - repository.setCookieName("SCIM_SERVER_MGMT_XSRF"); - return repository; - } - - private OidcUserService oidcUserService() { - OidcUserService delegate = new OidcUserService(); - - return new OidcUserService() { - @Override - public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { - OidcUser oidcUser = delegate.loadUser(userRequest); - Set mappedAuthorities = new HashSet<>(oidcUser.getAuthorities()); - - addMappedAuthorities(oidcUser.getClaimAsStringList("roles"), mappedAuthorities); - - String sub = oidcUser.getSubject(); - String email = resolveEmail(oidcUser); - if (sub != null && !sub.isBlank()) { - mgmtUserService.provisionUser(sub, email); - } - - return new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo()); - } - }; - } - - private void addMappedAuthorities(List roles, Set mappedAuthorities) { - if (roles == null) { - return; - } - for (String role : roles) { - String normalized = normalizeRole(role); - if (normalized == null) { - continue; - } - if (normalized.equals(adminRole)) { - mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); - } - if (normalized.equals(userRole)) { - mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_USER")); - } - } - } - - private static String resolveEmail(OidcUser u) { - String email = u.getEmail(); - if (email != null && !email.isBlank()) return email; - String upn = u.getClaimAsString("upn"); - if (upn != null && !upn.isBlank()) return upn; - String unique = u.getClaimAsString("unique_name"); - if (unique != null && !unique.isBlank()) return unique; - return u.getPreferredUsername(); - } - - private String normalizeRole(String role) { - if (role == null || role.isBlank()) { - return null; - } - String normalized = role.trim().toUpperCase(); - if (normalized.startsWith("ROLE_")) { - return normalized.substring("ROLE_".length()); - } - return normalized; - } -} diff --git a/scim-server-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/scim-server-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..3345f32 --- /dev/null +++ b/scim-server-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,88 @@ +{ + "groups": [ + { + "name": "app", + "type": "java.lang.Object" + }, + { + "name": "app.security", + "type": "java.lang.Object" + }, + { + "name": "app.security.actuator", + "type": "java.lang.Object" + }, + { + "name": "app.security.azure", + "type": "java.lang.Object" + }, + { + "name": "app.security.oidc", + "type": "java.lang.Object" + }, + { + "name": "app.security.cloudflare", + "type": "java.lang.Object" + }, + { + "name": "app.scim-api", + "type": "java.lang.Object" + }, + { + "name": "app.scim-api.base", + "type": "java.lang.Object" + } + ], + "properties": [ + { + "name": "app.security.actuator.api-key", + "type": "java.lang.String", + "description": "Actuator API key used by the management filter." + }, + { + "name": "app.security.azure.role-claim", + "type": "java.lang.String", + "description": "OIDC role claim used when Azure profile is active." + }, + { + "name": "app.security.oidc.admin-role", + "type": "java.lang.String", + "description": "Configured Azure admin role name." + }, + { + "name": "app.security.oidc.user-role", + "type": "java.lang.String", + "description": "Configured Azure user role name." + }, + { + "name": "app.security.cloudflare.role-claim", + "type": "java.lang.String", + "description": "JWT claim used when Cloudflare profile is active." + }, + { + "name": "app.security.cloudflare.issuer-uri", + "type": "java.lang.String" + }, + { + "name": "app.security.cloudflare.audience", + "type": "java.lang.String" + }, + { + "name": "app.security.cloudflare.jwk-set-uri", + "type": "java.lang.String" + }, + { + "name": "app.security.cloudflare.logout-url", + "type": "java.lang.String" + }, + { + "name": "app.security.cloudflare.token-header", + "type": "java.lang.String" + }, + { + "name": "app.scim-api.base.url", + "type": "java.lang.String", + "description": "Base URL for the SCIM API." + } + ] +} \ No newline at end of file diff --git a/scim-server-mgmt/src/main/resources/application.yml b/scim-server-mgmt/src/main/resources/application.yml index 392d0bc..7e3f1f2 100644 --- a/scim-server-mgmt/src/main/resources/application.yml +++ b/scim-server-mgmt/src/main/resources/application.yml @@ -1,6 +1,18 @@ +server: + servlet: + session: + cookie: + name: SCIM_SERVER_MGMT_SESSION + spring: application: name: scim-server-mgmt + flyway: + locations: + - classpath:db/migration + - classpath:db/common + profiles: + default: azure jpa: hibernate: ddl-auto: validate @@ -31,17 +43,6 @@ logging: "[org.springframework.security.web.csrf]": TRACE "[org.springframework.security.web.FilterChainProxy]": DEBUG -app: - scim-api: - base: - url: ${APP_SCIM_API_BASE_URL:http://localhost:8080} - -server: - servlet: - session: - cookie: - name: SCIM_SERVER_MGMT_SESSION - management: endpoint: health: @@ -57,3 +58,25 @@ management: enabled: false diskspace: enabled: false + + +app: + security: + actuator: + api-key: ${ACTUATOR_API_KEY} + azure: + role-claim: ${APP_SECURITY_AZURE_ROLE_CLAIM:roles} + oidc: + admin-role: ${APP_SECURITY_OIDC_ADMIN_ROLE:admin} + user-role: ${APP_SECURITY_OIDC_USER_ROLE:user} + cloudflare: + role-claim: ${APP_SECURITY_CLOUDFLARE_ROLE_CLAIM:roles} + issuer-uri: ${CLOUDFLARE_ACCESS_ISSUER_URI} + audience: ${CLOUDFLARE_ACCESS_AUDIENCE} + jwk-set-uri: ${CLOUDFLARE_ACCESS_JWK_SET_URI:} + logout-url: ${CLOUDFLARE_ACCESS_LOGOUT_URL:/} + token-header: ${CLOUDFLARE_ACCESS_TOKEN_HEADER:Cf-Access-Jwt-Assertion} + + scim-api: + base: + url: ${APP_SCIM_API_BASE_URL:http://localhost:8080} \ No newline at end of file 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 0e3c235..6afc088 100644 --- a/scim-server-mgmt/src/main/resources/static/css/workspace.css +++ b/scim-server-mgmt/src/main/resources/static/css/workspace.css @@ -178,8 +178,29 @@ label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bott .pagination { display: flex; gap: 0.5rem; align-items: center; justify-content: flex-end; margin-top: 0.75rem; } .page-info { font-size: 0.75rem; color: var(--text-muted); } .empty-state { text-align: center; padding: 2rem 1rem; color: var(--text-muted); font-size: 0.9rem; } -.toast { position: fixed; bottom: 1.5rem; 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; } +.bmc-widget { position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 90; } +.bmc-widget summary { list-style: none; min-height: 3.5rem; display: inline-flex; align-items: center; justify-content: center; gap: 0.55rem; padding: 0.75rem 0.95rem; border-radius: 999px; cursor: pointer; background: linear-gradient(180deg, #ffe066 0%, #ffd43b 100%); box-shadow: 0 20px 45px rgba(15, 23, 42, 0.45); border: 1px solid rgba(255, 255, 255, 0.35); } +.bmc-widget summary::-webkit-details-marker { display: none; } +.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-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; } +.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) { + .bmc-widget { right: 1rem; bottom: 1rem; } + .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; } + .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; } .stats-grid { display: grid; diff --git a/scim-server-mgmt/src/main/resources/static/images/buy-me-a-coffee-icon.svg b/scim-server-mgmt/src/main/resources/static/images/buy-me-a-coffee-icon.svg new file mode 100644 index 0000000..4de4489 --- /dev/null +++ b/scim-server-mgmt/src/main/resources/static/images/buy-me-a-coffee-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/scim-server-mgmt/src/main/resources/static/images/buy-me-a-coffee.png b/scim-server-mgmt/src/main/resources/static/images/buy-me-a-coffee.png new file mode 100644 index 0000000..755ff0a Binary files /dev/null and b/scim-server-mgmt/src/main/resources/static/images/buy-me-a-coffee.png differ diff --git a/scim-server-mgmt/src/main/resources/templates/index.html b/scim-server-mgmt/src/main/resources/templates/index.html index 3a44b99..bf95c3e 100644 --- a/scim-server-mgmt/src/main/resources/templates/index.html +++ b/scim-server-mgmt/src/main/resources/templates/index.html @@ -60,7 +60,18 @@ .empty-state { text-align: center; padding: 3rem 1rem; color: var(--text-muted); } .badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.7rem; font-weight: 600; } .badge-info { background: var(--primary); color: #fff; } - .toast { position: fixed; bottom: 1.5rem; 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; } + .bmc-widget { position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 90; } + .bmc-widget summary { list-style: none; min-height: 3.5rem; display: inline-flex; align-items: center; justify-content: center; gap: 0.55rem; padding: 0.75rem 0.95rem; border-radius: 999px; cursor: pointer; background: linear-gradient(180deg, #ffe066 0%, #ffd43b 100%); box-shadow: 0 20px 45px rgba(15, 23, 42, 0.45); border: 1px solid rgba(255, 255, 255, 0.35); } + .bmc-widget summary::-webkit-details-marker { display: none; } + .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-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; } + .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; } .logo { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem; } .logo svg { width: 32px; height: 32px; } @@ -80,6 +91,14 @@ } @media (max-width: 720px) { .stats-grid, .stats-breakdown { grid-template-columns: 1fr; } + .bmc-widget { right: 1rem; bottom: 1rem; } + .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; } + .toast { right: 1rem; bottom: 4.75rem; } } .auto-style-1 { margin:0 } @@ -414,5 +433,17 @@

Workspace Stats

toast('Failed to load workspaces'); } +
+ + Buy Me a Coffee icon + Buy me a coffee + + + Buy Me a Coffee QR code + + Scan or click + + +
diff --git a/scim-server-mgmt/src/main/resources/templates/workspace.html b/scim-server-mgmt/src/main/resources/templates/workspace.html index f44234e..ff977b7 100644 --- a/scim-server-mgmt/src/main/resources/templates/workspace.html +++ b/scim-server-mgmt/src/main/resources/templates/workspace.html @@ -455,6 +455,18 @@

Request Log

+
+ + Buy Me a Coffee icon + Buy me a coffee + + + Buy Me a Coffee QR code + + Scan or click + + +
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 cef73ba..0561796 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 @@ -1,14 +1,18 @@ package de.palsoftware.scim.server.mgmt.security; import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -68,6 +72,16 @@ void username_oidcUser_subFallback() { assertThat(AuthenticatedUser.username(auth)).isEqualTo("sub-123"); } + @Test + void username_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"); + } + @Test void username_nonOidc_fallsBackToName() { Authentication auth = mock(Authentication.class); @@ -87,6 +101,16 @@ void username_noIdentifier_throws() { .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 @@ -114,6 +138,16 @@ void displayName_oidcUser_noEmail_fallsBackToUsername() { assertThat(AuthenticatedUser.displayName(auth)).isEqualTo("preferred"); } + @Test + void displayName_jwt_returnsEmail() { + Authentication auth = new TestingAuthenticationToken(jwt(Map.of( + "sub", "jwt-sub", + "email", "display@example.com" + )), "n/a"); + + assertThat(AuthenticatedUser.displayName(auth)).isEqualTo("display@example.com"); + } + // ─── isAdmin ──────────────────────────────────────────────────────── @Test @@ -158,4 +192,9 @@ private Authentication mockAuthWithPrincipal(Object principal) { when(auth.getPrincipal()).thenReturn(principal); return auth; } + + private 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-validator-mgmt/Dockerfile b/scim-validator-mgmt/Dockerfile index 66ea868..8441026 100644 --- a/scim-validator-mgmt/Dockerfile +++ b/scim-validator-mgmt/Dockerfile @@ -9,6 +9,7 @@ COPY scim-validator/pom.xml scim-validator/ COPY scim-validator-mgmt/pom.xml scim-validator-mgmt/ RUN mvn -pl scim-validator-mgmt -am -DskipTests dependency:go-offline -B +COPY scim-server-common/src scim-server-common/src COPY scim-validator/src scim-validator/src COPY scim-validator-mgmt/src scim-validator-mgmt/src RUN mvn -pl scim-validator-mgmt -am -DskipTests package -B diff --git a/scim-validator-mgmt/pom.xml b/scim-validator-mgmt/pom.xml index 566e106..bf65c6b 100644 --- a/scim-validator-mgmt/pom.xml +++ b/scim-validator-mgmt/pom.xml @@ -17,7 +17,7 @@ 17 - 3.5.12 + 3.5.13 4.0.24 2.4-M4-groovy-4.0 5.5.0 @@ -67,8 +67,18 @@ org.springframework.boot spring-boot-starter-oauth2-client
+ + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + de.pal-software.scim + scim-server-common + ${project.version} + de.palsoftware.scim 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 938f69b..381891c 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 @@ -7,7 +7,6 @@ import de.palsoftware.scim.validator.mgmt.service.ValidationRunService; import jakarta.validation.Valid; import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; @@ -106,11 +105,10 @@ private String currentUserRole(Authentication authentication) { } private String resolveDisplayName(Authentication authentication) { - if (authentication != null && authentication.getPrincipal() instanceof OidcUser oidcUser) { - String sub = oidcUser.getSubject(); - String fallback = AuthenticatedUser.displayName(authentication); - return mgmtUserService.resolveDisplayName(sub, fallback); + if (authentication == null) { + return null; } - return AuthenticatedUser.displayName(authentication); + String fallback = AuthenticatedUser.displayName(authentication); + return mgmtUserService.resolveDisplayName(AuthenticatedUser.userId(authentication), fallback); } } diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/ActuatorApiKeyAuthFilter.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/ActuatorApiKeyAuthFilter.java deleted file mode 100644 index da31a2c..0000000 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/ActuatorApiKeyAuthFilter.java +++ /dev/null @@ -1,41 +0,0 @@ -package de.palsoftware.scim.validator.mgmt.security; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.Objects; - -public class ActuatorApiKeyAuthFilter extends OncePerRequestFilter { - - public static final String API_KEY_HEADER = "X-API-KEY"; - - private final String actuatorApiKey; - - public ActuatorApiKeyAuthFilter(String actuatorApiKey) { - if (actuatorApiKey == null || actuatorApiKey.isBlank()) { - throw new IllegalArgumentException("ACTUATOR_API_KEY must be configured"); - } - this.actuatorApiKey = actuatorApiKey; - } - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - return !request.getRequestURI().startsWith("/actuator/"); - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - String providedApiKey = request.getHeader(API_KEY_HEADER); - if (!Objects.equals(actuatorApiKey, providedApiKey)) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing or invalid actuator API key"); - return; - } - - filterChain.doFilter(request, response); - } -} 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 e214528..3628679 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 @@ -2,6 +2,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.core.oidc.user.OidcUser; public final class AuthenticatedUser { @@ -20,6 +21,12 @@ public static String username(Authentication authentication) { return resolved; } } + if (principal instanceof Jwt jwt) { + String resolved = resolveJwtUsername(jwt); + if (resolved != null) { + return resolved; + } + } String fallback = authentication.getName(); if (fallback == null || fallback.isBlank()) { throw new IllegalStateException("Unable to resolve authenticated username"); @@ -47,6 +54,26 @@ private static String resolveOidcUsername(OidcUser oidcUser) { 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"); @@ -59,6 +86,12 @@ public static String userId(Authentication authentication) { 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()) { @@ -78,6 +111,12 @@ public static String displayName(Authentication authentication) { return email; } } + if (principal instanceof Jwt jwt) { + String email = jwt.getClaimAsString("email"); + if (email != null && !email.isBlank()) { + return email; + } + } return username(authentication); } diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AzureSecurityConfig.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AzureSecurityConfig.java new file mode 100644 index 0000000..72b8a6b --- /dev/null +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AzureSecurityConfig.java @@ -0,0 +1,56 @@ +package de.palsoftware.scim.validator.mgmt.security; + +import de.palsoftware.scim.server.common.security.AzureOidcSecuritySupport; +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.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@Profile("!cloudflare") +public class AzureSecurityConfig { + + private static final String CSRF_COOKIE_NAME = "SCIM_VALIDATOR_MGMT_XSRF"; + + private final String adminRole; + private final String userRole; + private final String roleClaim; + private final String actuatorApiKey; + + private final MgmtUserService mgmtUserService; + + public AzureSecurityConfig(@Value("${app.security.oidc.admin-role}") String adminRole, + @Value("${app.security.oidc.user-role}") String userRole, + @Value("${app.security.azure.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.actuatorApiKey = actuatorApiKey; + this.mgmtUserService = mgmtUserService; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + MgmtSecuritySupport.configureBaseSecurity(http, actuatorApiKey) + .oauth2Login(oauth2 -> oauth2 + .loginPage("/oauth2/authorization/azure") + .userInfoEndpoint(userInfo -> userInfo.oidcUserService( + AzureOidcSecuritySupport.createOidcUserService( + roleClaim, + adminRole, + userRole, + mgmtUserService::provisionUser)))) + .logout(logout -> logout.logoutSuccessUrl("/")) + .csrf(csrf -> csrf.csrfTokenRepository(MgmtSecuritySupport.csrfTokenRepository(CSRF_COOKIE_NAME))); + return http.build(); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..791597d --- /dev/null +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/CloudflareSecurityConfig.java @@ -0,0 +1,77 @@ +package de.palsoftware.scim.validator.mgmt.security; + +import de.palsoftware.scim.server.common.security.CloudflareJwtSecuritySupport; +import de.palsoftware.scim.server.common.security.MgmtSecuritySupport; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@Profile("cloudflare") +public class CloudflareSecurityConfig { + + private static final String CSRF_COOKIE_NAME = "SCIM_VALIDATOR_MGMT_XSRF"; + + private final String adminRole; + private final String userRole; + private final String roleClaim; + private final String actuatorApiKey; + + 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, + @Value("${app.security.actuator.api-key}") String actuatorApiKey) { + this.adminRole = adminRole; + this.userRole = userRole; + this.roleClaim = roleClaim; + this.actuatorApiKey = actuatorApiKey; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + JwtDecoder cloudflareJwtDecoder, + Converter cloudflareJwtAuthenticationConverter, + BearerTokenResolver cloudflareBearerTokenResolver, + @Value("${app.security.cloudflare.logout-url}") String logoutSuccessUrl) throws Exception { + MgmtSecuritySupport.configureBaseSecurity(http, actuatorApiKey) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .oauth2ResourceServer(oauth2 -> oauth2 + .bearerTokenResolver(cloudflareBearerTokenResolver) + .jwt(jwt -> jwt + .decoder(cloudflareJwtDecoder) + .jwtAuthenticationConverter(cloudflareJwtAuthenticationConverter))) + .logout(logout -> logout.logoutSuccessUrl(logoutSuccessUrl)) + .csrf(csrf -> csrf.csrfTokenRepository(MgmtSecuritySupport.csrfTokenRepository(CSRF_COOKIE_NAME))); + return http.build(); + } + + @Bean + public BearerTokenResolver cloudflareBearerTokenResolver( + @Value("${app.security.cloudflare.token-header}") String tokenHeader) { + return CloudflareJwtSecuritySupport.createBearerTokenResolver(tokenHeader); + } + + @Bean + public JwtDecoder cloudflareJwtDecoder( + @Value("${app.security.cloudflare.issuer-uri}") String issuerUri, + @Value("${app.security.cloudflare.audience}") String audience, + @Value("${app.security.cloudflare.jwk-set-uri}") String jwkSetUri) { + return CloudflareJwtSecuritySupport.createJwtDecoder(issuerUri, audience, jwkSetUri); + } + + @Bean + public Converter cloudflareJwtAuthenticationConverter() { + return CloudflareJwtSecuritySupport.createJwtAuthenticationConverter(roleClaim, adminRole, userRole); + } +} \ No newline at end of file diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/SecurityConfig.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/SecurityConfig.java deleted file mode 100644 index 7eae4db..0000000 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/SecurityConfig.java +++ /dev/null @@ -1,135 +0,0 @@ -package de.palsoftware.scim.validator.mgmt.security; - -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.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -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.user.DefaultOidcUser; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -import org.springframework.security.web.SecurityFilterChain; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - private final String adminRole; - private final String userRole; - private final MgmtUserService mgmtUserService; - private final String actuatorApiKey; - - public SecurityConfig(@Value("${app.security.oidc.admin-role:admin}") String adminRole, - @Value("${app.security.oidc.user-role:user}") String userRole, - @Lazy MgmtUserService mgmtUserService, - @Value("${ACTUATOR_API_KEY}") String actuatorApiKey) { - this.adminRole = normalizeRole(adminRole); - this.userRole = normalizeRole(userRole); - this.mgmtUserService = mgmtUserService; - this.actuatorApiKey = actuatorApiKey; - } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/actuator/**").permitAll() - .requestMatchers("/css/**", "/js/**", "/images/**", "/error").permitAll() - .anyRequest().authenticated()) - .addFilterBefore(actuatorApiKeyAuthFilter(), org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class) - .oauth2Login(oauth2 -> oauth2 - .loginPage("/oauth2/authorization/azure") - .userInfoEndpoint(userInfo -> userInfo.oidcUserService(oidcUserService()))) - .logout(logout -> logout.logoutSuccessUrl("/")) - .csrf(csrf -> csrf.csrfTokenRepository(csrfTokenRepository())); - return http.build(); - } - - @Bean - public ActuatorApiKeyAuthFilter actuatorApiKeyAuthFilter() { - return new ActuatorApiKeyAuthFilter(actuatorApiKey); - } - - // CSRF token cookies intentionally omit HttpOnly so JavaScript can read and submit the token - // (Double Submit Cookie pattern). The session cookie remains HttpOnly. The Secure flag is - // set automatically by Spring Security based on request.isSecure(), so HTTPS-only in production. - @SuppressWarnings("java:S3330") - private CookieCsrfTokenRepository csrfTokenRepository() { - CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse(); - repository.setCookieName("SCIM_VALIDATOR_MGMT_XSRF"); - return repository; - } - - private OidcUserService oidcUserService() { - OidcUserService delegate = new OidcUserService(); - - return new OidcUserService() { - @Override - public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { - OidcUser oidcUser = delegate.loadUser(userRequest); - Set mappedAuthorities = new HashSet<>(oidcUser.getAuthorities()); - - addMappedAuthorities(oidcUser.getClaimAsStringList("roles"), mappedAuthorities); - - String sub = oidcUser.getSubject(); - String email = resolveEmail(oidcUser); - if (sub != null && !sub.isBlank()) { - mgmtUserService.provisionUser(sub, email); - } - - return new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo()); - } - }; - } - - private void addMappedAuthorities(List roles, Set mappedAuthorities) { - if (roles == null) { - return; - } - for (String role : roles) { - String normalized = normalizeRole(role); - if (normalized == null) { - continue; - } - if (normalized.equals(adminRole)) { - mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); - } - if (normalized.equals(userRole)) { - mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_USER")); - } - } - } - - private static String resolveEmail(OidcUser u) { - String email = u.getEmail(); - if (email != null && !email.isBlank()) return email; - String upn = u.getClaimAsString("upn"); - if (upn != null && !upn.isBlank()) return upn; - String unique = u.getClaimAsString("unique_name"); - if (unique != null && !unique.isBlank()) return unique; - return u.getPreferredUsername(); - } - - private String normalizeRole(String role) { - if (role == null || role.isBlank()) { - return null; - } - String normalized = role.trim().toUpperCase(); - if (normalized.startsWith("ROLE_")) { - return normalized.substring("ROLE_".length()); - } - return normalized; - } -} diff --git a/scim-validator-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/scim-validator-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..a965c9b --- /dev/null +++ b/scim-validator-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,75 @@ +{ + "groups": [ + { + "name": "app", + "type": "java.lang.Object" + }, + { + "name": "app.security", + "type": "java.lang.Object" + }, + { + "name": "app.security.actuator", + "type": "java.lang.Object" + }, + { + "name": "app.security.azure", + "type": "java.lang.Object" + }, + { + "name": "app.security.oidc", + "type": "java.lang.Object" + }, + { + "name": "app.security.cloudflare", + "type": "java.lang.Object" + } + ], + "properties": [ + { + "name": "app.security.actuator.api-key", + "type": "java.lang.String", + "description": "Actuator API key used by the management filter." + }, + { + "name": "app.security.azure.role-claim", + "type": "java.lang.String", + "description": "OIDC role claim used when Azure profile is active." + }, + { + "name": "app.security.oidc.admin-role", + "type": "java.lang.String", + "description": "Configured Azure admin role name." + }, + { + "name": "app.security.oidc.user-role", + "type": "java.lang.String", + "description": "Configured Azure user role name." + }, + { + "name": "app.security.cloudflare.role-claim", + "type": "java.lang.String", + "description": "JWT claim used when Cloudflare profile is active." + }, + { + "name": "app.security.cloudflare.issuer-uri", + "type": "java.lang.String" + }, + { + "name": "app.security.cloudflare.audience", + "type": "java.lang.String" + }, + { + "name": "app.security.cloudflare.jwk-set-uri", + "type": "java.lang.String" + }, + { + "name": "app.security.cloudflare.logout-url", + "type": "java.lang.String" + }, + { + "name": "app.security.cloudflare.token-header", + "type": "java.lang.String" + } + ] +} \ No newline at end of file diff --git a/scim-validator-mgmt/src/main/resources/application.yml b/scim-validator-mgmt/src/main/resources/application.yml index c457067..6f88349 100644 --- a/scim-validator-mgmt/src/main/resources/application.yml +++ b/scim-validator-mgmt/src/main/resources/application.yml @@ -1,6 +1,11 @@ spring: application: name: scim-validator-mgmt + flyway: + locations: + - classpath:db/validator + profiles: + default: azure jpa: hibernate: ddl-auto: validate @@ -48,3 +53,29 @@ logging: "[org.springframework.security]": DEBUG "[org.springframework.security.web.csrf]": TRACE "[org.springframework.security.web.FilterChainProxy]": DEBUG + +app: + security: + actuator: + api-key: ${ACTUATOR_API_KEY} + azure: + role-claim: ${APP_SECURITY_AZURE_ROLE_CLAIM:roles} + oidc: + admin-role: ${APP_SECURITY_OIDC_ADMIN_ROLE:admin} + user-role: ${APP_SECURITY_OIDC_USER_ROLE:user} + +--- +spring: + config: + activate: + on-profile: cloudflare + +app: + security: + cloudflare: + role-claim: ${APP_SECURITY_CLOUDFLARE_ROLE_CLAIM:roles} + issuer-uri: ${CLOUDFLARE_ACCESS_ISSUER_URI} + audience: ${CLOUDFLARE_ACCESS_AUDIENCE} + jwk-set-uri: ${CLOUDFLARE_ACCESS_JWK_SET_URI:} + logout-url: ${CLOUDFLARE_ACCESS_LOGOUT_URL:/} + token-header: ${CLOUDFLARE_ACCESS_TOKEN_HEADER:Cf-Access-Jwt-Assertion} diff --git a/scim-validator-mgmt/src/main/resources/db/migration/V1__init_validator_schema.sql b/scim-validator-mgmt/src/main/resources/db/validator/V1__init_validator_schema.sql similarity index 100% rename from scim-validator-mgmt/src/main/resources/db/migration/V1__init_validator_schema.sql rename to scim-validator-mgmt/src/main/resources/db/validator/V1__init_validator_schema.sql diff --git a/scim-validator-mgmt/src/main/resources/static/images/buy-me-a-coffee-icon.svg b/scim-validator-mgmt/src/main/resources/static/images/buy-me-a-coffee-icon.svg new file mode 100644 index 0000000..4de4489 --- /dev/null +++ b/scim-validator-mgmt/src/main/resources/static/images/buy-me-a-coffee-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/scim-validator-mgmt/src/main/resources/static/images/buy-me-a-coffee.png b/scim-validator-mgmt/src/main/resources/static/images/buy-me-a-coffee.png new file mode 100644 index 0000000..755ff0a Binary files /dev/null and b/scim-validator-mgmt/src/main/resources/static/images/buy-me-a-coffee.png differ diff --git a/scim-validator-mgmt/src/main/resources/templates/index.html b/scim-validator-mgmt/src/main/resources/templates/index.html index a28a723..d77ab36 100644 --- a/scim-validator-mgmt/src/main/resources/templates/index.html +++ b/scim-validator-mgmt/src/main/resources/templates/index.html @@ -58,6 +58,25 @@ @keyframes spin { to { transform: rotate(360deg); } } .run-dialog p { font-size: 15px; font-weight: 600; color: #202124; margin: 0; } .run-dialog small { font-size: 12px; color: #6b7280; } + .bmc-widget { position: fixed; right: 24px; bottom: 24px; z-index: 90; } + .bmc-widget summary { list-style: none; min-height: 56px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 16px; border-radius: 999px; cursor: pointer; background: linear-gradient(180deg, #ffe066 0%, #ffd43b 100%); box-shadow: 0 18px 36px rgba(0,0,0,0.24); border: 1px solid rgba(255,255,255,0.65); } + .bmc-widget summary::-webkit-details-marker { display: none; } + .bmc-widget summary img { display: block; width: 30px; height: 30px; } + .bmc-widget-label { color: #172554; font-size: 13px; font-weight: 800; line-height: 1; white-space: nowrap; } + .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: 8px 10px 10px; background: #fff4b3; color: #7c2d12; text-align: center; } + .bmc-panel-hint { font-size: 12px; font-weight: 700; opacity: 0.85; } + @media (max-width: 720px) { + .bmc-widget { right: 16px; bottom: 16px; } + .bmc-widget summary { min-height: 48px; gap: 6px; padding: 10px 12px; } + .bmc-widget summary img { width: 26px; height: 26px; } + .bmc-widget-label { font-size: 11px; } + .bmc-panel { width: min(120px, calc(100vw - 24px)); border-radius: 18px; } + .bmc-panel-copy { padding: 6px 8px 8px; } + .bmc-panel-hint { font-size: 9px; } + } @@ -161,5 +180,17 @@

Validation Runs

} })(); +
+ + Buy Me a Coffee icon + Buy me a coffee + + + Buy Me a Coffee QR code + + Scan or click + + +
diff --git a/scim-validator-mgmt/src/main/resources/templates/run-detail.html b/scim-validator-mgmt/src/main/resources/templates/run-detail.html index a295218..7b0e4ab 100644 --- a/scim-validator-mgmt/src/main/resources/templates/run-detail.html +++ b/scim-validator-mgmt/src/main/resources/templates/run-detail.html @@ -31,6 +31,25 @@ .details-row.is-open { display: table-row; } .details-panel { background: #fcfdff; } a { color: #1d4ed8; text-decoration: none; } + .bmc-widget { position: fixed; right: 24px; bottom: 24px; z-index: 90; } + .bmc-widget summary { list-style: none; min-height: 56px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 16px; border-radius: 999px; cursor: pointer; background: linear-gradient(180deg, #ffe066 0%, #ffd43b 100%); box-shadow: 0 18px 36px rgba(0,0,0,0.24); border: 1px solid rgba(255,255,255,0.65); } + .bmc-widget summary::-webkit-details-marker { display: none; } + .bmc-widget summary img { display: block; width: 30px; height: 30px; } + .bmc-widget-label { color: #172554; font-size: 13px; font-weight: 800; line-height: 1; white-space: nowrap; } + .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: 8px 10px 10px; background: #fff4b3; color: #7c2d12; text-align: center; } + .bmc-panel-hint { font-size: 12px; font-weight: 700; opacity: 0.85; } + @media (max-width: 720px) { + .bmc-widget { right: 16px; bottom: 16px; } + .bmc-widget summary { min-height: 48px; gap: 6px; padding: 10px 12px; } + .bmc-widget summary img { width: 26px; height: 26px; } + .bmc-widget-label { font-size: 11px; } + .bmc-panel { width: min(120px, calc(100vw - 24px)); border-radius: 18px; } + .bmc-panel-copy { padding: 6px 8px 8px; } + .bmc-panel-hint { font-size: 9px; } + } @@ -150,5 +169,17 @@

Requests and Responses

}); }); +
+ + Buy Me a Coffee icon + Buy me a coffee + + + Buy Me a Coffee QR code + + Scan or click + + +
diff --git a/scim-validator-mgmt/src/test/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUserTest.java b/scim-validator-mgmt/src/test/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUserTest.java index 1b86366..d93d3e9 100644 --- a/scim-validator-mgmt/src/test/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUserTest.java +++ b/scim-validator-mgmt/src/test/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUserTest.java @@ -7,6 +7,7 @@ import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; 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; @@ -75,6 +76,18 @@ void userId_oidcPrincipal_returnsSub() { assertThat(result).isEqualTo("oidc-sub-789"); } + @Test + void userId_jwtPrincipal_returnsSub() { + Authentication auth = new TestingAuthenticationToken(buildJwt(Map.of( + "sub", "jwt-sub-789", + "email", "jwt@example.com" + )), "n/a"); + + String result = AuthenticatedUser.userId(auth); + + assertThat(result).isEqualTo("jwt-sub-789"); + } + @Test void displayName_nullAuthentication_returnsNull() { String result = AuthenticatedUser.displayName(null); @@ -93,6 +106,18 @@ void displayName_oidcClaims_returnExpectedDisplayName(Map claims assertThat(result).isEqualTo(expectedDisplayName); } + @Test + void displayName_jwtClaims_returnExpectedDisplayName() { + Authentication auth = new TestingAuthenticationToken(buildJwt(Map.of( + "sub", "jwt-sub-123", + "email", "display@example.com" + )), "n/a"); + + String result = AuthenticatedUser.displayName(auth); + + assertThat(result).isEqualTo("display@example.com"); + } + @Test void isAdmin_nullAuthentication_returnsFalse() { boolean result = AuthenticatedUser.isAdmin(null); @@ -136,6 +161,12 @@ private static OidcUser buildOidcUser(Map claims) { return new DefaultOidcUser(Collections.emptyList(), idToken); } + private static Jwt buildJwt(Map claims) { + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plusSeconds(3600); + return new Jwt("token-value", issuedAt, expiresAt, Map.of("alg", "none"), claims); + } + private static Stream usernameOidcCases() { return Stream.of( Arguments.of(Map.of("sub", "sub-123", "preferred_username", "preferred.user"), "preferred.user"), diff --git a/scim-validator/pom.xml b/scim-validator/pom.xml index 3053be3..8a12028 100644 --- a/scim-validator/pom.xml +++ b/scim-validator/pom.xml @@ -45,6 +45,12 @@ groovy-json ${groovy.version}
+ + org.apache.groovy + groovy-yaml + ${groovy.version} + test + diff --git a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimBaseSpec.groovy b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimBaseSpec.groovy index ba32f59..a01db0a 100644 --- a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimBaseSpec.groovy +++ b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimBaseSpec.groovy @@ -65,14 +65,12 @@ abstract class ScimBaseSpec extends Specification { } protected static void refreshConfiguration() { - String explicitBaseUrl = System.getProperty("scim.baseUrl") ?: System.getenv("SCIM_BASE_URL") - String configuredWorkspace = System.getProperty("scim.workspaceId") ?: System.getenv("SCIM_WORKSPACE_ID") - String explicitApiUrl = System.getProperty("scim.apiUrl") ?: System.getenv("SCIM_API_URL") - String configuredAuthToken = System.getProperty("scim.authToken") ?: System.getenv("SCIM_AUTH_TOKEN") - boolean testcontainersEnabled = booleanSetting( - System.getProperty("scim.testcontainers.enabled") ?: System.getenv("SCIM_TESTCONTAINERS_ENABLED"), - true - ) + ValidatorConfiguration.Config configuration = ValidatorConfiguration.current() + String explicitBaseUrl = configuration.baseUrl + String configuredWorkspace = configuration.workspaceId + String explicitApiUrl = configuration.apiUrl + String configuredAuthToken = configuration.authToken + boolean testcontainersEnabled = configuration.testcontainersEnabled if (!hasText(explicitBaseUrl) && !hasText(configuredWorkspace) && !hasText(explicitApiUrl) && !hasText(configuredAuthToken)) { if (testcontainersEnabled) { @@ -91,7 +89,7 @@ abstract class ScimBaseSpec extends Specification { ) } - String configuredApiUrl = explicitApiUrl ?: "http://localhost:8080" + String configuredApiUrl = explicitApiUrl workspaceId = configuredWorkspace AUTH_TOKEN = configuredAuthToken @@ -119,13 +117,6 @@ abstract class ScimBaseSpec extends Specification { return value != null && !value.isBlank() } - protected static boolean booleanSetting(String value, boolean defaultValue) { - if (!hasText(value)) { - return defaultValue - } - return Boolean.parseBoolean(value) - } - protected static void configureRestAssured() { RestAssured.baseURI = SCIM_API_URL RestAssured.basePath = BASE_PATH diff --git a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimValidatorEnvironment.groovy b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimValidatorEnvironment.groovy index ff1fcad..4216d21 100644 --- a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimValidatorEnvironment.groovy +++ b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimValidatorEnvironment.groovy @@ -26,14 +26,6 @@ final class ScimValidatorEnvironment { private static final Logger LOGGER = LoggerFactory.getLogger(ScimValidatorEnvironment) private static final SecureRandom SECURE_RANDOM = new SecureRandom() - private static final String DEFAULT_POSTGRES_IMAGE = "postgres:18-alpine3.22" - private static final String DEFAULT_API_IMAGE = "edipal/scim-server-api:latest" - private static final String POSTGRES_ALIAS = "validator-postgres" - private static final String DATABASE_NAME = "scimplayground" - private static final String DATABASE_USERNAME = "scim_playground" - private static final String DATABASE_PASSWORD = "scim_playground" - private static final int API_PORT = 8080 - private static Network network private static PostgreSQLContainer postgres private static GenericContainer api @@ -48,42 +40,38 @@ final class ScimValidatorEnvironment { } try { + ValidatorConfiguration.Config configuration = ValidatorConfiguration.current() + ValidatorConfiguration.PostgresConfig postgresConfiguration = configuration.postgres + ValidatorConfiguration.ApiConfig apiConfiguration = configuration.api + network = Network.newNetwork() - postgres = new PostgreSQLContainer<>(dockerImageName( - "scim.validator.postgresImage", - "SCIM_VALIDATOR_POSTGRES_IMAGE", - DEFAULT_POSTGRES_IMAGE - )) - .withDatabaseName(DATABASE_NAME) - .withUsername(DATABASE_USERNAME) - .withPassword(DATABASE_PASSWORD) + postgres = new PostgreSQLContainer<>(DockerImageName.parse(configuration.postgresImage)) + .withDatabaseName(postgresConfiguration.databaseName) + .withUsername(postgresConfiguration.username) + .withPassword(postgresConfiguration.password) .withNetwork(network) - .withNetworkAliases(POSTGRES_ALIAS) + .withNetworkAliases(postgresConfiguration.alias) postgres.start() String actuatorApiKey = randomToken(32) - api = new GenericContainer<>(dockerImageName( - "scim.validator.apiImage", - "SCIM_VALIDATOR_API_IMAGE", - DEFAULT_API_IMAGE - )) + api = new GenericContainer<>(DockerImageName.parse(configuration.apiImage)) .withNetwork(network) - .withExposedPorts(API_PORT) - .withEnv("SERVER_PORT", Integer.toString(API_PORT)) - .withEnv("SPRING_DATASOURCE_URL", "jdbc:postgresql://${POSTGRES_ALIAS}:5432/${DATABASE_NAME}") - .withEnv("SPRING_DATASOURCE_USERNAME", DATABASE_USERNAME) - .withEnv("SPRING_DATASOURCE_PASSWORD", DATABASE_PASSWORD) + .withExposedPorts(apiConfiguration.port) + .withEnv("SERVER_PORT", Integer.toString(apiConfiguration.port)) + .withEnv("SPRING_DATASOURCE_URL", "jdbc:postgresql://${postgresConfiguration.alias}:5432/${postgresConfiguration.databaseName}") + .withEnv("SPRING_DATASOURCE_USERNAME", postgresConfiguration.username) + .withEnv("SPRING_DATASOURCE_PASSWORD", postgresConfiguration.password) .withEnv("ACTUATOR_API_KEY", actuatorApiKey) .waitingFor(Wait.forHttp("/actuator/health") - .forPort(API_PORT) + .forPort(apiConfiguration.port) .withHeader("X-API-KEY", actuatorApiKey) .forStatusCode(200)) .withStartupTimeout(Duration.ofMinutes(3)) api.start() SeededTenant seededTenant = seedTenant() - String apiUrl = "http://${api.getHost()}:${api.getMappedPort(API_PORT)}" + String apiUrl = "http://${api.getHost()}:${api.getMappedPort(apiConfiguration.port)}" runtimeConfiguration = new ScimRuntimeConfiguration(apiUrl, seededTenant.workspaceId, seededTenant.authToken) Runtime.runtime.addShutdownHook(new Thread(ScimValidatorEnvironment::shutdown)) @@ -101,9 +89,10 @@ final class ScimValidatorEnvironment { Instant now = Instant.now() String rawToken = randomToken(64) String tokenHash = sha256Hex(rawToken) + ValidatorConfiguration.PostgresConfig postgresConfiguration = ValidatorConfiguration.current().postgres try (Connection connection = DriverManager.getConnection( - postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) { + postgres.getJdbcUrl(), postgresConfiguration.username, postgresConfiguration.password)) { insertWorkspace(connection, workspaceId, now) insertWorkspaceToken(connection, tokenId, workspaceId, tokenHash, now) } @@ -144,17 +133,6 @@ final class ScimValidatorEnvironment { } } - private static DockerImageName dockerImageName(String systemPropertyName, String environmentVariableName, String defaultImage) { - String imageName = System.getProperty(systemPropertyName) - if (imageName == null || imageName.isBlank()) { - imageName = System.getenv(environmentVariableName) - } - if (imageName == null || imageName.isBlank()) { - imageName = defaultImage - } - return DockerImageName.parse(imageName) - } - private static String randomToken(int bytes) { byte[] randomBytes = new byte[bytes] SECURE_RANDOM.nextBytes(randomBytes) diff --git a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ValidatorConfiguration.groovy b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ValidatorConfiguration.groovy new file mode 100644 index 0000000..7f6b5e3 --- /dev/null +++ b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ValidatorConfiguration.groovy @@ -0,0 +1,153 @@ +package de.palsoftware.scim.validator.base + +import groovy.yaml.YamlSlurper + +final class ValidatorConfiguration { + + private static final String CONFIG_RESOURCE = "/application.yml" + private static final Config CURRENT = load() + + private ValidatorConfiguration() { + } + + static Config current() { + return CURRENT + } + + private static Config load() { + InputStream inputStream = ValidatorConfiguration.getResourceAsStream(CONFIG_RESOURCE) + if (inputStream == null) { + throw new IllegalStateException("Missing validator configuration resource ${CONFIG_RESOURCE}") + } + + Map root = (Map) new YamlSlurper().parse(inputStream) + return new Config( + stringValue(root, "scim.base-url"), + stringValue(root, "scim.api-url"), + stringValue(root, "scim.workspace-id"), + stringValue(root, "scim.auth-token"), + booleanValue(root, "scim.testcontainers.enabled"), + stringValue(root, "scim.testcontainers.postgres-image"), + stringValue(root, "scim.testcontainers.api-image"), + new PostgresConfig( + stringValue(root, "scim.testcontainers.postgres.alias"), + stringValue(root, "scim.testcontainers.postgres.database-name"), + stringValue(root, "scim.testcontainers.postgres.username"), + stringValue(root, "scim.testcontainers.postgres.password") + ), + new ApiConfig(intValue(root, "scim.testcontainers.api.port")) + ) + } + + private static String stringValue(Map root, String path) { + Object value = requiredValue(root, path) + if (!(value instanceof CharSequence) && !(value instanceof Number) && !(value instanceof Boolean)) { + throw new IllegalStateException("Unsupported configuration value for ${path}: ${value}") + } + return resolveValue(value.toString()) + } + + private static boolean booleanValue(Map root, String path) { + String value = stringValue(root, path) + if (value == null || value.isBlank()) { + throw new IllegalStateException("Blank boolean configuration value for ${path}") + } + return Boolean.parseBoolean(value) + } + + private static int intValue(Map root, String path) { + String value = stringValue(root, path) + try { + return Integer.parseInt(value) + } catch (NumberFormatException exception) { + throw new IllegalStateException("Invalid integer configuration value for ${path}: ${value}", exception) + } + } + + private static Object requiredValue(Map root, String path) { + Object current = root + for (String segment : path.split("\\.")) { + if (!(current instanceof Map) || !((Map) current).containsKey(segment)) { + throw new IllegalStateException("Missing validator configuration key ${path}") + } + current = ((Map) current).get(segment) + } + return current + } + + private static String resolveValue(String rawValue) { + if (rawValue == null) { + return null + } + def matcher = rawValue =~ /^\$\{([^:}]+)(?::([^}]*))?\}$/ + if (!matcher.matches()) { + return rawValue + } + + String variableName = matcher.group(1) + String defaultValue = matcher.groupCount() >= 2 ? matcher.group(2) : null + String systemValue = System.getProperty(variableName) + if (systemValue != null && !systemValue.isBlank()) { + return systemValue + } + String environmentValue = System.getenv(variableName) + if (environmentValue != null && !environmentValue.isBlank()) { + return environmentValue + } + return defaultValue + } + + static final class Config { + final String baseUrl + final String apiUrl + final String workspaceId + final String authToken + final boolean testcontainersEnabled + final String postgresImage + final String apiImage + final PostgresConfig postgres + final ApiConfig api + + Config(String baseUrl, + String apiUrl, + String workspaceId, + String authToken, + boolean testcontainersEnabled, + String postgresImage, + String apiImage, + PostgresConfig postgres, + ApiConfig api) { + this.baseUrl = baseUrl + this.apiUrl = apiUrl + this.workspaceId = workspaceId + this.authToken = authToken + this.testcontainersEnabled = testcontainersEnabled + this.postgresImage = postgresImage + this.apiImage = apiImage + this.postgres = postgres + this.api = api + } + } + + static final class PostgresConfig { + final String alias + final String databaseName + final String username + final String password + + PostgresConfig(String alias, String databaseName, String username, String password) { + this.alias = alias + this.databaseName = databaseName + this.username = username + this.password = password + } + } + + static final class ApiConfig { + final int port + + ApiConfig(int port) { + this.port = port + } + } +} \ No newline at end of file diff --git a/scim-validator/src/test/resources/application.yml b/scim-validator/src/test/resources/application.yml new file mode 100644 index 0000000..afbf5b8 --- /dev/null +++ b/scim-validator/src/test/resources/application.yml @@ -0,0 +1,16 @@ +scim: + base-url: "${SCIM_BASE_URL:}" + api-url: "${SCIM_API_URL:http://localhost:8080}" + workspace-id: "${SCIM_WORKSPACE_ID:}" + auth-token: "${SCIM_AUTH_TOKEN:}" + testcontainers: + enabled: "${SCIM_TESTCONTAINERS_ENABLED:true}" + postgres-image: "${SCIM_VALIDATOR_POSTGRES_IMAGE:postgres:18-alpine3.22}" + api-image: "${SCIM_VALIDATOR_API_IMAGE:edipal/scim-server-api:latest}" + postgres: + alias: validator-postgres + database-name: scimplayground + username: scim_playground + password: scim_playground + api: + port: 8080 \ No newline at end of file diff --git a/test.patch b/test.patch deleted file mode 100644 index 88dc8de..0000000 --- a/test.patch +++ /dev/null @@ -1,11 +0,0 @@ ---- a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/scim/filter/ScimFilterParser.java -+++ b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/scim/filter/ScimFilterParser.java -@@ -37,7 +37,7 @@ - - // Token patterns - private static final Pattern TOKEN_PATTERN = Pattern.compile( -- "\\(|\\)|" + -+ "\\(|\\)|\\[|\\]|" + - "\"(?:[^\"\\\\]|\\\\.)*+\"|" + - "\\b(?:true|false)\\b|" + - "\\b(?:and|or|not)\\b|" +