@universis/janitor 2.1.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ import {ApplicationBase, ApplicationService} from '@themost/common';
2
+ import {Handler} from 'express';
3
+
4
+ export declare class NetworkRestrictAccess extends ApplicationService {
5
+ constructor(app: ApplicationBase);
6
+ }
7
+
8
+ export declare class EnableNetworkRestrictAccess extends NetworkRestrictAccess {
9
+ constructor(app: ApplicationBase);
10
+ }
11
+
12
+ export declare class DisableNetworkRestrictAccess extends NetworkRestrictAccess {
13
+ constructor(app: ApplicationBase);
14
+ }
15
+
16
+ export declare function validateNetworkAccess(): Handler;
@@ -0,0 +1,181 @@
1
+ import {ApplicationService, HttpForbiddenError, TraceUtils} from '@themost/common';
2
+ import {SchemaLoaderStrategy} from '@themost/data';
3
+ import ipRangeCheck from 'ip-range-check';
4
+ import express from 'express';
5
+ import { take } from 'rxjs/operators';
6
+ import { AccountNetworkAddress } from './config/models/AccountNetworkAddress';
7
+ import path from 'path';
8
+
9
+ class HttpNetworkAccessForbiddenError extends HttpForbiddenError {
10
+ constructor() {
11
+ super('Access is denied due to network address conflict.');
12
+ this.statusCode = 403.6;
13
+ }
14
+
15
+ }
16
+
17
+ class NetworkRestrictAccess extends ApplicationService {
18
+ constructor(app) {
19
+ super(app);
20
+ Object.defineProperty(this, 'proxyAddressForwarding', {
21
+ configurable: true,
22
+ enumerable: false,
23
+ value: !!app.getConfiguration().getSourceAt('settings/universis/api/proxyAddressForwarding'),
24
+ writable: false
25
+ })
26
+ this.apply();
27
+ }
28
+
29
+ apply() {
30
+ // get schema loader
31
+ const schemaLoader = this.getApplication()
32
+ .getConfiguration()
33
+ .getStrategy(SchemaLoaderStrategy);
34
+ const accountNetworkAddressModel = schemaLoader.getModelDefinition('AccountNetworkAddress');
35
+ if (accountNetworkAddressModel == null) {
36
+ schemaLoader.setModelDefinition(AccountNetworkAddress);
37
+ }
38
+ // get model definition
39
+ const model = schemaLoader.getModelDefinition('Account');
40
+ if (model == null) {
41
+ TraceUtils.warn('NetworkRestrictAccess: Account model definition not found.');
42
+ return;
43
+ }
44
+ const found = model.fields.find((field) => {
45
+ return field.name === 'networkAddress';
46
+ });
47
+ if (!found) {
48
+ model.fields.push({
49
+ "name": "networkAddress",
50
+ "type": "Text",
51
+ "many": true,
52
+ "mapping": {
53
+ "parentModel": "Account",
54
+ "parentField": "id",
55
+ "associationType": "junction",
56
+ "associationAdapter": "AccountNetworkAddress",
57
+ "cascade": "delete",
58
+ "associationObjectField": "account",
59
+ "associationValueField": "value"
60
+ }
61
+ });
62
+ }
63
+ model.eventListeners = model.eventListeners || [];
64
+ let index = model.eventListeners.findIndex((listener) => {
65
+ return listener.type === '@themost/data/previous-state-listener';
66
+ });
67
+ if (index < 0) {
68
+ model.eventListeners.push({
69
+ type: '@themost/data/previous-state-listener'
70
+ });
71
+ index = model.eventListeners.length - 1;
72
+ }
73
+ let otherIndex = model.eventListeners.findIndex((listener) => {
74
+ return listener.type === path.resolve(__dirname, './OnAccountUpdate');
75
+ });
76
+ if (otherIndex < 0) {
77
+ model.eventListeners.splice(index, 0, {
78
+ type: path.resolve(__dirname, './OnAccountUpdate')
79
+ });
80
+ }
81
+ schemaLoader.setModelDefinition(model);
82
+ }
83
+
84
+ /**
85
+ * Gets remote address from request
86
+ * @param {import('express').Request} req
87
+ * @returns
88
+ */
89
+ getRemoteAddress(req) {
90
+ let remoteAddress;
91
+ if (this.proxyAddressForwarding) {
92
+ // get proxy headers or remote address
93
+ // noinspection JSDeprecatedSymbols
94
+ remoteAddress = req.headers['x-real-ip'] || req.headers['x-forwarded-for'] || (req.connection ? req.connection.remoteAddress : req.socket.remoteAddress);
95
+ } else {
96
+ // noinspection JSDeprecatedSymbols
97
+ remoteAddress = req.connection ? req.connection.remoteAddress : req.socket.remoteAddress;
98
+ }
99
+ return remoteAddress;
100
+ }
101
+ }
102
+
103
+
104
+
105
+ /**
106
+ * Validate network address for the current request against the assigned network addresses for the user account.
107
+ * @returns {import('express').Handler}
108
+ */
109
+ function validateNetworkAddress() {
110
+ /**
111
+ * @param {import('express').Request} req
112
+ * @param {import('express').Response} res
113
+ * @param {import('express').NextFunction} next
114
+ */
115
+ return function(req, res, next) {
116
+ try {
117
+ const service = req.context.getApplication().getService(NetworkRestrictAccess);
118
+ if (service == null) {
119
+ return next();
120
+ }
121
+ req.context.user$.pipe(
122
+ take(1)
123
+ ).subscribe({
124
+ next: (user) => {
125
+ if (user == null) {
126
+ return next();
127
+ }
128
+ if (Array.isArray(user.networkAddress)) {
129
+ if (user.networkAddress.length > 0) {
130
+ // get remote address
131
+ const remoteAddress = service.getRemoteAddress(req);
132
+ const result = ipRangeCheck(remoteAddress, user.networkAddress);
133
+ if (result !== true) {
134
+ return next(new HttpNetworkAccessForbiddenError());
135
+ }
136
+ }
137
+ }
138
+ return next();
139
+ },
140
+ error: (err) => {
141
+ return next(err);
142
+ }
143
+ });
144
+ } catch (err) {
145
+ return next(err);
146
+ }
147
+
148
+ }
149
+ }
150
+
151
+ class EnableNetworkRestrictAccess extends NetworkRestrictAccess {
152
+ constructor(app) {
153
+ super(app);
154
+ if (app && app.serviceRouter) {
155
+ app.serviceRouter.subscribe((serviceRouter) => {
156
+ if (serviceRouter == null) {
157
+ return;
158
+ }
159
+ const addRouter = express.Router();
160
+ addRouter.use(validateNetworkAddress());
161
+ // insert router at the beginning of serviceRouter.stack
162
+ serviceRouter.stack.unshift.apply(serviceRouter.stack, addRouter.stack);
163
+ });
164
+ }
165
+ }
166
+ }
167
+
168
+ class DisableNetworkRestrictAccess extends NetworkRestrictAccess {
169
+ constructor(app) {
170
+ super(app);
171
+ }
172
+ }
173
+
174
+ export {
175
+ NetworkRestrictAccess,
176
+ EnableNetworkRestrictAccess,
177
+ DisableNetworkRestrictAccess,
178
+ HttpNetworkAccessForbiddenError,
179
+ validateNetworkAddress
180
+ }
181
+
@@ -0,0 +1,55 @@
1
+ import {TraceUtils} from '@themost/common';
2
+
3
+ /**
4
+ *
5
+ * @param {import('@themost/data').DataEventArgs} event
6
+ * @param {Function} callback
7
+ */
8
+ export function afterChange(event, callback) {
9
+ try {
10
+ if (event.previous == null) {
11
+ return callback();
12
+ }
13
+ const { context } = event.model;
14
+ const { name, additionalType } = event.previous;
15
+ /**
16
+ * @type {import('@themost/data').DataCacheStrategy}
17
+ */
18
+ const service = context.getConfiguration().getStrategy(function DataCacheStrategy() {});
19
+ if (service) {
20
+ return service.remove(`/${additionalType}/${name}`).then(() => {
21
+ return callback();
22
+ }).catch((err) => {
23
+ TraceUtils.warn(`Unable to remove ${name} from cache`);
24
+ TraceUtils.error(err);
25
+ return callback();
26
+ });
27
+ }
28
+ return callback();
29
+ } catch (err) {
30
+ return callback();
31
+ }
32
+ }
33
+
34
+ /**
35
+ *
36
+ * @param {import('@themost/data').DataEventArgs} event
37
+ * @param {Function} callback
38
+ */
39
+ function afterSave(event, callback) {
40
+ return afterChange(event, callback);
41
+ }
42
+
43
+ /**
44
+ *
45
+ * @param {import('@themost/data').DataEventArgs} event
46
+ * @param {Function} callback
47
+ */
48
+ function afterRemove(event, callback) {
49
+ return afterChange(event, callback);
50
+ }
51
+
52
+ export {
53
+ afterSave,
54
+ afterRemove
55
+ }
@@ -0,0 +1,7 @@
1
+ import {ApplicationService} from '@themost/common';
2
+ import {OutputCachingElement, OutputCachingProfile} from './outputCacheRouter';
3
+
4
+ export declare class OutputCachingService extends ApplicationService{
5
+ profiles: Map<string, OutputCachingProfile>;
6
+ elements: OutputCachingElement[];
7
+ }
@@ -0,0 +1,47 @@
1
+ import { ApplicationService } from '@themost/common';
2
+ import { outputCache } from './outputCacheRouter';
3
+ import path from 'path';
4
+ class OutputCachingService extends ApplicationService {
5
+ constructor(app) {
6
+ super(app);
7
+ let options = app.getConfiguration().getSourceAt('settings/universis/outputCaching') || {
8
+ profiles: [],
9
+ elements: []
10
+ };
11
+ if (typeof options.extends === 'string') {
12
+ const configurationDir = this.getApplication().getConfiguration().getConfigurationPath();
13
+ const parentOptions = require(path.resolve(configurationDir, options.extends));
14
+ options = Object.assign({}, parentOptions, options);
15
+ }
16
+ /**
17
+ * @type {Map<string, OutputCachingProfile>}
18
+ */
19
+ this.profiles = new Map(options.profiles || []);
20
+ /**
21
+ * @type {OutputCachingElement[]}
22
+ */
23
+ this.elements = options.elements || [];
24
+ if (app && app.container) {
25
+ app.container.subscribe((container) => {
26
+ if (container == null) {
27
+ return;
28
+ }
29
+ container.use(outputCache({
30
+ profiles: this.profiles,
31
+ elements: this.elements
32
+ }));
33
+ const stack = container._router && container._router.stack;
34
+ if (Array.isArray(stack)) {
35
+ let index = stack.findIndex((item) => item.name === 'dataContextMiddleware' );
36
+ if (index > -1) {
37
+ stack.splice(index, 0, stack.pop());
38
+ }
39
+ }
40
+ });
41
+ }
42
+ }
43
+ }
44
+
45
+ export {
46
+ OutputCachingService
47
+ }
@@ -1,10 +1,14 @@
1
- import { ApplicationService } from '@themost/common';
1
+ import {ApplicationService, HttpForbiddenError} from '@themost/common';
2
2
  import { Request } from 'express';
