Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions packages/keto-client-wrapper/src/lib/ory-authorization.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand Down
84 changes: 67 additions & 17 deletions packages/keto-client-wrapper/test/app.controller.mock.ts
Original file line number Diff line number Diff line change
@@ -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) {}
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
}
}
163 changes: 126 additions & 37 deletions packages/keto-client-wrapper/test/keto-client-wrapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,8 @@ describe('Keto client wrapper E2E', () => {
controllers: [ExampleController],
}).compile();

oryPermissionService = module.get<OryPermissionsService>(
OryPermissionsService
);
oryRelationshipsService = module.get<OryRelationshipsService>(
OryRelationshipsService
);
oryPermissionService = module.get(OryPermissionsService);
oryRelationshipsService = module.get(OryRelationshipsService);

app = module.createNestApplication();
await app.init();
Expand All @@ -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,
});
});
});
});