diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4898e48..222c41c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,6 +9,9 @@ on: env: CI: true +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-CI + jobs: main: runs-on: ubuntu-latest @@ -41,7 +44,7 @@ jobs: - uses: nrwl/nx-set-shas@v4 - - uses: 8BitJonny/gh-get-current-pr@2.2.0 + - uses: 8BitJonny/gh-get-current-pr@3.0.0 id: current-pr - if: steps.current-pr.outputs.number != 'null' && github.ref_name != 'main' diff --git a/nx.json b/nx.json index 5e07272..83e5a62 100644 --- a/nx.json +++ b/nx.json @@ -19,15 +19,10 @@ "changelog": { "projectChangelogs": { "createRelease": "github" - }, - "workspaceChangelog": { - "createRelease": "github" } }, "git": { "commit": true, - "push": true, - "pushRemote": "origin", "tag": true } }, @@ -57,6 +52,13 @@ "codeCoverage": true } } + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}", + "registry": "https://registry.npmjs.org/" + }, + "dependsOn": ["build"] } }, "nxCloudAccessToken": "ZDU0ZWUwNDktM2YzZC00ODgzLWFiNTktYzQzNDVmOWZlNTE0fHJlYWQtd3JpdGU=" diff --git a/package-lock.json b/package-lock.json index 6534638..fad8b9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nestjs-ory-integration", - "version": "0.0.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nestjs-ory-integration", - "version": "0.0.1", + "version": "0.1.0", "license": "MIT", "workspaces": [ "packages/*" @@ -18,7 +18,6 @@ "@nestjs/platform-express": "^10.0.2", "@ory/client": "^1.4.9", "defekt": "^9.3.1", - "lodash.get": "^4.4.2", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", "tslib": "^2.3.0" @@ -35,7 +34,6 @@ "@swc-node/register": "~1.6.7", "@swc/core": "~1.3.85", "@types/jest": "^29.4.0", - "@types/lodash.get": "^4.4.9", "@types/node": "18.16.9", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^6.9.1", @@ -3973,15 +3971,6 @@ "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==", "dev": true }, - "node_modules/@types/lodash.get": { - "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@types/lodash.get/-/lodash.get-4.4.9.tgz", - "integrity": "sha512-J5dvW98sxmGnamqf+/aLP87PYXyrha9xIgc2ZlHl6OHMFR2Ejdxep50QfU0abO1+CH6+ugx+8wEUN1toImAinA==", - "dev": true, - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -9671,11 +9660,6 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -12822,7 +12806,7 @@ }, "packages/base-client-wrapper": { "name": "@getlarge/base-client-wrapper", - "version": "0.0.1", + "version": "0.1.0", "license": "MIT", "dependencies": { "@nestjs/axios": "^3.0.1", @@ -12833,12 +12817,12 @@ }, "packages/keto-client-wrapper": { "name": "@getlarge/keto-client-wrapper", - "version": "0.0.1", + "version": "0.1.0", "license": "MIT", "dependencies": { - "@getlarge/keto-relations-parser": "0.0.1", + "@getlarge/keto-relations-parser": "0.0.3", "defekt": "^9.3.1", - "lodash.get": "^4.4.2", + "rxjs": "^7.8.0", "tslib": "^2.3.0" }, "peerDependencies": { @@ -12851,17 +12835,16 @@ }, "packages/keto-relations-parser": { "name": "@getlarge/keto-relations-parser", - "version": "0.0.1", + "version": "0.0.3", "license": "MIT", "dependencies": { "defekt": "^9.3.1", - "lodash.get": "^4.4.2", "tslib": "^2.3.0" } }, "packages/kratos-client-wrapper": { "name": "@getlarge/kratos-client-wrapper", - "version": "0.0.1", + "version": "0.1.0", "license": "MIT", "dependencies": { "tslib": "^2.3.0" diff --git a/package.json b/package.json index bb27fd8..28f5f19 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "@nestjs/platform-express": "^10.0.2", "@ory/client": "^1.4.9", "defekt": "^9.3.1", - "lodash.get": "^4.4.2", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", "tslib": "^2.3.0" @@ -28,7 +27,6 @@ "@swc-node/register": "~1.6.7", "@swc/core": "~1.3.85", "@types/jest": "^29.4.0", - "@types/lodash.get": "^4.4.9", "@types/node": "18.16.9", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^6.9.1", diff --git a/packages/keto-client-wrapper/package.json b/packages/keto-client-wrapper/package.json index d10100a..b483fdc 100644 --- a/packages/keto-client-wrapper/package.json +++ b/packages/keto-client-wrapper/package.json @@ -7,7 +7,7 @@ "@getlarge/keto-relations-parser": "0.0.3", "tslib": "^2.3.0", "defekt": "^9.3.1", - "lodash.get": "^4.4.2" + "rxjs": "^7.8.0" }, "peerDependencies": { "axios": "1.6.5", diff --git a/packages/keto-client-wrapper/project.json b/packages/keto-client-wrapper/project.json index 8f9d861..bf2e3b8 100644 --- a/packages/keto-client-wrapper/project.json +++ b/packages/keto-client-wrapper/project.json @@ -21,12 +21,7 @@ "dependsOn": ["build"] }, "nx-release-publish": { - "executor": "@nx/js:release-publish", - "options": { - "packageRoot": "dist/packages/keto-client-wrapper", - "registry": "https://registry.npmjs.org/" - }, - "dependsOn": ["build"] + "executor": "@nx/js:release-publish" }, "lint": { "executor": "@nx/eslint:lint", diff --git a/packages/keto-client-wrapper/src/lib/ory-authorization.guard.spec.ts b/packages/keto-client-wrapper/src/lib/ory-authorization.guard.spec.ts new file mode 100644 index 0000000..fe3540a --- /dev/null +++ b/packages/keto-client-wrapper/src/lib/ory-authorization.guard.spec.ts @@ -0,0 +1,150 @@ +import { OryBaseService } from '@getlarge/base-client-wrapper'; +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AxiosHeaders } from 'axios'; + +import { + IAuthorizationGuard, + OryAuthorizationGuard, +} from './ory-authorization.guard'; +import { EnhancedRelationTupleFactory } from './ory-permission-checks.decorator'; +import { + OryPermissionsModuleOptions, + OryPermissionsService, +} from './ory-permissions'; + +const mockAxiosResponse = (data: D) => { + return { + status: 200, + statusText: 'OK', + headers: {}, + config: { + url: 'http://localhost', + headers: new AxiosHeaders(), + }, + data, + }; +}; + +describe('OryAuthorizationGuard', () => { + let oryPermissionsService: OryPermissionsService; + let reflector: Reflector; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OryPermissionsService, + { + provide: OryPermissionsModuleOptions, + useValue: { basePath: 'http://localhost' }, + }, + { + provide: OryBaseService, + useValue: { + axios: { + request: jest.fn(), + }, + }, + }, + ], + }).compile(); + + oryPermissionsService = module.get(OryPermissionsService); + reflector = module.get(Reflector); + }); + + describe('evaluateConditions', () => { + let oryAuthorizationGuard: IAuthorizationGuard; + let context: ExecutionContext; + + beforeEach(() => { + context = {} as ExecutionContext; // Mock execution context + const guardFactory = OryAuthorizationGuard({}); + oryAuthorizationGuard = new guardFactory( + reflector, + oryPermissionsService + ); + }); + + it('should allow access for a single permitted relation tuple', async () => { + const factory = () => 'user:123#access@resource:456'; + jest + .spyOn(oryPermissionsService, 'checkPermission') + .mockResolvedValue(mockAxiosResponse({ allowed: true })); + + const result = await oryAuthorizationGuard.evaluateConditions( + factory, + context + ); + expect(result.allowed).toBe(true); + }); + + it('should deny access if one relation in AND condition is not permitted', async () => { + const factory = { + type: 'AND', + conditions: [ + () => 'user:123#access@resource:456', + () => 'user:123#access@resource:789', + ], + } satisfies EnhancedRelationTupleFactory; + jest + .spyOn(oryPermissionsService, 'checkPermission') + .mockResolvedValueOnce(mockAxiosResponse({ allowed: true })) + .mockResolvedValueOnce(mockAxiosResponse({ allowed: false })); + + const result = await oryAuthorizationGuard.evaluateConditions( + factory, + context + ); + expect(result.allowed).toBe(false); + }); + + it('should allow access if one relation in OR condition is permitted', async () => { + const factory = { + type: 'OR', + conditions: [ + () => 'user:123#access@resource:456', + () => 'user:123#access@resource:789', + ], + } satisfies EnhancedRelationTupleFactory; + jest + .spyOn(oryPermissionsService, 'checkPermission') + .mockResolvedValueOnce(mockAxiosResponse({ allowed: false })) + .mockResolvedValueOnce(mockAxiosResponse({ allowed: true })); + + const result = await oryAuthorizationGuard.evaluateConditions( + factory, + context + ); + expect(result.allowed).toBe(true); + }); + + it('should correctly evaluate nested AND/OR conditions', async () => { + const factory = { + type: 'AND', + conditions: [ + { + type: 'OR', + conditions: [ + () => 'user:123#access@resource:456', + () => 'user:123#access@resource:789', + ], + }, + () => 'user:123#access@resource:101', + ], + } satisfies EnhancedRelationTupleFactory; + jest + .spyOn(oryPermissionsService, 'checkPermission') + .mockResolvedValueOnce(mockAxiosResponse({ allowed: true })) + .mockResolvedValueOnce(mockAxiosResponse({ allowed: true })) + .mockResolvedValueOnce(mockAxiosResponse({ allowed: true })); + + const result = await oryAuthorizationGuard.evaluateConditions( + factory, + context + ); + expect(result.allowed).toBe(true); + }); + }); +}); 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 82ec5b2..958a4c3 100644 --- a/packages/keto-client-wrapper/src/lib/ory-authorization.guard.ts +++ b/packages/keto-client-wrapper/src/lib/ory-authorization.guard.ts @@ -1,6 +1,6 @@ import { createPermissionCheckQuery, - RelationTuple, + parseRelationTuple, } from '@getlarge/keto-relations-parser'; import { CanActivate, @@ -11,16 +11,35 @@ import { Type, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import type { Observable } from 'rxjs'; -import { getOryPermissionChecks } from './ory-permission-checks.decorator'; +import { + EnhancedRelationTupleFactory, + getOryPermissionChecks, +} from './ory-permission-checks.decorator'; import { OryPermissionsService } from './ory-permissions'; export interface OryAuthorizationGuardOptions { errorFactory?: (error: Error) => Error; - postCheck?: (relationTuple: RelationTuple, isPermitted: boolean) => void; + postCheck?: (relationTuple: string | string[], isPermitted: boolean) => void; unauthorizedFactory: (ctx: ExecutionContext, error: unknown) => Error; } +export abstract class IAuthorizationGuard implements CanActivate { + abstract options: OryAuthorizationGuardOptions; + abstract canActivate( + context: ExecutionContext + ): boolean | Promise | Observable; + + abstract evaluateConditions( + factory: EnhancedRelationTupleFactory, + context: ExecutionContext + ): Promise<{ + allowed: boolean; + relationTuple: string | string[]; + }>; +} + const defaultOptions: OryAuthorizationGuardOptions = { unauthorizedFactory: () => { return new ForbiddenException(); @@ -29,7 +48,7 @@ const defaultOptions: OryAuthorizationGuardOptions = { export const OryAuthorizationGuard = ( options: Partial = {} -): Type => { +): Type => { @Injectable() class AuthorizationGuard implements CanActivate { constructor( @@ -37,36 +56,75 @@ export const OryAuthorizationGuard = ( readonly oryService: OryPermissionsService ) {} - async canActivate(context: ExecutionContext): Promise { - const factories = - getOryPermissionChecks(this.reflector, context.getHandler()) ?? []; - if (!factories?.length) { - return true; - } - const { postCheck, unauthorizedFactory } = { + get options(): OryAuthorizationGuardOptions { + return { ...defaultOptions, ...options, }; - for (const { relationTupleFactory } of factories) { - const relationTuple = relationTupleFactory(context); - const result = createPermissionCheckQuery(relationTuple); + } + + async evaluateConditions( + factory: EnhancedRelationTupleFactory, + context: ExecutionContext + ): Promise<{ + allowed: boolean; + relationTuple: string | string[]; + }> { + if (typeof factory === 'string' || typeof factory === 'function') { + const { unauthorizedFactory } = this.options; + + const relationTuple = + typeof factory === 'string' ? factory : factory(context); + const result = createPermissionCheckQuery( + parseRelationTuple(relationTuple).unwrapOrThrow() + ); + if (result.hasError()) { throw unauthorizedFactory(context, result.error); } - let isPermitted = false; + try { const { data } = await this.oryService.checkPermission(result.value); - isPermitted = data.allowed; + return { allowed: data.allowed, relationTuple }; } catch (error) { throw unauthorizedFactory(context, error); } + } + const evaluatedConditions = await Promise.all( + factory.conditions.map((cond) => this.evaluateConditions(cond, context)) + ); + const results = evaluatedConditions.flatMap(({ allowed }) => allowed); + const allowed = + factory.type === 'AND' ? results.every(Boolean) : results.some(Boolean); + + return { + allowed, + relationTuple: evaluatedConditions.flatMap( + ({ relationTuple }) => relationTuple + ), + }; + } + + async canActivate(context: ExecutionContext): Promise { + const factories = + getOryPermissionChecks(this.reflector, context.getHandler()) ?? []; + if (!factories?.length) { + return true; + } + const { postCheck, unauthorizedFactory } = this.options; + for (const factory of factories) { + const { allowed, relationTuple } = await this.evaluateConditions( + factory, + context + ); + if (postCheck) { - postCheck(relationTuple, isPermitted); + postCheck(relationTuple, allowed); } - if (!isPermitted) { + if (!allowed) { throw unauthorizedFactory( context, - new Error(`Unauthorized access for ${relationTuple.toString()}`) + new Error(`Unauthorized access for ${relationTuple}`) ); } } diff --git a/packages/keto-client-wrapper/src/lib/ory-permission-checks.decorator.ts b/packages/keto-client-wrapper/src/lib/ory-permission-checks.decorator.ts index d335571..773cb06 100644 --- a/packages/keto-client-wrapper/src/lib/ory-permission-checks.decorator.ts +++ b/packages/keto-client-wrapper/src/lib/ory-permission-checks.decorator.ts @@ -1,54 +1,40 @@ -import { - parseRelationTuple, - RelationTuple, -} from '@getlarge/keto-relations-parser'; import { ExecutionContext, SetMetadata } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -type OryPermissionCheckMetadataType = { - relationTupleFactory: (ctx: ExecutionContext) => RelationTuple; -}; +const ORY_PERMISSION_CHECKS_METADATA_KEY = Symbol('OryPermissionChecksKey'); -type RelationTupleFactory = string | ((ctx: ExecutionContext) => string); +export type RelationTupleFactory = string | ((ctx: ExecutionContext) => string); -const ORY_PERMISSION_CHECKS_METADATA_KEY = Symbol('OryPermissionChecksKey'); +export type RelationTupleCondition = { + type: 'AND' | 'OR'; + conditions: (RelationTupleFactory | RelationTupleCondition)[]; +}; + +export type EnhancedRelationTupleFactory = + | RelationTupleFactory + | RelationTupleCondition; /** * @description Decorator to add permission checks to a handler, will be consumed by the `OryAuthorizationGuard` {@link OryAuthorizationGuard} using the `getOryPermissionChecks` {@link getOryPermissionChecks} function * @param relationTupleFactories * @returns - * @todo add `where` argument to allow for more complex permission checks with `where` clauses - * such as : - * - where relationTupleFactories[0] and relationTupleFactories[1] are true - * - where relationTupleFactories[0] is true or (relationTupleFactories[1] and relationTupleFactories[2]) are true */ export const OryPermissionChecks = ( - ...relationTupleFactories: RelationTupleFactory[] + ...relationTupleFactories: EnhancedRelationTupleFactory[] ) => { - const valueToSet: OryPermissionCheckMetadataType[] = []; - for (const relationTupleFactory of relationTupleFactories) { - if (typeof relationTupleFactory === 'string') { - valueToSet.push({ - relationTupleFactory: () => - parseRelationTuple(relationTupleFactory).unwrapOrThrow(), - }); - } else { - valueToSet.push({ - relationTupleFactory: (ctx) => - parseRelationTuple(relationTupleFactory(ctx)).unwrapOrThrow(), - }); - } - } - return SetMetadata(ORY_PERMISSION_CHECKS_METADATA_KEY, valueToSet); + return SetMetadata( + ORY_PERMISSION_CHECKS_METADATA_KEY, + relationTupleFactories + ); }; export const getOryPermissionChecks = ( reflector: Reflector, handler: Parameters[1] -): OryPermissionCheckMetadataType[] | null => { +): EnhancedRelationTupleFactory[] | null => { const oryPermissions = reflector.get< - OryPermissionCheckMetadataType[], + EnhancedRelationTupleFactory[], typeof ORY_PERMISSION_CHECKS_METADATA_KEY >(ORY_PERMISSION_CHECKS_METADATA_KEY, handler) ?? []; return oryPermissions.length > 0 ? oryPermissions : null; diff --git a/packages/keto-client-wrapper/test/Dockerfile b/packages/keto-client-wrapper/test/Dockerfile index d4ca597..39e18b5 100644 --- a/packages/keto-client-wrapper/test/Dockerfile +++ b/packages/keto-client-wrapper/test/Dockerfile @@ -1,4 +1,4 @@ -FROM oryd/keto:v0.11.1 +FROM oryd/keto:v0.12 COPY ./keto.yaml /home/ory/keto.yaml COPY ./namespaces.ts /home/ory/namespaces.ts diff --git a/packages/keto-client-wrapper/test/app.controller.mock.ts b/packages/keto-client-wrapper/test/app.controller.mock.ts index ec99aca..2b95313 100644 --- a/packages/keto-client-wrapper/test/app.controller.mock.ts +++ b/packages/keto-client-wrapper/test/app.controller.mock.ts @@ -32,4 +32,58 @@ export class ExampleController { getExample(@Param('id') id?: string) { return this.exampleService.getExample(); } + + @OryPermissionChecks({ + type: 'AND', + conditions: [ + (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(); + }, + { + type: 'OR', + conditions: [ + (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('puppetmasters') + .of('Toy', resourceId) + .toString(); + }, + (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('steal') + .of('Toy', resourceId) + .toString(); + }, + ], + }, + ], + }) + @UseGuards( + OryAuthorizationGuard({ + postCheck(relationTuple, isPermitted) { + Logger.log('relationTuple', relationTuple); + Logger.log('isPermitted', isPermitted); + }, + }) + ) + @Get('complex/:id') + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getExampleComplex(@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 9175a1c..7dfe32a 100644 --- a/packages/keto-client-wrapper/test/keto-client-wrapper.spec.ts +++ b/packages/keto-client-wrapper/test/keto-client-wrapper.spec.ts @@ -1,6 +1,7 @@ import { createPermissionCheckQuery, createRelationQuery, + RelationTupleBuilder, relationTupleBuilder, } from '@getlarge/keto-relations-parser'; import { INestApplication } from '@nestjs/common'; @@ -22,24 +23,42 @@ describe('Keto client wrapper E2E', () => { let oryRelationshipsService: OryRelationshipsService; const dockerComposeFile = resolve(join(__dirname, 'docker-compose.yaml')); - const route = '/Example'; - - const createOryRelation = async (object: string, subjectObject: string) => { - const relationTuple = relationTupleBuilder() - .subject('User', subjectObject) - .isIn('owners') - .of('Toy', object) - .toJSON(); + const createOryRelation = async (relationTuple: RelationTupleBuilder) => { await oryRelationshipsService.createRelationship({ - createRelationshipBody: - createRelationQuery(relationTuple).unwrapOrThrow(), + createRelationshipBody: createRelationQuery( + relationTuple.toJSON() + ).unwrapOrThrow(), }); const { data } = await oryPermissionService.checkPermission( - createPermissionCheckQuery(relationTuple).unwrapOrThrow() + createPermissionCheckQuery(relationTuple.toJSON()).unwrapOrThrow() ); expect(data.allowed).toEqual(true); }; + const createOwnerRelation = async (object: string, subjectObject: string) => { + const relationTuple = relationTupleBuilder() + .subject('User', subjectObject) + .isIn('owners') + .of('Toy', object); + await createOryRelation(relationTuple); + }; + + const createAdminRelation = async (subjectObject: string) => { + const relationTuple = relationTupleBuilder() + .subject('User', subjectObject) + .isIn('members') + .of('Group', 'admin'); + await createOryRelation(relationTuple); + }; + + const createPuppetmasterRelation = async (object: string) => { + const relationTuple = relationTupleBuilder() + .subject('Group', 'admin', 'members') + .isIn('puppetmasters') + .of('Toy', object); + await createOryRelation(relationTuple); + }; + beforeAll(() => { if (!process.env['CI']) { execSync(`docker-compose -f ${dockerComposeFile} up -d --wait`, { @@ -91,10 +110,10 @@ describe('Keto client wrapper E2E', () => { it('should pass authorization when relation exists in Ory Keto', async () => { const object = 'car'; const subjectObject = 'Bob'; - await createOryRelation(object, subjectObject); + await createOwnerRelation(object, subjectObject); const { body } = await request(app.getHttpServer()) - .get(`${route}/${object}`) + .get(`/Example/${object}`) .set({ 'x-current-user-id': subjectObject, }); @@ -106,7 +125,7 @@ describe('Keto client wrapper E2E', () => { const subjectObject = 'Alice'; const { body } = await request(app.getHttpServer()) - .get(`${route}/${object}`) + .get(`/Example/${object}`) .set({ 'x-current-user-id': subjectObject, }); @@ -115,4 +134,19 @@ describe('Keto client wrapper E2E', () => { statusCode: 403, }); }); + + 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' }); + }); }); diff --git a/packages/keto-client-wrapper/test/namespaces.ts b/packages/keto-client-wrapper/test/namespaces.ts index 9c0fd17..c8d87a6 100644 --- a/packages/keto-client-wrapper/test/namespaces.ts +++ b/packages/keto-client-wrapper/test/namespaces.ts @@ -1,15 +1,29 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import type { Context, Namespace } from '@ory/permission-namespace-types'; +import type { + Context, + Namespace, + SubjectSet, + // @ts-expect-error - This is a private type from the internal Ory Keto SDK +} from '@ory/permission-namespace-types'; class User implements Namespace {} +class Group implements Namespace { + related: { + members: User[]; + }; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars class Toy implements Namespace { related: { owners: User[]; + puppetmasters: SubjectSet[]; }; permits = { - play: (ctx: Context) => this.related.owners.includes(ctx.subject), + play: (ctx: Context) => + this.related.owners.includes(ctx.subject) || + this.related.puppetmasters.includes(ctx.subject), break: (ctx: Context) => this.permits.play(ctx), steal: (ctx: Context) => !this.permits.play(ctx), }; diff --git a/packages/keto-relations-parser/package.json b/packages/keto-relations-parser/package.json index c0e01a7..2bf254d 100644 --- a/packages/keto-relations-parser/package.json +++ b/packages/keto-relations-parser/package.json @@ -5,7 +5,6 @@ "homepage": "https://github.com/getlarge/nestjs-ory-integration/tree/main/packages/keto-relations-parser", "dependencies": { "defekt": "^9.3.1", - "lodash.get": "^4.4.2", "tslib": "^2.3.0" } } diff --git a/packages/keto-relations-parser/project.json b/packages/keto-relations-parser/project.json index ee7d375..236bf9f 100644 --- a/packages/keto-relations-parser/project.json +++ b/packages/keto-relations-parser/project.json @@ -20,12 +20,7 @@ "dependsOn": ["build"] }, "nx-release-publish": { - "executor": "@nx/js:release-publish", - "options": { - "packageRoot": "dist/packages/keto-relations-parser", - "registry": "https://registry.npmjs.org/" - }, - "dependsOn": ["build"] + "executor": "@nx/js:release-publish" }, "lint": { "executor": "@nx/eslint:lint", diff --git a/packages/keto-relations-parser/src/lib/keto-converters/tuple-to-relationships-parameters.ts b/packages/keto-relations-parser/src/lib/keto-converters/tuple-to-relationships-parameters.ts index ca9194a..b20aea3 100644 --- a/packages/keto-relations-parser/src/lib/keto-converters/tuple-to-relationships-parameters.ts +++ b/packages/keto-relations-parser/src/lib/keto-converters/tuple-to-relationships-parameters.ts @@ -1,6 +1,5 @@ import type { RelationQuery, Relationship } from '@ory/client'; import { error, Result, value } from 'defekt'; -import get from 'lodash.get'; import { TupleToRelationshipError } from '../errors/tuple-to-relationship.error'; import { UnknownError } from '../errors/unknown.error'; @@ -9,6 +8,7 @@ import { isRelationTupleWithReplacements, } from '../is-relation-tuple'; import { RelationTuple } from '../relation-tuple'; +import { get } from '../util/get'; import { RelationTupleWithReplacements } from '../with-replacements/relation-tuple-with-replacements'; import { ReplacementValues } from '../with-replacements/replacement-values'; @@ -35,7 +35,7 @@ const resolveTupleProperty = < tuple: U, replacements?: U extends RelationTupleWithReplacements ? T : never ): string | undefined => { - const factory = get(tuple, property); + const factory = get(tuple, property as keyof U); if (typeof factory === 'function') { return factory(replacements ?? ({} as T)); } diff --git a/packages/keto-relations-parser/src/lib/util/get.ts b/packages/keto-relations-parser/src/lib/util/get.ts new file mode 100644 index 0000000..356ad1a --- /dev/null +++ b/packages/keto-relations-parser/src/lib/util/get.ts @@ -0,0 +1,18 @@ +export const get = ( + obj: TObject, + path: TKey, + defaultValue = undefined +) => { + const travel = (regexp: RegExp) => + String.prototype.split + .call(path, regexp) + .filter(Boolean) + .reduce( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (res: any, key: string) => + res !== null && res !== undefined ? res[key] : res, + obj + ); + const result = travel(/[,[\]]+?/) || travel(/[,[\].]+?/); + return result === undefined || result === obj ? defaultValue : result; +}; diff --git a/packages/keto-relations-parser/src/lib/with-replacements/generate-replacer-function.ts b/packages/keto-relations-parser/src/lib/with-replacements/generate-replacer-function.ts index 3f6e906..4ee5e08 100644 --- a/packages/keto-relations-parser/src/lib/with-replacements/generate-replacer-function.ts +++ b/packages/keto-relations-parser/src/lib/with-replacements/generate-replacer-function.ts @@ -1,5 +1,4 @@ -import lodashGet from 'lodash.get'; - +import { get } from '../util/get'; import { TwoWayMap } from '../util/two-way-map'; import type { ReplaceableString } from './relation-tuple-with-replacements'; import type { ReplacementValues } from './replacement-values'; @@ -41,9 +40,7 @@ function generateReplacerFunctions( sortedFoundReplacements.forEach(({ start, endExcl, prop }) => { const strPart = str.substring(pos, Math.max(0, start)); // let calculation happen before resultStringParts.push(() => strPart); - resultStringParts.push((replacements) => - String(lodashGet(replacements, prop)) - ); + resultStringParts.push((replacements) => String(get(replacements, prop))); pos = endExcl; }); @@ -71,7 +68,7 @@ export const generateReplacerFunction = ( return () => str; } else if (isWholeStringReplacement) { return (replacements) => - String(lodashGet(replacements, foundReplacements[0].prop)); + String(get(replacements, foundReplacements[0].prop)); } const foundReplacementsSortedByStartIndexASC = foundReplacements.sort( diff --git a/packages/keto-relations-parser/tsconfig.json b/packages/keto-relations-parser/tsconfig.json index 19b9eec..f5b8565 100644 --- a/packages/keto-relations-parser/tsconfig.json +++ b/packages/keto-relations-parser/tsconfig.json @@ -1,7 +1,13 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "commonjs" + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true }, "files": [], "include": [], diff --git a/packages/keto-relations-parser/tsconfig.lib.json b/packages/keto-relations-parser/tsconfig.lib.json index 3f06e80..bf553c7 100644 --- a/packages/keto-relations-parser/tsconfig.lib.json +++ b/packages/keto-relations-parser/tsconfig.lib.json @@ -4,7 +4,13 @@ "module": "commonjs", "outDir": "../../dist/out-tsc", "declaration": true, - "types": ["node"] + "types": ["node"], + "target": "es2021", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true }, "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], "include": ["src/**/*.ts"] diff --git a/packages/kratos-client-wrapper/project.json b/packages/kratos-client-wrapper/project.json index 0c70c96..067e439 100644 --- a/packages/kratos-client-wrapper/project.json +++ b/packages/kratos-client-wrapper/project.json @@ -21,12 +21,7 @@ "dependsOn": ["build"] }, "nx-release-publish": { - "executor": "@nx/js:release-publish", - "options": { - "packageRoot": "dist/packages/kratos-client-wrapper", - "registry": "https://registry.npmjs.org/" - }, - "dependsOn": ["build"] + "executor": "@nx/js:release-publish" }, "lint": { "executor": "@nx/eslint:lint",