diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4db2945..5fbf5fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
+## [1.2.0] - 16-07-2026
+### Added
+- Added configuration option for BaseUrl to specify the CAPP12 API endpoint
+- Added configuration option CompliantCertificatesOnly to filter certificates by compliance status
+- Added configuration option IncludeCertificateCodes to include certificate codes in the output
+- Enhanced compliance logic for contract and person mappings
+
+### Changed
+- Refactored persons.ps1 for improved logging and error handling
+- Enhanced user data processing and compliance checks
+- Modified validation requirements in mapping.json to improve data quality
+- Updated HistoricalDays configuration placeholder for better clarity
+- Updated RequiredCertificatesOnly default value in configuration
+- Improved README.md with clearer usage instructions, requirements, and feature descriptions
+
## [1.1.0] - 15-06-2026
### Added
- Added the option to only import certificates that are required to be compliant.
diff --git a/README.md b/README.md
index c435e69..7754d2d 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,10 @@
# HelloID-Conn-Prov-Source-CAPP12
-
-
> [!IMPORTANT]
> This repository contains the connector and configuration code only. The implementer is responsible to acquire the connection details such as username, password, certificate, etc. You might even need to sign a contract or agreement with the supplier before implementing this connector. Please contact the client's application manager to coordinate the connector requirements.
->
-> [!WARNING]
-> This connector has not been tested on a Capp12 environment in combination with HelloID. Therefore, changes may have to be made accordingly.
-
+
## Table of contents
@@ -19,11 +12,18 @@
- [HelloID-Conn-Prov-Source-CAPP12](#helloid-conn-prov-source-capp12)
- [Table of contents](#table-of-contents)
- [Introduction](#introduction)
+ - [Supported features](#supported-features)
- [Getting started](#getting-started)
- - [Prerequisites](#prerequisites)
- - [Connection settings](#connection-settings)
+ - [HelloID Icon URL](#helloid-icon-url)
+ - [Requirements](#requirements)
+ - [CAPP12 API Access](#capp12-api-access)
+ - [Configuration settings](#configuration-settings)
- [Mapping](#mapping)
- [Remarks](#remarks)
+ - [HelloID contract limit](#helloid-contract-limit)
+ - [Business Rules limitations](#business-rules-limitations)
+ - [Certificate filtering logic](#certificate-filtering-logic)
+ - [Development resources](#development-resources)
- [API endpoints](#api-endpoints)
- [API documentation](#api-documentation)
- [Getting help](#getting-help)
@@ -31,65 +31,185 @@
## Introduction
-_HelloID-Conn-Prov-Source-CAPP12_ is a _Source_ connector. _CAPP12_ provides a set of REST API's that allow you to programmatically interact with its data.
+_HelloID-Conn-Prov-Source-CAPP12_ is a _Source_ connector for CAPP12 Learning Management System. CAPP12 provides a set of REST API's that allow you to programmatically interact with its data.
+
+This connector is designed to be used as an aggregation source in combination with a primary HR source system. The HR system provides the base persons, while this connector imports certificate compliance data as contracts. This contract data can then be used in HelloID Business Rules and Dynamic Permissions to manage access and entitlements in target systems.
+
+### Supported features
-This connector does not import HR data, it only imports basic user identifying information and educational certificate compliance status information. The connector can be used in combination with a HR import to create accounts based on the user identifying information and assigns contracts to these accounts based on the compliance status information.
+The following features are available:
+
+| Feature | Supported | Description | Notes |
+| ------------------ | --------- | ------------------------------------------------- | --------------------------------------------------- |
+| Persons import | ✅ | Import persons with certificate data as contracts | User code (employee number) used as aggregation key |
+| Departments import | ❌ | - | Not available in CAPP12 API |
## Getting started
-### Prerequisites
+### HelloID Icon URL
+
+URL of the icon used for the HelloID Provisioning source system.
+
+```
+https://raw.githubusercontent.com/Tools4everBV/HelloID-Conn-Prov-Source-CAPP12/refs/heads/main/Icon.png
+```
+
+### Requirements
+
+**Primary HR source system:**
+This connector requires a primary HR source system that imports persons into HelloID. The CAPP12 connector acts as an aggregation source that adds certificate data as contracts to existing persons.
+
+**Person aggregation:**
+- Person aggregation must be configured in HelloID (see [HelloID documentation](https://docs.helloid.com/en/provisioning/Source-systems/powershell-v2-Source-systems.html#person-aggregation))
+- The aggregation key used for CAPP12 is the **user code** (employee number)
+- **Important:** HelloID supports only one aggregation key across all source systems. If your primary HR system uses a different aggregation key than employee number, this connector cannot be used for person aggregation
+- Ensure the user codes in CAPP12 match the employee numbers in your primary HR system
+
+**CAPP12 API credentials:**
+- CAPP12 instance with API access
+- Client ID and Client Secret with appropriate scopes
+
+### CAPP12 API Access
-Configure person aggregation in HelloID. This can be done by following the steps in the [HelloID documentation](https://docs.helloid.com/en/provisioning/Source-systems/powershell-v2-Source-systems.html#person-aggregation).
+**Required from CAPP12:**
+1. **Base URL** - Your CAPP12 instance URL (e.g., `https://yourcompany.capp12.nl`)
+2. **Client ID** - OAuth2 client identifier
+3. **Client Secret** - OAuth2 client secret
+4. **API Scope** - Ensure the client has `api/compliance` scope enabled
-### Connection settings
+**How to obtain credentials:**
+- Contact your CAPP12 application manager or support
+- Request API access for HelloID integration
+- Verify the client credentials have read access to:
+ - `/api/v1/users` - User information
+ - `/api/v3/compliance_status.csv` - Required certificate status
+ - `/api/v3/achievement_status.csv` - Achieved certificate status
+
+### Configuration settings
The following settings are required to connect to the API.
-| Setting | Description | Mandatory |
-| -------- | ---------------------------------- | --------- |
-| ClientId | The ClientId to connect to the API | Yes |
-| ClientSecret | The ClientSecret to connect to the API | Yes |
-| BaseUrl | The URL to the API | Yes |
-| HistoricalDays | Only certificates that were not expired X days ago will be imported. | Yes |
-| RequiredCertificatesOnly| Only import certificates that are required to be compliant | Yes |
+| Setting | Description | Example | Mandatory |
+| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | --------- |
+| BaseUrl | The URL to your CAPP12 instance | `https://yourcompany.capp12.nl` | Yes |
+| ClientId | The ClientId to connect to the API | `a1b2c3d4e5f6example` | Yes |
+| ClientSecret | The ClientSecret to connect to the API | `x9y8z7w6v5u4example` | Yes |
+| HistoricalDays | Number of days in the past from which expired certificates will be imported. Historical filtering applies only to required certificates. | `60` | Yes |
+| RequiredCertificatesOnly | When enabled, only required certificates will be imported (filters out non-required achievements) | `true`/`false` | Yes |
+| CompliantCertificatesOnly | When enabled, only certificates that have been achieved will be imported. This filters out non-compliant required certificates. Automatically implies RequiredCertificatesOnly. | `true`/`false` | Yes |
+| IncludeCertificateCodes | Comma-separated whitelist of certificate codes to import. When empty, all certificates (subject to other filters) are imported. When specified, ONLY these certificate codes will be processed. | `CE-0001,CE-0002,CE-0003` | No |
+
+**Recommended initial settings:**
+
+For most implementations, start with these settings:
+- `HistoricalDays`: `60` (imports certificates expiring within last 60 days)
+- `RequiredCertificatesOnly`: `true` (only import required certificates)
+- `CompliantCertificatesOnly`: `false` (import both compliant and non-compliant)
+- `IncludeCertificateCodes`: `` (empty - import all certificates)
+
+Adjust these settings based on your specific requirements and the number of certificates per person. Monitor the average and maximum values to stay within HelloID's performance limits (average: 30, maximum: 100 contracts per person).
### Mapping
-The mapping file contains the mapping between the API response and the HelloID contract attributes. The mapping file is included in this repository as `mapping.json`. As there are no natural HelloID contract attributes for the compliance status information, the mapping file maps the API responses to rather arbitrary contract attributes. Change the mapping in helloid accordingly if you want to use other contract attributes.
-Division.Code contains the Required status (true/false)
-Type.code contains the Compliance status (compliant/non-compliant)
-Title.code contains the certificate code
-Title.name contains the certificate name
+The mapping file (`mapping.json`) contains the mapping between the CAPP12 API response and HelloID person and contract attributes. The mapping file is included in this repository.
+
+As there are no natural HelloID contract attributes for certificate compliance data, the mapping uses standard contract attributes to represent the information:
+- **Title** = Certificate identification (which certificate)
+- **Division** = Required status (is this certificate mandatory for the user?)
+- **Team** = Compliance status (has the user achieved this required certificate?)
+- **StartDate/EndDate** = Validity period (when achieved and when expires)
+
+**Why StartDate = 9999-12-31 for non-achieved certificates:**
+
+For non-compliant required certificates (required but not achieved), the `achieved_at` field is empty in CAPP12. HelloID treats contracts **without a StartDate as active**, which is incorrect for certificates that haven't been achieved yet. Setting the `StartDate` to a far future date (`9999-12-31`) ensures:
+1. **The contract is NOT treated as active** - HelloID treats contracts with a future StartDate as future, correctly reflecting that the certificate hasn't been achieved
+2. **Automatic update when achieved** - Once the certificate is achieved, the `StartDate` updates to the actual achievement date and the contract becomes active with the correct validity period
## Remarks
-- This connector does not import HR data, it only imports basic user identifying information and educational certificate compliance status information. The connector can be used in combination with a HR import to create accounts based on the user identifying information and assigns contracts to these accounts based on the compliance status information.
-- The Business Rule conditions in HelloID can only compare attribute values in a single contract. If you want to compare values across multiple contracts, you will need to use either user Dynamic permissions with custom PowerShell code, or use user attributes instead of contract attributes by changing the connector code accordingly.
-
-- This means that in practice you can only create a BR on obtaining a specific certificate and that everyone within this BR gets the same authorizations. From HelloID you cannot subdivide rights, after obtaining certification, based on function or department additionally.
+### HelloID contract limit
+
+**Important:** HelloID has performance limits for contracts per person:
+- **Maximum:** 100 contracts per person (hard limit)
+- **Recommended average:** 30 contracts per person across all persons
+
+For more information, see [Performance limits (Provisioning)](https://docs.helloid.com/en/provisioning/performance-limits--provisioning-.html).
+
+If users have more than the recommended number of certificates, you must reduce the count using one or more of these strategies:
+
+1. **Use `IncludeCertificateCodes`** - Whitelist only relevant certificate codes
+2. **Enable `RequiredCertificatesOnly`** - Import only required certificates
+3. **Enable `CompliantCertificatesOnly`** - Import only achieved required certificates
+4. **Reduce `HistoricalDays`** - Import fewer expired certificates
+5. **Combine multiple filters** - Stack filters to reduce counts further
+
+**Monitor statistics after import:**
+The connector logs statistics including `Max per person: X` and `Average per person: Y`. If the maximum exceeds 100 or the average exceeds 30, you must adjust your filters to reduce the number of certificates per person.
+
+### Business Rules limitations
+
+Business Rule conditions in HelloID can only compare attribute values within a **single contract**. Cross-contract comparisons are not supported in standard BR conditions.
+
+**Example use case:**
+You can create a Business Rule that grants access to a specific application when a user obtains a certain certificate. However, you cannot use standard BR conditions to further filter based on other contract attributes (such as function or department) in combination with the certificate. For such scenarios, use Dynamic Permissions with custom PowerShell logic.
+
+### Certificate filtering logic
+
+**Understanding certificate types:**
+
+The connector distinguishes between three types of certificates:
+
+1. **Required certificates** - Certificates that are mandatory for a user according to CAPP12 compliance rules
+2. **Compliant certificates** - Required certificates that have been achieved by the user (even if the achievement has expired)
+3. **Non-required achieved certificates** - Certificates obtained by the user that are not mandatory
+
+**Default import behavior:**
+
+By default, the connector imports **all required certificates** (both compliant and non-compliant) for all users. Use the configuration settings (see [Configuration settings](#configuration-settings)) to filter which certificates are imported.
+
+**Best practice - Certificate code whitelist:**
+
+> [!IMPORTANT]
+> **Recommended:** Use the `IncludeCertificateCodes` filter to import only the certificates that are used in your Business Rules and Dynamic Permissions.
+
+**Why this is important:**
+- HelloID has a **100 contracts per person maximum** and a **recommended average of 30 contracts per person**
+- Only a small subset of certificates typically determines access rights
+- Filtering by specific codes prevents hitting the limits while maintaining all relevant access control data
+
+**Technical implementation notes:**
+- Certificates are deduplicated per user - each unique user+certificate combination appears only once
+- Required certificates are retrieved from `/api/v3/compliance_status.csv`
+- Achieved certificates are retrieved from `/api/v3/achievement_status.csv`
+- Compliance status is determined by checking if a required certificate exists in the achievement data
+- Achievement status is checked without historical filtering to ensure accurate compliance determination
+- An expired achievement still counts as compliant if the required certificate falls within the historical period
+
+## Development resources
+
### API endpoints
-The following endpoints are used by the connector
+The following endpoints are used by the connector:
-| Endpoint | Description |
-| -------- | ------------------------- |
-| /api/v3/compliance_status.csv | Retrieve the compliance status information of all users |
-| /api/v3/achievement_status.csv | Retrieve the achievement status information of all users |
-| /api/v1/users | Retrieve the user identifying information of all users |
+| Endpoint | Description |
+| ---------------------------------- | ---------------------------------------------------- |
+| POST /oauth2/token | OAuth2 authentication endpoint |
+| GET /api/v1/users | Retrieve user identifying information of all users |
+| GET /api/v3/compliance_status.csv | Retrieve compliance status information of all users |
+| GET /api/v3/achievement_status.csv | Retrieve achievement status information of all users |
### API documentation
-https://documenter.getpostman.com/view/17909805/UV5f6tSy#intro
+The official CAPP12 API documentation can be found at:
+
+- [CAPP12 API Documentation](https://documenter.getpostman.com/view/17909805/UV5f6tSy#intro) - Complete API reference and usage guides
## Getting help
> [!TIP]
> _For more information on how to configure a HelloID PowerShell connector, please refer to our [documentation](https://docs.helloid.com/en/provisioning/Source-systems/powershell-v2-Source-systems.html) pages_.
-> [!TIP]
-> _If you need help, feel free to ask questions on our [forum](https://forum.helloid.com)_.
-
## HelloID docs
The official HelloID documentation can be found at: https://docs.helloid.com/
diff --git a/configuration.json b/configuration.json
index 9851908..4b19756 100644
--- a/configuration.json
+++ b/configuration.json
@@ -1,10 +1,20 @@
[
+ {
+ "defaultValue": "https://.capp12.nl",
+ "key": "BaseUrl",
+ "type": "input",
+ "templateOptions": {
+ "label": "BaseUrl",
+ "description": "The base URL of the API",
+ "placeholder": "https://.capp12.nl",
+ "required": true
+ }
+ },
{
"key": "ClientId",
"type": "input",
"defaultValue": "",
"templateOptions": {
- "type": "password",
"label": "ClientId",
"description": "The client ID used for API authentication",
"required": true
@@ -21,16 +31,6 @@
"required": true
}
},
- {
- "key": "BaseUrl",
- "type": "input",
- "defaultValue": "",
- "templateOptions": {
- "label": "BaseUrl",
- "description": "The base URL of the API",
- "required": true
- }
- },
{
"key": "HistoricalDays",
"type": "input",
@@ -38,17 +38,39 @@
"templateOptions": {
"label": "HistoricalDays",
"description": "The number of days in the past from which expired certificates will be imported",
+ "placeholder": "60",
"required": true
}
},
{
"key": "RequiredCertificatesOnly",
"type": "checkbox",
- "defaultValue": false,
+ "defaultValue": true,
"templateOptions": {
"label": "Required Certificates Only",
"description": "When toggled, only required certificates will be imported",
"required": false
}
+ },
+ {
+ "key": "CompliantCertificatesOnly",
+ "type": "checkbox",
+ "defaultValue": false,
+ "templateOptions": {
+ "label": "Compliant Certificates Only",
+ "description": "When toggled, only certificates that have been achieved will be imported. Required certificates (within HistoricalDays) that have NOT been achieved are filtered out. Certificates achieved in the past (even if expired) are still imported as long as the required certificate falls within HistoricalDays. This option automatically implies required certificates only",
+ "required": false
+ }
+ },
+ {
+ "key": "IncludeCertificateCodes",
+ "type": "input",
+ "defaultValue": "",
+ "templateOptions": {
+ "label": "Include Certificate Codes",
+ "description": "Comma-separated whitelist of certificate codes to import. When empty, all certificates (subject to other filters) are imported. When specified, ONLY these certificate codes will be processed. Example: CE-0001,CE-0002,CE-0003",
+ "placeholder": "CE-0001,CE-0002,CE-0003",
+ "required": false
+ }
}
]
\ No newline at end of file
diff --git a/mapping.json b/mapping.json
index 17ff3d6..7acc752 100644
--- a/mapping.json
+++ b/mapping.json
@@ -1,13 +1,21 @@
{
"personMappings": [
+ {
+ "name": "Aggregation",
+ "mode": "field",
+ "value": "code",
+ "validation": {
+ "required": false
+ }
+ },
{
"name": "ExternalId",
"mode": "field",
"value": "ExternalId",
"validation": {
- "required": false
+ "required": true
}
- },
+ },
{
"name": "Name.FamilyName",
"mode": "field",
@@ -26,6 +34,30 @@
}
],
"contractMappings": [
+ {
+ "name": "Division.Code",
+ "mode": "field",
+ "value": "required",
+ "validation": {
+ "required": false
+ }
+ },
+ {
+ "name": "Division.ExternalId",
+ "mode": "field",
+ "value": "required",
+ "validation": {
+ "required": false
+ }
+ },
+ {
+ "name": "Division.Name",
+ "mode": "complex",
+ "value": "function getRequiredStatus(){\r\n let requiredStatus = 'Required status: ' + sourceContract.required;\r\n\r\n return requiredStatus;\r\n}\r\n\r\ngetRequiredStatus();",
+ "validation": {
+ "required": false
+ }
+ },
{
"name": "EndDate",
"mode": "field",
@@ -44,56 +76,56 @@
},
{
"name": "StartDate",
- "mode": "field",
- "value": "achieved_at",
+ "mode": "complex",
+ "value": "function getStartDate() {\r\n let startDate = sourceContract.achieved_at;\r\n \r\n if (typeof startDate === 'undefined' || \r\n startDate === null || \r\n startDate === '' || \r\n (typeof startDate === 'string' && startDate.trim() === '')) {\r\n startDate = '9999-12-31';\r\n }\r\n \r\n return startDate;\r\n}\r\n\r\ngetStartDate();",
"validation": {
"required": false
}
},
{
- "name": "Title.Name",
+ "name": "Team.Code",
"mode": "field",
- "value": "certificate_title",
+ "value": "compliant",
"validation": {
"required": false
}
},
{
- "name": "Title.Code",
+ "name": "Team.ExternalId",
"mode": "field",
- "value": "certificate_code",
+ "value": "compliant",
"validation": {
"required": false
}
},
{
- "name": "Type.Code",
- "mode": "field",
- "value": "compliant",
+ "name": "Team.Name",
+ "mode": "complex",
+ "value": "function getComplianceStatus(){\r\n let complianceStatus = 'Compliance status: ' + sourceContract.compliant;\r\n\r\n return complianceStatus;\r\n}\r\n\r\ngetComplianceStatus();",
"validation": {
"required": false
}
},
{
- "name": "Type.Description",
- "mode": "fixed",
- "value": "Compliance status",
+ "name": "Title.Code",
+ "mode": "field",
+ "value": "certificate_code",
"validation": {
"required": false
}
},
{
- "name": "Division.Name",
- "mode": "fixed",
- "value": "required status",
+ "name": "Title.ExternalId",
+ "mode": "field",
+ "value": "certificate_code",
"validation": {
"required": false
}
},
{
- "name": "Division.Code",
+ "name": "Title.Name",
"mode": "field",
- "value": "required",
+ "value": "certificate_title",
"validation": {
"required": false
}
diff --git a/persons.ps1 b/persons.ps1
index 5e9ef23..0646da4 100644
--- a/persons.ps1
+++ b/persons.ps1
@@ -3,14 +3,19 @@
#
# Version: 1.0.0
##################################################
-# Initialize default value's
-
+# Initialize default values
$config = $configuration | ConvertFrom-Json
+
# Set debug logging
switch ($($config.IsDebug)) {
$true { $VerbosePreference = 'Continue' }
$false { $VerbosePreference = 'SilentlyContinue' }
}
+$InformationPreference = "Continue"
+$WarningPreference = "Continue"
+
+# Set TLS to accept TLS, TLS 1.1 and TLS 1.2
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls -bor [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls12
#region functions
function Get-Capp12AuthorizationTokenAndCreateHeaders {
@@ -18,10 +23,10 @@ function Get-Capp12AuthorizationTokenAndCreateHeaders {
param(
[Parameter()]
[object]
- $Configuration = $config
+ $Configuration
)
try {
- Write-Information 'Creating Access Token'
+ Write-Verbose 'Creating Access Token'
$authorizationBody = @{
grant_type = 'client_credentials'
client_id = $Configuration.ClientId
@@ -35,13 +40,14 @@ function Get-Capp12AuthorizationTokenAndCreateHeaders {
Body = $authorizationBody | ConvertTo-Json -Depth 10
}
- $accessToken = Invoke-RestMethod @splatInvoke
+ $accessToken = Invoke-RestMethod @splatInvoke -Verbose:$false
- Write-Information 'Adding Authorization headers'
+ Write-Verbose 'Adding Authorization headers'
$headers = [System.Collections.Generic.Dictionary[[String], [String]]]::new()
$headers.Add('Authorization', "$($accessToken.token_type) $($accessToken.access_token)")
- $headers.Add('Accept', 'text/csv')
+ $headers.Add('Accept', 'application/json,text/csv')
$headers.Add('Content-Type', 'application/json')
+
Write-Output $headers
}
catch {
@@ -76,6 +82,7 @@ function Resolve-CAPP12Error {
}
try {
$errorDetailsObject = ($httpErrorObj.ErrorDetails | ConvertFrom-Json)
+ # Make sure to inspect the error result object and add only the error message as a FriendlyMessage.
$httpErrorObj.FriendlyMessage = $errorDetailsObject.error
}
catch {
@@ -85,111 +92,218 @@ function Resolve-CAPP12Error {
}
}
-function ConvertTo-Date() {
+function ConvertTo-Date {
[CmdletBinding()]
param(
[String]
$datefield
)
- if ([string]::IsNullOrEmpty($datefield)) { $null }
+ if ([string]::IsNullOrEmpty($datefield)) {
+ $null
+ }
else {
$culture = [Globalization.CultureInfo]::InvariantCulture
-
[datetime]::ParseExact($datefield, 'yyyy-MM-dd HH:mm:ss', $culture).ToUniversalTime()
}
}
-#endregion
+#endregion functions
-try {
+Write-Information "Starting person import. Base URL: $($config.BaseUrl)"
+
+# Parse include certificate codes (whitelist)
+$includeCertificates = @{}
+$includeCertificatesEnabled = $false
+if (-not [string]::IsNullOrWhiteSpace($config.IncludeCertificateCodes)) {
+ $includeList = $config.IncludeCertificateCodes -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
+ foreach ($code in $includeList) {
+ $includeCertificates[$code] = $true
+ }
+ $includeCertificatesEnabled = $true
+ Write-Information "Certificate include filter enabled: Only $($includeCertificates.Count) certificate code(s) will be imported (whitelist)"
+}
- $historicalDate = (Get-Date).ToUniversalTime().AddDays( - $($config.HistoricalDays))
+try {
+ # Query users
+ $actionMessage = "querying users at [$($config.BaseUrl)/api/v1/users]"
$headers = Get-Capp12AuthorizationTokenAndCreateHeaders -Configuration $config
$splatGetPersons = @{
Uri = "$($config.BaseUrl)/api/v1/users"
Headers = $headers
Method = 'GET'
- }
+ }
+ $usersList = Invoke-RestMethod @splatGetPersons -Verbose:$false | ConvertFrom-Csv -Delimiter ';'
+
+ # Make sure users are unique
+ $users = $usersList | Where-Object { $_.code -ne $null } | Sort-Object code -Unique
+ Write-Information "Queried users. Result: $(@($users).Count)"
- $Users = Invoke-RestMethod @splatGetPersons -Verbose:$false | ConvertFrom-Csv -Delimiter ';'
+ # Query compliance status
+ $actionMessage = "querying compliance status at [$($config.BaseUrl)/api/v3/compliance_status.csv]"
$splatGetComplianceStatus = @{
Uri = "$($config.BaseUrl)/api/v3/compliance_status.csv"
Headers = $headers
Method = 'GET'
}
+ $complianceStatusList = Invoke-RestMethod @splatGetComplianceStatus -Verbose:$false | ConvertFrom-Csv -Delimiter ';'
+ Write-Information "Queried compliance status (required). Result: $(@($complianceStatusList).Count)"
- $ComplianceStatusList = Invoke-RestMethod @splatGetComplianceStatus -Verbose:$false | ConvertFrom-Csv -Delimiter ';'
- [System.Collections.Generic.SortedList[string, System.Collections.Generic.List[object]]]$UserCompliance = [System.Collections.Generic.SortedList[string, System.Collections.Generic.List[object]]]::new()
-
- foreach ($ComplianceStatus in $ComplianceStatusList) {
- if ($null -ne $ComplianceStatus.user_code) {
+ # Query achievement status (always needed for compliance checking)
+ $actionMessage = "querying achievement status at [$($config.BaseUrl)/api/v3/achievement_status.csv]"
+ $splatGetAchievementStatus = @{
+ Uri = "$($config.BaseUrl)/api/v3/achievement_status.csv"
+ Headers = $headers
+ Method = 'GET'
+ }
+ $achievementStatusList = Invoke-RestMethod @splatGetAchievementStatus -Verbose:$false | ConvertFrom-Csv -Delimiter ';'
+ Write-Information "Queried achievement status (all). Result: $(@($achievementStatusList).Count)"
+
+ if ($config.CompliantCertificatesOnly -eq $true) {
+ Write-Information "Filtering enabled: Only compliant certificates (Required & Achieved) will be imported"
+ }
+ elseif ($config.RequiredCertificatesOnly -eq $true) {
+ Write-Information "Filtering enabled: Only required certificates will be imported"
+ }
- $complianceExpirationDate = ConvertTo-Date($ComplianceStatus.valid_until)
+ # Process compliance and achievement data
+ $actionMessage = "processing compliance and achievement data"
+ $historicalDate = (Get-Date).ToUniversalTime().AddDays( - $($config.HistoricalDays))
+ [System.Collections.Generic.SortedList[string, System.Collections.Generic.List[object]]]$userCompliance = [System.Collections.Generic.SortedList[string, System.Collections.Generic.List[object]]]::new()
+
+ # Build lookup for achieved certificates
+ # IMPORTANT: NO historical filtering applied here!
+ # Reason: A required certificate within historical days that was achieved (even if that achievement is now expired)
+ # should still be considered compliant. We only filter required certificates on historical date.
+ # Example: Required cert expires 50 days ago (within 60 days) + Achievement expires 70 days ago (outside 60 days)
+ # = Still compliant (was achieved), so should be exported if CompliantCertificatesOnly is enabled
+ $achievementLookup = @{}
+ foreach ($achievementStatus in $achievementStatusList) {
+ if ($null -ne $achievementStatus.user_code) {
+ $key = "$($achievementStatus.user_code)|$($achievementStatus.certificate_code)"
+ if (-not $achievementLookup.ContainsKey($key)) {
+ $achievementLookup[$key] = $achievementStatus
+ }
+ }
+ }
+
+ foreach ($complianceStatus in $complianceStatusList) {
+ if ($null -ne $complianceStatus.user_code) {
+ # Skip certificate codes not in the include list (if whitelist is enabled)
+ if ($includeCertificatesEnabled -and -not $includeCertificates.ContainsKey($complianceStatus.certificate_code)) {
+ continue
+ }
+
+ $complianceExpirationDate = ConvertTo-Date($complianceStatus.valid_until)
- if (($null -eq $complianceExpirationDate) -or ($complianceExpirationDate -ge $historicalDate)) {
- if (-not $UserCompliance.ContainsKey($ComplianceStatus.user_code)) {
- $UserCompliance.Add($ComplianceStatus.user_code, [System.Collections.Generic.List[object]]::new())
+ # Only process required certificates within historical days
+ if (($null -eq $complianceExpirationDate) -or ($complianceExpirationDate -ge $historicalDate)) {
+ # Check if this required certificate is also achieved (compliant)
+ # Compliant = certificate exists in both compliance_status AND achievement_status
+ # Note: Achievement may be expired, but still counts as compliant if it was achieved
+ $key = "$($complianceStatus.user_code)|$($complianceStatus.certificate_code)"
+ $isCompliant = $achievementLookup.ContainsKey($key)
+
+ # Skip non-compliant certificates (required but NOT achieved) if CompliantCertificatesOnly is enabled
+ if ($config.CompliantCertificatesOnly -eq $true -and -not $isCompliant) {
+ continue
+ }
+
+ if (-not $userCompliance.ContainsKey($complianceStatus.user_code)) {
+ $userCompliance.Add($complianceStatus.user_code, [System.Collections.Generic.List[object]]::new())
}
- $ComplianceStatus | Add-Member -NotePropertyMembers @{ ExternalId = "$($ComplianceStatus.user_code)-$($ComplianceStatus.certificate_code)" }
- $UserCompliance[$ComplianceStatus.user_code].Add($ComplianceStatus)
+ $complianceStatus | Add-Member -NotePropertyMembers @{ ExternalId = "$($complianceStatus.user_code)-$($complianceStatus.certificate_code)" }
+ $userCompliance[$complianceStatus.user_code].Add($complianceStatus)
}
}
}
- if ($Config.RequiredCertificatesOnly -eq $false) {
-
- $splatGetAchievementStatus = @{
- Uri = "$($config.BaseUrl)/api/v3/achievement_status.csv"
- Headers = $headers
- Method = 'GET'
- }
- $AchievementStatusList = Invoke-RestMethod @splatGetAchievementStatus -Verbose:$false | ConvertFrom-Csv -Delimiter ';'
- foreach ($AchievementStatus in $AchievementStatusList) {
- if ($null -ne $AchievementStatus.user_code) {
- $achievementExpirationDate = ConvertTo-Date($AchievementStatus.valid_until)
+ if ($config.RequiredCertificatesOnly -eq $false -and $config.CompliantCertificatesOnly -eq $false) {
+ foreach ($achievementStatus in $achievementStatusList) {
+ if ($null -ne $achievementStatus.user_code) {
+ # Skip certificate codes not in the include list (if whitelist is enabled)
+ if ($includeCertificatesEnabled -and -not $includeCertificates.ContainsKey($achievementStatus.certificate_code)) {
+ continue
+ }
+
+ $achievementExpirationDate = ConvertTo-Date($achievementStatus.valid_until)
if (($null -eq $achievementExpirationDate) -or ($achievementExpirationDate -ge $historicalDate)) {
- if (-not $UserCompliance.ContainsKey($AchievementStatus.user_code)) {
- $UserCompliance.Add($AchievementStatus.user_code, [System.Collections.Generic.List[object]]::new())
+ if (-not $userCompliance.ContainsKey($achievementStatus.user_code)) {
+ $userCompliance.Add($achievementStatus.user_code, [System.Collections.Generic.List[object]]::new())
}
- if (-not ($UserCompliance[$AchievementStatus.user_code].certificate_code -contains $AchievementStatus.certificate_code)) {
- $AchievementStatus | Add-Member -NotePropertyMembers @{ ExternalId = "$($AchievementStatus.user_code)-$($AchievementStatus.certificate_code)" }
- $UserCompliance[$AchievementStatus.user_code].Add($AchievementStatus)
+ if (-not ($userCompliance[$achievementStatus.user_code].certificate_code -contains $achievementStatus.certificate_code)) {
+ $achievementStatus | Add-Member -NotePropertyMembers @{ ExternalId = "$($achievementStatus.user_code)-$($achievementStatus.certificate_code)" }
+ $userCompliance[$achievementStatus.user_code].Add($achievementStatus)
}
}
}
}
}
-
- foreach ($User in $Users) {
-
- $person = $User
- $person | Add-Member -NotePropertyMembers @{ DisplayName = "$($User.first_name) $($User.last_name)".trim(' ') }
- $person | Add-Member -NotePropertyMembers @{ ExternalId = $User.code }
- $person | Add-Member -NotePropertyMembers @{ Contracts = [System.Collections.Generic.List[Object]]::new() }
- if ($null -ne $User.code) {
- [System.Collections.Generic.List[object]] $complianceStatusForUser = $UserCompliance[$User.code]
- if ($null -ne $complianceStatusForUser) {
+ Write-Information "Processed compliance and achievement data"
+
+ # Enhance and export person objects to HelloID
+ $actionMessage = "enhancing and exporting person objects to HelloID"
+
+ # Set counter to keep track of actual exported person objects and certificates
+ $exportedPersons = 0
+ $exportedCertificates = 0
+ $certificateCounts = @{}
+
+ # Enhance the users model with required properties
+ $users | Add-Member -MemberType NoteProperty -Name "ExternalId" -Value $null -Force
+ $users | Add-Member -MemberType NoteProperty -Name "DisplayName" -Value $null -Force
+ $users | Add-Member -MemberType NoteProperty -Name "Contracts" -Value $null -Force
- $person.Contracts.AddRange($complianceStatusForUser)
- Write-Output $person | ConvertTo-Json -Depth 10
+ foreach ($user in $users) {
+ $actionMessage = "enhancing and exporting person with code [$($user.code)]"
+
+ # Set required fields for HelloID
+ $user.ExternalId = $user.code
+ $user.DisplayName = "$($user.first_name) $($user.last_name) ($($user.ExternalId))"
+
+ # Create contracts object
+ $user.Contracts = [System.Collections.Generic.List[Object]]::new()
+
+ if ($null -ne $user.code) {
+ [System.Collections.Generic.List[object]]$complianceStatusForUser = $userCompliance[$user.code]
+
+ if ($null -ne $complianceStatusForUser) {
+ $user.Contracts.AddRange($complianceStatusForUser)
+
+ # Sanitize and export the json
+ $person = $user | ConvertTo-Json -Depth 10
+ $person = $person.Replace("._", "__")
+ Write-Output $person
+
+ # Update counters to keep track of actual exported person objects and certificates
+ $exportedPersons++
+ $certificateCount = $complianceStatusForUser.Count
+ $exportedCertificates += $certificateCount
+ $certificateCounts[$user.code] = $certificateCount
}
- }
+ }
}
-
-
+
+ # Calculate statistics
+ $averageCertificates = if ($exportedPersons -gt 0) { [math]::Round($exportedCertificates / $exportedPersons, 2) } else { 0 }
+ $maxCertificates = if ($certificateCounts.Count -gt 0) { ($certificateCounts.Values | Measure-Object -Maximum).Maximum } else { 0 }
+
+ Write-Information "Enhanced and exported person objects to HelloID. Total persons: $exportedPersons | Total certificates: $exportedCertificates | Average per person: $averageCertificates | Max per person: $maxCertificates"
+ Write-Information "Person import completed"
}
catch {
$ex = $PSItem
if ($($ex.Exception.GetType().FullName -eq 'Microsoft.PowerShell.Commands.HttpResponseException') -or
$($ex.Exception.GetType().FullName -eq 'System.Net.WebException')) {
$errorObj = Resolve-CAPP12Error -ErrorObject $ex
- Write-Verbose "Could not import $Name persons. Error at Line '$($errorObj.ScriptLineNumber)': $($errorObj.Line). Error: $($errorObj.ErrorDetails)"
- Write-Error "Could not import $Name persons. Error: $($errorObj.FriendlyMessage)"
+ $auditMessage = "Error $($actionMessage). Error: $($errorObj.FriendlyMessage)"
+ $warningMessage = "Error at Line [$($errorObj.ScriptLineNumber)]: $($errorObj.Line). Error: $($errorObj.ErrorDetails)"
}
else {
- Write-Verbose "Could not import $Name persons. Error at Line '$($ex.InvocationInfo.ScriptLineNumber)': $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)"
- Write-Error "Could not import $Name persons. Error: $($ex.Exception.Message )"
+ $auditMessage = "Error $($actionMessage). Error: $($ex.Exception.Message)"
+ $warningMessage = "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)"
}
-}
-
\ No newline at end of file
+
+ Write-Warning $warningMessage
+ throw $auditMessage
+}
\ No newline at end of file