Skip to content

Commit

Permalink
feat(events-subscription): allow to instantly refresh permissions whe…
Browse files Browse the repository at this point in the history
…n they change (#692)
  • Loading branch information
Thenkei authored May 25, 2023
1 parent 2d09e79 commit e108183
Show file tree
Hide file tree
Showing 33 changed files with 2,481 additions and 1,674 deletions.
1 change: 1 addition & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/// <reference types="jest-extended" />

declare module 'forest-ip-utils';
declare module 'eventsource';
2 changes: 2 additions & 0 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
!isProduction && typingsPath
? this.customizer.updateTypesOnFileSystem(typingsPath, typingsMaxDepth)
: Promise.resolve(),

this.options.forestAdminClient.subscribeToServerEvents(),
]);

await this.mount(router);
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type AgentOptions = {
schemaPath?: string;
typingsPath?: string | null;
typingsMaxDepth?: number;
instantCacheRefresh?: boolean;
permissionsCacheDurationInSeconds?: number;
skipSchemaUpdate?: boolean;
forestAdminClient?: ForestAdminClient;
Expand Down
36 changes: 24 additions & 12 deletions packages/agent/src/utils/options-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import path from 'path';
import { AgentOptions, AgentOptionsWithDefaults } from '../types';

const DEFAULT_MINIMUM_CACHE_DURATION = 60;
// One year cache duration when using events
const DEFAULT_CACHE_DURATION_WITH_EVENTS = 31560000;

export default class OptionsValidator {
private static loggerPrefix = {
Expand All @@ -31,22 +33,22 @@ export default class OptionsValidator {
copyOptions.forestServerUrl = copyOptions.forestServerUrl || 'https://api.forestadmin.com';
copyOptions.typingsMaxDepth = copyOptions.typingsMaxDepth ?? 5;
copyOptions.prefix = copyOptions.prefix || '';
copyOptions.permissionsCacheDurationInSeconds =
copyOptions.permissionsCacheDurationInSeconds ?? 15 * 60;
copyOptions.loggerLevel = copyOptions.loggerLevel || 'Info';
copyOptions.skipSchemaUpdate = copyOptions.skipSchemaUpdate || false;
copyOptions.instantCacheRefresh = copyOptions.instantCacheRefresh ?? true;

copyOptions.forestAdminClient =
copyOptions.forestAdminClient ||
createForestAdminClient({
envSecret: copyOptions.envSecret,
forestServerUrl: copyOptions.forestServerUrl,
logger: copyOptions.logger,
permissionsCacheDurationInSeconds: copyOptions.permissionsCacheDurationInSeconds,
});
if (copyOptions.instantCacheRefresh && copyOptions.permissionsCacheDurationInSeconds) {
copyOptions.logger(
'Warn',
'ignoring options.permissionsCacheDurationInSeconds: when using ' +
'options.instantCacheRefresh=true permissions caches are instantly refreshed',
);
}

copyOptions.permissionsCacheDurationInSeconds =
copyOptions.permissionsCacheDurationInSeconds ?? 15 * DEFAULT_MINIMUM_CACHE_DURATION;
// When using the event source to refresh cache we set a one year cache duration
copyOptions.permissionsCacheDurationInSeconds = copyOptions.instantCacheRefresh
? DEFAULT_CACHE_DURATION_WITH_EVENTS
: copyOptions.permissionsCacheDurationInSeconds ?? DEFAULT_MINIMUM_CACHE_DURATION * 15;

if (copyOptions.permissionsCacheDurationInSeconds < DEFAULT_MINIMUM_CACHE_DURATION) {
copyOptions.permissionsCacheDurationInSeconds = DEFAULT_MINIMUM_CACHE_DURATION;
Expand All @@ -57,6 +59,16 @@ export default class OptionsValidator {
);
}

copyOptions.forestAdminClient =
copyOptions.forestAdminClient ||
createForestAdminClient({
envSecret: copyOptions.envSecret,
forestServerUrl: copyOptions.forestServerUrl,
logger: copyOptions.logger,
permissionsCacheDurationInSeconds: copyOptions.permissionsCacheDurationInSeconds,
instantCacheRefresh: copyOptions.instantCacheRefresh,
});

return {
loggerLevel: 'Info',
...copyOptions,
Expand Down
1 change: 1 addition & 0 deletions packages/agent/test/__factories__/forest-admin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const forestAdminClientFactory = ForestAdminClientFactory.define(() => ({
},
getOpenIdClient: jest.fn(),
getUserInfo: jest.fn(),
subscribeToServerEvents: jest.fn(),
}));

export default forestAdminClientFactory;
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default Factory.define<AgentOptionsWithDefaults>(() => ({
logger: () => {},
loggerLevel: 'Error',
permissionsCacheDurationInSeconds: 15 * 60,
instantCacheRefresh: false,
prefix: 'prefix',
schemaPath: '/tmp/.testschema.json',
skipSchemaUpdate: false,
Expand Down
23 changes: 22 additions & 1 deletion packages/agent/test/utils/http-driver-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ describe('OptionsValidator', () => {
expect(options).toHaveProperty('prefix', '');
expect(options).toHaveProperty('schemaPath', '.forestadmin-schema.json');
expect(options).toHaveProperty('typingsMaxDepth', 5);
expect(options).toHaveProperty('permissionsCacheDurationInSeconds', 900);
expect(options).toHaveProperty('instantCacheRefresh', true);
expect(options).toHaveProperty('permissionsCacheDurationInSeconds', 31560000);
expect(options).toHaveProperty('skipSchemaUpdate', false);
});

Expand Down Expand Up @@ -79,6 +80,7 @@ describe('OptionsValidator', () => {
const options = OptionsValidator.withDefaults({
...mandatoryOptions,
logger: jest.fn(),
instantCacheRefresh: false,
permissionsCacheDurationInSeconds: 1,
});

Expand All @@ -92,11 +94,30 @@ describe('OptionsValidator', () => {
test('should allow user to configure it with realistic value', () => {
const options = OptionsValidator.withDefaults({
...mandatoryOptions,
instantCacheRefresh: false,
permissionsCacheDurationInSeconds: 5 * 60,
});

expect(options).toHaveProperty('permissionsCacheDurationInSeconds', 300);
});

describe('when using Server Events (instantCacheRefresh=true)', () => {
test('should set permissionsCacheDurationInSeconds to 1 year', () => {
const options = OptionsValidator.withDefaults({
...mandatoryOptions,
logger: jest.fn(),
instantCacheRefresh: true,
permissionsCacheDurationInSeconds: 5 * 60,
});

expect(options).toHaveProperty('permissionsCacheDurationInSeconds', 31560000);
expect(options.logger).toHaveBeenCalledWith(
'Warn',
'ignoring options.permissionsCacheDurationInSeconds: when using ' +
'options.instantCacheRefresh=true permissions caches are instantly refreshed',
);
});
});
});
});

Expand Down
1 change: 1 addition & 0 deletions packages/forestadmin-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"directory": "packages/forestadmin-client"
},
"dependencies": {
"eventsource": "2.0.2",
"json-api-serializer": "^2.6.6",
"jsonwebtoken": "^9.0.0",
"lru-cache": "^7.14.1",
Expand Down
27 changes: 24 additions & 3 deletions packages/forestadmin-client/src/build-application-services.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import AuthService from './auth';
import ChartHandler from './charts/chart-handler';
import EventsSubscriptionService from './events-subscription';
import NativeRefreshEventsHandlerService from './events-subscription/native-refresh-events-handler-service';
import IpWhiteListService from './ip-whitelist';
import ActionPermissionService from './permissions/action-permission';
import PermissionService from './permissions/permission-with-cache';
Expand All @@ -26,31 +28,50 @@ export default function buildApplicationServices(
permission: PermissionService;
chartHandler: ChartHandler;
auth: AuthService;
eventsSubscription: EventsSubscriptionService;
} {
const optionsWithDefaults = {
forestServerUrl: 'https://api.forestadmin.com',
permissionsCacheDurationInSeconds: 15 * 60,
logger: defaultLogger,
instantCacheRefresh: true,
...options,
};

const usersPermission = new UserPermissionService(
optionsWithDefaults,
forestAdminServerInterface,
);

const renderingPermission = new RenderingPermissionService(
optionsWithDefaults,
new UserPermissionService(optionsWithDefaults, forestAdminServerInterface),
usersPermission,
forestAdminServerInterface,
);

const actionPermission = new ActionPermissionService(
optionsWithDefaults,
forestAdminServerInterface,
);

const contextVariables = new ContextVariablesInstantiator(renderingPermission);

const permission = new PermissionService(
new ActionPermissionService(optionsWithDefaults, forestAdminServerInterface),
const permission = new PermissionService(actionPermission, renderingPermission);

const eventsHandler = new NativeRefreshEventsHandlerService(
actionPermission,
usersPermission,
renderingPermission,
);

const eventsSubscription = new EventsSubscriptionService(optionsWithDefaults, eventsHandler);

return {
renderingPermission,
optionsWithDefaults,
permission,
contextVariables,
eventsSubscription,
chartHandler: new ChartHandler(contextVariables),
ipWhitelist: new IpWhiteListService(optionsWithDefaults),
schema: new SchemaService(optionsWithDefaults),
Expand Down
87 changes: 87 additions & 0 deletions packages/forestadmin-client/src/events-subscription/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import EventSource from 'eventsource';

import { RefreshEventsHandlerService, ServerEvent, ServerEventType } from './types';
import { ForestAdminClientOptionsWithDefaults } from '../types';

export default class EventsSubscriptionService {
constructor(
private readonly options: ForestAdminClientOptionsWithDefaults,
private readonly refreshEventsHandlerService: RefreshEventsHandlerService,
) {}

async subscribeEvents(): Promise<void> {
if (!this.options.instantCacheRefresh) {
this.options.logger(
'Debug',
'Event source deactivated.. Use agent option [instantCacheRefresh=true] ' +
'if you want to activate them',
);

return;
}

const eventSourceConfig = {
// forest-secret-key act as the credential
withCredentials: false,
headers: { 'forest-secret-key': this.options.envSecret },
https: {
rejectUnauthorized: process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0',
},
};
const url = new URL('/liana/v4/subscribe-to-events', this.options.forestServerUrl).toString();

const source = new EventSource(url, eventSourceConfig);

source.addEventListener('error', this.onEventError.bind(this));

source.addEventListener('open', () => this.onEventOpen());

source.addEventListener(ServerEventType.RefreshUsers, async () =>
this.refreshEventsHandlerService.refreshUsers(),
);

source.addEventListener(ServerEventType.RefreshRoles, async () =>
this.refreshEventsHandlerService.refreshRoles(),
);

source.addEventListener(ServerEventType.RefreshRenderings, async (event: ServerEvent) =>
this.handleSeverEventRefreshRenderings(event),
);

source.addEventListener(ServerEventType.RefreshCustomizations, async () =>
this.refreshEventsHandlerService.refreshCustomizations(),
);
}

private async handleSeverEventRefreshRenderings(event: ServerEvent) {
if (!event.data) {
this.options.logger('Debug', 'Server Event - RefreshRenderings missing required data.');

return;
}

const { renderingIds } = JSON.parse(event.data as unknown as string);
await this.refreshEventsHandlerService.refreshRenderings(renderingIds);
}

private onEventError(event: { type: string; status?: number; message?: string }) {
if (event.status === 502) {
this.options.logger('Debug', 'Server Event - Connection lost');

return;
}

if (event.message)
this.options.logger('Warn', `Server Event - Error: ${JSON.stringify(event)}`);
}

private onEventOpen() {
this.options.logger(
'Debug',
'Server Event - Open EventSource (SSE) connection with Forest Admin servers',
);

// Flush all previous data as we could have missed some events
this.refreshEventsHandlerService.refreshEverything();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import EventEmitter from 'events';

import { RefreshEventsHandlerService } from './types';
import ActionPermissionService from '../permissions/action-permission';
import RenderingPermissionService from '../permissions/rendering-permission';
import UserPermissionService from '../permissions/user-permission';

export default class NativeRefreshEventsHandlerService
extends EventEmitter
implements RefreshEventsHandlerService
{
constructor(
private readonly actionPermissionService: ActionPermissionService,
private readonly usersPermissionService: UserPermissionService,
private readonly renderingPermissionService: RenderingPermissionService,
) {
super();
}

public refreshUsers() {
this.usersPermissionService.invalidateCache();
}

public refreshRoles() {
this.actionPermissionService.invalidateCache();
}

public refreshRenderings(renderingIds: (string | number)[]) {
for (const renderingId of renderingIds)
this.renderingPermissionService.invalidateCache(renderingId);
}

public refreshCustomizations() {
this.emit('RefreshCustomizations');
}

public refreshEverything() {
this.usersPermissionService.invalidateCache();
this.actionPermissionService.invalidateCache();
this.renderingPermissionService.invalidateAllCache();

// Emit RefreshCustomizations event
this.emit('RefreshCustomizations');
}
}
22 changes: 22 additions & 0 deletions packages/forestadmin-client/src/events-subscription/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import EventEmitter from 'events';

export enum ServerEventType {
RefreshUsers = 'refresh-users',
RefreshRoles = 'refresh-roles',
RefreshRenderings = 'refresh-renderings',
RefreshCustomizations = 'refresh-customizations',
}

export type ServerEvent = MessageEvent<{
type: `${ServerEventType}`;
data?: string;
}>;

export interface RefreshEventsHandlerService extends EventEmitter {
refreshUsers: () => Promise<void> | void;
refreshRoles: () => Promise<void> | void;
refreshRenderings: (renderingIds: [string | number]) => Promise<void> | void;
refreshCustomizations: () => Promise<void> | void;

refreshEverything: () => Promise<void> | void;
}
Loading

0 comments on commit e108183

Please sign in to comment.