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

Implements findUserByMe endpoint + trimmings #19

Merged
merged 7 commits into from
Sep 14, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion cmd/server/pactasrv/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ go_library(
"//db",
"//openapi:pacta_generated",
"//pacta",
"@org_uber_go_zap//:zap",
"@com_github_go_chi_jwtauth_v5//:jwtauth",
],
)
23 changes: 19 additions & 4 deletions cmd/server/pactasrv/conv_pacta_to_oapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,32 @@ func initiativeToOAPI(i *pacta.Initiative) (*api.Initiative, error) {
}, nil
}

func userToOAPI(user *pacta.User) (*api.User, error) {
if user == nil {
return nil, errorInternal(fmt.Errorf("userToOAPI: can't convert nil pointer"))
Copy link
Collaborator

Choose a reason for hiding this comment

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

I hear your point about keeping all the conv stuff in the pactasrv package so you can access these, but my counterpoint would be that determining the 'type' of an error truly is a concern of the server, and this should just return a fmt.Errorf(...) or some conv-specific error that the srv layer then converts into an errorWhatever

Functionally, it probably doesn't matter, just cleaner abstractions and easier testing

Comment on lines +29 to +31
Copy link
Collaborator

Choose a reason for hiding this comment

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

I remember you had a reason for using *api.Whatever and dereference instead of just using structs directly, but it seems like a lot of contorting to be using pointers, but having to check for errors/disallow them, only to convert them back into not-pointers later

}
return &api.User{
Admin: user.Admin,
CanonicalEmail: &user.CanonicalEmail,
EnteredEmail: user.EnteredEmail,
Id: string(user.ID),
Name: user.Name,
PreferredLanguage: api.UserPreferredLanguage(user.PreferredLanguage),
SuperAdmin: user.SuperAdmin,
}, nil
}

func pactaVersionToOAPI(pv *pacta.PACTAVersion) (*api.PactaVersion, error) {
if pv == nil {
return nil, errorInternal(fmt.Errorf("pactaVersionToOAPI: can't convert nil pointer"))
}
return &api.PactaVersion{
CreatedAt: pv.CreatedAt,
Description: pv.Description,
Digest: pv.Digest,
Id: string(pv.ID),
Name: pv.Name,
IsDefault: pv.IsDefault,
Digest: pv.Digest,
Description: pv.Description,
CreatedAt: pv.CreatedAt,
Name: pv.Name,
}, nil
}

Expand Down
3 changes: 3 additions & 0 deletions cmd/server/pactasrv/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,6 @@ func (response apiError) VisitFindUserByIdResponse(w http.ResponseWriter) error
func (response apiError) VisitUpdateUserResponse(w http.ResponseWriter) error {
return response.visit(w)
}
func (response apiError) VisitFindUserByMeResponse(w http.ResponseWriter) error {
return response.visit(w)
}
3 changes: 1 addition & 2 deletions cmd/server/pactasrv/pactasrv.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,9 @@ type DB interface {
CreatePortfolioInitiativeMembership(tx db.Tx, pim *pacta.PortfolioInitiativeMembership) error
DeletePortfolioInitiativeMembership(tx db.Tx, pid pacta.PortfolioID, iid pacta.InitiativeID) error

GetOrCreateUserByAuthn(tx db.Tx, authnMechanism pacta.AuthnMechanism, authnID string, email string) (*pacta.User, error)
User(tx db.Tx, id pacta.UserID) (*pacta.User, error)
UserByAuthn(tx db.Tx, authnMechanism pacta.AuthnMechanism, authnID string) (*pacta.User, error)
Users(tx db.Tx, ids []pacta.UserID) (map[pacta.UserID]*pacta.User, error)
CreateUser(tx db.Tx, u *pacta.User) (pacta.UserID, error)
UpdateUser(tx db.Tx, id pacta.UserID, mutations ...db.UpdateUserFn) error
DeleteUser(tx db.Tx, id pacta.UserID) error
}
Expand Down
45 changes: 45 additions & 0 deletions cmd/server/pactasrv/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package pactasrv

import (
"context"
"fmt"

api "github.com/RMI/pacta/openapi/pacta"
"github.com/RMI/pacta/pacta"
"github.com/go-chi/jwtauth/v5"
)

// Returns a user by ID
Expand Down Expand Up @@ -50,3 +53,45 @@ func (s *Server) deleteUser(ctx context.Context, request api.DeleteUserRequestOb
// TODO(#12) Implement Authorization
return errorNotImplemented("deleteUser")
}

