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

TopicDelete Ensign integration #223

Merged
merged 13 commits into from Feb 20, 2023
Merged

TopicDelete Ensign integration #223

merged 13 commits into from Feb 20, 2023

Conversation

pdeziel
Copy link
Contributor

@pdeziel pdeziel commented Feb 16, 2023

Scope of changes

This updates the TopicDelete endpoint so that it actually calls Ensign to delete the topic. This endpoint is protected by a confirmation token to prevent accidental deletes, since this will destroy all of the data in the topic. The user must call the endpoint once to retrieve the confirmation token. At this point the frontend should prompt the user for a confirmation, then send a second request to the endpoint including the token to actually perform the delete.

Since the delete is actually performed in Ensign, the Tenant database object will stay around, although the frontend can interpret the state value on the topic in order to determine whether or not to render the resource for the user. In a future story we will actually implement a routine on Tenant which polls Ensign asynchronously and makes the database updates.

Fixes SC-13773

Type of change

  • new feature
  • bug fix
  • documentation
  • testing
  • technical debt
  • other (describe)

Acceptance criteria

See checklist

Author checklist

  • I have manually tested the change and/or added automation in the form of unit tests or integration tests
  • I have updated the dependencies list
  • I have recompiled and included new protocol buffers to reflect changes I made
  • I have added new test fixtures as needed to support added tests
  • Check this box if a reviewer can merge this pull request after approval (leave it unchecked if you want to do it yourself)
  • I have moved the associated Shortcut story to "Ready for Review"

Reviewer(s) checklist

  • Any new user-facing content that has been added for this PR has been QA'ed to ensure correct grammar, spelling, and understandability.
  • Are there any TODOs in this PR that should be turned into stories?
  • Does the confirmation flow make sense?

@shortcut-integration
Copy link

This pull request has been linked to Shortcut Story #13773: Update TopicDelete.

}
}
return true
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copied directly from GDS

Copy link
Contributor

Choose a reason for hiding this comment

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

Doh, not sure why we didn't use crypto/rand in GDS - but that's probably a problem. Would you mind copying the quarterdeck/keygen package instead?

https://github.com/rotationalio/ensign/blob/main/pkg/quarterdeck/keygen/keygen.go

(You can use it directly if you'd like, but copying it into a secrets utils is fine; I'd like them decoupled so that we can change the way that Quarterdeck issues api keys; e.g. either use the keygen package directly or port it to utils so quarterdeck does not have a dependency on utils).

I'll create a story to fix GDS.

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 that works for me

err = suite.client.TopicDelete(ctx, "01GNA926JCTKDH3VZBTJM8MAF6")
suite.requireError(err, http.StatusNotFound, "could not delete topic", "expected error when topic ID is not found")
_, err = suite.client.TopicDelete(ctx, req)
suite.requireError(err, http.StatusNotFound, "topic not found", "expected error when topic ID is not found")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The rest of the endpoint will be tested in SC-13774

Copy link
Contributor

@bbengfort bbengfort left a comment

Choose a reason for hiding this comment

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

Great start! I had an alternative suggestion for the token; let me know what you think.

pkg/tenant/api/v1/api.go Show resolved Hide resolved
Comment on lines 22 to 23
ConfirmDeleteToken string `msgpack:"confirm_delete_token"`
ConfirmDeleteExpire time.Time `msgpack:"confirm_delete_expire"`
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd recommend the token be a base64 encoded msgpack data structure that includes the topic ID and expiration time stamp as well as a cryptographically randomly generated string. That way the token is self composed and in the future we can do things like cryptographically sign the token for further security.

