diff --git a/Server/SchoolBusAPI/Authorization/MigrationAuthorizationAttribute.cs b/Server/SchoolBusAPI/Authorization/MigrationAuthorizationAttribute.cs new file mode 100644 index 000000000..fe4fa74dd --- /dev/null +++ b/Server/SchoolBusAPI/Authorization/MigrationAuthorizationAttribute.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; + +namespace SchoolBusAPI.Authorization +{ + /// + /// Protects migration endpoints: allow if X-Migration-Api-Key matches config, or JWT user has required permissions. + /// + public class MigrationAuthorizationAttribute : TypeFilterAttribute + { + public MigrationAuthorizationAttribute(params string[] permissions) : base(typeof(MigrationAuthorizationFilter)) + { + Arguments = new object[] { new PermissionRequirement(permissions) }; + } + } +} diff --git a/Server/SchoolBusAPI/Authorization/MigrationAuthorizationFilter.cs b/Server/SchoolBusAPI/Authorization/MigrationAuthorizationFilter.cs new file mode 100644 index 000000000..a306d0d61 --- /dev/null +++ b/Server/SchoolBusAPI/Authorization/MigrationAuthorizationFilter.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Configuration; +using System.Linq; +using System.Threading.Tasks; + +namespace SchoolBusAPI.Authorization +{ + /// + /// Allows migration endpoints via API key (no user in legacy DB) or JWT user with required permissions. + /// + public class MigrationAuthorizationFilter : IAsyncAuthorizationFilter + { + public const string MigrationApiKeyHeaderName = "X-Migration-Api-Key"; + public const string ConfigKey = "Migration:ApiKey"; + + private readonly IConfiguration _configuration; + private readonly IAuthorizationService _authService; + private readonly PermissionRequirement _requiredPermissions; + + public MigrationAuthorizationFilter( + IConfiguration configuration, + IAuthorizationService authService, + PermissionRequirement requiredPermissions) + { + _configuration = configuration; + _authService = authService; + _requiredPermissions = requiredPermissions; + } + + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + var apiKeyFromConfig = _configuration[ConfigKey]?.Trim(); + var apiKeyFromRequest = context.HttpContext.Request.Headers[MigrationApiKeyHeaderName].FirstOrDefault()?.Trim(); + + if (!string.IsNullOrEmpty(apiKeyFromConfig) && + !string.IsNullOrEmpty(apiKeyFromRequest) && + apiKeyFromConfig.Equals(apiKeyFromRequest, System.StringComparison.Ordinal)) + { + return; + } + + var result = await _authService.AuthorizeAsync( + context.HttpContext.User, + context.ActionDescriptor.DisplayName, + _requiredPermissions); + + if (!result.Succeeded) + { + var problem = new ValidationProblemDetails() + { + Type = "https://sb.bc.gov.ca/exception", + Title = "Access denied", + Status = StatusCodes.Status401Unauthorized, + Detail = "Valid X-Migration-Api-Key header or JWT with permission required.", + Instance = context.HttpContext.Request.Path + }; + problem.Extensions.Add("traceId", context.HttpContext.TraceIdentifier); + context.Result = new UnauthorizedObjectResult(problem); + } + } + } +} diff --git a/Server/SchoolBusAPI/Controllers/MigrationController.cs b/Server/SchoolBusAPI/Controllers/MigrationController.cs new file mode 100644 index 000000000..d602c885d --- /dev/null +++ b/Server/SchoolBusAPI/Controllers/MigrationController.cs @@ -0,0 +1,97 @@ +/* + * Migration export API for moving data to the new system. + * Consumed by Hangfire jobs in the new app via HTTP. Supports pagination for large sets. + * Access: valid X-Migration-Api-Key header (when Migration:ApiKey is set) OR JWT user with required permission. + */ + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SchoolBusAPI.Authorization; +using SchoolBusAPI.Services; + +namespace SchoolBusAPI.Controllers +{ + /// + /// Endpoints that return full entity data for migration to the new system + /// + [ApiVersion("1.0")] + [ApiController] + [AllowAnonymous] + public class MigrationController : ControllerBase + { + private readonly IMigrationService _migrationService; + + public MigrationController(IMigrationService migrationService) + { + _migrationService = migrationService; + } + + /// + /// Full inspection records with SchoolBus and Inspector. Use skip/take for batching. + /// + /// Number of records to skip (optional) + /// Max records to return (optional) + [HttpGet] + [Route("/api/migration/inspections")] + [MigrationAuthorization(Permissions.SchoolBusRead)] + public IActionResult GetInspections([FromQuery] int? skip, [FromQuery] int? take) + { + return _migrationService.GetInspections(skip, take); + } + + /// + /// Full school bus records with Notes, Attachments, History, CCWData, and related entities. Use skip/take for batching. + /// + [HttpGet] + [Route("/api/migration/schoolbuses")] + [MigrationAuthorization(Permissions.SchoolBusRead)] + public IActionResult GetSchoolBuses([FromQuery] int? skip, [FromQuery] int? take) + { + return _migrationService.GetSchoolBuses(skip, take); + } + + /// + /// Full school bus owner records with Contacts, Notes, Attachments, History. Use skip/take for batching. + /// + [HttpGet] + [Route("/api/migration/schoolbusowners")] + [MigrationAuthorization(Permissions.OwnerRead)] + public IActionResult GetSchoolBusOwners([FromQuery] int? skip, [FromQuery] int? take) + { + return _migrationService.GetSchoolBusOwners(skip, take); + } + + /// + /// All school districts (reference data, typically small set) + /// + [HttpGet] + [Route("/api/migration/schooldistricts")] + [MigrationAuthorization(Permissions.CodeRead)] + public IActionResult GetSchoolDistricts() + { + return _migrationService.GetSchoolDistricts(); + } + + /// + /// All service areas with District (reference data) + /// + [HttpGet] + [Route("/api/migration/serviceareas")] + [MigrationAuthorization(Permissions.CodeRead)] + public IActionResult GetServiceAreas() + { + return _migrationService.GetServiceAreas(); + } + + /// + /// Full contact records with SchoolBusOwner. Use skip/take for batching. + /// + [HttpGet] + [Route("/api/migration/contacts")] + [MigrationAuthorization(Permissions.OwnerRead)] + public IActionResult GetContacts([FromQuery] int? skip, [FromQuery] int? take) + { + return _migrationService.GetContacts(skip, take); + } + } +} diff --git a/Server/SchoolBusAPI/Extensions/ServiceCollectionExtensions.cs b/Server/SchoolBusAPI/Extensions/ServiceCollectionExtensions.cs index e0ce3e38a..d97cd0fea 100644 --- a/Server/SchoolBusAPI/Extensions/ServiceCollectionExtensions.cs +++ b/Server/SchoolBusAPI/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -/* +/* * REST API Documentation for Schoolbus * * This project is to replace the existing permitting and inspection scheduling functionality in AVIS such that the mainframe application can be retired. @@ -49,6 +49,7 @@ public static IServiceCollection RegisterApplicationServices(this IServiceCollec services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); var mappingConfig = new MapperConfiguration(cfg => { diff --git a/Server/SchoolBusAPI/Services/IMigrationService.cs b/Server/SchoolBusAPI/Services/IMigrationService.cs new file mode 100644 index 000000000..6413ca570 --- /dev/null +++ b/Server/SchoolBusAPI/Services/IMigrationService.cs @@ -0,0 +1,33 @@ +/* + * Migration export endpoints for moving data to new system. + * Used by Hangfire in the new app to pull full entity sets. + */ + +using Microsoft.AspNetCore.Mvc; + +namespace SchoolBusAPI.Services +{ + /// + /// Service that returns full entity data for migration export + /// + public interface IMigrationService + { + /// All inspections with SchoolBus and Inspector + IActionResult GetInspections(int? skip, int? take); + + /// All school buses with Notes, Attachments, History, CCWData, related entities + IActionResult GetSchoolBuses(int? skip, int? take); + + /// All school bus owners with Contacts, Notes, Attachments, History + IActionResult GetSchoolBusOwners(int? skip, int? take); + + /// All school districts + IActionResult GetSchoolDistricts(); + + /// All service areas with District + IActionResult GetServiceAreas(); + + /// All contacts with SchoolBusOwner + IActionResult GetContacts(int? skip, int? take); + } +} diff --git a/Server/SchoolBusAPI/Services/MigrationService.cs b/Server/SchoolBusAPI/Services/MigrationService.cs new file mode 100644 index 000000000..bb3051149 --- /dev/null +++ b/Server/SchoolBusAPI/Services/MigrationService.cs @@ -0,0 +1,117 @@ +/* + * Migration export implementation. Read-only, AsNoTracking for bulk export. + */ + +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SchoolBusAPI.Models; + +namespace SchoolBusAPI.Services +{ + /// + /// Exports full entity sets for migration; read-only, no tracking + /// + public class MigrationService : IMigrationService + { + private readonly DbAppContext _context; + + public MigrationService(DbAppContext context) + { + _context = context; + } + + /// + public IActionResult GetInspections(int? skip, int? take) + { + var query = _context.Inspections + .AsNoTracking() + .Include(x => x.SchoolBus) + .Include(x => x.Inspector) + .OrderBy(x => x.Id); + + var list = ApplyPagination(query, skip, take).ToList(); + return new ObjectResult(list); + } + + /// + public IActionResult GetSchoolBuses(int? skip, int? take) + { + var query = _context.SchoolBuss + .AsNoTracking() + .Include(x => x.HomeTerminalCity) + .Include(x => x.SchoolDistrict) + .Include(x => x.SchoolBusOwner).ThenInclude(o => o.PrimaryContact) + .Include(x => x.District).ThenInclude(d => d.Region) + .Include(x => x.Inspector) + .Include(x => x.CCWData) + .Include(x => x.Notes) + .Include(x => x.Attachments) + .Include(x => x.History) + .Include(x => x.CCWNotifications) + .OrderBy(x => x.Id); + + var list = ApplyPagination(query, skip, take).ToList(); + return new ObjectResult(list); + } + + /// + public IActionResult GetSchoolBusOwners(int? skip, int? take) + { + var query = _context.SchoolBusOwners + .AsNoTracking() + .Include(x => x.PrimaryContact) + .Include(x => x.District) + .Include(x => x.Contacts) + .Include(x => x.Notes) + .Include(x => x.Attachments) + .Include(x => x.History) + .OrderBy(x => x.Id); + + var list = ApplyPagination(query, skip, take).ToList(); + return new ObjectResult(list); + } + + /// + public IActionResult GetSchoolDistricts() + { + var list = _context.SchoolDistricts + .AsNoTracking() + .OrderBy(x => x.Id) + .ToList(); + return new ObjectResult(list); + } + + /// + public IActionResult GetServiceAreas() + { + var list = _context.ServiceAreas + .AsNoTracking() + .Include(x => x.District) + .OrderBy(x => x.Id) + .ToList(); + return new ObjectResult(list); + } + + /// + public IActionResult GetContacts(int? skip, int? take) + { + var query = _context.Contacts + .AsNoTracking() + .Include(x => x.SchoolBusOwner) + .OrderBy(x => x.Id); + + var list = ApplyPagination(query, skip, take).ToList(); + return new ObjectResult(list); + } + + private static IQueryable ApplyPagination(IQueryable query, int? skip, int? take) + { + if (skip.HasValue && skip.Value > 0) + query = query.Skip(skip.Value); + if (take.HasValue && take.Value > 0) + query = query.Take(take.Value); + return query; + } + } +} diff --git a/Server/SchoolBusAPI/appsettings.json b/Server/SchoolBusAPI/appsettings.json index 9354a3da8..f7384b598 100644 --- a/Server/SchoolBusAPI/appsettings.json +++ b/Server/SchoolBusAPI/appsettings.json @@ -51,6 +51,9 @@ "CCW_BATCH_APP_ID": "batchAppId", "CCW_PASSWORD": "password", "PDF_SERVICE_NAME": "http://pdf", + "Migration": { + "ApiKey": "CVCSVSBC_3_5_26" + }, "ENABLE_HANGFIRE_CREATE": "Y", "ENABLE_HANGFIRE_UPDATE": "Y", "SMTP_SERVER": "apps.smtp.gov.bc.ca",