feat(infra): Docker-compose configuration and environment-specific security guards for Beta deployment to Test environment #56
@@ -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
|
||||
@@ -29,6 +29,7 @@ Thumbs.db
|
||||
*.epub
|
||||
|
||||
.fake
|
||||
.env
|
||||
src/NexusReader.Web/nexus.db
|
||||
src/NexusReader.Web/wwwroot/covers/
|
||||
src/NexusReader.Web/wwwroot/uploads/
|
||||
|
||||
@@ -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
|
||||
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
Antigravity
commented
🟡 Design — Qdrant healthcheck uses raw TCP socket; brittle and platform-dependent The current healthcheck The Qdrant HTTP API exposes a 🟡 **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
```
Antigravity
commented
🟡 Design — Still unresolved. Qdrant healthcheck uses bash The healthcheck remains 🟡 **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()
|
||||
};
|
||||
|
||||
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
Antigravity
commented
🔴 Blocking — Triple-layer fallback exposes hardcoded default credential in production The current fallback chain The Suggested fix: 🔴 **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.");
}
}
```
Antigravity
commented
🔴 Blocking — Still unresolved. Silent credential fallback in non-Development environments. This code is identical to the original. The Please add the environment check before applying the fallback: This also eliminates the redundant 🔴 **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);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
@@ -56,9 +56,14 @@ public static class DependencyInjection
|
||||
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
||||
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";
|
||||
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
|
||||
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JS
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
<div class="login-page-container">
|
||||
<div class="mesh-bg"></div>
|
||||
@@ -80,8 +81,14 @@
|
||||
</EditForm>
|
||||
|
||||
<div class="auth-footer">
|
||||
@if (_allowPasswordReset)
|
||||
{
|
||||
<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 class="auth-legal">
|
||||
@@ -106,9 +113,14 @@
|
||||
private string? _errorMessage;
|
||||
private bool _isSubmitting;
|
||||
private bool _showPassword;
|
||||
private bool _allowRegistration;
|
||||
private bool _allowPasswordReset;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_allowRegistration = Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
|
||||
_allowPasswordReset = Configuration.GetValue<bool?>("Features:AllowPasswordReset") ?? true;
|
||||
|
||||
if (!string.IsNullOrEmpty(ErrorCode))
|
||||
{
|
||||
_errorMessage = ErrorCode switch
|
||||
@@ -118,6 +130,7 @@
|
||||
"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.",
|
||||
"InvalidCredentials" => "Nieprawidłowy e-mail lub hasło.",
|
||||
"RegistrationDisabled" => "Rejestracja jest wyłączona w tym środowisku.",
|
||||
_ => "Wystąpił nieoczekiwany błąd podczas logowania."
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JS
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
<div class="login-page-container">
|
||||
<div class="mesh-bg"></div>
|
||||
@@ -81,6 +82,15 @@
|
||||
private string? _errorMessage;
|
||||
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()
|
||||
{
|
||||
_isSubmitting = true;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using Microsoft.Extensions.Configuration
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using NexusReader.Application.Queries.Reader
|
||||
|
||||
@@ -252,6 +252,35 @@ app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
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.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||
|
||||
@@ -520,7 +549,13 @@ app.MapGet("/identity/callback/google", async (
|
||||
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);
|
||||
if (email != null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Features": {
|
||||
"AllowRegistration": false,
|
||||
"AllowPasswordReset": false
|
||||
},
|
||||
"ApiBaseUrl": "http://localhost:5000"
|
||||
}
|
||||
🟡 Design — Hardcoded internal port for Qdrant gRPC in
ConnectionStrings__QdrantConnectionThe web container connects to Qdrant via
http://qdrant:6334(internal container port), which is correct for inter-service Docker networking. However, the gRPC port6334is hardcoded in the environment variable string rather than being derived from theQDRANT_GRPC_PORTvariable. 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}: