feat(infra): Docker-compose configuration and environment-specific security guards for Beta deployment to Test environment #56

Merged
mjasin merged 12 commits from infra/beta-deploy-test into develop 2026-06-01 17:17:46 +00:00
10 changed files with 223 additions and 6 deletions
Showing only changes of commit 539ad79f18 - Show all commits
+41
View File
@@ -0,0 +1,41 @@
# ===================================================================
# NexusReader — Test Environment Variables
# ===================================================================
# Copy this file to `.env` and fill in the values before deployment:
# cp .env.test.template .env
#
# Then deploy with:
# docker compose -f docker-compose.test.yml up -d --build
# ===================================================================
# === PostgreSQL ===
POSTGRES_USER=nexus_user
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
POSTGRES_DB=nexus_test_db
POSTGRES_PORT=5433
# === Neo4j ===
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
# === Qdrant (leave empty to disable API key auth) ===
QDRANT_API_KEY=
# === Web App ===
WEB_PORT=5050
# === Google OAuth (placeholder for test) ===
GOOGLE_CLIENT_ID=placeholder
GOOGLE_CLIENT_SECRET=placeholder
# === Gemini AI (placeholder for test) ===
GOOGLE_AI_API_KEY=placeholder
# === Admin Seed Password ===
NEXUS_ADMIN_PASSWORD=aQ13EdSw2
# === Non-standard ports for auxiliary services ===
QDRANT_HTTP_PORT=6343
QDRANT_GRPC_PORT=6344
NEO4J_HTTP_PORT=7484
NEO4J_BOLT_PORT=7697
+1
View File
@@ -29,6 +29,7 @@ Thumbs.db
*.epub *.epub
.fake .fake
.env
src/NexusReader.Web/nexus.db src/NexusReader.Web/nexus.db
src/NexusReader.Web/wwwroot/covers/ src/NexusReader.Web/wwwroot/covers/
src/NexusReader.Web/wwwroot/uploads/ src/NexusReader.Web/wwwroot/uploads/
+97
View File
@@ -0,0 +1,97 @@
services:
db:
image: pgvector/pgvector:pg17
container_name: nexus-db-test
environment:
POSTGRES_USER: ${POSTGRES_USER:-nexus_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB:-nexus_test_db}
ports:
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- pgdata_test:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nexus_user} -d ${POSTGRES_DB:-nexus_test_db}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- nexus-test
restart: unless-stopped
web:
build:
context: .
dockerfile: Dockerfile
container_name: nexus-web-test
ports:
- "${WEB_PORT:-5050}:5000"
mjasin marked this conversation as resolved
Review

🟡 Design — Hardcoded internal port for Qdrant gRPC in ConnectionStrings__QdrantConnection

The web container connects to Qdrant via http://qdrant:6334 (internal container port), which is correct for inter-service Docker networking. However, the gRPC port 6334 is hardcoded in the environment variable string rather than being derived from the QDRANT_GRPC_PORT variable. This is fine for the internal network but is inconsistent with how other connection strings are parameterized.

Consider making this explicit with a comment noting this is the internal container-to-container port, not the host-mapped port, so future maintainers don't confuse it with ${QDRANT_GRPC_PORT}:

# Internal Docker network ports (NOT the host-mapped ports from QDRANT_HTTP_PORT/QDRANT_GRPC_PORT)
- ConnectionStrings__QdrantConnection=http://qdrant:6334
🟡 **Design — Hardcoded internal port for Qdrant gRPC in `ConnectionStrings__QdrantConnection`** The web container connects to Qdrant via `http://qdrant:6334` (internal container port), which is correct for inter-service Docker networking. However, the gRPC port `6334` is hardcoded in the environment variable string rather than being derived from the `QDRANT_GRPC_PORT` variable. This is fine for the *internal* network but is inconsistent with how other connection strings are parameterized. Consider making this explicit with a comment noting this is the *internal* container-to-container port, not the host-mapped port, so future maintainers don't confuse it with `${QDRANT_GRPC_PORT}`: ```yaml # Internal Docker network ports (NOT the host-mapped ports from QDRANT_HTTP_PORT/QDRANT_GRPC_PORT) - ConnectionStrings__QdrantConnection=http://qdrant:6334 ```
environment:
- ASPNETCORE_ENVIRONMENT=Test
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_test_db};Username=${POSTGRES_USER:-nexus_user};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
- ConnectionStrings__QdrantConnection=http://qdrant:6334
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder}
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder}
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder}
- NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:-aQ13EdSw2}
depends_on:
db:
condition: service_healthy
qdrant:
condition: service_healthy
neo4j:
condition: service_healthy
networks:
- nexus-test
restart: unless-stopped
qdrant:
image: qdrant/qdrant:latest
container_name: nexus-qdrant-test
environment:
- QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY:-}
ports:
- "${QDRANT_HTTP_PORT:-6343}:6333"
- "${QDRANT_GRPC_PORT:-6344}:6334"
mjasin marked this conversation as resolved
Review

