Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
The following new endpoints have been added:
- `PUT /v3/persons/<trn>/welsh-induction` - to set a person's induction for teachers in Wales.

## 20260416

An endpoint has been added to activate a dormant TRN request: `PUT /v3/trn-requests/active/<request_id>`.

## 20260120

The `GET /v3/trns/<trn>` endpoint has been revised to behave in the same was as `GET /v3/trns/<trn>` with respect to deactivated records:
Expand Down
4 changes: 4 additions & 0 deletions TeachingRecordSystem/src/TeachingRecordSystem.Api/ApiError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public static class ErrorCodes
public static int RecordIsMerged => 10059;
public static int OpenNameChangeRequestAlreadyExists => 10060;
public static int OpenDateOfBirthChangeRequestAlreadyExists => 10061;
public static int UnsupportedTrnRequestStatus => 10062;
}

public static class DataKeys
Expand Down Expand Up @@ -176,6 +177,9 @@ public static ApiError OpenChangeNameRequestAlreadyExists() =>
public static ApiError OpenChangeDateOfBirthRequestAlreadyExists() =>
new(ErrorCodes.OpenDateOfBirthChangeRequestAlreadyExists, "An open date of birth change request already exists.");

public static ApiError UnsupportedTrnRequestStatus(string requestId) =>
new(ErrorCodes.UnsupportedTrnRequestStatus, "Unsupported TRN request status.", $"TRN request ID: '{requestId}'");

public IActionResult ToActionResult(int statusCode = 400)
{
var problemDetails = new ProblemDetails()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,10 @@ public static IServiceCollection AddApiServices(this IServiceCollection services
.RequireAssertion(ctx =>
{
var scopes = (ctx.User.FindFirstValue("scope") ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries);
return scopes.Contains("dqt:read") || scopes.Contains("teaching_record");
})
.RequireClaim("trn"));
var isIdUser = scopes.Contains("dqt:read") && ctx.User.HasClaim(c => c.Type == "trn");
var isAuthorizeAccessUser = scopes.Contains("teaching_record");
return isIdUser || isAuthorizeAccessUser;
}));
});

services
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;

