diff --git a/Solutions/SAP BTP/Data Connectors/SAPBTPPollerConnector/SAPBTP_DataConnectorDefinition.json b/Solutions/SAP BTP/Data Connectors/SAPBTPPollerConnector/SAPBTP_DataConnectorDefinition.json index 2860ef39b74..d0f4cf6cad4 100644 --- a/Solutions/SAP BTP/Data Connectors/SAPBTPPollerConnector/SAPBTP_DataConnectorDefinition.json +++ b/Solutions/SAP BTP/Data Connectors/SAPBTPPollerConnector/SAPBTP_DataConnectorDefinition.json @@ -97,6 +97,15 @@ "name": "subaccountName" } }, + { + "type": "Textbox", + "parameters": { + "label": "Subaccount ID (GUID)", + "placeholder": "SubaccountId property from the BTP service key JSON", + "type": "text", + "name": "subaccountId" + } + }, { "type": "Textbox", "parameters": { @@ -177,7 +186,15 @@ "mapping": [ { "columnName": "Subaccount Name", + "columnValue": "properties.addOnAttributes.SubaccountName" + }, + { + "columnName": "Subaccount ID", "columnValue": "name" + }, + { + "columnName": "Polling Frequency (minutes)", + "columnValue": "properties.request.queryWindowInMin" } ], "menuItems": [ diff --git a/Solutions/SAP BTP/Data Connectors/SAPBTPPollerConnector/SAPBTP_PollingConfig.json b/Solutions/SAP BTP/Data Connectors/SAPBTPPollerConnector/SAPBTP_PollingConfig.json index e944d929a58..bf44d95e8d0 100644 --- a/Solutions/SAP BTP/Data Connectors/SAPBTPPollerConnector/SAPBTP_PollingConfig.json +++ b/Solutions/SAP BTP/Data Connectors/SAPBTPPollerConnector/SAPBTP_PollingConfig.json @@ -1,10 +1,11 @@ [ { - "name": "{{innerWorkspace}}/Microsoft.SecurityInsights/BTP_{{subaccountName}}", + "name": "{{innerWorkspace}}/Microsoft.SecurityInsights/{{subaccountId}}", "apiVersion": "2025-07-01-preview", "type": "Microsoft.SecurityInsights/dataConnectors", "location": "{{location}}", "kind": "RestApiPoller", + "UseRandomGuid": false, "properties": { "connectorDefinitionName": "SAPBTPAuditEvents", "dcrConfig": { diff --git a/Solutions/SAP BTP/Package/3.0.12.zip b/Solutions/SAP BTP/Package/3.0.12.zip new file mode 100644 index 00000000000..554e41df171 Binary files /dev/null and b/Solutions/SAP BTP/Package/3.0.12.zip differ diff --git a/Solutions/SAP BTP/Package/mainTemplate.json b/Solutions/SAP BTP/Package/mainTemplate.json index b7f31e2da5e..fec1557be90 100644 --- a/Solutions/SAP BTP/Package/mainTemplate.json +++ b/Solutions/SAP BTP/Package/mainTemplate.json @@ -53,7 +53,7 @@ }, "variables": { "_solutionName": "SAP BTP", - "_solutionVersion": "3.0.11", + "_solutionVersion": "3.0.12", "solutionId": "sentinel4sap.sap_btp_sentinel_solution", "_solutionId": "[variables('solutionId')]", "workbookVersion1": "1.0.0", @@ -188,7 +188,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "SAPBTPActivity Workbook with template version 3.0.11", + "description": "SAPBTPActivity Workbook with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('workbookVersion1')]", @@ -275,7 +275,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Audit log service unavailable_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Audit log service unavailable_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject1').analyticRuleVersion1]", @@ -333,14 +333,14 @@ "aggregationKind": "AlertPerResult" }, "customDetails": { - "TimeSinceLastLog": "TimeSinceLastLog", - "SubaccountName": "SubaccountName", "Tenant": "Tenant", - "LastLogTime": "LastLogTime" + "SubaccountName": "SubaccountName", + "LastLogTime": "LastLogTime", + "TimeSinceLastLog": "TimeSinceLastLog" }, "alertDetailsOverride": { - "alertDescriptionFormat": "The SAP BTP subaccount '{{SubaccountName}}' has not reported any audit logs since {{LastLogTime}}.\n\nTime without logs: {{TimeSinceLastLog}} minutes\n\nThis could indicate:\n- Audit log service has been disabled (potential compromise to hide malicious activity)\n- Data connector authentication or connectivity issues\n- SAP BTP service availability problems\n\nRecommended actions:\n1. Verify the audit log service status in SAP BTP cockpit\n2. Check the data connector health in Microsoft Sentinel\n3. Review any recent administrative changes to the subaccount\n4. Investigate for potential unauthorized access or configuration changes\n", - "alertDisplayNameFormat": "SAP BTP: No audit logs received from {{SubaccountName}} for {{TimeSinceLastLog}} minutes" + "alertDisplayNameFormat": "SAP BTP: No audit logs received from {{SubaccountName}} for {{TimeSinceLastLog}} minutes", + "alertDescriptionFormat": "The SAP BTP subaccount '{{SubaccountName}}' has not reported any audit logs since {{LastLogTime}}.\n\nTime without logs: {{TimeSinceLastLog}} minutes\n\nThis could indicate:\n- Audit log service has been disabled (potential compromise to hide malicious activity)\n- Data connector authentication or connectivity issues\n- SAP BTP service availability problems\n\nRecommended actions:\n1. Verify the audit log service status in SAP BTP cockpit\n2. Check the data connector health in Microsoft Sentinel\n3. Review any recent administrative changes to the subaccount\n4. Investigate for potential unauthorized access or configuration changes\n" } } }, @@ -394,7 +394,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Build Work Zone unauthorized access and role tampering_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Build Work Zone unauthorized access and role tampering_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject2').analyticRuleVersion2]", @@ -471,8 +471,8 @@ "ProviderId": "ProviderId" }, "alertDetailsOverride": { - "alertDescriptionFormat": "{{MessageText}} by {{UserName}} in tenant {{Tenant}}.\n\nThis could indicate unauthorized access attempts or malicious removal of access controls.\n", - "alertDisplayNameFormat": "SAP Build Work Zone: {{MessageText}}" + "alertDisplayNameFormat": "SAP Build Work Zone: {{MessageText}}", + "alertDescriptionFormat": "{{MessageText}} by {{UserName}} in tenant {{Tenant}}.\n\nThis could indicate unauthorized access attempts or malicious removal of access controls.\n" } } }, @@ -526,7 +526,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Cloud Identity Service application configuration monitor_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Cloud Identity Service application configuration monitor_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject3').analyticRuleVersion3]", @@ -607,13 +607,13 @@ }, "customDetails": { "Action": "Action", - "SourceIP": "ipAddress", "FederationType": "FederationType", - "ServiceProviderName": "ServiceProviderName" + "ServiceProviderName": "ServiceProviderName", + "SourceIP": "ipAddress" }, "alertDetailsOverride": { - "alertDescriptionFormat": "{{MessageText}} by {{UserName}}. Identity provider name: {{ServiceProviderName}}\n \nThis could indicate:\n- Legitimate administrative change to federated applications\n- Unauthorized application registration for persistent access\n- Rogue SAML/OIDC application added by attacker\n", - "alertDisplayNameFormat": "SAP Cloud Identity Service: {{MessageText}}" + "alertDisplayNameFormat": "SAP Cloud Identity Service: {{MessageText}}", + "alertDescriptionFormat": "{{MessageText}} by {{UserName}}. Identity provider name: {{ServiceProviderName}}\n \nThis could indicate:\n- Legitimate administrative change to federated applications\n- Unauthorized application registration for persistent access\n- Rogue SAML/OIDC application added by attacker\n" } } }, @@ -667,7 +667,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Cloud Integration access policy tampering_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Cloud Integration access policy tampering_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject4').analyticRuleVersion4]", @@ -746,15 +746,15 @@ "aggregationKind": "SingleAlert" }, "customDetails": { + "PolicyMessage": "PolicyMessage", + "ObjectId": "ObjectId", "ObjectType": "ObjectType", - "SourceIP": "ipAddress", "Action": "Action", - "PolicyMessage": "PolicyMessage", - "ObjectId": "ObjectId" + "SourceIP": "ipAddress" }, "alertDetailsOverride": { - "alertDescriptionFormat": "{{MessageText}} by {{UserName}} from IP {{ipAddress}}.\n\nThis could indicate:\n- Legitimate access policy administration\n- Unauthorized privilege escalation attempt\n- Attacker modifying security controls to access sensitive integrations\n", - "alertDisplayNameFormat": "SAP Cloud Integration: {{MessageText}}" + "alertDisplayNameFormat": "SAP Cloud Integration: {{MessageText}}", + "alertDescriptionFormat": "{{MessageText}} by {{UserName}} from IP {{ipAddress}}.\n\nThis could indicate:\n- Legitimate access policy administration\n- Unauthorized privilege escalation attempt\n- Attacker modifying security controls to access sensitive integrations\n" } } }, @@ -808,7 +808,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Cloud Integration artifact deployment_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Cloud Integration artifact deployment_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject5').analyticRuleVersion5]", @@ -894,8 +894,8 @@ "SourceIP": "ipAddress" }, "alertDetailsOverride": { - "alertDescriptionFormat": "{{MessageText}} by {{UserName}} from IP {{ipAddress}}.\n\nThis could indicate:\n- Legitimate integration artifact deployment or maintenance\n- Unauthorized deployment of malicious integration code\n- Attacker undeploying security-relevant integrations\n", - "alertDisplayNameFormat": "SAP Cloud Integration: {{MessageText}}" + "alertDisplayNameFormat": "SAP Cloud Integration: {{MessageText}}", + "alertDescriptionFormat": "{{MessageText}} by {{UserName}} from IP {{ipAddress}}.\n\nThis could indicate:\n- Legitimate integration artifact deployment or maintenance\n- Unauthorized deployment of malicious integration code\n- Attacker undeploying security-relevant integrations\n" } } }, @@ -949,7 +949,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Cloud Integration JDBC data source changes_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Cloud Integration JDBC data source changes_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject6').analyticRuleVersion6]", @@ -1029,12 +1029,12 @@ }, "customDetails": { "Action": "Action", - "SourceIP": "ipAddress", - "DataSourceName": "DataSourceName" + "DataSourceName": "DataSourceName", + "SourceIP": "ipAddress" }, "alertDetailsOverride": { - "alertDescriptionFormat": "{{MessageText}} by {{UserName}} from IP {{ipAddress}}.\n\nJDBC data sources contain database connection credentials. Changes to these configurations\nshould be carefully reviewed.\n\nThis could indicate:\n- Legitimate database configuration management\n- Unauthorized database connection configuration\n- Attacker establishing lateral movement paths to backend systems\n", - "alertDisplayNameFormat": "SAP Cloud Integration: {{MessageText}}" + "alertDisplayNameFormat": "SAP Cloud Integration: {{MessageText}}", + "alertDescriptionFormat": "{{MessageText}} by {{UserName}} from IP {{ipAddress}}.\n\nJDBC data sources contain database connection credentials. Changes to these configurations\nshould be carefully reviewed.\n\nThis could indicate:\n- Legitimate database configuration management\n- Unauthorized database connection configuration\n- Attacker establishing lateral movement paths to backend systems\n" } } }, @@ -1088,7 +1088,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Cloud Integration package import or transport_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Cloud Integration package import or transport_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject7').analyticRuleVersion7]", @@ -1167,16 +1167,16 @@ "aggregationKind": "SingleAlert" }, "customDetails": { - "OperationType": "OperationType", - "ObjectType": "ObjectType", - "SourceIP": "ipAddress", "Action": "Action", - "ObjectId": "ObjectId", - "OperationStatus": "OperationStatus" + "SourceIP": "ipAddress", + "ObjectType": "ObjectType", + "OperationType": "OperationType", + "OperationStatus": "OperationStatus", + "ObjectId": "ObjectId" }, "alertDetailsOverride": { - "alertDescriptionFormat": "{{MessageText}} by {{UserName}} from IP {{ipAddress}}.\n\nThis could indicate:\n- Legitimate package management and deployment\n- Import of malicious integration content from untrusted sources\n- Unauthorized transport of artifacts between environments\n", - "alertDisplayNameFormat": "SAP Cloud Integration: {{MessageText}}" + "alertDisplayNameFormat": "SAP Cloud Integration: {{MessageText}}", + "alertDescriptionFormat": "{{MessageText}} by {{UserName}} from IP {{ipAddress}}.\n\nThis could indicate:\n- Legitimate package management and deployment\n- Import of malicious integration content from untrusted sources\n- Unauthorized transport of artifacts between environments\n" } } }, @@ -1230,7 +1230,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Cloud Integration tampering with security material_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Cloud Integration tampering with security material_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject8').analyticRuleVersion8]", @@ -1309,15 +1309,15 @@ "aggregationKind": "SingleAlert" }, "customDetails": { + "ObjectId": "ObjectId", "ObjectType": "ObjectType", - "SourceIP": "ipAddress", "KeystoreName": "KeystoreName", "Action": "Action", - "ObjectId": "ObjectId" + "SourceIP": "ipAddress" }, "alertDetailsOverride": { - "alertDescriptionFormat": "{{MessageText}} by {{UserName}} from IP {{ipAddress}}.\n \nThis could indicate:\n- Legitimate security material management\n- Unauthorized credential or certificate manipulation\n- Attacker tampering with security artifacts to gain access or cover tracks\n", - "alertDisplayNameFormat": "SAP Cloud Integration: {{MessageText}}" + "alertDisplayNameFormat": "SAP Cloud Integration: {{MessageText}}", + "alertDescriptionFormat": "{{MessageText}} by {{UserName}} from IP {{ipAddress}}.\n \nThis could indicate:\n- Legitimate security material management\n- Unauthorized credential or certificate manipulation\n- Attacker tampering with security artifacts to gain access or cover tracks\n" } } }, @@ -1371,7 +1371,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Failed access attempts across multiple BAS subaccounts_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Failed access attempts across multiple BAS subaccounts_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject9').analyticRuleVersion9]", @@ -1441,8 +1441,8 @@ "aggregationKind": "SingleAlert" }, "alertDetailsOverride": { - "alertDescriptionFormat": "{{UserName}} attempted, and failed, to log into multiple Business Application Studio dev spaces. Tenants accessed: {{Tenants}}", - "alertDisplayNameFormat": "BTP - Unauthorized access attempt to multiple tenants" + "alertDisplayNameFormat": "BTP - Unauthorized access attempt to multiple tenants", + "alertDescriptionFormat": "{{UserName}} attempted, and failed, to log into multiple Business Application Studio dev spaces. Tenants accessed: {{Tenants}}" } } }, @@ -1496,7 +1496,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Malware detected in BAS dev space_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Malware detected in BAS dev space_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject10').analyticRuleVersion10]", @@ -1577,8 +1577,8 @@ "aggregationKind": "SingleAlert" }, "alertDetailsOverride": { - "alertDescriptionFormat": "Malware was found in the following subaccount: {{Tenant}}", - "alertDisplayNameFormat": "BTP - Malware detected in Business Apps Studio dev space" + "alertDisplayNameFormat": "BTP - Malware detected in Business Apps Studio dev space", + "alertDescriptionFormat": "Malware was found in the following subaccount: {{Tenant}}" } } }, @@ -1632,7 +1632,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Mass user deletion in a sub account_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Mass user deletion in a sub account_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject11').analyticRuleVersion11]", @@ -1756,7 +1756,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Mass user deletion in Cloud Identity Service_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Mass user deletion in Cloud Identity Service_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject12').analyticRuleVersion12]", @@ -1880,7 +1880,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - Trust and authorization Identity Provider monitor_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - Trust and authorization Identity Provider monitor_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject13').analyticRuleVersion13]", @@ -1951,8 +1951,8 @@ "aggregationKind": "SingleAlert" }, "alertDetailsOverride": { - "alertDescriptionFormat": "{{MessageText}} by {{UserName}}. Identity provider name: {{IdentityProviderName}}", - "alertDisplayNameFormat": "SAP BTP: {{MessageText}}" + "alertDisplayNameFormat": "SAP BTP: {{MessageText}}", + "alertDescriptionFormat": "{{MessageText}} by {{UserName}}. Identity provider name: {{IdentityProviderName}}" } } }, @@ -2006,7 +2006,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - User added to privileged Administrators list_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - User added to privileged Administrators list_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject14').analyticRuleVersion14]", @@ -2136,7 +2136,7 @@ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" ], "properties": { - "description": "BTP - User added to sensitive privileged role collection_AnalyticalRules Analytics Rule with template version 3.0.11", + "description": "BTP - User added to sensitive privileged role collection_AnalyticalRules Analytics Rule with template version 3.0.12", "mainTemplate": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('analyticRuleObject15').analyticRuleVersion15]", @@ -2365,6 +2365,15 @@ "name": "subaccountName" } }, + { + "type": "Textbox", + "parameters": { + "label": "Subaccount ID (GUID)", + "placeholder": "SubaccountId property from the BTP service key JSON", + "type": "text", + "name": "subaccountId" + } + }, { "type": "Textbox", "parameters": { @@ -2445,7 +2454,15 @@ "mapping": [ { "columnName": "Subaccount Name", + "columnValue": "properties.addOnAttributes.SubaccountName" + }, + { + "columnName": "Subaccount ID", "columnValue": "name" + }, + { + "columnName": "Polling Frequency (minutes)", + "columnValue": "properties.request.queryWindowInMin" } ], "menuItems": [ @@ -2746,6 +2763,15 @@ "name": "subaccountName" } }, + { + "type": "Textbox", + "parameters": { + "label": "Subaccount ID (GUID)", + "placeholder": "SubaccountId property from the BTP service key JSON", + "type": "text", + "name": "subaccountId" + } + }, { "type": "Textbox", "parameters": { @@ -2826,7 +2852,15 @@ "mapping": [ { "columnName": "Subaccount Name", + "columnValue": "properties.addOnAttributes.SubaccountName" + }, + { + "columnName": "Subaccount ID", "columnValue": "name" + }, + { + "columnName": "Polling Frequency (minutes)", + "columnValue": "properties.request.queryWindowInMin" } ], "menuItems": [ @@ -2890,10 +2924,6 @@ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "[variables('dataConnectorCCPVersion')]", "parameters": { - "guidValue": { - "defaultValue": "[[newGuid()]", - "type": "securestring" - }, "innerWorkspace": { "defaultValue": "[parameters('workspace')]", "type": "securestring" @@ -2919,6 +2949,11 @@ "type": "securestring", "minLength": 1 }, + "subaccountId": { + "defaultValue": "subaccountId", + "type": "securestring", + "minLength": 1 + }, "clientId": { "defaultValue": "clientId", "type": "securestring", @@ -2980,7 +3015,7 @@ } }, { - "name": "[[concat(parameters('innerWorkspace'), '/Microsoft.SecurityInsights', '/BTP_', parameters('subaccountName'), parameters('guidValue'))]", + "name": "[[concat(parameters('innerWorkspace'), '/Microsoft.SecurityInsights', '/', parameters('subaccountId'))]", "apiVersion": "2023-02-01-preview", "type": "Microsoft.OperationalInsights/workspaces/providers/dataConnectors", "location": "[parameters('workspace-location')]", @@ -3054,7 +3089,7 @@ "apiVersion": "2023-04-01-preview", "location": "[parameters('workspace-location')]", "properties": { - "version": "3.0.11", + "version": "3.0.12", "kind": "Solution", "contentSchemaVersion": "3.0.0", "displayName": "SAP BTP", diff --git a/Solutions/SAP BTP/Tools/BtpHelpers.ps1 b/Solutions/SAP BTP/Tools/BtpHelpers.ps1 index 31a338450d9..a5b54e574bb 100644 --- a/Solutions/SAP BTP/Tools/BtpHelpers.ps1 +++ b/Solutions/SAP BTP/Tools/BtpHelpers.ps1 @@ -16,19 +16,6 @@ function Write-Log { Write-Host "[$timestamp] [$Level] $Message" -ForegroundColor $color } -# Function to check if Cloud Foundry CLI is installed -function Test-CfCli { - try { - $cfVersion = cf --version 2>&1 - Write-Log "CF CLI is installed: $cfVersion" - return $true - } - catch { - Write-Log "CF CLI is not installed or not in PATH. Please install it first." -Level "ERROR" - return $false - } -} - # Function to check if BTP CLI is installed function Test-BtpCli { try { @@ -79,6 +66,127 @@ function Get-AzureAccessToken { } } +# Function to acquire CF UAA OAuth token +function Get-CfUaaToken { + param( + [Parameter(Mandatory=$true)] + [string]$Username, + + [Parameter(Mandatory=$true)] + [SecureString]$Password, + + [Parameter(Mandatory=$true)] + [string]$Region + ) + + try { + Write-Log "Acquiring CF UAA OAuth token for region $Region..." + + $cfUaaEndpoint = "https://login.cf.$Region.hana.ondemand.com/oauth/token" + + # Convert SecureString to plain text for API call + $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password) + $plainPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR) + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR) + + $body = @{ + username = $Username + password = $plainPassword + client_id = "cf" + grant_type = "password" + response_type = "token" + } + + # Clear password from memory + $plainPassword = $null + + # Basic Auth: cf: (empty password) + $basicAuth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("cf:")) + + $headers = @{ + "Authorization" = "Basic $basicAuth" + "Content-Type" = "application/x-www-form-urlencoded" + } + + $response = Invoke-RestMethod -Uri $cfUaaEndpoint ` + -Method Post ` + -Headers $headers ` + -Body $body ` + -ErrorAction Stop + + Write-Log "CF UAA token acquired successfully (expires in $($response.expires_in)s)" -Level "SUCCESS" + + return @{ + AccessToken = $response.access_token + TokenType = $response.token_type + ExpiresIn = $response.expires_in + } + } + catch { + Write-Log "Failed to acquire CF UAA token: $($_.Exception.Message)" -Level "ERROR" + return $null + } +} + +# Function to make authenticated CF API calls +function Invoke-CfApi { + param( + [Parameter(Mandatory=$true)] + [string]$CfApiEndpoint, + + [Parameter(Mandatory=$true)] + [string]$AccessToken, + + [Parameter(Mandatory=$true)] + [string]$Path, + + [Parameter(Mandatory=$false)] + [ValidateSet("GET", "POST", "DELETE", "PATCH")] + [string]$Method = "GET", + + [Parameter(Mandatory=$false)] + [object]$Body = $null + ) + + try { + $headers = @{ + "Authorization" = "Bearer $AccessToken" + "Content-Type" = "application/json" + } + + $uri = "$CfApiEndpoint$Path" + + $params = @{ + Uri = $uri + Method = $Method + Headers = $headers + ErrorAction = "Stop" + StatusCodeVariable = "statusCode" + } + + if ($Body) { + $params.Body = ($Body | ConvertTo-Json -Depth 10) + } + + $response = Invoke-RestMethod @params + + # For successful operations that return no body (like 202 Accepted), return a success indicator + if (-not $response -and $statusCode -ge 200 -and $statusCode -lt 300) { + return @{ Success = $true; StatusCode = $statusCode } + } + + return $response + } + catch { + $errorMessage = $_.Exception.Message + if ($_.ErrorDetails.Message) { + $errorMessage = $_.ErrorDetails.Message + } + Write-Log "CF API call failed ($Method $Path): $errorMessage" -Level "ERROR" + return $null + } +} + # Function to validate and prompt for CF credentials function Get-CfCredentials { param( @@ -190,65 +298,55 @@ function Get-BtpCredentials { } } -# Function to perform CF login with credentials +# Function to perform CF authentication using OAuth function Invoke-CfLogin { param( [string]$ApiEndpoint, [string]$Username, [SecureString]$Password, [string]$OrgName = "", - [string]$SpaceName = "" + [string]$SpaceName = "", + [string]$Region = "" ) try { if ([string]::IsNullOrWhiteSpace($Username) -or $null -eq $Password) { Write-Log "CF credentials missing for login attempt." -Level "ERROR" - Write-Log "Username provided: $(-not [string]::IsNullOrWhiteSpace($Username))" -Level "ERROR" - Write-Log "Password provided: $($null -ne $Password)" -Level "ERROR" return $false } - Write-Log "Authenticating to Cloud Foundry API: $ApiEndpoint" - - # Convert SecureString to plain text for CF CLI (only in memory during login) - $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password) - $plainPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR) - [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR) - - # Build cf login command with org and space if provided - $loginArgs = @("login", "-a", $ApiEndpoint, "-u", $Username, "-p", $plainPassword) - if (-not [string]::IsNullOrWhiteSpace($OrgName)) { - $loginArgs += @("-o", $OrgName) + # Extract region from API endpoint if not provided + if ([string]::IsNullOrWhiteSpace($Region) -and $ApiEndpoint -match '\.cf\.([^.]+)\.') { + $Region = $matches[1] + Write-Log "Extracted region from API endpoint: $Region" } - if (-not [string]::IsNullOrWhiteSpace($SpaceName)) { - $loginArgs += @("-s", $SpaceName) + + if ([string]::IsNullOrWhiteSpace($Region)) { + Write-Log "Cannot determine CF region for OAuth authentication" -Level "ERROR" + return $false } - # Execute cf login - $result = & cf $loginArgs 2>&1 + Write-Log "Authenticating to Cloud Foundry API: $ApiEndpoint using OAuth" - # Clear the password from memory immediately - $plainPassword = $null + $tokenInfo = Get-CfUaaToken -Username $Username -Password $Password -Region $Region - if ($LASTEXITCODE -eq 0) { - Write-Log "Successfully authenticated to Cloud Foundry" -Level "SUCCESS" - return $true - } - else { - Write-Log "Authentication failed: $result" -Level "ERROR" + if ($null -eq $tokenInfo) { + Write-Log "Failed to acquire CF OAuth token" -Level "ERROR" return $false } + + $script:CfAccessToken = $tokenInfo.AccessToken + $script:CfApiEndpoint = $ApiEndpoint + $script:CfOrgName = $OrgName + $script:CfSpaceName = $SpaceName + + Write-Log "Successfully authenticated to Cloud Foundry using OAuth" -Level "SUCCESS" + return $true } catch { Write-Log "Error during authentication: $_" -Level "ERROR" return $false } - finally { - # Ensure password is cleared from memory - if ($plainPassword) { - $plainPassword = $null - } - } } # Function to perform BTP CLI login with credentials @@ -304,7 +402,7 @@ function Invoke-BtpLogin { } } -# Function to switch CF API endpoint and authenticate +# Function to switch CF API endpoint and authenticate (OAuth-based) function Set-CfApiEndpoint { param( [string]$ApiEndpoint, @@ -315,41 +413,32 @@ function Set-CfApiEndpoint { ) try { - # Get current API endpoint - $currentApi = cf api 2>&1 | Select-String -Pattern "api endpoint:" | ForEach-Object { $_.ToString().Split(":")[1].Trim() } - - if ($currentApi -eq $ApiEndpoint) { - Write-Log "Already connected to API endpoint: $ApiEndpoint" - - # Verify authentication is still valid - $authCheck = cf apps 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Log "Session expired or not authenticated. Re-authenticating..." -Level "WARNING" - if (-not (Invoke-CfLogin -ApiEndpoint $ApiEndpoint -Username $Username -Password $Password -OrgName $OrgName -SpaceName $SpaceName)) { - return $false - } - } - return $true + # Extract region from API endpoint + $region = $null + if ($ApiEndpoint -match '\.cf\.([^.]+)\.') { + $region = $matches[1] } - Write-Log "Switching to API endpoint: $ApiEndpoint" - $result = cf api $ApiEndpoint 2>&1 + if ([string]::IsNullOrWhiteSpace($region)) { + Write-Log "Cannot extract region from API endpoint: $ApiEndpoint" -Level "ERROR" + return $false + } - if ($LASTEXITCODE -eq 0) { - Write-Log "Successfully switched to API endpoint: $ApiEndpoint" -Level "SUCCESS" - - # API switch logs you out, so re-authenticate - Write-Log "Re-authenticating after API endpoint switch..." - if (-not (Invoke-CfLogin -ApiEndpoint $ApiEndpoint -Username $Username -Password $Password -OrgName $OrgName -SpaceName $SpaceName)) { - Write-Log "Failed to re-authenticate after API switch" -Level "ERROR" - return $false - } + # Check if already authenticated to this endpoint + if ($script:CfApiEndpoint -eq $ApiEndpoint -and $script:CfAccessToken) { + Write-Log "Already authenticated to API endpoint: $ApiEndpoint" return $true } - else { - Write-Log "Failed to switch API endpoint: $result" -Level "ERROR" + + Write-Log "Authenticating to API endpoint: $ApiEndpoint" + + # Authenticate using OAuth + if (-not (Invoke-CfLogin -ApiEndpoint $ApiEndpoint -Username $Username -Password $Password -OrgName $OrgName -SpaceName $SpaceName -Region $region)) { + Write-Log "Failed to authenticate to API endpoint" -Level "ERROR" return $false } + + return $true } catch { Write-Log "Error switching API endpoint: $_" -Level "ERROR" @@ -365,21 +454,182 @@ function Set-CfTarget { ) try { - Write-Log "Targeting org: '$OrgName', space: '$SpaceName'" - $result = cf target -o $OrgName -s $SpaceName 2>&1 + Write-Log "Setting target org: '$OrgName', space: '$SpaceName'" - if ($LASTEXITCODE -eq 0) { - Write-Log "Successfully targeted org/space" -Level "SUCCESS" - return $true + # Update script scope variables (used by CF API calls) + $script:CfOrgName = $OrgName + $script:CfSpaceName = $SpaceName + + Write-Log "Successfully set target org/space" -Level "SUCCESS" + return $true + } + catch { + Write-Log "Error targeting org/space: $_" -Level "ERROR" + return $false + } +} + +# Helper function to get CF Space ID from org name and space name +function Get-CfSpaceId { + param( + [Parameter(Mandatory=$true)] + [string]$OrgName, + + [Parameter(Mandatory=$false)] + [string]$SpaceName = "" + ) + + try { + # First, get the org ID + $orgsResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/organizations?names=$OrgName" + + if (-not $orgsResponse -or $orgsResponse.pagination.total_results -eq 0) { + Write-Log "Org '$OrgName' not found" -Level "ERROR" + return $null + } + + $orgId = $orgsResponse.resources[0].guid + + # Get spaces in this org + $spacePath = "/v3/spaces?organization_guids=$orgId" + if (-not [string]::IsNullOrWhiteSpace($SpaceName)) { + $spacePath += "&names=$SpaceName" + } + + $spacesResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path $spacePath + + if (-not $spacesResponse -or $spacesResponse.pagination.total_results -eq 0) { + Write-Log "Space '$SpaceName' not found in org '$OrgName'" -Level "ERROR" + return $null + } + + # Return first space (or specified space) + return $spacesResponse.resources[0].guid + } + catch { + Write-Log "Error getting space ID: $_" -Level "ERROR" + return $null + } +} + +# Function to discover service instances by offering type +function Get-CfServiceKeys { + param( + [Parameter(Mandatory=$true)] + [string]$InstanceName + ) + + try { + Write-Log "Listing service keys for instance '$InstanceName'..." + + # Get space ID + if ([string]::IsNullOrWhiteSpace($script:CfOrgName) -or [string]::IsNullOrWhiteSpace($script:CfSpaceName)) { + Write-Log "CF org/space not set. Call Set-CfTarget first." -Level "ERROR" + return @() + } + + $spaceId = Get-CfSpaceId -OrgName $script:CfOrgName -SpaceName $script:CfSpaceName + if (-not $spaceId) { + return @() + } + + # Get service instance GUID by name + $instancesResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_instances?space_guids=$spaceId&names=$InstanceName" + + if (-not $instancesResponse -or $instancesResponse.pagination.total_results -eq 0) { + Write-Log "Service instance '$InstanceName' not found" -Level "ERROR" + return @() + } + + $instanceGuid = $instancesResponse.resources[0].guid + + # Get service credential bindings (keys) for this instance + $keysResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_credential_bindings?service_instance_guids=$instanceGuid&type=key" + + if (-not $keysResponse) { + Write-Log "Failed to list service keys" -Level "ERROR" + return @() + } + + # Extract key names + $keyNames = @($keysResponse.resources | ForEach-Object { $_.name }) + + if ($keyNames.Count -gt 0) { + Write-Log "Found $($keyNames.Count) service key(s)" -Level "SUCCESS" } else { - Write-Log "Failed to target org/space: $result" -Level "ERROR" - return $false + Write-Log "No service keys found for instance '$InstanceName'" -Level "INFO" } + + return $keyNames } catch { - Write-Log "Error targeting org/space: $_" -Level "ERROR" - return $false + Write-Log "Error listing service keys: $_" -Level "ERROR" + return @() + } +} + +# Function to list service keys with full details +function Get-CfServiceKeysWithDetails { + param( + [Parameter(Mandatory=$true)] + [string]$InstanceName + ) + + try { + # Get space ID + if ([string]::IsNullOrWhiteSpace($script:CfOrgName) -or [string]::IsNullOrWhiteSpace($script:CfSpaceName)) { + Write-Log "CF org/space not set. Call Set-CfTarget first." -Level "ERROR" + return @() + } + + $spaceId = Get-CfSpaceId -OrgName $script:CfOrgName -SpaceName $script:CfSpaceName + if (-not $spaceId) { + return @() + } + + # Get service instance GUID by name + $instancesResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_instances?space_guids=$spaceId&names=$InstanceName" + + if (-not $instancesResponse -or $instancesResponse.pagination.total_results -eq 0) { + Write-Log "Service instance '$InstanceName' not found" -Level "ERROR" + return @() + } + + $instanceGuid = $instancesResponse.resources[0].guid + + # Get service credential bindings (keys) with full details + $keysResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_credential_bindings?service_instance_guids=$instanceGuid&type=key" + + if (-not $keysResponse) { + Write-Log "Failed to list service keys" -Level "ERROR" + return @() + } + + # Return full key objects (includes guid, name, created_at, updated_at) + return @($keysResponse.resources) + } + catch { + Write-Log "Error listing service keys with details: $_" -Level "ERROR" + return @() } } @@ -387,54 +637,96 @@ function Set-CfTarget { function Get-CfServiceKey { param( [string]$InstanceName, - [string]$KeyName + [string]$KeyName, + [int]$MaxRetries = 5, + [int]$InitialDelaySeconds = 2 ) try { Write-Log "Retrieving service key '$KeyName' for instance '$InstanceName'..." - # Get service key output (may contain informational text before JSON) - $rawOutput = cf service-key $InstanceName $KeyName 2>&1 + # Get space ID + if ([string]::IsNullOrWhiteSpace($script:CfOrgName) -or [string]::IsNullOrWhiteSpace($script:CfSpaceName)) { + Write-Log "CF org/space not set. Call Set-CfTarget first." -Level "ERROR" + return $null + } - if ($LASTEXITCODE -ne 0) { - Write-Log "Failed to retrieve service key: $rawOutput" -Level "ERROR" + $spaceId = Get-CfSpaceId -OrgName $script:CfOrgName -SpaceName $script:CfSpaceName + if (-not $spaceId) { return $null } - # Extract JSON from the output (skip lines until we find the opening brace) - $jsonLines = @() - $jsonStarted = $false + # Get service instance GUID + $instancesResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_instances?space_guids=$spaceId&names=$InstanceName" - foreach ($line in $rawOutput) { - if ($line -match '^\s*\{') { - $jsonStarted = $true - } - if ($jsonStarted) { - $jsonLines += $line - } + if (-not $instancesResponse -or $instancesResponse.pagination.total_results -eq 0) { + Write-Log "Service instance '$InstanceName' not found" -Level "ERROR" + return $null } - if ($jsonLines.Count -eq 0) { - Write-Log "No JSON found in service key output" -Level "ERROR" + $instanceGuid = $instancesResponse.resources[0].guid + + # Get service credential binding (key) by name + $keysResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_credential_bindings?service_instance_guids=$instanceGuid&type=key&names=$KeyName" + + if (-not $keysResponse -or $keysResponse.pagination.total_results -eq 0) { + Write-Log "Service key '$KeyName' not found" -Level "ERROR" return $null } - # Parse the JSON - $jsonString = $jsonLines -join "`n" - $result = $jsonString | ConvertFrom-Json + $keyGuid = $keysResponse.resources[0].guid - if ($null -ne $result) { - Write-Log "Successfully retrieved service key" -Level "SUCCESS" - return $result + # Get key details (credentials) with retry logic for async provisioning + $attempt = 0 + $delaySeconds = $InitialDelaySeconds + $keyDetailsResponse = $null + + while ($attempt -lt $MaxRetries) { + $attempt++ + + $keyDetailsResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_credential_bindings/$keyGuid/details" + + # Check if we got a valid response + if ($keyDetailsResponse -and $keyDetailsResponse.credentials) { + break + } + + # If this was the last attempt, fail + if ($attempt -ge $MaxRetries) { + Write-Log "Failed to retrieve key credentials after $MaxRetries attempts" -Level "ERROR" + return $null + } + + # Key might still be provisioning - wait with exponential backoff + Write-Log "Key provisioning in progress (attempt $attempt/$MaxRetries). Waiting $delaySeconds seconds..." -Level "WARNING" + Start-Sleep -Seconds $delaySeconds + + # Exponential backoff: 2, 4, 8, 16 seconds + $delaySeconds = $delaySeconds * 2 } - else { - Write-Log "Failed to parse service key JSON" -Level "ERROR" + + if (-not $keyDetailsResponse) { + Write-Log "Failed to retrieve key credentials" -Level "ERROR" return $null } + + Write-Log "Successfully retrieved service key (attempt $attempt/$MaxRetries)" -Level "SUCCESS" + + # CF API v3 returns credentials directly in the response (no .credentials wrapper) + # Return the entire response as it contains the credentials + return $keyDetailsResponse } catch { Write-Log "Error retrieving service key: $_" -Level "ERROR" - Write-Log "Raw output: $rawOutput" -Level "ERROR" return $null } } @@ -447,45 +739,74 @@ function Get-BtpServiceKeyCredentials { ) try { - # Extract credentials from service key structure - $credentials = $ServiceKey.credentials - if ($null -eq $credentials) { - Write-Log "Service key missing 'credentials' property" -Level "ERROR" - return $null + # CF API v3 returns credentials directly (no wrapper), but structure may vary + # Try to detect if this is wrapped in .credentials or not + $credentials = $ServiceKey + + # If it has a .credentials property, use that (legacy format) + if ($null -ne $ServiceKey.credentials) { + $credentials = $ServiceKey.credentials + } + + # Validate required fields - check both direct access and .uaa nested access + $clientId = $null + $clientSecret = $null + $uaaUrl = $null + $apiUrl = $null + + # Try direct access first (CF API v3 format) + if ($credentials.uaa) { + $clientId = $credentials.uaa.clientid + $clientSecret = $credentials.uaa.clientsecret + $uaaUrl = $credentials.uaa.url + $apiUrl = $credentials.url + } + # Fallback to old nested format if needed + elseif ($credentials.clientid) { + $clientId = $credentials.clientid + $clientSecret = $credentials.clientsecret + $uaaUrl = $credentials.url + $apiUrl = $credentials.url } - # Validate required fields - if ([string]::IsNullOrWhiteSpace($credentials.uaa.clientid)) { + if ([string]::IsNullOrWhiteSpace($clientId)) { Write-Log "Service key missing UAA client ID" -Level "ERROR" + Write-Log "Service key structure: $($ServiceKey | ConvertTo-Json -Depth 3)" -Level "ERROR" return $null } - if ([string]::IsNullOrWhiteSpace($credentials.uaa.clientsecret)) { + if ([string]::IsNullOrWhiteSpace($clientSecret)) { Write-Log "Service key missing UAA client secret" -Level "ERROR" return $null } - if ([string]::IsNullOrWhiteSpace($credentials.uaa.url)) { + if ([string]::IsNullOrWhiteSpace($uaaUrl)) { Write-Log "Service key missing UAA URL" -Level "ERROR" return $null } - if ([string]::IsNullOrWhiteSpace($credentials.url)) { + if ([string]::IsNullOrWhiteSpace($apiUrl)) { Write-Log "Service key missing audit log API URL" -Level "ERROR" return $null } # Extract subdomain from UAA URL (e.g., https://subdomain.authentication.region.hana.ondemand.com) $subdomain = "" - if ($credentials.uaa.url -match "https?://([^.]+)\..*") { + if ($uaaUrl -match "https?://([^.]+)\..*") { $subdomain = $matches[1] } + # Extract subaccount ID + $subaccountId = $credentials.uaa.subaccountid + if ([string]::IsNullOrWhiteSpace($subaccountId)) { + $subaccountId = $credentials.subaccountid + } + # Return validated credentials object # Note: Full OAuth token endpoint path with grant_type parameter is required return @{ - ClientId = $credentials.uaa.clientid - ClientSecret = $credentials.uaa.clientsecret - TokenEndpoint = "$($credentials.uaa.url)/oauth/token?grant_type=client_credentials" - ApiUrl = $credentials.url - SubaccountId = $credentials.uaa.subaccountid + ClientId = $clientId + ClientSecret = $clientSecret + TokenEndpoint = "$uaaUrl/oauth/token?grant_type=client_credentials" + ApiUrl = $apiUrl + SubaccountId = $subaccountId Subdomain = $subdomain } } @@ -604,22 +925,87 @@ function New-CfServiceInstance { try { Write-Log "Creating service instance '$InstanceName' with service '$Service' and plan '$Plan'..." + # Get space ID + if ([string]::IsNullOrWhiteSpace($script:CfOrgName) -or [string]::IsNullOrWhiteSpace($script:CfSpaceName)) { + Write-Log "CF org/space not set. Call Set-CfTarget first." -Level "ERROR" + return $false + } + + $spaceId = Get-CfSpaceId -OrgName $script:CfOrgName -SpaceName $script:CfSpaceName + if (-not $spaceId) { + return $false + } + # Check if service instance already exists - $existingService = cf service $InstanceName 2>&1 - if ($LASTEXITCODE -eq 0) { + $existingResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_instances?space_guids=$spaceId&names=$InstanceName" + + if ($existingResponse -and $existingResponse.pagination.total_results -gt 0) { Write-Log "Service instance '$InstanceName' already exists. Skipping creation." -Level "WARNING" return $true } - # Create service instance using CF CLI - $result = cf create-service $Service $Plan $InstanceName 2>&1 + # Get service offering and plan GUIDs + # First, get service offerings + $offeringsResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_offerings?names=$Service" - if ($LASTEXITCODE -eq 0) { - Write-Log "Successfully created service instance '$InstanceName'" -Level "SUCCESS" + if (-not $offeringsResponse -or $offeringsResponse.pagination.total_results -eq 0) { + Write-Log "Service offering '$Service' not found" -Level "ERROR" + return $false + } + + $serviceOfferingGuid = $offeringsResponse.resources[0].guid + + # Get service plan + $plansResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_plans?service_offering_guids=$serviceOfferingGuid&names=$Plan" + + if (-not $plansResponse -or $plansResponse.pagination.total_results -eq 0) { + Write-Log "Service plan '$Plan' not found for service '$Service'" -Level "ERROR" + return $false + } + + $servicePlanGuid = $plansResponse.resources[0].guid + + # Create service instance + $createBody = @{ + type = "managed" + name = $InstanceName + relationships = @{ + space = @{ + data = @{ + guid = $spaceId + } + } + service_plan = @{ + data = @{ + guid = $servicePlanGuid + } + } + } + } + + $createResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_instances" ` + -Method "POST" ` + -Body $createBody + + if ($createResponse -and ($createResponse.Success -eq $true -or $createResponse.guid)) { + Write-Log "Successfully initiated creation of service instance '$InstanceName'" -Level "SUCCESS" + Write-Log "Note: Service instance creation is asynchronous. It may take a few minutes to complete." -Level "INFO" return $true } else { - Write-Log "Failed to create service instance '$InstanceName': $result" -Level "ERROR" + Write-Log "Failed to create service instance '$InstanceName' - check error details above" -Level "ERROR" return $false } } @@ -629,7 +1015,7 @@ function New-CfServiceInstance { } } -# Function to create CF service key +# Function to create CF service key (CF API-based) function New-CfServiceKey { param( [string]$InstanceName, @@ -639,22 +1025,67 @@ function New-CfServiceKey { try { Write-Log "Creating service key '$KeyName' for service instance '$InstanceName'..." - # Check if service key already exists - $existingKey = cf service-key $InstanceName $KeyName 2>&1 - if ($LASTEXITCODE -eq 0) { + # Get space ID + if ([string]::IsNullOrWhiteSpace($script:CfOrgName) -or [string]::IsNullOrWhiteSpace($script:CfSpaceName)) { + Write-Log "CF org/space not set. Call Set-CfTarget first." -Level "ERROR" + return $false + } + + $spaceId = Get-CfSpaceId -OrgName $script:CfOrgName -SpaceName $script:CfSpaceName + if (-not $spaceId) { + return $false + } + + # Get service instance GUID + $instancesResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_instances?space_guids=$spaceId&names=$InstanceName" + + if (-not $instancesResponse -or $instancesResponse.pagination.total_results -eq 0) { + Write-Log "Service instance '$InstanceName' not found" -Level "ERROR" + return $false + } + + $instanceGuid = $instancesResponse.resources[0].guid + + # Check if key already exists + $existingKeysResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_credential_bindings?service_instance_guids=$instanceGuid&type=key&names=$KeyName" + + if ($existingKeysResponse -and $existingKeysResponse.pagination.total_results -gt 0) { Write-Log "Service key '$KeyName' already exists. Skipping creation." -Level "WARNING" return $true } - # Create service key - $result = cf create-service-key $InstanceName $KeyName 2>&1 + # Create the service key + $createKeyBody = @{ + type = "key" + name = $KeyName + relationships = @{ + service_instance = @{ + data = @{ + guid = $instanceGuid + } + } + } + } - if ($LASTEXITCODE -eq 0) { + $newKeyResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_credential_bindings" ` + -Method "POST" ` + -Body $createKeyBody + + if ($newKeyResponse -and ($newKeyResponse.Success -eq $true -or $newKeyResponse.guid)) { Write-Log "Successfully created service key '$KeyName'" -Level "SUCCESS" return $true } else { - Write-Log "Failed to create service key '$KeyName': $result" -Level "ERROR" + Write-Log "Failed to create service key '$KeyName' - check error details above" -Level "ERROR" return $false } } @@ -664,6 +1095,40 @@ function New-CfServiceKey { } } +# Function to delete CF service key (CF API-based) +function Remove-CfServiceKey { + param( + [Parameter(Mandatory=$true)] + [string]$KeyGuid, + + [Parameter(Mandatory=$false)] + [string]$KeyName = "Unknown" + ) + + try { + Write-Log "Deleting service key '$KeyName' (GUID: $KeyGuid)..." + + $deleteResponse = Invoke-CfApi ` + -CfApiEndpoint $script:CfApiEndpoint ` + -AccessToken $script:CfAccessToken ` + -Path "/v3/service_credential_bindings/$KeyGuid" ` + -Method "DELETE" + + if ($deleteResponse -and ($deleteResponse.Success -eq $true -or $deleteResponse.guid)) { + Write-Log "Successfully deleted service key '$KeyName'" -Level "SUCCESS" + return $true + } + else { + Write-Log "Failed to delete service key '$KeyName'" -Level "ERROR" + return $false + } + } + catch { + Write-Log "Error deleting service key: $_" -Level "ERROR" + return $false + } +} + # Function to load and validate CSV with required columns function Import-BtpSubaccountsCsv { param( @@ -752,6 +1217,291 @@ function Export-BtpSubaccountsCsv { } } +# Function to export service key credentials to CSV (for split permissions scenarios) +# WARNING: This stores sensitive credentials in plaintext. Use only for testing or with secure file transfer. +function Export-ServiceKeyToCsv { + param( + [Parameter(Mandatory=$true)] + [string]$CsvPath, + + [Parameter(Mandatory=$true)] + [string]$SubaccountId, + + [Parameter(Mandatory=$true)] + [hashtable]$Credentials + ) + + try { + if (-not (Test-Path $CsvPath)) { + Write-Log "CSV file not found at path: $CsvPath" -Level "ERROR" + return $false + } + + Write-Log "Exporting service key credentials for subaccount '$SubaccountId' to CSV..." + + # Load existing CSV + $csvData = Import-Csv -Path $CsvPath -Delimiter ';' + + # Find the row matching this subaccount + $updated = $false + foreach ($row in $csvData) { + if ($row.SubaccountId -eq $SubaccountId) { + # Add credential columns to this row + $row | Add-Member -NotePropertyName 'ClientId' -NotePropertyValue $Credentials.ClientId -Force + $row | Add-Member -NotePropertyName 'ClientSecret' -NotePropertyValue $Credentials.ClientSecret -Force + $row | Add-Member -NotePropertyName 'TokenEndpoint' -NotePropertyValue $Credentials.TokenEndpoint -Force + $row | Add-Member -NotePropertyName 'ApiUrl' -NotePropertyValue $Credentials.ApiUrl -Force + $row | Add-Member -NotePropertyName 'Subdomain' -NotePropertyValue $Credentials.Subdomain -Force + $updated = $true + Write-Log "Updated credentials for subaccount '$SubaccountId'" -Level "SUCCESS" + break + } + } + + if (-not $updated) { + Write-Log "Subaccount '$SubaccountId' not found in CSV" -Level "ERROR" + return $false + } + + # Write back to CSV + $csvData | Export-Csv -Path $CsvPath -Delimiter ';' -NoTypeInformation + Write-Log "Successfully exported credentials to CSV" -Level "SUCCESS" + Write-Log "WARNING: CSV contains sensitive credentials in plaintext. Secure this file appropriately." -Level "WARNING" + + return $true + } + catch { + Write-Log "Error exporting service key to CSV: $_" -Level "ERROR" + return $false + } +} + +# Function to import service key credentials from CSV (for split permissions scenarios) +function Get-ServiceKeyFromCsv { + param( + [Parameter(Mandatory=$true)] + [string]$CsvPath, + + [Parameter(Mandatory=$true)] + [string]$SubaccountId + ) + + try { + if (-not (Test-Path $CsvPath)) { + Write-Log "CSV file not found at path: $CsvPath" -Level "ERROR" + return $null + } + + Write-Log "Loading service key credentials for subaccount '$SubaccountId' from CSV..." + + # Load CSV + $csvData = Import-Csv -Path $CsvPath -Delimiter ';' + + # Find the row matching this subaccount + $row = $csvData | Where-Object { $_.SubaccountId -eq $SubaccountId } | Select-Object -First 1 + + if ($null -eq $row) { + Write-Log "Subaccount '$SubaccountId' not found in CSV" -Level "ERROR" + return $null + } + + # Check if credential columns exist and are populated + $requiredFields = @('ClientId', 'ClientSecret', 'TokenEndpoint', 'ApiUrl', 'Subdomain') + $missingFields = @() + + foreach ($field in $requiredFields) { + if (-not $row.PSObject.Properties.Name.Contains($field) -or + [string]::IsNullOrWhiteSpace($row.$field)) { + $missingFields += $field + } + } + + if ($missingFields.Count -gt 0) { + Write-Log "CSV row for subaccount '$SubaccountId' missing required credential fields: $($missingFields -join ', ')" -Level "ERROR" + return $null + } + + # Return credentials object matching the format from Get-BtpServiceKeyCredentials + $credentials = @{ + ClientId = $row.ClientId + ClientSecret = $row.ClientSecret + TokenEndpoint = $row.TokenEndpoint + ApiUrl = $row.ApiUrl + SubaccountId = $SubaccountId + Subdomain = $row.Subdomain + } + + Write-Log "Successfully loaded credentials from CSV" -Level "SUCCESS" + return $credentials + } + catch { + Write-Log "Error reading service key from CSV: $_" -Level "ERROR" + return $null + } +} + +# Function to export service key credentials to Azure Key Vault (for split permissions scenarios) +function Export-ServiceKeyToKeyVault { + param( + [Parameter(Mandatory=$true)] + [string]$KeyVaultName, + + [Parameter(Mandatory=$true)] + [string]$SubaccountId, + + [Parameter(Mandatory=$true)] + [hashtable]$Credentials + ) + + try { + Write-Log "Exporting service key credentials for subaccount '$SubaccountId' to Key Vault '$KeyVaultName'..." + + # Normalize subaccount ID for secret name (replace invalid characters) + $normalizedId = $SubaccountId -replace '[^a-zA-Z0-9-]', '-' + + # Secret name: btp-{subaccount-id} + $secretName = "btp-$normalizedId" + + # Create JSON object with all credentials + $credentialsJson = @{ + ClientId = $Credentials.ClientId + ClientSecret = $Credentials.ClientSecret + TokenEndpoint = $Credentials.TokenEndpoint + ApiUrl = $Credentials.ApiUrl + Subdomain = $Credentials.Subdomain + } | ConvertTo-Json -Compress + + # Store as single secret - use PowerShell's call operator with proper escaping + Write-Log "Storing credentials as secret: $secretName" + + # Escape JSON for Azure CLI - need to escape double quotes + $escapedJson = $credentialsJson.Replace('"', '\"') + + # Use az CLI with properly escaped JSON + $result = az keyvault secret set --vault-name $KeyVaultName --name $secretName --value $escapedJson 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Log "Successfully stored credentials to Key Vault" -Level "SUCCESS" + return $true + } else { + # Check for common permission errors + $errorMessage = $result -join " " + + if ($errorMessage -match "Forbidden|ForbiddenByRbac|not authorized") { + Write-Log "Access denied to Key Vault '$KeyVaultName'" -Level "ERROR" + Write-Log "Please ensure your account has the 'Key Vault Secrets Officer' role assignment" -Level "ERROR" + Write-Log "Run: az role assignment create --role 'Key Vault Secrets Officer' --assignee --scope /subscriptions/$SubscriptionId/resourceGroups//providers/Microsoft.KeyVault/vaults/$KeyVaultName" -Level "ERROR" + } elseif ($errorMessage -match "ObjectIsDeletedButRecoverable|deleted but recoverable") { + Write-Log "Secret '$secretName' is in deleted state (soft-delete enabled)" -Level "WARNING" + Write-Log "Purging and recreating..." -Level "INFO" + + # Purge the deleted secret + $purgeResult = & az keyvault secret purge --vault-name $KeyVaultName --name $secretName 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Log "Successfully purged deleted secret" -Level "SUCCESS" + Start-Sleep -Seconds 2 + + # Retry creation + $result = & az @azArgs 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Log "Successfully stored credentials to Key Vault" -Level "SUCCESS" + return $true + } else { + Write-Log "Failed to store secret after purge: $result" -Level "ERROR" + return $false + } + } else { + Write-Log "Failed to purge secret: $purgeResult" -Level "ERROR" + Write-Log "Run: az keyvault secret purge --vault-name $KeyVaultName --name $secretName" -Level "ERROR" + return $false + } + } elseif ($errorMessage -match "not found|does not exist") { + Write-Log "Key Vault '$KeyVaultName' not found" -Level "ERROR" + Write-Log "Please verify the Key Vault name and ensure it exists" -Level "ERROR" + } else { + Write-Log "Failed to store secret: $result" -Level "ERROR" + } + + return $false + } + } + catch { + Write-Log "Error exporting service key to Key Vault: $_" -Level "ERROR" + return $false + } +} + +# Function to import service key credentials from Azure Key Vault (for split permissions scenarios) +function Get-ServiceKeyFromKeyVault { + param( + [Parameter(Mandatory=$true)] + [string]$KeyVaultName, + + [Parameter(Mandatory=$true)] + [string]$SubaccountId + ) + + try { + Write-Log "Loading service key credentials for subaccount '$SubaccountId' from Key Vault '$KeyVaultName'..." + + # Normalize subaccount ID for secret name (replace invalid characters) + $normalizedId = $SubaccountId -replace '[^a-zA-Z0-9-]', '-' + + # Secret name: btp-{subaccount-id} + $secretName = "btp-$normalizedId" + + # Retrieve the secret + Write-Log "Retrieving secret: $secretName" + $result = az keyvault secret show --vault-name $KeyVaultName --name $secretName --query "value" -o tsv 2>&1 + + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($result)) { + # Check for common errors + $errorMessage = $result -join " " + + if ($errorMessage -match "Forbidden|ForbiddenByRbac|not authorized") { + Write-Log "Access denied to Key Vault '$KeyVaultName'" -Level "ERROR" + Write-Log "Please ensure your account has the 'Key Vault Secrets User' role assignment" -Level "ERROR" + } elseif ($errorMessage -match "SecretNotFound|not found") { + Write-Log "Secret '$secretName' not found in Key Vault '$KeyVaultName'" -Level "ERROR" + Write-Log "Please ensure credentials were exported using -ExportCredentialsToKeyVault" -Level "ERROR" + } elseif ($errorMessage -match "VaultNotFound|does not exist") { + Write-Log "Key Vault '$KeyVaultName' not found" -Level "ERROR" + } else { + Write-Log "Failed to retrieve secret '$secretName': $result" -Level "ERROR" + } + + return $null + } + + Write-Log "Successfully retrieved secret '$secretName'" -Level "SUCCESS" + + # Debug: Show what we retrieved + Write-Log "Retrieved value: $result" -Level "INFO" + + # Parse JSON + $credentialsObj = $result | ConvertFrom-Json + + # Return credentials object matching the format from Get-BtpServiceKeyCredentials + $credentials = @{ + ClientId = $credentialsObj.ClientId + ClientSecret = $credentialsObj.ClientSecret + TokenEndpoint = $credentialsObj.TokenEndpoint + ApiUrl = $credentialsObj.ApiUrl + SubaccountId = $SubaccountId + Subdomain = $credentialsObj.Subdomain + } + + Write-Log "Successfully loaded credentials from Key Vault" -Level "SUCCESS" + return $credentials + } + catch { + Write-Log "Error reading service key from Key Vault: $_" -Level "ERROR" + return $null + } +} + # Function to create SAP BTP connection in Microsoft Sentinel function New-SentinelBtpConnection { param( @@ -778,14 +1528,21 @@ function New-SentinelBtpConnection { ) try { - # Sanitize connection name to be URL-compliant by removing unsupported characters - # Keep only alphanumeric, hyphens, underscores, and periods - $sanitizedConnectionName = $ConnectionName -replace '[^a-zA-Z0-9\-_\.]', '' - - Write-Log "Creating SAP BTP connection '$sanitizedConnectionName' for subaccount '$SubaccountId'..." - if ($sanitizedConnectionName -ne $ConnectionName) { - Write-Log " Original name '$ConnectionName' was sanitized for URL compliance" -Level "WARNING" + # Validate SubaccountId is a valid GUID (connector ID must be the subaccount GUID) + try { + $guidTest = [System.Guid]::Parse($SubaccountId) } + catch { + Write-Log "SubaccountId '$SubaccountId' is not a valid GUID" -Level "ERROR" + return $false + } + + # Use SubaccountId (GUID) as the connector ID (already validated as proper GUID format) + $connectorId = $SubaccountId.ToLower() + + Write-Log "Creating SAP BTP connection for subaccount '$SubaccountId'..." + Write-Log " Connector ID (ARM resource): $connectorId" + Write-Log " Display Name: $ConnectionName" # Validate credentials $requiredFields = @('ClientId', 'ClientSecret', 'TokenEndpoint', 'ApiUrl') @@ -817,17 +1574,16 @@ function New-SentinelBtpConnection { } # Build request body with DCR configuration - # Use sanitized connection name for SubaccountName to ensure consistency - $bodyObject = New-BtpConnectionRequestBody -BtpCredentials $BtpCredentials -DcrConfig $DcrConfig -SubaccountName $sanitizedConnectionName -PollingFrequencyMinutes $PollingFrequencyMinutes -IngestDelayMinutes $IngestDelayMinutes + # Use ConnectionName for display/friendly name in the connection properties + $bodyObject = New-BtpConnectionRequestBody -BtpCredentials $BtpCredentials -DcrConfig $DcrConfig -SubaccountName $ConnectionName -PollingFrequencyMinutes $PollingFrequencyMinutes -IngestDelayMinutes $IngestDelayMinutes if ($null -eq $bodyObject) { return $false } $body = $bodyObject | ConvertTo-Json -Depth 10 - # Construct ARM API URI - # Construct ARM API URI - backtick escapes ? to ensure it's treated as literal character - $uri = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.OperationalInsights/workspaces/$WorkspaceName/providers/Microsoft.SecurityInsights/dataConnectors/$sanitizedConnectionName`?api-version=$ApiVersion" + # Construct ARM API URI using SubaccountId (GUID) as the connector resource ID + $uri = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.OperationalInsights/workspaces/$WorkspaceName/providers/Microsoft.SecurityInsights/dataConnectors/$connectorId`?api-version=$ApiVersion" # Create headers $headers = @{ @@ -838,7 +1594,8 @@ function New-SentinelBtpConnection { # Make REST API call $response = Invoke-RestMethod -Uri $uri -Method Put -Headers $headers -Body $body - Write-Log "Successfully created SAP BTP connection '$sanitizedConnectionName'" -Level "SUCCESS" + Write-Log "Successfully created SAP BTP connection with connector ID '$connectorId'" -Level "SUCCESS" + Write-Log " Display Name: $ConnectionName" Write-Log " Subaccount: $SubaccountId" Write-Log " API Endpoint: $($BtpCredentials.ApiUrl)" return $true @@ -864,38 +1621,32 @@ function New-SentinelBtpConnection { # Function to get list of BTP subaccounts function Get-BtpSubaccounts { try { - Write-Log "Retrieving list of BTP subaccounts..." - $subaccountsOutput = btp list accounts/subaccount 2>&1 + Write-Log "Retrieving BTP subaccounts..." + + # Use --format json for reliable parsing + $subaccountsJson = btp --format json list accounts/subaccount 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { - Write-Log "Failed to retrieve subaccounts: $subaccountsOutput" -Level "ERROR" + Write-Log "Failed to retrieve subaccounts: $subaccountsJson" -Level "ERROR" return $null } - # Parse table output - $subaccounts = @() - $headerPassed = $false + # Parse JSON output + $subaccountsData = $subaccountsJson | ConvertFrom-Json - foreach ($line in $subaccountsOutput) { - if ($line -match 'guid|subaccount id') { - $headerPassed = $true - continue - } - - if ($line -match '^[-\s]+$' -or [string]::IsNullOrWhiteSpace($line)) { - continue - } - - if ($headerPassed) { - $columns = $line -split '\s{2,}' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - - if ($columns.Count -ge 2) { - $subaccounts += [PSCustomObject]@{ - guid = $columns[0].Trim() - displayName = $columns[1].Trim() - region = if ($columns.Count -ge 4) { $columns[3].Trim() } else { "" } - } - } + if (-not $subaccountsData.value -or $subaccountsData.value.Count -eq 0) { + Write-Log "No subaccounts found" -Level "WARNING" + return @() + } + + # Return array of subaccounts (maintains compatibility with existing code) + $subaccounts = $subaccountsData.value | ForEach-Object { + [PSCustomObject]@{ + guid = $_.guid + displayName = $_.displayName + subdomain = $_.subdomain + region = $_.region + state = $_.state } } @@ -916,79 +1667,55 @@ function Get-BtpSubaccountCfDetails { ) try { - # Get Cloud Foundry environment instances - $envInstancesOutput = btp list accounts/environment-instance --subaccount $SubaccountId 2>&1 + # Get Cloud Foundry environment instances using --format json + $envInstancesJson = btp --format json list accounts/environment-instance --subaccount $SubaccountId 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { Write-Log "Failed to list environment instances for subaccount $SubaccountId" -Level "WARNING" return $null } - # Join all output lines into a single string to handle line wrapping - $fullOutput = $envInstancesOutput -join " " - - # Find Cloud Foundry instance ID using regex pattern - # Pattern looks for: any text followed by GUID format followed by "cloudfoundry" - # GUID format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX - $cfInstanceId = $null - - if ($fullOutput -match '([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})\s+cloudfoundry') { - $cfInstanceId = $matches[1] - Write-Log "Found Cloud Foundry instance ID: $cfInstanceId" - } - else { - Write-Log "Could not find cloudfoundry environment instance in output" -Level "WARNING" - } + # Parse JSON output + $envInstancesData = $envInstancesJson | ConvertFrom-Json - if ([string]::IsNullOrWhiteSpace($cfInstanceId)) { - Write-Log "No Cloud Foundry instance found for subaccount $SubaccountId" -Level "WARNING" + if (-not $envInstancesData.environmentInstances) { + Write-Log "No environment instances found for subaccount $SubaccountId" -Level "WARNING" return $null } - # Get CF instance details - $cfDetailsOutput = btp get accounts/environment-instance $cfInstanceId --subaccount $SubaccountId 2>&1 + # Find Cloud Foundry instance + $cfInstance = $envInstancesData.environmentInstances | Where-Object { $_.environmentType -eq "cloudfoundry" } - if ($LASTEXITCODE -ne 0) { - Write-Log "Failed to get CF instance details for subaccount $SubaccountId" -Level "WARNING" + if (-not $cfInstance) { + Write-Log "No Cloud Foundry instance found for subaccount $SubaccountId" -Level "WARNING" return $null } - # Parse CF details - join output to handle line wrapping - $fullDetailsOutput = $cfDetailsOutput -join " " + Write-Log "Found Cloud Foundry instance: $($cfInstance.name) (ID: $($cfInstance.id))" - $cfApiEndpoint = $null - $cfOrgId = $null - $cfOrgName = $null + # Parse labels (it's a JSON string in the response) + $labels = $cfInstance.labels | ConvertFrom-Json - # Extract labels JSON using regex - it appears between "labels:" and "service name:" - if ($fullDetailsOutput -match 'labels:\s+(\{.+?\})\s+service name:') { - $labelsJson = $matches[1].Trim() - try { - $labels = $labelsJson | ConvertFrom-Json - - # Handle both formats: with and without trailing colons in property names - $cfApiEndpoint = if ($labels.'API Endpoint:') { $labels.'API Endpoint:' } else { $labels.'API Endpoint' } - $cfOrgId = if ($labels.'Org ID:') { $labels.'Org ID:' } else { $labels.'Org ID' } - $cfOrgName = if ($labels.'Org Name:') { $labels.'Org Name:' } else { $labels.'Org Name' } - } - catch { - Write-Log "Failed to parse labels JSON for subaccount $SubaccountId : $_" -Level "WARNING" - Write-Log "Labels JSON: $labelsJson" -Level "WARNING" - } - } - else { - Write-Log "Could not find labels in output for subaccount $SubaccountId" -Level "WARNING" - } + $cfApiEndpoint = $labels.'API Endpoint' + $cfOrgId = $labels.'Org ID' + $cfOrgName = $labels.'Org Name' if ([string]::IsNullOrWhiteSpace($cfApiEndpoint) -or [string]::IsNullOrWhiteSpace($cfOrgName)) { - Write-Log "Could not extract CF details for subaccount $SubaccountId" -Level "WARNING" + Write-Log "Could not extract CF details from environment instance for subaccount $SubaccountId" -Level "WARNING" return $null } + # Extract region from API endpoint (e.g., https://api.cf.eu10.hana.ondemand.com -> eu10) + $cfRegion = $null + if ($cfApiEndpoint -match '\.cf\.([^.]+)\.') { + $cfRegion = $matches[1] + } + return @{ ApiEndpoint = $cfApiEndpoint OrgId = $cfOrgId OrgName = $cfOrgName + Region = $cfRegion } } catch { @@ -1009,37 +1736,105 @@ function Get-SentinelWorkspaceDetails { ) try { - Write-Log "Getting workspace details for '$WorkspaceName'..." + Write-Log "Getting workspace details via Azure Resource Graph for '$WorkspaceName'..." $token = Get-AzureAccessToken if ($null -eq $token) { return $null } - $uri = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.OperationalInsights/workspaces/$WorkspaceName`?api-version=2022-10-01" - $headers = @{ "Authorization" = "Bearer $token" "Content-Type" = "application/json" } + + $argQuery = @" +Resources +| where subscriptionId =~ '$SubscriptionId' +| where resourceGroup =~ '$ResourceGroupName' +| where type =~ 'Microsoft.OperationalInsights/workspaces' and name =~ '$WorkspaceName' +| extend workspaceGuid = tostring(properties.customerId) +| extend workspaceShortId = substring(workspaceGuid, 0, 12) +| project workspaceId = id, workspaceLocation = location, workspaceName = name, workspaceGuid, workspaceShortId, + dceName = strcat('ASI-', workspaceGuid), + dcrNamePrefix = strcat('Microsoft-Sentinel-SAP-BTP-DCR-', workspaceShortId), + resourceName = '', resourceType = '', resourceId = '', resourceProperties = dynamic({}) +| union ( + Resources + | where subscriptionId =~ '$SubscriptionId' + | where resourceGroup =~ '$ResourceGroupName' + | where type in~ ('Microsoft.Insights/dataCollectionEndpoints', 'Microsoft.Insights/dataCollectionRules') + | project workspaceId = '', workspaceLocation = '', workspaceName = '', workspaceGuid = '', workspaceShortId = '', + dceName = '', dcrNamePrefix = '', + resourceName = name, resourceType = type, resourceId = id, resourceProperties = properties +) +"@ + + $argBody = @{ + subscriptions = @($SubscriptionId) + query = $argQuery + } | ConvertTo-Json -Depth 10 + + $uri = "https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" + $response = Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -Body $argBody + + if ($null -eq $response.data -or $response.data.Count -eq 0) { + Write-Log "Workspace '$WorkspaceName' not found in resource group '$ResourceGroupName'" -Level "ERROR" + return $null + } - $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers + $workspaceRow = $response.data | Where-Object { -not [string]::IsNullOrEmpty($_.workspaceName) } | Select-Object -First 1 - # Extract a short ID from workspace ID for naming (first 12 chars of last GUID segment) - $workspaceId = $response.properties.customerId - $shortId = $workspaceId.Substring(0, [Math]::Min(12, $workspaceId.Length)) + if ($null -eq $workspaceRow) { + Write-Log "Workspace '$WorkspaceName' not found in ARG response" -Level "ERROR" + return $null + } + + $dceName = $workspaceRow.dceName + $dcrNamePrefix = $workspaceRow.dcrNamePrefix + + $dceRow = $response.data | Where-Object { + $_.resourceType -eq "microsoft.insights/datacollectionendpoints" -and $_.resourceName -eq $dceName + } | Select-Object -First 1 + + $dcrRow = $response.data | Where-Object { + $_.resourceType -eq "microsoft.insights/datacollectionrules" -and $_.resourceName -like "$dcrNamePrefix*" + } | Select-Object -First 1 + + Write-Log "Workspace details retrieved successfully via ARG" -Level "SUCCESS" + Write-Log " Location: $($workspaceRow.workspaceLocation)" + Write-Log " Workspace GUID: $($workspaceRow.workspaceGuid)" + Write-Log " Short ID for naming: $($workspaceRow.workspaceShortId)" - Write-Log "Workspace details retrieved successfully" -Level "SUCCESS" - Write-Log " Location: $($response.location)" - Write-Log " Workspace ID: $workspaceId" - Write-Log " Short ID for naming: $shortId" + $dceInfo = $null + if ($dceRow) { + Write-Log " Found existing DCE: $($dceRow.resourceName)" -Level "SUCCESS" + $dceInfo = @{ + Name = $dceRow.resourceName + ResourceId = $dceRow.resourceId + LogsIngestionEndpoint = $dceRow.resourceProperties.logsIngestion.endpoint + } + } + + $dcrInfo = $null + if ($dcrRow) { + Write-Log " Found existing DCR: $($dcrRow.resourceName)" -Level "SUCCESS" + $dcrInfo = @{ + Name = $dcrRow.resourceName + ResourceId = $dcrRow.resourceId + ImmutableId = $dcrRow.resourceProperties.immutableId + DataCollectionEndpointId = $dcrRow.resourceProperties.dataCollectionEndpointId + } + } return @{ - ResourceId = $response.id - Location = $response.location - WorkspaceId = $workspaceId - ShortId = $shortId + ResourceId = $workspaceRow.workspaceId + Location = $workspaceRow.workspaceLocation + WorkspaceGuid = $workspaceRow.workspaceGuid + ShortId = $workspaceRow.workspaceShortId Name = $WorkspaceName + ExistingDCE = $dceInfo + ExistingDCR = $dcrInfo } } catch { @@ -1048,24 +1843,24 @@ function Get-SentinelWorkspaceDetails { } } -# Function to get or create Data Collection Endpoint for SAP BTP -function Get-OrCreateDataCollectionEndpoint { +# Function to create a new Data Collection Endpoint for SAP BTP +# Only called when DCE doesn't exist (checked via ARG query upfront) +function New-DataCollectionEndpoint { param( [Parameter(Mandatory=$true)] [string]$SubscriptionId, [Parameter(Mandatory=$true)] [string]$ResourceGroupName, [Parameter(Mandatory=$true)] - [string]$WorkspaceShortId, + [string]$WorkspaceGuid, [Parameter(Mandatory=$true)] [string]$Location ) try { - # DCE naming convention matching portal: Microsoft-Sentinel-SAP-BTP-{workspace-short-id} - $dceName = "Microsoft-Sentinel-SAP-BTP-$WorkspaceShortId" + $dceName = "ASI-$WorkspaceGuid" - Write-Log "Checking for existing Data Collection Endpoint '$dceName'..." + Write-Log "Creating Data Collection Endpoint '$dceName'..." $token = Get-AzureAccessToken if ($null -eq $token) { @@ -1079,29 +1874,6 @@ function Get-OrCreateDataCollectionEndpoint { $uri = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Insights/dataCollectionEndpoints/$dceName`?api-version=2022-06-01" - # Try to get existing DCE - try { - $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers - - Write-Log "Found existing DCE '$dceName'" -Level "SUCCESS" - Write-Log " Logs Ingestion Endpoint: $($response.properties.logsIngestion.endpoint)" - - return @{ - Name = $dceName - ResourceId = $response.id - LogsIngestionEndpoint = $response.properties.logsIngestion.endpoint - } - } - catch { - if ($_.Exception.Response.StatusCode -eq 404) { - Write-Log "DCE '$dceName' not found, creating new one..." - } - else { - throw $_ - } - } - - # Create new DCE $dceBody = @{ location = $Location properties = @{ @@ -1123,26 +1895,33 @@ function Get-OrCreateDataCollectionEndpoint { } } catch { - Write-Log "Error with Data Collection Endpoint: $_" -Level "ERROR" + Write-Log "Error creating Data Collection Endpoint: $_" -Level "ERROR" return $null } } -# Function to get Data Collection Endpoint by resource ID -# DCE naming pattern differs from DCR - DCE uses patterns like ASI-{guid} -# DCE logsIngestion URL format: https://{dce-name-lowercase}-{random}.{region}.ingest.monitor.azure.com -function Get-DataCollectionEndpointById { +# Function to create or verify Log Analytics table exists +# Creates the table with schema from template if it doesn't exist +function Get-OrCreateLogAnalyticsTable { param( [Parameter(Mandatory=$true)] - [string]$DataCollectionEndpointId + [string]$SubscriptionId, + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, + [Parameter(Mandatory=$true)] + [string]$WorkspaceName, + [Parameter(Mandatory=$true)] + [string]$TableName, + [Parameter(Mandatory=$true)] + [array]$Columns ) try { - Write-Log "Getting Data Collection Endpoint details from: $DataCollectionEndpointId" + Write-Log "Checking if Log Analytics table '$TableName' exists..." $token = Get-AzureAccessToken if ($null -eq $token) { - return $null + return $false } $headers = @{ @@ -1150,109 +1929,260 @@ function Get-DataCollectionEndpointById { "Content-Type" = "application/json" } - $uri = "https://management.azure.com$DataCollectionEndpointId`?api-version=2022-06-01" + $uri = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.OperationalInsights/workspaces/$WorkspaceName/tables/$TableName`?api-version=2021-12-01-preview" - $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers + try { + $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers + Write-Log "Table '$TableName' already exists" -Level "SUCCESS" + return $true + } + catch { + if ($_.Exception.Response.StatusCode -eq 404) { + Write-Log "Table '$TableName' not found, creating..." + } + else { + throw $_ + } + } - # Extract DCE name from resource ID - $dceName = $DataCollectionEndpointId.Split('/')[-1] + $hasTimeGenerated = $Columns | Where-Object { $_.name -eq "TimeGenerated" } + if (-not $hasTimeGenerated) { + Write-Log "Adding TimeGenerated column to table schema" + $Columns = @(@{ name = "TimeGenerated"; type = "datetime" }) + $Columns + } - Write-Log "Found DCE '$dceName'" -Level "SUCCESS" - Write-Log " Logs Ingestion Endpoint: $($response.properties.logsIngestion.endpoint)" + $tableBody = @{ + properties = @{ + schema = @{ + name = $TableName + columns = $Columns + } + } + } | ConvertTo-Json -Depth 10 - return @{ - Name = $dceName - ResourceId = $response.id - LogsIngestionEndpoint = $response.properties.logsIngestion.endpoint - } + Write-Log "Creating table with $($Columns.Count) columns..." + $response = Invoke-RestMethod -Uri $uri -Method Put -Headers $headers -Body $tableBody + + Write-Log "Successfully created table '$TableName'" -Level "SUCCESS" + return $true } catch { - Write-Log "Error getting Data Collection Endpoint: $_" -Level "ERROR" - return $null + Write-Log "Error with Log Analytics table: $_" -Level "ERROR" + Write-Log "Table creation is required before DCR can ingest data" -Level "ERROR" + return $false } } -# Function to load DCR template from SAPBTP_DCR.json file -# This provides a single source of truth for the DCR schema definition -# Note: Only returns the 'properties' section; 'location' is set by the caller at top-level -function Get-SapBtpDcrTemplate { +# Function to query SAP BTP Content Template API for DCR schema +# Returns DCR configuration from the Content Hub template including columns, streams, and transforms +function Get-SapBtpContentTemplate { param( [Parameter(Mandatory=$true)] - [string]$WorkspaceResourceId, + [string]$SubscriptionId, + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, [Parameter(Mandatory=$true)] - [string]$DataCollectionEndpointId + [string]$WorkspaceName ) try { - # Determine the path to SAPBTP_DCR.json relative to this script - $scriptDir = $PSScriptRoot - if ([string]::IsNullOrWhiteSpace($scriptDir)) { - # Fallback if $PSScriptRoot is not available (e.g., running in ISE) - $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + Write-Log "Querying Content Template API for SAP BTP DCR schema..." + + $token = Get-AzureAccessToken + if ($null -eq $token) { + return $null + } + + $headers = @{ + "Authorization" = "Bearer $token" + "Content-Type" = "application/json" } - # Path to DCR template: ../Data Connectors/SAPBTPPollerConnector/SAPBTP_DCR.json - $dcrTemplatePath = Join-Path -Path $scriptDir -ChildPath "..\Data Connectors\SAPBTPPollerConnector\SAPBTP_DCR.json" - $dcrTemplatePath = [System.IO.Path]::GetFullPath($dcrTemplatePath) + $contentId = "SAPBTPAuditEvents" + $filterExpression = "properties/contentId eq '$contentId'" + $encodedFilter = [System.Web.HttpUtility]::UrlEncode($filterExpression) - if (-not (Test-Path $dcrTemplatePath)) { - Write-Log "DCR template file not found at: $dcrTemplatePath" -Level "ERROR" + $listUri = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.OperationalInsights/workspaces/$WorkspaceName/providers/Microsoft.SecurityInsights/contentTemplates?api-version=2023-11-01&`$filter=$encodedFilter" + + Write-Log "Listing content templates to find resource ID..." + $listResponse = Invoke-RestMethod -Uri $listUri -Method Get -Headers $headers + + if ($null -eq $listResponse.value -or $listResponse.value.Count -eq 0) { + Write-Log "SAP BTP content template not found. Is the solution installed from Content Hub?" -Level "WARNING" + return $null + } + + $template = $listResponse.value[0] + $templateResourceId = $template.id + Write-Log "Found template: $($template.name)" + + $getUri = "https://management.azure.com$templateResourceId`?api-version=2025-09-01" + + Write-Log "Retrieving template mainTemplate with api-version=2025-09-01..." + $getResponse = Invoke-RestMethod -Uri $getUri -Method Get -Headers $headers + + if ($null -eq $getResponse.properties.mainTemplate) { + Write-Log "Template does not contain mainTemplate" -Level "WARNING" + return $null + } + + $dcrResource = $getResponse.properties.mainTemplate.resources | Where-Object { + $_.type -eq "Microsoft.Insights/dataCollectionRules" + } | Select-Object -First 1 + + if ($null -eq $dcrResource) { + Write-Log "No DCR resource found in mainTemplate" -Level "WARNING" return $null } - Write-Log "Loading DCR template from: $dcrTemplatePath" + Write-Log "Found DCR resource in template: $($dcrResource.name)" -Level "SUCCESS" - # Read and parse the JSON template - $templateContent = Get-Content -Path $dcrTemplatePath -Raw + $tableResource = $getResponse.properties.mainTemplate.resources | Where-Object { + $_.type -eq "Microsoft.OperationalInsights/workspaces/tables" + } | Select-Object -First 1 - # Replace placeholders with actual values (only properties-level placeholders) - # Note: {{location}} is at top-level of JSON, not in properties, so we don't replace it here - $templateContent = $templateContent -replace '\{\{workspaceResourceId\}\}', $WorkspaceResourceId - $templateContent = $templateContent -replace '\{\{dataCollectionEndpointId\}\}', $DataCollectionEndpointId + if ($null -eq $tableResource) { + Write-Log "No table resource found in mainTemplate" -Level "WARNING" + return $null + } - # Parse JSON - the template is an array with one element - $templateArray = $templateContent | ConvertFrom-Json - $template = $templateArray[0] + Write-Log "Found table resource in template: $($tableResource.name)" -Level "SUCCESS" - if ($null -eq $template -or $null -eq $template.properties) { - Write-Log "Invalid DCR template structure" -Level "ERROR" + $streamDeclarations = $dcrResource.properties.streamDeclarations + if ($null -eq $streamDeclarations) { + Write-Log "No stream declarations found in DCR template" -Level "WARNING" return $null } - Write-Log "Successfully loaded DCR template" -Level "SUCCESS" + $streamName = ($streamDeclarations.PSObject.Properties | Select-Object -First 1).Name + $streamConfig = $streamDeclarations.$streamName + + $dataFlows = $dcrResource.properties.dataFlows + $transformKql = $null + if ($dataFlows -and $dataFlows.Count -gt 0) { + $transformKql = $dataFlows[0].transformKql + } + + Write-Log " DCR Name: $($dcrResource.name)" -Level "SUCCESS" + Write-Log " Stream: $streamName" -Level "SUCCESS" + Write-Log " Stream Input Columns: $($streamConfig.columns.Count)" -Level "SUCCESS" + Write-Log " Table Name: $($tableResource.name)" -Level "SUCCESS" + Write-Log " Table Schema Columns: $($tableResource.properties.schema.columns.Count)" -Level "SUCCESS" + Write-Log " Transform KQL: $(if ($transformKql) { 'Present' } else { 'None' })" -Level "SUCCESS" + + return @{ + DcrName = $dcrResource.name + StreamName = $streamName + StreamColumns = $streamConfig.columns + TableName = $tableResource.name + TableColumns = $tableResource.properties.schema.columns + TransformKql = $transformKql + DataFlows = $dataFlows + StreamDeclarations = $streamDeclarations + } + } + catch { + Write-Log "Error querying Content Template API: $_" -Level "ERROR" + Write-Log "Cannot proceed without Content Template. Ensure SAP BTP solution is installed from Content Hub." -Level "ERROR" + return $null + } +} + +# Function to build SAP BTP DCR schema from template configuration +# This provides the DCR schema definition based on the Content Template +# Returns the 'properties' section for the DCR; 'location' is set by the caller at top-level +function Get-SapBtpDcrTemplate { + param( + [Parameter(Mandatory=$true)] + [string]$WorkspaceResourceId, + [Parameter(Mandatory=$true)] + [string]$DataCollectionEndpointId, + [Parameter(Mandatory=$true)] + [object]$TemplateConfig + ) + + try { + Write-Log "Building SAP BTP DCR schema from template..." + Write-Log " Stream: $($TemplateConfig.StreamName)" + Write-Log " Stream Input Columns: $($TemplateConfig.StreamColumns.Count)" + + $streamDeclarations = @{} + $streamDeclarations[$TemplateConfig.StreamName] = @{ + columns = $TemplateConfig.StreamColumns + } + + $dataFlows = @( + @{ + streams = @($TemplateConfig.StreamName) + destinations = @("clv2ws1") + transformKql = $TemplateConfig.TransformKql + outputStream = $TemplateConfig.StreamName + } + ) + + $dcrProperties = @{ + dataCollectionEndpointId = $DataCollectionEndpointId + streamDeclarations = $streamDeclarations + dataSources = @{} + destinations = @{ + logAnalytics = @( + @{ + workspaceResourceId = $WorkspaceResourceId + name = "clv2ws1" + } + ) + } + dataFlows = $dataFlows + } - # Return the properties section which contains the DCR configuration - return $template.properties + Write-Log "Successfully built DCR schema" -Level "SUCCESS" + return $dcrProperties } catch { - Write-Log "Error loading DCR template: $_" -Level "ERROR" + Write-Log "Error building DCR template: $_" -Level "ERROR" return $null } } -# Function to get or create Data Collection Rule for SAP BTP -# When DCR exists, also returns the associated DCE ID from its properties -function Get-OrCreateDataCollectionRule { +# Function to create a new Data Collection Rule for SAP BTP +# Only called when DCR doesn't exist (checked via ARG query upfront) +function New-DataCollectionRule { param( [Parameter(Mandatory=$true)] [string]$SubscriptionId, [Parameter(Mandatory=$true)] [string]$ResourceGroupName, [Parameter(Mandatory=$true)] + [string]$WorkspaceName, + [Parameter(Mandatory=$true)] [string]$WorkspaceShortId, [Parameter(Mandatory=$true)] [string]$WorkspaceResourceId, - [Parameter(Mandatory=$false)] - [string]$DataCollectionEndpointId = "", + [Parameter(Mandatory=$true)] + [string]$DataCollectionEndpointId, [Parameter(Mandatory=$true)] [string]$Location ) try { - # DCR naming convention matching portal: Microsoft-Sentinel-SAP-BTP-DCR-{workspace-short-id} - $dcrName = "Microsoft-Sentinel-SAP-BTP-DCR-$WorkspaceShortId" + $templateConfig = Get-SapBtpContentTemplate -SubscriptionId $SubscriptionId -ResourceGroupName $ResourceGroupName -WorkspaceName $WorkspaceName + + if ($null -eq $templateConfig) { + Write-Log "Failed to retrieve Content Template. Cannot proceed with DCR creation." -Level "ERROR" + Write-Log "Ensure the SAP BTP solution is installed from Content Hub." -Level "ERROR" + return $null + } - Write-Log "Checking for existing Data Collection Rule '$dcrName'..." + $dcrBaseName = $templateConfig.DcrName + if ($dcrBaseName.StartsWith("Microsoft-Sentinel-")) { + $dcrName = "$dcrBaseName-$WorkspaceShortId" + } else { + $dcrName = "Microsoft-Sentinel-$dcrBaseName-$WorkspaceShortId" + } + + Write-Log "DCR Base Name from template: $dcrBaseName" + Write-Log "Full DCR Name: $dcrName" + Write-Log "Creating new DCR '$dcrName'..." $token = Get-AzureAccessToken if ($null -eq $token) { @@ -1266,45 +2196,33 @@ function Get-OrCreateDataCollectionRule { $uri = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Insights/dataCollectionRules/$dcrName`?api-version=2022-06-01" - # Try to get existing DCR - try { - $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers - - Write-Log "Found existing DCR '$dcrName'" -Level "SUCCESS" - Write-Log " Immutable ID: $($response.properties.immutableId)" - Write-Log " DCE Reference: $($response.properties.dataCollectionEndpointId)" - - return @{ - Name = $dcrName - ResourceId = $response.id - ImmutableId = $response.properties.immutableId - DataCollectionEndpointId = $response.properties.dataCollectionEndpointId - } - } - catch { - if ($_.Exception.Response.StatusCode -eq 404) { - Write-Log "DCR '$dcrName' not found, creating new one..." - } - else { - throw $_ - } + $tableName = $templateConfig.TableName + + if ($tableName -match '/([^/]+)$') { + $tableName = $matches[1] } - # For creating new DCR, DataCollectionEndpointId is required - if ([string]::IsNullOrWhiteSpace($DataCollectionEndpointId)) { - Write-Log "DataCollectionEndpointId is required to create a new DCR" -Level "ERROR" + Write-Log "Table name: $tableName" + + $tableCreated = Get-OrCreateLogAnalyticsTable ` + -SubscriptionId $SubscriptionId ` + -ResourceGroupName $ResourceGroupName ` + -WorkspaceName $WorkspaceName ` + -TableName $tableName ` + -Columns $templateConfig.TableColumns + + if (-not $tableCreated) { + Write-Log "Failed to create or verify table. Cannot proceed with DCR creation." -Level "ERROR" return $null } - # Load DCR schema from SAPBTP_DCR.json to avoid duplication and ensure single source of truth - $dcrProperties = Get-SapBtpDcrTemplate -WorkspaceResourceId $WorkspaceResourceId -DataCollectionEndpointId $DataCollectionEndpointId + $dcrProperties = Get-SapBtpDcrTemplate -WorkspaceResourceId $WorkspaceResourceId -DataCollectionEndpointId $DataCollectionEndpointId -TemplateConfig $templateConfig if ($null -eq $dcrProperties) { - Write-Log "Failed to load DCR template from SAPBTP_DCR.json" -Level "ERROR" + Write-Log "Failed to build DCR schema from template" -Level "ERROR" return $null } - # Build the DCR body with location and loaded properties $dcrBody = @{ location = $Location properties = $dcrProperties diff --git a/Solutions/SAP BTP/Tools/README.md b/Solutions/SAP BTP/Tools/README.md index 25212a55379..c283c7e58d5 100644 --- a/Solutions/SAP BTP/Tools/README.md +++ b/Solutions/SAP BTP/Tools/README.md @@ -2,57 +2,190 @@ This directory contains PowerShell script blue prints to handle Microsoft Sentinel Solution for SAP BTP onboarding with SAP Business Technology Platform subaccounts using the CloudFoundry environment at scale. +## Table of Contents + +- [Scripts](#scripts) +- [Prerequisites](#prerequisites) +- [Usage](#usage) +- [Examples](#examples) + - [Split Permissions](#split-permissions) + - [Full Permissions](#full-permissions) + ## Scripts -- `provision-audit-to-subaccount.ps1`: Script to provision auditlog management service in SAP BTP subaccounts. It reads subaccount details from a CSV file and provisions the service using the CloudFoundry CLI. +- `provision-audit-to-subaccounts.ps1`: Script to provision auditlog management service in SAP BTP subaccounts. It reads subaccount details from a CSV file and provisions the service using the CloudFoundry CLI. - `connect-sentinel-to-btp.ps1`: Main script to connect Microsoft Sentinel Solution for SAP BTP to SAP BTP subaccounts. It reads subaccount details from a CSV file, reads the SAP BTP service keys, and creates connections in the Sentinel SAP BTP data connector. - `export-subaccounts.ps1`: Script to enumerate SAP BTP subaccounts and export them to a CSV file for use with other scripts. - `BtpHelpers.ps1`: Helper functions used by the main scripts for tasks such as logging, authentication, and API interactions. -## Getting Started +## Prerequisites + +Ensure you have the following: +- PowerShell 7 or later +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) installed and authenticated +- [BTP CLI](https://help.sap.com/docs/btp/sap-business-technology-platform/download-and-start-using-btp-cli-client) installed and in path +- [CloudFoundry CLI](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html) installed and authenticated +- Appropriate permissions in both Azure and SAP BTP. Learn more [here](https://learn.microsoft.com/azure/sentinel/sap/deploy-sap-btp-solution#prerequisites) +- Azure Key Vault (optional, required for split permissions workflow) + +## Usage + +### export-subaccounts.ps1 + +Generates a CSV file with all subaccounts from your BTP global account. + +**Parameters:** +- `-BtpSubdomain`: Your BTP global account subdomain + +### provision-audit-to-subaccounts.ps1 + +Provisions auditlog management services and creates service keys. + +**Parameters:** +- `-CsvPath`: Path to subaccounts CSV file (default: `.\subaccounts.csv`) +- `-InstanceName`: Service instance name (default: `sentinel-audit-srv`) +- `-KeyRotationMode`: Key rotation mode + - `CreateNewKey` (default): Creates new key, keeps old keys → zero downtime + - `Cleanup`: Keeps newest key, deletes old keys → run after rotation confirmed +- `-ExportCredentialsToKeyVault`: Export credentials to Azure Key Vault (recommended for split permissions) +- `-KeyVaultName`: Key Vault name (required with `-ExportCredentialsToKeyVault`) +- `-ExportCredentialsToCsv`: Export credentials to CSV (not recommended) + +### connect-sentinel-to-btp.ps1 + +Creates or updates Sentinel connections to BTP subaccounts. + +**Parameters:** +- `-SubscriptionId`: Azure subscription ID +- `-ResourceGroupName`: Resource group containing Sentinel workspace +- `-WorkspaceName`: Sentinel workspace name +- `-CsvPath`: Path to subaccounts CSV file (default: `.\subaccounts.csv`) +- `-UseKeyVault`: Retrieve credentials from Key Vault (for split permissions) +- `-KeyVaultName`: Key Vault name (required with `-UseKeyVault`) +- `-UseCredentialsFromCsv`: Retrieve credentials from CSV (not recommended) + +## Examples + +## Split Permissions + +**Use this if your permissions are separated:** You have separate SAP BTP and Microsoft Sentinel administrators without cross-access. BTP admins cannot access Sentinel, and Sentinel admins cannot access BTP. + +**How it works:** BTP admins provision services and export credentials to a central Azure Key Vault. Sentinel admins retrieve credentials from Key Vault to create connections. This approach maintains security boundaries and enables zero-downtime key rotation. + +**Required permissions:** +- BTP Admins: `Key Vault Secrets Officer` role to upload service keys +- Sentinel Admins: `Key Vault Secrets User` role to read secrets + +**Security Note:** CSV export is supported for testing but not recommended for production. CSV files store credentials in plaintext without encryption, access controls, or audit trails. Use Azure Key Vault for production deployments to maintain proper security boundaries. + +### Initial Deployment + +**Step 1: SAP BTP Admin - Generate subaccounts CSV** + +```powershell +.\export-subaccounts.ps1 -BtpSubdomain "" + +# (Or manually create the CSV file using subaccounts-sample.csv as template) +``` + +**Step 2: SAP BTP Admin - Provision audit services and export credentials** + +```powershell +# Export to Key Vault (recommended) +.\provision-audit-to-subaccounts.ps1 -ExportCredentialsToKeyVault -KeyVaultName "" + +# Export to CSV (not recommended) +# .\provision-audit-to-subaccounts.ps1 -ExportCredentialsToCsv +``` + +**Step 3: Sentinel Admin - Create Sentinel connections** + +```powershell +# From Key Vault +.\connect-sentinel-to-btp.ps1 -SubscriptionId "" -ResourceGroupName "" -WorkspaceName "" -UseKeyVault -KeyVaultName "" + +# From CSV (if using CSV export) +# .\connect-sentinel-to-btp.ps1 -SubscriptionId "" -ResourceGroupName "" -WorkspaceName "" -UseCredentialsFromCsv +``` + +### Key Rotation + +It is recommend to rotate service keys for security best practices. Zero-downtime rotation is supported through Key Vault secret versioning. See "[Rotate the BTP client secret](https://learn.microsoft.com/azure/sentinel/sap/deploy-sap-btp-solution#rotate-the-btp-client-secret)" section on Microsoft Learn for more details. + +**Step 1: SAP BTP Admin - Create new keys** + +```powershell +# Export to Key Vault (recommended) +.\provision-audit-to-subaccounts.ps1 -KeyRotationMode CreateNewKey -ExportCredentialsToKeyVault -KeyVaultName "" + +# Export to CSV (not recommended) +# .\provision-audit-to-subaccounts.ps1 -KeyRotationMode CreateNewKey -ExportCredentialsToCsv +``` + +**Step 2: Sentinel Admin - Update connections** + +```powershell +# From Key Vault +.\connect-sentinel-to-btp.ps1 -SubscriptionId "" -ResourceGroupName "" -WorkspaceName "" -UseKeyVault -KeyVaultName "" + +# From CSV (if using CSV export) +# .\connect-sentinel-to-btp.ps1 -SubscriptionId "" -ResourceGroupName "" -WorkspaceName "" -UseCredentialsFromCsv +``` + +**Step 3: SAP BTP Admin - Clean up old keys** + +```powershell +.\provision-audit-to-subaccounts.ps1 -KeyRotationMode Cleanup +``` + +--- + +## Full Permissions + +**Use this if you have access to both Sentinel and BTP:** You have a single administrator (or team) with access to both SAP BTP and Microsoft Sentinel. + +**How it works:** Simpler workflow where the same person runs both provisioning and connection scripts directly without credential handoff via Key Vault. + +### Initial Deployment + +**Step 1: Generate subaccounts CSV** + +```powershell +.\export-subaccounts.ps1 -BtpSubdomain "" + +# (Or manually create the CSV file using subaccounts-sample.csv as template) +``` -1. Ensure you have the prerequisites: - - PowerShell 7 or later - - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) installed and authenticated - - [CloudFoundry CLI](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html) installed and authenticated - - Appropriate permissions in both Azure and SAP BTP. Learn more [here](https://learn.microsoft.com/azure/sentinel/sap/deploy-sap-btp-solution#prerequisites) - - (Optionally) install the SAP BTP CLI for subaccount enumeration and CSV file generation. Learn more [here](https://help.sap.com/docs/btp/sap-business-technology-platform/download-and-start-using-btp-cli-client). -2. Use the [subaccounts-sample.csv](subaccounts-sample.csv) file to create your own `subaccounts.csv` file with your SAP BTP subaccount details or use the `export-subaccounts.ps1` script to generate it automatically. -3. Run the scripts in the following order: +**Step 2: Provision audit services** - - (Optionally) run `export-subaccounts.ps1` to generate the CSV file with your SAP BTP subaccount details. Sample commands to fetch global account info and trigger the onboarding info export: +```powershell +.\provision-audit-to-subaccounts.ps1 +``` - ```powershell - btp login - btp get accounts/global-account - ``` +**Step 3: Create Sentinel connections** - Use the retrieved global account subdomain (e.g. "my-global-account-12345") to run: +```powershell +.\connect-sentinel-to-btp.ps1 -SubscriptionId "" -ResourceGroupName "" -WorkspaceName "" +``` - ```powershell - $securePassword = Read-Host "Enter BTP Password" -AsSecureString - .\export-subaccounts.ps1 -BtpUsername "" -BtpPassword $securePassword -BtpSubdomain "-" - ``` +### Key Rotation - - Next, run `provision-audit-to-subaccount.ps1` to provision the auditlog service if not yet available. Sample command: +It is recommend to rotate service keys for security best practices. See "[Rotate the BTP client secret](https://learn.microsoft.com/azure/sentinel/sap/deploy-sap-btp-solution#rotate-the-btp-client-secret)" section on Microsoft Learn for more details. - ```powershell - $securePassword = Read-Host "Enter CF Password" -AsSecureString - .\provision-audit-to-subaccount.ps1 -CfUsername "" -CfPassword $securePassword - ``` +**Zero-downtime rotation:** - - Then, run `connect-sentinel-to-btp.ps1` to create connections in the Sentinel SAP BTP data connector. Sample command: +```powershell +# Step 1: Create new keys +.\provision-audit-to-subaccounts.ps1 -KeyRotationMode CreateNewKey - ```powershell - az login --tenant "" - az account set --subscription "" - $securePassword = Read-Host "Enter CF Password" -AsSecureString - .\connect-sentinel-to-btp.ps1 -SubscriptionId "" -ResourceGroupName "" -WorkspaceName "" -CfUsername "" -CfPassword $securePassword - ``` +# Step 2: Update connections +.\connect-sentinel-to-btp.ps1 -SubscriptionId "" -ResourceGroupName "" -WorkspaceName "" -## Lifecycle Management +# Step 3: Clean up old keys +.\provision-audit-to-subaccounts.ps1 -KeyRotationMode Cleanup +``` -It is recommend to rotate service keys for security best practices. Consider Azure Key Vault integration for managing secrets. Expiry events can be acted upon from Azure Logic Apps or Azure Functions to trigger the rotation process. See "[Rotate the BTP client secret](https://learn.microsoft.com/azure/sentinel/sap/deploy-sap-btp-solution#rotate-the-btp-client-secret)" section on Microsoft Learn for more details. +--- ## Contributing diff --git a/Solutions/SAP BTP/Tools/connect-sentinel-to-btp.ps1 b/Solutions/SAP BTP/Tools/connect-sentinel-to-btp.ps1 index 2ac0c406b86..d33a79ead35 100644 --- a/Solutions/SAP BTP/Tools/connect-sentinel-to-btp.ps1 +++ b/Solutions/SAP BTP/Tools/connect-sentinel-to-btp.ps1 @@ -7,7 +7,7 @@ # # Prerequisites: # - Azure CLI installed: https://learn.microsoft.com/cli/azure/install-azure-cli -# - Cloud Foundry CLI (cf) installed and configured +# - SAP BTP CLI installed: https://tools.hana.ondemand.com/#cloud-btpcli # - Successful run of provision-audit-to-subaccounts.ps1 # - SAP BTP Solution installed from Content Hub # - SAP BTP data connector deployed in the workspace @@ -38,7 +38,7 @@ param( [string]$CsvPath = ".\subaccounts.csv", [Parameter(Mandatory=$false)] - [string]$InstanceNamePrefix = "sentinel-audit-srv", + [string]$InstanceName = "sentinel-audit-srv", [Parameter(Mandatory=$false)] [string]$CfUsername = $env:CF_USERNAME, @@ -46,6 +46,15 @@ param( [Parameter(Mandatory=$false)] [SecureString]$CfPassword, + [Parameter(Mandatory=$false)] + [switch]$UseCredentialsFromCsv, + + [Parameter(Mandatory=$false)] + [switch]$UseKeyVault, + + [Parameter(Mandatory=$false)] + [string]$KeyVaultName, + [Parameter(Mandatory=$false)] [int]$PollingFrequencyMinutes = 1, @@ -60,17 +69,47 @@ param( $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path Import-Module "$scriptPath\BtpHelpers.ps1" -Force -# Validate and get CF credentials using helper function -$credentials = Get-CfCredentials -Username $CfUsername -Password $CfPassword -if ($null -eq $credentials) { - exit 1 +# Validate Key Vault parameters if using Key Vault +if ($UseKeyVault) { + if ([string]::IsNullOrWhiteSpace($KeyVaultName)) { + Write-Log "KeyVaultName parameter is required when UseKeyVault is specified" -Level "ERROR" + exit 1 + } +} + +# Determine credential source +if ($UseCredentialsFromCsv) { + Write-Log "Credential source: CSV file (split permissions)" -Level "INFO" + $credentialSource = "CSV" + # CF credentials not required in CSV mode + $CfUsername = $null + $CfPassword = $null +} elseif ($UseKeyVault) { + Write-Log "Credential source: Azure Key Vault (split permissions)" -Level "INFO" + $credentialSource = "KeyVault" + # CF credentials not required in Key Vault mode + $CfUsername = $null + $CfPassword = $null +} else { + Write-Log "Credential source: CloudFoundry (full permissions)" -Level "INFO" + $credentialSource = "CloudFoundry" + + # Validate and get CF credentials using helper function + $credentials = Get-CfCredentials -Username $CfUsername -Password $CfPassword + if ($null -eq $credentials) { + exit 1 + } + $CfUsername = $credentials.Username + $CfPassword = $credentials.Password } -$CfUsername = $credentials.Username -$CfPassword = $credentials.Password # Main script execution Write-Log "=======================================================================" Write-Log "Starting Sentinel Solution for SAP BTP Connection Creation Process" +Write-Log "Credential Source: $credentialSource" +if ($credentialSource -eq "KeyVault") { + Write-Log "Key Vault Name: $KeyVaultName" +} Write-Log "IMPORTANT: This adds connections to the existing SAP BTP data connector" Write-Log "Make sure the SAP BTP solution is installed from Content Hub first!" Write-Log "=======================================================================" @@ -81,11 +120,6 @@ if (-not (Test-AzCli)) { exit 1 } -# Check if CF CLI is installed -if (-not (Test-CfCli)) { - Write-Log "Exiting script due to missing CF CLI." -Level "ERROR" - exit 1 -} # Check Azure login try { @@ -108,6 +142,7 @@ Write-Log "===================================================================== Write-Log "Setting up Data Collection Endpoint (DCE) and Data Collection Rule (DCR)" Write-Log "=======================================================================" +# Single ARG query gets workspace details AND checks for existing DCE/DCR $workspaceDetails = Get-SentinelWorkspaceDetails ` -SubscriptionId $SubscriptionId ` -ResourceGroupName $ResourceGroupName ` @@ -118,49 +153,41 @@ if ($null -eq $workspaceDetails) { exit 1 } -# First try to find existing DCR - if found, we can get DCE from its properties -# DCE naming pattern (e.g., ASI-{guid}) differs from DCR naming pattern -$dcrInfo = Get-OrCreateDataCollectionRule ` - -SubscriptionId $SubscriptionId ` - -ResourceGroupName $ResourceGroupName ` - -WorkspaceShortId $workspaceDetails.ShortId ` - -WorkspaceResourceId $workspaceDetails.ResourceId ` - -Location $workspaceDetails.Location - -$dceInfo = $null - -if ($null -ne $dcrInfo -and -not [string]::IsNullOrWhiteSpace($dcrInfo.DataCollectionEndpointId)) { - # DCR exists - get DCE details from the DCR's referenced endpoint - Write-Log "Found existing DCR with DCE reference, retrieving DCE details..." - $dceInfo = Get-DataCollectionEndpointById -DataCollectionEndpointId $dcrInfo.DataCollectionEndpointId -} - -if ($null -eq $dceInfo) { - # DCE not found via DCR - create new DCE first, then create DCR - Write-Log "No existing DCE found, creating new Data Collection Endpoint..." - - $dceInfo = Get-OrCreateDataCollectionEndpoint ` +# Handle DCE: Use existing or create new +if ($workspaceDetails.ExistingDCE) { + Write-Log "Using existing DCE" -Level "SUCCESS" + $dceInfo = $workspaceDetails.ExistingDCE +} else { + Write-Log "Creating new DCE..." + $dceInfo = New-DataCollectionEndpoint ` -SubscriptionId $SubscriptionId ` -ResourceGroupName $ResourceGroupName ` - -WorkspaceShortId $workspaceDetails.ShortId ` + -WorkspaceGuid $workspaceDetails.WorkspaceGuid ` -Location $workspaceDetails.Location if ($null -eq $dceInfo) { - Write-Log "Failed to get or create DCE. Exiting." -Level "ERROR" + Write-Log "Failed to create DCE. Exiting." -Level "ERROR" exit 1 } - - # Now create DCR with the new DCE - $dcrInfo = Get-OrCreateDataCollectionRule ` +} + +# Handle DCR: Use existing or create new +if ($workspaceDetails.ExistingDCR) { + Write-Log "Using existing DCR" -Level "SUCCESS" + $dcrInfo = $workspaceDetails.ExistingDCR +} else { + Write-Log "Creating new DCR..." + $dcrInfo = New-DataCollectionRule ` -SubscriptionId $SubscriptionId ` -ResourceGroupName $ResourceGroupName ` + -WorkspaceName $WorkspaceName ` -WorkspaceShortId $workspaceDetails.ShortId ` -WorkspaceResourceId $workspaceDetails.ResourceId ` -DataCollectionEndpointId $dceInfo.ResourceId ` -Location $workspaceDetails.Location if ($null -eq $dcrInfo) { - Write-Log "Failed to get or create DCR. Exiting." -Level "ERROR" + Write-Log "Failed to create DCR. Exiting." -Level "ERROR" exit 1 } } @@ -205,43 +232,90 @@ foreach ($subaccount in $subaccounts) { Write-Log "Org: $orgName | Space: $spaceName" Write-Log "=======================================================================" - # Switch API endpoint if needed - if ($currentApiEndpoint -ne $apiEndpoint) { - if (-not (Set-CfApiEndpoint -ApiEndpoint $apiEndpoint -Username $CfUsername -Password $CfPassword -OrgName $orgName -SpaceName $spaceName)) { - Write-Log "Failed to switch API endpoint. Skipping subaccount." -Level "ERROR" + # Get BTP credentials based on source + $btpCredentials = $null + + if ($credentialSource -eq "CSV") { + # Read credentials directly from CSV + Write-Log "Reading credentials from CSV..." + $btpCredentials = Get-ServiceKeyFromCsv -CsvPath $CsvPath -SubaccountId $subaccountId + + if ($null -eq $btpCredentials) { + Write-Log "Failed to read credentials from CSV for subaccount $subaccountId. Skipping." -Level "ERROR" + $failureCount++ + continue + } + } elseif ($credentialSource -eq "KeyVault") { + # Read credentials from Azure Key Vault + Write-Log "Reading credentials from Key Vault..." + $btpCredentials = Get-ServiceKeyFromKeyVault -KeyVaultName $KeyVaultName -SubaccountId $subaccountId + + if ($null -eq $btpCredentials) { + Write-Log "Failed to read credentials from Key Vault for subaccount $subaccountId. Skipping." -Level "ERROR" + $failureCount++ + continue + } + } else { + # CloudFoundry mode: authenticate and retrieve service key + + # Switch API endpoint if needed + if ($currentApiEndpoint -ne $apiEndpoint) { + if (-not (Set-CfApiEndpoint -ApiEndpoint $apiEndpoint -Username $CfUsername -Password $CfPassword -OrgName $orgName -SpaceName $spaceName)) { + Write-Log "Failed to switch API endpoint. Skipping subaccount." -Level "ERROR" + $failureCount++ + continue + } + $currentApiEndpoint = $apiEndpoint + } + + # Target the org and space + if (-not (Set-CfTarget -OrgName $orgName -SpaceName $spaceName)) { + Write-Log "Failed to target org/space. Skipping subaccount." -Level "ERROR" + $failureCount++ + continue + } + + # Check if the specified service instance exists (using same approach as provision script) + Write-Log "Looking for service instance: $InstanceName" -Level "INFO" + + # Get existing service keys to verify instance exists (also needed later) + $existingKeys = @(Get-CfServiceKeys -InstanceName $InstanceName) + + if ($existingKeys.Count -eq 0) { + Write-Log "Service instance '$InstanceName' not found or has no keys in org '$orgName' space '$spaceName'. Skipping." -Level "ERROR" + $failureCount++ + continue + } + + $instanceName = $InstanceName + Write-Log "Using instance: $instanceName" -Level "INFO" + + # CF returns keys in creation order - use the last one (newest) + # Ensure we handle both single key (string) and multiple keys (array) + $keyName = if ($existingKeys.Count -eq 1) { $existingKeys[0] } else { $existingKeys[-1] } + Write-Log "Using newest service key: $keyName" -Level "INFO" + + if ($existingKeys.Count -gt 1) { + Write-Log "Found $($existingKeys.Count) service keys. Using most recent: $keyName" -Level "INFO" + } + + # Get service key + $serviceKey = Get-CfServiceKey -InstanceName $instanceName -KeyName $keyName + + if ($null -eq $serviceKey) { + Write-Log "Failed to retrieve service key for subaccount $subaccountId. Skipping." -Level "ERROR" + $failureCount++ + continue + } + + # Extract and validate credentials from service key + $btpCredentials = Get-BtpServiceKeyCredentials -ServiceKey $serviceKey + + if ($null -eq $btpCredentials) { + Write-Log "Failed to extract valid credentials from service key for subaccount $subaccountId. Skipping." -Level "ERROR" $failureCount++ continue } - $currentApiEndpoint = $apiEndpoint - } - - # Target the org and space - if (-not (Set-CfTarget -OrgName $orgName -SpaceName $spaceName)) { - Write-Log "Failed to target org/space. Skipping subaccount." -Level "ERROR" - $failureCount++ - continue - } - - # Define instance and key names - $instanceName = $InstanceNamePrefix - $keyName = "$InstanceNamePrefix-key" - - # Get service key - $serviceKey = Get-CfServiceKey -InstanceName $instanceName -KeyName $keyName - - if ($null -eq $serviceKey) { - Write-Log "Failed to retrieve service key for subaccount $subaccountId. Skipping." -Level "ERROR" - $failureCount++ - continue - } - - # Extract and validate credentials from service key - $btpCredentials = Get-BtpServiceKeyCredentials -ServiceKey $serviceKey - - if ($null -eq $btpCredentials) { - Write-Log "Failed to extract valid credentials from service key for subaccount $subaccountId. Skipping." -Level "ERROR" - $failureCount++ - continue } Write-Log "Successfully extracted credentials from service key:" -Level "SUCCESS" diff --git a/Solutions/SAP BTP/Tools/provision-audit-to-subaccounts.ps1 b/Solutions/SAP BTP/Tools/provision-audit-to-subaccounts.ps1 index df0b5802515..f726541afb3 100644 --- a/Solutions/SAP BTP/Tools/provision-audit-to-subaccounts.ps1 +++ b/Solutions/SAP BTP/Tools/provision-audit-to-subaccounts.ps1 @@ -4,15 +4,15 @@ # The service key contains the necessary credentials to connect SAP BTP Auditlog Management with Microsoft Sentinel for SAP BTP. # # Prerequisites: -# - Cloud Foundry CLI (cf) installed and configured -# - CF login session established (run 'cf login' before executing this script) +# - SAP BTP CLI installed: https://tools.hana.ondemand.com/#cloud-btpcli +# - Azure CLI installed (if using Key Vault export): https://learn.microsoft.com/cli/azure/install-azure-cli # - Appropriate permissions in SAP BTP to create services in target orgs/spaces # - SAP BTP entitlements/quota for 'auditlog-management' service in each subaccount # - Sentinel Solution for SAP BTP deployed in your Azure environment # - A CSV file named 'subaccounts.csv' with columns: SubaccountId;cf-api-endpoint;cf-org-name;cf-space-name # # Usage: -# 1. Run 'cf login' first to establish authentication +# 1. Ensure you have CF credentials (username/password) # 2. Update 'subaccounts.csv' with your subaccount details # 3. Execute this script in PowerShell from the Tools folder: # $securePassword = Read-Host "Enter CF Password" -AsSecureString @@ -30,19 +30,69 @@ param( [string]$ServicePlan = "default", [Parameter(Mandatory=$false)] - [string]$InstanceNamePrefix = "sentinel-audit-srv", + [string]$InstanceName = "sentinel-audit-srv", [Parameter(Mandatory=$false)] [string]$CfUsername = $env:CF_USERNAME, [Parameter(Mandatory=$false)] - [SecureString]$CfPassword + [SecureString]$CfPassword, + + [Parameter(Mandatory=$false)] + [switch]$ExportCredentialsToCsv, + + [Parameter(Mandatory=$false)] + [switch]$ExportCredentialsToKeyVault, + + [Parameter(Mandatory=$false)] + [string]$KeyVaultName, + + [Parameter(Mandatory=$false)] + [ValidateSet("CreateNewKey", "Cleanup")] + [string]$KeyRotationMode = "CreateNewKey" ) # Import shared helper functions $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path Import-Module "$scriptPath\BtpHelpers.ps1" -Force +# Validate Key Vault parameters if exporting to Key Vault +if ($ExportCredentialsToKeyVault) { + if ([string]::IsNullOrWhiteSpace($KeyVaultName)) { + Write-Log "KeyVaultName parameter is required when ExportCredentialsToKeyVault is specified" -Level "ERROR" + exit 1 + } + + # Check if Azure CLI is available + if (-not (Test-AzCli)) { + Write-Log "Azure CLI is required for Key Vault operations. Exiting." -Level "ERROR" + exit 1 + } +} + +# Warn users about CSV security implications +if ($ExportCredentialsToCsv) { + Write-Host "" + Write-Host "WARNING: CSV Export Security Notice" -ForegroundColor Yellow + Write-Host "========================================" -ForegroundColor Yellow + Write-Host "" + Write-Host "CSV files store credentials in plaintext without encryption, access controls," + Write-Host "or audit trails. This method should ONLY be used for testing purposes." + Write-Host "" + Write-Host "For production environments, use Azure Key Vault instead:" -ForegroundColor Cyan + Write-Host " -ExportCredentialsToKeyVault -KeyVaultName ''" + Write-Host "" + + $confirmation = Read-Host "Do you want to continue with CSV export? (yes/no)" + + if ($confirmation -notmatch '^(y|yes)$') { + Write-Log "CSV export cancelled by user" -Level "INFO" + exit 0 + } + + Write-Log "User confirmed CSV export. Proceeding with caution..." -Level "WARNING" +} + # Validate and get CF credentials using helper function $credentials = Get-CfCredentials -Username $CfUsername -Password $CfPassword if ($null -eq $credentials) { @@ -54,13 +104,16 @@ $CfPassword = $credentials.Password # Main script execution Write-Log "=======================================================================" Write-Log "Starting SAP BTP Audit Log Management Onboarding Process" -Write-Log "=======================================================================" - -# Check if CF CLI is installed -if (-not (Test-CfCli)) { - Write-Log "Exiting script due to missing CF CLI." -Level "ERROR" - exit 1 +Write-Log "Key Rotation Mode: $KeyRotationMode" +if ($ExportCredentialsToCsv) { + Write-Log "Export Credentials to CSV: Enabled" + Write-Log "WARNING: Credentials will be stored in plaintext in CSV file" -Level "WARNING" } +if ($ExportCredentialsToKeyVault) { + Write-Log "Export Credentials to Key Vault: Enabled" + Write-Log "Key Vault Name: $KeyVaultName" +} +Write-Log "=======================================================================" # Load subaccounts from CSV using helper function $subaccounts = Import-BtpSubaccountsCsv -CsvPath $CsvPath @@ -90,6 +143,10 @@ foreach ($subaccount in $subaccounts) { Write-Log "Org: $orgName | Space: $spaceName" Write-Log "=======================================================================" + # Initialize variables for this subaccount iteration + $serviceKey = $null + $credentials = $null + # Switch API endpoint if needed if ($currentApiEndpoint -ne $apiEndpoint) { if (-not (Set-CfApiEndpoint -ApiEndpoint $apiEndpoint -Username $CfUsername -Password $CfPassword -OrgName $orgName -SpaceName $spaceName)) { @@ -107,11 +164,13 @@ foreach ($subaccount in $subaccounts) { continue } - # Generate instance name (same for all subaccounts) - $instanceName = $InstanceNamePrefix - $keyName = "$InstanceNamePrefix-key" + # Use the specified instance name (consistent with connect script approach) + $instanceName = $InstanceName + Write-Log "Using instance name: $instanceName" -Level "INFO" - # Create service instance + $keyName = "$instanceName-key" + + # Create service instance (will skip if already exists) $serviceCreated = New-CfServiceInstance -InstanceName $instanceName -Service $ServiceName -Plan $ServicePlan if (-not $serviceCreated) { @@ -123,18 +182,119 @@ foreach ($subaccount in $subaccounts) { Write-Log "Waiting for service instance to be ready..." Start-Sleep -Seconds 5 + # Handle key rotation based on mode + $existingKeyObjects = Get-CfServiceKeysWithDetails -InstanceName $instanceName + $existingKeys = @($existingKeyObjects | ForEach-Object { $_.name }) + $keyExists = $existingKeys -contains $keyName + + if ($KeyRotationMode -eq "Cleanup") { + # Cleanup mode: Keep only the newest key, delete all others + Write-Log "Running in Cleanup mode: Removing old service keys..." + + if ($existingKeyObjects.Count -gt 1) { + # Sort by created_at timestamp, oldest first + $sortedKeys = $existingKeyObjects | Sort-Object created_at + $keysToDelete = $sortedKeys[0..($sortedKeys.Count-2)] + $newestKey = $sortedKeys[-1] + + Write-Log "Keeping newest key: $($newestKey.name) (created: $($newestKey.created_at))" -Level "INFO" + Write-Log "Deleting $($keysToDelete.Count) old key(s)..." -Level "INFO" + + foreach ($oldKey in $keysToDelete) { + Write-Log "Deleting old service key: $($oldKey.name) (created: $($oldKey.created_at))" + $deleted = Remove-CfServiceKey -KeyGuid $oldKey.guid -KeyName $oldKey.name + + if (-not $deleted) { + Write-Log "Failed to delete service key '$($oldKey.name)'" -Level "WARNING" + } + } + + $successCount++ + Write-Log "Cleanup completed for subaccount $subaccountId" -Level "SUCCESS" + } else { + Write-Log "Only one or no keys exist, nothing to clean up" -Level "INFO" + $successCount++ + } + + # Skip to next subaccount in cleanup mode (no key creation) + Start-Sleep -Seconds 2 + continue + } + + # For CreateNewKey mode, handle key creation + if ($keyExists) { + # CreateNewKey mode with existing key - generate timestamped key name + $timestamp = Get-Date -Format "yyyyMMddHHmmss" + $keyName = "$InstanceName-key-$timestamp" + Write-Log "Key already exists. Creating new key with timestamp: $keyName" -Level "INFO" + } + # Create service key $keyCreated = New-CfServiceKey -InstanceName $instanceName -KeyName $keyName - if ($keyCreated) { - $successCount++ - Write-Log "Subaccount $subaccountId processed successfully" -Level "SUCCESS" - } - else { + if (-not $keyCreated) { $failureCount++ - Write-Log "Subaccount $subaccountId partially completed (service created, key failed)" -Level "WARNING" + continue + } + + # Export credentials to CSV if requested + if ($ExportCredentialsToCsv) { + Write-Log "Retrieving service key for CSV export..." + $serviceKey = Get-CfServiceKey -InstanceName $instanceName -KeyName $keyName + + if ($null -ne $serviceKey) { + $credentials = Get-BtpServiceKeyCredentials -ServiceKey $serviceKey + + if ($null -ne $credentials) { + $exported = Export-ServiceKeyToCsv -CsvPath $CsvPath -SubaccountId $subaccountId -Credentials $credentials + + if ($exported) { + Write-Log "Credentials exported to CSV for subaccount $subaccountId" -Level "SUCCESS" + } else { + Write-Log "Failed to export credentials to CSV" -Level "WARNING" + } + } else { + Write-Log "Failed to extract credentials from service key" -Level "WARNING" + } + } else { + Write-Log "Failed to retrieve service key for export" -Level "WARNING" + } } + # Export credentials to Key Vault if requested + if ($ExportCredentialsToKeyVault) { + Write-Log "Retrieving service key for Key Vault export..." + + # Only retrieve service key if not already retrieved for CSV + if ($null -eq $serviceKey) { + $serviceKey = Get-CfServiceKey -InstanceName $instanceName -KeyName $keyName + } + + if ($null -ne $serviceKey) { + # Only extract credentials if not already extracted for CSV + if ($null -eq $credentials) { + $credentials = Get-BtpServiceKeyCredentials -ServiceKey $serviceKey + } + + if ($null -ne $credentials) { + $exported = Export-ServiceKeyToKeyVault -KeyVaultName $KeyVaultName -SubaccountId $subaccountId -Credentials $credentials + + if ($exported) { + Write-Log "Credentials exported to Key Vault for subaccount $subaccountId" -Level "SUCCESS" + } else { + Write-Log "Failed to export credentials to Key Vault" -Level "WARNING" + } + } else { + Write-Log "Failed to extract credentials from service key" -Level "WARNING" + } + } else { + Write-Log "Failed to retrieve service key for export" -Level "WARNING" + } + } + + $successCount++ + Write-Log "Subaccount $subaccountId processed successfully" -Level "SUCCESS" + # Small delay between subaccounts Start-Sleep -Seconds 2 }