1
0
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:
Tymoteusz Czech 2024-11-18 16:15:56 +01:00 committed by GitHub
parent 695873132e
commit 7820ca62ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 185 additions and 14 deletions

View File

@ -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>

View File

@ -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} />;
};

View File

@ -34,6 +34,7 @@ export interface IUiConfig {
oidcConfiguredThroughEnv?: boolean;
samlConfiguredThroughEnv?: boolean;
unleashAIAvailable?: boolean;
maxSessionsCount?: number;
}
export interface IProclamationToast {

View File

@ -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. */

View File

@ -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;
}

View File

@ -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;

View File

@ -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: {

View File

@ -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(

View File

@ -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;

View File

@ -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,

View File

@ -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>;
}

View File

@ -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);
});
});

View File

@ -56,4 +56,8 @@ export default class FakeSessionStore implements ISessionStore {
async getSessionsCount(): Promise<{ userId: number; count: number }[]> {
return [];
}
async getMaxSessionsCount(): Promise<number> {
return 0;
}
}