Skip to content

Commit 44b51ee

Browse files
authored
Merge pull request #109 from getlarge/108-fetory-keto-client-wrapper-enable-broad-permission-checks-via-expansion-api
feat(keto-client-wrapper): enable partially public permission via Expansion API
2 parents c44729a + cdba369 commit 44b51ee

File tree

3 files changed

+214
-56
lines changed

3 files changed

+214
-56
lines changed

packages/keto-client-wrapper/src/lib/ory-authorization.guard.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
Type,
1212
} from '@nestjs/common';
1313
import { Reflector } from '@nestjs/core';
14+
import { PermissionApiExpandPermissionsRequest } from '@ory/client';
1415
import type { Observable } from 'rxjs';
1516

1617
import {
@@ -91,8 +92,26 @@ export const OryAuthorizationGuard = (
9192
}
9293

9394
try {
94-
const { data } = await this.oryService.checkPermission(result.value);
95-
return { allowed: data.allowed, relationTuple };
95+
if (result.value.subjectId || result.value.subjectSetNamespace) {
96+
const { data } = await this.oryService.checkPermission(
97+
result.value
98+
);
99+
return { allowed: data.allowed, relationTuple };
100+
}
101+
/**
102+
* !experimental and counter-inituitive: to use with care
103+
* We check that this resolves to no children, meaning that the object has no relations with any subject => it is public
104+
*/
105+
const { data } = await this.oryService.expandPermissions(
106+
result.value as PermissionApiExpandPermissionsRequest
107+
);
108+
/**
109+
* This Keto API endpoint has a quirk,it returns {code: 404, ... } when relation is not found
110+
* ? maybe the check should be more complex based on data.type or data.children[n].type
111+
**/
112+
//
113+
const allowed = !data.children || data.children.length === 0;
114+
return { allowed, relationTuple };
96115
} catch (error) {
97116
throw unauthorizedFactory.bind(this)(context, error);
98117
}

packages/keto-client-wrapper/test/app.controller.mock.ts

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
11
import { RelationTupleBuilder } from '@getlarge/keto-relations-parser';
2-
import { Controller, Get, Logger, Param, UseGuards } from '@nestjs/common';
2+
import {
3+
Controller,
4+
ForbiddenException,
5+
Get,
6+
Logger,
7+
Param,
8+
UseGuards,
9+
} from '@nestjs/common';
10+
import { inspect } from 'node:util';
311

412
import { OryAuthorizationGuard } from '../src/lib/ory-authorization.guard';
513
import { OryPermissionChecks } from '../src/lib/ory-permission-checks.decorator';
614
import { ExampleService } from './app.service.mock';
715

16+
const AuthorizationGuard = () =>
17+
OryAuthorizationGuard({
18+
postCheck(relationTuple, isPermitted) {
19+
Logger.log('relationTuple', relationTuple);
20+
Logger.log('isPermitted', isPermitted);
21+
},
22+
unauthorizedFactory(ctx, error) {
23+
console.error(inspect((error as any).error.response, false, null, true));
24+
return new ForbiddenException();
25+
},
26+
});
27+
828
@Controller('Example')
929
export class ExampleController {
1030
constructor(private readonly exampleService: ExampleService) {}
@@ -19,14 +39,7 @@ export class ExampleController {
1939
.of('Toy', resourceId)
2040
.toString();
2141
})
22-
@UseGuards(
23-
OryAuthorizationGuard({
24-
postCheck(relationTuple, isPermitted) {
25-
Logger.log('relationTuple', relationTuple);
26-
Logger.log('isPermitted', isPermitted);
27-
},
28-
})
29-
)
42+
@UseGuards(AuthorizationGuard())
3043
@Get(':id')
3144
// eslint-disable-next-line @typescript-eslint/no-unused-vars
3245
getExample(@Param('id') id?: string) {
@@ -73,17 +86,54 @@ export class ExampleController {
7386
},
7487
],
7588
})
76-
@UseGuards(
77-
OryAuthorizationGuard({
78-
postCheck(relationTuple, isPermitted) {
79-
Logger.log('relationTuple', relationTuple);
80-
Logger.log('isPermitted', isPermitted);
81-
},
82-
})
83-
)
89+
@UseGuards(AuthorizationGuard())
8490
@Get('complex/:id')
8591
// eslint-disable-next-line @typescript-eslint/no-unused-vars
8692
getExampleComplex(@Param('id') id?: string) {
8793
return this.exampleService.getExample();
8894
}
95+
96+
@OryPermissionChecks((ctx) => {
97+
const req = ctx.switchToHttp().getRequest();
98+
const currentUserId = req.headers['x-current-user-id'] as string;
99+
const resourceId = req.params.id;
100+
return new RelationTupleBuilder()
101+
.subject('User', currentUserId)
102+
.isAllowedTo('play')
103+
.of('Toy', resourceId)
104+
.toString();
105+
})
106+
@UseGuards(AuthorizationGuard())
107+
@Get('play/:id')
108+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
109+
play(@Param('id') _id?: string) {
110+
return this.exampleService.getExample();
111+
}
112+
113+
@OryPermissionChecks({
114+
type: 'OR',
115+
conditions: [
116+
(ctx) => {
117+
const req = ctx.switchToHttp().getRequest();
118+
const resourceId = req.params.id;
119+
return `Toy:${resourceId}#owners`;
120+
},
121+
(ctx) => {
122+
const req = ctx.switchToHttp().getRequest();
123+
const currentUserId = req.headers['x-current-user-id'] as string;
124+
const resourceId = req.params.id;
125+
return new RelationTupleBuilder()
126+
.subject('User', currentUserId)
127+
.isIn('owners')
128+
.of('Toy', resourceId)
129+
.toString();
130+
},
131+
],
132+
})
133+
@UseGuards(AuthorizationGuard())
134+
@Get('poly/:id')
135+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
136+
poly(@Param('id') _id?: string) {
137+
return this.exampleService.getExample();
138+
}
89139
}

