mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 18:01:10 +00:00
5e90512987
Sealed-Jetson + SaaS architecture eliminates the credential-reuse-across-
machines threat that motivated hardware fingerprint binding. The binding's
only remaining effect was a real production failure mode on legitimate
hardware events.
Production:
- Drop PUT /users/hardware/set and POST /resources/check.
- Simplify POST /resources/get/{dataFolder?} (no Hardware field).
- Remove CheckHardwareHash, UpdateHardware, Security.GetHWHash.
- GetApiEncryptionKey signature: (email, password) — no hardwareHash.
- Drop SetHWRequest DTO and Hardware property from GetResourceRequest.
- Remove HardwareIdMismatch (40) and BadHardware (45) ExceptionEnum
entries; numeric codes left as a gap, not for reuse.
Wire-compat policy: drop entirely (no Loader; no in-flight legacy
clients). Stale callers will see 404s, which is the right loud failure.
Tombstones:
- User.Hardware DB column kept (nullable, unused) — separate cleanup
ticket for the migration per workspace "no rename without confirmation".
- User.LastLogin is now never written by app code (only writer was inside
the deleted CheckHardwareHash); flagged in batch_06_review for a future
ticket.
Tests:
- Delete e2e HardwareBindingTests (165 lines) and Azaion.Test
UserServiceTest (sole test was CheckHardwareHashTest).
- Drop Hardware payloads + /resources/check preconditions from e2e
ResourceTests, SecurityTests, ResilienceTests; drop hardwareId arg
from Azaion.Test SecurityTest.
- Add SecurityTests.Hardware_endpoints_are_removed_AZ_197 (AC-2 regression
asserting both removed routes return 404).
Docs:
- architecture.md: System Context note, ADR-003 new key formula, ADR-004
retired with rationale.
- diagrams/flows/flow_hardware_check.md: tombstoned.
Also archives the four batch-1+batch-2 task files into _docs/02_tasks/done/
(file moves were missed by the batch_05 commit).
Code review: PASS — see _docs/03_implementation/reviews/batch_06_review.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
330 lines
13 KiB
C#
330 lines
13 KiB
C#
using System.Text;
|
|
using Azaion.Common;
|
|
using Azaion.Common.Configs;
|
|
using Azaion.Common.Database;
|
|
using Azaion.Common.Entities;
|
|
using Azaion.Common.Requests;
|
|
using Azaion.Services;
|
|
using FluentValidation;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Rewrite;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Microsoft.OpenApi;
|
|
using Serilog;
|
|
|
|
Log.Logger = new LoggerConfiguration()
|
|
.Enrich.FromLogContext()
|
|
.MinimumLevel.Information()
|
|
.WriteTo.Console()
|
|
.WriteTo.File(
|
|
path: "logs/log.txt",
|
|
rollingInterval: RollingInterval.Day)
|
|
.CreateLogger();
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 209715200);
|
|
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
|
|
o.MultipartBodyLengthLimit = 209715200);
|
|
|
|
var jwtConfig = builder.Configuration.GetSection(nameof(JwtConfig)).Get<JwtConfig>();
|
|
if (jwtConfig == null || string.IsNullOrEmpty(jwtConfig.Secret))
|
|
throw new Exception("Missing configuration section: JwtConfig");
|
|
var signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtConfig.Secret));
|
|
|
|
builder.Services.AddSerilog();
|
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(o =>
|
|
{
|
|
o.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true,
|
|
ValidIssuer = jwtConfig.Issuer,
|
|
ValidAudience = jwtConfig.Audience,
|
|
IssuerSigningKey = signingKey
|
|
};
|
|
});
|
|
|
|
#region Policies
|
|
|
|
var apiAdminPolicy = new AuthorizationPolicyBuilder()
|
|
.RequireRole(RoleEnum.ApiAdmin.ToString()).Build();
|
|
|
|
var apiUploaderPolicy = new AuthorizationPolicyBuilder()
|
|
.RequireRole(RoleEnum.ResourceUploader.ToString(), RoleEnum.ApiAdmin.ToString()).Build();
|
|
|
|
builder.Services.AddAuthorization(o =>
|
|
{
|
|
o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy);
|
|
o.AddPolicy(nameof(apiUploaderPolicy), apiUploaderPolicy);
|
|
});
|
|
|
|
#endregion Policies
|
|
|
|
|
|
builder.Services.AddHttpContextAccessor();
|
|
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddSwaggerGen(c =>
|
|
{
|
|
c.SwaggerDoc("v1", new OpenApiInfo {Title = "Azaion.API", Version = "v1"});
|
|
c.CustomSchemaIds(type => type.ToString());
|
|
var jwtSecurityScheme = new OpenApiSecurityScheme
|
|
{
|
|
Scheme = "bearer",
|
|
BearerFormat = "JWT",
|
|
Name = "JWT Authentication",
|
|
In = ParameterLocation.Header,
|
|
Type = SecuritySchemeType.Http,
|
|
Description = "Put **_ONLY_** your JWT Bearer token on textbox below!",
|
|
};
|
|
|
|
c.AddSecurityDefinition(JwtBearerDefaults.AuthenticationScheme, jwtSecurityScheme);
|
|
|
|
c.AddSecurityRequirement(_ => new OpenApiSecurityRequirement
|
|
{
|
|
{ new OpenApiSecuritySchemeReference(JwtBearerDefaults.AuthenticationScheme, null), new List<string>() }
|
|
});
|
|
});
|
|
builder.Services.Configure<ResourcesConfig>(builder.Configuration.GetSection(nameof(ResourcesConfig)));
|
|
builder.Services.Configure<JwtConfig>(builder.Configuration.GetSection(nameof(JwtConfig)));
|
|
builder.Services.Configure<ConnectionStrings>(builder.Configuration.GetSection(nameof(ConnectionStrings)));
|
|
|
|
builder.Services.AddScoped<IUserService, UserService>();
|
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
|
builder.Services.AddScoped<IResourcesService, ResourcesService>();
|
|
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
|
|
builder.Services.AddScoped<IResourceUpdateService, ResourceUpdateService>();
|
|
builder.Services.AddSingleton<IDbFactory, DbFactory>();
|
|
|
|
builder.Services.AddLazyCache();
|
|
builder.Services.AddScoped<ICache, MemoryCache>();
|
|
|
|
builder.Services.AddValidatorsFromAssemblyContaining<RegisterUserValidator>();
|
|
builder.Services.AddExceptionHandler<BusinessExceptionHandler>();
|
|
|
|
// Add CORS configuration
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddPolicy("AdminCorsPolicy", policy =>
|
|
{
|
|
policy.WithOrigins("https://admin.azaion.com", "http://admin.azaion.com")
|
|
.AllowAnyMethod()
|
|
.AllowAnyHeader()
|
|
.AllowCredentials();
|
|
});
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI();
|
|
}
|
|
|
|
app.UseCors("AdminCorsPolicy");
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.UseRewriter(new RewriteOptions().AddRedirect("^$", "/swagger"));
|
|
|
|
app.MapPost("/login",
|
|
async (LoginRequest request, IUserService userService, IAuthService authService, CancellationToken cancellationToken) =>
|
|
{
|
|
var user = await userService.ValidateUser(request, ct: cancellationToken);
|
|
return Results.Ok(new { Token = authService.CreateToken(user)});
|
|
})
|
|
.WithSummary("Login");
|
|
|
|
app.MapPost("/users",
|
|
async (RegisterUserRequest registerUserRequest, IValidator<RegisterUserRequest> validator,
|
|
IUserService userService, CancellationToken cancellationToken) =>
|
|
{
|
|
var validation = await validator.ValidateAsync(registerUserRequest, cancellationToken);
|
|
if (!validation.IsValid)
|
|
return Results.ValidationProblem(validation.ToDictionary());
|
|
await userService.RegisterUser(registerUserRequest, cancellationToken);
|
|
return Results.Ok();
|
|
})
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Creates a new user");
|
|
|
|
app.MapPost("/devices",
|
|
async (IUserService userService, CancellationToken cancellationToken)
|
|
=> await userService.RegisterDevice(cancellationToken))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Creates a new device (server-assigned serial, email and password)");
|
|
|
|
app.MapGet("/users/current",
|
|
async (IAuthService authService) => await authService.GetCurrentUser())
|
|
.RequireAuthorization()
|
|
.WithSummary("Get Current User");
|
|
|
|
app.MapGet("/users",
|
|
async (string? searchEmail, RoleEnum? searchRole, IUserService userService, CancellationToken ct)
|
|
=> await userService.GetUsers(searchEmail, searchRole, ct))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("List users by criteria");
|
|
|
|
app.MapPut("/users/queue-offsets/set",
|
|
async ([FromBody]SetUserQueueOffsetsRequest request, IUserService userService, CancellationToken ct)
|
|
=> await userService.UpdateQueueOffsets(request.Email, request.Offsets, ct))
|
|
.RequireAuthorization()
|
|
.WithSummary("Sets user's queue offsets");
|
|
|
|
app.MapPut("/users/{email}/set-role/{role}", async (string email, RoleEnum role, IUserService userService, CancellationToken ct)
|
|
=> await userService.ChangeRole(email, role, ct))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Set user's role");
|
|
|
|
app.MapPut("/users/{email}/enable", async (string email, IUserService userService, CancellationToken ct)
|
|
=> await userService.SetEnableStatus(email, true, ct))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Enable user");
|
|
|
|
app.MapPut("/users/{email}/disable", async (string email, IUserService userService, CancellationToken ct)
|
|
=> await userService.SetEnableStatus(email, false, ct))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Disable user");
|
|
|
|
app.MapDelete("/users/{email}", async (string email, IUserService userService, CancellationToken ct)
|
|
=> await userService.RemoveUser(email, ct))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Remove user");
|
|
|
|
app.MapPost("/resources/{dataFolder?}",
|
|
async ([FromRoute]string? dataFolder, IFormFile? data, IResourcesService resourceService, CancellationToken ct) =>
|
|
{
|
|
if (data is null)
|
|
throw new BusinessException(ExceptionEnum.NoFileProvided);
|
|
await resourceService.SaveResource(dataFolder, data, ct);
|
|
})
|
|
.Accepts<IFormFile>("multipart/form-data")
|
|
.RequireAuthorization()
|
|
.WithSummary("Upload resource")
|
|
.DisableAntiforgery();
|
|
|
|
app.MapGet("/resources/list/{dataFolder?}",
|
|
async ([FromRoute]string? dataFolder, string? search, IResourcesService resourcesService, CancellationToken ct)
|
|
=> await resourcesService.ListResources(dataFolder, search, ct))
|
|
.RequireAuthorization()
|
|
.WithSummary("Lists resources in folder");
|
|
|
|
app.MapPost("/resources/clear/{dataFolder?}",
|
|
([FromRoute]string? dataFolder, IResourcesService resourcesService) => resourcesService.ClearFolder(dataFolder))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Clear folder");
|
|
|
|
app.MapPost("/resources/get/{dataFolder?}", //Need to have POST method for secure password
|
|
async ([FromBody]GetResourceRequest request, [FromRoute]string? dataFolder, IAuthService authService,
|
|
IResourcesService resourcesService, CancellationToken ct) =>
|
|
{
|
|
var user = await authService.GetCurrentUser();
|
|
if (user == null)
|
|
throw new UnauthorizedAccessException();
|
|
|
|
var key = Security.GetApiEncryptionKey(user.Email, request.Password);
|
|
var stream = await resourcesService.GetEncryptedResource(dataFolder, request.FileName, key, ct);
|
|
|
|
return Results.File(stream, "application/octet-stream", request.FileName);
|
|
}).RequireAuthorization()
|
|
.WithSummary("Gets encrypted by user's Password resource. POST method for secure password");
|
|
|
|
app.MapGet("/resources/get-installer",
|
|
async (IAuthService authService, IResourcesService resourcesService, CancellationToken ct) =>
|
|
{
|
|
var user = await authService.GetCurrentUser();
|
|
if (user == null)
|
|
throw new UnauthorizedAccessException();
|
|
var (name, stream) = resourcesService.GetInstaller(isStage: false);
|
|
if (stream == null)
|
|
throw new FileNotFoundException("Installer file was not found!");
|
|
return Results.File(stream, "application/octet-stream", name);
|
|
}).RequireAuthorization()
|
|
.WithSummary("Gets latest installer");
|
|
|
|
app.MapGet("/resources/get-installer/stage",
|
|
async (IAuthService authService, IResourcesService resourcesService, CancellationToken ct) =>
|
|
{
|
|
var user = await authService.GetCurrentUser();
|
|
if (user == null)
|
|
throw new UnauthorizedAccessException();
|
|
var (name, stream) = resourcesService.GetInstaller(isStage: true);
|
|
if (stream == null)
|
|
throw new FileNotFoundException("Installer file was not found!");
|
|
return Results.File(stream, "application/octet-stream", name);
|
|
}).RequireAuthorization()
|
|
.WithSummary("Gets latest installer");
|
|
|
|
|
|
app.MapPost("/classes",
|
|
async (CreateDetectionClassRequest request, IValidator<CreateDetectionClassRequest> validator,
|
|
IDetectionClassService detectionClassService, CancellationToken ct) =>
|
|
{
|
|
var validation = await validator.ValidateAsync(request, ct);
|
|
if (!validation.IsValid)
|
|
return Results.ValidationProblem(validation.ToDictionary());
|
|
var created = await detectionClassService.Create(request, ct);
|
|
return Results.Ok(created);
|
|
})
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Creates a new detection class");
|
|
|
|
app.MapPatch("/classes/{id:int}",
|
|
async (int id, UpdateDetectionClassRequest request, IValidator<UpdateDetectionClassRequest> validator,
|
|
IDetectionClassService detectionClassService, CancellationToken ct) =>
|
|
{
|
|
var validation = await validator.ValidateAsync(request, ct);
|
|
if (!validation.IsValid)
|
|
return Results.ValidationProblem(validation.ToDictionary());
|
|
var updated = await detectionClassService.Update(id, request, ct);
|
|
return updated == null ? Results.NotFound() : Results.Ok(updated);
|
|
})
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Updates an existing detection class (partial-merge accepted)");
|
|
|
|
app.MapDelete("/classes/{id:int}",
|
|
async (int id, IDetectionClassService detectionClassService, CancellationToken ct) =>
|
|
{
|
|
var ok = await detectionClassService.Delete(id, ct);
|
|
return ok ? Results.NoContent() : Results.NotFound();
|
|
})
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Deletes a detection class");
|
|
|
|
app.MapPost("/get-update",
|
|
async (GetUpdateRequest request, IValidator<GetUpdateRequest> validator,
|
|
IResourceUpdateService resourceUpdateService, CancellationToken ct) =>
|
|
{
|
|
var validation = await validator.ValidateAsync(request, ct);
|
|
if (!validation.IsValid)
|
|
return Results.ValidationProblem(validation.ToDictionary());
|
|
var updates = await resourceUpdateService.GetUpdate(request, ct);
|
|
return Results.Ok(updates);
|
|
})
|
|
.RequireAuthorization()
|
|
.WithSummary("Returns resources newer than the device's reported current versions");
|
|
|
|
app.MapPost("/resources/publish",
|
|
async (PublishResourceRequest request, IValidator<PublishResourceRequest> validator,
|
|
IResourceUpdateService resourceUpdateService, CancellationToken ct) =>
|
|
{
|
|
var validation = await validator.ValidateAsync(request, ct);
|
|
if (!validation.IsValid)
|
|
return Results.ValidationProblem(validation.ToDictionary());
|
|
await resourceUpdateService.Publish(request, ct);
|
|
return Results.Ok();
|
|
})
|
|
.RequireAuthorization(apiUploaderPolicy)
|
|
.WithSummary("CI/CD: publish a new resource version (encrypts encryption_key at rest, invalidates the per-(arch,stage) latest-versions cache)");
|
|
|
|
app.UseExceptionHandler(_ => {});
|
|
|
|
app.Run();
|