Skip to content

Commit b4b7937

Browse files
committed
fix: Support multiple namespaces
1 parent ca7bd8a commit b4b7937

3 files changed

Lines changed: 81 additions & 40 deletions

File tree

packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,27 @@ export class NamespacedAuthorizer implements Authorizer {
3535
// No permissions if no query
3636
if (!query || query.length === 0) return [];
3737

38-
// Base namespace on first resource
39-
const ns = query[0].resource_id ? await this.findNamespace(query[0].resource_id) : undefined;
38+
// Group requested permissions by applicable authorizer
39+
const groupedQueries = new Map<Authorizer, Partial<Permission>[]>();
40+
for (const permission of query) {
41+
const ns = permission.resource_id ? await this.findNamespace(permission.resource_id) : undefined;
42+
const authorizer = (ns && this.authorizers[ns]) || this.fallback;
43+
const existing = groupedQueries.get(authorizer);
4044

41-
// Check namespaces of other resources
42-
for (let i = 1; i < query.length; ++i) {
43-
if ((query[i].resource_id ? await this.findNamespace(query[i].resource_id) : undefined) !== ns) {
44-
this.logger.warn(`Cannot calculate permissions over multiple namespaces at once.`);
45-
return [];
45+
if (existing) {
46+
existing.push(permission);
47+
} else {
48+
groupedQueries.set(authorizer, [ permission ]);
4649
}
4750
}
4851

49-
// Find applicable authorizer
50-
const authorizer = (ns && this.authorizers[ns]) || this.fallback;
51-
52-
// Delegate to authorizer
53-
return authorizer.permissions(claims, query);
52+
// Delegate each namespace-specific subset and merge all granted permissions.
53+
const permissionSets = await Promise.all(
54+
[ ...groupedQueries.entries() ].map(
55+
([ authorizer, groupedQuery ]) => authorizer.permissions(claims, groupedQuery),
56+
),
57+
);
58+
return permissionSets.flat();
5459
}
5560

5661
/**

packages/uma/test/unit/policies/authorizers/NamespacedAuthorizer.test.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { Registration, RegistrationStore } from '../../../../src/util/Registrati
66

77
describe('NamespacedAuthorizer', (): void => {
88
const claims: ClaimSet = { claim: 'set' };
9+
const perm1 = [{ resource_id: 'res1', resource_scopes: [ 'scope1' ] }];
10+
const perm2 = [{ resource_id: 'res2', resource_scopes: [ 'scope2' ] }];
11+
const fallbackPerm = [{ resource_id: 'fallback', resource_scopes: [ 'scopef' ] }];
912

1013
let authorizers: Record<string, Mocked<Authorizer>>;
1114
let fallback: Mocked<Authorizer>;
@@ -14,11 +17,11 @@ describe('NamespacedAuthorizer', (): void => {
1417

1518
beforeEach(async(): Promise<void> => {
1619
authorizers = {
17-
ns1: { permissions: vi.fn().mockResolvedValue('perm1'), },
18-
ns2: { permissions: vi.fn().mockResolvedValue('perm2'), },
20+
ns1: { permissions: vi.fn().mockResolvedValue(perm1), },
21+
ns2: { permissions: vi.fn().mockResolvedValue(perm2), },
1922
};
2023

21-
fallback = { permissions: vi.fn().mockResolvedValue('perm'), };
24+
fallback = { permissions: vi.fn().mockResolvedValue(fallbackPerm), };
2225

2326
const descriptions: Record<string, Registration> = {
2427
res1: { description: { name: 'http://example.com/foo/ns1/res', resource_scopes: [] }, owner: 'owner1' },
@@ -33,19 +36,17 @@ describe('NamespacedAuthorizer', (): void => {
3336
});
3437

3538
describe('.permissions', (): void => {
36-
it('returns an empty list if there is no query or multiple identifiers.', async(): Promise<void> => {
39+
it('returns an empty list if there is no query.', async(): Promise<void> => {
3740
await expect(authorizer.permissions(claims)).resolves.toEqual([]);
3841
await expect(authorizer.permissions(claims, [])).resolves.toEqual([]);
39-
const query = [{ resource_id: 'res1' }, { resource_id: 'res2' }];
40-
await expect(authorizer.permissions(claims, query)).resolves.toEqual([]);
4142
expect(authorizers.ns1.permissions).toHaveBeenCalledTimes(0);
4243
expect(authorizers.ns2.permissions).toHaveBeenCalledTimes(0);
4344
expect(fallback.permissions).toHaveBeenCalledTimes(0);
4445
});
4546

4647
it('calls the matching authorizer.', async(): Promise<void> => {
4748
const query = [{ resource_id: 'res2', resource_scopes: [ 'scope1' ] }];
48-
await expect(authorizer.permissions(claims, query)).resolves.toEqual('perm2');
49+
await expect(authorizer.permissions(claims, query)).resolves.toEqual(perm2);
4950
expect(authorizers.ns1.permissions).toHaveBeenCalledTimes(0);
5051
expect(authorizers.ns2.permissions).toHaveBeenCalledTimes(1);
5152
expect(authorizers.ns2.permissions).toHaveBeenLastCalledWith(claims, query);
@@ -55,14 +56,31 @@ describe('NamespacedAuthorizer', (): void => {
5556
it('calls the fallback authorizer if there is no match.', async(): Promise<void> => {
5657
const query1 = [{ resource_id: 'res3' }];
5758
const query2 = [{ resource_id: 'unknown' }];
58-
await expect(authorizer.permissions(claims, query1)).resolves.toEqual('perm');
59-
await expect(authorizer.permissions(claims, query2)).resolves.toEqual('perm');
59+
await expect(authorizer.permissions(claims, query1)).resolves.toEqual(fallbackPerm);
60+
await expect(authorizer.permissions(claims, query2)).resolves.toEqual(fallbackPerm);
6061
expect(authorizers.ns1.permissions).toHaveBeenCalledTimes(0);
6162
expect(authorizers.ns2.permissions).toHaveBeenCalledTimes(0);
6263
expect(fallback.permissions).toHaveBeenCalledTimes(2);
6364
expect(fallback.permissions).toHaveBeenCalledWith(claims, query1);
6465
expect(fallback.permissions).toHaveBeenCalledWith(claims, query2);
6566
});
67+
68+
it('merges permissions of mixed namespaces.', async(): Promise<void> => {
69+
const query = [
70+
{ resource_id: 'res1', resource_scopes: [ 'scope1' ] },
71+
{ resource_id: 'res2', resource_scopes: [ 'scope2' ] },
72+
{ resource_id: 'res3', resource_scopes: [ 'scope3' ] },
73+
];
74+
75+
await expect(authorizer.permissions(claims, query)).resolves.toEqual([ ...perm1, ...perm2, ...fallbackPerm ]);
76+
77+
expect(authorizers.ns1.permissions).toHaveBeenCalledTimes(1);
78+
expect(authorizers.ns1.permissions).toHaveBeenCalledWith(claims, [ query[0] ]);
79+
expect(authorizers.ns2.permissions).toHaveBeenCalledTimes(1);
80+
expect(authorizers.ns2.permissions).toHaveBeenCalledWith(claims, [ query[1] ]);
81+
expect(fallback.permissions).toHaveBeenCalledTimes(1);
82+
expect(fallback.permissions).toHaveBeenCalledWith(claims, [ query[2] ]);
83+
});
6684
});
6785

6886
it('can be configured to use a different path segment.', async(): Promise<void> => {

test/integration/Partial.test.ts

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ interface UmaConfig {
1818
describe('A server with partial results enabled', (): void => {
1919
const owner = 'http://example.com/alice#me';
2020
const user = `http://example.com/bob`;
21-
const resource = `http://localhost:${rsPort}/alice/data`;
21+
const aliceResource = `http://localhost:${rsPort}/alice/data`;
22+
const bobResource = `http://localhost:${rsPort}/bob/data`;
2223
const readScope = 'http://www.w3.org/ns/odrl/2/read';
2324
const writeScope = 'http://www.w3.org/ns/odrl/2/write';
2425
let umaApp: App;
@@ -75,18 +76,31 @@ describe('A server with partial results enabled', (): void => {
7576
const patJson = await patResponse.json() as { access_token: string, token_type: string };
7677
pat = `${patJson.token_type} ${patJson.access_token}`;
7778

78-
const registrationBody = {
79-
name: resource,
80-
resource_scopes: [ readScope, writeScope ],
81-
};
82-
const resourceRegistrationResponse = await fetch(umaConfig.resource_registration_endpoint, {
79+
let resourceRegistrationResponse = await fetch(umaConfig.resource_registration_endpoint, {
8380
method: 'POST',
8481
headers: {
8582
Authorization: pat,
8683
'Content-Type': 'application/json',
8784
Accept: 'application/json',
8885
},
89-
body: JSON.stringify(registrationBody),
86+
body: JSON.stringify({
87+
name: aliceResource,
88+
resource_scopes: [ readScope, writeScope ],
89+
}),
90+
});
91+
expect(resourceRegistrationResponse.status).toBe(201);
92+
93+
resourceRegistrationResponse = await fetch(umaConfig.resource_registration_endpoint, {
94+
method: 'POST',
95+
headers: {
96+
Authorization: pat,
97+
'Content-Type': 'application/json',
98+
Accept: 'application/json',
99+
},
100+
body: JSON.stringify({
101+
name: bobResource,
102+
resource_scopes: [ readScope, writeScope ],
103+
}),
90104
});
91105
expect(resourceRegistrationResponse.status).toBe(201);
92106

@@ -99,7 +113,7 @@ describe('A server with partial results enabled', (): void => {
99113
}
100114

101115
const auth = request.headers.authorization;
102-
if (hasScope(auth, resource, readScope)) {
116+
if (hasScope(auth, aliceResource, readScope)) {
103117
response.statusCode = 200;
104118
response.end('protected data');
105119
return;
@@ -114,7 +128,7 @@ describe('A server with partial results enabled', (): void => {
114128
},
115129
body: JSON.stringify([
116130
{
117-
resource_id: resource,
131+
resource_id: aliceResource,
118132
resource_scopes: [ readScope ],
119133
}
120134
]),
@@ -174,13 +188,13 @@ describe('A server with partial results enabled', (): void => {
174188
odrl:assigner <${owner}> ;
175189
odrl:action odrl:create, odrl:modify ;
176190
odrl:target <http://localhost:${rsPort}/alice/> ,
177-
<${resource}> .
191+
<${aliceResource}> .
178192
179193
ex:userPermission a odrl:Permission ;
180194
odrl:assignee <${user}> ;
181195
odrl:assigner <${owner}> ;
182196
odrl:action odrl:read ;
183-
odrl:target <${resource}> .`;
197+
odrl:target <${aliceResource}> .`;
184198

185199
const url = `http://localhost:${umaPort}/uma/policies`;
186200
let response = await fetch(url, {
@@ -190,11 +204,11 @@ describe('A server with partial results enabled', (): void => {
190204
});
191205
expect(response.status).toBe(201);
192206

193-
response = await umaFetch(resource, {}, user);
207+
response = await umaFetch(aliceResource, {}, user);
194208
expect(response.status).toBe(200);
195209
});
196210

197-
it('returns partial=true when not all requested scopes are granted.', async(): Promise<void> => {
211+
it('returns partial=true for mixed namespaces when not all scopes are granted.', async(): Promise<void> => {
198212
const permissionResponse = await fetch(umaConfig.permission_endpoint, {
199213
method: 'POST',
200214
headers: {
@@ -204,9 +218,13 @@ describe('A server with partial results enabled', (): void => {
204218
},
205219
body: JSON.stringify([
206220
{
207-
resource_id: resource,
221+
resource_id: aliceResource,
208222
resource_scopes: [ readScope, writeScope ],
209-
}
223+
},
224+
{
225+
resource_id: bobResource,
226+
resource_scopes: [ readScope ],
227+
},
210228
]),
211229
});
212230
expect(permissionResponse.status).toBe(201);
@@ -220,14 +238,14 @@ describe('A server with partial results enabled', (): void => {
220238
// Verify the token contains the allowed scope
221239
const jwtPayload = JSON.parse(Buffer.from(token.access_token.split('.')[1], 'base64').toString());
222240
expect(Array.isArray(jwtPayload.permissions)).toBe(true);
223-
expect(jwtPayload.permissions).toContainEqual({
224-
resource_id: resource,
241+
expect(jwtPayload.permissions).toEqual([{
242+
resource_id: aliceResource,
225243
resource_scopes: [ readScope ]
226-
});
244+
}]);
227245
});
228246

229247
it('can access a protected resource with partial results.', async(): Promise<void> => {
230-
const response = await umaFetch(resource, {}, user);
248+
const response = await umaFetch(aliceResource, {}, user);
231249
expect(response.status).toBe(200);
232250
});
233251
});

0 commit comments

Comments
 (0)