diff --git a/README.md b/README.md index 78cde7a..99f23a3 100644 --- a/README.md +++ b/README.md @@ -617,6 +617,22 @@ An exceptionGroup list containing groups whose users should be excluded from val An optional --disable-users CLI flag that, when passed, will disable users identified as invalid due to missing or disabled 2FA. +An optional --filtered-by-month (-f) CLI flag that, when passed, restricts disable candidates to users whose account was created at least `disableAfterMonths` months ago (configured in the datastore). + +An optional --created-date-overrides-file CLI flag that accepts a path to a JSON file. For each user listed in the file, their real DHIS2 creation date is replaced by the `createdDate` field in the file when evaluating the month-based filter (`--filtered-by-month`). This allows treating specific users as if they were created on a given date. **Only has effect when --filtered-by-month is also set.** + +The overrides file format: + +```json +{ + "createdDate": "2026-06-10", + "users": [ + { "id": "uid1" }, + { "id": "uid2", "username": "pepito" } + ] +} +``` + The datastore must contain: ```json diff --git a/src/domain/entities/DateTime.ts b/src/domain/entities/DateTime.ts index 24c1de7..2205f1f 100644 --- a/src/domain/entities/DateTime.ts +++ b/src/domain/entities/DateTime.ts @@ -30,3 +30,8 @@ export function getMonthsDiff(startDate: string, endDate: string): number { const diff = end.diff(start, "months").months; return Math.floor(diff); } + +/* Return true if the given string is a valid ISO 8601 date/datetime (as parsed by luxon). */ +export function isValidIso8601(date: string): boolean { + return DateTime.fromISO(date).isValid; +} diff --git a/src/domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUser.ts b/src/domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUser.ts index f01ca93..7344fcf 100644 --- a/src/domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUser.ts +++ b/src/domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUser.ts @@ -12,6 +12,11 @@ export interface TwoFactorUser { created: Timestamp; } +export interface UserDateOverride { + createdDate: string; + users: Array<{ id: string; username?: string }>; +} + export function filterByCreationDate( users: TwoFactorUser[], disableAfterMonths: number | undefined @@ -28,3 +33,14 @@ export function filterByCreationDate( return monthsDiff >= disableAfterMonths; }); } + +export function applyUserDateOverrides( + users: TwoFactorUser[], + overrides: UserDateOverride | undefined +): TwoFactorUser[] { + if (!overrides) return users; + const overrideIds = new Set(overrides.users.map(u => u.id)); + return users.map(user => + overrideIds.has(user.id) ? { ...user, created: overrides.createdDate } : user + ); +} diff --git a/src/domain/usecases/user-monitoring/two-factor-monitoring/RunTwoFactorReportUseCase.ts b/src/domain/usecases/user-monitoring/two-factor-monitoring/RunTwoFactorReportUseCase.ts index be0c65e..10b8255 100644 --- a/src/domain/usecases/user-monitoring/two-factor-monitoring/RunTwoFactorReportUseCase.ts +++ b/src/domain/usecases/user-monitoring/two-factor-monitoring/RunTwoFactorReportUseCase.ts @@ -6,7 +6,8 @@ import { TwoFactorReportRepository } from "domain/repositories/user-monitoring/t import { TwoFactorConfigRepository } from "domain/repositories/user-monitoring/two-factor-monitoring/TwoFactorConfigRepository"; import { UserMonitoringProgramRepository } from "domain/repositories/user-monitoring/common/UserMonitoringProgramRepository"; import { - TwoFactorUser, + UserDateOverride, + applyUserDateOverrides, filterByCreationDate, } from "domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUser"; @@ -82,7 +83,10 @@ export class RunTwoFactorReportUseCase { }); const filteredInvalidTwoFactorUsers = filteredByMonth - ? filterByCreationDate(invalidTwoFactorUsers, options.disableAfterMonths) + ? filterByCreationDate( + applyUserDateOverrides(invalidTwoFactorUsers, twoFactorUseCaseOption.userDateOverrides), + options.disableAfterMonths + ) : invalidTwoFactorUsers; const report: TwoFactorUserReport = { @@ -146,4 +150,5 @@ export class RunTwoFactorReportUseCase { interface TwoFactorUseCaseOptions { shouldDisableInvalidUsers: boolean; filteredByMonth: boolean; + userDateOverrides?: UserDateOverride; } diff --git a/src/domain/usecases/user-monitoring/two-factor-monitoring/__tests__/TwoFactorReportUseCase.specs.ts b/src/domain/usecases/user-monitoring/two-factor-monitoring/__tests__/TwoFactorReportUseCase.specs.ts index 6869a5d..fa9d774 100644 --- a/src/domain/usecases/user-monitoring/two-factor-monitoring/__tests__/TwoFactorReportUseCase.specs.ts +++ b/src/domain/usecases/user-monitoring/two-factor-monitoring/__tests__/TwoFactorReportUseCase.specs.ts @@ -18,7 +18,12 @@ import { import { TwoFactorUserD2Repository } from "data/user-monitoring/two-factor-monitoring/TwoFactorUserD2Repository"; import { TwoFactorReportD2Repository } from "data/user-monitoring/two-factor-monitoring/TwoFactorReportD2Repository"; import { UserMonitoringProgramD2Repository } from "data/user-monitoring/common/UserMonitoringProgramD2Repository"; -import { TwoFactorUser, filterByCreationDate } from "domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUser"; +import { + TwoFactorUser, + UserDateOverride, + applyUserDateOverrides, + filterByCreationDate, +} from "domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUser"; import { TwoFactorUserOptions } from "domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUserOptions"; const TWO_FACTOR_GROUP_ID = "2FA"; const WHO_ACCOUNT_GROUP_ID = "WHO"; @@ -417,6 +422,107 @@ describe("TwoFactorReportUseCase", () => { }); }); +describe("TwoFactorReportUseCase with userDateOverrides", () => { + const now = new Date(); + const oldDate = new Date(now.getFullYear() - 1, now.getMonth(), 1).toISOString(); + const recentDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString(); + + const configWithMonths: TwoFactorUserOptions = { ...defaultConfig, disableAfterMonths: 6 }; + + function makeInvalid2FAUser(id: string, created: string): TwoFactorUser { + return { + ...baseUser, + id, + username: id, + created, + userGroups: [{ id: TWO_FACTOR_GROUP_ID, name: "TwoFactorGroup" }], + }; + } + + it("Should protect an overridden user with a recent JSON date from being disabled", async () => { + const oldUser = makeInvalid2FAUser("old-user", oldDate); + const overrides: UserDateOverride = { + createdDate: recentDate, + users: [{ id: "old-user", username: "old-user" }], + }; + const useCase = createUseCase({ users: [oldUser], config: configWithMonths }); + + const result = await useCase.execute({ + shouldDisableInvalidUsers: true, + filteredByMonth: true, + userDateOverrides: overrides, + }); + + expect(result.report.invalidTwoFAList).toEqual([]); + expect(result.disableUsersMessage).contain("no invalid users found"); + }); + + it("Should still disable an overridden user whose JSON date is old enough", async () => { + const recentUser = makeInvalid2FAUser("recent-user", recentDate); + const overrides: UserDateOverride = { + createdDate: oldDate, + users: [{ id: "recent-user", username: "recent-user" }], + }; + const useCase = createUseCase({ users: [recentUser], config: configWithMonths }); + + const result = await useCase.execute({ + shouldDisableInvalidUsers: true, + filteredByMonth: true, + userDateOverrides: overrides, + }); + + expect(result.report.invalidTwoFAList).toEqual([{ id: "recent-user", name: "recent-user" }]); + expect(result.disableUsersMessage).contain("Disabled users action is enabled and executed."); + }); + + it("Should not apply overrides when filteredByMonth is false (real date is used)", async () => { + const recentUser = makeInvalid2FAUser("recent-user", recentDate); + const overrides: UserDateOverride = { + createdDate: oldDate, + users: [{ id: "recent-user", username: "recent-user" }], + }; + const useCase = createUseCase({ users: [recentUser], config: configWithMonths }); + + const result = await useCase.execute({ + shouldDisableInvalidUsers: false, + filteredByMonth: false, + userDateOverrides: overrides, + }); + + expect(result.report.invalidTwoFAList).toEqual([{ id: "recent-user", name: "recent-user" }]); + }); + + it("Should not affect users whose id is not in the overrides list", async () => { + const oldUser = makeInvalid2FAUser("old-user", oldDate); + const overrides: UserDateOverride = { + createdDate: recentDate, + users: [{ id: "different-user", username: "different-user" }], + }; + const useCase = createUseCase({ users: [oldUser], config: configWithMonths }); + + const result = await useCase.execute({ + shouldDisableInvalidUsers: false, + filteredByMonth: true, + userDateOverrides: overrides, + }); + + expect(result.report.invalidTwoFAList).toEqual([{ id: "old-user", name: "old-user" }]); + }); + + it("Should behave identically to no override when userDateOverrides is undefined", async () => { + const oldUser = makeInvalid2FAUser("old-user", oldDate); + const useCase = createUseCase({ users: [oldUser], config: configWithMonths }); + + const result = await useCase.execute({ + shouldDisableInvalidUsers: false, + filteredByMonth: true, + userDateOverrides: undefined, + }); + + expect(result.report.invalidTwoFAList).toEqual([{ id: "old-user", name: "old-user" }]); + }); +}); + describe("filterByCreationDate", () => { const makeUser = (created: string): TwoFactorUser => ({ ...baseUser, @@ -458,14 +564,61 @@ describe("filterByCreationDate", () => { }); }); +describe("applyUserDateOverrides", () => { + const baseUser2FA: TwoFactorUser = { + ...baseUser, + id: "u1", + userGroups: [{ id: TWO_FACTOR_GROUP_ID, name: "" }], + created: "2020-01-01T00:00:00.000", + }; + + it("Should return users unchanged when overrides is undefined", () => { + const result = applyUserDateOverrides([baseUser2FA], undefined); + expect(result).toEqual([baseUser2FA]); + }); + + it("Should replace created date for a user in the overrides list", () => { + const overrides: UserDateOverride = { + createdDate: "2026-06-10", + users: [{ id: "u1", username: "testuser" }], + }; + const result = applyUserDateOverrides([baseUser2FA], overrides); + expect(result[0]?.created).toBe("2026-06-10"); + }); + + it("Should not modify a user whose id is not in the overrides list", () => { + const overrides: UserDateOverride = { + createdDate: "2026-06-10", + users: [{ id: "other-id", username: "other" }], + }; + const result = applyUserDateOverrides([baseUser2FA], overrides); + expect(result[0]?.created).toBe("2020-01-01T00:00:00.000"); + }); + + it("Should handle an empty overrides user list without modifying any user", () => { + const overrides: UserDateOverride = { createdDate: "2026-06-10", users: [] }; + const result = applyUserDateOverrides([baseUser2FA], overrides); + expect(result[0]?.created).toBe("2020-01-01T00:00:00.000"); + }); + + it("Should apply override only to matching user when multiple users are present", () => { + const user2: TwoFactorUser = { ...baseUser2FA, id: "u2", created: "2019-01-01T00:00:00.000" }; + const overrides: UserDateOverride = { + createdDate: "2026-06-10", + users: [{ id: "u1", username: "testuser" }], + }; + const result = applyUserDateOverrides([baseUser2FA, user2], overrides); + expect(result[0]?.created).toBe("2026-06-10"); + expect(result[1]?.created).toBe("2019-01-01T00:00:00.000"); + }); +}); + function createUseCase({ users, config = alternativeConfig, }: { users: TwoFactorUser[]; config?: TwoFactorUserOptions; - programId?: string; - programName?: string; }): RunTwoFactorReportUseCase { const excludedGroupIds = config.exceptionGroup?.map(g => g.id) ?? []; diff --git a/src/scripts/commands/__tests__/userMonitoring.specs.ts b/src/scripts/commands/__tests__/userMonitoring.specs.ts new file mode 100644 index 0000000..796c39f --- /dev/null +++ b/src/scripts/commands/__tests__/userMonitoring.specs.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { parseUserDateOverridesFile } from "../userMonitoring"; + +describe("parseUserDateOverridesFile", () => { + let tmpFile: string; + + beforeEach(() => { + tmpFile = path.join(os.tmpdir(), `test-overrides-${Date.now()}.json`); + }); + + afterEach(() => { + if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); + }); + + it("Should parse a valid overrides file", () => { + fs.writeFileSync( + tmpFile, + JSON.stringify({ createdDate: "2026-06-10", users: [{ id: "uid1" }, { id: "uid2", username: "pepito" }] }) + ); + const result = parseUserDateOverridesFile(tmpFile); + expect(result.createdDate).toBe("2026-06-10"); + expect(result.users).toHaveLength(2); + }); + + it("Should throw if the file does not contain valid JSON", () => { + fs.writeFileSync(tmpFile, "this is not json {{{"); + expect(() => parseUserDateOverridesFile(tmpFile)).toThrow( + `Could not parse ${tmpFile} as JSON. Make sure the file contains valid JSON.` + ); + }); + + it("Should throw if createdDate field is missing", () => { + fs.writeFileSync(tmpFile, JSON.stringify({ users: [{ id: "uid1" }] })); + expect(() => parseUserDateOverridesFile(tmpFile)).toThrow(`Invalid format in ${tmpFile}`); + }); + + it("Should throw if users field is missing", () => { + fs.writeFileSync(tmpFile, JSON.stringify({ createdDate: "2026-06-10" })); + expect(() => parseUserDateOverridesFile(tmpFile)).toThrow(`Invalid format in ${tmpFile}`); + }); + + it("Should throw if createdDate is not a valid date", () => { + fs.writeFileSync(tmpFile, JSON.stringify({ createdDate: "not-a-date", users: [] })); + expect(() => parseUserDateOverridesFile(tmpFile)).toThrow( + `Invalid createdDate "not-a-date" in ${tmpFile}: must be a valid ISO date (e.g. "2026-06-10").` + ); + }); + + it("Should throw if createdDate is a non-ISO date format (e.g. DD/MM/YYYY)", () => { + fs.writeFileSync(tmpFile, JSON.stringify({ createdDate: "10/06/2026", users: [] })); + expect(() => parseUserDateOverridesFile(tmpFile)).toThrow( + `Invalid createdDate "10/06/2026" in ${tmpFile}: must be a valid ISO date (e.g. "2026-06-10").` + ); + }); + + it("Should throw if the file does not exist", () => { + expect(() => parseUserDateOverridesFile("/nonexistent/path/file.json")).toThrow( + "Could not parse /nonexistent/path/file.json as JSON. Make sure the file contains valid JSON." + ); + }); +}); diff --git a/src/scripts/commands/userMonitoring.ts b/src/scripts/commands/userMonitoring.ts index 94a7d03..5987a0b 100644 --- a/src/scripts/commands/userMonitoring.ts +++ b/src/scripts/commands/userMonitoring.ts @@ -1,12 +1,14 @@ import fs from "fs"; import "json5/lib/register"; import * as Codec from "purify-ts"; -import { command, subcommands, option, string, boolean, flag } from "cmd-ts"; +import { command, subcommands, option, string, boolean, flag, optional } from "cmd-ts"; import { getD2ApiFromArgs } from "scripts/common"; import log from "utils/log"; import { RunTwoFactorReportUseCase } from "domain/usecases/user-monitoring/two-factor-monitoring/RunTwoFactorReportUseCase"; +import { UserDateOverride } from "domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUser"; +import { isValidIso8601 } from "domain/entities/DateTime"; import { TwoFactorConfigD2Repository } from "data/user-monitoring/two-factor-monitoring/TwoFactorConfigD2Repository"; import { PermissionFixerConfigD2Repository } from "data/user-monitoring/permission-fixer/PermissionFixerConfigD2Repository"; @@ -70,10 +72,26 @@ const run2FAReporterCmd = command({ description: "Filter users by creation date using disableAfterMonths from datastore config.", }), + createdDateOverridesFile: option({ + type: optional(string), + long: "created-date-overrides-file", + description: + "Path to a JSON file with user creation date overrides. Overrides the creation date used for the month-based filter for specific users. Only has effect when --filtered-by-month is set. Format: { \"createdDate\": \"YYYY-MM-DD\", \"users\": [{ \"id\": \"uid\", \"username\": \"name\" }] }", + }), }, handler: async args => { const api = getApiFromConfigFile(args.configFile); + const userDateOverrides = args.createdDateOverridesFile + ? parseUserDateOverridesFile(args.createdDateOverridesFile) + : undefined; + + if (userDateOverrides && !args.filteredByMonth) { + log.warn( + "--created-date-overrides-file has no effect without --filtered-by-month. Date overrides will be ignored." + ); + } + const usersRepository = new TwoFactorUserD2Repository(api); const externalConfigRepository = new TwoFactorConfigD2Repository(api); const userMonitoringReportRepository = new TwoFactorReportD2Repository(api); @@ -87,6 +105,7 @@ const run2FAReporterCmd = command({ ).execute({ shouldDisableInvalidUsers: args.disableusers, filteredByMonth: args.filteredByMonth, + userDateOverrides, }); log.info(JSON.stringify(response)); @@ -276,4 +295,24 @@ function getWebhookConfFromFile(configFile: string): MSTeamsWebhookOptions { }; } +export function parseUserDateOverridesFile(filePath: string): UserDateOverride { + let raw: UserDateOverride; + try { + raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as UserDateOverride; + } catch { + throw new Error(`Could not parse ${filePath} as JSON. Make sure the file contains valid JSON.`); + } + if (typeof raw.createdDate !== "string" || !Array.isArray(raw.users)) { + throw new Error( + `Invalid format in ${filePath}: expected { "createdDate": "YYYY-MM-DD", "users": [{ "id": "uid" }] }` + ); + } + if (!isValidIso8601(raw.createdDate)) { + throw new Error( + `Invalid createdDate "${raw.createdDate}" in ${filePath}: must be a valid ISO date (e.g. "2026-06-10").` + ); + } + return raw; +} + export type UserMonitoringAuth = { apiurl: string };