🟡 Design — Qdrant healthcheck uses raw TCP socket; brittle and platform-dependent

The current healthcheck bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333' depends on bash's /dev/tcp pseudo-device, which is not available in all minimal Docker images (e.g., Alpine-based). If the Qdrant image is ever updated to an Alpine base, this will silently fail, causing the web service to hang at startup.

The Qdrant HTTP API exposes a /healthz endpoint. Use that instead:

healthcheck:
  test: ["CMD-SHELL", "curl -sf http://localhost:6333/healthz || exit 1"]
  interval: 5s
  timeout: 5s
  retries: 5
  start_period: 10s
🟡 **Design — Qdrant healthcheck uses raw TCP socket; brittle and platform-dependent** The current healthcheck `bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'` depends on bash's `/dev/tcp` pseudo-device, which is not available in all minimal Docker images (e.g., Alpine-based). If the Qdrant image is ever updated to an Alpine base, this will silently fail, causing the `web` service to hang at startup. The Qdrant HTTP API exposes a `/healthz` endpoint. Use that instead: ```yaml healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:6333/healthz || exit 1"] interval: 5s timeout: 5s retries: 5 start_period: 10s ```
Review

🟡 Design — Still unresolved. Qdrant healthcheck uses bash /dev/tcp.

The healthcheck remains bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'. This is platform-dependent and not supported on Alpine-based images. Prefer the /healthz HTTP endpoint:

healthcheck:
  test: ["CMD-SHELL", "curl -sf http://localhost:6333/healthz || exit 1"]
  interval: 5s
  timeout: 5s
  retries: 5
  start_period: 10s
🟡 **Design — Still unresolved. Qdrant healthcheck uses bash `/dev/tcp`.** The healthcheck remains `bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'`. This is platform-dependent and not supported on Alpine-based images. Prefer the `/healthz` HTTP endpoint: ```yaml healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:6333/healthz || exit 1"] interval: 5s timeout: 5s retries: 5 start_period: 10s ```
volumes:
- qdrant_test_data:/qdrant/storage
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"]
interval: 5s
timeout: 5s
retries: 5
networks:
- nexus-test
restart: unless-stopped
neo4j:
image: neo4j:5-community
container_name: nexus-neo4j-test
environment:
- NEO4J_AUTH=${NEO4J_USERNAME:-neo4j}/${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
ports:
- "${NEO4J_HTTP_PORT:-7484}:7474"
- "${NEO4J_BOLT_PORT:-7697}:7687"
volumes:
- neo4j_test_data:/data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"]
interval: 10s
timeout: 10s
retries: 10
start_period: 30s
networks:
- nexus-test
restart: unless-stopped
volumes:
pgdata_test:
qdrant_test_data:
neo4j_test_data:
networks:
nexus-test:
driver: bridge
@@ -68,7 +68,8 @@ public static class DbInitializer
SecurityStamp = Guid.NewGuid().ToString() SecurityStamp = Guid.NewGuid().ToString()
}; };
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!"); var adminPassword = Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD") ?? "Admin123!";
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, adminPassword);
mjasin marked this conversation as resolved
Review

🔴 Blocking — Triple-layer fallback exposes hardcoded default credential in production

The current fallback chain Nexus:AdminPasswordNEXUS_ADMIN_PASSWORD (from IConfiguration) → Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD")"Admin123!" is dangerous. If NEXUS_ADMIN_PASSWORD is not injected at container startup (e.g. operator error, mis-spelled var), the process will silently seed the admin account with "Admin123!" without any warning — a critical security regression in Test/Prod.

The Environment.GetEnvironmentVariable call is also redundant because IConfiguration in ASP.NET Core already reads environment variables. You only need two keys, and the final fallback must only be allowed in Development.

Suggested fix:

var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
var adminPassword = configuration?["Nexus:AdminPassword"]
                    ?? configuration?["NEXUS_ADMIN_PASSWORD"];