// Returns the logged in user
// (GET /user/me)
func (s *Server) FindUserByMe(ctx context.Context, request api.FindUserByMeRequestObject) (api.FindUserByMeResponseObject, error) {
user, err := s.findUserByMe(ctx, request)
if err != nil {
return errToAPIError(err)
}
return api.FindUserByMe200JSONResponse(*user), nil
}

func (s *Server) findUserByMe(ctx context.Context, request api.FindUserByMeRequestObject) (*api.User, error) {
token, _, err := jwtauth.FromContext(ctx)
if err != nil {
// TODO(grady) upgrade this to the new error handling strategy after #12
return nil, errorUnauthorized("lookup self", "without authorization token")
}
if token == nil {
return nil, errorUnauthorized("lookup self ", "without authorization token")
}
email, ok := token.PrivateClaims()["emails"]
if !ok {
return nil, errorUnauthorized("lookup self", "without email claim in token")
}
emails, ok := email.([]string)
if !ok || len(emails) == 0 {
return nil, errorInternal(fmt.Errorf("couldn't parse email claim in token"))
}
// TODO(#18) Handle Multiple Emails in the Token Claims gracefully
if len(emails) > 1 {
return nil, errorBadRequest("jwt token", "multiple emails in token")
}
authnID := token.Subject()
if authnID == "" {
return nil, fmt.Errorf("couldn't find authn id in jwt")
}
user, err := s.DB.GetOrCreateUserByAuthn(s.DB.NoTxn(ctx), pacta.AuthnMechanism_EmailAndPass, authnID, emails[0])
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would not use a GetOrCreate endpoint for something like this. GetOrCreate-ing a user should happen on a dedicated login or similar endpoint, it should be an error if a user tries to do anything else without an account in the DB.

There's a bunch of reasons to do this, but the main one is to have a single location where we create accounts in the DB.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What are the other reasons? We can remove the other CreateUser method trivially.

Copy link
Collaborator

Choose a reason for hiding this comment

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

The other reasons are something like:

  1. We shouldn't have exported methods that nobody is using, it increases the package's surface area unnecessarily
  2. Given the way our login system works ('get token, send to server'), the only way of account creation that makes sense is GetOrCreateUser[...], we shouldn't make it tempting to create accounts any other way

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wait I might be misunderstanding. When does the token from Azure get sent directly to the server? Aren't we always facilitating that exchange through the Frontend? If so wouldn't just having getOrCreateUser make sense at the FE layer too?

Agreed on the idea of one method at the DB layer. - I'll make CreateUser testonly.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Copying image from Slack:
image

Copy link
Collaborator

@bcspragu bcspragu Sep 14, 2023

Choose a reason for hiding this comment

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

The token that would go to a PACTA /login endpoint would be the RMI-specific token from step 4, and that would get or create the user account in the PACTA database

if err != nil {
return nil, fmt.Errorf("getting user by authn: %w", err)
}
return userToOAPI(user)
}
39 changes: 38 additions & 1 deletion db/sqldb/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func (d *DB) User(tx db.Tx, id pacta.UserID) (*pacta.User, error) {
return exactlyOne("user", id, us)
}

