@guardian/permissions-client 0.0.0 → 0.0.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @guardian/permissions-client
2
+
3
+ ## 0.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - fbf5e70: Initial release for @guardian/permissions-client
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,30 @@
1
+ # `@guardian/permissions-client`
2
+
3
+ Client for reading Guardian user permissions data in Node.js backends
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
+ ```
package/package.json CHANGED
@@ -1,27 +1,31 @@
1
1
  {
2
- "name": "@guardian/permissions-client",
3
- "version": "0.0.0",
4
- "description": "Client for Guardian's permissions service",
5
- "scripts": {
6
- "build": "tsc",
7
- "test": "echo \"Error: no test specified\" && exit 1",
8
- "format": "prettier --write \"src/**/*.ts\"",
9
- "format:ci": "prettier --check \"src/**/*.ts\"",
10
- "lint": "eslint src/** --ext .ts"
11
- },
12
- "type": "module",
13
- "exports": {
14
- ".": {
15
- "import": "./dist/index.js",
16
- "types": "./dist/declaration/index.d.ts"
17
- }
18
- },
19
- "devDependencies": {
20
- "@aws-sdk/client-s3": "3.1090.0",
21
- "@aws-sdk/credential-providers": "^3.1090.0"
22
- },
23
- "peerDependencies": {
24
- "@aws-sdk/client-s3": "^3.1090.0",
25
- "@aws-sdk/credential-providers": "^3.1090.0"
26
- }
27
- }
2
+ "name": "@guardian/permissions-client",
3
+ "version": "0.0.1",
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
+ "exports": {
11
+ ".": {
12
+ "import": "./dist/index.js",
13
+ "types": "./dist/declaration/index.d.ts"
14
+ }
15
+ },
16
+ "devDependencies": {
17
+ "@aws-sdk/client-s3": "3.1090.0",
18
+ "@aws-sdk/credential-providers": "^3.1090.0"
19
+ },
20
+ "peerDependencies": {
21
+ "@aws-sdk/client-s3": "^3.1090.0",
22
+ "@aws-sdk/credential-providers": "^3.1090.0"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc --project tsconfig.emit.json",
26
+ "test": "node --test --experimental-test-module-mocks src/**/*.test.ts",
27
+ "format": "prettier --write \"src/**/*.ts\"",
28
+ "format:ci": "prettier --check \"src/**/*.ts\"",
29
+ "lint": "eslint src/** --ext .ts"
30
+ }
31
+ }
@@ -0,0 +1,181 @@
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 CHANGED
@@ -1,6 +1,11 @@
1
1
  import { S3 } from "@aws-sdk/client-s3";
2
2
  import { fromIni, fromNodeProviderChain } from "@aws-sdk/credential-providers";
3
- import type { PermissionsCache, PermissionWithUsers } from "./model";
3
+ import type {
4
+ NormalisedPermissionsCache,
5
+ PermissionsCache,
6
+ UserEntry,
7
+ } from "./model.ts";
8
+ import { normalisePermissionsCache } from "./normalise.ts";
4
9
 
5
10
  interface ClientConfig {
6
11
  stage?: string;
@@ -13,25 +18,19 @@ interface ClientConfig {
13
18
  }
14
19
 
15
20
  type PermissionsResult = {
16
- allPermissions: PermissionsCache;
21
+ allPermissions: NormalisedPermissionsCache;
17
22
  loadTime: number | null;
18
23
  };
19
24
 
20
25
  let permissionsResult: PermissionsResult = {
21
- allPermissions: [],
26
+ allPermissions: {},
22
27
  loadTime: null,
23
28
  };
24
29
  let permissionsRequest: Promise<PermissionsResult> =
25
30
  Promise.resolve(permissionsResult);
26
31
 
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
- };
32
+ const permissionValidForUser = (userEntry: UserEntry) =>
33
+ !!(userEntry.active && !userEntry.isCasualNotOnShift);
35
34
 
36
35
  export function init({
37
36
  stage = "CODE",
@@ -62,16 +61,18 @@ export function init({
62
61
  }
63
62
 
64
63
  const transformedBody = await Body.transformToString();
65
- const allPermissions = JSON.parse(transformedBody) as PermissionsCache;
64
+ const permissionsCache = JSON.parse(transformedBody) as PermissionsCache;
65
+ const allPermissions = normalisePermissionsCache(permissionsCache);
66
66
 
67
67
  return { allPermissions, loadTime: Date.now() };
68
68
  }
69
69
 
70
70
  async function getOrRefresh() {
71
71
  const latestResult = await permissionsRequest;
72
- const cacheExpired = latestResult.loadTime
73
- ? Date.now() > latestResult.loadTime + cacheIntervalMs
74
- : true;
72
+ const cacheExpired =
73
+ latestResult.loadTime !== null
74
+ ? Date.now() > latestResult.loadTime + cacheIntervalMs
75
+ : true;
75
76
 
76
77
  if (cacheExpired) {
77
78
  try {
@@ -90,23 +91,16 @@ export function init({
90
91
 
91
92
  async function hasPermission(permissionName: string, userId: string) {
92
93
  const permissionsCache = await getOrRefresh();
93
- const permissionData = permissionsCache.find(
94
- (p) => p.permission.name === permissionName,
95
- );
94
+ const userEntry = permissionsCache[userId]?.[permissionName];
96
95
 
97
- if (!permissionData) {
98
- throw Error(`Permission data for "${permissionName}" not found`);
99
- }
100
-
101
- return permissionValidForUser(permissionData, userId);
96
+ return userEntry ? permissionValidForUser(userEntry) : false;
102
97
  }
103
98
 
104
99
  async function listPermissions(userId: string) {
105
100
  const permissionsCache = await getOrRefresh();
106
-
107
- return permissionsCache
108
- .filter((p) => permissionValidForUser(p, userId))
109
- .map((p) => p.permission.name);
101
+ return Object.entries(permissionsCache[userId] ?? {})
102
+ .filter(([, userEntry]) => permissionValidForUser(userEntry))
103
+ .flatMap(([permissionName]) => permissionName);
110
104
  }
111
105
 
112
106
  function storeIsEmpty() {
package/src/model.ts CHANGED
@@ -1,6 +1,5 @@
1
- // TODO: should we have runtime type checking with zod, or just trust what's coming back from the bucket?
2
-
3
- interface UserSetting {
1
+ // Types from S3
2
+ interface UserOverride {
4
3
  userId: string;
5
4
  active: boolean;
6
5
  isCasualNotOnShift: boolean;
@@ -12,9 +11,30 @@ interface PermissionDefinition {
12
11
  defaultValue: boolean;
13
12
  }
14
13
 
15
- export interface PermissionWithUsers {
14
+ interface PermissionWithOverrides {
16
15
  permission: PermissionDefinition;
17
- overrides: UserSetting[];
16
+ overrides: UserOverride[];
17
+ }
18
+
19
+ export type PermissionsCache = PermissionWithOverrides[];
20
+
21
+ // Normalised types
22
+ export interface UserEntry {
23
+ permission: PermissionDefinition;
24
+ active: boolean;
25
+ isCasualNotOnShift: boolean;
18
26
  }
19
27
 
20
- export type PermissionsCache = PermissionWithUsers[];
28
+ /**
29
+ * I would find an interface more readable here than a record, but @typescript-eslint/consistent-indexed-object-style says no
30
+ * Here's what it would look like as an interface, with named types for the keys
31
+ * interface NormalisedPermissionsCache {
32
+ * [userId: string]: {
33
+ * [permissionName: string]: UserEntry;
34
+ * }
35
+ * }
36
+ */
37
+ export type NormalisedPermissionsCache = Record<
38
+ string,
39
+ Record<string, UserEntry>
40
+ >;
@@ -0,0 +1,20 @@
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
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "exclude": ["src/**/*.test.ts"]
4
+ }
package/tsconfig.json CHANGED
@@ -2,15 +2,19 @@
2
2
  "extends": "@guardian/tsconfig/tsconfig.json",
3
3
  "allowImportingTsExtensions": true,
4
4
  "compilerOptions": {
5
- "module": "esnext",
5
+ "target": "esnext",
6
+ "module": "nodenext",
7
+ "moduleResolution": "nodenext",
6
8
  "types": [
7
9
  "vitest/globals",
8
10
  "node"
9
11
  ],
10
- "declaration": true,
11
12
  "rootDir": "./src",
12
13
  "outDir": "dist",
14
+ "declaration": true,
13
15
  "declarationDir": "dist/declaration",
16
+ "rewriteRelativeImportExtensions": true,
17
+ "erasableSyntaxOnly": true,
14
18
  },
15
19
  "include": ["src/**/*"],
16
20
  }
@@ -1,16 +0,0 @@
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
- listPermissions: (userId: string) => Promise<string[]>;
14
- storeIsEmpty: () => boolean;
15
- };
16
- export {};
@@ -1,16 +0,0 @@
1
- interface UserSetting {
2
- userId: string;
3
- active: boolean;
4
- isCasualNotOnShift: boolean;
5
- }
6
- interface PermissionDefinition {
7
- name: string;
8
- app: string;
9
- defaultValue: boolean;
10
- }
11
- export interface PermissionWithUsers {
12
- permission: PermissionDefinition;
13
- overrides: UserSetting[];
14
- }
15
- export type PermissionsCache = PermissionWithUsers[];
16
- export {};
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
@@ -1,2 +0,0 @@
1
- // TODO: should we have runtime type checking with zod, or just trust what's coming back from the bucket?
2
- export {};