diff --git a/include/records.hrl b/include/records.hrl index 8bcfb84..abeba77 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -172,12 +172,15 @@ }). %% ------------------- Аудит ------------------------------------------- --record(audit_log, { - id :: binary(), - admin_id :: binary(), - action :: binary(), - target_type :: binary() | undefined, - target_id :: binary() | undefined, - details :: binary() | undefined, - created_at :: calendar:datetime() +-record(admin_audit, { + id :: binary(), + admin_id :: binary(), + email :: binary(), + role :: atom(), + action :: binary(), + entity_type :: binary(), + entity_id :: binary(), + timestamp :: calendar:datetime(), + ip :: binary(), + reason :: binary() | undefined }). \ No newline at end of file diff --git a/src/core/core_admin_audit.erl b/src/core/core_admin_audit.erl new file mode 100644 index 0000000..f9ca0b4 --- /dev/null +++ b/src/core/core_admin_audit.erl @@ -0,0 +1,40 @@ +-module(core_admin_audit). +-include("records.hrl"). +-export([log/7, list/0, list/1]). + +log(AdminId, Email, Role, Action, EntityType, EntityId, Ip) -> + log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, undefined). +log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, Reason) -> + Id = base64:encode(crypto:strong_rand_bytes(9)), + Entry = #admin_audit{ + id = Id, + admin_id = AdminId, + email = Email, + role = Role, + action = Action, + entity_type = EntityType, + entity_id = EntityId, + timestamp = calendar:universal_time(), + ip = Ip, + reason = Reason + }, + mnesia:dirty_write(Entry), + {ok, Entry}. + +list() -> + mnesia:dirty_match_object(#admin_audit{_ = '_'}). + +%% Фильтрация по параметрам (простая версия) +list(Filters) -> + All = list(), + lists:filter(fun(E) -> + case proplists:get_value(admin_id, Filters) of + undefined -> true; + Id -> E#admin_audit.admin_id =:= Id + end andalso + case proplists:get_value(action, Filters) of + undefined -> true; + Act -> E#admin_audit.action =:= Act + end + % можно добавить фильтр по дате и т.д. + end, All). \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 1716ffb..82d1ab7 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -11,6 +11,7 @@ start(_StartType, _StartArgs) -> ok = infra_mnesia:init_tables(), ok = infra_mnesia:wait_for_tables(), connect_nodes(), + init_default_superadmin(), start_http(), % Пользовательский API (8080) start_admin_http(), % Административный API (8445) application:ensure_all_started(prometheus), @@ -89,7 +90,12 @@ start_admin_http() -> {"/v1/admin/subscriptions", admin_handler_subscriptions, []}, {"/v1/admin/subscriptions/:id", admin_handler_subscriptions, []}, % ================== МОДЕРАЦИЯ (общий маршрут) ================== - {"/v1/admin/:target_type/:id", admin_handler_moderation, []} + {"/v1/admin/:target_type/:id", admin_handler_moderation, []}, + % ================== Управление ролями (только для superadmin) ================== + {"/v1/admin/me", admin_handler_me, []}, + {"/v1/admin/admins", admin_handler_admins, []}, + {"/v1/admin/admins/:id", admin_handler_admins, []}, + {"/v1/admin/audit", admin_handler_audit, []} ]} ]), @@ -123,4 +129,19 @@ connect_nodes() -> ignored -> ok end end, Nodes) + end. + +init_default_superadmin() -> + case core_admin:list_all() of + [] -> + AdminEmail = os:getenv("ADMIN_EMAIL", "admin@eventhub.local"), + AdminPassword = os:getenv("ADMIN_PASSWORD", "123456"), + {ok, _Admin} = core_admin:create( + list_to_binary(AdminEmail), + list_to_binary(AdminPassword), + superadmin + ), + io:format("Default superadmin created: ~s~n", [AdminEmail]); + _ -> + io:format("Superadmin already exists. Skipping creation.~n") end. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_admins.erl b/src/handlers/admin/admin_handler_admins.erl new file mode 100644 index 0000000..728baef --- /dev/null +++ b/src/handlers/admin/admin_handler_admins.erl @@ -0,0 +1,115 @@ +-module(admin_handler_admins). +-behaviour(cowboy_handler). + +-include("records.hrl"). + +-export([init/2]). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> list_admins(Req); + <<"POST">> -> create_admin(Req); + <<"PUT">> -> update_admin_role(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +list_admins(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case admin_utils:check_role(AdminId, superadmin) of + true -> + Admins = core_admin:list_all(), + Json = [admin_to_json(A) || A <- Admins], + send_json(Req1, 200, Json); + false -> + send_error(Req1, 403, <<"Superadmin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +create_admin(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case admin_utils:check_role(AdminId, superadmin) of + true -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"email">> := Email, <<"password">> := Password, <<"role">> := RoleBin} -> + Role = binary_to_atom(RoleBin, utf8), + case core_admin:create(Email, Password, Role) of + {ok, Admin} -> + % Заглушка отправки email + % send_invitation_email(Email), + core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role, + <<"create_admin">>, <<"admin">>, Admin, + admin_utils:client_ip(Req)), + send_json(Req2, 201, admin_to_json(Admin)); + {error, Reason} -> + send_error(Req2, 500, Reason) + end; + _ -> + send_error(Req2, 400, <<"Missing required fields (email, password, role)">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + false -> + send_error(Req1, 403, <<"Superadmin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +update_admin_role(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case admin_utils:check_role(AdminId, superadmin) of + true -> + AdminIdToUpdate = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"role">> := RoleBin} -> + NewRole = binary_to_atom(RoleBin, utf8), + case core_admin:update_role(AdminIdToUpdate, NewRole) of + {ok, Admin} -> + send_json(Req2, 200, admin_to_json(Admin)); + {error, not_found} -> + send_error(Req2, 404, <<"Admin not found">>); + {error, Reason} -> + send_error(Req2, 500, Reason) + end; + _ -> + send_error(Req2, 400, <<"Missing 'role' field">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + false -> + send_error(Req1, 403, <<"Superadmin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +admin_to_json(A) -> + #{ + id => A#admin.id, + email => A#admin.email, + role => A#admin.role, + status => A#admin.status, + created_at => datetime_to_iso8601(A#admin.created_at), + updated_at => datetime_to_iso8601(A#admin.updated_at) + }. + +datetime_to_iso8601({{Y,M,D},{H,Min,S}}) -> + iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S])); +datetime_to_iso8601(_) -> null. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + Req2 = cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Req2, []}. + +send_error(Req, Code, Message) -> + Body = jsx:encode(#{error => Message}), + Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Req2, []}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_audit.erl b/src/handlers/admin/admin_handler_audit.erl new file mode 100644 index 0000000..104c723 --- /dev/null +++ b/src/handlers/admin/admin_handler_audit.erl @@ -0,0 +1,63 @@ +-module(admin_handler_audit). +-behaviour(cowboy_handler). + +-include("records.hrl"). + +-export([init/2]). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case admin_utils:check_role(AdminId, superadmin) of + true -> + Filters = parse_filters(Req1), + Entries = core_admin_audit:list(Filters), + Json = [audit_to_json(E) || E <- Entries], + send_json(Req1, 200, Json); + false -> + send_error(Req1, 403, <<"Superadmin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end; + _ -> + send_error(Req, 405, <<"Method not allowed">>) + end. + +parse_filters(Req) -> + Qs = cowboy_req:parse_qs(Req), + lists:filtermap(fun + ({<<"admin_id">>, Val}) -> {true, {admin_id, Val}}; + ({<<"action">>, Val}) -> {true, {action, Val}}; + (_) -> false + end, Qs). + +audit_to_json(E) -> + #{ + id => E#admin_audit.id, + admin_id => E#admin_audit.admin_id, + email => E#admin_audit.email, + role => E#admin_audit.role, + action => E#admin_audit.action, + entity_type => E#admin_audit.entity_type, + entity_id => E#admin_audit.entity_id, + timestamp => datetime_to_iso8601(E#admin_audit.timestamp), + ip => E#admin_audit.ip, + reason => E#admin_audit.reason + }. + +datetime_to_iso8601({{Y,M,D},{H,Min,S}}) -> + iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S])); +datetime_to_iso8601(_) -> null. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + Req2 = cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Req2, []}. + +send_error(Req, Code, Message) -> + Body = jsx:encode(#{error => Message}), + Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Req2, []}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_me.erl b/src/handlers/admin/admin_handler_me.erl new file mode 100644 index 0000000..d4f91b2 --- /dev/null +++ b/src/handlers/admin/admin_handler_me.erl @@ -0,0 +1,37 @@ +-module(admin_handler_me). +-behaviour(cowboy_handler). + +-include("records.hrl"). + +-export([init/2]). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case core_admin:get_by_id(AdminId) of + {ok, Admin} -> + Permissions = admin_utils:get_permissions(Admin#admin.role), + Resp = jsx:encode(#{ + id => Admin#admin.id, + email => Admin#admin.email, + role => Admin#admin.role, + permissions => Permissions + }), + Req2 = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Resp, Req1), + {ok, Req2, []}; + {error, not_found} -> + send_error(Req1, 404, <<"Admin not found">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end; + _ -> + send_error(Req, 405, <<"Method not allowed">>) + end. + +send_error(Req, Code, Message) -> + Body = jsx:encode(#{error => Message}), + Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Req2, []}. \ No newline at end of file diff --git a/src/infra/admin_utils.erl b/src/infra/admin_utils.erl index abd9149..467361f 100644 --- a/src/infra/admin_utils.erl +++ b/src/infra/admin_utils.erl @@ -1,13 +1,42 @@ -module(admin_utils). -include("records.hrl"). --export([is_admin/1]). +-export([is_admin/1, check_role/2, get_permissions/1]). +-export([client_ip/1]). is_admin(UserId) -> case core_admin:get_by_id(UserId) of - {ok, User} -> - Role = User#admin.role, - Role =:= admin orelse Role =:= superadmin orelse - Role =:= moderator orelse Role =:= support; + {ok, _Admin} -> true; _ -> false - end. \ No newline at end of file + end. + +%% Проверка конкретной роли (или одной из списка ролей) +-spec check_role(UserId :: binary(), RequiredRole :: atom() | [atom()]) -> boolean(). +check_role(UserId, RequiredRoles) when is_list(RequiredRoles) -> + case core_admin:get_by_id(UserId) of + {ok, Admin} -> lists:member(Admin#admin.role, RequiredRoles); + _ -> false + end; +check_role(UserId, RequiredRole) when is_atom(RequiredRole) -> + case core_admin:get_by_id(UserId) of + {ok, Admin} -> Admin#admin.role =:= RequiredRole; + _ -> false + end. + +%% Возвращает список прав для роли администратора +-spec get_permissions(Role :: atom()) -> [binary()]. +get_permissions(superadmin) -> + [<<"manage_admins">>, <<"manage_users">>, <<"manage_events">>, + <<"manage_calendars">>, <<"manage_reviews">>, <<"manage_reports">>, + <<"manage_tickets">>, <<"manage_banned_words">>, <<"view_stats">>, + <<"view_audit">>]; +get_permissions(moderator) -> + [<<"manage_events">>, <<"manage_calendars">>, <<"manage_reviews">>, + <<"manage_reports">>, <<"manage_tickets">>, <<"manage_banned_words">>, + <<"view_stats">>]; +get_permissions(support) -> + [<<"manage_tickets">>, <<"view_stats">>]; +get_permissions(_) -> + []. + +client_ip(_Req) -> <<"127.0.0.1">>. \ No newline at end of file diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index a0581f6..67409ff 100644 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -9,7 +9,7 @@ -define(TABLES, [ user, session, admin, admin_session, calendar, calendar_share, event, recurrence_exception, - booking, review, report, banned_word, ticket, subscription, audit_log + booking, review, report, banned_word, ticket, subscription, admin_audit ]). start_link() -> @@ -138,8 +138,8 @@ table_opts(subscription) -> {attributes, record_info(fields, subscription)}, {ram_copies, [node()]} ]; -table_opts(audit_log) -> +table_opts(admin_audit) -> [ - {attributes, record_info(fields, audit_log)}, + {attributes, record_info(fields, admin_audit)}, {ram_copies, [node()]} ]. \ No newline at end of file