mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 06:33:13 +00:00
6-domain audit (API, web, mobile, lambdas, infra, QA) with fixes: API security: scope internal API key middleware to allowed paths only, return 401 on invalid key instead of falling through, remove unvalidated JWT code path, sanitize error messages, add UpdateProposal validator, remove status field from UpdateProposalRequest to prevent over-posting, log swallowed exceptions in ProposalService. Infrastructure: enforce SSL on all S3 buckets, encrypt SQS queues, enable optional MFA on Cognito, add API Gateway access logging. Lambdas: fix _retry_request undefined variable across all 4 Lambdas, re-raise exceptions in pdf-extract/pdf-generate instead of swallowing, add idempotency guard to suggestions Lambda. Web: add ErrorBoundary, add auth loading state to ProtectedRoute, add mutation error toasts in AdminWorkspace, fix dead Cognito link. Mobile: add mutex to offline queue processing, distinguish permanent vs retryable failures, register all screens for both roles, log sync errors. Swagger/OpenAPI: add Swashbuckle with JWT bearer security definition, add ProducesResponseType attributes to key endpoints. Includes AUDIT-REPORT.md with complete findings and CLAUDE.md project instructions.
238 lines
7.8 KiB
C#
238 lines
7.8 KiB
C#
using System.Text;
|
|
using Amazon.DynamoDBv2;
|
|
using Amazon.S3;
|
|
using Amazon.SecretsManager;
|
|
using Amazon.SQS;
|
|
using FluentValidation;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Microsoft.OpenApi.Models;
|
|
using ProposalSystem.Api.Middleware;
|
|
using ProposalSystem.Api.Services;
|
|
using ProposalSystem.Application.Interfaces;
|
|
using ProposalSystem.Application.Validators;
|
|
using ProposalSystem.Infrastructure.Data;
|
|
using ProposalSystem.Infrastructure.Services;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Dev mode flag (read early for conditional setup)
|
|
var devMode = builder.Configuration.GetValue<bool>("Auth:DevMode") && builder.Environment.IsDevelopment();
|
|
|
|
// AWS SDK clients (skip in dev mode — no real AWS credentials needed)
|
|
if (!devMode)
|
|
{
|
|
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
|
|
builder.Services.AddAWSService<IAmazonS3>();
|
|
builder.Services.AddAWSService<IAmazonSQS>();
|
|
builder.Services.AddAWSService<IAmazonSecretsManager>();
|
|
builder.Services.AddAWSService<IAmazonDynamoDB>();
|
|
}
|
|
|
|
// Database
|
|
var dbSecretArn = builder.Configuration["DB_SECRET_ARN"];
|
|
if (!string.IsNullOrEmpty(dbSecretArn))
|
|
{
|
|
var smClient = new AmazonSecretsManagerClient();
|
|
var connectionString = SecretsManagerConnectionString.ResolveAsync(smClient, dbSecretArn).GetAwaiter().GetResult();
|
|
builder.Services.AddDbContext<ProposalDbContext>(options =>
|
|
options.UseNpgsql(connectionString));
|
|
}
|
|
else
|
|
{
|
|
builder.Services.AddDbContext<ProposalDbContext>(options =>
|
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
|
}
|
|
|
|
// Internal API key (for Lambda-to-API calls)
|
|
var internalApiKeySecretArn = builder.Configuration["INTERNAL_API_KEY_SECRET_ARN"];
|
|
if (!string.IsNullOrEmpty(internalApiKeySecretArn))
|
|
{
|
|
var smClient = new AmazonSecretsManagerClient();
|
|
var secretResponse = smClient.GetSecretValueAsync(new Amazon.SecretsManager.Model.GetSecretValueRequest
|
|
{
|
|
SecretId = internalApiKeySecretArn,
|
|
}).GetAwaiter().GetResult();
|
|
builder.Configuration["INTERNAL_API_KEY"] = secretResponse.SecretString;
|
|
}
|
|
|
|
// Authentication
|
|
var cognitoAuthority = builder.Configuration["Auth:Authority"];
|
|
|
|
if (!string.IsNullOrEmpty(cognitoAuthority))
|
|
{
|
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(options =>
|
|
{
|
|
options.Authority = cognitoAuthority;
|
|
var webClientId = builder.Configuration["COGNITO_WEB_CLIENT_ID"] ?? "";
|
|
var mobileClientId = builder.Configuration["COGNITO_MOBILE_CLIENT_ID"] ?? "";
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuerSigningKey = true,
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidAudiences = new[] { webClientId, mobileClientId }.Where(s => !string.IsNullOrEmpty(s)).ToList(),
|
|
ValidateLifetime = true,
|
|
RoleClaimType = "cognito:groups",
|
|
};
|
|
});
|
|
}
|
|
else if (devMode)
|
|
{
|
|
var devSigningKey = builder.Configuration["Auth:DevSigningKey"]!;
|
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(options =>
|
|
{
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(devSigningKey)),
|
|
ValidateIssuer = true,
|
|
ValidIssuer = "proposal-system-dev",
|
|
ValidateAudience = true,
|
|
ValidAudience = "proposal-system-dev",
|
|
ValidateLifetime = true,
|
|
RoleClaimType = "cognito:groups",
|
|
};
|
|
});
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Authentication is not configured. Set Auth:Authority for Cognito or Auth:DevMode=true (Development only).");
|
|
}
|
|
|
|
builder.Services.AddAuthorization();
|
|
|
|
// Services
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
|
builder.Services.AddScoped<IProposalService, ProposalService>();
|
|
builder.Services.AddScoped<ILineItemService, LineItemService>();
|
|
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
|
builder.Services.AddScoped<IAuditService, AuditService>();
|
|
builder.Services.AddScoped<IProposalNumberGenerator, ProposalNumberGenerator>();
|
|
if (devMode)
|
|
{
|
|
builder.Services.AddScoped<IS3Service, DevS3Service>();
|
|
builder.Services.AddScoped<ISiteService, DevSiteService>();
|
|
}
|
|
else
|
|
{
|
|
builder.Services.AddScoped<IS3Service, S3Service>();
|
|
builder.Services.AddScoped<ISiteService, SiteService>();
|
|
}
|
|
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
|
|
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
|
|
if (string.IsNullOrEmpty(jobsQueueUrl))
|
|
{
|
|
builder.Services.AddScoped<IJobPublisher, NoOpJobPublisher>();
|
|
}
|
|
else
|
|
{
|
|
builder.Services.AddScoped<IJobPublisher>(sp =>
|
|
{
|
|
var sqsClient = sp.GetRequiredService<IAmazonSQS>();
|
|
return new SqsJobPublisher(sqsClient, jobsQueueUrl);
|
|
});
|
|
}
|
|
|
|
// HTTP client for Cognito token exchange
|
|
builder.Services.AddHttpClient();
|
|
|
|
// Validation
|
|
builder.Services.AddValidatorsFromAssemblyContaining<CreateProposalValidator>();
|
|
|
|
// Controllers
|
|
builder.Services.AddControllers(options =>
|
|
{
|
|
options.Filters.Add<ValidationFilter>();
|
|
}).AddJsonOptions(options =>
|
|
{
|
|
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
|
});
|
|
|
|
// OpenAPI / Swagger
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddSwaggerGen(options =>
|
|
{
|
|
options.SwaggerDoc("v1", new OpenApiInfo
|
|
{
|
|
Title = "Proposal System API",
|
|
Version = "v1",
|
|
Description = "Sea Haven Industries proposal management API",
|
|
});
|
|
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
|
{
|
|
Name = "Authorization",
|
|
Type = SecuritySchemeType.Http,
|
|
Scheme = "bearer",
|
|
BearerFormat = "JWT",
|
|
In = ParameterLocation.Header,
|
|
Description = "Cognito JWT access token",
|
|
});
|
|
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
|
{
|
|
{
|
|
new OpenApiSecurityScheme
|
|
{
|
|
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" },
|
|
},
|
|
Array.Empty<string>()
|
|
},
|
|
});
|
|
});
|
|
|
|
// Middleware
|
|
builder.Services.AddTransient<GlobalExceptionHandler>();
|
|
|
|
// Health checks
|
|
builder.Services.AddHealthChecks()
|
|
.AddDbContextCheck<ProposalDbContext>();
|
|
|
|
// CORS
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddDefaultPolicy(policy =>
|
|
{
|
|
var origins = new List<string> { "https://proposals.seahaven.com" };
|
|
if (builder.Environment.IsDevelopment())
|
|
origins.Add("http://localhost:5173");
|
|
policy.WithOrigins(origins.ToArray())
|
|
.AllowAnyMethod()
|
|
.AllowAnyHeader();
|
|
});
|
|
});
|
|
|
|
// Lambda hosting
|
|
builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseMiddleware<GlobalExceptionHandler>();
|
|
|
|
if (!app.Environment.IsProduction())
|
|
{
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Proposal System API v1"));
|
|
}
|
|
|
|
app.UseCors();
|
|
app.UseMiddleware<InternalApiKeyMiddleware>();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.Use(async (context, next) =>
|
|
{
|
|
if (context.User.Identity?.IsAuthenticated == true)
|
|
{
|
|
var userService = context.RequestServices.GetRequiredService<ICurrentUserService>();
|
|
await userService.ResolveAsync();
|
|
}
|
|
await next();
|
|
});
|
|
app.MapControllers();
|
|
app.MapHealthChecks("/api/health");
|
|
|
|
app.Run();
|