src/graphservice/ApplicationAPI.ps1
# Copyright 2021, Adam Edwards # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. . (import-script ../cmdlets/Invoke-GraphApiRequest) . (import-script ../common/LocalCertificate) . (import-script ../common/ScopeHelper) enum AppTenancy { Auto SingleTenant AnyTenant } ScriptClass ApplicationAPI { static { const DefaultApplicationApiVersion 'v1.0' $TenantToGraphServicePrincipal = @{} } $version = $null $connection = $null function __initialize($connection, $version) { $this.connection = $connection $this.version = if ( $version ) { $version } else { $this.scriptclass.DefaultApplicationApiVersion } } function CreateApp($appObject) { Invoke-GraphApiRequest /applications -method POST -body $appObject -version $this.version -connection $this.connection -ConsistencyLevel Session } function AddKeyCredentials($appObjectId, $existingKeyCredentials, [object[]] $appCertificates, [bool] $preserveExisting, [bool] $isServicePrincipal) { if ( ! $appCertificates -and $preserveExisting -and ( $existingKeyCredentials -eq $null ) ) { throw "No certificates were specified" } # This should be additive, but methods to add to the collection # don't seem to work $keyCredentials = @() if ( $preserveExisting -and ($existingkeyCredentials | measure-object).count ) { $existingkeyCredentials | foreach { $keyCredentials += $_ } } foreach ( $appCertificate in $appCertificates ) { $encodedCertificate = if ( $appCertificate -is [System.Security.Cryptography.X509Certificates.X509Certificate2 ] ) { $::.LocalCertificate |=> GetEncodedPublicCertificateData $appCertificate } else { $appCertificate |=> GetEncodedPublicCertificateData } $keyCredentials += [PSCustomObject] @{ type = 'AsymmetricX509Cert' usage = 'Verify' key = $encodedCertificate } } $appPatch = ( [PSCustomObject] @{ keyCredentials = $keyCredentials } ) | convertto-json -depth 6 $targetClass = if ( $isServicePrincipal ) { 'servicePrincipals' } else { 'applications' } Invoke-GraphApiRequest "/$targetClass/$appObjectId" -method PATCH -Body $appPatch -version $this.version -connection $this.connection -ConsistencyLevel Session | out-null } function GetKeyCredentials($appObjectId, [bool] $isServicePrincipal) { $targetClass = if ( $isServicePrincipal ) { 'servicePrincipals' } else { 'applications' } $targetUri = "/$targetClass/$appObjectId/keyCredentials" Invoke-GraphApiRequest "/$targetUri" -method GET -version $this.version -connection $this.connection -ConsistencyLevel Session } function SetKeyCredentials($appObjectId, $keyCredentials, [bool] $isServicePrincipal) { AddKeyCredentials $appObjectId $keyCredentials $null $false $isServicePrincipal } function RegisterApplication($appId, $isExternal) { write-verbose "Attempting to register existing application '$appId', isExternalTenant: '$isExternal'" if ( ! $isExternal ) { # For user experience reasons, when the user believes they are registering an app from their own tenant, # we explicitly check for this. We only skip this check if they specify that they are OK with registering # an application owned by another tenant. write-verbose "Looking for existing application '$appId' in this tenant" $existingApp = $null $retryCount = 3 $waitTime = 5 # The directory does not guarantee read after write, and this method is often invoked after an app is created. # If we need to find the app, it's possible that a newly created app is not accessible by read operations # for some amount of time, so add a reasonable retry just in case. do { $existingApp = GetApplicationByAppId $appId if ( ! $existingApp ) { start-sleep $waitTime } $waitTime += 10 } while ( ! $existingApp -and --$retryCount ) if ( ! $existingApp ) { throw "An application with AppId '$AppId' could not be found in this tenant -- the application may have just been created but not fully replicated or it may be from a different tenant." } write-verbose "Found existing application '$appId' in this tenant" } write-verbose "Looking for existing service principal for application '$appId' in this tenant" $appSP = GetAppServicePrincipal $appId if ( $appSP ) { throw "Application with Application Id '$appID' is already registered with service principal id = '$($appSP.id)'" } write-verbose "No existing service principal found for application '$appId', registering it" $newSP = NewAppServicePrincipal $appId write-verbose "Registered application '$appId' with service principal '$($newSP.id)'" $newSP } function NewAppServicePrincipal($appId) { Invoke-GraphApiRequest /servicePrincipals -method POST -body @{appId=$appId} -Version $this.version -connection $this.connection -erroraction stop -ConsistencyLevel Session } function GetAppServicePrincipal($appId, $properties, $errorAction = 'stop') { $selectArguments = @{} if ( $properties ) { $selectArguments['select'] = $properties } $result = Invoke-GraphApiRequest /servicePrincipals -method GET -Filter "appId eq '$appId'" -Version $this.version -connection $this.connection -erroraction $errorAction @selectArguments -ConsistencyLevel Session __NormalizeSearchResult $result } function GetApplicationByAppId($appId, $getServicePrincipal, $errorAction = 'stop') { $targetClass = if ( $getServicePrincipal ) { 'servicePrincipals' } else { 'applications' } $result = Invoke-GraphApiRequest "/$targetClass" -method GET -Filter "appId eq '$appId'" -Version $this.version -connection $this.connection -erroraction $errorAction -ConsistencyLevel Session __NormalizeSearchResult $result } function GetApplicationByObjectId($objectId, $getServicePrincipal, $errorAction = 'stop') { $targetClass = if ( $getServicePrincipal ) { 'servicePrincipals' } else { 'applications' } Invoke-GraphApiRequest "/$targetClass/$objectId" -method GET -Version $this.version -connection $this.connection -erroraction $errorAction -ConsistencyLevel Session } function GetApplicationByObjectIdOrAppId($objectId, $appId, $getServicePrincipal, $errorAction = 'stop') { if ( $objectId ) { GetApplicationByObjectId $objectId $getServicePrincipal $errorAction } else { GetApplicationByAppId $appId $getServicePrincipal $errorAction } } function RemoveApplicationByObjectId($objectId, $errorAction = 'stop') { Invoke-GraphApiRequest "/applications/$objectId" -method DELETE -Version $this.version -connection $this.connection -erroraction $erroraction -ConsistencyLevel Session | out-null } function GetReducedPermissionsString($permissionsString, $permissionsToRemove) { $permissions = $permissionsString -split ' ' $newPermissions = $permissions | where { $permissionsToRemove -notcontains $_ } $reducedPermissionsString = $newPermissions -join ' ' if ( $permissionsString -ne $reducedPermissionsString ) { $reducedPermissionsString } } function SetConsent ( $appId, [string[]] $delegatedPermissions, [string[]] $appOnlyPermissions, $consentRequiredPermissions, $userIdToConsent, $consentAllUsers, $appWithRequiredResource, $appServicePrincipalId, $errorIfNoDelegatedUserTarget ) { $isUserConsentNeeded = $false $consentUserId = if ( $userIdToConsent ) { write-verbose "User '$userIdToConsent' specified for consent" $isUserConsentNeeded = $true $userIdToConsent } elseif ( ! $consentAllUsers ) { write-verbose "No user was specified for consent, but all user consent was not specified, so consent will be made for the user making this Graph API call" $userObjectId = $this.connection.Identity.GetUserInformation().userObjectId if ( $userObjectId ) { $isUserConsentNeeded = $true write-verbose "Attempting to grant consent to app '$appId' for current user '$userObjectId'" } else { write-verbose "Unable to determine current user and all users consent not specified, so no consent for the user will be attempted; the current user is likely an app-only identity" if ( $delegatedPermissions -and $errorIfNoDelegatedUserTarget ) { throw 'Delegated permissions were specified for consent, but no target user was specified and the target user could not be inferred from the signed in account. Explicitly specify a user consent target or sign in with a delegated user identity.' } } $userObjectId } else { write-verbose "User consent was not specified, and consent for required delegated permissions was specified, will attempt to consent those permissions for all users in the tenant" $isUserConsentNeeded = $true } if ( ! $isUserConsentNeeded -and ! $ConsentAllUsers -and ! $appOnlyPermissions ) { write-verbose "Consent for all users was not required and no specific user consent was required and no app only permissions were specified, so skipping consent completely" return } if ( $isUserConsentNeeded ) { write-verbose 'Processing user consent...' $grant = GetConsentGrantForApp $appId $consentUserId $DelegatedPermissions $consentRequiredPermissions $appWithRequiredResource if ( $grant ) { Invoke-GraphApiRequest /oauth2PermissionGrants -method POST -body $grant -version $this.version -connection $this.connection -ConsistencyLevel Session | out-null } else { write-verbose 'Skipping consent because no consent was specified' } } if ( $AppOnlyPermissions -or $consentRequiredPermissions ) { $targetServicePrincipalId = if ( $appServicePrincipalId ) { $appServicePrincipalId } else { $servicePrincipal = GetAppServicePrincipal $appId if ( ! $servicePrincipal -or ! ($servicePrincipal | gm id -erroraction ignore) ) { throw "Application '$AppId' was not found" } $servicePrincipal.Id } write-verbose ( 'Processing app-only consent: SpecificPermissionsSpecified: {0}; ConsentRequiredPermissionsSpecified: {1}' -f ($AppOnlyPermissions -ne $null -and $AppOnlyPermissions.length -gt 0), $consentRequiredPermissions ) ConsentAppOnlyRolesForTenant $appId $AppOnlyPermissions $consentRequiredPermissions $appWithRequiredResource $targetServicePrincipalId } else { write-verbose 'Skipping consent for app only permissions because no permissions are specified' } } function GetConsentGrantForApp( $appId, $consentUser, $scopes = @(), $ConsentRequiredPermissions, $appWithRequiredResource ) { $targetPermissions = if ( ! $ConsentRequiredPermissions ) { foreach ( $scopeName in $scopes ) { $scopeId = try { $::.ScopeHelper |=> GraphPermissionNameToId $scopeName Scope } catch { } $canonicalScopeName = if ( $scopeId ) { $::.ScopeHelper |=> GraphPermissionIdToName $scopeId Scope $null $true } if ( $canonicalScopeName ) { $canonicalScopeName } else { $scopeName } } } else { $permissions = @() if ( $appWithRequiredResource -and ( $appWithRequiredResource | gm requiredResourceAccess -erroraction ignore ) ) { $graphResourceAccess = $appWithRequiredResource.requiredResourceAccess | where resourceAppid -eq 00000003-0000-0000-c000-000000000000 $graphResourceAccess.resourceAccess | foreach { if ( $_.type -eq 'Scope') { $permissionId = $_.id $permissionName = $::.ScopeHelper |=> GraphPermissionIdToName $permissionId $null $this.connection $permissions += $permissionName } } } $permissions } if ( $targetPermissions -and $targetPermissions.length -gt 0 ) { __NewOauth2Grant $appId ($targetPermissions -join ' ') $consentUser } } function ConsentAppOnlyRolesForTenant( $appId, $appPermissions, $ConsentRequiredPermissions, $appWithRequiredResource, $appServicePrincipalId ) { $targetPermissions = if ( ! $ConsentRequiredPermissions ) { foreach ( $roleName in $appPermissions ) { $::.ScopeHelper |=> GraphPermissionNameToId $roleName 'Role' $this.connection $true } } else { $permissions = @() if ( $appWithRequiredResource -and ( $appWithRequiredResource | gm requiredResourceAccess ) ) { $graphResourceAccess = $appWithRequiredResource.requiredResourceAccess | where resourceAppid -eq 00000003-0000-0000-c000-000000000000 $graphResourceAccess.resourceAccess | foreach { if ( $_.type -eq 'Role') { $permissions += $_.id } } } $permissions } $appRoleAssignments = foreach ( $roleId in $targetPermissions ) { __NewAppRoleAssignment $appServicePrincipalId $roleId } foreach ( $assignment in $appRoleAssignments ) { Invoke-GraphApiRequest /servicePrincipals/$appServicePrincipalId/appRoleAssignments -method POST -body $assignment -version $this.version -connection $this.connection -ConsistencyLevel Session | out-null } } function GetGraphServicePrincipalId($connection) { $tenantId = $connection.identity.TenantDisplayId.tostring() $spId = $this.scriptclass.TenantToGraphServicePrincipal[$tenantId] if ( ! $spId ) { $spResult = GetAppServicePrincipal $::.ScopeHelper.GraphApplicationId @('id') if ( ! $spResult ) { throw 'Unable to find service principal for Microsoft Graph in the tenant' } $spId = $spResult.id $this.scriptclass.TenantToGraphServicePrincipal[$tenantId] = $spId write-verbose "Retrieved Graph service principal id '$spId' for tenant '$tenantId' from Graph" } else { write-verbose "Found Graph service principal id '$spId' for tenant '$tenantId' in cache" } $spId } function __NewOauth2Grant($appId, [string] $permissionName, $consentUserId) { $appSP = GetAppServicePrincipal $appId if ( ! $appSP -or ! ($appSP | gm id -erroraction ignore) ) { throw "Application '$AppId' was not found" } $consentType = if ( $consentUserId ) { 'Principal' } else { 'AllPrincipals' } @{ clientId = $appSP.id consentType = $consentType resourceId = GetGraphServicePrincipalId $this.connection principalId = $consentUserId scope = $permissionName startTime = (([DateTime]::UtcNow) - ([TimeSpan]::FromDays(1))).tostring('s') expiryTime = (([DateTime]::UtcNow) + ([TimeSpan]::FromDays(365))).tostring('s') } } function __NewAppRoleAssignment($appServicePrincipalId, [string] $roleId) { @{ principalId = $appServicePrincipalId resourceId = GetGraphServicePrincipalId $this.connection principalType = 'ServicePrincipal' appRoleId = $roleId resourceDisplayName = 'Microsoft Graph' } } function __NormalizeSearchResult($result) { # Search results can return empty sets, but the search result itself # is non-empty, so make sure we convert a search result with no items # into a null result to simplify processing for callers -- they won't # need to inspect the payload itself for empty results if ( $result -and ( $result | gm -erroraction ignore id ) ) { $result } } } |