if (string.IsNullOrWhiteSpace(adminPassword))
{
    if (environment == "Development")
    {
        adminPassword = "Admin123!";
    }
    else
    {
        throw new InvalidOperationException(
            "NEXUS_ADMIN_PASSWORD must be set for non-Development environments.");
    }
}
🔴 **Blocking — Triple-layer fallback exposes hardcoded default credential in production** The current fallback chain `Nexus:AdminPassword` → `NEXUS_ADMIN_PASSWORD` (from `IConfiguration`) → `Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD")` → `"Admin123!"` is dangerous. If `NEXUS_ADMIN_PASSWORD` is not injected at container startup (e.g. operator error, mis-spelled var), the process will silently seed the admin account with `"Admin123!"` without any warning — a critical security regression in Test/Prod. The `Environment.GetEnvironmentVariable` call is also redundant because `IConfiguration` in ASP.NET Core already reads environment variables. You only need **two** keys, and the final fallback must only be allowed in `Development`. **Suggested fix:** ```csharp var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"; var adminPassword = configuration?["Nexus:AdminPassword"] ?? configuration?["NEXUS_ADMIN_PASSWORD"]; if (string.IsNullOrWhiteSpace(adminPassword)) { if (environment == "Development") { adminPassword = "Admin123!"; } else { throw new InvalidOperationException( "NEXUS_ADMIN_PASSWORD must be set for non-Development environments."); } } ```
Review

🔴 Blocking — Still unresolved. Silent credential fallback in non-Development environments.

This code is identical to the original. The "Admin123!" default will still be reached silently in Test/Production if NEXUS_ADMIN_PASSWORD is absent (e.g. from a typo in the .env file or a missing Docker secret). The docker-compose.test.yml does enforce ${NEXUS_ADMIN_PASSWORD:?...} at the compose level, but this C# fallback provides a false safety net that can be triggered by non-compose deployments (e.g., direct kubectl apply).

Please add the environment check before applying the fallback:

var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
var adminPassword = configuration?["Nexus:AdminPassword"]
                    ?? configuration?["NEXUS_ADMIN_PASSWORD"];

if (string.IsNullOrWhiteSpace(adminPassword))
{
    if (environment == "Development")
    {
        adminPassword = "Admin123!";
    }
    else
    {
        throw new InvalidOperationException(
            "NEXUS_ADMIN_PASSWORD must be configured for non-Development environments. Aborting startup.");
    }
}

This also eliminates the redundant Environment.GetEnvironmentVariable call, since IConfiguration already reads env vars.

