Power BI is used to gain insights into business processes and to support the creation of business processes. This is a one-way flow of information, in which a user cannot, out of the box, simply send information back to, for example, an underlying data warehouse. However, this may sometimes be desirable for a business process.
Within the Power Platform, it is possible to create a Power App that you can place on the canvas in a Power BI report and that allow a user to submit data. However, if you want to send this data back to one of the premium connectors, such as an Azure SQL database, you'll need a premium license for each user. This license fees are in addition to the Power BI license and can quickly add up to a significant amount.
Within the Azure ecosystem, however, it is also possible to create a feedback option for users without using Power Apps. One way to do this is by using an Azure Function. With Consumption hosting, you only pay the minimum cost each time you invoke the Azure Function, regardless of the number of users. In this case, we can use the Azure Function’s Managed Identity as a service account for authorization when the target is a service within the Azure environment, such as an Azure SQL database or an Azure Analysis Services instance.
In this blog post, I'll provide an example of how you can use an Azure Function for this purpose, based on the following use case:
At Company X, all purchase invoices with a total amount exceeding €1,000 must be reviewed by a financial controller. The data warehouse runs on an Azure SQL database. The report is located in a Power BI workspace and is connected to a semantic model hosted in Azure Analysis Services.
This is an example use case; for this solution, it doesn’t matter where the data warehouse or the semantic model is hosted, as long as it is accessible to the Azure Function. If we stay entirely within the Azure environment, the advantage is that we can use the System Managed Identity (SMI) for authentication; however, if the data warehouse runs outside of Azure, for example, access will need to be configured differently.
Required Components
For this use case, we need the following components:
- Azure Function App
- Azure SQL Database
- Azure Analysis Services
- Power BI workspace
Azure Function App
We'll create an Azure Function App under the Consumption hosting plan. Then we only pay when we call the function. We use PowerShell Core as our runtime stack.

After creating the Azure Function App, we need to enable SMI so we can use it for authorization. You can find it under Settings -> Identity. We'll need this as soon as we begin further developing our data warehouse.

Azure SQL Database
Our data warehouse is hosted in an Azure SQL database. We don't want users to be able to directly modify records in our data warehouse via the Function, so we're creating a new schema writeBack with a table POWriteback. We can then grant our SMI permissions for this specific schema, and in our semantic model, we can link this table to the PO data to retrieve these changes.
CREATE SCHEMA [writeBack]
GO
CREATE TABLE [writeBack].[POWriteBack] ([recordKey] varchar(25), [Content] varchar(25))
GO
We want to access this SQL from the Function, so we need to allow them to communicate with each other. To do this, in the SQL Server settings, under Security -> Networking the option Allow Azure services and resources to access this server on.

Now we need to add the SMI to the SQL database and grant the appropriate permissions. To add a user from Entra ID, use the FROM EXTERNAL PROVIDER option. For the username, enter the name as it appears in Entra ID, enclosed in square brackets. We grant permissions specifically on the newly created schema writeBack.
CREATE USER [] FROM EXTERNAL PROVIDER GO GRANT INSERT, UPDATE ON SCHEMA::[writeBack] TO []
GO
Azure Analysis Services
In this example, our semantic model is hosted in AAS. With the necessary configuration, this solution can also be used when the model is hosted in a different architecture, such as Fabric Capacity or Premium Per User. The relevant sections of our model look like this:

- Purchase Invoices: This table contains the purchase invoices from our source system.
- recordKey: the primary key for an invoice.
- Purchase Invoice Write-Back: In this table, we store the statuses that we write back to the database. By separating them from the Purchase Invoices With this table, we can allow users to load only the statuses into the model, which is much faster than the complex logic of the Purchase Invoices to load the table completely.
- recordKey: foreign key to Purchase Invoices
- status: the current status of an invoice
- Purchase Invoice Write-Back Status: This table lists the possible statuses we want to assign to an invoice. It is used in a slicer to construct the Azure Function call.
- status: a list of possible statuses
- order: the order in which we want to sort the statuses
We want our Function App to also Purchases, Invoices, Writeback refresh the table so that a user can update their changes in the reports themselves. To do this, we need to add the SMI as an admin on the Azure Analysis Services instance. You cannot find the SMI in the Azure GUI, but you can add it manually in the format app:@.
Power BI
A location where you can host the final report, such as a Power BI Service Pro workspace, so you can collaborate with your colleagues. This does not affect the script's functionality in any other way, so this point will not be discussed further.
Azure Function PowerShell script
Now that we’ve configured all the components and the underlying permissions, we can create the Function to write data back to our data warehouse from a Power BI report. We’ve chosen a PowerShell Core stack for our Function App, so we’ll also write the scripts in PowerShell. We’ll use an HTTP trigger to invoke our Functions.

