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

First attempt at effective name for admin mock #466

Merged
merged 2 commits into from
Apr 2, 2023

Conversation

sfrunza13
Copy link
Contributor

Fixes #306 or trying to anyways.

I found that I could not set multiple key value pairs on session to commit it so I made a new cookie instead, curious to see what people think.

@sfrunza13 sfrunza13 requested a review from humphd March 28, 2023 21:17
@sfrunza13 sfrunza13 self-assigned this Mar 28, 2023
export async function setEffectiveName(username: string, name: string): Promise<string> {
const user = await getUserByUsername(username);

if (user?.group.includes('admin')) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's fix the User returned in https:/DevelopingSpace/starchart/blob/main/app/models/user.server.ts#L7-L21 and re-use/modify the code in https:/DevelopingSpace/starchart/blob/main/app/models/user.server.ts#L61-L77 to add properties to the User object that indicate if this is an admin.

User.isAdmin

This way we can do the /mycustomdomain(-dev)?-admins/.test(group) stuff to set it on the object.

Copy link
Contributor Author

@sfrunza13 sfrunza13 Mar 28, 2023

Choose a reason for hiding this comment

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

Is making use of isAdmin when setting effectiveName enough or should I do as you said and make a boolean property on a user for whether they are Admin?

Nvm :) got it

Copy link
Contributor

Choose a reason for hiding this comment

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

Whatever you do, we need to put it on the User so we can pass that info around. But using isAdmin() like you are is good internally for your needs.

}

export async function setEffectiveName(username: string, name: string): Promise<string> {
const user = await getUserByUsername(username);
Copy link
Contributor

Choose a reason for hiding this comment

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

If we don't have a user here, should we throw?

return await effectiveNameCookie.serialize(name);
}

return await effectiveNameCookie.serialize(user?.username);
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems odd to not have a user at this point.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

right, I had tried to use getUser() before and this is a relic from that. We should have a user and if we don't I will throw.

Copy link
Contributor

@humphd humphd left a comment

Choose a reason for hiding this comment

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

Looking better, but I want to see us decorate the user object with more details. The front-end is going to need to know the correct username to use, whether this is an admin, etc via the User we return in https:/DevelopingSpace/starchart/blob/main/app/root.tsx#L34.

Please add this info to that User object so it can get passed around the app.

@sfrunza13
Copy link
Contributor Author

So it's ok to change the prisma schema for user?

@humphd
Copy link
Contributor

humphd commented Mar 28, 2023

You don't need to change the database, change the object we send back when you query it, same as I do with baseDomain which isn't in the db either.

@@ -17,7 +18,7 @@ export async function getUserByUsername(username: PrismaUser['username']) {
}

// Decorate with the user's base domain as well
return { ...user, baseDomain: buildUserBaseDomain(username) };
return { ...user, baseDomain: buildUserBaseDomain(username), isAdmin: isAdmin(username) };
Copy link
Contributor

Choose a reason for hiding this comment

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

Throw the effectiveUsername on here too. So we know:

  • are you an admin?
  • what is your real username? (who are you really)
  • what is your current effective username? (which user are you actually pretending to be)

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'm not sure how to do this since my effective user name is currently passed as a cookie from request to request on the header and we don't have access to the header in the getUserByUsername method.

}

