Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions docs/developer-guide/authorization/samples.md
Original file line number Diff line number Diff line change
@@ -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.
125 changes: 125 additions & 0 deletions src/casl/abilities/samples.ability.ts
Original file line number Diff line number Diff line change
@@ -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<AccessGroupsType>("accessGroups") ??
({} as AccessGroupsType);
}

buildAbility(
user: JWTUser | null,
): MongoAbility<PossibleAbilities, Conditions> {
const { can, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
);
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<Subjects>,
});
}

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<Subjects>,
});
}
}
51 changes: 11 additions & 40 deletions src/casl/action.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
// --------------
Expand Down
3 changes: 3 additions & 0 deletions src/casl/casl-ability.factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -15,6 +16,7 @@ describe("CaslAbilityFactory", () => {
configService,
new JobConfigService({}, {}, configService),
new DatasetAbility(configService),
new SampleAbility(configService),
),
).toBeDefined();
});
Expand All @@ -38,6 +40,7 @@ describe("CaslAbilityFactory", () => {
configService,
{ allJobConfigs: {} } as unknown as JobConfigService,
new DatasetAbility(configService),
new SampleAbility(configService),
);
};

Expand Down
Loading
Loading