├── .checkov.yml ├── .config └── dotnet-tools.json ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── chaos.yaml │ ├── ci.yaml │ ├── lint-pr-title.yaml │ ├── release-please.yaml │ ├── schedule.yaml │ └── scorecards.yaml ├── .gitignore ├── .gitleaks.toml ├── .kics.yaml ├── .lychee.toml ├── .mdlrc ├── .mega-linter.yml ├── .pre-commit-config.yaml ├── .prettierignore ├── .protolintrc.yml ├── .release-please-manifest.json ├── .renovaterc.json ├── .stylecop.json ├── .trivyignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .yamllint ├── CHANGELOG.md ├── Dockerfile ├── FhirPseudonymizer.sln ├── LICENSE ├── README.md ├── SECURITY.md ├── Taskfile.yaml ├── benchmark ├── bombardier.sh ├── bundle.json └── observation.json ├── compose.dev.yaml ├── compose ├── README.md ├── anonymization-hipaa.yaml └── compose.yaml ├── docs └── img │ ├── logo.png │ └── openapi.png ├── hack ├── keycloak │ └── fhir-pseudonymizer-test-realm-export.json └── mocks │ ├── README.md │ ├── initializer.json │ └── initializer.yaml ├── release-please-config.json ├── src ├── Directory.Build.props ├── FhirPseudonymizer.StressTests │ ├── FhirPseudonymizer.StressTests.csproj │ ├── StressTests.cs │ └── Usings.cs ├── FhirPseudonymizer.Tests │ ├── CryptoHashProcessorTests.cs │ ├── FhirControllerTests.cs │ ├── FhirPseudonymizer.Tests.csproj │ ├── Fixtures │ │ ├── Data │ │ │ ├── Configs │ │ │ │ ├── generalize-birth-date.yaml │ │ │ │ ├── hipaa.yaml │ │ │ │ ├── pseudonymization.yaml │ │ │ │ ├── truncate-crypto-hashed-values.yaml │ │ │ │ └── whitelist-resource-parts.yaml │ │ │ └── Resources │ │ │ │ ├── Ashleigh_Olson_9d9b8bed-7b79-7fa9-cea1-f133a6b4d551.json │ │ │ │ └── patient.json │ │ └── MII-Pseudonymization │ │ │ ├── mii-patient.json │ │ │ └── patient-to-pseuded.yaml │ ├── IntegrationTests.cs │ ├── Pseudonymization │ │ ├── EnticiFhirClientTests.cs │ │ ├── GPasFhirClientTests.cs │ │ ├── GPasPseudonymizationProcessorTests.cs │ │ ├── NoopPseudonymServiceClientTests.cs │ │ └── VfpsPseudonymServiceClientTests.cs │ ├── PseudonymizationServiceConfigurationTests.cs │ ├── ReferenceUtilityTests.cs │ ├── RequestCompressionTests.cs │ ├── SnapshotTests.cs │ ├── Snapshots │ │ ├── IntegrationTests.PostDeIdentify_WithCryptoHashKeySetViaAppSettingsConfig_ShouldCryptoHashValue.verified.json │ │ ├── IntegrationTests.PostDeIdentify_WithShouldAddSecurityTagSetToFalse_ShouldNotAddSecurityMetaDataToResult.verified.json │ │ ├── generalize-birth-date-Ashleigh_Olson_9d9b8bed-7b79-7fa9-cea1-f133a6b4d551.verified.json │ │ ├── generalize-birth-date-patient.verified.json │ │ ├── hipaa-Ashleigh_Olson_9d9b8bed-7b79-7fa9-cea1-f133a6b4d551.verified.json │ │ ├── hipaa-patient.verified.json │ │ ├── patient-to-pseuded-mii-patient.verified.json │ │ ├── pseudonymization-Ashleigh_Olson_9d9b8bed-7b79-7fa9-cea1-f133a6b4d551.verified.json │ │ ├── pseudonymization-patient.verified.json │ │ ├── truncate-crypto-hashed-values-Ashleigh_Olson_9d9b8bed-7b79-7fa9-cea1-f133a6b4d551.verified.json │ │ ├── truncate-crypto-hashed-values-patient.verified.json │ │ ├── whitelist-resource-parts-Ashleigh_Olson_9d9b8bed-7b79-7fa9-cea1-f133a6b4d551.verified.json │ │ └── whitelist-resource-parts-patient.verified.json │ ├── Usings.cs │ ├── WebApplicationFactory.cs │ └── runsettings.xml └── FhirPseudonymizer │ ├── AnonymizerEngineExtensions.cs │ ├── ApiKeyExtensions.cs │ ├── CompressionMiddleware.cs │ ├── Config │ └── AppConfig.cs │ ├── Controllers │ └── FhirController.cs │ ├── DecryptProcessor.cs │ ├── FhirFormatter.cs │ ├── FhirPseudonymizer.csproj │ ├── Microsoft.Health.Fhir.Anonymizer.R4.Core │ ├── Constants.cs │ └── Processors │ │ └── PerturbProcessor.cs │ ├── Microsoft.Health.Fhir.Anonymizer.Shared.Core │ ├── AnonymizerConfigurationManager.cs │ ├── AnonymizerConfigurations │ │ ├── AnonymizationFhirPathRule.cs │ │ ├── AnonymizerConfiguration.cs │ │ ├── AnonymizerConfigurationErrorsException.cs │ │ ├── AnonymizerConfigurationValidator.cs │ │ ├── AnonymizerMethod.cs │ │ ├── AnonymizerRule.cs │ │ ├── AnonymizerRuleType.cs │ │ ├── AnonymizerSettings.cs │ │ ├── DateShiftScope.cs │ │ └── ParameterConfiguration.cs │ ├── AnonymizerEngine.cs │ ├── AnonymizerLogging.cs │ ├── Constants.cs │ ├── Extensions │ │ ├── ElementNodeExtension.cs │ │ ├── ElementNodeNavExtensions.cs │ │ ├── ElementNodeOperationExtensions.cs │ │ ├── ElementNodeVisitorExtensions.cs │ │ └── FhirPathSymbolExtensions.cs │ ├── IAnonymizerEngine.cs │ ├── Models │ │ ├── ProcessContext.cs │ │ ├── ProcessResult.cs │ │ └── SecurityLabels.cs │ ├── PartitionedExecution │ │ ├── BatchAnonymizeProgressDetail.cs │ │ ├── FhirEnumerableReader.cs │ │ ├── FhirPartitionedExecutor.cs │ │ ├── FhirStreamConsumer.cs │ │ ├── FhirStreamReader.cs │ │ ├── IFhirDataConsumer.cs │ │ └── IFhirDataReader.cs │ ├── Processors │ │ ├── AnonymizationOperations.cs │ │ ├── CryptoHashProcessor.cs │ │ ├── DateShiftProcessor.cs │ │ ├── EncryptProcessor.cs │ │ ├── GeneralizeProcessor.cs │ │ ├── IAnonymizerProcessor.cs │ │ ├── KeepProcessor.cs │ │ ├── PerturbProcessor.cs │ │ ├── RedactProcessor.cs │ │ ├── Settings │ │ │ ├── GeneralizeOtherValuesOperation.cs │ │ │ ├── GeneralizeSetting.cs │ │ │ ├── PerturbRangeType.cs │ │ │ ├── PerturbSetting.cs │ │ │ ├── RuleKeys.cs │ │ │ └── SubstituteSetting.cs │ │ └── SubstituteProcessor.cs │ ├── Utility │ │ ├── CryptoHashUtility.cs │ │ ├── DateTimeUtility.cs │ │ ├── EncryptUtility.cs │ │ ├── PostalCodeUtility.cs │ │ └── ReferenceUtility.cs │ ├── Validation │ │ ├── AttributeValidator.cs │ │ ├── ResourceNotValidException.cs │ │ └── ResourceValidator.cs │ └── Visitors │ │ ├── AbstractElementNodeVisitor.cs │ │ └── AnonymizationVisitor.cs │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Protos │ ├── google │ │ └── api │ │ │ ├── annotations.proto │ │ │ └── http.proto │ └── vfps │ │ └── api │ │ └── v1 │ │ ├── meta.proto │ │ └── pseudonyms.proto │ ├── Pseudonymization │ ├── Entici │ │ ├── EnticiExtensions.cs │ │ ├── EnticiFhirClient.cs │ │ └── EnticiPseudonymizationRequest.cs │ ├── GPas │ │ ├── GPasExtensions.cs │ │ └── GPasFhirClient.cs │ ├── IPseudonymServiceClient.cs │ ├── NoopPseudonymServiceClient.cs │ ├── PseudonymizationProcessor.cs │ ├── PseudonymizationServiceType.cs │ └── Vfps │ │ ├── VfpsExtensions.cs │ │ └── VfpsPseudonymServiceClient.cs │ ├── Startup.cs │ ├── TracingConfigurationExtensions.cs │ ├── anonymization.yaml │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── hipaa-anonymization.yaml │ └── packages.lock.json ├── stylecop.ruleset.xml ├── tests ├── chaos │ ├── argo-workflows-values.yaml │ ├── chaos-mesh-rbac.yaml │ ├── chaos.yaml │ ├── fhir-pseudonymizer-values.yaml │ └── workflow.yaml └── iter8 │ ├── experiment.yaml │ └── values.yaml ├── trivy.yaml └── version.txt /.checkov.yml: -------------------------------------------------------------------------------- 1 | skip-check: 2 | - CKV_DOCKER_3 3 | - CKV_DOCKER_2 4 | # CKV_K8S_21: "The default namespace should not be used" - used for simple testing inside a KinD cluster 5 | - CKV_K8S_21 6 | # CKV_K8S_10: "CPU requests should be set" - ignored for iter8 job pod 7 | - CKV_K8S_10 8 | # CKV_K8S_11: "CPU limits should be set" - ignored for iter8 job pod 9 | - CKV_K8S_11 10 | # CKV_K8S_12: "Memory requests should be set" 11 | - CKV_K8S_12 12 | # CKV_K8S_13: "Memory limits should be set" - ignored for iter8 job pod 13 | - CKV_K8S_13 14 | # CKV_K8S_15: "Image Pull Policy should be Always" - ignored for digest-pinned iter8 15 | - CKV_K8S_15 16 | # CKV_K8S_12: "Memory requests should be set" - ignored for iter8 17 | - CKV_K8S_12 18 | # CKV_K8S_38: "Ensure that Service Account Tokens are only mounted where necessary" - necessary for iter8 19 | - CKV_K8S_38 20 | # CKV_ARGO_2: "Ensure Workflow pods are running as non-root user" - necessary, see inline comments in workflow and Dockerfile 21 | - CKV_ARGO_2 22 | # CKV_SECRET_6: "Base64 High Entropy String" - already covered by gitleaks & co. with more configuration options. 23 | - CKV_SECRET_6 24 | -------------------------------------------------------------------------------- /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-outdated-tool": { 6 | "version": "4.6.8", 7 | "commands": ["dotnet-outdated"], 8 | "rollForward": false 9 | }, 10 | "csharpier": { 11 | "version": "1.0.2", 12 | "commands": ["csharpier"], 13 | "rollForward": false 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !src 3 | src/**/bin 4 | src/**/obj 5 | !*stylecop* 6 | !.editorconfig 7 | !tests/chaos 8 | !LICENSE 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh text eol=lf 3 | *.shellcheckrc text eol=lf 4 | *Dockerfile text eol=lf 5 | 6 | *.verified.json text eol=lf working-tree-encoding=UTF-8 7 | -------------------------------------------------------------------------------- /.github/workflows/chaos.yaml: -------------------------------------------------------------------------------- 1 | name: nightly chaos testing 2 | 3 | on: 4 | workflow_dispatch: {} 5 | schedule: 6 | # daily at 06:23 7 | - cron: "23 06 * * *" 8 | 9 | # Declare default permissions as read only. 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | chaos-testing: 15 | name: chaos testing 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | with: 21 | persist-credentials: false 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 25 | 26 | - name: Install Task 27 | uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 # v2.0.0 28 | with: 29 | version: 3.x 30 | repo-token: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Run chaos-test task 33 | run: task chaos-test 34 | 35 | - name: Print cluster logs 36 | if: always() 37 | run: | 38 | kubectl cluster-info dump -o yaml | tee kind-cluster-dump.txt 39 | 40 | - name: Upload cluster dump 41 | if: always() 42 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 43 | with: 44 | name: kind-cluster-dump.txt 45 | path: | 46 | kind-cluster-dump.txt 47 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr-title.yaml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | check-pr-title: 15 | name: Validate PR title 16 | runs-on: ubuntu-24.04 17 | permissions: 18 | pull-requests: write 19 | steps: 20 | - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yaml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | release-please: 13 | runs-on: ubuntu-24.04 14 | permissions: 15 | contents: write 16 | pull-requests: write 17 | 18 | steps: 19 | - uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0 20 | with: 21 | token: ${{ secrets.MIRACUM_BOT_SEMANTIC_RELEASE_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/schedule.yaml: -------------------------------------------------------------------------------- 1 | name: scheduled 2 | 3 | on: 4 | repository_dispatch: {} 5 | workflow_dispatch: {} 6 | schedule: 7 | - cron: "00 18 * * *" 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | schedule: 14 | uses: miracum/.github/.github/workflows/standard-schedule.yaml@ea119ab4361974cc57f38719dd14ede3a289724a # v1.16.17 15 | permissions: 16 | contents: read 17 | issues: write 18 | security-events: write 19 | secrets: 20 | github-token: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/scorecards.yaml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecards supply-chain security 6 | on: 7 | workflow_dispatch: 8 | # For Branch-Protection check. Only the default branch is supported. See 9 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 10 | branch_protection_rule: 11 | # To guarantee Maintained check is occasionally updated. See 12 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 13 | schedule: 14 | - cron: "37 19 * * 1" 15 | push: 16 | branches: ["master"] 17 | 18 | # Declare default permissions as read only. 19 | permissions: 20 | contents: read 21 | 22 | jobs: 23 | analysis: 24 | name: Scorecards analysis 25 | runs-on: ubuntu-latest 26 | permissions: 27 | # Needed to upload the results to code-scanning dashboard. 28 | security-events: write 29 | # Needed to publish results and get a badge (see publish_results below). 30 | id-token: write 31 | # Uncomment the permissions below if installing in a private repository. 32 | # contents: read 33 | # actions: read 34 | 35 | steps: 36 | - name: "Checkout code" 37 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 38 | with: 39 | persist-credentials: false 40 | 41 | - name: "Run analysis" 42 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 43 | with: 44 | results_file: results.sarif 45 | results_format: sarif 46 | # (Optional) Read-only PAT token. Uncomment the `repo_token` line below if: 47 | # - you want to enable the Branch-Protection check on a *public* repository, or 48 | # - you are installing Scorecards on a *private* repository 49 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. 50 | # repo_token: ${{ secrets.SCORECARD_READ_TOKEN }} 51 | 52 | # Public repositories: 53 | # - Publish results to OpenSSF REST API for easy access by consumers 54 | # - Allows the repository to include the Scorecard badge. 55 | # - See https://github.com/ossf/scorecard-action#publishing-results. 56 | # For private repositories: 57 | # - `publish_results` will always be set to `false`, regardless 58 | # of the value entered here. 59 | publish_results: true 60 | 61 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 62 | # format to the repository Actions tab. 63 | - name: "Upload artifact" 64 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 65 | with: 66 | name: SARIF file 67 | path: results.sarif 68 | retention-days: 5 69 | 70 | # Upload the results to GitHub's code scanning dashboard. 71 | - name: "Upload to code-scanning" 72 | uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 73 | with: 74 | sarif_file: results.sarif 75 | -------------------------------------------------------------------------------- /.gitleaks.toml: -------------------------------------------------------------------------------- 1 | [allowlist] 2 | paths = ["hack/*"] 3 | -------------------------------------------------------------------------------- /.kics.yaml: -------------------------------------------------------------------------------- 1 | exclude-paths: 2 | - "tests/" 3 | exclude-queries: 4 | # 5 | # informational, doesn't work any other way with .NET for now. 6 | # 7 | # 8 | - b16e8501-ef3c-44e1-a543-a093238099c9 9 | -------------------------------------------------------------------------------- /.lychee.toml: -------------------------------------------------------------------------------- 1 | exclude_all_private = true 2 | -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | rules "~MD013", "~MD033" 2 | -------------------------------------------------------------------------------- /.mega-linter.yml: -------------------------------------------------------------------------------- 1 | # Configuration file for MegaLinter 2 | # See all available variables at https://oxsecurity.github.io/megalinter/configuration/ and in linters documentation 3 | 4 | APPLY_FIXES: none # all, none, or list of linter keys 5 | # ENABLE: # If you use ENABLE variable, all other languages/formats/tooling-formats will be disabled by default 6 | # ENABLE_LINTERS: # If you use ENABLE_LINTERS variable, all other linters will be disabled by default 7 | DISABLE: 8 | - COPYPASTE # Comment to enable checks of excessive copy-pastes 9 | - SPELL # Comment to enable checks of spelling mistakes 10 | 11 | DISABLE_LINTERS: 12 | - REPOSITORY_DEVSKIM 13 | - MARKDOWN_MARKDOWN_TABLE_FORMATTER 14 | - CSHARP_DOTNET_FORMAT 15 | - MARKDOWN_MARKDOWN_LINK_CHECK 16 | - SPELL_LYCHEE 17 | 18 | SHOW_ELAPSED_TIME: true 19 | FILEIO_REPORTER: false 20 | # DISABLE_ERRORS: true # Uncomment if you want MegaLinter to detect errors but not block CI to pass 21 | 22 | BASH_SHFMT_ARGUMENTS: 23 | - "--indent=2" 24 | 25 | REPOSITORY_TRIVY_ARGUMENTS: 26 | - "--severity=HIGH,CRITICAL" 27 | 28 | REPOSITORY_CHECKOV_ARGUMENTS: 29 | - "--skip-path=tests/iter8" 30 | - "--skip-path=src/FhirPseudonymizer.Tests/Fixtures/Data/Resources/" 31 | - "--skip-path=src/FhirPseudonymizer.Tests/Snapshots/" 32 | 33 | IGNORE_GITIGNORED_FILES: true 34 | 35 | REPOSITORY_KICS_ARGUMENTS: 36 | - --fail-on=HIGH 37 | 38 | REPOSITORY_KICS_CONFIG_FILE: .kics.yaml 39 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.0.1 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | args: [--allow-multiple-documents] 11 | - id: check-added-large-files 12 | - id: fix-byte-order-marker 13 | - id: check-case-conflict 14 | - id: check-executables-have-shebangs 15 | - id: check-json 16 | - repo: https://github.com/jorisroovers/gitlint 17 | rev: v0.15.1 18 | hooks: 19 | - id: gitlint 20 | stages: [commit-msg] 21 | entry: gitlint 22 | args: [--contrib=CT1, --ignore=body-is-missing, --msg-filename] 23 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/FhirPseudonymizer.Tests/Snapshots 2 | packages.lock.json 3 | -------------------------------------------------------------------------------- /.protolintrc.yml: -------------------------------------------------------------------------------- 1 | lint: 2 | rules_option: 3 | max_line_length: 4 | max_chars: 120 5 | indent: 6 | not_insert_newline: true 7 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "2.22.10" 3 | } 4 | -------------------------------------------------------------------------------- /.renovaterc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>miracum/.github//renovate/default", "schedule:quarterly"], 4 | "kubernetes": { 5 | "managerFilePatterns": ["/tests/iter8/experiment.yaml/"] 6 | }, 7 | "ignoreDeps": ["ghcr.io/miracum/fhir-pseudonymizer"] 8 | } 9 | -------------------------------------------------------------------------------- /.stylecop.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", 3 | "settings": { 4 | "documentationRules": { 5 | "documentExposedElements": false, 6 | "documentInterfaces": false, 7 | "documentInternalElements": false, 8 | "documentPrivateElements": false, 9 | "documentPrivateFields": false, 10 | "xmlHeader": false 11 | }, 12 | "layoutRules": { 13 | "newlineAtEndOfFile": "require" 14 | }, 15 | "indentation": { 16 | "indentationSize": 4, 17 | "useTabs": false 18 | }, 19 | "orderingRules": { 20 | "usingDirectivesPlacement": "outsideNamespace", 21 | "elementOrder": [ 22 | "kind", 23 | "constant", 24 | "accessibility", 25 | "static", 26 | "readonly" 27 | ] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.trivyignore: -------------------------------------------------------------------------------- 1 | # iter8 requires access to secrets 2 | AVD-KSV-0041 3 | KSV041 4 | # necessary, see inline comments in workflow and Dockerfile. Only used for stress-testing 5 | AVD-DS-0002 6 | 7 | # Dockerfiles HEALTHCHECK implementation isn't relevant for Kubernetes-first deployments 8 | AVD-DS-0026 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": ".NET Core Launch (web)", 6 | "type": "coreclr", 7 | "request": "launch", 8 | "preLaunchTask": "build", 9 | "program": "${workspaceFolder}/src/FhirPseudonymizer/bin/Debug/net8.0/FhirPseudonymizer.dll", 10 | "args": [], 11 | "cwd": "${workspaceFolder}/src/FhirPseudonymizer", 12 | "stopAtEntry": false, 13 | "serverReadyAction": { 14 | "action": "openExternally", 15 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 16 | }, 17 | "env": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | }, 20 | "sourceFileMap": { 21 | "/Views": "${workspaceFolder}/Views" 22 | } 23 | }, 24 | { 25 | "name": ".NET Core Attach", 26 | "type": "coreclr", 27 | "request": "attach", 28 | "processId": "${command:pickProcess}" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "dotnet.defaultSolution": "FhirPseudonymizer.sln" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/src/FhirPseudonymizer/FhirPseudonymizer.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/src/FhirPseudonymizer/FhirPseudonymizer.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "${workspaceFolder}/src/FhirPseudonymizer/FhirPseudonymizer.csproj", 36 | "/property:GenerateFullPaths=true", 37 | "/consoleloggerparameters:NoSummary" 38 | ], 39 | "problemMatcher": "$msCompile" 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | extends: default 2 | rules: 3 | document-start: disable 4 | line-length: disable 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [2.22.10](https://github.com/miracum/fhir-pseudonymizer/compare/v2.22.9...v2.22.10) (2025-05-25) 4 | 5 | 6 | ### Miscellaneous Chores 7 | 8 | * **config:** migrate renovate config ([#249](https://github.com/miracum/fhir-pseudonymizer/issues/249)) ([fe2f060](https://github.com/miracum/fhir-pseudonymizer/commit/fe2f060417af768a3f552f89a8b1f7349d9f12af)) 9 | * **deps:** nbomber to v6 ([#258](https://github.com/miracum/fhir-pseudonymizer/issues/258)) ([d43602a](https://github.com/miracum/fhir-pseudonymizer/commit/d43602a7e6300662f80d4e08e0f3c56567593723)) 10 | * **deps:** update all non-major dependencies ([#239](https://github.com/miracum/fhir-pseudonymizer/issues/239)) ([ab0ed2e](https://github.com/miracum/fhir-pseudonymizer/commit/ab0ed2e1079b949bdc0729f9a8a57c1d31bb87c0)) 11 | * **deps:** update dependency aspnetcore.authentication.apikey to v9 ([#251](https://github.com/miracum/fhir-pseudonymizer/issues/251)) ([bf920c2](https://github.com/miracum/fhir-pseudonymizer/commit/bf920c24673b8c7739b7f1546568af2712cb76c9)) 12 | * **deps:** update dependency csharpier to v1 ([2408ff2](https://github.com/miracum/fhir-pseudonymizer/commit/2408ff23bb406e3a1d477823b1d242c2773ce4c4)) 13 | * **deps:** update dependency swashbuckle.aspnetcore to 8.1.2 ([#250](https://github.com/miracum/fhir-pseudonymizer/issues/250)) ([04e52cf](https://github.com/miracum/fhir-pseudonymizer/commit/04e52cf4a297476333ef9ef50e12e164b4dfebaa)) 14 | * **deps:** update dependency verify.xunit to v30 ([#253](https://github.com/miracum/fhir-pseudonymizer/issues/253)) ([499be19](https://github.com/miracum/fhir-pseudonymizer/commit/499be19311dd6a71f03b9991048f7fb2bd5cfec9)) 15 | * **deps:** update github-actions ([#248](https://github.com/miracum/fhir-pseudonymizer/issues/248)) ([053544a](https://github.com/miracum/fhir-pseudonymizer/commit/053544ac284b7bf71e163d8ca8879466762528cd)) 16 | * **format:** ran csharpier ([#257](https://github.com/miracum/fhir-pseudonymizer/issues/257)) ([4025a6b](https://github.com/miracum/fhir-pseudonymizer/commit/4025a6bc07a66a374908134af1bacbe157359424)) 17 | 18 | 19 | ### CI/CD 20 | 21 | * run ci on merge queue ([8a2a34b](https://github.com/miracum/fhir-pseudonymizer/commit/8a2a34b556e3284c3b1a1b47c8b5568782e0083a)) 22 | * run ci on merge queue ([8a2a34b](https://github.com/miracum/fhir-pseudonymizer/commit/8a2a34b556e3284c3b1a1b47c8b5568782e0083a)) 23 | * switch to release please ([3f72f46](https://github.com/miracum/fhir-pseudonymizer/commit/3f72f46c69a80189371ffc6ab7f3cd052ef9a168)) 24 | * switch to release please ([3f72f46](https://github.com/miracum/fhir-pseudonymizer/commit/3f72f46c69a80189371ffc6ab7f3cd052ef9a168)) 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # kics false positive "Missing User Instruction": 2 | # kics-scan ignore-line 3 | FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/aspnet:9.0.5-noble-chiseled@sha256:363d11f5c5979e7894c2eaaa878aed13946d52de68f5bef98d5bae11d2f242b1 AS runtime 4 | WORKDIR /opt/fhir-pseudonymizer 5 | EXPOSE 8080/tcp 8081/tcp 6 | USER 65532:65532 7 | ENV ASPNETCORE_ENVIRONMENT="Production" \ 8 | DOTNET_CLI_TELEMETRY_OPTOUT=1 \ 9 | ASPNETCORE_URLS="http://*:8080" 10 | 11 | FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0.300-noble@sha256:58fa5442c6da3bd654cab866fd6668de2713769511e412a3aa23c14368b84b16 AS build 12 | ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 13 | WORKDIR /build 14 | COPY src/Directory.Build.props . 15 | COPY src/FhirPseudonymizer/FhirPseudonymizer.csproj . 16 | COPY src/FhirPseudonymizer/packages.lock.json . 17 | RUN dotnet restore --locked-mode 18 | COPY . . 19 | 20 | RUN dotnet publish \ 21 | -c Release \ 22 | -o /build/publish \ 23 | -a "$TARGETARCH" \ 24 | src/FhirPseudonymizer/FhirPseudonymizer.csproj 25 | 26 | FROM build AS build-test 27 | WORKDIR /build/src/FhirPseudonymizer.Tests 28 | RUN dotnet test \ 29 | --configuration=Release \ 30 | --collect:"XPlat Code Coverage" \ 31 | --results-directory=./coverage \ 32 | -l "console;verbosity=detailed" \ 33 | --settings=runsettings.xml 34 | 35 | FROM scratch AS test 36 | WORKDIR /build/src/FhirPseudonymizer.Tests/coverage 37 | COPY --from=build-test /build/src/FhirPseudonymizer.Tests/coverage . 38 | ENTRYPOINT [ "true" ] 39 | 40 | FROM build AS build-stress-test 41 | WORKDIR /build/src/FhirPseudonymizer.StressTests 42 | RUN < 64 | # when running as non-root. 65 | # hadolint ignore=DL3002 66 | USER 0:0 67 | ENTRYPOINT ["dotnet"] 68 | CMD ["test", "/opt/fhir-pseudonymizer-stress/FhirPseudonymizer.StressTests.dll", "-l", "console;verbosity=detailed"] 69 | 70 | FROM runtime 71 | COPY LICENSE . 72 | COPY --from=build /build/publish/*anonymization.yaml /etc/ 73 | COPY --from=build /build/publish . 74 | COPY --from=build /build/packages.lock.json . 75 | 76 | ENTRYPOINT ["dotnet", "/opt/fhir-pseudonymizer/FhirPseudonymizer.dll"] 77 | -------------------------------------------------------------------------------- /FhirPseudonymizer.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{46407B6E-B03E-4146-9139-F1EDF4AEB15B}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FhirPseudonymizer", "src\FhirPseudonymizer\FhirPseudonymizer.csproj", "{8B0AA303-35F3-4D4B-904F-58296B98D15E}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FhirPseudonymizer.Tests", "src\FhirPseudonymizer.Tests\FhirPseudonymizer.Tests.csproj", "{963E71CF-4D0C-43E3-B7A1-152538D40389}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FhirPseudonymizer.StressTests", "src\FhirPseudonymizer.StressTests\FhirPseudonymizer.StressTests.csproj", "{52DDDF0D-51E6-4863-AA1A-052454AB5A43}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Release|Any CPU = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {8B0AA303-35F3-4D4B-904F-58296B98D15E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {8B0AA303-35F3-4D4B-904F-58296B98D15E}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {8B0AA303-35F3-4D4B-904F-58296B98D15E}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {8B0AA303-35F3-4D4B-904F-58296B98D15E}.Release|Any CPU.Build.0 = Release|Any CPU 27 | {963E71CF-4D0C-43E3-B7A1-152538D40389}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {963E71CF-4D0C-43E3-B7A1-152538D40389}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {963E71CF-4D0C-43E3-B7A1-152538D40389}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {963E71CF-4D0C-43E3-B7A1-152538D40389}.Release|Any CPU.Build.0 = Release|Any CPU 31 | {52DDDF0D-51E6-4863-AA1A-052454AB5A43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 32 | {52DDDF0D-51E6-4863-AA1A-052454AB5A43}.Debug|Any CPU.Build.0 = Debug|Any CPU 33 | {52DDDF0D-51E6-4863-AA1A-052454AB5A43}.Release|Any CPU.ActiveCfg = Release|Any CPU 34 | {52DDDF0D-51E6-4863-AA1A-052454AB5A43}.Release|Any CPU.Build.0 = Release|Any CPU 35 | EndGlobalSection 36 | GlobalSection(NestedProjects) = preSolution 37 | {8B0AA303-35F3-4D4B-904F-58296B98D15E} = {46407B6E-B03E-4146-9139-F1EDF4AEB15B} 38 | {963E71CF-4D0C-43E3-B7A1-152538D40389} = {46407B6E-B03E-4146-9139-F1EDF4AEB15B} 39 | {52DDDF0D-51E6-4863-AA1A-052454AB5A43} = {46407B6E-B03E-4146-9139-F1EDF4AEB15B} 40 | EndGlobalSection 41 | EndGlobal 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) MIRACUM.org 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the most recent major version is regularly updated and receives security fixes. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Please use the project's [private vulnerability reporting feature](https://github.com/miracum/fhir-pseudonymizer/security/advisories) 10 | to report any vulnerabilities. For more information, see 11 | -------------------------------------------------------------------------------- /Taskfile.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | tasks: 4 | build-stress-test-image: 5 | desc: build the container image used for stress testing 6 | vars: 7 | BUILDKIT_PROGRESS: plain 8 | cmds: 9 | - docker build -t ghcr.io/miracum/fhir-pseudonymizer/stress-test:v1 --target=stress-test --iidfile=./stress-test-iid.txt . 10 | sources: 11 | - src/FhirPseudonymizer.StressTests/*.cs 12 | generates: 13 | - ./stress-test-iid.txt 14 | 15 | install-argo-cli: 16 | desc: download and install the argo workflows cli 17 | dir: tests/chaos 18 | cmds: 19 | - curl -sL -o - https://github.com/argoproj/argo-workflows/releases/download/v3.5.2/argo-linux-amd64.gz | gunzip > argo 20 | - chmod +x ./argo 21 | - ./argo version 22 | status: 23 | - ./argo version 24 | 25 | create-cluster: 26 | desc: create a KinD cluster 27 | cmds: 28 | - kind create cluster --name kind 29 | status: 30 | - kind get clusters | grep kind 31 | 32 | chaos-test: 33 | desc: run the chaos testing workflow 34 | dir: tests/chaos 35 | deps: 36 | - create-cluster 37 | - install-argo-cli 38 | - build-stress-test-image 39 | cmds: 40 | - helm repo add chaos-mesh https://charts.chaos-mesh.org 41 | - kind load docker-image ghcr.io/miracum/fhir-pseudonymizer/stress-test:v1 42 | - helm upgrade --install --create-namespace -n fhir-pseudonymizer -f fhir-pseudonymizer-values.yaml --wait fhir-pseudonymizer oci://ghcr.io/miracum/charts/fhir-pseudonymizer 43 | - helm upgrade --install chaos-mesh chaos-mesh/chaos-mesh --create-namespace --wait -n chaos-mesh --set chaosDaemon.runtime=containerd --set chaosDaemon.socketPath='/run/containerd/containerd.sock' 44 | - kubectl apply -f chaos-mesh-rbac.yaml 45 | - helm upgrade --install --create-namespace -n argo-workflows -f argo-workflows-values.yaml --wait argo-workflows oci://ghcr.io/argoproj/argo-helm/argo-workflows 46 | - ./argo submit workflow.yaml -n fhir-pseudonymizer --wait --log 47 | -------------------------------------------------------------------------------- /benchmark/bombardier.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RESOURCE_PATH=${RESOURCE_PATH:-bundle.json} 4 | 5 | bombardier -f "${RESOURCE_PATH}" \ 6 | --timeout=10s \ 7 | -H "Content-Type:application/fhir+json" \ 8 | -m POST \ 9 | -d 60s \ 10 | -l \ 11 | "http://localhost:5000/fhir/\$de-identify" 12 | -------------------------------------------------------------------------------- /benchmark/observation.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceType": "Observation", 3 | "id": "o001", 4 | "identifier": [ 5 | { 6 | "use": "official", 7 | "system": "http://www.bmc.nl/zorgportal/identifiers/observations", 8 | "value": "6323" 9 | }, 10 | { 11 | "use": "official", 12 | "system": "http://www.bmc.nl/zorgportal/identifiers/observations2", 13 | "value": "6323" 14 | } 15 | ], 16 | "status": "final", 17 | "code": { 18 | "coding": [ 19 | { 20 | "system": "http://loinc.org", 21 | "code": "15074-8", 22 | "display": "Glucose [Moles/volume] in Blood" 23 | } 24 | ] 25 | }, 26 | "subject": { 27 | "type": "Patient", 28 | "identifier": { 29 | "system": "http://example.com/id", 30 | "value": "f1" 31 | } 32 | }, 33 | "effectivePeriod": { 34 | "start": "2013-04-02T09:30:10+01:00" 35 | }, 36 | "issued": "2013-04-03T15:30:10+01:00", 37 | "performer": [ 38 | { 39 | "reference": "Practitioner/f005", 40 | "display": "A. Langeveld" 41 | } 42 | ], 43 | "valueQuantity": { 44 | "value": 6.3, 45 | "unit": "mmol/l", 46 | "system": "http://unitsofmeasure.org", 47 | "code": "mmol/L" 48 | }, 49 | "interpretation": [ 50 | { 51 | "coding": [ 52 | { 53 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", 54 | "code": "H", 55 | "display": "High" 56 | } 57 | ] 58 | } 59 | ], 60 | "referenceRange": [ 61 | { 62 | "low": { 63 | "value": 3.1, 64 | "unit": "mmol/l", 65 | "system": "http://unitsofmeasure.org", 66 | "code": "mmol/L" 67 | }, 68 | "high": { 69 | "value": 6.2, 70 | "unit": "mmol/l", 71 | "system": "http://unitsofmeasure.org", 72 | "code": "mmol/L" 73 | } 74 | } 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /compose.dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | jaeger: 3 | image: docker.io/jaegertracing/all-in-one:1.60@sha256:4fd2d70fa347d6a47e79fcb06b1c177e6079f92cba88b083153d56263082135e 4 | restart: unless-stopped 5 | deploy: 6 | resources: 7 | limits: 8 | memory: 2g 9 | cpus: "1" 10 | reservations: 11 | memory: 1g 12 | cpus: "1" 13 | cap_drop: 14 | - ALL 15 | ipc: none 16 | security_opt: 17 | - "no-new-privileges:true" 18 | privileged: false 19 | ports: 20 | - "6831:6831/udp" 21 | - "127.0.0.1:16686:16686" 22 | 23 | vfps-db: 24 | image: docker.io/library/postgres:17.5@sha256:6efd0df010dc3cb40d5e33e3ef84acecc5e73161bd3df06029ee8698e5e12c60 25 | restart: unless-stopped 26 | deploy: 27 | resources: 28 | limits: 29 | memory: 1g 30 | cpus: "1" 31 | reservations: 32 | memory: 1g 33 | cpus: "1" 34 | ipc: private 35 | security_opt: 36 | - "no-new-privileges:true" 37 | privileged: false 38 | environment: 39 | # kics-scan ignore-line 40 | POSTGRES_PASSWORD: postgres 41 | POSTGRES_DB: vfps 42 | 43 | vfps: 44 | image: ghcr.io/miracum/vfps:v1.3.6@sha256:21f45ea0c6f9b08d672b3e8529720b65340183c21609e13f056be25325d50be8 45 | restart: unless-stopped 46 | deploy: 47 | resources: 48 | limits: 49 | memory: 512m 50 | cpus: "2" 51 | reservations: 52 | memory: 512m 53 | cpus: "2" 54 | ipc: none 55 | cap_drop: 56 | - ALL 57 | read_only: true 58 | privileged: false 59 | security_opt: 60 | - "no-new-privileges:true" 61 | environment: 62 | COMPlus_EnableDiagnostics: "0" 63 | ForceRunDatabaseMigrations: "true" 64 | ConnectionStrings__PostgreSQL: "Host=vfps-db:5432;Database=vfps;Timeout=60;Max Auto Prepare=5;Application Name=vfps;Maximum Pool Size=50;" 65 | PGUSER: postgres 66 | # kics-scan ignore-line 67 | PGPASSWORD: postgres 68 | Tracing__IsEnabled: "true" 69 | Tracing__Jaeger__AgentHost: "jaeger" 70 | Pseudonymization__Caching__Namespaces__IsEnabled: "true" 71 | depends_on: 72 | - vfps-db 73 | ports: 74 | # Http1, Http2, Http3 75 | - "127.0.0.1:8080:8080" 76 | # Http2-only for plaintext gRPC 77 | - "127.0.0.1:8081:8081" 78 | 79 | gpas-entici-mock: 80 | image: docker.io/mockserver/mockserver:5.15.0@sha256:0f9ef78c94894ac3e70135d156193b25e23872575d58e2228344964273b4af6b 81 | ipc: none 82 | security_opt: 83 | - "no-new-privileges:true" 84 | cap_drop: 85 | - ALL 86 | privileged: false 87 | deploy: 88 | resources: 89 | limits: 90 | memory: 512m 91 | cpus: "1" 92 | reservations: 93 | memory: 512m 94 | cpus: "1" 95 | environment: 96 | MOCKSERVER_INITIALIZATION_JSON_PATH: /config/initializer.json 97 | MOCKSERVER_WATCH_INITIALIZATION_JSON: "true" 98 | ports: 99 | - 127.0.0.1:1080:1080 100 | volumes: 101 | - ./hack/mocks:/config:ro 102 | 103 | keycloak: 104 | image: quay.io/keycloak/keycloak:26.2.4@sha256:4a81762677f8911c6266e3dea57a2d78fa17bd63408debbf23afd8cc46fe256e 105 | restart: unless-stopped 106 | profiles: 107 | - keycloak 108 | ipc: none 109 | security_opt: 110 | - "no-new-privileges:true" 111 | cap_drop: 112 | - ALL 113 | privileged: false 114 | deploy: 115 | resources: 116 | limits: 117 | memory: 2g 118 | cpus: "1" 119 | reservations: 120 | memory: 2g 121 | cpus: "1" 122 | command: 123 | - start-dev 124 | - --import-realm 125 | environment: 126 | KEYCLOAK_ADMIN: admin 127 | # kics-scan ignore-line 128 | KEYCLOAK_ADMIN_PASSWORD: admin 129 | volumes: 130 | - type: bind 131 | # /opt/keycloak/bin/kc.sh export --file /tmp/fhir-pseudonymizer-test-realm-export.json --realm fhir-pseudonymizer-test 132 | source: ./hack/keycloak/fhir-pseudonymizer-test-realm-export.json 133 | target: /opt/keycloak/data/import/fhir-pseudonymizer-test-realm-export.json 134 | read_only: true 135 | ports: 136 | - "127.0.0.1:8083:8080" 137 | -------------------------------------------------------------------------------- /compose/README.md: -------------------------------------------------------------------------------- 1 | # Deploy the FHIR Pseudonymizer using Compose 2 | 3 | This uses an example anonymization config based on the [HIPAA Safe Harbor rules](anonymization-hipaa.yaml): 4 | 5 | ```sh 6 | docker compose up 7 | # or 8 | nerdctl compose up 9 | # or 10 | podman-compose up 11 | ``` 12 | 13 | Open your browser at . Or simply POST any FHIR resource to . 14 | -------------------------------------------------------------------------------- /compose/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | fhir-pseudonymizer: 3 | image: ghcr.io/miracum/fhir-pseudonymizer:v2.22.10 # x-release-please-version 4 | restart: unless-stopped 5 | cap_drop: 6 | - ALL 7 | ipc: none 8 | security_opt: 9 | - "no-new-privileges:true" 10 | read_only: true 11 | privileged: false 12 | environment: 13 | DOTNET_EnableDiagnostics: "0" 14 | PseudonymizationService: "None" 15 | AnonymizationEngineConfigPath: "/opt/fhir-pseudonymizer/anonymization-hipaa.yaml" 16 | UseSystemTextJsonFhirSerializer: "true" 17 | volumes: 18 | - "./anonymization-hipaa.yaml:/opt/fhir-pseudonymizer/anonymization-hipaa.yaml:ro" 19 | ports: 20 | - "127.0.0.1:8080:8080" 21 | -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miracum/fhir-pseudonymizer/83ab36c7f2fe4add39cf7fb7e706a4a32227b3e3/docs/img/logo.png -------------------------------------------------------------------------------- /docs/img/openapi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miracum/fhir-pseudonymizer/83ab36c7f2fe4add39cf7fb7e706a4a32227b3e3/docs/img/openapi.png -------------------------------------------------------------------------------- /hack/mocks/README.md: -------------------------------------------------------------------------------- 1 | # Generating MockServer's initialization config 2 | 3 | Because it's easier to read, the initializers are managed as YAML and converted to JSON 4 | for MockServer. 5 | 6 | Run: 7 | 8 | ```sh 9 | yq -o json hack/mocks/initializer.yaml > hack/mocks/initializer.json 10 | ``` 11 | 12 | to convert. 13 | -------------------------------------------------------------------------------- /hack/mocks/initializer.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "gpas-pseudonymize", 4 | "httpRequest": { 5 | "method": "POST", 6 | "path": "/ttp-fhir/fhir/gpas/$pseudonymizeAllowCreate" 7 | }, 8 | "httpResponseTemplate": { 9 | "templateType": "VELOCITY", 10 | "template": "{\n \"body\": {\n \"resourceType\": \"Parameters\",\n \"parameter\": [\n {\n \"name\": \"pseudonym\",\n \"part\": [\n {\n \"name\": \"original\",\n \"valueIdentifier\": {\n \"system\": \"https://ths-greifswald.de/gpas\",\n \"value\": \"test\"\n }\n },\n {\n \"name\": \"target\",\n \"valueIdentifier\": {\n \"system\": \"https://ths-greifswald.de/gpas\",\n \"value\": \"benchmark\"\n }\n },\n {\n \"name\": \"pseudonym\",\n \"valueIdentifier\": {\n \"system\": \"https://ths-greifswald.de/gpas\",\n #set($jsonBody = $json.parse($!request.body))\n #set($originalValue = \"\")\n #foreach($parameter in $jsonBody.parameter)\n #if($parameter.name == 'original')\n #set($originalValue = $parameter.valueString)\n #end\n #end\n \"value\": \"pseuded-$originalValue\"\n }\n }\n ]\n }\n ]\n }\n}\n" 11 | } 12 | }, 13 | { 14 | "id": "entici-pseudonymize", 15 | "httpRequest": { 16 | "method": "POST", 17 | "path": "/entici/$pseudonymize" 18 | }, 19 | "httpResponseTemplate": { 20 | "templateType": "VELOCITY", 21 | "template": "{\n \"body\": {\n \"resourceType\": \"Parameters\",\n \"parameter\": [\n {\n \"name\": \"pseudonym\",\n \"valueIdentifier\": {\n \"use\": \"secondary\",\n \"system\": \"urn:fdc:difuture.de:trustcenter.plain\",\n #set($jsonBody = $json.parse($!request.body))\n #set($originalValue = \"\")\n #foreach($parameter in $jsonBody.parameter)\n #if($parameter.name == 'identifier')\n #set($originalValue = $parameter.valueIdentifier.value)\n #end\n #end\n \"value\": \"pseuded-$originalValue\"\n }\n }\n ]\n }\n}\n" 22 | } 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /hack/mocks/initializer.yaml: -------------------------------------------------------------------------------- 1 | - id: gpas-pseudonymize 2 | httpRequest: 3 | method: POST 4 | path: /ttp-fhir/fhir/gpas/$pseudonymizeAllowCreate 5 | httpResponseTemplate: 6 | templateType: VELOCITY 7 | template: | 8 | { 9 | "body": { 10 | "resourceType": "Parameters", 11 | "parameter": [ 12 | { 13 | "name": "pseudonym", 14 | "part": [ 15 | { 16 | "name": "original", 17 | "valueIdentifier": { 18 | "system": "https://ths-greifswald.de/gpas", 19 | "value": "test" 20 | } 21 | }, 22 | { 23 | "name": "target", 24 | "valueIdentifier": { 25 | "system": "https://ths-greifswald.de/gpas", 26 | "value": "benchmark" 27 | } 28 | }, 29 | { 30 | "name": "pseudonym", 31 | "valueIdentifier": { 32 | "system": "https://ths-greifswald.de/gpas", 33 | #set($jsonBody = $json.parse($!request.body)) 34 | #set($originalValue = "") 35 | #foreach($parameter in $jsonBody.parameter) 36 | #if($parameter.name == 'original') 37 | #set($originalValue = $parameter.valueString) 38 | #end 39 | #end 40 | "value": "pseuded-$originalValue" 41 | } 42 | } 43 | ] 44 | } 45 | ] 46 | } 47 | } 48 | - id: entici-pseudonymize 49 | httpRequest: 50 | method: POST 51 | path: /entici/$pseudonymize 52 | httpResponseTemplate: 53 | templateType: VELOCITY 54 | template: | 55 | { 56 | "body": { 57 | "resourceType": "Parameters", 58 | "parameter": [ 59 | { 60 | "name": "pseudonym", 61 | "valueIdentifier": { 62 | "use": "secondary", 63 | "system": "urn:fdc:difuture.de:trustcenter.plain", 64 | #set($jsonBody = $json.parse($!request.body)) 65 | #set($originalValue = "") 66 | #foreach($parameter in $jsonBody.parameter) 67 | #if($parameter.name == 'identifier') 68 | #set($originalValue = $parameter.valueIdentifier.value) 69 | #end 70 | #end 71 | "value": "pseuded-$originalValue" 72 | } 73 | } 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/refs/heads/main/schemas/config.json", 3 | "bump-minor-pre-major": true, 4 | "bump-patch-for-minor-pre-major": true, 5 | "include-v-in-tag": true, 6 | "separate-pull-requests": true, 7 | "extra-label": "release-please", 8 | "release-type": "simple", 9 | "packages": { 10 | ".": { 11 | "release-type": "simple", 12 | "bump-minor-pre-major": true, 13 | "bump-patch-for-minor-pre-major": true, 14 | "changelog-sections": [ 15 | { "type": "feat", "section": "Features" }, 16 | { "type": "fix", "section": "Bug Fixes" }, 17 | { "type": "perf", "section": "Performance Improvements" }, 18 | { "type": "docs", "section": "Documentation", "hidden": false }, 19 | { 20 | "type": "chore", 21 | "section": "Miscellaneous Chores", 22 | "hidden": false 23 | }, 24 | { "type": "build", "section": "Build", "hidden": false }, 25 | { "type": "ci", "section": "CI/CD", "hidden": false } 26 | ], 27 | "extra-files": [ 28 | { 29 | "type": "generic", 30 | "path": "src/Directory.Build.props" 31 | }, 32 | { 33 | "type": "generic", 34 | "path": "compose/compose.yaml" 35 | }, 36 | { 37 | "type": "generic", 38 | "path": "README.md" 39 | }, 40 | { 41 | "type": "generic", 42 | "path": "tests/chaos/fhir-pseudonymizer-values.yaml" 43 | } 44 | ] 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | latest 5 | 6 | enable 7 | latest 8 | true 9 | false 10 | miracum.org 11 | A REST service to pseudonymize and anonymize FHIR resources. 12 | © miracum.org. All rights reserved. 13 | en-US 14 | miracum.org 15 | 16 | 2.22.10 17 | 18 | 19 | 20 | true 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.StressTests/FhirPseudonymizer.StressTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | false 4 | ..\..\stylecop.ruleset.xml 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | all 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.StressTests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using FluentAssertions; 2 | global using NBomber.Contracts; 3 | global using NBomber.CSharp; 4 | global using Xunit; 5 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/CryptoHashProcessorTests.cs: -------------------------------------------------------------------------------- 1 | using Hl7.Fhir.ElementModel; 2 | using Hl7.Fhir.FhirPath; 3 | using Hl7.Fhir.Model; 4 | using Microsoft.Health.Fhir.Anonymizer.Core.Extensions; 5 | using Microsoft.Health.Fhir.Anonymizer.Core.Processors; 6 | 7 | namespace FhirPseudonymizer.Tests; 8 | 9 | public class CryptoHashProcessorTests 10 | { 11 | public static IEnumerable GetProcessData() 12 | { 13 | yield return new object[] 14 | { 15 | new FhirString("12345"), 16 | "098fe201710ca56e73dfb56cb0c610a66900add818c6d625b44b91eaafe79022", 17 | }; 18 | yield return new object[] 19 | { 20 | new ResourceReference("Patient/12345"), 21 | "Patient/098fe201710ca56e73dfb56cb0c610a66900add818c6d625b44b91eaafe79022", 22 | }; 23 | yield return new object[] 24 | { 25 | new FhirUri("Patient/12345"), 26 | "Patient/098fe201710ca56e73dfb56cb0c610a66900add818c6d625b44b91eaafe79022", 27 | }; 28 | } 29 | 30 | public static IEnumerable GetTruncatedProcessData() 31 | { 32 | yield return new object[] { new FhirString("12345"), "098fe201710ca56e73dfb56cb0c610a6" }; 33 | yield return new object[] 34 | { 35 | new ResourceReference("Patient/12345"), 36 | "Patient/098fe201710ca56e73dfb56cb0c610a6", 37 | }; 38 | yield return new object[] 39 | { 40 | new FhirUri("Patient/12345"), 41 | "Patient/098fe201710ca56e73dfb56cb0c610a6", 42 | }; 43 | } 44 | 45 | [Theory] 46 | [MemberData(nameof(GetProcessData))] 47 | public void Process_HashesIdPart(DataType element, string expected) 48 | { 49 | var processor = new CryptoHashProcessor("test"); 50 | 51 | var node = ElementNode.FromElement(element.ToTypedElement()); 52 | while (!node.HasValue()) 53 | { 54 | node = node.Children().CastElementNodes().First(); 55 | } 56 | 57 | processor.Process(node); 58 | 59 | node.Value.ToString().Should().Be(expected); 60 | } 61 | 62 | [Theory] 63 | [MemberData(nameof(GetTruncatedProcessData))] 64 | public void Process_WithTruncatedHashLengthSet_HashHasMaxLength( 65 | DataType element, 66 | string expected 67 | ) 68 | { 69 | var processor = new CryptoHashProcessor("test"); 70 | 71 | var node = ElementNode.FromElement(element.ToTypedElement()); 72 | while (!node.HasValue()) 73 | { 74 | node = node.Children().CastElementNodes().First(); 75 | } 76 | 77 | processor.Process( 78 | node, 79 | settings: new Dictionary() { { "truncateToMaxLength", 32 } } 80 | ); 81 | 82 | node.Value.ToString().Should().Be(expected); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/FhirControllerTests.cs: -------------------------------------------------------------------------------- 1 | using FhirPseudonymizer.Config; 2 | using FhirPseudonymizer.Controllers; 3 | using Hl7.Fhir.Model; 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Health.Fhir.Anonymizer.Core; 7 | using Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations; 8 | 9 | namespace FhirPseudonymizer.Tests; 10 | 11 | public class FhirControllerTests 12 | { 13 | [Fact] 14 | public void DeIdentify_ParsesDynamicSettings() 15 | { 16 | const string domainPrefix = "domain-prefix"; 17 | var domainPrefixValue = new FhirString("test-"); 18 | Dictionary ruleSettings = null; 19 | 20 | var anonymizer = A.Fake(); 21 | A.CallTo(() => anonymizer.AnonymizeResource(A._, A._)) 22 | .Invokes((Resource _, AnonymizerSettings s) => ruleSettings = s?.DynamicRuleSettings); 23 | 24 | var controller = new FhirController( 25 | A.Fake(), 26 | A.Fake>(), 27 | anonymizer, 28 | A.Fake() 29 | ); 30 | 31 | var parameters = new Parameters() 32 | .Add("settings", new[] { Tuple.Create(domainPrefix, domainPrefixValue) }) 33 | .Add("resource", new Patient()); 34 | 35 | controller.DeIdentify(parameters); 36 | 37 | ruleSettings.Should().ContainKey(domainPrefix).WhoseValue.Should().Be(domainPrefixValue); 38 | } 39 | 40 | [Fact] 41 | public void DeIdentify_WithExceptionThrownInAnonymizer_ShouldReturnInternalError() 42 | { 43 | var anonymizer = A.Fake(); 44 | A.CallTo(() => anonymizer.AnonymizeResource(A._, A._)) 45 | .Throws(new Exception("something went wrong")); 46 | 47 | var controller = new FhirController( 48 | A.Fake(), 49 | A.Fake>(), 50 | anonymizer, 51 | A.Fake() 52 | ); 53 | 54 | var response = controller.DeIdentify(new Bundle()); 55 | 56 | response.StatusCode.Should().Be(500); 57 | 58 | response.Value.Should().BeOfType(); 59 | } 60 | 61 | [Fact] 62 | public void DePseudonymize_WithExceptionThrownInDePseudonymizer_ShouldReturnInternalError() 63 | { 64 | var dePseudonymizer = A.Fake(); 65 | A.CallTo(() => 66 | dePseudonymizer.DePseudonymizeResource(A._, A._) 67 | ) 68 | .Throws(new Exception("something went wrong")); 69 | 70 | var controller = new FhirController( 71 | A.Fake(), 72 | A.Fake>(), 73 | A.Fake(), 74 | dePseudonymizer 75 | ); 76 | 77 | var response = controller.DePseudonymize(new Bundle()); 78 | 79 | response.StatusCode.Should().Be(500); 80 | 81 | response.Value.Should().BeOfType(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/FhirPseudonymizer.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | false 4 | ..\..\stylecop.ruleset.xml 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | all 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Fixtures/Data/Configs/generalize-birth-date.yaml: -------------------------------------------------------------------------------- 1 | fhirVersion: R4 2 | fhirPathRules: 3 | - path: Patient.birthDate 4 | method: generalize 5 | cases: 6 | "$this": "$this.toString().replaceMatches('(?\\\\d{2,4})-(?\\\\d{2})-(?\\\\d{2})\\\\b', '${year}-${month}')" 7 | otherValues: redact 8 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Fixtures/Data/Configs/pseudonymization.yaml: -------------------------------------------------------------------------------- 1 | fhirVersion: R4 2 | fhirPathRules: 3 | - path: nodesByType('HumanName') 4 | method: redact 5 | - path: nodesByType('Identifier').where(type.coding.where(system='http://terminology.hl7.org/CodeSystem/v2-0203' and code='VN').exists()).value 6 | method: pseudonymize 7 | domain: visit-identifiers 8 | - path: nodesByType('Identifier').where(type.coding.where(system='http://terminology.hl7.org/CodeSystem/v2-0203' and code='MR').exists()).value 9 | method: pseudonymize 10 | domain: patient-identifiers 11 | parameters: 12 | dateShiftKey: "" 13 | dateShiftScope: resource 14 | cryptoHashKey: "secret" 15 | encryptKey: "" 16 | enablePartialAgesForRedact: true 17 | enablePartialDatesForRedact: true 18 | enablePartialZipCodesForRedact: true 19 | restrictedZipCodeTabulationAreas: [] 20 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Fixtures/Data/Configs/truncate-crypto-hashed-values.yaml: -------------------------------------------------------------------------------- 1 | fhirVersion: R4 2 | fhirPathRules: 3 | - path: Resource.id 4 | method: cryptoHash 5 | truncateToMaxLength: 16 6 | - path: nodesByType('Reference').reference 7 | method: cryptoHash 8 | truncateToMaxLength: 16 9 | - path: Bundle.entry.fullUrl 10 | method: cryptoHash 11 | truncateToMaxLength: 16 12 | - path: Bundle.entry.request.where(method = 'PUT').url 13 | method: cryptoHash 14 | truncateToMaxLength: 16 15 | parameters: 16 | cryptoHashKey: fhir-pseudonymizer 17 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Fixtures/Data/Configs/whitelist-resource-parts.yaml: -------------------------------------------------------------------------------- 1 | fhirVersion: R4 2 | fhirPathRules: 3 | - path: Resource.id 4 | method: keep 5 | - path: Patient.birthDate 6 | method: keep 7 | - path: Resource 8 | method: redact 9 | parameters: 10 | dateShiftKey: "" 11 | dateShiftScope: resource 12 | cryptoHashKey: fhir-pseudonymizer 13 | # must be of a valid AES key length; here the key is padded to 192 bits 14 | encryptKey: fhir-pseudonymizer000000 15 | enablePartialAgesForRedact: false 16 | enablePartialDatesForRedact: false 17 | enablePartialZipCodesForRedact: false 18 | restrictedZipCodeTabulationAreas: [] 19 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Fixtures/Data/Resources/patient.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceType": "Patient", 3 | "id": "example", 4 | "address": [ 5 | { 6 | "use": "home", 7 | "city": "PleasantVille", 8 | "type": "both", 9 | "state": "Vic", 10 | "line": ["534 Erewhon St"], 11 | "postalCode": "3999", 12 | "period": { 13 | "start": "1974-12-25" 14 | }, 15 | "district": "Rainbow", 16 | "text": "534 Erewhon St PeasantVille, Rainbow, Vic 3999" 17 | } 18 | ], 19 | "managingOrganization": { 20 | "reference": "Organization/1" 21 | }, 22 | "name": [ 23 | { 24 | "use": "official", 25 | "given": ["Peter", "James"], 26 | "family": "Chalmers" 27 | }, 28 | { 29 | "use": "usual", 30 | "given": ["Jim"] 31 | }, 32 | { 33 | "use": "maiden", 34 | "given": ["Peter", "James"], 35 | "family": "Windsor", 36 | "period": { 37 | "end": "2002" 38 | } 39 | } 40 | ], 41 | "birthDate": "1974-12-25", 42 | "deceasedBoolean": false, 43 | "active": true, 44 | "identifier": [ 45 | { 46 | "use": "usual", 47 | "type": { 48 | "coding": [ 49 | { 50 | "code": "MR", 51 | "system": "http://terminology.hl7.org/CodeSystem/v2-0203" 52 | } 53 | ] 54 | }, 55 | "value": "12345", 56 | "period": { 57 | "start": "2001-05-06" 58 | }, 59 | "system": "urn:oid:1.2.36.146.595.217.0.1", 60 | "assigner": { 61 | "display": "Acme Healthcare" 62 | } 63 | } 64 | ], 65 | "telecom": [ 66 | { 67 | "use": "home" 68 | }, 69 | { 70 | "use": "work", 71 | "rank": 1, 72 | "value": "(03) 5555 6473", 73 | "system": "phone" 74 | }, 75 | { 76 | "use": "mobile", 77 | "rank": 2, 78 | "value": "(03) 3410 5613", 79 | "system": "phone" 80 | }, 81 | { 82 | "use": "old", 83 | "value": "(03) 5555 8834", 84 | "period": { 85 | "end": "2014" 86 | }, 87 | "system": "phone" 88 | } 89 | ], 90 | "gender": "male", 91 | "contact": [ 92 | { 93 | "name": { 94 | "given": ["Bénédicte"], 95 | "family": "du Marché", 96 | "_family": { 97 | "extension": [ 98 | { 99 | "url": "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix", 100 | "valueString": "VV" 101 | } 102 | ] 103 | } 104 | }, 105 | "gender": "female", 106 | "period": { 107 | "start": "2012" 108 | }, 109 | "address": { 110 | "use": "home", 111 | "city": "PleasantVille", 112 | "line": ["534 Erewhon St"], 113 | "type": "both", 114 | "state": "Vic", 115 | "period": { 116 | "start": "1974-12-25" 117 | }, 118 | "district": "Rainbow", 119 | "postalCode": "3999" 120 | }, 121 | "telecom": [ 122 | { 123 | "value": "+33 (237) 998327", 124 | "system": "phone" 125 | } 126 | ], 127 | "relationship": [ 128 | { 129 | "coding": [ 130 | { 131 | "code": "N", 132 | "system": "http://hl7.org/fhir/v2/0131" 133 | } 134 | ] 135 | } 136 | ] 137 | } 138 | ] 139 | } 140 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Fixtures/MII-Pseudonymization/mii-patient.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceType": "Patient", 3 | "id": "mii-exa-person-patient-full", 4 | "meta": { 5 | "security": [ 6 | { 7 | "code": "HTEST", 8 | "system": "http://terminology.hl7.org/CodeSystem/v3-ActReason", 9 | "display": "test health data" 10 | } 11 | ], 12 | "profile": [ 13 | "https://www.medizininformatik-initiative.de/fhir/core/modul-person/StructureDefinition/Patient|2025.0.1" 14 | ] 15 | }, 16 | "name": [ 17 | { 18 | "use": "official", 19 | "family": "Van-der-Dussen", 20 | "_family": { 21 | "extension": [ 22 | { 23 | "url": "http://hl7.org/fhir/StructureDefinition/humanname-own-name", 24 | "valueString": "Van-der-Dussen" 25 | } 26 | ] 27 | }, 28 | "given": ["Maja", "Julia"], 29 | "prefix": ["Prof. Dr. med."], 30 | "_prefix": [ 31 | { 32 | "extension": [ 33 | { 34 | "url": "http://hl7.org/fhir/StructureDefinition/iso21090-EN-qualifier", 35 | "valueCode": "AC" 36 | } 37 | ] 38 | } 39 | ] 40 | }, 41 | { 42 | "use": "maiden", 43 | "family": "Haffer" 44 | } 45 | ], 46 | "identifier": [ 47 | { 48 | "use": "usual", 49 | "type": { 50 | "coding": [ 51 | { 52 | "code": "MR", 53 | "system": "http://terminology.hl7.org/CodeSystem/v2-0203" 54 | } 55 | ] 56 | }, 57 | "system": "https://www.charite.de/fhir/sid/patienten", 58 | "value": "42285243", 59 | "assigner": { 60 | "display": "Charité - Universitätsmedizin Berlin", 61 | "identifier": { 62 | "system": "https://www.medizininformatik-initiative.de/fhir/core/CodeSystem/core-location-identifier", 63 | "value": "Charité" 64 | } 65 | } 66 | }, 67 | { 68 | "use": "official", 69 | "type": { 70 | "coding": [ 71 | { 72 | "code": "KVZ10", 73 | "system": "http://fhir.de/CodeSystem/identifier-type-de-basis" 74 | } 75 | ] 76 | }, 77 | "system": "http://fhir.de/sid/gkv/kvid-10", 78 | "value": "Z234567890", 79 | "assigner": { 80 | "identifier": { 81 | "use": "official", 82 | "value": "109519005", 83 | "system": "http://fhir.de/sid/arge-ik/iknr" 84 | } 85 | } 86 | } 87 | ], 88 | "gender": "other", 89 | "_gender": { 90 | "extension": [ 91 | { 92 | "url": "http://fhir.de/StructureDefinition/gender-amtlich-de", 93 | "valueCoding": { 94 | "code": "D", 95 | "system": "http://fhir.de/CodeSystem/gender-amtlich-de", 96 | "display": "divers" 97 | } 98 | } 99 | ] 100 | }, 101 | "birthDate": "1998-09-19", 102 | "deceasedBoolean": false, 103 | "address": [ 104 | { 105 | "type": "both", 106 | "line": ["Anna-Louisa-Karsch Str. 2"], 107 | "city": "Berlin", 108 | "_city": { 109 | "extension": [ 110 | { 111 | "url": "http://fhir.de/StructureDefinition/destatis/ags", 112 | "valueCoding": { 113 | "code": "11000000", 114 | "system": "http://fhir.de/sid/destatis/ags" 115 | } 116 | } 117 | ] 118 | }, 119 | "state": "DE-BE", 120 | "postalCode": "10178", 121 | "country": "DE" 122 | } 123 | ], 124 | "managingOrganization": { 125 | "reference": "Organization/Charite-Universitaetsmedizin-Berlin" 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Fixtures/MII-Pseudonymization/patient-to-pseuded.yaml: -------------------------------------------------------------------------------- 1 | fhirVersion: R4 2 | fhirPathRules: 3 | - path: Patient.id 4 | method: cryptoHash 5 | - path: Patient.meta.profile.where($this='https://www.medizininformatik-initiative.de/fhir/core/modul-person/StructureDefinition/Patient|2025.0.1') 6 | method: substitute 7 | replaceWith: "https://www.medizininformatik-initiative.de/fhir/core/modul-person/StructureDefinition/PatientPseudonymisiert|2025.0.1" 8 | - path: nodesByType('Identifier').where(type.coding.system='http://terminology.hl7.org/CodeSystem/v2-0203' and type.coding.code='MR').system 9 | method: substitute 10 | replaceWith: https://www.charite.de/fhir/sid/patienten-pseudonymisiert 11 | - path: nodesByType('Identifier').where(type.coding.system='http://terminology.hl7.org/CodeSystem/v2-0203' and type.coding.code='MR').assigner 12 | method: keep 13 | - path: nodesByType('Identifier').where(type.coding.where(system='http://terminology.hl7.org/CodeSystem/v2-0203' and code='MR').exists()).value 14 | method: pseudonymize 15 | domain: patient-identifiers 16 | - path: nodesByType('Identifier').type.where(coding.system='http://terminology.hl7.org/CodeSystem/v2-0203' and coding.code='MR') 17 | method: substitute 18 | replaceWith: | 19 | { 20 | "coding": [ 21 | { 22 | "system": "http://terminology.hl7.org/CodeSystem/v2-0203", 23 | "code": "MR", 24 | "display": "Medical Record Number" 25 | }, 26 | { 27 | "code": "PSEUDED", 28 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 29 | "display": "pseudonymized" 30 | } 31 | ], 32 | "text": "Pseudonymized Medical Record Number" 33 | } 34 | - path: Patient.birthDate 35 | method: generalize 36 | # currently, only generalizes to the year and avoids introducing possibly misleading rounded quarter dates. 37 | cases: 38 | "$this": "$this.toString().replaceMatches('(?\\\\d{2,4})-(?\\\\d{2})-(?\\\\d{2})\\\\b', '${year}')" 39 | otherValues: redact 40 | - path: Patient.address.postalCode 41 | method: generalize 42 | cases: 43 | "$this": "$this.toString().substring(0,2)" 44 | otherValues: redact 45 | - path: Patient.address.country 46 | method: keep 47 | - path: Patient.gender 48 | method: keep 49 | - path: Patient.deceased 50 | method: keep 51 | - path: Resource 52 | method: redact 53 | parameters: 54 | cryptoHashKey: fhir-pseudonymizer 55 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Pseudonymization/GPasPseudonymizationProcessorTests.cs: -------------------------------------------------------------------------------- 1 | using FhirPseudonymizer.Config; 2 | using FhirPseudonymizer.Pseudonymization; 3 | using Hl7.Fhir.ElementModel; 4 | using Hl7.Fhir.FhirPath; 5 | using Hl7.Fhir.Model; 6 | using Microsoft.Health.Fhir.Anonymizer.Core.Extensions; 7 | 8 | namespace FhirPseudonymizer.Tests.Pseudonymization; 9 | 10 | public class GPasPseudonymizationProcessorTests 11 | { 12 | public static IEnumerable GetProcessData() 13 | { 14 | foreach (var enableConditionalReferencePseudonymization in new[] { true, false }) 15 | { 16 | yield return new object[] 17 | { 18 | "foo-", 19 | "bar", 20 | new FhirString("12345"), 21 | "foo-bar", 22 | enableConditionalReferencePseudonymization, 23 | }; 24 | yield return new object[] 25 | { 26 | null, 27 | "bar", 28 | new FhirString("12345"), 29 | "bar", 30 | enableConditionalReferencePseudonymization, 31 | }; 32 | yield return new object[] 33 | { 34 | "foo-", 35 | null, 36 | new ResourceReference("Patient/12345"), 37 | "foo-Patient", 38 | enableConditionalReferencePseudonymization, 39 | }; 40 | yield return new object[] 41 | { 42 | null, 43 | null, 44 | new ResourceReference("Patient/12345"), 45 | "Patient", 46 | enableConditionalReferencePseudonymization, 47 | }; 48 | } 49 | } 50 | 51 | [Theory] 52 | [MemberData(nameof(GetProcessData))] 53 | public void Process_SupportsDomainPrefixSetting( 54 | string domainPrefix, 55 | string domainName, 56 | DataType element, 57 | string expectedDomain, 58 | bool enableConditionalReferencePseudonymization 59 | ) 60 | { 61 | var features = new FeatureManagement() 62 | { 63 | ConditionalReferencePseudonymization = enableConditionalReferencePseudonymization, 64 | }; 65 | var psnClient = A.Fake(); 66 | var processor = new PseudonymizationProcessor(psnClient, features); 67 | 68 | var node = ElementNode.FromElement(element.ToTypedElement()); 69 | while (!node.HasValue()) 70 | { 71 | node = node.Children().CastElementNodes().First(); 72 | } 73 | 74 | processor.Process( 75 | node, 76 | null, 77 | new Dictionary 78 | { 79 | { "domain", domainName }, 80 | { "domain-prefix", domainPrefix }, 81 | } 82 | ); 83 | 84 | A.CallTo(() => 85 | psnClient.GetOrCreatePseudonymFor( 86 | A._, 87 | expectedDomain, 88 | A>._ 89 | ) 90 | ) 91 | .MustHaveHappenedOnceExactly(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Pseudonymization/NoopPseudonymServiceClientTests.cs: -------------------------------------------------------------------------------- 1 | using FhirPseudonymizer.Pseudonymization; 2 | 3 | namespace FhirPseudonymizer.Tests.Pseudonymization; 4 | 5 | public class NoopPseudonymServiceClientTests 6 | { 7 | [Fact] 8 | public void GetOrCreatePseudonymFor_WithAnyOriginalValue_ShouldAlwaysThrow() 9 | { 10 | var client = new NoopPseudonymServiceClient(); 11 | 12 | Action act = () => client.GetOrCreatePseudonymFor("something", "somewhere"); 13 | 14 | act.Should().Throw(); 15 | } 16 | 17 | [Fact] 18 | public void GetOriginalValueFor_WithAnyPseudonym_ShouldAlwaysThrow() 19 | { 20 | var client = new NoopPseudonymServiceClient(); 21 | 22 | Action act = () => client.GetOriginalValueFor("something", "somewhere"); 23 | 24 | act.Should().Throw(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Pseudonymization/VfpsPseudonymServiceClientTests.cs: -------------------------------------------------------------------------------- 1 | using FhirPseudonymizer.Pseudonymization.Vfps; 2 | using Grpc.Core; 3 | using Microsoft.Extensions.Logging; 4 | using Vfps.Protos; 5 | 6 | namespace FhirPseudonymizer.Tests.Pseudonymization; 7 | 8 | public class VfpsPseudonymServiceClientTests 9 | { 10 | [Fact] 11 | public async Task GetOrCreatePseudonymFor_WithGivenOriginalValue_ShouldReturnPseudonym() 12 | { 13 | // Arrange 14 | var client = A.Fake(); 15 | 16 | var fakeResponse = new PseudonymServiceCreateResponse 17 | { 18 | Pseudonym = new() { PseudonymValue = "not test" }, 19 | }; 20 | 21 | A.CallTo(() => client.CreateAsync(A._, null, null, default)) 22 | .Returns( 23 | new AsyncUnaryCall( 24 | Task.FromResult(fakeResponse), 25 | null, 26 | null, 27 | null, 28 | null 29 | ) 30 | ); 31 | 32 | var sut = new VfpsPseudonymServiceClient( 33 | A.Fake>(), 34 | client 35 | ); 36 | 37 | // Act 38 | var result = await sut.GetOrCreatePseudonymFor("test", "namespace"); 39 | 40 | // Assert 41 | result.Should().Be("not test"); 42 | } 43 | 44 | [Fact] 45 | public async Task GetOriginalValueFor_WithGivenPseudonym_ShouldReturnOriginalValue() 46 | { 47 | // Arrange 48 | var client = A.Fake(); 49 | 50 | var fakeResponse = new PseudonymServiceGetResponse 51 | { 52 | Pseudonym = new() { PseudonymValue = "not test", OriginalValue = "test" }, 53 | }; 54 | 55 | A.CallTo(() => client.GetAsync(A._, null, null, default)) 56 | .Returns( 57 | new AsyncUnaryCall( 58 | Task.FromResult(fakeResponse), 59 | null, 60 | null, 61 | null, 62 | null 63 | ) 64 | ); 65 | 66 | var sut = new VfpsPseudonymServiceClient( 67 | A.Fake>(), 68 | client 69 | ); 70 | 71 | // Act 72 | var result = await sut.GetOriginalValueFor("not test", "namespace"); 73 | 74 | // Assert 75 | result.Should().Be("test"); 76 | } 77 | 78 | [Fact] 79 | public async Task GetOriginalValueFor_WithNonExistingPseudonym_ShouldReturnPseudonymValueInsteadOfOriginal() 80 | { 81 | // Arrange 82 | var client = A.Fake(); 83 | 84 | A.CallTo(() => client.GetAsync(A._, null, null, default)) 85 | .Throws(() => throw new RpcException(new Status(StatusCode.NotFound, "doesn't exist"))); 86 | 87 | var sut = new VfpsPseudonymServiceClient( 88 | A.Fake>(), 89 | client 90 | ); 91 | 92 | // Act 93 | var result = await sut.GetOriginalValueFor("test", "namespace"); 94 | 95 | // Assert 96 | result.Should().Be("test"); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/PseudonymizationServiceConfigurationTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Headers; 3 | using FhirPseudonymizer.Pseudonymization; 4 | 5 | namespace FhirPseudonymizer.Tests; 6 | 7 | public class PseudonymizationServiceConfigurationTests 8 | : IClassFixture> 9 | { 10 | private readonly CustomWebApplicationFactory factory; 11 | 12 | public PseudonymizationServiceConfigurationTests(CustomWebApplicationFactory factory) 13 | { 14 | this.factory = factory; 15 | } 16 | 17 | public class PseudonymizationServiceTestData : TheoryData 18 | { 19 | public PseudonymizationServiceTestData() 20 | { 21 | foreach (var backend in Enum.GetValues()) 22 | { 23 | Add(backend); 24 | } 25 | } 26 | } 27 | 28 | [Theory] 29 | [ClassData(typeof(PseudonymizationServiceTestData))] 30 | public async Task PostDeIdentify_WithConfiguredPseudonymizationService_ShouldSucceed( 31 | PseudonymizationServiceType serviceType 32 | ) 33 | { 34 | factory.CustomInMemorySettings = new Dictionary 35 | { 36 | ["PseudonymizationService"] = serviceType.ToString(), 37 | ["EnableMetrics"] = "false", 38 | }; 39 | 40 | var client = factory.CreateClient(); 41 | 42 | var patient = 43 | @"{ 44 | ""resourceType"": ""Patient"", 45 | ""id"": ""glossy"" 46 | }"; 47 | 48 | var content = new StringContent(patient); 49 | content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/fhir+json"); 50 | var response = await client.PostAsync("/fhir/$de-identify", content); 51 | 52 | response.StatusCode.Should().Be(HttpStatusCode.OK); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/ReferenceUtilityTests.cs: -------------------------------------------------------------------------------- 1 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Utility; 2 | 3 | public class ReferenceUtilityTests 4 | { 5 | [Theory] 6 | [InlineData("Patient/123")] 7 | [InlineData("Encounter?identifier=123")] 8 | [InlineData("Patient?identifier=http://fhir.test.de/sid/patient-id|123")] 9 | public void IsResourceReference_MatchesConditionalReferences(string uri) 10 | { 11 | Assert.True(ReferenceUtility.IsResourceReference(uri)); 12 | } 13 | 14 | [Theory] 15 | [InlineData("Patient/123")] 16 | [InlineData("Encounter?identifier=123")] 17 | [InlineData("Patient?identifier=http://fhir.test.de/sid/patient-id|123")] 18 | [InlineData("identifier=http://fhir.test.de/sid/patient-id|123")] 19 | public void TransformReferenceId_MatchesConditionalReferences(string uri) 20 | { 21 | Assert.Equal( 22 | ReferenceUtility.TransformReferenceId(uri, _ => "xxx"), 23 | uri.Replace("123", "xxx") 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/RequestCompressionTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO.Compression; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.Http; 8 | 9 | namespace FhirPseudonymizer.Tests; 10 | 11 | public class RequestCompressionTests 12 | { 13 | private readonly RequestCompression sut; 14 | 15 | public RequestCompressionTests() 16 | { 17 | RequestDelegate next = (HttpContext hc) => Task.CompletedTask; 18 | sut = new RequestCompression(next); 19 | } 20 | 21 | [Theory] 22 | [InlineData("gzip", typeof(GZipStream))] 23 | [InlineData("br", typeof(BrotliStream))] 24 | [InlineData("deflate", typeof(DeflateStream))] 25 | public async Task Invoke_WithHttpContextWithCompressionHeader_ShouldDecompressRequestBody( 26 | string contentEncoding, 27 | Type expectedBodyType 28 | ) 29 | { 30 | var ctx = new DefaultHttpContext(); 31 | ctx.Request.Headers["Content-Encoding"] = contentEncoding; 32 | 33 | await sut.Invoke(ctx); 34 | 35 | ctx.Request.Body.Should().BeOfType(expectedBodyType); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/SnapshotTests.cs: -------------------------------------------------------------------------------- 1 | using Hl7.Fhir.Model; 2 | using Hl7.Fhir.Rest; 3 | using Hl7.Fhir.Serialization; 4 | 5 | namespace FhirPseudonymizer.Tests; 6 | 7 | public class SnapshotTests 8 | { 9 | private static readonly FhirJsonParser FhirJsonParser = new(); 10 | 11 | public class SnapshotTestData : TheoryData 12 | { 13 | public SnapshotTestData() 14 | { 15 | // relative path are always awkward. We might instead copy the Fixtures/ folder 16 | // to the output directory instead. 17 | foreach (var configFile in Directory.EnumerateFiles("../../../Fixtures/Data/Configs")) 18 | { 19 | foreach ( 20 | var resourceFile in Directory.EnumerateFiles("../../../Fixtures/Data/Resources") 21 | ) 22 | { 23 | Add(configFile, resourceFile); 24 | } 25 | } 26 | } 27 | } 28 | 29 | [Theory] 30 | [ClassData(typeof(SnapshotTestData))] 31 | public async Task DeIdentify_WithGivenConfigAndResource_ShouldReturnResponseMatchingSnapshot( 32 | string anonymizationConfigFilePath, 33 | string resourcePath 34 | ) 35 | { 36 | var factory = new CustomWebApplicationFactory 37 | { 38 | CustomInMemorySettings = new Dictionary 39 | { 40 | ["AnonymizationEngineConfigPath"] = anonymizationConfigFilePath, 41 | ["EnableMetrics"] = "false", 42 | }, 43 | }; 44 | 45 | var client = factory.CreateClient(); 46 | 47 | var fhirClient = new FhirClient( 48 | "http://localhost/fhir", 49 | client, 50 | settings: new() { PreferredFormat = ResourceFormat.Json } 51 | ); 52 | 53 | var input = await FhirJsonParser.ParseAsync(File.ReadAllText(resourcePath)); 54 | var parameters = new Parameters().Add("resource", input); 55 | var response = await fhirClient.WholeSystemOperationAsync("de-identify", parameters); 56 | 57 | var json = response.ToJson(new() { Pretty = true }); 58 | 59 | var settings = new VerifySettings(); 60 | settings.UseDirectory("Snapshots"); 61 | settings.UseFileName( 62 | $"{Path.GetFileNameWithoutExtension(anonymizationConfigFilePath)}-{Path.GetFileNameWithoutExtension(resourcePath)}" 63 | ); 64 | 65 | await Verify(json, "json", settings); 66 | } 67 | 68 | [Theory] 69 | [InlineData( 70 | "../../../Fixtures/MII-Pseudonymization/patient-to-pseuded.yaml", 71 | "../../../Fixtures/MII-Pseudonymization/mii-patient.json" 72 | )] 73 | public async Task DeIdentify_WithGivenMIIPatient_ShouldReturnMIIPseudonymizedPatient( 74 | string anonymizationConfigFilePath, 75 | string resourcePath 76 | ) 77 | { 78 | await DeIdentify_WithGivenConfigAndResource_ShouldReturnResponseMatchingSnapshot( 79 | anonymizationConfigFilePath, 80 | resourcePath 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Snapshots/IntegrationTests.PostDeIdentify_WithCryptoHashKeySetViaAppSettingsConfig_ShouldCryptoHashValue.verified.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceType": "Bundle", 3 | "id": "88cd2108b5347d973cf39cdf9053d7dd42704876d8c9a9bd8e2d168259d3ddf7", 4 | "meta": { 5 | "security": [ 6 | { 7 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 8 | "code": "CRYTOHASH", 9 | "display": "cryptographic hash function" 10 | } 11 | ] 12 | }, 13 | "type": "batch", 14 | "entry": [ 15 | { 16 | "resource": { 17 | "resourceType": "Patient", 18 | "id": "8f46c4efd18605901786e3b736505654fd879f0b990f10605d346767022afeb9", 19 | "meta": { 20 | "security": [ 21 | { 22 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 23 | "code": "CRYTOHASH", 24 | "display": "cryptographic hash function" 25 | } 26 | ] 27 | }, 28 | "gender": "female", 29 | "birthDate": "1985-10-14" 30 | }, 31 | "request": { 32 | "method": "PUT", 33 | "url": "Patient/8f46c4efd18605901786e3b736505654fd879f0b990f10605d346767022afeb9" 34 | } 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Snapshots/IntegrationTests.PostDeIdentify_WithShouldAddSecurityTagSetToFalse_ShouldNotAddSecurityMetaDataToResult.verified.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceType": "Bundle", 3 | "type": "batch", 4 | "entry": [ 5 | { 6 | "resource": { 7 | "resourceType": "Patient", 8 | "gender": "female", 9 | "birthDate": "1985-10-14" 10 | }, 11 | "request": { 12 | "method": "PUT", 13 | "url": "Patient/8f46c4efd18605901786e3b736505654fd879f0b990f10605d346767022afeb9" 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Snapshots/generalize-birth-date-patient.verified.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceType": "Patient", 3 | "id": "example", 4 | "meta": { 5 | "security": [ 6 | { 7 | "code": "GENERALIZED", 8 | "display": "exact value is replaced with a general value" 9 | } 10 | ] 11 | }, 12 | "identifier": [ 13 | { 14 | "use": "usual", 15 | "type": { 16 | "coding": [ 17 | { 18 | "system": "http://terminology.hl7.org/CodeSystem/v2-0203", 19 | "code": "MR" 20 | } 21 | ] 22 | }, 23 | "system": "urn:oid:1.2.36.146.595.217.0.1", 24 | "value": "12345", 25 | "period": { 26 | "start": "2001-05-06" 27 | }, 28 | "assigner": { 29 | "display": "Acme Healthcare" 30 | } 31 | } 32 | ], 33 | "active": true, 34 | "name": [ 35 | { 36 | "use": "official", 37 | "family": "Chalmers", 38 | "given": [ 39 | "Peter", 40 | "James" 41 | ] 42 | }, 43 | { 44 | "use": "usual", 45 | "given": [ 46 | "Jim" 47 | ] 48 | }, 49 | { 50 | "use": "maiden", 51 | "family": "Windsor", 52 | "given": [ 53 | "Peter", 54 | "James" 55 | ], 56 | "period": { 57 | "end": "2002" 58 | } 59 | } 60 | ], 61 | "telecom": [ 62 | { 63 | "use": "home" 64 | }, 65 | { 66 | "system": "phone", 67 | "value": "(03) 5555 6473", 68 | "use": "work", 69 | "rank": 1 70 | }, 71 | { 72 | "system": "phone", 73 | "value": "(03) 3410 5613", 74 | "use": "mobile", 75 | "rank": 2 76 | }, 77 | { 78 | "system": "phone", 79 | "value": "(03) 5555 8834", 80 | "use": "old", 81 | "period": { 82 | "end": "2014" 83 | } 84 | } 85 | ], 86 | "gender": "male", 87 | "birthDate": "1974-12", 88 | "deceasedBoolean": false, 89 | "address": [ 90 | { 91 | "use": "home", 92 | "type": "both", 93 | "text": "534 Erewhon St PeasantVille, Rainbow, Vic 3999", 94 | "line": [ 95 | "534 Erewhon St" 96 | ], 97 | "city": "PleasantVille", 98 | "district": "Rainbow", 99 | "state": "Vic", 100 | "postalCode": "3999", 101 | "period": { 102 | "start": "1974-12-25" 103 | } 104 | } 105 | ], 106 | "contact": [ 107 | { 108 | "relationship": [ 109 | { 110 | "coding": [ 111 | { 112 | "system": "http://hl7.org/fhir/v2/0131", 113 | "code": "N" 114 | } 115 | ] 116 | } 117 | ], 118 | "name": { 119 | "family": "du Marché", 120 | "_family": { 121 | "extension": [ 122 | { 123 | "url": "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix", 124 | "valueString": "VV" 125 | } 126 | ] 127 | }, 128 | "given": [ 129 | "Bénédicte" 130 | ] 131 | }, 132 | "telecom": [ 133 | { 134 | "system": "phone", 135 | "value": "+33 (237) 998327" 136 | } 137 | ], 138 | "address": { 139 | "use": "home", 140 | "type": "both", 141 | "line": [ 142 | "534 Erewhon St" 143 | ], 144 | "city": "PleasantVille", 145 | "district": "Rainbow", 146 | "state": "Vic", 147 | "postalCode": "3999", 148 | "period": { 149 | "start": "1974-12-25" 150 | } 151 | }, 152 | "gender": "female", 153 | "period": { 154 | "start": "2012" 155 | } 156 | } 157 | ], 158 | "managingOrganization": { 159 | "reference": "Organization/1" 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Snapshots/hipaa-patient.verified.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceType": "Patient", 3 | "id": "a43d05b5c2dce2cef9e716ae2812722c49f5ec93f75c0f3b2a21c35f39c072ed", 4 | "meta": { 5 | "security": [ 6 | { 7 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 8 | "code": "REDACTED", 9 | "display": "redacted" 10 | }, 11 | { 12 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 13 | "code": "ABSTRED", 14 | "display": "abstracted" 15 | }, 16 | { 17 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 18 | "code": "CRYTOHASH", 19 | "display": "cryptographic hash function" 20 | }, 21 | { 22 | "code": "PERTURBED", 23 | "display": "exact value is replaced with another exact value" 24 | } 25 | ] 26 | }, 27 | "identifier": [ 28 | { 29 | "use": "usual", 30 | "type": { 31 | "coding": [ 32 | { 33 | "system": "http://terminology.hl7.org/CodeSystem/v2-0203" 34 | } 35 | ] 36 | }, 37 | "system": "urn:oid:1.2.36.146.595.217.0.1", 38 | "period": { 39 | "start": "2001-04-02" 40 | } 41 | } 42 | ], 43 | "active": true, 44 | "name": [ 45 | { 46 | "use": "official" 47 | }, 48 | { 49 | "use": "usual" 50 | }, 51 | { 52 | "use": "maiden", 53 | "period": { 54 | "end": "2002" 55 | } 56 | } 57 | ], 58 | "telecom": [ 59 | { 60 | "period": { 61 | "end": "2014" 62 | } 63 | } 64 | ], 65 | "gender": "male", 66 | "birthDate": "1974-11-21", 67 | "deceasedBoolean": false, 68 | "address": [ 69 | { 70 | "state": "Vic", 71 | "postalCode": "3990", 72 | "period": { 73 | "start": "1974-11-21" 74 | } 75 | } 76 | ], 77 | "contact": [ 78 | { 79 | "relationship": [ 80 | { 81 | "coding": [ 82 | { 83 | "system": "http://hl7.org/fhir/v2/0131" 84 | } 85 | ] 86 | } 87 | ], 88 | "address": { 89 | "state": "Vic", 90 | "postalCode": "3990", 91 | "period": { 92 | "start": "1974-11-21" 93 | } 94 | }, 95 | "gender": "female", 96 | "period": { 97 | "start": "2012" 98 | } 99 | } 100 | ], 101 | "managingOrganization": { 102 | "reference": "Organization/f65daf157ca4736e7b43da47d959d83705b89ee31c635a381e2c626273a903b4" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Snapshots/patient-to-pseuded-mii-patient.verified.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceType": "Patient", 3 | "id": "b53b0c230b0a00ef7840f52bae229136b9d60f09e269c2a84c32189bcfadda4e", 4 | "meta": { 5 | "profile": [ 6 | "https://www.medizininformatik-initiative.de/fhir/core/modul-person/StructureDefinition/PatientPseudonymisiert|2025.0.1" 7 | ], 8 | "security": [ 9 | { 10 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 11 | "code": "REDACTED", 12 | "display": "redacted" 13 | }, 14 | { 15 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 16 | "code": "CRYTOHASH", 17 | "display": "cryptographic hash function" 18 | }, 19 | { 20 | "code": "SUBSTITUTED", 21 | "display": "exact value is replaced with a predefined value" 22 | }, 23 | { 24 | "code": "GENERALIZED", 25 | "display": "exact value is replaced with a general value" 26 | }, 27 | { 28 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 29 | "code": "PSEUDED", 30 | "display": "pseudonymized" 31 | } 32 | ] 33 | }, 34 | "identifier": [ 35 | { 36 | "type": { 37 | "coding": [ 38 | { 39 | "system": "http://terminology.hl7.org/CodeSystem/v2-0203", 40 | "code": "MR", 41 | "display": "Medical Record Number" 42 | }, 43 | { 44 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 45 | "code": "PSEUDED", 46 | "display": "pseudonymized" 47 | } 48 | ], 49 | "text": "Pseudonymized Medical Record Number" 50 | }, 51 | "system": "https://www.charite.de/fhir/sid/patienten-pseudonymisiert", 52 | "value": "pseuded-42285243@patient-identifiers", 53 | "assigner": { 54 | "identifier": { 55 | "system": "https://www.medizininformatik-initiative.de/fhir/core/CodeSystem/core-location-identifier", 56 | "value": "Charité" 57 | }, 58 | "display": "Charité - Universitätsmedizin Berlin" 59 | } 60 | } 61 | ], 62 | "gender": "other", 63 | "_gender": { 64 | "extension": [ 65 | { 66 | "url": "http://fhir.de/StructureDefinition/gender-amtlich-de", 67 | "valueCoding": { 68 | "system": "http://fhir.de/CodeSystem/gender-amtlich-de", 69 | "code": "D", 70 | "display": "divers" 71 | } 72 | } 73 | ] 74 | }, 75 | "birthDate": "1998", 76 | "deceasedBoolean": false, 77 | "address": [ 78 | { 79 | "postalCode": "10", 80 | "country": "DE" 81 | } 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Snapshots/pseudonymization-patient.verified.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceType": "Patient", 3 | "id": "example", 4 | "meta": { 5 | "security": [ 6 | { 7 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 8 | "code": "REDACTED", 9 | "display": "redacted" 10 | }, 11 | { 12 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 13 | "code": "PSEUDED", 14 | "display": "pseudonymized" 15 | } 16 | ] 17 | }, 18 | "identifier": [ 19 | { 20 | "use": "usual", 21 | "type": { 22 | "coding": [ 23 | { 24 | "system": "http://terminology.hl7.org/CodeSystem/v2-0203", 25 | "code": "MR" 26 | } 27 | ] 28 | }, 29 | "system": "urn:oid:1.2.36.146.595.217.0.1", 30 | "value": "pseuded-12345@patient-identifiers", 31 | "period": { 32 | "start": "2001-05-06" 33 | }, 34 | "assigner": { 35 | "display": "Acme Healthcare" 36 | } 37 | } 38 | ], 39 | "active": true, 40 | "name": [ 41 | { 42 | "period": { 43 | "end": "2002" 44 | } 45 | } 46 | ], 47 | "telecom": [ 48 | { 49 | "use": "home" 50 | }, 51 | { 52 | "system": "phone", 53 | "value": "(03) 5555 6473", 54 | "use": "work", 55 | "rank": 1 56 | }, 57 | { 58 | "system": "phone", 59 | "value": "(03) 3410 5613", 60 | "use": "mobile", 61 | "rank": 2 62 | }, 63 | { 64 | "system": "phone", 65 | "value": "(03) 5555 8834", 66 | "use": "old", 67 | "period": { 68 | "end": "2014" 69 | } 70 | } 71 | ], 72 | "gender": "male", 73 | "birthDate": "1974-12-25", 74 | "deceasedBoolean": false, 75 | "address": [ 76 | { 77 | "use": "home", 78 | "type": "both", 79 | "text": "534 Erewhon St PeasantVille, Rainbow, Vic 3999", 80 | "line": [ 81 | "534 Erewhon St" 82 | ], 83 | "city": "PleasantVille", 84 | "district": "Rainbow", 85 | "state": "Vic", 86 | "postalCode": "3999", 87 | "period": { 88 | "start": "1974-12-25" 89 | } 90 | } 91 | ], 92 | "contact": [ 93 | { 94 | "relationship": [ 95 | { 96 | "coding": [ 97 | { 98 | "system": "http://hl7.org/fhir/v2/0131", 99 | "code": "N" 100 | } 101 | ] 102 | } 103 | ], 104 | "telecom": [ 105 | { 106 | "system": "phone", 107 | "value": "+33 (237) 998327" 108 | } 109 | ], 110 | "address": { 111 | "use": "home", 112 | "type": "both", 113 | "line": [ 114 | "534 Erewhon St" 115 | ], 116 | "city": "PleasantVille", 117 | "district": "Rainbow", 118 | "state": "Vic", 119 | "postalCode": "3999", 120 | "period": { 121 | "start": "1974-12-25" 122 | } 123 | }, 124 | "gender": "female", 125 | "period": { 126 | "start": "2012" 127 | } 128 | } 129 | ], 130 | "managingOrganization": { 131 | "reference": "Organization/1" 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Snapshots/truncate-crypto-hashed-values-patient.verified.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceType": "Patient", 3 | "id": "a43d05b5c2dce2ce", 4 | "meta": { 5 | "security": [ 6 | { 7 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 8 | "code": "CRYTOHASH", 9 | "display": "cryptographic hash function" 10 | } 11 | ] 12 | }, 13 | "identifier": [ 14 | { 15 | "use": "usual", 16 | "type": { 17 | "coding": [ 18 | { 19 | "system": "http://terminology.hl7.org/CodeSystem/v2-0203", 20 | "code": "MR" 21 | } 22 | ] 23 | }, 24 | "system": "urn:oid:1.2.36.146.595.217.0.1", 25 | "value": "12345", 26 | "period": { 27 | "start": "2001-05-06" 28 | }, 29 | "assigner": { 30 | "display": "Acme Healthcare" 31 | } 32 | } 33 | ], 34 | "active": true, 35 | "name": [ 36 | { 37 | "use": "official", 38 | "family": "Chalmers", 39 | "given": [ 40 | "Peter", 41 | "James" 42 | ] 43 | }, 44 | { 45 | "use": "usual", 46 | "given": [ 47 | "Jim" 48 | ] 49 | }, 50 | { 51 | "use": "maiden", 52 | "family": "Windsor", 53 | "given": [ 54 | "Peter", 55 | "James" 56 | ], 57 | "period": { 58 | "end": "2002" 59 | } 60 | } 61 | ], 62 | "telecom": [ 63 | { 64 | "use": "home" 65 | }, 66 | { 67 | "system": "phone", 68 | "value": "(03) 5555 6473", 69 | "use": "work", 70 | "rank": 1 71 | }, 72 | { 73 | "system": "phone", 74 | "value": "(03) 3410 5613", 75 | "use": "mobile", 76 | "rank": 2 77 | }, 78 | { 79 | "system": "phone", 80 | "value": "(03) 5555 8834", 81 | "use": "old", 82 | "period": { 83 | "end": "2014" 84 | } 85 | } 86 | ], 87 | "gender": "male", 88 | "birthDate": "1974-12-25", 89 | "deceasedBoolean": false, 90 | "address": [ 91 | { 92 | "use": "home", 93 | "type": "both", 94 | "text": "534 Erewhon St PeasantVille, Rainbow, Vic 3999", 95 | "line": [ 96 | "534 Erewhon St" 97 | ], 98 | "city": "PleasantVille", 99 | "district": "Rainbow", 100 | "state": "Vic", 101 | "postalCode": "3999", 102 | "period": { 103 | "start": "1974-12-25" 104 | } 105 | } 106 | ], 107 | "contact": [ 108 | { 109 | "relationship": [ 110 | { 111 | "coding": [ 112 | { 113 | "system": "http://hl7.org/fhir/v2/0131", 114 | "code": "N" 115 | } 116 | ] 117 | } 118 | ], 119 | "name": { 120 | "family": "du Marché", 121 | "_family": { 122 | "extension": [ 123 | { 124 | "url": "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix", 125 | "valueString": "VV" 126 | } 127 | ] 128 | }, 129 | "given": [ 130 | "Bénédicte" 131 | ] 132 | }, 133 | "telecom": [ 134 | { 135 | "system": "phone", 136 | "value": "+33 (237) 998327" 137 | } 138 | ], 139 | "address": { 140 | "use": "home", 141 | "type": "both", 142 | "line": [ 143 | "534 Erewhon St" 144 | ], 145 | "city": "PleasantVille", 146 | "district": "Rainbow", 147 | "state": "Vic", 148 | "postalCode": "3999", 149 | "period": { 150 | "start": "1974-12-25" 151 | } 152 | }, 153 | "gender": "female", 154 | "period": { 155 | "start": "2012" 156 | } 157 | } 158 | ], 159 | "managingOrganization": { 160 | "reference": "Organization/f65daf157ca4736e" 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Snapshots/whitelist-resource-parts-patient.verified.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceType": "Patient", 3 | "id": "example", 4 | "meta": { 5 | "security": [ 6 | { 7 | "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 8 | "code": "REDACTED", 9 | "display": "redacted" 10 | } 11 | ] 12 | }, 13 | "birthDate": "1974-12-25" 14 | } 15 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using FakeItEasy; 2 | global using FluentAssertions; 3 | global using Xunit; 4 | global using Task = System.Threading.Tasks.Task; 5 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/WebApplicationFactory.cs: -------------------------------------------------------------------------------- 1 | using FhirPseudonymizer.Pseudonymization; 2 | using Microsoft.AspNetCore.Hosting; 3 | using Microsoft.AspNetCore.Mvc.Testing; 4 | using Microsoft.AspNetCore.TestHost; 5 | using Microsoft.Extensions.Configuration; 6 | using Microsoft.Extensions.DependencyInjection; 7 | 8 | namespace FhirPseudonymizer.Tests 9 | { 10 | public class CustomWebApplicationFactory : WebApplicationFactory 11 | where TStartup : class 12 | { 13 | public IDictionary CustomInMemorySettings { get; set; } = null; 14 | 15 | protected override void ConfigureWebHost(IWebHostBuilder builder) 16 | { 17 | builder.ConfigureTestServices(services => 18 | { 19 | // remove the existing context configuration 20 | var descriptor = services.SingleOrDefault(d => 21 | d.ServiceType == typeof(IPseudonymServiceClient) 22 | ); 23 | if (descriptor != null) 24 | { 25 | services.Remove(descriptor); 26 | } 27 | 28 | var psnClient = A.Fake(); 29 | A.CallTo(() => 30 | psnClient.GetOrCreatePseudonymFor( 31 | A._, 32 | A._, 33 | A>._ 34 | ) 35 | ) 36 | .ReturnsLazily( 37 | ( 38 | string original, 39 | string domain, 40 | IReadOnlyDictionary settings 41 | ) => $"pseuded-{original}@{domain}" 42 | ); 43 | A.CallTo(() => 44 | psnClient.GetOriginalValueFor( 45 | A._, 46 | A._, 47 | A>._ 48 | ) 49 | ) 50 | .ReturnsLazily( 51 | ( 52 | string pseudonym, 53 | string domain, 54 | IReadOnlyDictionary settings 55 | ) => $"original-{pseudonym}@{domain}" 56 | ); 57 | 58 | services.AddTransient(_ => psnClient); 59 | }); 60 | 61 | if (CustomInMemorySettings is not null) 62 | { 63 | builder.ConfigureAppConfiguration( 64 | (context, configBuilder) => 65 | configBuilder.AddInMemoryCollection(CustomInMemorySettings) 66 | ); 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer.Tests/runsettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | cobertura,lcov,opencover 8 | [*]FhirPseudonymizer.Protos.*,[*]Microsoft.Health.Fhir.Anonymizer.*,[*]Google.Api.* 9 | Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute 10 | true 11 | true 12 | true 13 | false 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/AnonymizerEngineExtensions.cs: -------------------------------------------------------------------------------- 1 | using FhirPseudonymizer.Config; 2 | using FhirPseudonymizer.Pseudonymization; 3 | using Microsoft.Health.Fhir.Anonymizer.Core; 4 | 5 | namespace FhirPseudonymizer; 6 | 7 | public static class AnonymizerEngineExtensions 8 | { 9 | public static IServiceCollection AddAnonymizerEngine( 10 | this IServiceCollection services, 11 | AppConfig appConfig 12 | ) 13 | { 14 | AnonymizerEngine.InitializeFhirPathExtensionSymbols(); 15 | 16 | var configFilePath = appConfig.AnonymizationEngineConfigPath; 17 | 18 | AnonymizerConfigurationManager anonConfigManager = null; 19 | if (!string.IsNullOrEmpty(appConfig.AnonymizationEngineConfigInline)) 20 | { 21 | anonConfigManager = AnonymizerConfigurationManager.CreateFromYamlConfigString( 22 | appConfig.AnonymizationEngineConfigInline, 23 | appConfig.Anonymization 24 | ); 25 | } 26 | else if (!string.IsNullOrEmpty(configFilePath)) 27 | { 28 | anonConfigManager = AnonymizerConfigurationManager.CreateFromYamlConfigFile( 29 | configFilePath, 30 | appConfig.Anonymization 31 | ); 32 | } 33 | else 34 | { 35 | throw new InvalidOperationException( 36 | "Anonymization config not set. Specify either a path or an inline config." 37 | ); 38 | } 39 | 40 | // add the anon config as an additional service to allow mocking it 41 | services.AddSingleton(_ => anonConfigManager); 42 | 43 | services.AddSingleton(sp => 44 | { 45 | var anonConfig = sp.GetRequiredService(); 46 | var engine = new AnonymizerEngine(anonConfig); 47 | 48 | var psnClient = sp.GetRequiredService(); 49 | engine.AddProcessor( 50 | "pseudonymize", 51 | new PseudonymizationProcessor(psnClient, appConfig.Features) 52 | ); 53 | 54 | return engine; 55 | }); 56 | 57 | services.AddSingleton(sp => 58 | { 59 | var anonConfig = sp.GetRequiredService(); 60 | var engine = new DePseudonymizerEngine(anonConfig); 61 | 62 | var psnClient = sp.GetRequiredService(); 63 | engine.AddProcessor( 64 | "pseudonymize", 65 | new DePseudonymizationProcessor(psnClient, appConfig.Features) 66 | ); 67 | 68 | engine.AddProcessor( 69 | "encrypt", 70 | new DecryptProcessor(anonConfig.GetParameterConfiguration().EncryptKey) 71 | ); 72 | return engine; 73 | }); 74 | 75 | return services; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/ApiKeyExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Claims; 3 | using System.Threading.Tasks; 4 | using AspNetCore.Authentication.ApiKey; 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | namespace FhirPseudonymizer; 8 | 9 | public static class ApiKeyExtensions 10 | { 11 | public static IServiceCollection AddApiKeyAuth(this IServiceCollection services, string apiKey) 12 | { 13 | services 14 | .AddAuthentication(ApiKeyDefaults.AuthenticationScheme) 15 | .AddApiKeyInHeaderOrQueryParams(options => 16 | { 17 | options.Realm = "FHIR Pseudonymizer"; 18 | options.KeyName = "X-Api-Key"; 19 | options.IgnoreAuthenticationIfAllowAnonymous = true; 20 | 21 | options.Events = new ApiKeyEvents 22 | { 23 | OnValidateKey = ctx => 24 | { 25 | if ( 26 | string.IsNullOrWhiteSpace(apiKey) 27 | || !apiKey.Equals(ctx.ApiKey, StringComparison.InvariantCulture) 28 | ) 29 | { 30 | ctx.ValidationFailed(); 31 | return Task.CompletedTask; 32 | } 33 | 34 | var claims = new[] 35 | { 36 | new Claim("ApiAccess", "Access to FHIR Pseudonymizer API"), 37 | }; 38 | 39 | ctx.Principal = new ClaimsPrincipal( 40 | new ClaimsIdentity(claims, ctx.Scheme.Name) 41 | ); 42 | ctx.Success(); 43 | return Task.CompletedTask; 44 | }, 45 | }; 46 | }); 47 | 48 | return services; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/CompressionMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.IO.Compression; 2 | 3 | namespace FhirPseudonymizer; 4 | 5 | public class RequestCompression 6 | { 7 | private readonly RequestDelegate next; 8 | private const string ContentEncodingHeader = "Content-Encoding"; 9 | private const string ContentEncodingGzip = "gzip"; 10 | private const string ContentEncodingBrotli = "br"; 11 | private const string ContentEncodingDeflate = "deflate"; 12 | 13 | public RequestCompression(RequestDelegate next) 14 | { 15 | this.next = next ?? throw new ArgumentNullException(nameof(next)); 16 | } 17 | 18 | public async Task Invoke(HttpContext context) 19 | { 20 | if (context.Request.Headers.ContainsKey(ContentEncodingHeader)) 21 | { 22 | switch (context.Request.Headers[ContentEncodingHeader]) 23 | { 24 | case ContentEncodingGzip: 25 | context.Request.Body = new GZipStream( 26 | context.Request.Body, 27 | CompressionMode.Decompress, 28 | true 29 | ); 30 | break; 31 | case ContentEncodingBrotli: 32 | context.Request.Body = new BrotliStream( 33 | context.Request.Body, 34 | CompressionMode.Decompress, 35 | true 36 | ); 37 | break; 38 | case ContentEncodingDeflate: 39 | context.Request.Body = new DeflateStream( 40 | context.Request.Body, 41 | CompressionMode.Decompress, 42 | true 43 | ); 44 | break; 45 | } 46 | } 47 | 48 | await next(context); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Config/AppConfig.cs: -------------------------------------------------------------------------------- 1 | using FhirPseudonymizer.Pseudonymization; 2 | 3 | namespace FhirPseudonymizer.Config; 4 | 5 | public record AppConfig 6 | { 7 | public string AnonymizationEngineConfigPath { get; init; } 8 | public string AnonymizationEngineConfigInline { get; set; } 9 | public bool UseSystemTextJsonFhirSerializer { get; init; } 10 | public string ApiKey { get; init; } 11 | public PseudonymizationServiceType PseudonymizationService { get; init; } 12 | public CacheConfig Cache { get; init; } = new(); 13 | public GPasConfig GPas { get; init; } = new(); 14 | public VfpsConfig Vfps { get; init; } = new(); 15 | public EnticiConfig Entici { get; init; } = new(); 16 | public ushort MetricsPort { get; set; } = 8081; 17 | public bool EnableMetrics { get; set; } = true; 18 | public FeatureManagement Features { get; set; } = new(); 19 | public AnonymizationConfig Anonymization { get; set; } = new(); 20 | } 21 | 22 | public record CacheConfig 23 | { 24 | public uint SizeLimit { get; init; } 25 | public uint SlidingExpirationMinutes { get; init; } 26 | public uint AbsoluteExpirationMinutes { get; init; } 27 | } 28 | 29 | public record GPasConfig 30 | { 31 | public Uri Url { get; init; } 32 | public int RequestRetryCount { get; init; } 33 | public string Version { get; init; } 34 | public PseudonymServiceAuthConfig Auth { get; init; } = new(); 35 | public CacheConfig Cache { get; init; } = new(); 36 | } 37 | 38 | public record EnticiConfig 39 | { 40 | public Uri Url { get; init; } 41 | public PseudonymServiceAuthConfig Auth { get; init; } = new(); 42 | public int RequestRetryCount { get; init; } 43 | } 44 | 45 | public record VfpsConfig 46 | { 47 | public Uri Address { get; init; } 48 | public PseudonymServiceAuthConfig Auth { get; init; } = new(); 49 | public bool UnsafeUseInsecureChannelCallCredentials { get; init; } 50 | public bool UseTls { get; init; } 51 | } 52 | 53 | public record PseudonymServiceAuthConfig 54 | { 55 | public PseudonymServiceBasicAuthConfig Basic { get; init; } = new(); 56 | public PseudonymServiceOAuthConfig OAuth { get; init; } = new(); 57 | } 58 | 59 | public record PseudonymServiceOAuthConfig 60 | { 61 | public Uri TokenEndpoint { get; init; } 62 | 63 | public string ClientId { get; init; } 64 | 65 | public string ClientSecret { get; init; } 66 | 67 | public string Scope { get; init; } 68 | 69 | public string Resource { get; init; } 70 | } 71 | 72 | public record PseudonymServiceBasicAuthConfig 73 | { 74 | public string Username { get; init; } 75 | public string Password { get; init; } 76 | } 77 | 78 | public record FeatureManagement 79 | { 80 | public bool ConditionalReferencePseudonymization { get; init; } 81 | } 82 | 83 | public record AnonymizationConfig 84 | { 85 | public string CryptoHashKey { get; set; } 86 | public string EncryptKey { get; set; } 87 | public bool ShouldAddSecurityTag { get; set; } = true; 88 | } 89 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/DecryptProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Hl7.Fhir.ElementModel; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Health.Fhir.Anonymizer.Core; 7 | using Microsoft.Health.Fhir.Anonymizer.Core.Models; 8 | using Microsoft.Health.Fhir.Anonymizer.Core.Processors; 9 | using Microsoft.Health.Fhir.Anonymizer.Core.Utility; 10 | 11 | namespace FhirPseudonymizer 12 | { 13 | public class DecryptProcessor : IAnonymizerProcessor 14 | { 15 | private readonly byte[] _key; 16 | private readonly ILogger _logger = AnonymizerLogging.CreateLogger(); 17 | 18 | public DecryptProcessor(string decryptKey) 19 | { 20 | _key = Encoding.UTF8.GetBytes(decryptKey); 21 | } 22 | 23 | public ProcessResult Process( 24 | ElementNode node, 25 | ProcessContext context = null, 26 | Dictionary settings = null 27 | ) 28 | { 29 | var processResult = new ProcessResult(); 30 | if (string.IsNullOrEmpty(node?.Value?.ToString())) 31 | { 32 | return processResult; 33 | } 34 | 35 | var input = node.Value.ToString(); 36 | try 37 | { 38 | node.Value = EncryptUtility.DecryptTextFromHexStringWithAes(input, _key); 39 | } 40 | catch (Exception exc) 41 | { 42 | _logger.LogWarning(exc, "Decryption failed. Returning original value."); 43 | } 44 | 45 | _logger.LogDebug( 46 | $"Fhir value '{input}' at '{node.Location}' is decrypted to '{node.Value}'." 47 | ); 48 | 49 | return processResult; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/FhirPseudonymizer.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 74fd5f09-6f06-4b68-8c73-152e215728ff 4 | true 5 | true 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.R4.Core/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace Microsoft.Health.Fhir.Anonymizer.Core 2 | { 3 | internal static partial class Constants 4 | { 5 | // InstanceType constants 6 | internal const string SupportedVersion = "R4"; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.R4.Core/Processors/PerturbProcessor.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Hl7.Fhir.Model; 3 | 4 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors 5 | { 6 | public partial class PerturbProcessor : IAnonymizerProcessor 7 | { 8 | private static readonly HashSet s_quantityTypeNames = new HashSet 9 | { 10 | FHIRAllTypes.Age.ToString(), 11 | FHIRAllTypes.Count.ToString(), 12 | FHIRAllTypes.Duration.ToString(), 13 | FHIRAllTypes.Distance.ToString(), 14 | FHIRAllTypes.Money.ToString(), 15 | FHIRAllTypes.MoneyQuantity.ToString(), 16 | FHIRAllTypes.Quantity.ToString(), 17 | FHIRAllTypes.SimpleQuantity.ToString(), 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/AnonymizerConfigurations/AnonymizationFhirPathRule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations 6 | { 7 | public class AnonymizationFhirPathRule : AnonymizerRule 8 | { 9 | private static readonly Regex s_pathRegex = new Regex( 10 | @"^(?[A-Z][a-zA-Z]*)?(\.)?(?.*?)$" 11 | ); 12 | 13 | public AnonymizationFhirPathRule( 14 | string path, 15 | string expression, 16 | string resourceType, 17 | string method, 18 | AnonymizerRuleType type, 19 | string source, 20 | Dictionary settings = null 21 | ) 22 | : base(path, method, type, source) 23 | { 24 | if (string.IsNullOrEmpty(expression)) 25 | { 26 | throw new ArgumentNullException("expression"); 27 | } 28 | 29 | Expression = expression; 30 | ResourceType = resourceType; 31 | RuleSettings = settings; 32 | } 33 | 34 | public string Expression { get; set; } 35 | 36 | public string ResourceType { get; } 37 | 38 | public bool IsResourceTypeRule => Path.Equals(ResourceType); 39 | 40 | public static AnonymizationFhirPathRule CreateAnonymizationFhirPathRule( 41 | Dictionary config 42 | ) 43 | { 44 | if (config == null) 45 | { 46 | throw new ArgumentNullException("config"); 47 | } 48 | 49 | if (!config.ContainsKey(Constants.PathKey)) 50 | { 51 | throw new ArgumentException("Missing path in rule config"); 52 | } 53 | 54 | if (!config.ContainsKey(Constants.MethodKey)) 55 | { 56 | throw new ArgumentException("Missing method in rule config"); 57 | } 58 | 59 | var path = config[Constants.PathKey].ToString(); 60 | var method = config[Constants.MethodKey].ToString(); 61 | 62 | // Parse expression and resource type from path 63 | string resourceType = null; 64 | string expression = null; 65 | var match = s_pathRegex.Match(path); 66 | if (match.Success) 67 | { 68 | resourceType = match.Groups["resourceType"].Value; 69 | expression = match.Groups["expression"].Value; 70 | } 71 | 72 | if (string.IsNullOrEmpty(expression)) 73 | { 74 | // For case: Path == "Resource" 75 | expression = path; 76 | } 77 | 78 | return new AnonymizationFhirPathRule( 79 | path, 80 | expression, 81 | resourceType, 82 | method, 83 | AnonymizerRuleType.FhirPathRule, 84 | path, 85 | config 86 | ); 87 | } 88 | 89 | public AnonymizationFhirPathRule ShallowCopy() 90 | { 91 | return (AnonymizationFhirPathRule)this.MemberwiseClone(); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/AnonymizerConfigurations/AnonymizerConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Runtime.Serialization; 4 | 5 | namespace Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations 6 | { 7 | [DataContract] 8 | public class AnonymizerConfiguration 9 | { 10 | // Static default crypto hash key to provide a same default key for all engine instances 11 | private static readonly Lazy s_defaultCryptoKey = new Lazy(() => 12 | Guid.NewGuid().ToString("N") 13 | ); 14 | 15 | [DataMember(Name = "fhirVersion")] 16 | public string FhirVersion { get; set; } 17 | 18 | [DataMember(Name = "fhirPathRules")] 19 | public Dictionary[] FhirPathRules { get; set; } 20 | 21 | [DataMember(Name = "parameters")] 22 | // renamed to "Parameters" due to missing support for DataMember attributes 23 | // https://github.com/aaubry/YamlDotNet/issues/461 24 | public ParameterConfiguration Parameters { get; set; } 25 | 26 | public void GenerateDefaultParametersIfNotConfigured() 27 | { 28 | // if not configured, a random string will be generated as date shift key, others will keep their default values 29 | if (Parameters == null) 30 | { 31 | Parameters = new ParameterConfiguration 32 | { 33 | DateShiftKey = Guid.NewGuid().ToString("N"), 34 | CryptoHashKey = s_defaultCryptoKey.Value, 35 | EncryptKey = s_defaultCryptoKey.Value, 36 | }; 37 | return; 38 | } 39 | 40 | if (string.IsNullOrEmpty(Parameters.DateShiftKey)) 41 | { 42 | Parameters.DateShiftKey = Guid.NewGuid().ToString("N"); 43 | } 44 | 45 | if (string.IsNullOrEmpty(Parameters.CryptoHashKey)) 46 | { 47 | Parameters.CryptoHashKey = s_defaultCryptoKey.Value; 48 | } 49 | 50 | if (string.IsNullOrEmpty(Parameters.EncryptKey)) 51 | { 52 | Parameters.EncryptKey = s_defaultCryptoKey.Value; 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/AnonymizerConfigurations/AnonymizerConfigurationErrorsException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations 4 | { 5 | public class AnonymizerConfigurationErrorsException : Exception 6 | { 7 | public AnonymizerConfigurationErrorsException(string message) 8 | : base(message) { } 9 | 10 | public AnonymizerConfigurationErrorsException(string message, Exception innerException) 11 | : base(message, innerException) { } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/AnonymizerConfigurations/AnonymizerMethod.cs: -------------------------------------------------------------------------------- 1 | namespace Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations 2 | { 3 | public enum AnonymizerMethod 4 | { 5 | Redact, 6 | DateShift, 7 | CryptoHash, 8 | Substitute, 9 | Encrypt, 10 | Perturb, 11 | Keep, 12 | Generalize, 13 | Pseudonymize, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/AnonymizerConfigurations/AnonymizerRule.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations 4 | { 5 | public class AnonymizerRule 6 | { 7 | public AnonymizerRule(string path, string method, AnonymizerRuleType type, string source) 8 | { 9 | Path = path; 10 | Method = method; 11 | Type = type; 12 | Source = source; 13 | } 14 | 15 | public string Path { get; set; } 16 | 17 | public string Method { get; set; } 18 | 19 | public AnonymizerRuleType Type { get; set; } 20 | 21 | public string Source { get; set; } 22 | 23 | public Dictionary RuleSettings { get; set; } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/AnonymizerConfigurations/AnonymizerRuleType.cs: -------------------------------------------------------------------------------- 1 | namespace Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations 2 | { 3 | public enum AnonymizerRuleType 4 | { 5 | FhirPathRule, 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/AnonymizerConfigurations/AnonymizerSettings.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations 4 | { 5 | public class AnonymizerSettings 6 | { 7 | public bool IsPrettyOutput { get; set; } 8 | 9 | public bool ValidateInput { get; set; } 10 | 11 | public bool ValidateOutput { get; set; } 12 | 13 | public bool ShouldAddSecurityTag { get; set; } = true; 14 | 15 | public Dictionary DynamicRuleSettings { get; set; } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/AnonymizerConfigurations/DateShiftScope.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.Serialization; 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Converters; 4 | 5 | namespace Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations 6 | { 7 | [JsonConverter(typeof(StringEnumConverter))] 8 | public enum DateShiftScope 9 | { 10 | [EnumMember(Value = "resource")] 11 | Resource, 12 | 13 | [EnumMember(Value = "file")] 14 | File, 15 | 16 | [EnumMember(Value = "folder")] 17 | Folder, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/AnonymizerConfigurations/ParameterConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Runtime.Serialization; 3 | 4 | namespace Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations 5 | { 6 | [DataContract] 7 | public class ParameterConfiguration 8 | { 9 | [DataMember(Name = "dateShiftKey")] 10 | public string DateShiftKey { get; set; } 11 | 12 | [DataMember(Name = "dateShiftScope")] 13 | public DateShiftScope DateShiftScope { get; set; } 14 | 15 | [DataMember(Name = "cryptoHashKey")] 16 | public string CryptoHashKey { get; set; } 17 | 18 | [DataMember(Name = "encryptKey")] 19 | public string EncryptKey { get; set; } 20 | 21 | [DataMember(Name = "enablePartialAgesForRedact")] 22 | public bool EnablePartialAgesForRedact { get; set; } 23 | 24 | [DataMember(Name = "enablePartialDatesForRedact")] 25 | public bool EnablePartialDatesForRedact { get; set; } 26 | 27 | [DataMember(Name = "enablePartialZipCodesForRedact")] 28 | public bool EnablePartialZipCodesForRedact { get; set; } 29 | 30 | [DataMember(Name = "restrictedZipCodeTabulationAreas")] 31 | public List RestrictedZipCodeTabulationAreas { get; set; } 32 | 33 | public string DateShiftKeyPrefix { get; set; } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/AnonymizerLogging.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | 3 | namespace Microsoft.Health.Fhir.Anonymizer.Core 4 | { 5 | public static class AnonymizerLogging 6 | { 7 | public static ILoggerFactory LoggerFactory { get; set; } = new LoggerFactory(); 8 | 9 | public static ILogger CreateLogger() 10 | { 11 | return LoggerFactory.CreateLogger(); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace Microsoft.Health.Fhir.Anonymizer.Core 2 | { 3 | internal static partial class Constants 4 | { 5 | // InstanceType constants 6 | internal const string DateTypeName = "date"; 7 | internal const string DateTimeTypeName = "dateTime"; 8 | internal const string DecimalTypeName = "decimal"; 9 | internal const string InstantTypeName = "instant"; 10 | internal const string AgeTypeName = "Age"; 11 | internal const string BundleTypeName = "Bundle"; 12 | internal const string ReferenceTypeName = "Reference"; 13 | 14 | // NodeName constants 15 | internal const string PostalCodeNodeName = "postalCode"; 16 | internal const string ReferenceStringNodeName = "reference"; 17 | internal const string ContainedNodeName = "contained"; 18 | internal const string EntryNodeName = "entry"; 19 | internal const string EntryResourceNodeName = "resource"; 20 | internal const string ValueNodeName = "value"; 21 | 22 | // Rule constants 23 | internal const string PathKey = "path"; 24 | internal const string MethodKey = "method"; 25 | 26 | internal const int DefaultPartitionedExecutionCount = 4; 27 | internal const int DefaultPartitionedExecutionBatchSize = 1000; 28 | 29 | internal const string GeneralResourceType = "Resource"; 30 | internal const string GeneralDomainResourceType = "DomainResource"; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Extensions/ElementNodeNavExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Hl7.Fhir.ElementModel; 4 | 5 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Extensions 6 | { 7 | public static class ElementNodeNavExtensions 8 | { 9 | public static List GetEntryResourceChildren(this ElementNode node) 10 | { 11 | return node 12 | ?.Children(Constants.EntryNodeName) 13 | .Select(entry => entry?.Children(Constants.EntryResourceNodeName).FirstOrDefault()) 14 | .Where(resource => resource != null) 15 | .CastElementNodes() 16 | .ToList(); 17 | } 18 | 19 | public static List GetContainedChildren(this ElementNode node) 20 | { 21 | return node?.Children(Constants.ContainedNodeName).CastElementNodes().ToList(); 22 | } 23 | 24 | public static IEnumerable ResourceDescendantsWithoutSubResource( 25 | this ElementNode node 26 | ) 27 | { 28 | foreach (var child in node.Children().CastElementNodes()) 29 | { 30 | // Skip sub resources in bundle entry and contained list 31 | if (child.IsFhirResource()) 32 | { 33 | continue; 34 | } 35 | 36 | yield return child; 37 | 38 | foreach (var n in child.ResourceDescendantsWithoutSubResource()) 39 | { 40 | yield return n; 41 | } 42 | } 43 | } 44 | 45 | public static IEnumerable SelfAndDescendantsWithoutSubResource( 46 | this IEnumerable nodes 47 | ) 48 | { 49 | foreach (var node in nodes) 50 | { 51 | yield return node; 52 | 53 | foreach (var descendant in node.ResourceDescendantsWithoutSubResource()) 54 | { 55 | yield return descendant; 56 | } 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Extensions/ElementNodeVisitorExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Hl7.Fhir.ElementModel; 3 | using Microsoft.Health.Fhir.Anonymizer.Core.Visitors; 4 | 5 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Extensions 6 | { 7 | public static class ElementNodeVisitorExtensions 8 | { 9 | public static void Accept(this ElementNode node, AbstractElementNodeVisitor visitor) 10 | { 11 | var shouldVisitChild = visitor.Visit(node); 12 | 13 | if (shouldVisitChild) 14 | { 15 | foreach (var child in node.Children().CastElementNodes()) 16 | { 17 | child.Accept(visitor); 18 | } 19 | } 20 | 21 | visitor.EndVisit(node); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Extensions/FhirPathSymbolExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Hl7.Fhir.ElementModel; 4 | using Hl7.FhirPath.Expressions; 5 | 6 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Extensions 7 | { 8 | public static class FhirPathSymbolExtensions 9 | { 10 | private static readonly object _lock = new object(); 11 | 12 | public static SymbolTable AddExtensionSymbols(this SymbolTable t) 13 | { 14 | // Add lock here to ensure thread safety when modifying a symbol table 15 | lock (_lock) 16 | { 17 | // Check whether extension method already exists 18 | if (t.Filter("nodesByType", 2).Count() == 0) 19 | { 20 | t.Add( 21 | "nodesByType", 22 | (IEnumerable f, string typeName) => NodesByType(f, typeName), 23 | true 24 | ); 25 | } 26 | 27 | if (t.Filter("nodesByName", 2).Count() == 0) 28 | { 29 | t.Add( 30 | "nodesByName", 31 | (IEnumerable f, string name) => NodesByName(f, name), 32 | true 33 | ); 34 | } 35 | } 36 | 37 | return t; 38 | } 39 | 40 | public static IEnumerable NodesByType( 41 | IEnumerable nodes, 42 | string typeName 43 | ) 44 | { 45 | return nodes 46 | .CastElementNodes() 47 | .SelfAndDescendantsWithoutSubResource() 48 | .Where(n => typeName.Equals(n.InstanceType)); 49 | } 50 | 51 | public static IEnumerable NodesByName( 52 | IEnumerable nodes, 53 | string name 54 | ) 55 | { 56 | return nodes 57 | .CastElementNodes() 58 | .SelfAndDescendantsWithoutSubResource() 59 | .Where(n => name.Equals(n.Name)); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/IAnonymizerEngine.cs: -------------------------------------------------------------------------------- 1 | using Hl7.Fhir.Model; 2 | using Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations; 3 | 4 | namespace Microsoft.Health.Fhir.Anonymizer.Core 5 | { 6 | public interface IAnonymizerEngine 7 | { 8 | Resource AnonymizeResource(Resource resource, AnonymizerSettings settings = null); 9 | } 10 | 11 | public interface IDePseudonymizerEngine 12 | { 13 | Resource DePseudonymizeResource(Resource resource, AnonymizerSettings settings = null); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Models/ProcessContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Hl7.Fhir.ElementModel; 3 | 4 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Models 5 | { 6 | public class ProcessContext 7 | { 8 | public HashSet VisitedNodes { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Models/ProcessResult.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Hl7.Fhir.ElementModel; 3 | using Microsoft.Health.Fhir.Anonymizer.Core.Processors; 4 | 5 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Models 6 | { 7 | public class ProcessResult 8 | { 9 | public bool IsRedacted => ProcessRecords.ContainsKey(AnonymizationOperations.Redact); 10 | 11 | public bool IsAbstracted => ProcessRecords.ContainsKey(AnonymizationOperations.Abstract); 12 | 13 | public bool IsCryptoHashed => 14 | ProcessRecords.ContainsKey(AnonymizationOperations.CryptoHash); 15 | 16 | public bool IsEncrypted => ProcessRecords.ContainsKey(AnonymizationOperations.Encrypt); 17 | 18 | public bool IsPerturbed => ProcessRecords.ContainsKey(AnonymizationOperations.Perturb); 19 | public bool IsSubstituted => ProcessRecords.ContainsKey(AnonymizationOperations.Substitute); 20 | 21 | public bool IsGeneralized => ProcessRecords.ContainsKey(AnonymizationOperations.Generalize); 22 | 23 | public bool IsPseudonymized => 24 | ProcessRecords.ContainsKey(AnonymizationOperations.Pseudonymize); 25 | 26 | public Dictionary> ProcessRecords { get; } = 27 | new Dictionary>(); 28 | 29 | public void AddProcessRecord(string operationName, ITypedElement node) 30 | { 31 | if (ProcessRecords.ContainsKey(operationName)) 32 | { 33 | ProcessRecords[operationName].Add(node); 34 | } 35 | else 36 | { 37 | ProcessRecords[operationName] = new HashSet { node }; 38 | } 39 | } 40 | 41 | public void Update(ProcessResult result) 42 | { 43 | if (result == null) 44 | { 45 | return; 46 | } 47 | 48 | foreach (var pair in result.ProcessRecords) 49 | { 50 | if (!ProcessRecords.ContainsKey(pair.Key)) 51 | { 52 | ProcessRecords[pair.Key] = pair.Value; 53 | } 54 | else 55 | { 56 | ProcessRecords[pair.Key].UnionWith(pair.Value); 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Models/SecurityLabels.cs: -------------------------------------------------------------------------------- 1 | using Hl7.Fhir.Model; 2 | 3 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Models 4 | { 5 | public static class SecurityLabels 6 | { 7 | public static readonly Coding REDACT = new Coding 8 | { 9 | System = "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 10 | Code = "REDACTED", 11 | Display = "redacted", 12 | }; 13 | 14 | public static readonly Coding ABSTRED = new Coding 15 | { 16 | System = "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 17 | Code = "ABSTRED", 18 | Display = "abstracted", 19 | }; 20 | 21 | public static readonly Coding CRYTOHASH = new Coding 22 | { 23 | System = "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 24 | Code = "CRYTOHASH", 25 | Display = "cryptographic hash function", 26 | }; 27 | 28 | public static readonly Coding ENCRYPT = new Coding 29 | { 30 | Code = "ENCRYPT", 31 | Display = "exact value is transformed into ciphertext", 32 | }; 33 | 34 | public static readonly Coding PERTURBED = new Coding 35 | { 36 | Code = "PERTURBED", 37 | Display = "exact value is replaced with another exact value", 38 | }; 39 | 40 | public static readonly Coding SUBSTITUTED = new Coding 41 | { 42 | Code = "SUBSTITUTED", 43 | Display = "exact value is replaced with a predefined value", 44 | }; 45 | 46 | public static readonly Coding GENERALIZED = new Coding 47 | { 48 | Code = "GENERALIZED", 49 | Display = "exact value is replaced with a general value", 50 | }; 51 | 52 | public static readonly Coding PSEUDED = new Coding 53 | { 54 | System = "http://terminology.hl7.org/CodeSystem/v3-ObservationValue", 55 | Code = "PSEUDED", 56 | Display = "pseudonymized", 57 | }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/PartitionedExecution/BatchAnonymizeProgressDetail.cs: -------------------------------------------------------------------------------- 1 | namespace Microsoft.Health.Fhir.Anonymizer.Core.PartitionedExecution 2 | { 3 | public class BatchAnonymizeProgressDetail 4 | { 5 | public int CurrentThreadId { get; set; } 6 | 7 | public int ProcessCompleted { get; set; } 8 | 9 | public int ProcessFailed { get; set; } 10 | 11 | public int ConsumeCompleted { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/PartitionedExecution/FhirEnumerableReader.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | 4 | namespace Microsoft.Health.Fhir.Anonymizer.Core.PartitionedExecution 5 | { 6 | public class FhirEnumerableReader : IFhirDataReader 7 | { 8 | private readonly IEnumerator _enumerator; 9 | 10 | public FhirEnumerableReader(IEnumerable data) 11 | { 12 | _enumerator = data.GetEnumerator(); 13 | } 14 | 15 | public Task NextAsync() 16 | { 17 | if (_enumerator.MoveNext()) 18 | { 19 | return Task.FromResult(_enumerator.Current); 20 | } 21 | 22 | return Task.FromResult(default); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/PartitionedExecution/FhirStreamConsumer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Threading.Tasks; 5 | 6 | namespace Microsoft.Health.Fhir.Anonymizer.Core.PartitionedExecution 7 | { 8 | public class FhirStreamConsumer : IFhirDataConsumer, IDisposable 9 | { 10 | private readonly StreamWriter _writer; 11 | 12 | public FhirStreamConsumer(Stream stream) 13 | { 14 | _writer = new StreamWriter(stream); 15 | } 16 | 17 | public async Task CompleteAsync() 18 | { 19 | await _writer.FlushAsync().ConfigureAwait(false); 20 | } 21 | 22 | public async Task ConsumeAsync(IEnumerable data) 23 | { 24 | var result = 0; 25 | foreach (var content in data) 26 | { 27 | await _writer.WriteLineAsync(content).ConfigureAwait(false); 28 | result++; 29 | } 30 | 31 | return result; 32 | } 33 | 34 | #region IDisposable Support 35 | 36 | private bool disposedValue; 37 | 38 | protected virtual void Dispose(bool disposing) 39 | { 40 | if (!disposedValue) 41 | { 42 | if (disposing) 43 | { 44 | _writer?.Dispose(); 45 | } 46 | 47 | disposedValue = true; 48 | } 49 | } 50 | 51 | public void Dispose() 52 | { 53 | Dispose(true); 54 | GC.SuppressFinalize(this); 55 | } 56 | 57 | #endregion 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/PartitionedExecution/FhirStreamReader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | 5 | namespace Microsoft.Health.Fhir.Anonymizer.Core 6 | { 7 | public class FhirStreamReader : IFhirDataReader, IDisposable 8 | { 9 | private readonly StreamReader _reader; 10 | 11 | public FhirStreamReader(Stream stream) 12 | { 13 | _reader = new StreamReader(stream); 14 | } 15 | 16 | public async Task NextAsync() 17 | { 18 | return await _reader.ReadLineAsync().ConfigureAwait(false); 19 | } 20 | 21 | #region IDisposable Support 22 | 23 | private bool disposedValue; 24 | 25 | protected virtual void Dispose(bool disposing) 26 | { 27 | if (!disposedValue) 28 | { 29 | if (disposing) 30 | { 31 | _reader?.Dispose(); 32 | } 33 | 34 | disposedValue = true; 35 | } 36 | } 37 | 38 | public void Dispose() 39 | { 40 | Dispose(true); 41 | GC.SuppressFinalize(this); 42 | } 43 | 44 | #endregion 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/PartitionedExecution/IFhirDataConsumer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | 4 | namespace Microsoft.Health.Fhir.Anonymizer.Core 5 | { 6 | public interface IFhirDataConsumer 7 | { 8 | Task ConsumeAsync(IEnumerable data); 9 | 10 | Task CompleteAsync(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/PartitionedExecution/IFhirDataReader.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace Microsoft.Health.Fhir.Anonymizer.Core 4 | { 5 | public interface IFhirDataReader 6 | { 7 | Task NextAsync(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Processors/AnonymizationOperations.cs: -------------------------------------------------------------------------------- 1 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors 2 | { 3 | public static class AnonymizationOperations 4 | { 5 | public const string Redact = "REDACT"; 6 | public const string Abstract = "ABSTRACT"; 7 | public const string Perturb = "PERTURB"; 8 | public const string CryptoHash = "CRYPTOHASH"; 9 | public const string Encrypt = "ENCRYPT"; 10 | public const string Substitute = "SUBSTITUTE"; 11 | public const string Generalize = "GENERALIZE"; 12 | public const string Pseudonymize = "PSEUDED"; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Processors/CryptoHashProcessor.cs: -------------------------------------------------------------------------------- 1 | using Hl7.Fhir.ElementModel; 2 | using Microsoft.Health.Fhir.Anonymizer.Core.Extensions; 3 | using Microsoft.Health.Fhir.Anonymizer.Core.Models; 4 | using Microsoft.Health.Fhir.Anonymizer.Core.Utility; 5 | 6 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors 7 | { 8 | public class CryptoHashProcessor : IAnonymizerProcessor 9 | { 10 | private readonly Func _cryptoHashFunction; 11 | private readonly string _cryptoHashKey; 12 | private readonly ILogger _logger = AnonymizerLogging.CreateLogger(); 13 | 14 | public CryptoHashProcessor(string cryptoHashKey) 15 | { 16 | _cryptoHashKey = cryptoHashKey; 17 | _cryptoHashFunction = input => 18 | CryptoHashUtility.ComputeHmacSHA256Hash(input, _cryptoHashKey); 19 | } 20 | 21 | public ProcessResult Process( 22 | ElementNode node, 23 | ProcessContext context = null, 24 | Dictionary settings = null 25 | ) 26 | { 27 | var processResult = new ProcessResult(); 28 | if (string.IsNullOrEmpty(node?.Value?.ToString())) 29 | { 30 | return processResult; 31 | } 32 | 33 | var cryptoHashFunction = _cryptoHashFunction; 34 | 35 | if ( 36 | settings?.TryGetValue("truncateToMaxLength", out var truncateToMaxLengthObject) 37 | == true 38 | ) 39 | { 40 | var truncateToMaxLength = Convert.ToInt32(truncateToMaxLengthObject); 41 | cryptoHashFunction = (input) => 42 | { 43 | var fullHash = CryptoHashUtility.ComputeHmacSHA256Hash(input, _cryptoHashKey); 44 | return fullHash[..Math.Min(truncateToMaxLength, fullHash.Length)]; 45 | }; 46 | } 47 | 48 | var input = node.Value.ToString(); 49 | // Hash the id part for "reference" and "uri" nodes and hash whole input for other node types 50 | if (node.IsReferenceStringNode() || node.IsReferenceUriNode(input)) 51 | { 52 | var newReference = ReferenceUtility.TransformReferenceId(input, cryptoHashFunction); 53 | node.Value = newReference; 54 | } 55 | else 56 | { 57 | node.Value = cryptoHashFunction(input); 58 | } 59 | 60 | _logger.LogDebug( 61 | "Fhir value '{Input}' at '{NodeLocation}' is hashed to '{NodeValue}'.", 62 | input, 63 | node.Location, 64 | node.Value 65 | ); 66 | 67 | processResult.AddProcessRecord(AnonymizationOperations.CryptoHash, node); 68 | return processResult; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Processors/DateShiftProcessor.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Hl7.Fhir.ElementModel; 3 | using Microsoft.Health.Fhir.Anonymizer.Core.Extensions; 4 | using Microsoft.Health.Fhir.Anonymizer.Core.Models; 5 | using Microsoft.Health.Fhir.Anonymizer.Core.Utility; 6 | 7 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors 8 | { 9 | public class DateShiftProcessor : IAnonymizerProcessor 10 | { 11 | public DateShiftProcessor( 12 | string dateShiftKey, 13 | string dateShiftKeyPrefix, 14 | bool enablePartialDatesForRedact 15 | ) 16 | { 17 | DateShiftKey = dateShiftKey; 18 | DateShiftKeyPrefix = dateShiftKeyPrefix; 19 | EnablePartialDatesForRedact = enablePartialDatesForRedact; 20 | } 21 | 22 | public string DateShiftKey { get; set; } = string.Empty; 23 | 24 | public string DateShiftKeyPrefix { get; set; } = string.Empty; 25 | 26 | public bool EnablePartialDatesForRedact { get; set; } 27 | 28 | public ProcessResult Process( 29 | ElementNode node, 30 | ProcessContext context = null, 31 | Dictionary settings = null 32 | ) 33 | { 34 | var processResult = new ProcessResult(); 35 | if (string.IsNullOrEmpty(node?.Value?.ToString())) 36 | { 37 | return processResult; 38 | } 39 | 40 | if (node.IsDateNode()) 41 | { 42 | return DateTimeUtility.ShiftDateNode( 43 | node, 44 | DateShiftKey, 45 | DateShiftKeyPrefix, 46 | EnablePartialDatesForRedact 47 | ); 48 | } 49 | 50 | if (node.IsDateTimeNode() || node.IsInstantNode()) 51 | { 52 | return DateTimeUtility.ShiftDateTimeAndInstantNode( 53 | node, 54 | DateShiftKey, 55 | DateShiftKeyPrefix, 56 | EnablePartialDatesForRedact 57 | ); 58 | } 59 | 60 | return processResult; 61 | } 62 | 63 | public static DateShiftProcessor Create(AnonymizerConfigurationManager configuratonManager) 64 | { 65 | var parameters = configuratonManager.GetParameterConfiguration(); 66 | return new DateShiftProcessor( 67 | parameters.DateShiftKey, 68 | parameters.DateShiftKeyPrefix, 69 | parameters.EnablePartialDatesForRedact 70 | ); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Processors/EncryptProcessor.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text; 3 | using Hl7.Fhir.ElementModel; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Health.Fhir.Anonymizer.Core.Models; 6 | using Microsoft.Health.Fhir.Anonymizer.Core.Utility; 7 | 8 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors 9 | { 10 | public class EncryptProcessor : IAnonymizerProcessor 11 | { 12 | private readonly byte[] _key; 13 | private readonly ILogger _logger = AnonymizerLogging.CreateLogger(); 14 | 15 | public EncryptProcessor(string encryptKey) 16 | { 17 | _key = Encoding.UTF8.GetBytes(encryptKey); 18 | } 19 | 20 | public ProcessResult Process( 21 | ElementNode node, 22 | ProcessContext context = null, 23 | Dictionary settings = null 24 | ) 25 | { 26 | var processResult = new ProcessResult(); 27 | if (string.IsNullOrEmpty(node?.Value?.ToString())) 28 | { 29 | return processResult; 30 | } 31 | 32 | var input = node.Value.ToString(); 33 | node.Value = EncryptUtility.EncryptTextToHexWithAes(input, _key); 34 | _logger.LogDebug( 35 | $"Fhir value '{input}' at '{node.Location}' is encrypted to '{node.Value}'." 36 | ); 37 | 38 | processResult.AddProcessRecord(AnonymizationOperations.Encrypt, node); 39 | return processResult; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Processors/GeneralizeProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using EnsureThat; 4 | using Hl7.Fhir.ElementModel; 5 | using Hl7.Fhir.Model; 6 | using Hl7.FhirPath; 7 | using Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations; 8 | using Microsoft.Health.Fhir.Anonymizer.Core.Models; 9 | using Microsoft.Health.Fhir.Anonymizer.Core.Processors.Settings; 10 | 11 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors 12 | { 13 | public class GeneralizeProcessor : IAnonymizerProcessor 14 | { 15 | public ProcessResult Process( 16 | ElementNode node, 17 | ProcessContext context = null, 18 | Dictionary settings = null 19 | ) 20 | { 21 | EnsureArg.IsNotNull(node); 22 | EnsureArg.IsNotNull(context?.VisitedNodes); 23 | EnsureArg.IsNotNull(settings); 24 | 25 | var result = new ProcessResult(); 26 | if (!ModelInfo.IsPrimitive(node.InstanceType) || node.Value == null) 27 | { 28 | return result; 29 | } 30 | 31 | var generalizeSetting = GeneralizeSetting.CreateFromRuleSettings(settings); 32 | foreach (var eachCase in generalizeSetting.Cases) 33 | { 34 | try 35 | { 36 | if (node.Predicate(eachCase.Key)) 37 | { 38 | node.Value = node.Scalar(eachCase.Value); 39 | result.AddProcessRecord(AnonymizationOperations.Generalize, node); 40 | return result; 41 | } 42 | } 43 | catch (InvalidOperationException ex) 44 | { 45 | throw new AnonymizerConfigurationErrorsException( 46 | $"Invalid cases expression '{eachCase}': {ex.Message}", 47 | ex 48 | ); 49 | } 50 | } 51 | 52 | if (generalizeSetting.OtherValues == GeneralizationOtherValuesOperation.Redact) 53 | { 54 | node.Value = null; 55 | } 56 | 57 | result.AddProcessRecord(AnonymizationOperations.Generalize, node); 58 | return result; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Processors/IAnonymizerProcessor.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Hl7.Fhir.ElementModel; 3 | using Microsoft.Health.Fhir.Anonymizer.Core.Models; 4 | 5 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors 6 | { 7 | public interface IAnonymizerProcessor 8 | { 9 | public ProcessResult Process( 10 | ElementNode node, 11 | ProcessContext context = null, 12 | Dictionary settings = null 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Processors/KeepProcessor.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Hl7.Fhir.ElementModel; 3 | using Microsoft.Health.Fhir.Anonymizer.Core.Models; 4 | 5 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors 6 | { 7 | public class KeepProcessor : IAnonymizerProcessor 8 | { 9 | public ProcessResult Process( 10 | ElementNode node, 11 | ProcessContext context = null, 12 | Dictionary settings = null 13 | ) 14 | { 15 | return new ProcessResult(); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Processors/RedactProcessor.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Hl7.Fhir.ElementModel; 3 | using Microsoft.Health.Fhir.Anonymizer.Core.Extensions; 4 | using Microsoft.Health.Fhir.Anonymizer.Core.Models; 5 | using Microsoft.Health.Fhir.Anonymizer.Core.Utility; 6 | 7 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors 8 | { 9 | public class RedactProcessor : IAnonymizerProcessor 10 | { 11 | public RedactProcessor( 12 | bool enablePartialDatesForRedact, 13 | bool enablePartialAgesForRedact, 14 | bool enablePartialZipCodesForRedact, 15 | List restrictedZipCodeTabulationAreas 16 | ) 17 | { 18 | EnablePartialDatesForRedact = enablePartialDatesForRedact; 19 | EnablePartialAgesForRedact = enablePartialAgesForRedact; 20 | EnablePartialZipCodesForRedact = enablePartialZipCodesForRedact; 21 | RestrictedZipCodeTabulationAreas = restrictedZipCodeTabulationAreas; 22 | } 23 | 24 | public bool EnablePartialDatesForRedact { get; set; } 25 | 26 | public bool EnablePartialAgesForRedact { get; set; } 27 | 28 | public bool EnablePartialZipCodesForRedact { get; set; } 29 | 30 | public List RestrictedZipCodeTabulationAreas { get; set; } 31 | 32 | public ProcessResult Process( 33 | ElementNode node, 34 | ProcessContext context = null, 35 | Dictionary settings = null 36 | ) 37 | { 38 | if (string.IsNullOrEmpty(node?.Value?.ToString())) 39 | { 40 | return new ProcessResult(); 41 | } 42 | 43 | if (node.IsDateNode()) 44 | { 45 | return DateTimeUtility.RedactDateNode(node, EnablePartialDatesForRedact); 46 | } 47 | 48 | if (node.IsDateTimeNode() || node.IsInstantNode()) 49 | { 50 | return DateTimeUtility.RedactDateTimeAndInstantNode( 51 | node, 52 | EnablePartialDatesForRedact 53 | ); 54 | } 55 | 56 | if (node.IsAgeDecimalNode()) 57 | { 58 | return DateTimeUtility.RedactAgeDecimalNode(node, EnablePartialAgesForRedact); 59 | } 60 | 61 | if (node.IsPostalCodeNode()) 62 | { 63 | return PostalCodeUtility.RedactPostalCode( 64 | node, 65 | EnablePartialZipCodesForRedact, 66 | RestrictedZipCodeTabulationAreas 67 | ); 68 | } 69 | 70 | node.Value = null; 71 | var result = new ProcessResult(); 72 | result.AddProcessRecord(AnonymizationOperations.Redact, node); 73 | return result; 74 | } 75 | 76 | public static RedactProcessor Create(AnonymizerConfigurationManager configuratonManager) 77 | { 78 | var parameters = configuratonManager.GetParameterConfiguration(); 79 | return new RedactProcessor( 80 | parameters.EnablePartialDatesForRedact, 81 | parameters.EnablePartialAgesForRedact, 82 | parameters.EnablePartialZipCodesForRedact, 83 | parameters.RestrictedZipCodeTabulationAreas 84 | ); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Processors/Settings/GeneralizeOtherValuesOperation.cs: -------------------------------------------------------------------------------- 1 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors.Settings 2 | { 3 | public enum GeneralizationOtherValuesOperation 4 | { 5 | Redact, 6 | Keep, 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Processors/Settings/PerturbRangeType.cs: -------------------------------------------------------------------------------- 1 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors.Settings 2 | { 3 | public enum PerturbRangeType 4 | { 5 | Fixed, 6 | Proportional, 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Processors/Settings/RuleKeys.cs: -------------------------------------------------------------------------------- 1 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors.Settings 2 | { 3 | internal static class RuleKeys 4 | { 5 | //perturb 6 | internal const string ReplaceWith = "replaceWith"; 7 | internal const string RangeType = "rangeType"; 8 | internal const string RoundTo = "roundTo"; 9 | internal const string Span = "span"; 10 | 11 | //generalize 12 | internal const string Cases = "cases"; 13 | internal const string OtherValues = "otherValues"; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Processors/Settings/SubstituteSetting.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using EnsureThat; 3 | using Microsoft.Health.Fhir.Anonymizer.Core.AnonymizerConfigurations; 4 | 5 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Processors.Settings 6 | { 7 | public class SubstituteSetting 8 | { 9 | public string ReplaceWith { get; set; } 10 | 11 | public static SubstituteSetting CreateFromRuleSettings( 12 | Dictionary ruleSettings 13 | ) 14 | { 15 | EnsureArg.IsNotNull(ruleSettings); 16 | 17 | var replaceWith = ruleSettings.GetValueOrDefault(RuleKeys.ReplaceWith)?.ToString(); 18 | return new SubstituteSetting { ReplaceWith = replaceWith }; 19 | } 20 | 21 | public static void ValidateRuleSettings(Dictionary ruleSettings) 22 | { 23 | if (ruleSettings == null) 24 | { 25 | throw new AnonymizerConfigurationErrorsException( 26 | "Substitute rule should not be null." 27 | ); 28 | } 29 | 30 | if (!ruleSettings.ContainsKey(Constants.PathKey)) 31 | { 32 | throw new AnonymizerConfigurationErrorsException( 33 | "Missing path in FHIR path rule config." 34 | ); 35 | } 36 | 37 | if (!ruleSettings.ContainsKey(RuleKeys.ReplaceWith)) 38 | { 39 | throw new AnonymizerConfigurationErrorsException( 40 | $"Missing replaceWith value in substitution rule at {ruleSettings[Constants.PathKey]}." 41 | ); 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Utility/CryptoHashUtility.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Security.Cryptography; 3 | using System.Text; 4 | 5 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Utility 6 | { 7 | public class CryptoHashUtility 8 | { 9 | public static string ComputeHmacSHA256Hash(string input, string hashKey) 10 | { 11 | if (string.IsNullOrEmpty(input)) 12 | { 13 | return input; 14 | } 15 | 16 | var key = Encoding.UTF8.GetBytes(hashKey); 17 | using var hmac = new HMACSHA256(key); 18 | var plainData = Encoding.UTF8.GetBytes(input); 19 | var hashData = hmac.ComputeHash(plainData); 20 | 21 | return string.Concat(hashData.Select(b => b.ToString("x2"))); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Utility/PostalCodeUtility.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Text.RegularExpressions; 4 | using Hl7.Fhir.ElementModel; 5 | using Microsoft.Health.Fhir.Anonymizer.Core.Extensions; 6 | using Microsoft.Health.Fhir.Anonymizer.Core.Models; 7 | using Microsoft.Health.Fhir.Anonymizer.Core.Processors; 8 | 9 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Utility 10 | { 11 | public class PostalCodeUtility 12 | { 13 | private static readonly string s_replacementDigit = "0"; 14 | private static readonly int s_initialDigitsCount = 3; 15 | 16 | public static ProcessResult RedactPostalCode( 17 | ElementNode node, 18 | bool enablePartialZipCodesForRedact = false, 19 | List restrictedZipCodeTabulationAreas = null 20 | ) 21 | { 22 | var processResult = new ProcessResult(); 23 | if (!node.IsPostalCodeNode() || string.IsNullOrEmpty(node?.Value?.ToString())) 24 | { 25 | return processResult; 26 | } 27 | 28 | if (enablePartialZipCodesForRedact) 29 | { 30 | if ( 31 | restrictedZipCodeTabulationAreas != null 32 | && restrictedZipCodeTabulationAreas.Any(x => 33 | node.Value.ToString().StartsWith(x) 34 | ) 35 | ) 36 | { 37 | node.Value = Regex.Replace(node.Value.ToString(), @"\d", s_replacementDigit); 38 | } 39 | else if (node.Value.ToString().Length >= s_initialDigitsCount) 40 | { 41 | var suffix = node.Value.ToString().Substring(s_initialDigitsCount); 42 | node.Value = 43 | $"{node.Value.ToString().Substring(0, s_initialDigitsCount)}{Regex.Replace(suffix, @"\d", s_replacementDigit)}"; 44 | } 45 | 46 | processResult.AddProcessRecord(AnonymizationOperations.Abstract, node); 47 | } 48 | else 49 | { 50 | node.Value = null; 51 | processResult.AddProcessRecord(AnonymizationOperations.Redact, node); 52 | } 53 | 54 | return processResult; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Validation/AttributeValidator.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.ComponentModel.DataAnnotations; 3 | using Hl7.Fhir.Model; 4 | using Hl7.Fhir.Validation; 5 | 6 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Validation 7 | { 8 | public class AttributeValidator 9 | { 10 | public IEnumerable Validate(Resource resource) 11 | { 12 | var result = new List(); 13 | DotNetAttributeValidation.TryValidate(resource, result, true); 14 | return result; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Validation/ResourceNotValidException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Validation 4 | { 5 | public class ResourceNotValidException : Exception 6 | { 7 | public ResourceNotValidException(string message) 8 | : base(message) { } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Validation/ResourceValidator.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Hl7.Fhir.Model; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Validation 6 | { 7 | public class ResourceValidator 8 | { 9 | private readonly ILogger _logger = AnonymizerLogging.CreateLogger(); 10 | private readonly AttributeValidator _validator = new AttributeValidator(); 11 | 12 | public void ValidateInput(Resource resource) 13 | { 14 | var results = _validator.Validate(resource); 15 | foreach (var error in results) 16 | { 17 | var path = string.IsNullOrEmpty(error.MemberNames?.FirstOrDefault()) 18 | ? string.Empty 19 | : error.MemberNames?.FirstOrDefault(); 20 | _logger.LogDebug( 21 | string.IsNullOrEmpty(resource?.Id) 22 | ? $"The input is non-conformant with FHIR specification: {error.ErrorMessage} for {path} in {resource.TypeName}." 23 | : $"The input of resource ID {resource.Id} is non-conformant with FHIR specification: {error.ErrorMessage} for {path} in {resource.TypeName}." 24 | ); 25 | } 26 | 27 | if (results.Any()) 28 | { 29 | throw new ResourceNotValidException( 30 | "The input is non-conformant with FHIR specification. Please open verbose log for more details. (-v)" 31 | ); 32 | } 33 | } 34 | 35 | public void ValidateOutput(Resource resource) 36 | { 37 | var results = _validator.Validate(resource); 38 | foreach (var error in results) 39 | { 40 | var path = error.MemberNames?.FirstOrDefault() ?? string.Empty; 41 | _logger.LogDebug( 42 | string.IsNullOrEmpty(resource?.Id) 43 | ? $"The output is non-conformant with FHIR specification: {error.ErrorMessage} for {path} in {resource.TypeName}." 44 | : $"The output of resource ID {resource.Id} is non-conformant with FHIR specification: {error.ErrorMessage} for {path} in {resource.TypeName}." 45 | ); 46 | } 47 | 48 | if (results.Any()) 49 | { 50 | throw new ResourceNotValidException( 51 | "The output is non-conformant with FHIR specification. Please open verbose log for more details. (-v)" 52 | ); 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Microsoft.Health.Fhir.Anonymizer.Shared.Core/Visitors/AbstractElementNodeVisitor.cs: -------------------------------------------------------------------------------- 1 | using Hl7.Fhir.ElementModel; 2 | 3 | namespace Microsoft.Health.Fhir.Anonymizer.Core.Visitors 4 | { 5 | public abstract class AbstractElementNodeVisitor 6 | { 7 | public virtual bool Visit(ElementNode node) 8 | { 9 | return true; 10 | } 11 | 12 | public virtual void EndVisit(ElementNode node) { } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace FhirPseudonymizer 4 | { 5 | public static class Program 6 | { 7 | internal static ActivitySource ActivitySource { get; } = 8 | new ActivitySource( 9 | "FhirPseudonymizer", 10 | typeof(Program).Assembly.GetName().Version.ToString() 11 | ); 12 | 13 | public static void Main(string[] args) 14 | { 15 | CreateHostBuilder(args).Build().Run(); 16 | } 17 | 18 | public static IHostBuilder CreateHostBuilder(string[] args) 19 | { 20 | return Host.CreateDefaultBuilder(args) 21 | .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()) 22 | .ConfigureLogging(builder => 23 | builder.AddSimpleConsole(options => 24 | { 25 | options.UseUtcTimestamp = true; 26 | options.IncludeScopes = true; 27 | options.TimestampFormat = "yyyy-MM-ddTHH:mm:ssZ "; 28 | }) 29 | ); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:50416", 7 | "sslPort": 44323 8 | } 9 | }, 10 | "$schema": "http://json.schemastore.org/launchsettings.json", 11 | "profiles": { 12 | "IIS Express": { 13 | "commandName": "IISExpress", 14 | "launchBrowser": true, 15 | "launchUrl": "swagger", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "FhirPseudonymizer": { 21 | "commandName": "Project", 22 | "launchBrowser": true, 23 | "launchUrl": "swagger", 24 | "environmentVariables": { 25 | "ASPNETCORE_ENVIRONMENT": "Development" 26 | }, 27 | "dotnetRunMessages": true, 28 | "applicationUrl": "https://localhost:5001;http://localhost:5000" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Protos/google/api/annotations.proto: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | package google.api; 18 | 19 | import "Protos/google/api/http.proto"; 20 | import "google/protobuf/descriptor.proto"; 21 | 22 | option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; 23 | option java_multiple_files = true; 24 | option java_outer_classname = "AnnotationsProto"; 25 | option java_package = "com.google.api"; 26 | option objc_class_prefix = "GAPI"; 27 | 28 | extend google.protobuf.MethodOptions { 29 | // See `HttpRule`. 30 | HttpRule http = 72295728; 31 | } 32 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Protos/vfps/api/v1/meta.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package vfps.api.v1; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | 7 | option csharp_namespace = "Vfps.Protos"; 8 | 9 | // metadata about an entity 10 | message Meta { 11 | // time when the entity was created 12 | google.protobuf.Timestamp created_at = 1; 13 | // time when the entity was last updated 14 | google.protobuf.Timestamp last_updated_at = 2; 15 | } 16 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Protos/vfps/api/v1/pseudonyms.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package vfps.api.v1; 4 | 5 | import "Protos/google/api/annotations.proto"; 6 | import "Protos/vfps/api/v1/meta.proto"; 7 | 8 | option csharp_namespace = "Vfps.Protos"; 9 | 10 | // service to manage pseudonyms 11 | service PseudonymService { 12 | // create a new pseudonym in the given namespace for an original value 13 | rpc Create(PseudonymServiceCreateRequest) returns (PseudonymServiceCreateResponse) { 14 | option (google.api.http) = { 15 | post: "/v1/namespaces/{namespace}/pseudonyms" 16 | body: "*" 17 | }; 18 | } 19 | 20 | // get information about the given pseudonym. Including its original value. 21 | rpc Get(PseudonymServiceGetRequest) returns (PseudonymServiceGetResponse) { 22 | option (google.api.http) = { 23 | get: "/v1/namespaces/{namespace}/pseudonyms/{pseudonym_value}" 24 | }; 25 | } 26 | } 27 | 28 | // message to fetch details for a given pseudonym 29 | message PseudonymServiceGetRequest { 30 | // the namespace the pseudonym is a part of 31 | string namespace = 1; 32 | // the actual pseudonym 33 | string pseudonym_value = 2; 34 | } 35 | 36 | // response for getting a pseudonym entity 37 | message PseudonymServiceGetResponse { 38 | // the found pseudonym 39 | Pseudonym pseudonym = 1; 40 | } 41 | 42 | // request to pseudonymize and store a given value 43 | message PseudonymServiceCreateRequest { 44 | // the namespace in which the pseudonym should be created 45 | string namespace = 1; 46 | // the original value to be pseudonymized 47 | string original_value = 2; 48 | } 49 | 50 | // response for creating a pseudonym entity 51 | message PseudonymServiceCreateResponse { 52 | // the created pseudonym 53 | Pseudonym pseudonym = 1; 54 | } 55 | 56 | // the pseudonym entity 57 | message Pseudonym { 58 | // metadata about the pseudonym 59 | Meta meta = 1; 60 | // the namespace of the pseudonym 61 | string namespace = 2; 62 | // the original value that was pseudonymized 63 | string original_value = 3; 64 | // the pseudonym created for the original value 65 | string pseudonym_value = 4; 66 | } 67 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Pseudonymization/Entici/EnticiExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Net.Http.Headers; 3 | using System.Text; 4 | using FhirPseudonymizer.Config; 5 | using Polly; 6 | using Polly.Extensions.Http; 7 | using Polly.Retry; 8 | using Prometheus; 9 | 10 | namespace FhirPseudonymizer.Pseudonymization.Entici; 11 | 12 | public static class EnticiExtensions 13 | { 14 | public static IServiceCollection AddEnticiClient( 15 | this IServiceCollection services, 16 | EnticiConfig enticiConfig 17 | ) 18 | { 19 | if (string.IsNullOrWhiteSpace(enticiConfig.Url?.AbsoluteUri)) 20 | { 21 | throw new ValidationException( 22 | "entici is enabled but the backend service URL is unset." 23 | ); 24 | } 25 | 26 | var oAuthConfig = enticiConfig.Auth.OAuth; 27 | 28 | var isOAuthEnabled = oAuthConfig.TokenEndpoint is not null; 29 | if (isOAuthEnabled) 30 | { 31 | services 32 | .AddClientCredentialsTokenManagement() 33 | .AddClient( 34 | $"{EnticiFhirClient.HttpClientName}.oAuth.client", 35 | client => 36 | { 37 | client.TokenEndpoint = oAuthConfig.TokenEndpoint.AbsoluteUri; 38 | 39 | client.ClientId = oAuthConfig.ClientId; 40 | client.ClientSecret = oAuthConfig.ClientSecret; 41 | 42 | client.Scope = oAuthConfig.Scope; 43 | client.Resource = oAuthConfig.Resource; 44 | } 45 | ); 46 | } 47 | 48 | IHttpClientBuilder clientBuilder = null; 49 | if (isOAuthEnabled) 50 | { 51 | clientBuilder = services.AddClientCredentialsHttpClient( 52 | EnticiFhirClient.HttpClientName, 53 | $"{EnticiFhirClient.HttpClientName}.oAuth.client", 54 | client => 55 | { 56 | client.BaseAddress = enticiConfig.Url; 57 | } 58 | ); 59 | } 60 | else 61 | { 62 | clientBuilder = services.AddHttpClient( 63 | EnticiFhirClient.HttpClientName, 64 | (client) => 65 | { 66 | client.BaseAddress = enticiConfig.Url; 67 | 68 | if (!string.IsNullOrEmpty(enticiConfig.Auth.Basic.Username)) 69 | { 70 | var basicAuthString = 71 | $"{enticiConfig.Auth.Basic.Username}:{enticiConfig.Auth.Basic.Password}"; 72 | var byteArray = Encoding.UTF8.GetBytes(basicAuthString); 73 | client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( 74 | "Basic", 75 | Convert.ToBase64String(byteArray) 76 | ); 77 | } 78 | } 79 | ); 80 | } 81 | 82 | clientBuilder 83 | .SetHandlerLifetime(TimeSpan.FromMinutes(5)) 84 | .AddPolicyHandler(GetRetryPolicy(enticiConfig.RequestRetryCount)) 85 | .UseHttpClientMetrics(); 86 | 87 | services.AddTransient(); 88 | 89 | return services; 90 | } 91 | 92 | private static AsyncRetryPolicy GetRetryPolicy(int retryCount = 3) 93 | { 94 | return HttpPolicyExtensions 95 | .HandleTransientHttpError() 96 | .WaitAndRetryAsync( 97 | retryCount, 98 | retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) 99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Pseudonymization/Entici/EnticiFhirClient.cs: -------------------------------------------------------------------------------- 1 | using Hl7.Fhir.Model; 2 | using Hl7.Fhir.Rest; 3 | 4 | namespace FhirPseudonymizer.Pseudonymization.Entici; 5 | 6 | public class EnticiFhirClient : IPseudonymServiceClient 7 | { 8 | public static readonly string HttpClientName = "entici"; 9 | private readonly ILogger logger; 10 | 11 | public EnticiFhirClient(ILogger logger, IHttpClientFactory clientFactory) 12 | { 13 | this.logger = logger; 14 | 15 | ClientFactory = clientFactory; 16 | } 17 | 18 | private IHttpClientFactory ClientFactory { get; } 19 | 20 | public async Task GetOrCreatePseudonymFor( 21 | string value, 22 | string domain, 23 | IReadOnlyDictionary settings = null 24 | ) 25 | { 26 | ArgumentNullException.ThrowIfNull(settings); 27 | 28 | var hasEnticiSettings = settings.TryGetValue("entici", out var enticiSettingsObject); 29 | if (!hasEnticiSettings || enticiSettingsObject is null) 30 | { 31 | throw new InvalidOperationException( 32 | "Pseudonymization using Entici requires a special settings object as part of the rule definition." 33 | ); 34 | } 35 | 36 | var enticiSettings = enticiSettingsObject as IReadOnlyDictionary; 37 | 38 | // this should throw if resourceType is unset. 39 | var resourceType = Enum.Parse(enticiSettings["resourceType"].ToString()); 40 | 41 | var request = new EnticiPseudonymizationRequest 42 | { 43 | Identifier = new Identifier(domain, value), 44 | ResourceType = new Code(resourceType.ToString()), 45 | }; 46 | 47 | var hasTargetSystem = enticiSettings.TryGetValue("project", out var targetSystemObject); 48 | if (hasTargetSystem) 49 | { 50 | request.Project = new FhirString(targetSystemObject.ToString()); 51 | } 52 | 53 | var client = ClientFactory.CreateClient(HttpClientName); 54 | 55 | using var fhirClient = new FhirClient( 56 | client.BaseAddress, 57 | client, 58 | settings: new() { PreferredFormat = ResourceFormat.Json } 59 | ); 60 | 61 | var response = await fhirClient.WholeSystemOperationAsync( 62 | "pseudonymize", 63 | request.ToFhirParameters() 64 | ); 65 | 66 | if (response is Parameters responseParameters) 67 | { 68 | var pseudonym = responseParameters.GetSingleValue("pseudonym"); 69 | ArgumentException.ThrowIfNullOrEmpty(pseudonym?.Value); 70 | return pseudonym.Value; 71 | } 72 | else 73 | { 74 | throw new InvalidOperationException( 75 | "Pseudonymization failed. Entici backend did not return a response of type 'Parameters'." 76 | ); 77 | } 78 | } 79 | 80 | public Task GetOriginalValueFor( 81 | string pseudonym, 82 | string domain, 83 | IReadOnlyDictionary settings = null 84 | ) 85 | { 86 | throw new NotImplementedException( 87 | "De-Pseudonymization is not yet implemented for the Entici backend." 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Pseudonymization/Entici/EnticiPseudonymizationRequest.cs: -------------------------------------------------------------------------------- 1 | using FhirParametersGenerator; 2 | using Hl7.Fhir.Model; 3 | 4 | namespace FhirPseudonymizer.Pseudonymization.Entici; 5 | 6 | [GenerateFhirParameters] 7 | public class EnticiPseudonymizationRequest 8 | { 9 | public Identifier Identifier { get; set; } 10 | public Code ResourceType { get; set; } 11 | public FhirString Project { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Pseudonymization/GPas/GPasExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Net.Http.Headers; 3 | using System.Text; 4 | using FhirPseudonymizer.Config; 5 | using Polly; 6 | using Polly.Extensions.Http; 7 | using Polly.Retry; 8 | using Prometheus; 9 | 10 | namespace FhirPseudonymizer.Pseudonymization.GPas; 11 | 12 | public static class GPasExtensions 13 | { 14 | public static IServiceCollection AddGPasClient( 15 | this IServiceCollection services, 16 | GPasConfig gPasConfig 17 | ) 18 | { 19 | if (string.IsNullOrWhiteSpace(gPasConfig.Url?.AbsoluteUri)) 20 | { 21 | throw new ValidationException("gPAS is enabled but the backend service URL is unset."); 22 | } 23 | 24 | var oAuthConfig = gPasConfig.Auth.OAuth; 25 | 26 | var isOAuthEnabled = oAuthConfig.TokenEndpoint is not null; 27 | if (isOAuthEnabled) 28 | { 29 | services 30 | .AddClientCredentialsTokenManagement() 31 | .AddClient( 32 | $"{GPasFhirClient.HttpClientName}.oAuth.client", 33 | client => 34 | { 35 | client.TokenEndpoint = oAuthConfig.TokenEndpoint.AbsoluteUri; 36 | 37 | client.ClientId = oAuthConfig.ClientId; 38 | client.ClientSecret = oAuthConfig.ClientSecret; 39 | 40 | client.Scope = oAuthConfig.Scope; 41 | client.Resource = oAuthConfig.Resource; 42 | } 43 | ); 44 | } 45 | 46 | IHttpClientBuilder clientBuilder = null; 47 | if (isOAuthEnabled) 48 | { 49 | clientBuilder = services.AddClientCredentialsHttpClient( 50 | GPasFhirClient.HttpClientName, 51 | $"{GPasFhirClient.HttpClientName}.oAuth.client", 52 | client => 53 | { 54 | client.BaseAddress = gPasConfig.Url; 55 | } 56 | ); 57 | } 58 | else 59 | { 60 | clientBuilder = services.AddHttpClient( 61 | GPasFhirClient.HttpClientName, 62 | (client) => 63 | { 64 | client.BaseAddress = gPasConfig.Url; 65 | 66 | if (!string.IsNullOrEmpty(gPasConfig.Auth.Basic.Username)) 67 | { 68 | var basicAuthString = 69 | $"{gPasConfig.Auth.Basic.Username}:{gPasConfig.Auth.Basic.Password}"; 70 | var byteArray = Encoding.UTF8.GetBytes(basicAuthString); 71 | client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( 72 | "Basic", 73 | Convert.ToBase64String(byteArray) 74 | ); 75 | } 76 | } 77 | ); 78 | } 79 | 80 | clientBuilder 81 | .SetHandlerLifetime(TimeSpan.FromMinutes(5)) 82 | .AddPolicyHandler(GetRetryPolicy(gPasConfig.RequestRetryCount)) 83 | .UseHttpClientMetrics(); 84 | 85 | services.AddTransient(); 86 | 87 | return services; 88 | } 89 | 90 | private static AsyncRetryPolicy GetRetryPolicy(int retryCount = 3) 91 | { 92 | return HttpPolicyExtensions 93 | .HandleTransientHttpError() 94 | .WaitAndRetryAsync( 95 | retryCount, 96 | retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Pseudonymization/IPseudonymServiceClient.cs: -------------------------------------------------------------------------------- 1 | namespace FhirPseudonymizer.Pseudonymization; 2 | 3 | public interface IPseudonymServiceClient 4 | { 5 | Task GetOriginalValueFor( 6 | string pseudonym, 7 | string domain, 8 | IReadOnlyDictionary settings = null 9 | ); 10 | Task GetOrCreatePseudonymFor( 11 | string value, 12 | string domain, 13 | IReadOnlyDictionary settings = null 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Pseudonymization/NoopPseudonymServiceClient.cs: -------------------------------------------------------------------------------- 1 | namespace FhirPseudonymizer.Pseudonymization; 2 | 3 | public class NoopPseudonymServiceClient : IPseudonymServiceClient 4 | { 5 | public Task GetOrCreatePseudonymFor( 6 | string value, 7 | string domain, 8 | IReadOnlyDictionary settings = null 9 | ) 10 | { 11 | throw new InvalidOperationException( 12 | "PseudonymizationService config is set to 'None' but configuration used pseudonymization method." 13 | ); 14 | } 15 | 16 | public Task GetOriginalValueFor( 17 | string pseudonym, 18 | string domain, 19 | IReadOnlyDictionary settings = null 20 | ) 21 | { 22 | throw new InvalidOperationException( 23 | "PseudonymizationService config is set to 'None' but configuration used pseudonymization method." 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Pseudonymization/PseudonymizationServiceType.cs: -------------------------------------------------------------------------------- 1 | namespace FhirPseudonymizer.Pseudonymization; 2 | 3 | public enum PseudonymizationServiceType 4 | { 5 | Vfps, 6 | gPAS, 7 | entici, 8 | None, 9 | } 10 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Pseudonymization/Vfps/VfpsExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Text; 3 | using FhirPseudonymizer.Config; 4 | using Grpc.Core; 5 | using Grpc.Net.Client.Configuration; 6 | using Vfps.Protos; 7 | 8 | namespace FhirPseudonymizer.Pseudonymization.Vfps; 9 | 10 | public static class VfpsExtensions 11 | { 12 | public static IServiceCollection AddVfpsClient( 13 | this IServiceCollection services, 14 | VfpsConfig vfpsConfig 15 | ) 16 | { 17 | if (string.IsNullOrWhiteSpace(vfpsConfig.Address.AbsoluteUri)) 18 | { 19 | throw new ValidationException( 20 | "Vfps is enabled but the backend service address is unset." 21 | ); 22 | } 23 | 24 | var defaultMethodConfig = new MethodConfig 25 | { 26 | Names = { MethodName.Default }, 27 | RetryPolicy = new RetryPolicy 28 | { 29 | MaxAttempts = 3, 30 | InitialBackoff = TimeSpan.FromSeconds(1), 31 | MaxBackoff = TimeSpan.FromSeconds(5), 32 | BackoffMultiplier = 1.5, 33 | RetryableStatusCodes = { StatusCode.Unavailable, StatusCode.Internal }, 34 | }, 35 | }; 36 | 37 | services 38 | .AddGrpcClient(o => 39 | o.Address = vfpsConfig.Address 40 | ) 41 | .ConfigureChannel(o => 42 | { 43 | o.ServiceConfig = new ServiceConfig { MethodConfigs = { defaultMethodConfig } }; 44 | 45 | if (vfpsConfig.UseTls) 46 | { 47 | o.Credentials = new SslCredentials(); 48 | } 49 | else 50 | { 51 | o.Credentials = ChannelCredentials.Insecure; 52 | } 53 | 54 | o.UnsafeUseInsecureChannelCallCredentials = 55 | vfpsConfig.UnsafeUseInsecureChannelCallCredentials; 56 | }) 57 | .AddCallCredentials( 58 | (_, metadata) => 59 | { 60 | if (!string.IsNullOrEmpty(vfpsConfig.Auth.Basic.Username)) 61 | { 62 | var basicAuthString = 63 | $"{vfpsConfig.Auth.Basic.Username}:{vfpsConfig.Auth.Basic.Password}"; 64 | var byteArray = Encoding.UTF8.GetBytes(basicAuthString); 65 | var basicAuthValue = Convert.ToBase64String(byteArray); 66 | 67 | metadata.Add("Authorization", $"Basic {basicAuthValue}"); 68 | } 69 | 70 | return Task.CompletedTask; 71 | } 72 | ); 73 | 74 | services.AddTransient(); 75 | 76 | return services; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/Pseudonymization/Vfps/VfpsPseudonymServiceClient.cs: -------------------------------------------------------------------------------- 1 | using Vfps.Protos; 2 | 3 | namespace FhirPseudonymizer.Pseudonymization.Vfps; 4 | 5 | public class VfpsPseudonymServiceClient : IPseudonymServiceClient 6 | { 7 | private readonly ILogger logger; 8 | 9 | public VfpsPseudonymServiceClient( 10 | ILogger logger, 11 | PseudonymService.PseudonymServiceClient client 12 | ) 13 | { 14 | Client = client; 15 | this.logger = logger; 16 | } 17 | 18 | private PseudonymService.PseudonymServiceClient Client { get; } 19 | 20 | public async Task GetOrCreatePseudonymFor( 21 | string value, 22 | string domain, 23 | IReadOnlyDictionary settings = null 24 | ) 25 | { 26 | var request = new PseudonymServiceCreateRequest 27 | { 28 | OriginalValue = value, 29 | Namespace = domain, 30 | }; 31 | 32 | var response = await Client.CreateAsync(request); 33 | 34 | return response.Pseudonym.PseudonymValue; 35 | } 36 | 37 | public async Task GetOriginalValueFor( 38 | string pseudonym, 39 | string domain, 40 | IReadOnlyDictionary settings = null 41 | ) 42 | { 43 | var request = new PseudonymServiceGetRequest 44 | { 45 | PseudonymValue = pseudonym, 46 | Namespace = domain, 47 | }; 48 | 49 | try 50 | { 51 | var response = await Client.GetAsync(request); 52 | return response.Pseudonym.OriginalValue; 53 | } 54 | catch (Exception exc) 55 | { 56 | logger.LogWarning( 57 | exc, 58 | "Failed to de-pseudonymize {Pseudonym}. Returning pseudonymized value.", 59 | pseudonym 60 | ); 61 | return pseudonym; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/TracingConfigurationExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using OpenTelemetry; 3 | using OpenTelemetry.Exporter; 4 | using OpenTelemetry.Instrumentation.AspNetCore; 5 | using OpenTelemetry.Metrics; 6 | using OpenTelemetry.Resources; 7 | using OpenTelemetry.Trace; 8 | 9 | namespace FhirPseudonymizer; 10 | 11 | public static class TracingConfigurationExtensions 12 | { 13 | public static IServiceCollection AddTracing( 14 | this IServiceCollection services, 15 | IConfiguration configuration 16 | ) 17 | { 18 | var assembly = Assembly.GetExecutingAssembly().GetName(); 19 | var assemblyVersion = assembly.Version?.ToString() ?? "unknown"; 20 | var tracingExporter = 21 | configuration.GetValue("Tracing:Exporter")?.ToLowerInvariant() ?? "jaeger"; 22 | var serviceName = 23 | configuration.GetValue("Tracing:ServiceName", assembly.Name) ?? "fhir-pseudonymizer"; 24 | 25 | // Build a resource configuration action to set service information. 26 | void configureResource(ResourceBuilder r) => 27 | r.AddService( 28 | serviceName: serviceName, 29 | serviceVersion: assemblyVersion, 30 | serviceInstanceId: Environment.MachineName 31 | ); 32 | 33 | var rootSamplerType = configuration.GetValue("Tracing:RootSampler", "AlwaysOnSampler"); 34 | var samplingRatio = configuration.GetValue("Tracing:SamplingProbability", 0.1d); 35 | 36 | Sampler rootSampler = rootSamplerType switch 37 | { 38 | nameof(AlwaysOnSampler) => new AlwaysOnSampler(), 39 | nameof(AlwaysOffSampler) => new AlwaysOffSampler(), 40 | nameof(TraceIdRatioBasedSampler) => new TraceIdRatioBasedSampler(samplingRatio), 41 | _ => throw new ArgumentException($"Unsupported sampler type '{rootSamplerType}'"), 42 | }; 43 | 44 | services 45 | .AddOpenTelemetry() 46 | .ConfigureResource(configureResource) 47 | .WithTracing(tracingBuilder => 48 | { 49 | tracingBuilder 50 | .SetSampler(new ParentBasedSampler(rootSampler)) 51 | .AddSource(Program.ActivitySource.Name) 52 | .AddGrpcClientInstrumentation() 53 | .AddHttpClientInstrumentation() 54 | .AddAspNetCoreInstrumentation(o => 55 | { 56 | o.Filter = (r) => 57 | { 58 | var ignoredPaths = new[] 59 | { 60 | "/healthz", 61 | "/readyz", 62 | "/livez", 63 | "/fhir/metadata", 64 | }; 65 | 66 | var path = r.Request.Path.Value!; 67 | return !ignoredPaths.Any(path.Contains); 68 | }; 69 | }); 70 | 71 | services.Configure( 72 | configuration.GetSection("Tracing:AspNetCoreInstrumentation") 73 | ); 74 | 75 | switch (tracingExporter) 76 | { 77 | case "jaeger": 78 | tracingBuilder.AddJaegerExporter(); 79 | services.Configure( 80 | configuration.GetSection("Tracing:Jaeger") 81 | ); 82 | break; 83 | 84 | case "otlp": 85 | var endpoint = 86 | configuration.GetValue("Tracing:Otlp:Endpoint") 87 | ?? throw new ArgumentException("Missing OTLP exporter endpoint URL"); 88 | 89 | tracingBuilder.AddOtlpExporter(otlpOptions => 90 | otlpOptions.Endpoint = new Uri(endpoint) 91 | ); 92 | break; 93 | } 94 | }); 95 | 96 | return services; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/anonymization.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fhirVersion: R4 3 | fhirPathRules: 4 | - path: nodesByType('HumanName') 5 | method: redact 6 | - path: nodesByType('Identifier').where(type.coding.where(system='http://terminology.hl7.org/CodeSystem/v2-0203' and code='VN').exists()).value 7 | method: encrypt 8 | - path: nodesByType('Identifier').where(type.coding.where(system='http://terminology.hl7.org/CodeSystem/v2-0203' and code='MR').exists()).value 9 | method: encrypt 10 | - path: nodesByType('id') 11 | method: cryptoHash 12 | parameters: 13 | dateShiftKey: "" 14 | dateShiftScope: resource 15 | cryptoHashKey: fhir-pseudonymizer 16 | # must be of a valid AES key length; here the key is padded to 192 bits 17 | encryptKey: fhir-pseudonymizer000000 18 | enablePartialAgesForRedact: true 19 | enablePartialDatesForRedact: true 20 | enablePartialZipCodesForRedact: true 21 | restrictedZipCodeTabulationAreas: [] 22 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information", 7 | "System.Net.Http.HttpClient": "Information", 8 | "Fhir.Anonymizer.Core": "Debug" 9 | } 10 | }, 11 | "UseSystemTextJsonFhirSerializer": true, 12 | "AnonymizationEngineConfigPath": "anonymization.yaml", 13 | "PseudonymizationService": "gPAS", 14 | "ApiKey": "dev", 15 | "Tracing": { 16 | "Enabled": true 17 | }, 18 | "gPAS": { 19 | "Url": "http://localhost:1080/ttp-fhir/fhir/gpas/", 20 | "Version": "2023.1.0" 21 | }, 22 | "Vfps": { 23 | "Address": "dns:///localhost:8081" 24 | }, 25 | "Entici": { 26 | "Url": "http://localhost:1080/entici/", 27 | "Auth": { 28 | "OAuth": { 29 | "TokenEndpoint": "", 30 | "ClientId": "fhir-pseudonymizer", 31 | "ClientSecret": "", 32 | "Scope": "", 33 | "Resource": "entici" 34 | } 35 | } 36 | }, 37 | "MetricsPort": 8082 38 | } 39 | -------------------------------------------------------------------------------- /src/FhirPseudonymizer/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information", 7 | "System.Net.Http.HttpClient": "Warning", 8 | "AspNetCore.Authentication": "Warning" 9 | } 10 | }, 11 | "AllowedHosts": "*", 12 | "AnonymizationEngineConfigPath": "/etc/anonymization.yaml", 13 | "AnonymizationEngineConfigInline": "", 14 | "PseudonymizationService": "gPAS", 15 | "UseSystemTextJsonFhirSerializer": false, 16 | "ApiKey": "", 17 | "Tracing": { 18 | "ServiceName": "fhir-pseudonymizer", 19 | "Enabled": false, 20 | "Jaeger": { 21 | "AgentHost": "localhost", 22 | "AgentPort": 6831, 23 | "MaxPayloadSizeInBytes": 65000 24 | }, 25 | "AspNetCoreInstrumentation": { 26 | "RecordException": "true" 27 | } 28 | }, 29 | "EnableMetrics": true, 30 | "MetricsPort": 8081, 31 | "gPAS": { 32 | "Url": "", 33 | "RequestRetryCount": 3, 34 | "Auth": { 35 | "Basic": { 36 | "Username": "", 37 | "Password": "" 38 | }, 39 | "OAuth": { 40 | "TokenEndpoint": "", 41 | "ClientId": "", 42 | "ClientSecret": "", 43 | "Scope": "", 44 | "Resource": "" 45 | } 46 | }, 47 | "Cache": { 48 | "SizeLimit": 2048, 49 | "SlidingExpirationMinutes": 5, 50 | "AbsoluteExpirationMinutes": 30 51 | }, 52 | "Version": "1.10.1" 53 | }, 54 | "Vfps": { 55 | "Address": "", 56 | "UnsafeUseInsecureChannelCallCredentials": true, 57 | "UseTls": false 58 | }, 59 | "entici": { 60 | "Url": "", 61 | "RequestRetryCount": 3, 62 | "Auth": { 63 | "Basic": { 64 | "Username": "", 65 | "Password": "" 66 | }, 67 | "OAuth": { 68 | "TokenEndpoint": "", 69 | "ClientId": "", 70 | "ClientSecret": "", 71 | "Scope": "", 72 | "Resource": "" 73 | } 74 | } 75 | }, 76 | "Features": { 77 | "ConditionalReferencePseudonymization": false 78 | }, 79 | "Anonymization": { 80 | "CryptoHashKey": "", 81 | "EncryptKey": "", 82 | "ShouldAddSecurityTag": true 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /stylecop.ruleset.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/chaos/argo-workflows-values.yaml: -------------------------------------------------------------------------------- 1 | controller: 2 | workflowNamespaces: 3 | - default 4 | - argo-workflows 5 | - fhir-pseudonymizer 6 | 7 | server: 8 | extraArgs: [--auth-mode=server] 9 | -------------------------------------------------------------------------------- /tests/chaos/chaos-mesh-rbac.yaml: -------------------------------------------------------------------------------- 1 | kind: ServiceAccount 2 | apiVersion: v1 3 | metadata: 4 | namespace: chaos-mesh 5 | name: chaos-mesh-cluster-manager 6 | --- 7 | kind: ServiceAccount 8 | apiVersion: v1 9 | metadata: 10 | namespace: fhir-pseudonymizer 11 | name: chaos-mesh-cluster-manager 12 | --- 13 | kind: ClusterRole 14 | apiVersion: rbac.authorization.k8s.io/v1 15 | metadata: 16 | name: role-chaos-mesh-cluster-manager 17 | rules: 18 | - apiGroups: [""] 19 | resources: ["pods", "namespaces"] 20 | verbs: ["get", "list", "watch", "create", "delete", "patch", "update"] 21 | - apiGroups: ["chaos-mesh.org"] 22 | resources: ["*"] 23 | verbs: ["get", "list", "watch", "create", "delete", "patch", "update"] 24 | --- 25 | apiVersion: rbac.authorization.k8s.io/v1 26 | kind: ClusterRoleBinding 27 | metadata: 28 | name: bind-chaos-mesh-cluster-manager 29 | subjects: 30 | - kind: ServiceAccount 31 | name: chaos-mesh-cluster-manager 32 | namespace: chaos-mesh 33 | - kind: ServiceAccount 34 | name: chaos-mesh-cluster-manager 35 | namespace: fhir-pseudonymizer 36 | roleRef: 37 | apiGroup: rbac.authorization.k8s.io 38 | kind: ClusterRole 39 | name: role-chaos-mesh-cluster-manager 40 | --- 41 | apiVersion: rbac.authorization.k8s.io/v1 42 | kind: ClusterRoleBinding 43 | metadata: 44 | name: bind-chaos-mesh-cluster-manager-to-argo-workflow 45 | subjects: 46 | - kind: ServiceAccount 47 | name: chaos-mesh-cluster-manager 48 | namespace: fhir-pseudonymizer 49 | roleRef: 50 | apiGroup: rbac.authorization.k8s.io 51 | kind: ClusterRole 52 | name: argo-workflows-admin 53 | -------------------------------------------------------------------------------- /tests/chaos/chaos.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: chaos-mesh.org/v1alpha1 2 | kind: Schedule 3 | metadata: 4 | namespace: fhir-pseudonymizer 5 | name: fail-one-of-the-fhir-pseudonymizer-pods 6 | spec: 7 | schedule: "@every 2m" 8 | concurrencyPolicy: Forbid 9 | historyLimit: 1 10 | type: PodChaos 11 | podChaos: 12 | selector: 13 | namespaces: 14 | - fhir-pseudonymizer 15 | labelSelectors: 16 | app.kubernetes.io/name: fhir-pseudonymizer 17 | app.kubernetes.io/instance: fhir-pseudonymizer 18 | mode: one 19 | action: pod-failure 20 | duration: 30s 21 | --- 22 | apiVersion: chaos-mesh.org/v1alpha1 23 | kind: Schedule 24 | metadata: 25 | namespace: fhir-pseudonymizer 26 | name: fail-one-of-the-vfps-pods 27 | spec: 28 | schedule: "@every 2m" 29 | concurrencyPolicy: Forbid 30 | historyLimit: 1 31 | type: PodChaos 32 | podChaos: 33 | selector: 34 | namespaces: 35 | - fhir-pseudonymizer 36 | labelSelectors: 37 | app.kubernetes.io/name: vfps 38 | app.kubernetes.io/instance: fhir-pseudonymizer 39 | mode: one 40 | action: pod-failure 41 | duration: 30s 42 | -------------------------------------------------------------------------------- /tests/chaos/fhir-pseudonymizer-values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 3 2 | 3 | image: 4 | tag: v2.22.10 # x-release-please-version 5 | 6 | anonymizationConfig: | 7 | --- 8 | fhirVersion: R4 9 | fhirPathRules: 10 | - path: Resource.id 11 | method: cryptoHash 12 | - path: nodesByType('HumanName') 13 | method: redact 14 | - path: nodesByType('Identifier').where(type.coding.system='http://terminology.hl7.org/CodeSystem/v2-0203' and type.coding.code='MR').value 15 | method: pseudonymize 16 | namespace: stress 17 | parameters: 18 | dateShiftKey: "" 19 | dateShiftScope: resource 20 | cryptoHashKey: fhir-pseudonymizer 21 | # must be of a valid AES key length; here the key is padded to 192 bits 22 | encryptKey: fhir-pseudonymizer000000 23 | enablePartialAgesForRedact: true 24 | enablePartialDatesForRedact: true 25 | enablePartialZipCodesForRedact: true 26 | restrictedZipCodeTabulationAreas: [] 27 | 28 | pseudonymizationService: Vfps 29 | 30 | vfps: 31 | enabled: true 32 | replicaCount: 3 33 | postgresql: 34 | auth: 35 | database: "vfps" 36 | appsettings: | 37 | { 38 | "Init": { 39 | "v1": { 40 | "Namespaces": [ 41 | { 42 | "Name": "stress", 43 | "Description": "a namespace for stress testing", 44 | "PseudonymGenerationMethod": "Sha256HexEncoded", 45 | "PseudonymLength": 64, 46 | "PseudonymPrefix": "stress-" 47 | } 48 | ] 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/chaos/workflow.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/argoproj/argo-workflows/v3.4.3/api/jsonschema/schema.json 2 | apiVersion: argoproj.io/v1alpha1 3 | kind: Workflow 4 | metadata: 5 | generateName: fhir-pseudonymizer-chaos-workflow- 6 | spec: 7 | entrypoint: run-chaos-and-test 8 | serviceAccountName: chaos-mesh-cluster-manager 9 | onExit: exit-handler 10 | templates: 11 | - name: test 12 | container: 13 | image: ghcr.io/miracum/fhir-pseudonymizer/stress-test:v1 14 | imagePullPolicy: IfNotPresent 15 | command: 16 | - dotnet 17 | args: 18 | - test 19 | - /opt/fhir-pseudonymizer-stress/FhirPseudonymizer.StressTests.dll 20 | - -l 21 | - console;verbosity=detailed 22 | env: 23 | - name: FHIR_PSEUDONYMIZER_BASE_URL 24 | value: http://fhir-pseudonymizer:8080/fhir 25 | securityContext: 26 | allowPrivilegeEscalation: false 27 | capabilities: 28 | drop: 29 | - ALL 30 | privileged: false 31 | # currently running into 32 | # when running as non-root. 33 | runAsNonRoot: false 34 | 35 | - name: install-chaos 36 | container: 37 | image: ghcr.io/miracum/fhir-pseudonymizer/stress-test:v1 38 | imagePullPolicy: IfNotPresent 39 | command: 40 | - kubectl 41 | args: 42 | - apply 43 | - -f 44 | - /tmp/chaos.yaml 45 | securityContext: 46 | allowPrivilegeEscalation: false 47 | capabilities: 48 | drop: 49 | - ALL 50 | privileged: false 51 | runAsNonRoot: true 52 | runAsUser: 65532 53 | runAsGroup: 65532 54 | 55 | - name: delete-chaos 56 | container: 57 | image: ghcr.io/miracum/fhir-pseudonymizer/stress-test:v1 58 | imagePullPolicy: IfNotPresent 59 | command: 60 | - kubectl 61 | args: 62 | - delete 63 | - -f 64 | - /tmp/chaos.yaml 65 | securityContext: 66 | allowPrivilegeEscalation: false 67 | capabilities: 68 | drop: 69 | - ALL 70 | privileged: false 71 | runAsNonRoot: true 72 | runAsUser: 65532 73 | runAsGroup: 65532 74 | 75 | - name: exit-handler 76 | steps: 77 | - - name: delete-chaos 78 | template: delete-chaos 79 | 80 | - name: run-chaos-and-test 81 | steps: 82 | - - name: install-chaos 83 | template: install-chaos 84 | - - name: test 85 | template: test 86 | -------------------------------------------------------------------------------- /tests/iter8/values.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | limits: 3 | cpu: 1000m 4 | memory: 512Mi 5 | requests: 6 | cpu: 500m 7 | memory: 512Mi 8 | 9 | pseudonymizationService: Vfps 10 | 11 | # the namespaces are created by the iter8 test. 12 | # see ./experiment.yaml 13 | vfps: 14 | enabled: true 15 | 16 | anonymizationConfig: | 17 | --- 18 | fhirVersion: R4 19 | fhirPathRules: 20 | - path: nodesByType('HumanName') 21 | method: redact 22 | - path: nodesByType('Identifier').where(type.coding.system='http://terminology.hl7.org/CodeSystem/v2-0203' and type.coding.code='VN').value 23 | method: pseudonymize 24 | namespace: vns 25 | - path: nodesByType('Identifier').where(type.coding.system='http://terminology.hl7.org/CodeSystem/v2-0203' and type.coding.code='MR').value 26 | method: pseudonymize 27 | namespace: mrns 28 | parameters: 29 | dateShiftKey: "" 30 | dateShiftScope: resource 31 | cryptoHashKey: fhir-pseudonymizer 32 | # must be of a valid AES key length; here the key is padded to 192 bits 33 | encryptKey: fhir-pseudonymizer000000 34 | enablePartialAgesForRedact: true 35 | enablePartialDatesForRedact: true 36 | enablePartialZipCodesForRedact: true 37 | restrictedZipCodeTabulationAreas: [] 38 | -------------------------------------------------------------------------------- /trivy.yaml: -------------------------------------------------------------------------------- 1 | scan: 2 | skip-dirs: 3 | - tests/ 4 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 2.22.10 2 | --------------------------------------------------------------------------------