packages/keto-client-wrapper/test/keto-client-wrapper.spec.ts

Lines changed: 126 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,8 @@ describe('Keto client wrapper E2E', () => {
9292
controllers: [ExampleController],
9393
}).compile();
9494

95-
oryPermissionService = module.get<OryPermissionsService>(
96-
OryPermissionsService
97-
);
98-
oryRelationshipsService = module.get<OryRelationshipsService>(
99-
OryRelationshipsService
100-
);
95+
oryPermissionService = module.get(OryPermissionsService);
96+
oryRelationshipsService = module.get(OryRelationshipsService);
10197

10298
app = module.createNestApplication();
10399
await app.init();
@@ -107,46 +103,139 @@ describe('Keto client wrapper E2E', () => {
107103
return app?.close();
108104
});
109105

110-
it('should pass authorization when relation exists in Ory Keto', async () => {
111-
const object = 'car';
112-
const subjectObject = 'Bob';
113-
await createOwnerRelation(object, subjectObject);
106+
describe('GET /Example/:id', () => {
107+
it('should pass authorization when relation exists in Ory Keto', async () => {
108+
const object = 'car';
109+
const subjectObject = 'Bob';
110+
await createOwnerRelation(object, subjectObject);
111+
112+
const { body } = await request(app.getHttpServer())
113+
.get(`/Example/${object}`)
114+
.set({
115+
'x-current-user-id': subjectObject,
116+
});
117+
expect(body).toEqual({ message: 'OK' });
118+
});
114119

115-
const { body } = await request(app.getHttpServer())
116-
.get(`/Example/${object}`)
117-
.set({
118-
'x-current-user-id': subjectObject,
120+
it('should fail authorization when relation does not exist in Ory Keto', async () => {
121+
const object = 'car';
122+
const subjectObject = 'Alice';
123+
124+
const { body } = await request(app.getHttpServer())
125+
.get(`/Example/${object}`)
126+
.set({
127+
'x-current-user-id': subjectObject,
128+
});
129+
expect(body).toEqual({
130+
message: 'Forbidden',
131+
statusCode: 403,
119132
});
120-
expect(body).toEqual({ message: 'OK' });
133+
});
121134
});
122135

123-
it('should fail authorization when relation does not exist in Ory Keto', async () => {
124-
const object = 'car';
125-
const subjectObject = 'Alice';
136+
describe('GET /Example/complex/:id', () => {
137+
it('should pass authorization when relations exist in Ory Keto', async () => {
138+
const object = 'tractor';
139+
const subjectObject = 'Bob';
140+
await createOwnerRelation(object, subjectObject);
141+
await createAdminRelation(subjectObject);
142+
await createPuppetmasterRelation(object);
143+
144+
const { body } = await request(app.getHttpServer())
145+
.get(`/Example/complex/${object}`)
146+
.set({
147+
'x-current-user-id': subjectObject,
148+
});
149+
expect(body).toEqual({ message: 'OK' });
150+
});
151+
});
126152

127-
const { body } = await request(app.getHttpServer())
128-
.get(`/Example/${object}`)
129-
.set({
130-
'x-current-user-id': subjectObject,
153+
describe('GET /Example/play/:id', () => {
154+
it('should fail authorization when relations does not exist in Ory Keto as owner or puppetmaster', async () => {
155+
const object = 'truck';
156+
const subjectObject = 'Isabella';
157+
158+
const { body } = await request(app.getHttpServer())
159+
.get(`/Example/play/${object}`)
160+
.set({
161+
'x-current-user-id': subjectObject,
162+
});
163+
expect(body).toEqual({
164+
message: 'Forbidden',
165+
statusCode: 403,
131166
});
132-
expect(body).toEqual({
133-
message: 'Forbidden',
134-
statusCode: 403,
167+
});
168+
169+
it('should pass authorization when relations exist in Ory Keto as owner', async () => {
170+
const object = 'truck';
171+
const subjectObject = 'Honza';
172+
await createOwnerRelation(object, subjectObject);
173+
174+
const { body } = await request(app.getHttpServer())
175+
.get(`/Example/play/${object}`)
176+
.set({
177+
'x-current-user-id': subjectObject,
178+
});
179+
expect(body).toEqual({ message: 'OK' });
180+
});
181+
182+
it('should pass authorization when relations exist in Ory Keto as puppetmaster', async () => {
183+
const object = 'xylophone';
184+
const subjectObject = 'Tomas';
185+
await createAdminRelation(subjectObject);
186+
await createPuppetmasterRelation(object);
187+
188+
const { body } = await request(app.getHttpServer())
189+
.get(`/Example/play/${object}`)
190+
.set({
191+
'x-current-user-id': subjectObject,
192+
});
193+
expect(body).toEqual({ message: 'OK' });
135194
});
136195
});
137196

138-
it('should pass authorization when relations exist in Ory Keto', async () => {
139-
const object = 'tractor';
140-
const subjectObject = 'Bob';
141-
await createOwnerRelation(object, subjectObject);
142-
await createAdminRelation(subjectObject);
143-
await createPuppetmasterRelation(object);
144-
145-
const { body } = await request(app.getHttpServer())
146-
.get(`/Example/complex/${object}`)
147-
.set({
148-
'x-current-user-id': subjectObject,
197+
describe('GET /Example/poly/:id', () => {
198+
it.only('should pass authorization when object has NO owner', async () => {
199+
const object = 'ice-cream';
200+
const subjectObject = 'Honza';
201+
202+
const { body } = await request(app.getHttpServer())
203+
.get(`/Example/poly/${object}`)
204+
.set({
205+
'x-current-user-id': subjectObject,
206+
});
207+
expect(body).toEqual({ message: 'OK' });
208+
});
209+
210+
it('should fail authorization when object has owner', async () => {
211+
const object = 'ice-cream';
212+
await createOwnerRelation(object, 'Jean-Eude');
213+
214+
const { body } = await request(app.getHttpServer())
215+
.get(`/Example/poly/${object}`)
216+
.set({
217+
'x-current-user-id': 'Marek',
218+
});
219+
expect(body).toEqual({
220+
message: 'Forbidden',
221+
statusCode: 403,
149222
});
150-
expect(body).toEqual({ message: 'OK' });
223+
});
224+
225+
it('should pass authorization when user access its own object', async () => {
226+
const object = 'ice-cream';
227+
const subjectObject = 'Wojtek';
228+
await createOwnerRelation(object, subjectObject);
229+
230+
const { body } = await request(app.getHttpServer())
231+
.get(`/Example/poly/${object}`)
232+
.set({
233+
'x-current-user-id': subjectObject,
234+
});
235+
expect(body).toEqual({
236+
message: 'Forbidden',
237+
statusCode: 403,
238+
});
239+
});
151240
});
152241
});

0 commit comments

Comments
 (0)