Skip to content
Merged
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
53 changes: 53 additions & 0 deletions docs/developer-guide/authorization/users.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Users Authorization Model

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

## Actions

The following actions are defined for users:

- `AccessAny`
- `UserCreate`
- `UserRead`
- `UserUpdate`
- `UserDelete`
- `UserCreateJwt`

## 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 access to user records.

### Authenticated

An authenticated user may create, read, update and delete their own user record, which matches the request user's `_id` to the user record's `_id`.

### ADMIN_GROUPS

If a user is part of a group listed in configuration as part of `ADMIN_GROUPS`, they have unrestricted create, read, update and delete access to all user records. They are also permitted to create JWT tokens for any user.

## 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 | `ADMIN_GROUPS` |
| - | - | - | - |
| `UserCreate` | - | own | any |
| `UserRead` | - | own | any |
| `UserUpdate` | - | own | any |
| `UserDelete` | - | own | any |
| `UserCreateJwt` | - | - | any |

Legend:
- own: user's `_id` must match the user record's `_id`
- any: unrestricted access

## Implementation Notes

The definition is implemented in the casl module under `/src/casl/abilities/users.ability.ts` and accessible elsewhere via `CaslAbilityFactory.userAccess`. 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 `UserAbility` module in `/src/casl/abilities/users.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 users 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/users.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 { User } from "src/users/schemas/user.schema";

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

const matchUid = { _id: user._id };

/**
* Authenticated user
*/
can(Action.UserCreate, User, matchUid);
can(Action.UserRead, User, matchUid);
can(Action.UserUpdate, User, matchUid);
can(Action.UserDelete, User, matchUid);

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

can(Action.UserCreate, User);
can(Action.UserRead, User);
can(Action.UserUpdate, User);
can(Action.UserDelete, User);
can(Action.UserCreateJwt, User);
}

return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}
}
21 changes: 7 additions & 14 deletions src/casl/action.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ export enum Action {
RuntimeConfigRead = "runtimeconfig_read",
RuntimeConfigUpdate = "runtimeconfig_update",

// Users
UserCreate = "user_create",
UserRead = "user_read",
UserUpdate = "user_update",
UserDelete = "user_delete",
UserCreateJwt = "user_create_jwt",

// -------------------------------------
// Samples
// -------------------------------------
Expand Down Expand Up @@ -131,18 +138,4 @@ export enum Action {
SampleAttachmentUpdateAny = "sample_attachment_update_any",
SampleAttachmentDeleteOwner = "sample_attachment_delete_owner",
SampleAttachmentDeleteAny = "sample_attachment_delete_any",

// -------------
// Users actions
UserReadOwn = "user_read_own",
UserReadAny = "user_read_any",
UserCreateOwn = "user_create_own",
UserCreateAny = "user_create_any",
UserUpdateOwn = "user_update_own",
UserUpdateAny = "user_update_any",
UserDeleteOwn = "user_delete_own",
UserDeleteAny = "user_delete_any",
UserCreateJwt = "user_create_jwt",
UserListAll = "user_list_all",
UserListOwn = "user_list_own",
}
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 @@ -19,6 +19,7 @@ import { PolicyAbility } from "./abilities/policies.ability";
import { ProposalAbility } from "./abilities/proposals.ability";
import { PublishedDataAbility } from "./abilities/published-data.ability";
import { RuntimeConfigAbility } from "./abilities/runtime-config.ability";
import { UserAbility } from "./abilities/users.ability";

describe("CaslAbilityFactory", () => {
it("should be defined", () => {
Expand All @@ -44,6 +45,7 @@ describe("CaslAbilityFactory", () => {
new ProposalAbility(configService),
new PublishedDataAbility(configService),
new RuntimeConfigAbility(configService),
new UserAbility(configService),
),
).toBeDefined();
});
Expand Down Expand Up @@ -81,6 +83,7 @@ describe("CaslAbilityFactory", () => {
new ProposalAbility(configService),
new PublishedDataAbility(configService),
new RuntimeConfigAbility(configService),
new UserAbility(configService),
);
};

Expand Down
71 changes: 7 additions & 64 deletions src/casl/casl-ability.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { ConfigService } from "@nestjs/config";
import { JWTUser } from "src/auth/interfaces/jwt-user.interface";
import { AccessGroupsType } from "src/config/configuration";
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 { AttachmentAbility } from "./abilities/attachments.ability";
Expand All @@ -26,6 +25,7 @@ import { PolicyAbility } from "./abilities/policies.ability";
import { ProposalAbility } from "./abilities/proposals.ability";
import { PublishedDataAbility } from "./abilities/published-data.ability";
import { RuntimeConfigAbility } from "./abilities/runtime-config.ability";
import { UserAbility } from "./abilities/users.ability";

export type AppAbility = MongoAbility<PossibleAbilities, Conditions>;

Expand All @@ -47,6 +47,7 @@ export class CaslAbilityFactory {
private proposalAbility: ProposalAbility,
private publishedDataAbility: PublishedDataAbility,
private runtimeConfigAbility: RuntimeConfigAbility,
private userAbility: UserAbility,
) {
this.accessGroups =
this.configService.get<AccessGroupsType>("accessGroups");
Expand All @@ -71,7 +72,7 @@ export class CaslAbilityFactory {
publisheddata: this.publishedDataAccess,
runtimeconfig: this.runtimeConfigAccess,
samples: this.samplesEndpointAccess,
users: this.userEndpointAccess,
users: this.userAccess,
};

endpointAccess(endpoint: string, user: JWTUser) {
Expand Down Expand Up @@ -140,6 +141,10 @@ export class CaslAbilityFactory {
return this.runtimeConfigAbility.buildAbility(user);
}

userAccess(user: JWTUser | null) {
return this.userAbility.buildAbility(user);
}

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

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

if (!user) {
/**
/* unauthenticated users
**/

cannot(Action.UserReadOwn, User);
cannot(Action.UserCreateOwn, User);
cannot(Action.UserUpdateOwn, User);
cannot(Action.UserDeleteOwn, User);
cannot(Action.UserReadAny, User);
cannot(Action.UserCreateAny, User);
cannot(Action.UserUpdateAny, User);
cannot(Action.UserDeleteAny, User);
} else {
if (
user.currentGroups.some((g) => this.accessGroups?.admin.includes(g))
) {
/*
/ user that belongs to any of the group listed in ADMIN_GROUPS
*/

// can(Action.ReadAll, UserIdentity); NOT used?

// -------------------------------------
// user endpoint, including useridentity
can(Action.UserReadAny, User);
can(Action.UserReadOwn, User);
can(Action.UserCreateAny, User);
can(Action.UserUpdateAny, User);
can(Action.UserDeleteAny, User);
can(Action.UserCreateJwt, User);
can(Action.UserListAll, User);

// -------------------------------------
} else if (user) {
/**
/* authenticated users
**/
cannot(Action.UserReadAny, User);
cannot(Action.UserCreateAny, User);
cannot(Action.UserUpdateAny, User);
cannot(Action.UserDeleteAny, User);
cannot(Action.UserCreateJwt, User);
cannot(Action.UserListAll, User);
}
can(Action.UserReadOwn, User, { _id: user._id });
can(Action.UserCreateOwn, User, { _id: user._id });
can(Action.UserUpdateOwn, User, { _id: user._id });
can(Action.UserDeleteOwn, User, { _id: user._id });
can(Action.UserListOwn, User);
}
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}

samplesInstanceAccess(user: JWTUser) {
const { can, cannot, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
Expand Down
2 changes: 2 additions & 0 deletions src/casl/casl.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { PolicyAbility } from "./abilities/policies.ability";
import { ProposalAbility } from "./abilities/proposals.ability";
import { PublishedDataAbility } from "./abilities/published-data.ability";
import { RuntimeConfigAbility } from "./abilities/runtime-config.ability";
import { UserAbility } from "./abilities/users.ability";

@Module({
imports: [JobConfigModule, ConfigModule],
Expand All @@ -35,6 +36,7 @@ import { RuntimeConfigAbility } from "./abilities/runtime-config.ability";
ProposalAbility,
PublishedDataAbility,
RuntimeConfigAbility,
UserAbility,
],
exports: [CaslAbilityFactory],
})
Expand Down
20 changes: 8 additions & 12 deletions src/users/user-identities.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,8 @@ export class UserIdentitiesController {
) {}

@UseGuards(AuthenticatedPoliciesGuard)
@CheckPolicies(
"users",
(ability: AppAbility) =>
ability.can(Action.UserReadOwn, User) ||
ability.can(Action.UserReadAny, User),
@CheckPolicies("users", (ability: AppAbility) =>
ability.can(Action.UserRead, User),
)
@Get("/findOne")
@ApiQuery({
Expand Down Expand Up @@ -83,12 +80,11 @@ export class UserIdentitiesController {
}

const authenticatedUser: JWTUser = request.user as JWTUser;
const ability =
await this.caslAbilityFactory.userEndpointAccess(authenticatedUser);
const ability = this.caslAbilityFactory.userAccess(authenticatedUser);

if (
!ability.can(Action.UserReadAny, User) &&
ability.can(Action.UserReadOwn, User)
!ability.can(Action.AccessAny, User) &&
ability.can(Action.UserRead, User)
) {
// this user can only see his/her user identity
filter = { userId: authenticatedUser._id, ...filter };
Expand All @@ -106,8 +102,8 @@ export class UserIdentitiesController {
user._id = identity.userId;
user.id = identity.userId;
if (
!ability.can(Action.UserReadOwn, user) &&
!ability.can(Action.UserReadAny, User)
!ability.can(Action.UserRead, user) &&
!ability.can(Action.AccessAny, User)
) {
throw new ForbiddenException("Access Forbidden or Unauthorized");
}
Expand All @@ -117,7 +113,7 @@ export class UserIdentitiesController {

@UseGuards(AuthenticatedPoliciesGuard)
@CheckPolicies("users", (ability: AppAbility) =>
ability.can(Action.UserReadAny, User),
ability.can(Action.AccessAny, User),
)
@Get("/isValidEmail")
@ApiOperation({
Expand Down
Loading
Loading