Skip to content

Frontend Build Out #41

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

Merged
merged 9 commits into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions cmd/server/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ go_library(
"//executors/docker",
"//oapierr",
"//openapi:pacta_generated",
"//pacta",
"//secrets",
"//task",
"@com_github_azure_azure_sdk_for_go_sdk_azcore//:azcore",
Expand Down
59 changes: 58 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/RMI/pacta/executors/docker"
"github.com/RMI/pacta/oapierr"
oapipacta "github.com/RMI/pacta/openapi/pacta"
"github.com/RMI/pacta/pacta"
"github.com/RMI/pacta/secrets"
"github.com/RMI/pacta/task"
"github.com/Silicon-Ally/cryptorand"
Expand Down Expand Up @@ -268,11 +269,12 @@ func run(args []string) error {
// LogEntry created by the logging middleware.
chimiddleware.RequestID,
chimiddleware.RealIP,
zaphttplog.NewMiddleware(logger),
zaphttplog.NewMiddleware(logger, zaphttplog.WithConcise(true)),
chimiddleware.Recoverer,

jwtauth.Verifier(jwtauth.New("EdDSA", nil, jwKey)),
jwtauth.Authenticator,
addUserIdentityToContextIfLoggedIn(logger, db),

oapimiddleware.OapiRequestValidator(pactaSwagger),

Expand Down Expand Up @@ -340,6 +342,61 @@ func rateLimitMiddleware(maxReq int, windowLength time.Duration) func(http.Handl
}))
}

