Skip to content

Commit 6ffbaa1

Browse files
batdimoiprintclaude
andcommitted
Migrate backend to Azure Functions (Flex Consumption)
Ports the ASP.NET Core Web API to a .NET 9 isolated-worker Function App. The Web API project stays in the tree and keeps building; it serves production until cutover. Structure: - Flowboard.Core: shared library (services, models, DTOs, JWT params). No hosting dependencies, referenced by both hosts. - Flowboard.Functions: 52 endpoints ported from 8 controllers with exact route parity. Responses verified byte-identical to the old backend across 7 endpoints, including camelCase dictionary keys. Functions runs none of the ASP.NET pipeline, so auth and rate limiting are IFunctionsWorkerMiddleware. Auth is default-deny: every function is protected unless it carries [AllowAnonymous] (5 endpoints do), so a forgotten attribute over-protects instead of exposing. The 5 policies in use were all RequireAuthenticatedUser() under different names, and ClientReadOnly was never applied to any endpoint, so default-deny reproduces the original authorization exactly. Four bugs found by verifying against the deployed app: - Declaring "options" per-function created 10 (route, OPTIONS) collisions. Colliding functions fail to register, producing a clean deploy with green CI and 404s at runtime. CORS is now configured at the platform level; adding an origin is an Azure config change, not a code change. - The host answers OPTIONS before the worker runs, so worker-level CORS never executed. Confirmed via App Insights: a catch-all preflight function logged zero invocations while every GET and 401 appeared. - /api/subtasks/me matched {id} and returned 400 where the old backend returned 200. Fixed with an ObjectId-length constraint on {id}. - Rate limiting keyed on X-Forwarded-For, which Azure writes as ip:port. The port changes per connection, so every request created a new counter and the limit never tripped: 110 requests against a 100/60s limit gave zero 429s. Now 99x 200 then 429 with Retry-After. Also fixes AllowedHosts, which was set to a URL rather than a hostname and to the frontend's host rather than the backend's. Development mode masked it; it 400s every request under Production. Deletes TasksController and DetailedTasksController (comment-only files). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123HJQhm6bmiyiprJUhRUmD
1 parent 66632f7 commit 6ffbaa1

29 files changed

