feat(api): user management and audit log for admins (M1)
- /admin/users: list with search, role filter and cursor pages; create with a one-time temporary password; rename, change role, block; reset password; delete - every change is checked against the role policy, a forbidden field rejects the whole patch; the last active admin is locked in the transaction and cannot be demoted, blocked or deleted - role change and blocking revoke access tokens at once; blocking and password reset also end all sessions - /admin/audit: newest-first journal with actor names - path and query parameter errors are problem+json now Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed4a492dba
commit
f075863f15
13 files changed
+2578
-70
No files matched your search
@@ -11,6 +11,7 @@ tags:
|
||||
- name: system
|
||||
- name: auth
|
||||
- name: me
|
||||
- name: admin
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
@@ -251,7 +252,146 @@ paths:
|
||||
description: Сессия завершена
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/users:
|
||||
get:
|
||||
operationId: listUsers
|
||||
tags: [admin]
|
||||
summary: Пользователи (admin, moderator)
|
||||
description: Сортировка по логину. `q` ищет по логину и имени без учёта регистра.
|
||||
parameters:
|
||||
- name: q
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 64
|
||||
- name: role
|
||||
in: query
|
||||
schema:
|
||||
$ref: "#/components/schemas/Role"
|
||||
- $ref: "#/components/parameters/Limit"
|
||||
- $ref: "#/components/parameters/Cursor"
|
||||
responses:
|
||||
"200":
|
||||
description: Страница пользователей
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminUserPage"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
post:
|
||||
operationId: createUser
|
||||
tags: [admin]
|
||||
summary: Создать пользователя
|
||||
description: |
|
||||
Пароль генерирует сервер и возвращает **один раз**; при первом входе его нужно сменить.
|
||||
Модератор создаёт только `user`, админ — любые роли.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateUserRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Пользователь создан
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreatedUser"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/users/{id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/UserId"
|
||||
patch:
|
||||
operationId: updateUser
|
||||
tags: [admin]
|
||||
summary: Изменить имя, роль, блокировку
|
||||
description: |
|
||||
Роль меняет только админ. Смена роли и блокировка мгновенно отзывают токены пользователя.
|
||||
Последнего активного админа нельзя понизить или заблокировать — `409 users.last_admin`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateUserRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Обновлённый пользователь
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminUser"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
delete:
|
||||
operationId: deleteUser
|
||||
tags: [admin]
|
||||
summary: Удалить пользователя (admin)
|
||||
responses:
|
||||
"204":
|
||||
description: Удалён
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/users/{id}/reset-password:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/UserId"
|
||||
post:
|
||||
operationId: resetUserPassword
|
||||
tags: [admin]
|
||||
summary: Сбросить пароль
|
||||
description: Новый временный пароль возвращается один раз; все сессии пользователя завершаются.
|
||||
responses:
|
||||
"200":
|
||||
description: Временный пароль
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TemporaryPassword"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/audit:
|
||||
get:
|
||||
operationId: listAudit
|
||||
tags: [admin]
|
||||
summary: Журнал действий (admin), новые сверху
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Limit"
|
||||
- $ref: "#/components/parameters/Cursor"
|
||||
responses:
|
||||
"200":
|
||||
description: Страница журнала
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuditPage"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
components:
|
||||
parameters:
|
||||
Limit:
|
||||
name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 50
|
||||
Cursor:
|
||||
name: cursor
|
||||
in: query
|
||||
description: Непрозрачный курсор из `next_cursor` предыдущей страницы
|
||||
schema:
|
||||
type: string
|
||||
UserId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
@@ -429,6 +569,132 @@ components:
|
||||
type: string
|
||||
new_password:
|
||||
type: string
|
||||
AdminUser:
|
||||
type: object
|
||||
required: [id, username, display_name, role, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
username:
|
||||
type: string
|
||||
display_name:
|
||||
type: string
|
||||
nullable: true
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
locale:
|
||||
type: string
|
||||
is_blocked:
|
||||
type: boolean
|
||||
must_change_password:
|
||||
type: boolean
|
||||
must_change_credentials:
|
||||
type: boolean
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
last_login_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
AdminUserPage:
|
||||
type: object
|
||||
required: [items, next_cursor]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AdminUser"
|
||||
next_cursor:
|
||||
type: string
|
||||
nullable: true
|
||||
CreateUserRequest:
|
||||
type: object
|
||||
required: [username, role]
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
display_name:
|
||||
type: string
|
||||
maxLength: 64
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
CreatedUser:
|
||||
type: object
|
||||
required: [user, temporary_password]
|
||||
properties:
|
||||
user:
|
||||
$ref: "#/components/schemas/AdminUser"
|
||||
temporary_password:
|
||||
type: string
|
||||
description: Показывается один раз
|
||||
UpdateUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
display_name:
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: Пустая строка — сбросить имя
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
is_blocked:
|
||||
type: boolean
|
||||
TemporaryPassword:
|
||||
type: object
|
||||
required: [temporary_password]
|
||||
properties:
|
||||
temporary_password:
|
||||
type: string
|
||||
AuditActor:
|
||||
type: object
|
||||
required: [id, username]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
username:
|
||||
type: string
|
||||
AuditEntry:
|
||||
type: object
|
||||
required: [id, actor, action, target_type, target_id, payload, created_at]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
actor:
|
||||
description: null — система или удалённый пользователь
|
||||
nullable: true
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/AuditActor"
|
||||
action:
|
||||
type: string
|
||||
example: user.create
|
||||
target_type:
|
||||
type: string
|
||||
nullable: true
|
||||
target_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
payload:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
AuditPage:
|
||||
type: object
|
||||
required: [items, next_cursor]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AuditEntry"
|
||||
next_cursor:
|
||||
type: string
|
||||
nullable: true
|
||||
Session:
|
||||
type: object
|
||||
required: [id, device_name, client_type, created_at, last_used_at, current]
|
||||
|
||||
@@ -234,6 +234,94 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/users": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Пользователи (admin, moderator)
|
||||
* @description Сортировка по логину. `q` ищет по логину и имени без учёта регистра.
|
||||
*/
|
||||
get: operations["listUsers"];
|
||||
put?: never;
|
||||
/**
|
||||
* Создать пользователя
|
||||
* @description Пароль генерирует сервер и возвращает **один раз**; при первом входе его нужно сменить.
|
||||
* Модератор создаёт только `user`, админ — любые роли.
|
||||
*/
|
||||
post: operations["createUser"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/users/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["UserId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Удалить пользователя (admin) */
|
||||
delete: operations["deleteUser"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Изменить имя, роль, блокировку
|
||||
* @description Роль меняет только админ. Смена роли и блокировка мгновенно отзывают токены пользователя.
|
||||
* Последнего активного админа нельзя понизить или заблокировать — `409 users.last_admin`.
|
||||
*/
|
||||
patch: operations["updateUser"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/users/{id}/reset-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["UserId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Сбросить пароль
|
||||
* @description Новый временный пароль возвращается один раз; все сессии пользователя завершаются.
|
||||
*/
|
||||
post: operations["resetUserPassword"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/audit": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Журнал действий (admin), новые сверху */
|
||||
get: operations["listAudit"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -340,6 +428,69 @@ export interface components {
|
||||
new_username: string;
|
||||
new_password: string;
|
||||
};
|
||||
AdminUser: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
role: components["schemas"]["Role"];
|
||||
locale: string;
|
||||
is_blocked: boolean;
|
||||
must_change_password: boolean;
|
||||
must_change_credentials: boolean;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
/** Format: date-time */
|
||||
last_login_at: string | null;
|
||||
};
|
||||
AdminUserPage: {
|
||||
items: components["schemas"]["AdminUser"][];
|
||||
next_cursor: string | null;
|
||||
};
|
||||
CreateUserRequest: {
|
||||
username: string;
|
||||
display_name?: string;
|
||||
role: components["schemas"]["Role"];
|
||||
};
|
||||
CreatedUser: {
|
||||
user: components["schemas"]["AdminUser"];
|
||||
/** @description Показывается один раз */
|
||||
temporary_password: string;
|
||||
};
|
||||
UpdateUserRequest: {
|
||||
/** @description Пустая строка — сбросить имя */
|
||||
display_name?: string;
|
||||
role?: components["schemas"]["Role"];
|
||||
is_blocked?: boolean;
|
||||
};
|
||||
TemporaryPassword: {
|
||||
temporary_password: string;
|
||||
};
|
||||
AuditActor: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
AuditEntry: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** @description null — система или удалённый пользователь */
|
||||
actor: components["schemas"]["AuditActor"] | null;
|
||||
/** @example user.create */
|
||||
action: string;
|
||||
target_type: string | null;
|
||||
/** Format: uuid */
|
||||
target_id: string | null;
|
||||
payload: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
};
|
||||
AuditPage: {
|
||||
items: components["schemas"]["AuditEntry"][];
|
||||
next_cursor: string | null;
|
||||
};
|
||||
Session: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -388,7 +539,12 @@ export interface components {
|
||||
};
|
||||
};
|
||||
};
|
||||
parameters: never;
|
||||
parameters: {
|
||||
Limit: number;
|
||||
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
|
||||
Cursor: string;
|
||||
UserId: string;
|
||||
};
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
@@ -693,4 +849,152 @@ export interface operations {
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
listUsers: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
role?: components["schemas"]["Role"];
|
||||
limit?: components["parameters"]["Limit"];
|
||||
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
|
||||
cursor?: components["parameters"]["Cursor"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Страница пользователей */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminUserPage"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
createUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateUserRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Пользователь создан */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreatedUser"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
deleteUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["UserId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Удалён */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
updateUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["UserId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateUserRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Обновлённый пользователь */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminUser"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
resetUserPassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["UserId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Временный пароль */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TemporaryPassword"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
listAudit: {
|
||||
parameters: {
|
||||
query?: {
|
||||
limit?: components["parameters"]["Limit"];
|
||||
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
|
||||
cursor?: components["parameters"]["Cursor"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Страница журнала */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AuditPage"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,8 @@
|
||||
"current_password_required": "Enter your current password",
|
||||
"current_password_invalid": "The current password is wrong",
|
||||
"bootstrap_not_required": "The administrator credentials have already been changed",
|
||||
"session_not_found": "Session not found"
|
||||
"session_not_found": "Session not found",
|
||||
"forbidden": "You don't have permission to do this"
|
||||
},
|
||||
"validation": {
|
||||
"password_too_short": "The password must be at least {{min}} characters",
|
||||
@@ -34,6 +35,12 @@
|
||||
"locale_unsupported": "This language is not supported",
|
||||
"display_name_too_long": "The name must be at most {{max}} characters",
|
||||
"ui_prefs_invalid": "Invalid setting value",
|
||||
"client_type_invalid": "Unknown app type"
|
||||
"client_type_invalid": "Unknown app type",
|
||||
"role_invalid": "Unknown role",
|
||||
"cursor_invalid": "The list is outdated, please reload"
|
||||
},
|
||||
"users": {
|
||||
"not_found": "User not found",
|
||||
"last_admin": "The last administrator cannot be demoted, blocked or deleted"
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@
|
||||
"current_password_required": "Введите текущий пароль",
|
||||
"current_password_invalid": "Текущий пароль указан неверно",
|
||||
"bootstrap_not_required": "Учётные данные администратора уже изменены",
|
||||
"session_not_found": "Сессия не найдена"
|
||||
"session_not_found": "Сессия не найдена",
|
||||
"forbidden": "Недостаточно прав для этого действия"
|
||||
},
|
||||
"validation": {
|
||||
"password_too_short": "Пароль должен быть не короче {{min}} символов",
|
||||
@@ -34,6 +35,12 @@
|
||||
"locale_unsupported": "Этот язык не поддерживается",
|
||||
"display_name_too_long": "Имя должно быть не длиннее {{max}} символов",
|
||||
"ui_prefs_invalid": "Недопустимое значение настройки",
|
||||
"client_type_invalid": "Неизвестный тип приложения"
|
||||
"client_type_invalid": "Неизвестный тип приложения",
|
||||
"role_invalid": "Неизвестная роль",
|
||||
"cursor_invalid": "Список устарел, обновите страницу"
|
||||
},
|
||||
"users": {
|
||||
"not_found": "Пользователь не найден",
|
||||
"last_admin": "Нельзя понизить, заблокировать или удалить последнего администратора"
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,51 @@ const (
|
||||
Island UiPrefsWebPlayer = "island"
|
||||
)
|
||||
|
||||
// AdminUser defines model for AdminUser.
|
||||
type AdminUser struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
IsBlocked bool `json:"is_blocked"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
Locale string `json:"locale"`
|
||||
MustChangeCredentials bool `json:"must_change_credentials"`
|
||||
MustChangePassword bool `json:"must_change_password"`
|
||||
Role Role `json:"role"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// AdminUserPage defines model for AdminUserPage.
|
||||
type AdminUserPage struct {
|
||||
Items []AdminUser `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
}
|
||||
|
||||
// AuditActor defines model for AuditActor.
|
||||
type AuditActor struct {
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// AuditEntry defines model for AuditEntry.
|
||||
type AuditEntry struct {
|
||||
Action string `json:"action"`
|
||||
|
||||
// Actor null — система или удалённый пользователь
|
||||
Actor *AuditActor `json:"actor"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
Payload *map[string]interface{} `json:"payload"`
|
||||
TargetId *openapi_types.UUID `json:"target_id"`
|
||||
TargetType *string `json:"target_type"`
|
||||
}
|
||||
|
||||
// AuditPage defines model for AuditPage.
|
||||
type AuditPage struct {
|
||||
Items []AuditEntry `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
}
|
||||
|
||||
// AuthTokens defines model for AuthTokens.
|
||||
type AuthTokens struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
@@ -112,6 +157,20 @@ type ChangePasswordRequest struct {
|
||||
// ClientType defines model for ClientType.
|
||||
type ClientType string
|
||||
|
||||
// CreateUserRequest defines model for CreateUserRequest.
|
||||
type CreateUserRequest struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Role Role `json:"role"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// CreatedUser defines model for CreatedUser.
|
||||
type CreatedUser struct {
|
||||
// TemporaryPassword Показывается один раз
|
||||
TemporaryPassword string `json:"temporary_password"`
|
||||
User AdminUser `json:"user"`
|
||||
}
|
||||
|
||||
// FieldError defines model for FieldError.
|
||||
type FieldError struct {
|
||||
Code string `json:"code"`
|
||||
@@ -218,6 +277,11 @@ type SessionList struct {
|
||||
Items []Session `json:"items"`
|
||||
}
|
||||
|
||||
// TemporaryPassword defines model for TemporaryPassword.
|
||||
type TemporaryPassword struct {
|
||||
TemporaryPassword string `json:"temporary_password"`
|
||||
}
|
||||
|
||||
// UiPrefs defines model for UiPrefs.
|
||||
type UiPrefs struct {
|
||||
Ambient *bool `json:"ambient,omitempty"`
|
||||
@@ -249,11 +313,52 @@ type UpdateMeRequest struct {
|
||||
UiPrefs *UiPrefs `json:"ui_prefs,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest defines model for UpdateUserRequest.
|
||||
type UpdateUserRequest struct {
|
||||
// DisplayName Пустая строка — сбросить имя
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
IsBlocked *bool `json:"is_blocked,omitempty"`
|
||||
Role *Role `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// Cursor defines model for Cursor.
|
||||
type Cursor = string
|
||||
|
||||
// Limit defines model for Limit.
|
||||
type Limit = int
|
||||
|
||||
// UserId defines model for UserId.
|
||||
type UserId = openapi_types.UUID
|
||||
|
||||
// ListAuditParams defines parameters for ListAudit.
|
||||
type ListAuditParams struct {
|
||||
Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"`
|
||||
|
||||
// Cursor Непрозрачный курсор из `next_cursor` предыдущей страницы
|
||||
Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
// ListUsersParams defines parameters for ListUsers.
|
||||
type ListUsersParams struct {
|
||||
Q *string `form:"q,omitempty" json:"q,omitempty"`
|
||||
Role *Role `form:"role,omitempty" json:"role,omitempty"`
|
||||
Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"`
|
||||
|
||||
// Cursor Непрозрачный курсор из `next_cursor` предыдущей страницы
|
||||
Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
// RefreshTokensParams defines parameters for RefreshTokens.
|
||||
type RefreshTokensParams struct {
|
||||
PipuRt *string `form:"pipu_rt,omitempty" json:"pipu_rt,omitempty"`
|
||||
}
|
||||
|
||||
// CreateUserJSONRequestBody defines body for CreateUser for application/json ContentType.
|
||||
type CreateUserJSONRequestBody = CreateUserRequest
|
||||
|
||||
// UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType.
|
||||
type UpdateUserJSONRequestBody = UpdateUserRequest
|
||||
|
||||
// LoginJSONRequestBody defines body for Login for application/json ContentType.
|
||||
type LoginJSONRequestBody = LoginRequest
|
||||
|
||||
@@ -271,6 +376,24 @@ type ChangePasswordJSONRequestBody = ChangePasswordRequest
|
||||
|
||||
// ServerInterface represents all server handlers.
|
||||
type ServerInterface interface {
|
||||
// Журнал действий (admin), новые сверху
|
||||
// (GET /api/v1/admin/audit)
|
||||
ListAudit(w http.ResponseWriter, r *http.Request, params ListAuditParams)
|
||||
// Пользователи (admin, moderator)
|
||||
// (GET /api/v1/admin/users)
|
||||
ListUsers(w http.ResponseWriter, r *http.Request, params ListUsersParams)
|
||||
// Создать пользователя
|
||||
// (POST /api/v1/admin/users)
|
||||
CreateUser(w http.ResponseWriter, r *http.Request)
|
||||
// Удалить пользователя (admin)
|
||||
// (DELETE /api/v1/admin/users/{id})
|
||||
DeleteUser(w http.ResponseWriter, r *http.Request, id UserId)
|
||||
// Изменить имя, роль, блокировку
|
||||
// (PATCH /api/v1/admin/users/{id})
|
||||
UpdateUser(w http.ResponseWriter, r *http.Request, id UserId)
|
||||
// Сбросить пароль
|
||||
// (POST /api/v1/admin/users/{id}/reset-password)
|
||||
ResetUserPassword(w http.ResponseWriter, r *http.Request, id UserId)
|
||||
// Вход по логину и паролю
|
||||
// (POST /api/v1/auth/login)
|
||||
Login(w http.ResponseWriter, r *http.Request)
|
||||
@@ -316,6 +439,42 @@ type ServerInterface interface {
|
||||
|
||||
type Unimplemented struct{}
|
||||
|
||||
// Журнал действий (admin), новые сверху
|
||||
// (GET /api/v1/admin/audit)
|
||||
func (_ Unimplemented) ListAudit(w http.ResponseWriter, r *http.Request, params ListAuditParams) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Пользователи (admin, moderator)
|
||||
// (GET /api/v1/admin/users)
|
||||
func (_ Unimplemented) ListUsers(w http.ResponseWriter, r *http.Request, params ListUsersParams) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Создать пользователя
|
||||
// (POST /api/v1/admin/users)
|
||||
func (_ Unimplemented) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Удалить пользователя (admin)
|
||||
// (DELETE /api/v1/admin/users/{id})
|
||||
func (_ Unimplemented) DeleteUser(w http.ResponseWriter, r *http.Request, id UserId) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Изменить имя, роль, блокировку
|
||||
// (PATCH /api/v1/admin/users/{id})
|
||||
func (_ Unimplemented) UpdateUser(w http.ResponseWriter, r *http.Request, id UserId) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Сбросить пароль
|
||||
// (POST /api/v1/admin/users/{id}/reset-password)
|
||||
func (_ Unimplemented) ResetUserPassword(w http.ResponseWriter, r *http.Request, id UserId) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Вход по логину и паролю
|
||||
// (POST /api/v1/auth/login)
|
||||
func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -403,6 +562,217 @@ type ServerInterfaceWrapper struct {
|
||||
|
||||
type MiddlewareFunc func(http.Handler) http.Handler
|
||||
|
||||
// ListAudit operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ListAudit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Parameter object where we will unmarshal all parameters from the context
|
||||
var params ListAuditParams
|
||||
|
||||
// ------------- Optional query parameter "limit" -------------
|
||||
|
||||
err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), ¶ms.Limit)
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
// ------------- Optional query parameter "cursor" -------------
|
||||
|
||||
err = runtime.BindQueryParameter("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor)
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.ListAudit(w, r, params)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ListUsers operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Parameter object where we will unmarshal all parameters from the context
|
||||
var params ListUsersParams
|
||||
|
||||
// ------------- Optional query parameter "q" -------------
|
||||
|
||||
err = runtime.BindQueryParameter("form", true, false, "q", r.URL.Query(), ¶ms.Q)
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "q", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
// ------------- Optional query parameter "role" -------------
|
||||
|
||||
err = runtime.BindQueryParameter("form", true, false, "role", r.URL.Query(), ¶ms.Role)
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "role", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
// ------------- Optional query parameter "limit" -------------
|
||||
|
||||
err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), ¶ms.Limit)
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
// ------------- Optional query parameter "cursor" -------------
|
||||
|
||||
err = runtime.BindQueryParameter("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor)
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.ListUsers(w, r, params)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// CreateUser operation middleware
|
||||
func (siw *ServerInterfaceWrapper) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.CreateUser(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// DeleteUser operation middleware
|
||||
func (siw *ServerInterfaceWrapper) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
|
||||
// ------------- Path parameter "id" -------------
|
||||
var id UserId
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.DeleteUser(w, r, id)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// UpdateUser operation middleware
|
||||
func (siw *ServerInterfaceWrapper) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
|
||||
// ------------- Path parameter "id" -------------
|
||||
var id UserId
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.UpdateUser(w, r, id)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ResetUserPassword operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
|
||||
// ------------- Path parameter "id" -------------
|
||||
var id UserId
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.ResetUserPassword(w, r, id)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Login operation middleware
|
||||
func (siw *ServerInterfaceWrapper) Login(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -777,6 +1147,24 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
||||
ErrorHandlerFunc: options.ErrorHandlerFunc,
|
||||
}
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/admin/audit", wrapper.ListAudit)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/admin/users", wrapper.ListUsers)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Post(options.BaseURL+"/api/v1/admin/users", wrapper.CreateUser)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Delete(options.BaseURL+"/api/v1/admin/users/{id}", wrapper.DeleteUser)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Patch(options.BaseURL+"/api/v1/admin/users/{id}", wrapper.UpdateUser)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Post(options.BaseURL+"/api/v1/admin/users/{id}/reset-password", wrapper.ResetUserPassword)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Post(options.BaseURL+"/api/v1/auth/login", wrapper.Login)
|
||||
})
|
||||
@@ -822,6 +1210,180 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
||||
|
||||
type ProblemApplicationProblemPlusJSONResponse Problem
|
||||
|
||||
type ListAuditRequestObject struct {
|
||||
Params ListAuditParams
|
||||
}
|
||||
|
||||
type ListAuditResponseObject interface {
|
||||
VisitListAuditResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type ListAudit200JSONResponse AuditPage
|
||||
|
||||
func (response ListAudit200JSONResponse) VisitListAuditResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type ListAuditdefaultApplicationProblemPlusJSONResponse struct {
|
||||
Body Problem
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (response ListAuditdefaultApplicationProblemPlusJSONResponse) VisitListAuditResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type ListUsersRequestObject struct {
|
||||
Params ListUsersParams
|
||||
}
|
||||
|
||||
type ListUsersResponseObject interface {
|
||||
VisitListUsersResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type ListUsers200JSONResponse AdminUserPage
|
||||
|
||||
func (response ListUsers200JSONResponse) VisitListUsersResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type ListUsersdefaultApplicationProblemPlusJSONResponse struct {
|
||||
Body Problem
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (response ListUsersdefaultApplicationProblemPlusJSONResponse) VisitListUsersResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type CreateUserRequestObject struct {
|
||||
Body *CreateUserJSONRequestBody
|
||||
}
|
||||
|
||||
type CreateUserResponseObject interface {
|
||||
VisitCreateUserResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type CreateUser201JSONResponse CreatedUser
|
||||
|
||||
func (response CreateUser201JSONResponse) VisitCreateUserResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(201)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type CreateUserdefaultApplicationProblemPlusJSONResponse struct {
|
||||
Body Problem
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (response CreateUserdefaultApplicationProblemPlusJSONResponse) VisitCreateUserResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type DeleteUserRequestObject struct {
|
||||
Id UserId `json:"id"`
|
||||
}
|
||||
|
||||
type DeleteUserResponseObject interface {
|
||||
VisitDeleteUserResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type DeleteUser204Response struct {
|
||||
}
|
||||
|
||||
func (response DeleteUser204Response) VisitDeleteUserResponse(w http.ResponseWriter) error {
|
||||
w.WriteHeader(204)
|
||||
return nil
|
||||
}
|
||||
|
||||
type DeleteUserdefaultApplicationProblemPlusJSONResponse struct {
|
||||
Body Problem
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (response DeleteUserdefaultApplicationProblemPlusJSONResponse) VisitDeleteUserResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type UpdateUserRequestObject struct {
|
||||
Id UserId `json:"id"`
|
||||
Body *UpdateUserJSONRequestBody
|
||||
}
|
||||
|
||||
type UpdateUserResponseObject interface {
|
||||
VisitUpdateUserResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type UpdateUser200JSONResponse AdminUser
|
||||
|
||||
func (response UpdateUser200JSONResponse) VisitUpdateUserResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type UpdateUserdefaultApplicationProblemPlusJSONResponse struct {
|
||||
Body Problem
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (response UpdateUserdefaultApplicationProblemPlusJSONResponse) VisitUpdateUserResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type ResetUserPasswordRequestObject struct {
|
||||
Id UserId `json:"id"`
|
||||
}
|
||||
|
||||
type ResetUserPasswordResponseObject interface {
|
||||
VisitResetUserPasswordResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type ResetUserPassword200JSONResponse TemporaryPassword
|
||||
|
||||
func (response ResetUserPassword200JSONResponse) VisitResetUserPasswordResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type ResetUserPassworddefaultApplicationProblemPlusJSONResponse struct {
|
||||
Body Problem
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (response ResetUserPassworddefaultApplicationProblemPlusJSONResponse) VisitResetUserPasswordResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type LoginRequestObject struct {
|
||||
Body *LoginJSONRequestBody
|
||||
}
|
||||
@@ -1177,6 +1739,24 @@ func (response GetReadyz503JSONResponse) VisitGetReadyzResponse(w http.ResponseW
|
||||
|
||||
// StrictServerInterface represents all server handlers.
|
||||
type StrictServerInterface interface {
|
||||
// Журнал действий (admin), новые сверху
|
||||
// (GET /api/v1/admin/audit)
|
||||
ListAudit(ctx context.Context, request ListAuditRequestObject) (ListAuditResponseObject, error)
|
||||
// Пользователи (admin, moderator)
|
||||
// (GET /api/v1/admin/users)
|
||||
ListUsers(ctx context.Context, request ListUsersRequestObject) (ListUsersResponseObject, error)
|
||||
// Создать пользователя
|
||||
// (POST /api/v1/admin/users)
|
||||
CreateUser(ctx context.Context, request CreateUserRequestObject) (CreateUserResponseObject, error)
|
||||
// Удалить пользователя (admin)
|
||||
// (DELETE /api/v1/admin/users/{id})
|
||||
DeleteUser(ctx context.Context, request DeleteUserRequestObject) (DeleteUserResponseObject, error)
|
||||
// Изменить имя, роль, блокировку
|
||||
// (PATCH /api/v1/admin/users/{id})
|
||||
UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error)
|
||||
// Сбросить пароль
|
||||
// (POST /api/v1/admin/users/{id}/reset-password)
|
||||
ResetUserPassword(ctx context.Context, request ResetUserPasswordRequestObject) (ResetUserPasswordResponseObject, error)
|
||||
// Вход по логину и паролю
|
||||
// (POST /api/v1/auth/login)
|
||||
Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error)
|
||||
@@ -1247,6 +1827,174 @@ type strictHandler struct {
|
||||
options StrictHTTPServerOptions
|
||||
}
|
||||
|
||||
// ListAudit operation middleware
|
||||
func (sh *strictHandler) ListAudit(w http.ResponseWriter, r *http.Request, params ListAuditParams) {
|
||||
var request ListAuditRequestObject
|
||||
|
||||
request.Params = params
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.ListAudit(ctx, request.(ListAuditRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "ListAudit")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(ListAuditResponseObject); ok {
|
||||
if err := validResponse.VisitListAuditResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// ListUsers operation middleware
|
||||
func (sh *strictHandler) ListUsers(w http.ResponseWriter, r *http.Request, params ListUsersParams) {
|
||||
var request ListUsersRequestObject
|
||||
|
||||
request.Params = params
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.ListUsers(ctx, request.(ListUsersRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "ListUsers")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(ListUsersResponseObject); ok {
|
||||
if err := validResponse.VisitListUsersResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUser operation middleware
|
||||
func (sh *strictHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var request CreateUserRequestObject
|
||||
|
||||
var body CreateUserJSONRequestBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
|
||||
return
|
||||
}
|
||||
request.Body = &body
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.CreateUser(ctx, request.(CreateUserRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "CreateUser")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(CreateUserResponseObject); ok {
|
||||
if err := validResponse.VisitCreateUserResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteUser operation middleware
|
||||
func (sh *strictHandler) DeleteUser(w http.ResponseWriter, r *http.Request, id UserId) {
|
||||
var request DeleteUserRequestObject
|
||||
|
||||
request.Id = id
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.DeleteUser(ctx, request.(DeleteUserRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "DeleteUser")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(DeleteUserResponseObject); ok {
|
||||
if err := validResponse.VisitDeleteUserResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateUser operation middleware
|
||||
func (sh *strictHandler) UpdateUser(w http.ResponseWriter, r *http.Request, id UserId) {
|
||||
var request UpdateUserRequestObject
|
||||
|
||||
request.Id = id
|
||||
|
||||
var body UpdateUserJSONRequestBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
|
||||
return
|
||||
}
|
||||
request.Body = &body
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.UpdateUser(ctx, request.(UpdateUserRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "UpdateUser")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(UpdateUserResponseObject); ok {
|
||||
if err := validResponse.VisitUpdateUserResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// ResetUserPassword operation middleware
|
||||
func (sh *strictHandler) ResetUserPassword(w http.ResponseWriter, r *http.Request, id UserId) {
|
||||
var request ResetUserPasswordRequestObject
|
||||
|
||||
request.Id = id
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.ResetUserPassword(ctx, request.(ResetUserPasswordRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "ResetUserPassword")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(ResetUserPasswordResponseObject); ok {
|
||||
if err := validResponse.VisitResetUserPasswordResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// Login operation middleware
|
||||
func (sh *strictHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var request LoginRequestObject
|
||||
@@ -1601,64 +2349,82 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
"f8HaVub3Bs19it3CrZWt/wkAAP//LpP8775MAAA=",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
// Коды ошибок админки пользователей.
|
||||
const (
|
||||
CodeForbidden = "auth.forbidden"
|
||||
CodeUserNotFound = "users.not_found"
|
||||
CodeLastAdmin = "users.last_admin"
|
||||
CodeRoleInvalid = "validation.role_invalid"
|
||||
CodeCursorBad = "validation.cursor_invalid"
|
||||
)
|
||||
|
||||
// Длина временного пароля (создание пользователя, сброс).
|
||||
const tempPasswordLen = 12
|
||||
|
||||
// Пределы размера страницы.
|
||||
const (
|
||||
defaultPageSize = 50
|
||||
maxPageSize = 200
|
||||
)
|
||||
|
||||
func forbidden() error { return apperr.Forbidden(CodeForbidden, "not allowed") }
|
||||
|
||||
func pageSize(limit *int) int {
|
||||
if limit == nil || *limit <= 0 {
|
||||
return defaultPageSize
|
||||
}
|
||||
return min(*limit, maxPageSize)
|
||||
}
|
||||
|
||||
func actorOf(p Principal) Actor { return Actor{ID: p.UserID, Role: p.Role} }
|
||||
|
||||
func actorOfUser(u User) Actor { return Actor{ID: u.ID, Role: u.Role} }
|
||||
|
||||
// ListUsers — страница пользователей для админки (admin, moderator).
|
||||
func (s *Service) ListUsers(ctx context.Context, p Principal, query string, role *Role, cursor string, limit *int,
|
||||
) ([]User, string, error) {
|
||||
if !Can(p.Role, PermListUsers) {
|
||||
return nil, "", forbidden()
|
||||
}
|
||||
if role != nil && !role.Valid() {
|
||||
return nil, "", apperr.Validation("role", CodeRoleInvalid, "unknown role", nil)
|
||||
}
|
||||
after := ""
|
||||
if cursor != "" {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return nil, "", apperr.Validation("cursor", CodeCursorBad, "malformed cursor", nil)
|
||||
}
|
||||
after = string(raw)
|
||||
}
|
||||
n := pageSize(limit)
|
||||
users, err := s.store.ListUsers(ctx, UserFilter{Query: strings.TrimSpace(query), Role: role, After: after, Limit: n + 1})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
next := ""
|
||||
if len(users) > n {
|
||||
users = users[:n]
|
||||
next = base64.RawURLEncoding.EncodeToString([]byte(users[n-1].Username))
|
||||
}
|
||||
return users, next, nil
|
||||
}
|
||||
|
||||
// CreateUserInput — форма создания пользователя.
|
||||
type CreateUserInput struct {
|
||||
Username string
|
||||
DisplayName string
|
||||
Role Role
|
||||
}
|
||||
|
||||
// CreateUser создаёт пользователя с временным паролем, который возвращается один раз.
|
||||
func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInput) (User, string, error) {
|
||||
if !in.Role.Valid() {
|
||||
return User{}, "", apperr.Validation("role", CodeRoleInvalid, "unknown role", nil)
|
||||
}
|
||||
if !CanAssignRole(p.Role, in.Role) || !CanManageUser(actorOf(p), Actor{Role: in.Role}, UserCreate) {
|
||||
return User{}, "", forbidden()
|
||||
}
|
||||
username := NormalizeUsername(in.Username)
|
||||
if err := ValidateUsername("username", username); err != nil {
|
||||
return User{}, "", err
|
||||
}
|
||||
name, err := displayName(in.DisplayName)
|
||||
if err != nil {
|
||||
return User{}, "", err
|
||||
}
|
||||
password, err := GeneratePassword(tempPasswordLen)
|
||||
if err != nil {
|
||||
return User{}, "", err
|
||||
}
|
||||
hash, err := s.hasher.Hash(password)
|
||||
if err != nil {
|
||||
return User{}, "", err
|
||||
}
|
||||
locale := "ru"
|
||||
if len(s.cfg.SupportedLocales) > 0 {
|
||||
locale = s.cfg.SupportedLocales[0]
|
||||
}
|
||||
u := User{
|
||||
ID: s.ids.New(), Username: username, DisplayName: name, PasswordHash: hash, Role: in.Role, Locale: locale,
|
||||
MustChangePassword: true, CreatedAt: s.clock.Now(),
|
||||
}
|
||||
err = s.store.WithTx(ctx, func(tx Store) error {
|
||||
if err := tx.CreateUser(ctx, u, &p.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||
ActorID: &p.UserID, Action: "user.create", TargetType: "user", TargetID: &u.ID,
|
||||
Payload: map[string]any{"username": username, "role": string(in.Role)},
|
||||
})
|
||||
})
|
||||
if errors.Is(err, ErrUsernameTaken) {
|
||||
return User{}, "", apperr.Validation("username", CodeUsernameTaken, "username is taken", nil)
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, "", err
|
||||
}
|
||||
return u, password, nil
|
||||
}
|
||||
|
||||
// displayName обрезает пробелы и проверяет длину; пустое имя — nil.
|
||||
func displayName(s string) (*string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if utf8.RuneCountInString(s) > MaxDisplayName {
|
||||
return nil, apperr.Validation("display_name", CodeDisplayNameTooLong, "display name too long",
|
||||
map[string]any{"max": MaxDisplayName})
|
||||
}
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// UserPatch — изменения пользователя из админки. nil — не менять.
|
||||
type UserPatch struct {
|
||||
DisplayName *string
|
||||
Role *Role
|
||||
IsBlocked *bool
|
||||
}
|
||||
|
||||
// UpdateUser меняет имя, роль и блокировку с проверкой прав по каждому изменению.
|
||||
func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, patch UserPatch) (User, error) {
|
||||
if patch.Role != nil && !patch.Role.Valid() {
|
||||
return User{}, apperr.Validation("role", CodeRoleInvalid, "unknown role", nil)
|
||||
}
|
||||
var out User
|
||||
err := s.store.WithTx(ctx, func(tx Store) error {
|
||||
target, err := s.target(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actor, tgt := actorOf(p), actorOfUser(target)
|
||||
var (
|
||||
upd UserUpdate
|
||||
audits []AuditEntry
|
||||
)
|
||||
audit := func(action string, payload map[string]any) {
|
||||
audits = append(audits, AuditEntry{
|
||||
ActorID: &p.UserID, Action: action, TargetType: "user", TargetID: &target.ID, Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
if patch.DisplayName != nil {
|
||||
if !CanManageUser(actor, tgt, UserEdit) {
|
||||
return forbidden()
|
||||
}
|
||||
name, err := displayName(*patch.DisplayName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upd.DisplayName = &name
|
||||
audit("user.rename", map[string]any{"display_name": name})
|
||||
}
|
||||
roleChange := patch.Role != nil && *patch.Role != target.Role
|
||||
if roleChange {
|
||||
if !CanManageUser(actor, tgt, UserChangeRole) || !CanAssignRole(p.Role, *patch.Role) {
|
||||
return forbidden()
|
||||
}
|
||||
upd.Role, upd.RevokeTokens = patch.Role, true
|
||||
audit("user.role_change", map[string]any{"from": string(target.Role), "to": string(*patch.Role)})
|
||||
}
|
||||
blockChange := patch.IsBlocked != nil && *patch.IsBlocked != target.IsBlocked
|
||||
if blockChange {
|
||||
if !CanManageUser(actor, tgt, UserBlock) {
|
||||
return forbidden()
|
||||
}
|
||||
upd.IsBlocked, upd.RevokeTokens = patch.IsBlocked, true
|
||||
if *patch.IsBlocked {
|
||||
audit("user.block", nil)
|
||||
} else {
|
||||
audit("user.unblock", nil)
|
||||
}
|
||||
}
|
||||
if len(audits) == 0 {
|
||||
// Пустое изменение: отвечаем текущим состоянием, но только тем, кому оно доступно.
|
||||
if !CanManageUser(actor, tgt, UserEdit) {
|
||||
return forbidden()
|
||||
}
|
||||
out = target
|
||||
return nil
|
||||
}
|
||||
|
||||
losesAdmin := target.Role == RoleAdmin && !target.IsBlocked &&
|
||||
((roleChange && *patch.Role != RoleAdmin) || (blockChange && *patch.IsBlocked))
|
||||
if losesAdmin {
|
||||
if err := s.ensureNotLastAdmin(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if out, err = tx.UpdateUser(ctx, target.ID, upd); err != nil {
|
||||
return err
|
||||
}
|
||||
if blockChange && *patch.IsBlocked {
|
||||
if err := tx.RevokeUserSessions(ctx, target.ID, s.clock.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, a := range audits {
|
||||
if err := tx.InsertAudit(ctx, s.ids.New(), a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) target(ctx context.Context, st Store, id uuid.UUID) (User, error) {
|
||||
u, err := st.GetUserByID(ctx, id)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return User{}, apperr.NotFound(CodeUserNotFound, "user not found")
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Service) ensureNotLastAdmin(ctx context.Context, tx Store) error {
|
||||
n, err := tx.LockActiveAdmins(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
return apperr.Conflict(CodeLastAdmin, "the last active admin cannot be demoted, blocked or deleted")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPassword выдаёт новый временный пароль и завершает все сессии пользователя.
|
||||
func (s *Service) ResetPassword(ctx context.Context, p Principal, id uuid.UUID) (string, error) {
|
||||
password, err := GeneratePassword(tempPasswordLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash, err := s.hasher.Hash(password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = s.store.WithTx(ctx, func(tx Store) error {
|
||||
target, err := s.target(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !CanManageUser(actorOf(p), actorOfUser(target), UserResetPassword) {
|
||||
return forbidden()
|
||||
}
|
||||
if err := tx.ResetPassword(ctx, target.ID, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.RevokeUserSessions(ctx, target.ID, s.clock.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||
ActorID: &p.UserID, Action: "user.password_reset", TargetType: "user", TargetID: &target.ID,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// DeleteUser удаляет пользователя (только админ, не себя и не последнего админа).
|
||||
func (s *Service) DeleteUser(ctx context.Context, p Principal, id uuid.UUID) error {
|
||||
return s.store.WithTx(ctx, func(tx Store) error {
|
||||
target, err := s.target(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !CanManageUser(actorOf(p), actorOfUser(target), UserDelete) {
|
||||
return forbidden()
|
||||
}
|
||||
if target.Role == RoleAdmin && !target.IsBlocked {
|
||||
if err := s.ensureNotLastAdmin(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||
ActorID: &p.UserID, Action: "user.delete", TargetType: "user", TargetID: &target.ID,
|
||||
Payload: map[string]any{"username": target.Username, "role": string(target.Role)},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.DeleteUser(ctx, target.ID)
|
||||
})
|
||||
}
|
||||
|
||||
// ListAudit — страница журнала действий (только админ).
|
||||
func (s *Service) ListAudit(ctx context.Context, p Principal, cursor string, limit *int) ([]AuditRecord, string, error) {
|
||||
if !Can(p.Role, PermSystemStatus) {
|
||||
return nil, "", forbidden()
|
||||
}
|
||||
before := uuid.Nil
|
||||
if cursor != "" {
|
||||
var err error
|
||||
if before, err = uuid.Parse(cursor); err != nil {
|
||||
return nil, "", apperr.Validation("cursor", CodeCursorBad, "malformed cursor", nil)
|
||||
}
|
||||
}
|
||||
n := pageSize(limit)
|
||||
records, err := s.store.ListAudit(ctx, before, n+1)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
next := ""
|
||||
if len(records) > n {
|
||||
records = records[:n]
|
||||
next = records[n-1].ID.String()
|
||||
}
|
||||
return records, next, nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
)
|
||||
|
||||
// ListUsers реализует gen.StrictServerInterface.
|
||||
func (h *Handler) ListUsers(ctx context.Context, req gen.ListUsersRequestObject) (gen.ListUsersResponseObject, error) {
|
||||
p, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
q, cursor string
|
||||
role *Role
|
||||
)
|
||||
if req.Params.Q != nil {
|
||||
q = *req.Params.Q
|
||||
}
|
||||
if req.Params.Cursor != nil {
|
||||
cursor = *req.Params.Cursor
|
||||
}
|
||||
if req.Params.Role != nil {
|
||||
r := Role(*req.Params.Role)
|
||||
role = &r
|
||||
}
|
||||
users, next, err := h.svc.ListUsers(ctx, p, q, role, cursor, req.Params.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]gen.AdminUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
items = append(items, AdminUserDTO(u))
|
||||
}
|
||||
return gen.ListUsers200JSONResponse{Items: items, NextCursor: optional(next)}, nil
|
||||
}
|
||||
|
||||
// CreateUser реализует gen.StrictServerInterface.
|
||||
func (h *Handler) CreateUser(ctx context.Context, req gen.CreateUserRequestObject) (gen.CreateUserResponseObject, error) {
|
||||
p, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in := CreateUserInput{Username: req.Body.Username, Role: Role(req.Body.Role)}
|
||||
if req.Body.DisplayName != nil {
|
||||
in.DisplayName = *req.Body.DisplayName
|
||||
}
|
||||
u, password, err := h.svc.CreateUser(ctx, p, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.CreateUser201JSONResponse{User: AdminUserDTO(u), TemporaryPassword: password}, nil
|
||||
}
|
||||
|
||||
// UpdateUser реализует gen.StrictServerInterface.
|
||||
func (h *Handler) UpdateUser(ctx context.Context, req gen.UpdateUserRequestObject) (gen.UpdateUserResponseObject, error) {
|
||||
p, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patch := UserPatch{DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked}
|
||||
if req.Body.Role != nil {
|
||||
r := Role(*req.Body.Role)
|
||||
patch.Role = &r
|
||||
}
|
||||
u, err := h.svc.UpdateUser(ctx, p, req.Id, patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.UpdateUser200JSONResponse(AdminUserDTO(u)), nil
|
||||
}
|
||||
|
||||
// DeleteUser реализует gen.StrictServerInterface.
|
||||
func (h *Handler) DeleteUser(ctx context.Context, req gen.DeleteUserRequestObject) (gen.DeleteUserResponseObject, error) {
|
||||
p, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := h.svc.DeleteUser(ctx, p, req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.DeleteUser204Response{}, nil
|
||||
}
|
||||
|
||||
// ResetUserPassword реализует gen.StrictServerInterface.
|
||||
func (h *Handler) ResetUserPassword(ctx context.Context, req gen.ResetUserPasswordRequestObject) (gen.ResetUserPasswordResponseObject, error) {
|
||||
p, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
password, err := h.svc.ResetPassword(ctx, p, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.ResetUserPassword200JSONResponse{TemporaryPassword: password}, nil
|
||||
}
|
||||
|
||||
// ListAudit реализует gen.StrictServerInterface.
|
||||
func (h *Handler) ListAudit(ctx context.Context, req gen.ListAuditRequestObject) (gen.ListAuditResponseObject, error) {
|
||||
p, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cursor := ""
|
||||
if req.Params.Cursor != nil {
|
||||
cursor = *req.Params.Cursor
|
||||
}
|
||||
records, next, err := h.svc.ListAudit(ctx, p, cursor, req.Params.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]gen.AuditEntry, 0, len(records))
|
||||
for _, r := range records {
|
||||
e := gen.AuditEntry{
|
||||
Id: r.ID, Action: r.Action, TargetType: r.TargetType, TargetId: r.TargetID, CreatedAt: r.CreatedAt,
|
||||
}
|
||||
if r.ActorID != nil && r.ActorUsername != nil {
|
||||
e.Actor = &gen.AuditActor{Id: *r.ActorID, Username: *r.ActorUsername}
|
||||
}
|
||||
if r.Payload != nil {
|
||||
payload := r.Payload
|
||||
e.Payload = &payload
|
||||
}
|
||||
items = append(items, e)
|
||||
}
|
||||
return gen.ListAudit200JSONResponse{Items: items, NextCursor: optional(next)}, nil
|
||||
}
|
||||
|
||||
// AdminUserDTO переводит пользователя в схему AdminUser.
|
||||
func AdminUserDTO(u User) gen.AdminUser {
|
||||
return gen.AdminUser{
|
||||
Id: u.ID,
|
||||
Username: u.Username,
|
||||
DisplayName: u.DisplayName,
|
||||
Role: gen.Role(u.Role),
|
||||
Locale: u.Locale,
|
||||
IsBlocked: u.IsBlocked,
|
||||
MustChangePassword: u.MustChangePassword,
|
||||
MustChangeCredentials: u.MustChangeCredentials,
|
||||
CreatedAt: u.CreatedAt,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
}
|
||||
}
|
||||
|
||||
func optional(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
// signIn входит под пользователем, у которого может стоять must_change_password, и сразу меняет пароль.
|
||||
func (e *env) signIn(username, password string) string {
|
||||
e.t.Helper()
|
||||
r := e.mustLogin(username, password, "web")
|
||||
if user(r)["must_change_password"] == true {
|
||||
r = e.do(http.MethodPost, "/api/v1/me/password",
|
||||
map[string]any{"new_password": username + "-own-password"}, bearer(access(r)))
|
||||
require.Equal(e.t, http.StatusOK, r.Code, r.Body.String())
|
||||
}
|
||||
return access(r)
|
||||
}
|
||||
|
||||
func (e *env) createUser(token, username, role string) resp {
|
||||
e.t.Helper()
|
||||
return e.do(http.MethodPost, "/api/v1/admin/users",
|
||||
map[string]any{"username": username, "role": role, "display_name": " " + username + " "}, bearer(token))
|
||||
}
|
||||
|
||||
// Сценарий 2 из docs/testing.md: админ создаёт модератора, модератор — пользователя,
|
||||
// но не модератора.
|
||||
func TestAdminCreatesModeratorWhoCreatesUser(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
|
||||
created := e.createUser(admin, "Mod.One", "moderator")
|
||||
require.Equal(t, http.StatusCreated, created.Code, created.Body.String())
|
||||
modUser := created.body["user"].(map[string]any)
|
||||
assert.Equal(t, "mod.one", modUser["username"])
|
||||
assert.Equal(t, "Mod.One", modUser["display_name"])
|
||||
assert.Equal(t, true, modUser["must_change_password"])
|
||||
temp := created.body["temporary_password"].(string)
|
||||
assert.Len(t, temp, 12)
|
||||
|
||||
first := e.mustLogin("mod.one", temp, "web")
|
||||
assert.Equal(t, auth.CodePasswordChangeRequired,
|
||||
e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(access(first))).code())
|
||||
mod := e.signIn("mod.one", temp)
|
||||
|
||||
listenerUser := e.createUser(mod, "listener", "user")
|
||||
require.Equal(t, http.StatusCreated, listenerUser.Code)
|
||||
for _, role := range []string{"moderator", "admin"} {
|
||||
r := e.createUser(mod, "sneaky-"+role, role)
|
||||
assert.Equal(t, http.StatusForbidden, r.Code, role)
|
||||
assert.Equal(t, auth.CodeForbidden, r.code())
|
||||
}
|
||||
|
||||
list := e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(mod))
|
||||
require.Equal(t, http.StatusOK, list.Code)
|
||||
var names []string
|
||||
for _, it := range list.body["items"].([]any) {
|
||||
names = append(names, it.(map[string]any)["username"].(string))
|
||||
}
|
||||
assert.Equal(t, []string{"listener", "mod.one", "root"}, names)
|
||||
assert.Nil(t, list.body["next_cursor"])
|
||||
|
||||
listener := e.signIn("listener", listenerUser.body["temporary_password"].(string))
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(listener)).code())
|
||||
assert.Equal(t, auth.CodeForbidden, e.createUser(listener, "another", "user").code())
|
||||
}
|
||||
|
||||
func TestCreateUserValidation(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
|
||||
assert.Equal(t, auth.CodeUsernameTaken, e.createUser(admin, "ROOT", "user").code())
|
||||
assert.Equal(t, auth.CodeUsernameInvalid, e.createUser(admin, "no spaces", "user").code())
|
||||
assert.Equal(t, auth.CodeRoleInvalid, e.createUser(admin, "valid", "root").code())
|
||||
}
|
||||
|
||||
// Матрица «кто кого может» через API: сброс пароля, блокировка, смена роли, удаление.
|
||||
func TestManageUsersMatrix(t *testing.T) {
|
||||
type actorCase struct {
|
||||
role auth.Role
|
||||
// разрешено ли действие над целью с ролью user / moderator / admin
|
||||
reset, block, role2, del [3]bool
|
||||
}
|
||||
cases := []actorCase{
|
||||
{auth.RoleModerator, [3]bool{true, false, false}, [3]bool{true, false, false}, [3]bool{false, false, false}, [3]bool{false, false, false}},
|
||||
{auth.RoleAdmin, [3]bool{true, true, true}, [3]bool{true, true, true}, [3]bool{true, true, true}, [3]bool{true, true, true}},
|
||||
}
|
||||
targets := [3]auth.Role{auth.RoleUser, auth.RoleModerator, auth.RoleAdmin}
|
||||
for _, ac := range cases {
|
||||
for ti, tr := range targets {
|
||||
actions := []struct {
|
||||
name string
|
||||
allowed bool
|
||||
call func(e *env, tok, id string) resp
|
||||
}{
|
||||
{"reset", ac.reset[ti], func(e *env, tok, id string) resp {
|
||||
return e.do(http.MethodPost, "/api/v1/admin/users/"+id+"/reset-password", nil, bearer(tok))
|
||||
}},
|
||||
{"block", ac.block[ti], func(e *env, tok, id string) resp {
|
||||
return e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"is_blocked": true}, bearer(tok))
|
||||
}},
|
||||
{"role", ac.role2[ti], func(e *env, tok, id string) resp {
|
||||
next := "user"
|
||||
if tr == auth.RoleUser {
|
||||
next = "moderator"
|
||||
}
|
||||
return e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"role": next}, bearer(tok))
|
||||
}},
|
||||
{"delete", ac.del[ti], func(e *env, tok, id string) resp {
|
||||
return e.do(http.MethodDelete, "/api/v1/admin/users/"+id, nil, bearer(tok))
|
||||
}},
|
||||
}
|
||||
for _, a := range actions {
|
||||
t.Run(fmt.Sprintf("%s %s %s", ac.role, a.name, tr), func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("actor", "actor-password-1", ac.role)
|
||||
e.addUser("spare-admin", "spare-password-1", auth.RoleAdmin) // чтобы не упереться в «последнего админа»
|
||||
target := e.addUser("target", "target-password", tr)
|
||||
tok := e.signIn("actor", "actor-password-1")
|
||||
r := a.call(e, tok, target.ID.String())
|
||||
if a.allowed {
|
||||
assert.Less(t, r.Code, 300, r.Body.String())
|
||||
} else {
|
||||
assert.Equal(t, http.StatusForbidden, r.Code, r.Body.String())
|
||||
assert.Equal(t, auth.CodeForbidden, r.code())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeratorCannotTouchSelfOrRoles(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
mod := e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
|
||||
u := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||
tok := e.signIn("mod", "moderator-pass-1")
|
||||
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+mod.ID.String(),
|
||||
map[string]any{"display_name": "Boss"}, bearer(tok)).code())
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+u.ID.String(),
|
||||
map[string]any{"display_name": "ok", "role": "moderator"}, bearer(tok)).code(), "one forbidden change rejects the patch")
|
||||
assert.Nil(t, e.store.Users[u.ID].DisplayName, "rejected patch changes nothing")
|
||||
|
||||
ok := e.do(http.MethodPatch, "/api/v1/admin/users/"+u.ID.String(),
|
||||
map[string]any{"display_name": "Listener", "role": "user"}, bearer(tok))
|
||||
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
|
||||
assert.Equal(t, "Listener", ok.body["display_name"])
|
||||
}
|
||||
|
||||
func TestAdminCannotDemoteBlockOrDeleteSelf(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
root := e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
e.addUser("other", "other-password-1", auth.RoleAdmin)
|
||||
tok := e.signIn("root", "root-password-1")
|
||||
id := root.ID.String()
|
||||
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"role": "user"}, bearer(tok)).code())
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"is_blocked": true}, bearer(tok)).code())
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodDelete, "/api/v1/admin/users/"+id, nil, bearer(tok)).code())
|
||||
rename := e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"display_name": "Root"}, bearer(tok))
|
||||
assert.Equal(t, http.StatusOK, rename.Code)
|
||||
}
|
||||
|
||||
func TestLastAdminIsProtected(t *testing.T) {
|
||||
// Через API последнего админа не затронуть: себя менять нельзя, а действующий админ — второй.
|
||||
// Остаётся гонка (админ удалён, пока его запрос в пути) — её и закрывает проверка в сервисе.
|
||||
e := newEnv(t)
|
||||
only := e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
ghost := auth.Principal{UserID: uuid.New(), Role: auth.RoleAdmin}
|
||||
ctx := context.Background()
|
||||
|
||||
role := auth.RoleUser
|
||||
blocked := true
|
||||
for name, call := range map[string]func() error{
|
||||
"demote": func() error { _, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{Role: &role}); return err },
|
||||
"block": func() error {
|
||||
_, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{IsBlocked: &blocked})
|
||||
return err
|
||||
},
|
||||
"delete": func() error { return e.svc.DeleteUser(ctx, ghost, only.ID) },
|
||||
} {
|
||||
err := call()
|
||||
var ae *apperr.Error
|
||||
require.ErrorAs(t, err, &ae, name)
|
||||
assert.Equal(t, auth.CodeLastAdmin, ae.Code, name)
|
||||
assert.Equal(t, http.StatusConflict, ae.Status, name)
|
||||
}
|
||||
assert.Equal(t, auth.RoleAdmin, e.store.Users[only.ID].Role)
|
||||
assert.False(t, e.store.Users[only.ID].IsBlocked)
|
||||
assert.Empty(t, e.store.Audit, "rolled back changes leave no audit")
|
||||
|
||||
// Заблокированный админ не считается: второго активного нет.
|
||||
e.addUser("sleeping", "sleeping-pass-1", auth.RoleAdmin, func(u *auth.User) { u.IsBlocked = true })
|
||||
_, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{Role: &role})
|
||||
assert.ErrorIs(t, err, apperr.Conflict(auth.CodeLastAdmin, ""))
|
||||
}
|
||||
|
||||
func TestBlockAndRoleChangeRevokeAccess(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
target := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
web := e.mustLogin("listener", "listener-pass", "web")
|
||||
cookie := web.cookie(auth.RefreshCookie)
|
||||
|
||||
r := e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"role": "moderator"}, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
assert.Equal(t, "moderator", r.body["role"])
|
||||
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(web))).code())
|
||||
|
||||
refreshed := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(cookie))
|
||||
require.Equal(t, http.StatusOK, refreshed.Code, "role change keeps sessions")
|
||||
assert.Equal(t, "moderator", user(refreshed)["role"])
|
||||
tok := access(refreshed)
|
||||
|
||||
r = e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"is_blocked": true}, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code)
|
||||
assert.Equal(t, true, r.body["is_blocked"])
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).Code)
|
||||
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||
e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(refreshed.cookie(auth.RefreshCookie))).code(),
|
||||
"blocking ends sessions")
|
||||
|
||||
r = e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"is_blocked": false}, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code)
|
||||
e.mustLogin("listener", "listener-pass", "web")
|
||||
|
||||
var actions []string
|
||||
for _, a := range e.store.Audit {
|
||||
actions = append(actions, a.Action)
|
||||
}
|
||||
assert.Equal(t, []string{"user.role_change", "user.block", "user.unblock"}, actions)
|
||||
assert.Equal(t, map[string]any{"from": "user", "to": "moderator"}, e.store.Audit[0].Payload)
|
||||
}
|
||||
|
||||
func TestNoOpPatch(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
target := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
r := e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(),
|
||||
map[string]any{"role": "user", "is_blocked": false}, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code)
|
||||
assert.Empty(t, e.store.Audit)
|
||||
assert.Zero(t, e.store.Users[target.ID].TokenVersion)
|
||||
|
||||
assert.Equal(t, auth.CodeUserNotFound, e.do(http.MethodPatch, "/api/v1/admin/users/"+uuid.NewString(),
|
||||
map[string]any{"display_name": "x"}, bearer(admin)).code())
|
||||
}
|
||||
|
||||
// Сценарий 10 из docs/testing.md: сброс пароля → обязательная смена при входе.
|
||||
func TestResetPassword(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
|
||||
target := e.addUser("listener", "listener-pass", auth.RoleUser, func(u *auth.User) { u.FailedLoginCount = 9 })
|
||||
mod := e.signIn("mod", "moderator-pass-1")
|
||||
phone := e.mustLogin("listener", "listener-pass", "android")
|
||||
|
||||
r := e.do(http.MethodPost, "/api/v1/admin/users/"+target.ID.String()+"/reset-password", nil, bearer(mod))
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
temp := r.body["temporary_password"].(string)
|
||||
|
||||
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(phone))).code())
|
||||
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh",
|
||||
map[string]any{"refresh_token": phone.body["refresh_token"]}).code())
|
||||
assert.Equal(t, auth.CodeInvalidCredentials, e.login("listener", "listener-pass", "web").code())
|
||||
|
||||
again := e.mustLogin("listener", temp, "web")
|
||||
assert.Equal(t, true, user(again)["must_change_password"])
|
||||
assert.Equal(t, auth.CodePasswordChangeRequired, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(again))).code())
|
||||
assert.Equal(t, "user.password_reset", e.store.Audit[len(e.store.Audit)-1].Action)
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
target := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
tok := access(e.mustLogin("listener", "listener-pass", "web"))
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, e.do(http.MethodDelete, "/api/v1/admin/users/"+target.ID.String(), nil, bearer(admin)).Code)
|
||||
assert.NotContains(t, e.store.Users, target.ID)
|
||||
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
|
||||
assert.Equal(t, auth.CodeUserNotFound, e.do(http.MethodDelete, "/api/v1/admin/users/"+target.ID.String(), nil, bearer(admin)).code())
|
||||
last := e.store.Audit[len(e.store.Audit)-1]
|
||||
assert.Equal(t, "user.delete", last.Action)
|
||||
assert.Equal(t, "listener", last.Payload["username"])
|
||||
}
|
||||
|
||||
func TestListUsersFiltersAndPages(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
for _, n := range []string{"anna", "boris", "vera", "gleb", "dina"} {
|
||||
e.addUser(n, n+"-password", auth.RoleUser)
|
||||
}
|
||||
name := "Анна Каренина"
|
||||
e.addUser("kate", "kate-password", auth.RoleModerator, func(u *auth.User) { u.DisplayName = &name })
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
list := func(q url.Values) resp {
|
||||
return e.do(http.MethodGet, "/api/v1/admin/users?"+q.Encode(), nil, bearer(admin))
|
||||
}
|
||||
usernames := func(r resp) []string {
|
||||
var out []string
|
||||
for _, it := range r.body["items"].([]any) {
|
||||
out = append(out, it.(map[string]any)["username"].(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
all []string
|
||||
cursor string
|
||||
)
|
||||
for page := 0; ; page++ {
|
||||
q := url.Values{"limit": {"3"}}
|
||||
if cursor != "" {
|
||||
q.Set("cursor", cursor)
|
||||
}
|
||||
r := list(q)
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
all = append(all, usernames(r)...)
|
||||
next, _ := r.body["next_cursor"].(string)
|
||||
if next == "" {
|
||||
break
|
||||
}
|
||||
cursor = next
|
||||
require.Less(t, page, 5)
|
||||
}
|
||||
assert.Equal(t, []string{"anna", "boris", "dina", "gleb", "kate", "root", "vera"}, all)
|
||||
|
||||
assert.Equal(t, []string{"kate"}, usernames(list(url.Values{"q": {"анна"}})), "matches display name, case-insensitive")
|
||||
assert.Equal(t, []string{"anna"}, usernames(list(url.Values{"q": {"AN"}})))
|
||||
assert.Equal(t, []string{"kate"}, usernames(list(url.Values{"role": {"moderator"}})))
|
||||
assert.Equal(t, auth.CodeCursorBad, list(url.Values{"cursor": {"%%%"}}).code())
|
||||
assert.Equal(t, http.StatusBadRequest, list(url.Values{"limit": {"lots"}}).Code)
|
||||
}
|
||||
|
||||
func TestAudit(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
for _, n := range []string{"u1", "u2", "u3"} {
|
||||
require.Equal(t, http.StatusCreated, e.createUser(admin, n+"-user", "user").Code)
|
||||
}
|
||||
|
||||
r := e.do(http.MethodGet, "/api/v1/admin/audit?limit=2", nil, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
items := r.body["items"].([]any)
|
||||
require.Len(t, items, 2)
|
||||
newest := items[0].(map[string]any)
|
||||
assert.Equal(t, "user.create", newest["action"])
|
||||
assert.Equal(t, "u3-user", newest["payload"].(map[string]any)["username"])
|
||||
assert.Equal(t, "root", newest["actor"].(map[string]any)["username"])
|
||||
assert.Equal(t, "user", newest["target_type"])
|
||||
|
||||
next := r.body["next_cursor"].(string)
|
||||
r2 := e.do(http.MethodGet, "/api/v1/admin/audit?limit=2&cursor="+next, nil, bearer(admin))
|
||||
rest := r2.body["items"].([]any)
|
||||
require.Len(t, rest, 1)
|
||||
assert.Equal(t, "u1-user", rest[0].(map[string]any)["payload"].(map[string]any)["username"])
|
||||
assert.Nil(t, r2.body["next_cursor"])
|
||||
|
||||
mod := e.signIn("mod", "moderator-pass-1")
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodGet, "/api/v1/admin/audit", nil, bearer(mod)).code())
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type MemStore struct {
|
||||
Users map[uuid.UUID]auth.User
|
||||
Tokens map[uuid.UUID]auth.RefreshToken
|
||||
Audit []auth.AuditEntry
|
||||
AuditIDs []uuid.UUID
|
||||
Settings map[string]any
|
||||
}
|
||||
|
||||
@@ -48,13 +49,16 @@ func (s *MemStore) lock() func() {
|
||||
func (s *MemStore) WithTx(_ context.Context, fn func(auth.Store) error) error {
|
||||
unlock := s.lock()
|
||||
defer unlock()
|
||||
users, tokens, audit, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), slices.Clone(s.Audit), maps.Clone(s.Settings)
|
||||
tx := &MemStore{mu: s.mu, inTx: true, Users: s.Users, Tokens: s.Tokens, Audit: s.Audit, Settings: s.Settings}
|
||||
users, tokens, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), maps.Clone(s.Settings)
|
||||
audit, auditIDs := slices.Clone(s.Audit), slices.Clone(s.AuditIDs)
|
||||
tx := &MemStore{
|
||||
mu: s.mu, inTx: true, Users: s.Users, Tokens: s.Tokens, Audit: s.Audit, AuditIDs: s.AuditIDs, Settings: s.Settings,
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
s.Users, s.Tokens, s.Audit, s.Settings = users, tokens, audit, settings
|
||||
s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings = users, tokens, audit, auditIDs, settings
|
||||
return err
|
||||
}
|
||||
s.Users, s.Tokens, s.Audit, s.Settings = tx.Users, tx.Tokens, tx.Audit, tx.Settings
|
||||
s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings = tx.Users, tx.Tokens, tx.Audit, tx.AuditIDs, tx.Settings
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -251,13 +255,120 @@ func (s *MemStore) ListSessions(_ context.Context, userID uuid.UUID, now time.Ti
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InsertAudit реализует auth.Store.
|
||||
func (s *MemStore) InsertAudit(_ context.Context, _ uuid.UUID, e auth.AuditEntry) error {
|
||||
// ListUsers реализует auth.Store.
|
||||
func (s *MemStore) ListUsers(_ context.Context, f auth.UserFilter) ([]auth.User, error) {
|
||||
defer s.lock()()
|
||||
q := strings.ToLower(f.Query)
|
||||
var out []auth.User
|
||||
for _, u := range s.Users {
|
||||
name := ""
|
||||
if u.DisplayName != nil {
|
||||
name = strings.ToLower(*u.DisplayName)
|
||||
}
|
||||
if q != "" && !strings.Contains(strings.ToLower(u.Username), q) && !strings.Contains(name, q) {
|
||||
continue
|
||||
}
|
||||
if f.Role != nil && u.Role != *f.Role {
|
||||
continue
|
||||
}
|
||||
if f.After != "" && strings.ToLower(u.Username) <= strings.ToLower(f.After) {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneUser(u))
|
||||
}
|
||||
slices.SortFunc(out, func(a, b auth.User) int {
|
||||
return strings.Compare(strings.ToLower(a.Username), strings.ToLower(b.Username))
|
||||
})
|
||||
if len(out) > f.Limit {
|
||||
out = out[:f.Limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LockActiveAdmins реализует auth.Store.
|
||||
func (s *MemStore) LockActiveAdmins(ctx context.Context) (int, error) { return s.CountAdmins(ctx) }
|
||||
|
||||
// UpdateUser реализует auth.Store.
|
||||
func (s *MemStore) UpdateUser(ctx context.Context, id uuid.UUID, upd auth.UserUpdate) (auth.User, error) {
|
||||
err := s.update(id, func(u *auth.User) {
|
||||
if upd.DisplayName != nil {
|
||||
u.DisplayName = *upd.DisplayName
|
||||
}
|
||||
if upd.Role != nil {
|
||||
u.Role = *upd.Role
|
||||
}
|
||||
if upd.IsBlocked != nil {
|
||||
u.IsBlocked = *upd.IsBlocked
|
||||
}
|
||||
if upd.RevokeTokens {
|
||||
u.TokenVersion++
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
return s.GetUserByID(ctx, id)
|
||||
}
|
||||
|
||||
// ResetPassword реализует auth.Store.
|
||||
func (s *MemStore) ResetPassword(_ context.Context, id uuid.UUID, hash string) error {
|
||||
return s.update(id, func(u *auth.User) {
|
||||
u.PasswordHash, u.MustChangePassword = hash, true
|
||||
u.FailedLoginCount, u.LockedUntil = 0, nil
|
||||
u.TokenVersion++
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteUser реализует auth.Store: токены удаляются, ссылки в журнале обнуляются, как в PostgreSQL.
|
||||
func (s *MemStore) DeleteUser(_ context.Context, id uuid.UUID) error {
|
||||
defer s.lock()()
|
||||
delete(s.Users, id)
|
||||
for tid, t := range s.Tokens {
|
||||
if t.UserID == id {
|
||||
delete(s.Tokens, tid)
|
||||
}
|
||||
}
|
||||
for i := range s.Audit {
|
||||
if s.Audit[i].ActorID != nil && *s.Audit[i].ActorID == id {
|
||||
s.Audit[i].ActorID = nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertAudit реализует auth.Store. ID сохраняется в AuditIDs параллельно Audit.
|
||||
func (s *MemStore) InsertAudit(_ context.Context, id uuid.UUID, e auth.AuditEntry) error {
|
||||
defer s.lock()()
|
||||
s.Audit = append(s.Audit, e)
|
||||
s.AuditIDs = append(s.AuditIDs, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAudit реализует auth.Store.
|
||||
func (s *MemStore) ListAudit(_ context.Context, before uuid.UUID, limit int) ([]auth.AuditRecord, error) {
|
||||
defer s.lock()()
|
||||
var out []auth.AuditRecord
|
||||
for i := len(s.Audit) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
id, e := s.AuditIDs[i], s.Audit[i]
|
||||
if before != uuid.Nil && id.String() >= before.String() {
|
||||
continue
|
||||
}
|
||||
r := auth.AuditRecord{ID: id, ActorID: e.ActorID, Action: e.Action, TargetID: e.TargetID, Payload: e.Payload}
|
||||
if e.TargetType != "" {
|
||||
tt := e.TargetType
|
||||
r.TargetType = &tt
|
||||
}
|
||||
if e.ActorID != nil {
|
||||
if u, ok := s.Users[*e.ActorID]; ok {
|
||||
name := u.Username
|
||||
r.ActorUsername = &name
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetSettingBool реализует auth.Store.
|
||||
func (s *MemStore) GetSettingBool(_ context.Context, key string) (bool, error) {
|
||||
defer s.lock()()
|
||||
|
||||
@@ -95,6 +95,18 @@ type Principal struct {
|
||||
MustChangeCredentials bool
|
||||
}
|
||||
|
||||
// AuditRecord — прочитанная запись audit_log с логином автора.
|
||||
type AuditRecord struct {
|
||||
ID uuid.UUID
|
||||
ActorID *uuid.UUID
|
||||
ActorUsername *string
|
||||
Action string
|
||||
TargetType *string
|
||||
TargetID *uuid.UUID
|
||||
Payload map[string]any
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AuditEntry — запись audit_log.
|
||||
type AuditEntry struct {
|
||||
ActorID *uuid.UUID
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -273,6 +274,135 @@ func (s *PgStore) ListSessions(ctx context.Context, userID uuid.UUID, now time.T
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListUsers реализует Store.
|
||||
func (s *PgStore) ListUsers(ctx context.Context, f UserFilter) ([]User, error) {
|
||||
var role *string
|
||||
if f.Role != nil {
|
||||
r := string(*f.Role)
|
||||
role = &r
|
||||
}
|
||||
// Спецсимволы LIKE в запросе экранируются: ищем подстроку буквально.
|
||||
pattern := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(f.Query) + "%"
|
||||
rows, err := s.q.Query(ctx, `
|
||||
SELECT `+userColumns+` FROM users
|
||||
WHERE ($1 = '%%' OR username ILIKE $1 OR display_name ILIKE $1)
|
||||
AND ($2::user_role IS NULL OR role = $2::user_role)
|
||||
AND ($3 = '' OR username > $3::citext)
|
||||
ORDER BY username
|
||||
LIMIT $4`, pattern, role, f.After, f.Limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: list users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("auth: list users: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LockActiveAdmins реализует Store.
|
||||
func (s *PgStore) LockActiveAdmins(ctx context.Context) (int, error) {
|
||||
rows, err := s.q.Query(ctx, `SELECT id FROM users WHERE role = 'admin' AND NOT is_blocked FOR UPDATE`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("auth: lock admins: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
n++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, fmt.Errorf("auth: lock admins: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// UpdateUser реализует Store.
|
||||
func (s *PgStore) UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate) (User, error) {
|
||||
var (
|
||||
setName bool
|
||||
name *string
|
||||
role *string
|
||||
)
|
||||
if upd.DisplayName != nil {
|
||||
setName, name = true, *upd.DisplayName
|
||||
}
|
||||
if upd.Role != nil {
|
||||
r := string(*upd.Role)
|
||||
role = &r
|
||||
}
|
||||
return scanUser(s.q.QueryRow(ctx, `
|
||||
UPDATE users SET
|
||||
display_name = CASE WHEN $2 THEN $3 ELSE display_name END,
|
||||
role = COALESCE($4::user_role, role),
|
||||
is_blocked = COALESCE($5, is_blocked),
|
||||
token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens))
|
||||
}
|
||||
|
||||
// ResetPassword реализует Store.
|
||||
func (s *PgStore) ResetPassword(ctx context.Context, id uuid.UUID, hash string) error {
|
||||
_, err := s.q.Exec(ctx, `
|
||||
UPDATE users SET password_hash = $2, must_change_password = true, failed_login_count = 0,
|
||||
locked_until = NULL, token_version = token_version + 1, updated_at = now()
|
||||
WHERE id = $1`, id, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: reset password: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser реализует Store. Токены удаляются каскадно, ссылки в audit_log и created_by обнуляются.
|
||||
func (s *PgStore) DeleteUser(ctx context.Context, id uuid.UUID) error {
|
||||
if _, err := s.q.Exec(ctx, `DELETE FROM users WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("auth: delete user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAudit реализует Store.
|
||||
func (s *PgStore) ListAudit(ctx context.Context, before uuid.UUID, limit int) ([]AuditRecord, error) {
|
||||
rows, err := s.q.Query(ctx, `
|
||||
SELECT a.id, a.actor_id, u.username::text, a.action, a.target_type, a.target_id, a.payload, a.created_at
|
||||
FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id
|
||||
WHERE $1 = '00000000-0000-0000-0000-000000000000'::uuid OR a.id < $1
|
||||
ORDER BY a.id DESC
|
||||
LIMIT $2`, before, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: list audit: %w", err)
|
||||
}
|
||||
out, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (AuditRecord, error) {
|
||||
var (
|
||||
r AuditRecord
|
||||
payload []byte
|
||||
)
|
||||
if err := row.Scan(&r.ID, &r.ActorID, &r.ActorUsername, &r.Action, &r.TargetType, &r.TargetID, &payload,
|
||||
&r.CreatedAt); err != nil {
|
||||
return r, err
|
||||
}
|
||||
if payload != nil {
|
||||
if err := json.Unmarshal(payload, &r.Payload); err != nil {
|
||||
return r, err
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: list audit: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InsertAudit реализует Store.
|
||||
func (s *PgStore) InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error {
|
||||
var payload []byte
|
||||
|
||||
@@ -21,6 +21,22 @@ type ProfileUpdate struct {
|
||||
UIPrefs *UIPrefs
|
||||
}
|
||||
|
||||
// UserFilter — параметры списка пользователей.
|
||||
type UserFilter struct {
|
||||
Query string // подстрока логина или имени
|
||||
Role *Role
|
||||
After string // логин последнего элемента предыдущей страницы
|
||||
Limit int
|
||||
}
|
||||
|
||||
// UserUpdate — изменения из админки. nil — не менять.
|
||||
type UserUpdate struct {
|
||||
DisplayName **string
|
||||
Role *Role
|
||||
IsBlocked *bool
|
||||
RevokeTokens bool
|
||||
}
|
||||
|
||||
// Store — доступ к users, refresh_tokens, audit_log и app_settings.
|
||||
type Store interface {
|
||||
// WithTx выполняет fn в транзакции; Store внутри fn работает в ней же.
|
||||
@@ -49,7 +65,20 @@ type Store interface {
|
||||
// ListSessions — активные семьи пользователя: есть неиспользованный, неотозванный, не истёкший токен.
|
||||
ListSessions(ctx context.Context, userID uuid.UUID, now time.Time) ([]Session, error)
|
||||
|
||||
// ListUsers — страница пользователей по возрастанию логина, после логина f.After.
|
||||
ListUsers(ctx context.Context, f UserFilter) ([]User, error)
|
||||
// LockActiveAdmins блокирует строки активных админов до конца транзакции и возвращает их число:
|
||||
// два параллельных понижения не оставят систему без админа.
|
||||
LockActiveAdmins(ctx context.Context) (int, error)
|
||||
// UpdateUser применяет изменения админки; RevokeTokens увеличивает token_version.
|
||||
UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate) (User, error)
|
||||
// ResetPassword ставит временный пароль: must_change_password, сброс блокировки входа, token_version + 1.
|
||||
ResetPassword(ctx context.Context, id uuid.UUID, hash string) error
|
||||
DeleteUser(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error
|
||||
// ListAudit — записи журнала новее-первыми, с id меньше before (uuid.Nil — с начала).
|
||||
ListAudit(ctx context.Context, before uuid.UUID, limit int) ([]AuditRecord, error)
|
||||
GetSettingBool(ctx context.Context, key string) (bool, error)
|
||||
SetSetting(ctx context.Context, key string, value any) error
|
||||
}
|
||||
|
||||
@@ -57,5 +57,9 @@ func NewRouter(d Deps) http.Handler {
|
||||
RequestErrorHandlerFunc: httpx.RequestError,
|
||||
ResponseErrorHandlerFunc: httpx.ResponseError(log),
|
||||
})
|
||||
return gen.HandlerWithOptions(strict, gen.ChiServerOptions{BaseRouter: r})
|
||||
return gen.HandlerWithOptions(strict, gen.ChiServerOptions{
|
||||
BaseRouter: r,
|
||||
// Ошибки разбора path- и query-параметров — тоже problem+json, а не текст по умолчанию.
|
||||
ErrorHandlerFunc: httpx.RequestError,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user