Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(events-subscription): allow to instantly refresh permissions when they change #692

Merged
merged 7 commits into from
May 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(),
]);

return super.start(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);
Thenkei marked this conversation as resolved.
Show resolved Hide resolved
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));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we close the eventSource here and try to start another one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's already handled. The event source tries to reconnect every second. I can look for the exact option to make it clear.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, thanks. Is there a configuration option regarding the delay?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I never responded sorry the delay is 1000ms and not configurable without some magical access to the eventSource object.


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');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be inside this PR, as this is not handled yet?

Copy link
Contributor Author

@Thenkei Thenkei May 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I implemented everything and than remove some parts. Do you think it's an issue since no one is listening for RefreshCustomizations? (The basement is here, just the additional behavior has not been implemented in this PR since the code was on the alpha branch)

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