func (d *DB) UserByAuthn(tx db.Tx, authnMechanism pacta.AuthnMechanism, authnID string) (*pacta.User, error) {
func (d *DB) userByAuthn(tx db.Tx, authnMechanism pacta.AuthnMechanism, authnID string) (*pacta.User, error) {
rows, err := d.query(tx, `
SELECT `+userSelectColumns+`
FROM pacta_user
Expand All @@ -52,6 +52,43 @@ func (d *DB) UserByAuthn(tx db.Tx, authnMechanism pacta.AuthnMechanism, authnID
return exactlyOne("user", fmt.Sprintf("%s:%s", authnMechanism, authnID), us)
}

func (d *DB) GetOrCreateUserByAuthn(tx db.Tx, authnMechanism pacta.AuthnMechanism, authnID string, email string) (*pacta.User, error) {
var user *pacta.User
err := d.RunOrContinueTransaction(tx, func(tx db.Tx) error {
u, err := d.userByAuthn(tx, authnMechanism, authnID)
if err == nil {
user = u
return nil
}
if !db.IsNotFound(err) {
return fmt.Errorf("looking up user by authn: %w", err)
}
canonizedEmail, err := pacta.CanonizeEmail(email)
gbdubs marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return fmt.Errorf("canonizing email: %w", err)
}
uID, err := d.CreateUser(tx, &pacta.User{
gbdubs marked this conversation as resolved.
Show resolved Hide resolved
CanonicalEmail: canonizedEmail,
EnteredEmail: email,
AuthnMechanism: authnMechanism,
AuthnID: authnID,
})
if err != nil {
return fmt.Errorf("creating user: %w", err)
}
u, err = d.User(tx, uID)
if err != nil {
return fmt.Errorf("reading back created user: %w", err)
}
user = u
return nil
})
if err != nil {
return nil, fmt.Errorf("running getOrCreateUser txn: %w", err)
}
return user, nil
}

func (d *DB) Users(tx db.Tx, ids []pacta.UserID) (map[pacta.UserID]*pacta.User, error) {
ids = dedupeIDs(ids)
rows, err := d.query(tx, `
Expand Down
4 changes: 2 additions & 2 deletions db/sqldb/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestCreateUser(t *testing.T) {
}

// Read by Authn
actual, err = tdb.UserByAuthn(tx, u.AuthnMechanism, u.AuthnID)
actual, err = tdb.userByAuthn(tx, u.AuthnMechanism, u.AuthnID)
if err != nil {
t.Fatalf("getting user by authn: %w", err)
}
Expand Down Expand Up @@ -230,7 +230,7 @@ func TestDeleteUser(t *testing.T) {
}

// Read by Authn
_, err = tdb.UserByAuthn(tx, u.AuthnMechanism, u.AuthnID)
_, err = tdb.userByAuthn(tx, u.AuthnMechanism, u.AuthnID)
if err == nil {
t.Fatalf("expected error, got nil")
}
Expand Down
6 changes: 6 additions & 0 deletions deps.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,12 @@ def go_dependencies():
sum = "h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=",
version = "v1.1.1",
)
go_repository(
name = "com_github_dimuska139_go_email_normalizer",
importpath = "github.com/dimuska139/go-email-normalizer",
sum = "h1:pJNZnU7uS9MRoYqpoir05B+bCYXrS9sPGE4G1o9EDA8=",
version = "v1.2.1",
)
gbdubs marked this conversation as resolved.
Show resolved Hide resolved

go_repository(
name = "com_github_docker_distribution",
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/standard/Content.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
margin: 0.5rem 0;
}

min-height: calc(100vh - 9rem - 4px);
min-height: calc(100vh - 9.25rem - 4px);
justify-content: center;
}
</style>
20 changes: 13 additions & 7 deletions frontend/components/standard/Nav.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { type MenuItem } from 'primevue/menuitem'

const { showStandardDebug } = useLocalStorage()
const { isAuthenticated, signIn, signOut } = await useMSAL()
const router = useRouter()

Expand All @@ -10,11 +11,11 @@ const menuHidden = useState<boolean>(`${prefix}.menuHidden`, () => false)
const menuStyles = computed(() => {
return {
transition: menuHidden.value ? 'max-height .1s ease' : 'max-height .5s ease',
overflow: menuHidden.value ? 'hidden' : undefined,
overflow: 'hidden',
'max-height': menuHidden.value ? '0px' : '100vh',
border: menuHidden.value ? undefined : '2px solid',
'margin-top': menuHidden.value ? '0' : '-2px',
padding: menuHidden.value ? '0' : '.25rem 0'
'margin-top': menuHidden.value ? '0' : '-2px'
// padding: menuHidden.value ? '0' : '.25rem 0'
gbdubs marked this conversation as resolved.
Show resolved Hide resolved
}
})

Expand All @@ -25,10 +26,16 @@ const menuItems = computed(() => {
label: 'Home'
},
{
to: 'https://github.com/RMI/pacta/issues/new',
to: 'https://github.com/RMI-PACTA/app/issues/new',
label: 'File a Bug'
}
]
if (showStandardDebug) {
result.push({
label: 'Admin',
to: '/admin'
})
}
if (isAuthenticated.value) {
result.push({
label: 'Sign Out',
Expand Down Expand Up @@ -61,7 +68,7 @@ const menuItems = computed(() => {
/>
</div>
<div
class="flex gap-2 sm:pt-1 flex-1 flex-column sm:flex-row border-primary sm:border-none sm:max-h-full border-round justify-content-end"
class="flex gap-2 sm:p-1 flex-1 flex-column sm:flex-row border-primary sm:border-none sm:max-h-full border-round justify-content-end"
:style="menuStyles"
>
<template
Expand All @@ -70,8 +77,7 @@ const menuItems = computed(() => {
<LinkButton
v-if="mi.to"
:key="index"
class="p-button-text"
:class="mi.to === router.currentRoute"
:class="mi.to === router.currentRoute.value.fullPath ? 'border-noround sm:border-round' : 'p-button-text'"
:to="mi.to"
:label="`${mi.label}`"
/>
Expand Down
74 changes: 74 additions & 0 deletions frontend/composables/useSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { type User } from '@/openapi/generated/pacta'

export const useSession = () => {
const { pactaClient } = useAPI()
const { error: { handleOAPIError } } = useModal()

const prefix = 'useSession'
const signedIn = useState<boolean>(`${prefix}.signedIn`, () => true)

const currentUser = useState<User | undefined>(`${prefix}.currentUser`, () => undefined)
const isAdmin = computed(() => currentUser.value && currentUser.value.admin)
const isSuperAdmin = computed(() => currentUser.value && currentUser.value.superAdmin)

const resolvers = useState<Array<() => void>>(`${prefix}.resolvers`, () => [])
const loadCurrentUser = (hardRefresh = false): Promise<void> => {
// Return the cached user if we don't explicitly want a new one
if (currentUser.value && !hardRefresh) {
return Promise.resolve()
}

// We're already loading a user, wait with everyone else
if (resolvers.value.length > 0) {
return new Promise((resolve) => {
resolvers.value.push(resolve)
})
}

// We're the first to request a user, kick of the request and hop in line at the front of the queue.
return new Promise((resolve) => {
resolvers.value.push(resolve)
void pactaClient.findUserByMe()
.then((m) => {
console.log(m)
gbdubs marked this conversation as resolved.
Show resolved Hide resolved
return m
})
.then(handleOAPIError)
.then(m => {
currentUser.value = m

// Let everyone else know we've loaded the user and clear the queue.
resolvers.value.forEach((fn) => { fn() })
resolvers.value = []
})
})
}
const getMe = async () => {
await loadCurrentUser()
return {
// LoadCurrentUser's return is only undefined as a technicality to support
// the single-lookup behavior above. This cast is safe.
me: currentUser as Ref<User>,
isAdmin,
isSuperAdmin
}
}
const getMaybeMe = async () => {
if (signedIn.value) {
await loadCurrentUser()
}
return {
// Will be a Ref with a value of undefined if the user isn't logged in.
maybeMe: currentUser,
isAdmin,
isSuperAdmin
}
}
gbdubs marked this conversation as resolved.
Show resolved Hide resolved

return {
signedIn,
getMe,
getMaybeMe,
currentUser
}
}
22 changes: 11 additions & 11 deletions frontend/layouts/default.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<script setup lang="ts">
import { onMounted } from 'vue'

const { loading: { onMountedWithLoading, loadingSet }, anyBlockingModalOpen, error: { setError } } = useModal()
const {
loading: { onMountedWithLoading, loadingSet },
anyBlockingModalOpen,
error: { setError }
} = useModal()

const handleError = (event: Event & { reason: Error }) => {
event.preventDefault()
Expand All @@ -12,9 +16,10 @@ const handleError = (event: Event & { reason: Error }) => {

onMountedWithLoading(() => { /* nothing to do */ }, 'defaultLayout.onMountedWithLoading')
onMounted(() => {
window.addEventListener('unhandledrejection', handleError)
window.addEventListener('unhandledrejection', (event) => {
handleError(event)
})
})

</script>

<template>
Expand All @@ -26,15 +31,10 @@ onMounted(() => {
>
<main
class="px-3 md:px-4 w-full lg:w-10 xl:w-8 mx-auto"
style="min-height: calc(100vh - 9rem - 4px);"
style="min-height: calc(100vh - 9.25rem - 4px);"
>
<NuxtErrorBoundary>
<template #error="{ error, clearError }">
{{ setError(error) }}
{{ clearError() }}
</template>
<NuxtPage />
</NuxtErrorBoundary>
<!-- TODO(#16) Consider adding back in NuxtErrorBoundary once fixed -->
<NuxtPage />
</main>
</div>
<ModalGroup />
Expand Down
Loading