EDFIAL-527-update-tenantOwnership guard - #108
Conversation
|
⏳ I'm reviewing this pull request for security vulnerabilities and code quality issues. I'll provide an update when I'm done |
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
|
✅ I finished the code review, and didn't find any security or code quality issues. |
edandylytics
left a comment
There was a problem hiding this comment.
Hey Rachel -- thanks for putting this together and calling out the big questions in your write-up in the PR description. I kept the review fairly high level, just looking at the overall factoring and things that felt like they'd impact scope.
The last comment might be the place to start first. I think we can use Reflector and a route decorator to remove knowledge of any specific resource or privilege from the guard, and also get a setup that's default-closed to meta-tenant access, so we can just add privileges to the routes we explicitly want to give metatenant access to.
As always, let me know if you'd like to pair on any of this.
| ]) | ||
| ), | ||
| User: Object.freeze(new Set<PrivilegeKey>(['school-year-config.read'])), | ||
| SupportUser: Object.freeze(new Set<PrivilegeKey>(['job.metatenant.read'])), |
There was a problem hiding this comment.
SupportUser will also need all privileges that User has.
I know reading Jobs is our focus, let's think about how a few other things would work:
- Is there a reason not to do OdsConfigs as well? I think that'd be fine
- What about
updateandcreate? I thinkupdatemakes sense forSupportUserbut notcreate. Withupdatethere's already an owning tenant on the resource that can be evaluated against the meta-tenant hierarchy, but now so withcreate.- Currently, there's no update operation for Jobs, but we'll likely add a "resubmit" eventually and that'd be nice to have available.
For now, let's include ODS configs, but keep the privileges scoped to just reading. There are probably some extra wrinkles around updating that we want to consider (e.g. displaying the resource's owning tenant clearly in the UI first so we don't have mistaken edits.)
There was a problem hiding this comment.
Should I make any of those front end changes now as part of this PR? If the SupportUser role is enabled for people before including the update privilege for ods configs, we'd want a read only view of the ods config form I assume. But maybe you are imagining we would add that privilege too before anyone actually has this access level?
There was a problem hiding this comment.
Good question on the read-only form -- let's actually leave ODS configs out of this PR. It'd be weird UX to have an editable form when the backend will reject updates. And practically, single ODS configs are (a) harder to navigate to directly since we don't link to them and (b) not really that useful for troubleshooting. So ignore that part of my earlier comment and let's just stick to jobs.
| constructor(private readonly resourceKey: keyof Request) { | ||
| this.reflector = new Reflector(); | ||
| } | ||
| export function makeTenantOwnershipGuard(resourceKey: keyof Request) { |
There was a problem hiding this comment.
I think the original pattern I set up way back when set us on the path for needing a factory function here, but I think we can refactor to simplify how this guard is instantiated.
Parameterizing resourceKey (originally on the constructor, now in the factory fn) is what makes it so that we can't just use normal Nest DI to inject the Prisma client (I believe). But I think we can remove that parameterization. Instead, we can use Reflector to pass the resource from the controller to the guard.
So on the controller, we'd have something like this:
@ResourceKey('job')
@UseGuard(TenantOwnershipGuard)
class JobController {...}And then in the Guard, we can just look up the resource key from reflector:
const resourceKey = this.reflector.get<keyof Request>(RESOURCE_KEY, context.getHandler())We'd have to create ResourceKey, of course. I can't say I love that, but it is more consistent with Nest's approach.
| // either the session tenant code and resource tenant code must match exactly, or the | ||
| // resource tenant must be a descendant of the session tenant AND the user must hold the | ||
| // job.metatenant.read privilege | ||
| const isExactTenantMatch = |
There was a problem hiding this comment.
Most requests will be for exact tenant matches. Let's check that first and allow the request through if it matches. Then we only need to do the DB lookup for the meta-tenancy check in the small minority of cases where it's relevant.
| } | ||
| const resourceTenant = toGetTenantDto(resourceTenantRow); | ||
| const sessionData = plainToInstance(GetSessionDataDto, request.user); | ||
| const hasMetatenantJobReadPrivilege = sessionData.privileges.has('job.metatenant.read'); |
There was a problem hiding this comment.
Let's refactor away from checking explicit privileges in this guard. The main reason is that the guard applies to the entire controller but the privileges are specific to each controller method.
One way to do this would be to use Reflector to tag routes with a privilege required for meta-tenant access:
class JobControlls {
@AllowMetatenant('job,metatenant.read')
get() {...}
}
Then in the guard we can check (after doing the exact tenancy check):
- is there a privilege that grants meta-tenant access? If not -> reject
- does the user have that privilege? If not -> reject
- is the owning tenant for the resource a descendant of the user's session tenant? if not -> reject
edandylytics
left a comment
There was a problem hiding this comment.
The updates to the TenantOwnershipGuard look good!
The main thing remaining is test coverage. Currently, we're just exercising "user has privilege" vs "user does not have privilege". But since the privilege operates differently depending on whether the session tenant is global or not, whether the resource tenant is global or not, whether the route allows metatenant access, we should test those dimensions, too.
Most of the remaining comments are fairly minor readability tidying. Overall, this PR is looking good.
| import { Tenant } from '@prisma/client'; | ||
| import { DtoGetBase } from '../utils'; | ||
|
|
||
| export const isDescendant = (potentialParent: Tenant, potentialChild: Tenant) => { |
There was a problem hiding this comment.
Let's move this to the tenant guard file, or maybe a helper file next to the the guard file. That'll keep it in the package it's used in. I wouldn't expect this check needs to be available on the frontend
| context.getHandler() | ||
| ); | ||
|
|
||
| const allowMetatenantPrivilege = this.reflector.get<PrivilegeKey | null>( |
There was a problem hiding this comment.
For readability, let's set allowMetatenantPrivilege closer to where it's used. Currently, it's squating between setting skipTenantOwnershipCheck and the logic that evaluates skipTenantOwnershipCheck to determine whether to continue.
Ditto for the resourceKey.
| if (!resourceTenant) { | ||
| throw new ForbiddenException('Forbidden'); // resource points at a tenant that doesn't exist | ||
| } | ||
| const resourceTenantIsDescendantOfSessionTenant = isDescendant(sessionTenant, resourceTenant); |
There was a problem hiding this comment.
I'm realizing I have to reference the isDescendent implementation to confirm that the first tenant is intended to be the potential parent and the second the potential child. Rather than positional params, perhaps some named keys would make it easier to read and harder to mix up the order
There was a problem hiding this comment.
Let's also add test cases for all combinations of:
- user has / does not have
job.metatenant.read - is logged into global tenant / non-global tenant
- is attempting to access resource owned by a different global tenant / a non global tenant
Additionally, let's test that a route without the AllowMetatenant decorator does not permit meta-tenant access.
|
|
||
| // Global tenant for partner A — support users (PartnerAdmin) logged in here | ||
| // get access to any non-global tenant under partner A (tenantA, tenantB). | ||
| export const tenantAGlobal: WithoutAudit<Tenant> = { |
There was a problem hiding this comment.
could we give it a different letter? E.g. tenantDGlobal? It's unclear reading the tests whether tenantA and tenantAGlobal refer to the same tenant in different states or different tenants altogether.
edandylytics
left a comment
There was a problem hiding this comment.
Updates look good and test well!
There are a couple small logging updates that'd help diagnose cases where we'd issue 500s, but no need for me to re-review after that. Marking approve!
| ); | ||
|
|
||
| const resource = request[resourceKey]; | ||
| if (!resource || !resourceKey) {throw new InternalServerErrorException()} |
There was a problem hiding this comment.
Let's add some logging here to indicate why the failure happen.
| where: { code_partnerId: { code: resource.tenantCode, partnerId: resource.partnerId } }, | ||
| }); | ||
| if (!resourceTenant) { | ||
| throw new ForbiddenException('Forbidden'); // resource points at a tenant that doesn't exist |
There was a problem hiding this comment.
Let's make this a 500, too. A missing tenant ownership is an unexpected state on the server. And logging for visibility would be helpful.
ticket
I have this in draft right now since there were a couple approaches we discussed but this is what makes the most sense to me at the moment.
isDescendantmethod to tenant dtoSupportUserwhich grants the new privilegejob.metatenant.read. I want to confirm this is the only thing we need added to UM before reaching out to Bjorn (tracking in this ticket)TenantOwnershipGuardmakeEarthbeamJWTGuardin order to look up the resource's tenantisExactTenantMatchresourceKey === 'job'is something we want to avoid doing. If so, an alternative approach could be to make the guard only check partner Id and then handle the other checks within the/:jobIdroute, but that would mean we also need to add the privileges etc for odsConfigs now