Skip to content

Commit

Permalink
auth + populates
Browse files Browse the repository at this point in the history
  • Loading branch information
wermarter committed Jan 25, 2024
1 parent fddfcc6 commit c7f1de5
Show file tree
Hide file tree
Showing 27 changed files with 335 additions and 60 deletions.
52 changes: 49 additions & 3 deletions apps/hcdc-access-service/src/domain/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ import {
createMongoAbility,
} from '@casl/ability'
import { $or, or } from '@ucast/mongo2js'
import { FilterQuery } from 'mongoose'
import { accessibleBy } from '@casl/mongoose'

import { AuthAction } from './action'
import { AuthSubject, SubjectEntityMapping } from './subject'
import { AuthAction, AuthActionUnionType } from './action'
import {
AuthSubject,
AuthSubjectUnionType,
SubjectEntityMapping,
} from './subject'
import { EAuthzPermissionDenied } from 'src/domain/exception'
import { AUTH_ACTION_ALL, AUTH_SUBJECT_ALL } from './constants'
import { PermissionRule } from '../entity'
import { BaseEntity, PermissionRule } from '../entity'
import { EntityFindOneOptions } from '../interface'

const conditionsMatcher = buildMongoQueryMatcher({ $or }, { or })

Expand Down Expand Up @@ -43,3 +50,42 @@ export function assertPermission<TSubject extends keyof typeof AuthSubject>(
)
}
}

export type AuthMappingFn<TEntity> = (path: keyof TEntity) => {
subject: AuthSubjectUnionType
action: AuthActionUnionType
}

export function authorizePopulates<TEntity extends BaseEntity>(
ability: MongoAbility,
populates: EntityFindOneOptions<TEntity>['populates'],
authMapping: AuthMappingFn<TEntity>,
) {
if (populates === undefined) {
return
}

const rv: EntityFindOneOptions<TEntity>['populates'] = []
for (const populate of populates) {
const { subject, action } = authMapping(populate.path)
const authMatchObj = accessibleBy(ability, action)[subject]
if (populate.match) {
let matchObject: FilterQuery<TEntity>

if (typeof populate.match === 'function') {
matchObject = populate.match()
} else {
matchObject = populate.match
}

rv.push({
...populate,
match: { $and: [matchObject, authMatchObj] },
})
} else {
rv.push({ ...populate, match: authMatchObj })
}
}

return rv
}
1 change: 1 addition & 0 deletions apps/hcdc-access-service/src/domain/entity/branch/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export enum BranchAction {
Read = 'Read',
Update = 'Update',
Delete = 'Delete',
AssignToSubject = 'AssignToSubject',
}

declare module '@casl/mongoose' {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { MongoQuery } from '@casl/ability'

import { AuthAction, AuthSubject, SubjectEntityMapping } from 'src/domain/auth'
import {
AUTH_ACTION_ALL,
AUTH_SUBJECT_ALL,
AuthAction,
AuthSubject,
SubjectEntityMapping,
} from 'src/domain/auth'

export type PermissionRule<
TSubject extends keyof typeof AuthSubject = keyof typeof AuthSubject,
> = {
subject: TSubject
action: (typeof AuthAction)[TSubject][number]
subject: TSubject | typeof AUTH_SUBJECT_ALL
action: (typeof AuthAction)[TSubject][number] | typeof AUTH_ACTION_ALL
inverted?: boolean
conditions?: MongoQuery<SubjectEntityMapping[TSubject]>
}
2 changes: 2 additions & 0 deletions apps/hcdc-access-service/src/domain/entity/role/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export enum RoleAction {
Read = 'Read',
Update = 'Update',
Delete = 'Delete',
AssignToUser = 'AssignToUser',
AssignUserInline = 'AssignUserInline',
}

declare module '@casl/mongoose' {
Expand Down
11 changes: 11 additions & 0 deletions apps/hcdc-access-service/src/domain/exception/entity/crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,14 @@ export class EEntityNotFound extends EEntity {
)
}
}

