From f075863f1520b3690deafdb542bbb1f69c1e08cc Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:18:02 +0300 Subject: [PATCH] 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 --- api/openapi.yaml | 266 ++++++ packages/api-client/src/generated/schema.ts | 306 +++++- packages/i18n/locales/en/errors.json | 11 +- packages/i18n/locales/ru/errors.json | 11 +- services/api/internal/api/gen/api.gen.go | 882 ++++++++++++++++-- services/api/internal/auth/admin.go | 341 +++++++ services/api/internal/auth/admin_handler.go | 152 +++ services/api/internal/auth/admin_test.go | 379 ++++++++ .../api/internal/auth/authtest/memstore.go | 123 ++- services/api/internal/auth/model.go | 12 + services/api/internal/auth/pgstore.go | 130 +++ services/api/internal/auth/store.go | 29 + services/api/internal/server/router.go | 6 +- 13 files changed, 2578 insertions(+), 70 deletions(-) create mode 100644 services/api/internal/auth/admin.go create mode 100644 services/api/internal/auth/admin_handler.go create mode 100644 services/api/internal/auth/admin_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 40c5fb3..6f6a34a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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] diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index d6fadfc..a8f5941 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -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; 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"]; + }; + }; } diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index 551a677..c9c899f 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -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" } } diff --git a/packages/i18n/locales/ru/errors.json b/packages/i18n/locales/ru/errors.json index 2389350..56fd24d 100644 --- a/packages/i18n/locales/ru/errors.json +++ b/packages/i18n/locales/ru/errors.json @@ -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": "Нельзя понизить, заблокировать или удалить последнего администратора" } } diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index ed03545..500dd44 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -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 diff --git a/services/api/internal/auth/admin.go b/services/api/internal/auth/admin.go new file mode 100644 index 0000000..d0e54fa --- /dev/null +++ b/services/api/internal/auth/admin.go @@ -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 +} diff --git a/services/api/internal/auth/admin_handler.go b/services/api/internal/auth/admin_handler.go new file mode 100644 index 0000000..f552c83 --- /dev/null +++ b/services/api/internal/auth/admin_handler.go @@ -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 +} diff --git a/services/api/internal/auth/admin_test.go b/services/api/internal/auth/admin_test.go new file mode 100644 index 0000000..afe4b63 --- /dev/null +++ b/services/api/internal/auth/admin_test.go @@ -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()) +} diff --git a/services/api/internal/auth/authtest/memstore.go b/services/api/internal/auth/authtest/memstore.go index fb34bbf..6bb551b 100644 --- a/services/api/internal/auth/authtest/memstore.go +++ b/services/api/internal/auth/authtest/memstore.go @@ -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()() diff --git a/services/api/internal/auth/model.go b/services/api/internal/auth/model.go index 0604e32..221a70a 100644 --- a/services/api/internal/auth/model.go +++ b/services/api/internal/auth/model.go @@ -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 diff --git a/services/api/internal/auth/pgstore.go b/services/api/internal/auth/pgstore.go index 4656611..ce63ed5 100644 --- a/services/api/internal/auth/pgstore.go +++ b/services/api/internal/auth/pgstore.go @@ -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 diff --git a/services/api/internal/auth/store.go b/services/api/internal/auth/store.go index c9ea7db..6f271d1 100644 --- a/services/api/internal/auth/store.go +++ b/services/api/internal/auth/store.go @@ -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 } diff --git a/services/api/internal/server/router.go b/services/api/internal/server/router.go index 19e4a60..8a075f2 100644 --- a/services/api/internal/server/router.go +++ b/services/api/internal/server/router.go @@ -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, + }) }