namespace TeachingRecordSystem.Api.Infrastructure.Security;
Expand All @@ -9,7 +10,7 @@ public static bool TryGetCurrentApplicationUserFromHttpContext(HttpContext httpC
var principal = httpContext.User;

// If there's a TRN claim then it's either an access token from ID or from Teacher Auth (i.e. AuthorizeAccess).
if (principal.HasClaim(c => c.Type == "trn"))
if (principal.HasClaim(c => c.Type is "trn" or "trn_request_id"))
{
if (principal.HasClaim(c => c.Type == "scope" && c.Value.Contains("dqt:read")))
{
Expand Down Expand Up @@ -49,4 +50,18 @@ public Guid GetCurrentApplicationUserId()

return userId;
}

public bool TryGetTrnRequestId([NotNullWhen(true)] out string? trnRequestId)
{
var httpContext = httpContextAccessor.HttpContext ?? throw new Exception("No HttpContext.");

if (httpContext.User.FindFirst(AuthorizeAccessClaimTypes.TrnRequestId) is { Value: var claimTrnRequestId })
{
trnRequestId = claimTrnRequestId;
return true;
}

trnRequestId = null;
return false;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System.Diagnostics.CodeAnalysis;

namespace TeachingRecordSystem.Api.Infrastructure.Security;

public interface ICurrentUserProvider
{
Guid GetCurrentApplicationUserId();
bool TryGetTrnRequestId([NotNullWhen(true)] out string? trnRequestId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using TeachingRecordSystem.Api.Infrastructure.Security;
using TeachingRecordSystem.Api.V3.Implementation.Dtos;
using TeachingRecordSystem.Core.Services.TrnRequests;

namespace TeachingRecordSystem.Api.V3.Implementation.Operations;

public record ActivateTrnRequestCommand(string RequestId) : ICommand<ActivateTrnRequestResult>;

public record ActivateTrnRequestResult(bool WasActivated, Dtos.TrnRequestInfo TrnRequestInfo);

public class ActivateTrnRequestHandler(TrnRequestService trnRequestService, TimeProvider timeProvider, ICurrentUserProvider currentUserProvider) :
ICommandHandler<ActivateTrnRequestCommand, ActivateTrnRequestResult>
{
public async Task<ApiResult<ActivateTrnRequestResult>> ExecuteAsync(ActivateTrnRequestCommand command)
{
var currentApplicationUserId = currentUserProvider.GetCurrentApplicationUserId();

var trnRequestInfo = await trnRequestService.GetTrnRequestAsync(currentApplicationUserId, command.RequestId);

if (trnRequestInfo is null)
{
return ApiError.TrnRequestDoesNotExist(command.RequestId);
}

var trnRequest = trnRequestInfo.TrnRequest;
var needsActivating = trnRequest.Status is TrnRequestStatus.Dormant;

if (needsActivating)
{
var processContext = new ProcessContext(ProcessType.TrnRequestActivating, timeProvider.UtcNow, currentApplicationUserId);

await trnRequestService.ActivateTrnRequestAsync(trnRequest, processContext);
}

return new ActivateTrnRequestResult(
WasActivated: needsActivating,
new Dtos.TrnRequestInfo()
{
RequestId = command.RequestId,
#pragma warning disable TRS0001
Person = new TrnRequestInfoPerson()
#pragma warning restore TRS0001
{
FirstName = trnRequest.FirstName!,
LastName = trnRequest.LastName!,
MiddleName = trnRequest.MiddleName,
EmailAddress = trnRequest.EmailAddress,
NationalInsuranceNumber = trnRequest.NationalInsuranceNumber,
DateOfBirth = trnRequest.DateOfBirth
},
Trn = trnRequestInfo.ResolvedPersonTrn,
Status = trnRequest.Status,
PotentialDuplicate = trnRequest.PotentialDuplicate,
AccessYourTeachingQualificationsLink = trnRequest is { TrnToken: string trnToken, Status: TrnRequestStatus.Completed } ?
trnRequestService.GetAccessYourTeachingQualificationsLink(trnToken) :
null
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@

namespace TeachingRecordSystem.Api.V3.Implementation.Operations;

public record GetTrnRequestCommand(string RequestId) : ICommand<TrnRequestInfo>;
[Flags]
public enum GetTrnRequestCommandOptions
{
None = 0,
SupportsDormantRequests = 1 << 0
}

public record GetTrnRequestCommand(string RequestId, GetTrnRequestCommandOptions Options = GetTrnRequestCommandOptions.None) : ICommand<TrnRequestInfo>;

public class GetTrnRequestHandler(TrnRequestService trnRequestService, ICurrentUserProvider currentUserProvider) :
ICommandHandler<GetTrnRequestCommand, TrnRequestInfo>
Expand All @@ -25,6 +32,16 @@ public async Task<ApiResult<TrnRequestInfo>> ExecuteAsync(GetTrnRequestCommand c
var status = trnRequest.Status;
var trn = status == TrnRequestStatus.Completed ? trnRequestInfo.ResolvedPersonTrn : null;

if (status is TrnRequestStatus.Rejected)
{
return ApiError.UnsupportedTrnRequestStatus(command.RequestId);
}

if (!command.Options.HasFlag(GetTrnRequestCommandOptions.SupportsDormantRequests) && status is TrnRequestStatus.Dormant)
{
return ApiError.UnsupportedTrnRequestStatus(command.RequestId);
}

return new TrnRequestInfo()
{
RequestId = command.RequestId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using TeachingRecordSystem.Api.Infrastructure.Security;
using TeachingRecordSystem.Api.V3.Implementation.Operations;
using TeachingRecordSystem.Core.ApiSchema.V3.V20250425.Dtos;

namespace TeachingRecordSystem.Api.V3.V20260416.Controllers;

[Route("trn-requests")]
public class TrnRequestController(ICommandDispatcher commandDispatcher, IMapper mapper, ICurrentUserProvider currentUserProvider) : ControllerBase
{
[HttpPut("active/{trnRequestId}")]
Comment thread
hortha marked this conversation as resolved.
[SwaggerOperation(
OperationId = "ActivateTrnRequest",
Summary = "Activate dormant TRN request",
Description = "Activates a dormant request created by Teacher Auth.")]
[Authorize(AuthorizationPolicies.IdentityUserWithTrn)]
[ProducesResponseType(typeof(TrnRequestInfo), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(TrnRequestInfo), StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ActivateAsync([FromRoute] string trnRequestId)
{
if (!currentUserProvider.TryGetTrnRequestId(out var tokenTrnRequestId) || tokenTrnRequestId != trnRequestId)
{
return Forbid();
}

var command = new ActivateTrnRequestCommand(trnRequestId);
var result = await commandDispatcher.DispatchAsync(command);

return result.ToActionResult(
r => StatusCode(
r.WasActivated ? StatusCodes.Status200OK : StatusCodes.Status204NoContent,
mapper.Map<TrnRequestInfo>(r.TrnRequestInfo)))
.MapErrorCode(ApiError.ErrorCodes.TrnRequestDoesNotExist, StatusCodes.Status404NotFound);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ await eventPublisher.PublishSingleEventAsync(
}

var user = authenticateResult.Principal;
var subject = user.FindFirstValue(ClaimTypes.Subject) ??
throw new InvalidOperationException($"Principal does not contain a '{ClaimTypes.Subject}' claim.");
var subject = user.FindFirstValue(AuthorizeAccessClaimTypes.Subject) ??
throw new InvalidOperationException($"Principal does not contain a '{AuthorizeAccessClaimTypes.Subject}' claim.");

var authorizations = await authorizationManager.FindAsync(
subject: subject,
Expand All @@ -121,7 +121,7 @@ await eventPublisher.PublishSingleEventAsync(
var identity = new ClaimsIdentity(
claims: user.Claims,
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
nameType: ClaimTypes.Subject,
nameType: AuthorizeAccessClaimTypes.Subject,
roleType: null);

identity.SetScopes(request.GetScopes());
Expand Down Expand Up @@ -162,7 +162,7 @@ public async Task<IActionResult> TokenAsync()
var identity = new ClaimsIdentity(
result.Principal!.Claims,
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
nameType: ClaimTypes.Subject,
nameType: AuthorizeAccessClaimTypes.Subject,
roleType: null);

identity.SetDestinations(GetDestinations);
Expand All @@ -179,11 +179,11 @@ public async Task<IActionResult> TokenAsync()
[Produces("application/json")]
public async Task<IActionResult> UserInfoAsync()
{
var subject = User.GetClaim(ClaimTypes.Subject)!;
var subject = User.GetClaim(AuthorizeAccessClaimTypes.Subject)!;

var claims = new Dictionary<string, object>(StringComparer.Ordinal)
{
[ClaimTypes.Subject] = subject
[AuthorizeAccessClaimTypes.Subject] = subject
};

var oneLoginUser = await dbContext.OneLoginUsers
Expand All @@ -192,16 +192,16 @@ public async Task<IActionResult> UserInfoAsync()

if (oneLoginUser.Person is Person p)
{
claims.Add(ClaimTypes.Trn, p.Trn);
claims.Add(AuthorizeAccessClaimTypes.Trn, p.Trn);
}

if (User.HasScope(Scopes.Email))
{
claims.Add(ClaimTypes.Email, oneLoginUser.EmailAddress!);
claims.Add(AuthorizeAccessClaimTypes.Email, oneLoginUser.EmailAddress!);
}

claims.Add(ClaimTypes.VerifiedName, oneLoginUser.VerifiedNames!.First());
claims.Add(ClaimTypes.VerifiedDateOfBirth, oneLoginUser.VerifiedDatesOfBirth!.First());
claims.Add(AuthorizeAccessClaimTypes.VerifiedName, oneLoginUser.VerifiedNames!.First());
claims.Add(AuthorizeAccessClaimTypes.VerifiedDateOfBirth, oneLoginUser.VerifiedDatesOfBirth!.First());

return Ok(claims);
}
Expand All @@ -210,25 +210,25 @@ private static IEnumerable<string> GetDestinations(Claim claim)
{
switch (claim.Type)
{
case ClaimTypes.Subject:
case ClaimTypes.Trn:
case AuthorizeAccessClaimTypes.Subject:
case AuthorizeAccessClaimTypes.Trn:
yield return Destinations.AccessToken;
yield return Destinations.IdentityToken;
yield break;

case ClaimTypes.Email:
case AuthorizeAccessClaimTypes.Email:
if (claim.Subject!.HasScope(Scopes.Email))
{
yield return Destinations.IdentityToken;
}

yield break;

case ClaimTypes.OneLoginIdToken:
case AuthorizeAccessClaimTypes.OneLoginIdToken:
yield return Destinations.IdentityToken;
yield break;

case ClaimTypes.TrsUserId:
case AuthorizeAccessClaimTypes.TrsUserId:
yield return Destinations.AccessToken;
yield break;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public static IServiceCollection AddAuthorizeAccessServices(this IServiceCollect
services.AddDfeAnalytics()
.AddAspNetCoreIntegration(options =>
{
options.UserIdClaimType = ClaimTypes.Subject;
options.UserIdClaimType = AuthorizeAccessClaimTypes.Subject;

options.RequestFilter = ctx =>
ctx.Request.Path != "/status" &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ await HttpContext.SignOutAsync(
{
RedirectUri = HttpContext.Response.Headers.Location
};
var oneLoginIdToken = _authenticateResult!.Principal!.FindFirstValue(ClaimTypes.OneLoginIdToken)!;
var oneLoginIdToken = _authenticateResult!.Principal!.FindFirstValue(AuthorizeAccessClaimTypes.OneLoginIdToken)!;
authenticationProperties.SetParameter(OpenIdConnectParameterNames.IdToken, oneLoginIdToken);

return SignOut(authenticationProperties, _client!.OneLoginAuthenticationSchemeName!);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ public void Complete(SignInJourneyState state, string trn)
throw new InvalidOperationException("User is not authenticated with One Login.");
}

var specificClaims = new[] { new Claim(ClaimTypes.Trn, trn) };
var specificClaims = new[] { new Claim(AuthorizeAccessClaimTypes.Trn, trn) };
CreateAuthenticationTicket(state, specificClaims);
}

Expand All @@ -360,7 +360,7 @@ public void CompleteWithExistingTrnRequest(SignInJourneyState state, string trnR
throw new InvalidOperationException("User is not authenticated with One Login.");
}

var specificClaims = new[] { new Claim(ClaimTypes.TrnRequestId, trnRequestId) };
var specificClaims = new[] { new Claim(AuthorizeAccessClaimTypes.TrnRequestId, trnRequestId) };
CreateAuthenticationTicket(state, specificClaims);
}

Expand Down Expand Up @@ -406,7 +406,7 @@ public async Task<string> CompleteWithDeferredMatchingAsync(SignInJourneyState s
},
processContext);

var specificClaims = new[] { new Claim(ClaimTypes.TrnRequestId, trnRequestInfo.TrnRequest.RequestId) };
var specificClaims = new[] { new Claim(AuthorizeAccessClaimTypes.TrnRequestId, trnRequestInfo.TrnRequest.RequestId) };
CreateAuthenticationTicket(state, specificClaims);

return requestId;
Expand All @@ -419,18 +419,18 @@ private void CreateAuthenticationTicket(SignInJourneyState state, Claim[] specif

var claims = new List<Claim>
{
new Claim(ClaimTypes.Subject, oneLoginPrincipal.FindFirstValue("sub")!),
new Claim(ClaimTypes.Email, oneLoginPrincipal.FindFirstValue("email")!),
new Claim(ClaimTypes.OneLoginIdToken, oneLoginIdToken),
new Claim(ClaimTypes.TrsUserId, state.ClientApplicationUserId.ToString())
new Claim(AuthorizeAccessClaimTypes.Subject, oneLoginPrincipal.FindFirstValue("sub")!),
new Claim(AuthorizeAccessClaimTypes.Email, oneLoginPrincipal.FindFirstValue("email")!),
new Claim(AuthorizeAccessClaimTypes.OneLoginIdToken, oneLoginIdToken),
new Claim(AuthorizeAccessClaimTypes.TrsUserId, state.ClientApplicationUserId.ToString())
};

claims.AddRange(specificClaims);

var teachingRecordIdentity = new ClaimsIdentity(
claims,
authenticationType: "Authorize access to a teaching record",
nameType: ClaimTypes.Subject,
nameType: AuthorizeAccessClaimTypes.Subject,
roleType: null);

var principal = new ClaimsPrincipal(teachingRecordIdentity);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ private static void ConfigureOpenIdConnectOptions(OpenIdConnectOptions options,
options.Scope.Add("offline_access");
options.Scope.Add(CustomScopes.TeachingRecord);

options.ClaimActions.Add(new MapJsonClaimAction(ClaimTypes.VerifiedName));
options.ClaimActions.Add(new MapJsonClaimAction(ClaimTypes.VerifiedDateOfBirth));
options.ClaimActions.Add(new MapJsonClaimAction(AuthorizeAccessClaimTypes.VerifiedName));
options.ClaimActions.Add(new MapJsonClaimAction(AuthorizeAccessClaimTypes.VerifiedDateOfBirth));

options.Events.OnRedirectToIdentityProvider = ctx =>
{
Expand Down
Loading
Loading