export class EEntityPopulatePathUnknown extends EEntity {
constructor(reason: string) {
super(
DomainErrorCode.ENTITY_POPULATE_PATH_UNKNOWN,
`populate path unknown: ${reason}`,
undefined,
HttpStatus.BAD_REQUEST,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,8 @@ import {

import { BaseEntity } from 'src/domain/entity'

export type EntitySearchOptions<TEntity> = {
offset?: number
limit?: number
export type EntityFindOneOptions<TEntity extends BaseEntity = BaseEntity> = {
filter?: FilterQuery<TEntity>
sort?: { [key in keyof TEntity]: SortOrder | { $meta: 'textScore' } }
projection?:
| keyof TEntity
| (keyof TEntity)[]
Expand All @@ -26,29 +23,21 @@ export type EntitySearchOptions<TEntity> = {
isDeleted?: boolean | null
}

export type EntitySearchOptions<TEntity extends BaseEntity = BaseEntity> =
EntityFindOneOptions<TEntity> & {
offset?: number
limit?: number
sort?: { [key in keyof TEntity]: SortOrder | { $meta: 'textScore' } }
}

export type SearchResult<TEntity extends BaseEntity> = {
total: number
offset: number
limit: number
items: TEntity[]
}

export type EntityFindOneOptions<TEntity> = {
filter: FilterQuery<TEntity>
projection?:
| keyof TEntity
| (keyof TEntity)[]
| Record<keyof TEntity, number | boolean | object>
populates?: Array<{
path: keyof TEntity
isDeleted?: boolean | null
fields?: Array<string>
match?: FilterQuery<TEntity> | (() => FilterQuery<TEntity>)
}>
isDeleted?: boolean | null
}

export interface IRepository<TEntity extends BaseEntity> {
export interface IRepository<TEntity extends BaseEntity = BaseEntity> {
findById(id: string, isDeleted?: boolean | null): Promise<TEntity | null>

findOne(options?: EntityFindOneOptions<TEntity>): Promise<TEntity | null>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class AuthPopulateContextUseCase {
...user.inlinePermissions,
])
const permissions = permissionTemplate({ user }) as PermissionRule[]

const ability = createAbility(permissions)

return { user, ability }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Inject, Injectable } from '@nestjs/common'

import { AuthSubject, authorizePopulates } from 'src/domain/auth'
import { BranchAction, BioProduct } from 'src/domain/entity'
import { EEntityPopulatePathUnknown } from 'src/domain/exception'
import {
AuthContextToken,
EntityFindOneOptions,
IAuthContext,
} from 'src/domain/interface'

@Injectable()
export class BioProductAuthorizePopulatesUseCase {
constructor(
@Inject(AuthContextToken)
private readonly authContext: IAuthContext,
) {}
execute(input: EntityFindOneOptions<BioProduct>['populates']) {
const { ability } = this.authContext.getData()

return authorizePopulates(ability, input, (path) => {
switch (path) {
case 'branch':
return { subject: AuthSubject.Branch, action: BranchAction.Read }
default:
throw new EEntityPopulatePathUnknown(path)
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
IAuthContext,
IBioProductRepository,
} from 'src/domain/interface'
import { BioProductAuthorizePopulatesUseCase } from './authorize-populates'

@Injectable()
export class BioProductFindOneUseCase {
Expand All @@ -17,9 +18,13 @@ export class BioProductFindOneUseCase {
private readonly bioProductRepository: IBioProductRepository,
@Inject(AuthContextToken)
private readonly authContext: IAuthContext,
private readonly bioProductAuthorizePopulatesUseCase: BioProductAuthorizePopulatesUseCase,
) {}

async execute(input: EntityFindOneOptions<BioProduct>) {
input.populates = this.bioProductAuthorizePopulatesUseCase.execute(
input.populates,
)
const entity = await this.bioProductRepository.findOne(input)
const { ability } = this.authContext.getData()
assertPermission(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from 'src/domain/interface'
import { BioProduct, BioProductAction } from 'src/domain/entity'
import { AuthSubject, assertPermission } from 'src/domain/auth'
import { BioProductAuthorizePopulatesUseCase } from './authorize-populates'

@Injectable()
export class BioProductSearchUseCase {
Expand All @@ -18,11 +19,15 @@ export class BioProductSearchUseCase {
private readonly bioProductRepository: IBioProductRepository,
@Inject(AuthContextToken)
private readonly authContext: IAuthContext,
private readonly bioProductAuthorizePopulatesUseCase: BioProductAuthorizePopulatesUseCase,
) {}

async execute(input: EntitySearchOptions<BioProduct>) {
const { ability } = this.authContext.getData()
assertPermission(ability, AuthSubject.BioProduct, BioProductAction.Read)
input.populates = this.bioProductAuthorizePopulatesUseCase.execute(
input.populates,
)

const paginationResult = await this.bioProductRepository.search({
...input,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
import { Injectable } from '@nestjs/common'
import { Inject, Injectable } from '@nestjs/common'

import { BioProduct, EntityData } from 'src/domain/entity'
import { BioProduct, BranchAction, EntityData } from 'src/domain/entity'
import { BranchAssertExistsUseCase } from '../branch/assert-exists'
import { AuthContextToken, IAuthContext } from 'src/domain/interface'
import { AuthSubject, assertPermission } from 'src/domain/auth'

@Injectable()
export class BioProductValidateUseCase {
constructor(
@Inject(AuthContextToken)
private readonly authContext: IAuthContext,
private readonly branchAssertExistsUseCase: BranchAssertExistsUseCase,
) {}

async execute(input: Partial<Pick<EntityData<BioProduct>, 'branchId'>>) {
async execute(input: Partial<EntityData<BioProduct>>) {
const { ability } = this.authContext.getData()
const { branchId } = input

if (branchId !== undefined) {
await this.branchAssertExistsUseCase.execute({ _id: branchId })
const branch = await this.branchAssertExistsUseCase.execute({
_id: branchId,
})
assertPermission(
ability,
AuthSubject.Branch,
BranchAction.AssignToSubject,
branch,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Inject, Injectable } from '@nestjs/common'

import { AuthSubject, authorizePopulates } from 'src/domain/auth'
import { BranchAction, Branch } from 'src/domain/entity'
import { EEntityPopulatePathUnknown } from 'src/domain/exception'
import {
AuthContextToken,
EntityFindOneOptions,
IAuthContext,
} from 'src/domain/interface'

@Injectable()
export class BranchAuthorizePopulatesUseCase {
constructor(
@Inject(AuthContextToken)
private readonly authContext: IAuthContext,
) {}
execute(input: EntityFindOneOptions<Branch>['populates']) {
const { ability } = this.authContext.getData()

return authorizePopulates(ability, input, (path) => {
switch (path) {
case 'sampleOrigins':
return { subject: AuthSubject.Branch, action: BranchAction.Read }
default:
throw new EEntityPopulatePathUnknown(path)
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
IAuthContext,
IBranchRepository,
} from 'src/domain/interface'
import { BranchAuthorizePopulatesUseCase } from './authorize-populates'

@Injectable()
export class BranchFindOneUseCase {
Expand All @@ -17,9 +18,13 @@ export class BranchFindOneUseCase {
private readonly branchRepository: IBranchRepository,
@Inject(AuthContextToken)
private readonly authContext: IAuthContext,
private readonly branchAuthorizePopulatesUseCase: BranchAuthorizePopulatesUseCase,
) {}

async execute(input: EntityFindOneOptions<Branch>) {
input.populates = this.branchAuthorizePopulatesUseCase.execute(
input.populates,
)
const entity = await this.branchRepository.findOne(input)
const { ability } = this.authContext.getData()
assertPermission(ability, AuthSubject.Branch, BranchAction.Read, entity)
Expand Down
5 changes: 5 additions & 0 deletions apps/hcdc-access-service/src/domain/use-case/branch/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from 'src/domain/interface'
import { Branch, BranchAction } from 'src/domain/entity'
import { AuthSubject, assertPermission } from 'src/domain/auth'
import { BranchAuthorizePopulatesUseCase } from './authorize-populates'

@Injectable()
export class BranchSearchUseCase {
Expand All @@ -18,11 +19,15 @@ export class BranchSearchUseCase {
private readonly branchRepository: IBranchRepository,
@Inject(AuthContextToken)
private readonly authContext: IAuthContext,
private readonly branchAuthorizePopulatesUseCase: BranchAuthorizePopulatesUseCase,
) {}

async execute(input: EntitySearchOptions<Branch>) {
const { ability } = this.authContext.getData()
assertPermission(ability, AuthSubject.Branch, BranchAction.Read)
input.populates = this.branchAuthorizePopulatesUseCase.execute(
input.populates,
)

const paginationResult = await this.branchRepository.search({
...input,
Expand Down
Loading

0 comments on commit c7f1de5

Please sign in to comment.