diff --git a/.github/agents/coder.agent.md b/.github/agents/coder.agent.md index 72c3cd8..4a46c5b 100644 --- a/.github/agents/coder.agent.md +++ b/.github/agents/coder.agent.md @@ -2,7 +2,17 @@ description: "Use when: writing, refactoring, or reviewing application code and tests — especially .NET Web APIs, React frontends, Entity Framework Core data access, unit/integration tests, or Playwright end-to-end tests. Produces simple, maintainable, idiomatic code that follows SOLID and Clean Code principles. Trigger phrases: clean code, idiomatic, SOLID, refactor, .NET API, ASP.NET Core, React component, EF Core, DbContext, write tests, unit test, integration test, e2e test, Playwright, maintainable code." name: "Clean Coder" tools: - [vscode, execute, read, edit, search, "playwright/*", azure-mcp/search, todo] + [ + vscode, + execute, + read, + edit, + search, + com.microsoft/azure/search, + "playwright/*", + "azure-mcp/*", + todo, + ] user-invocable: false model: Claude Opus 4.7 (copilot) --- diff --git a/.github/agents/devops.agent.md b/.github/agents/devops.agent.md index 39d7abc..98dce25 100644 --- a/.github/agents/devops.agent.md +++ b/.github/agents/devops.agent.md @@ -1,8 +1,22 @@ --- description: "Use when: authoring or reviewing Azure Infrastructure as Code (Bicep), GitHub Actions CI/CD pipelines, or Git commit messages and PR titles. Handles IaC creation, pipelines as code, and commit/PR conventions. Trigger phrases: bicep, iac, infra, azure resources, main.bicep, bicepparam, AVM, azd, github actions, workflow, CI, CD, pipeline, deploy, OIDC, reusable workflow, commit message, conventional commit, PR title." name: "DevOps" -tools: [read, edit, search, execute, todo, azure/*, bicep/*, github/*] -user-invocable: false +tools: + [ + vscode/askQuestions, + execute, + read, + edit, + search, + "github/*", + "azure-mcp/*", + "bicep/*", + "com.microsoft/azure/*", + "github/*", + "github/*", + todo, + ] +user-invocable: true --- You are a DevOps specialist. You write and review three things: diff --git a/.github/workflows/_deploy.yml b/.github/workflows/_deploy.yml new file mode 100644 index 0000000..cb4a47d --- /dev/null +++ b/.github/workflows/_deploy.yml @@ -0,0 +1,170 @@ +name: Deploy (reusable) + +on: + workflow_call: + inputs: + environment: + required: true + type: string + bicepParamFile: + required: true + type: string + +permissions: + id-token: write + contents: read + +jobs: + deploy: + name: ${{ inputs.environment }} + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + env: + SQL_ADMIN_OBJECT_ID: ${{ vars.SQL_ADMIN_OBJECT_ID }} + SQL_ADMIN_PRINCIPAL_NAME: ${{ vars.SQL_ADMIN_PRINCIPAL_NAME }} + AZURE_DEPLOYER_OBJECT_ID: ${{ vars.AZURE_DEPLOYER_OBJECT_ID }} + steps: + - uses: actions/checkout@v4 + + - uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + # ---------- Infra ---------- + - name: Ensure resource group + run: | + az group create \ + --name "${{ vars.AZURE_RESOURCE_GROUP }}" \ + --location "${{ vars.AZURE_LOCATION }}" \ + --tags workload=notes environment=${{ inputs.environment }} managed-by=bicep >/dev/null + + - name: Deploy Bicep + id: bicep + run: | + outputs=$(az deployment group create \ + --resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \ + --template-file infra/main.bicep \ + --parameters "${{ inputs.bicepParamFile }}" \ + --query properties.outputs \ + -o json) + echo "$outputs" | jq -r 'to_entries[] | "\(.key)=\(.value.value)"' >> "$GITHUB_OUTPUT" + + # ---------- Secrets ---------- + - name: Seed JWT signing key (if missing) + env: + KV_NAME: ${{ steps.bicep.outputs.keyVaultName }} + run: | + if ! az keyvault secret show --vault-name "$KV_NAME" --name jwt-signing-key >/dev/null 2>&1; then + echo "Creating jwt-signing-key" + value=$(openssl rand -base64 64 | tr -d '\n') + az keyvault secret set --vault-name "$KV_NAME" --name jwt-signing-key --value "$value" --only-show-errors >/dev/null + else + echo "jwt-signing-key already exists" + fi + + # ---------- SQL: grant App Service MI access ---------- + - name: Grant App Service MI access to SQL DB + env: + SQL_SERVER: ${{ steps.bicep.outputs.sqlServerName }} + SQL_DB: ${{ steps.bicep.outputs.sqlDatabaseName }} + APP_NAME: ${{ steps.bicep.outputs.appServiceName }} + APP_MI_ID: ${{ steps.bicep.outputs.appServicePrincipalId }} + run: | + set -euo pipefail + fqdn="${SQL_SERVER}.database.windows.net" + + # Allow this runner's public IP temporarily so sqlcmd can connect. + runner_ip=$(curl -s https://api.ipify.org) + az sql server firewall-rule create \ + --resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \ + --server "$SQL_SERVER" \ + --name "gh-runner-${{ github.run_id }}" \ + --start-ip-address "$runner_ip" \ + --end-ip-address "$runner_ip" >/dev/null + + # Install sqlcmd (go edition). + curl -fsSL https://github.com/microsoft/go-sqlcmd/releases/download/v1.8.0/sqlcmd-linux-amd64.tar.bz2 \ + | tar -xj -C /tmp + sudo mv /tmp/sqlcmd /usr/local/bin/sqlcmd + + # Acquire access token as the deployer SP (already logged in via OIDC). + token=$(az account get-access-token --resource https://database.windows.net/ --query accessToken -o tsv) + + cat < /tmp/grant.sql + IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'${APP_NAME}') + BEGIN + CREATE USER [${APP_NAME}] FROM EXTERNAL PROVIDER; + END + ALTER ROLE db_datareader ADD MEMBER [${APP_NAME}]; + ALTER ROLE db_datawriter ADD MEMBER [${APP_NAME}]; + ALTER ROLE db_ddladmin ADD MEMBER [${APP_NAME}]; + SQL + + sqlcmd -S "$fqdn" -d "$SQL_DB" --access-token "$token" -i /tmp/grant.sql + + # Clean up firewall rule. + az sql server firewall-rule delete \ + --resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \ + --server "$SQL_SERVER" \ + --name "gh-runner-${{ github.run_id }}" >/dev/null + + # ---------- Backend ---------- + - uses: actions/download-artifact@v4 + with: + name: api + path: . + + - name: Deploy API + uses: azure/webapps-deploy@v3 + with: + app-name: ${{ steps.bicep.outputs.appServiceName }} + package: api.zip + + # ---------- Frontend ---------- + - uses: actions/download-artifact@v4 + with: + name: frontend-src + path: frontend-src + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Build frontend with environment API URL + working-directory: frontend-src + env: + VITE_API_BASE_URL: https://${{ steps.bicep.outputs.appServiceDefaultHostName }} + run: | + npm ci + npm run build + + - name: Get SWA deployment token + id: swa + run: | + token=$(az staticwebapp secrets list \ + --name "${{ steps.bicep.outputs.staticWebAppName }}" \ + --resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \ + --query properties.apiKey -o tsv) + echo "::add-mask::$token" + echo "token=$token" >> "$GITHUB_OUTPUT" + + - name: Deploy SWA + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ steps.swa.outputs.token }} + action: upload + app_location: frontend-src/dist + skip_app_build: true + skip_api_build: true + + - name: Summary + run: | + { + echo "## Deployment (${{ inputs.environment }})"; + echo "- API: https://${{ steps.bicep.outputs.appServiceDefaultHostName }}"; + echo "- SWA: https://${{ steps.bicep.outputs.staticWebAppDefaultHostName }}"; + echo "- SQL: ${{ steps.bicep.outputs.sqlServerName }} / ${{ steps.bicep.outputs.sqlDatabaseName }}"; + echo "- KeyVault: ${{ steps.bicep.outputs.keyVaultName }}"; + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..3bfa9aa --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,95 @@ +name: CD + +on: + push: + branches: [ main ] + workflow_dispatch: + inputs: + environment: + description: Target environment + required: true + default: dev + type: choice + options: [ dev, prod ] + +permissions: + id-token: write + contents: read + +concurrency: + group: cd-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-backend: + name: Build backend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.x" + - name: Publish + run: | + dotnet publish backend/Notes.Api/Notes.Api.csproj \ + --configuration Release \ + --runtime linux-x64 \ + --self-contained false \ + --output ./publish + - name: Zip + run: | + cd publish + zip -r ../api.zip . + - uses: actions/upload-artifact@v4 + with: + name: api + path: api.zip + retention-days: 7 + + build-frontend: + name: Build frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + working-directory: frontend + - name: Build (base URL set at deploy time) + run: npm run build + working-directory: frontend + env: + # The API base URL is stamped in per-environment during deploy by + # rebuilding there. This build is for CI sanity only. + VITE_API_BASE_URL: https://placeholder.invalid + - uses: actions/upload-artifact@v4 + with: + name: frontend-src + path: | + frontend + !frontend/node_modules + !frontend/dist + retention-days: 1 + + deploy-dev: + name: Deploy dev + needs: [ build-backend, build-frontend ] + if: github.event_name == 'push' || github.event.inputs.environment == 'dev' + uses: ./.github/workflows/_deploy.yml + with: + environment: dev + bicepParamFile: infra/main.dev.bicepparam + secrets: inherit + + deploy-prod: + name: Deploy prod + needs: [ deploy-dev ] + if: github.event_name == 'push' || github.event.inputs.environment == 'prod' + uses: ./.github/workflows/_deploy.yml + with: + environment: prod + bicepParamFile: infra/main.prod.bicepparam + secrets: inherit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..605fb08 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend build & test + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.x" + + - name: Cache NuGet + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('backend/**/*.csproj') }} + restore-keys: ${{ runner.os }}-nuget- + + - run: dotnet restore + - run: dotnet build --no-restore --configuration Release + - run: dotnet test --no-build --configuration Release --logger + "trx;LogFileName=test-results.trx" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-test-results + path: backend/**/TestResults/*.trx + if-no-files-found: ignore + + frontend: + name: Frontend build & test + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm test -- --run + - run: npm run build + + bicep: + name: Bicep lint & build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build main.bicep + run: az bicep build --file infra/main.bicep diff --git a/.github/workflows/infra-validate.yml b/.github/workflows/infra-validate.yml new file mode 100644 index 0000000..b442978 --- /dev/null +++ b/.github/workflows/infra-validate.yml @@ -0,0 +1,57 @@ +name: Infra what-if + +on: + pull_request: + branches: [ main ] + paths: + - "infra/**" + - ".github/workflows/infra-validate.yml" + +permissions: + id-token: write + contents: read + pull-requests: write + +concurrency: + group: infra-whatif-${{ github.ref }} + cancel-in-progress: true + +jobs: + whatif: + name: What-if (dev) + runs-on: ubuntu-latest + environment: dev + env: + SQL_ADMIN_OBJECT_ID: ${{ vars.SQL_ADMIN_OBJECT_ID }} + SQL_ADMIN_PRINCIPAL_NAME: ${{ vars.SQL_ADMIN_PRINCIPAL_NAME }} + AZURE_DEPLOYER_OBJECT_ID: ${{ vars.AZURE_DEPLOYER_OBJECT_ID }} + steps: + - uses: actions/checkout@v4 + + - uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Ensure resource group + run: | + az group create \ + --name "${{ vars.AZURE_RESOURCE_GROUP }}" \ + --location "${{ vars.AZURE_LOCATION }}" \ + --tags workload=notes environment=dev managed-by=bicep >/dev/null + + - name: What-if + run: | + az deployment group what-if \ + --resource-group "${{ vars.AZURE_RESOURCE_GROUP }}" \ + --template-file infra/main.bicep \ + --parameters infra/main.dev.bicepparam \ + --no-pretty-print \ + > whatif.txt || true + { + echo '### Bicep what-if (dev)'; + echo '```'; + cat whatif.txt; + echo '```'; + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 5f6bf28..109646f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ backend/Notes.Api/notes.db backend/Notes.Api/notes.db-shm backend/Notes.Api/notes.db-wal + +# Compiled Bicep (ARM JSON) artifacts +infra/**/*.json +!infra/abbreviations.json + diff --git a/backend/Notes.Api.Tests/AuthEndpointsTests.cs b/backend/Notes.Api.Tests/AuthEndpointsTests.cs new file mode 100644 index 0000000..828bdae --- /dev/null +++ b/backend/Notes.Api.Tests/AuthEndpointsTests.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; + +using FluentAssertions; + +using Notes.Api.Auth; + +namespace Notes.Api.Tests; + +public sealed class AuthEndpointsTests(NotesApiFactory factory) : IClassFixture +{ + private readonly NotesApiFactory _factory = factory; + + [Fact] + public async Task Register_WithValidBody_Returns200_WithTokenAndUser() + { + using var client = _factory.CreateClient(); + var email = $"user-{Guid.NewGuid():N}@test.local"; + + var response = await client.PostAsJsonAsync("/api/auth/register", + new { email, password = "Password123!" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var auth = await response.Content.ReadFromJsonAsync(); + auth.Should().NotBeNull(); + auth!.AccessToken.Should().NotBeNullOrWhiteSpace(); + auth.User.Email.Should().Be(email); + auth.User.Id.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Register_WithDuplicateEmail_Returns400() + { + using var client = _factory.CreateClient(); + var email = $"dup-{Guid.NewGuid():N}@test.local"; + + var first = await client.PostAsJsonAsync("/api/auth/register", + new { email, password = "Password123!" }); + first.EnsureSuccessStatusCode(); + + var second = await client.PostAsJsonAsync("/api/auth/register", + new { email, password = "Password123!" }); + + second.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Theory] + [InlineData("not-an-email", "Password123!")] + [InlineData("ok@test.local", "short")] + public async Task Register_WithInvalidInput_Returns400(string email, string password) + { + using var client = _factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/auth/register", new { email, password }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Login_WithValidCredentials_Returns200_WithToken() + { + using var client = _factory.CreateClient(); + var email = $"login-{Guid.NewGuid():N}@test.local"; + const string password = "Password123!"; + + (await client.PostAsJsonAsync("/api/auth/register", new { email, password })) + .EnsureSuccessStatusCode(); + + var response = await client.PostAsJsonAsync("/api/auth/login", new { email, password }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var auth = await response.Content.ReadFromJsonAsync(); + auth!.AccessToken.Should().NotBeNullOrWhiteSpace(); + auth.User.Email.Should().Be(email); + } + + [Fact] + public async Task Login_WithWrongPassword_Returns401() + { + using var client = _factory.CreateClient(); + var email = $"wrong-{Guid.NewGuid():N}@test.local"; + + (await client.PostAsJsonAsync("/api/auth/register", + new { email, password = "Password123!" })).EnsureSuccessStatusCode(); + + var response = await client.PostAsJsonAsync("/api/auth/login", + new { email, password = "WrongPassword1!" }); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Me_WithoutToken_Returns401() + { + using var client = _factory.CreateClient(); + + var response = await client.GetAsync("/api/auth/me"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Me_WithToken_ReturnsUser() + { + var email = $"me-{Guid.NewGuid():N}@test.local"; + using var client = await _factory.CreateAuthenticatedClientAsync(email); + + var response = await client.GetAsync("/api/auth/me"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var user = await response.Content.ReadFromJsonAsync(); + user.Should().NotBeNull(); + user!.Email.Should().Be(email); + user.Id.Should().NotBeNullOrWhiteSpace(); + } +} \ No newline at end of file diff --git a/backend/Notes.Api.Tests/NotesApiFactory.cs b/backend/Notes.Api.Tests/NotesApiFactory.cs index 46fe5f8..c06038f 100644 --- a/backend/Notes.Api.Tests/NotesApiFactory.cs +++ b/backend/Notes.Api.Tests/NotesApiFactory.cs @@ -1,7 +1,12 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; + using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; +using Notes.Api.Auth; + namespace Notes.Api.Tests; public sealed class NotesApiFactory : WebApplicationFactory, IAsyncLifetime @@ -17,11 +22,40 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) config.AddInMemoryCollection(new Dictionary { ["Database:Provider"] = "Sqlite", - ["ConnectionStrings:Sqlite"] = $"Data Source={_dbPath}" + ["ConnectionStrings:Sqlite"] = $"Data Source={_dbPath}", + ["Jwt:Issuer"] = "notes-api-tests", + ["Jwt:Audience"] = "notes-api-tests", + ["Jwt:SigningKey"] = "test-signing-key-test-signing-key-test-0123", + ["Jwt:AccessTokenLifetimeMinutes"] = "60" }); }); } + public async Task CreateAuthenticatedClientAsync( + string? email = null, + string password = "Password123!") + { + var (client, _, _) = await CreateAuthenticatedClientWithUserAsync(email, password); + return client; + } + + public async Task<(HttpClient Client, string UserId, string Email)> CreateAuthenticatedClientWithUserAsync( + string? email = null, + string password = "Password123!") + { + email ??= $"user-{Guid.NewGuid():N}@test.local"; + var client = CreateClient(); + + var response = await client.PostAsJsonAsync("/api/auth/register", new { email, password }); + response.EnsureSuccessStatusCode(); + + var auth = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Failed to read AuthResponse"); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth.AccessToken); + return (client, auth.User.Id, auth.User.Email); + } + public Task InitializeAsync() => Task.CompletedTask; public new Task DisposeAsync() diff --git a/backend/Notes.Api.Tests/NotesAuthorizationTests.cs b/backend/Notes.Api.Tests/NotesAuthorizationTests.cs new file mode 100644 index 0000000..a821091 --- /dev/null +++ b/backend/Notes.Api.Tests/NotesAuthorizationTests.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Net.Http.Json; + +using FluentAssertions; + +using Notes.Api.Contracts; + +namespace Notes.Api.Tests; + +public sealed class NotesAuthorizationTests(NotesApiFactory factory) : IClassFixture +{ + private readonly NotesApiFactory _factory = factory; + + [Fact] + public async Task GetAll_WithoutToken_Returns401() + { + using var client = _factory.CreateClient(); + + var response = await client.GetAsync("/api/notes"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Notes_AreScopedToOwner() + { + var (clientA, _, _) = await _factory.CreateAuthenticatedClientWithUserAsync(); + var (clientB, _, _) = await _factory.CreateAuthenticatedClientWithUserAsync(); + + using (clientA) + using (clientB) + { + var createResp = await clientA.PostAsJsonAsync("/api/notes", + new CreateNoteRequest("A's note", "secret")); + createResp.EnsureSuccessStatusCode(); + var created = await createResp.Content.ReadFromJsonAsync(); + created.Should().NotBeNull(); + + var listResp = await clientB.GetAsync("/api/notes"); + listResp.StatusCode.Should().Be(HttpStatusCode.OK); + var bList = await listResp.Content.ReadFromJsonAsync>(); + bList.Should().NotBeNull(); + bList!.Should().BeEmpty(); + + var byIdResp = await clientB.GetAsync($"/api/notes/{created!.Id}"); + byIdResp.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } +} \ No newline at end of file diff --git a/backend/Notes.Api.Tests/NotesEndpointsTests.cs b/backend/Notes.Api.Tests/NotesEndpointsTests.cs index e9b2f37..4fabb28 100644 --- a/backend/Notes.Api.Tests/NotesEndpointsTests.cs +++ b/backend/Notes.Api.Tests/NotesEndpointsTests.cs @@ -11,26 +11,28 @@ namespace Notes.Api.Tests; public sealed class NotesEndpointsTests(NotesApiFactory factory) : IClassFixture { - private readonly HttpClient _client = factory.CreateClient(); + private readonly NotesApiFactory _factory = factory; [Fact] public async Task Get_ReturnsEmptyList_OnFreshDatabase() { - using var client = factory.CreateClient(); + using var client = await _factory.CreateAuthenticatedClientAsync(); var response = await client.GetAsync("/api/notes"); response.StatusCode.Should().Be(HttpStatusCode.OK); var notes = await response.Content.ReadFromJsonAsync>(); notes.Should().NotBeNull(); + notes!.Should().BeEmpty(); } [Fact] public async Task Post_WithValidBody_Returns201_WithLocationAndTimestamps() { + using var client = await _factory.CreateAuthenticatedClientAsync(); var request = new CreateNoteRequest("My title", "My content"); - var response = await _client.PostAsJsonAsync("/api/notes", request); + var response = await client.PostAsJsonAsync("/api/notes", request); response.StatusCode.Should().Be(HttpStatusCode.Created); response.Headers.Location.Should().NotBeNull(); @@ -47,9 +49,10 @@ public async Task Post_WithValidBody_Returns201_WithLocationAndTimestamps() [Fact] public async Task Post_WithMissingTitle_Returns400ValidationProblem() { + using var client = await _factory.CreateAuthenticatedClientAsync(); var body = new { Title = "", Content = "some content" }; - var response = await _client.PostAsJsonAsync("/api/notes", body); + var response = await client.PostAsJsonAsync("/api/notes", body); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); var problem = await response.Content.ReadFromJsonAsync(); @@ -60,7 +63,9 @@ public async Task Post_WithMissingTitle_Returns400ValidationProblem() [Fact] public async Task Get_WithUnknownId_Returns404() { - var response = await _client.GetAsync($"/api/notes/{Guid.NewGuid()}"); + using var client = await _factory.CreateAuthenticatedClientAsync(); + + var response = await client.GetAsync($"/api/notes/{Guid.NewGuid()}"); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } @@ -68,9 +73,10 @@ public async Task Get_WithUnknownId_Returns404() [Fact] public async Task Put_UpdatesExisting_And_Returns404ForUnknown() { - var created = await CreateNoteAsync("original", "old content"); + using var client = await _factory.CreateAuthenticatedClientAsync(); + var created = await CreateNoteAsync(client, "original", "old content"); - var updateResp = await _client.PutAsJsonAsync( + var updateResp = await client.PutAsJsonAsync( $"/api/notes/{created.Id}", new UpdateNoteRequest("updated", "new content")); @@ -80,7 +86,7 @@ public async Task Put_UpdatesExisting_And_Returns404ForUnknown() updated.Content.Should().Be("new content"); updated.UpdatedAt.Should().BeOnOrAfter(created.UpdatedAt); - var missingResp = await _client.PutAsJsonAsync( + var missingResp = await client.PutAsJsonAsync( $"/api/notes/{Guid.NewGuid()}", new UpdateNoteRequest("x", "y")); @@ -90,21 +96,22 @@ public async Task Put_UpdatesExisting_And_Returns404ForUnknown() [Fact] public async Task Delete_RemovesExisting_And_Returns404AfterwardsAndForUnknown() { - var created = await CreateNoteAsync("to-delete", "bye"); + using var client = await _factory.CreateAuthenticatedClientAsync(); + var created = await CreateNoteAsync(client, "to-delete", "bye"); - var deleteResp = await _client.DeleteAsync($"/api/notes/{created.Id}"); + var deleteResp = await client.DeleteAsync($"/api/notes/{created.Id}"); deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent); - var getResp = await _client.GetAsync($"/api/notes/{created.Id}"); + var getResp = await client.GetAsync($"/api/notes/{created.Id}"); getResp.StatusCode.Should().Be(HttpStatusCode.NotFound); - var unknownResp = await _client.DeleteAsync($"/api/notes/{Guid.NewGuid()}"); + var unknownResp = await client.DeleteAsync($"/api/notes/{Guid.NewGuid()}"); unknownResp.StatusCode.Should().Be(HttpStatusCode.NotFound); } - private async Task CreateNoteAsync(string title, string content) + private static async Task CreateNoteAsync(HttpClient client, string title, string content) { - var response = await _client.PostAsJsonAsync( + var response = await client.PostAsJsonAsync( "/api/notes", new CreateNoteRequest(title, content)); response.EnsureSuccessStatusCode(); diff --git a/backend/Notes.Api/Auth/AuthContracts.cs b/backend/Notes.Api/Auth/AuthContracts.cs new file mode 100644 index 0000000..6301b8c --- /dev/null +++ b/backend/Notes.Api/Auth/AuthContracts.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Notes.Api.Auth; + +public sealed record RegisterRequest( + [Required][EmailAddress] string Email, + [Required][StringLength(256, MinimumLength = 8)] string Password); + +public sealed record LoginRequest( + [Required][EmailAddress] string Email, + [Required] string Password); + +public sealed record UserDto(string Id, string Email); + +public sealed record AuthResponse(string AccessToken, DateTimeOffset ExpiresAt, UserDto User); \ No newline at end of file diff --git a/backend/Notes.Api/Auth/AuthController.cs b/backend/Notes.Api/Auth/AuthController.cs new file mode 100644 index 0000000..459ad39 --- /dev/null +++ b/backend/Notes.Api/Auth/AuthController.cs @@ -0,0 +1,81 @@ +using System.Security.Claims; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +using Notes.Api.Domain; + +namespace Notes.Api.Auth; + +[ApiController] +[Route("api/[controller]")] +public sealed class AuthController( + UserManager userManager, + IJwtTokenService tokens) : ControllerBase +{ + [HttpPost("register")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Register(RegisterRequest request, CancellationToken ct) + { + var user = new ApplicationUser + { + UserName = request.Email, + Email = request.Email + }; + + var result = await userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + { + var errors = new Dictionary(); + foreach (var err in result.Errors) + { + var key = err.Code switch + { + "DuplicateUserName" or "DuplicateEmail" or "InvalidEmail" => nameof(RegisterRequest.Email), + var c when c.StartsWith("Password", StringComparison.Ordinal) => nameof(RegisterRequest.Password), + _ => "" + }; + + errors[key] = !errors.TryGetValue(key, out var existing) + ? new[] { err.Description } + : existing.Append(err.Description).ToArray(); + } + + return ValidationProblem(new ValidationProblemDetails(errors)); + } + + return Ok(tokens.Issue(user)); + } + + [HttpPost("login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Login(LoginRequest request, CancellationToken ct) + { + var user = await userManager.FindByEmailAsync(request.Email); + if (user is null || !await userManager.CheckPasswordAsync(user, request.Password)) + { + return Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Invalid credentials"); + } + + return Ok(tokens.Issue(user)); + } + + [Authorize] + [HttpGet("me")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public ActionResult Me() + { + var id = User.FindFirstValue(ClaimTypes.NameIdentifier); + var email = User.FindFirstValue(ClaimTypes.Email); + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(email)) + { + return Unauthorized(); + } + + return Ok(new UserDto(id, email)); + } +} \ No newline at end of file diff --git a/backend/Notes.Api/Auth/IJwtTokenService.cs b/backend/Notes.Api/Auth/IJwtTokenService.cs new file mode 100644 index 0000000..6c7c50f --- /dev/null +++ b/backend/Notes.Api/Auth/IJwtTokenService.cs @@ -0,0 +1,8 @@ +using Notes.Api.Domain; + +namespace Notes.Api.Auth; + +public interface IJwtTokenService +{ + AuthResponse Issue(ApplicationUser user); +} \ No newline at end of file diff --git a/backend/Notes.Api/Auth/JwtOptions.cs b/backend/Notes.Api/Auth/JwtOptions.cs new file mode 100644 index 0000000..cc6ad88 --- /dev/null +++ b/backend/Notes.Api/Auth/JwtOptions.cs @@ -0,0 +1,11 @@ +namespace Notes.Api.Auth; + +public sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Issuer { get; init; } = ""; + public string Audience { get; init; } = ""; + public string SigningKey { get; init; } = ""; + public int AccessTokenLifetimeMinutes { get; init; } = 480; +} \ No newline at end of file diff --git a/backend/Notes.Api/Auth/JwtTokenService.cs b/backend/Notes.Api/Auth/JwtTokenService.cs new file mode 100644 index 0000000..e471ef2 --- /dev/null +++ b/backend/Notes.Api/Auth/JwtTokenService.cs @@ -0,0 +1,48 @@ +using System.Security.Claims; +using System.Text; + +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +using Notes.Api.Domain; + +namespace Notes.Api.Auth; + +public sealed class JwtTokenService(IOptions options) : IJwtTokenService +{ + private readonly JwtOptions _options = options.Value; + + public AuthResponse Issue(ApplicationUser user) + { + var now = DateTime.UtcNow; + var expires = now.AddMinutes(_options.AccessTokenLifetimeMinutes); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Email, user.Email ?? string.Empty), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var descriptor = new SecurityTokenDescriptor + { + Issuer = _options.Issuer, + Audience = _options.Audience, + NotBefore = now, + Expires = expires, + Subject = new ClaimsIdentity(claims), + SigningCredentials = creds + }; + + var accessToken = new JsonWebTokenHandler().CreateToken(descriptor); + + return new AuthResponse( + accessToken, + new DateTimeOffset(expires, TimeSpan.Zero), + new UserDto(user.Id, user.Email ?? string.Empty)); + } +} \ No newline at end of file diff --git a/backend/Notes.Api/Controllers/NotesController.cs b/backend/Notes.Api/Controllers/NotesController.cs index e5854be..c2887ea 100644 --- a/backend/Notes.Api/Controllers/NotesController.cs +++ b/backend/Notes.Api/Controllers/NotesController.cs @@ -1,3 +1,6 @@ +using System.Security.Claims; + +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -8,15 +11,21 @@ namespace Notes.Api.Controllers; [ApiController] +[Authorize] [Route("api/[controller]")] public sealed class NotesController(NotesDbContext db) : ControllerBase { + private string CurrentUserId => + User.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new UnauthorizedAccessException(); + [HttpGet] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetAll(CancellationToken ct) { + var userId = CurrentUserId; var notes = await db.Notes .AsNoTracking() + .Where(n => n.OwnerId == userId) .OrderByDescending(n => n.UpdatedAt) .Select(n => new NoteDto(n.Id, n.Title, n.Content, n.CreatedAt, n.UpdatedAt)) .ToListAsync(ct); @@ -29,9 +38,10 @@ public async Task>> GetAll(CancellationToken ct) [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> GetById(Guid id, CancellationToken ct) { + var userId = CurrentUserId; var note = await db.Notes .AsNoTracking() - .Where(n => n.Id == id) + .Where(n => n.Id == id && n.OwnerId == userId) .Select(n => new NoteDto(n.Id, n.Title, n.Content, n.CreatedAt, n.UpdatedAt)) .FirstOrDefaultAsync(ct); @@ -50,7 +60,8 @@ public async Task> Create(CreateNoteRequest request, Cance Title = request.Title, Content = request.Content, CreatedAt = now, - UpdatedAt = now + UpdatedAt = now, + OwnerId = CurrentUserId }; db.Notes.Add(note); @@ -66,7 +77,8 @@ public async Task> Create(CreateNoteRequest request, Cance [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Update(Guid id, UpdateNoteRequest request, CancellationToken ct) { - var note = await db.Notes.FirstOrDefaultAsync(n => n.Id == id, ct); + var userId = CurrentUserId; + var note = await db.Notes.FirstOrDefaultAsync(n => n.Id == id && n.OwnerId == userId, ct); if (note is null) { return NotFound(); @@ -86,7 +98,8 @@ public async Task> Update(Guid id, UpdateNoteRequest reque [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Delete(Guid id, CancellationToken ct) { - var note = await db.Notes.FirstOrDefaultAsync(n => n.Id == id, ct); + var userId = CurrentUserId; + var note = await db.Notes.FirstOrDefaultAsync(n => n.Id == id && n.OwnerId == userId, ct); if (note is null) { return NotFound(); diff --git a/backend/Notes.Api/Data/Configurations/NoteConfiguration.cs b/backend/Notes.Api/Data/Configurations/NoteConfiguration.cs index 459dbec..ef0119f 100644 --- a/backend/Notes.Api/Data/Configurations/NoteConfiguration.cs +++ b/backend/Notes.Api/Data/Configurations/NoteConfiguration.cs @@ -14,5 +14,12 @@ public void Configure(EntityTypeBuilder builder) builder.Property(n => n.Content).IsRequired(); builder.Property(n => n.CreatedAt).IsRequired(); builder.Property(n => n.UpdatedAt).IsRequired(); + builder.Property(n => n.OwnerId).IsRequired(); + builder.HasIndex(n => n.OwnerId); + builder + .HasOne() + .WithMany() + .HasForeignKey(n => n.OwnerId) + .OnDelete(DeleteBehavior.Cascade); } } \ No newline at end of file diff --git a/backend/Notes.Api/Data/NotesDbContext.cs b/backend/Notes.Api/Data/NotesDbContext.cs index 09d57b3..2f78dc8 100644 --- a/backend/Notes.Api/Data/NotesDbContext.cs +++ b/backend/Notes.Api/Data/NotesDbContext.cs @@ -1,15 +1,17 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Notes.Api.Domain; namespace Notes.Api.Data; -public sealed class NotesDbContext(DbContextOptions options) : DbContext(options) +public sealed class NotesDbContext(DbContextOptions options) : IdentityDbContext(options) { public DbSet Notes => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfigurationsFromAssembly(typeof(NotesDbContext).Assembly); } } \ No newline at end of file diff --git a/backend/Notes.Api/Domain/ApplicationUser.cs b/backend/Notes.Api/Domain/ApplicationUser.cs new file mode 100644 index 0000000..bc80806 --- /dev/null +++ b/backend/Notes.Api/Domain/ApplicationUser.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Identity; + +namespace Notes.Api.Domain; + +public class ApplicationUser : IdentityUser +{ +} \ No newline at end of file diff --git a/backend/Notes.Api/Domain/Note.cs b/backend/Notes.Api/Domain/Note.cs index d5e655a..96e64ea 100644 --- a/backend/Notes.Api/Domain/Note.cs +++ b/backend/Notes.Api/Domain/Note.cs @@ -7,4 +7,5 @@ public sealed class Note public string Content { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } + public string OwnerId { get; set; } = default!; } \ No newline at end of file diff --git a/backend/Notes.Api/Extensions/ServiceCollectionExtensions.cs b/backend/Notes.Api/Extensions/ServiceCollectionExtensions.cs index 589a463..51013a3 100644 --- a/backend/Notes.Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Notes.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,14 @@ +using System.Text; + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Notes.Api.Auth; using Notes.Api.Data; +using Notes.Api.Domain; namespace Notes.Api.Extensions; @@ -28,4 +36,59 @@ public static IServiceCollection AddPersistence(this IServiceCollection services return services; } + + public static IServiceCollection AddAppAuthentication(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(JwtOptions.SectionName)) + .Validate( + jwt => !string.IsNullOrWhiteSpace(jwt.SigningKey) + && Encoding.UTF8.GetByteCount(jwt.SigningKey) >= 32, + "Jwt:SigningKey must be at least 32 bytes.") + .ValidateOnStart(); + + services + .AddIdentityCore(options => + { + options.User.RequireUniqueEmail = true; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + options.Password.RequireLowercase = false; + options.Password.RequireDigit = false; + options.Password.RequiredLength = 8; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + services.AddSingleton(); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + + // Configure JwtBearer lazily from IOptions so the final + // (fully-layered) configuration is used — not the value observed at + // service-registration time. + services + .AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure>((bearer, jwtOptions) => + { + var jwt = jwtOptions.Value; + bearer.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwt.Issuer, + ValidAudience = jwt.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)), + ClockSkew = TimeSpan.FromSeconds(30) + }; + }); + + services.AddAuthorization(); + + return services; + } } \ No newline at end of file diff --git a/backend/Notes.Api/Migrations/SqlServer/20260418000922_AddIdentityAndNoteOwner.Designer.cs b/backend/Notes.Api/Migrations/SqlServer/20260418000922_AddIdentityAndNoteOwner.Designer.cs new file mode 100644 index 0000000..10cc6ec --- /dev/null +++ b/backend/Notes.Api/Migrations/SqlServer/20260418000922_AddIdentityAndNoteOwner.Designer.cs @@ -0,0 +1,320 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Notes.Api.Data; + +#nullable disable + +namespace Notes.Api.Migrations.SqlServer +{ + [DbContext(typeof(NotesDbContext))] + [Migration("20260418000922_AddIdentityAndNoteOwner")] + partial class AddIdentityAndNoteOwner + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Notes.Api.Domain.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Notes.Api.Domain.Note", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Notes.Api.Domain.Note", b => + { + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Notes.Api/Migrations/SqlServer/20260418000922_AddIdentityAndNoteOwner.cs b/backend/Notes.Api/Migrations/SqlServer/20260418000922_AddIdentityAndNoteOwner.cs new file mode 100644 index 0000000..02e0c1f --- /dev/null +++ b/backend/Notes.Api/Migrations/SqlServer/20260418000922_AddIdentityAndNoteOwner.cs @@ -0,0 +1,257 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Notes.Api.Migrations.SqlServer +{ + /// + public partial class AddIdentityAndNoteOwner : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OwnerId", + table: "Notes", + type: "nvarchar(450)", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Notes_OwnerId", + table: "Notes", + column: "OwnerId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + + migrationBuilder.AddForeignKey( + name: "FK_Notes_AspNetUsers_OwnerId", + table: "Notes", + column: "OwnerId", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Notes_AspNetUsers_OwnerId", + table: "Notes"); + + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + + migrationBuilder.DropIndex( + name: "IX_Notes_OwnerId", + table: "Notes"); + + migrationBuilder.DropColumn( + name: "OwnerId", + table: "Notes"); + } + } +} \ No newline at end of file diff --git a/backend/Notes.Api/Migrations/Sqlite/20260418001323_AddIdentityAndNoteOwner.Designer.cs b/backend/Notes.Api/Migrations/Sqlite/20260418001323_AddIdentityAndNoteOwner.Designer.cs new file mode 100644 index 0000000..c9a5ebe --- /dev/null +++ b/backend/Notes.Api/Migrations/Sqlite/20260418001323_AddIdentityAndNoteOwner.Designer.cs @@ -0,0 +1,309 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Notes.Api.Data; + +#nullable disable + +namespace Notes.Api.Migrations.Sqlite +{ + [DbContext(typeof(NotesDbContext))] + [Migration("20260418001323_AddIdentityAndNoteOwner")] + partial class AddIdentityAndNoteOwner + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.6"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Notes.Api.Domain.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Notes.Api.Domain.Note", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Notes.Api.Domain.Note", b => + { + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Notes.Api/Migrations/Sqlite/20260418001323_AddIdentityAndNoteOwner.cs b/backend/Notes.Api/Migrations/Sqlite/20260418001323_AddIdentityAndNoteOwner.cs new file mode 100644 index 0000000..f219baa --- /dev/null +++ b/backend/Notes.Api/Migrations/Sqlite/20260418001323_AddIdentityAndNoteOwner.cs @@ -0,0 +1,257 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Notes.Api.Migrations.Sqlite +{ + /// + public partial class AddIdentityAndNoteOwner : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OwnerId", + table: "Notes", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + Email = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "TEXT", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "INTEGER", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: true), + SecurityStamp = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + PhoneNumber = table.Column(type: "TEXT", nullable: true), + PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), + TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), + LockoutEnd = table.Column(type: "TEXT", nullable: true), + LockoutEnabled = table.Column(type: "INTEGER", nullable: false), + AccessFailedCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoleId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "TEXT", nullable: false), + ProviderKey = table.Column(type: "TEXT", nullable: false), + ProviderDisplayName = table.Column(type: "TEXT", nullable: true), + UserId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + RoleId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + LoginProvider = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Notes_OwnerId", + table: "Notes", + column: "OwnerId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.Sql("DELETE FROM \"Notes\" WHERE \"OwnerId\" = '';"); + + migrationBuilder.AddForeignKey( + name: "FK_Notes_AspNetUsers_OwnerId", + table: "Notes", + column: "OwnerId", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Notes_AspNetUsers_OwnerId", + table: "Notes"); + + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + + migrationBuilder.DropIndex( + name: "IX_Notes_OwnerId", + table: "Notes"); + + migrationBuilder.DropColumn( + name: "OwnerId", + table: "Notes"); + } + } +} \ No newline at end of file diff --git a/backend/Notes.Api/Migrations/Sqlite/NotesDbContextModelSnapshot.cs b/backend/Notes.Api/Migrations/Sqlite/NotesDbContextModelSnapshot.cs index 9dc1c63..534da63 100644 --- a/backend/Notes.Api/Migrations/Sqlite/NotesDbContextModelSnapshot.cs +++ b/backend/Notes.Api/Migrations/Sqlite/NotesDbContextModelSnapshot.cs @@ -1,8 +1,7 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Notes.Api.Data; @@ -18,6 +17,198 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.6"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Notes.Api.Domain.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + modelBuilder.Entity("Notes.Api.Domain.Note", b => { b.Property("Id") @@ -31,6 +222,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("TEXT"); + b.Property("OwnerId") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("Title") .IsRequired() .HasMaxLength(200) @@ -41,8 +236,70 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("OwnerId"); + b.ToTable("Notes"); }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Notes.Api.Domain.Note", b => + { + b.HasOne("Notes.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 } } diff --git a/backend/Notes.Api/Notes.Api.csproj b/backend/Notes.Api/Notes.Api.csproj index 09c14a9..2a20222 100644 --- a/backend/Notes.Api/Notes.Api.csproj +++ b/backend/Notes.Api/Notes.Api.csproj @@ -7,6 +7,8 @@ + + diff --git a/backend/Notes.Api/Program.cs b/backend/Notes.Api/Program.cs index 5eb719d..efe1a84 100644 --- a/backend/Notes.Api/Program.cs +++ b/backend/Notes.Api/Program.cs @@ -8,6 +8,7 @@ builder.Services.AddControllers(); builder.Services.AddOpenApi(); builder.Services.AddPersistence(builder.Configuration); +builder.Services.AddAppAuthentication(builder.Configuration); builder.Services.AddCors(options => { options.AddPolicy("Frontend", policy => policy @@ -29,6 +30,7 @@ app.UseHttpsRedirection(); app.UseCors("Frontend"); +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); diff --git a/backend/Notes.Api/appsettings.Development.json b/backend/Notes.Api/appsettings.Development.json index 0c208ae..65f4437 100644 --- a/backend/Notes.Api/appsettings.Development.json +++ b/backend/Notes.Api/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Jwt": { + "SigningKey": "dev-only-signing-key-change-me-please-0123456789" } } diff --git a/backend/Notes.Api/appsettings.json b/backend/Notes.Api/appsettings.json index 5ed75e1..54b8865 100644 --- a/backend/Notes.Api/appsettings.json +++ b/backend/Notes.Api/appsettings.json @@ -12,5 +12,11 @@ "ConnectionStrings": { "Sqlite": "Data Source=notes.db", "SqlServer": "" + }, + "Jwt": { + "Issuer": "notes-api", + "Audience": "notes-api", + "SigningKey": "", + "AccessTokenLifetimeMinutes": 480 } } diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..45625aa --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,219 @@ +# Deployment + +How the Notes app is deployed to Azure. + +## Architecture + +```text +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Static Web │ │ App Service │ │ Azure SQL │ +│ App │─────▶│ (Linux, F1) │─────▶│ Serverless │ +│ (Free) │ CORS │ .NET 10 │ MI │ auto-pause │ +└──────────────┘ └──────┬───────┘ └──────────────┘ + │ MI + ▼ + ┌──────────────┐ + │ Key Vault │ + │ (JWT key) │ + └──────────────┘ + │ + ┌──────┴───────┐ + │ App Insights + Log Analytics │ + └──────────────────────────────┘ +``` + +| Layer | Resource | SKU | Cost (idle → active) | +| ------------- | ---------------------------- | -------------------------------------------------------------- | -------------------- | +| Frontend | Static Web Apps | Free | $0 | +| Backend | App Service (Linux) | F1 | $0 | +| Database | Azure SQL | GP Serverless, 1 vCore, auto-pause 60 min, `useFreeLimit=true` | ~$0 when paused | +| Secrets | Key Vault | Standard | ~$0.03/mo | +| Observability | Log Analytics + App Insights | Pay-as-you-go | ~$0 under free quota | + +**Region:** `eastus2` · **Environments:** `dev`, `prod` + +### Known tradeoffs of the free tier + +- **SWA Free does not support linked backends.** The React app calls the App Service URL directly with CORS. The API hostname is visible to the browser. +- **F1 App Service** has no Always On, 60 min CPU/day, 1 GB RAM, shared infra. First request after idle (combined with SQL auto-pause) can take 30–90 s. +- Upgrading to **B1 App Service (~$13/mo) + SWA Standard (~$9/mo)** eliminates cold starts and enables same-origin `/api` proxying. + +## Security model + +- **GitHub → Azure:** OIDC federated identity. No client secrets. +- **App Service → SQL:** system-assigned managed identity; Entra-only auth (`Authentication=Active Directory Default`). No password anywhere. +- **App Service → Key Vault:** managed identity with `Key Vault Secrets User` role. JWT signing key is a Key Vault reference in app settings. +- No secrets in source control. No secrets in `.bicepparam` files. Environment-scoped variables only. + +## Repository layout + +```text +infra/ +├── main.bicep subscription-scope; creates RG + workload +├── main.dev.bicepparam dev params (reads env vars, no literals) +├── main.prod.bicepparam prod params +├── abbreviations.json CAF naming prefixes +├── bootstrap/ +│ └── setup-oidc.sh one-time: create GitHub OIDC app regs +└── modules/ + ├── workload.bicep orchestrator + ├── log-analytics.bicep + ├── app-insights.bicep + ├── key-vault.bicep + ├── sql.bicep Entra-only, serverless, free limit + ├── app-service.bicep Linux F1, MI, KV ref, CORS + └── static-web-app.bicep + +.github/workflows/ +├── ci.yml PR/push: build+test backend, frontend, bicep +├── infra-validate.yml PR touching infra/**: what-if to PR summary +├── cd.yml push to main: build → deploy-dev → deploy-prod +└── _deploy.yml reusable deploy job +``` + +## First-time setup + +### Prerequisites + +- Azure CLI (`az`) logged in as a user with: + - **Owner** or **User Access Administrator** on the target subscription + - **Application Developer** (or better) in Entra ID +- GitHub `gh` CLI (optional, for setting env vars from the terminal) + +### 1. Bootstrap OIDC (once per environment) + +Creates an Entra app registration, federated credentials scoped to the GitHub environment, and assigns `Contributor` + `User Access Administrator` at subscription scope. + +```bash +infra/bootstrap/setup-oidc.sh amis-4630 sample-app dev +infra/bootstrap/setup-oidc.sh amis-4630 sample-app prod +``` + +The script prints the exact variable values to paste into each GitHub Environment. + +### 2. Configure GitHub Environments + +In `https://github.com/amis-4630/sample-app/settings/environments`, create `dev` and `prod` environments and add these **variables** (not secrets — none are sensitive): + +| Name | Value (dev example) | Source | +| -------------------------- | ---------------------- | ------------------------------------------------------------ | +| `AZURE_CLIENT_ID` | OIDC app reg client ID | bootstrap script output | +| `AZURE_TENANT_ID` | your tenant ID | bootstrap script output | +| `AZURE_SUBSCRIPTION_ID` | target subscription | bootstrap script output | +| `AZURE_RESOURCE_GROUP` | `rg-notes-dev` | bootstrap script output | +| `AZURE_LOCATION` | `eastus2` | bootstrap script output | +| `AZURE_DEPLOYER_OBJECT_ID` | OIDC SP object ID | bootstrap script output | +| `SQL_ADMIN_OBJECT_ID` | your user object ID | `az ad signed-in-user show --query id -o tsv` | +| `SQL_ADMIN_PRINCIPAL_NAME` | your UPN | `az ad signed-in-user show --query userPrincipalName -o tsv` | + +Enable **Required reviewers** on `prod`. + +### 3. First deploy + +Push to `main`. The `cd.yml` workflow: + +1. Builds the backend (`dotnet publish`) and uploads `api.zip`. +2. Builds the frontend (sanity check). +3. Calls `_deploy.yml` for `dev`: + - Ensures the resource group. + - Runs `az deployment group create` with `main.dev.bicepparam`. + - Seeds `jwt-signing-key` in Key Vault if missing (`openssl rand`). + - Creates the App Service managed identity as a contained SQL user and grants `db_datareader/writer/ddladmin` via `sqlcmd` + Entra access token (runner IP is temporarily whitelisted and removed). + - Deploys the API zip. + - Rebuilds the frontend with `VITE_API_BASE_URL` set to the real App Service hostname and uploads to SWA. +4. On success, calls `_deploy.yml` for `prod` behind the reviewer gate. + +## Day-to-day operations + +### Validate Bicep locally + +```bash +az bicep build --file infra/main.bicep +az bicep build-params --file infra/main.dev.bicepparam +``` + +### Preview changes (what-if) + +Any PR that touches `infra/**` triggers `infra-validate.yml`, which posts a what-if diff to the PR summary. To run locally: + +```bash +export SQL_ADMIN_OBJECT_ID=$(az ad signed-in-user show --query id -o tsv) +export SQL_ADMIN_PRINCIPAL_NAME=$(az ad signed-in-user show --query userPrincipalName -o tsv) +az deployment group what-if \ + --resource-group rg-notes-dev \ + --template-file infra/main.bicep \ + --parameters infra/main.dev.bicepparam +``` + +### Manually trigger a deploy + +`Actions → CD → Run workflow`, choose the environment. + +### Rotate the JWT signing key + +```bash +az keyvault secret set \ + --vault-name \ + --name jwt-signing-key \ + --value "$(openssl rand -base64 64 | tr -d '\n')" +az webapp restart --name --resource-group rg-notes- +``` + +Sessions issued with the previous key become invalid on restart. + +### Tail logs + +```bash +az webapp log tail --name --resource-group rg-notes- +``` + +Or query App Insights via the `>` resource in the portal. + +### Tear down an environment + +```bash +az group delete --name rg-notes-dev --yes --no-wait +``` + +Key Vault soft-delete is 7 days; if you recreate immediately, use a different name suffix or purge: + +```bash +az keyvault purge --name +``` + +## App-code contract + +The Bicep and workflows assume the application honors these environment variables / configuration keys. They are set by [infra/modules/app-service.bicep](../infra/modules/app-service.bicep). + +| Setting | Purpose | +| ------------------------------------------ | --------------------------------------------------------------------------------------- | +| `ASPNETCORE_ENVIRONMENT=Production` | Standard | +| `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` | App Service terminates TLS; the app must honor `X-Forwarded-*` | +| `APPLICATIONINSIGHTS_CONNECTION_STRING` | App Insights auto-instrumentation | +| `Database__Provider=SqlServer` | Switches EF Core provider | +| `ConnectionStrings__SqlServer` | Passwordless: `Server=tcp:...;Authentication=Active Directory Default;Encrypt=True;...` | +| `Jwt__SigningKey` | `@Microsoft.KeyVault(VaultName=...;SecretName=jwt-signing-key)` | +| `Cors__AllowedOrigins__0` | SWA hostname (`https://.azurestaticapps.net`) | +| `RUN_MIGRATIONS_ON_START=true` | Opt-in flag for `Database.Migrate()` on startup | + +The frontend receives `VITE_API_BASE_URL` at build time (stamped in by [.github/workflows/\_deploy.yml](../.github/workflows/_deploy.yml)). + +### Outstanding app-code work + +The infra deploys successfully today, but the app will not start until these are implemented: + +- [ ] `backend/Notes.Api/Program.cs`: read CORS origins from `Cors:AllowedOrigins` instead of hardcoded `localhost:5173`; add `UseForwardedHeaders` guarded on `ASPNETCORE_FORWARDEDHEADERS_ENABLED`; guard `db.Database.Migrate()` with `RUN_MIGRATIONS_ON_START` instead of `IsDevelopment`. +- [ ] `AddPersistence`: when `Database:Provider=SqlServer`, pass the connection string to `UseSqlServer` as-is (EF Core 10 / SqlClient 5 handle `Authentication=Active Directory Default`). +- [ ] `frontend/src/api/http.ts`: prefix request URLs with `import.meta.env.VITE_API_BASE_URL`. + +## Troubleshooting + +**`Error: jwt-signing-key not found`** — first deploy should seed it; re-run the `Seed JWT signing key` step or create manually with `az keyvault secret set`. + +**App returns 500 on every request, logs show `Login failed for user ''`** — the App Service managed identity wasn't granted to the SQL database. Re-run the `Grant App Service MI access to SQL DB` step. Confirm the app name matches the SQL user (the step uses the App Service name, which is the default AAD login name for its MI). + +**SQL deploy fails with `useFreeLimit` error** — the free serverless offer is limited to one database per subscription. Set `useFreeLimit: false` in [infra/modules/sql.bicep](../infra/modules/sql.bicep) or consume the offer elsewhere. + +**SWA deploy succeeds but app shows CORS errors** — confirm `Cors__AllowedOrigins__0` on the App Service matches the actual SWA hostname; the Bicep wires this automatically but a manually edited setting will drift. + +**OIDC login fails with `AADSTS70021`** — the federated credential subject doesn't match. Verify the GitHub repo, owner, and environment name exactly match what was registered by `setup-oidc.sh`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 66b381b..ed79656 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,14 +3,21 @@ import { Layout } from "./components/Layout"; import { NotesListPage } from "./pages/NotesListPage"; import { NoteCreatePage } from "./pages/NoteCreatePage"; import { NoteEditPage } from "./pages/NoteEditPage"; +import { LoginPage } from "./pages/LoginPage"; +import { RegisterPage } from "./pages/RegisterPage"; +import { ProtectedRoute } from "./auth/ProtectedRoute"; export function App() { return ( - }> - } /> - } /> - } /> + } /> + } /> + }> + }> + } /> + } /> + } /> + ); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..d3f73e7 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,26 @@ +import { request } from './http' + +export type UserDto = { id: string; email: string } +export type AuthResponse = { + accessToken: string + expiresAt: string + user: UserDto +} + +export function register(email: string, password: string): Promise { + return request('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ email, password }), + }) +} + +export function login(email: string, password: string): Promise { + return request('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }) +} + +export function me(): Promise { + return request('/api/auth/me') +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts new file mode 100644 index 0000000..3384790 --- /dev/null +++ b/frontend/src/api/http.ts @@ -0,0 +1,61 @@ +export class UnauthorizedError extends Error { + constructor(message = 'Unauthorized') { + super(message) + this.name = 'UnauthorizedError' + } +} + +type AuthHooks = { + getToken: () => string | null + onUnauthorized: () => void +} + +let getToken: () => string | null = () => null +let onUnauthorized: () => void = () => { } + +export function setAuthHooks(hooks: AuthHooks): void { + getToken = hooks.getToken + onUnauthorized = hooks.onUnauthorized +} + +async function extractErrorMessage(response: Response): Promise { + try { + const data = await response.json() + if (data && typeof data === 'object') { + if (typeof (data as { detail?: unknown }).detail === 'string') + return (data as { detail: string }).detail + if (typeof (data as { title?: unknown }).title === 'string') + return (data as { title: string }).title + } + } catch { + // fall through + } + return `Request failed with status ${response.status}` +} + +export async function request(url: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers) + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + const token = getToken() + if (token && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`) + } + + const response = await fetch(url, { ...init, headers }) + + if (response.status === 401) { + onUnauthorized() + const message = await extractErrorMessage(response) + throw new UnauthorizedError(message) + } + + if (!response.ok) { + const message = await extractErrorMessage(response) + throw new Error(message) + } + + if (response.status === 204) return undefined as T + return (await response.json()) as T +} diff --git a/frontend/src/api/notes.ts b/frontend/src/api/notes.ts index 67f0312..4e8ce39 100644 --- a/frontend/src/api/notes.ts +++ b/frontend/src/api/notes.ts @@ -1,3 +1,5 @@ +import { request } from './http' + export type Note = { id: string title: string @@ -11,32 +13,6 @@ export type NoteInput = { content: string } -async function extractErrorMessage(response: Response): Promise { - try { - const data = await response.json() - if (data && typeof data === 'object') { - if (typeof data.detail === 'string') return data.detail - if (typeof data.title === 'string') return data.title - } - } catch { - // fall through - } - return `Request failed with status ${response.status}` -} - -async function request(url: string, init?: RequestInit): Promise { - const response = await fetch(url, { - headers: { 'Content-Type': 'application/json' }, - ...init, - }) - if (!response.ok) { - const message = await extractErrorMessage(response) - throw new Error(message) - } - if (response.status === 204) return undefined as T - return (await response.json()) as T -} - export function listNotes(): Promise { return request('/api/notes') } diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx new file mode 100644 index 0000000..551e088 --- /dev/null +++ b/frontend/src/auth/AuthContext.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import * as authApi from "../api/auth"; +import { setAuthHooks } from "../api/http"; +import { + AuthContext, + type AuthContextValue, + type AuthState, +} from "./authContextInternal"; +import { + clearStoredToken, + readStoredToken, + writeStoredToken, +} from "./tokenStorage"; + +// Module-level state keeps the HTTP layer's view of the current token and the +// unauthorized handler in sync with the provider, without requiring refs to be +// written during render. `setAuthHooks` is called once at module load so the +// first outbound request from any child carries whatever token the provider +// writes synchronously during its `useState` initializer. +let currentToken: string | null = null; +let currentOnUnauthorized: () => void = () => {}; + +setAuthHooks({ + getToken: () => currentToken, + onUnauthorized: () => currentOnUnauthorized(), +}); + +type AuthProviderProps = { + children: ReactNode; +}; + +export function AuthProvider({ children }: AuthProviderProps) { + const queryClient = useQueryClient(); + + // Initializer runs synchronously before children render, so the module-level + // token is populated before any child can fire a request. + const [state, setState] = useState(() => { + const stored = readStoredToken(); + currentToken = stored; + return stored ? { status: "loading" } : { status: "unauthenticated" }; + }); + + const handleUnauthorized = useCallback(() => { + clearStoredToken(); + currentToken = null; + setState({ status: "unauthenticated" }); + queryClient.clear(); + }, [queryClient]); + + useEffect(() => { + currentOnUnauthorized = handleUnauthorized; + }, [handleUnauthorized]); + + // Bootstrap: if we started with a stored token, verify it with /api/auth/me. + useEffect(() => { + if (state.status !== "loading") return; + const bootstrapToken = currentToken; + if (!bootstrapToken) return; + let cancelled = false; + authApi + .me() + .then((user) => { + if (cancelled) return; + setState({ status: "authenticated", user, token: bootstrapToken }); + }) + .catch(() => { + if (cancelled) return; + clearStoredToken(); + currentToken = null; + setState({ status: "unauthenticated" }); + }); + return () => { + cancelled = true; + }; + // Bootstrap should only run once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const login = useCallback(async (email: string, password: string) => { + const response = await authApi.login(email, password); + writeStoredToken(response.accessToken); + currentToken = response.accessToken; + setState({ + status: "authenticated", + user: response.user, + token: response.accessToken, + }); + }, []); + + const register = useCallback(async (email: string, password: string) => { + const response = await authApi.register(email, password); + writeStoredToken(response.accessToken); + currentToken = response.accessToken; + setState({ + status: "authenticated", + user: response.user, + token: response.accessToken, + }); + }, []); + + const logout = useCallback(() => { + clearStoredToken(); + currentToken = null; + setState({ status: "unauthenticated" }); + queryClient.clear(); + }, [queryClient]); + + const value: AuthContextValue = { ...state, login, register, logout }; + + return {children}; +} diff --git a/frontend/src/auth/ProtectedRoute.test.tsx b/frontend/src/auth/ProtectedRoute.test.tsx new file mode 100644 index 0000000..b85e0f1 --- /dev/null +++ b/frontend/src/auth/ProtectedRoute.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import { Route, Routes } from "react-router-dom"; +import { renderWithProviders } from "../test/renderWithProviders"; +import { ProtectedRoute } from "./ProtectedRoute"; + +function LoginProbe() { + return
Login
; +} + +function ProtectedContent() { + return
Secret
; +} + +describe("ProtectedRoute", () => { + it("redirects to /login when unauthenticated", async () => { + renderWithProviders( + + } /> + }> + } /> + + , + { initialEntries: ["/"], initialAuth: null }, + ); + + expect(await screen.findByTestId("login-page")).toBeInTheDocument(); + expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument(); + }); + + it("renders child routes when authenticated", async () => { + renderWithProviders( + + } /> + }> + } /> + + , + { initialEntries: ["/"] }, + ); + + expect(await screen.findByTestId("protected-content")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/auth/ProtectedRoute.tsx b/frontend/src/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..2500ada --- /dev/null +++ b/frontend/src/auth/ProtectedRoute.tsx @@ -0,0 +1,25 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useAuth } from "./useAuth"; + +export function ProtectedRoute() { + const auth = useAuth(); + const location = useLocation(); + + if (auth.status === "loading") { + return ( +
+ Loading… +
+ ); + } + + if (auth.status === "unauthenticated") { + return ; + } + + return ; +} diff --git a/frontend/src/auth/authContextInternal.ts b/frontend/src/auth/authContextInternal.ts new file mode 100644 index 0000000..5d0766b --- /dev/null +++ b/frontend/src/auth/authContextInternal.ts @@ -0,0 +1,15 @@ +import { createContext } from "react"; +import type { UserDto } from "../api/auth"; + +export type AuthState = + | { status: "loading" } + | { status: "unauthenticated" } + | { status: "authenticated"; user: UserDto; token: string }; + +export type AuthContextValue = AuthState & { + login: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; + logout: () => void; +}; + +export const AuthContext = createContext(null); diff --git a/frontend/src/auth/tokenStorage.ts b/frontend/src/auth/tokenStorage.ts new file mode 100644 index 0000000..1a586ea --- /dev/null +++ b/frontend/src/auth/tokenStorage.ts @@ -0,0 +1,25 @@ +export const TOKEN_STORAGE_KEY = "notes.authToken"; + +export function readStoredToken(): string | null { + try { + return localStorage.getItem(TOKEN_STORAGE_KEY); + } catch { + return null; + } +} + +export function writeStoredToken(token: string): void { + try { + localStorage.setItem(TOKEN_STORAGE_KEY, token); + } catch { + // ignore + } +} + +export function clearStoredToken(): void { + try { + localStorage.removeItem(TOKEN_STORAGE_KEY); + } catch { + // ignore + } +} diff --git a/frontend/src/auth/useAuth.ts b/frontend/src/auth/useAuth.ts new file mode 100644 index 0000000..e604ed7 --- /dev/null +++ b/frontend/src/auth/useAuth.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { AuthContext, type AuthContextValue } from "./authContextInternal"; + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +} diff --git a/frontend/src/components/Layout.test.tsx b/frontend/src/components/Layout.test.tsx new file mode 100644 index 0000000..c498af2 --- /dev/null +++ b/frontend/src/components/Layout.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Route, Routes } from "react-router-dom"; +import { renderWithProviders } from "../test/renderWithProviders"; +import { TOKEN_STORAGE_KEY } from "../auth/tokenStorage"; +import { SEED_USER_EMAIL } from "../test/handlers"; +import { Layout } from "./Layout"; + +function HomeProbe() { + return
Home
; +} + +function LoginProbe() { + return
Login
; +} + +describe("Layout", () => { + it("shows the authenticated user's email", async () => { + renderWithProviders( + + }> + } /> + + , + { initialEntries: ["/"] }, + ); + + expect(await screen.findByText(SEED_USER_EMAIL)).toBeInTheDocument(); + }); + + it("signs out when the Sign out button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders( + + }> + } /> + + } /> + , + { initialEntries: ["/"] }, + ); + + await user.click(await screen.findByRole("button", { name: /sign out/i })); + + await waitFor(() => { + expect(screen.getByTestId("login-page")).toBeInTheDocument(); + }); + expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull(); + expect(screen.queryByText(SEED_USER_EMAIL)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 63cca03..2d9711a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,16 @@ -import { Link, Outlet } from "react-router-dom"; +import { Link, Outlet, useNavigate } from "react-router-dom"; +import { useAuth } from "../auth/useAuth"; +import { Button } from "./Button"; export function Layout() { + const auth = useAuth(); + const navigate = useNavigate(); + + function handleSignOut() { + auth.logout(); + navigate("/login"); + } + return (
@@ -8,12 +18,24 @@ export function Layout() { Notes - - New note - +
+ + New note + + {auth.status === "authenticated" && ( + <> + + {auth.user.email} + + + + )} +
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e55e910..bf8602c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import "./index.css"; import { App } from "./App.tsx"; +import { AuthProvider } from "./auth/AuthContext"; const queryClient = new QueryClient({ defaultOptions: { @@ -19,7 +20,9 @@ createRoot(document.getElementById("root")!).render( - + + + diff --git a/frontend/src/pages/LoginPage.test.tsx b/frontend/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..cab14e9 --- /dev/null +++ b/frontend/src/pages/LoginPage.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Route, Routes } from "react-router-dom"; +import { renderWithProviders } from "../test/renderWithProviders"; +import { SEED_USER_EMAIL, SEED_USER_PASSWORD } from "../test/handlers"; +import { LoginPage } from "./LoginPage"; + +function HomeProbe() { + return
Home
; +} + +describe("LoginPage", () => { + it("renders the Sign in heading", async () => { + renderWithProviders(, { + initialEntries: ["/login"], + initialAuth: null, + }); + + expect( + await screen.findByRole("heading", { name: /sign in/i }), + ).toBeInTheDocument(); + }); + + it("shows validation errors when submitting empty form", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + initialEntries: ["/login"], + initialAuth: null, + }); + + await user.click(await screen.findByRole("button", { name: /sign in/i })); + + expect(await screen.findByText(/email is required/i)).toBeInTheDocument(); + expect(screen.getByText(/password is required/i)).toBeInTheDocument(); + }); + + it("signs in with valid credentials and navigates home", async () => { + const user = userEvent.setup(); + renderWithProviders( + + } /> + } /> + , + { initialEntries: ["/login"], initialAuth: null }, + ); + + await user.type(await screen.findByLabelText(/email/i), SEED_USER_EMAIL); + await user.type(screen.getByLabelText(/password/i), SEED_USER_PASSWORD); + await user.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => { + expect(screen.getByTestId("home-page")).toBeInTheDocument(); + }); + }); + + it("shows an error message for invalid credentials", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + initialEntries: ["/login"], + initialAuth: null, + }); + + await user.type(await screen.findByLabelText(/email/i), SEED_USER_EMAIL); + await user.type(screen.getByLabelText(/password/i), "wrong-password"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + + expect( + await screen.findByText(/invalid email or password/i), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..39641a3 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,154 @@ +import { useState, type FormEvent } from "react"; +import { + Link, + useLocation, + useNavigate, + type Location, +} from "react-router-dom"; +import { Button } from "../components/Button"; +import { useAuth } from "../auth/useAuth"; +import { UnauthorizedError } from "../api/http"; + +type FieldErrors = { + email?: string; + password?: string; +}; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function validate(email: string, password: string): FieldErrors { + const errors: FieldErrors = {}; + const trimmed = email.trim(); + if (!trimmed) errors.email = "Email is required."; + else if (!EMAIL_PATTERN.test(trimmed)) + errors.email = "Enter a valid email address."; + if (!password) errors.password = "Password is required."; + return errors; +} + +type LocationState = { from?: Location } | null; + +export function LoginPage() { + const auth = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [errors, setErrors] = useState({}); + const [attempted, setAttempted] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setAttempted(true); + setSubmitError(null); + const nextErrors = validate(email, password); + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) return; + + setIsSubmitting(true); + try { + await auth.login(email.trim(), password); + const from = (location.state as LocationState)?.from?.pathname ?? "/"; + navigate(from, { replace: true }); + } catch (err) { + if (err instanceof UnauthorizedError) { + setSubmitError("Invalid email or password."); + } else if (err instanceof Error) { + setSubmitError(err.message); + } else { + setSubmitError("Sign in failed."); + } + } finally { + setIsSubmitting(false); + } + } + + const showErrors = attempted; + + return ( +
+

Sign in

+
+
+ + setEmail(e.target.value)} + aria-invalid={Boolean(showErrors && errors.email)} + aria-describedby={ + showErrors && errors.email ? "login-email-error" : undefined + } + className="mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" + /> + {showErrors && errors.email && ( +

+ {errors.email} +

+ )} +
+ +
+ + setPassword(e.target.value)} + aria-invalid={Boolean(showErrors && errors.password)} + aria-describedby={ + showErrors && errors.password ? "login-password-error" : undefined + } + className="mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" + /> + {showErrors && errors.password && ( +

+ {errors.password} +

+ )} +
+ + {submitError && ( +

+ {submitError} +

+ )} + + +
+

+ Don’t have an account?{" "} + + Register + +

+
+ ); +} diff --git a/frontend/src/pages/RegisterPage.test.tsx b/frontend/src/pages/RegisterPage.test.tsx new file mode 100644 index 0000000..4b88570 --- /dev/null +++ b/frontend/src/pages/RegisterPage.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Route, Routes } from "react-router-dom"; +import { renderWithProviders } from "../test/renderWithProviders"; +import { defaultAuthStore, SEED_USER_EMAIL } from "../test/handlers"; +import { RegisterPage } from "./RegisterPage"; + +function HomeProbe() { + return
Home
; +} + +describe("RegisterPage", () => { + it("registers a new user and navigates home", async () => { + const user = userEvent.setup(); + renderWithProviders( + + } /> + } /> + , + { initialEntries: ["/register"], initialAuth: null }, + ); + + await user.type( + await screen.findByLabelText(/email/i), + "new-user@test.local", + ); + await user.type(screen.getByLabelText(/password/i), "Password123!"); + await user.click(screen.getByRole("button", { name: /create account/i })); + + await waitFor(() => { + expect(screen.getByTestId("home-page")).toBeInTheDocument(); + }); + + expect(defaultAuthStore.users.has("new-user@test.local")).toBe(true); + }); + + it("shows a client-side error for a short password", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + initialEntries: ["/register"], + initialAuth: null, + }); + + await user.type( + await screen.findByLabelText(/email/i), + "someone@test.local", + ); + await user.type(screen.getByLabelText(/password/i), "short"); + await user.click(screen.getByRole("button", { name: /create account/i })); + + expect( + await screen.findByText(/at least 8 characters/i), + ).toBeInTheDocument(); + }); + + it("shows a server error when the email is already taken", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + initialEntries: ["/register"], + initialAuth: null, + }); + + await user.type(await screen.findByLabelText(/email/i), SEED_USER_EMAIL); + await user.type(screen.getByLabelText(/password/i), "Password123!"); + await user.click(screen.getByRole("button", { name: /create account/i })); + + expect(await screen.findByText(/already registered/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..aa02b3e --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,159 @@ +import { useState, type FormEvent } from "react"; +import { + Link, + useLocation, + useNavigate, + type Location, +} from "react-router-dom"; +import { Button } from "../components/Button"; +import { useAuth } from "../auth/useAuth"; + +type FieldErrors = { + email?: string; + password?: string; +}; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const PASSWORD_MIN = 8; + +function validate(email: string, password: string): FieldErrors { + const errors: FieldErrors = {}; + const trimmed = email.trim(); + if (!trimmed) errors.email = "Email is required."; + else if (!EMAIL_PATTERN.test(trimmed)) + errors.email = "Enter a valid email address."; + if (!password) errors.password = "Password is required."; + else if (password.length < PASSWORD_MIN) + errors.password = `Password must be at least ${PASSWORD_MIN} characters.`; + return errors; +} + +type LocationState = { from?: Location } | null; + +export function RegisterPage() { + const auth = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [errors, setErrors] = useState({}); + const [attempted, setAttempted] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setAttempted(true); + setSubmitError(null); + const nextErrors = validate(email, password); + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) return; + + setIsSubmitting(true); + try { + await auth.register(email.trim(), password); + const from = (location.state as LocationState)?.from?.pathname ?? "/"; + navigate(from, { replace: true }); + } catch (err) { + if (err instanceof Error) { + setSubmitError(err.message); + } else { + setSubmitError("Registration failed."); + } + } finally { + setIsSubmitting(false); + } + } + + const showErrors = attempted; + + return ( +
+

Create account

+
+
+ + setEmail(e.target.value)} + aria-invalid={Boolean(showErrors && errors.email)} + aria-describedby={ + showErrors && errors.email ? "register-email-error" : undefined + } + className="mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" + /> + {showErrors && errors.email && ( +

+ {errors.email} +

+ )} +
+ +
+ + setPassword(e.target.value)} + aria-invalid={Boolean(showErrors && errors.password)} + aria-describedby={ + showErrors && errors.password + ? "register-password-error" + : undefined + } + className="mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" + /> + {showErrors && errors.password && ( +

+ {errors.password} +

+ )} +
+ + {submitError && ( +

+ {submitError} +

+ )} + + +
+

