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
72 changes: 72 additions & 0 deletions docs/developer-guide/authorization/published-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Published Data Authorization Model

This document describes the authorization model used for published data and associated endpoints.

## Actions

The following actions are defined for published data:

- `AccessAny`
- `Create`
- `Read`
- `Update`
- `Delete`

## Permissions

Permissions are granted cumulatively to users based on their group association. The following permission levels are granted to users:

### Unauthenticated

Unauthenticated users do not have casl permissions to interact with published data records. However, all read endpoints are unguarded with the `@AllowAny` decorator, effectively giving unauthenticated users unrestricted read access.
Additional access restrictions are enforced at the controller level in API v4 only:
Read access is limited to published data records with status `PUBLIC`, `REGISTERED` or `AMENDED`.

### Authenticated

An authenticated user has unrestricted create, read and update casl permissions for any published data record. Additional access restrictions are enforced at the controller level in API v4 only:
Read access is limited to published data records with status `PUBLIC`, `REGISTERED` or `AMENDED` and records with status `PRIVATE` if the user is listed under the record's `createdBy` field.
Update access is limited to `PRIVATE` records with the user listed under the record's `createdBy` field.

### 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 casl permissions. For API v4, they are only allowed to update records with `PRIVATE` or `PUBLIC` status, regardless of ownership.

### 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 published data records 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.

### API v3

| Operation | Unauthenticated | Authenticated | `ADMIN_GROUPS` | `DELETE_GROUPS` |
| - | - | - | - | - |
| `Create` | - | any | any | - |
| `Read` | any | any | any | any |
| `Update` | - | any | any | - |
| `Delete` | - | - | - | any |

### API v4

| Operation | Unauthenticated | Authenticated | `ADMIN_GROUPS` | `DELETE_GROUPS` |
| - | - | - | - | - |
| `Create` | - | any | any | - |
| `Read` | public | public/owner | any | public/owner |
| `Update` | - | owner | unregistered | - |
| `Delete` | - | - | - | any |

Legend:
- owner: publishedData's `status` field must be `PRIVATE` and the `createdBy` field must match the user's username
- unregistered: publishedData's `status` field must one of `PRIVATE`, `PUBLIC`
- public: publishedData's `status` field must be one of `PUBLIC`, `REGISTERED`, `AMENDED`
- any: unrestricted access

## Implementation Notes

