From 014d4cd161d95172de5e056214369edc50b4adfc Mon Sep 17 00:00:00 2001 From: Isaac Abraham Date: Thu, 24 Oct 2024 19:46:49 +0100 Subject: [PATCH 1/6] Cleaned up some code, better support for Entra Auth. --- src/Farmer/Arm/Sql.fs | 140 ++++++------------ src/Farmer/Builders/Builders.Sql.fs | 163 ++++++++++++--------- src/Farmer/Common.fs | 6 + src/Farmer/Types.fs | 7 +- src/Tests/Sql.fs | 127 ++++++++-------- src/Tests/test-data/lots-of-resources.json | 4 +- 6 files changed, 215 insertions(+), 232 deletions(-) diff --git a/src/Farmer/Arm/Sql.fs b/src/Farmer/Arm/Sql.fs index 2bd68f42f..b71612efd 100644 --- a/src/Farmer/Arm/Sql.fs +++ b/src/Farmer/Arm/Sql.fs @@ -2,6 +2,7 @@ module Farmer.Arm.Sql open Farmer +open Farmer.Arm open Farmer.Sql open System.Net @@ -23,109 +24,36 @@ type DbKind = | Standalone of DbPurchaseModel | Pool of ResourceName -type ActiveDirectoryPrincipalType = - | User - | Group - -type ActiveDirectoryAdminSettings = { - /// Ideally same as AD name +type EntraAuthentication = { Login: string - /// Active Directory object id of user or group - Sid: string - PrincipalType: ActiveDirectoryPrincipalType - AdOnlyAuth: bool + Sid: ObjectId + PrincipalType: PrincipalType } -let (|MixedModeAuth|AdOnlyAuth|SqlOnlyAuth|) activeDirAdmin = - match activeDirAdmin with - | Some x when x.AdOnlyAuth -> AdOnlyAuth(x) - | Some x when not x.AdOnlyAuth -> MixedModeAuth(x) - | _ -> SqlOnlyAuth - -type SqlServerADAdminJsonProperties = { - administratorType: string - principalType: string - login: string - sid: string - azureADOnlyAuthentication: bool +type SqlAuthentication = { + Username: string + Password: SecureParameter } -type SqlServerJsonProperties = { - version: string - minimalTlsVersion: string - administratorLogin: string - administratorLoginPassword: string - administrators: SqlServerADAdminJsonProperties -} +type SqlCredentials = + | SqlOnly of SqlAuthentication + | EntraOnly of EntraAuthentication + | SqlAndEntra of SqlAuthentication * EntraAuthentication type Server = { ServerName: SqlAccountName Location: Location - Credentials: {| - Username: string - Password: SecureParameter - |} - ActiveDirectoryAdmin: ActiveDirectoryAdminSettings option + Credentials: SqlCredentials MinTlsVersion: TlsVersion option Tags: Map } with - member private this.BuildSqlSeverPropertiesBase() : SqlServerJsonProperties = { - version = "12.0" - minimalTlsVersion = - match this.MinTlsVersion with - | Some Tls10 -> "1.0" - | Some Tls11 -> "1.1" - | Some Tls12 -> "1.2" - | None -> null - administratorLogin = null - administratorLoginPassword = null - administrators = Unchecked.defaultof - } - - member private this.BuildSqlServerADOnlyAdmin(x: ActiveDirectoryAdminSettings) : SqlServerADAdminJsonProperties = { - administratorType = "ActiveDirectory" - principalType = - match x.PrincipalType with - | Group -> "Group" - | User -> "User" - login = x.Login - sid = x.Sid - azureADOnlyAuthentication = true - } - - member private this.BuildSqlServerPropertiesWithMixedModeAdministrator - (x: ActiveDirectoryAdminSettings) - : SqlServerJsonProperties = - { - this.BuildSqlSeverPropertiesBase() with - administratorLogin = this.Credentials.Username - administratorLoginPassword = this.Credentials.Password.ArmExpression.Eval() - administrators = { - this.BuildSqlServerADOnlyAdmin(x) with - azureADOnlyAuthentication = false - } - } - - member private this.BuildSqlServerPropertiesWithADOnlyAdministrator - (x: ActiveDirectoryAdminSettings) - : SqlServerJsonProperties = - { - this.BuildSqlSeverPropertiesBase() with - administrators = this.BuildSqlServerADOnlyAdmin(x) - } - - member private this.BuildSqlServerPropertiesWithSqlOnlyAdministrator() : SqlServerJsonProperties = { - this.BuildSqlSeverPropertiesBase() with - administratorLogin = this.Credentials.Username - administratorLoginPassword = this.Credentials.Password.ArmExpression.Eval() - } - interface IParameters with member this.SecureParameters = - match this.ActiveDirectoryAdmin with - | Some(x) when x.AdOnlyAuth -> [] - | _ -> [ this.Credentials.Password ] + match this.Credentials with + | EntraOnly _ -> [] + | SqlOnly creds + | SqlAndEntra(creds, _) -> [ creds.Password ] interface IArmResource with member this.ResourceId = servers.resourceId this.ServerName.ResourceName @@ -137,10 +65,38 @@ type Server = { tags = (this.Tags |> Map.add "displayName" this.ServerName.ResourceName.Value) ) with properties = - match this.ActiveDirectoryAdmin with - | MixedModeAuth x -> this.BuildSqlServerPropertiesWithMixedModeAdministrator(x) - | AdOnlyAuth x -> this.BuildSqlServerPropertiesWithADOnlyAdministrator(x) - | SqlOnlyAuth -> this.BuildSqlServerPropertiesWithSqlOnlyAdministrator() + Map [ + "version", box "12.0" + match this.MinTlsVersion with + | Some tlsVersion -> "minimalTlsVersion", tlsVersion.ArmValue + | None -> () + yield! + match this.Credentials with + | EntraOnly _ -> [] + | SqlOnly sqlCredentials + | SqlAndEntra(sqlCredentials, _) -> [ + "administratorLogin", box sqlCredentials.Username + "administratorLoginPassword", sqlCredentials.Password.ArmExpression.Eval() + ] + yield! + match this.Credentials with + | SqlOnly _ -> [] + | SqlAndEntra(_, entraCredentials) + | EntraOnly entraCredentials -> [ + "administrators", + box {| + administratorType = "ActiveDirectory" + principalType = entraCredentials.PrincipalType.ArmValue + login = entraCredentials.Login + sid = entraCredentials.Sid.Value + azureADOnlyAuthentication = + match this.Credentials with + | EntraOnly _ -> true + | SqlAndEntra _ + | SqlOnly _ -> false + |} + ] + ] |} module Servers = diff --git a/src/Farmer/Builders/Builders.Sql.fs b/src/Farmer/Builders/Builders.Sql.fs index 2c307ed93..61e0d3d30 100644 --- a/src/Farmer/Builders/Builders.Sql.fs +++ b/src/Farmer/Builders/Builders.Sql.fs @@ -2,11 +2,11 @@ module Farmer.Builders.SqlAzure open Farmer +open Farmer.Arm +open Farmer.Arm.Sql.Servers +open Farmer.Arm.Sql.Servers.Databases open Farmer.Sql -open Farmer.Arm.Sql open System.Net -open Servers -open Databases type SqlAzureDbConfig = { Name: ResourceName @@ -18,11 +18,7 @@ type SqlAzureDbConfig = { type SqlAzureConfig = { Name: SqlAccountName - AdministratorCredentials: {| - UserName: string - Password: SecureParameter - |} - ActiveDirectoryAdmin: ActiveDirectoryAdminSettings option + Credentials: SqlCredentials option MinTlsVersion: TlsVersion option FirewallRules: {| @@ -43,16 +39,23 @@ type SqlAzureConfig = { /// Gets a basic .NET connection string using the administrator username / password. member this.ConnectionString(database: SqlAzureDbConfig) = - let expr = - ArmExpression.concat [ - ArmExpression.literal - $"Server=tcp:{this.Name.ResourceName.Value}.database.windows.net,1433;Initial Catalog={database.Name.Value};Persist Security Info=False;User ID={this.AdministratorCredentials.UserName};Password=" - this.AdministratorCredentials.Password.ArmExpression - ArmExpression.literal - ";MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" - ] - - expr.WithOwner(databases.resourceId (this.Name.ResourceName, database.Name)) + match this.Credentials with + | Some(SqlOnly sqlCredentials) + | Some(SqlAndEntra(sqlCredentials, _)) -> + let expr = + ArmExpression.concat [ + ArmExpression.literal + $"Server=tcp:{this.Name.ResourceName.Value}.database.windows.net,1433;Initial Catalog={database.Name.Value};Persist Security Info=False;User ID={sqlCredentials.Username};Password=" + sqlCredentials.Password.ArmExpression + ArmExpression.literal + ";MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" + ] + + expr.WithOwner(databases.resourceId (this.Name.ResourceName, database.Name)) + | Some(EntraOnly _) -> + raiseFarmer + "Cannot create a connection string for a database that is using Azure Active Directory authentication." + | None -> raiseFarmer "Cannot create a connection string for a database that does not have any credentials set." member this.ConnectionString databaseName = this.Databases @@ -78,13 +81,9 @@ type SqlAzureConfig = { ServerName = this.Name Location = location Credentials = - match this.ActiveDirectoryAdmin with - | AdOnlyAuth _ -> Unchecked.defaultof<_> - | _ -> {| - Username = this.AdministratorCredentials.UserName - Password = this.AdministratorCredentials.Password - |} - ActiveDirectoryAdmin = this.ActiveDirectoryAdmin + match this.Credentials with + | Some credentials -> credentials + | None -> raiseFarmer "No credentials have been set for the SQL Server instance." MinTlsVersion = this.MinTlsVersion Tags = this.Tags } @@ -145,11 +144,10 @@ type SqlAzureConfig = { { ServerName = replicaServerName Location = replica.Location - Credentials = {| - Username = this.AdministratorCredentials.UserName - Password = this.AdministratorCredentials.Password - |} - ActiveDirectoryAdmin = this.ActiveDirectoryAdmin + Credentials = + match this.Credentials with + | Some credentials -> credentials + | None -> raiseFarmer "No credentials have been set for the SQL Server instance." MinTlsVersion = this.MinTlsVersion Tags = this.Tags } @@ -172,13 +170,11 @@ type SqlAzureConfig = { apiVersion = "2021-02-01-preview" location = replica.Location.ArmValue dependsOn = [ - Farmer.ResourceId - .create(Farmer.Arm.Sql.servers, replicaServerName.ResourceName) - .Eval() + Farmer.ResourceId.create(servers, replicaServerName.ResourceName).Eval() primaryDatabaseFullId ] name = $"{replicaServerName.ResourceName.Value}/{database.Name.Value + replica.NameSuffix}" - ``type`` = Farmer.Arm.Sql.databases.Type + ``type`` = databases.Type sku = {| name = fst geoSku tier = snd geoSku @@ -271,11 +267,7 @@ type SqlServerBuilder() = member _.Yield _ = { Name = SqlAccountName.Empty - AdministratorCredentials = {| - UserName = "" - Password = SecureParameter "" - |} - ActiveDirectoryAdmin = None + Credentials = None ElasticPoolSettings = {| Name = None Sku = PoolSku.Basic50 @@ -293,26 +285,10 @@ type SqlServerBuilder() = if state.Name.ResourceName = ResourceName.Empty then raiseFarmer "No SQL Server account name has been set." - let getStateWithAdminCredentials () = - if System.String.IsNullOrWhiteSpace state.AdministratorCredentials.UserName then - raiseFarmer - $"You must specify the admin_username for SQL Server instance {state.Name.ResourceName.Value}" - - { - state with - AdministratorCredentials = {| - state.AdministratorCredentials with - Password = SecureParameter state.PasswordParameter - |} - } + if state.Credentials.IsNone then + raiseFarmer "No credentials have been set for the SQL Server instance." - match state.ActiveDirectoryAdmin with - | AdOnlyAuth _ -> { - state with - AdministratorCredentials = Unchecked.defaultof<_> - } - | MixedModeAuth _ -> getStateWithAdminCredentials () - | SqlOnlyAuth -> getStateWithAdminCredentials () + state /// Sets the name of the SQL server. [] @@ -404,15 +380,24 @@ type SqlServerBuilder() = member this.UseAzureFirewall(state: SqlAzureConfig) = this.AddFirewallRule(state, "allow-azure-services", "0.0.0.0", "0.0.0.0") - /// Sets the admin username of the server (note: the password is supplied as a securestring parameter to the generated ARM template). + /// Sets the admin username of the server (note: the password is supplied as a securestring parameter to the generated ARM template) using SQL authentication. + /// If you have already set the Entra ID credentials, they will be preserved as a hybrid setup. [] - member _.AdminUsername(state: SqlAzureConfig, username) = { - state with - AdministratorCredentials = {| - state.AdministratorCredentials with - UserName = username - |} - } + member _.AdminUsername(state: SqlAzureConfig, username) = + let sqlCredentials = { + Username = username + Password = SecureParameter state.PasswordParameter + } + + { + state with + Credentials = + match state.Credentials with + | None + | Some(SqlOnly _) -> Some(SqlOnly sqlCredentials) + | Some(SqlAndEntra(_, entraCredentials)) + | Some(EntraOnly entraCredentials) -> Some(SqlAndEntra(sqlCredentials, entraCredentials)) + } /// Set minimum TLS version [] @@ -423,16 +408,54 @@ type SqlServerBuilder() = /// Geo-replicate all the databases in this server to another location, having NameSuffix after original server and database names. [] - member _.SetGeoReplication(state: SqlAzureConfig, replicaSettings) = { + member _.SetGeoReplication(state, replicaSettings) = { state with GeoReplicaServer = Some replicaSettings } - /// Sets the active directory admin and optionally turns on AD only auth. + /// Activates Entra ID authentication using the supplied credentials for the administrator account. Farmer determines the Object ID / SID using `ad user list`. + /// If you have already set the SQL admin credentials, they will be preserved as a hybrid setup. + [] + member this.SetEntraIdAuthenticationUser(state: SqlAzureConfig, login) = + match AccessPolicy.findUsers [ login ] |> Array.toList with + | [] -> raiseFarmer $"Login {login} not found in the directory." + | user :: _ -> + this.SetEntraIdAuthentication( + state, + SqlServerBuilder.CreateCredentials(login, user.Id.Value, PrincipalType.User) + ) + + /// Activates Entra ID authentication using the supplied credentials for the administrator account. Farmer determines the Object ID / SID using `ad user list`. + /// If you have already set the SQL admin credentials, they will be preserved as a hybrid setup. + [] + member this.SetEntraIdAuthenticationGroup(state: SqlAzureConfig, login) = + match AccessPolicy.findGroups [ login ] |> Array.toList with + | [] -> raiseFarmer $"Login {login} not found in the directory." + | user :: _ -> + this.SetEntraIdAuthentication( + state, + SqlServerBuilder.CreateCredentials(login, user.Id.Value, PrincipalType.Group) + ) + + /// Activates Entra ID authentication using the supplied credentials for the administrator account, with the object id / sid and principal type manually supplied by you. + /// If you have already set the SQL admin credentials, they will be preserved as a hybrid setup. + static member private CreateCredentials(login, objectId, principalType) = { + Login = login + Sid = ObjectId objectId + PrincipalType = principalType + } + + /// Activates Entra ID authentication using the supplied credentials for the administrator account. + /// If you have already set the SQL admin credentials, they will be preserved as a hybrid setup. [] - member _.SetActiveDirectoryAdmin(state: SqlAzureConfig, activeDirectoryAdminSettings) = { + member _.SetEntraIdAuthentication(state: SqlAzureConfig, entraCredentials) = { state with - ActiveDirectoryAdmin = activeDirectoryAdminSettings + Credentials = + match state.Credentials with + | None + | Some(EntraOnly _) -> Some(EntraOnly entraCredentials) + | Some(SqlAndEntra(sqlCredentials, _)) + | Some(SqlOnly sqlCredentials) -> Some(SqlAndEntra(sqlCredentials, entraCredentials)) } interface ITaggable with diff --git a/src/Farmer/Common.fs b/src/Farmer/Common.fs index 531330854..3b2bf918b 100644 --- a/src/Farmer/Common.fs +++ b/src/Farmer/Common.fs @@ -132,6 +132,12 @@ type TlsVersion = | Tls11 | Tls12 + member this.ArmValue = + match this with + | Tls10 -> "1.0" + | Tls11 -> "1.1" + | Tls12 -> "1.2" + /// Represents an environment variable that can be set, typically on Docker container services. type EnvVar = /// Use for non-secret environment variables. These will be stored in cleartext in the ARM template. diff --git a/src/Farmer/Types.fs b/src/Farmer/Types.fs index bfc4dd336..7d0c451e7 100644 --- a/src/Farmer/Types.fs +++ b/src/Farmer/Types.fs @@ -438,7 +438,12 @@ type PrincipalId = match this with | PrincipalId e -> e -type ObjectId = ObjectId of Guid +type ObjectId = + | ObjectId of Guid + + member this.Value = + match this with + | ObjectId id -> id /// Represents a secret to be captured either via an ARM expression or a secure parameter. type SecretValue = diff --git a/src/Tests/Sql.fs b/src/Tests/Sql.fs index cef6f58fb..249222be0 100644 --- a/src/Tests/Sql.fs +++ b/src/Tests/Sql.fs @@ -304,77 +304,70 @@ let tests = "Incorrect autoPauseDelay" } - test "Must set a SQL Server account name" { - Expect.throws - (fun () -> sqlServer { admin_username "test" } |> ignore) - "Must set a name on a sql server account" + test "Must set either SQL Server or AD authentication" { + Expect.throws (fun () -> sqlServer { name "test" } |> ignore) "Should throw if no auth set" } - for (adOnlyAuth, principalType, adminUserName) in - [ - true, ActiveDirectoryPrincipalType.User, null - false, ActiveDirectoryPrincipalType.User, "sqladmin" - true, ActiveDirectoryPrincipalType.Group, null - false, ActiveDirectoryPrincipalType.Group, "sqladmin" - ] do - test $"AD Auth - ADOnly: {adOnlyAuth}, Principal: {principalType}, Username: {adminUserName}" { - let sql = - let activeDirectoryUserAdmin: ActiveDirectoryAdminSettings = { - Login = "adadmin" - Sid = "F9D49C34-01BA-4897-B7E2-3694BF3DE2CF" - PrincipalType = principalType - AdOnlyAuth = adOnlyAuth - } - - sqlServer { - name "adtestserver" - active_directory_admin (Some(activeDirectoryUserAdmin)) - admin_username adminUserName - } + test "Can use Entra ID auth" { + let server = sqlServer { + name "my-sql-server" - let template = arm { - location Location.AustraliaEast - add_resources [ sql ] + active_directory_admin { + Login = "entra-user" + Sid = ObjectId(Guid.Parse "f9d49c34-01ba-4897-b7e2-3694bf3de2cf") + PrincipalType = PrincipalType.User } + } + + let template = arm { add_resource server } + let jobj = template.Template |> Writer.toJson |> Newtonsoft.Json.Linq.JObject.Parse + + let selectProp prop = + jobj + .SelectToken($"resources[?(@.name=='my-sql-server')].properties.administrators.{prop}") + .ToString() + + Expect.equal (selectProp "administratorType") "ActiveDirectory" "Incorrect administrator type" + Expect.equal (selectProp "login") "entra-user" "Incorrect AD login name" + Expect.equal (selectProp "principalType") $"User" "Incorrect principal type" + Expect.equal (selectProp "sid") "f9d49c34-01ba-4897-b7e2-3694bf3de2cf" "Incorrect SID" + Expect.equal (selectProp "azureADOnlyAuthentication") "True" $"Should only have AD auth." + } + + test "No Entra ARM when just using SQL" { + let theServer = sqlServer { + name "my-sql-server" + admin_username "test" + } + + let template = arm { add_resource theServer } + let jobj = template.Template |> Writer.toJson |> Newtonsoft.Json.Linq.JObject.Parse + + let administratorsJson = + jobj.SelectToken "resources[?(@.name=='my-sql-server')].properties.administrators" + + Expect.isNull administratorsJson "Should not have an AD admin" + } + + test "Can set both SQL and Entra ID auth" { + let theServer = sqlServer { + name "my-sql-server" + admin_username "test" - let jsn = template.Template |> Writer.toJson - let jobj = jsn |> Newtonsoft.Json.Linq.JObject.Parse - - Expect.equal - (jobj - .SelectToken("resources[?(@.name=='adtestserver')].properties.administrators.administratorType") - .ToString()) - "ActiveDirectory" - "Incorrect administrator type" - - Expect.equal - (jobj - .SelectToken( - "resources[?(@.name=='adtestserver')].properties.administrators.azureADOnlyAuthentication" - ) - .ToString()) - (adOnlyAuth.ToString()) - $"AD only auth should be {adOnlyAuth.ToString()}" - - Expect.equal - (jobj - .SelectToken("resources[?(@.name=='adtestserver')].properties.administrators.login") - .ToString()) - "adadmin" - "Incorrect AD login name" - - Expect.equal - (jobj - .SelectToken("resources[?(@.name=='adtestserver')].properties.administrators.principalType") - .ToString()) - $"{principalType.ToString()}" - "Incorrect principal type" - - Expect.equal - (jobj - .SelectToken("resources[?(@.name=='adtestserver')].properties.administrators.sid") - .ToString()) - "F9D49C34-01BA-4897-B7E2-3694BF3DE2CF" - "Incorrect SID" + active_directory_admin { + Login = "" + Sid = ObjectId Guid.Empty + PrincipalType = PrincipalType.User + } } + + let template = arm { add_resource theServer } + let jobj = template.Template |> Writer.toJson |> Newtonsoft.Json.Linq.JObject.Parse + + let azureAdOnlyAuth = + jobj.SelectToken + "resources[?(@.name=='my-sql-server')].properties.administrators.azureADOnlyAuthentication" + + Expect.equal (azureAdOnlyAuth.ToString()) "False" "Should not only have AD auth." + } ] \ No newline at end of file diff --git a/src/Tests/test-data/lots-of-resources.json b/src/Tests/test-data/lots-of-resources.json index 5ea46e34e..e456d6b6e 100644 --- a/src/Tests/test-data/lots-of-resources.json +++ b/src/Tests/test-data/lots-of-resources.json @@ -16,9 +16,9 @@ "location": "northeurope", "name": "farmersql1979", "properties": { - "version": "12.0", "administratorLogin": "farmersqladmin", - "administratorLoginPassword": "[parameters('password-for-farmersql1979')]" + "administratorLoginPassword": "[parameters('password-for-farmersql1979')]", + "version": "12.0" }, "tags": { "displayName": "farmersql1979" From 9d3b54bbf4d198f902d382c59d1654bedd55bd6d Mon Sep 17 00:00:00 2001 From: Isaac Abraham Date: Thu, 24 Oct 2024 19:57:51 +0100 Subject: [PATCH 2/6] Tidy up SQL Builder API --- RELEASE_NOTES.md | 1 + src/Farmer/Builders/Builders.Sql.fs | 62 +++++++++++++---------------- src/Tests/Sql.fs | 14 +------ 3 files changed, 30 insertions(+), 47 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8765a9dba..37de46cd1 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,7 @@ Release Notes ============= ## vNext +* SQL Azure: Clean up Entra ID authentication support. * Az: Update `ad` commands to work with latest (breaking) structure. ## 1.9.2 diff --git a/src/Farmer/Builders/Builders.Sql.fs b/src/Farmer/Builders/Builders.Sql.fs index 61e0d3d30..bf6eccfea 100644 --- a/src/Farmer/Builders/Builders.Sql.fs +++ b/src/Farmer/Builders/Builders.Sql.fs @@ -6,6 +6,7 @@ open Farmer.Arm open Farmer.Arm.Sql.Servers open Farmer.Arm.Sql.Servers.Databases open Farmer.Sql +open System open System.Net type SqlAzureDbConfig = { @@ -413,50 +414,41 @@ type SqlServerBuilder() = GeoReplicaServer = Some replicaSettings } - /// Activates Entra ID authentication using the supplied credentials for the administrator account. Farmer determines the Object ID / SID using `ad user list`. - /// If you have already set the SQL admin credentials, they will be preserved as a hybrid setup. - [] + /// Activates Entra ID authentication using the supplied Entra username (i.e. email address) for the administrator account. Farmer determines the Object ID / SID using `ad user list`. + /// If you have set the SQL admin credentials, they will be preserved as a hybrid setup. + [] member this.SetEntraIdAuthenticationUser(state: SqlAzureConfig, login) = match AccessPolicy.findUsers [ login ] |> Array.toList with | [] -> raiseFarmer $"Login {login} not found in the directory." - | user :: _ -> - this.SetEntraIdAuthentication( - state, - SqlServerBuilder.CreateCredentials(login, user.Id.Value, PrincipalType.User) - ) - - /// Activates Entra ID authentication using the supplied credentials for the administrator account. Farmer determines the Object ID / SID using `ad user list`. - /// If you have already set the SQL admin credentials, they will be preserved as a hybrid setup. + | user :: _ -> this.SetEntraIdAuthentication(state, login, user.Id.Value.ToString(), PrincipalType.User) + + /// Activates Entra ID authentication using the supplied Entra groupname for the administrator account. Farmer determines the Object ID / SID using `ad user list`. + /// If you have set the SQL admin credentials, they will be preserved as a hybrid setup. [] member this.SetEntraIdAuthenticationGroup(state: SqlAzureConfig, login) = match AccessPolicy.findGroups [ login ] |> Array.toList with | [] -> raiseFarmer $"Login {login} not found in the directory." - | user :: _ -> - this.SetEntraIdAuthentication( - state, - SqlServerBuilder.CreateCredentials(login, user.Id.Value, PrincipalType.Group) - ) - - /// Activates Entra ID authentication using the supplied credentials for the administrator account, with the object id / sid and principal type manually supplied by you. - /// If you have already set the SQL admin credentials, they will be preserved as a hybrid setup. - static member private CreateCredentials(login, objectId, principalType) = { - Login = login - Sid = ObjectId objectId - PrincipalType = principalType - } + | group :: _ -> this.SetEntraIdAuthentication(state, login, group.Id.Value.ToString(), PrincipalType.Group) /// Activates Entra ID authentication using the supplied credentials for the administrator account. - /// If you have already set the SQL admin credentials, they will be preserved as a hybrid setup. - [] - member _.SetEntraIdAuthentication(state: SqlAzureConfig, entraCredentials) = { - state with - Credentials = - match state.Credentials with - | None - | Some(EntraOnly _) -> Some(EntraOnly entraCredentials) - | Some(SqlAndEntra(sqlCredentials, _)) - | Some(SqlOnly sqlCredentials) -> Some(SqlAndEntra(sqlCredentials, entraCredentials)) - } + /// If you have set the SQL admin credentials, they will be preserved as a hybrid setup. + [] + member _.SetEntraIdAuthentication(state: SqlAzureConfig, login, objectId: string, principalType) = + let entraCredentials = { + Login = login + Sid = objectId |> Guid.Parse |> ObjectId + PrincipalType = principalType + } + + { + state with + Credentials = + match state.Credentials with + | None + | Some(EntraOnly _) -> Some(EntraOnly entraCredentials) + | Some(SqlAndEntra(sqlCredentials, _)) + | Some(SqlOnly sqlCredentials) -> Some(SqlAndEntra(sqlCredentials, entraCredentials)) + } interface ITaggable with member _.Add state tags = { diff --git a/src/Tests/Sql.fs b/src/Tests/Sql.fs index 249222be0..73448f91c 100644 --- a/src/Tests/Sql.fs +++ b/src/Tests/Sql.fs @@ -311,12 +311,7 @@ let tests = test "Can use Entra ID auth" { let server = sqlServer { name "my-sql-server" - - active_directory_admin { - Login = "entra-user" - Sid = ObjectId(Guid.Parse "f9d49c34-01ba-4897-b7e2-3694bf3de2cf") - PrincipalType = PrincipalType.User - } + entra_id_admin "entra-user" "f9d49c34-01ba-4897-b7e2-3694bf3de2cf" PrincipalType.User } let template = arm { add_resource server } @@ -353,12 +348,7 @@ let tests = let theServer = sqlServer { name "my-sql-server" admin_username "test" - - active_directory_admin { - Login = "" - Sid = ObjectId Guid.Empty - PrincipalType = PrincipalType.User - } + entra_id_admin "" (string Guid.Empty) PrincipalType.User } let template = arm { add_resource theServer } From ba7160e2e5204f600f6c715e748362fbfbfb9c3c Mon Sep 17 00:00:00 2001 From: Isaac Abraham Date: Fri, 25 Oct 2024 01:22:14 +0100 Subject: [PATCH 3/6] Small renames and documentation. --- docs/content/api-overview/resources/sql.md | 38 ++++++++++------------ src/Farmer/Builders/Builders.Sql.fs | 10 +++--- src/Tests/KeyVault.fs | 1 + 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/docs/content/api-overview/resources/sql.md b/docs/content/api-overview/resources/sql.md index 87e0eeb40..02590231c 100644 --- a/docs/content/api-overview/resources/sql.md +++ b/docs/content/api-overview/resources/sql.md @@ -11,28 +11,26 @@ The SQL Azure module contains two builders - `sqlServer`, used to create SQL Azu * SQL Azure server (`Microsoft.Sql/servers`) #### SQL Server Builder Keywords -| Keyword | Purpose | +| Keyword | Purpose | |-|---------------------------------------------------------------------------------------------------------------------------------| -| name | Sets the name of the SQL server. | -| active_directory_admin | Sets Active Directory admin of the server | -| add_firewall_rule | Adds a custom firewall rule given a name, start and end IP address range. | -| add_firewall_rules | As add_firewall_rule but a list of rules | -| enable_azure_firewall | Adds a firewall rule that enables access to other Azure services. | -| admin_username | Sets the admin username of the server. | -| elastic_pool_name | Sets the name of the elastic pool, if required. If not set, Farmer will generate a name for you. | -| elastic_pool_sku | Sets the sku of the elastic pool, if required. If not set, Farmer will default to Basic 50. | -| elastic_pool_database_min_max | Sets the optional minimum and maximum DTUs for the elastic pool for each database. | -| elastic_pool_capacity | Sets the optional disk size in MB for the elastic pool for each database. | -| min_tls_version | Sets the minium TLS version for the SQL server | +| name | Sets the name of the SQL server. | +| active_directory_admin | Sets Active Directory admin of the server | +| add_firewall_rule | Adds a custom firewall rule given a name, start and end IP address range. | +| add_firewall_rules | As add_firewall_rule but a list of rules | +| enable_azure_firewall | Adds a firewall rule that enables access to other Azure services. | +| admin_username | Sets the admin username of the server. The password is supplied as a secret parameter at runtime. | +| entra_id_admin | Activates Entra ID authentication using the supplied login named, associated objectId and principal type of the administrator account. | +| entra_id_admin_user | Activates Entra ID authentication for the User Principal Type using the supplied user's login name. The ObjectId will be retrieved automatically from Azure at runtime. | +| entra_id_admin_group | Activates Entra ID authentication for the Group Principal Type using the supplied group's login name. The ObjectId will be retrieved automatically from Azure at runtime. | +| elastic_pool_name | Sets the name of the elastic pool, if required. If not set, Farmer will generate a name for you. | +| elastic_pool_sku | Sets the sku of the elastic pool, if required. If not set, Farmer will default to Basic 50. | +| elastic_pool_database_min_max | Sets the optional minimum and maximum DTUs for the elastic pool for each database. | +| elastic_pool_capacity | Sets the optional disk size in MB for the elastic pool for each database. | +| min_tls_version | Sets the minium TLS version for the SQL server | | geo_replicate | Geo-replicate all the databases in this server to another location, having NameSuffix after original server and database names. | -#### ActiveDirectoryAdminSettings Members -| Member | Purpose | -|-|----------------------------------------------------------------------------| -| Login | Display name of AD admin | -| Sid | AD object id of AD admin (user or group) | -| PrincipalType | ActiveDirectoryPrincipalType User or Group | -| AdOnlyAuth | Disables SQL authentication. False value required admin_username to be set | +> You can set at least one of SQL user / pass (using `admin_username`) or Entra ID login (using one of the `entra_id_admin` variants). +> Setting both will leave both activated; setting only Entra ID will automatically explicitly deactivate user / pass authentication. #### SQL Server Configuration Members | Member | Purpose | @@ -111,7 +109,7 @@ let activeDirectoryAdmin: ActiveDirectoryAdminSettings = AdOnlyAuth = false // when false, admin_username is required // when true admin_username is ignored } - + let myDatabases = sqlServer { name "my_server" active_directory_admin (Some(activeDirectoryAdmin)) diff --git a/src/Farmer/Builders/Builders.Sql.fs b/src/Farmer/Builders/Builders.Sql.fs index bf6eccfea..062452ee7 100644 --- a/src/Farmer/Builders/Builders.Sql.fs +++ b/src/Farmer/Builders/Builders.Sql.fs @@ -420,7 +420,7 @@ type SqlServerBuilder() = member this.SetEntraIdAuthenticationUser(state: SqlAzureConfig, login) = match AccessPolicy.findUsers [ login ] |> Array.toList with | [] -> raiseFarmer $"Login {login} not found in the directory." - | user :: _ -> this.SetEntraIdAuthentication(state, login, user.Id.Value.ToString(), PrincipalType.User) + | user :: _ -> this.SetEntraIdAuthentication(state, login, user.Id, PrincipalType.User) /// Activates Entra ID authentication using the supplied Entra groupname for the administrator account. Farmer determines the Object ID / SID using `ad user list`. /// If you have set the SQL admin credentials, they will be preserved as a hybrid setup. @@ -428,15 +428,15 @@ type SqlServerBuilder() = member this.SetEntraIdAuthenticationGroup(state: SqlAzureConfig, login) = match AccessPolicy.findGroups [ login ] |> Array.toList with | [] -> raiseFarmer $"Login {login} not found in the directory." - | group :: _ -> this.SetEntraIdAuthentication(state, login, group.Id.Value.ToString(), PrincipalType.Group) + | group :: _ -> this.SetEntraIdAuthentication(state, login, group.Id, PrincipalType.Group) - /// Activates Entra ID authentication using the supplied credentials for the administrator account. + /// Activates Entra ID authentication using the supplied login named, associated objectId and principal type of the administrator account. /// If you have set the SQL admin credentials, they will be preserved as a hybrid setup. [] - member _.SetEntraIdAuthentication(state: SqlAzureConfig, login, objectId: string, principalType) = + member _.SetEntraIdAuthentication(state: SqlAzureConfig, login, objectId: ObjectId, principalType) = let entraCredentials = { Login = login - Sid = objectId |> Guid.Parse |> ObjectId + Sid = objectId PrincipalType = principalType } diff --git a/src/Tests/KeyVault.fs b/src/Tests/KeyVault.fs index 7bde8adaa..865e3da6b 100644 --- a/src/Tests/KeyVault.fs +++ b/src/Tests/KeyVault.fs @@ -48,6 +48,7 @@ let tests = let p = AccessPolicy.create (ObjectId Guid.Empty) Expect.equal (set [ Secret.Get; Secret.List ]) p.Permissions.Secrets "Incorrect default secrets" } + test "Creates key vault secrets correctly" { let parameterSecret = SecretConfig.create "test" Expect.equal parameterSecret.SecretName "test" "Invalid name of simple secret" From 13f8b512f3b38eb1718accead0695a17bdf54870 Mon Sep 17 00:00:00 2001 From: Isaac Abraham Date: Fri, 25 Oct 2024 02:10:20 +0100 Subject: [PATCH 4/6] Use STJ for json navigation --- src/Tests/Sql.fs | 49 +++++++++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/Tests/Sql.fs b/src/Tests/Sql.fs index 73448f91c..11b216cde 100644 --- a/src/Tests/Sql.fs +++ b/src/Tests/Sql.fs @@ -8,8 +8,8 @@ open Farmer.Builders open Microsoft.Azure.Management.Sql open System open Microsoft.Rest - -let sql = sqlServer +open System.Text.Json +open System.Text.Json.Nodes let client = new SqlManagementClient(Uri "http://management.azure.com", TokenCredentials "NotNullOrWhiteSpace") @@ -311,22 +311,23 @@ let tests = test "Can use Entra ID auth" { let server = sqlServer { name "my-sql-server" - entra_id_admin "entra-user" "f9d49c34-01ba-4897-b7e2-3694bf3de2cf" PrincipalType.User + + entra_id_admin + "entra-user" + (ObjectId(Guid.Parse "f9d49c34-01ba-4897-b7e2-3694bf3de2cf")) + PrincipalType.User } let template = arm { add_resource server } - let jobj = template.Template |> Writer.toJson |> Newtonsoft.Json.Linq.JObject.Parse - - let selectProp prop = - jobj - .SelectToken($"resources[?(@.name=='my-sql-server')].properties.administrators.{prop}") - .ToString() - - Expect.equal (selectProp "administratorType") "ActiveDirectory" "Incorrect administrator type" - Expect.equal (selectProp "login") "entra-user" "Incorrect AD login name" - Expect.equal (selectProp "principalType") $"User" "Incorrect principal type" - Expect.equal (selectProp "sid") "f9d49c34-01ba-4897-b7e2-3694bf3de2cf" "Incorrect SID" - Expect.equal (selectProp "azureADOnlyAuthentication") "True" $"Should only have AD auth." + + let json = template.Template |> Writer.toJson |> JsonObject.Parse + let adminToken = json.["resources"].[0].["properties"].["administrators"] + + Expect.equal (adminToken["administratorType"].GetValue()) "ActiveDirectory" "Incorrect administrator type" + Expect.equal (adminToken["login"].GetValue()) "entra-user" "Incorrect AD login name" + Expect.equal (adminToken["principalType"].GetValue()) "User" "Incorrect principal type" + Expect.equal (adminToken["sid"].GetValue()) "f9d49c34-01ba-4897-b7e2-3694bf3de2cf" "Incorrect SID" + Expect.isTrue (adminToken["azureADOnlyAuthentication"].GetValue()) "Should only have AD auth." } test "No Entra ARM when just using SQL" { @@ -336,28 +337,24 @@ let tests = } let template = arm { add_resource theServer } - let jobj = template.Template |> Writer.toJson |> Newtonsoft.Json.Linq.JObject.Parse - - let administratorsJson = - jobj.SelectToken "resources[?(@.name=='my-sql-server')].properties.administrators" - - Expect.isNull administratorsJson "Should not have an AD admin" + let json = template.Template |> Writer.toJson |> JsonObject.Parse + let properties = json.["resources"].[0].["properties"].AsObject() + Expect.isFalse (properties.ContainsKey "administrators") "Should not have an AD admin" } test "Can set both SQL and Entra ID auth" { let theServer = sqlServer { name "my-sql-server" admin_username "test" - entra_id_admin "" (string Guid.Empty) PrincipalType.User + entra_id_admin "" (ObjectId Guid.Empty) PrincipalType.User } let template = arm { add_resource theServer } - let jobj = template.Template |> Writer.toJson |> Newtonsoft.Json.Linq.JObject.Parse + let json = template.Template |> Writer.toJson |> JsonObject.Parse let azureAdOnlyAuth = - jobj.SelectToken - "resources[?(@.name=='my-sql-server')].properties.administrators.azureADOnlyAuthentication" + json.["resources"].[0].["properties"].["administrators"].["azureADOnlyAuthentication"] - Expect.equal (azureAdOnlyAuth.ToString()) "False" "Should not only have AD auth." + Expect.isFalse (azureAdOnlyAuth.GetValue()) "Should not only have AD auth." } ] \ No newline at end of file From 22c8396332deb1b9ea05ab980572a3a050be53b9 Mon Sep 17 00:00:00 2001 From: Isaac Abraham Date: Sat, 26 Oct 2024 19:19:15 +0100 Subject: [PATCH 5/6] Tiny cleanup --- docs/content/api-overview/resources/sql.md | 9 ++++----- src/Tests/Sql.fs | 5 +++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/content/api-overview/resources/sql.md b/docs/content/api-overview/resources/sql.md index 02590231c..c7df8e065 100644 --- a/docs/content/api-overview/resources/sql.md +++ b/docs/content/api-overview/resources/sql.md @@ -14,14 +14,13 @@ The SQL Azure module contains two builders - `sqlServer`, used to create SQL Azu | Keyword | Purpose | |-|---------------------------------------------------------------------------------------------------------------------------------| | name | Sets the name of the SQL server. | -| active_directory_admin | Sets Active Directory admin of the server | | add_firewall_rule | Adds a custom firewall rule given a name, start and end IP address range. | | add_firewall_rules | As add_firewall_rule but a list of rules | | enable_azure_firewall | Adds a firewall rule that enables access to other Azure services. | -| admin_username | Sets the admin username of the server. The password is supplied as a secret parameter at runtime. | -| entra_id_admin | Activates Entra ID authentication using the supplied login named, associated objectId and principal type of the administrator account. | -| entra_id_admin_user | Activates Entra ID authentication for the User Principal Type using the supplied user's login name. The ObjectId will be retrieved automatically from Azure at runtime. | -| entra_id_admin_group | Activates Entra ID authentication for the Group Principal Type using the supplied group's login name. The ObjectId will be retrieved automatically from Azure at runtime. | +| admin_username | Sets the admin username of the server. The password is supplied as a secret parameter at runtime. Optional if you use any of the `entra_id_admin` keywords. | +| entra_id_admin | Activates Entra ID authentication using the supplied login named, associated objectId and principal type of the administrator account. Optional if you use `admin_username`. | +| entra_id_admin_user | Activates Entra ID authentication for the User Principal Type using the supplied user's login name. The ObjectId will be retrieved automatically from Azure at runtime. Optional if you use `admin_username`. | +| entra_id_admin_group | Activates Entra ID authentication for the Group Principal Type using the supplied group's login name. The ObjectId will be retrieved automatically from Azure at runtime. Optional if you use `admin_username`. | | elastic_pool_name | Sets the name of the elastic pool, if required. If not set, Farmer will generate a name for you. | | elastic_pool_sku | Sets the sku of the elastic pool, if required. If not set, Farmer will default to Basic 50. | | elastic_pool_database_min_max | Sets the optional minimum and maximum DTUs for the elastic pool for each database. | diff --git a/src/Tests/Sql.fs b/src/Tests/Sql.fs index 11b216cde..fac9f9e0d 100644 --- a/src/Tests/Sql.fs +++ b/src/Tests/Sql.fs @@ -330,7 +330,7 @@ let tests = Expect.isTrue (adminToken["azureADOnlyAuthentication"].GetValue()) "Should only have AD auth." } - test "No Entra ARM when just using SQL" { + test "No Entra ARM when just using SQL auth" { let theServer = sqlServer { name "my-sql-server" admin_username "test" @@ -354,7 +354,8 @@ let tests = let azureAdOnlyAuth = json.["resources"].[0].["properties"].["administrators"].["azureADOnlyAuthentication"] + .GetValue() - Expect.isFalse (azureAdOnlyAuth.GetValue()) "Should not only have AD auth." + Expect.isFalse azureAdOnlyAuth "Should be mixed authetication." } ] \ No newline at end of file From 63c0034582fd7ed663f00a73c5c8a25d113c219e Mon Sep 17 00:00:00 2001 From: Isaac Abraham Date: Sun, 27 Oct 2024 15:45:56 +0000 Subject: [PATCH 6/6] No longer automatically get the ObjectId when setting auth. --- docs/content/api-overview/resources/sql.md | 4 +-- samples/SampleApp/SampleApp.fsproj | 17 ++++++----- src/Farmer/Builders/Builders.Sql.fs | 12 +++----- src/Tests/Sql.fs | 33 +++++++++++++++++++++- 4 files changed, 46 insertions(+), 20 deletions(-) diff --git a/docs/content/api-overview/resources/sql.md b/docs/content/api-overview/resources/sql.md index c7df8e065..7e4e76a6b 100644 --- a/docs/content/api-overview/resources/sql.md +++ b/docs/content/api-overview/resources/sql.md @@ -19,8 +19,8 @@ The SQL Azure module contains two builders - `sqlServer`, used to create SQL Azu | enable_azure_firewall | Adds a firewall rule that enables access to other Azure services. | | admin_username | Sets the admin username of the server. The password is supplied as a secret parameter at runtime. Optional if you use any of the `entra_id_admin` keywords. | | entra_id_admin | Activates Entra ID authentication using the supplied login named, associated objectId and principal type of the administrator account. Optional if you use `admin_username`. | -| entra_id_admin_user | Activates Entra ID authentication for the User Principal Type using the supplied user's login name. The ObjectId will be retrieved automatically from Azure at runtime. Optional if you use `admin_username`. | -| entra_id_admin_group | Activates Entra ID authentication for the Group Principal Type using the supplied group's login name. The ObjectId will be retrieved automatically from Azure at runtime. Optional if you use `admin_username`. | +| entra_id_admin_user | Activates Entra ID authentication for the User Principal Type using the supplied user's login name. You can determine the ObjectId using `Farmer.Builders.AccessPolicy.findUsers`. Optional if you use `admin_username`. | +| entra_id_admin_group | Activates Entra ID authentication for the Group Principal Type using the supplied group's login name. You can determine the ObjectId using `Farmer.Builders.AccessPolicy.findGroups`. Optional if you use `admin_username`. | | elastic_pool_name | Sets the name of the elastic pool, if required. If not set, Farmer will generate a name for you. | | elastic_pool_sku | Sets the sku of the elastic pool, if required. If not set, Farmer will default to Basic 50. | | elastic_pool_database_min_max | Sets the optional minimum and maximum DTUs for the elastic pool for each database. | diff --git a/samples/SampleApp/SampleApp.fsproj b/samples/SampleApp/SampleApp.fsproj index ca9658c03..0acafb348 100644 --- a/samples/SampleApp/SampleApp.fsproj +++ b/samples/SampleApp/SampleApp.fsproj @@ -1,14 +1,13 @@  - - Exe - net5.0 - + + Exe + net8.0 + - - - - - + + + + diff --git a/src/Farmer/Builders/Builders.Sql.fs b/src/Farmer/Builders/Builders.Sql.fs index 062452ee7..4db08d3b1 100644 --- a/src/Farmer/Builders/Builders.Sql.fs +++ b/src/Farmer/Builders/Builders.Sql.fs @@ -417,18 +417,14 @@ type SqlServerBuilder() = /// Activates Entra ID authentication using the supplied Entra username (i.e. email address) for the administrator account. Farmer determines the Object ID / SID using `ad user list`. /// If you have set the SQL admin credentials, they will be preserved as a hybrid setup. [] - member this.SetEntraIdAuthenticationUser(state: SqlAzureConfig, login) = - match AccessPolicy.findUsers [ login ] |> Array.toList with - | [] -> raiseFarmer $"Login {login} not found in the directory." - | user :: _ -> this.SetEntraIdAuthentication(state, login, user.Id, PrincipalType.User) + member this.SetEntraIdAuthenticationUser(state: SqlAzureConfig, login, sid) = + this.SetEntraIdAuthentication(state, login, sid, PrincipalType.User) /// Activates Entra ID authentication using the supplied Entra groupname for the administrator account. Farmer determines the Object ID / SID using `ad user list`. /// If you have set the SQL admin credentials, they will be preserved as a hybrid setup. [] - member this.SetEntraIdAuthenticationGroup(state: SqlAzureConfig, login) = - match AccessPolicy.findGroups [ login ] |> Array.toList with - | [] -> raiseFarmer $"Login {login} not found in the directory." - | group :: _ -> this.SetEntraIdAuthentication(state, login, group.Id, PrincipalType.Group) + member this.SetEntraIdAuthenticationGroup(state: SqlAzureConfig, login, sid) = + this.SetEntraIdAuthentication(state, login, sid, PrincipalType.Group) /// Activates Entra ID authentication using the supplied login named, associated objectId and principal type of the administrator account. /// If you have set the SQL admin credentials, they will be preserved as a hybrid setup. diff --git a/src/Tests/Sql.fs b/src/Tests/Sql.fs index fac9f9e0d..9ec43c4d0 100644 --- a/src/Tests/Sql.fs +++ b/src/Tests/Sql.fs @@ -319,7 +319,6 @@ let tests = } let template = arm { add_resource server } - let json = template.Template |> Writer.toJson |> JsonObject.Parse let adminToken = json.["resources"].[0].["properties"].["administrators"] @@ -358,4 +357,36 @@ let tests = Expect.isFalse azureAdOnlyAuth "Should be mixed authetication." } + + test "Can set Entra Auth for users" { + let server = sqlServer { + name "my-sql-server" + entra_id_admin_user "entra-user" (ObjectId Guid.Empty) + } + + let template = arm { add_resource server } + let json = template.Template |> Writer.toJson |> JsonObject.Parse + + let principalType = + json.["resources"].[0].["properties"].["administrators"].["principalType"] + .GetValue() + + Expect.equal principalType "User" "Principal type should be User" + } + + test "Can set Entra Auth for groups" { + let server = sqlServer { + name "my-sql-server" + entra_id_admin_group "entra-group" (ObjectId Guid.Empty) + } + + let template = arm { add_resource server } + let json = template.Template |> Writer.toJson |> JsonObject.Parse + + let principalType = + json.["resources"].[0].["properties"].["administrators"].["principalType"] + .GetValue() + + Expect.equal principalType "Group" "Principal type should be Group" + } ] \ No newline at end of file