mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-09 00:18:00 +01:00
Add possibility to soft delete users (#2497)
Previously we hard deleted the users, but due to change requests and possibly other features in future, we really want to hard-link user table and have meaningful relationships. But this means, when user is deleted, all linked data is also deleted. **Workaround is to soft delete users and just clear users data and keep the relationships alive for audit logs.** This PR implements this feature.
This commit is contained in:
parent
9bf3e09d5a
commit
b071de6742
@ -28,6 +28,8 @@ const T = {
|
|||||||
PERMISSIONS: 'permissions',
|
PERMISSIONS: 'permissions',
|
||||||
PERMISSION_TYPES: 'permission_types',
|
PERMISSION_TYPES: 'permission_types',
|
||||||
CHANGE_REQUEST_SETTINGS: 'change_request_settings',
|
CHANGE_REQUEST_SETTINGS: 'change_request_settings',
|
||||||
|
PERSONAL_ACCESS_TOKENS: 'personal_access_tokens',
|
||||||
|
PUBLIC_SIGNUP_TOKENS_USER: 'public_signup_tokens_user',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IPermissionRow {
|
interface IPermissionRow {
|
||||||
@ -215,6 +217,30 @@ export class AccessStore implements IAccessStore {
|
|||||||
.delete();
|
.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async unlinkUserGroups(userId: number): Promise<void> {
|
||||||
|
return this.db(T.GROUP_USER)
|
||||||
|
.where({
|
||||||
|
user_id: userId,
|
||||||
|
})
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearUserPersonalAccessTokens(userId: number): Promise<void> {
|
||||||
|
return this.db(T.PERSONAL_ACCESS_TOKENS)
|
||||||
|
.where({
|
||||||
|
user_id: userId,
|
||||||
|
})
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearPublicSignupUserTokens(userId: number): Promise<void> {
|
||||||
|
return this.db(T.PUBLIC_SIGNUP_TOKENS_USER)
|
||||||
|
.where({
|
||||||
|
user_id: userId,
|
||||||
|
})
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
async getProjectUsersForRole(
|
async getProjectUsersForRole(
|
||||||
roleId: number,
|
roleId: number,
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
|
@ -77,7 +77,9 @@ class UserStore implements IUserStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, fields: IUserUpdateFields): Promise<User> {
|
async update(id: number, fields: IUserUpdateFields): Promise<User> {
|
||||||
await this.db(TABLE).where('id', id).update(mapUserToColumns(fields));
|
await this.activeUsers()
|
||||||
|
.where('id', id)
|
||||||
|
.update(mapUserToColumns(fields));
|
||||||
return this.get(id);
|
return this.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,7 +100,7 @@ class UserStore implements IUserStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildSelectUser(q: IUserLookup): any {
|
buildSelectUser(q: IUserLookup): any {
|
||||||
const query = this.db(TABLE);
|
const query = this.activeUsers();
|
||||||
if (q.id) {
|
if (q.id) {
|
||||||
return query.where('id', q.id);
|
return query.where('id', q.id);
|
||||||
}
|
}
|
||||||
@ -111,6 +113,10 @@ class UserStore implements IUserStore {
|
|||||||
throw new Error('Can only find users with id, username or email.');
|
throw new Error('Can only find users with id, username or email.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activeUsers(): any {
|
||||||
|
return this.db(TABLE).where('deleted_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
async hasUser(idQuery: IUserLookup): Promise<number | undefined> {
|
async hasUser(idQuery: IUserLookup): Promise<number | undefined> {
|
||||||
const query = this.buildSelectUser(idQuery);
|
const query = this.buildSelectUser(idQuery);
|
||||||
const item = await query.first('id');
|
const item = await query.first('id');
|
||||||
@ -118,14 +124,13 @@ class UserStore implements IUserStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAll(): Promise<User[]> {
|
async getAll(): Promise<User[]> {
|
||||||
const users = await this.db.select(USER_COLUMNS).from(TABLE);
|
const users = await this.activeUsers().select(USER_COLUMNS);
|
||||||
return users.map(rowToUser);
|
return users.map(rowToUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string): Promise<User[]> {
|
async search(query: string): Promise<User[]> {
|
||||||
const users = await this.db
|
const users = await this.activeUsers()
|
||||||
.select(USER_COLUMNS_PUBLIC)
|
.select(USER_COLUMNS_PUBLIC)
|
||||||
.from(TABLE)
|
|
||||||
.where('name', 'ILIKE', `%${query}%`)
|
.where('name', 'ILIKE', `%${query}%`)
|
||||||
.orWhere('username', 'ILIKE', `${query}%`)
|
.orWhere('username', 'ILIKE', `${query}%`)
|
||||||
.orWhere('email', 'ILIKE', `${query}%`);
|
.orWhere('email', 'ILIKE', `${query}%`);
|
||||||
@ -133,9 +138,8 @@ class UserStore implements IUserStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAllWithId(userIdList: number[]): Promise<User[]> {
|
async getAllWithId(userIdList: number[]): Promise<User[]> {
|
||||||
const users = await this.db
|
const users = await this.activeUsers()
|
||||||
.select(USER_COLUMNS_PUBLIC)
|
.select(USER_COLUMNS_PUBLIC)
|
||||||
.from(TABLE)
|
|
||||||
.whereIn('id', userIdList);
|
.whereIn('id', userIdList);
|
||||||
return users.map(rowToUser);
|
return users.map(rowToUser);
|
||||||
}
|
}
|
||||||
@ -146,11 +150,18 @@ class UserStore implements IUserStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: number): Promise<void> {
|
async delete(id: number): Promise<void> {
|
||||||
return this.db(TABLE).where({ id }).del();
|
return this.activeUsers()
|
||||||
|
.where({ id })
|
||||||
|
.update({
|
||||||
|
deleted_at: new Date(),
|
||||||
|
email: null,
|
||||||
|
username: null,
|
||||||
|
name: this.db.raw('name || ?', '(Deleted)'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPasswordHash(userId: number): Promise<string> {
|
async getPasswordHash(userId: number): Promise<string> {
|
||||||
const item = await this.db(TABLE)
|
const item = await this.activeUsers()
|
||||||
.where('id', userId)
|
.where('id', userId)
|
||||||
.first('password_hash');
|
.first('password_hash');
|
||||||
|
|
||||||
@ -162,7 +173,7 @@ class UserStore implements IUserStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setPasswordHash(userId: number, passwordHash: string): Promise<void> {
|
async setPasswordHash(userId: number, passwordHash: string): Promise<void> {
|
||||||
return this.db(TABLE).where('id', userId).update({
|
return this.activeUsers().where('id', userId).update({
|
||||||
password_hash: passwordHash,
|
password_hash: passwordHash,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -179,12 +190,11 @@ class UserStore implements IUserStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteAll(): Promise<void> {
|
async deleteAll(): Promise<void> {
|
||||||
await this.db(TABLE).del();
|
await this.activeUsers().del();
|
||||||
}
|
}
|
||||||
|
|
||||||
async count(): Promise<number> {
|
async count(): Promise<number> {
|
||||||
return this.db
|
return this.activeUsers()
|
||||||
.from(TABLE)
|
|
||||||
.count('*')
|
.count('*')
|
||||||
.then((res) => Number(res[0].count));
|
.then((res) => Number(res[0].count));
|
||||||
}
|
}
|
||||||
@ -193,7 +203,7 @@ class UserStore implements IUserStore {
|
|||||||
|
|
||||||
async exists(id: number): Promise<boolean> {
|
async exists(id: number): Promise<boolean> {
|
||||||
const result = await this.db.raw(
|
const result = await this.db.raw(
|
||||||
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`,
|
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ? and deleted_at = null) AS present`,
|
||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
const { present } = result.rows[0];
|
const { present } = result.rows[0];
|
||||||
@ -201,14 +211,13 @@ class UserStore implements IUserStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(id: number): Promise<User> {
|
async get(id: number): Promise<User> {
|
||||||
const row = await this.db(TABLE).where({ id }).first();
|
const row = await this.activeUsers().where({ id }).first();
|
||||||
return rowToUser(row);
|
return rowToUser(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserByPersonalAccessToken(secret: string): Promise<User> {
|
async getUserByPersonalAccessToken(secret: string): Promise<User> {
|
||||||
const row = await this.db
|
const row = await this.activeUsers()
|
||||||
.select(USER_COLUMNS.map((column) => `${TABLE}.${column}`))
|
.select(USER_COLUMNS.map((column) => `${TABLE}.${column}`))
|
||||||
.from(TABLE)
|
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
'personal_access_tokens',
|
'personal_access_tokens',
|
||||||
'personal_access_tokens.user_id',
|
'personal_access_tokens.user_id',
|
||||||
|
@ -351,8 +351,13 @@ export class AccessService {
|
|||||||
return this.store.getRolesForUserId(userId);
|
return this.store.getRolesForUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlinkUserRoles(userId: number): Promise<void> {
|
async wipeUserPermissions(userId: number): Promise<void[]> {
|
||||||
return this.store.unlinkUserRoles(userId);
|
return Promise.all([
|
||||||
|
this.store.unlinkUserRoles(userId),
|
||||||
|
this.store.unlinkUserGroups(userId),
|
||||||
|
this.store.clearUserPersonalAccessTokens(userId),
|
||||||
|
this.store.clearPublicSignupUserTokens(userId),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUsersForRole(roleId: number): Promise<IUser[]> {
|
async getUsersForRole(roleId: number): Promise<IUser[]> {
|
||||||
|
@ -264,7 +264,7 @@ class UserService {
|
|||||||
|
|
||||||
async deleteUser(userId: number, updatedBy?: User): Promise<void> {
|
async deleteUser(userId: number, updatedBy?: User): Promise<void> {
|
||||||
const user = await this.store.get(userId);
|
const user = await this.store.get(userId);
|
||||||
await this.accessService.unlinkUserRoles(userId);
|
await this.accessService.wipeUserPermissions(userId);
|
||||||
await this.sessionService.deleteSessionsForUser(userId);
|
await this.sessionService.deleteSessionsForUser(userId);
|
||||||
|
|
||||||
await this.store.delete(userId);
|
await this.store.delete(userId);
|
||||||
|
@ -53,6 +53,12 @@ export interface IAccessStore extends Store<IRole, number> {
|
|||||||
|
|
||||||
unlinkUserRoles(userId: number): Promise<void>;
|
unlinkUserRoles(userId: number): Promise<void>;
|
||||||
|
|
||||||
|
unlinkUserGroups(userId: number): Promise<void>;
|
||||||
|
|
||||||
|
clearUserPersonalAccessTokens(userId: number): Promise<void>;
|
||||||
|
|
||||||
|
clearPublicSignupUserTokens(userId: number): Promise<void>;
|
||||||
|
|
||||||
getRolesForUserId(userId: number): Promise<IRoleWithProject[]>;
|
getRolesForUserId(userId: number): Promise<IRoleWithProject[]>;
|
||||||
|
|
||||||
getProjectUsersForRole(
|
getProjectUsersForRole(
|
||||||
|
19
src/migrations/20221121133546-soft-delete-user.js
Normal file
19
src/migrations/20221121133546-soft-delete-user.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
exports.up = function (db, callback) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE;
|
||||||
|
`,
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (db, callback) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS deleted_at;
|
||||||
|
`,
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
};
|
@ -215,7 +215,8 @@ test('get a single user', async () => {
|
|||||||
test('should delete user', async () => {
|
test('should delete user', async () => {
|
||||||
const user = await userStore.insert({ email: 'some@mail.com' });
|
const user = await userStore.insert({ email: 'some@mail.com' });
|
||||||
|
|
||||||
return app.request.delete(`/api/admin/user-admin/${user.id}`).expect(200);
|
await app.request.delete(`/api/admin/user-admin/${user.id}`).expect(200);
|
||||||
|
await app.request.get(`/api/admin/user-admin/${user.id}`).expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('validator should require strong password', async () => {
|
test('validator should require strong password', async () => {
|
||||||
@ -341,7 +342,7 @@ test('generates USER_CREATED event', async () => {
|
|||||||
|
|
||||||
test('generates USER_DELETED event', async () => {
|
test('generates USER_DELETED event', async () => {
|
||||||
const user = await userStore.insert({ email: 'some@mail.com' });
|
const user = await userStore.insert({ email: 'some@mail.com' });
|
||||||
await app.request.delete(`/api/admin/user-admin/${user.id}`);
|
await app.request.delete(`/api/admin/user-admin/${user.id}`).expect(200);
|
||||||
|
|
||||||
const events = await eventStore.getEvents();
|
const events = await eventStore.getEvents();
|
||||||
expect(events[0].type).toBe(USER_DELETED);
|
expect(events[0].type).toBe(USER_DELETED);
|
||||||
|
@ -15,6 +15,7 @@ import { simpleAuthSettingsKey } from '../../../lib/types/settings/simple-auth-s
|
|||||||
import { addDays, minutesToMilliseconds } from 'date-fns';
|
import { addDays, minutesToMilliseconds } from 'date-fns';
|
||||||
import { GroupService } from '../../../lib/services/group-service';
|
import { GroupService } from '../../../lib/services/group-service';
|
||||||
import { randomId } from '../../../lib/util/random-id';
|
import { randomId } from '../../../lib/util/random-id';
|
||||||
|
import { BadDataError } from '../../../lib/error';
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
let stores;
|
let stores;
|
||||||
@ -102,6 +103,20 @@ test('should create user with password', async () => {
|
|||||||
expect(user.username).toBe('test');
|
expect(user.username).toBe('test');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should not be able to login with deleted user', async () => {
|
||||||
|
const user = await userService.createUser({
|
||||||
|
username: 'deleted_user',
|
||||||
|
password: 'unleash4all',
|
||||||
|
rootRole: adminRole.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userService.deleteUser(user.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
userService.loginUser('deleted_user', 'unleash4all'),
|
||||||
|
).rejects.toThrow(new NotFoundError(`No user found`));
|
||||||
|
});
|
||||||
|
|
||||||
test('should not login user if simple auth is disabled', async () => {
|
test('should not login user if simple auth is disabled', async () => {
|
||||||
await settingService.insert(
|
await settingService.insert(
|
||||||
simpleAuthSettingsKey,
|
simpleAuthSettingsKey,
|
||||||
@ -225,16 +240,14 @@ test('should login and create user via SSO', async () => {
|
|||||||
test('should throw if rootRole is wrong via SSO', async () => {
|
test('should throw if rootRole is wrong via SSO', async () => {
|
||||||
expect.assertions(1);
|
expect.assertions(1);
|
||||||
|
|
||||||
try {
|
await expect(
|
||||||
await userService.loginUserSSO({
|
userService.loginUserSSO({
|
||||||
email: 'some@test.com',
|
email: 'some@test.com',
|
||||||
rootRole: RoleName.MEMBER,
|
rootRole: RoleName.MEMBER,
|
||||||
name: 'some',
|
name: 'some',
|
||||||
autoCreate: true,
|
autoCreate: true,
|
||||||
});
|
}),
|
||||||
} catch (e) {
|
).rejects.toThrow(new BadDataError(`Could not find rootRole=Member`));
|
||||||
expect(e.message).toBe('Could not find rootRole=Member');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should update user name when signing in via SSO', async () => {
|
test('should update user name when signing in via SSO', async () => {
|
||||||
@ -284,14 +297,12 @@ test('should update name if it is different via SSO', async () => {
|
|||||||
test('should throw if autoCreate is false via SSO', async () => {
|
test('should throw if autoCreate is false via SSO', async () => {
|
||||||
expect.assertions(1);
|
expect.assertions(1);
|
||||||
|
|
||||||
try {
|
await expect(
|
||||||
await userService.loginUserSSO({
|
userService.loginUserSSO({
|
||||||
email: 'some@test.com',
|
email: 'some@test.com',
|
||||||
rootRole: RoleName.MEMBER,
|
rootRole: RoleName.MEMBER,
|
||||||
name: 'some',
|
name: 'some',
|
||||||
autoCreate: false,
|
autoCreate: false,
|
||||||
});
|
}),
|
||||||
} catch (e) {
|
).rejects.toThrow(new NotFoundError(`No user found`));
|
||||||
expect(e.message).toBe('No user found');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
@ -160,3 +160,15 @@ test('should always lowercase emails on updates', async () => {
|
|||||||
storedUser = await store.get(storedUser.id);
|
storedUser = await store.get(storedUser.id);
|
||||||
expect(storedUser.email).toBe(updatedUser.email.toLowerCase());
|
expect(storedUser.email).toBe(updatedUser.email.toLowerCase());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should delete user', async () => {
|
||||||
|
const user = await stores.userStore.upsert({
|
||||||
|
email: 'deleteuser@mail.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
await stores.userStore.delete(user.id);
|
||||||
|
|
||||||
|
await expect(() => stores.userStore.get(user.id)).rejects.toThrow(
|
||||||
|
new NotFoundError('No user found'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
12
src/test/fixtures/fake-access-store.ts
vendored
12
src/test/fixtures/fake-access-store.ts
vendored
@ -199,6 +199,18 @@ class AccessStoreMock implements IAccessStore {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return Promise.resolve(undefined);
|
return Promise.resolve(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearUserPersonalAccessTokens(userId: number): Promise<void> {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinkUserGroups(userId: number): Promise<void> {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPublicSignupUserTokens(userId: number): Promise<void> {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = AccessStoreMock;
|
module.exports = AccessStoreMock;
|
||||||
|
Loading…
Reference in New Issue
Block a user