mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +02:00
feat: show max count of sessions that users have to an admin (#8781)
Add info about large number of parallel sessions per user.
This commit is contained in:
parent
695873132e
commit
7820ca62ad
@ -22,6 +22,7 @@ import { ExternalBanners } from './banners/externalBanners/ExternalBanners';
|
|||||||
import { LicenseBanner } from './banners/internalBanners/LicenseBanner';
|
import { LicenseBanner } from './banners/internalBanners/LicenseBanner';
|
||||||
import { Demo } from './demo/Demo';
|
import { Demo } from './demo/Demo';
|
||||||
import { LoginRedirect } from './common/LoginRedirect/LoginRedirect';
|
import { LoginRedirect } from './common/LoginRedirect/LoginRedirect';
|
||||||
|
import { SecurityBanner } from './banners/internalBanners/SecurityBanner';
|
||||||
|
|
||||||
const StyledContainer = styled('div')(() => ({
|
const StyledContainer = styled('div')(() => ({
|
||||||
'& ul': {
|
'& ul': {
|
||||||
@ -66,6 +67,7 @@ export const App = () => {
|
|||||||
show={<MaintenanceBanner />}
|
show={<MaintenanceBanner />}
|
||||||
/>
|
/>
|
||||||
<LicenseBanner />
|
<LicenseBanner />
|
||||||
|
<SecurityBanner />
|
||||||
<ExternalBanners />
|
<ExternalBanners />
|
||||||
<InternalBanners />
|
<InternalBanners />
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
|
@ -0,0 +1,32 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { Banner } from 'component/banners/Banner/Banner';
|
||||||
|
import AccessContext from 'contexts/AccessContext';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
import type { BannerVariant } from 'interfaces/banner';
|
||||||
|
|
||||||
|
export const SecurityBanner = () => {
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const showUserDeviceCount = useUiFlag('showUserDeviceCount');
|
||||||
|
const { isAdmin } = useContext(AccessContext);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isAdmin ||
|
||||||
|
!showUserDeviceCount ||
|
||||||
|
!uiConfig.maxSessionsCount ||
|
||||||
|
uiConfig.maxSessionsCount < 5
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner = {
|
||||||
|
message: `Potential security issue: there are ${uiConfig.maxSessionsCount} parallel sessions for a single user account.`,
|
||||||
|
variant: 'warning' as BannerVariant,
|
||||||
|
sticky: false,
|
||||||
|
link: '/admin/users',
|
||||||
|
plausibleEvent: 'showUserDeviceCount',
|
||||||
|
linkText: 'Review user accounts',
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Banner key='showUserDeviceCount' banner={banner} />;
|
||||||
|
};
|
@ -34,6 +34,7 @@ export interface IUiConfig {
|
|||||||
oidcConfiguredThroughEnv?: boolean;
|
oidcConfiguredThroughEnv?: boolean;
|
||||||
samlConfiguredThroughEnv?: boolean;
|
samlConfiguredThroughEnv?: boolean;
|
||||||
unleashAIAvailable?: boolean;
|
unleashAIAvailable?: boolean;
|
||||||
|
maxSessionsCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProclamationToast {
|
export interface IProclamationToast {
|
||||||
|
@ -36,6 +36,8 @@ export interface UiConfigSchema {
|
|||||||
links?: UiConfigSchemaLinksItem[];
|
links?: UiConfigSchemaLinksItem[];
|
||||||
/** Whether maintenance mode is currently active or not. */
|
/** Whether maintenance mode is currently active or not. */
|
||||||
maintenanceMode?: boolean;
|
maintenanceMode?: boolean;
|
||||||
|
/** The maximum number of sessions that a user has. */
|
||||||
|
maxSessionsCount?: number;
|
||||||
/** The name of this Unleash instance. Used to build the text in the footer. */
|
/** The name of this Unleash instance. Used to build the text in the footer. */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** Whether to enable the Unleash network view or not. */
|
/** Whether to enable the Unleash network view or not. */
|
||||||
|
@ -10,8 +10,15 @@
|
|||||||
export interface UserSchema {
|
export interface UserSchema {
|
||||||
/** A user is either an actual User or a Service Account */
|
/** A user is either an actual User or a Service Account */
|
||||||
accountType?: string;
|
accountType?: string;
|
||||||
|
/**
|
||||||
|
* Count of active browser sessions for this user
|
||||||
|
* @nullable
|
||||||
|
*/
|
||||||
|
activeSessions?: number | null;
|
||||||
/** The user was created at this time */
|
/** The user was created at this time */
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
/** Experimental. The number of deleted browser sessions after last login */
|
||||||
|
deletedSessions?: number;
|
||||||
/** Email of the user */
|
/** Email of the user */
|
||||||
email?: string;
|
email?: string;
|
||||||
/** Is the welcome email sent to the user or not */
|
/** Is the welcome email sent to the user or not */
|
||||||
@ -59,6 +66,4 @@ export interface UserSchema {
|
|||||||
* @nullable
|
* @nullable
|
||||||
*/
|
*/
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
deletedSessions?: number;
|
|
||||||
activeSessions?: number;
|
|
||||||
}
|
}
|
||||||
|
@ -120,6 +120,17 @@ export default class SessionStore implements ISessionStore {
|
|||||||
count: Number(row.count),
|
count: Number(row.count),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMaxSessionsCount(): Promise<number> {
|
||||||
|
const result = await this.db(TABLE)
|
||||||
|
.select(this.db.raw("sess->'user'->>'id' AS user_id"))
|
||||||
|
.count('* as count')
|
||||||
|
.groupBy('user_id')
|
||||||
|
.orderBy('count', 'desc')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return result ? Number(result.count) : 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = SessionStore;
|
module.exports = SessionStore;
|
||||||
|
@ -191,6 +191,11 @@ export const uiConfigSchema = {
|
|||||||
description: 'Whether Unleash AI is available.',
|
description: 'Whether Unleash AI is available.',
|
||||||
example: false,
|
example: false,
|
||||||
},
|
},
|
||||||
|
maxSessionsCount: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'The maximum number of sessions that a user has.',
|
||||||
|
example: 10,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
@ -23,9 +23,10 @@ import type { IAuthRequest } from '../unleash-types';
|
|||||||
import NotFoundError from '../../error/notfound-error';
|
import NotFoundError from '../../error/notfound-error';
|
||||||
import type { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema';
|
import type { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema';
|
||||||
import { createRequestSchema } from '../../openapi/util/create-request-schema';
|
import { createRequestSchema } from '../../openapi/util/create-request-schema';
|
||||||
import type { FrontendApiService } from '../../services';
|
import type { FrontendApiService, SessionService } from '../../services';
|
||||||
import type MaintenanceService from '../../features/maintenance/maintenance-service';
|
import type MaintenanceService from '../../features/maintenance/maintenance-service';
|
||||||
import type ClientInstanceService from '../../features/metrics/instance/instance-service';
|
import type ClientInstanceService from '../../features/metrics/instance/instance-service';
|
||||||
|
import type { IFlagResolver } from '../../types';
|
||||||
|
|
||||||
class ConfigController extends Controller {
|
class ConfigController extends Controller {
|
||||||
private versionService: VersionService;
|
private versionService: VersionService;
|
||||||
@ -38,8 +39,12 @@ class ConfigController extends Controller {
|
|||||||
|
|
||||||
private clientInstanceService: ClientInstanceService;
|
private clientInstanceService: ClientInstanceService;
|
||||||
|
|
||||||
|
private sessionService: SessionService;
|
||||||
|
|
||||||
private maintenanceService: MaintenanceService;
|
private maintenanceService: MaintenanceService;
|
||||||
|
|
||||||
|
private flagResolver: IFlagResolver;
|
||||||
|
|
||||||
private readonly openApiService: OpenApiService;
|
private readonly openApiService: OpenApiService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -52,6 +57,7 @@ class ConfigController extends Controller {
|
|||||||
frontendApiService,
|
frontendApiService,
|
||||||
maintenanceService,
|
maintenanceService,
|
||||||
clientInstanceService,
|
clientInstanceService,
|
||||||
|
sessionService,
|
||||||
}: Pick<
|
}: Pick<
|
||||||
IUnleashServices,
|
IUnleashServices,
|
||||||
| 'versionService'
|
| 'versionService'
|
||||||
@ -61,6 +67,7 @@ class ConfigController extends Controller {
|
|||||||
| 'frontendApiService'
|
| 'frontendApiService'
|
||||||
| 'maintenanceService'
|
| 'maintenanceService'
|
||||||
| 'clientInstanceService'
|
| 'clientInstanceService'
|
||||||
|
| 'sessionService'
|
||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
super(config);
|
super(config);
|
||||||
@ -71,6 +78,8 @@ class ConfigController extends Controller {
|
|||||||
this.frontendApiService = frontendApiService;
|
this.frontendApiService = frontendApiService;
|
||||||
this.maintenanceService = maintenanceService;
|
this.maintenanceService = maintenanceService;
|
||||||
this.clientInstanceService = clientInstanceService;
|
this.clientInstanceService = clientInstanceService;
|
||||||
|
this.sessionService = sessionService;
|
||||||
|
this.flagResolver = config.flagResolver;
|
||||||
this.route({
|
this.route({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '',
|
path: '',
|
||||||
@ -113,13 +122,23 @@ class ConfigController extends Controller {
|
|||||||
req: AuthedRequest,
|
req: AuthedRequest,
|
||||||
res: Response<UiConfigSchema>,
|
res: Response<UiConfigSchema>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [frontendSettings, simpleAuthSettings, maintenanceMode] =
|
const getMaxSessionsCount = async () => {
|
||||||
await Promise.all([
|
if (this.flagResolver.isEnabled('showUserDeviceCount')) {
|
||||||
|
return this.sessionService.getMaxSessionsCount();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [
|
||||||
|
frontendSettings,
|
||||||
|
simpleAuthSettings,
|
||||||
|
maintenanceMode,
|
||||||
|
maxSessionsCount,
|
||||||
|
] = await Promise.all([
|
||||||
this.frontendApiService.getFrontendSettings(false),
|
this.frontendApiService.getFrontendSettings(false),
|
||||||
this.settingService.get<SimpleAuthSettings>(
|
this.settingService.get<SimpleAuthSettings>(simpleAuthSettingsKey),
|
||||||
simpleAuthSettingsKey,
|
|
||||||
),
|
|
||||||
this.maintenanceService.isMaintenanceMode(),
|
this.maintenanceService.isMaintenanceMode(),
|
||||||
|
getMaxSessionsCount(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const disablePasswordAuth =
|
const disablePasswordAuth =
|
||||||
@ -153,6 +172,7 @@ class ConfigController extends Controller {
|
|||||||
maintenanceMode,
|
maintenanceMode,
|
||||||
feedbackUriPath: this.config.feedbackUriPath,
|
feedbackUriPath: this.config.feedbackUriPath,
|
||||||
unleashAIAvailable: this.config.openAIAPIKey !== undefined,
|
unleashAIAvailable: this.config.openAIAPIKey !== undefined,
|
||||||
|
maxSessionsCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.openApiService.respondWithValidation(
|
this.openApiService.respondWithValidation(
|
||||||
|
@ -2,12 +2,14 @@ import type { IUnleashStores } from '../types/stores';
|
|||||||
import type { IUnleashConfig } from '../types/option';
|
import type { IUnleashConfig } from '../types/option';
|
||||||
import type { Logger } from '../logger';
|
import type { Logger } from '../logger';
|
||||||
import type { ISession, ISessionStore } from '../types/stores/session-store';
|
import type { ISession, ISessionStore } from '../types/stores/session-store';
|
||||||
import { compareDesc } from 'date-fns';
|
import { compareDesc, minutesToMilliseconds } from 'date-fns';
|
||||||
|
import memoizee from 'memoizee';
|
||||||
|
|
||||||
export default class SessionService {
|
export default class SessionService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
private sessionStore: ISessionStore;
|
private sessionStore: ISessionStore;
|
||||||
|
private resolveMaxSessions: () => Promise<number>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
{ sessionStore }: Pick<IUnleashStores, 'sessionStore'>,
|
{ sessionStore }: Pick<IUnleashStores, 'sessionStore'>,
|
||||||
@ -15,6 +17,14 @@ export default class SessionService {
|
|||||||
) {
|
) {
|
||||||
this.logger = getLogger('lib/services/session-service.ts');
|
this.logger = getLogger('lib/services/session-service.ts');
|
||||||
this.sessionStore = sessionStore;
|
this.sessionStore = sessionStore;
|
||||||
|
|
||||||
|
this.resolveMaxSessions = memoizee(
|
||||||
|
async () => await this.sessionStore.getMaxSessionsCount(),
|
||||||
|
{
|
||||||
|
promise: true,
|
||||||
|
maxAge: minutesToMilliseconds(1),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActiveSessions(): Promise<ISession[]> {
|
async getActiveSessions(): Promise<ISession[]> {
|
||||||
@ -69,6 +79,10 @@ export default class SessionService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMaxSessionsCount() {
|
||||||
|
return this.resolveMaxSessions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = SessionService;
|
module.exports = SessionService;
|
||||||
|
@ -282,6 +282,10 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_ENTERPRISE_PAYG,
|
process.env.UNLEASH_EXPERIMENTAL_ENTERPRISE_PAYG,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
showUserDeviceCount: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_SHOW_USER_DEVICE_COUNT,
|
||||||
|
false,
|
||||||
|
),
|
||||||
simplifyProjectOverview: parseEnvVarBoolean(
|
simplifyProjectOverview: parseEnvVarBoolean(
|
||||||
process.env.UNLEASH_EXPERIMENTAL_SIMPLIFY_PROJECT_OVERVIEW,
|
process.env.UNLEASH_EXPERIMENTAL_SIMPLIFY_PROJECT_OVERVIEW,
|
||||||
false,
|
false,
|
||||||
|
@ -13,4 +13,5 @@ export interface ISessionStore extends Store<ISession, string> {
|
|||||||
deleteSessionsForUser(userId: number): Promise<void>;
|
deleteSessionsForUser(userId: number): Promise<void>;
|
||||||
insertSession(data: Omit<ISession, 'createdAt'>): Promise<ISession>;
|
insertSession(data: Omit<ISession, 'createdAt'>): Promise<ISession>;
|
||||||
getSessionsCount(): Promise<{ userId: number; count: number }[]>;
|
getSessionsCount(): Promise<{ userId: number; count: number }[]>;
|
||||||
|
getMaxSessionsCount(): Promise<number>;
|
||||||
}
|
}
|
||||||
|
@ -5,13 +5,14 @@ import {
|
|||||||
} from '../../helpers/test-helper';
|
} from '../../helpers/test-helper';
|
||||||
import getLogger from '../../../fixtures/no-logger';
|
import getLogger from '../../../fixtures/no-logger';
|
||||||
import { simpleAuthSettingsKey } from '../../../../lib/types/settings/simple-auth-settings';
|
import { simpleAuthSettingsKey } from '../../../../lib/types/settings/simple-auth-settings';
|
||||||
import { TEST_AUDIT_USER } from '../../../../lib/types';
|
import { RoleName, TEST_AUDIT_USER } from '../../../../lib/types';
|
||||||
|
import { addDays, minutesToMilliseconds } from 'date-fns';
|
||||||
|
|
||||||
let db: ITestDb;
|
let db: ITestDb;
|
||||||
let app: IUnleashTest;
|
let app: IUnleashTest;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('config_api_serial', getLogger);
|
db = await dbInit('config_api_serial', getLogger);
|
||||||
|
|
||||||
app = await setupAppWithCustomConfig(
|
app = await setupAppWithCustomConfig(
|
||||||
db.stores,
|
db.stores,
|
||||||
{
|
{
|
||||||
@ -98,3 +99,72 @@ test('sets ui config with frontendSettings', async () => {
|
|||||||
expect(res.body.frontendApiOrigins).toEqual(frontendApiOrigins),
|
expect(res.body.frontendApiOrigins).toEqual(frontendApiOrigins),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('maxSessionsCount', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// prevent memoization of session count
|
||||||
|
await app?.destroy();
|
||||||
|
app = await setupAppWithCustomConfig(
|
||||||
|
db.stores,
|
||||||
|
{
|
||||||
|
experimental: {
|
||||||
|
flags: {
|
||||||
|
strictSchemaValidation: true,
|
||||||
|
showUserDeviceCount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
db.rawDatabase,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return max sessions count', async () => {
|
||||||
|
const { body: noLoggedInUsers } = await app.request
|
||||||
|
.get(`/api/admin/ui-config`)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(noLoggedInUsers.maxSessionsCount).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should count number of session per user', async () => {
|
||||||
|
const email = 'user@getunleash.io';
|
||||||
|
|
||||||
|
const adminRole = (await db.stores.roleStore.getRootRoles()).find(
|
||||||
|
(r) => r.name === RoleName.ADMIN,
|
||||||
|
)!;
|
||||||
|
const user = await app.services.userService.createUser(
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
password: 'test password',
|
||||||
|
rootRole: adminRole.id,
|
||||||
|
},
|
||||||
|
TEST_AUDIT_USER,
|
||||||
|
);
|
||||||
|
|
||||||
|
const userSession = (index: number) => ({
|
||||||
|
sid: `sid${index}`,
|
||||||
|
sess: {
|
||||||
|
cookie: {
|
||||||
|
originalMaxAge: minutesToMilliseconds(48),
|
||||||
|
expires: addDays(Date.now(), 1).toDateString(),
|
||||||
|
secure: false,
|
||||||
|
httpOnly: true,
|
||||||
|
path: '/',
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await app.services.sessionService.insertSession(userSession(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { body: withSessions } = await app.request
|
||||||
|
.get(`/api/admin/ui-config`)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(withSessions.maxSessionsCount).toEqual(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
4
src/test/fixtures/fake-session-store.ts
vendored
4
src/test/fixtures/fake-session-store.ts
vendored
@ -56,4 +56,8 @@ export default class FakeSessionStore implements ISessionStore {
|
|||||||
async getSessionsCount(): Promise<{ userId: number; count: number }[]> {
|
async getSessionsCount(): Promise<{ userId: number; count: number }[]> {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMaxSessionsCount(): Promise<number> {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user