diff --git a/packages/keto-client-wrapper/src/lib/ory-authorization.guard.ts b/packages/keto-client-wrapper/src/lib/ory-authorization.guard.ts index 42ecc1a..48b3507 100644 --- a/packages/keto-client-wrapper/src/lib/ory-authorization.guard.ts +++ b/packages/keto-client-wrapper/src/lib/ory-authorization.guard.ts @@ -11,6 +11,7 @@ import { Type, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { PermissionApiExpandPermissionsRequest } from '@ory/client'; import type { Observable } from 'rxjs'; import { @@ -91,8 +92,26 @@ export const OryAuthorizationGuard = ( } try { - const { data } = await this.oryService.checkPermission(result.value); - return { allowed: data.allowed, relationTuple }; + if (result.value.subjectId || result.value.subjectSetNamespace) { + const { data } = await this.oryService.checkPermission( + result.value + ); + return { allowed: data.allowed, relationTuple }; + } + /** + * !experimental and counter-inituitive: to use with care + * We check that this resolves to no children, meaning that the object has no relations with any subject => it is public + */ + const { data } = await this.oryService.expandPermissions( + result.value as PermissionApiExpandPermissionsRequest + ); + /** + * This Keto API endpoint has a quirk,it returns {code: 404, ... } when relation is not found + * ? maybe the check should be more complex based on data.type or data.children[n].type + **/ + // + const allowed = !data.children || data.children.length === 0; + return { allowed, relationTuple }; } catch (error) { throw unauthorizedFactory.bind(this)(context, error); } diff --git a/packages/keto-client-wrapper/test/app.controller.mock.ts b/packages/keto-client-wrapper/test/app.controller.mock.ts index 2b95313..4590b35 100644 --- a/packages/keto-client-wrapper/test/app.controller.mock.ts +++ b/packages/keto-client-wrapper/test/app.controller.mock.ts @@ -1,10 +1,30 @@ import { RelationTupleBuilder } from '@getlarge/keto-relations-parser'; -import { Controller, Get, Logger, Param, UseGuards } from '@nestjs/common'; +import { + Controller, + ForbiddenException, + Get, + Logger, + Param, + UseGuards, +} from '@nestjs/common'; +import { inspect } from 'node:util'; import { OryAuthorizationGuard } from '../src/lib/ory-authorization.guard'; import { OryPermissionChecks } from '../src/lib/ory-permission-checks.decorator'; import { ExampleService } from './app.service.mock'; +const AuthorizationGuard = () => + OryAuthorizationGuard({ + postCheck(relationTuple, isPermitted) { + Logger.log('relationTuple', relationTuple); + Logger.log('isPermitted', isPermitted); + }, + unauthorizedFactory(ctx, error) { + console.error(inspect((error as any).error.response, false, null, true)); + return new ForbiddenException(); + }, + }); + @Controller('Example') export class ExampleController { constructor(private readonly exampleService: ExampleService) {} @@ -19,14 +39,7 @@ export class ExampleController { .of('Toy', resourceId) .toString(); }) - @UseGuards( - OryAuthorizationGuard({ - postCheck(relationTuple, isPermitted) { - Logger.log('relationTuple', relationTuple); - Logger.log('isPermitted', isPermitted); - }, - }) - ) + @UseGuards(AuthorizationGuard()) @Get(':id') // eslint-disable-next-line @typescript-eslint/no-unused-vars getExample(@Param('id') id?: string) { @@ -73,17 +86,54 @@ export class ExampleController { }, ], }) - @UseGuards( - OryAuthorizationGuard({ - postCheck(relationTuple, isPermitted) { - Logger.log('relationTuple', relationTuple); - Logger.log('isPermitted', isPermitted); - }, - }) - ) + @UseGuards(AuthorizationGuard()) @Get('complex/:id') // eslint-disable-next-line @typescript-eslint/no-unused-vars getExampleComplex(@Param('id') id?: string) { return this.exampleService.getExample(); } + + @OryPermissionChecks((ctx) => { + const req = ctx.switchToHttp().getRequest(); + const currentUserId = req.headers['x-current-user-id'] as string; + const resourceId = req.params.id; + return new RelationTupleBuilder() + .subject('User', currentUserId) + .isAllowedTo('play') + .of('Toy', resourceId) + .toString(); + }) + @UseGuards(AuthorizationGuard()) + @Get('play/:id') + // eslint-disable-next-line @typescript-eslint/no-unused-vars + play(@Param('id') _id?: string) { + return this.exampleService.getExample(); + } + + @OryPermissionChecks({ + type: 'OR', + conditions: [ + (ctx) => { + const req = ctx.switchToHttp().getRequest(); + const resourceId = req.params.id; + return `Toy:${resourceId}#owners`; + }, + (ctx) => { + const req = ctx.switchToHttp().getRequest(); + const currentUserId = req.headers['x-current-user-id'] as string; + const resourceId = req.params.id; + return new RelationTupleBuilder() + .subject('User', currentUserId) + .isIn('owners') + .of('Toy', resourceId) + .toString(); + }, + ], + }) + @UseGuards(AuthorizationGuard()) + @Get('poly/:id') + // eslint-disable-next-line @typescript-eslint/no-unused-vars + poly(@Param('id') _id?: string) { + return this.exampleService.getExample(); + } } diff --git a/packages/keto-client-wrapper/test/keto-client-wrapper.spec.ts b/packages/keto-client-wrapper/test/keto-client-wrapper.spec.ts index 68a614f..336114b 100644 --- a/packages/keto-client-wrapper/test/keto-client-wrapper.spec.ts +++ b/packages/keto-client-wrapper/test/keto-client-wrapper.spec.ts @@ -92,12 +92,8 @@ describe('Keto client wrapper E2E', () => { controllers: [ExampleController], }).compile(); - oryPermissionService = module.get( - OryPermissionsService - ); - oryRelationshipsService = module.get( - OryRelationshipsService - ); + oryPermissionService = module.get(OryPermissionsService); + oryRelationshipsService = module.get(OryRelationshipsService); app = module.createNestApplication(); await app.init(); @@ -107,46 +103,139 @@ describe('Keto client wrapper E2E', () => { return app?.close(); }); - it('should pass authorization when relation exists in Ory Keto', async () => { - const object = 'car'; - const subjectObject = 'Bob'; - await createOwnerRelation(object, subjectObject); + describe('GET /Example/:id', () => { + it('should pass authorization when relation exists in Ory Keto', async () => { + const object = 'car'; + const subjectObject = 'Bob'; + await createOwnerRelation(object, subjectObject); + + const { body } = await request(app.getHttpServer()) + .get(`/Example/${object}`) + .set({ + 'x-current-user-id': subjectObject, + }); + expect(body).toEqual({ message: 'OK' }); + }); - const { body } = await request(app.getHttpServer()) - .get(`/Example/${object}`) - .set({ - 'x-current-user-id': subjectObject, + it('should fail authorization when relation does not exist in Ory Keto', async () => { + const object = 'car'; + const subjectObject = 'Alice'; + + const { body } = await request(app.getHttpServer()) + .get(`/Example/${object}`) + .set({ + 'x-current-user-id': subjectObject, + }); + expect(body).toEqual({ + message: 'Forbidden', + statusCode: 403, }); - expect(body).toEqual({ message: 'OK' }); + }); }); - it('should fail authorization when relation does not exist in Ory Keto', async () => { - const object = 'car'; - const subjectObject = 'Alice'; + describe('GET /Example/complex/:id', () => { + it('should pass authorization when relations exist in Ory Keto', async () => { + const object = 'tractor'; + const subjectObject = 'Bob'; + await createOwnerRelation(object, subjectObject); + await createAdminRelation(subjectObject); + await createPuppetmasterRelation(object); + + const { body } = await request(app.getHttpServer()) + .get(`/Example/complex/${object}`) + .set({ + 'x-current-user-id': subjectObject, + }); + expect(body).toEqual({ message: 'OK' }); + }); + }); - const { body } = await request(app.getHttpServer()) - .get(`/Example/${object}`) - .set({ - 'x-current-user-id': subjectObject, + describe('GET /Example/play/:id', () => { + it('should fail authorization when relations does not exist in Ory Keto as owner or puppetmaster', async () => { + const object = 'truck'; + const subjectObject = 'Isabella'; + + const { body } = await request(app.getHttpServer()) + .get(`/Example/play/${object}`) + .set({ + 'x-current-user-id': subjectObject, + }); + expect(body).toEqual({ + message: 'Forbidden', + statusCode: 403, }); - expect(body).toEqual({ - message: 'Forbidden', - statusCode: 403, + }); + + it('should pass authorization when relations exist in Ory Keto as owner', async () => { + const object = 'truck'; + const subjectObject = 'Honza'; + await createOwnerRelation(object, subjectObject); + + const { body } = await request(app.getHttpServer()) + .get(`/Example/play/${object}`) + .set({ + 'x-current-user-id': subjectObject, + }); + expect(body).toEqual({ message: 'OK' }); + }); + + it('should pass authorization when relations exist in Ory Keto as puppetmaster', async () => { + const object = 'xylophone'; + const subjectObject = 'Tomas'; + await createAdminRelation(subjectObject); + await createPuppetmasterRelation(object); + + const { body } = await request(app.getHttpServer()) + .get(`/Example/play/${object}`) + .set({ + 'x-current-user-id': subjectObject, + }); + expect(body).toEqual({ message: 'OK' }); }); }); - it('should pass authorization when relations exist in Ory Keto', async () => { - const object = 'tractor'; - const subjectObject = 'Bob'; - await createOwnerRelation(object, subjectObject); - await createAdminRelation(subjectObject); - await createPuppetmasterRelation(object); - - const { body } = await request(app.getHttpServer()) - .get(`/Example/complex/${object}`) - .set({ - 'x-current-user-id': subjectObject, + describe('GET /Example/poly/:id', () => { + it.only('should pass authorization when object has NO owner', async () => { + const object = 'ice-cream'; + const subjectObject = 'Honza'; + + const { body } = await request(app.getHttpServer()) + .get(`/Example/poly/${object}`) + .set({ + 'x-current-user-id': subjectObject, + }); + expect(body).toEqual({ message: 'OK' }); + }); + + it('should fail authorization when object has owner', async () => { + const object = 'ice-cream'; + await createOwnerRelation(object, 'Jean-Eude'); + + const { body } = await request(app.getHttpServer()) + .get(`/Example/poly/${object}`) + .set({ + 'x-current-user-id': 'Marek', + }); + expect(body).toEqual({ + message: 'Forbidden', + statusCode: 403, }); - expect(body).toEqual({ message: 'OK' }); + }); + + it('should pass authorization when user access its own object', async () => { + const object = 'ice-cream'; + const subjectObject = 'Wojtek'; + await createOwnerRelation(object, subjectObject); + + const { body } = await request(app.getHttpServer()) + .get(`/Example/poly/${object}`) + .set({ + 'x-current-user-id': subjectObject, + }); + expect(body).toEqual({ + message: 'Forbidden', + statusCode: 403, + }); + }); }); });