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
19 changes: 19 additions & 0 deletions app/api/integration/fixtures/context-fixtures/tenant-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,25 @@ export const tenantB: WithoutAudit<Tenant> = {
deletedOn: null,
};

// Global tenant for partner A — support users (PartnerAdmin) logged in here
// get access to any non-global tenant under partner A (tenantA, tenantB).
export const tenantDGlobal: WithoutAudit<Tenant> = {
code: 'tenant-d-global',
partnerId: partnerA.id,
isGlobal: true,
deletedOn: null,
};

// A second global tenant for partner A, distinct from tenantDGlobal — used to
// test that metatenant access is denied when the *resource's* tenant is itself
// global (isDescendant requires the resource tenant to be non-global).
export const tenantEGlobal: WithoutAudit<Tenant> = {
code: 'tenant-e-global',
partnerId: partnerA.id,
isGlobal: true,
deletedOn: null,
};

export const tenantC: WithoutAudit<Tenant> = {
code: 'tenant-c',
partnerId: partnerC.id, // shares idp with partner A
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
} from '../../../fixtures/context-fixtures/idp-fixtures';
import {
tenantA,
tenantDGlobal,
tenantEGlobal,
tenantB,
tenantC,
tenantX,
Expand Down Expand Up @@ -86,7 +88,7 @@ const load = async () => {
// Depend on idp
await Promise.all([
prisma.tenant.createMany({
data: [tenantA, tenantB, tenantC, tenantX],
data: [tenantA, tenantDGlobal, tenantEGlobal, tenantB, tenantC, tenantX],
}),
prisma.user.createMany({
data: [userA, userB, userX],
Expand Down
119 changes: 117 additions & 2 deletions app/api/integration/tests/jobs.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import request from 'supertest';
import { sessionCookie } from '../helpers/session/session-cookie';
import sessionStore from '../helpers/session/session-store';
import { tenantA, tenantB, tenantX } from '../fixtures/context-fixtures/tenant-fixtures';
import {
tenantA,
tenantDGlobal,
tenantEGlobal,
tenantB,
tenantX,
} from '../fixtures/context-fixtures/tenant-fixtures';
import { userA, userB, userX } from '../fixtures/user-fixtures';
import { sessionData } from '../helpers/session/session-factory';
import {
Expand Down Expand Up @@ -177,9 +183,11 @@ describe('GET /jobs/:id', () => {
let endpointB: string;
let jobA: Job;
let jobB: Job;
let jobDGlobal: Job;
let jobEGlobal: Job;

beforeEach(async () => {
[jobA, jobB] = await Promise.all([
[jobA, jobB, jobDGlobal, jobEGlobal] = await Promise.all([
seedJob({
odsConfig: odsConfigA2425,
bundle: bundleA,
Expand All @@ -190,6 +198,16 @@ describe('GET /jobs/:id', () => {
bundle: bundleA, // same bundle for both tenants is fine
tenant: tenantB,
}),
seedJob({
odsConfig: odsConfigA2425, // same partner as tenantA/tenantB is fine
bundle: bundleA,
tenant: tenantDGlobal,
}),
seedJob({
odsConfig: odsConfigA2425,
bundle: bundleA,
tenant: tenantEGlobal,
}),
]);
endpointA = `/jobs/${jobA.id}`;
endpointB = `/jobs/${jobB.id}`;
Expand Down Expand Up @@ -236,6 +254,86 @@ describe('GET /jobs/:id', () => {
expect(resA.status).toBe(403);
expect(resB.status).toBe(403);
});

describe('metatenant access (route has @AllowMetatenant)', () => {
const SUPPORT_ROLES = ['runway.test.user', 'runway.test.supportuser'];
const USER_ROLE = 'runway.test.user';

it.each([
{
description:
'global session tenant + non-global resource tenant (same partner) + metatenant privilege -> allowed',
sessionTenant: tenantDGlobal,
resourceJob: () => jobB,
roles: SUPPORT_ROLES,
expectedStatus: 200,
},
{
description:
'global session tenant + non-global resource tenant (same partner) + no metatenant privilege -> forbidden',
sessionTenant: tenantDGlobal,
resourceJob: () => jobB,
roles: USER_ROLE,
expectedStatus: 403,
},
{
description:
'global session tenant + resource owned by a different global tenant + metatenant privilege -> forbidden',
sessionTenant: tenantDGlobal,
resourceJob: () => jobEGlobal,
roles: SUPPORT_ROLES,
expectedStatus: 403,
},
{
description:
'global session tenant + resource owned by a different global tenant + no metatenant privilege -> forbidden',
sessionTenant: tenantDGlobal,
resourceJob: () => jobEGlobal,
roles: USER_ROLE,
expectedStatus: 403,
},
{
description:
'non-global session tenant + non-global resource tenant (same partner) + metatenant privilege -> forbidden',
sessionTenant: tenantA,
resourceJob: () => jobB,
roles: SUPPORT_ROLES,
expectedStatus: 403,
},
{
description:
'non-global session tenant + non-global resource tenant (same partner) + no metatenant privilege -> forbidden',
sessionTenant: tenantA,
resourceJob: () => jobB,
roles: USER_ROLE,
expectedStatus: 403,
},
{
description:
'non-global session tenant + resource owned by a different global tenant + metatenant privilege -> forbidden',
sessionTenant: tenantA,
resourceJob: () => jobDGlobal,
roles: SUPPORT_ROLES,
expectedStatus: 403,
},
{
description:
'non-global session tenant + resource owned by a different global tenant + no metatenant privilege -> forbidden',
sessionTenant: tenantA,
resourceJob: () => jobDGlobal,
roles: USER_ROLE,
expectedStatus: 403,
},
])('$description', async ({ sessionTenant, resourceJob, roles, expectedStatus }) => {
const cookie = (await authHelper.login(idpA, userA, sessionTenant, roles)).cookies;

const res = await request(app.getHttpServer())
.get(`/jobs/${resourceJob().id}`)
.set('Cookie', [cookie]);

expect(res.status).toBe(expectedStatus);
});
});
});
});

Expand Down Expand Up @@ -542,6 +640,23 @@ describe('PUT /jobs/:id/resolve', () => {
expect(resB.status).toBe(403);
});

it('should reject a SupportUser logged into the global tenant, since this route has no @AllowMetatenant', async () => {
const supportUserGlobalCookie = (
await authHelper.login(idpA, userA, tenantDGlobal, [
'runway.test.user',
'runway.test.supportuser',
])
).cookies;

// jobB is a descendant of tenantDGlobal and this same session/privilege combo
// is sufficient to access GET /jobs/:id (which has @AllowMetatenant) — it
// should still be rejected here since this route lacks the decorator.
const resB = await request(app.getHttpServer())
.put(endpoint(jobB.id))
.set('Cookie', [supportUserGlobalCookie]);
expect(resB.status).toBe(403);
});

it('should reject requests for jobs whose status is not changeable', async () => {
const mock = jest
.spyOn(GetJobDto.prototype, 'isStatusChangeable', 'get')
Expand Down
5 changes: 5 additions & 0 deletions app/api/src/auth/authorization/allow-metatenant.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { PrivilegeKey } from 'models/src/dtos/privileges';

export const ALLOW_METATENANT = 'allowMetatenant';
export const AllowMetatenant = (privilege: PrivilegeKey | null) => SetMetadata(ALLOW_METATENANT, privilege);
16 changes: 16 additions & 0 deletions app/api/src/auth/authorization/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Tenant } from "@prisma/client";

export const isDescendant = ({
potentialParent,
potentialChild,
}: {
potentialParent: Tenant;
potentialChild: Tenant;
}) => {
//TODO: expand this when we have the full metatenancy hierarchy synced
return (
!potentialChild.isGlobal &&
potentialParent.isGlobal &&
potentialChild.partnerId === potentialParent.partnerId
);
};
80 changes: 64 additions & 16 deletions app/api/src/auth/authorization/tenant-ownership.guard.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,89 @@
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Inject,
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import { GetSessionDataDto, PrivilegeKey } from '@edanalytics/models';
import { plainToInstance } from 'class-transformer';
import { Request } from 'express';
import { PRISMA_READ_ONLY } from '../../database';
import { SKIP_TENANT_OWNERSHIP } from './skip-tenant-ownership.decorator';
import { ALLOW_METATENANT } from './allow-metatenant.decorator';
import { TENANT_RESOURCE_KEY } from './tenant-resource-key.decorator';
import { isDescendant } from './helpers';


@Injectable()
export class TenantOwnership implements CanActivate {
private reflector: Reflector;
constructor(private readonly resourceKey: keyof Request) {
this.reflector = new Reflector();
}
export class TenantOwnershipGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
@Inject(PRISMA_READ_ONLY) private readonly prisma: PrismaClient
) {}

canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const skipTenantOwnershipCheck = this.reflector.get<boolean>(
SKIP_TENANT_OWNERSHIP,
context.getHandler()
);

if (skipTenantOwnershipCheck) {
return true;
}



const request = context.switchToHttp().getRequest<Request>();
const tenant = request.user.tenant;
if (!tenant) {
const sessionTenant = request.user.tenant;
if (!sessionTenant) {
throw new ForbiddenException('Forbidden'); // if there is no tenant, something is wrong with the session
}

// TODO: get some better typing around this
const resource = request[this.resourceKey];
if (
!resource ||
resource.tenantCode !== tenant.code ||
resource.partnerId !== tenant.partnerId
) {
const resourceKey = this.reflector.get<keyof Request>(
TENANT_RESOURCE_KEY,
context.getClass()
);

const resource = request[resourceKey];
if (!resource || !resourceKey) {throw new InternalServerErrorException('resource or resourceKey is undefined')}
const isExactTenantMatch =
resource.tenantCode === sessionTenant.code && resource.partnerId === sessionTenant.partnerId;

if (isExactTenantMatch) {
return true;
}

const allowMetatenantPrivilege = this.reflector.get<PrivilegeKey | null>(
ALLOW_METATENANT,
context.getHandler()
);
if (!allowMetatenantPrivilege) {
throw new ForbiddenException('Forbidden');
}
const sessionData = plainToInstance(GetSessionDataDto, request.user);
if (!sessionData.privileges.has(allowMetatenantPrivilege)) {
throw new ForbiddenException('Forbidden');
}

const resourceTenant = await this.prisma.tenant.findUnique({
where: { code_partnerId: { code: resource.tenantCode, partnerId: resource.partnerId } },
});
if (!resourceTenant) {
throw new InternalServerErrorException('resourceTenant is undefined'); // resource points at a tenant that doesn't exist
}
const resourceTenantIsDescendantOfSessionTenant = isDescendant({
potentialParent: sessionTenant,
potentialChild: resourceTenant,
});

if (!resourceTenantIsDescendantOfSessionTenant) {
throw new ForbiddenException('Forbidden');
}
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Request } from 'express';

export const TENANT_RESOURCE_KEY = 'tenantResourceKey';
export const TenantResourceKey = (key: keyof Request) => SetMetadata(TENANT_RESOURCE_KEY, key);
Loading