feat(api): user management and audit log for admins (M1)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 38s
ci / api / api (push) Successful in 18s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 35s

- /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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:18:02 +03:00
1 parent ed4a492dba
commit f075863f15
13 files changed
+2578 -70

No files matched your search

+266
View File
@@ -11,6 +11,7 @@ tags:
- name: system - name: system
- name: auth - name: auth
- name: me - name: me
- name: admin
security: security:
- bearerAuth: [] - bearerAuth: []
paths: paths:
@@ -251,7 +252,146 @@ paths:
description: Сессия завершена description: Сессия завершена
default: default:
$ref: "#/components/responses/Problem" $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: 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: securitySchemes:
bearerAuth: bearerAuth:
type: http type: http
@@ -429,6 +569,132 @@ components:
type: string type: string
new_password: new_password:
type: string 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: Session:
type: object type: object
required: [id, device_name, client_type, created_at, last_used_at, current] required: [id, device_name, client_type, created_at, last_used_at, current]
+305 -1
View File
@@ -234,6 +234,94 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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 type webhooks = Record<string, never>;
export interface components { export interface components {
@@ -340,6 +428,69 @@ export interface components {
new_username: string; new_username: string;
new_password: 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: { Session: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
@@ -388,7 +539,12 @@ export interface components {
}; };
}; };
}; };
parameters: never; parameters: {
Limit: number;
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
Cursor: string;
UserId: string;
};
requestBodies: never; requestBodies: never;
headers: never; headers: never;
pathItems: never; pathItems: never;
@@ -693,4 +849,152 @@ export interface operations {
default: components["responses"]["Problem"]; 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"];
};
};
} }
+9 -2
View File
@@ -21,7 +21,8 @@
"current_password_required": "Enter your current password", "current_password_required": "Enter your current password",
"current_password_invalid": "The current password is wrong", "current_password_invalid": "The current password is wrong",
"bootstrap_not_required": "The administrator credentials have already been changed", "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": { "validation": {
"password_too_short": "The password must be at least {{min}} characters", "password_too_short": "The password must be at least {{min}} characters",
@@ -34,6 +35,12 @@
"locale_unsupported": "This language is not supported", "locale_unsupported": "This language is not supported",
"display_name_too_long": "The name must be at most {{max}} characters", "display_name_too_long": "The name must be at most {{max}} characters",
"ui_prefs_invalid": "Invalid setting value", "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"
} }
} }
+9 -2
View File
@@ -21,7 +21,8 @@
"current_password_required": "Введите текущий пароль", "current_password_required": "Введите текущий пароль",
"current_password_invalid": "Текущий пароль указан неверно", "current_password_invalid": "Текущий пароль указан неверно",
"bootstrap_not_required": "Учётные данные администратора уже изменены", "bootstrap_not_required": "Учётные данные администратора уже изменены",
"session_not_found": "Сессия не найдена" "session_not_found": "Сессия не найдена",
"forbidden": "Недостаточно прав для этого действия"
}, },
"validation": { "validation": {
"password_too_short": "Пароль должен быть не короче {{min}} символов", "password_too_short": "Пароль должен быть не короче {{min}} символов",
@@ -34,6 +35,12 @@
"locale_unsupported": "Этот язык не поддерживается", "locale_unsupported": "Этот язык не поддерживается",
"display_name_too_long": "Имя должно быть не длиннее {{max}} символов", "display_name_too_long": "Имя должно быть не длиннее {{max}} символов",
"ui_prefs_invalid": "Недопустимое значение настройки", "ui_prefs_invalid": "Недопустимое значение настройки",
"client_type_invalid": "Неизвестный тип приложения" "client_type_invalid": "Неизвестный тип приложения",
"role_invalid": "Неизвестная роль",
"cursor_invalid": "Список устарел, обновите страницу"
},
"users": {
"not_found": "Пользователь не найден",
"last_admin": "Нельзя понизить, заблокировать или удалить последнего администратора"
} }
} }
+824 -58
View File
@@ -81,6 +81,51 @@ const (
Island UiPrefsWebPlayer = "island" 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. // AuthTokens defines model for AuthTokens.
type AuthTokens struct { type AuthTokens struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
@@ -112,6 +157,20 @@ type ChangePasswordRequest struct {
// ClientType defines model for ClientType. // ClientType defines model for ClientType.
type ClientType string 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. // FieldError defines model for FieldError.
type FieldError struct { type FieldError struct {
Code string `json:"code"` Code string `json:"code"`
@@ -218,6 +277,11 @@ type SessionList struct {
Items []Session `json:"items"` Items []Session `json:"items"`
} }
// TemporaryPassword defines model for TemporaryPassword.
type TemporaryPassword struct {
TemporaryPassword string `json:"temporary_password"`
}
// UiPrefs defines model for UiPrefs. // UiPrefs defines model for UiPrefs.
type UiPrefs struct { type UiPrefs struct {
Ambient *bool `json:"ambient,omitempty"` Ambient *bool `json:"ambient,omitempty"`
@@ -249,11 +313,52 @@ type UpdateMeRequest struct {
UiPrefs *UiPrefs `json:"ui_prefs,omitempty"` 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. // RefreshTokensParams defines parameters for RefreshTokens.
type RefreshTokensParams struct { type RefreshTokensParams struct {
PipuRt *string `form:"pipu_rt,omitempty" json:"pipu_rt,omitempty"` 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. // LoginJSONRequestBody defines body for Login for application/json ContentType.
type LoginJSONRequestBody = LoginRequest type LoginJSONRequestBody = LoginRequest
@@ -271,6 +376,24 @@ type ChangePasswordJSONRequestBody = ChangePasswordRequest
// ServerInterface represents all server handlers. // ServerInterface represents all server handlers.
type ServerInterface interface { 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) // (POST /api/v1/auth/login)
Login(w http.ResponseWriter, r *http.Request) Login(w http.ResponseWriter, r *http.Request)
@@ -316,6 +439,42 @@ type ServerInterface interface {
type Unimplemented struct{} 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) // (POST /api/v1/auth/login)
func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request) { func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request) {
@@ -403,6 +562,217 @@ type ServerInterfaceWrapper struct {
type MiddlewareFunc func(http.Handler) http.Handler 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(), &params.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(), &params.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(), &params.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(), &params.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(), &params.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(), &params.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 // Login operation middleware
func (siw *ServerInterfaceWrapper) Login(w http.ResponseWriter, r *http.Request) { 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, 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.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/auth/login", wrapper.Login) 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 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 { type LoginRequestObject struct {
Body *LoginJSONRequestBody Body *LoginJSONRequestBody
} }
@@ -1177,6 +1739,24 @@ func (response GetReadyz503JSONResponse) VisitGetReadyzResponse(w http.ResponseW
// StrictServerInterface represents all server handlers. // StrictServerInterface represents all server handlers.
type StrictServerInterface interface { 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) // (POST /api/v1/auth/login)
Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error)
@@ -1247,6 +1827,174 @@ type strictHandler struct {
options StrictHTTPServerOptions 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 // Login operation middleware
func (sh *strictHandler) Login(w http.ResponseWriter, r *http.Request) { func (sh *strictHandler) Login(w http.ResponseWriter, r *http.Request) {
var request LoginRequestObject var request LoginRequestObject
@@ -1601,64 +2349,82 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"f8HaVub3Bs19it3CrZWt/wkAAP//LpP8775MAAA=",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file
+341
View 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
}
+152
View File
@@ -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
}
+379
View File
@@ -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())
}
+117 -6
View File
@@ -23,6 +23,7 @@ type MemStore struct {
Users map[uuid.UUID]auth.User Users map[uuid.UUID]auth.User
Tokens map[uuid.UUID]auth.RefreshToken Tokens map[uuid.UUID]auth.RefreshToken
Audit []auth.AuditEntry Audit []auth.AuditEntry
AuditIDs []uuid.UUID
Settings map[string]any 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 { func (s *MemStore) WithTx(_ context.Context, fn func(auth.Store) error) error {
unlock := s.lock() unlock := s.lock()
defer unlock() defer unlock()
users, tokens, audit, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), slices.Clone(s.Audit), maps.Clone(s.Settings) users, tokens, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), maps.Clone(s.Settings)
tx := &MemStore{mu: s.mu, inTx: true, Users: s.Users, Tokens: s.Tokens, Audit: s.Audit, Settings: 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 { 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 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 return nil
} }
@@ -251,13 +255,120 @@ func (s *MemStore) ListSessions(_ context.Context, userID uuid.UUID, now time.Ti
return out, nil return out, nil
} }
// InsertAudit реализует auth.Store. // ListUsers реализует auth.Store.
func (s *MemStore) InsertAudit(_ context.Context, _ uuid.UUID, e auth.AuditEntry) error { 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()() defer s.lock()()
s.Audit = append(s.Audit, e) s.Audit = append(s.Audit, e)
s.AuditIDs = append(s.AuditIDs, id)
return nil 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. // GetSettingBool реализует auth.Store.
func (s *MemStore) GetSettingBool(_ context.Context, key string) (bool, error) { func (s *MemStore) GetSettingBool(_ context.Context, key string) (bool, error) {
defer s.lock()() defer s.lock()()
+12
View File
@@ -95,6 +95,18 @@ type Principal struct {
MustChangeCredentials bool 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. // AuditEntry — запись audit_log.
type AuditEntry struct { type AuditEntry struct {
ActorID *uuid.UUID ActorID *uuid.UUID
+130
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -273,6 +274,135 @@ func (s *PgStore) ListSessions(ctx context.Context, userID uuid.UUID, now time.T
return out, nil 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. // InsertAudit реализует Store.
func (s *PgStore) InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error { func (s *PgStore) InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error {
var payload []byte var payload []byte
+29
View File
@@ -21,6 +21,22 @@ type ProfileUpdate struct {
UIPrefs *UIPrefs 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. // Store — доступ к users, refresh_tokens, audit_log и app_settings.
type Store interface { type Store interface {
// WithTx выполняет fn в транзакции; Store внутри fn работает в ней же. // WithTx выполняет fn в транзакции; Store внутри fn работает в ней же.
@@ -49,7 +65,20 @@ type Store interface {
// ListSessions — активные семьи пользователя: есть неиспользованный, неотозванный, не истёкший токен. // ListSessions — активные семьи пользователя: есть неиспользованный, неотозванный, не истёкший токен.
ListSessions(ctx context.Context, userID uuid.UUID, now time.Time) ([]Session, error) 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 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) GetSettingBool(ctx context.Context, key string) (bool, error)
SetSetting(ctx context.Context, key string, value any) error SetSetting(ctx context.Context, key string, value any) error
} }
+5 -1
View File
@@ -57,5 +57,9 @@ func NewRouter(d Deps) http.Handler {
RequestErrorHandlerFunc: httpx.RequestError, RequestErrorHandlerFunc: httpx.RequestError,
ResponseErrorHandlerFunc: httpx.ResponseError(log), 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,
})
} }