< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Shared.Retention.DataRetentionOptions
Assembly: ArturRios.Heimdall.Shared
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Shared/Retention/DataRetentionOptions.cs
Line coverage
100%
Covered lines: 82
Uncovered lines: 0
Coverable lines: 82
Total lines: 430
Line coverage: 100%
Branch coverage
100%
Covered branches: 20
Total branches: 20
Branch coverage: 100%
Method coverage
100%
Covered methods: 22
Fully covered methods: 22
Total methods: 22
Method coverage: 100%
Full method coverage: 100%

Metrics

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/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 holding the most batches a pass takes from one table per run.</summary>
 36    public const string MaxBatchesPerTickVariable = "HEIMDALL_RETENTION_MAX_BATCHES_PER_TICK";
 37
 38    /// <summary>Environment variable switching the scheduled purge off (<c>false</c> disables it).</summary>
 39    public const string PurgeEnabledVariable = "HEIMDALL_RETENTION_PURGE_ENABLED";
 40
 41    /// <summary>Environment variable holding the statutory erasure deadline, in days.</summary>
 42    public const string SubjectErasureDeadlineDaysVariable = "HEIMDALL_RETENTION_ERASURE_DEADLINE_DAYS";
 43
 44    /// <summary>Environment variable holding the administrative reversal window, in days.</summary>
 45    public const string AdministrativeDeletionWindowDaysVariable = "HEIMDALL_RETENTION_DELETION_WINDOW_DAYS";
 46
 47    /// <summary>Environment variable switching the scheduled anonymisation off.</summary>
 48    public const string AnonymisationEnabledVariable = "HEIMDALL_RETENTION_ANONYMISATION_ENABLED";
 49
 50    /// <summary>Environment variable holding how long an audit entry stays attributed, in days.</summary>
 51    public const string AuditActorRetentionDaysVariable = "HEIMDALL_RETENTION_AUDIT_ACTOR_DAYS";
 52
 53    /// <summary>Environment variable switching the scheduled audit pseudonymisation off.</summary>
 54    public const string AuditPseudonymisationEnabledVariable = "HEIMDALL_RETENTION_AUDIT_PSEUDONYMISATION_ENABLED";
 55
 56    /// <summary>Environment variable holding how long application log files are kept, in days.</summary>
 57    public const string LogRetentionDaysVariable = "HEIMDALL_RETENTION_LOG_DAYS";
 58
 59    /// <summary>Environment variable switching security signal detection off.</summary>
 60    public const string SecurityMonitoringEnabledVariable = "HEIMDALL_MONITORING_ENABLED";
 61
 62    /// <summary>Environment variable holding the window each signal is measured over, in minutes.</summary>
 63    public const string MonitoringWindowMinutesVariable = "HEIMDALL_MONITORING_WINDOW_MINUTES";
 64
 65    /// <summary>Environment variable holding how many refusals by one actor raise a signal.</summary>
 66    public const string RefusalThresholdVariable = "HEIMDALL_MONITORING_REFUSAL_THRESHOLD";
 67
 68    /// <summary>Environment variable holding how many locked-out accounts raise a signal.</summary>
 69    public const string LockoutThresholdVariable = "HEIMDALL_MONITORING_LOCKOUT_THRESHOLD";
 70
 71    /// <summary>Seven days — see <see cref="SingleUseTokenGrace" /> for why it is not zero.</summary>
 572    public static readonly TimeSpan DefaultSingleUseTokenGrace = TimeSpan.FromDays(7);
 73
 74    /// <summary>One hour. Nothing here is urgent; the grace period dwarfs the interval.</summary>
 575    public static readonly TimeSpan DefaultPurgeInterval = TimeSpan.FromHours(1);
 76
 77    /// <summary>Rows removed from one table in one run, by default.</summary>
 78    public const int DefaultPurgeBatchSize = 500;
 79
 80    /// <summary>Batches a pass takes from one table in one run, by default: 10,000 rows at the default batch size.</sum
 81    public const int DefaultMaxBatchesPerTick = 20;
 82
 83    /// <summary>Thirty days — the outer limit GDPR Art. 12(3) allows, not a chosen period.</summary>
 584    public static readonly TimeSpan DefaultSubjectErasureDeadline = TimeSpan.FromDays(30);
 85
 86    /// <summary>Ninety days — the reversal window for an administrative deletion.</summary>
 587    public static readonly TimeSpan DefaultAdministrativeDeletionWindow = TimeSpan.FromDays(90);
 88
 89    /// <summary>548 days — eighteen months, the period the retention schedule sets.</summary>
 590    public static readonly TimeSpan DefaultAuditActorRetention = TimeSpan.FromDays(548);
 91
 92    /// <summary>365 days — twelve months, the period the retention schedule sets for logs.</summary>
 593    public static readonly TimeSpan DefaultLogRetention = TimeSpan.FromDays(365);
 94
 95    /// <summary>Ninety days — how long an ended or expired scope owner invitation is kept.</summary>
 596    public static readonly TimeSpan DefaultScopeInvitationRetention = TimeSpan.FromDays(90);
 97
 98    /// <summary>Fifteen minutes — long enough to see a pattern, short enough to still be news.</summary>
 599    public static readonly TimeSpan DefaultMonitoringWindow = TimeSpan.FromMinutes(15);
 100
 101    /// <summary>Refusals by one actor in the window before it is worth telling somebody.</summary>
 102    public const int DefaultRefusalThreshold = 20;
 103
 104    /// <summary>Accounts locked out at once before it is worth telling somebody.</summary>
 105    public const int DefaultLockoutThreshold = 10;
 106
 107    /// <summary>Bounds on the monitoring window, in minutes.</summary>
 108    private const double MinimumMonitoringWindowMinutes = 1;
 109
 110    /// <inheritdoc cref="MinimumMonitoringWindowMinutes" />
 111    private const double MaximumMonitoringWindowMinutes = 1440;
 112
 113    /// <summary>Shortest accepted log retention, in days.</summary>
 114    private const double MinimumLogRetentionDays = 1;
 115
 116    /// <summary>Longest accepted log retention, in days.</summary>
 117    private const double MaximumLogRetentionDays = 3650;
 118
 119    /// <summary>
 120    ///     Shortest accepted audit attribution retention, in days. Matches the floor the database
 121    ///     trigger enforces: below it the pass would select rows the trigger then refuses, turning a
 122    ///     configuration mistake into a run that fails every time rather than one that quietly does
 123    ///     the wrong thing.
 124    /// </summary>
 125    private const double MinimumAuditActorRetentionDays = 30;
 126
 127    /// <summary>Longest accepted audit attribution retention, in days.</summary>
 128    private const double MaximumAuditActorRetentionDays = 3650;
 129
 130    /// <summary>
 131    ///     Longest accepted erasure deadline, in days. Thirty is the statutory limit, so a value
 132    ///     above it is not a policy choice but a compliance failure, and the ceiling refuses it.
 133    /// </summary>
 134    private const double MaximumErasureDeadlineDays = 30;
 135
 136    /// <summary>
 137    ///     Longest accepted administrative window, in days. Two years is already far past any
 138    ///     reversal purpose; beyond it the window has stopped being a window.
 139    /// </summary>
 140    private const double MaximumDeletionWindowDays = 730;
 141
 142    /// <summary>
 143    ///     Longest accepted grace period, in days. Ten years is far past any purpose these tokens
 144    ///     could serve, so a larger value is a typo — a stray unit, usually — rather than a policy,
 145    ///     and honouring it would keep the data indefinitely under the appearance of a stated period.
 146    /// </summary>
 147    private const double MaximumGraceDays = 3650;
 148
 149    /// <summary>
 150    ///     Shortest accepted purge interval, in minutes. Keeps a mistyped value from turning the
 151    ///     purge into continuous delete pressure on the tables the login path writes to.
 152    /// </summary>
 153    private const double MinimumIntervalMinutes = 1;
 154
 155    /// <summary>
 156    ///     Longest accepted purge interval, in minutes — seven days.
 157    /// </summary>
 158    /// <remarks>
 159    ///     A ceiling is needed, not just tidy: <see cref="PeriodicTimer" /> refuses a period beyond
 160    ///     roughly 49 days, and an interval it rejects would throw inside the hosted service, whose
 161    ///     default behaviour on an unhandled exception is to stop the host. Without this, a stray
 162    ///     digit in a retention setting would take the API down.
 163    /// </remarks>
 164    private const double MaximumIntervalMinutes = 7 * 24 * 60;
 165
 166    /// <summary>
 167    ///     How long a single-use token is kept after it expires, before the purge removes it
 168    ///     (GDPR Art. 5(1)(e), LGPD Art. 15 III).
 169    /// </summary>
 170    /// <remarks>
 171    ///     Deliberately not zero. UC-13 answers a presented token with three distinct outcomes —
 172    ///     <c>TokenInvalid</c>, <c>TokenExpired</c>, <c>TokenAlreadyUsed</c> — and purging a row the
 173    ///     moment it expires collapses the last two into the first, so a person following a stale
 174    ///     link is told the token never existed rather than that it ran out. The grace period is
 175    ///     what keeps that answer truthful for as long as anyone is plausibly still holding the
 176    ///     link, and no longer.
 177    /// </remarks>
 5145178    public TimeSpan SingleUseTokenGrace { get; init; } = DefaultSingleUseTokenGrace;
 179
 180    /// <summary>How often the scheduled purge runs.</summary>
 9938181    public TimeSpan PurgeInterval { get; init; } = DefaultPurgeInterval;
 182
 183    /// <summary>
 184    ///     The most rows the purge removes from any one table in a single run.
 185    /// </summary>
 186    /// <remarks>
 187    ///     The bound is not a performance nicety. SRD §6.3.2 measured the write path degrading with
 188    ///     the size of the table under sustained insert pressure, and a purge is sustained delete
 189    ///     pressure on tables the login and recovery paths write to. Bounding the batch means a
 190    ///     backlog drains over several runs instead of one long transaction competing with live
 191    ///     traffic.
 192    /// </remarks>
 5014193    public int PurgeBatchSize { get; init; } = DefaultPurgeBatchSize;
 194
 195    /// <summary>
 196    ///     The most batches of <see cref="PurgeBatchSize" /> a pass takes from any one table in a single
 197    ///     run: it repeats a batch while the last one came back full, and stops here (#206).
 198    /// </summary>
 199    /// <remarks>
 200    ///     <para>
 201    ///         One batch per run capped a pass at 12,000 rows a day per table at the defaults, which a
 202    ///         busy deployment's audit trail outgrows: the backlog then never drains, and attribution
 203    ///         outlives the published period. Repeating the batch lets a backlog clear in a few runs
 204    ///         while each batch stays its own short transaction, so the reason for bounding the batch
 205    ///         still holds.
 206    ///     </para>
 207    ///     <para>
 208    ///         The cap is what keeps a run finite: a backlog larger than it is left for the next run,
 209    ///         and the run's summary says how much was left, so a backlog that is not draining is
 210    ///         visible rather than silent.
 211    ///     </para>
 212    /// </remarks>
 4778213    public int MaxBatchesPerTick { get; init; } = DefaultMaxBatchesPerTick;
 214
 215    /// <summary>
 216    ///     Whether the scheduled purge is registered at start-up. On by default: a deployment that
 217    ///     sets nothing still enforces the schedule.
 218    /// </summary>
 5103219    public bool PurgeEnabled { get; init; } = true;
 220
 221    /// <summary>
 222    ///     How long a logically deleted identity is kept before anonymisation when the data subject
 223    ///     asked to be erased (NFR-20).
 224    /// </summary>
 225    /// <remarks>
 226    ///     An outer limit rather than a target. GDPR Art. 12(3) allows at most one month to act on
 227    ///     an erasure request, so this is the deadline the erasure must not exceed — it completes as
 228    ///     soon as it can. Raising it above thirty days is refused, because no configuration can
 229    ///     make a longer period lawful.
 230    /// </remarks>
 5265231    public TimeSpan SubjectErasureDeadline { get; init; } = DefaultSubjectErasureDeadline;
 232
 233    /// <summary>
 234    ///     How long a logically deleted identity is kept before anonymisation when an administrator
 235    ///     deleted it and no subject asked (NFR-20).
 236    /// </summary>
 237    /// <remarks>
 238    ///     A reversal window: an administrator who deleted the wrong person needs to undo it, and
 239    ///     the client systems in that person's scope need time to notice. Ninety days rather than
 240    ///     the thirty most consumer services use, because a deletion here is not confined to one
 241    ///     product — every client system authenticates against this one.
 242    /// </remarks>
 5202243    public TimeSpan AdministrativeDeletionWindow { get; init; } = DefaultAdministrativeDeletionWindow;
 244
 245    /// <summary>
 246    ///     Whether the scheduled anonymisation is registered at start-up. On by default, for the
 247    ///     same reason the purge is.
 248    /// </summary>
 5101249    public bool AnonymisationEnabled { get; init; } = true;
 250
 251    /// <summary>
 252    ///     How long an audit entry stays attributed to the identity that produced it, before the
 253    ///     attribution is cleared (NFR-21).
 254    /// </summary>
 255    /// <remarks>
 256    ///     Eighteen months by default. Most security baselines settle on twelve — PCI DSS requires a
 257    ///     year of audit history — and breach discovery lag routinely exceeds it, so twelve is a
 258    ///     floor rather than a comfortable answer; eighteen covers a compliance cycle plus that lag.
 259    ///     Past it the accountability value of <em>who</em> falls away sharply while the value of
 260    ///     what happened, when and how often does not, and that half survives the attribution being
 261    ///     cleared.
 262    /// </remarks>
 5187263    public TimeSpan AuditActorRetention { get; init; } = DefaultAuditActorRetention;
 264
 265    /// <summary>Whether the scheduled audit pseudonymisation is registered at start-up.</summary>
 5101266    public bool AuditPseudonymisationEnabled { get; init; } = true;
 267
 268    /// <summary>
 269    ///     How long application log files are kept before the file sink removes them (NFR-22).
 270    /// </summary>
 271    /// <remarks>
 272    ///     Twelve months, because these are the telemetry a breach is detected from rather than
 273    ///     debugging output: breach discovery is measured in months, and a shorter period would
 274    ///     delete the evidence before anyone knew to look. GDPR Art. 33's clock starts at awareness,
 275    ///     which a log that had already expired never contributed to.
 276    /// </remarks>
 5101277    public TimeSpan LogRetention { get; init; } = DefaultLogRetention;
 278
 279    /// <summary>
 280    ///     How long a scope owner invitation is kept after it ended or expired, before the purge removes it
 281    ///     (FR-SC-18). Long enough for an owner to see who declined or let an invitation lapse, and short:
 282    ///     an ended invitation does nothing. Fixed by the Data Retention Schedule, so no variable sets it.
 283    /// </summary>
 3140284    public TimeSpan ScopeInvitationRetention { get; init; } = DefaultScopeInvitationRetention;
 285
 286    /// <summary>Whether security signal detection is registered at start-up (NFR-26).</summary>
 5101287    public bool SecurityMonitoringEnabled { get; init; } = true;
 288
 289    /// <summary>
 290    ///     How far back each signal looks. A signal is about a rate, not a total: twenty refusals
 291    ///     over a year is somebody who forgets their password, and twenty in a quarter of an hour is
 292    ///     somebody trying things.
 293    /// </summary>
 6102294    public TimeSpan MonitoringWindow { get; init; } = DefaultMonitoringWindow;
 295
 296    /// <summary>Refusals by one actor within the window before a signal is raised.</summary>
 5128297    public int RefusalThreshold { get; init; } = DefaultRefusalThreshold;
 298
 299    /// <summary>Accounts locked out at once before a signal is raised.</summary>
 5113300    public int LockoutThreshold { get; init; } = DefaultLockoutThreshold;
 301
 302    /// <summary>
 303    ///     Names the variables whose values were unusable and fell back to a default, so the caller
 304    ///     can say so in the log. Empty when every variable was absent or valid — an absent variable
 305    ///     is not a fallback, it is the documented default being chosen.
 306    /// </summary>
 5125307    public IReadOnlyCollection<string> InvalidVariables { get; init; } = [];
 308
 309    /// <summary>Reads the retention settings from the current process environment.</summary>
 310    public static DataRetentionOptions FromEnvironment()
 311    {
 1982312        var invalid = new List<string>();
 313
 1982314        return new DataRetentionOptions
 1982315        {
 1982316            SingleUseTokenGrace = ReadBoundedTimeSpan(
 1982317                SingleUseTokenGraceDaysVariable, DefaultSingleUseTokenGrace, TimeSpan.FromDays,
 1982318                minimum: 0, MaximumGraceDays, invalid),
 1982319            PurgeInterval = ReadBoundedTimeSpan(
 1982320                PurgeIntervalMinutesVariable, DefaultPurgeInterval, TimeSpan.FromMinutes,
 1982321                MinimumIntervalMinutes, MaximumIntervalMinutes, invalid),
 1982322            PurgeBatchSize = ReadPositiveInt(PurgeBatchSizeVariable, DefaultPurgeBatchSize, invalid),
 1982323            MaxBatchesPerTick = ReadPositiveInt(MaxBatchesPerTickVariable, DefaultMaxBatchesPerTick, invalid),
 1982324            PurgeEnabled = ReadBool(PurgeEnabledVariable, defaultValue: true, invalid),
 1982325            SubjectErasureDeadline = ReadBoundedTimeSpan(
 1982326                SubjectErasureDeadlineDaysVariable, DefaultSubjectErasureDeadline, TimeSpan.FromDays,
 1982327                minimum: 0, MaximumErasureDeadlineDays, invalid),
 1982328            AdministrativeDeletionWindow = ReadBoundedTimeSpan(
 1982329                AdministrativeDeletionWindowDaysVariable, DefaultAdministrativeDeletionWindow,
 1982330                TimeSpan.FromDays, minimum: 0, MaximumDeletionWindowDays, invalid),
 1982331            AnonymisationEnabled = ReadBool(AnonymisationEnabledVariable, defaultValue: true, invalid),
 1982332            AuditActorRetention = ReadBoundedTimeSpan(
 1982333                AuditActorRetentionDaysVariable, DefaultAuditActorRetention, TimeSpan.FromDays,
 1982334                MinimumAuditActorRetentionDays, MaximumAuditActorRetentionDays, invalid),
 1982335            AuditPseudonymisationEnabled = ReadBool(
 1982336                AuditPseudonymisationEnabledVariable, defaultValue: true, invalid),
 1982337            LogRetention = ReadBoundedTimeSpan(
 1982338                LogRetentionDaysVariable, DefaultLogRetention, TimeSpan.FromDays,
 1982339                MinimumLogRetentionDays, MaximumLogRetentionDays, invalid),
 1982340            SecurityMonitoringEnabled = ReadBool(
 1982341                SecurityMonitoringEnabledVariable, defaultValue: true, invalid),
 1982342            MonitoringWindow = ReadBoundedTimeSpan(
 1982343                MonitoringWindowMinutesVariable, DefaultMonitoringWindow, TimeSpan.FromMinutes,
 1982344                MinimumMonitoringWindowMinutes, MaximumMonitoringWindowMinutes, invalid),
 1982345            RefusalThreshold = ReadPositiveInt(RefusalThresholdVariable, DefaultRefusalThreshold, invalid),
 1982346            LockoutThreshold = ReadPositiveInt(LockoutThresholdVariable, DefaultLockoutThreshold, invalid),
 1982347            InvalidVariables = invalid
 1982348        };
 349    }
 350
 351    /// <summary>
 352    ///     Reads a period expressed in <paramref name="unit" />, accepting the raw number only when
 353    ///     it is positive, at least <paramref name="minimum" />, and no greater than
 354    ///     <paramref name="maximum" /> — both given in the variable's own unit, days or minutes. A
 355    ///     <paramref name="minimum" /> of zero therefore means "more than 0", and any other is
 356    ///     inclusive, which is how the Data Retention Schedule states each range ("1 to 10080",
 357    ///     "30 to 3650"). It used to be exclusive throughout, so the documented lower bound of every
 358    ///     range was itself refused.
 359    /// </summary>
 360    /// <remarks>
 361    ///     The bounds are checked on the number, before it is converted, and that ordering is the
 362    ///     point: <see cref="TimeSpan.FromDays" /> throws on a large enough <c>double</c>, so a
 363    ///     check performed on the converted value would never run. An exception here would fail
 364    ///     start-up, which is the one outcome this type exists to avoid.
 365    /// </remarks>
 366    private static TimeSpan ReadBoundedTimeSpan(
 367        string variable,
 368        TimeSpan defaultValue,
 369        Func<double, TimeSpan> unit,
 370        double minimum,
 371        double maximum,
 372        List<string> invalid)
 373    {
 13874374        var raw = Environment.GetEnvironmentVariable(variable);
 375
 13874376        if (string.IsNullOrWhiteSpace(raw))
 377        {
 13846378            return defaultValue;
 379        }
 380
 381        // A zero or negative period would purge rows the moment they expire, or run continuously;
 382        // both are mistakes rather than policies.
 28383        if (!double.TryParse(raw, out var value) || value <= 0 || value < minimum || value > maximum)
 384        {
 21385            invalid.Add(variable);
 386
 21387            return defaultValue;
 388        }
 389
 7390        return unit(value);
 391    }
 392
 393    private static int ReadPositiveInt(string variable, int defaultValue, List<string> invalid)
 394    {
 7928395        var raw = Environment.GetEnvironmentVariable(variable);
 396
 7928397        if (string.IsNullOrWhiteSpace(raw))
 398        {
 7920399            return defaultValue;
 400        }
 401
 8402        if (!int.TryParse(raw, out var value) || value <= 0)
 403        {
 6404            invalid.Add(variable);
 405
 6406            return defaultValue;
 407        }
 408
 2409        return value;
 410    }
 411
 412    private static bool ReadBool(string variable, bool defaultValue, List<string> invalid)
 413    {
 7928414        var raw = Environment.GetEnvironmentVariable(variable);
 415
 7928416        if (string.IsNullOrWhiteSpace(raw))
 417        {
 7926418            return defaultValue;
 419        }
 420
 2421        if (!bool.TryParse(raw, out var value))
 422        {
 1423            invalid.Add(variable);
 424
 1425            return defaultValue;
 426        }
 427
 1428        return value;
 429    }
 430}