@pcg/auth 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`Helpers > must merge resolved permissions 1`] = `
4
+ [
5
+ {
6
+ "id": "js:core:episodes:create",
7
+ "scopes": [
8
+ "org#hci",
9
+ "user#xxx",
10
+ [
11
+ "org#hci",
12
+ "review",
13
+ ],
14
+ ],
15
+ },
16
+ {
17
+ "id": "js:core:videos:create",
18
+ "scopes": [],
19
+ },
20
+ {
21
+ "id": "js:core:seasons:create",
22
+ "scopes": [
23
+ "org#hci",
24
+ ],
25
+ },
26
+ {
27
+ "id": "js:core:subtitles:list",
28
+ "scopes": [
29
+ [
30
+ "org#hci",
31
+ "published",
32
+ ],
33
+ ],
34
+ },
35
+ {
36
+ "id": "js:core:episodes:update",
37
+ "scopes": [
38
+ "org#hci",
39
+ "user#yyy",
40
+ ],
41
+ },
42
+ ]
43
+ `;
@@ -0,0 +1,111 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ test,
5
+ } from 'vitest';
6
+
7
+ import { ActionScopes } from '../../src/index.js';
8
+
9
+ describe('ActionScopes', () => {
10
+ describe('set', () => {
11
+ test(`must add 'OR' scope`, () => {
12
+ const scopes = new ActionScopes().set('foo');
13
+
14
+ expect(scopes.array).toContain('foo');
15
+ });
16
+
17
+ test(`must add 'AND' scope as array`, () => {
18
+ const scopes = new ActionScopes().set('foo+bar');
19
+
20
+ expect(Array.from(scopes.array)).toContainEqual(['foo', 'bar']);
21
+ });
22
+
23
+ test(`must memorize ids`, () => {
24
+ const scopes = new ActionScopes()
25
+ .set('org', 'jsorg:hci')
26
+ .set('org+published');
27
+
28
+ expect(Array.from(scopes.array)).toMatchObject([
29
+ 'org#jsorg:hci',
30
+ ['org#jsorg:hci', 'published'],
31
+ ]);
32
+ });
33
+ });
34
+
35
+ describe(`canBeExecutedInScopes with 'OR' scopes`, () => {
36
+ test(`must return true if at least one required 'OR' scope is present`, () => {
37
+ const scopes = new ActionScopes()
38
+ .set('foo')
39
+ .set('bar');
40
+
41
+ expect(scopes.canBeExecutedInScopes(['foo'])).toBeTruthy();
42
+ expect(scopes.canBeExecutedInScopes(['foo', 'bar'])).toBeTruthy();
43
+ expect(scopes.canBeExecutedInScopes(['foo', 'extra'])).toBeTruthy();
44
+ });
45
+
46
+ test(`must return false if required 'OR' scope is missed`, () => {
47
+ const scopes = new ActionScopes()
48
+ .set('foo')
49
+ .set('bar');
50
+
51
+ expect(scopes.canBeExecutedInScopes(['baz'])).toBeFalsy();
52
+ });
53
+ });
54
+
55
+ describe(`canBeExecutedInScopes with 'AND' scopes`, () => {
56
+ test(`must return true if all required 'AND' scopes are present`, () => {
57
+ const scopes = new ActionScopes().set('foo+bar');
58
+
59
+ expect(scopes.canBeExecutedInScopes([['foo', 'bar']])).toBeTruthy();
60
+ expect(
61
+ scopes.canBeExecutedInScopes([['foo', 'bar'], 'extra']),
62
+ ).toBeTruthy();
63
+ });
64
+
65
+ test(`must return false if required 'AND' scope is missed`, () => {
66
+ const scopes = new ActionScopes().set('foo+bar+baz');
67
+
68
+ expect(scopes.canBeExecutedInScopes([])).toBeTruthy();
69
+ expect(scopes.canBeExecutedInScopes([['foo', 'beez']])).toBeFalsy();
70
+ });
71
+ });
72
+
73
+ describe(`canBeExecutedInScopes with mixed scopes`, () => {
74
+ test(`must return true if all required 'AND' or one of 'OR' scopes are present`, () => {
75
+ const scopes = new ActionScopes()
76
+ .set('foo+bar')
77
+ .set('baz');
78
+
79
+ expect(scopes.canBeExecutedInScopes([['foo', 'bar']])).toBeTruthy();
80
+ expect(
81
+ scopes.canBeExecutedInScopes([['foo', 'bar'], 'extra']),
82
+ ).toBeTruthy();
83
+ expect(scopes.canBeExecutedInScopes(['baz'])).toBeTruthy();
84
+ expect(scopes.canBeExecutedInScopes(['baz', 'extra'])).toBeTruthy();
85
+ });
86
+
87
+ test(`must return true if main scope is matched`, () => {
88
+ const scopes = new ActionScopes().set('foo+bar');
89
+
90
+ expect(scopes.canBeExecutedInScopes(['foo'])).toBeTruthy();
91
+ });
92
+
93
+ test(`must return true if subscopes is matched`, () => {
94
+ const scopes = new ActionScopes().set('foo+bar+baz');
95
+
96
+ expect(scopes.canBeExecutedInScopes([['foo', 'bar']])).toBeTruthy();
97
+ });
98
+
99
+ test(`must return false if no one scopes is matched`, () => {
100
+ const scopes = new ActionScopes().set('bar+baz');
101
+
102
+ expect(scopes.canBeExecutedInScopes([['foo']])).toBeFalsy();
103
+ });
104
+
105
+ test(`must return false if no one subscopes is matched`, () => {
106
+ const scopes = new ActionScopes().set('foo+bar+baz');
107
+
108
+ expect(scopes.canBeExecutedInScopes([['foo+beez']])).toBeFalsy();
109
+ });
110
+ });
111
+ });
@@ -0,0 +1,169 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ test,
5
+ } from 'vitest';
6
+
7
+ import {
8
+ encodeScopes,
9
+ injectScopesIntoPermission,
10
+ mergeResolvedPermissions,
11
+ replaceScope,
12
+ resolvePermissionGroups,
13
+ resolvePermissions,
14
+ } from '../../src/index.js';
15
+
16
+ describe('Helpers', () => {
17
+ test.each([
18
+ [['org#xxx'], '[org#xxx]'],
19
+ [['org#xxx', 'user#xxx'], '[org#xxx,user#xxx]'],
20
+ [[['org#xxx', 'published']], '[org#xxx+published]'],
21
+ [[['org#xxx', 'published'], 'user#xxx'], '[org#xxx+published,user#xxx]'],
22
+ ])('must encode scopes', (scopes, encodedScopes) => {
23
+ expect(encodeScopes(scopes)).toBe(encodedScopes);
24
+ });
25
+
26
+ test('must inject scopes into permission', async () => {
27
+ expect(injectScopesIntoPermission('js:core:episodes:create', [])).toBe(
28
+ 'js:core:episodes:create',
29
+ );
30
+
31
+ expect(injectScopesIntoPermission('js:core:episodes:create', ['org'])).toBe(
32
+ 'js:core:episodes[org]:create',
33
+ );
34
+
35
+ expect(
36
+ injectScopesIntoPermission('js:core:episodes[org]:create', ['shared']),
37
+ ).toBe('js:core:episodes[org,shared]:create');
38
+ });
39
+
40
+ test('must resolve permissions with scopes', async () => {
41
+ const permissions = [
42
+ 'js:*:*:*',
43
+ 'foo:*:*[org]:*',
44
+ 'js:core:*:*',
45
+ 'foo:bar:*[org]:*',
46
+ 'js:core:episodes:*',
47
+ 'js:core:episodes:create',
48
+ 'js:core:episodes[published]:create',
49
+ 'js:core:episodes[action#approve]:create',
50
+ 'js:core:episodes[published,org]:create',
51
+ 'js:core:episodes[published+my]:create',
52
+ 'js:core:episodes[my]:create',
53
+ 'js:core:articles[published]:create',
54
+ ];
55
+ const resolvedPermissions = resolvePermissions(permissions);
56
+ const expectedPermissions = [
57
+ {
58
+ id: 'js:*:*:*',
59
+ scopes: [],
60
+ },
61
+ {
62
+ id: 'foo:*:*:*',
63
+ scopes: ['org'],
64
+ },
65
+ {
66
+ id: 'js:core:*:*',
67
+ scopes: [],
68
+ },
69
+ {
70
+ id: 'foo:bar:*:*',
71
+ scopes: ['org'],
72
+ },
73
+ {
74
+ id: 'js:core:episodes:*',
75
+ scopes: [],
76
+ },
77
+ {
78
+ id: 'js:core:episodes:create',
79
+ scopes: [
80
+ 'published',
81
+ 'action#approve',
82
+ 'org',
83
+ ['published', 'my'],
84
+ 'my',
85
+ ],
86
+ },
87
+ {
88
+ id: 'js:core:articles:create',
89
+ scopes: ['published'],
90
+ },
91
+ ];
92
+
93
+ expect(resolvedPermissions).toEqual(expectedPermissions);
94
+ });
95
+
96
+ test('must resolve permission groups with scopes', async () => {
97
+ const permissionGroups = [
98
+ 'g:js:mam:brands:read',
99
+ 'g:js:mam:episodes[org,shared]:read',
100
+ ];
101
+
102
+ expect(resolvePermissionGroups(permissionGroups)).toEqual([
103
+ {
104
+ id: 'g:js:mam:brands:read',
105
+ scopes: [],
106
+ },
107
+ {
108
+ id: 'g:js:mam:episodes:read',
109
+ scopes: ['org', 'shared'],
110
+ },
111
+ ]);
112
+ });
113
+
114
+ test('must merge resolved permissions', () => {
115
+ expect(
116
+ mergeResolvedPermissions(
117
+ [
118
+ {
119
+ id: 'js:core:episodes:create',
120
+ scopes: ['org#hci'],
121
+ },
122
+ {
123
+ id: 'js:core:videos:create',
124
+ scopes: ['org#hci'],
125
+ },
126
+ {
127
+ id: 'js:core:seasons:create',
128
+ scopes: ['org#hci'],
129
+ },
130
+ {
131
+ id: 'js:core:subtitles:list',
132
+ scopes: [['org#hci', 'published']],
133
+ },
134
+ ],
135
+ [
136
+ {
137
+ id: 'js:core:episodes:create',
138
+ scopes: ['user#xxx', ['org#hci', 'review']],
139
+ },
140
+ {
141
+ id: 'js:core:episodes:update',
142
+ scopes: ['org#hci', 'user#yyy'],
143
+ },
144
+ {
145
+ id: 'js:core:videos:create',
146
+ scopes: [],
147
+ },
148
+ {
149
+ id: 'js:core:subtitles:list',
150
+ scopes: [['org#hci', 'published']],
151
+ },
152
+ ],
153
+ ),
154
+ ).toMatchSnapshot();
155
+ });
156
+
157
+ test.each([
158
+ [['assigned'], 'assigned', 'brand#xxx', ['brand#xxx']],
159
+ [['shared'], 'shared', [['brand#xxx', 'stage#publication']], [['brand#xxx', 'stage#publication']]],
160
+ [
161
+ [['assigned', 'social']],
162
+ 'assigned',
163
+ 'brand#xxx',
164
+ [['brand#xxx', 'social']],
165
+ ],
166
+ ])('must replace scopes', (scopes, from, to, result) => {
167
+ expect(replaceScope(scopes, from, to)).toMatchObject(result);
168
+ });
169
+ });
@@ -0,0 +1,10 @@
1
+ import { IUser } from '../../src/types/user.js';
2
+
3
+ export const createMockUser = (input?: Partial<IUser>): IUser => {
4
+ return {
5
+ id: 'hcu:xxx',
6
+ permissions: [],
7
+ resolvedPermissions: [],
8
+ ...input,
9
+ };
10
+ };
@@ -0,0 +1,218 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ test,
5
+ } from 'vitest';
6
+
7
+ import {
8
+ ActionScopes, isGranted, resolvePermissions,
9
+ } from '../../src/index.js';
10
+ import { createMockUser } from './helpers.js';
11
+
12
+ describe('Permissions', () => {
13
+ describe.each([
14
+ [
15
+ 'js:core:roles:create',
16
+ ['js:core:roles:create'],
17
+ [],
18
+ ],
19
+ [
20
+ 'js:core:roles:update',
21
+ ['js:core:roles:*'],
22
+ [],
23
+ ],
24
+ [
25
+ 'js:core:roles:create',
26
+ ['js:core:*:*'],
27
+ [],
28
+ ],
29
+ [
30
+ 'js:mam:episodes:create',
31
+ ['js:*:*[org]:*'],
32
+ ['org'],
33
+ ],
34
+ [
35
+ 'js:mam:episodes:create',
36
+ ['*:mam:episodes:create'],
37
+ [],
38
+ ],
39
+ [
40
+ 'js:mam:episodes:create',
41
+ ['js:mam:*[org]:*'],
42
+ ['org'],
43
+ ],
44
+ [
45
+ 'js:core:users:update',
46
+ ['js:*:*:*'],
47
+ [],
48
+ ],
49
+ [
50
+ 'js:core:episodes:get',
51
+ ['js:core:episodes[group]:get'],
52
+ ['group'],
53
+ ],
54
+ [
55
+ 'js:core:episodes:get',
56
+ ['js:core:episodes[group,published]:get'],
57
+ ['group', 'published'],
58
+ ],
59
+ [
60
+ 'js:core:episodes:get',
61
+ ['js:core:episodes[published,group]:get'],
62
+ ['group'],
63
+ ],
64
+ [
65
+ 'js:core:episodes:get',
66
+ ['js:core:episodes[published+org]:get'],
67
+ [['published', 'org']],
68
+ ],
69
+ [
70
+ 'js:core:episodes:get',
71
+ ['js:core:episodes[published,group+org]:get'],
72
+ ['group', ['group', 'org']],
73
+ ],
74
+ [
75
+ 'js:core:episodes:get',
76
+ ['js:core:episodes[group#published]:get'],
77
+ ['group#published'],
78
+ ],
79
+ ])(
80
+ 'Access to action \'%s\' with permissions \'%s\' (%j)',
81
+ (operation, permissions, scopes) => {
82
+ const user = createMockUser({
83
+ resolvedPermissions: resolvePermissions(permissions),
84
+ });
85
+
86
+ const userWithoutPermissions = createMockUser({
87
+ resolvedPermissions: [],
88
+ });
89
+
90
+ describe('Success', () => {
91
+ test('must allow operation with permission', async () => {
92
+ expect(isGranted(user, operation, scopes)).toBeTruthy();
93
+ });
94
+ });
95
+
96
+ describe('Fail', () => {
97
+ test('must deny operation without permission', async () => {
98
+ expect(isGranted(userWithoutPermissions, operation, scopes)).toBeFalsy();
99
+ });
100
+ });
101
+ },
102
+ );
103
+ });
104
+
105
+ describe('Other Cases', () => {
106
+ test('must be granted with any scope', async () => {
107
+ const user = createMockUser({
108
+ resolvedPermissions: resolvePermissions(['js:core:episodes[acc]:list']),
109
+ });
110
+
111
+ expect(
112
+ isGranted(user, 'js:core:episodes:list', ['*']),
113
+ ).toBeTruthy();
114
+ });
115
+
116
+ test('must be granted with any order of scopes grouped with "and" logic', async () => {
117
+ const user = createMockUser({
118
+ resolvedPermissions: resolvePermissions(['js:core:episodes[foo+bar+baz]:list']),
119
+ });
120
+
121
+ expect(
122
+ isGranted(user, 'js:core:episodes:list', [['baz', 'foo', 'bar']]),
123
+ ).toBeTruthy();
124
+ });
125
+ });
126
+
127
+ describe('Permission Denied', () => {
128
+ test('must not be granted if org is not specified in auth context', async () => {
129
+ const user = createMockUser({
130
+ resolvedPermissions: [
131
+ {
132
+ id: 'js:core:roles:assign-org',
133
+ scopes: ['org#jsorg:test-org'],
134
+ },
135
+ {
136
+ id: 'js:core:roles:assign-system',
137
+ scopes: ['org#jsorg:test-org'],
138
+ },
139
+ ],
140
+ });
141
+
142
+ const scopes = new ActionScopes();
143
+
144
+ expect(isGranted(user, 'js:core:roles:assign-system', scopes)).toBeFalsy();
145
+ expect(isGranted(user, 'js:core:roles:assign-org', scopes)).toBeFalsy();
146
+ });
147
+
148
+ test('must not be granted if another org specified in auth context', async () => {
149
+ const user = createMockUser({
150
+ resolvedPermissions: [
151
+ {
152
+ id: 'js:core:roles:assign-org',
153
+ scopes: ['org#jsorg:test-org-1'],
154
+ },
155
+ {
156
+ id: 'js:core:roles:assign-system',
157
+ scopes: ['org#jsorg:test-org-1'],
158
+ },
159
+ ],
160
+ });
161
+
162
+ const scopes = new ActionScopes();
163
+ scopes.set('org#jsorg:test-org-2');
164
+
165
+ expect(isGranted(user, 'js:core:roles:assign-system', scopes)).toBeFalsy();
166
+ expect(isGranted(user, 'js:core:roles:assign-org', scopes)).toBeFalsy();
167
+ });
168
+ });
169
+
170
+ describe('Organization Group Admin', () => {
171
+ test('must be granted update entity from organization group', async () => {
172
+ const user = createMockUser({
173
+ resolvedPermissions: [
174
+ {
175
+ id: '*:*:*:*',
176
+ scopes: ['org#mygroup'],
177
+ },
178
+ ],
179
+ });
180
+
181
+ const scopes = new ActionScopes();
182
+ scopes.set('org#mygroup');
183
+
184
+ expect(isGranted(user, 'js:core:brands:update', scopes)).toBeTruthy();
185
+ });
186
+
187
+ test('must be granted update organization in orggroup in any product', async () => {
188
+ const user = createMockUser({
189
+ resolvedPermissions: [
190
+ {
191
+ id: '*:core:organizations:update',
192
+ scopes: ['orggroup#hcgrp:ZT9mt8j9lSR'],
193
+ },
194
+ ],
195
+ });
196
+
197
+ const scopes = new ActionScopes();
198
+ scopes.set('orggroup#hcgrp:ZT9mt8j9lSR');
199
+
200
+ expect(isGranted(user, 'bo:core:organizations:update', scopes)).toBeTruthy();
201
+ });
202
+
203
+ test('must not be granted update entity from another organization group', async () => {
204
+ const user = createMockUser({
205
+ resolvedPermissions: [
206
+ {
207
+ id: '*:*:*:*',
208
+ scopes: ['org#mygroup'],
209
+ },
210
+ ],
211
+ });
212
+
213
+ const scopes = new ActionScopes();
214
+ scopes.set('org#notmygroup');
215
+
216
+ expect(isGranted(user, 'js:core:brands[org]:update', scopes)).toBeFalsy();
217
+ });
218
+ });
@@ -0,0 +1,80 @@
1
+ import {
2
+ beforeEach,
3
+ describe,
4
+ expect,
5
+ it,
6
+ } from 'vitest';
7
+
8
+ import { ScopesBulder } from '../../src/permissions/scopes-bulder.js';
9
+
10
+ describe('ScopesBulder', () => {
11
+ let scopes: ScopesBulder;
12
+
13
+ beforeEach(() => {
14
+ scopes = new ScopesBulder();
15
+ });
16
+
17
+ it('should add a single scope', () => {
18
+ scopes.append('org#hci');
19
+ expect(scopes.build()).toEqual(['org#hci']);
20
+ });
21
+
22
+ it('should add an array of scopes', () => {
23
+ scopes.extend(['org#hci', 'org#dv']);
24
+ expect(scopes.build()).toEqual(['org#hci', 'org#dv']);
25
+ });
26
+
27
+ it('should add another ScopesBulder', () => {
28
+ const otherScopes = new ScopesBulder(['org#hcc', 'org#dv']);
29
+ scopes.extend(otherScopes);
30
+ expect(scopes.build()).toEqual(['org#hcc', 'org#dv']);
31
+ });
32
+
33
+ it('should clone the ScopesBulder', () => {
34
+ scopes.append('org#hcc');
35
+ const clone = scopes.clone();
36
+ expect(clone.build()).toEqual(scopes.build());
37
+ expect(clone).not.toBe(scopes);
38
+ });
39
+
40
+ it('should join simple org scope', () => {
41
+ scopes.extend(['published', 'draft']);
42
+ scopes.join('org#hcc', 'before');
43
+ expect(scopes.build()).toEqual([
44
+ ['org#hcc', 'published'],
45
+ ['org#hcc', 'draft'],
46
+ ]);
47
+ });
48
+
49
+ it('should join lang scopes', () => {
50
+ scopes.append('season#xxx');
51
+ scopes.join(['lang#en', 'lang#de']);
52
+ expect(scopes.build()).toEqual([
53
+ ['season#xxx', 'lang#en'],
54
+ ['season#xxx', 'lang#de'],
55
+ ]);
56
+ });
57
+
58
+ it('should join lang scopes to complex scopes', () => {
59
+ scopes.append(['org#hci', 'season#xxx']);
60
+ scopes.join(['lang#en', 'lang#de']);
61
+ expect(scopes.build()).toEqual([
62
+ ['org#hci', 'season#xxx', 'lang#en'],
63
+ ['org#hci', 'season#xxx', 'lang#de'],
64
+ ]);
65
+ });
66
+
67
+ it('should keep scopes when joining empty scopes', () => {
68
+ scopes.append(['org#hci', 'season#xxx']);
69
+ scopes.join([]);
70
+ expect(scopes.build()).toEqual([
71
+ ['org#hci', 'season#xxx'],
72
+ ]);
73
+ });
74
+
75
+ it('should replace prefix in all scopes', () => {
76
+ scopes.extend(['org#hcorg:hci', 'org#hcorg:dv']);
77
+ scopes.replacePrefix('org', 'id');
78
+ expect(scopes.build()).toEqual(['id#hcorg:hci', 'id#hcorg:dv']);
79
+ });
80
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "moduleResolution": "bundler",
6
+ },
7
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
8
+ "exclude": ["node_modules", "dist"],
9
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'tsdown';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ tsconfig: '../../tsconfig.build.json',
6
+ outDir: 'dist',
7
+ dts: true,
8
+ clean: true,
9
+ sourcemap: true,
10
+ format: 'esm',
11
+ });
@@ -0,0 +1,23 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { defineConfig } from 'vitest/config';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ export default defineConfig({
8
+ test: {
9
+ environment: 'node',
10
+ include: ['tests/**/*.test.ts'],
11
+ exclude: ['tests/tools/**'],
12
+ coverage: {
13
+ provider: 'v8',
14
+ reporter: ['text', 'json', 'html', 'lcov', 'clover'],
15
+ reportsDirectory: './dev/coverage',
16
+ },
17
+ },
18
+ resolve: {
19
+ alias: {
20
+ '@': path.resolve(__dirname, './src'),
21
+ },
22
+ },
23
+ });