Lines changed: 3749 additions & 108 deletions
Lines changed: 87 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,90 @@
1-
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
2-
# More GitHub Actions for Azure: https://github.com/Azure/actions
3-
4-
name: Build and deploy ASP.Net Core app to Azure Web App - flowboard-backend
5-
6-
on:
7-
push:
8-
branches:
9-
- main
10-
workflow_dispatch:
11-
12-
jobs:
13-
build:
14-
runs-on: ubuntu-latest
15-
permissions:
16-
contents: read #This is required for actions/checkout
17-
18-
steps:
19-
- uses: actions/checkout@v4
20-
21-
- name: Set up .NET Core
22-
uses: actions/setup-dotnet@v4
23-
with:
24-
dotnet-version: '9.x'
25-
26-
- name: Build with dotnet
27-
run: dotnet build --configuration Release
28-
29-
- name: dotnet publish
30-
run: dotnet publish -c Release -o ${{env.DOTNET_ROOT}}/myapp
31-
32-
- name: Upload artifact for deployment job
33-
uses: actions/upload-artifact@v4
34-
with:
35-
name: .net-app
36-
path: ${{env.DOTNET_ROOT}}/myapp
37-
38-
deploy:
39-
runs-on: ubuntu-latest
40-
needs: build
1+
# Docs for the Azure Functions Deploy action: https://github.com/Azure/functions-action
2+
3+
name: Build and deploy Flowboard.Functions to Azure Function App - func-flowboard-backend
4+
5+
on:
6+
push:
7+
branches:
8+
- main
9+
workflow_dispatch:
10+
11+
env:
12+
AZURE_FUNCTIONAPP_NAME: func-flowboard-backend
13+
AZURE_RESOURCE_GROUP: ProjX-MVC
14+
AZURE_FUNCTIONAPP_PACKAGE_PATH: Flowboard.Functions
15+
DOTNET_VERSION: '9.0.x'
16+
PUBLISH_OUTPUT_DIR: ${{ github.workspace }}/publish-output
17+
18+
jobs:
19+
build-and-deploy:
20+
runs-on: ubuntu-latest
4121
permissions:
42-
id-token: write #This is required for requesting the JWT
43-
contents: read #This is required for actions/checkout
44-
45-
steps:
46-
- name: Download artifact from build job
47-
uses: actions/download-artifact@v4
48-
with:
49-
name: .net-app
50-
51-
- name: Login to Azure
22+
id-token: write # required for OIDC login to Azure
23+
contents: read # required for actions/checkout
24+
25+
steps:
26+
- uses: actions/checkout@v4
27+
28+
- name: Set up .NET Core
29+
uses: actions/setup-dotnet@v4
30+
with:
31+
dotnet-version: ${{ env.DOTNET_VERSION }}
32+
33+
- name: dotnet restore
34+
run: dotnet restore
35+
36+
- name: dotnet build
37+
run: dotnet build -c Release
38+
39+
- name: dotnet publish (Flowboard.Functions)
40+
run: >
41+
dotnet publish ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}/Flowboard.Functions.csproj
42+
-c Release
43+
-o ${{ env.PUBLISH_OUTPUT_DIR }}
44+
45+
- name: Login to Azure (OIDC)
5246
uses: azure/login@v2
5347
with:
54-
client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_D7930A81D9DD49EF959D38224DF371BE }}
55-
tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_0EBDFE6F28F44644B2972CB324EAD997 }}
56-
subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_633C3E4D1B5744B2A30E0CE0A9CE4F24 }}
57-
58-
- name: Deploy to Azure Web App
59-
id: deploy-to-webapp
60-
uses: azure/webapps-deploy@v3
61-
with:
62-
app-name: 'flowboard-backend'
63-
slot-name: 'Production'
64-
package: .
65-
48+
client-id: ${{ secrets.AZURE_FUNCTIONAPP_CLIENT_ID }}
49+
tenant-id: ${{ secrets.AZURE_FUNCTIONAPP_TENANT_ID }}
50+
subscription-id: ${{ secrets.AZURE_FUNCTIONAPP_SUBSCRIPTION_ID }}
51+
52+
- name: Deploy to Azure Function App
53+
id: deploy-to-function
54+
uses: Azure/functions-action@v1
55+
with:
56+
app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }}
57+
package: ${{ env.PUBLISH_OUTPUT_DIR }}
58+
59+
# Guard against the "green CI, zero functions" silent-failure mode:
60+
# a bad .funcignore or empty package can deploy successfully while
61+
# registering no functions, leaving the app returning clean 404s.
62+
# This step queries the live app immediately after deploy and fails
63+
# the pipeline if no functions were registered.
64+
- name: Verify functions were registered (fail on zero)
65+
run: |
66+
set -euo pipefail
67+
68+
# Trigger sync after a zip deploy is not instantaneous, especially
69+
# on Flex Consumption. Retry with backoff instead of a single fixed
70+
# sleep, so this guard doesn't false-positive on a good deploy.
71+
COUNT=0
72+
for i in $(seq 1 10); do
73+
echo "Attempt $i: querying deployed function list for ${{ env.AZURE_FUNCTIONAPP_NAME }}..."
74+
COUNT=$(az functionapp function list \
75+
--resource-group "${{ env.AZURE_RESOURCE_GROUP }}" \
76+
--name "${{ env.AZURE_FUNCTIONAPP_NAME }}" \
77+
--query "length(@)" -o tsv 2>/dev/null || echo 0)
78+
echo " -> $COUNT function(s) registered so far."
79+
if [ "$COUNT" -gt 0 ]; then
80+
break
81+
fi
82+
sleep 15
83+
done
84+
85+
if [ "$COUNT" -eq 0 ]; then
86+
echo "::error::Deploy succeeded but zero functions are registered on ${{ env.AZURE_FUNCTIONAPP_NAME }} after retrying for ~2.5 minutes. This usually means .funcignore stripped required files, or the publish output was empty/malformed. Failing the build."
87+
exit 1
88+
fi
89+
90+
echo "OK: $COUNT function(s) registered."

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
# dotenv files
77
.env
88