+ Already have an account?{" "} + + Sign in + +

+
+ ); +} diff --git a/frontend/src/test/handlers.ts b/frontend/src/test/handlers.ts index 7547049..28a9a31 100644 --- a/frontend/src/test/handlers.ts +++ b/frontend/src/test/handlers.ts @@ -2,17 +2,20 @@ import { http, HttpResponse } from 'msw' import type { Note } from '../api/notes' type NoteInputBody = { title?: unknown; content?: unknown } +type AuthBody = { email?: unknown; password?: unknown } + +type StoredNote = Note & { ownerId: string } export type NotesStore = { - notes: Note[] - reset: (seed?: Note[]) => void + notes: StoredNote[] + reset: (seed?: StoredNote[]) => void } -function cloneNotes(notes: Note[]): Note[] { +function cloneNotes(notes: StoredNote[]): StoredNote[] { return notes.map((n) => ({ ...n })) } -export function createNotesStore(seed: Note[] = []): NotesStore { +export function createNotesStore(seed: StoredNote[] = []): NotesStore { const store: NotesStore = { notes: cloneNotes(seed), reset(next = []) { @@ -22,13 +25,39 @@ export function createNotesStore(seed: Note[] = []): NotesStore { return store } -export const defaultSeed: Note[] = [ +export type StoredUser = { id: string; email: string; password: string } + +export type AuthStore = { + users: Map + tokens: Map + reset: () => void +} + +export function createAuthStore(): AuthStore { + const store: AuthStore = { + users: new Map(), + tokens: new Map(), + reset() { + store.users.clear() + store.tokens.clear() + }, + } + return store +} + +export const SEED_USER_ID = 'seed-user-id' +export const SEED_USER_EMAIL = 'seed@test.local' +export const SEED_USER_PASSWORD = 'Password123!' +export const SEED_USER_TOKEN = 'seed-token' + +export const defaultSeed: StoredNote[] = [ { id: '11111111-1111-1111-1111-111111111111', title: 'Shopping list', content: 'Eggs, bread, milk', createdAt: '2026-04-01T12:00:00.000Z', updatedAt: '2026-04-10T08:30:00.000Z', + ownerId: SEED_USER_ID, }, { id: '22222222-2222-2222-2222-222222222222', @@ -36,48 +65,195 @@ export const defaultSeed: Note[] = [ content: 'Write more tests.', createdAt: '2026-04-02T09:00:00.000Z', updatedAt: '2026-04-12T15:00:00.000Z', + ownerId: SEED_USER_ID, }, ] -export function createHandlers(store: NotesStore) { +export function seedAuthStore(authStore: AuthStore): void { + authStore.reset() + authStore.users.set(SEED_USER_EMAIL.toLowerCase(), { + id: SEED_USER_ID, + email: SEED_USER_EMAIL, + password: SEED_USER_PASSWORD, + }) + authStore.tokens.set(SEED_USER_TOKEN, SEED_USER_ID) +} + +function currentUserId(request: Request, authStore: AuthStore): string | null { + const header = request.headers.get('Authorization') + if (!header) return null + const match = /^Bearer\s+(.+)$/i.exec(header) + if (!match) return null + return authStore.tokens.get(match[1]) ?? null +} + +function unauthorized(): HttpResponse { + return HttpResponse.json( + { type: 'about:blank', title: 'Unauthorized', status: 401 }, + { status: 401 }, + ) +} + +function toNoteDto(note: StoredNote): Note { + return { + id: note.id, + title: note.title, + content: note.content, + createdAt: note.createdAt, + updatedAt: note.updatedAt, + } +} + +function mintToken(): string { + return crypto.randomUUID() +} + +function expiresAtIso(): string { + return new Date(Date.now() + 3_600_000).toISOString() +} + +export function createHandlers( + store: NotesStore, + authStore: AuthStore = defaultAuthStore, +) { return [ - http.get('/api/notes', () => { - return HttpResponse.json(store.notes) + http.post('/api/auth/register', async ({ request }) => { + const body = (await request.json()) as AuthBody + const email = typeof body.email === 'string' ? body.email.trim() : '' + const password = typeof body.password === 'string' ? body.password : '' + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return HttpResponse.json( + { + title: 'Validation failed', + status: 400, + detail: 'Email is required.', + errors: { email: ['Email is required.'] }, + }, + { status: 400 }, + ) + } + if (password.length < 8) { + return HttpResponse.json( + { + title: 'Validation failed', + status: 400, + detail: 'Password must be at least 8 characters.', + errors: { + password: ['Password must be at least 8 characters.'], + }, + }, + { status: 400 }, + ) + } + const key = email.toLowerCase() + if (authStore.users.has(key)) { + return HttpResponse.json( + { + title: 'Email already registered', + status: 400, + detail: 'Email already registered.', + }, + { status: 400 }, + ) + } + const user: StoredUser = { id: crypto.randomUUID(), email, password } + authStore.users.set(key, user) + const token = mintToken() + authStore.tokens.set(token, user.id) + return HttpResponse.json({ + accessToken: token, + expiresAt: expiresAtIso(), + user: { id: user.id, email: user.email }, + }) + }), + + http.post('/api/auth/login', async ({ request }) => { + const body = (await request.json()) as AuthBody + const email = typeof body.email === 'string' ? body.email.trim() : '' + const password = typeof body.password === 'string' ? body.password : '' + const user = authStore.users.get(email.toLowerCase()) + if (!user || user.password !== password) { + return HttpResponse.json( + { + type: 'about:blank', + title: 'Invalid credentials', + status: 401, + }, + { status: 401 }, + ) + } + const token = mintToken() + authStore.tokens.set(token, user.id) + return HttpResponse.json({ + accessToken: token, + expiresAt: expiresAtIso(), + user: { id: user.id, email: user.email }, + }) + }), + + http.get('/api/auth/me', ({ request }) => { + const userId = currentUserId(request, authStore) + if (!userId) return unauthorized() + const user = [...authStore.users.values()].find( + (u) => u.id === userId, + ) + if (!user) return unauthorized() + return HttpResponse.json({ id: user.id, email: user.email }) + }), + + http.get('/api/notes', ({ request }) => { + const userId = currentUserId(request, authStore) + if (!userId) return unauthorized() + const notes = store.notes + .filter((n) => n.ownerId === userId) + .map(toNoteDto) + return HttpResponse.json(notes) }), - http.get('/api/notes/:id', ({ params }) => { - const note = store.notes.find((n) => n.id === params.id) + http.get('/api/notes/:id', ({ params, request }) => { + const userId = currentUserId(request, authStore) + if (!userId) return unauthorized() + const note = store.notes.find( + (n) => n.id === params.id && n.ownerId === userId, + ) if (!note) { return HttpResponse.json( { title: 'Not Found', status: 404, detail: 'Note not found' }, { status: 404 }, ) } - return HttpResponse.json(note) + return HttpResponse.json(toNoteDto(note)) }), http.post('/api/notes', async ({ request }) => { + const userId = currentUserId(request, authStore) + if (!userId) return unauthorized() const body = (await request.json()) as NoteInputBody const title = typeof body.title === 'string' ? body.title : '' const content = typeof body.content === 'string' ? body.content : '' const now = new Date().toISOString() - const note: Note = { + const note: StoredNote = { id: crypto.randomUUID(), title, content, createdAt: now, updatedAt: now, + ownerId: userId, } store.notes = [...store.notes, note] - return HttpResponse.json(note, { + return HttpResponse.json(toNoteDto(note), { status: 201, headers: { Location: `/api/notes/${note.id}` }, }) }), http.put('/api/notes/:id', async ({ params, request }) => { + const userId = currentUserId(request, authStore) + if (!userId) return unauthorized() const body = (await request.json()) as NoteInputBody - const index = store.notes.findIndex((n) => n.id === params.id) + const index = store.notes.findIndex( + (n) => n.id === params.id && n.ownerId === userId, + ) if (index === -1) { return HttpResponse.json( { title: 'Not Found', status: 404, detail: 'Note not found' }, @@ -85,7 +261,7 @@ export function createHandlers(store: NotesStore) { ) } const existing = store.notes[index] - const updated: Note = { + const updated: StoredNote = { ...existing, title: typeof body.title === 'string' ? body.title : existing.title, content: @@ -95,11 +271,15 @@ export function createHandlers(store: NotesStore) { const next = [...store.notes] next[index] = updated store.notes = next - return HttpResponse.json(updated) + return HttpResponse.json(toNoteDto(updated)) }), - http.delete('/api/notes/:id', ({ params }) => { - const index = store.notes.findIndex((n) => n.id === params.id) + http.delete('/api/notes/:id', ({ params, request }) => { + const userId = currentUserId(request, authStore) + if (!userId) return unauthorized() + const index = store.notes.findIndex( + (n) => n.id === params.id && n.ownerId === userId, + ) if (index === -1) { return HttpResponse.json( { title: 'Not Found', status: 404, detail: 'Note not found' }, @@ -113,4 +293,5 @@ export function createHandlers(store: NotesStore) { } export const notesStore = createNotesStore() -export const handlers = createHandlers(notesStore) +export const defaultAuthStore = createAuthStore() +export const handlers = createHandlers(notesStore, defaultAuthStore) diff --git a/frontend/src/test/renderWithProviders.tsx b/frontend/src/test/renderWithProviders.tsx index 1e553bf..d7be653 100644 --- a/frontend/src/test/renderWithProviders.tsx +++ b/frontend/src/test/renderWithProviders.tsx @@ -1,21 +1,42 @@ -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; import { render, type RenderOptions } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { AuthProvider } from "../auth/AuthContext"; +import { TOKEN_STORAGE_KEY } from "../auth/tokenStorage"; +import type { UserDto } from "../api/auth"; +import { SEED_USER_EMAIL, SEED_USER_ID, SEED_USER_TOKEN } from "./handlers"; + +type InitialAuth = { token: string; user: UserDto } | null; type RenderWithProvidersOptions = Omit & { initialEntries?: string[]; path?: string; + // - `undefined` (default): pre-seed as the default seed user + // - `null`: unauthenticated (no token) + // - explicit object: use custom token/user + initialAuth?: InitialAuth; }; +// Tests should prefer `findBy*` assertions after render so the AuthProvider's +// `me()` bootstrap call has a chance to resolve before assertions run. export function renderWithProviders( ui: ReactElement, { initialEntries = ["/"], path, + initialAuth, ...renderOptions }: RenderWithProvidersOptions = {}, ) { + if (initialAuth === null) { + localStorage.removeItem(TOKEN_STORAGE_KEY); + } else if (initialAuth === undefined) { + localStorage.setItem(TOKEN_STORAGE_KEY, SEED_USER_TOKEN); + } else { + localStorage.setItem(TOKEN_STORAGE_KEY, initialAuth.token); + } + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, @@ -23,7 +44,7 @@ export function renderWithProviders( }, }); - const content = path ? ( + const content: ReactNode = path ? ( @@ -33,10 +54,17 @@ export function renderWithProviders( const utils = render( - {content} + + {content} + , renderOptions, ); return { ...utils, queryClient }; } + +export const seedUser: UserDto = { + id: SEED_USER_ID, + email: SEED_USER_EMAIL, +}; diff --git a/frontend/src/test/server.ts b/frontend/src/test/server.ts index 660e72d..8f15c98 100644 --- a/frontend/src/test/server.ts +++ b/frontend/src/test/server.ts @@ -1,5 +1,11 @@ import { setupServer } from 'msw/node' -import { handlers, notesStore, defaultSeed } from './handlers' +import { + defaultAuthStore, + defaultSeed, + handlers, + notesStore, + seedAuthStore, +} from './handlers' export const server = setupServer(...handlers) @@ -7,4 +13,8 @@ export function resetNotesStore(): void { notesStore.reset(defaultSeed) } -export { notesStore, defaultSeed } +export function resetAuthStore(): void { + seedAuthStore(defaultAuthStore) +} + +export { notesStore, defaultSeed, defaultAuthStore } diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 06b5ee4..1b014e8 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,8 +1,13 @@ import '@testing-library/jest-dom/vitest' import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest' -import { server, resetNotesStore } from './server' +import { server, resetNotesStore, resetAuthStore } from './server' +import { TOKEN_STORAGE_KEY } from '../auth/tokenStorage' beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) -beforeEach(() => resetNotesStore()) +beforeEach(() => { + resetAuthStore() + resetNotesStore() + localStorage.removeItem(TOKEN_STORAGE_KEY) +}) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) diff --git a/infra/abbreviations.json b/infra/abbreviations.json new file mode 100644 index 0000000..76c3a09 --- /dev/null +++ b/infra/abbreviations.json @@ -0,0 +1,12 @@ +{ + "resourceGroup": "rg-", + "appServicePlan": "asp-", + "appService": "app-", + "staticWebApp": "swa-", + "sqlServer": "sql-", + "sqlDatabase": "sqldb-", + "keyVault": "kv-", + "logAnalyticsWorkspace": "log-", + "applicationInsights": "appi-", + "userAssignedIdentity": "id-" +} diff --git a/infra/bootstrap/setup-oidc.ps1 b/infra/bootstrap/setup-oidc.ps1 new file mode 100644 index 0000000..cb3b78f --- /dev/null +++ b/infra/bootstrap/setup-oidc.ps1 @@ -0,0 +1,141 @@ +<# +.SYNOPSIS + One-time bootstrap: create GitHub OIDC app registration, federated + credentials, and subscription role assignments for the Notes sample app. + +.DESCRIPTION + PowerShell port of setup-oidc.sh. Idempotent. + + Prerequisites: Azure CLI (`az`), logged in as a user with: + - Owner or User Access Administrator on the subscription + - Application Developer (or better) in Entra ID + +.EXAMPLE + ./setup-oidc.ps1 -GitHubOwner amis-4630 -GitHubRepo sample-app -EnvName dev + +.EXAMPLE + ./setup-oidc.ps1 amis-4630 sample-app prod 00000000-0000-0000-0000-000000000000 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory, Position = 0)] + [string]$GitHubOwner, + + [Parameter(Mandatory, Position = 1)] + [string]$GitHubRepo, + + [Parameter(Mandatory, Position = 2)] + [ValidateSet('dev', 'prod')] + [string]$EnvName, + + [Parameter(Position = 3)] + [string]$SubscriptionId +) + +$ErrorActionPreference = 'Stop' + +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw "Azure CLI (az) is required. Install: winget install -e --id Microsoft.AzureCLI" +} + +if (-not $SubscriptionId) { + $SubscriptionId = az account show --query id -o tsv +} +$TenantId = az account show --query tenantId -o tsv + +$AppName = "gh-oidc-$GitHubOwner-$GitHubRepo-$EnvName" + +Write-Host "==> Subscription: $SubscriptionId" +Write-Host "==> Tenant: $TenantId" +Write-Host "==> App name: $AppName" + +# 1. App registration + service principal (idempotent). +$AppId = az ad app list --display-name $AppName --query '[0].appId' -o tsv +if (-not $AppId) { + Write-Host "==> Creating app registration" + $AppId = az ad app create --display-name $AppName --query appId -o tsv +} + +$SpId = az ad sp list --filter "appId eq '$AppId'" --query '[0].id' -o tsv +if (-not $SpId) { + Write-Host "==> Creating service principal" + $SpId = az ad sp create --id $AppId --query id -o tsv +} + +function New-FederatedCredential { + param( + [string]$AppId, + [string]$Name, + [string]$Subject + ) + + $existing = az ad app federated-credential list --id $AppId --query "[?name=='$Name'].name" -o tsv + if ($existing) { + Write-Host "==> Federated credential '$Name' already exists" + return + } + + Write-Host "==> Adding federated credential '$Name' for $Subject" + $payload = @{ + name = $Name + issuer = 'https://token.actions.githubusercontent.com' + subject = $Subject + audiences = @('api://AzureADTokenExchange') + } | ConvertTo-Json -Compress + + # Write to a temp file to avoid cross-shell quoting headaches. + $tmp = New-TemporaryFile + try { + Set-Content -Path $tmp -Value $payload -Encoding utf8 + az ad app federated-credential create --id $AppId --parameters "@$tmp" | Out-Null + } + finally { + Remove-Item $tmp -Force -ErrorAction SilentlyContinue + } +} + +# 2. Federated credential scoped to the GitHub Environment. +New-FederatedCredential ` + -AppId $AppId ` + -Name "gh-env-$EnvName" ` + -Subject "repo:$GitHubOwner/$GitHubRepo`:environment:$EnvName" + +# 3. pull_request federated credential (dev only) for what-if on PRs. +if ($EnvName -eq 'dev') { + New-FederatedCredential ` + -AppId $AppId ` + -Name 'gh-pr' ` + -Subject "repo:$GitHubOwner/$GitHubRepo`:pull_request" +} + +# 4. Subscription-scope role assignments. +$scope = "/subscriptions/$SubscriptionId" +foreach ($role in 'Contributor', 'User Access Administrator') { + Write-Host "==> Assigning '$role' to SP at subscription scope" + az role assignment create ` + --assignee-object-id $SpId ` + --assignee-principal-type ServicePrincipal ` + --role $role ` + --scope $scope ` + --only-show-errors 2>$null | Out-Null +} + +@" + +==> DONE. + +Add the following to GitHub Environment '$EnvName' in +https://github.com/$GitHubOwner/$GitHubRepo/settings/environments : + + Variables: + AZURE_CLIENT_ID = $AppId + AZURE_TENANT_ID = $TenantId + AZURE_SUBSCRIPTION_ID = $SubscriptionId + AZURE_RESOURCE_GROUP = rg-notes-$EnvName + AZURE_LOCATION = eastus2 + AZURE_DEPLOYER_OBJECT_ID = $SpId + SQL_ADMIN_OBJECT_ID = + SQL_ADMIN_PRINCIPAL_NAME = + +For the 'prod' environment, also enable "Required reviewers". +"@ diff --git a/infra/bootstrap/setup-oidc.sh b/infra/bootstrap/setup-oidc.sh new file mode 100755 index 0000000..6306bd0 --- /dev/null +++ b/infra/bootstrap/setup-oidc.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# One-time bootstrap: create GitHub OIDC app registrations, federated credentials, +# and subscription role assignments for the Notes sample app. +# +# Prerequisites: az CLI, logged in as a user with: +# - Owner or User Access Administrator on the subscription +# - Application Developer (or better) in Entra ID +# +# Usage: +# ./setup-oidc.sh [subscription-id] +# Example: +# ./setup-oidc.sh amis-4630 sample-app dev +set -euo pipefail + +GH_OWNER="${1:?github owner required}" +GH_REPO="${2:?github repo required}" +ENV_NAME="${3:?environment name required (dev|prod)}" +SUB_ID="${4:-$(az account show --query id -o tsv)}" +TENANT_ID="$(az account show --query tenantId -o tsv)" + +APP_NAME="gh-oidc-${GH_OWNER}-${GH_REPO}-${ENV_NAME}" + +echo "==> Subscription: $SUB_ID" +echo "==> Tenant: $TENANT_ID" +echo "==> App name: $APP_NAME" + +# 1. App registration + service principal (idempotent). +APP_ID="$(az ad app list --display-name "$APP_NAME" --query '[0].appId' -o tsv)" +if [[ -z "$APP_ID" ]]; then + echo "==> Creating app registration" + APP_ID="$(az ad app create --display-name "$APP_NAME" --query appId -o tsv)" +fi + +SP_ID="$(az ad sp list --filter "appId eq '$APP_ID'" --query '[0].id' -o tsv)" +if [[ -z "$SP_ID" ]]; then + echo "==> Creating service principal" + SP_ID="$(az ad sp create --id "$APP_ID" --query id -o tsv)" +fi + +# 2. Federated credential for GitHub Environment. +FIC_NAME="gh-env-${ENV_NAME}" +SUBJECT="repo:${GH_OWNER}/${GH_REPO}:environment:${ENV_NAME}" +EXISTING_FIC="$(az ad app federated-credential list --id "$APP_ID" --query "[?name=='$FIC_NAME'].name" -o tsv)" +if [[ -z "$EXISTING_FIC" ]]; then + echo "==> Adding federated credential for $SUBJECT" + az ad app federated-credential create --id "$APP_ID" --parameters "$(cat < Adding federated credential for pull_request" + az ad app federated-credential create --id "$APP_ID" --parameters "$(cat < Assigning '$ROLE' to SP at subscription scope" + az role assignment create \ + --assignee-object-id "$SP_ID" \ + --assignee-principal-type ServicePrincipal \ + --role "$ROLE" \ + --scope "/subscriptions/$SUB_ID" \ + --only-show-errors >/dev/null || true +done + +cat < DONE. + +Add the following to GitHub Environment '$ENV_NAME' in +https://github.com/$GH_OWNER/$GH_REPO/settings/environments : + + Variables: + AZURE_CLIENT_ID = $APP_ID + AZURE_TENANT_ID = $TENANT_ID + AZURE_SUBSCRIPTION_ID = $SUB_ID + AZURE_RESOURCE_GROUP = rg-notes-$ENV_NAME + AZURE_LOCATION = eastus2 + AZURE_DEPLOYER_OBJECT_ID = $SP_ID + SQL_ADMIN_OBJECT_ID = + SQL_ADMIN_PRINCIPAL_NAME = + +For the 'prod' environment, also enable "Required reviewers". +EOF diff --git a/infra/main.bicep b/infra/main.bicep new file mode 100644 index 0000000..c9e1754 --- /dev/null +++ b/infra/main.bicep @@ -0,0 +1,63 @@ +targetScope = 'subscription' + +@description('Environment name (dev, prod).') +@allowed([ 'dev', 'prod' ]) +param environmentName string + +@description('Short workload name used in resource naming.') +@minLength(2) +@maxLength(10) +param workloadName string = 'notes' + +@description('Primary Azure region for all resources.') +param location string = 'eastus2' + +@description('Object ID of the Entra principal (user or group) that will be the SQL Entra admin. Required; can be your user object ID.') +param sqlAdminPrincipalId string + +@description('Display name of the SQL Entra admin principal (for audit).') +param sqlAdminPrincipalName string + +@description('Type of the SQL Entra admin principal.') +@allowed([ 'User', 'Group', 'ServicePrincipal' ]) +param sqlAdminPrincipalType string = 'User' + +@description('Additional Entra principal IDs (e.g., the GitHub OIDC app reg) granted Key Vault Secrets Officer + SQL admin during CD.') +param deployerPrincipalIds array = [] + +var tags = { + environment: environmentName + workload: workloadName + 'managed-by': 'bicep' +} + +resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: 'rg-${workloadName}-${environmentName}' + location: location + tags: tags +} + +module workload 'modules/workload.bicep' = { + name: 'workload-${environmentName}' + scope: rg + params: { + location: location + environmentName: environmentName + workloadName: workloadName + tags: tags + sqlAdminPrincipalId: sqlAdminPrincipalId + sqlAdminPrincipalName: sqlAdminPrincipalName + sqlAdminPrincipalType: sqlAdminPrincipalType + deployerPrincipalIds: deployerPrincipalIds + } +} + +output resourceGroupName string = rg.name +output appServiceName string = workload.outputs.appServiceName +output appServiceDefaultHostName string = workload.outputs.appServiceDefaultHostName +output staticWebAppName string = workload.outputs.staticWebAppName +output staticWebAppDefaultHostName string = workload.outputs.staticWebAppDefaultHostName +output keyVaultName string = workload.outputs.keyVaultName +output sqlServerName string = workload.outputs.sqlServerName +output sqlDatabaseName string = workload.outputs.sqlDatabaseName +output applicationInsightsConnectionString string = workload.outputs.applicationInsightsConnectionString diff --git a/infra/main.dev.bicepparam b/infra/main.dev.bicepparam new file mode 100644 index 0000000..c19c0c4 --- /dev/null +++ b/infra/main.dev.bicepparam @@ -0,0 +1,17 @@ +using 'main.bicep' + +param environmentName = 'dev' +param workloadName = 'notes' +param location = 'eastus2' + +// Entra principal that becomes the SQL AD admin. +// Override via CLI: --parameters sqlAdminPrincipalId=$(az ad signed-in-user show --query id -o tsv) sqlAdminPrincipalName=$(az ad signed-in-user show --query userPrincipalName -o tsv) +param sqlAdminPrincipalId = readEnvironmentVariable('SQL_ADMIN_OBJECT_ID', '') +param sqlAdminPrincipalName = readEnvironmentVariable('SQL_ADMIN_PRINCIPAL_NAME', '') +param sqlAdminPrincipalType = 'User' + +// The GitHub OIDC service principal object IDs that need to run deployment scripts / rotate secrets. +// Populated by the CD workflow from vars.AZURE_DEPLOYER_OBJECT_ID. +param deployerPrincipalIds = empty(readEnvironmentVariable('AZURE_DEPLOYER_OBJECT_ID', '')) ? [] : [ + readEnvironmentVariable('AZURE_DEPLOYER_OBJECT_ID', '') +] diff --git a/infra/main.prod.bicepparam b/infra/main.prod.bicepparam new file mode 100644 index 0000000..b7982e2 --- /dev/null +++ b/infra/main.prod.bicepparam @@ -0,0 +1,13 @@ +using 'main.bicep' + +param environmentName = 'prod' +param workloadName = 'notes' +param location = 'eastus2' + +param sqlAdminPrincipalId = readEnvironmentVariable('SQL_ADMIN_OBJECT_ID', '') +param sqlAdminPrincipalName = readEnvironmentVariable('SQL_ADMIN_PRINCIPAL_NAME', '') +param sqlAdminPrincipalType = 'Group' + +param deployerPrincipalIds = empty(readEnvironmentVariable('AZURE_DEPLOYER_OBJECT_ID', '')) ? [] : [ + readEnvironmentVariable('AZURE_DEPLOYER_OBJECT_ID', '') +] diff --git a/infra/modules/app-insights.bicep b/infra/modules/app-insights.bicep new file mode 100644 index 0000000..49720a3 --- /dev/null +++ b/infra/modules/app-insights.bicep @@ -0,0 +1,28 @@ +@description('Application Insights component name.') +param name string + +@description('Azure region.') +param location string + +@description('Resource tags.') +param tags object + +@description('Linked Log Analytics workspace resource ID.') +param workspaceId string + +resource component 'Microsoft.Insights/components@2020-02-02' = { + name: name + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: workspaceId + IngestionMode: 'LogAnalytics' + publicNetworkAccessForIngestion: 'Enabled' + publicNetworkAccessForQuery: 'Enabled' + } +} + +output name string = component.name +output connectionString string = component.properties.ConnectionString diff --git a/infra/modules/app-service.bicep b/infra/modules/app-service.bicep new file mode 100644 index 0000000..6ede019 --- /dev/null +++ b/infra/modules/app-service.bicep @@ -0,0 +1,141 @@ +@description('App Service Plan name.') +param planName string + +@description('App Service (Web App) name.') +param appName string + +@description('Azure region.') +param location string + +@description('Resource tags.') +param tags object + +@description('App Service Plan SKU. F1 = Free, B1 = Basic.') +@allowed([ 'F1', 'B1' ]) +param skuName string = 'F1' + +@description('.NET runtime version on Linux (e.g., DOTNETCORE|10.0).') +param linuxFxVersion string = 'DOTNETCORE|10.0' + +@description('Key Vault name used for JWT signing key reference.') +param keyVaultName string + +@description('SQL logical server FQDN.') +param sqlServerFqdn string + +@description('SQL database name.') +param sqlDatabaseName string + +@description('Application Insights connection string.') +param appInsightsConnectionString string + +@description('Allowed CORS origin (e.g., https://.azurestaticapps.net).') +param allowedCorsOrigin string + +var skuTier = skuName == 'F1' ? 'Free' : 'Basic' +// F1 does not support Always On. +var alwaysOn = skuName != 'F1' + +resource plan 'Microsoft.Web/serverfarms@2024-04-01' = { + name: planName + location: location + tags: tags + kind: 'linux' + sku: { + name: skuName + tier: skuTier + } + properties: { + reserved: true // Linux + } +} + +resource site 'Microsoft.Web/sites@2024-04-01' = { + name: appName + location: location + tags: tags + kind: 'app,linux' + identity: { + type: 'SystemAssigned' + } + properties: { + serverFarmId: plan.id + httpsOnly: true + publicNetworkAccess: 'Enabled' + clientAffinityEnabled: false + siteConfig: { + linuxFxVersion: linuxFxVersion + alwaysOn: alwaysOn + http20Enabled: true + minTlsVersion: '1.2' + ftpsState: 'Disabled' + healthCheckPath: '/health' + cors: { + allowedOrigins: [ + allowedCorsOrigin + ] + supportCredentials: true + } + appSettings: [ + { + name: 'ASPNETCORE_ENVIRONMENT' + value: 'Production' + } + { + name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' + value: 'true' + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: appInsightsConnectionString + } + { + name: 'Database__Provider' + value: 'SqlServer' + } + { + name: 'ConnectionStrings__SqlServer' + value: 'Server=tcp:${sqlServerFqdn},1433;Database=${sqlDatabaseName};Authentication=Active Directory Default;Encrypt=True;TrustServerCertificate=False;Connection Timeout=60;' + } + { + name: 'Jwt__SigningKey' + value: '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=jwt-signing-key)' + } + { + name: 'Cors__AllowedOrigins__0' + value: allowedCorsOrigin + } + { + name: 'RUN_MIGRATIONS_ON_START' + value: 'true' + } + { + name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' + value: 'false' + } + ] + } + } +} + +// Grant the site's managed identity "Key Vault Secrets User" on the vault. +resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' existing = { + name: keyVaultName +} + +var secretsUserRoleId = '4633458b-17de-408a-b874-0445c86b69e6' + +resource kvRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, site.id, secretsUserRoleId) + scope: keyVault + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', secretsUserRoleId) + principalId: site.identity.principalId + principalType: 'ServicePrincipal' + } +} + +output name string = site.name +output defaultHostName string = site.properties.defaultHostName +output principalId string = site.identity.principalId +output planId string = plan.id diff --git a/infra/modules/key-vault.bicep b/infra/modules/key-vault.bicep new file mode 100644 index 0000000..5bcbe86 --- /dev/null +++ b/infra/modules/key-vault.bicep @@ -0,0 +1,55 @@ +@description('Key Vault name (globally unique, 3-24 chars).') +@minLength(3) +@maxLength(24) +param name string + +@description('Azure region.') +param location string + +@description('Resource tags.') +param tags object + +@description('Principal IDs granted "Key Vault Secrets Officer" (e.g., GitHub OIDC SPs).') +param secretsOfficerPrincipalIds array = [] + +@description('Enable purge protection. Leave false in dev for easy teardown.') +param enablePurgeProtection bool = false + +resource vault 'Microsoft.KeyVault/vaults@2024-11-01' = { + name: name + location: location + tags: tags + properties: { + tenantId: tenant().tenantId + sku: { + family: 'A' + name: 'standard' + } + enableRbacAuthorization: true + enableSoftDelete: true + softDeleteRetentionInDays: 7 + enablePurgeProtection: enablePurgeProtection ? true : null + publicNetworkAccess: 'Enabled' + networkAcls: { + bypass: 'AzureServices' + defaultAction: 'Allow' + } + } +} + +// Key Vault Secrets Officer: can CRUD secrets (used by CD to rotate JWT key). +var secretsOfficerRoleId = 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7' + +resource secretsOfficerAssignments 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for principalId in secretsOfficerPrincipalIds: { + name: guid(vault.id, principalId, secretsOfficerRoleId) + scope: vault + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', secretsOfficerRoleId) + principalId: principalId + principalType: 'ServicePrincipal' + } +}] + +output name string = vault.name +output id string = vault.id +output uri string = vault.properties.vaultUri diff --git a/infra/modules/log-analytics.bicep b/infra/modules/log-analytics.bicep new file mode 100644 index 0000000..24613e1 --- /dev/null +++ b/infra/modules/log-analytics.bicep @@ -0,0 +1,33 @@ +@description('Log Analytics workspace name.') +param name string + +@description('Azure region.') +param location string + +@description('Resource tags.') +param tags object + +@description('Data retention in days.') +@minValue(30) +@maxValue(730) +param retentionInDays int = 30 + +resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: name + location: location + tags: tags + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: retentionInDays + features: { + enableLogAccessUsingOnlyResourcePermissions: true + } + publicNetworkAccessForIngestion: 'Enabled' + publicNetworkAccessForQuery: 'Enabled' + } +} + +output workspaceId string = workspace.id +output name string = workspace.name diff --git a/infra/modules/sql.bicep b/infra/modules/sql.bicep new file mode 100644 index 0000000..53da202 --- /dev/null +++ b/infra/modules/sql.bicep @@ -0,0 +1,101 @@ +@description('Logical SQL Server name.') +param serverName string + +@description('Database name.') +param databaseName string + +@description('Azure region.') +param location string + +@description('Resource tags.') +param tags object + +@description('Entra principal ID (user, group, or SP) that becomes the SQL AD admin.') +param adminPrincipalId string + +@description('Display name / UPN of the SQL AD admin (metadata only).') +param adminPrincipalName string + +@description('Type of Entra principal.') +@allowed([ 'User', 'Group', 'ServicePrincipal' ]) +param adminPrincipalType string + +@description('Serverless auto-pause delay in minutes. 60 = aggressive savings for dev.') +param autoPauseDelayMinutes int = 60 + +@description('Minimum vCores when active. Serverless allows 0.5.') +param minCapacity string = '0.5' + +@description('Maximum vCores.') +param maxVCores int = 1 + +@description('Max database size in GB.') +param maxSizeGB int = 32 + +resource server 'Microsoft.Sql/servers@2023-08-01-preview' = { + name: serverName + location: location + tags: tags + identity: { + type: 'SystemAssigned' + } + properties: { + // Entra-only authentication. No SQL auth, no passwords. + administrators: { + administratorType: 'ActiveDirectory' + azureADOnlyAuthentication: true + login: adminPrincipalName + sid: adminPrincipalId + principalType: adminPrincipalType + tenantId: tenant().tenantId + } + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Enabled' + restrictOutboundNetworkAccess: 'Disabled' + version: '12.0' + } +} + +// Allow Azure services (App Service outbound IPs) to reach this server. +resource allowAzure 'Microsoft.Sql/servers/firewallRules@2023-08-01-preview' = { + name: 'AllowAllWindowsAzureIps' + parent: server + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } +} + +resource database 'Microsoft.Sql/servers/databases@2023-08-01-preview' = { + name: databaseName + parent: server + location: location + tags: tags + sku: { + name: 'GP_S_Gen5_${maxVCores}' + tier: 'GeneralPurpose' + family: 'Gen5' + capacity: maxVCores + } + properties: { + collation: 'SQL_Latin1_General_CP1_CI_AS' + maxSizeBytes: maxSizeGB * 1024 * 1024 * 1024 + autoPauseDelay: autoPauseDelayMinutes + minCapacity: json(minCapacity) + zoneRedundant: false + readScale: 'Disabled' + requestedBackupStorageRedundancy: 'Local' + // Enable the "free" serverless offer (100K vCore-sec/mo). + // Comment out if your subscription has already consumed the offer on another DB. + useFreeLimit: true + freeLimitExhaustionBehavior: 'AutoPause' + } + dependsOn: [ + allowAzure + ] +} + +output serverName string = server.name +output serverFqdn string = server.properties.fullyQualifiedDomainName +output databaseName string = database.name +output databaseId string = database.id diff --git a/infra/modules/static-web-app.bicep b/infra/modules/static-web-app.bicep new file mode 100644 index 0000000..8053088 --- /dev/null +++ b/infra/modules/static-web-app.bicep @@ -0,0 +1,34 @@ +@description('Static Web App name.') +param name string + +@description('Azure region. SWA Free is only available in select regions (eastus2, centralus, westus2, westeurope, eastasia).') +param location string + +@description('Resource tags.') +param tags object + +@description('SKU. "Free" has no linked-backend support; "Standard" does.') +@allowed([ 'Free', 'Standard' ]) +param skuName string = 'Free' + +resource swa 'Microsoft.Web/staticSites@2024-04-01' = { + name: name + location: location + tags: tags + sku: { + name: skuName + tier: skuName + } + properties: { + // Source control wiring is handled by the CD workflow via the deployment token, + // so we create the SWA "unlinked" here. + provider: 'None' + stagingEnvironmentPolicy: 'Enabled' + allowConfigFileUpdates: true + enterpriseGradeCdnStatus: 'Disabled' + } +} + +output name string = swa.name +output defaultHostName string = swa.properties.defaultHostname +output id string = swa.id diff --git a/infra/modules/workload.bicep b/infra/modules/workload.bicep new file mode 100644 index 0000000..f3f5ac2 --- /dev/null +++ b/infra/modules/workload.bicep @@ -0,0 +1,119 @@ +targetScope = 'resourceGroup' + +@description('Azure region.') +param location string + +@description('Environment name.') +param environmentName string + +@description('Short workload name.') +param workloadName string + +@description('Resource tags.') +param tags object + +@description('Entra principal ID for SQL admin.') +param sqlAdminPrincipalId string + +@description('Entra principal display name for SQL admin.') +param sqlAdminPrincipalName string + +@description('Entra principal type for SQL admin.') +param sqlAdminPrincipalType string + +@description('Additional deployer principal IDs (OIDC SPs) to grant data-plane roles to.') +param deployerPrincipalIds array + +var abbrs = loadJsonContent('../abbreviations.json') +var resourceToken = toLower(uniqueString(subscription().id, resourceGroup().id, environmentName)) + +// Compose names in one place. +var names = { + logAnalytics: '${abbrs.logAnalyticsWorkspace}${workloadName}-${environmentName}' + appInsights: '${abbrs.applicationInsights}${workloadName}-${environmentName}' + keyVault: '${abbrs.keyVault}${workloadName}-${environmentName}-${substring(resourceToken, 0, 6)}' + sqlServer: '${abbrs.sqlServer}${workloadName}-${environmentName}-${substring(resourceToken, 0, 6)}' + sqlDatabase: '${abbrs.sqlDatabase}${workloadName}-${environmentName}' + appServicePlan: '${abbrs.appServicePlan}${workloadName}-${environmentName}' + appService: '${abbrs.appService}${workloadName}-${environmentName}-${substring(resourceToken, 0, 6)}' + staticWebApp: '${abbrs.staticWebApp}${workloadName}-${environmentName}-${substring(resourceToken, 0, 6)}' +} + +module logs 'log-analytics.bicep' = { + name: 'log-analytics' + params: { + name: names.logAnalytics + location: location + tags: tags + } +} + +module appi 'app-insights.bicep' = { + name: 'app-insights' + params: { + name: names.appInsights + location: location + tags: tags + workspaceId: logs.outputs.workspaceId + } +} + +module kv 'key-vault.bicep' = { + name: 'key-vault' + params: { + name: names.keyVault + location: location + tags: tags + // Deployer SPs get Secrets Officer so CD can rotate the JWT signing key. + secretsOfficerPrincipalIds: deployerPrincipalIds + } +} + +module sql 'sql.bicep' = { + name: 'sql' + params: { + serverName: names.sqlServer + databaseName: names.sqlDatabase + location: location + tags: tags + adminPrincipalId: sqlAdminPrincipalId + adminPrincipalName: sqlAdminPrincipalName + adminPrincipalType: sqlAdminPrincipalType + } +} + +module app 'app-service.bicep' = { + name: 'app-service' + params: { + planName: names.appServicePlan + appName: names.appService + location: location + tags: tags + keyVaultName: kv.outputs.name + sqlServerFqdn: sql.outputs.serverFqdn + sqlDatabaseName: sql.outputs.databaseName + appInsightsConnectionString: appi.outputs.connectionString + allowedCorsOrigin: 'https://${swa.outputs.defaultHostName}' + } +} + +module swa 'static-web-app.bicep' = { + name: 'static-web-app' + params: { + name: names.staticWebApp + // SWA Free is only available in a limited set of regions; force one that always works. + location: 'eastus2' + tags: tags + } +} + +output appServiceName string = app.outputs.name +output appServiceDefaultHostName string = app.outputs.defaultHostName +output appServicePrincipalId string = app.outputs.principalId +output staticWebAppName string = swa.outputs.name +output staticWebAppDefaultHostName string = swa.outputs.defaultHostName +output keyVaultName string = kv.outputs.name +output sqlServerName string = sql.outputs.serverName +output sqlServerFqdn string = sql.outputs.serverFqdn +output sqlDatabaseName string = sql.outputs.databaseName +output applicationInsightsConnectionString string = appi.outputs.connectionString