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

Add reef admin relationships #128

Merged
merged 15 commits into from
Sep 11, 2020
Merged

Add reef admin relationships #128

merged 15 commits into from
Sep 11, 2020

Conversation

avalmas-programize
Copy link
Collaborator

@avalmas-programize avalmas-programize commented Sep 4, 2020

  • Add reef-to-admin table (custom intermediate table for manyToMany relationship between admin and reef)
  • Change reef \ admin relationship from manyToOne (users can have many reefs but a reef has only one admin) to manyToMany (reefs now can have more than one admin)
  • Refactor reef endpoints to follow the manyToMany relationship between user and reef
  • Add IsReefAdminGuard to restrict access to reef_managers to only their reefs
  • Refactor surveys endpoints:
    • Change survey's global prefix to reefs/:reef_id/surveys so that we always track the reef_id, which is needed for IsReefAdminGuard
    • Change GET /surveys/reefs/:id to /reefs/:reef_id/surveys
    • Make necessary changes in the DTOs and survey service

Some front-end refactor is needed due to the prefix change

Related issue: #111

@ericboucher
Copy link
Member

@avalmas-programize can you list the necessary frontend changes? Or even have a stab at them? They shouldn't be too complicated at the moment.

@@ -44,17 +43,6 @@ export class ReefsController {
return this.reefsService.findDailyData(id);
}

@Get(':id/surveys/:poi')
Copy link
Member

Choose a reason for hiding this comment

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

is this not needed anymore?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

As I see it, this endpoint was using a mock response which is already implemented in the survey details endpoint (GET /reefs/:reef_id/surveys/:id). The only difference was that I fetch all POIs of a survey and this was designed to fetch only one POI of a survey. I don't see any use for it anymore.
Also it was conflicting with the new endpoints (surveys endpoints with the updated prefix)

@avalmas-programize
Copy link
Collaborator Author

Front-end changes:
All surveys/ endpoints URLs should changes:

  • POST /surveys/ => POST /reefs/:reef_id/surveys/
  • POST /surveys/:survey_id/media/ => POST /reefs/:reef_id/surveys/:survey_id/media/
  • POST /surveys/upload/ => POST /reefs/:reef_id/surveys/upload/
  • GET /surveys/reefs/:reef_id => GET /reefs/:reef_id/surveys/
  • GET /surveys/:survey_id/ => GET /reefs/:reef_id/surveys/:survey_id/
  • GET /surveys/:survey_id/media/ => GET /reefs/:reef_id/surveys/:survey_id/media/
  • PUT /surveys/:survey_id/ => PUT /reefs/:reef_id/surveys/:survey_id/
  • PUT /surveys/media/:media_id/ => PUT /reefs/:reef_id/surveys/:survey_id/
  • DELETE /surveys/:survey_id/ => PUT /reefs/:reef_id/surveys/:survey_id/
  • DELETE /surveys/media/:media_id/ => PUT /reefs/:reef_id/surveys/:survey_id/

@ericboucher ericboucher linked an issue Sep 4, 2020 that may be closed by this pull request
Copy link
Collaborator

@bencpeters bencpeters left a comment

Choose a reason for hiding this comment

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

Some suggested clean-ups throughout, but the big thing is re-doing the schema & entities a bit...

return !!isReefAdmin;
}

return true;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we really want to return true in this case? The only way to reach this point is if reefId is null/undefined, which (probably) means the request was malformed. It seems to me like a safer course of action in that case is to return false...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmm I thought it from a different angle, but I see your point. I will return false by default.

ManyToOne,
Unique,
} from 'typeorm';
// eslint-disable-next-line import/no-cycle
Copy link
Collaborator

Choose a reason for hiding this comment

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

Don't disable the linter here, just import the type:

import type { Reef } from './reefs.entity';

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I removed all the lint disables from all the entities.
However the relations decorator expects a entity returned to use as a type for the relation.
To bypass this I had to change the way we declare a relationship from:

@ManyToOne(() => Reef, (reef) => reef.reefToAdmin, { cascade: true })
reef: Reef;

to

@ManyToOne<Reef>('Reef', (reef) => reef.reefToAdmin, { cascade: true })
reef: Reef;

But this does not solve all of the issues. After compiling the code an error appears in the dist files:

/home/antonios/Documents/Programize/aqualink-app-backend/packages/api/dist/src/reefs/reef-to-admin.entity.js
    __metadata("design:type", User)

ReferenceError: User is not defined

To solve this we have to create an interface for each entity and reference this as the type of the class member
typeorm/typeorm#4190
Are we okay with this refactor?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah, I see the issue, we need to actually use the types as a return value, not just a type annotation.

