Files
admin/Azaion.AdminApi/Program.cs
T
Oleksandr Bezdieniezhnykh 5ca9ccab2c [AZ-513] [AZ-196] [AZ-183] Add /classes CRUD, /devices, fleet OTA
AZ-513: POST/PATCH/DELETE /classes for detection-class CRUD; new
DetectionClass entity, schema, DTOs, IDetectionClassService. Unblocks
ui/AZ-512.

AZ-196: POST /devices auto-assigns sequential azj-NNNN serial+email
+password and inserts a CompanionPC user. Returns plaintext credentials
for the provisioning script.

AZ-183: Resources table + POST /get-update + POST /resources/publish
for fleet OTA. Per-resource encryption_key column AES-256-CBC encrypted
at rest with ResourcesConfig.EncryptionMasterKey; ICache wraps the
per-(arch,stage) latest-versions lookup and is invalidated on publish.

Adds IDbFactory.RunAdmin<T> overload for write-and-return.

Backfills _docs/02_document/module-layout.md to satisfy the implement
skill's File Ownership prerequisite (the _docs/ artifact set predates
the Step 1.5 module-layout addition).

Code review: PASS_WITH_WARNINGS — see
_docs/03_implementation/reviews/batch_05_review.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 04:34:42 +03:00

348 lines
14 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/hardware/set",
async ([FromBody]SetHWRequest request, IUserService userService, ICache cache, CancellationToken ct) =>
await userService.UpdateHardware(request.Email, request.Hardware, ct: ct))
.RequireAuthorization(apiAdminPolicy)
.WithSummary("Sets user's hardware");
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,
IUserService userService, IResourcesService resourcesService, CancellationToken ct) =>
{
var user = await authService.GetCurrentUser();
if (user == null)
throw new UnauthorizedAccessException();
var hwHash = await userService.CheckHardwareHash(user, request.Hardware);
var key = Security.GetApiEncryptionKey(user.Email, request.Password, hwHash);
var stream = await resourcesService.GetEncryptedResource(dataFolder, request.FileName, key, ct);
return Results.File(stream, "application/octet-stream", request.FileName);
}).RequireAuthorization()
.WithSummary("Gets encrypted by users Password and HardwareHash resources. 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("/resources/check",
async (CheckResourceRequest request, IAuthService authService, IUserService userService) =>
{
var user = await authService.GetCurrentUser();
if (user == null)
throw new UnauthorizedAccessException();
await userService.CheckHardwareHash(user, request.Hardware);
return true;
});
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();