Skip to content

Commit

Permalink
Guard against unauthorized project access
Browse files Browse the repository at this point in the history
Authorization guard for projects
Remove pass-through for planning email addresses
Feature flag use of dcpcreeper email

closes #NYCPlanning/ae-private#53
  • Loading branch information
TangoYankee committed Jan 3, 2025
1 parent 37cdcb0 commit 823961a
Show file tree
Hide file tree
Showing 12 changed files with 261 additions and 113 deletions.
5 changes: 3 additions & 2 deletions .github/workflows/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
deploy-server:
name: 🚀 Deploy server
needs: test-client
environment:
environment:
name: production
url: https://applicants-api.nycplanningdigital.com
runs-on: ubuntu-latest
Expand All @@ -46,6 +46,7 @@ jobs:
appdir: server
env:
HD_FEATURE_FLAG_SELF_SERVICE: ${{ vars.FEATURE_FLAG_SELF_SERVICE }}
HD_FEATURE_FLAG_CREEPER: ${{ vars.FEATURE_FLAG_CREEPER }}
HD_ADO_PRINCIPAL: ${{ secrets.ADO_PRINCIPAL }}
HD_AUTHORITY_HOST_URL: ${{ secrets.AUTHORITY_HOST_URL }}
HD_CITYPAY_AGENCYID: ${{ secrets.CITYPAY_AGENCYID }}
Expand Down Expand Up @@ -107,7 +108,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 12.x
- name: Install application dependencies
- name: Install application dependencies
working-directory: client
run: yarn install --immutable --immutable-cache --check-cache
- name: Build client
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
appdir: server
env:
HD_FEATURE_FLAG_SELF_SERVICE: ${{ vars.FEATURE_FLAG_SELF_SERVICE }}
HD_FEATURE_FLAG_CREEPER: ${{ vars.FEATURE_FLAG_CREEPER }}
HD_ADO_PRINCIPAL: ${{ secrets.ADO_PRINCIPAL }}
HD_AUTHORITY_HOST_URL: ${{ secrets.AUTHORITY_HOST_URL }}
HD_CITYPAY_AGENCYID: ${{ secrets.CITYPAY_AGENCYID }}
Expand Down Expand Up @@ -102,7 +103,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 12.x
- name: Install application dependencies
- name: Install application dependencies
working-directory: client
run: yarn install --immutable --immutable-cache --check-cache
- name: Build client
Expand All @@ -119,4 +120,3 @@ jobs:
--site ${{secrets.NETLIFY_SITE_ID}} \
--auth ${{secrets.NETLIFY_AUTH_TOKEN}} \
--message "${{ github.event.head_commit.message }}"
3 changes: 2 additions & 1 deletion .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
appdir: server
env:
HD_FEATURE_FLAG_SELF_SERVICE: ${{ vars.FEATURE_FLAG_SELF_SERVICE }}
HD_FEATURE_FLAG_CREEPER: ${{ vars.FEATURE_FLAG_CREEPER }}
HD_ADO_PRINCIPAL: ${{ secrets.ADO_PRINCIPAL }}
HD_AUTHORITY_HOST_URL: ${{ secrets.AUTHORITY_HOST_URL }}
HD_CLIENT_ID: ${{ secrets.CLIENT_ID }}
Expand Down Expand Up @@ -94,7 +95,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 12.x
- name: Install application dependencies
- name: Install application dependencies
working-directory: client
run: yarn install
- name: Build client
Expand Down
8 changes: 1 addition & 7 deletions server/src/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,7 @@ export class AuthMiddleware implements NestMiddleware {
const email = refererParams.searchParams.get('email'); // the query param, email, sent from the client. this is who the "Creeper" wants to be.
const { mail: userEmail } = validatedToken; // the "creepers" actual email, for verification.

if (
email &&
(userEmail === '[email protected]' ||
userEmail.includes('@planning.nyc.gov'))
) {
if (email && userEmail === '[email protected]') {
// simply include the "creeper" param in the session
validatedToken = {
...validatedToken,
Expand Down Expand Up @@ -82,8 +78,6 @@ export class AuthMiddleware implements NestMiddleware {
const validatedSpoofedToken =
await this.authService.validateCurrentToken(spoofedZapToken);

validatedSpoofedToken.isCreeper = true;

return validatedSpoofedToken;
}
}
215 changes: 215 additions & 0 deletions server/src/authorize.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from 'src/config/config.service';
import { CrmService } from 'src/crm/crm.service';
import { Relationship } from 'src/relationships.decorator';

const APPLICANT_ACTIVE_STATUS_CODE = 1;
const PROJECT_ACTIVE_STATE_CODE = 0;
const PROJECT_VISIBILITY_APPLICANT_ONLY = 717170002;
const PROJECT_VISIBILITY_GENERAL_PUBLIC = 717170003;

@Injectable()
export class AuthorizeGuard implements CanActivate {
constructor(
private readonly crmService: CrmService,
private readonly config: ConfigService,
private readonly reflector: Reflector,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const {
params: { projectId, packageId, projectApplicantId },
session: { contactId, mail, creeperTargetEmail },
body,
} = context.switchToHttp().getRequest() as {
params: {
projectId: string | undefined;
packageId: string | undefined;
projectApplicantId: string | undefined;
};
session: {
contactId: string;
mail: string;
creeperTargetEmail: string | null | undefined;
};
body: {
data?: {
relationships?: {
project?: {
data: {
id: string;
};
};
};
};
};
};

const projectIdBody = body?.data?.relationships?.project?.data?.id;
const relationships: Array<Relationship> =
this.reflector.get('relationships', context.getHandler()) ?? [];

return (
this.withHelper(relationships, creeperTargetEmail, mail) ||
this.withSelf(relationships, creeperTargetEmail) ||
(await this.withApplicantTeam({
contactId,
packageId,
projectId,
projectIdBody,
projectApplicantId,
relationships,
}))
);
}

withHelper(
relationships: Array<Relationship>,
creeperTargetEmail: string | null | undefined,
mail: string,
) {
if (!this.config.featureFlag.creeper) return false;
if (!relationships.includes('helper')) return false;
// There is no account being helped by creeper
if (creeperTargetEmail === null || creeperTargetEmail === undefined)
return false;
if (mail === '[email protected]') return true;
return false;
}

withSelf(
relationships: Array<Relationship>,
creeperTargetEmail: string | null | undefined,
) {
if (!relationships.includes('self')) return false;
// Access is not being attempted by the creeper account
if (creeperTargetEmail === null || creeperTargetEmail === undefined)
return true;
return false;
}

withApplicantTeam({
contactId,
packageId,
projectId,
projectIdBody,
projectApplicantId,
relationships,
}: {
contactId: string;
packageId: string | undefined;
projectId: string | undefined;
projectIdBody: string | undefined;
projectApplicantId: string | undefined;
relationships: Array<Relationship>;
}) {
if (!relationships.includes('applicant-team')) return false;
if (projectId !== undefined) {
return this.checkByProjectId(contactId, projectId);
} else if (projectIdBody !== undefined) {
return this.checkByProjectId(contactId, projectIdBody);
} else if (packageId !== undefined) {
return this.checkByPackageId(contactId, packageId);
} else if (projectApplicantId !== undefined) {
return this.checkByProjectApplicantId(contactId, projectApplicantId);
} else {
return false;
}
}

async checkByProjectApplicantId(
contactId: string,
projectApplicantId: string,
) {
try {
const { records } = await this.crmService.get(
'dcp_projects',
`
$filter=
dcp_dcp_project_dcp_projectapplicant_Project/
any(o:
o/dcp_projectapplicantid eq '${projectApplicantId}'
)
and dcp_dcp_project_dcp_projectapplicant_Project/
any(o:
o/_dcp_applicant_customer_value eq '${contactId}'
and o/statuscode eq ${APPLICANT_ACTIVE_STATUS_CODE}
)
`,
);
return records.length > 0;
} catch (e) {
console.error('failed to find project applicant', e);
throw new HttpException(
'Could not find project applicant',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}

async checkByProjectId(contactId: string, projectId: string) {
try {
const { records } = await this.crmService.get(
'dcp_projects',
`
$filter=
dcp_dcp_project_dcp_projectapplicant_Project/
any(o:
o/_dcp_applicant_customer_value eq '${contactId}'
and o/statuscode eq ${APPLICANT_ACTIVE_STATUS_CODE}
)
and dcp_projectid eq '${projectId}'
`,
);

return records.length > 0;
} catch (e) {
console.error('failed to find project for user', e);
throw new HttpException(
'Could not find project',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}

async checkByPackageId(contactId: string, packageId: string) {
try {
const { records } = await this.crmService.get(
'dcp_projects',
`
$filter=
dcp_dcp_project_dcp_package_project/
any(o:
o/dcp_packageid eq '${packageId}'
)
and
dcp_dcp_project_dcp_projectapplicant_Project/
any(o:
o/_dcp_applicant_customer_value eq '${contactId}'
and o/statuscode eq ${APPLICANT_ACTIVE_STATUS_CODE}
)
and (
dcp_visibility eq ${PROJECT_VISIBILITY_APPLICANT_ONLY}
or dcp_visibility eq ${PROJECT_VISIBILITY_GENERAL_PUBLIC}
)
and statecode eq ${PROJECT_ACTIVE_STATE_CODE}
`,
);

return records.length > 0;
} catch (e) {
console.error('cannot verify package or contact', e);
throw new HttpException(
'Cannot find package',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
1 change: 1 addition & 0 deletions server/src/config/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class ConfigService {
get featureFlag() {
return {
selfService: this.envConfig['FEATURE_FLAG_SELF_SERVICE'] === 'ON',
creeper: this.envConfig['FEATURE_FLAG_CREEPER'] === 'ON',
};
}
}
84 changes: 0 additions & 84 deletions server/src/packages/package-access.guard.ts

This file was deleted.

Loading

0 comments on commit 823961a

Please sign in to comment.