Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregi
gcloud run deploy connect-analyzer-mock \
--source backend/mocks/sap \
--region europe-southwest1 \
--port 8080 --allow-unauthenticated
--port 8080 --allow-unauthenticated --max-instances 3

# Grab its URL
MOCK_URL=$(gcloud run services describe connect-analyzer-mock \
Expand All @@ -44,10 +44,12 @@ MOCK_URL=$(gcloud run services describe connect-analyzer-mock \
gcloud run deploy connect-analyzer-api \
--source backend \
--region europe-southwest1 \
--port 8080 --allow-unauthenticated \
--port 8080 --allow-unauthenticated --max-instances 3 \
--set-env-vars "SalesSource=Mock,SapMock__BaseUrl=${MOCK_URL},Sqlite__Path=/tmp/sales.db"
```

`--max-instances 3` caps the cost/DoS blast radius of the public, unauthenticated services.

Smoke-test (URLs are printed by the deploys / `gcloud run services describe`):

```bash
Expand Down Expand Up @@ -94,3 +96,24 @@ Cors__AllowedOrigins__0 = https://<your-frontend>.vercel.app
```

Never widen to `AllowAnyOrigin`.

## Security notes

The demo serves **fictitious data** over a **public, unauthenticated** API (Cloud Run
`--allow-unauthenticated`). Residual risks and how they're handled:

- **Cost / DoS abuse** → `--max-instances 3` caps scaling, and a **billing budget alert** is set
on the project. Reading `/api/sales*` only exposes the demo dataset.
- **`POST /api/sales/refresh`** (re-ingestion) can be protected with a token: set
`Refresh__Token=<random>` on the `connect-analyzer-api` service, then callers must send a matching
`X-Refresh-Token` header (constant-time compared). Left unset, the endpoint stays open (demo
default). The frontend never calls it.
- **Real SAP/Shopify secrets** → use **Secret Manager + `--set-secrets`** instead of plain
`--set-env-vars`, so the values aren't visible in the service config:
```bash
gcloud secrets create sap-api-key --data-file=- <<< "<your-key>"
gcloud run services update connect-analyzer-api --region europe-southwest1 \
--update-secrets Sap__ApiKey=sap-api-key:latest
```
- App errors return **sanitized** `ProblemDetails` (no internal/upstream messages); the real detail
is logged server-side. SQLite access is parameterized; HTTPS is enforced by Cloud Run.
21 changes: 20 additions & 1 deletion backend/Infrastructure/Inbound/Http/SalesController.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using ConnectAnalyzer.Application;
using ConnectAnalyzer.Domain;
Expand All @@ -9,15 +11,32 @@ namespace ConnectAnalyzer.Infrastructure.Inbound.Http;
public sealed class SalesController(
SalesAnalytics analytics,
IngestSales ingest,
ILogger<SalesController> logger) : ControllerBase
ILogger<SalesController> logger,
IConfiguration configuration) : ControllerBase
{
private const string RefreshTokenHeader = "X-Refresh-Token";

// Re-ingestion is a write/admin action. If Refresh:Token is configured, require it in the
// X-Refresh-Token header (constant-time compare); if it isn't, the endpoint stays open
// (demo default). Protects against anyone triggering ingestion against the real source.
[HttpPost("refresh")]
public async Task<IActionResult> Refresh(CancellationToken ct)
{
var requiredToken = configuration["Refresh:Token"];
if (!string.IsNullOrEmpty(requiredToken) && !RefreshTokenMatches(requiredToken))
return Unauthorized();

var result = await ingest.ExecuteAsync(ct);
return result.Match<IActionResult>(ingested => Ok(new { ingested }), Fail);
}

private bool RefreshTokenMatches(string requiredToken)
{
var provided = Request.Headers[RefreshTokenHeader].ToString();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided), Encoding.UTF8.GetBytes(requiredToken));
}

[HttpGet]
public async Task<IActionResult> GetAll(CancellationToken ct)
{
Expand Down
52 changes: 41 additions & 11 deletions backend/tests/ConnectAnalyzer.Tests/Api/RefreshEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,7 @@ public class RefreshEndpointTests
public async Task Refresh_IngestsFromSourceIntoStoreAndReturnsCount()
{
var store = StubSalesStore.Containing(); // starts empty
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
services.RemoveAll(typeof(ISalesRepository));
services.AddSingleton<ISalesRepository>(new FakeSalesRepository());
services.RemoveAll(typeof(ISalesStore));
services.AddSingleton<ISalesStore>(store);
});
});
using var factory = CreateFactory(store, refreshToken: null);
var client = factory.CreateClient();

var response = await client.PostAsync("/api/sales/refresh", content: null);
Expand All @@ -45,6 +35,46 @@ public async Task Refresh_IngestsFromSourceIntoStoreAndReturnsCount()
Assert.Equal(3, store.LastSaved!.Count);
}

[Fact]
public async Task Refresh_WithTokenConfigured_RejectsRequestWithoutMatchingHeader()
{
using var factory = CreateFactory(StubSalesStore.Containing(), refreshToken: "s3cret");
var client = factory.CreateClient();

var response = await client.PostAsync("/api/sales/refresh", content: null);

Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task Refresh_WithTokenConfigured_AcceptsMatchingHeader()
{
using var factory = CreateFactory(StubSalesStore.Containing(), refreshToken: "s3cret");
var client = factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/sales/refresh");
request.Headers.Add("X-Refresh-Token", "s3cret");

var response = await client.SendAsync(request);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

private static WebApplicationFactory<Program> CreateFactory(
StubSalesStore store, string? refreshToken) =>
new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Testing");
if (refreshToken is not null)
builder.UseSetting("Refresh:Token", refreshToken);
builder.ConfigureServices(services =>
{
services.RemoveAll(typeof(ISalesRepository));
services.AddSingleton<ISalesRepository>(new FakeSalesRepository());
services.RemoveAll(typeof(ISalesStore));
services.AddSingleton<ISalesStore>(store);
});
});

private record RefreshResponse(int Ingested);

private sealed class FakeSalesRepository : ISalesRepository
Expand Down
4 changes: 3 additions & 1 deletion scripts/deploy-cloudrun.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ gcloud run deploy "$MOCK_SERVICE" \
--source backend/mocks/sap \
--region "$REGION" \
--port 8080 \
--allow-unauthenticated
--allow-unauthenticated \
--max-instances 3

MOCK_URL="$(gcloud run services describe "$MOCK_SERVICE" --region "$REGION" --format='value(status.url)')"
echo "✔ Mock en: $MOCK_URL"
Expand All @@ -31,6 +32,7 @@ gcloud run deploy "$API_SERVICE" \
--region "$REGION" \
--port 8080 \
--allow-unauthenticated \
--max-instances 3 \
--set-env-vars "SalesSource=Mock,SapMock__BaseUrl=${MOCK_URL},Sqlite__Path=/tmp/sales.db"

API_URL="$(gcloud run services describe "$API_SERVICE" --region "$REGION" --format='value(status.url)')"
Expand Down
Loading