@guardian/permissions-client 0.0.1 → 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/README.md +21 -4
- 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 +17 -0
- package/dist/declaration/index.test.d.ts +1 -0
- package/{src/model.ts → dist/declaration/model.d.ts} +13 -22
- 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 +10 -4
- package/CHANGELOG.md +0 -7
- package/src/index.test.ts +0 -181
- package/src/index.ts +0 -118
- package/src/normalise.ts +0 -20
- package/tsconfig.emit.json +0 -4
- package/tsconfig.json +0 -20
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `@guardian/permissions-client`
|
|
2
2
|
|
|
3
|
-
Client for reading Guardian user permissions data in Node.js
|
|
3
|
+
Client for reading Guardian user permissions data in Node.js services
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -19,12 +19,29 @@ pnpm add @guardian/permissions-client
|
|
|
19
19
|
## Usage
|
|
20
20
|
|
|
21
21
|
```js
|
|
22
|
-
import { init } from
|
|
22
|
+
import { init } from '@guardian/permissions-client';
|
|
23
23
|
|
|
24
24
|
const { hasPermission } = init({ isRunningLocally: true });
|
|
25
25
|
|
|
26
26
|
const doesLiamHaveGridAccess = await hasPermission(
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
'grid_access',
|
|
28
|
+
'liam.duffy@guardian.co.uk',
|
|
29
29
|
);
|
|
30
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;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface ClientConfig {
|
|
2
|
+
stage?: string;
|
|
3
|
+
region?: string;
|
|
4
|
+
bucket?: string;
|
|
5
|
+
file?: string;
|
|
6
|
+
cacheIntervalMs?: number;
|
|
7
|
+
isRunningLocally?: boolean;
|
|
8
|
+
localProfile?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function init({ stage, region, bucket, file, cacheIntervalMs, // 1 minute,
|
|
11
|
+
isRunningLocally, localProfile, }?: ClientConfig): {
|
|
12
|
+
hasPermission: (permissionName: string, userId: string) => Promise<boolean>;
|
|
13
|
+
listUserPermissions: (userId: string) => Promise<string[]>;
|
|
14
|
+
listUsersWithPermission: (permissionName: string) => Promise<string[]>;
|
|
15
|
+
storeIsEmpty: () => boolean;
|
|
16
|
+
};
|
|
17
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,30 +1,23 @@
|
|
|
1
|
-
// Types from S3
|
|
2
1
|
interface UserOverride {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
userId: string;
|
|
3
|
+
active: boolean;
|
|
4
|
+
isCasualNotOnShift: boolean;
|
|
6
5
|
}
|
|
7
|
-
|
|
8
6
|
interface PermissionDefinition {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
name: string;
|
|
8
|
+
app: string;
|
|
9
|
+
defaultValue: boolean;
|
|
12
10
|
}
|
|
13
|
-
|
|
14
11
|
interface PermissionWithOverrides {
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
permission: PermissionDefinition;
|
|
13
|
+
overrides: UserOverride[];
|
|
17
14
|
}
|
|
18
|
-
|
|
19
15
|
export type PermissionsCache = PermissionWithOverrides[];
|
|
20
|
-
|
|
21
|
-
// Normalised types
|
|
22
16
|
export interface UserEntry {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
permission: PermissionDefinition;
|
|
18
|
+
active: boolean;
|
|
19
|
+
isCasualNotOnShift: boolean;
|
|
26
20
|
}
|
|
27
|
-
|
|
28
21
|
/**
|
|
29
22
|
* I would find an interface more readable here than a record, but @typescript-eslint/consistent-indexed-object-style says no
|
|
30
23
|
* Here's what it would look like as an interface, with named types for the keys
|
|
@@ -34,7 +27,5 @@ export interface UserEntry {
|
|
|
34
27
|
* }
|
|
35
28
|
* }
|
|
36
29
|
*/
|
|
37
|
-
export type NormalisedPermissionsCache = Record<
|
|
38
|
-
|
|
39
|
-
Record<string, UserEntry>
|
|
40
|
-
>;
|
|
30
|
+
export type NormalisedPermissionsCache = Record<string, Record<string, UserEntry>>;
|
|
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,18 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@guardian/permissions-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Client for Guardian's permissions service",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "https://github.com/guardian/permissions",
|
|
7
7
|
"directory": "node-client"
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
|
+
"main": "./dist/cjs/index.js",
|
|
10
11
|
"exports": {
|
|
11
12
|
".": {
|
|
12
|
-
"import": "./dist/index.js",
|
|
13
|
+
"import": "./dist/esm/index.js",
|
|
14
|
+
"require": "./dist/cjs/index.js",
|
|
13
15
|
"types": "./dist/declaration/index.d.ts"
|
|
14
16
|
}
|
|
15
17
|
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
16
21
|
"devDependencies": {
|
|
17
22
|
"@aws-sdk/client-s3": "3.1090.0",
|
|
18
23
|
"@aws-sdk/credential-providers": "^3.1090.0"
|
|
@@ -21,9 +26,10 @@
|
|
|
21
26
|
"@aws-sdk/client-s3": "^3.1090.0",
|
|
22
27
|
"@aws-sdk/credential-providers": "^3.1090.0"
|
|
23
28
|
},
|
|
29
|
+
"prettier": "@guardian/prettier",
|
|
24
30
|
"scripts": {
|
|
25
|
-
"build": "tsc --project tsconfig.emit.json",
|
|
26
|
-
"test": "node --test --experimental-test-module-mocks src/**/*.test.ts",
|
|
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",
|
|
27
33
|
"format": "prettier --write \"src/**/*.ts\"",
|
|
28
34
|
"format:ci": "prettier --check \"src/**/*.ts\"",
|
|
29
35
|
"lint": "eslint src/** --ext .ts"
|
package/CHANGELOG.md
DELETED
package/src/index.test.ts
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { mock, test } from "node:test";
|
|
4
|
-
import type * as ClientModule from "./index.ts";
|
|
5
|
-
import type { PermissionsCache } from "./model.ts";
|
|
6
|
-
|
|
7
|
-
const mockPermissionsCache: PermissionsCache = [
|
|
8
|
-
{
|
|
9
|
-
permission: {
|
|
10
|
-
name: "permission1",
|
|
11
|
-
app: "app1",
|
|
12
|
-
defaultValue: false,
|
|
13
|
-
},
|
|
14
|
-
overrides: [
|
|
15
|
-
{
|
|
16
|
-
userId: "user1",
|
|
17
|
-
active: true,
|
|
18
|
-
isCasualNotOnShift: false,
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
userId: "user2",
|
|
22
|
-
active: false,
|
|
23
|
-
isCasualNotOnShift: true,
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
userId: "user3",
|
|
27
|
-
active: true,
|
|
28
|
-
isCasualNotOnShift: true,
|
|
29
|
-
},
|
|
30
|
-
],
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
permission: {
|
|
34
|
-
name: "permission2",
|
|
35
|
-
app: "app1",
|
|
36
|
-
defaultValue: false,
|
|
37
|
-
},
|
|
38
|
-
overrides: [
|
|
39
|
-
{
|
|
40
|
-
userId: "user1",
|
|
41
|
-
active: true,
|
|
42
|
-
isCasualNotOnShift: false,
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
userId: "user2",
|
|
46
|
-
active: true,
|
|
47
|
-
isCasualNotOnShift: false,
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
userId: "user3",
|
|
51
|
-
active: false,
|
|
52
|
-
isCasualNotOnShift: false,
|
|
53
|
-
},
|
|
54
|
-
],
|
|
55
|
-
},
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
let currentMockPermissionsCache = mockPermissionsCache;
|
|
59
|
-
|
|
60
|
-
const originalCredentialProviders =
|
|
61
|
-
await import("@aws-sdk/credential-providers");
|
|
62
|
-
|
|
63
|
-
mock.module("@aws-sdk/credential-providers", {
|
|
64
|
-
exports: {
|
|
65
|
-
...originalCredentialProviders,
|
|
66
|
-
fromNodeProviderChain: () => () => Promise.resolve({}),
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
const originalClientS3 = await import("@aws-sdk/client-s3");
|
|
71
|
-
|
|
72
|
-
mock.module("@aws-sdk/client-s3", {
|
|
73
|
-
exports: {
|
|
74
|
-
...originalClientS3,
|
|
75
|
-
S3: class {
|
|
76
|
-
getObject() {
|
|
77
|
-
return Promise.resolve({
|
|
78
|
-
Body: {
|
|
79
|
-
transformToString: () =>
|
|
80
|
-
Promise.resolve(JSON.stringify(currentMockPermissionsCache)),
|
|
81
|
-
},
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// This is required to get a fresh instance of the module for each test, with a new cache state.
|
|
89
|
-
const importIndex = () =>
|
|
90
|
-
import(`./index.ts?cache-bust=${randomUUID()}`) as Promise<
|
|
91
|
-
typeof ClientModule
|
|
92
|
-
>;
|
|
93
|
-
|
|
94
|
-
test("hasPermission() returns true for a user with active: true in overrides", async () => {
|
|
95
|
-
const { init } = await importIndex();
|
|
96
|
-
const client = init();
|
|
97
|
-
const result = await client.hasPermission("permission1", "user1");
|
|
98
|
-
|
|
99
|
-
assert.equal(result, true);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("hasPermission() returns false for a user with active: false in overrides", async () => {
|
|
103
|
-
const { init } = await importIndex();
|
|
104
|
-
const client = init();
|
|
105
|
-
const result = await client.hasPermission("permission1", "user2");
|
|
106
|
-
|
|
107
|
-
assert.equal(result, false);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("hasPermission() returns false for a user who is not present in overrides", async () => {
|
|
111
|
-
const { init } = await importIndex();
|
|
112
|
-
const client = init();
|
|
113
|
-
const result = await client.hasPermission("permission1", "user9001");
|
|
114
|
-
|
|
115
|
-
assert.equal(result, false);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("hasPermission() returns false for a user with active: true but isCasualNotOnShift: true in overrides", async () => {
|
|
119
|
-
const { init } = await importIndex();
|
|
120
|
-
const client = init();
|
|
121
|
-
const result = await client.hasPermission("permission1", "user3");
|
|
122
|
-
|
|
123
|
-
assert.equal(result, false);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("hasPermission() returns the latest permission information when the cache expires", async () => {
|
|
127
|
-
mock.timers.enable();
|
|
128
|
-
|
|
129
|
-
const { init } = await importIndex();
|
|
130
|
-
const client = init();
|
|
131
|
-
const result = await client.hasPermission("permission1", "user1");
|
|
132
|
-
assert.equal(result, true);
|
|
133
|
-
|
|
134
|
-
const updatedPermissionsCache: PermissionsCache = [
|
|
135
|
-
{
|
|
136
|
-
permission: {
|
|
137
|
-
name: "permission1",
|
|
138
|
-
app: "app1",
|
|
139
|
-
defaultValue: false,
|
|
140
|
-
},
|
|
141
|
-
overrides: [
|
|
142
|
-
{
|
|
143
|
-
userId: "user1",
|
|
144
|
-
active: false,
|
|
145
|
-
isCasualNotOnShift: false,
|
|
146
|
-
},
|
|
147
|
-
],
|
|
148
|
-
},
|
|
149
|
-
];
|
|
150
|
-
|
|
151
|
-
currentMockPermissionsCache = updatedPermissionsCache;
|
|
152
|
-
|
|
153
|
-
mock.timers.tick(30_000);
|
|
154
|
-
|
|
155
|
-
const resultAfter30Seconds = await client.hasPermission(
|
|
156
|
-
"permission1",
|
|
157
|
-
"user1",
|
|
158
|
-
);
|
|
159
|
-
assert.equal(resultAfter30Seconds, true);
|
|
160
|
-
|
|
161
|
-
mock.timers.tick(30_001);
|
|
162
|
-
const resultAfter60Seconds = await client.hasPermission(
|
|
163
|
-
"permission1",
|
|
164
|
-
"user1",
|
|
165
|
-
);
|
|
166
|
-
assert.equal(resultAfter60Seconds, false);
|
|
167
|
-
|
|
168
|
-
// Reset to original state
|
|
169
|
-
currentMockPermissionsCache = mockPermissionsCache;
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test("listPermissions() returns list of valid permissions for user", async () => {
|
|
173
|
-
const { init } = await importIndex();
|
|
174
|
-
const client = init();
|
|
175
|
-
const user1Result = await client.listPermissions("user1");
|
|
176
|
-
assert.deepEqual(user1Result, ["permission1", "permission2"]);
|
|
177
|
-
const user2Result = await client.listPermissions("user2");
|
|
178
|
-
assert.deepEqual(user2Result, ["permission2"]);
|
|
179
|
-
const user3Result = await client.listPermissions("user3");
|
|
180
|
-
assert.deepEqual(user3Result, []);
|
|
181
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { S3 } from "@aws-sdk/client-s3";
|
|
2
|
-
import { fromIni, fromNodeProviderChain } from "@aws-sdk/credential-providers";
|
|
3
|
-
import type {
|
|
4
|
-
NormalisedPermissionsCache,
|
|
5
|
-
PermissionsCache,
|
|
6
|
-
UserEntry,
|
|
7
|
-
} from "./model.ts";
|
|
8
|
-
import { normalisePermissionsCache } from "./normalise.ts";
|
|
9
|
-
|
|
10
|
-
interface ClientConfig {
|
|
11
|
-
stage?: string;
|
|
12
|
-
region?: string;
|
|
13
|
-
bucket?: string;
|
|
14
|
-
file?: string;
|
|
15
|
-
cacheIntervalMs?: number;
|
|
16
|
-
isRunningLocally?: boolean;
|
|
17
|
-
localProfile?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
type PermissionsResult = {
|
|
21
|
-
allPermissions: NormalisedPermissionsCache;
|
|
22
|
-
loadTime: number | null;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
let permissionsResult: PermissionsResult = {
|
|
26
|
-
allPermissions: {},
|
|
27
|
-
loadTime: null,
|
|
28
|
-
};
|
|
29
|
-
let permissionsRequest: Promise<PermissionsResult> =
|
|
30
|
-
Promise.resolve(permissionsResult);
|
|
31
|
-
|
|
32
|
-
const permissionValidForUser = (userEntry: UserEntry) =>
|
|
33
|
-
!!(userEntry.active && !userEntry.isCasualNotOnShift);
|
|
34
|
-
|
|
35
|
-
export function init({
|
|
36
|
-
stage = "CODE",
|
|
37
|
-
region = "eu-west-1",
|
|
38
|
-
bucket = "permissions-cache",
|
|
39
|
-
file = "permissions.json",
|
|
40
|
-
cacheIntervalMs = 60_000, // 1 minute,
|
|
41
|
-
isRunningLocally = false,
|
|
42
|
-
localProfile = "workflow",
|
|
43
|
-
}: ClientConfig = {}) {
|
|
44
|
-
const awsConfig = {
|
|
45
|
-
region,
|
|
46
|
-
credentials: isRunningLocally
|
|
47
|
-
? fromIni({ profile: localProfile })
|
|
48
|
-
: fromNodeProviderChain(),
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const s3 = new S3(awsConfig);
|
|
52
|
-
|
|
53
|
-
async function loadPermissions() {
|
|
54
|
-
const { Body } = await s3.getObject({
|
|
55
|
-
Bucket: bucket,
|
|
56
|
-
Key: `${stage}/${file}`,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
if (!Body) {
|
|
60
|
-
throw Error("Could not fetch permissions");
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const transformedBody = await Body.transformToString();
|
|
64
|
-
const permissionsCache = JSON.parse(transformedBody) as PermissionsCache;
|
|
65
|
-
const allPermissions = normalisePermissionsCache(permissionsCache);
|
|
66
|
-
|
|
67
|
-
return { allPermissions, loadTime: Date.now() };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async function getOrRefresh() {
|
|
71
|
-
const latestResult = await permissionsRequest;
|
|
72
|
-
const cacheExpired =
|
|
73
|
-
latestResult.loadTime !== null
|
|
74
|
-
? Date.now() > latestResult.loadTime + cacheIntervalMs
|
|
75
|
-
: true;
|
|
76
|
-
|
|
77
|
-
if (cacheExpired) {
|
|
78
|
-
try {
|
|
79
|
-
permissionsRequest = loadPermissions();
|
|
80
|
-
permissionsResult = await permissionsRequest;
|
|
81
|
-
} catch (error) {
|
|
82
|
-
console.error(
|
|
83
|
-
"Refreshing permissions cache failed with error, so keeping stale cache...",
|
|
84
|
-
error,
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return permissionsResult.allPermissions;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async function hasPermission(permissionName: string, userId: string) {
|
|
93
|
-
const permissionsCache = await getOrRefresh();
|
|
94
|
-
const userEntry = permissionsCache[userId]?.[permissionName];
|
|
95
|
-
|
|
96
|
-
return userEntry ? permissionValidForUser(userEntry) : false;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function listPermissions(userId: string) {
|
|
100
|
-
const permissionsCache = await getOrRefresh();
|
|
101
|
-
return Object.entries(permissionsCache[userId] ?? {})
|
|
102
|
-
.filter(([, userEntry]) => permissionValidForUser(userEntry))
|
|
103
|
-
.flatMap(([permissionName]) => permissionName);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function storeIsEmpty() {
|
|
107
|
-
return permissionsResult.loadTime === null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Run getOrRefresh() immediately after init so that the cache is populated and ready for use
|
|
111
|
-
void getOrRefresh();
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
hasPermission,
|
|
115
|
-
listPermissions,
|
|
116
|
-
storeIsEmpty,
|
|
117
|
-
};
|
|
118
|
-
}
|
package/src/normalise.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import type { NormalisedPermissionsCache, PermissionsCache } from "./model.ts";
|
|
2
|
-
|
|
3
|
-
// Invert shape of permissions cache, so we can look up permissions data without looping
|
|
4
|
-
export const normalisePermissionsCache = (
|
|
5
|
-
permissionsCache: PermissionsCache,
|
|
6
|
-
): NormalisedPermissionsCache => {
|
|
7
|
-
return permissionsCache.reduce<NormalisedPermissionsCache>((acc, p) => {
|
|
8
|
-
const { permission, overrides } = p;
|
|
9
|
-
overrides.forEach(({ userId, active, isCasualNotOnShift }) => {
|
|
10
|
-
acc[userId] ??= {};
|
|
11
|
-
acc[userId][permission.name] = {
|
|
12
|
-
permission,
|
|
13
|
-
active,
|
|
14
|
-
isCasualNotOnShift,
|
|
15
|
-
};
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
return acc;
|
|
19
|
-
}, {});
|
|
20
|
-
};
|
package/tsconfig.emit.json
DELETED
package/tsconfig.json
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"extends": "@guardian/tsconfig/tsconfig.json",
|
|
3
|
-
"allowImportingTsExtensions": true,
|
|
4
|
-
"compilerOptions": {
|
|
5
|
-
"target": "esnext",
|
|
6
|
-
"module": "nodenext",
|
|
7
|
-
"moduleResolution": "nodenext",
|
|
8
|
-
"types": [
|
|
9
|
-
"vitest/globals",
|
|
10
|
-
"node"
|
|
11
|
-
],
|
|
12
|
-
"rootDir": "./src",
|
|
13
|
-
"outDir": "dist",
|
|
14
|
-
"declaration": true,
|
|
15
|
-
"declarationDir": "dist/declaration",
|
|
16
|
-
"rewriteRelativeImportExtensions": true,
|
|
17
|
-
"erasableSyntaxOnly": true,
|
|
18
|
-
},
|
|
19
|
-
"include": ["src/**/*"],
|
|
20
|
-
}
|