9+
# Azure Functions local settings (contains secrets)
10+
local.settings.json
11+
912
generated-http
1013

1114
# User-specific files

Configurations/SecurityConfiguration.cs

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,29 +40,14 @@ public static IServiceCollection AddFrontendCors(this IServiceCollection service
4040
// Adds JWT Authentication services using environment variables for keys/issuer/audience.
4141
public static IServiceCollection AddJwtAuthentication(this IServiceCollection services)
4242
{
43-
var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY") ?? string.Empty;
44-
var jwtIssuer = Environment.GetEnvironmentVariable("JWT_ISSUER") ?? string.Empty;
45-
var jwtAudience = Environment.GetEnvironmentVariable("JWT_AUDIENCE") ?? string.Empty;
46-
47-
var keyBytes = Encoding.UTF8.GetBytes(jwtKey);
48-
4943
services.AddAuthentication(options =>
5044
{
5145
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
5246
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
5347
})
5448
.AddJwtBearer(options =>
5549
{
56-
options.TokenValidationParameters = new TokenValidationParameters
57-
{
58-
ValidateIssuer = true,
59-
ValidateAudience = true,
60-
ValidateLifetime = true,
61-
ValidateIssuerSigningKey = true,
62-
ValidIssuer = jwtIssuer,
63-
ValidAudience = jwtAudience,
64-
IssuerSigningKey = new SymmetricSecurityKey(keyBytes)
65-
};
50+
options.TokenValidationParameters = JwtSettings.BuildValidationParameters();
6651
});
6752

6853
// Register Authorization as well, so callers don't have to explicitly add it

Controllers/DetailedTasksController.cs

Lines changed: 0 additions & 12 deletions
This file was deleted.

Controllers/TasksController.cs

Lines changed: 0 additions & 12 deletions
This file was deleted.

Flowboard-Project-Management-System-Backend.csproj

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,26 @@
88
</PropertyGroup>
99

1010
<ItemGroup>
11-
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
11+
<!-- Exclude the sibling Azure Functions restructuring projects: they live in
12+
subdirectories of this project's root, and the Web SDK's default globs
13+
(**/*.cs, **/*.json) are recursive and would otherwise pull their code
14+
and config files into this project too. -->
15+
<Compile Remove="Flowboard.Core/**" />
16+
<Compile Remove="Flowboard.Functions/**" />
17+
<Content Remove="Flowboard.Core/**" />
18+
<Content Remove="Flowboard.Functions/**" />
19+
<None Remove="Flowboard.Core/**" />
20+
<None Remove="Flowboard.Functions/**" />
21+
</ItemGroup>
22+
23+
<ItemGroup>
1224
<PackageReference Include="DotNetEnv" Version="3.1.1" />
1325
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
1426
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
15-
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
16-
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
27+
</ItemGroup>
28+
29+
<ItemGroup>
30+
<ProjectReference Include="Flowboard.Core/Flowboard.Core.csproj" />
1731
</ItemGroup>
1832

1933
</Project>

Flowboard-Project-Management-System-Backend.sln

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
Microsoft Visual Studio Solution File, Format Version 12.00
1+
Microsoft Visual Studio Solution File, Format Version 12.00
22
# Visual Studio Version 17
33
VisualStudioVersion = 17.5.2.0
44
MinimumVisualStudioVersion = 10.0.40219.1
55
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flowboard-Project-Management-System-Backend", "Flowboard-Project-Management-System-Backend.csproj", "{551D44C5-78AE-A11C-8D89-DCE601A67077}"
66
EndProject
7+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flowboard.Core", "Flowboard.Core\Flowboard.Core.csproj", "{38D70EA1-56E9-4300-8D9F-724AC9D0B888}"
8+
EndProject
9+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flowboard.Functions", "Flowboard.Functions\Flowboard.Functions.csproj", "{8ACF5E13-EFF1-4827-9C87-B4714B7D6927}"
10+
EndProject
711
Global
812
GlobalSection(SolutionConfigurationPlatforms) = preSolution
913
Debug|Any CPU = Debug|Any CPU
@@ -14,6 +18,14 @@ Global
1418
{551D44C5-78AE-A11C-8D89-DCE601A67077}.Debug|Any CPU.Build.0 = Debug|Any CPU
1519
{551D44C5-78AE-A11C-8D89-DCE601A67077}.Release|Any CPU.ActiveCfg = Release|Any CPU
1620
{551D44C5-78AE-A11C-8D89-DCE601A67077}.Release|Any CPU.Build.0 = Release|Any CPU
21+
{38D70EA1-56E9-4300-8D9F-724AC9D0B888}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
22+
{38D70EA1-56E9-4300-8D9F-724AC9D0B888}.Debug|Any CPU.Build.0 = Debug|Any CPU
23+
{38D70EA1-56E9-4300-8D9F-724AC9D0B888}.Release|Any CPU.ActiveCfg = Release|Any CPU
24+
{38D70EA1-56E9-4300-8D9F-724AC9D0B888}.Release|Any CPU.Build.0 = Release|Any CPU
25+
{8ACF5E13-EFF1-4827-9C87-B4714B7D6927}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
26+
{8ACF5E13-EFF1-4827-9C87-B4714B7D6927}.Debug|Any CPU.Build.0 = Debug|Any CPU
27+
{8ACF5E13-EFF1-4827-9C87-B4714B7D6927}.Release|Any CPU.ActiveCfg = Release|Any CPU
28+
{8ACF5E13-EFF1-4827-9C87-B4714B7D6927}.Release|Any CPU.Build.0 = Release|Any CPU
1729
EndGlobalSection
1830
GlobalSection(SolutionProperties) = preSolution
1931
HideSolutionNode = FALSE
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System;
2+
using System.Text;
3+
using Microsoft.IdentityModel.Tokens;
4+
5+
namespace Flowboard_Project_Management_System_Backend.Configurations
6+
{
7+
/// <summary>
8+
/// Reusable JWT validation-parameter construction, shared between hosting models
9+
/// (ASP.NET Core middleware, Azure Functions isolated-worker middleware).
10+
/// Reads the same environment variables the app has always used:
11+
/// JWT_KEY, JWT_ISSUER, JWT_AUDIENCE.
12+
/// </summary>
13+
public static class JwtSettings
14+
{
15+
public static TokenValidationParameters BuildValidationParameters()
16+
{
17+
var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY") ?? string.Empty;
18+
var jwtIssuer = Environment.GetEnvironmentVariable("JWT_ISSUER") ?? string.Empty;
19+
var jwtAudience = Environment.GetEnvironmentVariable("JWT_AUDIENCE") ?? string.Empty;
20+
21+
var keyBytes = Encoding.UTF8.GetBytes(jwtKey);
22+
23+
return new TokenValidationParameters
24+
{
25+
ValidateIssuer = true,
26+
ValidateAudience = true,
27+
ValidateLifetime = true,
28+
ValidateIssuerSigningKey = true,
29+
ValidIssuer = jwtIssuer,
30+
ValidAudience = jwtAudience,
31+
IssuerSigningKey = new SymmetricSecurityKey(keyBytes)
32+
};
33+
}
34+
}
35+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net9.0</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
11+
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
12+
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
13+
</ItemGroup>
14+
15+
</Project>

0 commit comments

Comments
 (0)