New plan: Since circular dependencies are pretty much baked into how TypeORM does many-to-many relations, let's add a new ESlint rule override to disable that check in *.entity.ts files only.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Okay


@Entity()
@Unique('unique-relationship', ['reefId', 'adminId'])
export class ReefToAdmin {
Copy link
Collaborator

Choose a reason for hiding this comment

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

You shouldn't need to define a TypeORM entity at all for a join table unless you're trying to store custom information on the join table (e.g. an attribute that describes a condition on the join). In this case you're just trying to set up a many-to-many join between two entities, so defining it in this way has numerous disadvantages:

  • This is a class based entity that TypeORM will actually try to instantiate if you load records in some cases, increasing the overhead.
  • You have a completely extraneous extra column (reefToAdminId) that conveys no extra information and requires a hacky unique constraint (the "right" way to set up a table like this would just use the reefId, adminId as the primary key.
  • This entity forces you to specify a naming convention (which in this case you didn't use the "standard" SQL naming convention for join tables across most web frameworks which would be reefs_admins) rather than relying on predictable & standard defaults.

See the docs on Many-To-Many in TypeORM.

This is what you want to do:

class Reef {
  // Other attributes
  @ManyToMany(() => User, (user) => user.administered_reefs)
  @JoinTable()
  admins: User[]
}

class User {
  // Other attributes
  @ManyToMany(() => Reef, (reef) => reef.admins)
  administered_reefs: Reef[]
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I wanted to add a custom join table in case we need it in the future.
I will revert my change

}

if (reefId) {
const isReefAdmin = await this.reefToAdminRepository.findOne({
Copy link
Collaborator

Choose a reason for hiding this comment

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

We have an adminLevel that corresponds to a reef manager, do we want to enforce that that admin level is set as well?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The routes this guard is added are the already protected ones, i.e the ones that do not allow the Default user to access them, only ReefManager and SuperAdmin.
In the first lines of this guard I bypass the guard if the level of the user is SuperAdmin.
So in this we are left with the case that the user is a ReefManager.
Also if in the future this is added to a not protected route, i.e a default user is tested to pass this check they will fail because the are not a manager of the reef, which makes perfect sense.
We should not allow a default user access more than a reef manager.

Copy link
Collaborator

Choose a reason for hiding this comment

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

That may be true in the current use-case - wherever we use this is already a protected route, but it would be fairly easy down the line for someone not to realize that. That's why I was suggesting adding an adminLevel check inside this guard so that you don't have to ensure that a completely independent guard is also set whenever you use it.

// eslint-disable-next-line import/no-cycle
import { DailyData } from './daily-data.entity';
import { VideoStream } from './video-streams.entity';
// eslint-disable-next-line import/no-cycle
import { Survey } from '../surveys/surveys.entity';
// eslint-disable-next-line import/no-cycle
import { ReefToAdmin } from './reef-to-admin.entity';
Copy link
Collaborator

Choose a reason for hiding this comment

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

import type { ReefToAdmin } (the other no cycle imports should use this as well - get rid of all the linter disable comments)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Like I said this is more complicated than it seems.

export class SurveysController {
constructor(private surveyService: SurveysService) {}

@UseGuards(IsReefAdminGuard)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Rather than requiring us to specify this guard on every single handler, it might be worth adding a @Public decorator that does setMetadata("isPublic", true) and then look up the metadata on the handler inside the isReefAdminGuard function and only lets the request through without the check if that metadata is set. Then you could specify the guard on a controller-wise level here.

Copy link
Collaborator Author

@avalmas-programize avalmas-programize Sep 8, 2020

Choose a reason for hiding this comment

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

Interesting approach.
However I would name the decorator something more specific like @ManagersOnly because you would assume a @Public decorator to also have an effect to the @Auth decorator.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmmm. IsReefAdminGuard needs the Auth decorator to run first and since the first one is now a Controller decorator it will always run first. So we need a different approach if we want to implement this.
We could edit the Auth decorator in order to dynamically add the IsReefAdminGuard to the list of decorators used or have the new decorator (the Public one you suggested) affect both guards' behavior and add those as global or controller decorators

Copy link
Collaborator

Choose a reason for hiding this comment

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

have the new decorator (the Public one you suggested) affect both guards' behavior and add those as global or controller decorators

This is what I was imagining. If you move IsReefAdminGuard to the controller level, you definitely have to move the Auth guard as well.

@@ -10,6 +10,7 @@ import {
} from 'typeorm';
// eslint-disable-next-line import/no-cycle
import { Reef } from '../reefs/reefs.entity';
// eslint-disable-next-line import/no-cycle
Copy link
Collaborator

Choose a reason for hiding this comment

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

again, use import type { ... }

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Like I said this is more complicated than it seems.

} from 'typeorm';
import { Exclude } from 'class-transformer';
// eslint-disable-next-line import/no-cycle
import { ReefToAdmin } from '../reefs/reef-to-admin.entity';
Copy link
Collaborator

Choose a reason for hiding this comment

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

another import type { }

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Like I said this is more complicated than it seems.


// If relationship already exists, skip
if (relationshipExists) {
return Promise.resolve();
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is already an sync function, you don't need to return anything at all to get it to wrap in a Promise (or you could return true if that feels better)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It was not letting me to just write return; because map expects something returned so I thought I could return an empty Promise since that's what I want.
But I will change it to null since I agree it is a bit ugly.

@avalmas-programize avalmas-programize force-pushed the reef-admins branch 2 times, most recently from 83d2797 to 9ccda33 Compare September 8, 2020 10:36
Copy link
Collaborator

@bencpeters bencpeters left a comment

Choose a reason for hiding this comment

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

Getting close!

A few small cleanups, then:

  • for the cyclic dependencies, let's just leave them in the entity code, but add an eslint rule override for *.entity.ts files for that specific rule so that we don't need line-by-line overrides.
  • For the @Public decorator - I think you'd need to have it affect the auth guard too, then it should work. I don't feel super strongly about it either way, but I generally think that it might be a cleaner way to define a controller where almost all the routes are protected but there are 1 or 2 public ones... if it's taking too long don't worry about it though.

Comment on lines 50 to 52
@Get('current/administrated-reefs')
getAdministratedReefs(@Req() req: AuthRequest): Promise<Reef[]> {
return this.usersService.getAdministratedReefs(req);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's call these "administered" (not "administrated") (ref)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oops typo. Fixed

Comment on lines 127 to 135
// Add new user as admin
return this.reefRepository.update(
{
id: reef.id,
},
{
admins: [...reef.admins, user],
},
);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we add this relation from the "other side" (e.g. using the userRepository) instead? I think this is the only dependency the UserService has on the reefRepository, so if we could add it from the "other" side of the relation we could drop that dependency from this service...

this.usersRepository.update({ id: user.id }, { administeredReefs: ... })

Maybe?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

relations: ['reef', 'reef.admins'],
});

const reefEntities: Promise<UpdateResult | null>[] = reefAssociations.map(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Implicitly, a user who has user reef admin associations should also get the reef manager role, no?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah I thought it would have been set from the application. Done.

@avalmas-programize
Copy link
Collaborator Author

I have added the Public decorator. Implementation:

  • Override method AuthGuard('custom').canActivate so that it will not apply the strategy if isPublic equals to true
  • Override Levels and ReefAdmin guards to return true if isPublic equals to true
  • Add OverrideLevelAccess decorator which overrides the level defined in the Auth decorator on the Controller in order to have different level of access per Controller
  • Refactor Auth decorator in order for the above change to work without re-applying extra decorators

Change controller's decorators structure
Add public decorator and override behavior for both auth and
is-reef-admin decorators
Add override-level-access to override the default decorator access for
some endpoints
@ericboucher
Copy link
Member

When I tried it, I did not get promoted to "reef_manager" in the DB for some reason.

But I am actually wondering if this is still useful, if a user has a reef - user row, we can probably assume that they are an admin this way. And just keep default / super_admin

Thoughts @avalmas-programize @bencpeters

@avalmas-programize
Copy link
Collaborator Author

No I think keeping all three roles is much useful and easier to maintain.
Also there was a bug regarding the promotion of the user to reef_manager.
Saves after the update operation for the user promotion were reverting the level of the user to their prior one.
Added a new commit that resolves this.

id: user.id,
administeredReefs: newAdministeredReefs,
};
return this.usersRepository.save(newUser);
Copy link
Member

Choose a reason for hiding this comment

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

if we pass in an id, should we use .update instead of save?

Copy link
Collaborator Author

@avalmas-programize avalmas-programize Sep 11, 2020

Choose a reason for hiding this comment

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

Updating ManyToMany relations is not working properly with update, only with save.
typeorm/typeorm#2821

// eslint-disable-next-line fp/no-mutation
priorAccount.adminLevel = newUser.adminLevel;
}

const data = {
...priorAccount,
Copy link
Member

Choose a reason for hiding this comment

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

should we reverse the order here so that priorAccount info is prioritized over the createUserDto?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Do we want to do that?
I mean the createUserDto has the information added from the user in the registration form.

@ericboucher ericboucher changed the title Reef admins Add reef admin relationships Sep 11, 2020
@ericboucher ericboucher merged commit cbea1c4 into master Sep 11, 2020
@ericboucher ericboucher deleted the reef-admins branch September 11, 2020 10:08
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.

Detect new users as reef admins
3 participants