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

Feat/is 21 repo privatisation #806

Merged
merged 32 commits into from
Jul 19, 2023
Merged

Conversation

alexanderleegs
Copy link
Contributor

@alexanderleegs alexanderleegs commented Jun 21, 2023

Problem

This PR introduces a repo privatisation feature. To be reviewed in conjunction with PR #1316 on the isomercms-frontend repo.

This PR adds new fields to the sites and deployments table - isPrivate and encryptedPassword, encryptionIv, passwordDate respectively. The new endpoints have been added to the settings router and related services, with the deployment modification logic added to DeploymentsService.

Tests

See frontend for related tests - for private repos, the is_private param for the sites entry should be true and the encrypted_password, encryption_iv and password_date in deployments should contain the appropriate entries

Deploy Notes

Migrations will need to be run on staging/prod before merging this PR.

New environment variables:

  • SITE_PASSWORD_SECRET_KEY : Encryption key for stored passwords, should match the equivalent key on the backend
  • NETLIFY_ACCESS_TOKEN: this is the access token we use to access our account. We should be using the one generated for isomeradmin

Comment on lines +15 to +31
queryInterface.addColumn(
"deployments", // name of Source model
"encryption_iv", // name of column we're adding
{
type: Sequelize.TEXT,
allowNull: true,
transaction: t,
}
),
queryInterface.addColumn(
"deployments", // name of Source model
"password_date", // name of column we're adding
{
type: Sequelize.DATE,
allowNull: true,
transaction: t,
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could i get more information/context on what encryption_iv + password_date implies? i was under the impression that we'd only need encrypted_password?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

encryption_iv is the initialisation vector used for encryption - this is needed so that similar patterns of text when encrypted don't give rise to similar ciphertexts! This information needs to be stored to retrieve the original unencrypted value

password_date is an additional field which will be helpful for a future feature - we eventually want to be able to tell when a repo password has not been changed for a certain amount of time, so this is useful for that, though it's currently unused

Copy link
Contributor

@seaerchin seaerchin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some comments; also, should we update 1pw env vars?

const deploymentInfo = await this.deploymentsService.getDeploymentInfoFromSiteId(
id
)
if (deploymentInfo.isErr()) return deploymentInfo
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not just throw here rather than returning and throwing? also, can't help but feel that using neverthrow in .js files is asking for trouble .-.

@@ -77,11 +80,58 @@ class SettingsRouter {
return next()
}

async getRepoPassword(req, res, next) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe can prefix with underscore/ignore the unused stuff

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 129 to 133
attachReadRouteHandlerWrapper(this.getRepoPassword)
)
router.post(
"/repoPassword",
attachRollbackRouteHandlerWrapper(this.updateRepoPassword)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably should kebab-case sir

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +118 to +122
res.status(200).send("OK")
return next()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a difference between this and return res.status(200).send("OK")?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes for these cases - we have a notification middleware which can potentially trigger, so we need to move on to the next router instead of returning immediately

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skimmed

return errAsync(`Deployment for ${repoName} does not exist`)
const { id, hostingId: appId } = deploymentInfo
let updateAppInput
if (!enablePassword) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we just branch here and hide the rest of the logic into 2 separate methods?

very confusing to figure out where/what is used, especially because the purpose of the two is diametrically opposed (1 sets password, 1 removes)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that makes sense, f13e3f2


import { config } from "@config/config"

const ALGORITHM = "aes-256-cbc"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const ALGORITHM = "aes-256-cbc"
const DECRYPTION_ALGORITHM = "aes-256-cbc"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed in d1f2659!

Comment on lines 12 to 19
const decipher = createDecipheriv(
ALGORITHM,
SECRET_KEY,
Buffer.from(iv, "hex")
)
let decrypted = decipher.update(encryptedPassword, "hex", "utf8")
decrypted += decipher.final("utf8")
return decrypted
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, but this is abit confusing to me - it seems to me that we are decrypting first and thereafter, converting from hex to utf8 before calling a finalizer of utf8 and appending it to the updated value (in effect, decrypted === decipher<utf8> * 2) isn't it?)

am i misunderstanding something? shouldn't it be either

// update -> final
decipher.update(...)
return decipher.final(...)

// just return straight
return decipher.final(...)

why have a repeated output string?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the usage as given by the documentation - i'm not sure what to call the intermediate steps either tbh, so I used their naming. But .update alone doesn't actually provide the final decrypted string, and .final is needed to cut off the decipher usage, but doesn't actually provide any decryption


const ALGORITHM = "aes-256-cbc"

export const decryptPassword = (encryptedPassword: string, iv: string) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

highly suggest typing + branding the return and enforcing this on downstream callers. doesn't have to be here if the logic here is correct.

// type-def of decrypted password, prevents assigning string to it
type DecryptedPassword = Brand<string, "DecryptedPassword">

export const decryptPassword = (): DecryptedPassword

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in cea7e0e!

Comment on lines 278 to 285
const UpdateRepoPasswordRequestSchema = Joi.object().keys({
encryptedPassword: Joi.string(),
iv: Joi.string(),
enablePassword: Joi.boolean(),
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this all or nothing? ie, do we accept partial inputs like encryptedPassword + iv only

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No partial input - always specify whether we're enabling or disabling the password

Copy link
Contributor

@kishore03109 kishore03109 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey mostly lgtm, but I have some questions regarding the implementation of this.

  1. does FE generate the IV?
  2. Do we regenerate the IV everytime the user wishes to change the password?
  3. did we consider only FE not knowing the IV and secret key? Ie, we send unencrypted pass over secure HTTPS, but this way only BE is aware of the symmetric encryption + has the relevant keys to achieve this functionality?

doc: "Secret key used to encrypt password",
env: "SITE_PASSWORD_SECRET_KEY",
format: String,
default: "",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sanity check here: should this not be required string here? Having an empty string here should not be a sane value for decryption right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, 0c5622f

@@ -43,6 +50,37 @@ class SettingsService {
}
}

async getEncryptedPassword(sessionData) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could we enforce a return type here so that it doesnt resolve to Promise<any>?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a js file, I don't think we can enforce the return type :(

const deploymentInfo = await this.deploymentsService.getDeploymentInfoFromSiteId(
id
)
if (deploymentInfo.isErr()) return deploymentInfo
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sanity check here, shouldnt we reuturn some sort of error here instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns the wrapped error from getDeploymentInfoFromSiteId - we're not modifying it here

)

if (passwordRes.isErr()) {
throw passwordRes.error
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sanity check here: should we return some sort of 404 rather than thorwing an error here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had a chat with Chin about this - given that it's difficult to step through to retrieve all the possible errors here now, I'm leaning towards leaving this as throw and fixing it after the mvp is out (by refactoring to ts) - do you have concerns about this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixing it after the mvp

let's add a ticket for the refactor

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexanderleegs am ok with this, but are we sure that there is is no errors meant for internal consumption thrown back to the end user?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified offline - the thrown isomer errors have user facing error messages, and any other error gets caught by our error handler to throw a generic error and message

const { userWithSiteSessionData } = res.locals

const { error } = UpdateRepoPasswordRequestSchema.validate(req.body)
if (error) throw new BadRequestError(error.message)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment as above, should we be returning 404 rather than throwing here?

Buffer.from(iv, "hex")
)
let decrypted = decipher.update(encryptedPassword, "hex", "utf8")
decrypted += decipher.final("utf8")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to do an update + final here ah?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above on Chin's comment!

@@ -115,6 +118,83 @@ class DeploymentsService {
.map(() => amplifyInfo)
})
}