func addUserIdentityToContextIfLoggedIn(logger *zap.Logger, db *sqldb.DB) func(http.Handler) http.Handler {
fn := func(c context.Context) (context.Context, error) {
token, _, err := jwtauth.FromContext(c)
if err != nil {
return nil, fmt.Errorf("error getting authorization token: %w", err)
}
if token == nil {
return nil, fmt.Errorf("nil authorization token")
}
emailsClaim, ok := token.PrivateClaims()["emails"]
if !ok {
return nil, fmt.Errorf("no email claim in token")
}
emails, ok := emailsClaim.([]interface{})
if !ok || len(emails) == 0 {
return nil, fmt.Errorf("couldn't find email claim in token: %T", emailsClaim)
}
// TODO(#18) Handle Multiple Emails in the Token Claims gracefully
if len(emails) > 1 {
return nil, fmt.Errorf("multiple emails in token: %+v", emails)
}
email, ok := emails[0].(string)
if !ok {
return nil, fmt.Errorf("wrong type for email claim: %T", emails[0])
}
canonical, err := pacta.CanonicalizeEmail(email)
if err != nil {
return nil, fmt.Errorf("invalid email on token: %q", email)
}
authnID := token.Subject()
if authnID == "" {
return nil, fmt.Errorf("couldn't find authn id in jwt")
}
user, err := db.GetOrCreateUserByAuthn(db.NoTxn(c), pacta.AuthnMechanism_EmailAndPass, authnID, email, canonical)
if err != nil {
return nil, fmt.Errorf("failed to get user by authn: %w", err)
}
return jwtauth.WithUserId(c, string(user.ID)), nil
}

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := fn(r.Context())
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: I think this will go away with the above comment, but just in case it doesn't: normally, one only returns a new context from functions that are dedicated to contexts, e.g. WithX or context.WithX functions. So I'd change it to something like:

fn := func(c context.Context) (pacta.UserID, error) { ... }
[...]
return func(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		uID, err := fn(r.Context())
		ctx := session.WithUserID(ctx, uID)
		next.ServeHTTP(w, r.WithContext(ctx))
	}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep - Done.

if err != nil {
// Optionally log errors here when debugging authentication access.
// logger.Warn("couldn't authenticate", zap.Error(err))
// http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

func findFirstInClaims(claims map[string]any, keys ...string) (string, error) {
for _, k := range keys {
v, ok := claims[k]
Expand Down
2 changes: 2 additions & 0 deletions cmd/server/pactasrv/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ go_library(
name = "pactasrv",
srcs = [
"initiative.go",
"initiative_invitation.go",
"initiative_user_relationship.go",
"pacta_version.go",
"pactasrv.go",
"user.go",
Expand Down
16 changes: 16 additions & 0 deletions cmd/server/pactasrv/conv/oapi_to_pacta.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ func PactaVersionCreateFromOAPI(p *api.PactaVersionCreate) (*pacta.PACTAVersion,
}, nil
}

func InitiativeInvitationFromOAPI(i *api.InitiativeInvitationCreate) (*pacta.InitiativeInvitation, error) {
if i == nil {
return nil, oapierr.Internal("initiativeInvitationToOAPI: can't convert nil pointer")
}
if !initiativeIDRegex.MatchString(i.Id) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I can't remember if I asked this before, but why are we validating IDs, aren't we the ones generating them? And if we aren't the ones generating them, let's chat.

return nil, oapierr.BadRequest("id must contain only alphanumeric characters, underscores, and dashes")
}
if i.InitiativeId == "" {
return nil, oapierr.BadRequest("initiative_id must not be empty")
}
return &pacta.InitiativeInvitation{
ID: pacta.InitiativeInvitationID(i.Id),
Initiative: &pacta.Initiative{ID: pacta.InitiativeID(i.InitiativeId)},
}, nil
}

func ifNil[T any](t *T, fallback T) T {
if t == nil {
return fallback
Expand Down
40 changes: 40 additions & 0 deletions cmd/server/pactasrv/conv/pacta_to_oapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,46 @@ func PactaVersionToOAPI(pv *pacta.PACTAVersion) (*api.PactaVersion, error) {
}, nil
}

func InitiativeInvitationToOAPI(i *pacta.InitiativeInvitation) (*api.InitiativeInvitation, error) {
if i == nil {
return nil, oapierr.Internal("initiativeToOAPI: can't convert nil pointer")
}
var usedAt *string
if !i.UsedAt.IsZero() {
usedAt = ptr(i.UsedAt.String())
}
var usedBy *string
if i.UsedBy != nil {
usedBy = ptr(string(i.UsedBy.ID))
}
Comment on lines +66 to +68
Copy link
Collaborator

Choose a reason for hiding this comment

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

If you expect to be doing this a lot, you could make a generic nullablePtr or similar helper, then inline this in the return

return &api.InitiativeInvitation{
CreatedAt: i.CreatedAt,
Id: string(i.ID),
InitiativeId: string(i.Initiative.ID),
UsedAt: usedAt,
UsedByUserId: usedBy,
}, nil
}

func InitiativeUserRelationshipToOAPI(i *pacta.InitiativeUserRelationship) (*api.InitiativeUserRelationship, error) {
if i == nil {
return nil, oapierr.Internal("initiativeUserRelationshipToOAPI: can't convert nil pointer")
}
if i.User == nil {
return nil, oapierr.Internal("initiativeUserRelationshipToOAPI: can't convert nil user")
}
if i.Initiative == nil {
return nil, oapierr.Internal("initiativeUserRelationshipToOAPI: can't convert nil initiative")
}
return &api.InitiativeUserRelationship{
UpdatedAt: i.UpdatedAt,
InitiativeId: string(i.Initiative.ID),
UserId: string(i.User.ID),
Manager: i.Manager,
Member: i.Member,
}, nil
}

func ptr[T any](t T) *T {
return &t
}
118 changes: 118 additions & 0 deletions cmd/server/pactasrv/initiative_invitation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package pactasrv

import (
"context"
"fmt"
"time"

"github.com/RMI/pacta/cmd/server/pactasrv/conv"
"github.com/RMI/pacta/db"
"github.com/RMI/pacta/oapierr"
api "github.com/RMI/pacta/openapi/pacta"
"github.com/RMI/pacta/pacta"
"go.uber.org/zap"
)

// Creates an initiative invitation
// (POST /initiative-invitation)
func (s *Server) CreateInitiativeInvitation(ctx context.Context, request api.CreateInitiativeInvitationRequestObject) (api.CreateInitiativeInvitationResponseObject, error) {
// TODO(#12) Implement Authorization
ii, err := conv.InitiativeInvitationFromOAPI(request.Body)
if err != nil {
return nil, err
}
id, err := s.DB.CreateInitiativeInvitation(s.DB.NoTxn(ctx), ii)
if err != nil {
return nil, oapierr.Internal("failed to create initiative invitation", zap.Error(err))
}
if id != ii.ID {
return nil, oapierr.Internal(
"failed to create initiative invitation: ID mismatch",
zap.String("requested_id", string(ii.ID)),
zap.String("actual_id", string(id)),
)
}
return api.CreateInitiativeInvitation204Response{}, nil
}

// Deletes an initiative invitation by id
// (DELETE /initiative-invitation/{id})
func (s *Server) DeleteInitiativeInvitation(ctx context.Context, request api.DeleteInitiativeInvitationRequestObject) (api.DeleteInitiativeInvitationResponseObject, error) {
// TODO(#12) Implement Authorization
err := s.DB.DeleteInitiativeInvitation(s.DB.NoTxn(ctx), pacta.InitiativeInvitationID(request.Id))
if err != nil {
return nil, oapierr.Internal("failed to delete initiative invitation", zap.Error(err))
}
return api.DeleteInitiativeInvitation204Response{}, nil
}

// Returns the initiative invitation from this id, if it exists
// (GET /initiative-invitation/{id})
func (s *Server) GetInitiativeInvitation(ctx context.Context, request api.GetInitiativeInvitationRequestObject) (api.GetInitiativeInvitationResponseObject, error) {
// TODO(#12) Implement Authorization
ii, err := s.DB.InitiativeInvitation(s.DB.NoTxn(ctx), pacta.InitiativeInvitationID(request.Id))
if err != nil {
return nil, oapierr.Internal("failed to retrieve initiative invitation", zap.Error(err))
}
result, err := conv.InitiativeInvitationToOAPI(ii)
if err != nil {
return nil, err
}
return api.GetInitiativeInvitation200JSONResponse(*result), nil
}

// Claims this initiative invitation, if it exists
// (POST /initiative-invitation/{id})
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: I think of "claiming an invitation" as a non-CRUD action that should get a custom verb, like POST /initiative-invitation/{id}:claim. Benefits of this:

  • Unambigious about what it's for, makes reading logs and stuff easier
  • If you have another "updating an initiative invitation" endpoint you need to add, you won't have a collision on POST /initiative-invitation/{id}

Some Google Cloud guide that discusses this at length

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

func (s *Server) ClaimInitiativeInvitation(ctx context.Context, request api.ClaimInitiativeInvitationRequestObject) (api.ClaimInitiativeInvitationResponseObject, error) {
userID, err := getUserID(ctx)
if err != nil {
return nil, err
}
err = s.DB.Transactional(ctx, func(tx db.Tx) error {
ii, err := s.DB.InitiativeInvitation(tx, pacta.InitiativeInvitationID(request.Id))
if err != nil {
return fmt.Errorf("looking up initiative invite: %w", err)
}
if !ii.UsedAt.IsZero() || ii.UsedBy != nil {
if ii.UsedBy != nil && ii.UsedBy.ID == userID {
Copy link
Collaborator

Choose a reason for hiding this comment

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

My usual grumbling about new paradigms I don't like: the whole "We use objects for everything and only populate the IDs" makes all of this logic one conditional comparision more than it otherwise would need to be. E.g. as compared to

if ii.UsedByID == userID { ... }

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 I think in most cases you'll come to like this pattern. I've found it increasingly lovely for side projects.

// We don't return an error if the same user tries to claim the same invitation twice,
// which might happen by accident, but wouldn't impact the state of the initiative memberships.
// We may want to log this, though.
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd log at WARN

return nil
} else {
return fmt.Errorf("initiative is already used: %+v", ii)
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should be using oapierr.BadRequest (or similar) for this. For this case in particular, we likely want a custom error code so we can show a specific error to the end user. Then you'd just need to update the error logic below to:

if errors.Is(err, oapierr.Error{}) { // or something like that, could also make oapierr.Is(err) or similar
	return err
} else if err != nil {
	return oapierr.Internal(...)
}

You could even make a lil helper, like return oapierr.OrInternal(err)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done, but through different means, as an external var.

}
}
err = s.DB.UpdateInitiativeInvitation(tx, ii.ID,
db.SetInitiativeInvitationUsedAt(time.Now()),
db.SetInitiativeInvitationUsedBy(userID))
if err != nil {
return fmt.Errorf("updating initiative invite: %w", err)
}
err = s.DB.UpdateInitiativeUserRelationship(tx, ii.Initiative.ID, userID,
db.SetInitiativeUserRelationshipMember(true))
if err != nil {
return fmt.Errorf("creating initiative membership: %w", err)
}
return nil
})
if err != nil {
return nil, oapierr.Internal("failed to claim initiative invitation", zap.Error(err))
}
return api.ClaimInitiativeInvitation204Response{}, nil
}

// Returns all initiative invitations associated with the initiative
// (GET /initiative/{id}/invitations)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is this GET /initiative/{id}/invitations and not GET /initiative-invitation? I think the standard RESTful approach would be the latter, ex

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 getting the invitations associated with an initiative. I think this is the standard paradigm: resources that have a nested/ownership structure can be requested this way. The ID in this case is the initiative ID.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Following up on this, I'd just roll "listing invitations" into GET /initiative/{id}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We won't want that for public access. I think keeping them distinct will make authz much easier.

func (s *Server) ListInitiativeInvitations(ctx context.Context, request api.ListInitiativeInvitationsRequestObject) (api.ListInitiativeInvitationsResponseObject, error) {
// TODO(#12) Implement Authorization
iis, err := s.DB.InitiativeInvitationsByInitiative(s.DB.NoTxn(ctx), pacta.InitiativeID(request.Id))
if err != nil {
return nil, oapierr.Internal("failed to list initiative invitations", zap.Error(err))
}
result, err := dereference(mapAll(iis, conv.InitiativeInvitationToOAPI))
if err != nil {
return nil, err
}
return api.ListInitiativeInvitations200JSONResponse(result), nil
}
75 changes: 75 additions & 0 deletions cmd/server/pactasrv/initiative_user_relationship.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package pactasrv

import (
"context"

"github.com/RMI/pacta/cmd/server/pactasrv/conv"
"github.com/RMI/pacta/db"
"github.com/RMI/pacta/oapierr"
api "github.com/RMI/pacta/openapi/pacta"
"github.com/RMI/pacta/pacta"
"go.uber.org/zap"
)

// Returns all initiative user relationships for the user that the user has access to view
// (GET /initiative/{id}/user-relationships)
func (s *Server) ListInitiativeUserRelationshipsByUser(ctx context.Context, request api.ListInitiativeUserRelationshipsByUserRequestObject) (api.ListInitiativeUserRelationshipsByUserResponseObject, error) {
// TODO(#12) Implement Authorization
iurs, err := s.DB.InitiativeUserRelationshipsByUser(s.DB.NoTxn(ctx), pacta.UserID(request.UserId))
if err != nil {
return nil, oapierr.Internal("failed to retrieve initiative user relationships by user", zap.Error(err))
}
result, err := dereference(mapAll(iurs, conv.InitiativeUserRelationshipToOAPI))
if err != nil {
return nil, err
}
return api.ListInitiativeUserRelationshipsByUser200JSONResponse(result), nil
}

// Returns all initiative user relationships for the initiative that the user has access to view
// (GET /initiative/user-relationships/{id})
Copy link
Collaborator

Choose a reason for hiding this comment

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

This URL scheme is not intuitive to me, e.g. the difference between:

  • GET /initiative/{id}/user-relationships, and
  • GET /initiative/user-relationships/{id}, and

The benefit of RESTful stuff is that usually it's pretty obvious from the URL what the endpoint does, but I'm not getting that from these. How about something like:

  • GET /initiative/{id} - Gets the initiative and the relationships, can optionally hide the latter behind a query param like withRelationships=true
    • If for some reason we're not frequently loading initiatives + their relationships at the same time, could also do /initiative/{id}/relationships, basically like it is now.
  • GET /user/{id}/initiatives - Gets a list of initiatives the user has access to

Copy link
Collaborator

Choose a reason for hiding this comment

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

^^

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If for some reason we're not frequently loading initiatives + their relationships at the same time, could also do /initiative/{id}/relationships, basically like it is now.

Yep! Public access won't load relationships.

I think this actually fits nicely into the "composite key exception" listed here

func (s *Server) ListInitiativeUserRelationshipsByInitiative(ctx context.Context, request api.ListInitiativeUserRelationshipsByInitiativeRequestObject) (api.ListInitiativeUserRelationshipsByInitiativeResponseObject, error) {
// TODO(#12) Implement Authorization
iurs, err := s.DB.InitiativeUserRelationshipsByInitiatives(s.DB.NoTxn(ctx), pacta.InitiativeID(request.InitiativeId))
if err != nil {
return nil, oapierr.Internal("failed to retrieve initiative user relationships by user", zap.Error(err))
}
result, err := dereference(mapAll(iurs, conv.InitiativeUserRelationshipToOAPI))
if err != nil {
return nil, err
}
return api.ListInitiativeUserRelationshipsByInitiative200JSONResponse(result), nil
}

// Returns the initiative user relationship from this id, if it exists
// (GET /initiative/{initiativeId}/user-relationship/{userId})
Copy link
Collaborator

Choose a reason for hiding this comment

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

This API surface seems really broad to me, what's the motivation for having three different endpoints that return a user's relationship to an initiative? Unless we expect the list of relationships to be exceptionally large (>1,000), I'd just let the client filter for the stuff they're looking for.

More API surface means more stuff to secure, and more DB functions/tests/code to maintain overall.

Copy link
Collaborator

Choose a reason for hiding this comment

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

^^

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, they will be large. There is a public initiative, which anyone will have the ability to join, and may be segmented by region (i.e. can't be handled as a standalone case).

func (s *Server) GetInitiativeUserRelationship(ctx context.Context, request api.GetInitiativeUserRelationshipRequestObject) (api.GetInitiativeUserRelationshipResponseObject, error) {
// TODO(#12) Implement Authorization
iur, err := s.DB.InitiativeUserRelationship(s.DB.NoTxn(ctx), pacta.InitiativeID(request.InitiativeId), pacta.UserID(request.UserId))
if err != nil {
return nil, oapierr.Internal("failed to retrieve initiative user relationship", zap.Error(err))
}
result, err := conv.InitiativeUserRelationshipToOAPI(iur)
if err != nil {
return nil, err
}
return api.GetInitiativeUserRelationship200JSONResponse(*result), nil
}

// Updates initiative user relationship properties
// (PATCH /initiative/{initiativeId}/user-relationship/{userId})
func (s *Server) UpdateInitiativeUserRelationship(ctx context.Context, request api.UpdateInitiativeUserRelationshipRequestObject) (api.UpdateInitiativeUserRelationshipResponseObject, error) {
// TODO(#12) Implement Authorization
mutations := []db.UpdateInitiativeUserRelationshipFn{}
if request.Body.Manager != nil {
mutations = append(mutations, db.SetInitiativeUserRelationshipManager(*request.Body.Manager))
}
if request.Body.Member != nil {
mutations = append(mutations, db.SetInitiativeUserRelationshipMember(*request.Body.Member))
}
err := s.DB.UpdateInitiativeUserRelationship(s.DB.NoTxn(ctx), pacta.InitiativeID(request.InitiativeId), pacta.UserID(request.UserId), mutations...)
if err != nil {
return nil, oapierr.Internal("failed to update initiative user relationship", zap.Error(err))
}
return api.UpdateInitiativeUserRelationship204Response{}, nil
}
Loading