1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-23 00:16:25 +01:00

feat: add the account abstraction logic (#2918)

https://linear.app/unleash/issue/2-579/improve-user-like-behaviour-for-service-accounts-accounts-concept

Builds on top of https://github.com/Unleash/unleash/pull/2917 by moving
the responsibility of handling both account types from `users` to
`accounts`.

Ideally:
 - `users` - Should only handle users;
 - `service-accounts` - Should only handle service accounts;
 - `accounts` - Should handle any type of account;

This should hopefully also provide a good building block in case we
later decide to refactor this further down the `accounts` path.
This commit is contained in:
Nuno Góis 2023-01-18 16:08:07 +00:00 committed by GitHub
parent bb20c6d102
commit 7d73d772df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 444 additions and 127 deletions

View File

@ -150,6 +150,11 @@ export const ProjectAccessAssign = ({
user.id === id && type === ENTITY_TYPE.USER
)
)
.sort((a: IUser, b: IUser) => {
const aName = a.name || a.username || '';
const bName = b.name || b.username || '';
return aName.localeCompare(bName);
})
.map((user: IUser) => ({
id: user.id,
entity: user,
@ -165,6 +170,11 @@ export const ProjectAccessAssign = ({
type === ENTITY_TYPE.SERVICE_ACCOUNT
)
)
.sort((a: IServiceAccount, b: IServiceAccount) => {
const aName = a.name || a.username || '';
const bName = b.name || b.username || '';
return aName.localeCompare(bName);
})
.map((serviceAccount: IServiceAccount) => ({
id: serviceAccount.id,
entity: serviceAccount,

View File

@ -22,7 +22,7 @@ export const useAccess = (): IUseAccessOutput => {
return {
users: (data?.users as IUser[])?.filter(
({ accountType }) => accountType === 'User'
({ accountType }) => !accountType || accountType === 'User'
),
serviceAccounts: (data?.users as IServiceAccount[])?.filter(
({ accountType }) => accountType === 'Service Account'

View File

@ -66,7 +66,7 @@ const useProjectAccess = (
return formatAccessData({
roles: data.roles,
users: (data.users as IUser[]).filter(
({ accountType }) => accountType === 'User'
({ accountType }) => !accountType || accountType === 'User'
),
serviceAccounts: (data.users as IUser[]).filter(
({ accountType }) => accountType === 'Service Account'

172
src/lib/db/account-store.ts Normal file
View File

@ -0,0 +1,172 @@
import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger';
import User from '../types/user';
import NotFoundError from '../error/notfound-error';
import { IUserLookup } from '../types/stores/user-store';
import { IAccountStore } from '../types';
const TABLE = 'users';
const USER_COLUMNS_PUBLIC = [
'id',
'name',
'username',
'email',
'image_url',
'seen_at',
'is_service',
];
const USER_COLUMNS = [...USER_COLUMNS_PUBLIC, 'login_attempts', 'created_at'];
const emptify = (value) => {
if (!value) {
return undefined;
}
return value;
};
const safeToLower = (s?: string) => (s ? s.toLowerCase() : s);
const rowToUser = (row) => {
if (!row) {
throw new NotFoundError('No user found');
}
return new User({
id: row.id,
name: emptify(row.name),
username: emptify(row.username),
email: emptify(row.email),
imageUrl: emptify(row.image_url),
loginAttempts: row.login_attempts,
seenAt: row.seen_at,
createdAt: row.created_at,
isService: row.is_service,
});
};
export class AccountStore implements IAccountStore {
private db: Knex;
private logger: Logger;
constructor(db: Knex, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('account-store.ts');
}
buildSelectAccount(q: IUserLookup): any {
const query = this.activeAccounts();
if (q.id) {
return query.where('id', q.id);
}
if (q.email) {
return query.where('email', safeToLower(q.email));
}
if (q.username) {
return query.where('username', q.username);
}
throw new Error('Can only find users with id, username or email.');
}
activeAccounts(): any {
return this.db(TABLE).where({
deleted_at: null,
});
}
async hasAccount(idQuery: IUserLookup): Promise<number | undefined> {
const query = this.buildSelectAccount(idQuery);
const item = await query.first('id');
return item ? item.id : undefined;
}
async getAll(): Promise<User[]> {
const users = await this.activeAccounts().select(USER_COLUMNS);
return users.map(rowToUser);
}
async search(query: string): Promise<User[]> {
const users = await this.activeAccounts()
.select(USER_COLUMNS_PUBLIC)
.where('name', 'ILIKE', `%${query}%`)
.orWhere('username', 'ILIKE', `${query}%`)
.orWhere('email', 'ILIKE', `${query}%`);
return users.map(rowToUser);
}
async getAllWithId(userIdList: number[]): Promise<User[]> {
const users = await this.activeAccounts()
.select(USER_COLUMNS_PUBLIC)
.whereIn('id', userIdList);
return users.map(rowToUser);
}
async getByQuery(idQuery: IUserLookup): Promise<User> {
const row = await this.buildSelectAccount(idQuery).first(USER_COLUMNS);
return rowToUser(row);
}
async delete(id: number): Promise<void> {
return this.activeAccounts()
.where({ id })
.update({
deleted_at: new Date(),
email: null,
username: null,
name: this.db.raw('name || ?', '(Deleted)'),
});
}
async deleteAll(): Promise<void> {
await this.activeAccounts().del();
}
async count(): Promise<number> {
return this.activeAccounts()
.count('*')
.then((res) => Number(res[0].count));
}
destroy(): void {}
async exists(id: number): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ? and deleted_at = null) AS present`,
[id],
);
const { present } = result.rows[0];
return present;
}
async get(id: number): Promise<User> {
const row = await this.activeAccounts().where({ id }).first();
return rowToUser(row);
}
async getAccountByPersonalAccessToken(secret: string): Promise<User> {
const row = await this.activeAccounts()
.select(USER_COLUMNS.map((column) => `${TABLE}.${column}`))
.leftJoin(
'personal_access_tokens',
'personal_access_tokens.user_id',
`${TABLE}.id`,
)
.where('secret', secret)
.andWhere('expires_at', '>', 'now()')
.first();
return rowToUser(row);
}
async markSeenAt(secrets: string[]): Promise<void> {
const now = new Date();
try {
await this.db('personal_access_tokens')
.whereIn('secret', secrets)
.update({ seen_at: now });
} catch (err) {
this.logger.error('Could not update lastSeen, error: ', err);
}
}
}

View File

@ -34,6 +34,7 @@ import PatStore from './pat-store';
import { PublicSignupTokenStore } from './public-signup-token-store';
import { FavoriteFeaturesStore } from './favorite-features-store';
import { FavoriteProjectsStore } from './favorite-projects-store';
import { AccountStore } from './account-store';
export const createStores = (
config: IUnleashConfig,
@ -57,6 +58,7 @@ export const createStores = (
contextFieldStore: new ContextFieldStore(db, getLogger),
settingStore: new SettingStore(db, getLogger),
userStore: new UserStore(db, getLogger),
accountStore: new AccountStore(db, getLogger),
projectStore: new ProjectStore(
db,
eventBus,

View File

@ -126,11 +126,6 @@ class UserStore implements IUserStore {
}
async getAll(): Promise<User[]> {
const users = await this.activeAll().select(USER_COLUMNS);
return users.map(rowToUser);
}
async getAllUsers(): Promise<User[]> {
const users = await this.activeUsers().select(USER_COLUMNS);
return users.map(rowToUser);
}
@ -145,7 +140,7 @@ class UserStore implements IUserStore {
}
async getAllWithId(userIdList: number[]): Promise<User[]> {
const users = await this.activeAll()
const users = await this.activeUsers()
.select(USER_COLUMNS_PUBLIC)
.whereIn('id', userIdList);
return users.map(rowToUser);
@ -221,31 +216,6 @@ class UserStore implements IUserStore {
const row = await this.activeUsers().where({ id }).first();
return rowToUser(row);
}
async getUserByPersonalAccessToken(secret: string): Promise<User> {
const row = await this.activeAll()
.select(USER_COLUMNS.map((column) => `${TABLE}.${column}`))
.leftJoin(
'personal_access_tokens',
'personal_access_tokens.user_id',
`${TABLE}.id`,
)
.where('secret', secret)
.andWhere('expires_at', '>', 'now()')
.first();
return rowToUser(row);
}
async markSeenAt(secrets: string[]): Promise<void> {
const now = new Date();
try {
await this.db('personal_access_tokens')
.whereIn('secret', secrets)
.update({ seen_at: now });
} catch (err) {
this.logger.error('Could not update lastSeen, error: ', err);
}
}
}
module.exports = UserStore;

View File

@ -14,11 +14,11 @@ beforeEach(() => {
});
test('should not set user if unknown token', async () => {
const userService = {
getUserByPersonalAccessToken: jest.fn(),
const accountService = {
getAccountByPersonalAccessToken: jest.fn(),
};
const func = patMiddleware(config, { userService });
const func = patMiddleware(config, { accountService });
const cb = jest.fn();
@ -35,11 +35,11 @@ test('should not set user if unknown token', async () => {
});
test('should not set user if token wrong format', async () => {
const userService = {
getUserByPersonalAccessToken: jest.fn(),
const accountService = {
getAccountByPersonalAccessToken: jest.fn(),
};
const func = patMiddleware(config, { userService });
const func = patMiddleware(config, { accountService });
const cb = jest.fn();
@ -50,7 +50,9 @@ test('should not set user if token wrong format', async () => {
await func(req, undefined, cb);
expect(userService.getUserByPersonalAccessToken).not.toHaveBeenCalled();
expect(
accountService.getAccountByPersonalAccessToken,
).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalled();
expect(req.header).toHaveBeenCalled();
expect(req.user).toBeFalsy();
@ -61,11 +63,11 @@ test('should add user if known token', async () => {
id: 44,
username: 'my-user',
});
const userService = {
getUserByPersonalAccessToken: jest.fn().mockReturnValue(apiUser),
const accountService = {
getAccountByPersonalAccessToken: jest.fn().mockReturnValue(apiUser),
};
const func = patMiddleware(config, { userService });
const func = patMiddleware(config, { accountService });
const cb = jest.fn();
@ -82,15 +84,15 @@ test('should add user if known token', async () => {
expect(req.user).toBe(apiUser);
});
test('should call next if userService throws exception', async () => {
test('should call next if accountService throws exception', async () => {
getLogger.setMuteError(true);
const userService = {
getUserByPersonalAccessToken: () => {
const accountService = {
getAccountByPersonalAccessToken: () => {
throw new Error('Error occurred');
},
};
const func = patMiddleware(config, { userService });
const func = patMiddleware(config, { accountService });
const cb = jest.fn();

View File

@ -4,7 +4,7 @@ import { IAuthRequest } from '../routes/unleash-types';
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
const patMiddleware = (
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
{ userService }: any,
{ accountService }: any,
): any => {
const logger = getLogger('/middleware/pat-middleware.ts');
logger.debug('Enabling PAT middleware');
@ -13,11 +13,12 @@ const patMiddleware = (
try {
const apiToken = req.header('authorization');
if (apiToken?.startsWith('user:')) {
const user = await userService.getUserByPersonalAccessToken(
apiToken,
);
const user =
await accountService.getAccountByPersonalAccessToken(
apiToken,
);
req.user = user;
userService.addPATSeen(apiToken);
accountService.addPATSeen(apiToken);
}
} catch (error) {
logger.error(error);

View File

@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import Controller from '../controller';
import { ADMIN, NONE } from '../../types/permissions';
import UserService from '../../services/user-service';
import { AccountService } from '../../services/account-service';
import { AccessService } from '../../services/access-service';
import { Logger } from '../../logger';
import { IUnleashConfig, IUnleashServices } from '../../types';
@ -44,6 +45,8 @@ export default class UserAdminController extends Controller {
private userService: UserService;
private accountService: AccountService;
private accessService: AccessService;
private readonly logger: Logger;
@ -64,6 +67,7 @@ export default class UserAdminController extends Controller {
config: IUnleashConfig,
{
userService,
accountService,
accessService,
emailService,
resetTokenService,
@ -73,6 +77,7 @@ export default class UserAdminController extends Controller {
}: Pick<
IUnleashServices,
| 'userService'
| 'accountService'
| 'accessService'
| 'emailService'
| 'resetTokenService'
@ -83,6 +88,7 @@ export default class UserAdminController extends Controller {
) {
super(config);
this.userService = userService;
this.accountService = accountService;
this.accessService = accessService;
this.emailService = emailService;
this.resetTokenService = resetTokenService;
@ -262,7 +268,7 @@ export default class UserAdminController extends Controller {
}
async getUsers(req: Request, res: Response<UsersSchema>): Promise<void> {
const users = await this.userService.getAllUsers();
const users = await this.userService.getAll();
const rootRoles = await this.accessService.getRootRoles();
const inviteLinks = await this.resetTokenService.getActiveInvitations();
@ -310,7 +316,7 @@ export default class UserAdminController extends Controller {
req: Request,
res: Response<UsersGroupsBaseSchema>,
): Promise<void> {
let allUsers = await this.userService.getAll();
let allUsers = await this.accountService.getAll();
let users = allUsers.map((u) => {
return {
id: u.id,

View File

@ -9,9 +9,8 @@ import {
IUserPermission,
IUserRole,
} from '../types/stores/access-store';
import { IUserStore } from '../types/stores/user-store';
import { Logger } from '../logger';
import { IUnleashStores } from '../types/stores';
import { IAccountStore, IUnleashStores } from '../types/stores';
import {
IAvailablePermissions,
ICustomRole,
@ -67,7 +66,7 @@ const isProjectPermission = (permission) => PROJECT_ADMIN.includes(permission);
export class AccessService {
private store: IAccessStore;
private userStore: IUserStore;
private accountStore: IAccountStore;
private roleStore: IRoleStore;
@ -80,18 +79,18 @@ export class AccessService {
constructor(
{
accessStore,
userStore,
accountStore,
roleStore,
environmentStore,
}: Pick<
IUnleashStores,
'accessStore' | 'userStore' | 'roleStore' | 'environmentStore'
'accessStore' | 'accountStore' | 'roleStore' | 'environmentStore'
>,
{ getLogger }: { getLogger: Function },
groupService: GroupService,
) {
this.store = accessStore;
this.userStore = userStore;
this.accountStore = accountStore;
this.roleStore = roleStore;
this.groupService = groupService;
this.environmentStore = environmentStore;
@ -363,7 +362,7 @@ export class AccessService {
async getUsersForRole(roleId: number): Promise<IUser[]> {
const userIdList = await this.store.getUserIdsForRole(roleId);
if (userIdList.length > 0) {
return this.userStore.getAllWithId(userIdList);
return this.accountStore.getAllWithId(userIdList);
}
return [];
}
@ -378,7 +377,7 @@ export class AccessService {
);
if (userRoleList.length > 0) {
const userIdList = userRoleList.map((u) => u.userId);
const users = await this.userStore.getAllWithId(userIdList);
const users = await this.accountStore.getAllWithId(userIdList);
return users.map((user) => {
const role = userRoleList.find((r) => r.userId == user.id);
return {

View File

@ -0,0 +1,76 @@
import { Logger } from '../logger';
import { IUser } from '../types/user';
import { IUnleashConfig } from '../types/option';
import { IAccountStore, IUnleashStores } from '../types/stores';
import { minutesToMilliseconds } from 'date-fns';
import { AccessService } from './access-service';
import { RoleName } from '../types/model';
interface IUserWithRole extends IUser {
rootRole: number;
}
export class AccountService {
private logger: Logger;
private store: IAccountStore;
private accessService: AccessService;
private seenTimer: NodeJS.Timeout;
private lastSeenSecrets: Set<string> = new Set<string>();
constructor(
stores: Pick<IUnleashStores, 'accountStore' | 'eventStore'>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
services: {
accessService: AccessService;
},
) {
this.logger = getLogger('service/account-service.ts');
this.store = stores.accountStore;
this.accessService = services.accessService;
this.updateLastSeen();
}
async getAll(): Promise<IUserWithRole[]> {
const accounts = await this.store.getAll();
const defaultRole = await this.accessService.getRootRole(
RoleName.VIEWER,
);
const userRoles = await this.accessService.getRootRoleForAllUsers();
const accountsWithRootRole = accounts.map((u) => {
const rootRole = userRoles.find((r) => r.userId === u.id);
const roleId = rootRole ? rootRole.roleId : defaultRole.id;
return { ...u, rootRole: roleId };
});
return accountsWithRootRole;
}
async getAccountByPersonalAccessToken(secret: string): Promise<IUser> {
return this.store.getAccountByPersonalAccessToken(secret);
}
async updateLastSeen(): Promise<void> {
if (this.lastSeenSecrets.size > 0) {
const toStore = [...this.lastSeenSecrets];
this.lastSeenSecrets = new Set<string>();
await this.store.markSeenAt(toStore);
}
this.seenTimer = setTimeout(
async () => this.updateLastSeen(),
minutesToMilliseconds(3),
).unref();
}
addPATSeen(secret: string): void {
this.lastSeenSecrets.add(secret);
}
destroy(): void {
clearTimeout(this.seenTimer);
this.seenTimer = null;
}
}

View File

@ -13,7 +13,7 @@ import BadDataError from '../error/bad-data-error';
import { GROUP_CREATED, GROUP_UPDATED } from '../types/events';
import { IEventStore } from '../types/stores/event-store';
import NameExistsError from '../error/name-exists-error';
import { IUserStore } from '../types/stores/user-store';
import { IAccountStore } from '../types/stores/account-store';
import { IUser } from '../types/user';
export class GroupService {
@ -21,18 +21,21 @@ export class GroupService {
private eventStore: IEventStore;
private userStore: IUserStore;
private accountStore: IAccountStore;
private logger: Logger;
constructor(
stores: Pick<IUnleashStores, 'groupStore' | 'eventStore' | 'userStore'>,
stores: Pick<
IUnleashStores,
'groupStore' | 'eventStore' | 'accountStore'
>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
) {
this.logger = getLogger('service/group-service.js');
this.groupStore = stores.groupStore;
this.eventStore = stores.eventStore;
this.userStore = stores.userStore;
this.accountStore = stores.accountStore;
}
async getAll(): Promise<IGroupModel[]> {
@ -40,7 +43,7 @@ export class GroupService {
const allGroupUsers = await this.groupStore.getAllUsersByGroups(
groups.map((g) => g.id),
);
const users = await this.userStore.getAllWithId(
const users = await this.accountStore.getAllWithId(
allGroupUsers.map((u) => u.userId),
);
const groupProjects = await this.groupStore.getGroupProjects(
@ -72,7 +75,7 @@ export class GroupService {
async getGroup(id: number): Promise<IGroupModel> {
const group = await this.groupStore.get(id);
const groupUsers = await this.groupStore.getAllUsersByGroups([id]);
const users = await this.userStore.getAllWithId(
const users = await this.accountStore.getAllWithId(
groupUsers.map((u) => u.userId),
);
return this.mapGroupWithUsers(group, groupUsers, users);
@ -156,7 +159,7 @@ export class GroupService {
groups.map((g) => g.id),
);
const users = await this.userStore.getAllWithId(
const users = await this.accountStore.getAllWithId(
groupUsers.map((u) => u.userId),
);
return groups.map((group) => {

View File

@ -42,6 +42,7 @@ import MaintenanceService from './maintenance-service';
import ExportImportService from './export-import-service';
import SchedulerService from './scheduler-service';
import { minutesToMilliseconds } from 'date-fns';
import { AccountService } from './account-service';
export const createServices = (
stores: IUnleashStores,
@ -76,6 +77,9 @@ export const createServices = (
sessionService,
settingService,
});
const accountService = new AccountService(stores, config, {
accessService,
});
const versionService = new VersionService(stores, config);
const healthService = new HealthService(stores, config);
const userFeedbackService = new UserFeedbackService(stores, config);
@ -159,6 +163,7 @@ export const createServices = (
return {
accessService,
accountService,
addonService,
featureToggleService: featureToggleServiceV2,
featureToggleServiceV2,

View File

@ -16,7 +16,7 @@ import {
ProjectGroupRemovedEvent,
ProjectGroupUpdateRoleEvent,
} from '../types/events';
import { IUnleashStores, IUnleashConfig } from '../types';
import { IUnleashStores, IUnleashConfig, IAccountStore } from '../types';
import {
FeatureToggle,
IProject,
@ -42,7 +42,6 @@ import IncompatibleProjectError from '../error/incompatible-project-error';
import { DEFAULT_PROJECT } from '../types/project';
import { IFeatureTagStore } from 'lib/types/stores/feature-tag-store';
import ProjectWithoutOwnerError from '../error/project-without-owner-error';
import { IUserStore } from 'lib/types/stores/user-store';
import { arraysHaveSameItems } from '../util/arraysHaveSameItems';
import { GroupService } from './group-service';
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
@ -79,7 +78,7 @@ export default class ProjectService {
private tagStore: IFeatureTagStore;
private userStore: IUserStore;
private accountStore: IAccountStore;
private favoritesService: FavoritesService;
@ -92,7 +91,7 @@ export default class ProjectService {
environmentStore,
featureEnvironmentStore,
featureTagStore,
userStore,
accountStore,
}: Pick<
IUnleashStores,
| 'projectStore'
@ -102,7 +101,7 @@ export default class ProjectService {
| 'environmentStore'
| 'featureEnvironmentStore'
| 'featureTagStore'
| 'userStore'
| 'accountStore'
>,
config: IUnleashConfig,
accessService: AccessService,
@ -120,7 +119,7 @@ export default class ProjectService {
this.featureToggleService = featureToggleService;
this.favoritesService = favoriteService;
this.tagStore = featureTagStore;
this.userStore = userStore;
this.accountStore = accountStore;
this.groupService = groupService;
this.logger = config.getLogger('services/project-service.js');
}
@ -317,7 +316,7 @@ export default class ProjectService {
const [roles, users] = await this.accessService.getProjectRoleAccess(
projectId,
);
const user = await this.userStore.get(userId);
const user = await this.accountStore.get(userId);
const role = roles.find((r) => r.id === roleId);
if (!role) {
@ -359,7 +358,7 @@ export default class ProjectService {
await this.accessService.removeUserFromRole(userId, role.id, projectId);
const user = await this.userStore.get(userId);
const user = await this.accountStore.get(userId);
await this.eventStore.store(
new ProjectUserRemovedEvent({

View File

@ -28,7 +28,6 @@ import PasswordMismatch from '../error/password-mismatch';
import BadDataError from '../error/bad-data-error';
import { isDefined } from '../util/isDefined';
import { TokenUserSchema } from '../openapi/spec/token-user-schema';
import { minutesToMilliseconds } from 'date-fns';
const systemUser = new User({ id: -1, username: 'system' });
@ -79,10 +78,6 @@ class UserService {
private passwordResetTimeouts: { [key: string]: NodeJS.Timeout } = {};
private seenTimer: NodeJS.Timeout;
private lastSeenSecrets: Set<string> = new Set<string>();
constructor(
stores: Pick<IUnleashStores, 'userStore' | 'eventStore'>,
{
@ -108,7 +103,6 @@ class UserService {
if (authentication && authentication.createAdminUser) {
process.nextTick(() => this.initAdminUser());
}
this.updateLastSeen();
}
validatePassword(password: string): boolean {
@ -161,20 +155,6 @@ class UserService {
return usersWithRootRole;
}
async getAllUsers(): Promise<IUserWithRole[]> {
const users = await this.store.getAllUsers();
const defaultRole = await this.accessService.getRootRole(
RoleName.VIEWER,
);
const userRoles = await this.accessService.getRootRoleForAllUsers();
const usersWithRootRole = users.map((u) => {
const rootRole = userRoles.find((r) => r.userId === u.id);
const roleId = rootRole ? rootRole.roleId : defaultRole.id;
return { ...u, rootRole: roleId };
});
return usersWithRootRole;
}
async getUser(id: number): Promise<IUserWithRole> {
const roles = await this.accessService.getUserRootRoles(id);
const defaultRole = await this.accessService.getRootRole(
@ -442,32 +422,6 @@ class UserService {
);
return resetLink;
}
async getUserByPersonalAccessToken(secret: string): Promise<IUser> {
return this.store.getUserByPersonalAccessToken(secret);
}
async updateLastSeen(): Promise<void> {
if (this.lastSeenSecrets.size > 0) {
const toStore = [...this.lastSeenSecrets];
this.lastSeenSecrets = new Set<string>();
await this.store.markSeenAt(toStore);
}
this.seenTimer = setTimeout(
async () => this.updateLastSeen(),
minutesToMilliseconds(3),
).unref();
}
addPATSeen(secret: string): void {
this.lastSeenSecrets.add(secret);
}
destroy(): void {
clearTimeout(this.seenTimer);
this.seenTimer = null;
}
}
module.exports = UserService;

View File

@ -38,9 +38,11 @@ import { InstanceStatsService } from '../services/instance-stats-service';
import { FavoritesService } from '../services';
import MaintenanceService from '../services/maintenance-service';
import ExportImportService from 'lib/services/export-import-service';
import { AccountService } from '../services/account-service';
export interface IUnleashServices {
accessService: AccessService;
accountService: AccountService;
addonService: AddonService;
apiTokenService: ApiTokenService;
clientInstanceService: ClientInstanceService;

View File

@ -30,9 +30,11 @@ import { IPatStore } from './stores/pat-store';
import { IPublicSignupTokenStore } from './stores/public-signup-token-store';
import { IFavoriteFeaturesStore } from './stores/favorite-features';
import { IFavoriteProjectsStore } from './stores/favorite-projects';
import { IAccountStore } from './stores/account-store';
export interface IUnleashStores {
accessStore: IAccessStore;
accountStore: IAccountStore;
addonStore: IAddonStore;
apiTokenStore: IApiTokenStore;
clientApplicationsStore: IClientApplicationsStore;
@ -68,6 +70,7 @@ export interface IUnleashStores {
export {
IAccessStore,
IAccountStore,
IAddonStore,
IApiTokenStore,
IClientApplicationsStore,

View File

@ -0,0 +1,18 @@
import { IUser } from '../user';
import { Store } from './store';
export interface IUserLookup {
id?: number;
username?: string;
email?: string;
}
export interface IAccountStore extends Store<IUser, number> {
hasAccount(idQuery: IUserLookup): Promise<number | undefined>;
search(query: string): Promise<IUser[]>;
getAllWithId(userIdList: number[]): Promise<IUser[]>;
getByQuery(idQuery: IUserLookup): Promise<IUser>;
count(): Promise<number>;
getAccountByPersonalAccessToken(secret: string): Promise<IUser>;
markSeenAt(secrets: string[]): Promise<void>;
}

View File

@ -25,7 +25,6 @@ export interface IUserStore extends Store<IUser, number> {
upsert(user: ICreateUser): Promise<IUser>;
hasUser(idQuery: IUserLookup): Promise<number | undefined>;
search(query: string): Promise<IUser[]>;
getAllUsers(): Promise<IUser[]>;
getAllWithId(userIdList: number[]): Promise<IUser[]>;
getByQuery(idQuery: IUserLookup): Promise<IUser>;
getPasswordHash(userId: number): Promise<string>;
@ -33,6 +32,4 @@ export interface IUserStore extends Store<IUser, number> {
incLoginAttempts(user: IUser): Promise<void>;
successfullyLogin(user: IUser): Promise<void>;
count(): Promise<number>;
getUserByPersonalAccessToken(secret: string): Promise<IUser>;
markSeenAt(secrets: string[]): Promise<void>;
}

View File

@ -16,7 +16,7 @@ class AccessServiceMock extends AccessService {
super(
{
accessStore: undefined,
userStore: undefined,
accountStore: undefined,
roleStore: undefined,
environmentStore: undefined,
},

96
src/test/fixtures/fake-account-store.ts vendored Normal file
View File

@ -0,0 +1,96 @@
import { IUser } from '../../lib/types/user';
import {
// ICreateUser,
IUserLookup,
IAccountStore,
} from '../../lib/types/stores/account-store';
export class FakeAccountStore implements IAccountStore {
data: IUser[];
idSeq: number;
constructor() {
this.idSeq = 1;
this.data = [];
}
async hasAccount({
id,
username,
email,
}: IUserLookup): Promise<number | undefined> {
const user = this.data.find((i) => {
if (id && i.id === id) return true;
if (username && i.username === username) return true;
if (email && i.email === email) return true;
return false;
});
if (user) {
return user.id;
}
return undefined;
}
destroy(): void {}
async exists(key: number): Promise<boolean> {
return this.data.some((u) => u.id === key);
}
async count(): Promise<number> {
return this.data.length;
}
async get(key: number): Promise<IUser> {
return this.data.find((u) => u.id === key);
}
async getByQuery({ id, username, email }: IUserLookup): Promise<IUser> {
const user = this.data.find((i) => {
if (i.id && i.id === id) return true;
if (i.username && i.username === username) return true;
if (i.email && i.email === email) return true;
return false;
});
if (user) {
return user;
}
throw new Error('Could not find user');
}
async getAll(): Promise<IUser[]> {
return Promise.resolve(this.data);
}
async delete(id: number): Promise<void> {
this.data = this.data.filter((item) => item.id !== id);
return Promise.resolve();
}
buildSelectUser(): any {
throw new Error('Not implemented');
}
async search(): Promise<IUser[]> {
throw new Error('Not implemented');
}
async getAllWithId(): Promise<IUser[]> {
throw new Error('Not implemented');
}
deleteAll(): Promise<void> {
return Promise.resolve(undefined);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getAccountByPersonalAccessToken(secret: string): Promise<IUser> {
return Promise.resolve(undefined);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async markSeenAt(secrets: string[]): Promise<void> {
throw new Error('Not implemented');
}
}

View File

@ -31,6 +31,7 @@ import FakePatStore from './fake-pat-store';
import FakePublicSignupStore from './fake-public-signup-store';
import FakeFavoriteFeaturesStore from './fake-favorite-features-store';
import FakeFavoriteProjectsStore from './fake-favorite-projects-store';
import { FakeAccountStore } from './fake-account-store';
const createStores: () => IUnleashStores = () => {
const db = {
@ -56,6 +57,7 @@ const createStores: () => IUnleashStores = () => {
projectStore: new FakeProjectStore(),
userStore: new FakeUserStore(),
accessStore: new FakeAccessStore(),
accountStore: new FakeAccountStore(),
userFeedbackStore: new FakeUserFeedbackStore(),
featureStrategiesStore: new FakeFeatureStrategiesStore(),
featureTagStore: new FakeFeatureTagStore(),