🔴 **Blocking — Still unresolved. Silent credential fallback in non-Development environments.** This code is identical to the original. The `"Admin123!"` default will still be reached silently in Test/Production if `NEXUS_ADMIN_PASSWORD` is absent (e.g. from a typo in the `.env` file or a missing Docker secret). The `docker-compose.test.yml` does enforce `${NEXUS_ADMIN_PASSWORD:?...}` at the compose level, but this C# fallback provides a false safety net that can be triggered by non-compose deployments (e.g., direct `kubectl apply`). Please add the environment check before applying the fallback: ```csharp var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"; var adminPassword = configuration?["Nexus:AdminPassword"] ?? configuration?["NEXUS_ADMIN_PASSWORD"]; if (string.IsNullOrWhiteSpace(adminPassword)) { if (environment == "Development") { adminPassword = "Admin123!"; } else { throw new InvalidOperationException( "NEXUS_ADMIN_PASSWORD must be configured for non-Development environments. Aborting startup."); } } ``` This also eliminates the redundant `Environment.GetEnvironmentVariable` call, since `IConfiguration` already reads env vars.
dbContext.Users.Add(adminUser); dbContext.Users.Add(adminUser);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
@@ -56,9 +56,14 @@ public static class DependencyInjection
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334"; var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl))); services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl)));
// Neo4j Driver registration // Neo4j Driver registration (supports optional authentication)
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687"; var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, AuthTokens.None)); var neo4jUser = configuration["Neo4j:Username"];
var neo4jPass = configuration["Neo4j:Password"];
var neo4jAuth = !string.IsNullOrEmpty(neo4jUser)
? AuthTokens.Basic(neo4jUser, neo4jPass ?? string.Empty)
: AuthTokens.None;
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, neo4jAuth));
// Hangfire registration // Hangfire registration
if (!string.IsNullOrEmpty(pgConnectionString)) if (!string.IsNullOrEmpty(pgConnectionString))
@@ -7,6 +7,7 @@
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IJSRuntime JS @inject IJSRuntime JS
@inject IConfiguration Configuration
<div class="login-page-container"> <div class="login-page-container">
<div class="mesh-bg"></div> <div class="mesh-bg"></div>
@@ -80,8 +81,14 @@
</EditForm> </EditForm>
<div class="auth-footer"> <div class="auth-footer">
<a href="/account/forgot-password" class="auth-link">Zapomniałem hasła?</a> @if (_allowPasswordReset)
<p class="auth-switch">Nie masz konta? <a href="/account/register">Zarejestruj się</a></p> {
<a href="/account/forgot-password" class="auth-link">Zapomniałem hasła?</a>
}
@if (_allowRegistration)
{
<p class="auth-switch">Nie masz konta? <a href="/account/register">Zarejestruj się</a></p>
}
</div> </div>
<div class="auth-legal"> <div class="auth-legal">
@@ -106,9 +113,14 @@
private string? _errorMessage; private string? _errorMessage;
private bool _isSubmitting; private bool _isSubmitting;
private bool _showPassword; private bool _showPassword;
private bool _allowRegistration;
private bool _allowPasswordReset;
protected override void OnInitialized() protected override void OnInitialized()
{ {
_allowRegistration = Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
_allowPasswordReset = Configuration.GetValue<bool?>("Features:AllowPasswordReset") ?? true;
if (!string.IsNullOrEmpty(ErrorCode)) if (!string.IsNullOrEmpty(ErrorCode))
{ {
_errorMessage = ErrorCode switch _errorMessage = ErrorCode switch
@@ -118,6 +130,7 @@
"UserAlreadyExists" => "Użytkownik o tym adresie e-mail już istnieje. Zaloguj się tradycyjnie hasłem.", "UserAlreadyExists" => "Użytkownik o tym adresie e-mail już istnieje. Zaloguj się tradycyjnie hasłem.",
"LockedOut" => "Twoje konto zostało zablokowane. Spróbuj ponownie później.", "LockedOut" => "Twoje konto zostało zablokowane. Spróbuj ponownie później.",
"InvalidCredentials" => "Nieprawidłowy e-mail lub hasło.", "InvalidCredentials" => "Nieprawidłowy e-mail lub hasło.",
"RegistrationDisabled" => "Rejestracja jest wyłączona w tym środowisku.",
_ => "Wystąpił nieoczekiwany błąd podczas logowania." _ => "Wystąpił nieoczekiwany błąd podczas logowania."
}; };
} }
@@ -7,6 +7,7 @@
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IJSRuntime JS @inject IJSRuntime JS
@inject IConfiguration Configuration
<div class="login-page-container"> <div class="login-page-container">
<div class="mesh-bg"></div> <div class="mesh-bg"></div>
@@ -81,6 +82,15 @@
private string? _errorMessage; private string? _errorMessage;
private bool _isSubmitting; private bool _isSubmitting;
protected override void OnInitialized()
{
var allowRegistration = Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
if (!allowRegistration)
{
NavigationManager.NavigateTo("/account/login?error=RegistrationDisabled", replace: true);
}
}
private async Task HandleRegister() private async Task HandleRegister()
{ {
_isSubmitting = true; _isSubmitting = true;
+1
View File
@@ -16,6 +16,7 @@
@using NexusReader.UI.Shared.Components.Organisms @using NexusReader.UI.Shared.Components.Organisms
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using Microsoft.Extensions.Logging @using Microsoft.Extensions.Logging
@using Microsoft.Extensions.Configuration
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User @using NexusReader.Application.DTOs.User
@using NexusReader.Application.Queries.Reader @using NexusReader.Application.Queries.Reader
+36 -1
View File
@@ -252,6 +252,35 @@ app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseAntiforgery(); app.UseAntiforgery();
// Feature flags: block registration & password reset in restricted environments (e.g. Test)
var allowRegistration = app.Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
var allowPasswordReset = app.Configuration.GetValue<bool?>("Features:AllowPasswordReset") ?? true;
if (!allowRegistration || !allowPasswordReset)
{
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value?.ToLowerInvariant();
if (!allowRegistration && path is "/identity/register" or "/account/register")
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Registration is disabled in this environment.");
return;
}
if (!allowPasswordReset && path is "/identity/forgotpassword" or "/account/forgot-password")
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Password reset is disabled in this environment.");
return;
}
await next();
});
}
app.MapStaticAssets(); app.MapStaticAssets();
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub"); app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
@@ -520,7 +549,13 @@ app.MapGet("/identity/callback/google", async (
return Results.Redirect("/account/login?error=LockedOut"); return Results.Redirect("/account/login?error=LockedOut");
} }
// New user provisioning // New user provisioning (blocked when registration is disabled)
if (!allowRegistration)
{
logger.LogWarning("Google provisioning blocked: registration is disabled in this environment.");
return Results.Redirect("/account/login?error=RegistrationDisabled");
}
var email = info.Principal.FindFirstValue(ClaimTypes.Email); var email = info.Principal.FindFirstValue(ClaimTypes.Email);
if (email != null) if (email != null)
{ {
+13
View File
@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Features": {
"AllowRegistration": false,
"AllowPasswordReset": false
},
"ApiBaseUrl": "http://localhost:5000"
}