Skip to content
Open
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
2 changes: 2 additions & 0 deletions app/api/integration/helpers/session/session-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
},
},
};
Expand Down
4 changes: 2 additions & 2 deletions app/api/integration/tests/job-templates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('GET /job-templates', () => {
const sessM = sessionCookie('job-templates-spec-m');
let contextM: Awaited<ReturnType<typeof seedContext>>;

beforeAll(async () => {
beforeEach(async () => {
contextM = await seedContext(makePartnerUserTenantContext('m'));

jest.spyOn(EarthbeamBundlesService.prototype, 'getBundles').mockResolvedValue(allBundles);
Expand All @@ -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);
Expand Down
50 changes: 50 additions & 0 deletions app/api/integration/tests/partners.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
7 changes: 7 additions & 0 deletions app/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +24,7 @@ const resourceModules = [
SchoolYearsModule,
EarthbeamApiModule,
EarthbeamApiAuthModule,
PartnersModule,
];

@Module({
Expand All @@ -33,6 +36,10 @@ const resourceModules = [
provide: APP_GUARD,
useClass: AuthenticatedGuard,
},
{
provide: APP_GUARD,
useClass: AuthorizedGuard,
},
],
})
export class AppModule {}
5 changes: 5 additions & 0 deletions app/api/src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand All @@ -32,6 +33,10 @@ export const routes: Routes = [
path: 'school-years',
module: SchoolYearsModule,
},
{
path: 'partners',
module: PartnersModule,
},
{
path: EARTHBEAM_API_BASE_ROUTE,
module: EarthbeamApiModule,
Expand Down
6 changes: 6 additions & 0 deletions app/api/src/auth/helpers/authorize.decorator.ts
Original file line number Diff line number Diff line change
@@ -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);
54 changes: 54 additions & 0 deletions app/api/src/auth/login/authorized.guard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);

if (isPublic) return true;

const privilege = this.reflector.getAllAndOverride<PrivilegeKey | null>(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;
}
}
}
1 change: 1 addition & 0 deletions app/api/src/auth/login/identity-provider.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 0 additions & 1 deletion app/api/src/job-templates/job-templates.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions app/api/src/partners/partners.controller.ts
Original file line number Diff line number Diff line change
@@ -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() {}
}
9 changes: 9 additions & 0 deletions app/api/src/partners/partners.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PartnersController } from './partners.controller';

@Module({
imports: [],
providers: [],
controllers: [PartnersController],
})
export class PartnersModule {}
4 changes: 4 additions & 0 deletions app/models/src/dtos/privileges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type PrivilegeKey =
| 'partner-earthmover-bundle.read'
| 'partner-earthmover-bundle.create'
| 'partner-earthmover-bundle.delete';
13 changes: 13 additions & 0 deletions app/models/src/dtos/role-privileges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { PrivilegeKey } from './privileges';

export type AppRoles = 'PartnerAdmin';

export const rolePrivileges: Record<AppRoles, Set<PrivilegeKey>> = Object.freeze({
PartnerAdmin: Object.freeze(
new Set<PrivilegeKey>([
'partner-earthmover-bundle.read',
'partner-earthmover-bundle.create',
'partner-earthmover-bundle.delete',
])
),
});
11 changes: 10 additions & 1 deletion app/models/src/dtos/session-data.dto.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,8 +15,15 @@ export class GetSessionDataDto {
@Expose()
@Type(() => GetTenantDto)
tenant: Tenant;
@Expose()
roles?: AppRoles[];
get privileges() {
if (!this.roles) return new Set<PrivilegeKey>();
return new Set<PrivilegeKey>(
this.roles.flatMap((role) => (role in rolePrivileges ? Array.from(rolePrivileges[role]) : []))
);
}
}

export const toGetSessionDataDto = makeSerializerCustomType<GetSessionDataDto, IPassportSession>(
GetSessionDataDto
);
2 changes: 2 additions & 0 deletions app/models/src/interfaces/session.interface.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,4 +22,5 @@ export interface IPassportSession {
tenant: Tenant;
idpSessionId: string | null;
idToken: string | null;
roles: AppRoles[];
}