@guardian/permissions-client 0.0.0 → 0.0.2
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.
- package/LICENSE +8 -0
- package/README.md +47 -0
- package/dist/cjs/index.js +82 -0
- package/dist/cjs/model.js +2 -0
- package/dist/cjs/normalise.js +19 -0
- package/dist/declaration/index.d.ts +2 -1
- package/dist/declaration/index.test.d.ts +1 -0
- package/dist/declaration/model.d.ts +19 -4
- package/dist/declaration/normalise.d.ts +2 -0
- package/dist/esm/index.js +79 -0
- package/dist/esm/index.test.js +165 -0
- package/dist/esm/model.js +1 -0
- package/dist/esm/normalise.js +15 -0
- package/package.json +36 -26
- package/dist/index.js +0 -73
- package/dist/model.js +0 -2
- package/src/index.ts +0 -124
- package/src/model.ts +0 -20
- package/tsconfig.json +0 -16
package/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
This software is licensed under the Apache 2 license, quoted below.
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project except in compliance with
|
|
4
|
+
the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
|
|
5
|
+
|
|
6
|
+
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
|
|
7
|
+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific
|
|
8
|
+
language governing permissions and limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# `@guardian/permissions-client`
|
|
2
|
+
|
|
3
|
+
Client for reading Guardian user permissions data in Node.js services
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @guardian/permissions-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
or
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @guardian/permissions-client
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
(`@aws-sdk/client-s3` and `@aws-sdk/credential-providers` must also be installed)
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import { init } from '@guardian/permissions-client';
|
|
23
|
+
|
|
24
|
+
const { hasPermission } = init({ isRunningLocally: true });
|
|
25
|
+
|
|
26
|
+
const doesLiamHaveGridAccess = await hasPermission(
|
|
27
|
+
'grid_access',
|
|
28
|
+
'liam.duffy@guardian.co.uk',
|
|
29
|
+
);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Releasing
|
|
33
|
+
|
|
34
|
+
The Node client library is published to NPM using [Changesets](https://changesets.dev/)
|
|
35
|
+
|
|
36
|
+
- Please follow [semver](https://semver.org/) principles:
|
|
37
|
+
- **Patch**: bug fixes and backwards-compatible changes.
|
|
38
|
+
- **Minor**: backwards-compatible new features.
|
|
39
|
+
- **Major**: breaking changes.
|
|
40
|
+
|
|
41
|
+
- Run `npx changeset` to create a changeset when you make changes that should be included in a release.
|
|
42
|
+
- Follow the prompts to describe your changes, ensuring you choose `node-client` as the package being release, and select the appropriate version bump (patch, minor, major).
|
|
43
|
+
- This will generate a markdown file in the `.changeset` directory.
|
|
44
|
+
- Commit the changeset file along with your code changes.
|
|
45
|
+
|
|
46
|
+
- Once your changes are merged into the `main` branch, the changeset will be picked up in the next release cycle, and the version will be bumped accordingly and the package released once the generated `🦋 Release package updates` PR is merged.
|
|
47
|
+
- You can also create a canary release from a branch by opening a PR and adding the `🐥 Canaries` label, this can be useful for testing your changes with a downstream project before creating a production-ready release.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.init = init;
|
|
4
|
+
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
5
|
+
const credential_providers_1 = require("@aws-sdk/credential-providers");
|
|
6
|
+
const normalise_ts_1 = require("./normalise.js");
|
|
7
|
+
let permissionsResult = {
|
|
8
|
+
allPermissions: {},
|
|
9
|
+
loadTime: null,
|
|
10
|
+
};
|
|
11
|
+
let permissionsRequest = Promise.resolve(permissionsResult);
|
|
12
|
+
const permissionValidForUser = (userEntry) => !!(userEntry.active && !userEntry.isCasualNotOnShift);
|
|
13
|
+
function init({ stage = 'CODE', region = 'eu-west-1', bucket = 'permissions-cache', file = 'permissions.json', cacheIntervalMs = 60_000, // 1 minute,
|
|
14
|
+
isRunningLocally = false, localProfile = 'workflow', } = {}) {
|
|
15
|
+
const awsConfig = {
|
|
16
|
+
region,
|
|
17
|
+
credentials: isRunningLocally
|
|
18
|
+
? (0, credential_providers_1.fromIni)({ profile: localProfile })
|
|
19
|
+
: (0, credential_providers_1.fromNodeProviderChain)(),
|
|
20
|
+
};
|
|
21
|
+
const s3 = new client_s3_1.S3(awsConfig);
|
|
22
|
+
async function loadPermissions() {
|
|
23
|
+
const { Body } = await s3.getObject({
|
|
24
|
+
Bucket: bucket,
|
|
25
|
+
Key: `${stage}/${file}`,
|
|
26
|
+
});
|
|
27
|
+
if (!Body) {
|
|
28
|
+
throw Error('Could not fetch permissions');
|
|
29
|
+
}
|
|
30
|
+
const transformedBody = await Body.transformToString();
|
|
31
|
+
const permissionsCache = JSON.parse(transformedBody);
|
|
32
|
+
const allPermissions = (0, normalise_ts_1.normalisePermissionsCache)(permissionsCache);
|
|
33
|
+
return { allPermissions, loadTime: Date.now() };
|
|
34
|
+
}
|
|
35
|
+
async function getOrRefresh() {
|
|
36
|
+
const latestResult = await permissionsRequest;
|
|
37
|
+
const cacheExpired = latestResult.loadTime !== null
|
|
38
|
+
? Date.now() > latestResult.loadTime + cacheIntervalMs
|
|
39
|
+
: true;
|
|
40
|
+
if (cacheExpired) {
|
|
41
|
+
try {
|
|
42
|
+
permissionsRequest = loadPermissions();
|
|
43
|
+
permissionsResult = await permissionsRequest;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.error('Refreshing permissions cache failed with error, so keeping stale cache...', error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return permissionsResult.allPermissions;
|
|
50
|
+
}
|
|
51
|
+
async function hasPermission(permissionName, userId) {
|
|
52
|
+
const permissionsCache = await getOrRefresh();
|
|
53
|
+
const userEntry = permissionsCache[userId]?.[permissionName];
|
|
54
|
+
return userEntry ? permissionValidForUser(userEntry) : false;
|
|
55
|
+
}
|
|
56
|
+
async function listUserPermissions(userId) {
|
|
57
|
+
const permissionsCache = await getOrRefresh();
|
|
58
|
+
return Object.entries(permissionsCache[userId] ?? {})
|
|
59
|
+
.filter(([, userEntry]) => permissionValidForUser(userEntry))
|
|
60
|
+
.flatMap(([permissionName]) => permissionName);
|
|
61
|
+
}
|
|
62
|
+
async function listUsersWithPermission(permissionName) {
|
|
63
|
+
const permissionsCache = await getOrRefresh();
|
|
64
|
+
return Object.entries(permissionsCache)
|
|
65
|
+
.filter(([, userPermissions]) => {
|
|
66
|
+
const userEntry = userPermissions[permissionName];
|
|
67
|
+
return userEntry && permissionValidForUser(userEntry);
|
|
68
|
+
})
|
|
69
|
+
.flatMap(([userId]) => userId);
|
|
70
|
+
}
|
|
71
|
+
function storeIsEmpty() {
|
|
72
|
+
return permissionsResult.loadTime === null;
|
|
73
|
+
}
|
|
74
|
+
// Run getOrRefresh() immediately after init so that the cache is populated and ready for use
|
|
75
|
+
void getOrRefresh();
|
|
76
|
+
return {
|
|
77
|
+
hasPermission,
|
|
78
|
+
listUserPermissions,
|
|
79
|
+
listUsersWithPermission,
|
|
80
|
+
storeIsEmpty,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalisePermissionsCache = void 0;
|
|
4
|
+
// Invert shape of permissions cache, so we can look up permissions data without looping
|
|
5
|
+
const normalisePermissionsCache = (permissionsCache) => {
|
|
6
|
+
return permissionsCache.reduce((acc, p) => {
|
|
7
|
+
const { permission, overrides } = p;
|
|
8
|
+
overrides.forEach(({ userId, active, isCasualNotOnShift }) => {
|
|
9
|
+
acc[userId] ??= {};
|
|
10
|
+
acc[userId][permission.name] = {
|
|
11
|
+
permission,
|
|
12
|
+
active,
|
|
13
|
+
isCasualNotOnShift,
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
return acc;
|
|
17
|
+
}, {});
|
|
18
|
+
};
|
|
19
|
+
exports.normalisePermissionsCache = normalisePermissionsCache;
|
|
@@ -10,7 +10,8 @@ interface ClientConfig {
|
|
|
10
10
|
export declare function init({ stage, region, bucket, file, cacheIntervalMs, // 1 minute,
|
|
11
11
|
isRunningLocally, localProfile, }?: ClientConfig): {
|
|
12
12
|
hasPermission: (permissionName: string, userId: string) => Promise<boolean>;
|
|
13
|
-
|
|
13
|
+
listUserPermissions: (userId: string) => Promise<string[]>;
|
|
14
|
+
listUsersWithPermission: (permissionName: string) => Promise<string[]>;
|
|
14
15
|
storeIsEmpty: () => boolean;
|
|
15
16
|
};
|
|
16
17
|
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
interface
|
|
1
|
+
interface UserOverride {
|
|
2
2
|
userId: string;
|
|
3
3
|
active: boolean;
|
|
4
4
|
isCasualNotOnShift: boolean;
|
|
@@ -8,9 +8,24 @@ interface PermissionDefinition {
|
|
|
8
8
|
app: string;
|
|
9
9
|
defaultValue: boolean;
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
interface PermissionWithOverrides {
|
|
12
12
|
permission: PermissionDefinition;
|
|
13
|
-
overrides:
|
|
13
|
+
overrides: UserOverride[];
|
|
14
14
|
}
|
|
15
|
-
export type PermissionsCache =
|
|
15
|
+
export type PermissionsCache = PermissionWithOverrides[];
|
|
16
|
+
export interface UserEntry {
|
|
17
|
+
permission: PermissionDefinition;
|
|
18
|
+
active: boolean;
|
|
19
|
+
isCasualNotOnShift: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* I would find an interface more readable here than a record, but @typescript-eslint/consistent-indexed-object-style says no
|
|
23
|
+
* Here's what it would look like as an interface, with named types for the keys
|
|
24
|
+
* interface NormalisedPermissionsCache {
|
|
25
|
+
* [userId: string]: {
|
|
26
|
+
* [permissionName: string]: UserEntry;
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
export type NormalisedPermissionsCache = Record<string, Record<string, UserEntry>>;
|
|
16
31
|
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { S3 } from '@aws-sdk/client-s3';
|
|
2
|
+
import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers';
|
|
3
|
+
import { normalisePermissionsCache } from "./normalise.js";
|
|
4
|
+
let permissionsResult = {
|
|
5
|
+
allPermissions: {},
|
|
6
|
+
loadTime: null,
|
|
7
|
+
};
|
|
8
|
+
let permissionsRequest = Promise.resolve(permissionsResult);
|
|
9
|
+
const permissionValidForUser = (userEntry) => !!(userEntry.active && !userEntry.isCasualNotOnShift);
|
|
10
|
+
export function init({ stage = 'CODE', region = 'eu-west-1', bucket = 'permissions-cache', file = 'permissions.json', cacheIntervalMs = 60_000, // 1 minute,
|
|
11
|
+
isRunningLocally = false, localProfile = 'workflow', } = {}) {
|
|
12
|
+
const awsConfig = {
|
|
13
|
+
region,
|
|
14
|
+
credentials: isRunningLocally
|
|
15
|
+
? fromIni({ profile: localProfile })
|
|
16
|
+
: fromNodeProviderChain(),
|
|
17
|
+
};
|
|
18
|
+
const s3 = new S3(awsConfig);
|
|
19
|
+
async function loadPermissions() {
|
|
20
|
+
const { Body } = await s3.getObject({
|
|
21
|
+
Bucket: bucket,
|
|
22
|
+
Key: `${stage}/${file}`,
|
|
23
|
+
});
|
|
24
|
+
if (!Body) {
|
|
25
|
+
throw Error('Could not fetch permissions');
|
|
26
|
+
}
|
|
27
|
+
const transformedBody = await Body.transformToString();
|
|
28
|
+
const permissionsCache = JSON.parse(transformedBody);
|
|
29
|
+
const allPermissions = normalisePermissionsCache(permissionsCache);
|
|
30
|
+
return { allPermissions, loadTime: Date.now() };
|
|
31
|
+
}
|
|
32
|
+
async function getOrRefresh() {
|
|
33
|
+
const latestResult = await permissionsRequest;
|
|
34
|
+
const cacheExpired = latestResult.loadTime !== null
|
|
35
|
+
? Date.now() > latestResult.loadTime + cacheIntervalMs
|
|
36
|
+
: true;
|
|
37
|
+
if (cacheExpired) {
|
|
38
|
+
try {
|
|
39
|
+
permissionsRequest = loadPermissions();
|
|
40
|
+
permissionsResult = await permissionsRequest;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.error('Refreshing permissions cache failed with error, so keeping stale cache...', error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return permissionsResult.allPermissions;
|
|
47
|
+
}
|
|
48
|
+
async function hasPermission(permissionName, userId) {
|
|
49
|
+
const permissionsCache = await getOrRefresh();
|
|
50
|
+
const userEntry = permissionsCache[userId]?.[permissionName];
|
|
51
|
+
return userEntry ? permissionValidForUser(userEntry) : false;
|
|
52
|
+
}
|
|
53
|
+
async function listUserPermissions(userId) {
|
|
54
|
+
const permissionsCache = await getOrRefresh();
|
|
55
|
+
return Object.entries(permissionsCache[userId] ?? {})
|
|
56
|
+
.filter(([, userEntry]) => permissionValidForUser(userEntry))
|
|
57
|
+
.flatMap(([permissionName]) => permissionName);
|
|
58
|
+
}
|
|
59
|
+
async function listUsersWithPermission(permissionName) {
|
|
60
|
+
const permissionsCache = await getOrRefresh();
|
|
61
|
+
return Object.entries(permissionsCache)
|
|
62
|
+
.filter(([, userPermissions]) => {
|
|
63
|
+
const userEntry = userPermissions[permissionName];
|
|
64
|
+
return userEntry && permissionValidForUser(userEntry);
|
|
65
|
+
})
|
|
66
|
+
.flatMap(([userId]) => userId);
|
|
67
|
+
}
|
|
68
|
+
function storeIsEmpty() {
|
|
69
|
+
return permissionsResult.loadTime === null;
|
|
70
|
+
}
|
|
71
|
+
// Run getOrRefresh() immediately after init so that the cache is populated and ready for use
|
|
72
|
+
void getOrRefresh();
|
|
73
|
+
return {
|
|
74
|
+
hasPermission,
|
|
75
|
+
listUserPermissions,
|
|
76
|
+
listUsersWithPermission,
|
|
77
|
+
storeIsEmpty,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import assert from 'node:assert';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { mock, test } from 'node:test';
|
|
12
|
+
const mockPermissionsCache = [
|
|
13
|
+
{
|
|
14
|
+
permission: {
|
|
15
|
+
name: 'permission1',
|
|
16
|
+
app: 'app1',
|
|
17
|
+
defaultValue: false,
|
|
18
|
+
},
|
|
19
|
+
overrides: [
|
|
20
|
+
{
|
|
21
|
+
userId: 'user1',
|
|
22
|
+
active: true,
|
|
23
|
+
isCasualNotOnShift: false,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
userId: 'user2',
|
|
27
|
+
active: false,
|
|
28
|
+
isCasualNotOnShift: true,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
userId: 'user3',
|
|
32
|
+
active: true,
|
|
33
|
+
isCasualNotOnShift: true,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
permission: {
|
|
39
|
+
name: 'permission2',
|
|
40
|
+
app: 'app1',
|
|
41
|
+
defaultValue: false,
|
|
42
|
+
},
|
|
43
|
+
overrides: [
|
|
44
|
+
{
|
|
45
|
+
userId: 'user1',
|
|
46
|
+
active: true,
|
|
47
|
+
isCasualNotOnShift: false,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
userId: 'user2',
|
|
51
|
+
active: true,
|
|
52
|
+
isCasualNotOnShift: false,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
userId: 'user3',
|
|
56
|
+
active: false,
|
|
57
|
+
isCasualNotOnShift: false,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
let currentMockPermissionsCache = mockPermissionsCache;
|
|
63
|
+
const originalCredentialProviders = await import('@aws-sdk/credential-providers');
|
|
64
|
+
mock.module('@aws-sdk/credential-providers', {
|
|
65
|
+
exports: {
|
|
66
|
+
...originalCredentialProviders,
|
|
67
|
+
fromNodeProviderChain: () => () => Promise.resolve({}),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
const originalClientS3 = await import('@aws-sdk/client-s3');
|
|
71
|
+
mock.module('@aws-sdk/client-s3', {
|
|
72
|
+
exports: {
|
|
73
|
+
...originalClientS3,
|
|
74
|
+
S3: class {
|
|
75
|
+
getObject() {
|
|
76
|
+
return Promise.resolve({
|
|
77
|
+
Body: {
|
|
78
|
+
transformToString: () => Promise.resolve(JSON.stringify(currentMockPermissionsCache)),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const getClient = async () => {
|
|
86
|
+
// This is required to get a fresh instance of the module for each test, with a new cache state.
|
|
87
|
+
const { init } = (await import(__rewriteRelativeImportExtension(`./index.ts?cache-bust=${randomUUID()}`)));
|
|
88
|
+
return init();
|
|
89
|
+
};
|
|
90
|
+
test('hasPermission() returns true for a user with active: true in overrides', async () => {
|
|
91
|
+
const client = await getClient();
|
|
92
|
+
const result = await client.hasPermission('permission1', 'user1');
|
|
93
|
+
assert.equal(result, true);
|
|
94
|
+
});
|
|
95
|
+
test('hasPermission() returns false for a user with active: false in overrides', async () => {
|
|
96
|
+
const client = await getClient();
|
|
97
|
+
const result = await client.hasPermission('permission1', 'user2');
|
|
98
|
+
assert.equal(result, false);
|
|
99
|
+
});
|
|
100
|
+
test('hasPermission() returns false for a user who is not present in overrides', async () => {
|
|
101
|
+
const client = await getClient();
|
|
102
|
+
const result = await client.hasPermission('permission1', 'user9001');
|
|
103
|
+
assert.equal(result, false);
|
|
104
|
+
});
|
|
105
|
+
test('hasPermission() returns false for a user with active: true but isCasualNotOnShift: true in overrides', async () => {
|
|
106
|
+
const client = await getClient();
|
|
107
|
+
const result = await client.hasPermission('permission1', 'user3');
|
|
108
|
+
assert.equal(result, false);
|
|
109
|
+
});
|
|
110
|
+
test('hasPermission() returns the latest permission information when the cache expires', async () => {
|
|
111
|
+
mock.timers.enable();
|
|
112
|
+
const client = await getClient();
|
|
113
|
+
const result = await client.hasPermission('permission1', 'user1');
|
|
114
|
+
assert.equal(result, true);
|
|
115
|
+
const updatedPermissionsCache = [
|
|
116
|
+
{
|
|
117
|
+
permission: {
|
|
118
|
+
name: 'permission1',
|
|
119
|
+
app: 'app1',
|
|
120
|
+
defaultValue: false,
|
|
121
|
+
},
|
|
122
|
+
overrides: [
|
|
123
|
+
{
|
|
124
|
+
userId: 'user1',
|
|
125
|
+
active: false,
|
|
126
|
+
isCasualNotOnShift: false,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
currentMockPermissionsCache = updatedPermissionsCache;
|
|
132
|
+
mock.timers.tick(30_000);
|
|
133
|
+
const resultAfter30Seconds = await client.hasPermission('permission1', 'user1');
|
|
134
|
+
assert.equal(resultAfter30Seconds, true);
|
|
135
|
+
mock.timers.tick(30_001);
|
|
136
|
+
const resultAfter60Seconds = await client.hasPermission('permission1', 'user1');
|
|
137
|
+
assert.equal(resultAfter60Seconds, false);
|
|
138
|
+
// Reset to original state
|
|
139
|
+
currentMockPermissionsCache = mockPermissionsCache;
|
|
140
|
+
});
|
|
141
|
+
test('listUserPermissions() returns list of valid permissions for user', async () => {
|
|
142
|
+
const client = await getClient();
|
|
143
|
+
const user1Result = await client.listUserPermissions('user1');
|
|
144
|
+
assert.deepEqual(user1Result, ['permission1', 'permission2']);
|
|
145
|
+
const user2Result = await client.listUserPermissions('user2');
|
|
146
|
+
assert.deepEqual(user2Result, ['permission2']);
|
|
147
|
+
const user3Result = await client.listUserPermissions('user3');
|
|
148
|
+
assert.deepEqual(user3Result, []);
|
|
149
|
+
});
|
|
150
|
+
test('listUsersWithPermission() returns list of users validated for a given permission', async () => {
|
|
151
|
+
const client = await getClient();
|
|
152
|
+
const permission1Result = await client.listUsersWithPermission('permission1');
|
|
153
|
+
assert.deepEqual(permission1Result, ['user1']);
|
|
154
|
+
const permission2Result = await client.listUsersWithPermission('permission2');
|
|
155
|
+
assert.deepEqual(permission2Result, ['user1', 'user2']);
|
|
156
|
+
});
|
|
157
|
+
test('storeIsEmpty() returns true when cache is empty, and false when cache is populated', async (t) => {
|
|
158
|
+
const client = await getClient();
|
|
159
|
+
const initialResult = client.storeIsEmpty();
|
|
160
|
+
assert.equal(initialResult, true);
|
|
161
|
+
t.waitFor(() => {
|
|
162
|
+
const laterResult = client.storeIsEmpty();
|
|
163
|
+
assert.equal(laterResult, false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Invert shape of permissions cache, so we can look up permissions data without looping
|
|
2
|
+
export const normalisePermissionsCache = (permissionsCache) => {
|
|
3
|
+
return permissionsCache.reduce((acc, p) => {
|
|
4
|
+
const { permission, overrides } = p;
|
|
5
|
+
overrides.forEach(({ userId, active, isCasualNotOnShift }) => {
|
|
6
|
+
acc[userId] ??= {};
|
|
7
|
+
acc[userId][permission.name] = {
|
|
8
|
+
permission,
|
|
9
|
+
active,
|
|
10
|
+
isCasualNotOnShift,
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
return acc;
|
|
14
|
+
}, {});
|
|
15
|
+
};
|
package/package.json
CHANGED
|
@@ -1,27 +1,37 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
2
|
+
"name": "@guardian/permissions-client",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Client for Guardian's permissions service",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/guardian/permissions",
|
|
7
|
+
"directory": "node-client"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./dist/cjs/index.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./dist/esm/index.js",
|
|
14
|
+
"require": "./dist/cjs/index.js",
|
|
15
|
+
"types": "./dist/declaration/index.d.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@aws-sdk/client-s3": "3.1090.0",
|
|
23
|
+
"@aws-sdk/credential-providers": "^3.1090.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@aws-sdk/client-s3": "^3.1090.0",
|
|
27
|
+
"@aws-sdk/credential-providers": "^3.1090.0"
|
|
28
|
+
},
|
|
29
|
+
"prettier": "@guardian/prettier",
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc --project tsconfig.emit.json && tsc --project tsconfig.emit-cjs.json",
|
|
32
|
+
"test": "node --test --test-timeout=500 --experimental-test-module-mocks src/**/*.test.ts",
|
|
33
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
34
|
+
"format:ci": "prettier --check \"src/**/*.ts\"",
|
|
35
|
+
"lint": "eslint src/** --ext .ts"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/index.js
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { S3 } from "@aws-sdk/client-s3";
|
|
2
|
-
import { fromIni, fromNodeProviderChain } from "@aws-sdk/credential-providers";
|
|
3
|
-
let permissionsResult = {
|
|
4
|
-
allPermissions: [],
|
|
5
|
-
loadTime: null,
|
|
6
|
-
};
|
|
7
|
-
let permissionsRequest = Promise.resolve(permissionsResult);
|
|
8
|
-
const permissionValidForUser = (permission, userId) => {
|
|
9
|
-
const userOverride = permission.overrides.find((o) => o.userId === userId);
|
|
10
|
-
return !!(userOverride?.active && !userOverride.isCasualNotOnShift);
|
|
11
|
-
};
|
|
12
|
-
export function init({ stage = "CODE", region = "eu-west-1", bucket = "permissions-cache", file = "permissions.json", cacheIntervalMs = 60_000, // 1 minute,
|
|
13
|
-
isRunningLocally = false, localProfile = "workflow", } = {}) {
|
|
14
|
-
const awsConfig = {
|
|
15
|
-
region,
|
|
16
|
-
credentials: isRunningLocally
|
|
17
|
-
? fromIni({ profile: localProfile })
|
|
18
|
-
: fromNodeProviderChain(),
|
|
19
|
-
};
|
|
20
|
-
const s3 = new S3(awsConfig);
|
|
21
|
-
async function loadPermissions() {
|
|
22
|
-
const { Body } = await s3.getObject({
|
|
23
|
-
Bucket: bucket,
|
|
24
|
-
Key: `${stage}/${file}`,
|
|
25
|
-
});
|
|
26
|
-
if (!Body) {
|
|
27
|
-
throw Error("Could not fetch permissions");
|
|
28
|
-
}
|
|
29
|
-
const transformedBody = await Body.transformToString();
|
|
30
|
-
const allPermissions = JSON.parse(transformedBody);
|
|
31
|
-
return { allPermissions, loadTime: Date.now() };
|
|
32
|
-
}
|
|
33
|
-
async function getOrRefresh() {
|
|
34
|
-
const latestResult = await permissionsRequest;
|
|
35
|
-
const cacheExpired = latestResult.loadTime
|
|
36
|
-
? Date.now() > latestResult.loadTime + cacheIntervalMs
|
|
37
|
-
: true;
|
|
38
|
-
if (cacheExpired) {
|
|
39
|
-
try {
|
|
40
|
-
permissionsRequest = loadPermissions();
|
|
41
|
-
permissionsResult = await permissionsRequest;
|
|
42
|
-
}
|
|
43
|
-
catch (error) {
|
|
44
|
-
console.error("Refreshing permissions cache failed with error, so keeping stale cache...", error);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return permissionsResult.allPermissions;
|
|
48
|
-
}
|
|
49
|
-
async function hasPermission(permissionName, userId) {
|
|
50
|
-
const permissionsCache = await getOrRefresh();
|
|
51
|
-
const permissionData = permissionsCache.find((p) => p.permission.name === permissionName);
|
|
52
|
-
if (!permissionData) {
|
|
53
|
-
throw Error(`Permission data for "${permissionName}" not found`);
|
|
54
|
-
}
|
|
55
|
-
return permissionValidForUser(permissionData, userId);
|
|
56
|
-
}
|
|
57
|
-
async function listPermissions(userId) {
|
|
58
|
-
const permissionsCache = await getOrRefresh();
|
|
59
|
-
return permissionsCache
|
|
60
|
-
.filter((p) => permissionValidForUser(p, userId))
|
|
61
|
-
.map((p) => p.permission.name);
|
|
62
|
-
}
|
|
63
|
-
function storeIsEmpty() {
|
|
64
|
-
return permissionsResult.loadTime === null;
|
|
65
|
-
}
|
|
66
|
-
// Run getOrRefresh() immediately after init so that the cache is populated and ready for use
|
|
67
|
-
void getOrRefresh();
|
|
68
|
-
return {
|
|
69
|
-
hasPermission,
|
|
70
|
-
listPermissions,
|
|
71
|
-
storeIsEmpty,
|
|
72
|
-
};
|
|
73
|
-
}
|
package/dist/model.js
DELETED
package/src/index.ts
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { S3 } from "@aws-sdk/client-s3";
|
|
2
|
-
import { fromIni, fromNodeProviderChain } from "@aws-sdk/credential-providers";
|
|
3
|
-
import type { PermissionsCache, PermissionWithUsers } from "./model";
|
|
4
|
-
|
|
5
|
-
interface ClientConfig {
|
|
6
|
-
stage?: string;
|
|
7
|
-
region?: string;
|
|
8
|
-
bucket?: string;
|
|
9
|
-
file?: string;
|
|
10
|
-
cacheIntervalMs?: number;
|
|
11
|
-
isRunningLocally?: boolean;
|
|
12
|
-
localProfile?: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
type PermissionsResult = {
|
|
16
|
-
allPermissions: PermissionsCache;
|
|
17
|
-
loadTime: number | null;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
let permissionsResult: PermissionsResult = {
|
|
21
|
-
allPermissions: [],
|
|
22
|
-
loadTime: null,
|
|
23
|
-
};
|
|
24
|
-
let permissionsRequest: Promise<PermissionsResult> =
|
|
25
|
-
Promise.resolve(permissionsResult);
|
|
26
|
-
|
|
27
|
-
const permissionValidForUser = (
|
|
28
|
-
permission: PermissionWithUsers,
|
|
29
|
-
userId: string,
|
|
30
|
-
) => {
|
|
31
|
-
const userOverride = permission.overrides.find((o) => o.userId === userId);
|
|
32
|
-
|
|
33
|
-
return !!(userOverride?.active && !userOverride.isCasualNotOnShift);
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export function init({
|
|
37
|
-
stage = "CODE",
|
|
38
|
-
region = "eu-west-1",
|
|
39
|
-
bucket = "permissions-cache",
|
|
40
|
-
file = "permissions.json",
|
|
41
|
-
cacheIntervalMs = 60_000, // 1 minute,
|
|
42
|
-
isRunningLocally = false,
|
|
43
|
-
localProfile = "workflow",
|
|
44
|
-
}: ClientConfig = {}) {
|
|
45
|
-
const awsConfig = {
|
|
46
|
-
region,
|
|
47
|
-
credentials: isRunningLocally
|
|
48
|
-
? fromIni({ profile: localProfile })
|
|
49
|
-
: fromNodeProviderChain(),
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const s3 = new S3(awsConfig);
|
|
53
|
-
|
|
54
|
-
async function loadPermissions() {
|
|
55
|
-
const { Body } = await s3.getObject({
|
|
56
|
-
Bucket: bucket,
|
|
57
|
-
Key: `${stage}/${file}`,
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
if (!Body) {
|
|
61
|
-
throw Error("Could not fetch permissions");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const transformedBody = await Body.transformToString();
|
|
65
|
-
const allPermissions = JSON.parse(transformedBody) as PermissionsCache;
|
|
66
|
-
|
|
67
|
-
return { allPermissions, loadTime: Date.now() };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async function getOrRefresh() {
|
|
71
|
-
const latestResult = await permissionsRequest;
|
|
72
|
-
const cacheExpired = latestResult.loadTime
|
|
73
|
-
? Date.now() > latestResult.loadTime + cacheIntervalMs
|
|
74
|
-
: true;
|
|
75
|
-
|
|
76
|
-
if (cacheExpired) {
|
|
77
|
-
try {
|
|
78
|
-
permissionsRequest = loadPermissions();
|
|
79
|
-
permissionsResult = await permissionsRequest;
|
|
80
|
-
} catch (error) {
|
|
81
|
-
console.error(
|
|
82
|
-
"Refreshing permissions cache failed with error, so keeping stale cache...",
|
|
83
|
-
error,
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return permissionsResult.allPermissions;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function hasPermission(permissionName: string, userId: string) {
|
|
92
|
-
const permissionsCache = await getOrRefresh();
|
|
93
|
-
const permissionData = permissionsCache.find(
|
|
94
|
-
(p) => p.permission.name === permissionName,
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
if (!permissionData) {
|
|
98
|
-
throw Error(`Permission data for "${permissionName}" not found`);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return permissionValidForUser(permissionData, userId);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async function listPermissions(userId: string) {
|
|
105
|
-
const permissionsCache = await getOrRefresh();
|
|
106
|
-
|
|
107
|
-
return permissionsCache
|
|
108
|
-
.filter((p) => permissionValidForUser(p, userId))
|
|
109
|
-
.map((p) => p.permission.name);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function storeIsEmpty() {
|
|
113
|
-
return permissionsResult.loadTime === null;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Run getOrRefresh() immediately after init so that the cache is populated and ready for use
|
|
117
|
-
void getOrRefresh();
|
|
118
|
-
|
|
119
|
-
return {
|
|
120
|
-
hasPermission,
|
|
121
|
-
listPermissions,
|
|
122
|
-
storeIsEmpty,
|
|
123
|
-
};
|
|
124
|
-
}
|
package/src/model.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
// TODO: should we have runtime type checking with zod, or just trust what's coming back from the bucket?
|
|
2
|
-
|
|
3
|
-
interface UserSetting {
|
|
4
|
-
userId: string;
|
|
5
|
-
active: boolean;
|
|
6
|
-
isCasualNotOnShift: boolean;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
interface PermissionDefinition {
|
|
10
|
-
name: string;
|
|
11
|
-
app: string;
|
|
12
|
-
defaultValue: boolean;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface PermissionWithUsers {
|
|
16
|
-
permission: PermissionDefinition;
|
|
17
|
-
overrides: UserSetting[];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export type PermissionsCache = PermissionWithUsers[];
|
package/tsconfig.json
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"extends": "@guardian/tsconfig/tsconfig.json",
|
|
3
|
-
"allowImportingTsExtensions": true,
|
|
4
|
-
"compilerOptions": {
|
|
5
|
-
"module": "esnext",
|
|
6
|
-
"types": [
|
|
7
|
-
"vitest/globals",
|
|
8
|
-
"node"
|
|
9
|
-
],
|
|
10
|
-
"declaration": true,
|
|
11
|
-
"rootDir": "./src",
|
|
12
|
-
"outDir": "dist",
|
|
13
|
-
"declarationDir": "dist/declaration",
|
|
14
|
-
},
|
|
15
|
-
"include": ["src/**/*"],
|
|
16
|
-
}
|