Writeback to the data warehouse
Input
Users must be able to submit input so that we can then process it. To do this, we need a key to identify the record associated with the status and to specify which status it should be assigned. We can do this using the code below.
param($Request, $TriggerMetadata)
# Configuration
$global:server = ""
$global:database = ""
# Get query parameters
$RecordKey = $Request.Query.RecordKey
$Content = $Request.Query.Content
$sqlParameters = @{
"@RecordKey"=$RecordKey
"@Content"=$Content
}
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Body = $body
})
In the Configuration block, we set the global variables for the server and database so that we can easily change them. From the web request, we retrieve a RecordKey which indicates which record the status belongs to, and Content is the new status we want to assign. We combine these two parameters in a hash table sqlParameters so that we can easily process them later in the code and remain flexible regarding the number of parameters we want to use.
We also want to provide feedback to a user when the code is complete. We do this by Push-Output Binding. This returns a response that we will develop further so that a user knows whether the status assignment was successful.
Authentication
To begin with, we need to retrieve an authorization token for Azure SQL databases from our SMI in the code. To do this, we make an API call to the Azure IMDS. It is also possible to use the Az modules to request a token via Connect-AzAccount and Get-AzAccessToken. However, the downside of the Az module is that it has a fairly long startup time, which would result in a user having to wait a long time. By using IMDS, we avoid this startup time.
function Get-AccessToken {
param([string]$resource)
try{
$tokenAuthUri = $env:IDENTITY_ENDPOINT + "?resource=$resource&api-version=2019-08-01"
$tokenResponse = Invoke-RestMethod -Method Get -Headers @{ "X-IDENTITY-HEADER" = $env:IDENTITY_HEADER } -Uri $tokenAuthUri
$accessToken = $tokenResponse.access_token
return $response.access_token
} catch {
throw "Failed to acquire managed identity token: $($_.Exception.Message)"
}
}
param([string]$resource)
We use $resource as a parameter for the function. This is the Azure resource for which you want to request a token. By making this a parameter, we can reuse the function to request tokens for different resources. A token is valid for 1 hour. We already know that our function’s execution time is very short, so we don’t need to worry about this expiration time.
$tokenAuthUri = $env:IDENTITY_ENDPOINT + "?resource=$resource&api-version=2022-09-01"
An Azure Function App that uses a System-Managed Identity has an environment variable IDENTITY_ENDPOINT which contains the IMDS endpoint. We combine this with the Azure resource for which we want to request a token, and we specify a valid version of the API to use. This gives us the endpoint we will call to retrieve a token.
$tokenResponse = Invoke-RestMethod -Method Get -Headers @{ "X-IDENTITY-HEADER" = $env:IDENTITY_HEADER } -Uri $tokenAuthUri
This is how we make the API call. For authorization, we provide the environment variable IDENTITY_HEADER which Azure automatically creates when an SMI is active. This indicates that we are authenticating using the SMI. We then return the access_token from the $tokenResponse.
Everything is enclosed in a try{ } catch{ } block to handle errors and return them in the Function logging.
Executing SQL in the data warehouse
To execute the SQL, we create the following function Execute SQL . It takes the access token, the SQL query we want to execute, and the SQL parameters we want to pass as parameters. These are stored in the hash table created earlier sqlParameters. Here, we use the System.Data.SqlClient namespace to establish a connection sqlConnection to our data warehouse in Azure SQL via authorization with the SMI. The foreach loop iterates over all the parameters in our hash table sqlParameters and adds them one by one to our SQL command as parameters.
function Execute-SQL {
param(
[string]$accessToken,
[string]$sqlCommand,
[hashtable]$sqlParameters
)
$connectionString = "Server=tcp:$($global:server).database.windows.net,1433;Database=$($global:database)"
$sqlConnection = New-Object System.Data.SqlClient.SqlConnection
$sqlConnection.ConnectionString = $connectionString
$sqlConnection.AccessToken = $accessToken
$sqlConnection.Open()
$command = $sqlConnection.CreateCommand()
$command.CommandText = $sqlCommand
foreach ($key in $sqlParameters.Keys) {
$value = $sqlParameters[$key]
if ($value -is [int]) {
$param = New-Object System.Data.SqlClient.SqlParameter($key, [int]$value)
} elseif ($value -is [datetime]) {
$param = New-Object System.Data.SqlClient.SqlParameter($key, [datetime]$value)
} else {
$param = New-Object System.Data.SqlClient.SqlParameter($key, $value)
}
$command.Parameters.Add($param) | Out-Null
}
$command.ExecuteNonQuery() | Out-Null
$sqlConnection.Close()
}
We want users to be able to assign a status to a record and update it later. To do this, we'll execute a MERGE statement on the previously created data warehouse table [writeBack].[POWriteBack]. We'll use the function's parameters as input. Our sqlCommand becomes as follows
merge [writeBack].[POWriteBack] P
USING (SELECT @RecordKey AS [RecordKey], @Content AS [Content]) AS S
ON (P.[RecordKey] = S.[RecordKey])
WHEN MATCHED THEN
UPDATE SET [Content]=S.[Content]
WHEN NOT MATCHED THEN
INSERT ([RecordKey],[Content]) VALUES(S.[RecordKey],S.[Content]);
We merge the parameters RecordKey and Content which we get from the Function using the POWriteBack table. If there's a match, we update the status; otherwise, we add a new record.
PowerShell code
We'll put it all together and add some extra error handling so we can send this back to the user. The complete code will then look like this:
using namespace System.Data.SqlClient
using namespace System.Net
param($Request, $TriggerMetadata)
# Log the request
Write-Host "PowerShell HTTP trigger function processed a request."
# Configuration
$global:server = ""
$global:database = ""
# Get query parameters
$RecordKey = $Request.Query.RecordKey
$Content = $Request.Query.Content
$sqlParameters = @{
"@RecordKey" = $RecordKey
"@Content" = $Content
}
function Get-AccessToken {
param([string]$resource)
try {
$tokenAuthUri = $env:IDENTITY_ENDPOINT + "?resource=$resource&api-version=2019-08-01"
$tokenResponse = Invoke-RestMethod -Method Get -Headers @{ "X-IDENTITY-HEADER" = $env:IDENTITY_HEADER } -Uri $tokenAuthUri
$accessToken = $tokenResponse.access_token
return $accessToken
} catch {
throw "Failed to acquire managed identity token: $($_.Exception.Message)"
}
}
function Execute-SQL {
param(
[string]$accessToken,
[string]$sqlCommand,
[hashtable]$sqlParameters
)
$connectionString = "Server=tcp:$($global:server).database.windows.net,1433;Database=$($global:database)"
$sqlConnection = New-Object System.Data.SqlClient.SqlConnection
$sqlConnection.ConnectionString = $connectionString
$sqlConnection.AccessToken = $accessToken
$sqlConnection.Open()
$command = $sqlConnection.CreateCommand()
$command.CommandText = $sqlCommand
foreach ($key in $sqlParameters.Keys) {
$value = $sqlParameters[$key]
if ($value -is [int]) {
$param = New-Object System.Data.SqlClient.SqlParameter($key, [int]$value)
} elseif ($value -is [datetime]) {
$param = New-Object System.Data.SqlClient.SqlParameter($key, [datetime]$value)
} else {
$param = New-Object System.Data.SqlClient.SqlParameter($key, $value)
}
$command.Parameters.Add($param) | Out-Null
}
$command.ExecuteNonQuery() | Out-Null
$sqlConnection.Close()
}
try {
$accessToken = Get-AccessToken -resource "https://database.windows.net/"
$sqlCommand = @"
merge [writeBack].[POWriteBack] P
using (select @RecordKey as [RecordKey], @Content as [Content]) AS S
on (P.[RecordKey] = S.[RecordKey])
when matched then
update set [Content]=S.[Content]
when not matched then
insert ([RecordKey],[Content]) values(S.[RecordKey],S.[Content]);
"@
Execute-SQL -accessToken $accessToken -sqlCommand $sqlCommand -sqlParameters $sqlParameters
$body = "Data saved successfully: RecordKey=$RecordKey, Content=$Content."
$statusCode = [HttpStatusCode]::OK
} catch {
$body = "Error writing to database: $($_.Exception.Message)"
$statusCode = [HttpStatusCode]::InternalServerError
Write-Host $_.Exception.Message
}
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Body = $body
})
We can now call this function from a Power BI report by entering the function's URL. You can find this URL under "Get function URL," and it has the following format.
?code=&recordKey=&content=
Since you can't include a URL body in Power BI, we include the invoice key and the new status as parameters in the URL.

