mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Prevent from deleting the last admin user (#7008)
* Prevent from deleting the last admin user Fixes #6990 * Add missing return types
This commit is contained in:
@@ -6,7 +6,7 @@ import { clone, cloneDeep, isObject, isPlainObject, omit } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import getDatabase from '../database';
|
||||
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
|
||||
import { AbstractServiceOptions, Accountability, Item, PrimaryKey, Query, SchemaOverview } from '../types';
|
||||
import { AbstractServiceOptions, Accountability, Item, PrimaryKey, Query, SchemaOverview, Alterations } from '../types';
|
||||
import { toArray } from '../utils/to-array';
|
||||
import { ItemsService } from './items';
|
||||
|
||||
@@ -21,16 +21,6 @@ type Transformers = {
|
||||
}) => Promise<any>;
|
||||
};
|
||||
|
||||
type Alterations = {
|
||||
create: {
|
||||
[key: string]: any;
|
||||
}[];
|
||||
update: {
|
||||
[key: string]: any;
|
||||
}[];
|
||||
delete: (number | string)[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Process a given payload for a collection to ensure the special fields (hash, uuid, date etc) are
|
||||
* handled correctly.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UnprocessableEntityException } from '../exceptions';
|
||||
import { AbstractServiceOptions, PrimaryKey } from '../types';
|
||||
import { ItemsService } from './items';
|
||||
import { ForbiddenException, UnprocessableEntityException } from '../exceptions';
|
||||
import { AbstractServiceOptions, PrimaryKey, Query, Alterations, Item } from '../types';
|
||||
import { ItemsService, MutationOptions } from './items';
|
||||
import { PermissionsService } from './permissions';
|
||||
import { PresetsService } from './presets';
|
||||
import { UsersService } from './users';
|
||||
@@ -10,21 +10,89 @@ export class RolesService extends ItemsService {
|
||||
super('directus_roles', options);
|
||||
}
|
||||
|
||||
private async checkForOtherAdminRoles(excludeKeys: PrimaryKey[]): Promise<void> {
|
||||
// Make sure there's at least one admin role left after this deletion is done
|
||||
const otherAdminRoles = await this.knex
|
||||
.count('*', { as: 'count' })
|
||||
.from('directus_roles')
|
||||
.whereNotIn('id', excludeKeys)
|
||||
.andWhere({ admin_access: true })
|
||||
.first();
|
||||
|
||||
const otherAdminRolesCount = +(otherAdminRoles?.count || 0);
|
||||
if (otherAdminRolesCount === 0) throw new UnprocessableEntityException(`You can't delete the last admin role.`);
|
||||
}
|
||||
|
||||
private async checkForOtherAdminUsers(key: PrimaryKey, users: Alterations | Item[]): Promise<void> {
|
||||
const role = await this.knex.select('admin_access').from('directus_roles').where('id', '=', key).first();
|
||||
|
||||
if (!role) throw new ForbiddenException();
|
||||
|
||||
// The users that will now be in this new non-admin role
|
||||
let userKeys: PrimaryKey[] = [];
|
||||
|
||||
if (Array.isArray(users)) {
|
||||
userKeys = users.map((user) => (typeof user === 'string' ? user : user.id)).filter((id) => id);
|
||||
} else {
|
||||
userKeys = users.update.map((user) => user.id).filter((id) => id);
|
||||
}
|
||||
|
||||
const usersThatWereInRoleBefore = (await this.knex.select('id').from('directus_users').where('role', '=', key)).map(
|
||||
(user) => user.id
|
||||
);
|
||||
const usersThatAreRemoved = usersThatWereInRoleBefore.filter((id) => userKeys.includes(id) === false);
|
||||
|
||||
const usersThatAreAdded = Array.isArray(users) ? users : users.create;
|
||||
|
||||
// If the role the users are moved to is an admin-role, and there's at least 1 (new) admin
|
||||
// user, we don't have to check for other admin
|
||||
// users
|
||||
if ((role.admin_access === true || role.admin_access === 1) && usersThatAreAdded.length > 0) return;
|
||||
|
||||
const otherAdminUsers = await this.knex
|
||||
.count('*', { as: 'count' })
|
||||
.from('directus_users')
|
||||
.whereNotIn('directus_users.id', [...userKeys, ...usersThatAreRemoved])
|
||||
.andWhere({ 'directus_roles.admin_access': true })
|
||||
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
||||
.first();
|
||||
|
||||
const otherAdminUsersCount = +(otherAdminUsers?.count || 0);
|
||||
|
||||
if (otherAdminUsersCount === 0) {
|
||||
throw new UnprocessableEntityException(`You can't remove the last admin user from the admin role.`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async updateOne(key: PrimaryKey, data: Record<string, any>, opts?: MutationOptions) {
|
||||
if ('admin_access' in data && data.admin_access === false) {
|
||||
await this.checkForOtherAdminRoles([key]);
|
||||
}
|
||||
|
||||
if ('users' in data) {
|
||||
await this.checkForOtherAdminUsers(key, data.users);
|
||||
}
|
||||
|
||||
return super.updateOne(key, data, opts);
|
||||
}
|
||||
|
||||
async updateMany(keys: PrimaryKey[], data: Record<string, any>, opts?: MutationOptions) {
|
||||
if ('admin_access' in data && data.admin_access === false) {
|
||||
await this.checkForOtherAdminRoles(keys);
|
||||
}
|
||||
|
||||
return super.updateMany(keys, data, opts);
|
||||
}
|
||||
|
||||
async deleteOne(key: PrimaryKey): Promise<PrimaryKey> {
|
||||
await this.deleteMany([key]);
|
||||
return key;
|
||||
}
|
||||
|
||||
async deleteMany(keys: PrimaryKey[]): Promise<PrimaryKey[]> {
|
||||
// Make sure there's at least one admin role left after this deletion is done
|
||||
const otherAdminRoles = await this.knex
|
||||
.count('*', { as: 'count' })
|
||||
.from('directus_roles')
|
||||
.whereNotIn('id', keys)
|
||||
.andWhere({ admin_access: true })
|
||||
.first();
|
||||
const otherAdminRolesCount = +(otherAdminRoles?.count || 0);
|
||||
if (otherAdminRolesCount === 0) throw new UnprocessableEntityException(`You can't delete the last admin role.`);
|
||||
await this.checkForOtherAdminRoles(keys);
|
||||
|
||||
await this.knex.transaction(async (trx) => {
|
||||
const itemsService = new ItemsService('directus_roles', {
|
||||
@@ -77,6 +145,10 @@ export class RolesService extends ItemsService {
|
||||
return keys;
|
||||
}
|
||||
|
||||
deleteByQuery(query: Query, opts?: MutationOptions): Promise<PrimaryKey[]> {
|
||||
return super.deleteByQuery(query, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `deleteOne` or `deleteMany` instead
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import argon2 from 'argon2';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Knex } from 'knex';
|
||||
import { clone } from 'lodash';
|
||||
import { clone, cloneDeep } from 'lodash';
|
||||
import getDatabase from '../database';
|
||||
import env from '../env';
|
||||
import {
|
||||
@@ -99,6 +99,23 @@ export class UsersService extends ItemsService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async checkRemainingAdminExistence(excludeKeys: PrimaryKey[]) {
|
||||
// Make sure there's at least one admin user left after this deletion is done
|
||||
const otherAdminUsers = await this.knex
|
||||
.count('*', { as: 'count' })
|
||||
.from('directus_users')
|
||||
.whereNotIn('directus_users.id', excludeKeys)
|
||||
.andWhere({ 'directus_roles.admin_access': true })
|
||||
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
||||
.first();
|
||||
|
||||
const otherAdminUsersCount = +(otherAdminUsers?.count || 0);
|
||||
|
||||
if (otherAdminUsersCount === 0) {
|
||||
throw new UnprocessableEntityException(`You can't remove the last admin user from the role.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
@@ -129,6 +146,14 @@ export class UsersService extends ItemsService {
|
||||
}
|
||||
|
||||
async updateOne(key: PrimaryKey, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
|
||||
if (data.role) {
|
||||
const newRole = await this.knex.select('admin_access').from('directus_roles').where('id', data.role).first();
|
||||
|
||||
if (newRole && !newRole.admin_access) {
|
||||
await this.checkRemainingAdminExistence([key]);
|
||||
}
|
||||
}
|
||||
|
||||
const email = data.email?.toLowerCase();
|
||||
|
||||
if (email) {
|
||||
@@ -147,6 +172,14 @@ export class UsersService extends ItemsService {
|
||||
}
|
||||
|
||||
async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> {
|
||||
if (data.role) {
|
||||
const newRole = await this.knex.select('admin_access').from('directus_roles').where('id', data.role).first();
|
||||
|
||||
if (newRole && !newRole.admin_access) {
|
||||
await this.checkRemainingAdminExistence(keys);
|
||||
}
|
||||
}
|
||||
|
||||
const email = data.email?.toLowerCase();
|
||||
|
||||
if (email) {
|
||||
@@ -165,6 +198,29 @@ export class UsersService extends ItemsService {
|
||||
}
|
||||
|
||||
async updateByQuery(query: Query, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> {
|
||||
if (data.role) {
|
||||
const newRole = await this.knex.select('admin_access').from('directus_roles').where('id', data.role).first();
|
||||
|
||||
if (newRole && !newRole.admin_access) {
|
||||
// This is duplicated a touch, but we need to know the keys first
|
||||
// Not authenticated:
|
||||
const itemsService = new ItemsService('directus_users', {
|
||||
knex: this.knex,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const readQuery = cloneDeep(query);
|
||||
readQuery.fields = ['id'];
|
||||
|
||||
// We read the IDs of the items based on the query, and then run `updateMany`. `updateMany` does it's own
|
||||
// permissions check for the keys, so we don't have to make this an authenticated read
|
||||
const itemsToUpdate = await itemsService.readByQuery(readQuery);
|
||||
const keys = itemsToUpdate.map((item) => item.id);
|
||||
|
||||
await this.checkRemainingAdminExistence(keys);
|
||||
}
|
||||
}
|
||||
|
||||
const email = data.email?.toLowerCase();
|
||||
|
||||
if (email) {
|
||||
@@ -183,20 +239,7 @@ export class UsersService extends ItemsService {
|
||||
}
|
||||
|
||||
async deleteOne(key: PrimaryKey, opts?: MutationOptions): Promise<PrimaryKey> {
|
||||
// Make sure there's at least one admin user left after this deletion is done
|
||||
const otherAdminUsers = await this.knex
|
||||
.count('*', { as: 'count' })
|
||||
.from('directus_users')
|
||||
.whereNot('directus_users.id', key)
|
||||
.andWhere({ 'directus_roles.admin_access': true })
|
||||
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
||||
.first();
|
||||
|
||||
const otherAdminUsersCount = +(otherAdminUsers?.count || 0);
|
||||
|
||||
if (otherAdminUsersCount === 0) {
|
||||
throw new UnprocessableEntityException(`You can't delete the last admin user.`);
|
||||
}
|
||||
await this.checkRemainingAdminExistence([key]);
|
||||
|
||||
await this.service.deleteOne(key, opts);
|
||||
|
||||
@@ -204,26 +247,32 @@ export class UsersService extends ItemsService {
|
||||
}
|
||||
|
||||
async deleteMany(keys: PrimaryKey[], opts?: MutationOptions): Promise<PrimaryKey[]> {
|
||||
// Make sure there's at least one admin user left after this deletion is done
|
||||
const otherAdminUsers = await this.knex
|
||||
.count('*', { as: 'count' })
|
||||
.from('directus_users')
|
||||
.whereNotIn('directus_users.id', keys)
|
||||
.andWhere({ 'directus_roles.admin_access': true })
|
||||
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
||||
.first();
|
||||
|
||||
const otherAdminUsersCount = +(otherAdminUsers?.count || 0);
|
||||
|
||||
if (otherAdminUsersCount === 0) {
|
||||
throw new UnprocessableEntityException(`You can't delete the last admin user.`);
|
||||
}
|
||||
await this.checkRemainingAdminExistence(keys);
|
||||
|
||||
await this.service.deleteMany(keys, opts);
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
async deleteByQuery(query: Query, opts?: MutationOptions): Promise<PrimaryKey[]> {
|
||||
const primaryKeyField = this.schema.collections[this.collection].primary;
|
||||
const readQuery = cloneDeep(query);
|
||||
readQuery.fields = [primaryKeyField];
|
||||
|
||||
// Not authenticated:
|
||||
const itemsService = new ItemsService(this.collection, {
|
||||
knex: this.knex,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const itemsToDelete = await itemsService.readByQuery(readQuery);
|
||||
const keys: PrimaryKey[] = itemsToDelete.map((item: Item) => item[primaryKeyField]);
|
||||
|
||||
if (keys.length === 0) return [];
|
||||
|
||||
return await this.deleteMany(keys, opts);
|
||||
}
|
||||
|
||||
async inviteUser(email: string | string[], role: string, url: string | null, subject?: string | null): Promise<void> {
|
||||
const emails = toArray(email);
|
||||
|
||||
|
||||
@@ -6,3 +6,13 @@
|
||||
export type Item = Record<string, any>;
|
||||
|
||||
export type PrimaryKey = string | number;
|
||||
|
||||
export type Alterations = {
|
||||
create: {
|
||||
[key: string]: any;
|
||||
}[];
|
||||
update: {
|
||||
[key: string]: any;
|
||||
}[];
|
||||
delete: (number | string)[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user