1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: openapi schema for user admin (#4146)

This commit is contained in:
Mateusz Kwasniewski 2023-07-06 08:24:46 +02:00 committed by GitHub
parent 5dc560f911
commit 79b34121a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1253 additions and 47 deletions

View File

@ -16,7 +16,7 @@ class BadDataError extends UnleashError {
errors?: [ValidationErrorDescription, ...ValidationErrorDescription[]],
) {
const topLevelMessage =
'Request validation failed: your request body contains invalid data' +
'Request validation failed: your request body or params contain invalid data' +
(errors
? '. Refer to the `details` list for more information.'
: `: ${message}`);

View File

@ -133,6 +133,7 @@ import {
userSchema,
usersGroupsBaseSchema,
usersSchema,
createUserResponseSchema,
usersSearchSchema,
validatedEdgeTokensSchema,
validatePasswordSchema,
@ -327,6 +328,7 @@ export const schemas: UnleashSchemas = {
createStrategySchema,
updateStrategySchema,
userSchema,
createUserResponseSchema,
usersGroupsBaseSchema,
usersSchema,
usersSearchSchema,

View File

@ -96,7 +96,6 @@ const metaRules: Rule[] = [
'createApiTokenSchema',
'createFeatureSchema',
'createInvitedUserSchema',
'createUserSchema',
'environmentsSchema',
'environmentsProjectSchema',
'eventSchema',
@ -113,11 +112,9 @@ const metaRules: Rule[] = [
'groupSchema',
'groupsSchema',
'groupUserModelSchema',
'idSchema',
'maintenanceSchema',
'toggleMaintenanceSchema',
'meSchema',
'passwordSchema',
'patchSchema',
'permissionSchema',
'profileSchema',
@ -143,11 +140,9 @@ const metaRules: Rule[] = [
'updateFeatureSchema',
'updateFeatureStrategySchema',
'updateTagTypeSchema',
'updateUserSchema',
'upsertContextFieldSchema',
'upsertStrategySchema',
'usersGroupsBaseSchema',
'usersSchema',
'validateEdgeTokensSchema',
'validateTagTypeSchema',
'variantFlagSchema',
@ -175,7 +170,6 @@ const metaRules: Rule[] = [
'createFeatureSchema',
'createFeatureStrategySchema',
'createInvitedUserSchema',
'createUserSchema',
'dateSchema',
'environmentsSchema',
'eventSchema',
@ -191,12 +185,10 @@ const metaRules: Rule[] = [
'groupSchema',
'groupsSchema',
'groupUserModelSchema',
'idSchema',
'maintenanceSchema',
'toggleMaintenanceSchema',
'meSchema',
'parametersSchema',
'passwordSchema',
'patchesSchema',
'patchSchema',
'permissionSchema',
@ -223,11 +215,9 @@ const metaRules: Rule[] = [
'updateFeatureSchema',
'updateFeatureStrategySchema',
'updateTagTypeSchema',
'updateUserSchema',
'upsertContextFieldSchema',
'upsertStrategySchema',
'usersGroupsBaseSchema',
'usersSchema',
'usersSearchSchema',
'validateEdgeTokensSchema',
'validateTagTypeSchema',

View File

@ -0,0 +1,34 @@
import { FromSchema } from 'json-schema-to-ts';
import { userSchema } from './user-schema';
export const createUserResponseSchema = {
$id: '#/components/schemas/createUserResponseSchema',
type: 'object',
additionalProperties: false,
description: 'An Unleash user after creation',
required: ['id'],
properties: {
...userSchema.properties,
rootRole: {
description:
'Which [root role](https://docs.getunleash.io/reference/rbac#standard-roles) this user is assigned. Usually a numeric role ID, but can be a string when returning newly created user with an explicit string role.',
oneOf: [
{
type: 'integer',
example: 1,
minimum: 0,
},
{
type: 'string',
example: 'Admin',
enum: ['Admin', 'Editor', 'Viewer', 'Owner', 'Member'],
},
],
},
},
components: {},
} as const;
export type CreateUserResponseSchema = FromSchema<
typeof createUserResponseSchema
>;

View File

@ -5,24 +5,52 @@ export const createUserSchema = {
type: 'object',
additionalProperties: false,
required: ['rootRole'],
description:
'The payload must contain at least one of the name and email properties, though which one is up to you. For the user to be able to log in to the system, the user must have an email.',
properties: {
username: {
description:
"The user's username. Must be provided if email is not provided.",
type: 'string',
example: 'hunter',
},
email: {
description:
"The user's email address. Must be provided if username is not provided.",
type: 'string',
example: 'user@example.com',
},
name: {
description: "The user's name (not the user's username).",
type: 'string',
example: 'Sam Seawright',
},
password: {
type: 'string',
example: 'k!5As3HquUrQ',
description: 'Password for the user',
},
rootRole: {
type: 'number',
description:
"The role to assign to the user. Can be either the role's ID or its unique name.",
oneOf: [
{
type: 'integer',
example: 1,
minimum: 0,
},
{
type: 'string',
example: 'Admin',
enum: ['Admin', 'Editor', 'Viewer', 'Owner', 'Member'],
},
],
},
sendEmail: {
type: 'boolean',
example: false,
description:
'Whether to send a welcome email with a login link to the user or not. Defaults to `true`.',
},
},
components: {},

View File

@ -4,10 +4,13 @@ export const idSchema = {
$id: '#/components/schemas/idSchema',
type: 'object',
additionalProperties: false,
description: 'Email id used for password reset',
required: ['id'],
properties: {
id: {
type: 'string',
description: 'User email',
example: 'user@example.com',
},
},
components: {},

View File

@ -8,6 +8,7 @@ export * from './pats-schema';
export * from './role-schema';
export * from './tags-schema';
export * from './user-schema';
export * from './create-user-response-schema';
export * from './addon-schema';
export * from './addon-create-update-schema';
export * from './email-schema';

View File

@ -5,15 +5,24 @@ export const passwordSchema = {
type: 'object',
additionalProperties: false,
required: ['password'],
description: 'Fields used to create new password or update old password',
properties: {
password: {
type: 'string',
example: 'k!5As3HquUrQ',
description: 'The new password to change or validate.',
},
oldPassword: {
type: 'string',
example: 'Oldk!5As3HquUrQ',
description:
'The old password the user is changing. This field is for the non-admin users changing their own password.',
},
confirmPassword: {
type: 'string',
example: 'k!5As3HquUrQ',
description:
'The confirmation of the new password. This field is for the non-admin users changing their own password.',
},
},
components: {},

View File

@ -4,15 +4,34 @@ export const updateUserSchema = {
$id: '#/components/schemas/updateUserSchema',
type: 'object',
additionalProperties: true,
description: 'All fields that can be directly changed for the user',
properties: {
email: {
description:
"The user's email address. Must be provided if username is not provided.",
type: 'string',
example: 'user@example.com',
},
name: {
description: "The user's name (not the user's username).",
type: 'string',
example: 'Sam Seawright',
},
rootRole: {
type: 'number',
description:
"The role to assign to the user. Can be either the role's ID or its unique name.",
oneOf: [
{
type: 'integer',
example: 1,
minimum: 0,
},
{
type: 'string',
example: 'Admin',
enum: ['Admin', 'Editor', 'Viewer', 'Owner', 'Member'],
},
],
},
},
components: {},

View File

@ -84,6 +84,13 @@ export const userSchema = {
enum: AccountTypes,
example: 'User',
},
permissions: {
description: 'Deprecated',
type: 'array',
items: {
type: 'string',
},
},
},
components: {},
} as const;

View File

@ -6,16 +6,20 @@ export const usersSchema = {
$id: '#/components/schemas/usersSchema',
type: 'object',
additionalProperties: false,
description: 'Users and root roles',
required: ['users'],
properties: {
users: {
type: 'array',
description: 'A list of users in the Unleash instance.',
items: {
$ref: '#/components/schemas/userSchema',
},
},
rootRoles: {
type: 'array',
description:
'A list of [root roles](https://docs.getunleash.io/reference/rbac#standard-roles) in the Unleash instance.',
items: {
$ref: '#/components/schemas/roleSchema',
},

View File

@ -5,7 +5,7 @@ import UserService from '../../services/user-service';
import { AccountService } from '../../services/account-service';
import { AccessService } from '../../services/access-service';
import { Logger } from '../../logger';
import { IUnleashConfig, IUnleashServices } from '../../types';
import { IUnleashConfig, IUnleashServices, RoleName } from '../../types';
import { EmailService } from '../../services/email-service';
import ResetTokenService from '../../services/reset-token-service';
import { IAuthRequest } from '../unleash-types';
@ -15,7 +15,10 @@ import { simpleAuthSettingsKey } from '../../types/settings/simple-auth-settings
import { anonymise } from '../../util/anonymise';
import { OpenApiService } from '../../services/openapi-service';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
import { createResponseSchema } from '../../openapi/util/create-response-schema';
import {
createResponseSchema,
resourceCreatedResponseSchema,
} from '../../openapi/util/create-response-schema';
import { userSchema, UserSchema } from '../../openapi/spec/user-schema';
import { serializeDates } from '../../types/serialize-dates';
import { usersSchema, UsersSchema } from '../../openapi/spec/users-schema';
@ -31,7 +34,10 @@ import {
resetPasswordSchema,
ResetPasswordSchema,
} from '../../openapi/spec/reset-password-schema';
import { emptyResponse } from '../../openapi/util/standard-responses';
import {
emptyResponse,
getStandardResponses,
} from '../../openapi/util/standard-responses';
import { GroupService } from '../../services/group-service';
import {
UsersGroupsBaseSchema,
@ -45,6 +51,11 @@ import {
AdminCountSchema,
adminCountSchema,
} from '../../openapi/spec/admin-count-schema';
import { BadDataError } from '../../error';
import {
createUserResponseSchema,
CreateUserResponseSchema,
} from '../../openapi/spec/create-user-response-schema';
export default class UserAdminController extends Controller {
private flagResolver: IFlagResolver;
@ -114,8 +125,14 @@ export default class UserAdminController extends Controller {
openApiService.validPath({
tags: ['Users'],
operationId: 'validateUserPassword',
summary: 'Validate password for a user',
description:
'Validate the password strength. Minimum 10 characters, uppercase letter, number, special character.',
requestBody: createRequestSchema('passwordSchema'),
responses: { 200: emptyResponse },
responses: {
200: emptyResponse,
...getStandardResponses(400, 401, 415),
},
}),
],
});
@ -129,8 +146,13 @@ export default class UserAdminController extends Controller {
openApiService.validPath({
tags: ['Users'],
operationId: 'changeUserPassword',
summary: 'Change password for a user',
description: 'Change password for a user as an admin',
requestBody: createRequestSchema('passwordSchema'),
responses: { 200: emptyResponse },
responses: {
200: emptyResponse,
...getStandardResponses(400, 401, 403),
},
}),
],
});
@ -144,9 +166,12 @@ export default class UserAdminController extends Controller {
openApiService.validPath({
tags: ['Users'],
operationId: 'resetUserPassword',
summary: 'Reset user password',
description: 'Reset user password as an admin',
requestBody: createRequestSchema('idSchema'),
responses: {
200: createResponseSchema('resetPasswordSchema'),
...getStandardResponses(400, 401, 403, 404),
},
}),
],
@ -161,7 +186,14 @@ export default class UserAdminController extends Controller {
openApiService.validPath({
tags: ['Users'],
operationId: 'getUsers',
responses: { 200: createResponseSchema('usersSchema') },
summary:
'Get all users and [root roles](https://docs.getunleash.io/reference/rbac#standard-roles)',
description:
'Will return all users and all available root roles for the Unleash instance.',
responses: {
200: createResponseSchema('usersSchema'),
...getStandardResponses(401, 403),
},
}),
],
});
@ -175,7 +207,22 @@ export default class UserAdminController extends Controller {
openApiService.validPath({
tags: ['Users'],
operationId: 'searchUsers',
responses: { 200: createResponseSchema('usersSchema') },
summary: 'Search users',
description:
' It will preform a simple search based on name and email matching the given query. Requires minimum 2 characters',
parameters: [
{
name: 'q',
description:
'The pattern to search in the username or email',
schema: { type: 'string' },
in: 'query',
},
],
responses: {
200: createResponseSchema('usersSchema'),
...getStandardResponses(401),
},
}),
],
});
@ -189,8 +236,12 @@ export default class UserAdminController extends Controller {
openApiService.validPath({
tags: ['Users'],
operationId: 'getBaseUsersAndGroups',
summary: 'Get basic user and group information',
description:
'Get a subset of user and group information eligible even for non-admin users',
responses: {
200: createResponseSchema('usersGroupsBaseSchema'),
...getStandardResponses(401),
},
}),
],
@ -205,8 +256,12 @@ export default class UserAdminController extends Controller {
openApiService.validPath({
tags: ['Users'],
operationId: 'getAdminCount',
summary: 'Get total count of admin accounts',
description:
'Get a total count of admins with password, without password and admin service accounts',
responses: {
200: createResponseSchema('adminCountSchema'),
...getStandardResponses(401, 403),
},
}),
],
@ -221,8 +276,15 @@ export default class UserAdminController extends Controller {
openApiService.validPath({
tags: ['Users'],
operationId: 'createUser',
summary: 'Create a new user',
description: 'Creates a new user with the given root role.',
requestBody: createRequestSchema('createUserSchema'),
responses: { 200: createResponseSchema('userSchema') },
responses: {
201: resourceCreatedResponseSchema(
'createUserResponseSchema',
),
...getStandardResponses(400, 401, 403),
},
}),
rateLimit({
windowMs: minutesToMilliseconds(1),
@ -242,7 +304,12 @@ export default class UserAdminController extends Controller {
openApiService.validPath({
tags: ['Users'],
operationId: 'getUser',
responses: { 200: createResponseSchema('userSchema') },
summary: 'Get user',
description: 'Will return a single user by id',
responses: {
200: createResponseSchema('userSchema'),
...getStandardResponses(400, 401, 404),
},
}),
],
});
@ -256,8 +323,14 @@ export default class UserAdminController extends Controller {
openApiService.validPath({
tags: ['Users'],
operationId: 'updateUser',
summary: 'Update a user',
description:
'Only the explicitly specified fields get updated.',
requestBody: createRequestSchema('updateUserSchema'),
responses: { 200: createResponseSchema('userSchema') },
responses: {
200: createResponseSchema('createUserResponseSchema'),
...getStandardResponses(400, 401, 403, 404),
},
}),
],
});
@ -272,7 +345,12 @@ export default class UserAdminController extends Controller {
openApiService.validPath({
tags: ['Users'],
operationId: 'deleteUser',
responses: { 200: emptyResponse },
summary: 'Delete a user',
description: 'Deletes the user with the given userId',
responses: {
200: emptyResponse,
...getStandardResponses(401, 403, 404),
},
}),
],
});
@ -382,6 +460,9 @@ export default class UserAdminController extends Controller {
async getUser(req: Request, res: Response<UserSchema>): Promise<void> {
const { id } = req.params;
if (!Number.isInteger(Number(id))) {
throw new BadDataError('User id should be an integer');
}
const user = await this.userService.getUser(Number(id));
this.openApiService.respondWithValidation(
@ -394,11 +475,14 @@ export default class UserAdminController extends Controller {
async createUser(
req: IAuthRequest<unknown, unknown, CreateUserSchema>,
res: Response<UserSchema>,
res: Response<CreateUserResponseSchema>,
): Promise<void> {
const { username, email, name, rootRole, sendEmail, password } =
req.body;
const { user } = req;
const normalizedRootRole = Number.isInteger(Number(rootRole))
? Number(rootRole)
: (rootRole as RoleName);
const createdUser = await this.userService.createUser(
{
@ -406,7 +490,7 @@ export default class UserAdminController extends Controller {
email,
name,
password,
rootRole,
rootRole: normalizedRootRole,
},
user,
);
@ -451,48 +535,67 @@ export default class UserAdminController extends Controller {
);
}
const responseData: UserSchema = {
const responseData: CreateUserResponseSchema = {
...serializeDates(createdUser),
inviteLink: inviteLink || this.unleashUrl,
emailSent,
rootRole,
rootRole: normalizedRootRole,
};
this.openApiService.respondWithValidation(
201,
res,
userSchema.$id,
createUserResponseSchema.$id,
responseData,
{ location: `${responseData.id}` },
);
}
async updateUser(
req: IAuthRequest<{ id: string }, UserSchema, UpdateUserSchema>,
res: Response<UserSchema>,
req: IAuthRequest<
{ id: string },
CreateUserResponseSchema,
UpdateUserSchema
>,
res: Response<CreateUserResponseSchema>,
): Promise<void> {
const { user, params, body } = req;
const { id } = params;
const { name, email, rootRole } = body;
if (!Number.isInteger(Number(id))) {
throw new BadDataError('User id should be an integer');
}
const normalizedRootRole = Number.isInteger(Number(rootRole))
? Number(rootRole)
: (rootRole as RoleName);
const updateUser = await this.userService.updateUser(
{
id: Number(id),
name,
email,
rootRole,
rootRole: normalizedRootRole,
},
user,
);
this.openApiService.respondWithValidation(200, res, userSchema.$id, {
...serializeDates(updateUser),
rootRole,
});
this.openApiService.respondWithValidation(
200,
res,
createUserResponseSchema.$id,
{
...serializeDates(updateUser),
rootRole: normalizedRootRole,
},
);
}
async deleteUser(req: IAuthRequest, res: Response): Promise<void> {
const { user, params } = req;
const { id } = params;
if (!Number.isInteger(Number(id))) {
throw new BadDataError('User id should be an integer');
}
await this.userService.deleteUser(+id, user);
res.status(200).send();

View File

@ -192,7 +192,7 @@ class UserService {
const exists = await this.store.hasUser({ username, email });
if (exists) {
throw new Error('User already exists');
throw new BadDataError('User already exists');
}
const user = await this.store.insert({

View File

@ -1,4 +1,4 @@
import { setupApp, setupAppWithCustomConfig } from '../../helpers/test-helper';
import { setupAppWithCustomConfig } from '../../helpers/test-helper';
import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import {
@ -29,7 +29,13 @@ let adminRole: IRole;
beforeAll(async () => {
db = await dbInit('user_admin_api_serial', getLogger);
stores = db.stores;
app = await setupApp(stores);
app = await setupAppWithCustomConfig(stores, {
experimental: {
flags: {
strictSchemaValidation: true,
},
},
});
userStore = stores.userStore;
eventStore = stores.eventStore;

File diff suppressed because it is too large Load Diff

View File

@ -186,7 +186,7 @@ The payload **must** contain **at least one of** the `name` and `email` properti
`PUT https://unleash.host.com/api/admin/user-admin/:userId`
Updates use with new fields
Updates user with new fields
**Body**