diff --git a/docs/developer-guide/authorization/samples.md b/docs/developer-guide/authorization/samples.md new file mode 100644 index 000000000..2787029a6 --- /dev/null +++ b/docs/developer-guide/authorization/samples.md @@ -0,0 +1,78 @@ +# Samples Authorization Model + +This document describes the authorization model used for samples and associated endpoints. + +## Actions + +The following actions are defined for samples: + +- `SampleCreate` +- `SampleRead` +- `SampleUpdate` +- `SampleDelete` +- `SampleAttachmentCreate` +- `SampleAttachmentRead` +- `SampleAttachmentUpdate` +- `SampleAttachmentDelete` + +## Permissions + +Permissions are granted cumulatively to users based on their group association. The following permission levels are granted to users: + +### Unauthenticated + +An unauthenticated user may read samples and linked attachments only if the sample is public (the linked attachment's ownership is not considered). +Unauthenticated users do not have write access. + +### Authenticated + +An authenticated user may read samples and linked attachments if the sample is public or if they are a member of the sample's `ownerGroup` or one of the `accessGroups` (the linked attachment's ownership is not considered). +Authenticated users do not have write access by default. + +### SAMPLE_GROUPS + +If a user is part of a group listed in configuration as part of `SAMPLE_GROUPS`, in addition to the permissions granted to authenticated users, they are permitted to create and update samples and linked attachments if the `ownerGroup` matches one of the user's `currentGroups`. Importantly, it is not necessary that `ownerGroup` be in `SAMPLE_GROUPS`. They are additionally permitted to delete attachments linked to samples where the `ownerGroup` matches one of the user's `currentGroups`. + +This permission can be extended to all authenticated users by providing the token `#all` under `SAMPLE_GROUPS` in configuration. + +### SAMPLE_PRIVILEGED_GROUPS + +If a user is part of a group listed in configuration as part of `SAMPLE_PRIVILEGED_GROUPS`, in addition to the permissions granted to authenticated users, they are permitted to create samples and linked attachments for any `ownerGroup`. +They may update samples and linked attachments if the `ownerGroup` matches one of the user's `currentGroups`. +They are additionally permitted to delete attachments linked to samples where the `ownerGroup` matches one of the user's `currentGroups`. + +### ADMIN_GROUPS + +If a user is part of a group listed in configuration as part of `ADMIN_GROUPS`, they have unrestricted create, read and update access to all samples and linked attachments, and additionally unrestricted delete access to linked attachments. + +### DELETE_GROUPS + +If a user is part of a group listed in configuration as part of `DELETE_GROUPS`, they have unrestricted delete access to all samples and linked attachments in the database. + +## Permission Matrix + +Table of the different permission classes defined in casl. For all special permission groups, the full list includes the relevant permissions passed on from generic authenticated user permissions. + +| Operation | Unauthenticated | Authenticated | `SAMPLE_GROUPS` | `SAMPLE_PRIVILEGED_GROUPS` | `ADMIN_GROUPS` | `DELETE_GROUPS` | +| - | - | - | - | - | - | - | +| `SampleCreate` | - | - | owner | any | any | - | +| `SampleRead` | public | public/owner/access | public/owner/access | public/owner/access | any | public/owner/access | +| `SampleUpdate` | - | - | owner | owner | any | - | +| `SampleDelete` | - | - | - | - | - | any | +| `SampleAttachmentCreate` | - | - | owner | any | any | - | +| `SampleAttachmentRead` | public | public/owner/access | public/owner/access | public/owner/access | any | public/owner/access | +| `SampleAttachmentUpdate` | - | - | owner | owner | any | - | +| `SampleAttachmentDelete` | - | - | owner | owner | any | any | + +Legend: +- public: sample's `isPublished` field must be `true` +- owner: sample's `ownerGroup` must match one of the user's `currentGroups` +- access: one of the sample's `accessGroups` must match one of the user's `currentGroups` +- any: unrestricted access + +## Implementation Notes + +The definition is implemented in the casl module under `/src/casl/abilities/samples.ability.ts` and accessible elsewhere via `CaslAbilityFactory.sampleAccess`. This one function is used to build one casl ability for endpoint and instance authorization: When a user receives permission for an action under some instance-level condition, they should implicitly pass endpoint authorization. + +The `SampleAbility` module in `/src/casl/abilities/samples.ability.ts` is written in such a way that permissions are cumulative. In case multiple rules apply, casl will chain them in a logical or, ultimately giving precedence to the broadest applicable rule. The special permission groups are sorted roughly in ascending order of privilege level. +In case there are expectations of mutual exclusivity for certain special groups (not the case for samples currently), additional rules using the `cannot` ability expression can be added after all `can` rules have been defined. For an example, see the jobs subsystem authorization docs. \ No newline at end of file diff --git a/src/casl/abilities/samples.ability.ts b/src/casl/abilities/samples.ability.ts new file mode 100644 index 000000000..d0cdb00b2 --- /dev/null +++ b/src/casl/abilities/samples.ability.ts @@ -0,0 +1,125 @@ +import { + AbilityBuilder, + ExtractSubjectType, + MongoAbility, + createMongoAbility, +} from "@casl/ability"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { AccessGroupsType } from "src/config/configuration"; +import { Action } from "../action.enum"; +import { + Subjects, + PossibleAbilities, + Conditions, +} from "../types/casl-subjects"; +import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; +import { SampleClass } from "src/samples/schemas/sample.schema"; + +@Injectable() +export class SampleAbility { + private accessGroups?: AccessGroupsType; + constructor(private configService: ConfigService) { + this.accessGroups = + this.configService.get("accessGroups") ?? + ({} as AccessGroupsType); + } + + buildAbility( + user: JWTUser | null, + ): MongoAbility { + const { can, build } = new AbilityBuilder( + createMongoAbility, + ); + const ifPublished = { isPublished: true }; + + /** + * Unauthenticated user + */ + can(Action.SampleRead, SampleClass, ifPublished); + can(Action.SampleAttachmentRead, SampleClass, ifPublished); + + if (!user) { + return build({ + detectSubjectType: (item) => + item.constructor as ExtractSubjectType, + }); + } + + const ifOwner = { ownerGroup: { $in: user.currentGroups } }; + const ifAccess = { accessGroups: { $in: user.currentGroups } }; + + /** + * Authenticated user + */ + can(Action.SampleRead, SampleClass, ifOwner); + can(Action.SampleRead, SampleClass, ifAccess); + can(Action.SampleRead, SampleClass, ifPublished); + + can(Action.SampleAttachmentRead, SampleClass, ifOwner); + can(Action.SampleAttachmentRead, SampleClass, ifAccess); + can(Action.SampleAttachmentRead, SampleClass, ifPublished); + + if ( + user.currentGroups.some((g) => this.accessGroups?.sample?.includes(g)) || + this.accessGroups?.sample?.includes("#all") + ) { + /** + * User belonging to SAMPLE_GROUPS + */ + can(Action.SampleCreate, SampleClass, ifOwner); + can(Action.SampleUpdate, SampleClass, ifOwner); + + can(Action.SampleAttachmentCreate, SampleClass, ifOwner); + can(Action.SampleAttachmentUpdate, SampleClass, ifOwner); + can(Action.SampleAttachmentDelete, SampleClass, ifOwner); + } + + if ( + user.currentGroups.some((g) => + this.accessGroups?.samplePrivileged?.includes(g), + ) + ) { + /** + * User belonging to SAMPLE_PRIVILEGED_GROUPS + */ + can(Action.SampleCreate, SampleClass); + can(Action.SampleUpdate, SampleClass, ifOwner); + + can(Action.SampleAttachmentCreate, SampleClass); + can(Action.SampleAttachmentUpdate, SampleClass, ifOwner); + can(Action.SampleAttachmentDelete, SampleClass, ifOwner); + } + + if (user.currentGroups.some((g) => this.accessGroups?.admin?.includes(g))) { + /** + * User belonging to ADMIN_GROUPS + */ + can(Action.AccessAny, SampleClass); + + can(Action.SampleCreate, SampleClass); + can(Action.SampleRead, SampleClass); + can(Action.SampleUpdate, SampleClass); + + can(Action.SampleAttachmentCreate, SampleClass); + can(Action.SampleAttachmentRead, SampleClass); + can(Action.SampleAttachmentUpdate, SampleClass); + can(Action.SampleAttachmentDelete, SampleClass); + } + + if ( + user.currentGroups.some((g) => this.accessGroups?.delete?.includes(g)) + ) { + /** + * User belonging to DELETE_GROUPS + */ + can(Action.SampleDelete, SampleClass); + can(Action.SampleAttachmentDelete, SampleClass); + } + + return build({ + detectSubjectType: (item) => + item.constructor as ExtractSubjectType, + }); + } +} diff --git a/src/casl/action.enum.ts b/src/casl/action.enum.ts index 7e78bb28d..1f5102289 100644 --- a/src/casl/action.enum.ts +++ b/src/casl/action.enum.ts @@ -13,6 +13,17 @@ export enum Action { // Currently used by addAccessBasedFilters for admin/special group users AccessAny = "access_any", + // Samples + SampleCreate = "sample_create", + SampleRead = "sample_read", + SampleUpdate = "sample_update", + SampleDelete = "sample_delete", + + SampleAttachmentCreate = "sample_attachment_create", + SampleAttachmentRead = "sample_attachment_read", + SampleAttachmentUpdate = "sample_attachment_update", + SampleAttachmentDelete = "sample_attachment_delete", + // --------------- // Datasets DatasetCreate = "dataset_create", @@ -114,46 +125,6 @@ export enum Action { ProposalsAttachmentDeleteOwner = "proposals_attachment_delete_owner", ProposalsAttachmentDeleteAny = "proposals_attachment_delete_any", - // ------------------------------------- - // Samples - // ------------------------------------- - // sample endpoint authorization - SampleCreate = "sample_create", - SampleRead = "sample_read", - SampleUpdate = "sample_update", - SampleDelete = "sample_delete", - SampleAttachmentCreate = "sample_attachment_create", - SampleAttachmentRead = "sample_attachment_read", - SampleAttachmentUpdate = "sample_attachment_update", - SampleAttachmentDelete = "sample_attachment_delete", - SampleDatasetRead = "sample_dataset_read", - // ------------------------------------- - // sample data instance authorization - SampleCreateOwner = "sample_create_owner", - SampleCreateAny = "sample_create_any", - SampleReadManyPublic = "sample_read_many_public", - SampleReadManyAccess = "sample_read_many_access", - SampleReadManyOwner = "sample_read_many_owner", - SampleReadOnePublic = "sample_read_one_public", - SampleReadOneAccess = "sample_read_one_access", - SampleReadOneOwner = "sample_read_one_owner", - SampleReadAny = "sample_read_any", - - SampleUpdateOwner = "sample_update_owner", - SampleUpdateAny = "sample_update_any", - SampleDeleteOwner = "sample_delete_owner", - SampleDeleteAny = "sample_delete_any", - SampleAttachmentCreateOwner = "sample_attachment_create_owner", - SampleAttachmentCreateAny = "sample_attachment_create_any", - SampleAttachmentReadPublic = "sample_attachment_read_public", - SampleAttachmentReadAccess = "sample_attachment_read_access", - SampleAttachmentReadOwner = "sample_attachment_read_owner", - SampleAttachmentReadAny = "sample_attachment_read_any", - SampleAttachmentUpdateOwner = "sample_attachment_update_owner", - SampleAttachmentUpdateAny = "sample_attachment_update_any", - SampleAttachmentDeleteOwner = "sample_attachment_delete_owner", - SampleAttachmentDeleteAny = "sample_attachment_delete_any", - // -------------- // Jobs // -------------- diff --git a/src/casl/casl-ability.factory.spec.ts b/src/casl/casl-ability.factory.spec.ts index 05a546f6a..01753177f 100644 --- a/src/casl/casl-ability.factory.spec.ts +++ b/src/casl/casl-ability.factory.spec.ts @@ -6,6 +6,7 @@ import { DatasetClass } from "src/datasets/schemas/dataset.schema"; import { Action } from "./action.enum"; import { CaslAbilityFactory } from "./casl-ability.factory"; import { DatasetAbility } from "./abilities/datasets.ability"; +import { SampleAbility } from "./abilities/samples.ability"; describe("CaslAbilityFactory", () => { it("should be defined", () => { @@ -15,6 +16,7 @@ describe("CaslAbilityFactory", () => { configService, new JobConfigService({}, {}, configService), new DatasetAbility(configService), + new SampleAbility(configService), ), ).toBeDefined(); }); @@ -38,6 +40,7 @@ describe("CaslAbilityFactory", () => { configService, { allJobConfigs: {} } as unknown as JobConfigService, new DatasetAbility(configService), + new SampleAbility(configService), ); }; diff --git a/src/casl/casl-ability.factory.ts b/src/casl/casl-ability.factory.ts index 2825754d7..25a582c1e 100644 --- a/src/casl/casl-ability.factory.ts +++ b/src/casl/casl-ability.factory.ts @@ -24,11 +24,11 @@ import { Policy } from "src/policies/schemas/policy.schema"; import { ProposalClass } from "src/proposals/schemas/proposal.schema"; import { PublishedData } from "src/published-data/schemas/published-data.schema"; import { RuntimeConfig } from "src/config/runtime-config/schemas/runtime-config.schema"; -import { SampleClass } from "src/samples/schemas/sample.schema"; import { User } from "src/users/schemas/user.schema"; import { Action } from "./action.enum"; import { Subjects, PossibleAbilities, Conditions } from "./types/casl-subjects"; import { DatasetAbility } from "./abilities/datasets.ability"; +import { SampleAbility } from "./abilities/samples.ability"; export type AppAbility = MongoAbility; @@ -38,6 +38,7 @@ export class CaslAbilityFactory { private configService: ConfigService, private jobConfigService: JobConfigService, private datasetAbility: DatasetAbility, + private sampleAbility: SampleAbility, ) { this.accessGroups = this.configService.get("accessGroups"); @@ -61,7 +62,7 @@ export class CaslAbilityFactory { proposals: this.proposalsEndpointAccess, publisheddata: this.publishedDataEndpointAccess, runtimeconfig: this.runtimeConfigEndpointAccess, - samples: this.samplesEndpointAccess, + samples: this.sampleAccess, users: this.userEndpointAccess, }; @@ -79,6 +80,10 @@ export class CaslAbilityFactory { return this.datasetAbility.buildAbility(user); } + sampleAccess(user: JWTUser | null) { + return this.sampleAbility.buildAbility(user); + } + opensearchEndpointAccess(user: JWTUser) { const { can, build } = new AbilityBuilder( createMongoAbility, @@ -868,120 +873,6 @@ export class CaslAbilityFactory { }); } - samplesEndpointAccess(user: JWTUser) { - const { can, cannot, build } = new AbilityBuilder( - createMongoAbility, - ); - - if (!user) { - // ------------------------------------- - // unauthenticated users - // ------------------------------------- - - can(Action.SampleRead, SampleClass); - cannot(Action.SampleCreate, SampleClass); - cannot(Action.SampleUpdate, SampleClass); - cannot(Action.SampleDelete, SampleClass); - can(Action.SampleAttachmentRead, SampleClass); - cannot(Action.SampleAttachmentCreate, SampleClass); - cannot(Action.SampleAttachmentUpdate, SampleClass); - cannot(Action.SampleAttachmentDelete, SampleClass); - cannot(Action.SampleDatasetRead, SampleClass); - } else { - // ------------------------------------- - // authenticated users - // ------------------------------------- - - if ( - user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) - ) { - // ------------------------------------- - // users that belong to any of the group listed in DELETE_GROUPS - // ------------------------------------- - - can(Action.SampleDelete, SampleClass); - can(Action.SampleAttachmentDelete, SampleClass); - } else { - // ------------------------------------- - // users that do not belong to any of the group listed in DELETE_GROUPS - // ------------------------------------- - - cannot(Action.SampleDelete, SampleClass); - } - - if ( - user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) - ) { - // ------------------------------------- - // users belonging to any of the group listed in ADMIN_GROUPS - // ------------------------------------- - - can(Action.SampleRead, SampleClass); - can(Action.SampleCreate, SampleClass); - can(Action.SampleUpdate, SampleClass); - can(Action.SampleAttachmentRead, SampleClass); - can(Action.SampleAttachmentCreate, SampleClass); - can(Action.SampleAttachmentUpdate, SampleClass); - can(Action.SampleAttachmentDelete, SampleClass); - can(Action.SampleDatasetRead, SampleClass); - } else if ( - user.currentGroups.some((g) => - this.accessGroups?.samplePrivileged.includes(g), - ) - ) { - // ------------------------------------- - // users belonging to any of the group listed in SAMPLE_GROUPS - // - - can(Action.SampleRead, SampleClass); - can(Action.SampleCreate, SampleClass); - can(Action.SampleUpdate, SampleClass); - can(Action.SampleAttachmentRead, SampleClass); - can(Action.SampleAttachmentCreate, SampleClass); - can(Action.SampleAttachmentUpdate, SampleClass); - can(Action.SampleAttachmentDelete, SampleClass); - can(Action.SampleDatasetRead, SampleClass); - } else if ( - user.currentGroups.some((g) => this.accessGroups?.sample.includes(g)) || - this.accessGroups?.sample.includes("#all") - ) { - // ------------------------------------- - // users belonging to any of the group listed in SAMPLE_GROUPS - // - - can(Action.SampleRead, SampleClass); - can(Action.SampleCreate, SampleClass); - can(Action.SampleUpdate, SampleClass); - can(Action.SampleAttachmentRead, SampleClass); - can(Action.SampleAttachmentCreate, SampleClass); - can(Action.SampleAttachmentUpdate, SampleClass); - can(Action.SampleAttachmentDelete, SampleClass); - can(Action.SampleDatasetRead, SampleClass); - } else { - // ------------------------------------- - // users with no elevated permissions - // ------------------------------------- - - can(Action.SampleRead, SampleClass); - cannot(Action.SampleCreate, SampleClass); - cannot(Action.SampleUpdate, SampleClass); - can(Action.SampleAttachmentRead, SampleClass); - cannot(Action.SampleAttachmentCreate, SampleClass); - cannot(Action.SampleAttachmentUpdate, SampleClass); - if ( - !user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) - ) { - cannot(Action.SampleAttachmentDelete, SampleClass); - } - } - } - - return build({ - detectSubjectType: (item) => - item.constructor as ExtractSubjectType, - }); - } - userEndpointAccess(user: JWTUser) { const { can, cannot, build } = new AbilityBuilder( createMongoAbility, @@ -1492,174 +1383,6 @@ export class CaslAbilityFactory { }); } - samplesInstanceAccess(user: JWTUser) { - const { can, cannot, build } = new AbilityBuilder( - createMongoAbility, - ); - - if (!user) { - // ------------------------------------- - // unauthenticated users - // ------------------------------------- - - can(Action.SampleReadManyPublic, SampleClass); - can(Action.SampleReadOnePublic, SampleClass, { - isPublished: true, - }); - can(Action.SampleAttachmentReadPublic, SampleClass, { - isPublished: true, - }); - } else { - // ------------------------------------- - // authenticated users - // ------------------------------------- - - if ( - user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) - ) { - // ------------------------------------- - // users that belong to any of the group listed in DELETE_GROUPS - // ------------------------------------- - - can(Action.SampleDeleteAny, SampleClass); - can(Action.SampleAttachmentDeleteAny, SampleClass); - } else { - // ------------------------------------- - // users that do not belong to any of the group listed in DELETE_GROUPS - // ------------------------------------- - - cannot(Action.SampleDeleteAny, SampleClass); - cannot(Action.SampleDeleteOwner, SampleClass); - } - - if ( - user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) - ) { - // ------------------------------------- - // users belonging to any of the group listed in ADMIN_GROUPS - // ------------------------------------- - - can(Action.SampleReadAny, SampleClass); - can(Action.SampleCreateAny, SampleClass); - can(Action.SampleUpdateAny, SampleClass); - can(Action.SampleAttachmentReadAny, SampleClass); - can(Action.SampleAttachmentCreateAny, SampleClass); - can(Action.SampleAttachmentUpdateAny, SampleClass); - can(Action.SampleAttachmentDeleteAny, SampleClass); - } else if ( - user.currentGroups.some((g) => - this.accessGroups?.samplePrivileged.includes(g), - ) - ) { - // ------------------------------------- - // users belonging to any of the group listed in SAMPLE_GROUPS - // - - can(Action.SampleCreateAny, SampleClass); - can(Action.SampleUpdateOwner, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.SampleReadManyAccess, SampleClass); - can(Action.SampleReadOneAccess, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.SampleReadOneAccess, SampleClass, { - accessGroups: { $in: user.currentGroups }, - }); - can(Action.SampleReadOneAccess, SampleClass, { - isPublished: true, - }); - can(Action.SampleAttachmentCreateAny, SampleClass); - can(Action.SampleAttachmentReadAccess, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.SampleAttachmentReadAccess, SampleClass, { - accessGroups: { $in: user.currentGroups }, - }); - can(Action.SampleAttachmentReadAccess, SampleClass, { - isPublished: true, - }); - can(Action.SampleAttachmentUpdateOwner, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.SampleAttachmentDeleteOwner, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - } else if ( - user.currentGroups.some((g) => this.accessGroups?.sample.includes(g)) || - this.accessGroups?.sample.includes("#all") - ) { - // ------------------------------------- - // users belonging to any of the group listed in SAMPLE_GROUPS - // - - can(Action.SampleCreateOwner, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.SampleUpdateOwner, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.SampleReadManyAccess, SampleClass); - can(Action.SampleReadOneAccess, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.SampleReadOneAccess, SampleClass, { - accessGroups: { $in: user.currentGroups }, - }); - can(Action.SampleReadOneAccess, SampleClass, { - isPublished: true, - }); - can(Action.SampleAttachmentCreateOwner, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.SampleAttachmentReadAccess, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.SampleAttachmentReadAccess, SampleClass, { - accessGroups: { $in: user.currentGroups }, - }); - can(Action.SampleAttachmentReadAccess, SampleClass, { - isPublished: true, - }); - can(Action.SampleAttachmentUpdateOwner, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.SampleAttachmentDeleteOwner, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - } else { - // ------------------------------------- - // users with no elevated permissions - // ------------------------------------- - - can(Action.SampleReadManyAccess, SampleClass); - can(Action.SampleReadOneAccess, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.SampleReadOneAccess, SampleClass, { - accessGroups: { $in: user.currentGroups }, - }); - can(Action.SampleReadOneAccess, SampleClass, { - isPublished: true, - }); - can(Action.SampleAttachmentReadAccess, SampleClass, { - ownerGroup: { $in: user.currentGroups }, - }); - can(Action.SampleAttachmentReadAccess, SampleClass, { - accessGroups: { $in: user.currentGroups }, - }); - can(Action.SampleAttachmentReadAccess, SampleClass, { - isPublished: true, - }); - } - } - - return build({ - detectSubjectType: (item) => - item.constructor as ExtractSubjectType, - }); - } - attachmentInstanceAccess(user: JWTUser) { const { can, build } = new AbilityBuilder( createMongoAbility, diff --git a/src/casl/casl.module.ts b/src/casl/casl.module.ts index 8233cc19a..91d0bf348 100644 --- a/src/casl/casl.module.ts +++ b/src/casl/casl.module.ts @@ -3,10 +3,11 @@ import { ConfigModule } from "@nestjs/config"; import { CaslAbilityFactory } from "./casl-ability.factory"; import { JobConfigModule } from "src/config/job-config/jobconfig.module"; import { DatasetAbility } from "./abilities/datasets.ability"; +import { SampleAbility } from "./abilities/samples.ability"; @Module({ imports: [JobConfigModule, ConfigModule], - providers: [CaslAbilityFactory, DatasetAbility], + providers: [CaslAbilityFactory, DatasetAbility, SampleAbility], exports: [CaslAbilityFactory], }) export class CaslModule {} diff --git a/src/datasets/datasets-access.service.ts b/src/datasets/datasets-access.service.ts index 90c32acdb..8527222b5 100644 --- a/src/datasets/datasets-access.service.ts +++ b/src/datasets/datasets-access.service.ts @@ -50,9 +50,10 @@ export class DatasetsAccessService { return { canViewAny, canView }; } case DatasetLookupKeysEnum.samples: { - const ability = this.caslAbilityFactory.samplesInstanceAccess(user); - const canViewAny = ability.can(Action.SampleReadAny, SampleClass); - const canView = ability.can(Action.SampleReadManyAccess, SampleClass); + const ability = this.caslAbilityFactory.sampleAccess(user); + const canViewAny = ability.can(Action.AccessAny, SampleClass); + const canView = ability.can(Action.SampleRead, SampleClass); + return { canViewAny, canView }; } case DatasetLookupKeysEnum.instruments: { diff --git a/src/samples/samples.controller.ts b/src/samples/samples.controller.ts index 71b37888e..39f9d7487 100644 --- a/src/samples/samples.controller.ts +++ b/src/samples/samples.controller.ts @@ -11,8 +11,6 @@ import { UseInterceptors, HttpCode, HttpStatus, - Logger, - InternalServerErrorException, ForbiddenException, BadRequestException, Req, @@ -116,62 +114,11 @@ export class SamplesController { const sampleInstance = this.generateSampleInstanceForPermissions(sample); const user: JWTUser = request.user as JWTUser; - const ability = this.caslAbilityFactory.samplesInstanceAccess(user); - - try { - switch (group) { - case Action.SampleCreate: - return ( - ability.can(Action.SampleCreateAny, SampleClass) || - ability.can(Action.SampleCreateOwner, sampleInstance) - ); - case Action.SampleRead: - return ( - ability.can(Action.SampleReadAny, SampleClass) || - ability.can(Action.SampleReadOneOwner, sampleInstance) || - ability.can(Action.SampleReadOneAccess, sampleInstance) || - ability.can(Action.SampleReadOnePublic, sampleInstance) - ); - case Action.SampleUpdate: - return ( - ability.can(Action.SampleUpdateAny, SampleClass) || - ability.can(Action.SampleUpdateOwner, sampleInstance) - ); - case Action.SampleDelete: - return ( - ability.can(Action.SampleDeleteAny, SampleClass) || - ability.can(Action.SampleDeleteOwner, sampleInstance) - ); - case Action.SampleAttachmentCreate: - return ( - ability.can(Action.SampleAttachmentCreateAny, SampleClass) || - ability.can(Action.SampleAttachmentCreateOwner, sampleInstance) - ); - case Action.SampleAttachmentRead: - return ( - ability.can(Action.SampleAttachmentReadAny, SampleClass) || - ability.can(Action.SampleAttachmentReadOwner, sampleInstance) || - ability.can(Action.SampleAttachmentReadPublic, sampleInstance) || - ability.can(Action.SampleAttachmentReadAccess, sampleInstance) - ); - case Action.SampleAttachmentUpdate: - return ( - ability.can(Action.SampleAttachmentUpdateAny, SampleClass) || - ability.can(Action.SampleAttachmentUpdateOwner, sampleInstance) - ); - case Action.SampleAttachmentDelete: - return ( - ability.can(Action.SampleAttachmentDeleteAny, SampleClass) || - ability.can(Action.SampleAttachmentDeleteOwner, sampleInstance) - ); - - default: - Logger.error("Permission for the action is not specified"); - return false; - } - } catch (error) { - throw new InternalServerErrorException(error); - } + const ability = this.caslAbilityFactory.sampleAccess(user); + + const canDoAction = ability.can(group, sampleInstance); + + return canDoAction; } private async checkPermissionsForSample( @@ -217,49 +164,42 @@ export class SamplesController { mergedFilters: IFilters, ): IFilters { const user: JWTUser = request.user as JWTUser; - //mergedFilters.where = mergedFilters.where || {}; - /* eslint-disable @typescript-eslint/no-explicit-any */ - const authorizationFilter: Record = { where: {} }; - if (user) { - const ability = this.caslAbilityFactory.samplesInstanceAccess(user); - const canViewAll = ability.can(Action.SampleReadAny, SampleClass); - if (!canViewAll) { - const canViewAccess = ability.can( - Action.SampleReadManyAccess, - SampleClass, - ); - const canViewOwner = ability.can( - Action.SampleReadManyOwner, - SampleClass, - ); - const canViewPublic = ability.can( - Action.SampleReadManyPublic, - SampleClass, - ); - - if (canViewAccess) { - authorizationFilter.where["$or"] = [ - { ownerGroup: { $in: user.currentGroups } }, - { accessGroups: { $in: user.currentGroups } }, - { isPublished: true }, + + const ability = this.caslAbilityFactory.sampleAccess(user); + const canViewAny = ability.can(Action.AccessAny, SampleClass); + const canView = ability.can(Action.SampleRead, SampleClass); + + if (!canViewAny) { + mergedFilters.where = mergedFilters.where ?? {}; + if (!user) { + if (mergedFilters.where["$and"]) { + mergedFilters.where["$and"].push({ + isPublished: true, + }); + } else { + mergedFilters.where["$and"] = [{ isPublished: true }]; + } + } else if (canView) { + if (mergedFilters.where["$and"]) { + mergedFilters.where["$and"].push({ + $or: [ + { ownerGroup: { $in: user.currentGroups } }, + { accessGroups: { $in: user.currentGroups } }, + { isPublished: true }, + ], + }); + } else { + mergedFilters.where["$and"] = [ + { + $or: [ + { ownerGroup: { $in: user.currentGroups } }, + { accessGroups: { $in: user.currentGroups } }, + { isPublished: true }, + ], + }, ]; - } else if (canViewOwner) { - authorizationFilter.where = { - ownerGroup: { $in: user.currentGroups }, - }; - } else if (canViewPublic) { - authorizationFilter.where.isPublished = true; } } - } else { - authorizationFilter.where.isPublished = true; - } - if (mergedFilters.where) { - mergedFilters.where = { - $and: [authorizationFilter.where, mergedFilters.where], - }; - } else { - mergedFilters.where = authorizationFilter.where; } return mergedFilters; @@ -383,28 +323,15 @@ export class SamplesController { const user: JWTUser = request.user as JWTUser; const fields: ISampleFields = JSON.parse(filters.fields ?? "{}"); - const ability = this.caslAbilityFactory.samplesInstanceAccess(user); - const canViewAll = ability.can(Action.SampleReadAny, SampleClass); + const ability = this.caslAbilityFactory.sampleAccess(user); + const canViewAny = ability.can(Action.AccessAny, SampleClass); + const canView = ability.can(Action.SampleRead, SampleClass); - if (!canViewAll) { - const canViewAccess = ability.can( - Action.SampleReadManyAccess, - SampleClass, - ); - const canViewOwner = ability.can(Action.SampleReadManyOwner, SampleClass); - const canViewPublic = ability.can( - Action.SampleReadManyPublic, - SampleClass, - ); - if (canViewAccess) { - fields.userGroups = fields.userGroups ?? []; - fields.userGroups.push(...user.currentGroups); - } else if (canViewOwner) { - fields.ownerGroup = fields.ownerGroup ?? []; - fields.ownerGroup.push(...user.currentGroups); - } else if (canViewPublic) { - fields.isPublished = true; - } + if (!user) { + fields.isPublished = true; + } else if (!canViewAny && canView && !fields.isPublished) { + fields.userGroups = fields.userGroups ?? []; + fields.userGroups.push(...user.currentGroups); } return this.samplesService.count({ fields }); @@ -456,34 +383,18 @@ export class SamplesController { const user: JWTUser = request.user as JWTUser; const fields: ISampleFields = JSON.parse(filters.fields ?? "{}"); const limits: ILimitsFilter = JSON.parse(filters.limits ?? "{}"); - if (user) { - const ability = this.caslAbilityFactory.samplesInstanceAccess(user); - const canViewAll = ability.can(Action.SampleReadAny, SampleClass); - - if (!canViewAll) { - const canViewAccess = ability.can( - Action.SampleReadManyAccess, - SampleClass, - ); - const canViewOwner = ability.can( - Action.SampleReadManyOwner, - SampleClass, - ); - const canViewPublic = ability.can( - Action.SampleReadManyPublic, - SampleClass, - ); - if (canViewAccess) { - fields.userGroups = fields.userGroups ?? []; - fields.userGroups.push(...user.currentGroups); - } else if (canViewOwner) { - fields.ownerGroup = fields.ownerGroup ?? []; - fields.ownerGroup.push(...user.currentGroups); - } else if (canViewPublic) { - fields.isPublished = true; - } - } + + const ability = this.caslAbilityFactory.sampleAccess(user); + const canViewAny = ability.can(Action.AccessAny, SampleClass); + const canView = ability.can(Action.SampleRead, SampleClass); + + if (!user) { + fields.isPublished = true; + } else if (!canViewAny && canView && !fields.isPublished) { + fields.userGroups = fields.userGroups ?? []; + fields.userGroups.push(...user.currentGroups); } + const parsedFilters: IFilters = { fields, limits, @@ -536,37 +447,20 @@ export class SamplesController { @Query() filters: { fields?: string; limits?: string }, ): Promise { const user: JWTUser = request.user as JWTUser; - const fields: ISampleFields = JSON.parse(filters.fields ?? "{}"); const limits: ILimitsFilter = JSON.parse(filters.limits ?? "{}"); - if (user) { - const ability = this.caslAbilityFactory.samplesInstanceAccess(user); - const canViewAll = ability.can(Action.SampleReadAny, SampleClass); - - if (!canViewAll) { - const canViewAccess = ability.can( - Action.SampleReadManyAccess, - SampleClass, - ); - const canViewOwner = ability.can( - Action.SampleReadManyOwner, - SampleClass, - ); - const canViewPublic = ability.can( - Action.SampleReadManyPublic, - SampleClass, - ); - if (canViewAccess) { - fields.userGroups = fields.userGroups ?? []; - fields.userGroups.push(...user.currentGroups); - } else if (canViewOwner) { - fields.ownerGroup = fields.ownerGroup ?? []; - fields.ownerGroup.push(...user.currentGroups); - } else if (canViewPublic) { - fields.isPublished = true; - } - } + + const ability = this.caslAbilityFactory.sampleAccess(user); + const canViewAny = ability.can(Action.AccessAny, SampleClass); + const canView = ability.can(Action.SampleRead, SampleClass); + + if (!user) { + fields.isPublished = true; + } else if (!canViewAny && canView && !fields.isPublished) { + fields.userGroups = fields.userGroups ?? []; + fields.userGroups.push(...user.currentGroups); } + const parsedFilters: IFilters = { fields, limits, @@ -972,7 +866,7 @@ export class SamplesController { // GET /samples/:id/datasets @UseGuards(AuthenticatedPoliciesGuard) @CheckPolicies("samples", (ability: AppAbility) => - ability.can(Action.SampleDatasetRead, SampleClass), + ability.can(Action.SampleRead, SampleClass), ) @Get("/:id/datasets") @ApiOperation({