3
3
 
4
4
  export declare class RemoteAddressValidator extends ApplicationService {
5
-
5
+
6
6
  constructor(app: ApplicationService);
7
7
  validateRemoteAddress(request: Request): Promise<boolean>;
8
8
  getRemoteAddress(request: Request): string;
9
9
 
10
+ }
11
+
12
+ export declare class HttpRemoteAddrForbiddenError extends HttpForbiddenError {
13
+ constructor();
10
14
  }
@@ -0,0 +1,9 @@
1
+ import {DataContext, UserService} from '@themost/data';
2
+ import {ApplicationBase} from '@themost/common';
3
+
4
+ export declare class UserWithNetworkAddress extends UserService {
5
+ constructor(app: ApplicationBase);
6
+
7
+ protected queryUser(context: DataContext, username: string): Promise<any>;
8
+ protected queryGroup(context: DataContext, username: string): Promise<any>;
9
+ }
@@ -0,0 +1,94 @@
1
+ import {UserService} from '@themost/data';
2
+
3
+ class UserWithNetworkAddress extends UserService {
4
+ constructor(app) {
5
+ super(app);
6
+ }
7
+
8
+ /**
9
+ * @protected
10
+ * @param {import('@themost/data').DataContext} context
11
+ * @param {string} name
12
+ * @return {import('@themost/data').DataQueryable}
13
+ */
14
+ queryUser(context, name) {
15
+ return context.model('User').asQueryable().expand('groups', 'networkAddress')
16
+ .where('name').equal(name);
17
+ }
18
+
19
+ /**
20
+ * @protected
21
+ * @param {import('@themost/data').DataContext} context
22
+ * @param {string} name
23
+ * @return {import('@themost/data').DataQueryable}
24
+ */
25
+ queryGroup(context, name) {
26
+ return context.model('Group').asQueryable().expand('groups', 'networkAddress')
27
+ .where('name').equal(name);
28
+ }
29
+
30
+ /**
31
+ * Gets user by name
32
+ * @param context
33
+ * @param name
34
+ * @returns {Promise<any>}
35
+ */
36
+ getUser(context, name) {
37
+ // noinspection JSCheckFunctionSignatures
38
+ /**
39
+ * @type {import('./data-cache').DataCacheStrategy}
40
+ */
41
+ // noinspection JSCheckFunctionSignatures
42
+ const cache = context.getConfiguration().getStrategy(function DataCacheStrategy() {});
43
+ if (cache) {
44
+ return cache.getOrDefault(`/User/${name}`, () => {
45
+ return this.queryUser(context, name).silent().getItem().then((user) => {
46
+ return user;
47
+ });
48
+ });
49
+ }
50
+ return this.queryUser(context, name).silent().getItem().then((user) => {
51
+ return user;
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Gets group by name
57
+ * @param context
58
+ * @param name
59
+ * @returns {Promise<any>}
60
+ */
61
+ getGroup(context, name) {
62
+ // noinspection JSCheckFunctionSignatures
63
+ /**
64
+ * @type {import('./data-cache').DataCacheStrategy}
65
+ */
66
+ // noinspection JSCheckFunctionSignatures
67
+ const cache = context.getConfiguration().getStrategy(function DataCacheStrategy() {});
68
+ if (cache) {
69
+ return cache.getOrDefault(`/Group/${name}`, () => {
70
+ return this.queryGroup(context, name).silent().getItem().then((group) => {
71
+ return group;
72
+ });
73
+ });
74
+ }
75
+ return this.queryGroup(context, name).silent().getItem().then((group) => {
76
+ return group;
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Gets anonymous user
82
+ * @param context
83
+ * @returns {Promise<any>}
84
+ */
85
+ getAnonymousUser(context) {
86
+ return this.getUser(context, 'anonymous');
87
+ }
88
+
89
+ }
90
+
91
+ export {
92
+ UserWithNetworkAddress
93
+ }
94
+
@@ -0,0 +1,15 @@
1
+ import {ApplicationBase, ApplicationService, HttpForbiddenError} from '@themost/common';
2
+ import {DataContext} from '@themost/data';
3
+
4
+ export declare class InvalidClientScopeError extends HttpForbiddenError {
5
+ constructor();
6
+ }
7
+
8
+ export declare class VerifyUserClientScope extends ApplicationService {
9
+ constructor(app: ApplicationBase);
10
+ options: { whitelist: string[] };
11
+ verify(context: DataContext): Promise<boolean>;
12
+ }
13
+
14
+ export declare class DisableUserClientScopeVerification extends VerifyUserClientScope {
15
+ }
@@ -0,0 +1,87 @@
1
+ import {ApplicationService, HttpForbiddenError, TraceUtils} from '@themost/common';
2
+ import { ScopeString } from '@universis/janitor';
3
+ import express from 'express';
4
+ import {ExpressDataContext} from '@themost/express';
5
+ import {combineLatest} from 'rxjs';
6
+
7
+ class InvalidClientScopeError extends HttpForbiddenError {
8
+ constructor() {
9
+ super('Access denied due to invalid client scopes.');
10
+ }
11
+ }
12
+
13
+ class VerifyUserClientScope extends ApplicationService {
14
+
15
+ constructor(app) {
16
+ super(app);
17
+ // Sets the pre-defined OpenID Connect client scopes that are whitelisted and should be omitted during validation
18
+ this.options = {
19
+ whitelist: [
20
+ 'profile',
21
+ 'email',
22
+ 'openid'
23
+ ]
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Verify user scope access against its assigned groups,
29
+ * following the client scopes that are available for the user groups.
30
+ * @param {ExpressDataContext} context
31
+ * @param {boolean=} throwError
32
+ * @returns {Promise<boolean>}
33
+ */
34
+ async verify(context, throwError) {
35
+ // noinspection JSUnresolvedReference
36
+ const user = await context.getUser();
37
+ if (user == null) {
38
+ return false;
39
+ }
40
+ const groups = user.groups.map((group) => group.id);
41
+ /**
42
+ * @type {import('@themost/data').DataModel}
43
+ */
44
+ const ClientScopes = context.model('Group').convert({}).property('scopes').getBaseModel();
45
+ // set conditional caching
46
+ ClientScopes.caching = 'conditional';
47
+ /**
48
+ * get client scopes assigned to user groups
49
+ * @type {Array<{ scope: string }>}
50
+ */
51
+ const items = await ClientScopes.where('group').equal(groups).select('scope').cache(true).take(-1).silent().getItems();
52
+ /**
53
+ * @type {string[]}
54
+ */
55
+ const assignedScopes = items.map((item) => item.scope);
56
+ const contextScopes = new ScopeString(context.user.authenticationScope).split().filter((scope) => {
57
+ return this.options.whitelist.includes(scope) === false;
58
+ });
59
+
60
+ const unassigned = contextScopes.filter((scope) => {
61
+ return !assignedScopes.includes(scope);
62
+ });
63
+ if (unassigned.length) {
64
+ if (!!throwError) {
65
+ TraceUtils.error(`Access denied for user "${context.user.name}" due to invalid client scope(s) "${unassigned.join(',')}"`);
66
+ throw new InvalidClientScopeError();
67
+ }
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+ }
73
+
74
+ class DisableUserClientScopeVerification extends VerifyUserClientScope {
75
+ constructor(app) {
76
+ super(app);
77
+ }
78
+ async verify(context, throwError) {
79
+ return true;
80
+ }
81
+ }
82
+
83
+ export {
84
+ InvalidClientScopeError,
85
+ VerifyUserClientScope,
86
+ DisableUserClientScopeVerification
87
+ }
@@ -0,0 +1,56 @@
1
+ const AccountNetworkAddress = {
2
+ "$schema": "https://themost-framework.github.io/themost/models/2018/2/schema.json",
3
+ "@id": "https://universis.io/schemas/AccountNetworkAddress",
4
+ "name": "AccountNetworkAddress",
5
+ "hidden": true,
6
+ "source": "AccountNetworkAddress",
7
+ "view": "AccountNetworkAddress",
8
+ "version": "1.0.0",
9
+ "fields": [
10
+ {
11
+ "name": "id",
12
+ "type": "Counter",
13
+ "nullable": false,
14
+ "primary": true,
15
+ "many": false
16
+ },
17
+ {
18
+ "name": "account",
19
+ "type": "Account",
20
+ "nullable": false,
21
+ "many": false,
22
+ "indexed": true
23
+ },
24
+ {
25
+ "name": "value",
26
+ "type": "Text",
27
+ "nullable": false,
28
+ "many": false,
29
+ "indexed": false
30
+ }
31
+ ],
32
+ "constraints": [
33
+ {
34
+ "type": "unique",
35
+ "fields": [
36
+ "account",
37
+ "value"
38
+ ]
39
+ }
40
+ ],
41
+ "privileges": [
42
+ {
43
+ "mask": 15,
44
+ "type": "global"
45
+ },
46
+ {
47
+ "mask": 15,
48
+ "type": "global",
49
+ "account": "Administrators"
50
+ }
51
+ ]
52
+ }
53
+
54
+ export {
55
+ AccountNetworkAddress
56
+ }
package/src/index.d.ts CHANGED
@@ -7,5 +7,10 @@ export * from './OAuth2ClientService';
7
7
  export * from './RemoteAddressValidator';
8
8
  export * from './AppRateLimitService';
9
9
  export * from './AppSpeedLimitService';
10
- export * from './HttpBearerStategy';
11
- export * from './PassportService';
10
+ export * from './HttpBearerStrategy';
11
+ export * from './PassportService';
12
+ export * from './outputCacheRouter';
13
+ export * from './OutputCachingService';
14
+ export * from './VerifyUserClientScope';
15
+ export * from './UserWithNetworkAddress';
16
+ export * from './NetworkRestrictAccess';
package/src/index.js CHANGED
@@ -10,3 +10,8 @@ export * from './AppRateLimitService';
10
10
  export * from './AppSpeedLimitService';
11
11
  export * from './HttpBearerStategy';
12
12
  export * from './PassportService';
13
+ export * from './outputCacheRouter';
14
+ export * from './OutputCachingService';
15
+ export * from './VerifyUserClientScope';
16
+ export * from './UserWithNetworkAddress';
17
+ export * from './NetworkRestrictAccess';
@@ -0,0 +1,55 @@
1
+ import {Router} from 'express';
2
+
3
+ export declare interface OutputCachingProfile {
4
+ /**
5
+ * Defines the location where the output cache will be stored. Possible values are: client, server, clientServer, none. The default value is client.
6
+ */
7
+ location: 'any' | 'none' | 'server' | 'client' | 'serverAndClient';
8
+ /**
9
+ * Defines the duration in seconds for which the output cache will be stored. The default value is 60 seconds.
10
+ */
11
+ duration?: number;
12
+ /**
13
+ * Defines the content encoding that will be used to vary the output cache. The default value is null, which means that the output cache will not be varied by content encoding.
14
+ */
15
+ varyByContentEncoding?: string;
16
+ /**
17
+ * Defines the headers that will be used to vary the output cache. The default value is an empty array.
18
+ */
19
+ varyByHeader?: string[];
20
+ /**
21
+ * Defines the query string parameters that will be used to vary the output cache. The default value is a "*" character, which means that the output cache will be varied by all query string parameters.
22
+ */
23
+ varyByParam?: string[];
24
+ /**
25
+ * Defines the custom expression that will be used to vary the output cache. The default value is null.
26
+ * An example of a custom expression is: { "$md5": { "$user": 1 } } where the output cache will be varied by the MD5 hash of the user ID.
27
+ */
28
+ varyByExpr?: any;
29
+ }
30
+
31
+ declare module 'express' {
32
+ interface Request {
33
+ outputCaching?: OutputCachingProfile
34
+ }
35
+ }
36
+
37
+ export declare interface OutputCachingElement {
38
+ /**
39
+ * Defines the URL pattern for which the output caching profile will be applied. The URL pattern can include wildcards, such as /api/*, which means that the output caching profile will be applied to all URLs that start with /api/.
40
+ */
41
+ resource: string;
42
+ /**
43
+ * Defines the regular expression that will be used to match the URL pattern. The regular expression should be defined as a string and will be converted to a RegExp object internally. If the regular expression is not defined, the URL pattern will be used as a simple string match.
44
+ */
45
+ expr?: RegExp;
46
+ /**
47
+ * Defines the output caching profile that will be applied to the URL pattern. The output caching profile can be defined as an object of type OutputCachingProfile or as a string that references a predefined output caching profile.
48
+ */
49
+ profile: (OutputCachingProfile | string);
50
+ }
51
+
52
+ export declare function outputCache(options: {
53
+ profiles: Map<string, OutputCachingProfile>,
54
+ elements: OutputCachingElement[]
55
+ }): Router;