@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@guardian/permissions-client`
2
2
 
3
- Client for reading Guardian user permissions data in Node.js backends
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 "@guardian/permissions-client";
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
- "grid_access",
28
- "liam.duffy@guardian.co.uk",
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,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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
- userId: string;
4
- active: boolean;
5
- isCasualNotOnShift: boolean;
2
+ userId: string;
3
+ active: boolean;
4
+ isCasualNotOnShift: boolean;
6
5
  }
7
-
8
6
  interface PermissionDefinition {
9
- name: string;
10
- app: string;
11
- defaultValue: boolean;
7
+ name: string;
8
+ app: string;
9
+ defaultValue: boolean;
12
10
  }
13
-
14
11
  interface PermissionWithOverrides {
15
- permission: PermissionDefinition;
16
- overrides: UserOverride[];
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
- permission: PermissionDefinition;
24
- active: boolean;
25
- isCasualNotOnShift: boolean;
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
- string,
39
- Record<string, UserEntry>
40
- >;
30
+ export type NormalisedPermissionsCache = Record<string, Record<string, UserEntry>>;
31
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { NormalisedPermissionsCache, PermissionsCache } from './model.ts';
2
+ export declare const normalisePermissionsCache: (permissionsCache: PermissionsCache) => NormalisedPermissionsCache;
@@ -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.1",
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
@@ -1,7 +0,0 @@
1
- # @guardian/permissions-client
2
-
3
- ## 0.0.1
4
-
5
- ### Patch Changes
6
-
7
- - fbf5e70: Initial release for @guardian/permissions-client
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
- };
@@ -1,4 +0,0 @@
1
- {
2
- "extends": "./tsconfig.json",
3
- "exclude": ["src/**/*.test.ts"]
4
- }
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
- }