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:
parent
bb20c6d102
commit
7d73d772df
@ -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,
|
||||
|
@ -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'
|
||||
|
@ -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
172
src/lib/db/account-store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
76
src/lib/services/account-service.ts
Normal file
76
src/lib/services/account-service.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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) => {
|
||||
|
@ -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,
|
||||
|
@ -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({
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
18
src/lib/types/stores/account-store.ts
Normal file
18
src/lib/types/stores/account-store.ts
Normal 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>;
|
||||
}
|
@ -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>;
|
||||
}
|
||||
|
2
src/test/fixtures/access-service-mock.ts
vendored
2
src/test/fixtures/access-service-mock.ts
vendored
@ -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
96
src/test/fixtures/fake-account-store.ts
vendored
Normal 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');
|
||||
}
|
||||
}
|
2
src/test/fixtures/store.ts
vendored
2
src/test/fixtures/store.ts
vendored
@ -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(),
|
||||
|
Loading…
Reference in New Issue
Block a user