Some checks failed
Caterium QA / qa (push) Has been cancelled
Owners/admins can now see, on the Аккаунт settings tab, a tamper-proof journal of who created, edited or deleted each order and when — including employees who have orders.* permissions. It is written from inside sun_save_app_state itself (which already diffs orders server-side for permission checks), so it can't be spoofed or wiped by the client, unlike the old per-browser 'История изменений' list which only covered the current device and had a 'Clear history' button anyone could press. - New table public.sun_order_audit_log (workspace, order id, action, actor, summary, details), locked down to security-definer writes only — no client insert/update/delete policy exists. - New RPC sun_list_order_audit(workspace, limit), admin-only. - New settings card 'Журнал заказов' reading it, admin-only, classified into the existing Аккаунт settings tab. - Verified end-to-end against a local PGlite instance: create/edit/ delete each produce one correctly-attributed row, and a non-admin member is denied read access. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
10 KiB
PL/PgSQL
178 lines
10 KiB
PL/PgSQL
-- Server-side, tamper-proof audit trail for order changes.
|
|
-- The owner needs to see when an employee with orders.* permissions
|
|
-- created, edited or deleted an order, on any device, at any time —
|
|
-- not just in a local per-browser history that an employee could
|
|
-- clear themselves. This writes from inside sun_save_app_state,
|
|
-- which already detects created/deleted/edited orders server-side
|
|
-- for permission checks, so the log can't be spoofed or skipped by
|
|
-- the client and needs no extra client-side call.
|
|
|
|
create table if not exists public.sun_order_audit_log (
|
|
id bigint generated always as identity primary key,
|
|
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
|
|
order_id text not null,
|
|
action text not null check (action in ('create','update','delete')),
|
|
actor_id uuid references auth.users(id) on delete set null,
|
|
actor_name text not null default '',
|
|
actor_role text not null default '',
|
|
summary text not null default '',
|
|
details jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create index if not exists sun_order_audit_log_workspace_idx
|
|
on public.sun_order_audit_log(workspace_id, created_at desc);
|
|
|
|
-- Only the security-definer functions below (running as their owner,
|
|
-- which bypasses RLS) ever write here. No insert/update/delete policy
|
|
-- is granted to end users, so an employee cannot edit or clear their
|
|
-- own trail the way the old local-only history could be cleared.
|
|
alter table public.sun_order_audit_log enable row level security;
|
|
revoke all on public.sun_order_audit_log from public, anon, authenticated;
|
|
|
|
create or replace function public.sun_list_order_audit(p_workspace uuid, p_limit integer default 200)
|
|
returns table(
|
|
id bigint,
|
|
order_id text,
|
|
action text,
|
|
actor_name text,
|
|
actor_role text,
|
|
summary text,
|
|
details jsonb,
|
|
created_at timestamptz
|
|
)
|
|
language plpgsql
|
|
stable
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
begin
|
|
if coalesce(public.sun_member_role(p_workspace),'') <> 'admin' then
|
|
raise exception 'Administrator required';
|
|
end if;
|
|
return query
|
|
select l.id, l.order_id, l.action, l.actor_name, l.actor_role, l.summary, l.details, l.created_at
|
|
from public.sun_order_audit_log l
|
|
where l.workspace_id = p_workspace
|
|
order by l.created_at desc
|
|
limit least(greatest(coalesce(p_limit,200),1),1000);
|
|
end;
|
|
$$;
|
|
revoke all on function public.sun_list_order_audit(uuid,integer) from public, anon;
|
|
grant execute on function public.sun_list_order_audit(uuid,integer) to authenticated;
|
|
|
|
create or replace function public.sun_save_app_state(p_workspace uuid, p_payload jsonb, p_client_id text)
|
|
returns table(workspace_id uuid, payload jsonb, revision bigint, updated_at timestamptz, client_id text)
|
|
language plpgsql
|
|
security definer
|
|
set search_path='public'
|
|
as $$
|
|
declare
|
|
v_old jsonb := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb;
|
|
v_old_storage jsonb;
|
|
v_new_storage jsonb;
|
|
v_final_storage jsonb;
|
|
v_final_payload jsonb;
|
|
v_key text;
|
|
v_perm text;
|
|
v_feature text;
|
|
v_old_orders jsonb;
|
|
v_new_orders jsonb;
|
|
v_create boolean;
|
|
v_delete boolean;
|
|
v_edit boolean;
|
|
v_saved public.sun_app_state%rowtype;
|
|
v_actor_name text;
|
|
v_actor_role text;
|
|
v_row jsonb;
|
|
begin
|
|
if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
|
|
if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка истекла: база доступна только для просмотра'; end if;
|
|
if p_payload is null or jsonb_typeof(p_payload) <> 'object' or jsonb_typeof(coalesce(p_payload->'storage','{}'::jsonb)) <> 'object' then raise exception 'Invalid payload'; end if;
|
|
|
|
select a.payload into v_old from public.sun_app_state a where a.workspace_id=p_workspace;
|
|
if v_old is null then v_old := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; end if;
|
|
v_old_storage := coalesce(v_old->'storage','{}'::jsonb);
|
|
v_new_storage := coalesce(p_payload->'storage','{}'::jsonb);
|
|
v_final_storage := v_old_storage;
|
|
|
|
for v_key in select key from (select jsonb_object_keys(v_old_storage) key union select jsonb_object_keys(v_new_storage) key) q loop
|
|
if not public.sun_can_read_storage_key(p_workspace,v_key) and not (v_new_storage ? v_key) then continue; end if;
|
|
if coalesce(v_old_storage->v_key,'null'::jsonb) = coalesce(v_new_storage->v_key,'null'::jsonb) then continue; end if;
|
|
|
|
if v_key='sunOrders' then
|
|
if not public.sun_workspace_has_feature(p_workspace,'orders') then raise exception 'Заказы недоступны на текущем тарифе'; end if;
|
|
v_old_orders := coalesce(v_old_storage #> array['sunOrders','v'],'[]'::jsonb);
|
|
v_new_orders := coalesce(v_new_storage #> array['sunOrders','v'],'[]'::jsonb);
|
|
if jsonb_typeof(v_old_orders)<>'array' or jsonb_typeof(v_new_orders)<>'array' then raise exception 'Invalid orders payload'; end if;
|
|
select exists(select 1 from jsonb_array_elements(v_new_orders) n where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id')) into v_create;
|
|
select exists(select 1 from jsonb_array_elements(v_old_orders) o where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=o->>'id')) into v_delete;
|
|
select exists(select 1 from jsonb_array_elements(v_new_orders) n join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id' limit 1) x on true where x.o<>n) into v_edit;
|
|
if v_create and not public.sun_has_permission(p_workspace,'orders.create') then raise exception 'Нет права создавать заказы'; end if;
|
|
if v_edit and not public.sun_has_permission(p_workspace,'orders.edit') then raise exception 'Нет права изменять заказы'; end if;
|
|
if v_delete and not public.sun_has_permission(p_workspace,'orders.delete') then raise exception 'Нет права удалять заказы'; end if;
|
|
|
|
if v_create or v_edit or v_delete then
|
|
select m.display_name, m.role into v_actor_name, v_actor_role
|
|
from public.sun_workspace_members m where m.workspace_id=p_workspace and m.user_id=auth.uid();
|
|
if coalesce(trim(v_actor_name),'')='' then
|
|
select u.email into v_actor_name from auth.users u where u.id=auth.uid();
|
|
end if;
|
|
v_actor_name := coalesce(nullif(trim(v_actor_name),''),'Сотрудник');
|
|
v_actor_role := coalesce(v_actor_role,'');
|
|
|
|
for v_row in select n from jsonb_array_elements(v_new_orders) n
|
|
where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=(n)->>'id')
|
|
loop
|
|
insert into public.sun_order_audit_log(workspace_id,order_id,action,actor_id,actor_name,actor_role,summary,details)
|
|
values (p_workspace, coalesce(v_row->>'id',''), 'create', auth.uid(), v_actor_name, v_actor_role,
|
|
format('№%s · %s', coalesce(v_row->>'id','?'), coalesce(nullif(v_row->>'event',''),'Заказ')),
|
|
jsonb_build_object('event',v_row->'event','date',v_row->'date','status',v_row->'status','total',v_row->'total'));
|
|
end loop;
|
|
|
|
for v_row in select o from jsonb_array_elements(v_old_orders) o
|
|
where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=(o)->>'id')
|
|
loop
|
|
insert into public.sun_order_audit_log(workspace_id,order_id,action,actor_id,actor_name,actor_role,summary,details)
|
|
values (p_workspace, coalesce(v_row->>'id',''), 'delete', auth.uid(), v_actor_name, v_actor_role,
|
|
format('№%s · %s', coalesce(v_row->>'id','?'), coalesce(nullif(v_row->>'event',''),'Заказ')),
|
|
jsonb_build_object('event',v_row->'event','date',v_row->'date','status',v_row->'status','total',v_row->'total'));
|
|
end loop;
|
|
|
|
for v_row in select n from jsonb_array_elements(v_new_orders) n
|
|
join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=(n)->>'id' limit 1) x on true
|
|
where x.o<>n
|
|
loop
|
|
insert into public.sun_order_audit_log(workspace_id,order_id,action,actor_id,actor_name,actor_role,summary,details)
|
|
values (p_workspace, coalesce(v_row->>'id',''), 'update', auth.uid(), v_actor_name, v_actor_role,
|
|
format('№%s · %s', coalesce(v_row->>'id','?'), coalesce(nullif(v_row->>'event',''),'Заказ')),
|
|
jsonb_build_object('event',v_row->'event','date',v_row->'date','status',v_row->'status','total',v_row->'total','prepayment',v_row->'prepayment'));
|
|
end loop;
|
|
end if;
|
|
else
|
|
v_feature := public.sun_feature_for_storage_key(v_key);
|
|
if v_feature is not null and not public.sun_workspace_has_feature(p_workspace,v_feature) then raise exception 'Функция недоступна на текущем тарифе: %',v_feature; end if;
|
|
v_perm := public.sun_required_write_permission(v_key);
|
|
if v_perm='_member' then null;
|
|
elsif not public.sun_has_permission(p_workspace,v_perm) then raise exception 'Нет права изменять раздел: %',v_key;
|
|
end if;
|
|
end if;
|
|
|
|
if v_new_storage ? v_key then v_final_storage := v_final_storage || jsonb_build_object(v_key,v_new_storage->v_key);
|
|
else v_final_storage := v_final_storage - v_key; end if;
|
|
end loop;
|
|
|
|
v_final_payload := jsonb_build_object('format',coalesce(p_payload->'format',v_old->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(p_payload->'version',v_old->'version','2'::jsonb),'storage',v_final_storage);
|
|
insert into public.sun_app_state as app_state(workspace_id,payload,client_id)
|
|
values (p_workspace,v_final_payload,p_client_id)
|
|
on conflict on constraint sun_app_state_pkey do update set payload=excluded.payload,client_id=excluded.client_id
|
|
returning app_state.* into v_saved;
|
|
insert into public.sun_sync_events(workspace_id,revision,client_id) values (p_workspace,v_saved.revision,p_client_id);
|
|
return query select * from public.sun_fetch_app_state(p_workspace);
|
|
end;
|
|
$$;
|
|
revoke all on function public.sun_save_app_state(uuid,jsonb,text) from public, anon;
|
|
grant execute on function public.sun_save_app_state(uuid,jsonb,text) to authenticated;
|
|
|
|
notify pgrst, 'reload schema';
|