< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Shared.Retention.DataRetentionOptions
Assembly: ArturRios.Heimdall.Shared
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Shared/Retention/DataRetentionOptions.cs
Line coverage
100%
Covered lines: 78
Uncovered lines: 0
Coverable lines: 78
Total lines: 393
Line coverage: 100%
Branch coverage
100%
Covered branches: 20
Total branches: 20
Branch coverage: 100%
Method coverage
100%
Covered methods: 20
Fully covered methods: 20
Total methods: 20
Method coverage: 100%
Full method coverage: 100%

Metrics

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Shared/Retention/DataRetentionOptions.cs

#LineLine coverage
 1namespace ArturRios.Heimdall.Shared.Retention;
 2
 3/// <summary>
 4///     The retention periods NFR-19 requires the API to enforce, read from the
 5///     <c>HEIMDALL_RETENTION_*</c> environment variables. The full schedule — every table holding
 6///     personal data, its period, and the reason for it — is the Data Retention Schedule Document;
 7///     this type carries only the periods the API enforces itself, so nothing here promises a
 8///     retention rule that no code applies.
 9/// </summary>
 10/// <remarks>
 11///     <para>
 12///         Every value has a default that is safe to run with, because a deployment that sets none
 13///         of these variables must still enforce a period: an unset variable meaning "keep forever"
 14///         is exactly the state NFR-19 exists to end.
 15///     </para>
 16///     <para>
 17///         A malformed or out-of-range value falls back to the default rather than failing start-up.
 18///         The alternative was considered and rejected: refusing to start would turn a typo in an
 19///         operator's retention setting into an outage of an identity provider that every client
 20///         system authenticates against, which is a far worse failure than running one interval on
 21///         the documented default. The fallback is logged by the caller.
 22///     </para>
 23/// </remarks>
 24public sealed record DataRetentionOptions
 25{
 26    /// <summary>Environment variable holding the single-use token grace period, in days.</summary>
 27    public const string SingleUseTokenGraceDaysVariable = "HEIMDALL_RETENTION_TOKEN_GRACE_DAYS";
 28
 29    /// <summary>Environment variable holding the interval between purge runs, in minutes.</summary>
 30    public const string PurgeIntervalMinutesVariable = "HEIMDALL_RETENTION_PURGE_INTERVAL_MINUTES";
 31
 32    /// <summary>Environment variable holding the maximum rows removed from one table per run.</summary>
 33    public const string PurgeBatchSizeVariable = "HEIMDALL_RETENTION_PURGE_BATCH_SIZE";
 34
 35    /// <summary>Environment variable switching the scheduled purge off (<c>false</c> disables it).</summary>
 36    public const string PurgeEnabledVariable = "HEIMDALL_RETENTION_PURGE_ENABLED";
 37
 38    /// <summary>Environment variable holding the statutory erasure deadline, in days.</summary>
 39    public const string SubjectErasureDeadlineDaysVariable = "HEIMDALL_RETENTION_ERASURE_DEADLINE_DAYS";
 40
 41    /// <summary>Environment variable holding the administrative reversal window, in days.</summary>
 42    public const string AdministrativeDeletionWindowDaysVariable = "HEIMDALL_RETENTION_DELETION_WINDOW_DAYS";
 43
 44    /// <summary>Environment variable switching the scheduled anonymisation off.</summary>
 45    public const string AnonymisationEnabledVariable = "HEIMDALL_RETENTION_ANONYMISATION_ENABLED";
 46
 47    /// <summary>Environment variable holding how long an audit entry stays attributed, in days.</summary>
 48    public const string AuditActorRetentionDaysVariable = "HEIMDALL_RETENTION_AUDIT_ACTOR_DAYS";
 49
 50    /// <summary>Environment variable switching the scheduled audit pseudonymisation off.</summary>
 51    public const string AuditPseudonymisationEnabledVariable = "HEIMDALL_RETENTION_AUDIT_PSEUDONYMISATION_ENABLED";
 52
 53    /// <summary>Environment variable holding how long application log files are kept, in days.</summary>
 54    public const string LogRetentionDaysVariable = "HEIMDALL_RETENTION_LOG_DAYS";
 55
 56    /// <summary>Environment variable switching security signal detection off.</summary>
 57    public const string SecurityMonitoringEnabledVariable = "HEIMDALL_MONITORING_ENABLED";
 58
 59    /// <summary>Environment variable holding the window each signal is measured over, in minutes.</summary>
 60    public const string MonitoringWindowMinutesVariable = "HEIMDALL_MONITORING_WINDOW_MINUTES";
 61
 62    /// <summary>Environment variable holding how many refusals by one actor raise a signal.</summary>
 63    public const string RefusalThresholdVariable = "HEIMDALL_MONITORING_REFUSAL_THRESHOLD";
 64
 65    /// <summary>Environment variable holding how many locked-out accounts raise a signal.</summary>
 66    public const string LockoutThresholdVariable = "HEIMDALL_MONITORING_LOCKOUT_THRESHOLD";
 67
 68    /// <summary>Seven days — see <see cref="SingleUseTokenGrace" /> for why it is not zero.</summary>
 469    public static readonly TimeSpan DefaultSingleUseTokenGrace = TimeSpan.FromDays(7);
 70
 71    /// <summary>One hour. Nothing here is urgent; the grace period dwarfs the interval.</summary>
 472    public static readonly TimeSpan DefaultPurgeInterval = TimeSpan.FromHours(1);
 73
 74    /// <summary>Rows removed from one table in one run, by default.</summary>
 75    public const int DefaultPurgeBatchSize = 500;
 76
 77    /// <summary>Thirty days — the outer limit GDPR Art. 12(3) allows, not a chosen period.</summary>
 478    public static readonly TimeSpan DefaultSubjectErasureDeadline = TimeSpan.FromDays(30);
 79
 80    /// <summary>Ninety days — the reversal window for an administrative deletion.</summary>
 481    public static readonly TimeSpan DefaultAdministrativeDeletionWindow = TimeSpan.FromDays(90);
 82
 83    /// <summary>548 days — eighteen months, the period the retention schedule sets.</summary>
 484    public static readonly TimeSpan DefaultAuditActorRetention = TimeSpan.FromDays(548);
 85
 86    /// <summary>365 days — twelve months, the period the retention schedule sets for logs.</summary>
 487    public static readonly TimeSpan DefaultLogRetention = TimeSpan.FromDays(365);
 88
 89    /// <summary>Fifteen minutes — long enough to see a pattern, short enough to still be news.</summary>
 490    public static readonly TimeSpan DefaultMonitoringWindow = TimeSpan.FromMinutes(15);
 91
 92    /// <summary>Refusals by one actor in the window before it is worth telling somebody.</summary>
 93    public const int DefaultRefusalThreshold = 20;
 94
 95    /// <summary>Accounts locked out at once before it is worth telling somebody.</summary>
 96    public const int DefaultLockoutThreshold = 10;
 97
 98    /// <summary>Bounds on the monitoring window, in minutes.</summary>
 99    private const double MinimumMonitoringWindowMinutes = 1;
 100
 101    /// <inheritdoc cref="MinimumMonitoringWindowMinutes" />
 102    private const double MaximumMonitoringWindowMinutes = 1440;
 103
 104    /// <summary>Shortest accepted log retention, in days.</summary>
 105    private const double MinimumLogRetentionDays = 1;
 106
 107    /// <summary>Longest accepted log retention, in days.</summary>
 108    private const double MaximumLogRetentionDays = 3650;
 109
 110    /// <summary>
 111    ///     Shortest accepted audit attribution retention, in days. Matches the floor the database
 112    ///     trigger enforces: below it the pass would select rows the trigger then refuses, turning a
 113    ///     configuration mistake into a run that fails every time rather than one that quietly does
 114    ///     the wrong thing.
 115    /// </summary>
 116    private const double MinimumAuditActorRetentionDays = 30;
 117
 118    /// <summary>Longest accepted audit attribution retention, in days.</summary>
 119    private const double MaximumAuditActorRetentionDays = 3650;
 120
 121    /// <summary>
 122    ///     Longest accepted erasure deadline, in days. Thirty is the statutory limit, so a value
 123    ///     above it is not a policy choice but a compliance failure, and the ceiling refuses it.
 124    /// </summary>
 125    private const double MaximumErasureDeadlineDays = 30;
 126
 127    /// <summary>
 128    ///     Longest accepted administrative window, in days. Two years is already far past any
 129    ///     reversal purpose; beyond it the window has stopped being a window.
 130    /// </summary>
 131    private const double MaximumDeletionWindowDays = 730;
 132
 133    /// <summary>
 134    ///     Longest accepted grace period, in days. Ten years is far past any purpose these tokens
 135    ///     could serve, so a larger value is a typo — a stray unit, usually — rather than a policy,
 136    ///     and honouring it would keep the data indefinitely under the appearance of a stated period.
 137    /// </summary>
 138    private const double MaximumGraceDays = 3650;
 139
 140    /// <summary>
 141    ///     Shortest accepted purge interval, in minutes. Keeps a mistyped value from turning the
 142    ///     purge into continuous delete pressure on the tables the login path writes to.
 143    /// </summary>
 144    private const double MinimumIntervalMinutes = 1;
 145
 146    /// <summary>
 147    ///     Longest accepted purge interval, in minutes — seven days.
 148    /// </summary>
 149    /// <remarks>
 150    ///     A ceiling is needed, not just tidy: <see cref="PeriodicTimer" /> refuses a period beyond
 151    ///     roughly 49 days, and an interval it rejects would throw inside the hosted service, whose
 152    ///     default behaviour on an unhandled exception is to stop the host. Without this, a stray
 153    ///     digit in a retention setting would take the API down.
 154    /// </remarks>
 155    private const double MaximumIntervalMinutes = 7 * 24 * 60;
 156
 157    /// <summary>
 158    ///     How long a single-use token is kept after it expires, before the purge removes it
 159    ///     (GDPR Art. 5(1)(e), LGPD Art. 15 III).
 160    /// </summary>
 161    /// <remarks>
 162    ///     Deliberately not zero. UC-13 answers a presented token with three distinct outcomes —
 163    ///     <c>TokenInvalid</c>, <c>TokenExpired</c>, <c>TokenAlreadyUsed</c> — and purging a row the
 164    ///     moment it expires collapses the last two into the first, so a person following a stale
 165    ///     link is told the token never existed rather than that it ran out. The grace period is
 166    ///     what keeps that answer truthful for as long as anyone is plausibly still holding the
 167    ///     link, and no longer.
 168    /// </remarks>
 4694169    public TimeSpan SingleUseTokenGrace { get; init; } = DefaultSingleUseTokenGrace;
 170
 171    /// <summary>How often the scheduled purge runs.</summary>
 9161172    public TimeSpan PurgeInterval { get; init; } = DefaultPurgeInterval;
 173
 174    /// <summary>
 175    ///     The most rows the purge removes from any one table in a single run.
 176    /// </summary>
 177    /// <remarks>
 178    ///     The bound is not a performance nicety. SRD §6.3.2 measured the write path degrading with
 179    ///     the size of the table under sustained insert pressure, and a purge is sustained delete
 180    ///     pressure on tables the login and recovery paths write to. Bounding the batch means a
 181    ///     backlog drains over several runs instead of one long transaction competing with live
 182    ///     traffic.
 183    /// </remarks>
 4050184    public int PurgeBatchSize { get; init; } = DefaultPurgeBatchSize;
 185
 186    /// <summary>
 187    ///     Whether the scheduled purge is registered at start-up. On by default: a deployment that
 188    ///     sets nothing still enforces the schedule.
 189    /// </summary>
 4664190    public bool PurgeEnabled { get; init; } = true;
 191
 192    /// <summary>
 193    ///     How long a logically deleted identity is kept before anonymisation when the data subject
 194    ///     asked to be erased (NFR-20).
 195    /// </summary>
 196    /// <remarks>
 197    ///     An outer limit rather than a target. GDPR Art. 12(3) allows at most one month to act on
 198    ///     an erasure request, so this is the deadline the erasure must not exceed — it completes as
 199    ///     soon as it can. Raising it above thirty days is refused, because no configuration can
 200    ///     make a longer period lawful.
 201    /// </remarks>
 4758202    public TimeSpan SubjectErasureDeadline { get; init; } = DefaultSubjectErasureDeadline;
 203
 204    /// <summary>
 205    ///     How long a logically deleted identity is kept before anonymisation when an administrator
 206    ///     deleted it and no subject asked (NFR-20).
 207    /// </summary>
 208    /// <remarks>
 209    ///     A reversal window: an administrator who deleted the wrong person needs to undo it, and
 210    ///     the client systems in that person's scope need time to notice. Ninety days rather than
 211    ///     the thirty most consumer services use, because a deletion here is not confined to one
 212    ///     product — every client system authenticates against this one.
 213    /// </remarks>
 4714214    public TimeSpan AdministrativeDeletionWindow { get; init; } = DefaultAdministrativeDeletionWindow;
 215
 216    /// <summary>
 217    ///     Whether the scheduled anonymisation is registered at start-up. On by default, for the
 218    ///     same reason the purge is.
 219    /// </summary>
 4662220    public bool AnonymisationEnabled { get; init; } = true;
 221
 222    /// <summary>
 223    ///     How long an audit entry stays attributed to the identity that produced it, before the
 224    ///     attribution is cleared (NFR-21).
 225    /// </summary>
 226    /// <remarks>
 227    ///     Eighteen months by default. Most security baselines settle on twelve — PCI DSS requires a
 228    ///     year of audit history — and breach discovery lag routinely exceeds it, so twelve is a
 229    ///     floor rather than a comfortable answer; eighteen covers a compliance cycle plus that lag.
 230    ///     Past it the accountability value of <em>who</em> falls away sharply while the value of
 231    ///     what happened, when and how often does not, and that half survives the attribution being
 232    ///     cleared.
 233    /// </remarks>
 4738234    public TimeSpan AuditActorRetention { get; init; } = DefaultAuditActorRetention;
 235
 236    /// <summary>Whether the scheduled audit pseudonymisation is registered at start-up.</summary>
 4662237    public bool AuditPseudonymisationEnabled { get; init; } = true;
 238
 239    /// <summary>
 240    ///     How long application log files are kept before the file sink removes them (NFR-22).
 241    /// </summary>
 242    /// <remarks>
 243    ///     Twelve months, because these are the telemetry a breach is detected from rather than
 244    ///     debugging output: breach discovery is measured in months, and a shorter period would
 245    ///     delete the evidence before anyone knew to look. GDPR Art. 33's clock starts at awareness,
 246    ///     which a log that had already expired never contributed to.
 247    /// </remarks>
 4662248    public TimeSpan LogRetention { get; init; } = DefaultLogRetention;
 249
 250    /// <summary>Whether security signal detection is registered at start-up (NFR-26).</summary>
 4662251    public bool SecurityMonitoringEnabled { get; init; } = true;
 252
 253    /// <summary>
 254    ///     How far back each signal looks. A signal is about a rate, not a total: twenty refusals
 255    ///     over a year is somebody who forgets their password, and twenty in a quarter of an hour is
 256    ///     somebody trying things.
 257    /// </summary>
 5591258    public TimeSpan MonitoringWindow { get; init; } = DefaultMonitoringWindow;
 259
 260    /// <summary>Refusals by one actor within the window before a signal is raised.</summary>
 4693261    public int RefusalThreshold { get; init; } = DefaultRefusalThreshold;
 262
 263    /// <summary>Accounts locked out at once before a signal is raised.</summary>
 4680264    public int LockoutThreshold { get; init; } = DefaultLockoutThreshold;
 265
 266    /// <summary>
 267    ///     Names the variables whose values were unusable and fell back to a default, so the caller
 268    ///     can say so in the log. Empty when every variable was absent or valid — an absent variable
 269    ///     is not a fallback, it is the documented default being chosen.
 270    /// </summary>
 4683271    public IReadOnlyCollection<string> InvalidVariables { get; init; } = [];
 272
 273    /// <summary>Reads the retention settings from the current process environment.</summary>
 274    public static DataRetentionOptions FromEnvironment()
 275    {
 1830276        var invalid = new List<string>();
 277
 1830278        return new DataRetentionOptions
 1830279        {
 1830280            SingleUseTokenGrace = ReadBoundedTimeSpan(
 1830281                SingleUseTokenGraceDaysVariable, DefaultSingleUseTokenGrace, TimeSpan.FromDays,
 1830282                minimum: 0, MaximumGraceDays, invalid),
 1830283            PurgeInterval = ReadBoundedTimeSpan(
 1830284                PurgeIntervalMinutesVariable, DefaultPurgeInterval, TimeSpan.FromMinutes,
 1830285                MinimumIntervalMinutes, MaximumIntervalMinutes, invalid),
 1830286            PurgeBatchSize = ReadPositiveInt(PurgeBatchSizeVariable, DefaultPurgeBatchSize, invalid),
 1830287            PurgeEnabled = ReadBool(PurgeEnabledVariable, defaultValue: true, invalid),
 1830288            SubjectErasureDeadline = ReadBoundedTimeSpan(
 1830289                SubjectErasureDeadlineDaysVariable, DefaultSubjectErasureDeadline, TimeSpan.FromDays,
 1830290                minimum: 0, MaximumErasureDeadlineDays, invalid),
 1830291            AdministrativeDeletionWindow = ReadBoundedTimeSpan(
 1830292                AdministrativeDeletionWindowDaysVariable, DefaultAdministrativeDeletionWindow,
 1830293                TimeSpan.FromDays, minimum: 0, MaximumDeletionWindowDays, invalid),
 1830294            AnonymisationEnabled = ReadBool(AnonymisationEnabledVariable, defaultValue: true, invalid),
 1830295            AuditActorRetention = ReadBoundedTimeSpan(
 1830296                AuditActorRetentionDaysVariable, DefaultAuditActorRetention, TimeSpan.FromDays,
 1830297                MinimumAuditActorRetentionDays, MaximumAuditActorRetentionDays, invalid),
 1830298            AuditPseudonymisationEnabled = ReadBool(
 1830299                AuditPseudonymisationEnabledVariable, defaultValue: true, invalid),
 1830300            LogRetention = ReadBoundedTimeSpan(
 1830301                LogRetentionDaysVariable, DefaultLogRetention, TimeSpan.FromDays,
 1830302                MinimumLogRetentionDays, MaximumLogRetentionDays, invalid),
 1830303            SecurityMonitoringEnabled = ReadBool(
 1830304                SecurityMonitoringEnabledVariable, defaultValue: true, invalid),
 1830305            MonitoringWindow = ReadBoundedTimeSpan(
 1830306                MonitoringWindowMinutesVariable, DefaultMonitoringWindow, TimeSpan.FromMinutes,
 1830307                MinimumMonitoringWindowMinutes, MaximumMonitoringWindowMinutes, invalid),
 1830308            RefusalThreshold = ReadPositiveInt(RefusalThresholdVariable, DefaultRefusalThreshold, invalid),
 1830309            LockoutThreshold = ReadPositiveInt(LockoutThresholdVariable, DefaultLockoutThreshold, invalid),
 1830310            InvalidVariables = invalid
 1830311        };
 312    }
 313
 314    /// <summary>
 315    ///     Reads a period expressed in <paramref name="unit" />, accepting the raw number only when
 316    ///     it is positive, at least <paramref name="minimum" />, and no greater than
 317    ///     <paramref name="maximum" /> — both given in the variable's own unit, days or minutes. A
 318    ///     <paramref name="minimum" /> of zero therefore means "more than 0", and any other is
 319    ///     inclusive, which is how the Data Retention Schedule states each range ("1 to 10080",
 320    ///     "30 to 3650"). It used to be exclusive throughout, so the documented lower bound of every
 321    ///     range was itself refused.
 322    /// </summary>
 323    /// <remarks>
 324    ///     The bounds are checked on the number, before it is converted, and that ordering is the
 325    ///     point: <see cref="TimeSpan.FromDays" /> throws on a large enough <c>double</c>, so a
 326    ///     check performed on the converted value would never run. An exception here would fail
 327    ///     start-up, which is the one outcome this type exists to avoid.
 328    /// </remarks>
 329    private static TimeSpan ReadBoundedTimeSpan(
 330        string variable,
 331        TimeSpan defaultValue,
 332        Func<double, TimeSpan> unit,
 333        double minimum,
 334        double maximum,
 335        List<string> invalid)
 336    {
 12810337        var raw = Environment.GetEnvironmentVariable(variable);
 338
 12810339        if (string.IsNullOrWhiteSpace(raw))
 340        {
 12782341            return defaultValue;
 342        }
 343
 344        // A zero or negative period would purge rows the moment they expire, or run continuously;
 345        // both are mistakes rather than policies.
 28346        if (!double.TryParse(raw, out var value) || value <= 0 || value < minimum || value > maximum)
 347        {
 21348            invalid.Add(variable);
 349
 21350            return defaultValue;
 351        }
 352
 7353        return unit(value);
 354    }
 355
 356    private static int ReadPositiveInt(string variable, int defaultValue, List<string> invalid)
 357    {
 5490358        var raw = Environment.GetEnvironmentVariable(variable);
 359
 5490360        if (string.IsNullOrWhiteSpace(raw))
 361        {
 5486362            return defaultValue;
 363        }
 364
 4365        if (!int.TryParse(raw, out var value) || value <= 0)
 366        {
 3367            invalid.Add(variable);
 368
 3369            return defaultValue;
 370        }
 371
 1372        return value;
 373    }
 374
 375    private static bool ReadBool(string variable, bool defaultValue, List<string> invalid)
 376    {
 7320377        var raw = Environment.GetEnvironmentVariable(variable);
 378
 7320379        if (string.IsNullOrWhiteSpace(raw))
 380        {
 7318381            return defaultValue;
 382        }
 383
 2384        if (!bool.TryParse(raw, out var value))
 385        {
 1386            invalid.Add(variable);
 387
 1388            return defaultValue;
 389        }
 390
 1391        return value;
 392    }
 393}