// data by including a confirmation token in the request.
type Confirmation struct {
ID string `json:"id"`
Name string `json:"name"`
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the name something you're thinking the user should supply?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it would be part of the response, so the user shouldn't supply it in the request but it gives the frontend something to display in the confirmation prompt without having to reference the original object.

// as all of the data in Ensign. Because this is irreversible, the first call returns
// a confirmation token to the user. The user must provide this token in a subsequent
// request in order to confirm the deletion. Because this operation is asynchronous,
// the endpoint returns a 202 Accepted response.
Copy link
Contributor

Choose a reason for hiding this comment

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

Good choice of status code!

log.Error().Err(err).Msg("could not parse topic ulid")
c.JSON(http.StatusBadRequest, api.ErrorResponse("could not parse topic ulid"))
log.Warn().Err(err).Msg("could not parse topic id")
c.JSON(http.StatusBadRequest, api.ErrorResponse("could not parse topic id"))
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm thinking I'd actually prefer to return a 404 here (I know this has nothing to do with your PR, but just thinking about it again. E.g. if the user puts in /project/:projectID/topics/foo - that's not parseable as a ULID, but it should be not found not 400 right? The 404 is a little more secure and will allow us to change ID types in the future.

Suggested change
c.JSON(http.StatusBadRequest, api.ErrorResponse("could not parse topic id"))
c.JSON(http.StatusNotFound, api.ErrorResponse("topic not found"))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed, I think we've gone back and forth but it does seem to make a bit more sense to return 404 since the ID is a part of the URL.

// Verify that the user owns the topic
if claims.OrgID != topic.OrgID.String() {
log.Warn().Err(err).Str("user_org", claims.OrgID).Str("topic_org", topic.OrgID.String()).Msg("topic OrgID does not match user OrgID")
c.JSON(http.StatusForbidden, api.ErrorResponse("user is not authorized to delete this topic"))
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not for this PR - but in general I've been thinking about security principles with our responses. If we return a 403 here then we're letting someone know that the topic exists and belongs to a different organization -- which could be information they could use to get a topic ID to do other attacks.

E.g. a user simply has to register for an account to get valid claims then submit API requests with different Topic IDs - once they get a 403 instead of a 404 they know they have a good topic id belonging to someone else.

Because of this I think it is probably better to return a 404 here; e.g. semantically it is "no topic found belonging to that organization". But that gives no information that the topic even exists.

Suggested change
c.JSON(http.StatusForbidden, api.ErrorResponse("user is not authorized to delete this topic"))
c.JSON(http.StatusNotFound, api.ErrorResponse("topic not found"))

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, at some point we might need to audit the other Tenant endpoints to make sure we're being consistent about the responses and not accidentally exposing info.

Copy link
Contributor

Choose a reason for hiding this comment

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

Could you create a future story for that?

// Verify that the confirmation token is correct
if confirm.ConfirmToken != topic.ConfirmDeleteToken {
log.Warn().Err(err).Msg("confirmation token does not match")
c.JSON(http.StatusBadRequest, api.ErrorResponse("unrecognized confirmation token"))
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should return a 412 Precondition Failed here to let the user know they should try the delete without the confirmation token again and then use a valid confirmation token.

Suggested change
c.JSON(http.StatusBadRequest, api.ErrorResponse("unrecognized confirmation token"))
c.JSON(http.StatusPreconditionFailed, api.ErrorResponse("unrecognized confirmation token"))

// Verify the confirmation token has not expired
if time.Now().After(topic.ConfirmDeleteExpire) {
log.Warn().Err(err).Msg("confirmation token has expired")
c.JSON(http.StatusBadRequest, api.ErrorResponse("confirmation token has expired"))
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
c.JSON(http.StatusBadRequest, api.ErrorResponse("confirmation token has expired"))
c.JSON(http.StatusPreconditionFailed, api.ErrorResponse("confirmation token has expired"))

Copy link
Contributor

Choose a reason for hiding this comment

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

Going to be fun to document this in Postman!

Comment on lines +389 to +393
// The delete request is asynchronous so just update the state in the database
topic.State = tombstone.State
if err = db.UpdateTopic(ctx, topic); err != nil {
log.Error().Err(err).Str("topicID", topicID.String()).Msg("could not update topic state")
c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not delete topic"))
Copy link
Contributor

Choose a reason for hiding this comment

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

Nicely done!

}
}
return true
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Doh, not sure why we didn't use crypto/rand in GDS - but that's probably a problem. Would you mind copying the quarterdeck/keygen package instead?

https://github.com/rotationalio/ensign/blob/main/pkg/quarterdeck/keygen/keygen.go

(You can use it directly if you'd like, but copying it into a secrets utils is fine; I'd like them decoupled so that we can change the way that Quarterdeck issues api keys; e.g. either use the keygen package directly or port it to utils so quarterdeck does not have a dependency on utils).

I'll create a story to fix GDS.

Copy link
Contributor

@bbengfort bbengfort left a comment

Choose a reason for hiding this comment

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

Thank you for making those changes; I was confused about one thing but then it should be ready to go!

ConfirmToken string `json:"confirm_token,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Token string `json:"token,omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

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

I agree this is a bit nicer.

// Create a short-lived confirmation token in the database
topic.ConfirmDeleteToken = secrets.CreateToken(16)
topic.ConfirmDeleteExpire = time.Now().Add(5 * time.Minute)
if topic.ConfirmDeleteToken, err = db.NewResourceToken(topic.OrgID); err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is the resource token ID the topic's OrgID? I was expecting it to be the topic ID?

log.Warn().Err(err).Msg("confirmation token has expired")
c.JSON(http.StatusBadRequest, api.ErrorResponse("confirmation token has expired"))
// Verify that we have the right token
if token.ID.Compare(topic.ID) != 0 {
Copy link
Contributor

Choose a reason for hiding this comment

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

Am I missing something? It looks like the OrgID is put in but then it is compared to the topic ID. If I am not missing anything why aren't the tests catching this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My bad, it should be the topic ID and the test coverage isn't complete yet so I didn't catch it.

Copy link
Contributor

Choose a reason for hiding this comment

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

No worries - that's what code reviews are for!

@pdeziel pdeziel merged commit 8c13097 into main Feb 20, 2023
@pdeziel pdeziel deleted the sc-13773 branch February 20, 2023 13:56
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.

None yet

2 participants