The definition is implemented in the casl module under `/src/casl/abilities/published-data.ability.ts` and accessible elsewhere via `CaslAbilityFactory.publishedDataAccess`. 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 `PublishedDataAbility` module in `/src/casl/abilities/published-data.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 published data 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.
73 changes: 73 additions & 0 deletions src/casl/abilities/published-data.ability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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 { PublishedData } from "src/published-data/schemas/published-data.schema";

@Injectable()
export class PublishedDataAbility {
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>,
);

/**
* Unauthenticated user
*/
if (!user) {
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}

/**
* Authenticated user
*/
can(Action.Create, PublishedData);
can(Action.Read, PublishedData);
can(Action.Update, PublishedData);

if (user.currentGroups.some((g) => this.accessGroups?.admin?.includes(g))) {
/**
* User belonging to ADMIN_GROUPS
*/
can(Action.AccessAny, PublishedData);
}

if (
user.currentGroups.some((g) => this.accessGroups?.delete?.includes(g))
) {
/**
* User belonging to DELETE_GROUPS
*/
can(Action.Delete, PublishedData);
}

return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}
}
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 { PublishedDataAbility } from "./abilities/published-data.ability";

describe("CaslAbilityFactory", () => {
it("should be defined", () => {
Expand All @@ -15,6 +16,7 @@ describe("CaslAbilityFactory", () => {
configService,
new JobConfigService({}, {}, configService),
new DatasetAbility(configService),
new PublishedDataAbility(configService),
),
).toBeDefined();
});
Expand All @@ -38,6 +40,7 @@ describe("CaslAbilityFactory", () => {
configService,
{ allJobConfigs: {} } as unknown as JobConfigService,
new DatasetAbility(configService),
new PublishedDataAbility(configService),
);
};

Expand Down
56 changes: 7 additions & 49 deletions src/casl/casl-ability.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ import { Opensearch } from "src/opensearch/opensearch.subject";
import { OrigDatablock } from "src/origdatablocks/schemas/origdatablock.schema";
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 { PublishedDataAbility } from "./abilities/published-data.ability";

export type AppAbility = MongoAbility<PossibleAbilities, Conditions>;

Expand All @@ -38,6 +38,7 @@ export class CaslAbilityFactory {
private configService: ConfigService,
private jobConfigService: JobConfigService,
private datasetAbility: DatasetAbility,
private publishedDataAbility: PublishedDataAbility,
) {
this.accessGroups =
this.configService.get<AccessGroupsType>("accessGroups");
Expand All @@ -59,7 +60,7 @@ export class CaslAbilityFactory {
origdatablocks: this.origDatablockEndpointAccess,
policies: this.policyEndpointAccess,
proposals: this.proposalsEndpointAccess,
publisheddata: this.publishedDataEndpointAccess,
publisheddata: this.publishedDataAccess,
runtimeconfig: this.runtimeConfigEndpointAccess,
samples: this.samplesEndpointAccess,
users: this.userEndpointAccess,
Expand All @@ -79,6 +80,10 @@ export class CaslAbilityFactory {
return this.datasetAbility.buildAbility(user);
}

publishedDataAccess(user: JWTUser | null) {
return this.publishedDataAbility.buildAbility(user);
}

opensearchEndpointAccess(user: JWTUser) {
const { can, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
Expand Down Expand Up @@ -843,31 +848,6 @@ export class CaslAbilityFactory {
});
}

publishedDataEndpointAccess(user: JWTUser) {
const { can, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
);
if (user) {
can(Action.Read, PublishedData);
can(Action.Update, PublishedData);
can(Action.Create, PublishedData);
}

if (
user &&
user.currentGroups.some((g) => this.accessGroups?.delete.includes(g))
) {
/*
/ user that belongs to any of the group listed in DELETE_GROUPS
*/
can(Action.Delete, PublishedData);
}
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}

samplesEndpointAccess(user: JWTUser) {
const { can, cannot, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
Expand Down Expand Up @@ -1765,28 +1745,6 @@ export class CaslAbilityFactory {
});
}

publishedDataInstanceAccess(user: JWTUser) {
const { can, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
);

if (
user &&
user.currentGroups.some((g) => this.accessGroups?.admin.includes(g))
) {
// -------------------------------------
// users belonging to any of the group listed in ADMIN_GROUPS
// -------------------------------------

can(Action.AccessAny, PublishedData);
}

return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}

datablockInstanceAccess(user: JWTUser) {
const { can, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
Expand Down
3 changes: 2 additions & 1 deletion src/casl/casl.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { PublishedDataAbility } from "./abilities/published-data.ability";

@Module({
imports: [JobConfigModule, ConfigModule],
providers: [CaslAbilityFactory, DatasetAbility],
providers: [CaslAbilityFactory, DatasetAbility, PublishedDataAbility],
exports: [CaslAbilityFactory],
})
export class CaslModule {}
14 changes: 7 additions & 7 deletions src/published-data/published-data.v4.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export class PublishedDataV4Controller {
publishedDataFilters.limits = publishedDataLimits;
}

const ability = this.caslAbilityFactory.publishedDataInstanceAccess(
const ability = this.caslAbilityFactory.publishedDataAccess(
request.user as JWTUser,
);

Expand Down Expand Up @@ -189,7 +189,7 @@ export class PublishedDataV4Controller {
) {
const jsonFilters: IPublishedDataFilters = filter?.filter ?? {};

const ability = this.caslAbilityFactory.publishedDataInstanceAccess(
const ability = this.caslAbilityFactory.publishedDataAccess(
request.user as JWTUser,
);

Expand Down Expand Up @@ -287,7 +287,7 @@ export class PublishedDataV4Controller {
const filter: FilterQuery<PublishedData> = {
doi,
};
const ability = this.caslAbilityFactory.publishedDataInstanceAccess(
const ability = this.caslAbilityFactory.publishedDataAccess(
request.user as JWTUser,
);
if (ability.cannot(Action.AccessAny, PublishedData)) {
Expand Down Expand Up @@ -365,7 +365,7 @@ export class PublishedDataV4Controller {
throw new NotFoundException(`Published data with id ${id} not found.`);
}

const ability = this.caslAbilityFactory.publishedDataInstanceAccess(
const ability = this.caslAbilityFactory.publishedDataAccess(
request.user as JWTUser,
);

Expand Down Expand Up @@ -464,7 +464,7 @@ export class PublishedDataV4Controller {
@Req() request: Request,
@Param("id") id: string,
): Promise<PublishedData | null> {
const ability = this.caslAbilityFactory.publishedDataInstanceAccess(
const ability = this.caslAbilityFactory.publishedDataAccess(
request.user as JWTUser,
);

Expand Down Expand Up @@ -513,7 +513,7 @@ export class PublishedDataV4Controller {
throw new NotFoundException(`Published data with id ${id} not found.`);
}

const ability = this.caslAbilityFactory.publishedDataInstanceAccess(
const ability = this.caslAbilityFactory.publishedDataAccess(
request.user as JWTUser,
);

Expand Down Expand Up @@ -679,7 +679,7 @@ export class PublishedDataV4Controller {
throw new NotFoundException(`Published data with id ${id} not found.`);
}

const ability = this.caslAbilityFactory.publishedDataInstanceAccess(
const ability = this.caslAbilityFactory.publishedDataAccess(
request.user as JWTUser,
);

Expand Down
Loading