Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Guard against unauthorized project access #1341

Merged
merged 1 commit into from
Jan 13, 2025
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
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
Loading