export async function getUserByUsername(username: PrismaUser['username']) {
export async function getUserByUsername(username: PrismaUser['username'], request: Request) {
Copy link
Contributor

Choose a reason for hiding this comment

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

OK, I agree this is the wrong place to do this. Let's do it in the root.tsx loader.

On the server side, we'll read the username/effective username out of session. On client, we'll send it with the root on the loader.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So for client to use it I would make a utils method using useMathcesData() like we have for user?

Copy link
Contributor

Choose a reason for hiding this comment

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

The client would get it from the loader, which can access the request and also the db, and put all the data on the User object we return in root.tsx.

app/models/user.server.ts Outdated Show resolved Hide resolved
app/root.tsx Outdated
@@ -30,8 +30,13 @@ export const links: LinksFunction = () => [
];

export async function loader({ request, context }: LoaderArgs) {
const effectiveUsername = await getEffectiveName(request);
const userBeforeEffectiveName = await getUser(request);
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: if both getEffectiveName and getUser both require request, can you not return the effectiveUsername on the result of getUser (i.e., you could do this internally in getUser)?

@@ -36,6 +38,20 @@ export async function getUsername(request: Request): Promise<User['username'] |
return username;
}

export async function getEffectiveName(request: Request): Promise<string> {
Copy link
Contributor

Choose a reason for hiding this comment

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

getEffectiveUsername

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, change all effectiveName uses everywhere to effectiveUsername

Copy link
Contributor

@humphd humphd left a comment

Choose a reason for hiding this comment

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

Testing this locally, here is what I get on the client:

{
  "username": "user1",
  "displayName": "Johannes Kepler",
  "email": "[email protected]",
  "group": "mycustomdomain-faculty",
  "createdAt": "2023-03-28T17:18:24.557Z",
  "updatedAt": "2023-03-28T17:18:24.557Z",
  "deactivated": false,
  "baseDomain": "user1.starchart.com",
  "isAdmin": {},
  "effectiveUsername": null
}

According to the type of User, the effectiveUsername is string, but here we get null. Should we set the effectiveUsername to be the same as username if it's not set (e.g., regular user)?

Next, the isAdmin is an Object vs. a boolean.

@Genne23v Genne23v added this to the Milestone 0.8 milestone Mar 29, 2023
const user = await getUserByUsername(username);

if (user) {
if (user.isAdmin) {
Copy link
Contributor

Choose a reason for hiding this comment

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

if (user?.isAdmin) {
  return user;
}
throw redirect('/');

app/utils.ts Outdated
@@ -105,6 +105,26 @@ export function useUser(): User {
return maybeUser;
}

export async function useEffectiveUser(): Promise<User> {
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 this should be:

useEffectiveUsername()

The user object will be identical. It's just that we're going to use this hook to get the specific "username" that the current user is using, which will either be:

  1. user.effectiveUsername if the cookie is set and we're using someone else's username
  2. user.username if the cookie is not set and we're using our own username
export async function useEffectiveUsername(): User['username'] {
  // The user must be logged in to call this.  Use `useOptionalUser()` if you're not sure
  const { username, effectiveUsername } = useUser();
 
  // Your username is either your real username, or the username you are temporarily using as an admin 
  return effectiveUsername || username;
}

Copy link
Contributor Author

@sfrunza13 sfrunza13 Mar 30, 2023

Choose a reason for hiding this comment

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

I'm not sure how to go about this still, I have replaced what I had with what is suggested and it works for when we display it like on header instead of rendering a property of the user object I just render the effective username but we would have to have a different effective user object for the use of baseDomain for example. Or we could change how the method works so that getUserByUsername checks whether isAdmin and a non-empty effective username exist together and if that is the case will instead use the effectiveUsername when building domain. On second thought idk if this is possible because I don't think we have the extended User object in there since that is where we extend it in the first place. Maybe we just need to retrieve another effective user object like I was trying to do earlier?

@@ -13,7 +13,7 @@ export interface User extends PrismaUser {
username, admins (identified by group) will start with an empty string
that can be changed to another users username on the admin portal for
them to impersonate */
effectiveUsername: string;
effectiveUsername?: string;
Copy link
Contributor

Choose a reason for hiding this comment

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

The type here should be User['username'] so it matches what username is when we pass it around.

export async function getEffectiveUsername(request: Request): Promise<string> {
const cookie = request.headers.get('Cookie');
const effectiveUsername = await effectiveUsernameCookie.parse(cookie);
return effectiveUsername;
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's return this like so:

return effictiveUsername as User['username'];

And you can drop the return type on the function.

if (username === undefined) {
return null;
}

const user = await getUserByUsername(username);
if (user) {
return user;
return { ...user, effectiveUsername: effectiveUsername };
Copy link
Contributor

Choose a reason for hiding this comment

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

When the property name and value are the same, just use it once:

return { ...user, effectiveUsername };

export async function getUser(request: Request) {
const username = await getUsername(request);
const effectiveUsername = await getEffectiveUsername(request);
if (username === undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

if (!username) {

@humphd humphd mentioned this pull request Mar 30, 2023
username, admins (identified by group) will start with an empty string
that can be changed to another users username on the admin portal for
them to impersonate */
effectiveUsername?: User['username'];
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: if we're going to have a user and an effectiveUser (two different objects), is there any point having this value on the User type now? Should both user and effectiveUser be of type 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.

I don't think we do need it since everything kind of comes from the cookie now, is there a better way of doing this than with the cookie? Maybe keeping track of it on this property we are thinking about removing, but this is dynamically appended every time we load same as baseDomain so that does not make sense. Anyways, I am ok with removing it I think.

Copy link
Contributor Author

@sfrunza13 sfrunza13 Mar 31, 2023

Choose a reason for hiding this comment

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

Honestly it's kind of hard keeping track of stuff, and this isn't a big change either 😅

app/root.tsx Outdated
return json(
{
user: await getUser(request),
effectiveUser: await getEffectiveUser(request),
Copy link
Contributor

Choose a reason for hiding this comment

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

I would move your comment from User to this, so it explains what this is vs. user

app/root.tsx Outdated
}
// ,
// { headers: { 'Set-Cookie': await setEffectiveUsername('user3', 'user1') } }
// This is how I tested it, after refreshing once since user3 is admin it will mock user1
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we remove this?

app/root.tsx Outdated
);
return json({
user: await getUser(request),
/* the effectiveUsername, this is for admin impersonating users. Regular
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you format this differently?

user: await getUser(request),

/**
 * effictiveUser: ...
 * ...
 * ...
 */
effectiveUser: await ...

Also, effectiveUser vs. effectiveUsername.

@@ -58,7 +59,7 @@ export async function getEffectiveUser(request: Request) {

const user = await getUserByUsername(effectiveUsername);
if (user) {
return { ...user, effectiveUsername }; // should we have an effectiveUsername when mocking?
return user; // should we have an effectiveUsername when mocking?
Copy link
Contributor

Choose a reason for hiding this comment

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

You can drop this comment

humphd
humphd previously approved these changes Mar 31, 2023
Copy link
Contributor

@humphd humphd left a comment

Choose a reason for hiding this comment

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

I think this is right.

In a follow-up, we should add/update e2e tests for this stuff.

Does anything call useUser() now, or is it all useEffectiveUser()? Maybe the header will do it when an admin logs in and we want to show different usernames?

We should also file an issue to hook up the isAdmin stuff to the /admin route.

app/models/user.server.ts Show resolved Hide resolved
@sfrunza13
Copy link
Contributor Author

Everything calls useEffectiveUser, if there is no effectiveUsername it will call getUser() in session server. As it stands there is no call to useUser(). I think we may be able to take it out of the root loader.

@humphd
Copy link
Contributor

humphd commented Mar 31, 2023

Everything calls useEffectiveUser, if there is no effectiveUsername it will call getUser() in session server. As it stands there is no call to useUser(). I think we may be able to take it out of the root loader.

OK, thanks for confirming. I think we'll still want it to show that an admin is impersonating another user, but probably in no other cases.

humphd
humphd previously approved these changes Apr 1, 2023
Eakam1007
Eakam1007 previously approved these changes Apr 1, 2023
@humphd
Copy link
Contributor

humphd commented Apr 1, 2023

@sfrunza13 can you rebase and fix this conflict? Then we can get this in too!

@sfrunza13
Copy link
Contributor Author

yes, I will do this

export async function getEffectiveUser(request: Request) {
const username = await getUsername(request);
// get the effective username in the cookie at the time
const effectiveUsername = await getEffectiveUsername(request);
Copy link
Contributor

Choose a reason for hiding this comment

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

Run these at the same time:

const [username, effectiveUsername] = await Promise.all([getUsername(request), getEffectiveUsername(request)]);

}

// if there is no effective username let the admin be himself
if (!effectiveUsername || effectiveUsername === '') {
Copy link
Contributor

Choose a reason for hiding this comment

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

It makes me nervous that effectiveUsername could be ''. When could this happen and why? Can we avoid that?

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 was what I remembered from our discussion, admins would have an empty effectiveUsername until they select one

Copy link
Contributor

Choose a reason for hiding this comment

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

Can "empty" be undefined vs ''?

return null;
}

// if there is no effective username let the admin be himself
Copy link
Contributor

Choose a reason for hiding this comment

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

I might update this wording slightly:

// if the user doesn't have an effective username, we're using their normal username (not impersonating)


// if there is no effective username let the admin be himself
if (!effectiveUsername || effectiveUsername === '') {
return await getUser(request);
Copy link
Contributor

Choose a reason for hiding this comment

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

Here you use getUser() and in 69 you use getUserByUsername(). Can we use the same path in both cases? Not critical, but it feels odd that we have different code paths.

@@ -62,12 +102,14 @@ export async function requireUsername(
redirectTo: string = new URL(request.url).pathname
) {
const username = await getUsername(request);
const effectiveUsername = await getEffectiveUsername(request);
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment as above, run at same time.

@humphd
Copy link
Contributor

humphd commented Apr 1, 2023

Also, please squash this down into 1 commit at this point.

Copy link
Contributor

@humphd humphd left a comment

Choose a reason for hiding this comment

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

This is great, thanks for persevering through the many reviews. I've left 2 final updates, and this is R+ from me.


export async function setEffectiveUsername(
username: string,
name: string | undefined
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: you can use name?: string to mean "this is a string or undefined".

Copy link
Contributor

Choose a reason for hiding this comment

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

Should name really be called effectiveUsername? I think using name is ambiguous.

@Genne23v Genne23v modified the milestones: Milestone 0.8, Milestone 0.9 Apr 2, 2023
@sfrunza13 sfrunza13 requested a review from Eakam1007 April 2, 2023 17:03
@sfrunza13 sfrunza13 merged commit c6dc4b6 into DevelopingSpace:main Apr 2, 2023
@sfrunza13 sfrunza13 modified the milestones: Milestone 0.9, Milestone 0.8 Apr 2, 2023
@sfrunza13
Copy link
Contributor Author

The above was some misclicks

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.

Admin user impersonating
4 participants