getDeploymentInfoFromSiteId = async (repoId: string) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we rename repoId to siteId? we have a repos and a sites table, might be better to make a distinction here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, forgot to rename the var - d255ec4

iv: string,
enablePassword: boolean
) => {
const deploymentInfo = await this.repository.findOne({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey, this can be made simpler right?
eg:

    const deploymentInfo = await this.repository.findOne({
      where: {
        name: repoName,
      },
    })

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repoName only exists in Repo though, so we need to do the joins!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit]: rename for repository to deploymentRepository

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/services/identity/DeploymentsService.ts Outdated Show resolved Hide resolved
@@ -88,6 +96,28 @@ class DeploymentClient {
JEKYLL_ENV: branchName === "master" ? "production" : "staging",
},
})

generateUpdatePasswordInput = (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could we separate out the concerns of with two functions of
generateUpdatePasswordInput
and
generateDeletePasswordInput?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, changed in 592ab87

)

if (passwordRes.isErr()) {
throw passwordRes.error
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixing it after the mvp

let's add a ticket for the refactor

if (siteInfo.isErr()) {
// Missing site indicating netlify site - return special result
return okAsync({
password: "",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be possible to return this without the password when isAmplifySite is false? the password only applies if isAmplifySite is true right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that makes sense, have updated it in af96a20

encryptedPassword: string,
iv: string
): DecryptedPassword => {
const SECRET_KEY = Buffer.from(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be a argument to the method too? decryptPassword is a generic util to decrypt an encrypted pass with all necessary parameters given to it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, have made the change to both decrypt and encrypt in 67a47f2

Copy link
Contributor

@kishore03109 kishore03109 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sadly cicd failign ya

iv: string,
enablePassword: boolean
) => {
const deploymentInfo = await this.repository.findOne({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit]: rename for repository to deploymentRepository

Copy link
Contributor

@kishore03109 kishore03109 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexanderleegs not sure why but there are sadly alot of extra commits
Could I get your help to rebase this?
[Style] When we are making stacked diffs, shall we we only merge them in when the parent PR is approved? abit hard to reason about a larger pr in one sitting :(

CHANGELOG.md Outdated
- fix(test cases): fix failing tests [`c843865`](https://github.com/isomerpages/isomercms-backend/commit/c84386549a28602e1af10cd2eda9f056212afaa7)

#### [v0.24.0](https://github.com/isomerpages/isomercms-backend/compare/v0.23.1...v0.24.0)
#### [v0.24.1](https://github.com/isomerpages/isomercms-backend/compare/v0.23.1...v0.24.1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey i think these got committed by accident?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexanderleegs i think need to rebase

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup fixed on rebase!

* chore: update site fixtures

* test: add githubService tests

* test: add settings router test

* Fix: settings tests

* chore: update env vars for tests

* chore: update fixtures

* fix: requestSchema

* fix: use writehandler instead of rollback handler

* chore: update tests to use new dto

* feat: add crypto-utils test

* feat: add integration test

* fix: update updatePassword schema

* fix: update crypto-utils tests

* chore: remove unused imports

* chore: update names of imported fixtures

* feat: add new test case for invalid req

* chore: update const name

* fix: test input for new dto

* chore: add validator

* chore: update tests

* nit: update test names
@alexanderleegs alexanderleegs force-pushed the feat/IS-21-repo-privatisation branch from 98a17a0 to 58fa9d5 Compare July 7, 2023 01:38
@alexanderleegs
Copy link
Contributor Author

@alexanderleegs not sure why but there are sadly alot of extra commits Could I get your help to rebase this? [Style] When we are making stacked diffs, shall we we only merge them in when the parent PR is approved? abit hard to reason about a larger pr in one sitting :(

Sorry I thought the base PR was also approved already!

Copy link
Contributor

@kishore03109 kishore03109 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mostly ok pending 2 concerns:

  1. this pr contains throw .error. Have we made sure that the errors thrown back to the users are not meant for internal consumption? Eg, an axios error from github should not be returned to the user.
  2. This is a stacked diff, have we made sanity checks on the child diff? Specifically ensuring that
  • all non-email sites dont have access to this
  • all the email sites is able to privatise their repos?

)

if (passwordRes.isErr()) {
throw passwordRes.error
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexanderleegs am ok with this, but are we sure that there is is no errors meant for internal consumption thrown back to the end user?


async changeRepoPrivacy(sessionData, shouldMakePrivate) {
const { siteName, isomerUserId } = sessionData
const endpoint = `${siteName}`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sanity check here, wouldnt endpoint refer to repos/<repoName> and siteName refer to just repoName? should we just use the variable siteName instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is referring to the endpoint still! The actual endpoint to call is /repos/<orgname>/<repoName>, but our axios instance prefixes with /repos/<orgname>/, so the remaining endpoint is just <repoName> (referred to as siteName here

@kishore03109 kishore03109 self-requested a review July 19, 2023 04:38
* chore: add new env var for netlify access token

* feat: add netlify privatisation

* chore: update .env.test

* chore: add return type

* chore: updates stop_builds type

* chore: cast error

* chore: move netlify conditional out separately

* fix: check for error type

* chore: shift and rename file

* Fix: refactor axios-utils and add isAxiosError

* refactor: pass only password to updateNetlifySite

* fix: use isAxiosError

* fix: revert to require

* chore: swap to @utils

* chore: modify method name

* chore: rename netlifyService to netlifyApi

* Fix/restrict privatisation to email login (#840)

* fix: add verifyIsEmailUser wrapper to password endpoints

* chore: update tests
@alexanderleegs alexanderleegs merged commit 4c120ed into develop Jul 19, 2023
@mergify mergify bot deleted the feat/IS-21-repo-privatisation branch July 19, 2023 06:13
@kishore03109 kishore03109 mentioned this pull request Jul 19, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants