diff --git a/app/api/integration/helpers/session/session-factory.ts b/app/api/integration/helpers/session/session-factory.ts index 92cb267..93a8ddc 100644 --- a/app/api/integration/helpers/session/session-factory.ts +++ b/app/api/integration/helpers/session/session-factory.ts @@ -19,6 +19,8 @@ export const sessionData = ( tenant: tenant as Tenant, idpSessionId: `${user.idpId}-${user.id}-${tenant.code}`, idToken: `id_token_${user.idpId}-${user.id}-${tenant.code}`, + //Todo: update this when writing role tests + roles: [], }, }, }; diff --git a/app/api/integration/tests/job-templates.spec.ts b/app/api/integration/tests/job-templates.spec.ts index ae595d2..dda6a7e 100644 --- a/app/api/integration/tests/job-templates.spec.ts +++ b/app/api/integration/tests/job-templates.spec.ts @@ -34,7 +34,7 @@ describe('GET /job-templates', () => { const sessM = sessionCookie('job-templates-spec-m'); let contextM: Awaited>; - beforeAll(async () => { + beforeEach(async () => { contextM = await seedContext(makePartnerUserTenantContext('m')); jest.spyOn(EarthbeamBundlesService.prototype, 'getBundles').mockResolvedValue(allBundles); @@ -43,7 +43,7 @@ describe('GET /job-templates', () => { await sessionStore.set(sessM.sid, sessionData(contextM.user, contextM.tenant)); }); - afterAll(async () => { + afterEach(async () => { await sessionStore.destroy(sessA.sid); await sessionStore.destroy(sessX.sid); await sessionStore.destroy(sessM.sid); diff --git a/app/api/integration/tests/partners.spec.ts b/app/api/integration/tests/partners.spec.ts new file mode 100644 index 0000000..8cd6244 --- /dev/null +++ b/app/api/integration/tests/partners.spec.ts @@ -0,0 +1,50 @@ +import { sessionData } from '../helpers/session/session-factory'; +import { userA } from '../fixtures/user-fixtures'; +import { tenantA } from '../fixtures/context-fixtures/tenant-fixtures'; +import { allBundles } from '../fixtures/em-bundle-fixtures'; +import { sessionCookie } from '../helpers/session/session-cookie'; +import sessionStore from '../helpers/session/session-store'; +import request from 'supertest'; +import { EarthbeamBundlesService } from 'api/src/earthbeam/earthbeam-bundles.service'; +import { SessionData } from 'express-session'; + +describe('POST /partners', () => { + const endpoint = '/partners/assessments/test'; + it('should reject unauthenticated requests', async () => { + const res = await request(app.getHttpServer()).post(endpoint); + expect(res.status).toBe(401); + }); + + describe('authenticated requests', () => { + const sessA = sessionCookie('partners-spec-a'); + let mockGetBundles = jest + .spyOn(EarthbeamBundlesService.prototype, 'getBundles') + .mockResolvedValue(allBundles); + + beforeEach(async () => { + jest.spyOn(EarthbeamBundlesService.prototype, 'getBundles').mockResolvedValue(allBundles); + await sessionStore.set(sessA.sid, sessionData(userA, tenantA)); + }); + + afterEach(async () => { + await sessionStore.destroy(sessA.sid); + mockGetBundles.mockRestore(); + }); + + it('should reject requests from user without the PartnerAdmin role', async () => { + const resA = await request(app.getHttpServer()) + .post('/partners/assessments/test') + .set('Cookie', [sessA.cookie]); + expect(resA.status).toBe(403); + }); + it('allow partner admins to call this route', async () => { + const session = await sessionStore.get(sessA.sid); + session?.passport?.user.roles.push('PartnerAdmin'); + await sessionStore.set(sessA.sid, session as SessionData); + const resA = await request(app.getHttpServer()) + .post('/partners/assessments/test') + .set('Cookie', [sessA.cookie]); + expect(resA.status).toBe(201); + }); + }); +}); diff --git a/app/api/src/app/app.module.ts b/app/api/src/app/app.module.ts index 69c54bd..207e2d3 100644 --- a/app/api/src/app/app.module.ts +++ b/app/api/src/app/app.module.ts @@ -13,6 +13,8 @@ import { JobTemplatesModule } from '../job-templates/job-templates.module'; import { JobsModule } from '../jobs/jobs.module'; import { EarthbeamApiModule } from '../earthbeam/api/earthbeam-api.module'; import { EarthbeamApiAuthModule } from '../earthbeam/api/auth/earthbeam-api-auth.module'; +import { AuthorizedGuard } from '../auth/login/authorized.guard'; +import { PartnersModule } from '../partners/partners.module'; const resourceModules = [ UsersModule, @@ -22,6 +24,7 @@ const resourceModules = [ SchoolYearsModule, EarthbeamApiModule, EarthbeamApiAuthModule, + PartnersModule, ]; @Module({ @@ -33,6 +36,10 @@ const resourceModules = [ provide: APP_GUARD, useClass: AuthenticatedGuard, }, + { + provide: APP_GUARD, + useClass: AuthorizedGuard, + }, ], }) export class AppModule {} diff --git a/app/api/src/app/routes.ts b/app/api/src/app/routes.ts index e2730ff..0ae0357 100644 --- a/app/api/src/app/routes.ts +++ b/app/api/src/app/routes.ts @@ -10,6 +10,7 @@ import { EARTHBEAM_AUTH_BASE_ROUTE, EARTHBEAM_API_BASE_ROUTE, } from '../earthbeam/api/earthbeam-api.endpoints'; +import { PartnersModule } from '../partners/partners.module'; export const routes: Routes = [ { @@ -32,6 +33,10 @@ export const routes: Routes = [ path: 'school-years', module: SchoolYearsModule, }, + { + path: 'partners', + module: PartnersModule, + }, { path: EARTHBEAM_API_BASE_ROUTE, module: EarthbeamApiModule, diff --git a/app/api/src/auth/helpers/authorize.decorator.ts b/app/api/src/auth/helpers/authorize.decorator.ts new file mode 100644 index 0000000..d2497b0 --- /dev/null +++ b/app/api/src/auth/helpers/authorize.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { PrivilegeKey } from 'models/src/dtos/privileges'; + +export const AUTHORIZE_KEY = 'authorize_rule'; + +export const Authorize = (privilege: PrivilegeKey | null) => SetMetadata(AUTHORIZE_KEY, privilege); diff --git a/app/api/src/auth/login/authorized.guard.ts b/app/api/src/auth/login/authorized.guard.ts new file mode 100644 index 0000000..1f041df --- /dev/null +++ b/app/api/src/auth/login/authorized.guard.ts @@ -0,0 +1,54 @@ +import { GetSessionDataDto } from '@edanalytics/models'; +import { + CanActivate, + ExecutionContext, + HttpException, + Inject, + Injectable, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PrivilegeKey } from 'models/src/dtos/privileges'; +import { AUTHORIZE_KEY } from '../helpers/authorize.decorator'; +import { IS_PUBLIC_KEY } from './public.decorator'; +import { Request } from 'express'; +import { plainToInstance } from 'class-transformer'; +import { AuthService } from '../auth.service'; +@Injectable() +export class AuthorizedGuard implements CanActivate { + constructor( + private reflector: Reflector, + @Inject(AuthService) private readonly authService: AuthService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) return true; + + const privilege = this.reflector.getAllAndOverride(AUTHORIZE_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (privilege === undefined) return true; + + const request: Request = context.switchToHttp().getRequest(); + if (privilege === null) { + Logger.verbose('Authorization explicitly skipped for route ' + request.url); + return true; + } + + if (!request.isAuthenticated()) return false; + try { + const userDto = plainToInstance(GetSessionDataDto, request['user']); + return userDto.privileges.has(privilege); + } catch (err) { + Logger.log(err); + return false; + } + } +} diff --git a/app/api/src/auth/login/identity-provider.service.ts b/app/api/src/auth/login/identity-provider.service.ts index c0a8e8f..33fd51f 100644 --- a/app/api/src/auth/login/identity-provider.service.ts +++ b/app/api/src/auth/login/identity-provider.service.ts @@ -231,6 +231,7 @@ export class IdentityProviderService implements OnApplicationBootstrap { tenant, idpSessionId: (claims.sid as string) ?? null, // used to look up session for OIDC backchannel logout idToken: tokenset.id_token ?? null, + roles: [], }); } catch (err) { // Log error so we can troubleshoot config but pass null to `done` diff --git a/app/api/src/job-templates/job-templates.controller.ts b/app/api/src/job-templates/job-templates.controller.ts index 94adfc3..16fa6f7 100644 --- a/app/api/src/job-templates/job-templates.controller.ts +++ b/app/api/src/job-templates/job-templates.controller.ts @@ -5,7 +5,6 @@ import { EarthbeamBundlesService } from '../earthbeam/earthbeam-bundles.service' import { Tenant } from '../auth/helpers/tenant.decorator'; import type { PrismaClient, Tenant as TTenant } from '@prisma/client'; import { PRISMA_APP_USER } from '../database'; - @Controller() @ApiTags('JobTemplates') export class JobsTemplatesController { diff --git a/app/api/src/partners/partners.controller.ts b/app/api/src/partners/partners.controller.ts new file mode 100644 index 0000000..29b3a49 --- /dev/null +++ b/app/api/src/partners/partners.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Authorize } from '../auth/helpers/authorize.decorator'; + +@Controller() +@ApiTags('Partners') +export class PartnersController { + constructor() {} + + @Authorize('partner-earthmover-bundle.create') + @Post(':type/:bundleKey') + async enableBundle() {} +} diff --git a/app/api/src/partners/partners.module.ts b/app/api/src/partners/partners.module.ts new file mode 100644 index 0000000..253c6c8 --- /dev/null +++ b/app/api/src/partners/partners.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PartnersController } from './partners.controller'; + +@Module({ + imports: [], + providers: [], + controllers: [PartnersController], +}) +export class PartnersModule {} diff --git a/app/models/src/dtos/privileges.ts b/app/models/src/dtos/privileges.ts new file mode 100644 index 0000000..f94ec35 --- /dev/null +++ b/app/models/src/dtos/privileges.ts @@ -0,0 +1,4 @@ +export type PrivilegeKey = + | 'partner-earthmover-bundle.read' + | 'partner-earthmover-bundle.create' + | 'partner-earthmover-bundle.delete'; diff --git a/app/models/src/dtos/role-privileges.ts b/app/models/src/dtos/role-privileges.ts new file mode 100644 index 0000000..8a86d5d --- /dev/null +++ b/app/models/src/dtos/role-privileges.ts @@ -0,0 +1,13 @@ +import { PrivilegeKey } from './privileges'; + +export type AppRoles = 'PartnerAdmin'; + +export const rolePrivileges: Record> = Object.freeze({ + PartnerAdmin: Object.freeze( + new Set([ + 'partner-earthmover-bundle.read', + 'partner-earthmover-bundle.create', + 'partner-earthmover-bundle.delete', + ]) + ), +}); diff --git a/app/models/src/dtos/session-data.dto.ts b/app/models/src/dtos/session-data.dto.ts index 9655930..76b0be8 100644 --- a/app/models/src/dtos/session-data.dto.ts +++ b/app/models/src/dtos/session-data.dto.ts @@ -1,3 +1,5 @@ +import { PrivilegeKey } from './privileges'; +import { AppRoles, rolePrivileges } from './role-privileges'; import { GetUserDto, toGetUserDto } from './user.dto'; import { IPassportSession } from '../interfaces'; import { Expose, Transform, Type } from 'class-transformer'; @@ -13,8 +15,15 @@ export class GetSessionDataDto { @Expose() @Type(() => GetTenantDto) tenant: Tenant; + @Expose() + roles?: AppRoles[]; + get privileges() { + if (!this.roles) return new Set(); + return new Set( + this.roles.flatMap((role) => (role in rolePrivileges ? Array.from(rolePrivileges[role]) : [])) + ); + } } - export const toGetSessionDataDto = makeSerializerCustomType( GetSessionDataDto ); diff --git a/app/models/src/interfaces/session.interface.ts b/app/models/src/interfaces/session.interface.ts index 201c6ec..ea6b997 100644 --- a/app/models/src/interfaces/session.interface.ts +++ b/app/models/src/interfaces/session.interface.ts @@ -1,4 +1,5 @@ import { Tenant, User } from '@prisma/client'; +import { AppRoles } from '../dtos/role-privileges'; /** * This interface describes the full session object stored in the DB @@ -21,4 +22,5 @@ export interface IPassportSession { tenant: Tenant; idpSessionId: string | null; idToken: string | null; + roles: AppRoles[]; }