From cb620aa89484470950241caca8f7dcdb2c3f8e22 Mon Sep 17 00:00:00 2001 From: James Gunn Date: Tue, 12 Mar 2024 15:32:39 +0000 Subject: [PATCH] Add OIDC attributes to ApplicationUser --- .../Postgres/Mappings/UserMapping.cs | 5 + ...153410_ApplicationUserOidcInfo.Designer.cs | 1692 +++++++++++++++++ .../20240312153410_ApplicationUserOidcInfo.cs | 72 + .../Migrations/TrsDbContextModelSnapshot.cs | 24 + .../DataStore/Postgres/Models/User.cs | 8 + .../EnumerableExtensions.cs | 8 + .../Events/ApplicationUserUpdatedEvent.cs | 4 + .../Events/Models/ApplicationUser.cs | 8 + .../MultiLineStringModelBinder.cs | 23 + .../EditApplicationUser.cshtml | 18 +- .../EditApplicationUser.cshtml.cs | 68 +- .../ApplicationUserTests.cs | 8 + .../EditApplicationUserTests.cs | 194 +- 13 files changed, 2096 insertions(+), 36 deletions(-) create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240312153410_ApplicationUserOidcInfo.Designer.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240312153410_ApplicationUserOidcInfo.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Infrastructure/ModelBinding/MultiLineStringModelBinder.cs diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/UserMapping.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/UserMapping.cs index 87ef117d9..737d6cb89 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/UserMapping.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/UserMapping.cs @@ -34,6 +34,10 @@ public class ApplicationUserMapping : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.Property(e => e.ApiRoles).HasColumnType("varchar[]"); + builder.Property(e => e.ClientId).HasMaxLength(ApplicationUser.ClientIdMaxLength); + builder.Property(e => e.ClientSecret).HasMaxLength(ApplicationUser.ClientSecretMaxLength); + builder.Property(e => e.RedirectUris).HasColumnType("varchar[]"); + builder.Property(e => e.PostLogoutRedirectUris).HasColumnType("varchar[]"); builder.Property(e => e.OneLoginClientId).HasMaxLength(ApplicationUser.OneLoginClientIdMaxLength); builder.Property(e => e.OneLoginPrivateKeyPem).HasMaxLength(2000); builder.Property(e => e.OneLoginAuthenticationSchemeName).HasMaxLength(ApplicationUser.AuthenticationSchemeNameMaxLength); @@ -41,6 +45,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.OneLoginPostLogoutRedirectUriPath).HasMaxLength(ApplicationUser.RedirectUriPathMaxLength); builder.HasIndex(e => e.OneLoginAuthenticationSchemeName).IsUnique().HasDatabaseName(ApplicationUser.OneLoginAuthenticationSchemeNameUniqueIndexName) .HasFilter("one_login_authentication_scheme_name is not null"); + builder.HasIndex(e => e.ClientId).IsUnique().HasDatabaseName(ApplicationUser.ClientIdUniqueIndexName).HasFilter("client_id is not null"); } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240312153410_ApplicationUserOidcInfo.Designer.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240312153410_ApplicationUserOidcInfo.Designer.cs new file mode 100644 index 000000000..4b7d7ddd2 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240312153410_ApplicationUserOidcInfo.Designer.cs @@ -0,0 +1,1692 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeachingRecordSystem.Core.DataStore.Postgres; + +#nullable disable + +namespace TeachingRecordSystem.Core.DataStore.Postgres.Migrations +{ + [DbContext(typeof(TrsDbContext))] + [Migration("20240312153410_ApplicationUserOidcInfo")] + partial class ApplicationUserOidcInfo + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApiKey", b => + { + b.Property("ApiKeyId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("api_key_id"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid") + .HasColumnName("application_user_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.HasKey("ApiKeyId") + .HasName("pk_api_keys"); + + b.HasIndex("ApplicationUserId") + .HasDatabaseName("ix_api_keys_application_user_id"); + + b.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_api_keys_key"); + + b.ToTable("api_keys", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EntityChangesJournal", b => + { + b.Property("Key") + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("EntityLogicalName") + .HasColumnType("text") + .HasColumnName("entity_logical_name"); + + b.Property("DataToken") + .HasColumnType("text") + .HasColumnName("data_token"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_updated"); + + b.Property("LastUpdatedBy") + .HasColumnType("text") + .HasColumnName("last_updated_by"); + + b.Property("NextQueryPageNumber") + .HasColumnType("integer") + .HasColumnName("next_query_page_number"); + + b.Property("NextQueryPageSize") + .HasColumnType("integer") + .HasColumnName("next_query_page_size"); + + b.Property("NextQueryPagingCookie") + .HasColumnType("text") + .HasColumnName("next_query_paging_cookie"); + + b.HasKey("Key", "EntityLogicalName") + .HasName("pk_entity_changes_journals"); + + b.ToTable("entity_changes_journals", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Establishment", b => + { + b.Property("EstablishmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("establishment_id"); + + b.Property("Address3") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("address3") + .UseCollation("case_insensitive"); + + b.Property("County") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("county") + .UseCollation("case_insensitive"); + + b.Property("EstablishmentName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("establishment_name") + .UseCollation("case_insensitive"); + + b.Property("EstablishmentNumber") + .HasMaxLength(4) + .HasColumnType("character(4)") + .HasColumnName("establishment_number") + .IsFixedLength(); + + b.Property("EstablishmentStatusCode") + .HasColumnType("integer") + .HasColumnName("establishment_status_code"); + + b.Property("EstablishmentStatusName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("establishment_status_name"); + + b.Property("EstablishmentTypeCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("establishment_type_code"); + + b.Property("EstablishmentTypeGroupCode") + .HasColumnType("integer") + .HasColumnName("establishment_type_group_code"); + + b.Property("EstablishmentTypeGroupName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("establishment_type_group_name"); + + b.Property("EstablishmentTypeName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("establishment_type_name") + .UseCollation("case_insensitive"); + + b.Property("LaCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("la_code") + .IsFixedLength(); + + b.Property("LaName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("la_name") + .UseCollation("case_insensitive"); + + b.Property("Locality") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("locality") + .UseCollation("case_insensitive"); + + b.Property("Postcode") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("postcode") + .UseCollation("case_insensitive"); + + b.Property("Street") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("street") + .UseCollation("case_insensitive"); + + b.Property("Town") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("town") + .UseCollation("case_insensitive"); + + b.Property("Urn") + .HasMaxLength(6) + .HasColumnType("integer") + .HasColumnName("urn") + .IsFixedLength(); + + b.HasKey("EstablishmentId") + .HasName("pk_establishments"); + + b.HasIndex("Urn") + .IsUnique() + .HasDatabaseName("ix_establishment_urn"); + + b.HasIndex("LaCode", "EstablishmentNumber") + .HasDatabaseName("ix_establishment_la_code_establishment_number"); + + b.ToTable("establishments", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Event", b => + { + b.Property("EventId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("event_id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("EventName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("event_name"); + + b.Property("Inserted") + .HasColumnType("timestamp with time zone") + .HasColumnName("inserted"); + + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("Published") + .HasColumnType("boolean") + .HasColumnName("published"); + + b.HasKey("EventId") + .HasName("pk_events"); + + b.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_events_key") + .HasFilter("key is not null"); + + b.HasIndex("Payload") + .HasDatabaseName("ix_events_payload"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Payload"), "gin"); + + b.HasIndex("PersonId", "EventName") + .HasDatabaseName("ix_events_person_id_event_name") + .HasFilter("person_id is not null"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("PersonId", "EventName"), new[] { "Payload" }); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJob", b => + { + b.Property("EytsAwardedEmailsJobId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("eyts_awarded_emails_job_id"); + + b.Property("AwardedToUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_to_utc"); + + b.Property("ExecutedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("executed_utc"); + + b.HasKey("EytsAwardedEmailsJobId") + .HasName("pk_eyts_awarded_emails_jobs"); + + b.HasIndex("ExecutedUtc") + .HasDatabaseName("ix_eyts_awarded_emails_jobs_executed_utc"); + + b.ToTable("eyts_awarded_emails_jobs", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJobItem", b => + { + b.Property("EytsAwardedEmailsJobId") + .HasColumnType("uuid") + .HasColumnName("eyts_awarded_emails_job_id"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email_address"); + + b.Property("EmailSent") + .HasColumnType("boolean") + .HasColumnName("email_sent"); + + b.Property("Personalization") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("personalization"); + + b.Property("Trn") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character(7)") + .HasColumnName("trn") + .IsFixedLength(); + + b.HasKey("EytsAwardedEmailsJobId", "PersonId") + .HasName("pk_eyts_awarded_emails_job_items"); + + b.HasIndex("Personalization") + .HasDatabaseName("ix_eyts_awarded_emails_job_items_personalization"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Personalization"), "gin"); + + b.ToTable("eyts_awarded_emails_job_items", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJob", b => + { + b.Property("InductionCompletedEmailsJobId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("induction_completed_emails_job_id"); + + b.Property("AwardedToUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_to_utc"); + + b.Property("ExecutedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("executed_utc"); + + b.HasKey("InductionCompletedEmailsJobId") + .HasName("pk_induction_completed_emails_jobs"); + + b.HasIndex("ExecutedUtc") + .HasDatabaseName("ix_induction_completed_emails_jobs_executed_utc"); + + b.ToTable("induction_completed_emails_jobs", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJobItem", b => + { + b.Property("InductionCompletedEmailsJobId") + .HasColumnType("uuid") + .HasColumnName("induction_completed_emails_job_id"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email_address"); + + b.Property("EmailSent") + .HasColumnType("boolean") + .HasColumnName("email_sent"); + + b.Property("Personalization") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("personalization"); + + b.Property("Trn") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character(7)") + .HasColumnName("trn") + .IsFixedLength(); + + b.HasKey("InductionCompletedEmailsJobId", "PersonId") + .HasName("pk_induction_completed_emails_job_items"); + + b.HasIndex("Personalization") + .HasDatabaseName("ix_induction_completed_emails_job_items_personalization"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Personalization"), "gin"); + + b.ToTable("induction_completed_emails_job_items", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJob", b => + { + b.Property("InternationalQtsAwardedEmailsJobId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("international_qts_awarded_emails_job_id"); + + b.Property("AwardedToUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_to_utc"); + + b.Property("ExecutedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("executed_utc"); + + b.HasKey("InternationalQtsAwardedEmailsJobId") + .HasName("pk_international_qts_awarded_emails_jobs"); + + b.HasIndex("ExecutedUtc") + .HasDatabaseName("ix_international_qts_awarded_emails_jobs_executed_utc"); + + b.ToTable("international_qts_awarded_emails_jobs", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJobItem", b => + { + b.Property("InternationalQtsAwardedEmailsJobId") + .HasColumnType("uuid") + .HasColumnName("international_qts_awarded_emails_job_id"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email_address"); + + b.Property("EmailSent") + .HasColumnType("boolean") + .HasColumnName("email_sent"); + + b.Property("Personalization") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("personalization"); + + b.Property("Trn") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character(7)") + .HasColumnName("trn") + .IsFixedLength(); + + b.HasKey("InternationalQtsAwardedEmailsJobId", "PersonId") + .HasName("pk_international_qts_awarded_emails_job_items"); + + b.HasIndex("Personalization") + .HasDatabaseName("ix_international_qts_awarded_emails_job_items_personalization"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Personalization"), "gin"); + + b.ToTable("international_qts_awarded_emails_job_items", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.JourneyState", b => + { + b.Property("InstanceId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("instance_id"); + + b.Property("Completed") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("user_id"); + + b.HasKey("InstanceId") + .HasName("pk_journey_states"); + + b.ToTable("journey_states", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.MandatoryQualificationProvider", b => + { + b.Property("MandatoryQualificationProviderId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("mandatory_qualification_provider_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("MandatoryQualificationProviderId") + .HasName("pk_mandatory_qualification_providers"); + + b.ToTable("mandatory_qualification_providers", (string)null); + + b.HasData( + new + { + MandatoryQualificationProviderId = new Guid("e28ea41d-408d-4c89-90cc-8b9b04ac68f5"), + Name = "University of Birmingham" + }, + new + { + MandatoryQualificationProviderId = new Guid("89f9a1aa-3d68-4985-a4ce-403b6044c18c"), + Name = "University of Leeds" + }, + new + { + MandatoryQualificationProviderId = new Guid("aa5c300e-3b7c-456c-8183-3520b3d55dca"), + Name = "University of Manchester" + }, + new + { + MandatoryQualificationProviderId = new Guid("f417e73e-e2ad-40eb-85e3-55865be7f6be"), + Name = "Mary Hare School / University of Hertfordshire" + }, + new + { + MandatoryQualificationProviderId = new Guid("fbf22e04-b274-4c80-aba8-79fb6a7a32ce"), + Name = "University of Edinburgh" + }, + new + { + MandatoryQualificationProviderId = new Guid("26204149-349c-4ad6-9466-bb9b83723eae"), + Name = "Liverpool John Moores University" + }, + new + { + MandatoryQualificationProviderId = new Guid("0c30f666-647c-4ea8-8883-0fc6010b56be"), + Name = "University of Oxford/Oxford Polytechnic" + }, + new + { + MandatoryQualificationProviderId = new Guid("d0e6d54c-5e90-438a-945d-f97388c2b352"), + Name = "University of Cambridge" + }, + new + { + MandatoryQualificationProviderId = new Guid("aec32252-ef25-452e-a358-34a04e03369c"), + Name = "University of Newcastle-upon-Tyne" + }, + new + { + MandatoryQualificationProviderId = new Guid("d9ee7054-7fde-4cfd-9a5e-4b99511d1b3d"), + Name = "University of Plymouth" + }, + new + { + MandatoryQualificationProviderId = new Guid("707d58ca-1953-413b-9a46-41e9b0be885e"), + Name = "University of Hertfordshire" + }, + new + { + MandatoryQualificationProviderId = new Guid("3fc648a7-18e4-49e7-8a4b-1612616b72d5"), + Name = "University of London" + }, + new + { + MandatoryQualificationProviderId = new Guid("374dceb8-8224-45b8-b7dc-a6b0282b1065"), + Name = "Bristol Polytechnic" + }, + new + { + MandatoryQualificationProviderId = new Guid("d4fc958b-21de-47ec-9f03-36ae237a1b11"), + Name = "University College, Swansea" + }); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.NameSynonyms", b => + { + b.Property("NameSynonymsId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("name_synonyms_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NameSynonymsId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name") + .UseCollation("case_insensitive"); + + b.Property("Synonyms") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("synonyms") + .UseCollation("case_insensitive"); + + b.HasKey("NameSynonymsId") + .HasName("pk_name_synonyms"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_name_synonyms_name"); + + b.ToTable("name_synonyms", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", b => + { + b.Property("Subject") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("subject"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("FirstOneLoginSignIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_one_login_sign_in"); + + b.Property("FirstSignIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_sign_in"); + + b.Property("LastOneLoginSignIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_one_login_sign_in"); + + b.Property("LastSignIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sign_in"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.HasKey("Subject") + .HasName("pk_one_login_users"); + + b.ToTable("one_login_users", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Person", b => + { + b.Property("PersonId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("DeletedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_on"); + + b.Property("DqtContactId") + .HasColumnType("uuid") + .HasColumnName("dqt_contact_id"); + + b.Property("DqtCreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_created_on"); + + b.Property("DqtFirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("dqt_first_name") + .UseCollation("case_insensitive"); + + b.Property("DqtFirstSync") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_first_sync"); + + b.Property("DqtLastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("dqt_last_name") + .UseCollation("case_insensitive"); + + b.Property("DqtLastSync") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_last_sync"); + + b.Property("DqtMiddleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("dqt_middle_name") + .UseCollation("case_insensitive"); + + b.Property("DqtModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_modified_on"); + + b.Property("DqtState") + .HasColumnType("integer") + .HasColumnName("dqt_state"); + + b.Property("EmailAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("email_address") + .UseCollation("case_insensitive"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name") + .UseCollation("case_insensitive"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name") + .UseCollation("case_insensitive"); + + b.Property("MiddleName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("middle_name") + .UseCollation("case_insensitive"); + + b.Property("NationalInsuranceNumber") + .HasMaxLength(9) + .HasColumnType("character(9)") + .HasColumnName("national_insurance_number") + .IsFixedLength(); + + b.Property("Trn") + .HasMaxLength(7) + .HasColumnType("character(7)") + .HasColumnName("trn") + .IsFixedLength(); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.HasKey("PersonId") + .HasName("pk_persons"); + + b.HasIndex("DqtContactId") + .IsUnique() + .HasDatabaseName("ix_persons_dqt_contact_id") + .HasFilter("dqt_contact_id is not null"); + + b.HasIndex("Trn") + .IsUnique() + .HasDatabaseName("ix_persons_trn") + .HasFilter("trn is not null"); + + b.ToTable("persons", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.PersonEmployment", b => + { + b.Property("PersonEmploymentId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("person_employment_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("EmploymentType") + .HasColumnType("integer") + .HasColumnName("employment_type"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("EstablishmentId") + .HasColumnType("uuid") + .HasColumnName("establishment_id"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.HasKey("PersonEmploymentId") + .HasName("pk_person_employments"); + + b.ToTable("person_employments", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.PersonSearchAttribute", b => + { + b.Property("PersonSearchAttributeId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("person_search_attribute_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersonSearchAttributeId")); + + b.Property("AttributeKey") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("attribute_key") + .UseCollation("case_insensitive"); + + b.Property("AttributeType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("attribute_type") + .UseCollation("case_insensitive"); + + b.Property("AttributeValue") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("attribute_value") + .UseCollation("case_insensitive"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.HasKey("PersonSearchAttributeId") + .HasName("pk_person_search_attributes"); + + b.HasIndex("PersonId") + .HasDatabaseName("ix_person_search_attributes_person_id"); + + b.HasIndex("AttributeType", "AttributeValue") + .HasDatabaseName("ix_person_search_attributes_attribute_type_and_value"); + + b.ToTable("person_search_attributes", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJob", b => + { + b.Property("QtsAwardedEmailsJobId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("qts_awarded_emails_job_id"); + + b.Property("AwardedToUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_to_utc"); + + b.Property("ExecutedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("executed_utc"); + + b.HasKey("QtsAwardedEmailsJobId") + .HasName("pk_qts_awarded_emails_jobs"); + + b.HasIndex("ExecutedUtc") + .HasDatabaseName("ix_qts_awarded_emails_jobs_executed_utc"); + + b.ToTable("qts_awarded_emails_jobs", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJobItem", b => + { + b.Property("QtsAwardedEmailsJobId") + .HasColumnType("uuid") + .HasColumnName("qts_awarded_emails_job_id"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email_address"); + + b.Property("EmailSent") + .HasColumnType("boolean") + .HasColumnName("email_sent"); + + b.Property("Personalization") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("personalization"); + + b.Property("Trn") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character(7)") + .HasColumnName("trn") + .IsFixedLength(); + + b.HasKey("QtsAwardedEmailsJobId", "PersonId") + .HasName("pk_qts_awarded_emails_job_items"); + + b.HasIndex("Personalization") + .HasDatabaseName("ix_qts_awarded_emails_job_items_personalization"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Personalization"), "gin"); + + b.ToTable("qts_awarded_emails_job_items", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Qualification", b => + { + b.Property("QualificationId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("qualification_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DeletedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_on"); + + b.Property("DqtCreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_created_on"); + + b.Property("DqtFirstSync") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_first_sync"); + + b.Property("DqtLastSync") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_last_sync"); + + b.Property("DqtModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_modified_on"); + + b.Property("DqtQualificationId") + .HasColumnType("uuid") + .HasColumnName("dqt_qualification_id"); + + b.Property("DqtState") + .HasColumnType("integer") + .HasColumnName("dqt_state"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("QualificationType") + .HasColumnType("integer") + .HasColumnName("qualification_type"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.HasKey("QualificationId") + .HasName("pk_qualifications"); + + b.HasIndex("DqtQualificationId") + .IsUnique() + .HasDatabaseName("ix_qualifications_dqt_qualification_id") + .HasFilter("dqt_qualification_id is not null"); + + b.HasIndex("PersonId") + .HasDatabaseName("ix_qualifications_person_id"); + + b.ToTable("qualifications", (string)null); + + b.HasDiscriminator("QualificationType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.TpsCsvExtract", b => + { + b.Property("TpsCsvExtractId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tps_csv_extract_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Filename") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("filename"); + + b.HasKey("TpsCsvExtractId") + .HasName("pk_tps_csv_extracts"); + + b.ToTable("tps_csv_extracts", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.TpsCsvExtractItem", b => + { + b.Property("TpsCsvExtractItemId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tps_csv_extract_item_id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("DateOfDeath") + .HasColumnType("date") + .HasColumnName("date_of_death"); + + b.Property("EmploymentEndDate") + .HasColumnType("date") + .HasColumnName("employment_end_date"); + + b.Property("EmploymentStartDate") + .HasColumnType("date") + .HasColumnName("employment_start_date"); + + b.Property("EmploymentType") + .HasColumnType("integer") + .HasColumnName("employment_type"); + + b.Property("EstablishmentEmailAddress") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("establishment_email_address"); + + b.Property("EstablishmentNumber") + .HasMaxLength(4) + .HasColumnType("character(4)") + .HasColumnName("establishment_number") + .IsFixedLength(); + + b.Property("EstablishmentPostcode") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("establishment_postcode"); + + b.Property("ExtractDate") + .HasColumnType("date") + .HasColumnName("extract_date"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LocalAuthorityCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("local_authority_code") + .IsFixedLength(); + + b.Property("MemberEmailAddress") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("member_email_address"); + + b.Property("MemberId") + .HasColumnType("integer") + .HasColumnName("member_id"); + + b.Property("MemberPostcode") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("member_postcode"); + + b.Property("NationalInsuranceNumber") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character(9)") + .HasColumnName("national_insurance_number") + .IsFixedLength(); + + b.Property("Result") + .HasColumnType("integer") + .HasColumnName("result"); + + b.Property("TpsCsvExtractId") + .HasColumnType("uuid") + .HasColumnName("tps_csv_extract_id"); + + b.Property("TpsCsvExtractLoadItemId") + .HasColumnType("uuid") + .HasColumnName("tps_csv_extract_load_item_id"); + + b.Property("Trn") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character(7)") + .HasColumnName("trn") + .IsFixedLength(); + + b.Property("WithdrawlIndicator") + .HasMaxLength(1) + .HasColumnType("character(1)") + .HasColumnName("withdrawl_indicator") + .IsFixedLength(); + + b.HasKey("TpsCsvExtractItemId") + .HasName("pk_tps_csv_extract_items"); + + b.HasIndex("TpsCsvExtractId") + .HasDatabaseName("ix_tps_csv_extract_items_tps_csv_extract_id"); + + b.HasIndex("TpsCsvExtractLoadItemId") + .HasDatabaseName("ix_tps_csv_extract_items_tps_csv_extract_load_item_id"); + + b.HasIndex("Trn") + .HasDatabaseName("ix_tps_csv_extract_items_trn"); + + b.HasIndex("LocalAuthorityCode", "EstablishmentNumber") + .HasDatabaseName("ix_tps_csv_extract_items_la_code_establishment_number"); + + b.ToTable("tps_csv_extract_items", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.TpsCsvExtractLoadItem", b => + { + b.Property("TpsCsvExtractLoadItemId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tps_csv_extract_load_item_id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DateOfBirth") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("date_of_birth"); + + b.Property("DateOfDeath") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("date_of_death"); + + b.Property("EmploymentEndDate") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("employment_end_date"); + + b.Property("EmploymentStartDate") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("employment_start_date"); + + b.Property("Errors") + .HasColumnType("integer") + .HasColumnName("errors"); + + b.Property("EstablishmentEmailAddress") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("establishment_email_address"); + + b.Property("EstablishmentNumber") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("establishment_number"); + + b.Property("EstablishmentPostcode") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("establishment_postcode"); + + b.Property("ExtractDate") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("extract_date"); + + b.Property("FullOrPartTimeIndicator") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_or_part_time_indicator"); + + b.Property("Gender") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("gender"); + + b.Property("LocalAuthorityCode") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("local_authority_code"); + + b.Property("MemberEmailAddress") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("member_email_address"); + + b.Property("MemberId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("member_id"); + + b.Property("MemberPostcode") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("member_postcode"); + + b.Property("NationalInsuranceNumber") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("national_insurance_number"); + + b.Property("TpsCsvExtractId") + .HasColumnType("uuid") + .HasColumnName("tps_csv_extract_id"); + + b.Property("Trn") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("trn"); + + b.Property("WithdrawlIndicator") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("withdrawl_indicator"); + + b.HasKey("TpsCsvExtractLoadItemId") + .HasName("pk_tps_csv_extract_load_items"); + + b.ToTable("tps_csv_extract_load_items", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.TrnRequest", b => + { + b.Property("TrnRequestId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("trn_request_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TrnRequestId")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("client_id"); + + b.Property("IdentityUserId") + .HasColumnType("uuid") + .HasColumnName("identity_user_id"); + + b.Property("LinkedToIdentity") + .HasColumnType("boolean") + .HasColumnName("linked_to_identity"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("request_id"); + + b.Property("TeacherId") + .HasColumnType("uuid") + .HasColumnName("teacher_id"); + + b.Property("TrnToken") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("trn_token"); + + b.HasKey("TrnRequestId") + .HasName("pk_trn_requests"); + + b.HasIndex("ClientId", "RequestId") + .IsUnique() + .HasDatabaseName("ix_trn_requests_client_id_request_id"); + + b.ToTable("trn_requests", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.UserBase", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UserType") + .HasColumnType("integer") + .HasColumnName("user_type"); + + b.HasKey("UserId") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + + b.HasDiscriminator("UserType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.MandatoryQualification", b => + { + b.HasBaseType("TeachingRecordSystem.Core.DataStore.Postgres.Models.Qualification"); + + b.Property("DqtMqEstablishmentId") + .HasColumnType("uuid") + .HasColumnName("dqt_mq_establishment_id"); + + b.Property("DqtSpecialismId") + .HasColumnType("uuid") + .HasColumnName("dqt_specialism_id"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("mq_provider_id"); + + b.Property("Specialism") + .HasColumnType("integer") + .HasColumnName("mq_specialism"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("mq_status"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApplicationUser", b => + { + b.HasBaseType("TeachingRecordSystem.Core.DataStore.Postgres.Models.UserBase"); + + b.Property("ApiRoles") + .IsRequired() + .HasColumnType("varchar[]") + .HasColumnName("api_roles"); + + b.Property("ClientId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("client_secret"); + + b.Property("IsOidcClient") + .HasColumnType("boolean") + .HasColumnName("is_oidc_client"); + + b.Property("OneLoginAuthenticationSchemeName") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("one_login_authentication_scheme_name"); + + b.Property("OneLoginClientId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("one_login_client_id"); + + b.Property("OneLoginPostLogoutRedirectUriPath") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("one_login_post_logout_redirect_uri_path"); + + b.Property("OneLoginPrivateKeyPem") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("one_login_private_key_pem"); + + b.Property("OneLoginRedirectUriPath") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("one_login_redirect_uri_path"); + + b.Property>("PostLogoutRedirectUris") + .HasColumnType("varchar[]") + .HasColumnName("post_logout_redirect_uris"); + + b.Property>("RedirectUris") + .HasColumnType("varchar[]") + .HasColumnName("redirect_uris"); + + b.HasIndex("ClientId") + .IsUnique() + .HasDatabaseName("ix_users_client_id") + .HasFilter("client_id is not null"); + + b.HasIndex("OneLoginAuthenticationSchemeName") + .IsUnique() + .HasDatabaseName("ix_users_one_login_authentication_scheme_name") + .HasFilter("one_login_authentication_scheme_name is not null"); + + b.HasDiscriminator().HasValue(2); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.SystemUser", b => + { + b.HasBaseType("TeachingRecordSystem.Core.DataStore.Postgres.Models.UserBase"); + + b.HasDiscriminator().HasValue(3); + + b.HasData( + new + { + UserId = new Guid("a81394d1-a498-46d8-af3e-e077596ab303"), + Active = true, + Name = "System", + UserType = 0 + }); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.User", b => + { + b.HasBaseType("TeachingRecordSystem.Core.DataStore.Postgres.Models.UserBase"); + + b.Property("AzureAdUserId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("azure_ad_user_id"); + + b.Property("DqtUserId") + .HasColumnType("uuid") + .HasColumnName("dqt_user_id"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email") + .UseCollation("case_insensitive"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("varchar[]") + .HasColumnName("roles"); + + b.HasIndex("AzureAdUserId") + .IsUnique() + .HasDatabaseName("ix_users_azure_ad_user_id"); + + b.HasDiscriminator().HasValue(1); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApiKey", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApplicationUser", "ApplicationUser") + .WithMany("ApiKeys") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_key_application_user"); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJobItem", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJob", "EytsAwardedEmailsJob") + .WithMany("JobItems") + .HasForeignKey("EytsAwardedEmailsJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_eyts_awarded_emails_job_items_eyts_awarded_emails_jobs_eyts"); + + b.Navigation("EytsAwardedEmailsJob"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJobItem", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJob", "InductionCompletedEmailsJob") + .WithMany("JobItems") + .HasForeignKey("InductionCompletedEmailsJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_induction_completed_emails_job_items_induction_completed_em"); + + b.Navigation("InductionCompletedEmailsJob"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJobItem", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJob", "InternationalQtsAwardedEmailsJob") + .WithMany("JobItems") + .HasForeignKey("InternationalQtsAwardedEmailsJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_international_qts_awarded_emails_job_items_international_qt"); + + b.Navigation("InternationalQtsAwardedEmailsJob"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.Person", "Person") + .WithOne() + .HasForeignKey("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", "PersonId") + .HasConstraintName("fk_one_login_users_persons_person_id"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.PersonEmployment", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.Establishment", null) + .WithMany() + .HasForeignKey("EstablishmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_person_employments_establishment_id"); + + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_person_employments_person_id"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJobItem", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJob", "QtsAwardedEmailsJob") + .WithMany("JobItems") + .HasForeignKey("QtsAwardedEmailsJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qts_awarded_emails_job_items_qts_awarded_emails_jobs_qts_aw"); + + b.Navigation("QtsAwardedEmailsJob"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Qualification", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qualifications_person"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.TpsCsvExtractItem", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.TpsCsvExtract", null) + .WithMany() + .HasForeignKey("TpsCsvExtractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tps_csv_extract_items_tps_csv_extract_id"); + + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.TpsCsvExtractLoadItem", null) + .WithMany() + .HasForeignKey("TpsCsvExtractLoadItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tps_csv_extract_items_tps_csv_extract_load_item_id"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.TpsCsvExtractLoadItem", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.TpsCsvExtract", null) + .WithMany() + .HasForeignKey("TpsCsvExtractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tps_csv_extract_load_items_tps_csv_extract_id"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.MandatoryQualification", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.MandatoryQualificationProvider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .HasConstraintName("fk_qualifications_mandatory_qualification_provider"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJob", b => + { + b.Navigation("JobItems"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJob", b => + { + b.Navigation("JobItems"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJob", b => + { + b.Navigation("JobItems"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJob", b => + { + b.Navigation("JobItems"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApplicationUser", b => + { + b.Navigation("ApiKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240312153410_ApplicationUserOidcInfo.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240312153410_ApplicationUserOidcInfo.cs new file mode 100644 index 000000000..74f1642d0 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240312153410_ApplicationUserOidcInfo.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeachingRecordSystem.Core.DataStore.Postgres.Migrations +{ + /// + public partial class ApplicationUserOidcInfo : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "client_id", + table: "users", + type: "character varying(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "client_secret", + table: "users", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn>( + name: "post_logout_redirect_uris", + table: "users", + type: "varchar[]", + nullable: true); + + migrationBuilder.AddColumn>( + name: "redirect_uris", + table: "users", + type: "varchar[]", + nullable: true); + + migrationBuilder.CreateIndex( + name: "ix_users_client_id", + table: "users", + column: "client_id", + unique: true, + filter: "client_id is not null"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_users_client_id", + table: "users"); + + migrationBuilder.DropColumn( + name: "client_id", + table: "users"); + + migrationBuilder.DropColumn( + name: "client_secret", + table: "users"); + + migrationBuilder.DropColumn( + name: "post_logout_redirect_uris", + table: "users"); + + migrationBuilder.DropColumn( + name: "redirect_uris", + table: "users"); + } + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/TrsDbContextModelSnapshot.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/TrsDbContextModelSnapshot.cs index 76d24c426..a79a455f7 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/TrsDbContextModelSnapshot.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/TrsDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -1417,6 +1418,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("varchar[]") .HasColumnName("api_roles"); + b.Property("ClientId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("client_secret"); + b.Property("IsOidcClient") .HasColumnType("boolean") .HasColumnName("is_oidc_client"); @@ -1446,6 +1457,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(100)") .HasColumnName("one_login_redirect_uri_path"); + b.Property>("PostLogoutRedirectUris") + .HasColumnType("varchar[]") + .HasColumnName("post_logout_redirect_uris"); + + b.Property>("RedirectUris") + .HasColumnType("varchar[]") + .HasColumnName("redirect_uris"); + + b.HasIndex("ClientId") + .IsUnique() + .HasDatabaseName("ix_users_client_id") + .HasFilter("client_id is not null"); + b.HasIndex("OneLoginAuthenticationSchemeName") .IsUnique() .HasDatabaseName("ix_users_one_login_authentication_scheme_name") diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/User.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/User.cs index 42dc67eb6..c1155e421 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/User.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/User.cs @@ -24,13 +24,21 @@ public class ApplicationUser : UserBase { public const int AuthenticationSchemeNameMaxLength = 50; public const int RedirectUriPathMaxLength = 100; + public const int ClientIdMaxLength = 50; + public const int ClientSecretMaxLength = 200; + public const int ClientSecretMinLength = 16; public const int OneLoginClientIdMaxLength = 50; public const string NameUniqueIndexName = "ix_users_application_user_name"; + public const string ClientIdUniqueIndexName = "ix_users_client_id"; public const string OneLoginAuthenticationSchemeNameUniqueIndexName = "ix_users_one_login_authentication_scheme_name"; public required string[] ApiRoles { get; set; } public ICollection ApiKeys { get; } = null!; public bool IsOidcClient { get; set; } + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } + public List? RedirectUris { get; set; } + public List? PostLogoutRedirectUris { get; set; } public string? OneLoginClientId { get; set; } public string? OneLoginPrivateKeyPem { get; set; } public string? OneLoginAuthenticationSchemeName { get; set; } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/EnumerableExtensions.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/EnumerableExtensions.cs index e4faf504b..80a959e2d 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/EnumerableExtensions.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/EnumerableExtensions.cs @@ -65,4 +65,12 @@ public static string ToCommaSeparatedString( _ => string.Join(", ", valuesArray[0..^2].Append(string.Join($" {finalValuesConjunction} ", valuesArray[^2..]))) }; } + + public static bool SequenceEqualIgnoringOrder(this IEnumerable first, IEnumerable second) + where T : IComparable + { + var firstArray = first.ToArray().OrderBy(s => s); + var secondArray = second.ToArray().OrderBy(s => s); + return firstArray.SequenceEqual(secondArray); + } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Events/ApplicationUserUpdatedEvent.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Events/ApplicationUserUpdatedEvent.cs index 5cb20fc95..b1f62fe22 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Events/ApplicationUserUpdatedEvent.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Events/ApplicationUserUpdatedEvent.cs @@ -20,4 +20,8 @@ public enum ApplicationUserUpdatedEventChanges OneLoginAuthenticationSchemeName = 1 << 5, OneLoginRedirectUriPath = 1 << 6, OneLoginPostLogoutRedirectUriPath = 1 << 7, + ClientId = 1 << 8, + ClientSecret = 1 << 9, + RedirectUris = 1 << 10, + PostLogoutRedirectUris = 1 << 11, } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Events/Models/ApplicationUser.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Events/Models/ApplicationUser.cs index 576ab8368..6517d1483 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Events/Models/ApplicationUser.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Events/Models/ApplicationUser.cs @@ -6,6 +6,10 @@ public record ApplicationUser public required string Name { get; init; } public required string[] ApiRoles { get; init; } public bool IsOidcClient { get; init; } + public string? ClientId { get; init; } + public string? ClientSecret { get; init; } + public IReadOnlyCollection? RedirectUris { get; init; } + public IReadOnlyCollection? PostLogoutRedirectUris { get; init; } public string? OneLoginClientId { get; init; } public string? OneLoginPrivateKeyPem { get; init; } public string? OneLoginAuthenticationSchemeName { get; init; } @@ -18,6 +22,10 @@ public record ApplicationUser Name = user.Name, ApiRoles = user.ApiRoles, IsOidcClient = user.IsOidcClient, + ClientId = user.ClientId, + ClientSecret = user.ClientSecret, + RedirectUris = user.RedirectUris, + PostLogoutRedirectUris = user.PostLogoutRedirectUris, OneLoginClientId = user.OneLoginClientId, OneLoginPrivateKeyPem = user.OneLoginPrivateKeyPem, OneLoginAuthenticationSchemeName = user.OneLoginAuthenticationSchemeName, diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Infrastructure/ModelBinding/MultiLineStringModelBinder.cs b/TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Infrastructure/ModelBinding/MultiLineStringModelBinder.cs new file mode 100644 index 000000000..ab00291e7 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Infrastructure/ModelBinding/MultiLineStringModelBinder.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace TeachingRecordSystem.SupportUi.Infrastructure.ModelBinding; + +public class MultiLineStringModelBinder : IModelBinder +{ + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var modelType = bindingContext.ModelType; + if (!typeof(string[]).IsAssignableTo(modelType)) + { + throw new InvalidOperationException($"Cannot bind to {modelType}."); + } + + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + var value = valueProviderResult.FirstValue ?? string.Empty; + var splitByLines = value.Split("\n", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + bindingContext.Result = ModelBindingResult.Success(splitByLines); + + return Task.CompletedTask; + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/ApplicationUsers/EditApplicationUser.cshtml b/TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/ApplicationUsers/EditApplicationUser.cshtml index 6c0c5a42d..fedb1fbbf 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/ApplicationUsers/EditApplicationUser.cshtml +++ b/TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/ApplicationUsers/EditApplicationUser.cshtml @@ -80,15 +80,27 @@ @Html.DisplayNameFor(m => m.IsOidcClient) - + + + + + + @string.Join("\n", Model.RedirectUris!) + + + + @string.Join("\n", Model.PostLogoutRedirectUris!) + + + - + - + diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/ApplicationUsers/EditApplicationUser.cshtml.cs b/TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/ApplicationUsers/EditApplicationUser.cshtml.cs index 9f61a5aec..9ad957cc5 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/ApplicationUsers/EditApplicationUser.cshtml.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.SupportUi/Pages/ApplicationUsers/EditApplicationUser.cshtml.cs @@ -4,14 +4,17 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.RazorPages; using TeachingRecordSystem.Core.DataStore.Postgres; using TeachingRecordSystem.Core.DataStore.Postgres.Models; +using TeachingRecordSystem.SupportUi.Infrastructure.ModelBinding; using TeachingRecordSystem.SupportUi.Infrastructure.Security; namespace TeachingRecordSystem.SupportUi.Pages.ApplicationUsers; [Authorize(Policy = AuthorizationPolicies.UserManagement)] +[BindProperties] public class EditApplicationUserModel(TrsDbContext dbContext, TrsLinkGenerator linkGenerator, IClock clock) : PageModel { // From PathString @@ -23,47 +26,59 @@ public class EditApplicationUserModel(TrsDbContext dbContext, TrsLinkGenerator l [FromRoute] public Guid UserId { get; set; } - [BindProperty] [Display(Name = "Name")] [Required(ErrorMessage = "Enter a name")] [MaxLength(UserBase.NameMaxLength, ErrorMessage = "Name must be 200 characters or less")] public string? Name { get; set; } - [BindProperty] [Display(Name = "API roles")] public string[]? ApiRoles { get; set; } + [BindNever] [Display(Name = "API keys")] public ApiKeyInfo[]? ApiKeys { get; set; } - [BindProperty] [Display(Name = "OIDC client")] public bool IsOidcClient { get; set; } - [BindProperty] + [Display(Name = "Client ID")] + [Required(ErrorMessage = "Enter a client ID")] + [MaxLength(ApplicationUser.ClientIdMaxLength, ErrorMessage = "Client ID must be 50 characters or less")] + public string? ClientId { get; set; } + + [Display(Name = "Client secret")] + [Required(ErrorMessage = "Enter a client secret")] + [MinLength(ApplicationUser.ClientSecretMinLength, ErrorMessage = "Client secret must be at least 16 characters")] + [MaxLength(ApplicationUser.ClientSecretMaxLength, ErrorMessage = "Client secret must be 200 characters or less")] + public string? ClientSecret { get; set; } + + [Display(Name = "Redirect URIs", Description = "Enter one per line")] + [ModelBinder(BinderType = typeof(MultiLineStringModelBinder))] + public string[]? RedirectUris { get; set; } + + [Display(Name = "Post logout redirect URIs", Description = "Enter one per line")] + [ModelBinder(BinderType = typeof(MultiLineStringModelBinder))] + public string[]? PostLogoutRedirectUris { get; set; } + [Display(Name = "Authentication scheme name")] [Required(ErrorMessage = "Enter an authentication scheme name")] [MaxLength(ApplicationUser.AuthenticationSchemeNameMaxLength, ErrorMessage = "Authentication scheme name must be 50 characters or less")] public string? OneLoginAuthenticationSchemeName { get; set; } - [BindProperty] [Display(Name = "One Login client ID")] [Required(ErrorMessage = "Enter the One Login client ID")] [MaxLength(ApplicationUser.OneLoginClientIdMaxLength, ErrorMessage = "One Login client ID must be 50 characters or less")] public string? OneLoginClientId { get; set; } - [BindProperty] [Display(Name = "One Login private key", Description = "Enter a key in the PEM format")] [Required(ErrorMessage = "Enter the One Login private key")] public string? OneLoginPrivateKeyPem { get; set; } - [BindProperty] [Display(Name = "One Login redirect URI path")] [Required(ErrorMessage = "Enter the One Login redirect URI")] [MaxLength(ApplicationUser.RedirectUriPathMaxLength, ErrorMessage = "One Login redirect URI must be 100 characters or less")] public string? OneLoginRedirectUriPath { get; set; } - [BindProperty] [Display(Name = "One Login post logout redirect URI path")] [Required(ErrorMessage = "Enter the One Login post logout redirect URI")] [MaxLength(ApplicationUser.RedirectUriPathMaxLength, ErrorMessage = "One Login post logout redirect URI must be 100 characters or less")] @@ -74,6 +89,10 @@ public void OnGet() Name = _user!.Name; ApiRoles = _user.ApiRoles; IsOidcClient = _user.IsOidcClient; + ClientId = _user.ClientId; + ClientSecret = _user.ClientSecret; + RedirectUris = _user.RedirectUris?.ToArray() ?? []; + PostLogoutRedirectUris = _user.PostLogoutRedirectUris?.ToArray() ?? []; OneLoginAuthenticationSchemeName = _user.OneLoginAuthenticationSchemeName; OneLoginClientId = _user.OneLoginClientId; OneLoginPrivateKeyPem = _user.OneLoginPrivateKeyPem; @@ -88,6 +107,26 @@ public async Task OnPost() if (IsOidcClient) { + foreach (var redirectUri in RedirectUris!) + { + if (!Uri.TryCreate(redirectUri, UriKind.Absolute, out var uri) || + (uri.Scheme != "http" && uri.Scheme != "https")) + { + ModelState.AddModelError(nameof(RedirectUris), "One or more redirect URIs are not valid"); + break; + } + } + + foreach (var redirectUri in PostLogoutRedirectUris!) + { + if (!Uri.TryCreate(redirectUri, UriKind.Absolute, out var uri) || + (uri.Scheme != "http" && uri.Scheme != "https")) + { + ModelState.AddModelError(nameof(PostLogoutRedirectUris), "One or more post logout redirect URIs are not valid"); + break; + } + } + if (ModelState[nameof(OneLoginPrivateKeyPem)]!.Errors.Count == 0) { try @@ -114,10 +153,11 @@ public async Task OnPost() } else { - // Clear any errors for any One Login-related fields (since we're not persisting them any way) + // Clear any errors for any OIDC-related fields (since we're not saving them if IsOidcClient is false) foreach (var key in ModelState.Keys) { - if (key.StartsWith("OneLogin")) + if (key.StartsWith("OneLogin") || + key is nameof(ClientId) or nameof(ClientSecret) or nameof(RedirectUris) or nameof(PostLogoutRedirectUris)) { ModelState.Remove(key); } @@ -141,6 +181,10 @@ public async Task OnPost() var oldChanges = changes; changes |= + (ClientId != _user.ClientId ? ApplicationUserUpdatedEventChanges.ClientId : 0) | + (ClientSecret != _user.ClientSecret ? ApplicationUserUpdatedEventChanges.ClientSecret : 0) | + (!RedirectUris!.SequenceEqualIgnoringOrder(_user.RedirectUris ?? []) ? ApplicationUserUpdatedEventChanges.RedirectUris : 0) | + (!PostLogoutRedirectUris!.SequenceEqualIgnoringOrder(_user.PostLogoutRedirectUris ?? []) ? ApplicationUserUpdatedEventChanges.PostLogoutRedirectUris : 0) | (OneLoginClientId != _user.OneLoginClientId ? ApplicationUserUpdatedEventChanges.OneLoginClientId : 0) | (OneLoginPrivateKeyPem != _user.OneLoginPrivateKeyPem ? ApplicationUserUpdatedEventChanges.OneLoginPrivateKeyPem : 0) | (OneLoginAuthenticationSchemeName != _user.OneLoginAuthenticationSchemeName ? ApplicationUserUpdatedEventChanges.OneLoginAuthenticationSchemeName : 0) | @@ -164,6 +208,10 @@ public async Task OnPost() if (IsOidcClient) { _user.IsOidcClient = IsOidcClient; + _user.ClientId = ClientId; + _user.ClientSecret = ClientSecret; + _user.RedirectUris = [.. RedirectUris!]; + _user.PostLogoutRedirectUris = [.. PostLogoutRedirectUris!]; _user.OneLoginAuthenticationSchemeName = OneLoginAuthenticationSchemeName; _user.OneLoginClientId = OneLoginClientId; _user.OneLoginPrivateKeyPem = OneLoginPrivateKeyPem; diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.EndToEndTests/ApplicationUserTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.EndToEndTests/ApplicationUserTests.cs index 4fedab386..1164c6971 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.EndToEndTests/ApplicationUserTests.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.EndToEndTests/ApplicationUserTests.cs @@ -41,6 +41,10 @@ public async Task EditApplicationUser() var applicationUser = await TestData.CreateApplicationUser(); var applicationUserId = applicationUser.UserId; var newApplicationUserName = TestData.GenerateChangedApplicationUserName(applicationUser.Name); + var newClientId = Guid.NewGuid().ToString(); + var newClientSecret = Guid.NewGuid().ToString(); + var newRedirectUris = "https://localhost/callback"; + var newPostLogoutRedirectUris = "https://localhost/logout-callback"; var newAuthenticationSchemeName = Guid.NewGuid().ToString(); var newOneLoginClientId = Guid.NewGuid().ToString(); var newOneLoginPrivateKeyPem = TestCommon.TestData.GeneratePrivateKeyPem(); @@ -62,6 +66,10 @@ public async Task EditApplicationUser() await page.SetCheckedAsync($"label:text-is('{ApiRoles.GetPerson}')", true); await page.SetCheckedAsync($"label:text-is('{ApiRoles.UpdatePerson}')", true); await page.SetCheckedAsync($"label:text-is('OIDC client')", true); + await page.FillAsync("text=Client ID", newClientId); + await page.FillAsync("text=Client secret", newClientSecret); + await page.FillAsync("text=Redirect URIs", newRedirectUris); + await page.FillAsync("text=Post logout redirect URIs", newPostLogoutRedirectUris); await page.FillAsync("text=Authentication scheme name", newAuthenticationSchemeName); await page.FillAsync("text=One Login client ID", newOneLoginClientId); await page.FillAsync("text=One Login private key", newOneLoginPrivateKeyPem); diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/PageTests/ApplicationUsers/EditApplicationUserTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/PageTests/ApplicationUsers/EditApplicationUserTests.cs index 4cc6e7307..ad3fb406c 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/PageTests/ApplicationUsers/EditApplicationUserTests.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/PageTests/ApplicationUsers/EditApplicationUserTests.cs @@ -128,7 +128,6 @@ public async Task Post_NameNotProvided_RendersError() { // Arrange var applicationUser = await TestData.CreateApplicationUser(apiRoles: []); - var originalName = applicationUser.Name; var newName = ""; var request = new HttpRequestMessage(HttpMethod.Post, $"/application-users/{applicationUser.UserId}") @@ -151,7 +150,6 @@ public async Task Post_NameTooLong_RendersError() { // Arrange var applicationUser = await TestData.CreateApplicationUser(apiRoles: []); - var originalName = applicationUser.Name; var newName = new string('x', UserBase.NameMaxLength + 1); var request = new HttpRequestMessage(HttpMethod.Post, $"/application-users/{applicationUser.UserId}") @@ -170,8 +168,12 @@ public async Task Post_NameTooLong_RendersError() } [Theory] - [MemberData(nameof(InvalidOneLoginDetailsData))] + [MemberData(nameof(InvalidOidcDetailsData))] public async Task Post_WithOidcClientButInvalidDetails_RendersExpectedError( + string clientId, + string clientSecret, + string redirectUris, + string postLogoutRedirectUris, string oneLoginClientId, string oneLoginClientKeyPem, string oneLoginAuthenticationSchemeName, @@ -190,6 +192,10 @@ public async Task Post_WithOidcClientButInvalidDetails_RendersExpectedError( { "Name", applicationUser.Name }, { "ApiRoles", applicationUser.ApiRoles }, { "IsOidcClient", bool.TrueString }, + { "ClientId", clientId }, + { "ClientSecret", clientSecret }, + { "RedirectUris", redirectUris }, + { "PostLogoutRedirectUris", postLogoutRedirectUris }, { "OneLoginClientId", oneLoginClientId }, { "OneLoginClientKeyPem", oneLoginClientKeyPem }, { "OneLoginAuthenticationSchemeName", oneLoginAuthenticationSchemeName }, @@ -213,6 +219,10 @@ public async Task Post_ValidRequest_UpdatesNameAndRolesAndOneLoginSettingsAndCre var originalName = applicationUser.Name; var newName = TestData.GenerateChangedApplicationUserName(originalName); var newRoles = new[] { ApiRoles.GetPerson, ApiRoles.UpdatePerson }; + var clientId = "client-id"; + var clientSecret = "Secret0123456789"; + var redirectUris = "http://localhost/callback"; + var postLogoutRedirectUris = "http://localhost/logout-callback"; var oneLoginClientId = Guid.NewGuid().ToString(); var oneLoginPrivateKeyPem = TestData.GeneratePrivateKeyPem(); var oneLoginAuthenticationSchemeName = Guid.NewGuid().ToString(); @@ -226,6 +236,10 @@ public async Task Post_ValidRequest_UpdatesNameAndRolesAndOneLoginSettingsAndCre { "Name", newName }, { "ApiRoles", newRoles }, { "IsOidcClient", bool.TrueString }, + { "ClientId", clientId }, + { "ClientSecret", clientSecret }, + { "RedirectUris", redirectUris }, + { "PostLogoutRedirectUris", postLogoutRedirectUris }, { "OneLoginClientId", oneLoginClientId }, { "OneLoginPrivateKeyPem", oneLoginPrivateKeyPem }, { "OneLoginAuthenticationSchemeName", oneLoginAuthenticationSchemeName }, @@ -260,12 +274,20 @@ await WithDbContext(async dbContext => Assert.True(applicationUserUpdatedEvent.ApplicationUser.ApiRoles.SequenceEqual(newRoles)); Assert.Empty(applicationUserUpdatedEvent.OldApplicationUser.ApiRoles); Assert.False(applicationUserUpdatedEvent.OldApplicationUser.IsOidcClient); + Assert.Null(applicationUserUpdatedEvent.OldApplicationUser.ClientId); + Assert.Null(applicationUserUpdatedEvent.OldApplicationUser.ClientSecret); + Assert.Empty(applicationUserUpdatedEvent.OldApplicationUser.RedirectUris ?? []); + Assert.Empty(applicationUserUpdatedEvent.OldApplicationUser.PostLogoutRedirectUris ?? []); Assert.Null(applicationUserUpdatedEvent.OldApplicationUser.OneLoginClientId); Assert.Null(applicationUserUpdatedEvent.OldApplicationUser.OneLoginPrivateKeyPem); Assert.Null(applicationUserUpdatedEvent.OldApplicationUser.OneLoginAuthenticationSchemeName); Assert.Null(applicationUserUpdatedEvent.OldApplicationUser.OneLoginRedirectUriPath); Assert.Null(applicationUserUpdatedEvent.OldApplicationUser.OneLoginPostLogoutRedirectUriPath); Assert.True(applicationUserUpdatedEvent.ApplicationUser.IsOidcClient); + Assert.Equal(clientId, applicationUserUpdatedEvent.ApplicationUser.ClientId); + Assert.Equal(clientSecret, applicationUserUpdatedEvent.ApplicationUser.ClientSecret); + Assert.Collection(applicationUserUpdatedEvent.ApplicationUser.RedirectUris ?? [], uri => Assert.Equal(redirectUris, uri)); + Assert.Collection(applicationUserUpdatedEvent.ApplicationUser.PostLogoutRedirectUris ?? [], uri => Assert.Equal(postLogoutRedirectUris, uri)); Assert.Equal(oneLoginClientId, applicationUserUpdatedEvent.ApplicationUser.OneLoginClientId); Assert.Equal(oneLoginPrivateKeyPem, applicationUserUpdatedEvent.ApplicationUser.OneLoginPrivateKeyPem); Assert.Equal(oneLoginAuthenticationSchemeName, applicationUserUpdatedEvent.ApplicationUser.OneLoginAuthenticationSchemeName); @@ -275,6 +297,10 @@ await WithDbContext(async dbContext => ApplicationUserUpdatedEventChanges.ApiRoles | ApplicationUserUpdatedEventChanges.Name | ApplicationUserUpdatedEventChanges.IsOidcClient | + ApplicationUserUpdatedEventChanges.ClientId | + ApplicationUserUpdatedEventChanges.ClientSecret | + ApplicationUserUpdatedEventChanges.RedirectUris | + ApplicationUserUpdatedEventChanges.PostLogoutRedirectUris | ApplicationUserUpdatedEventChanges.OneLoginClientId | ApplicationUserUpdatedEventChanges.OneLoginPrivateKeyPem | ApplicationUserUpdatedEventChanges.OneLoginAuthenticationSchemeName | @@ -288,9 +314,13 @@ await WithDbContext(async dbContext => AssertEx.HtmlDocumentHasFlashSuccess(redirectDoc, "Application user updated"); } - public static TheoryData InvalidOneLoginDetailsData => new() - { - { + public static object[][] InvalidOidcDetailsData => + [ + [ + "client_id", + "Secret0123456789", + "https://localhost/callback", + "https://localhost/logout-callback", "client_id", _privateKeyPem, "", // OneLoginAuthenticationSchemeName @@ -298,8 +328,12 @@ await WithDbContext(async dbContext => $"/_onelogin/{Guid.NewGuid:N}/logout-callback", "OneLoginAuthenticationSchemeName", "Enter an authentication scheme name" - }, - { + ], + [ + "client_id", + "Secret0123456789", + "https://localhost/callback", + "https://localhost/logout-callback", "client_id", _privateKeyPem, new string('x', 51), // OneLoginAuthenticationSchemeName @@ -307,8 +341,12 @@ await WithDbContext(async dbContext => $"/_onelogin/{Guid.NewGuid:N}/logout-callback", "OneLoginAuthenticationSchemeName", "Authentication scheme name must be 50 characters or less" - }, - { + ], + [ + "client_id", + "Secret0123456789", + "https://localhost/callback", + "https://localhost/logout-callback", "client_id", "", // OneLoginPrivateKeyPem Guid.NewGuid().ToString(), @@ -316,9 +354,12 @@ await WithDbContext(async dbContext => $"/_onelogin/{Guid.NewGuid:N}/logout-callback", "OneLoginPrivateKeyPem", "Enter the One Login private key" - }, - - { + ], + [ + "client_id", + "Secret0123456789", + "https://localhost/callback", + "https://localhost/logout-callback", "", // OneLoginClientId _privateKeyPem, Guid.NewGuid().ToString(), @@ -326,8 +367,12 @@ await WithDbContext(async dbContext => $"/_onelogin/{Guid.NewGuid:N}/logout-callback", "OneLoginClientId", "Enter the One Login client ID" - }, - { + ], + [ + "client_id", + "Secret0123456789", + "https://localhost/callback", + "https://localhost/logout-callback", "client_id", _privateKeyPem, Guid.NewGuid().ToString(), @@ -335,8 +380,12 @@ await WithDbContext(async dbContext => $"/_onelogin/{Guid.NewGuid:N}/logout-callback", "OneLoginRedirectUriPath", "Enter the One Login redirect URI" - }, - { + ], + [ + "client_id", + "Secret0123456789", + "https://localhost/callback", + "https://localhost/logout-callback", "client_id", _privateKeyPem, Guid.NewGuid().ToString(), @@ -344,8 +393,12 @@ await WithDbContext(async dbContext => "", // OneLoginPostLogoutRedirectUriPath "OneLoginPostLogoutRedirectUriPath", "Enter the One Login post logout redirect URI" - }, - { + ], + [ + "client_id", + "Secret0123456789", + "https://localhost/callback", + "https://localhost/logout-callback", "client_id", _privateKeyPem, Guid.NewGuid().ToString(), @@ -353,8 +406,12 @@ await WithDbContext(async dbContext => $"/_onelogin/{Guid.NewGuid:N}/logout-callback", "OneLoginRedirectUriPath", "One Login redirect URI must be 100 characters or less" - }, - { + ], + [ + "client_id", + "Secret0123456789", + "https://localhost/callback", + "https://localhost/logout-callback", "client_id", _privateKeyPem, Guid.NewGuid().ToString(), @@ -362,8 +419,99 @@ await WithDbContext(async dbContext => new string('x', 101), // OneLoginPostLogoutRedirectUriPath "OneLoginPostLogoutRedirectUriPath", "One Login post logout redirect URI must be 100 characters or less" - } - }; + ], + [ + "", + "Secret0123456789", + "https://localhost/callback", + "https://localhost/logout-callback", + "client_id", + _privateKeyPem, + Guid.NewGuid().ToString(), + $"/_onelogin/{Guid.NewGuid:N}/callback", + $"/_onelogin/{Guid.NewGuid:N}/logout-callback", + "ClientId", + "Enter a client ID" + ], + [ + new string('x', 51), + "Secret0123456789", + "https://localhost/callback", + "https://localhost/logout-callback", + "client_id", + _privateKeyPem, + Guid.NewGuid().ToString(), + $"/_onelogin/{Guid.NewGuid:N}/callback", + $"/_onelogin/{Guid.NewGuid:N}/logout-callback", + "ClientId", + "Client ID must be 50 characters or less" + ], + [ + "client_id", + "", + "https://localhost/callback", + "https://localhost/logout-callback", + "client_id", + _privateKeyPem, + Guid.NewGuid().ToString(), + $"/_onelogin/{Guid.NewGuid:N}/callback", + $"/_onelogin/{Guid.NewGuid:N}/logout-callback", + "ClientSecret", + "Enter a client secret" + ], + [ + "client_id", + new string('x', 201), + "https://localhost/callback", + "https://localhost/logout-callback", + "client_id", + _privateKeyPem, + Guid.NewGuid().ToString(), + $"/_onelogin/{Guid.NewGuid:N}/callback", + $"/_onelogin/{Guid.NewGuid:N}/logout-callback", + "ClientSecret", + "Client secret must be 200 characters or less" + ], + [ + "client_id", + "S", + "https://localhost/callback", + "https://localhost/logout-callback", + "client_id", + _privateKeyPem, + Guid.NewGuid().ToString(), + $"/_onelogin/{Guid.NewGuid:N}/callback", + $"/_onelogin/{Guid.NewGuid:N}/logout-callback", + "ClientSecret", + "Client secret must be at least 16 characters" + ], + [ + "client_id", + "Secret0123456789", + "foo", + "https://localhost/logout-callback", + "client_id", + _privateKeyPem, + Guid.NewGuid().ToString(), + $"/_onelogin/{Guid.NewGuid:N}/callback", + $"/_onelogin/{Guid.NewGuid:N}/logout-callback", + "RedirectUris", + "One or more redirect URIs are not valid" + ], + [ + "client_id", + "Secret0123456789", + "https://localhost/callback", + "foo", + "client_id", + _privateKeyPem, + Guid.NewGuid().ToString(), + $"/_onelogin/{Guid.NewGuid:N}/callback", + $"/_onelogin/{Guid.NewGuid:N}/logout-callback", + "PostLogoutRedirectUris", + "One or more post logout redirect URIs are not valid" + ], + ]; private static readonly string _privateKeyPem = RSA.Create().ExportPkcs8PrivateKeyPem(); }