diff --git a/README.md b/README.md index 7c1bfac7..1224cead 100644 --- a/README.md +++ b/README.md @@ -399,6 +399,26 @@ yarn start users migrate \ } ``` +### Rename username + +DHIS2 does not support renaming usernames directly. While it is possible to update usernames through the API, this only modifies the main table. References in other tables, which rely on the hardcoded username (not `userinfoid` or `userinfo.uid`), will remain unchanged. + +To fully rename a username across all references, you need to execute a SQL script. Start by generating the script using the following command: + +```bash + yarn start users rename-username \ + --mapping=user1old:user1new,user2old:user2new \ + [--dry-run] --output=rename.sql +``` + +And then run the generated SQL script in your database to perform the actual renaming: + +```bash +psql -U dhis dhis2 -f rename.sql +``` + +Replace `dhis` with your database username and `dhis2` with your database name if they differ. Ensure you have a backup of the database before applying the changes. + ## User monitoring ### Users Permissions Fixer and 2FA Reporter diff --git a/package.json b/package.json index 92ce2ea7..0a6fba41 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "loglevel": "^1.8.0", "luxon": "3.4.2", "nodemailer": "^6.7.5", + "psqlformat": "^1.21.0", "purify-ts": "2.0.1", "random-seed": "^0.3.0", "simple-node-logger": "^21.8.12", diff --git a/src/data/UsernameRenameSqlRepository.ts b/src/data/UsernameRenameSqlRepository.ts new file mode 100644 index 00000000..9f5dca4d --- /dev/null +++ b/src/data/UsernameRenameSqlRepository.ts @@ -0,0 +1,63 @@ +import { Path } from "domain/entities/Base"; +import { UsernameRename } from "domain/entities/UsernameRename"; +import path from "path"; +import fs from "fs"; +import { UsernameRenameRepository } from "domain/repositories/UsernameRenameRepository"; +import logger from "utils/log"; +import _ from "lodash"; +import * as psqlformat from "psqlformat"; + +export class UsernameRenameSqlRepository implements UsernameRenameRepository { + constructor(private sqlFile: Path) {} + + async run(mapping: UsernameRename[], options: { dryRun: boolean }): Promise { + if (_.isEmpty(mapping)) { + logger.warn("No usernames to rename"); + return; + } + + logger.info(`Mapping: ${JSON.stringify(mapping)}`); + const sqlMapping = getSqlForTemporalMappingTable(mapping); + const sqlRename = getRenamingSql(); + + const fullSql = [ + sqlMapping, // + "BEGIN;", + sqlRename, + options.dryRun ? "ROLLBACK;" : "COMMIT;", + ].join("\n"); + + const formattedSql = formatSql(fullSql); + logger.info(`Writing SQL: ${this.sqlFile}`); + fs.writeFileSync(this.sqlFile, formattedSql + "\n"); + } +} + +function getRenamingSql() { + const sqlPath = path.join(__dirname, "./sql", "rename-usernames.sql"); + logger.debug(`Template SQL: ${sqlPath}`); + const sqlRename = fs.readFileSync(sqlPath, "utf8"); + return sqlRename; +} + +function getSqlForTemporalMappingTable(mapping: UsernameRename[]) { + return ` + CREATE TEMP TABLE + username_mapping (old_username TEXT, new_username TEXT); + INSERT INTO + username_mapping (old_username, new_username) + VALUES + ${mapping.map(x => `('${x.from}', '${x.to}')`).join(",\n")} + ; + `; +} + +function formatSql(fullSql: string) { + return psqlformat.formatSql(fullSql, { + commaStart: false, + commaEnd: true, + commaBreak: false, + formatType: true, + noSpaceFunction: true, + }); +} diff --git a/src/data/sql/rename-usernames.sql b/src/data/sql/rename-usernames.sql new file mode 100644 index 00000000..2bcb309c --- /dev/null +++ b/src/data/sql/rename-usernames.sql @@ -0,0 +1,143 @@ +-- Functions +-- +-- Rename plain string username in 'table_name.column_name' +CREATE OR REPLACE FUNCTION _update_username_string(table_name TEXT, column_name TEXT) RETURNS VOID AS $$ +DECLARE rows_updated INTEGER; +BEGIN EXECUTE format( + 'UPDATE %I + SET %I = username_mapping.new_username + FROM username_mapping + WHERE %I.%I = username_mapping.old_username', + table_name, + column_name, + table_name, + column_name +); +GET DIAGNOSTICS rows_updated = ROW_COUNT; +RAISE NOTICE '%.%: %', +table_name, +column_name, +rows_updated; +END; +$$ LANGUAGE plpgsql; +-- +-- Rename plain string username in aggregated data values if dataElement.valuetype is 'USERNAME' +CREATE OR REPLACE FUNCTION _update_username_values() RETURNS VOID AS $$ +DECLARE rows_updated INTEGER; +BEGIN +UPDATE datavalue dv +SET value = username_mapping.new_username +FROM username_mapping, + dataelement de +WHERE dv.dataelementid = de.dataelementid + AND de.valuetype = 'USERNAME' + AND dv.value = username_mapping.old_username; +GET DIAGNOSTICS rows_updated = ROW_COUNT; +RAISE NOTICE 'datavalue.value[dataelement.type="USERNAME"]: %', +rows_updated; +END; +$$ LANGUAGE plpgsql; +--- +-- Update string username in events data values if dataElement.valuetype is 'USERNAME' +CREATE OR REPLACE FUNCTION _update_event_datavalues() RETURNS VOID AS $$ +DECLARE rows_updated INTEGER; +BEGIN +UPDATE programstageinstance +SET eventdatavalues = jsonb_set( + eventdatavalues, + array [de.uid, 'value'], + to_jsonb(um.new_username::text) + ) +FROM dataelement de, + username_mapping um +WHERE de.valuetype = 'USERNAME' + AND programstageinstance.eventdatavalues->de.uid->>'value' = um.old_username; +GET DIAGNOSTICS rows_updated = ROW_COUNT; +RAISE NOTICE 'programstageinstance.eventdatavalues[dataelement.type="USERNAME"]: %', +rows_updated; +END; +$$ LANGUAGE plpgsql; +-- +-- Update tracked entity attributes values if the dataElement valuetype is 'USERNAME' +CREATE OR REPLACE FUNCTION _update_tracked_entity_attributes_values() RETURNS VOID AS $$ +DECLARE rows_updated INTEGER; +BEGIN WITH updated_values AS ( + SELECT teav.trackedentityattributeid, + username_mapping.new_username + FROM trackedentityattributevalue teav + JOIN trackedentityattribute tea ON teav.trackedentityattributeid = tea.trackedentityattributeid + JOIN username_mapping ON teav.value = username_mapping.old_username + WHERE tea.valuetype = 'USERNAME' +) +UPDATE trackedentityattributevalue teav +SET value = updated_values.new_username +FROM updated_values +WHERE teav.trackedentityattributeid = updated_values.trackedentityattributeid; +GET DIAGNOSTICS rows_updated = ROW_COUNT; +RAISE NOTICE 'trackedentityattributevalue.value[trackedentityattribute.type="USERNAME"]: %', +rows_updated; +END; +$$ LANGUAGE plpgsql; +-- +-- Rename username in JSONB table_name.column_name (key: "username") +CREATE OR REPLACE FUNCTION _update_username_jsonb(table_name TEXT, column_name TEXT) RETURNS VOID AS $$ +DECLARE rows_updated INTEGER; +BEGIN EXECUTE format( + 'UPDATE %I + SET %I = jsonb_set(%I, ''{username}'', to_jsonb(username_mapping.new_username::TEXT), true) + FROM username_mapping + WHERE %I->>''username'' = username_mapping.old_username', + table_name, + column_name, + column_name, + column_name +); +GET DIAGNOSTICS rows_updated = ROW_COUNT; +RAISE NOTICE '%.%[username]: %', +table_name, +column_name, +rows_updated; +END; +$$ LANGUAGE plpgsql; +-- +-- Actions +-- +SELECT _update_username_string('audit', 'createdby'); +SELECT _update_username_string('completedatasetregistration', 'lastupdatedby'); +SELECT _update_username_string('completedatasetregistration', 'storedby'); +SELECT _update_username_string('datastatisticsevent', 'username'); +SELECT _update_username_string('datavalue', 'storedby'); +SELECT _update_username_string('datavalueaudit', 'modifiedby'); +SELECT _update_username_string('deletedobject', 'deleted_by'); +SELECT _update_username_string('externalnotificationlogentry', 'triggerby'); +SELECT _update_username_string('potentialduplicate', 'createdbyusername'); +SELECT _update_username_string('potentialduplicate', 'lastupdatebyusername'); +SELECT _update_username_string('programinstance', 'completedby'); +SELECT _update_username_jsonb('programinstance', 'createdbyuserinfo'); +SELECT _update_username_jsonb('programinstance', 'lastupdatedbyuserinfo'); +SELECT _update_username_string('programinstance', 'storedby'); +SELECT _update_username_string('programownershiphistory', 'createdby'); +SELECT _update_username_string('programstageinstance', 'completedby'); +SELECT _update_username_jsonb('programstageinstance', 'createdbyuserinfo'); +SELECT _update_username_jsonb('programstageinstance', 'lastupdatedbyuserinfo'); +SELECT _update_username_string('programstageinstance', 'storedby'); +SELECT _update_username_string('programtempownershipaudit', 'accessedby'); +SELECT _update_username_string('trackedentityattributevalue', 'storedby'); +SELECT _update_username_string('trackedentityattributevalueaudit', 'modifiedby'); +SELECT _update_username_string('trackedentitydatavalueaudit', 'modifiedby'); +SELECT _update_username_jsonb('trackedentityinstance', 'createdbyuserinfo'); +SELECT _update_username_jsonb('trackedentityinstance', 'lastupdatedbyuserinfo'); +SELECT _update_username_string('trackedentityinstance', 'storedby'); +SELECT _update_username_string('trackedentityinstanceaudit', 'accessedby'); +SELECT _update_username_string('trackedentityprogramowner', 'createdby'); +SELECT _update_username_string('userinfo', 'username'); +SELECT _update_username_values(); +SELECT _update_event_datavalues(); +SELECT _update_tracked_entity_attributes_values(); +-- Delete all functions +SELECT pg_catalog.pg_get_function_identity_arguments(p.oid) AS arguments, + p.proname AS function_name, + n.nspname AS schema_name +FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid +WHERE p.proname LIKE '_update_%'; diff --git a/src/domain/entities/UsernameRename.ts b/src/domain/entities/UsernameRename.ts new file mode 100644 index 00000000..02fcfefe --- /dev/null +++ b/src/domain/entities/UsernameRename.ts @@ -0,0 +1,4 @@ +export type UsernameRename = { + from: string; + to: string; +}; diff --git a/src/domain/repositories/UsernameRenameRepository.ts b/src/domain/repositories/UsernameRenameRepository.ts new file mode 100644 index 00000000..250bdb17 --- /dev/null +++ b/src/domain/repositories/UsernameRenameRepository.ts @@ -0,0 +1,5 @@ +import { UsernameRename } from "domain/entities/UsernameRename"; + +export interface UsernameRenameRepository { + run(mapping: UsernameRename[], options: { dryRun: boolean }): Promise; +} diff --git a/src/domain/usecases/RenameUsernamesUseCase.ts b/src/domain/usecases/RenameUsernamesUseCase.ts new file mode 100644 index 00000000..6bac2626 --- /dev/null +++ b/src/domain/usecases/RenameUsernamesUseCase.ts @@ -0,0 +1,10 @@ +import { UsernameRename } from "domain/entities/UsernameRename"; +import { UsernameRenameRepository } from "domain/repositories/UsernameRenameRepository"; + +export class RenameUsernameUseCase { + constructor(private repository: UsernameRenameRepository) {} + + async execute(mapping: UsernameRename[], options: { dryRun: boolean }): Promise { + return this.repository.run(mapping, options); + } +} diff --git a/src/domain/usecases/user-monitoring/two-factor-monitoring/__tests__/TwoFactorTest.data.ts b/src/domain/usecases/user-monitoring/two-factor-monitoring/__tests__/TwoFactorTest.data.ts index 45ef694c..78d08c76 100644 --- a/src/domain/usecases/user-monitoring/two-factor-monitoring/__tests__/TwoFactorTest.data.ts +++ b/src/domain/usecases/user-monitoring/two-factor-monitoring/__tests__/TwoFactorTest.data.ts @@ -1,4 +1,3 @@ -import { UserMonitoringProgramMetadata } from "domain/entities/user-monitoring/common/UserMonitoringProgramMetadata"; import { TwoFactorUser } from "domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUser"; import { TwoFactorUserOptions } from "domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUserOptions"; diff --git a/src/scripts/commands/users.ts b/src/scripts/commands/users.ts index 16c603c2..4ca18e32 100644 --- a/src/scripts/commands/users.ts +++ b/src/scripts/commands/users.ts @@ -5,6 +5,10 @@ import { StringsSeparatedByCommas, getApiUrlOption, getD2Api } from "scripts/com import { MigrateUserNameUseCase } from "domain/usecases/MigrateUserNameUseCase"; import { NotificationsEmailRepository } from "data/NotificationsEmailRepository"; import logger from "utils/log"; +import { RenameUsernameUseCase } from "domain/usecases/RenameUsernamesUseCase"; +import { UsernameRenameSqlRepository } from "data/UsernameRenameSqlRepository"; +import { Maybe } from "utils/ts-utils"; +import { UsernameRename } from "domain/entities/UsernameRename"; export function getCommand() { const migrateUser = command({ @@ -99,8 +103,48 @@ export function getCommand() { }, }); + const renameUsername = command({ + name: "Rename username", + description: "Rename occurences of a username in the DHIS2 database", + args: { + mapping: option({ + type: string, + long: "mapping", + description: "oldusername:newusername,...", + }), + dryRun: flag({ + type: boolean, + long: "dry-run", + description: "The SQL will be executed within a rollback transaction", + }), + sqlFilePath: option({ + type: string, + long: "output", + description: "Path to the output file (SQL)", + }), + }, + handler: async args => { + const mapping = getMappingFromCommaSeparatedKeyValues(args.mapping); + const repository = new UsernameRenameSqlRepository(args.sqlFilePath); + await new RenameUsernameUseCase(repository).execute(mapping, { dryRun: args.dryRun }); + }, + }); + return subcommands({ name: "users", - cmds: { migrate: migrateUser }, + cmds: { + migrate: migrateUser, + "rename-username": renameUsername, + }, }); } + +function getMappingFromCommaSeparatedKeyValues(strMapping: string) { + return _(strMapping.split(",")) + .map((mapping: string): Maybe => { + const [from, to] = mapping.split(":"); + return from && to ? { from, to } : undefined; + }) + .compact() + .value(); +} diff --git a/yarn.lock b/yarn.lock index 8b255e53..cc377b98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1217,6 +1217,15 @@ cliui@^5.0.0: strip-ansi "^5.2.0" wrap-ansi "^5.1.0" +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + cliui@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" @@ -2525,7 +2534,7 @@ globals@^13.6.0, globals@^13.9.0: dependencies: type-fest "^0.20.2" -globby@^11.0.4: +globby@^11.0.1, globby@^11.0.4: version "11.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -3876,6 +3885,14 @@ psl@^1.1.33: resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== +psqlformat@^1.21.0: + version "1.21.0" + resolved "https://registry.yarnpkg.com/psqlformat/-/psqlformat-1.21.0.tgz#59c6422833754248b92b2c57d23f2013aefa59e3" + integrity sha512-A6k6Bvo9WT88xnsS9WDLjIhfGQFwUUkaHRLUUyMOa/6LbEB/8J6GjXHGmXr/n8O0BwKVxMJFSuX4hAc9tJaRDg== + dependencies: + globby "^11.0.1" + yargs "^16.0.3" + pstree.remy@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" @@ -5354,6 +5371,11 @@ yargs-parser@^15.0.1: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" @@ -5376,6 +5398,19 @@ yargs@^14.0.0: y18n "^4.0.0" yargs-parser "^15.0.1" +yargs@^16.0.3: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + yargs@^17.5.1: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"