Skip to content
Open
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>,
});
}
}
20 changes: 7 additions & 13 deletions src/casl/action.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export enum Action {
// Currently used by addAccessBasedFilters for admin/special group users
AccessAny = "access_any",

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

// ---------------
// Datasets
DatasetCreate = "dataset_create",
Expand Down Expand Up @@ -173,19 +180,6 @@ export enum Action {
JobUpdateAny = "job_update_any",
//JobDeleteAny = "job_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",
// Instrument actions
InstrumentRead = "instrument_read",
InstrumentUpdate = "instrument_update",
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 { UserAbility } from "./abilities/users.ability";

describe("CaslAbilityFactory", () => {
it("should be defined", () => {
Expand All @@ -15,6 +16,7 @@ describe("CaslAbilityFactory", () => {
configService,
new JobConfigService({}, {}, configService),
new DatasetAbility(configService),
new UserAbility(configService),
),
).toBeDefined();
});
Expand All @@ -38,6 +40,7 @@ describe("CaslAbilityFactory", () => {
configService,
{ allJobConfigs: {} } as unknown as JobConfigService,
new DatasetAbility(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 @@ -25,10 +25,10 @@ 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 { UserAbility } from "./abilities/users.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 userAbility: UserAbility,
) {
this.accessGroups =
this.configService.get<AccessGroupsType>("accessGroups");
Expand All @@ -62,7 +63,7 @@ export class CaslAbilityFactory {
publisheddata: this.publishedDataEndpointAccess,
runtimeconfig: this.runtimeConfigEndpointAccess,
samples: this.samplesEndpointAccess,
users: this.userEndpointAccess,
users: this.userAccess,
};

endpointAccess(endpoint: string, user: JWTUser) {
Expand All @@ -79,6 +80,10 @@ export class CaslAbilityFactory {
return this.datasetAbility.buildAbility(user);
}

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

opensearchEndpointAccess(user: JWTUser) {
const { can, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
Expand Down Expand Up @@ -982,68 +987,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>,
});
}

origDatablockInstanceAccess(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 { UserAbility } from "./abilities/users.ability";

@Module({
imports: [JobConfigModule, ConfigModule],
providers: [CaslAbilityFactory, DatasetAbility],
providers: [CaslAbilityFactory, DatasetAbility, UserAbility],
exports: [CaslAbilityFactory],
})
export class CaslModule {}
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