Refresh Status Table
We also want to give users a button that allows them to update the status table themselves. This gives users the freedom to update the statuses more frequently without having to wait for the standard model refresh. We’re separating this from the other script because we don’t want to update the table after every status change. So, we’ll create a separate function within the same Function App for this purpose. We’ll reuse the Get-AccessToken function that we defined earlier. Then we can use the script below to update the required table. We use a parameter for the model and the table we want to refresh so that we can reuse this function. We’ve hard-coded the Azure Analysis Services instance into the function for now, but you could also add it as a parameter.
$refreshUrl = "https://$($global:region).asazure.windows.net/servers/$($global:server)/models/$modelName/refreshes"
$accessToken = Get-AccessToken -resource "https://*.asazure.windows.net/"
$refreshBody = "{""type"": ""Full"",""objects"": [{""database"": ""$modelName"",""table"": ""$tableName""}]}"
$response = Invoke-RestMethod -Method POST -Uri $refreshUrl `
-Headers @{Authorization = "Bearer $accessToken"} `
-ContentType "application/json" `
-Body $refreshBody
$refreshUrl = "https://$($global:region).asazure.windows.net/servers/$($global:server)/models/$modelName/refreshes" This is the API endpoint for refreshes of our Azure Analysis Services. This is an asynchronous endpoint that returns a refresh operation ID. You can then use this ID to check the status of the refresh, and you can provide it to the end user, for example, for debugging purposes.
$accessToken = Get-AccessToken -resource "https://*.asazure.windows.net/"
is the function we created earlier to retrieve an authorization token. For an Azure Analysis Services token, we use the following resource: https://*.asazure.windows.net
in $refreshBody = "{""type"": ""Full"",""objects"": [{""database"": ""$modelName"",""table"": ""$tableName""}]}" We build the API body for our refresh call, specifying what needs to be refreshed.
in $response = Invoke-RestMethod -Method POST -Uri $refreshUrl -Headers @{Authorization = "Bearer $accessToken"} -ContentType "application/json" -Body $refreshBody We send a POST request to start the refresh.
We'll add some error handling and feedback for the end user, just like in the previous function, and then our complete code will look like this.
using namespace System.Data.SqlClient
using namespace System.Net
param($Request, $TriggerMetadata)
# Configuration
$global:region = "REGION"
$global:server = "SERVER"
# Get query parameters
$modelName = $Request.Query.modelName
$tableName = $Request.Query.tableName
# Functions
function Get-AccessToken {
param([string]$resource)
try{
$tokenAuthUri = $env:IDENTITY_ENDPOINT + "?resource=$resource&api-version=2019-08-01"
$tokenResponse = Invoke-RestMethod -Method Get -Headers @{ "X-IDENTITY-HEADER" = $env:IDENTITY_HEADER } -Uri $tokenAuthUri
$accessToken = $tokenResponse.access_token
return $accessToken
} catch {
throw "Failed to acquire managed identity token: $($_.Exception.Message)"
}
}
try{
$refreshUrl = "https://$($global:region).asazure.windows.net/servers/$($global:server)/models/$modelName/refreshes"
$accessToken = Get-AccessToken -resource "https://*.asazure.windows.net/"
$refreshBody = "{""type"": ""Full"",""objects"": [{""database"": ""$modelName"",""table"": ""$tableName""}]}"
$response = Invoke-RestMethod -Method POST -Uri $refreshUrl `
-Headers @{Authorization = "Bearer $accessToken"} `
-ContentType "application/json" `
-Body $refreshBody
$body = "Refresh triggered for model $modelName table $tableName (RefreshID: $($response.operationId))"
$statusCode = [HttpStatusCode]::OK
}catch{
$body = "Error starting the refresh: $($_.Exception.Message) $refreshBody || $response || $refreshUrl"
$statusCode = [HttpStatusCode]::InternalServerError
Write-Host $_.Exception.Message
}
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = $statusCode
Body = $body
})
We can also call this function again using the Function URL, and this time we'll pass the model name and the table to refresh as parameters.
?code=&modelName=&tableName=


