mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +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 { Demo } from './demo/Demo';
|
||||
import { LoginRedirect } from './common/LoginRedirect/LoginRedirect';
|
||||
import { SecurityBanner } from './banners/internalBanners/SecurityBanner';
|
||||
|
||||
const StyledContainer = styled('div')(() => ({
|
||||
'& ul': {
|
||||
@ -66,6 +67,7 @@ export const App = () => {
|
||||
show={<MaintenanceBanner />}
|
||||
/>
|
||||
<LicenseBanner />
|
||||
<SecurityBanner />
|
||||
<ExternalBanners />
|
||||
<InternalBanners />
|
||||
<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;
|
||||
samlConfiguredThroughEnv?: boolean;
|
||||
unleashAIAvailable?: boolean;
|
||||
maxSessionsCount?: number;
|
||||
}
|
||||
|
||||
export interface IProclamationToast {
|
||||
|
@ -36,6 +36,8 @@ export interface UiConfigSchema {
|
||||
links?: UiConfigSchemaLinksItem[];
|
||||
/** Whether maintenance mode is currently active or not. */
|
||||
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. */
|
||||
name?: string;
|
||||
/** Whether to enable the Unleash network view or not. */
|
||||
|
@ -10,8 +10,15 @@
|
||||
export interface UserSchema {
|
||||
/** A user is either an actual User or a Service Account */
|
||||
accountType?: string;
|
||||
/**
|
||||
* Count of active browser sessions for this user
|
||||
* @nullable
|
||||
*/
|
||||
activeSessions?: number | null;
|
||||
/** The user was created at this time */
|
||||
createdAt?: string;
|
||||
/** Experimental. The number of deleted browser sessions after last login */
|
||||
deletedSessions?: number;
|
||||
/** Email of the user */
|
||||
email?: string;
|
||||
/** Is the welcome email sent to the user or not */
|
||||
@ -59,6 +66,4 @@ export interface UserSchema {
|
||||
* @nullable
|
||||
*/
|
||||
username?: string | null;
|
||||
deletedSessions?: number;
|
||||
activeSessions?: number;
|
||||
}
|
||||
|
@ -120,6 +120,17 @@ export default class SessionStore implements ISessionStore {
|
||||
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;
|
||||
|
@ -191,6 +191,11 @@ export const uiConfigSchema = {
|
||||
description: 'Whether Unleash AI is available.',
|
||||
example: false,
|
||||
},
|
||||
maxSessionsCount: {
|
||||
type: 'number',
|
||||
description: 'The maximum number of sessions that a user has.',
|
||||
example: 10,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
|
@ -23,9 +23,10 @@ import type { IAuthRequest } from '../unleash-types';
|
||||
import NotFoundError from '../../error/notfound-error';
|
||||
import type { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-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 ClientInstanceService from '../../features/metrics/instance/instance-service';
|
||||
import type { IFlagResolver } from '../../types';
|
||||
|
||||
class ConfigController extends Controller {
|
||||
private versionService: VersionService;
|
||||
@ -38,8 +39,12 @@ class ConfigController extends Controller {
|
||||
|
||||
private clientInstanceService: ClientInstanceService;
|
||||
|
||||
private sessionService: SessionService;
|
||||
|
||||
private maintenanceService: MaintenanceService;
|
||||
|
||||
private flagResolver: IFlagResolver;
|
||||
|
||||
private readonly openApiService: OpenApiService;
|
||||
|
||||
constructor(
|
||||
@ -52,6 +57,7 @@ class ConfigController extends Controller {
|
||||
frontendApiService,
|
||||
maintenanceService,
|
||||
clientInstanceService,
|
||||
sessionService,
|
||||
}: Pick<
|
||||
IUnleashServices,
|
||||
| 'versionService'
|
||||
@ -61,6 +67,7 @@ class ConfigController extends Controller {
|
||||
| 'frontendApiService'
|
||||
| 'maintenanceService'
|
||||
| 'clientInstanceService'
|
||||
| 'sessionService'
|
||||
>,
|
||||
) {
|
||||
super(config);
|
||||
@ -71,6 +78,8 @@ class ConfigController extends Controller {
|
||||
this.frontendApiService = frontendApiService;
|
||||
this.maintenanceService = maintenanceService;
|
||||
this.clientInstanceService = clientInstanceService;
|
||||
this.sessionService = sessionService;
|
||||
this.flagResolver = config.flagResolver;
|
||||
this.route({
|
||||
method: 'get',
|
||||
path: '',
|
||||
@ -113,14 +122,24 @@ class ConfigController extends Controller {
|
||||
req: AuthedRequest,
|
||||
res: Response<UiConfigSchema>,
|
||||
): Promise<void> {
|
||||
const [frontendSettings, simpleAuthSettings, maintenanceMode] =
|
||||
await Promise.all([
|
||||
this.frontendApiService.getFrontendSettings(false),
|
||||
this.settingService.get<SimpleAuthSettings>(
|
||||
simpleAuthSettingsKey,
|
||||
),
|
||||
this.maintenanceService.isMaintenanceMode(),
|
||||
]);
|
||||
const getMaxSessionsCount = async () => {
|
||||
if (this.flagResolver.isEnabled('showUserDeviceCount')) {
|
||||
return this.sessionService.getMaxSessionsCount();
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const [
|
||||
frontendSettings,
|
||||
simpleAuthSettings,
|
||||
maintenanceMode,
|
||||
maxSessionsCount,
|
||||
] = await Promise.all([
|
||||
this.frontendApiService.getFrontendSettings(false),
|
||||
this.settingService.get<SimpleAuthSettings>(simpleAuthSettingsKey),
|
||||
this.maintenanceService.isMaintenanceMode(),
|
||||
getMaxSessionsCount(),
|
||||
]);
|
||||
|
||||
const disablePasswordAuth =
|
||||
simpleAuthSettings?.disabled ||
|
||||
@ -153,6 +172,7 @@ class ConfigController extends Controller {
|
||||
maintenanceMode,
|
||||
feedbackUriPath: this.config.feedbackUriPath,
|
||||
unleashAIAvailable: this.config.openAIAPIKey !== undefined,
|
||||
maxSessionsCount,
|
||||
};
|
||||
|
||||
this.openApiService.respondWithValidation(
|
||||
|
@ -2,12 +2,14 @@ import type { IUnleashStores } from '../types/stores';
|
||||
import type { IUnleashConfig } from '../types/option';
|
||||
import type { Logger } from '../logger';
|
||||
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 {
|
||||
private logger: Logger;
|
||||
|
||||
private sessionStore: ISessionStore;
|
||||
private resolveMaxSessions: () => Promise<number>;
|
||||
|
||||
constructor(
|
||||
{ sessionStore }: Pick<IUnleashStores, 'sessionStore'>,
|
||||
@ -15,6 +17,14 @@ export default class SessionService {
|
||||
) {
|
||||
this.logger = getLogger('lib/services/session-service.ts');
|
||||
this.sessionStore = sessionStore;
|
||||
|
||||
this.resolveMaxSessions = memoizee(
|
||||
async () => await this.sessionStore.getMaxSessionsCount(),
|
||||
{
|
||||
promise: true,
|
||||
maxAge: minutesToMilliseconds(1),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getActiveSessions(): Promise<ISession[]> {
|
||||
@ -69,6 +79,10 @@ export default class SessionService {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getMaxSessionsCount() {
|
||||
return this.resolveMaxSessions();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SessionService;
|
||||
|
@ -282,6 +282,10 @@ const flags: IFlags = {
|
||||
process.env.UNLEASH_EXPERIMENTAL_ENTERPRISE_PAYG,
|
||||
false,
|
||||
),
|
||||
showUserDeviceCount: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_SHOW_USER_DEVICE_COUNT,
|
||||
false,
|
||||
),
|
||||
simplifyProjectOverview: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_SIMPLIFY_PROJECT_OVERVIEW,
|
||||
false,
|
||||
|
@ -13,4 +13,5 @@ export interface ISessionStore extends Store<ISession, string> {
|
||||
deleteSessionsForUser(userId: number): Promise<void>;
|
||||
insertSession(data: Omit<ISession, 'createdAt'>): Promise<ISession>;
|
||||
getSessionsCount(): Promise<{ userId: number; count: number }[]>;
|
||||
getMaxSessionsCount(): Promise<number>;
|
||||
}
|
||||
|
@ -5,13 +5,14 @@ import {
|
||||
} from '../../helpers/test-helper';
|
||||
import getLogger from '../../../fixtures/no-logger';
|
||||
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 app: IUnleashTest;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('config_api_serial', getLogger);
|
||||
|
||||
app = await setupAppWithCustomConfig(
|
||||
db.stores,
|
||||
{
|
||||
@ -98,3 +99,72 @@ test('sets ui config with frontendSettings', async () => {
|
||||
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 }[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getMaxSessionsCount(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user