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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,44 @@ dotnet user-secrets set "Entra:ClientId" "<client-id>"
dotnet user-secrets set "Entra:ClientSecret" "<client-secret>"
```

## Session timeout (CDC.Web)

A signed-in session - an internal (Entra ID) or an external (CIDM) user's alike - ends after a period with no activity, as
the legacy application's 20-minute session did. Any request to CDC.Web is activity and renews the session (the sign-in
cookie is re-issued at most once every twentieth of the timeout); asking how long a session has left is not, so it can never
keep an idle session alive. Five minutes before a session ends, the user is asked "Extend session?"; once it has ended, a
"Session expired" dialog sends them back through sign-in to the page they were on. The dialogs are built into every page
that has a signed-in user (`_SessionTimeout.cshtml`, `wwwroot/js/session-timeout.js`, `SessionController`).

Both settings are optional - the defaults reproduce the legacy behaviour - and are validated on startup, so the app will not
start with values that would end a session without a user being able to respond to the warning:

| Config key | Default | Purpose |
| ------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------- |
| `SessionTimeout:IdleTimeout` | `00:20:00` | How long a session may sit idle before the user has to sign in again (the lifetime of both sign-in cookies) |
| `SessionTimeout:WarningBeforeExpiry` | `00:05:00` | How long before the session ends the user is asked whether to extend it: at least 20 seconds, shorter than the idle timeout |


## Spell check (CDC.Web)

The "Spell check" button of a profile text field - a text or long-text answer on Edit Profile Questions, the user guidance on
Edit questions and guidance, a source of further information, a review comment - opens a dialog that replaces the legacy
RadSpell one. It stops at each misspelt word, in its context and with the dictionary's suggestions, and offers Ignore, Ignore
All, Add Custom, Change, Change All, Undo and Close, as the legacy dialog did. A check that runs to the end applies its changes;
closing part-way asks whether to apply them. A page with a Spell check button includes `_SpellCheckDialog.cshtml` and
`wwwroot/js/spell-check.js`.

The dictionary is on the server, so the check does not depend on the browser. `SpellCheckController` (`/SpellCheck/Check`,
`/SpellCheck/Suggest` and `/SpellCheck/AddCustomWord`; JSON, for signed-in users, with the antiforgery token in a header) checks
words against an American English (`en_US`, as the legacy control was set to) Hunspell dictionary embedded in CDC.Web and read
with WeCantSpell.Hunspell. The words added with Add Custom are the legacy `SpellCustomWord` table, reached through CDC.Api
(`GET` and `POST /api/spell-check/custom-words`): anyone signed in has the custom words, and, as in legacy, only a profile
editor, or a contributor or reviewer of any profile, may add one.

WeCantSpell.Hunspell is tri-licensed (MPL 1.1, GPL 2.0 or LGPL 2.1, at the user's choice), and the MPL is the one that suits this
app. The dictionary is derived from SCOWL, and its licence, `src/CDC.Web/Infrastructure/SpellCheck/Dictionaries/en_US.license.txt`,
is published with the app. There are no configuration keys; to check another language, embed its `.aff` and `.dic` files in place
of `en_US.*` (the resource names are in `HunspellWordList`).

## Code style & pre-commit checks

Expand Down
3 changes: 3 additions & 0 deletions src/CDC.Api/Application/ApplicationDependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
using CDC.Api.Features.ReferenceData.Interfaces;
using CDC.Api.Features.Species;
using CDC.Api.Features.Species.Interfaces;
using CDC.Api.Features.SpellCheck;
using CDC.Api.Features.SpellCheck.Interfaces;
using CDC.Api.Features.StaticReports;
using CDC.Api.Features.StaticReports.Interfaces;
using CDC.Api.Features.Users;
Expand Down Expand Up @@ -70,6 +72,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services
services.AddScoped<IProfileReportContentService, ProfileReportContentService>();
services.AddScoped<IBespokeReportTemplateService, BespokeReportTemplateService>();
services.AddScoped<IStaticReportService, StaticReportService>();
services.AddScoped<ISpellCheckService, SpellCheckService>();
services.AddScoped<IDiseaseRankingService, DiseaseRankingService>();
services.AddScoped<IReferenceDataService, ReferenceDataService>();
services.AddScoped<IUserService, UserService>();
Expand Down
30 changes: 25 additions & 5 deletions src/CDC.Api/Application/IUserContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ namespace CDC.Api.Application;
/// <summary>
/// The signed-in user making the request: their <c>[User].Id</c>, which kind of user they are, and the roles that
/// kind of user can hold. An internal user (<c>SsoUserIdInt</c>) holds the global profile editor or policy profile
/// user role and nothing at profile level; an external user (<c>SsoUserIdExt</c>) holds roles on individual profiles
/// and no global role. This keeps the legacy <c>ProfilesIdentity</c> flags (<c>IsProfileEditor</c>,
/// user role or, when it holds neither, roles on individual profiles; an external user (<c>SsoUserIdExt</c>) holds roles
/// on individual profiles and never a global role. This keeps the legacy <c>ProfilesIdentity</c> flags (<c>IsProfileEditor</c>,
/// <c>IsUserManagementSystem</c>, <c>IsPolicyProfileUser</c>) that <c>Profile.vb</c> and
/// <c>ProfileContributorList.vb</c> used to gate actions, and adds what <c>ProfileAccess</c> needs to apply the
/// profile-level rules.
Expand All @@ -33,9 +33,18 @@ public interface IUserContext
/// <summary>Gets which kind of user this is, from the sign-on identifier held on their row.</summary>
UserKind Kind { get; }

/// <summary>Gets the role an external user holds on a profile.</summary>
/// <summary>
/// Gets a value indicating whether the user is a contributor or reviewer of at least one profile (legacy
/// <c>ProfilesIdentity.IsContributorOrReviewer</c>). A user who holds a global role holds no profile roles.
/// </summary>
bool HoldsAnyProfileRole { get; }

/// <summary>Gets the role a user holds on a profile.</summary>
/// <param name="profileId">The profile.</param>
/// <returns>The role, or <see langword="null"/> when the user is not an external user or holds no role on the profile.</returns>
/// <returns>
/// The role, or <see langword="null"/> when the user holds no role on the profile or cannot hold one: an unclassified user, or
/// an internal user who holds a global role.
/// </returns>
ProfileRoleAssignment? GetProfileRole(Guid profileId);

/// <summary>Gets whether an external author is assigned to a profile section.</summary>
Expand Down Expand Up @@ -77,9 +86,20 @@ public sealed class RequestUserContext : IUserContext
/// <inheritdoc />
public bool IsPolicyProfileUser => Kind == UserKind.Internal && actingUser!.IsPolicyProfileUser;

/// <inheritdoc />
public bool HoldsAnyProfileRole => HoldsProfileRoles && actingUser!.ProfileRoles.Count > 0;

/// <inheritdoc />
public ProfileRoleAssignment? GetProfileRole(Guid profileId) =>
Kind == UserKind.External ? actingUser!.ProfileRoles.FirstOrDefault(role => role.ProfileId == profileId) : null;
HoldsProfileRoles ? actingUser!.ProfileRoles.FirstOrDefault(role => role.ProfileId == profileId) : null;

// An external user, or an internal user with no global role; a global role makes the profile-level rows irrelevant.
private bool HoldsProfileRoles => Kind switch
{
UserKind.External => true,
UserKind.Internal => !IsProfileEditor && !IsPolicyProfileUser,
_ => false
};

/// <inheritdoc />
public bool IsAssignedToSection(Guid profileId, Guid profileSectionId) =>
Expand Down
76 changes: 41 additions & 35 deletions src/CDC.Api/Application/ProfileAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ namespace CDC.Api.Application;
/// <list type="bullet">
/// <item><description>The profile editor (an internal user) can do everything.</description></item>
/// <item><description>The policy profile user (an internal user) can read every draft and run every report, but change nothing.</description></item>
/// <item><description>An external author (technical, policy or VA) can read the drafts of the profiles they hold a role on, edit
/// the sections assigned to them, and edit the dates of the one review their role owns.</description></item>
/// <item><description>An external reviewer can read the drafts of the profiles they hold a role on, and change nothing.</description></item>
/// <item><description>A contributor (an author: technical, policy or VA) can read the drafts of the profiles they hold a role on,
/// edit the sections assigned to them, change the notes, and write review comments.</description></item>
/// <item><description>A reviewer can read the drafts of the profiles they hold a role on and write review comments, and change
/// nothing else.</description></item>
/// </list>
/// Published versions can be read by anyone signed in. Only the current draft can be changed. No profile role belongs to an
/// internal user and no global role to an external one: <see cref="IUserContext"/> already enforces that.
/// Published versions can be read by anyone signed in. Only the current draft can be changed. A user holds roles on profiles only
/// when they hold no global role, and an external user never holds a global role: <see cref="IUserContext"/> already enforces that.
/// </remarks>
public static class ProfileAccess
{
Expand Down Expand Up @@ -44,10 +45,30 @@ public static bool IsPolicyUser(IUserContext user)
/// <returns><see langword="true"/> for a global user.</returns>
public static bool IsGlobalUser(IUserContext user) => IsEditor(user) || IsPolicyUser(user);

/// <summary>Gets whether the user can read the words in the spell check's custom dictionary (legacy <c>CustomWordList.CanGetCustomWordList</c>).</summary>
/// <param name="user">The acting user.</param>
/// <returns><see langword="true"/> for any known user other than a user management system account.</returns>
public static bool CanViewCustomWords(IUserContext user)
{
ArgumentNullException.ThrowIfNull(user);

return user.UserId is not null && !user.IsUserManagementSystem;
}

/// <summary>Gets whether the user can add words to the spell check's custom dictionary (legacy <c>CustomWordList.CanEditCustomWordList</c>).</summary>
/// <param name="user">The acting user.</param>
/// <returns><see langword="true"/> for a profile editor, or a contributor or reviewer of any profile.</returns>
public static bool CanEditCustomWords(IUserContext user)
{
ArgumentNullException.ThrowIfNull(user);

return !user.IsUserManagementSystem && (user.IsProfileEditor || user.HoldsAnyProfileRole);
}

/// <summary>Gets whether the user holds a role on a profile.</summary>
/// <param name="user">The acting user.</param>
/// <param name="profileId">The profile.</param>
/// <returns><see langword="true"/> for an external author or reviewer of the profile.</returns>
/// <returns><see langword="true"/> for a contributor or reviewer of the profile.</returns>
public static bool IsAssigned(IUserContext user, Guid profileId)
{
ArgumentNullException.ThrowIfNull(user);
Expand All @@ -58,7 +79,7 @@ public static bool IsAssigned(IUserContext user, Guid profileId)
/// <summary>Gets whether the user can read a profile's draft versions, sections, notes, review comments and history.</summary>
/// <param name="user">The acting user.</param>
/// <param name="profileId">The profile.</param>
/// <returns><see langword="true"/> for a global user, or an external user who holds a role on the profile.</returns>
/// <returns><see langword="true"/> for a global user, or a user who holds a role on the profile.</returns>
public static bool CanViewDraft(IUserContext user, Guid profileId) => IsGlobalUser(user) || IsAssigned(user, profileId);

/// <summary>Gets whether the user can read a version: any published version, or a draft they can view.</summary>
Expand All @@ -81,7 +102,7 @@ public static bool CanViewDraftTabs(IUserContext user, ProfileVersionFacts versi
/// <param name="user">The acting user.</param>
/// <param name="version">The version.</param>
/// <param name="profileSectionId">The section.</param>
/// <returns><see langword="true"/> for a profile editor, or an external author assigned to the section, on the current draft.</returns>
/// <returns><see langword="true"/> for a profile editor, or a contributor assigned to the section, on the current draft.</returns>
public static bool CanEditSection(IUserContext user, ProfileVersionFacts version, Guid profileSectionId)
{
ArgumentNullException.ThrowIfNull(user);
Expand All @@ -93,7 +114,7 @@ public static bool CanEditSection(IUserContext user, ProfileVersionFacts version
/// <summary>Gets whether the user can edit at least one section of the version.</summary>
/// <param name="user">The acting user.</param>
/// <param name="version">The version.</param>
/// <returns><see langword="true"/> for a profile editor, or an external author assigned to any section, on the current draft.</returns>
/// <returns><see langword="true"/> for a profile editor, or a contributor assigned to any section, on the current draft.</returns>
public static bool CanEditAnySection(IUserContext user, ProfileVersionFacts version)
{
ArgumentNullException.ThrowIfNull(user);
Expand All @@ -111,7 +132,7 @@ public static bool CanEditAnySection(IUserContext user, ProfileVersionFacts vers
/// <summary>Gets whether the user can add, edit and delete notes.</summary>
/// <param name="user">The acting user.</param>
/// <param name="version">The version.</param>
/// <returns><see langword="true"/> for a profile editor, or an external author of the profile, on the current draft.</returns>
/// <returns><see langword="true"/> for a profile editor, or a contributor to the profile, on the current draft.</returns>
public static bool CanChangeNotes(IUserContext user, ProfileVersionFacts version)
{
ArgumentNullException.ThrowIfNull(user);
Expand All @@ -120,36 +141,21 @@ public static bool CanChangeNotes(IUserContext user, ProfileVersionFacts version
&& (user.IsProfileEditor || user.GetProfileRole(version.ProfileId) is { IsContributor: true });
}

/// <summary>Gets whether the user can edit one review's frequency, next date and status.</summary>
/// <summary>Gets whether the user can edit the review frequencies, next review dates and statuses.</summary>
/// <param name="user">The acting user.</param>
/// <param name="version">The version.</param>
/// <param name="reviewType">The review.</param>
/// <returns>
/// <see langword="true"/> for a profile editor, or an external author whose role owns the review, on the current draft.
/// </returns>
public static bool CanEditReviewDates(IUserContext user, ProfileVersionFacts version, ReviewType reviewType)
{
ArgumentNullException.ThrowIfNull(user);

return version.IsCurrentDraft && !user.IsUserManagementSystem
&& (user.IsProfileEditor || user.GetProfileRole(version.ProfileId) is { IsContributor: true } role && role.ReviewType == reviewType);
}

/// <summary>Gets whether the user can edit any review's dates.</summary>
/// <param name="user">The acting user.</param>
/// <param name="version">The version.</param>
/// <returns><see langword="true"/> when at least one review is editable.</returns>
public static bool CanEditAnyReviewDates(IUserContext user, ProfileVersionFacts version) =>
Enum.GetValues<ReviewType>().Any(reviewType => CanEditReviewDates(user, version, reviewType));
/// <returns><see langword="true"/> for a profile editor on the current draft.</returns>
public static bool CanEditReviewDates(IUserContext user, ProfileVersionFacts version) => version.IsCurrentDraft && IsEditor(user);

/// <summary>
/// Gets whether the user can add, edit and delete review comments. The role matrix lets everyone who can read the comments
/// read them and gives no other role the right to write one, so this stays with the profile editor.
/// Gets whether the user can add review comments, reply to them, and edit or delete their own. Everyone who can read the
/// comments reads them; the policy profile user can read them but not write one.
/// </summary>
/// <param name="user">The acting user.</param>
/// <param name="version">The version.</param>
/// <returns><see langword="true"/> for a profile editor on the current draft.</returns>
public static bool CanWriteReviewComments(IUserContext user, ProfileVersionFacts version) => version.IsCurrentDraft && IsEditor(user);
/// <returns><see langword="true"/> for a profile editor, or a contributor or reviewer of the profile, on the current draft.</returns>
public static bool CanWriteReviewComments(IUserContext user, ProfileVersionFacts version) =>
version.IsCurrentDraft && (IsEditor(user) || IsAssigned(user, version.ProfileId));

/// <summary>Gets whether the user can print the full profile or one section of a draft (legacy <c>CanGetPrintVersion</c>).</summary>
/// <param name="user">The acting user.</param>
Expand Down Expand Up @@ -185,8 +191,8 @@ public static bool CanCreateReports(IUserContext user, ProfileVersionFacts versi
}

/// <summary>
/// Gets whether the user receives the review-due emails of one review on a profile: a profile editor receives every one, an
/// external author or reviewer the one their role owns, and a policy profile user none.
/// Gets whether the user receives the review-due emails of one review on a profile: a profile editor receives every one, a
/// contributor or reviewer the one their role owns, and a policy profile user none.
/// </summary>
/// <param name="user">The acting user.</param>
/// <param name="profileId">The profile.</param>
Expand Down
Loading
Loading