diff --git a/README.md b/README.md index 72f1506..c11de19 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,69 @@ +## Azure Industrial IoT Services + +### OPC Unified Architecture (OPC UA) Certificate Management Service + +The certificate management service for OPC UA facilitates a CA certificate cloud service for OPC UA devices +based on Azure Key Vault and CosmosDB, a ASP.Net Core web application front end and a OPC UA GDS server based on .Net Standard. + +The implementation follows the GDS Certificate Management Services as described in the OPC UA specification Part 12. + +The CA certificates are stored in a HSM backed Azure Key Vault, which is also used to sign issued certificates. + +A web management application front end and a local OPC UA GDS server allow for easy connection to the services secured by Azure AD. + +### This repository contains the following: + +* **ASP.Net Core Certificate Management Service** to manage certificates with Azure Key Vault and CosmosDB. +* **ASP.Net Core Sample Application** as user interface for the Certificate Management Service. +* **OPC UA .Net Standard GDS Server** for local OPC UA device connectivity to the cloud Certificate Management Service. + +### Microservice Features +1. Production ready certificate microservice based on C# with ASP.Net Core 2.1. +2. Uses Azure Key Vault as CA certificate store, key pair generator and certificate signer backed by FIPS 140-2 Level 2 validated HSMs. +3. Uses Cosmos DB as application and certificate request database. Open database interface to integrate with other database services. +2. Secured by AzureAD role based access with separation of Reader, Writer, Approver and Administrator roles. +2. Exposes Rest API (with Swagger UI) to easily integrate certificate microservice in other cloud services. +7. Support for RSA certificates with a SHA256 signature and keys with a length of 2048, 3072 or 4096 bits. +8. Support to sign certificates created with new key pairs from Azure Key Vault or by using Certificate Signing Requests (CSR). +4. Key Pairs and signed certificates with extensions follow requirements and guidelines as specified in the OPC UA GDS Certificate Management Services, Part 12. +9. The CA has full CRL support with revocation of unregistered OPC UA applications. +5. Uses on behalf tokens to access Azure Key Vault to validate user permissions at KeyVault level in addition to the validation at the microservice Rest API. +8. Busines logic ensures secure workflow with assigned user roles and the validation of certificate requests against the application database. +9. Follows Microsoft SDL guidelines for public-key infrastructure. +5. Leverages OPC UA .NetStandard GDS Server Common libraries. +13. Uses Azure Key Vault versioning and auditing to track CA certificate access and CRL history. + +### Web Certificate Management Sample Features +5. Sample code is based on the certificate management microservice Rest API using C# with ASP.Net Core 2.1. +8. Workflow to secure a OPC UA application with a CA signed certificate: Register an OPC UA application, request a certificate or key pair, generate the signed certificate and download it. +7. Secure workflow to unregister and revoke a OPC UA application including CRL updates. +5. Forms to manage OPC UA applications and certificate requests. +8. CA certificate management for the Administrator role to configure CA cert lifetime and subject name. +9. Renewal of a CA certificates. +8. Create key pairs and sign certificates with a CSR validated with application database information. +11. Upload CSR for signing requests as file or base64 string. +9. Binary and base64 download of certificates and keys as PFX, PEM and DER. +10. Issues consolidated CRL updates for multiple unregistered applications in a single step. +11. Accesses the microservice on behalf of the user to be able to execute protected functions in Azure Key Vault (e.g. signing rights for Approver). + +### On premise Global Discovery Server (GDS) with cloud integration +5. Based on the GDS server common library of the OPC UA .NetStandard SDK. +6. Implements OPC UA Discovery and Certificate management services by connecting to the microservice. +7. Executes in a docker container or as a .Net Core 2.0 application on Windows or Linux. +8. Implements namespace of OPC UA GDS Discovery and Certificate Management Services V1.04, Part 12. +6. **Note:** At this time the server can only act in a reader role with limited functionality due +10. to the lack of user OAUTH2 authentication support in the .NetStandard SDK. +11. For development purposes and testing, the AzureAD registration can be enabled for a 'Writer' role to allow to create certificate requests and to update applications, +but this configuration shall not be used in production deployments. + +## [Build and Deploy](docs/howto-deploy-services.md) the service to Azure + +The documentation how to build and deploy the service is [here](docs/howto-deploy-services.md). + # Contributing @@ -12,3 +78,32 @@ provided by the bot. You will only need to do this once across all repos using o This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +### Give Feedback + +Please enter issues, bugs, or suggestions for any of the components and services as GitHub Issues [here](https://github.com/Azure/azure-iiot-opcvault-service/issues). + +### Contribute + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +If you want/plan to contribute, we ask you to sign a [CLA](https://cla.microsoft.com/) (Contribution License Agreement) and follow the project 's [code submission guidelines](docs/contributing.md). A friendly bot will remind you about it when you submit a pull-request. ​  + +## License + +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the [MIT](LICENSE) License. + +[azure-free]:https://azure.microsoft.com/en-us/free/ +[powershell-install]:https://azure.microsoft.com/en-us/downloads/#PowerShell + +[run-with-docker-url]: https://docs.microsoft.com/azure/iot-suite/iot-suite-remote-monitoring-deploy-local#run-the-microservices-in-docker +[rm-arch-url]: https://docs.microsoft.com/azure/iot-suite/iot-suite-remote-monitoring-sample-walkthrough +[postman-url]: https://www.getpostman.com +[iotedge-url]: https://github.com/Azure/iotedge +[docker-url]: https://www.docker.com/ +[dotnet-install]: https://www.microsoft.com/net/learn/get-started +[vs-install-url]: https://www.visualstudio.com/downloads +[dotnetcore-tools-url]: https://www.microsoft.com/net/core#windowsvs2017 + + diff --git a/app/Controllers/CertomatController.cs b/app/Controllers/CertomatController.cs index 55d6816..7befe9c 100644 --- a/app/Controllers/CertomatController.cs +++ b/app/Controllers/CertomatController.cs @@ -255,7 +255,8 @@ namespace Microsoft.Azure.IIoT.OpcUa.Services.Vault.App.Controllers string message = null; try { - await opcVault.ApproveCertificateRequestAsync(id, false); + // TODO: call depending on auto approve setup + //await opcVault.ApproveCertificateRequestAsync(id, false); } catch (Exception ex) { @@ -350,7 +351,8 @@ namespace Microsoft.Azure.IIoT.OpcUa.Services.Vault.App.Controllers string message = null; try { - await opcVault.ApproveCertificateRequestAsync(id, false); + // TODO: call depending on auto approve setup + //await opcVault.ApproveCertificateRequestAsync(id, false); } catch (Exception ex) { diff --git a/app/Microsoft.Azure.IIoT.OpcUa.Services.Vault.App.csproj b/app/Microsoft.Azure.IIoT.OpcUa.Services.Vault.App.csproj index 2ecb20e..5fe3500 100644 --- a/app/Microsoft.Azure.IIoT.OpcUa.Services.Vault.App.csproj +++ b/app/Microsoft.Azure.IIoT.OpcUa.Services.Vault.App.csproj @@ -1,4 +1,4 @@ - + netcoreapp2.1 @@ -10,12 +10,13 @@ + - + diff --git a/app/Program.cs b/app/Program.cs index 772d912..cdf0dde 100644 --- a/app/Program.cs +++ b/app/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for // license information. // @@ -19,6 +19,7 @@ namespace Microsoft.Azure.IIoT.OpcUa.Services.Vault.App public static IWebHostBuilder CreateWebHostBuilder(string[] args) => WebHost.CreateDefaultBuilder(args) + .UseApplicationInsights() .ConfigureAppConfiguration((context, config) => { var builtConfig = config.Build(); @@ -28,7 +29,7 @@ namespace Microsoft.Azure.IIoT.OpcUa.Services.Vault.App $"{builtConfig["KeyVault"]}", builtConfig["AzureAD:ClientId"], builtConfig["AzureAD:ClientSecret"], - new PrefixKeyVaultSecretManager("OpcVault.App") + new PrefixKeyVaultSecretManager("App") ); } }) diff --git a/app/Startup.cs b/app/Startup.cs index 1bca2b9..d3b2552 100644 --- a/app/Startup.cs +++ b/app/Startup.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for // license information. // @@ -57,7 +57,7 @@ namespace Microsoft.Azure.IIoT.OpcUa.Services.Vault.App options.CheckConsentNeeded = context => true; options.MinimumSameSitePolicy = SameSiteMode.None; }); - + services.AddApplicationInsightsTelemetry(); services.AddAuthentication(AzureADDefaults.AuthenticationScheme) .AddAzureAD(options => Configuration.Bind("AzureAd", options)) ; diff --git a/azure-iiot-opc-vault-service.sln b/azure-iiot-opc-vault-service.sln index b8dfe91..31a33ff 100644 --- a/azure-iiot-opc-vault-service.sln +++ b/azure-iiot-opc-vault-service.sln @@ -18,7 +18,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution src\Dockerfile.Windows = src\Dockerfile.Windows LICENSE = LICENSE project.props = project.props - README.md = README.md version.props = version.props EndProjectSection EndProject @@ -32,6 +31,20 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.IIoT.OpcUa. EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.IIoT.OpcUa.Api.Vault", "api-csharp\Microsoft.Azure.IIoT.OpcUa.Api.Vault.csproj", "{A887CAA1-2C60-4E3A-91F8-DB60BBE4CC26}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{E9C6A792-1D70-4DE9-B5FB-8D8BCAA223EE}" + ProjectSection(SolutionItems) = preProject + docs\howto-deploy-services.md = docs\howto-deploy-services.md + docs\howto-run-services-locally.md = docs\howto-run-services-locally.md + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{A3FF1EEF-11DA-470B-9C98-B043B3CB4DE7}" + ProjectSection(SolutionItems) = preProject + deploy\.gitignore = deploy\.gitignore + deploy\deploy.ps1 = deploy\deploy.ps1 + deploy\KeyVault.Secret.Groups.json = deploy\KeyVault.Secret.Groups.json + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/deploy/.gitignore b/deploy/.gitignore index 2e0a0ca..6df861e 100644 --- a/deploy/.gitignore +++ b/deploy/.gitignore @@ -1,3 +1,7 @@ .user .app -.env \ No newline at end of file +.env +*.zip +*.config +*.cmd +*.publishsettings diff --git a/deploy/KeyVault.Secret.Groups.json b/deploy/KeyVault.Secret.Groups.json new file mode 100644 index 0000000..5354160 --- /dev/null +++ b/deploy/KeyVault.Secret.Groups.json @@ -0,0 +1 @@ +[{"Id":"Default","CertificateType":"RsaSha256ApplicationCertificateType","SubjectName":"CN=Azure Industrial IoT CA, O=Microsoft Corp.","BaseStorePath":"/default","DefaultCertificateLifetime":12,"DefaultCertificateKeySize":2048,"DefaultCertificateHashSize":256,"CACertificateLifetime":60,"CACertificateKeySize":2048,"CACertificateHashSize":256}] diff --git a/deploy/cloud/run.ps1 b/deploy/cloud/run.ps1 index aa306a3..14dd857 100644 --- a/deploy/cloud/run.ps1 +++ b/deploy/cloud/run.ps1 @@ -6,18 +6,39 @@ Deploys an Azure Resource Manager template of choice .PARAMETER resourceGroupName - The resource group where the template will be deployed. + The resource group where the template will be deployed. + + .PARAMETER webAppName + The host name prefix of the web application. + + .PARAMETER webServiceName + The host name prefix of the web application. .PARAMETER aadConfig The AAD configuration the template will be configured with. + .PARAMETER groupsConfig + The certificate groups configuration. + + .PARAMETER autoApprove + Set the web app auto approval configuration. + + .PARAMETER environment + Set the web app environment configuration. (Production,Development) + .PARAMETER interactive Whether to run in interactive mode + #> param( [Parameter(Mandatory=$True)] [string] $resourceGroupName, + [string] $webAppName = $null, + [string] $webServiceName = $null, $aadConfig = $null, + [string] $groupsConfig = $null, + [string] $autoApprove = "false", + [string] $environment = "Production", $interactive = $true ) @@ -53,19 +74,14 @@ $ScriptDir = Split-Path $script:MyInvocation.MyCommand.Path # Register RPs Register-AzureRmResourceProvider -ProviderNamespace "microsoft.web" | Out-Null -#Register-AzureRmResourceProvider -ProviderNamespace "microsoft.compute" | Out-Null -# Set admin password -$adminPassword = CreateRandomPassword -$templateParameters = @{ - # adminPassword = $adminPassword -} +$templateParameters = @{ } try { # Try set branch name as current branch $output = cmd /c "git rev-parse --abbrev-ref HEAD" 2`>`&1 $branchName = ("{0}" -f $output); - Write-Host "VM deployment will use configuration from '$branchName' branch." + Write-Host "Deployment will use configuration from '$branchName' branch." # $templateParameters.Add("branchName", $branchName) } catch { @@ -75,26 +91,71 @@ catch { # Configure auth if ($aadConfig) { if (![string]::IsNullOrEmpty($aadConfig.Audience)) { - # $templateParameters.Add("authAudience", $aadConfig.Audience) + $templateParameters.Add("aadAudience", $aadConfig.Audience) + } + if (![string]::IsNullOrEmpty($aadConfig.ServiceId)) { + $templateParameters.Add("aadServiceId", $aadConfig.ServiceId) + } + if (![string]::IsNullOrEmpty($aadConfig.ServiceObjectId)) { + $templateParameters.Add("aadServicePrincipalId", $aadConfig.ServicePrincipalId) + } + if (![string]::IsNullOrEmpty($aadConfig.ServiceSecret)) { + $templateParameters.Add("aadServiceSecret", $aadConfig.ServiceSecret) } if (![string]::IsNullOrEmpty($aadConfig.ClientId)) { - # $templateParameters.Add("aadClientId", $aadConfig.ClientId) + $templateParameters.Add("aadClientId", $aadConfig.ClientId) + } + if (![string]::IsNullOrEmpty($aadConfig.ClientSecret)) { + $templateParameters.Add("aadClientSecret", $aadConfig.ClientSecret) + } + if (![string]::IsNullOrEmpty($aadConfig.ModuleId)) { + $templateParameters.Add("aadModuleId", $aadConfig.ModuleId) + } + if (![string]::IsNullOrEmpty($aadConfig.ModuleSecret)) { + $templateParameters.Add("aadModuleSecret", $aadConfig.ModuleSecret) } if (![string]::IsNullOrEmpty($aadConfig.TenantId)) { - # $templateParameters.Add("aadTenantId", $aadConfig.TenantId) + $templateParameters.Add("aadTenantId", $aadConfig.TenantId) } if (![string]::IsNullOrEmpty($aadConfig.Instance)) { - # $templateParameters.Add("aadInstance", $aadConfig.Instance) + $templateParameters.Add("aadInstance", $aadConfig.Instance) + } + if (![string]::IsNullOrEmpty($aadConfig.UserPrincipalId)) { + $templateParameters.Add("aadUserPrincipalId", $aadConfig.UserPrincipalId) } } +# Configure groups +if (![string]::IsNullOrEmpty($groupsConfig)) { + $templateParameters.Add("groupsConfig", $groupsConfig) +} -# Set website name -if ($interactive) { - $webAppName = Read-Host "Please specify a website name" - if (![string]::IsNullOrEmpty($webAppName)) { - $templateParameters.Add("webAppName", $webAppName) - } +# Set web app site name +if ($interactive -and [string]::IsNullOrEmpty($webAppName)) { + $webAppName = Read-Host "Please specify a web applications site name" +} + +if (![string]::IsNullOrEmpty($webAppName)) { + $templateParameters.Add("webAppName", $webAppName) +} + +# Set web service site name +if ($interactive -and [string]::IsNullOrEmpty($webServiceName)) { + $webServiceName = Read-Host "Please specify a web service site name" +} + +if (![string]::IsNullOrEmpty($webServiceName)) { + $templateParameters.Add("webServiceName", $webServiceName) +} + +# Configure web app auto approve +if (![string]::IsNullOrEmpty($autoApprove)) { + $templateParameters.Add("autoApprove", $autoApprove) +} + +# Configure web app environment +if (![string]::IsNullOrEmpty($environment)) { + $templateParameters.Add("environment", $environment) } # Start the deployment @@ -105,30 +166,27 @@ $deployment = New-AzureRmResourceGroupDeployment -ResourceGroupName $resourceGro $webAppPortalUrl = $deployment.Outputs["webAppPortalUrl"].Value $webAppServiceUrl = $deployment.Outputs["webAppServiceUrl"].Value -#$adminUser = $deployment.Outputs["adminUsername"].Value +$webAppPortalName = $deployment.Outputs["webAppPortalName"].Value +$webAppServiceName = $deployment.Outputs["webAppServiceName"].Value if ($aadConfig -and $aadConfig.ClientObjectId) { # - # Update client application to add reply urls required permissions. + # Update client application to add reply urls to required permissions. # + $adClient = Get-AzureADApplication -ObjectId $aadConfig.ClientObjectId Write-Host "Adding ReplyUrls:" - $replyUrls = New-Object System.Collections.Generic.List[System.String] - $replyUrls.Add($webAppPortalUrl) - $replyUrls.Add($webAppPortalUrl + "/oauth2-redirect.html") - Write-Host $webAppPortalUrl + "/oauth2-redirect.html" - # still connected - Set-AzureADApplication -ObjectId $aadConfig.ClientObjectId -ReplyUrls $replyUrls + $replyUrls = $adClient.ReplyUrls + # web app + $replyUrls.Add($webAppPortalUrl + "/signin-oidc") + # swagger + $replyUrls.Add($webAppServiceUrl + "/oauth2-redirect.html") + Write-Host $webAppPortalUrl"/signin-oidc" + Write-Host $webAppServiceUrl"/oauth2-redirect.html" + Set-AzureADApplication -ObjectId $aadConfig.ClientObjectId -ReplyUrls $replyUrls -HomePage $webAppPortalUrl } -Write-Host -Write-Host "To access the web portal go to:" -Write-Host $webAppPortalUrl -Write-Host -Write-Host "To access the web service go to:" -Write-Host $webAppServiceUrl -Write-Host -#Write-Host "Use the following User and Password to log onto your VM:" -#Write-Host -#Write-Host $adminUser -#Write-Host $adminPassword -#Write-Host +if ($aadConfig -and $aadConfig.ClientObjectId) { + Set-AzureADApplication -ObjectId $aadConfig.ServiceObjectId -HomePage $webServicePortalUrl +} + +Return $webAppPortalUrl, $webAppServiceUrl diff --git a/deploy/cloud/template.json b/deploy/cloud/template.json index 75e3731..040eab2 100644 --- a/deploy/cloud/template.json +++ b/deploy/cloud/template.json @@ -2,32 +2,150 @@ "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", "contentVersion": "1.0.0.0", "parameters": { + "aadTenantId": { + "type": "string", + "metadata": { + "description": "The AAD tenant identifier (GUID)" + } + }, + "aadInstance": { + "type": "string", + "defaultValue": "https://login.microsoftonline.com/", + "metadata": { + "description": "Url of the AAD login page (example: https://login.microsoftonline.com/)" + } + }, + "aadServiceId": { + "type": "string", + "metadata": { + "description": "AAD service application identifier (GUID)" + } + }, + "aadServicePrincipalId": { + "type": "string", + "metadata": { + "description": "AAD service application principal id (GUID)" + } + }, + "aadServiceSecret": { + "type": "securestring", + "metadata": { + "description": "AAD service application secret." + } + }, + "aadClientId": { + "type": "string", + "metadata": { + "description": "AAD client application identifier (GUID)" + } + }, + "aadClientSecret": { + "type": "securestring", + "metadata": { + "description": "AAD client application secret." + } + }, + "aadModuleId": { + "type": "string", + "metadata": { + "description": "AAD module application identifier (GUID)" + } + }, + "aadModuleSecret": { + "type": "securestring", + "metadata": { + "description": "AAD module application secret." + } + }, + "aadAudience": { + "type": "string", + "defaultValue": "[parameters('aadServiceId')]", + "metadata": { + "description": "Audience to validate token audience against." + } + }, + "aadTrustedIssuer": { + "type": "string", + "defaultValue": "[concat('https://sts.windows.net/', parameters('aadTenantId'))]", + "metadata": { + "description": "Audience to validate token audience against." + } + }, + "aadUserPrincipalId": { + "type": "string", + "metadata": { + "description": "The user principal id managing the vault. (GUID)" + } + }, "webAppName": { "type": "string", "metadata": { - "description": "Base name of the resource such as web app name and app service plan " + "description": "Web app base name." }, "minLength": 2 + }, + "webServiceName": { + "type": "string", + "metadata": { + "description": "Web service base name." + }, + "minLength": 2 + }, + "groupsConfig": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Default certificate group configuration. (JSON)" + } + }, + "autoApprove": { + "type": "string", + "defaultValue": "false", + "metadata": { + "description": "Preset service for certificate auto approval." + } + }, + "environment": { + "type": "string", + "defaultValue": "Production", + "metadata": { + "description": "Preset web app environment." + } } + }, "variables": { "tenantId": "[subscription().tenantId]", - "randomSuffix": "[take(uniqueString(subscription().subscriptionId, resourceGroup().id, parameters('webAppName')), 5)]", + "groupPrefix": "[take(resourceGroup().name, 8)]", + "randomSuffix": "[take(uniqueString(subscription().subscriptionId, resourceGroup().id, resourceGroup().name), 5)]", "applocation": "[resourceGroup().location]", - "keyVaultName": "[concat('vault-', variables('randomSuffix'))]", + "keyVaultName": "[concat(variables('groupPrefix'), '-', variables('randomSuffix'))]", "vaultSku": "Premium", "enabledForDeployment": true, "enabledForTemplateDeployment": false, "enableVaultForVolumeEncryption": false, "webAppSku": "S1", - "webAppPortalName": "[concat(parameters('webAppName'), '-app')]", - "webAppServiceName": "[concat(parameters('webAppName'), '-service')]", - "appServicePlanName": "[concat('AppServicePlan-', parameters('webAppName'))]", - "documentDBName": "[concat('cosmosdb-', variables('randomSuffix'))]", + "webAppPortalName": "[parameters('webAppName')]", + "webAppServiceName": "[parameters('webServiceName')]", + "appInsightsName": "[concat(variables('groupPrefix'), '-', variables('randomSuffix'))]", + "appServicePlanName": "[concat('AppServicePlan-', resourceGroup().name)]", + "documentDBName": "[concat(variables('groupPrefix'), '-', variables('randomSuffix'))]", "documentDBApiVersion": "2016-03-19", "documentDBResourceId": "[resourceId('Microsoft.DocumentDb/databaseAccounts', variables('documentDBName'))]", "apiType": "SQL", - "offerType": "Standard" + "offerType": "Standard", + "readPermissions": [ "Get", "List" ], + "writePermissions": [ "Get", "List", "Set" ], + "signPermissions": [ "Get", "List", "Sign" ], + "createPermissions": [ "Get", "List", "Update", "Create", "Import", "Delete"], + "groupsObject": { + "secrets": [ + { + "secretName": "groups", + "secretValue": "[parameters('groupsConfig')]" + } + ] + } }, "resources": [ { @@ -64,7 +182,7 @@ { "type": "Microsoft.KeyVault/vaults", "name": "[variables('keyVaultName')]", - "apiVersion": "2015-06-01", + "apiVersion": "2018-02-14", "location": "[variables('applocation')]", "tags": { "displayName": "KeyVault" @@ -79,8 +197,67 @@ "family": "A" }, "accessPolicies": [] + }, + "resources": [ + { + "type": "accessPolicies", + "name": "add", + "apiVersion": "2018-02-14", + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" + ], + "properties": { + "accessPolicies": [ + { + "tenantId": "[parameters('aadTenantId')]", + "objectId": "[parameters('aadServicePrincipalId')]", + "permissions": { + "secrets": "[variables('readPermissions')]", + "certificates": "[variables('readPermissions')]" + } + }, + { + "tenantId": "[parameters('aadTenantId')]", + "objectId": "[parameters('aadUserPrincipalId')]", + "permissions": { + "keys": "[variables('signPermissions')]", + "secrets": "[variables('writePermissions')]", + "certificates": "[variables('createPermissions')]" + } + } + ] + } + } + ] + }, + { + "condition": "[not(empty(parameters('groupsConfig')))]", + "type": "Microsoft.KeyVault/vaults/secrets", + "name": "[concat(variables('keyVaultName'), '/', variables('groupsObject').secrets[copyIndex()].secretName)]", + "apiVersion": "2018-02-14", + "properties": { + "value": "[variables('groupsObject').secrets[copyIndex()].secretValue]" + }, + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" + ], + "copy": { + "name": "secretsCopy", + "count": "[length(variables('groupsObject').secrets)]" } }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "name": "[concat(variables('keyVaultName'), '/', 'Service-OpcVault--CosmosDBToken')]", + "apiVersion": "2018-02-14", + "properties": { + "value": "[listKeys(resourceId('Microsoft.DocumentDb/databaseAccounts', variables('documentDBName')), variables('documentDBApiVersion')).primaryMasterKey]" + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDb/databaseAccounts', variables('documentDBName'))]", + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" + ] + }, { "apiVersion": "2016-08-01", "type": "Microsoft.Web/sites", @@ -89,10 +266,50 @@ "location": "[variables('applocation')]", "comments": "This is the web app, also the default 'nameless' slot.", "properties": { - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]" + "name": "[variables('webAppPortalName')]", + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]", + "httpsOnly": true }, "dependsOn": [ "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]" + ], + "resources": [ + { + "name": "appsettings", + "type": "config", + "apiVersion": "2015-08-01", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppPortalName'))]", + "[resourceId('Microsoft.Web/sites', variables('webAppServiceName'))]", + "Microsoft.ApplicationInsights.AzureWebSites" + ], + "tags": { + "displayName": "WebAppServiceSettings" + }, + "properties": { + "APPINSIGHTS_INSTRUMENTATIONKEY": "[reference(concat('microsoft.insights/components/', variables('appInsightsName'))).InstrumentationKey]", + "ASPNETCORE_ENVIRONMENT": "[parameters('environment')]", + "AZUREAD__CLIENTID": "[parameters('aadClientId')]", + "AZUREAD__CLIENTSECRET": "[parameters('aadClientSecret')]", + "AZUREAD__INSTANCE": "[parameters('aadInstance')]", + "AZUREAD__TENANTID": "[parameters('aadTenantId')]", + "OPCVAULT__BASEADDRESS": "[concat('https://', reference(resourceId('Microsoft.Web/sites', variables('webAppServiceName'))).hostNames[0])]", + "OPCVAULT__RESOURCEID": "[parameters('aadServiceId')]", + "OPCVAULT__AUTOAPPROVE": "[parameters('autoApprove')]", + "WEBSITE_RUN_FROM_PACKAGE": "1" + } + }, + { + "apiVersion": "2015-08-01", + "name": "Microsoft.ApplicationInsights.AzureWebSites", + "type": "siteextensions", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppPortalName'))]" + ], + "properties": { + } + } + ] }, { @@ -103,12 +320,68 @@ "location": "[variables('applocation')]", "comments": "This is the web app service, also the default 'nameless' slot.", "properties": { - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]" + "name": "[variables('webAppServiceName')]", + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]", + "httpsOnly": true }, "dependsOn": [ "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]" + ], + "resources": [ + { + "name": "appsettings", + "type": "config", + "apiVersion": "2015-08-01", + "dependsOn": [ + "[resourceId('Microsoft.DocumentDb/databaseAccounts', variables('documentDBName'))]", + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]", + "[resourceId('Microsoft.Web/sites', variables('webAppServiceName'))]", + "Microsoft.ApplicationInsights.AzureWebSites" + ], + "tags": { + "displayName": "WebAppServiceSettings" + }, + "properties": { + "APPINSIGHTS_INSTRUMENTATIONKEY": "[reference(concat('microsoft.insights/components/', variables('appInsightsName'))).InstrumentationKey]", + "ASPNETCORE_ENVIRONMENT": "[parameters('environment')]", + "KEYVAULT": "[reference(resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))).vaultUri]", + "OPCVAULT__KEYVAULTBASEURL": "[reference(resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))).vaultUri]", + "OPCVAULT__COSMOSDBENDPOINT": "[reference(resourceId('Microsoft.DocumentDb/databaseAccounts', variables('documentDBName'))).documentEndpoint]", + //"OPCVAULT__COSMOSDBTOKEN": "[listKeys(resourceId('Microsoft.DocumentDb/databaseAccounts', variables('documentDBName')), variables('documentDBApiVersion')).primaryMasterKey]", + "OPCVAULT__AUTOAPPROVE": "[parameters('autoApprove')]", + "AUTH__APPID": "[parameters('aadServiceId')]", + "AUTH__APPSECRET": "[parameters('aadServiceSecret')]", + "AUTH__AUDIENCE": "[parameters('aadAudience')]", + "AUTH__TENANTID": "[parameters('aadTenantId')]", + "AUTH__TRUSTEDISSUER": "[parameters('aadTrustedIssuer')]", + "SWAGGER__ENABLED": "true", + "SWAGGER__APPID": "[parameters('aadClientId')]", + "SWAGGER__APPSECRET": "[parameters('aadClientSecret')]", + "WEBSITE_RUN_FROM_PACKAGE": "1" + } + }, + { + "apiVersion": "2015-08-01", + "name": "Microsoft.ApplicationInsights.AzureWebSites", + "type": "siteextensions", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppServiceName'))]" + ], + "properties": { + } + } ] + }, + { + "apiVersion": "2014-04-01", + "name": "[variables('appInsightsName')]", + "type": "Microsoft.Insights/components", + "location": "[resourceGroup().location]", + "properties": { + "applicationId": "[variables('appInsightsName')]" + } } + ], "outputs": { "webAppPortalUrl": { @@ -119,6 +392,14 @@ "type": "string", "value": "[concat('https://', reference(concat('Microsoft.Web/sites/', variables('webAppServiceName'))).hostNames[0])]" }, + "webAppPortalName": { + "type": "string", + "value": "[variables('webAppPortalName')]" + }, + "webAppServiceName": { + "type": "string", + "value": "[variables('webAppServiceName')]" + }, "resourceGroup": { "type": "string", "value": "[resourceGroup().name]" @@ -126,6 +407,14 @@ "tenantId": { "type": "string", "value": "[variables('tenantId')]" + }, + "KeyVaultBaseUrl": { + "type": "string", + "value": "[reference(resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))).vaultUri]" + }, + "CosmosDBEndpoint": { + "type": "string", + "value": "[reference(resourceId('Microsoft.DocumentDb/databaseAccounts', variables('documentDBName'))).documentEndpoint]" } } } diff --git a/deploy/deploy.ps1 b/deploy/deploy.ps1 index 3eae1a3..5b1d63e 100644 --- a/deploy/deploy.ps1 +++ b/deploy/deploy.ps1 @@ -1,12 +1,12 @@ <# .SYNOPSIS - Deploys Industrial IoT services to Azure + Deploys the OpcVault service to Azure .DESCRIPTION - Deploys the Industrial IoT services dependencies and optionally micro services and UI to Azure. + Deploys the OpcVault services and UI to Azure. .PARAMETER type - The type of deployment (cloud, vm, local) + The type of deployment (cloud) - defaults to cloud .PARAMETER resourceGroupName Can be the name of an existing or a new resource group. @@ -20,14 +20,12 @@ .PARAMETER resourceGroupLocation Optional, a resource group location. If specified, will try to create a new resource group in this location. - .PARAMETER withAuthentication - Whether to enable authentication - defaults to $true. + .PARAMETER withAutoApprove + Whether to enable auto approval - defaults to $false. .PARAMETER tenantId AD tenant to use. - .PARAMETER credentials - To support non interactive usage of script. (TODO) #> param( @@ -36,13 +34,12 @@ param( [string] $resourceGroupLocation, [string] $subscriptionName, [string] $subscriptionId, - [string] $accountName, - $credentials, [string] $tenantId, - [bool] $withAuthentication = $true, + [bool] $withAutoApprove = $false, [ValidateSet("AzureCloud")] [string] $environmentName = "AzureCloud" ) +$script:credentials = $null $script:optionIndex = 0 #******************************************************************************************************* @@ -66,7 +63,9 @@ Function SelectEnvironment() { -ResourceManagerUrl https://management.azure.com/ ` -ManagementPortalUrl http://go.microsoft.com/fwlink/?LinkId=254433 } - $script:locations = @("West US", "North Europe", "West Europe") + # locations currently limited by Application Insights + # TODO: test "Canada Central", "Central India", "Southeast Asia") + $script:locations = @("East US", "West US 2", "North Europe", "West Europe") } default { throw ("'{0}' is not a supported Azure Cloud environment" -f $script:environmentName) @@ -76,6 +75,43 @@ Function SelectEnvironment() { $script:environmentName = $script:environment.Name } +#******************************************************************************************************* +# Deploy a zip to web app +#******************************************************************************************************* +Function ZipDeploy() +{ + Param ( + [string] $resourceGroupName, + [string] $webAppName, + [string] $folderPath, + $slotParameters + ) + + $filePath = $folderPath + ".zip" + if(Test-path $filePath) {Remove-item $filePath} + Add-Type -assembly "system.io.compression.filesystem" + [io.compression.zipfile]::CreateFromDirectory($folderPath, $filePath) + if(Test-path $publishFolder) {Remove-Item -Recurse -Force $publishFolder} + + $profileClient = Get-AzureRmWebAppSlotPublishingProfile ` + -Format WebDeploy ` + -ResourceGroupName $resourceGroupName ` + -Name $webAppName ` + @slotParameters + $publishProfilePath = Join-Path -Path ".\" -ChildPath "$($webAppName).$($slotParameters.Slot).publishsettings" + Write-Output $profileClient | Out-File -FilePath $publishProfilePath + $profileClientXml = [xml]$profileClient + $profileClient = $profileClientXml.publishData.publishProfile[0] + + $username = $profileClient.UserName + $password = $profileClient.userPWD + $apiUrl = "https://" + $profileClient.publishUrl + "/api/zipdeploy" + $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $username, $password))) + $userAgent = "powershell/1.0" + Invoke-RestMethod -Uri $apiUrl -Headers @{Authorization=("Basic {0}" -f $base64AuthInfo)} -UserAgent $userAgent -Method POST -InFile $filePath -ContentType "multipart/form-data" + +} + #******************************************************************************************************* # Called in case no account is configured to let user choose the account. #******************************************************************************************************* @@ -159,6 +195,7 @@ Function Login() { } if ($rmProfileLoaded) { $script:accountName = $rmProfile.Context.Account.Id + $script:profileFile = $profileFile; } } if (!$rmProfileLoaded) { @@ -174,6 +211,7 @@ Function Login() { $reply = Read-Host -Prompt "Save user profile in $profileFile? [y/n]" if ($reply -match "[yY]") { Save-AzureRmContext -Path "$profileFile" + $script:profileFile = $profileFile; } } } @@ -184,7 +222,8 @@ Function Login() { Function SelectSubscription() { $subscriptions = Get-AzureRMSubscription if ($script:subscriptionName -ne $null -and $script:subscriptionName -ne "") { - $subscriptionId = Get-AzureRmSubscription -SubscriptionName $script:subscriptionName + $subscription = Get-AzureRmSubscription -SubscriptionName $script:subscriptionName + $subscriptionId = $subscription.Id } else { $subscriptionId = $script:subscriptionId @@ -192,8 +231,8 @@ Function SelectSubscription() { if (![string]::IsNullOrEmpty($subscriptionId)) { if (!$subscriptions.Id.Contains($subscriptionId)) { - Write-Error ("Invalid subscription id {0}" -f $subscriptionId) - $subscriptionId = "" + Write-Error ("Invalid subscription id {0} {1}" -f $subscriptionId.Id, $script:subscriptionName) + $subscriptionId = $null } } @@ -389,12 +428,6 @@ Function ConnectToAzureADTenant() { if ($script:interactive) { # Interactive if (!$script:tenantId) { - if (!$script:withAuthentication) { - $reply = Read-Host -Prompt "Enable authentication? [y/n]" - if ( $reply -notmatch "[yY]" ) { - return $null - } - } $script:tenantId = SelectAzureADTenantId } } @@ -459,24 +492,31 @@ Function AddResourcePermission() { #******************************************************************************************************* Function GetRequiredPermissions() { Param( - [string] $applicationDisplayName, [string] $requiredDelegatedPermissions, - [string] $requiredApplicationPermissions, + [string] $requiredApplicationPermissions, + [string] $appId, + [string] $servicePrincipalName, $servicePrincipal ) - # If we are passed the service principal we use it directly, otherwise we find it from - # the display name (which might not be unique) if ($servicePrincipal) { $sp = $servicePrincipal } - else { - $sp = Get-AzureADServicePrincipal -Filter "DisplayName eq '$applicationDisplayName'" + else + { + if ($servicePrincipalName) + { + $sp = Get-AzureADServicePrincipal -Filter "ServicePrincipalNames eq '$servicePrincipalName'" + } + if ($appId) + { + $sp = Get-AzureADServicePrincipal -Filter "AppId eq '$appId'" + } } + $appId = $sp.AppId - $appid = $sp.AppId $requiredAccess = New-Object Microsoft.Open.AzureAD.Model.RequiredResourceAccess - $requiredAccess.ResourceAppId = $appid + $requiredAccess.ResourceAppId = $appId $requiredAccess.ResourceAccess = New-Object System.Collections.Generic.List[Microsoft.Open.AzureAD.Model.ResourceAccess] @@ -488,6 +528,7 @@ Function GetRequiredPermissions() { AddResourcePermission $requiredAccess -exposedPermissions $sp.AppRoles ` -requiredAccesses $requiredApplicationPermissions -permissionType "Role" } + return $requiredAccess } @@ -540,9 +581,9 @@ Function GetAzureADApplicationConfig() { -Filter "identifierUris/any(uri:uri eq 'https://$tenantName/$serviceDisplayName')" if (!$serviceAadApplication) { $serviceAadApplication = New-AzureADApplication -DisplayName $serviceDisplayName ` - -PublicClient $False -HomePage "https://localhost" ` + -PublicClient $False -HomePage "https://$serviceDisplayName.azurewebsites.net" ` -IdentifierUris "https://$tenantName/$serviceDisplayName" - Write-Host "Created new AAD service application."+$serviceDisplayName + Write-Host "Created new AAD service application '$($serviceDisplayName)'." } $serviceServicePrincipal=Get-AzureADServicePrincipal ` -Filter "AppId eq '$($serviceAadApplication.AppId)'" @@ -556,16 +597,18 @@ Function GetAzureADApplicationConfig() { -Filter "DisplayName eq '$clientDisplayName'" if (!$clientAadApplication) { $clientAadApplication = New-AzureADApplication -DisplayName $clientDisplayName ` - -PublicClient $True - Write-Host "Created new AAD client application."+$clientDisplayName + -PublicClient $False -HomePage "https://$clientDisplayName.azurewebsites.net" ` + -IdentifierUris "https://$tenantName/$clientDisplayName" + Write-Host "Created new AAD client application '$($clientDisplayName)'." } $moduleAadApplication=Get-AzureADApplication ` -Filter "DisplayName eq '$moduleDisplayName'" if (!$moduleAadApplication) { $moduleAadApplication = New-AzureADApplication -DisplayName $moduleDisplayName ` - -PublicClient $True - Write-Host "Created new AAD Module application. "+$moduleDisplayName + -PublicClient $False -HomePage "http://localhost" ` + -IdentifierUris "https://$tenantName/$moduleDisplayName" + Write-Host "Created new AAD Module application '$($moduleDisplayName)'." } # Find client principal @@ -582,8 +625,6 @@ Function GetAzureADApplicationConfig() { # try { $user = Get-AzureADUser -ObjectId $creds.Account.Id -ErrorAction Stop - # TODO: Check whether already owner... - Add-AzureADApplicationOwner -ObjectId $serviceAadApplication.ObjectId ` -RefObjectId $user.ObjectId Add-AzureADApplicationOwner -ObjectId $clientAadApplication.ObjectId ` @@ -612,34 +653,65 @@ Function GetAzureADApplicationConfig() { $appRoles.Add($adminRole) $knownApplications = New-Object System.Collections.Generic.List[System.String] $knownApplications.Add($clientAadApplication.AppId) + $knownApplications.Add($moduleAadApplication.AppId) $requiredResourcesAccess = ` New-Object System.Collections.Generic.List[Microsoft.Open.AzureAD.Model.RequiredResourceAccess] - $requiredPermissions = GetRequiredPermissions -applicationDisplayName "Azure Key Vault" ` - -requiredDelegatedPermissions "user_impersonation" + $requiredPermissions = GetRequiredPermissions -appId "cfa8b339-82a2-471a-a3c9-0fc0be7a4093" ` + -requiredDelegatedPermissions "user_impersonation" $requiredResourcesAccess.Add($requiredPermissions) - $requiredPermissions = GetRequiredPermissions -applicationDisplayName "Microsoft Graph" ` - -requiredDelegatedPermissions "User.Read" + $requiredPermissions = GetRequiredPermissions -appId "00000002-0000-0000-c000-000000000000" ` + -requiredDelegatedPermissions "User.Read" $requiredResourcesAccess.Add($requiredPermissions) Set-AzureADApplication -ObjectId $serviceAadApplication.ObjectId ` - -RequiredResourceAccess $requiredResourcesAccess ` - -KnownClientApplications $knownApplications -AppRoles $appRoles + -KnownClientApplications $knownApplications -AppRoles $appRoles ` + -RequiredResourceAccess $requiredResourcesAccess + Write-Host "'$($serviceDisplayName)' updated with required resource access, app roles and known applications." + + # read updated app roles for service principal + $serviceServicePrincipal=Get-AzureADServicePrincipal ` + -Filter "AppId eq '$($serviceAadApplication.AppId)'" + + # + # Add current user as Writer, Approver and Administrator + # + try { + $app_role_name = "Writer" + $app_role = $serviceServicePrincipal.AppRoles | Where-Object { $_.DisplayName -eq $app_role_name } + New-AzureADUserAppRoleAssignment -ObjectId $user.ObjectId -PrincipalId $user.ObjectId -ResourceId $serviceServicePrincipal.ObjectId -Id $app_role.Id + + $app_role_name = "Approver" + $app_role = $serviceServicePrincipal.AppRoles | Where-Object { $_.DisplayName -eq $app_role_name } + New-AzureADUserAppRoleAssignment -ObjectId $user.ObjectId -PrincipalId $user.ObjectId -ResourceId $serviceServicePrincipal.ObjectId -Id $app_role.Id + + $app_role_name = "Administrator" + $app_role = $serviceServicePrincipal.AppRoles | Where-Object { $_.DisplayName -eq $app_role_name } + New-AzureADUserAppRoleAssignment -ObjectId $user.ObjectId -PrincipalId $user.ObjectId -ResourceId $serviceServicePrincipal.ObjectId -Id $app_role.Id + } + catch + { + Write-Host "User has already app roles assigned." + } # # Update client application to add reply urls required permissions. # $replyUrls = New-Object System.Collections.Generic.List[System.String] - $replyUrls.Add("urn:ietf:wg:oauth:2.0:oob") + $replyUrls.Add("https://localhost:44342/signin-oidc") + $replyUrls.Add("http://localhost:44342/signin-oidc") + $replyUrls.Add("https://localhost:58801/oauth2-redirect.html") + $replyUrls.Add("http://localhost:58801/oauth2-redirect.html") $requiredResourcesAccess = ` New-Object System.Collections.Generic.List[Microsoft.Open.AzureAD.Model.RequiredResourceAccess] - $requiredPermissions = GetRequiredPermissions -applicationDisplayName $serviceDisplayName ` + $requiredPermissions = GetRequiredPermissions -servicePrincipal $serviceServicePrincipal ` -requiredDelegatedPermissions "user_impersonation" # "Directory.Read.All|User.Read" $requiredResourcesAccess.Add($requiredPermissions) - $requiredPermissions = GetRequiredPermissions -applicationDisplayName "Microsoft Graph" ` - -requiredDelegatedPermissions "User.Read" + $requiredPermissions = GetRequiredPermissions -appId "00000002-0000-0000-c000-000000000000" ` + -requiredDelegatedPermissions "User.Read" $requiredResourcesAccess.Add($requiredPermissions) Set-AzureADApplication -ObjectId $clientAadApplication.ObjectId ` -RequiredResourceAccess $requiredResourcesAccess -ReplyUrls $replyUrls ` -Oauth2AllowImplicitFlow $True -Oauth2AllowUrlPathMatching $True + Write-Host "'$($clientDisplayName)' updated with required resource access, reply url and implicit flow." # # Update module application to add reply urls required permissions. @@ -648,26 +720,42 @@ Function GetAzureADApplicationConfig() { $replyUrls.Add("urn:ietf:wg:oauth:2.0:oob") $requiredResourcesAccess = ` New-Object System.Collections.Generic.List[Microsoft.Open.AzureAD.Model.RequiredResourceAccess] - $requiredPermissions = GetRequiredPermissions -applicationDisplayName $serviceDisplayName ` + $requiredPermissions = GetRequiredPermissions -servicePrincipal $serviceServicePrincipal ` -requiredDelegatedPermissions "user_impersonation" # "Directory.Read.All|User.Read" $requiredResourcesAccess.Add($requiredPermissions) - $requiredPermissions = GetRequiredPermissions -applicationDisplayName "Microsoft Graph" ` - -requiredDelegatedPermissions "User.Read" + $requiredPermissions = GetRequiredPermissions -appId "00000002-0000-0000-c000-000000000000" ` + -requiredDelegatedPermissions "User.Read" $requiredResourcesAccess.Add($requiredPermissions) Set-AzureADApplication -ObjectId $moduleAadApplication.ObjectId ` -RequiredResourceAccess $requiredResourcesAccess -ReplyUrls $replyUrls ` - -Oauth2AllowImplicitFlow $True -Oauth2AllowUrlPathMatching $True + -Oauth2AllowImplicitFlow $False -Oauth2AllowUrlPathMatching $False + Write-Host "'$($moduleDisplayName)' updated with required resource access, reply url and implicit flow." - return [pscustomobject] @{ + $serviceSecret = New-AzureADApplicationPasswordCredential -ObjectId $serviceAadApplication.ObjectId ` + -CustomKeyIdentifier "Service Key" -EndDate (get-date).AddYears(2) + $clientSecret = New-AzureADApplicationPasswordCredential -ObjectId $clientAadApplication.ObjectId ` + -CustomKeyIdentifier "Client Key" -EndDate (get-date).AddYears(2) + $moduleSecret = New-AzureADApplicationPasswordCredential -ObjectId $moduleAadApplication.ObjectId ` + -CustomKeyIdentifier "Module Key" -EndDate (get-date).AddYears(2) + + return [pscustomobject] @{ TenantId = $tenantId Instance = $script:environment.ActiveDirectoryAuthority - Audience = $serviceAadApplication.IdentifierUris[0].ToString() - AppId = $serviceAadApplication.AppId - AppObjectId = $serviceAadApplication.ObjectId + Audience = $serviceAadApplication.AppId + ServiceId = $serviceAadApplication.AppId + ServiceSecret = $serviceSecret.Value + ServiceObjectId = $serviceAadApplication.ObjectId + ServicePrincipalId = $serviceServicePrincipal.ObjectId + ServiceDisplayName = $serviceDisplayName ClientId = $clientAadApplication.AppId + ClientSecret = $clientSecret.Value ClientObjectId = $clientAadApplication.ObjectId + ClientDisplayName = $clientDisplayName ModuleId = $moduleAadApplication.AppId + ModuleSecret = $moduleSecret.Value ModuleObjectId = $moduleAadApplication.ObjectId + ModuleDisplayName = $moduleDisplayName + UserPrincipalId = $user.ObjectId } } catch { @@ -681,10 +769,6 @@ Function GetAzureADApplicationConfig() { Write-Host "2) in the PowerShell window, type: Install-Module AzureAD" Write-Host - $reply = Read-Host -Prompt "Continue without authentication? [y/n]" - if ($reply -match "[yY]") { - return $null - } throw $ex } } @@ -697,8 +781,11 @@ Function GetOrCreateResourceGroup() { # Registering default resource providers Register-AzureRmResourceProvider -ProviderNamespace "microsoft.devices" | Out-Null Register-AzureRmResourceProvider -ProviderNamespace "microsoft.documentdb" | Out-Null - Register-AzureRmResourceProvider -ProviderNamespace "microsoft.eventhub" | Out-Null Register-AzureRmResourceProvider -ProviderNamespace "microsoft.storage" | Out-Null + Register-AzureRmResourceProvider -ProviderNamespace "microsoft.keyvault" | Out-Null + Register-AzureRmResourceProvider -ProviderNamespace "microsoft.authorization" | Out-Null + Register-AzureRmResourceProvider -ProviderNamespace "microsoft.insights" | Out-Null + Register-AzureRmResourceProvider -ProviderNamespace "microsoft.web" | Out-Null while ([string]::IsNullOrEmpty($script:resourceGroupName)) { Write-Host @@ -706,7 +793,25 @@ Function GetOrCreateResourceGroup() { } # Create or check for existing resource group - Select-AzureRmSubscription -SubscriptionId $script:subscriptionId -Force | Out-Host + Write-Host "Select Subscription '$script:subscriptionId'" + if ((Get-AzureRmContext).Subscription.Id -ne $script:subscriptionId) + { + Enable-AzureRmContextAutosave + Add-AzureRmAccount -SubscriptionId $script:subscriptionId + Set-AzureRmContext -SubscriptionId $script:subscriptionId -Force | Out-Host + # context change required a new logon and the saved context should be updated + if ($script:profileFile) + { + $reply = Read-Host -Prompt "Save user profile in $script:profileFile? [y/n]" + if ($reply -match "[yY]") { + Save-AzureRmContext -Path "$script:profileFile" + } + } + } + else + { + Select-AzureRmSubscription -SubscriptionId $script:subscriptionId -Force | Out-Host + } $resourceGroup = Get-AzureRmResourceGroup -Name $script:resourceGroupName ` -ErrorAction SilentlyContinue if(!$resourceGroup) { @@ -737,34 +842,129 @@ if(![System.IO.File]::Exists($deploymentScript)) { } $script:interactive = $($script:credential -eq $null) -$script:subscriptionId = $null SelectEnvironment Login SelectSubscription $deleteOnErrorPrompt = GetOrCreateResourceGroup -$aadConfig = GetAzureADApplicationConfig +$aadConfig = GetAzureADApplicationConfig +$webAppName = $script:resourceGroupName + "-app" +$webServiceName = $script:resourceGroupName + "-service" +# the initial group configuration is only set once +if ($deleteOnErrorPrompt) +{ + $groupsConfig = Get-Content .\KeyVault.Secret.Groups.json -Raw +} + +# start the ARM deployment script try { - Write-Host "Almost done..." - & ($deploymentScript) -resourceGroupName $script:resourceGroupName ` - -interactive $script:interactive -aadConfig $aadConfig + Write-Host "Start deployment..." + $serviceUrls = & ($deploymentScript) -resourceGroupName $script:resourceGroupName ` + -interactive $script:interactive -aadConfig $aadConfig ` + -webAppName $webAppName -webServiceName $webServiceName ` + -groupsConfig $groupsConfig -autoApprove $withAutoApprove ` + -environment "Development" Write-Host "Deployment succeeded." } catch { - Write-Host "Deployment failed." $ex = $_.Exception + Write-Host $_.Exception.Message + Write-Host "Deployment failed." if ($deleteOnErrorPrompt) { $reply = Read-Host -Prompt "Delete resource group? [y/n]" if ($reply -match "[yY]") { try { + Write-Host "Remove resource group "$script:resourceGroupName Remove-AzureRmResourceGroup -Name $script:resourceGroupName -Force } catch { Write-Host $_.Exception.Message } + try { + Write-Host "Delete AD App "$aadConfig.ServiceDisplayName + Remove-AzureADApplication -ObjectId $aadConfig.ServiceObjectId + Write-Host "Delete AD App "$aadConfig.ClientDisplayName + Remove-AzureADApplication -ObjectId $aadConfig.ClientObjectId + Write-Host "Delete AD App "$aadConfig.ModuleDisplayName + Remove-AzureADApplication -ObjectId $aadConfig.ModuleObjectId + } + catch { + Write-Host $_.Exception.Message + } } } throw $ex } + +# publishing slot +$slotParameters = @{ Slot = "Production" } +$deploydir = pwd + +# build and publish the service webapp +Write-Host 'Publish service' +$publishFolder = Join-Path -Path $deploydir -ChildPath "\service" +if(Test-path $publishFolder) {Remove-Item -Recurse -Force $publishFolder} +dotnet publish -c Debug -o $publishFolder ..\src\Microsoft.Azure.IIoT.OpcUa.Services.Vault.csproj +ZipDeploy $resourceGroupName $webServiceName $publishFolder $slotParameters + +# build and publish the client webapp +Write-Host 'Publish application' +$publishFolder = Join-Path -Path $deploydir -ChildPath "\app" +if(Test-path $publishFolder) {Remove-Item -Recurse -Force $publishFolder} +dotnet publish -c Debug -o $publishFolder ..\app\Microsoft.Azure.IIoT.OpcUa.Services.Vault.App.csproj +ZipDeploy $resourceGroupName $webAppName $publishFolder $slotParameters + +# build configuration options for module +$moduleConfiguration = '--vault="'+$serviceUrls[1]+'"' +$moduleConfiguration += ' --resource="'+$($aadConfig.ServiceId)+'"' +$moduleConfiguration += ' --clientid="'+$($aadConfig.ModuleId)+'"' +$moduleConfiguration += ' --secret="'+$($aadConfig.ModuleSecret)+'"' +$moduleConfiguration += ' --tenantid="'+$($aadConfig.TenantId)+'"' + +# save config for user, e.g. for VS debugging of the module +$moduleConfigPath = Join-Path -Path $deploydir -ChildPath "$($resourceGroupName).module.config" +Write-Output $moduleConfiguration | Out-File -FilePath $moduleConfigPath -Encoding ascii + +# output information +Write-Host "GDS module configuration:" +Write-Host "--vault="$serviceUrls[1] +Write-Host "--resource="$aadConfig.ServiceId +Write-Host "--clientid="$aadConfig.ModuleId +Write-Host "--secret="$aadConfig.ModuleSecret +Write-Host "--tenantid="$aadConfig.TenantId + +# prepare the GDS module docker image +cd ..\module\docker\linux +.\dockerbuild.bat +cd $deploydir + +# create batch file for user to start GDS docker container +$dockerrun = 'docker run -it -p 58850-58852:58850-58852 -e 58850-58852 -h %COMPUTERNAME% -v "/c/GDS:/root/.local/share/Microsoft/GDS" edgeopcvault:latest ' +$dockerrun += $moduleConfiguration +$dockerrunfilename = ".\"+$resourceGroupName+"-dockergds.cmd" +Write-Output $dockerrun | Out-File -FilePath $dockerrunfilename -Encoding ascii + +# create batch file for user to start GDS as dotnet app +$apprun = "cd ..\module `r`n" +$apprun += 'dotnet run --project ..\module\Microsoft.Azure.IIoT.OpcUa.Modules.Vault.csproj ' +$apprun += $moduleConfiguration +$apprunfilename = ".\"+$resourceGroupName+"-gds.cmd" +Write-Output $apprun | Out-File -FilePath $apprunfilename -Encoding ascii + +# deployment info +Write-Host +Write-Host "To access the web client go to:" +Write-Host $serviceUrls[0] +Write-Host +Write-Host "To access the web service go to:" +Write-Host $serviceUrls[1] +Write-Host +Write-Host "To start the local docker GDS server:" +Write-Host $dockerrunfilename +Write-Host +Write-Host "To start the local dotnet GDS server:" +Write-Host $apprunfilename +Write-Host + diff --git a/docs/ApproveReject.png b/docs/ApproveReject.png new file mode 100644 index 0000000..31e2197 Binary files /dev/null and b/docs/ApproveReject.png differ diff --git a/docs/GenerateNewKeyPair.png b/docs/GenerateNewKeyPair.png new file mode 100644 index 0000000..ab33d41 Binary files /dev/null and b/docs/GenerateNewKeyPair.png differ diff --git a/docs/RequestNewCertificate.png b/docs/RequestNewCertificate.png new file mode 100644 index 0000000..43da83a Binary files /dev/null and b/docs/RequestNewCertificate.png differ diff --git a/docs/UAReferenceServerRegistration.png b/docs/UAReferenceServerRegistration.png new file mode 100644 index 0000000..1ea4456 Binary files /dev/null and b/docs/UAReferenceServerRegistration.png differ diff --git a/docs/ViewKeyPair.png b/docs/ViewKeyPair.png new file mode 100644 index 0000000..d3fa8f0 Binary files /dev/null and b/docs/ViewKeyPair.png differ diff --git a/docs/howto-deploy-services.md b/docs/howto-deploy-services.md new file mode 100644 index 0000000..e9e262b --- /dev/null +++ b/docs/howto-deploy-services.md @@ -0,0 +1,169 @@ +# Build and Deploy the Azure Industrial IoT OPC UA Certificate Management Service and dependencies + +This article explains how to deploy the OPC UA Certificate Management Service in Azure. + +## Prerequisites + +### Install required software + +Currently the build and deploy operation is limited to Windows. +The samples are all written for .NetStandard, which is needed to build the service and samples for deployment. +All the tools you need for .Net Standard come with the .Net Core tools. See [here](https://docs.microsoft.com/en-us/dotnet/articles/core/getting-started) for what you need. + +1. [Install .NET Core 2.1+][dotnet-install]. +2. [Install Docker][docker-url]. +4. Install the [Azure Command Line tools for PowerShell][powershell-install]. +5. Sign up for an [Azure Subscription][azure-free]. + +### Clone the repository + +If you have not done so yet, clone this Github repository. Open a command prompt or terminal and run: + +```bash +git clone https://github.com/Azure/azure-iiot-opc-vault-service && cd azure-iiot-opc-vault-service +``` + +or clone the repo directly in Visual Studio 2017. + +### Build and Deploy the Azure service on Windows + +A Powershell script provides an easy way to deploy the OPC UA Vault service and the application.
+ +1. Open a Powershell window at the repo root. +3. Go to the deploy folder `cd deploy` +5. Start the deployment with `.\deploy.ps1` for interactive installation
+or enter a full command line: +`.\deploy.ps1 -subscriptionName "MySubscriptionName" -resourceGroupLocation "East US" -tenantId "myTenantId" -resourceGroupName "myResourceGroup"` +6. Follow the instructions in the script to login to your subscription and to provide additional information +9. After a successful build and deploy operation you should see the following message: + +``` +To access the web client go to: +https://myResourceGroup-app.azurewebsites.net + +To access the web service go to: +https://myResourceGroup-service.azurewebsites.net + +To start the local docker GDS server: +.\myResourceGroup-dockergds.cmd + +To start the local dotnet GDS server: +.\myResourceGroup-gds.cmd +``` +In case you run into issues please follow the steps [below](#Troubleshooting-deployment-failures). + +6. Give the web app and the web service a few minutes to start up for the first time. +10. Open your favorite browser and open the application page: `https://myResourceGroup-app.azurewebsites.net` +11. To take a look at the Swagger Api open: `https://myResourceGroup-service.azurewebsites.net` +13. To start a local GDS server with dotnet start `.\myResourceGroup-gds.cmd` or with docker start `.\myResourceGroup-dockergds.cmd`. + +As a sidenote, it is possible to redeploy a build with exactly the same settings. Be aware that such an operation renews all application secrets and may reset some settings in the AAD application registrations. + +### Create the root CA certificate + +1. Open your certificate service at `https://myResourceGroup-app.azurewebsites.net` and login. +2. Navigate to the `Certificate Groups` page. +3. There is one `Default` Certificate Group listed. Click on `Edit`. +4. In `Edit Certificate Group Details` you can modify the Subject Name and Lifetime of your CA and application certificates. +5. Enter a valid Subject in the valid, e.g. `CN=My CA Root, O=MyCompany, OU=MyDepartment`. +6. Click on the `Save` button. +1. If you hit a 'forbidden' error at this point, the user you are logged in with doesn't have the rights to modify or create a new root cert. By default the user who deployed the service has management and signing roles with the service, other users need to be added to the 'Approver', 'Writer' or 'Administrator' roles as appropriate in the AzureAD application registration. +7. Click on the `Details` button. The `View Certificate Group Details` should display the updated information. +8. Click on the `Renew CA Certificate` button to issue your first root CA certificate. Press `Ok` to proceed. +9. After a few seconds the `Certificate Details` are shown. Press `Issuer` or `Crl` to download the latest CA certificate and CRL for distribution to your OPC UA applications. +10. Now the OPC UA Certificate Management Service is ready to issue certificates for OPC UA applications. + +### Register your OPC UA application and create a new key pair and certificate + +1. Open your certificate service at `https://myResourceGroup-app.azurewebsites.net` and login. +2. Navigate to the `Register New` page. +1. For an application registration a user needs to have at least the 'Writer' role assigned. +2. The entry form follows naming conventions in the OPC UA world. As an example, in the picture below the settings for the [OPC UA Reference Server](https://github.com/OPCFoundation/UA-.NETStandard/tree/master/SampleApplications/Workshop/Reference) sample in the OPC UA .NetStandard stack is shown: + +![UA Reference Server Registration](UAReferenceServerRegistration.png "UA Reference Server Registration") + +5. Press the `Register` button to register the application in the certificate service application database. The workflow directly guides the user to the next step to request a signed certificate for the application. + +![Request New Certificate](RequestNewCertificate.png "Request New Certificate") + +6. Press 'Request new KeyPair and Certificate' to request a new certificate for your application. + +![Generate New Key Pair](GenerateNewKeyPair.png "Generate New Key Pair") + +7. Fill in the form with a subject, the domain names and choose PEM or PFX with password for the private key. Press the `Generate New Certificate` button to create the certificate request. + +![Approve Certificate](ApproveReject.png "Approve Certificate") + +8. Approve or Reject the certificate request to start or cancel the actual creation of the key pair and the signing operation. The new key pair is created and stored securely in Azure Key Vault until downloaded by the certificate requester. The resulting certificate with public key is signed by the CA. These operations may take a few seconds to finish. + +![View Key Pair](ViewKeyPair.png "View Key Pair") + +9. The resulting private key (PFX or PEM) and certificate (DER) can be downloaded from here in the format selected as binary file download. A base64 encoded version is also available, e.g. to copy paste the certificate to a command line or text entry. +10. Once the private key is downloaded and stored securely, it can be deleted from the service with the `Delete Private Key` button. The certificate with public key remains available for future use. +11. Due to the use of a CA signed certificate, the CA cert and CRL should be downloaded here as well. +12. Now it depends on the OPC UA device how to apply the new key pair. Typically, the CA cert and CRL are copied to a `trusted` folder, while the public and private key of the application certificate is applied to a `own` folder in the certificate store. Some devices may already support 'Server Push' for Certificate updates. Please refer to the documentation of your OPC UA device. + +## Troubleshooting deployment failures + +### Resource group name + +Ensure you use a short and simple resource group name. The name is used also to name resources and the service url prefix and as such, it must comply with resource naming requirements. + +### Website name already in use + +It is possible that the name of the website is already in use. If you run into this error, you need to use a different resource group name. + +### Azure Active Directory (AAD) Registration + +The deployment script tries to register 3 AAD applications in Azure Active Directory. +Depending on your rights to the selected AAD tenant, this operation might fail. There are 2 options: + +1. If you chose a AAD tenant from a list of tenants, restart the script and choose a different one from the list. +2. Alternatively, deploy a private AAD tenant in another subscription, restart the script and select to use it. + +## Deployment script options + +The script takes the following parameters: + + +``` +-resourceGroupName +``` + +Can be the name of an existing or a new resource group. + +``` +-subscriptionId +``` + + +Optional, the subscription id where resources will be deployed. + +``` +-subscriptionName +``` + + +Or alternatively the subscription name. + +``` +-resourceGroupLocation +``` + + +Optional, a resource group location. If specified, will try to create a new resource group in this location. + + +``` +-tenantId +``` + + +AAD tenant to use. + + +[azure-free]:https://azure.microsoft.com/en-us/free/ +[powershell-install]:https://azure.microsoft.com/en-us/downloads/#PowerShell +[docker-url]: https://www.docker.com/ +[dotnet-install]: https://www.microsoft.com/net/learn/get-started + diff --git a/module/Microsoft.Azure.IIoT.OpcUa.Modules.Vault.Config.xml b/module/Microsoft.Azure.IIoT.OpcUa.Modules.Vault.Config.xml index d718bb0..e68a76b 100644 --- a/module/Microsoft.Azure.IIoT.OpcUa.Modules.Vault.Config.xml +++ b/module/Microsoft.Azure.IIoT.OpcUa.Modules.Vault.Config.xml @@ -1,10 +1,10 @@ - + - Azure Industrial IoT OPC UA Global Discovery Server + Azure IoT OPC UA Global Discovery Server urn:localhost:azure.microsoft.com:iiot:GlobalDiscoveryServer http://azure.microsoft.com/iiot/UA/GlobalDiscoveryServer Server_0 @@ -13,7 +13,7 @@ X509Store CurrentUser\My - CN=OPC UA Global Discovery Server, O=Microsoft Corp., OU=Azure Industrial IoT + CN=OPC UA Global Discovery Server, O=Microsoft Corp., OU=Azure IoT @@ -188,4 +188,4 @@ 519 - \ No newline at end of file + diff --git a/module/Program.cs b/module/Program.cs index 5eab33f..6d95360 100644 --- a/module/Program.cs +++ b/module/Program.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------ +// ------------------------------------------------------------ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License (MIT). See License.txt in the repo root for license information. // ------------------------------------------------------------ @@ -86,8 +86,8 @@ namespace Opc.Ua.Gds.Server { "r|resource=", "OpcVault Resource Id", r => opcVaultOptions.ResourceId = r }, { "c|clientid=", "AD Client Id", c => azureADOptions.ClientId = c }, { "s|secret=", "AD Client Secret", s => azureADOptions.ClientSecret = s }, - { "a|authority", "Authority", a => azureADOptions.Authority = a }, - { "t|tenantid", "Tenant Id", t => azureADOptions.TenantId = t }, + { "a|authority=", "Authority", a => azureADOptions.Authority = a }, + { "t|tenantid=", "Tenant Id", t => azureADOptions.TenantId = t }, { "h|help", "show this message and exit", h => showHelp = h != null }, }; diff --git a/module/certGroups.json b/module/certGroups.json deleted file mode 100644 index 877e00b..0000000 --- a/module/certGroups.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "Id": "Default", - "CertificateType": "RsaSha256ApplicationCertificateType", - "SubjectName": "CN=Azure Industrial IoT GDS CA, O=Microsoft Corp.", - "BaseStorePath": "/default", - "DefaultCertificateLifetime": 12, - "DefaultCertificateKeySize": 2048, - "DefaultCertificateHashSize": 256, - "CACertificateLifetime": 60, - "CACertificateKeySize": 2048, - "CACertificateHashSize": 256 - } -] diff --git a/module/docker/linux/Dockerfile b/module/docker/linux/Dockerfile index 7f9e1f9..41efd6e 100644 --- a/module/docker/linux/Dockerfile +++ b/module/docker/linux/Dockerfile @@ -1,4 +1,4 @@ -FROM microsoft/dotnet:2.0.0-runtime +FROM microsoft/dotnet:2.2-runtime COPY ./publish /publish WORKDIR /publish diff --git a/module/docker/linux/dockerbuild.bat b/module/docker/linux/dockerbuild.bat index fe2a102..82ccf5c 100644 --- a/module/docker/linux/dockerbuild.bat +++ b/module/docker/linux/dockerbuild.bat @@ -1,3 +1,4 @@ -dotnet build Microsoft.Azure.IIoT.OpcUa.Modules.Vault.csproj -dotnet publish Microsoft.Azure.IIoT.OpcUa.Modules.Vault.csproj -o ./publish -docker build -t edgegds . +dotnet build ..\..\Microsoft.Azure.IIoT.OpcUa.Modules.Vault.csproj +dotnet publish ..\..\Microsoft.Azure.IIoT.OpcUa.Modules.Vault.csproj -o ./docker/linux/publish +docker build -t edgeopcvault . + diff --git a/module/docker/linux/dockerrun.bat b/module/docker/linux/dockerrun.bat index c46e6d4..854025a 100644 --- a/module/docker/linux/dockerrun.bat +++ b/module/docker/linux/dockerrun.bat @@ -1,3 +1,4 @@ rem start docker with mapped logs -rem push image: docker push mregen/edgegds:latest -docker run -it -p 58850-58852:58850-58852 -e 58850-58852 -h edgegds -v "/c/GDS:/root/.local/share/Microsoft/GDS" edgegds:latest --g http://opcvault.azurewebsites.net/" +rem push image: docker push mregen/edgeopcvault:latest +docker run -it -p 58850-58852:58850-58852 -e 58850-58852 -h %COMPUTERNAME% -v "/c/GDS:/root/.local/share/Microsoft/GDS" edgeopcvault:latest --vault="https://vault012-service.azurewebsites.net" --resource="46f44d87-87a2-4d91-ad34-f0ed5d6031ed" --clientid="f5a38dd7-4282-49eb-b1f3-d50b72462588" --secret="ydZ0rxTzsDrik09c4sFRKmF0jgNO0yAB+93vcdRLCs4=" --tenantid="660722d6-c658-431c-8b2e-a157f3134da5" + diff --git a/src/CertificateGroup/KeyVaultCertificateGroup.cs b/src/CertificateGroup/KeyVaultCertificateGroup.cs index a0c7828..0649f32 100644 --- a/src/CertificateGroup/KeyVaultCertificateGroup.cs +++ b/src/CertificateGroup/KeyVaultCertificateGroup.cs @@ -110,10 +110,16 @@ namespace Microsoft.Azure.IIoT.OpcUa.Services.Vault { var accessToken = request.Headers["Authorization"]; var token = accessToken.First().Remove(0, "Bearer ".Length); + var authority = String.IsNullOrEmpty(_clientConfig.InstanceUrl) ? _kAuthority : _clientConfig.InstanceUrl; + if (!authority.EndsWith("/")) + { + authority += "/"; + } + authority += _clientConfig.TenantId; var serviceClientCredentials = new KeyVaultCredentials( token, - (String.IsNullOrEmpty(_clientConfig.InstanceUrl) ? _kAuthority : _clientConfig.InstanceUrl) + _clientConfig.TenantId, + authority, _servicesConfig.KeyVaultResourceId, _clientConfig.AppId, _clientConfig.AppSecret); diff --git a/src/Microsoft.Azure.IIoT.OpcUa.Services.Vault.csproj b/src/Microsoft.Azure.IIoT.OpcUa.Services.Vault.csproj index 689fb9e..107dbba 100644 --- a/src/Microsoft.Azure.IIoT.OpcUa.Services.Vault.csproj +++ b/src/Microsoft.Azure.IIoT.OpcUa.Services.Vault.csproj @@ -43,10 +43,12 @@ - - + + + + @@ -76,8 +78,7 @@ - - + @@ -96,4 +97,7 @@ + + +
\ No newline at end of file diff --git a/src/Program.cs b/src/Program.cs index 40822ee..e1b6409 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -41,6 +41,7 @@ namespace Microsoft.Azure.IIoT.OpcUa.Services.Vault Console.WriteLine($"[{Uptime.ProcessId}] Starting web service, process ID: " + Uptime.ProcessId); var host = new WebHostBuilder() + .UseApplicationInsights() .UseConfiguration(configRoot) .UseKestrel(options => { options.AddServerHeader = false; }) .UseIISIntegration() diff --git a/src/Runtime/PrefixKeyVaultSecretManager.cs b/src/Runtime/PrefixKeyVaultSecretManager.cs new file mode 100644 index 0000000..b2746c7 --- /dev/null +++ b/src/Runtime/PrefixKeyVaultSecretManager.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for +// license information. +// + +using Microsoft.Azure.KeyVault.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureKeyVault; + +namespace Microsoft.Azure.IIoT.OpcUa.Services.Vault.Runtime +{ + public class PrefixKeyVaultSecretManager : IKeyVaultSecretManager + { + private readonly string _prefix; + + public PrefixKeyVaultSecretManager(string prefix) + { + _prefix = $"{prefix}-"; + } + + public bool Load(SecretItem secret) + { + // Load a vault secret when its secret name starts with the + // prefix. Other secrets won't be loaded. + return secret.Identifier.Name.StartsWith(_prefix); + } + + public string GetKey(SecretBundle secret) + { + // Remove the prefix from the secret name and replace two + // dashes in any name with the KeyDelimiter, which is the + // delimiter used in configuration (usually a colon). Azure + // Key Vault doesn't allow a colon in secret names. + return secret.SecretIdentifier.Name + .Substring(_prefix.Length) + .Replace("--", ConfigurationPath.KeyDelimiter); + } + } + +} diff --git a/src/Startup.cs b/src/Startup.cs index 3415520..ea0a82c 100644 --- a/src/Startup.cs +++ b/src/Startup.cs @@ -5,6 +5,7 @@ namespace Microsoft.Azure.IIoT.OpcUa.Services.Vault { + using System; using Autofac; using Autofac.Extensions.DependencyInjection; using Microsoft.AspNetCore.Builder; @@ -21,7 +22,6 @@ namespace Microsoft.Azure.IIoT.OpcUa.Services.Vault using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Swashbuckle.AspNetCore.Swagger; - using System; using CorsSetup = IIoT.Services.Cors.CorsSetup; using ILogger = Microsoft.Azure.IIoT.Diagnostics.ILogger; @@ -54,13 +54,31 @@ namespace Microsoft.Azure.IIoT.OpcUa.Services.Vault { Environment = env; - IConfigurationRoot config = new ConfigurationBuilder() + var configBuilder = new ConfigurationBuilder() .SetBasePath(env.ContentRootPath) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{env.EnvironmentName}.json", true, true) - .AddEnvironmentVariables() - .Build(); + .AddEnvironmentVariables(); + IConfigurationRoot config; + try + { + var builtConfig = configBuilder.Build(); + var keyVault = builtConfig["KeyVault"]; + if (!String.IsNullOrWhiteSpace(keyVault)) + { + configBuilder.AddAzureKeyVault( + keyVault, + builtConfig["Auth:AppId"], + builtConfig["Auth:AppSecret"], + new PrefixKeyVaultSecretManager("Service") + ); + } + } + catch + { + } + config = configBuilder.Build(); Config = new Config(config); } @@ -100,6 +118,8 @@ namespace Microsoft.Azure.IIoT.OpcUa.Services.Vault options.SerializerSettings.MaxDepth = 10; }); + services.AddApplicationInsightsTelemetry(); + services.AddSwagger(Config, new Info { Title = ServiceInfo.NAME, diff --git a/src/appsettings.json b/src/appsettings.json index be339a8..0f93e33 100644 --- a/src/appsettings.json +++ b/src/appsettings.json @@ -1,117 +1,121 @@ -{ - "OpcVault": { - // - // KeyVault service uri from Azure portal - // - // "KeyVaultResourceId": "https://vault.azure.net" - // "KeyVaultBaseUrl": "", - // - // CosmosDB service uri and token from Azure portal - // - // "CosmosDBEndpoint": "", - // "CosmosDBToken": "" - }, - - // - // Can be used when running services on multiple hostnames and/or ports - // e.g. "*" or "{ 'origins': ['*'], 'methods': ['*'], 'headers': ['*'] }" - // to allow everything. Leave it empty to disable CORS. - // - "CorsWhitelist": "*", - - // - // Auth configuration - // - "Auth": { - // - // This can be changed to false, for example during development, to allow - // invalid/missing authorizations. - // - "Required": true, +{ + "OpcVault": { + // + // KeyVault service uri from Azure portal + // + // "KeyVaultResourceId": "https://vault.azure.net" + // "KeyVaultBaseUrl": "", + // + // CosmosDB service uri and token from Azure portal + // + // "CosmosDBEndpoint": "", + // "CosmosDBToken": "" + }, // - // Identifies the security token service (STS) that constructs and - // returns the token. In the tokens that Azure AD returns, the - // issuer is sts.windows.net. The GUID in the Issuer claim value is - // the tenant ID of the Azure AD directory. The tenant ID is an - // immutable and reliable identifier of the directory. Used to verify - // that tokens are issued by Azure AD. + // Can be used when running services on multiple hostnames and/or ports + // e.g. "*" or "{ 'origins': ['*'], 'methods': ['*'], 'headers': ['*'] }" + // to allow everything. Leave it empty to disable CORS. // - // When using Azure Active Directory, the format of the Issuer is: - // https://sts.windows.net// - // example: issuer: - // https://sts.windows.net/fa01ade2-2365-4dd1-a084-a6ef027090fc/ - // - // "TrustedIssuer": "", + "CorsWhitelist": "*", // - // The authority to use to issue tokens, by default this is - // https://login.microsoftonline.com//. + // Auth configuration // - // "Authority": "", + "Auth": { + // + // This can be changed to false, for example during development, to allow + // invalid/missing authorizations. + // + "Required": true, - // - // The audience for tokens, typically this is the application id. - // - // "Audience": "", + // + // Identifies the security token service (STS) that constructs and + // returns the token. In the tokens that Azure AD returns, the + // issuer is sts.windows.net. The GUID in the Issuer claim value is + // the tenant ID of the Azure AD directory. The tenant ID is an + // immutable and reliable identifier of the directory. Used to verify + // that tokens are issued by Azure AD. + // + // When using Azure Active Directory, the format of the Issuer is: + // https://sts.windows.net// + // example: issuer: + // https://sts.windows.net/fa01ade2-2365-4dd1-a084-a6ef027090fc/ + // + // "TrustedIssuer": "", - // - // The optional tenant id. The tenant ID is an immutable and reliable - // identifier of the directory. - // - // "TenantId": "", + // + // The authority to use to issue tokens, by default this is + // https://login.microsoftonline.com//. + // + // "Authority": "", - // - // The application id - // - // "AppId": "", + // + // The audience for tokens, typically this is the application id. + // + // "Audience": "", - // - // The application secret for on behalf of authentication - // - // "AppSecret": "", + // + // The optional tenant id. The tenant ID is an immutable and reliable + // identifier of the directory. + // + // "TenantId": "", - // - // When validating the token expiration, allows some clock skew - // Default: 2 minutes - // - "AllowedClockSkewSeconds": 300 - }, - // - // Swagger - // - "Swagger": { - // Swagger needs an app registration. - "Enabled": true - // - // The application id as registered in AAS. Retrieve from portal - // as a guid, e.g. fa01ade2-2365-4dd1-a084-a6ef027090fc - // The reply url of the registration is:"http:///oauth2-redirect.html" + // + // The application id + // + // "AppId": "", - // - // "AppId": "", + // + // The application secret for on behalf of authentication + // + // "AppSecret": "", - // the app secret of the swagger app registration - // "AppSecret": "" - }, - // - // Logging - // - "Logging": { + // + // When validating the token expiration, allows some clock skew + // Default: 2 minutes + // + "AllowedClockSkewSeconds": 300 + }, // - // For more information about ASP.NET logging see - // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/logging - // This configuration block is used only to capture internal logs generated - // by ASP.NET + // Swagger // - "IncludeScopes": true, + "Swagger": { + // Swagger needs an app registration. + "Enabled": true + // + // The application id as registered in AAS. Retrieve from portal + // as a guid, e.g. fa01ade2-2365-4dd1-a084-a6ef027090fc + // The reply url of the registration is:"http:///oauth2-redirect.html" + + // + // "AppId": "", + + // the app secret of the swagger app registration + // "AppSecret": "" + }, // - // ASP.NET log levels: Trace, Debug, Information, Warning, Error, Critical + // KeyVault for service configuration // - "LogLevel": { - "Default": "Warning", - "System": "Warning", - "Microsoft": "Warning" + // "KeyVault": null, + // + // Logging + // + "Logging": { + // + // For more information about ASP.NET logging see + // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/logging + // This configuration block is used only to capture internal logs generated + // by ASP.NET + // + "IncludeScopes": true, + // + // ASP.NET log levels: Trace, Debug, Information, Warning, Error, Critical + // + "LogLevel": { + "Default": "Warning", + "System": "Warning", + "Microsoft": "Warning" + } } - } }