PostgreSQL – 带有 RLS UPDATE 和 SELECT 策略的软删除问题

huangapple go评论67阅读模式
英文:

PostgreSQL - Soft delete problem with RLS UPDATE and SELECT Policies

问题

我知道问题是什么(如下),并有一个解决方法,但我想摆脱它。

我想为my_table实现软删除。

问题是,如果我运行UPDATE "my_table" SET delete_at = now() WHERE id = ...,我会得到new row violates row-level security policy for table

插入和删除(真正的删除)工作正常,所以我在这里跳过了这部分。

-- 创建表
CREATE TABLE "my_table" (
"id" SERIAL PRIMARY KEY
"user_id" int not null references "user"(id) on delete cascade,
deleted_at timestamp with time zone
);

-- 启用行级安全
ALTER TABLE "my_table" ENABLE ROW LEVEL SECURITY;

-- 允许读取自己的数据和管理员的一切
CREATE POLICY my_table_read_policy ON "my_table" FOR SELECT
USING (
current_setting('role') = 'app_admin'
or
(
current_setting('role') = 'app_user'
and user_id = get_current_user_id()
and (
deleted_at is null
)
)
);

-- 更新,例如设置delete_at日期
CREATE POLICY my_table_update_policy ON "my_table" FOR UPDATE
USING (true)
WITH CHECK (
current_setting('role') = 'app_admin'
or
(
current_setting('role') = 'app_user'
and user_id = get_current_user_id()
)
);

我发现这是因为我的SELECT策略过滤了deleted_at而引起的。

在将SELECT策略更改为以下解决方法后(delete_at is null OR "within the last second"),它可以正常工作。

-- 允许读取自己的数据和管理员的一切
CREATE POLICY my_table_read_policy ON "my_table" FOR SELECT
USING (
current_setting('role') = 'app_admin'
or
(
current_setting('role') = 'app_user'
and user_id = get_current_user_id()
-- 解决方法: https://stackoverflow.com/a/74306008
and (
deleted_at is null OR ABS(EXTRACT(EPOCH FROM (now() - deleted_at))) < 1
)
)
);

但为什么如此,以及如何摆脱这个解决方法?

英文:

I know whats the problem is (below) and have a workaround, but i would like to get rid of it.

I would like to implement soft delete for my_table.

The Problem is that if i run UPDATE &quot;my_table&quot; set delete_at = now() where id = ...
i get new row violates row-level security policy for table.

The insert and delete (real delete) is working fine, so i skip this here.

-- create table
CREATE TABLE &quot;my_table&quot; (
  &quot;id&quot; SERIAL PRIMARY KEY
  &quot;user_id&quot; int not null references &quot;user&quot;(id) on delete cascade,
  deleted_at timestamp with time zone
);

-- enable row level security
ALTER TABLE &quot;my_table&quot; ENABLE ROW LEVEL SECURITY;

-- allow read own data and everything for admin
CREATE POLICY my_table_read_policy ON &quot;my_table&quot; FOR SELECT
USING (
  current_setting(&#39;role&#39;)  = &#39;app_admin&#39;
  or
  (
    current_setting(&#39;role&#39;)  = &#39;app_user&#39;
    and user_id = get_current_user_id()
    and (
      deleted_at is null
    )
  )
);

-- update, e.g. set delete_at date
CREATE POLICY my_table_update_policy ON &quot;my_table&quot; FOR UPDATE
USING (true)
WITH CHECK (
  current_setting(&#39;role&#39;)  = &#39;app_admin&#39;
  or
  (
    current_setting(&#39;role&#39;)  = &#39;app_user&#39;
    and user_id = get_current_user_id()
  )
);

I found that this happens because of my SELECT Policy which filters by deleted_at.

After changing the SELECT Policy to the following workaround (delete_at is null OR &quot;within the last second&quot;), it works.

-- allow read own data and everything for admin
CREATE POLICY my_table_read_policy ON &quot;my_table&quot; FOR SELECT
USING (
  current_setting(&#39;role&#39;)  = &#39;app_admin&#39;
  or
  (
    current_setting(&#39;role&#39;)  = &#39;app_user&#39;
    and user_id = get_current_user_id()
    -- workaround: https://stackoverflow.com/a/74306008
    and (
      deleted_at is null OR ABS(EXTRACT(EPOCH FROM (now() - deleted_at))) &lt; 1
    )
  )
);

But why, and how to get rid of this workaround?

答案1

得分: 4

为什么会发生这种情况?

根据Table 287(按命令类型应用的策略)1,当执行UPDATE命令时,如果在查询中使用了WHERERETURNING,则SELECT策略也会被应用于现有行和新行。对于RLS,您的SELECT/USING策略必须对任何有条件的UPDATE求值为TRUE

简单来说,为了更新特定的user_id,您必须能够读取具有该user_id的行。否则,将没有要更新的行。

您的更新查询包含 where user_id = 。除此之外,策略也被视为查询的一部分。因此,只要您在CHECK策略中引用了字段,UPDATE查询将始终触发SELECT/USING策略。

如何摆脱这种变通方法?

从语义上讲,实施软删除引入了另一个逻辑层,将行状态分为两个已删除状态。这是这种混乱交互的根本原因,处理它的最佳方式是在RLS中不使用软删除字段。

但是,如果您仍然想要这样做,并且不喜欢在策略中有那个&lt; 1s条件 —— 这是有道理的,对于运行时间超过1秒的更新,那些条件会失败 —— 您仍然可以实现您想要的,但策略会变得相对清晰。

解决方案是创建一个特定的软删除函数,将当前的role切换到app_admin,对记录进行软删除,然后将角色设置回app_user。当然,该函数将必须检查目标user_id是否与当前的user_id匹配。

类似于以下内容将会起作用,尽管极不推荐:

create or replace function soft_delete_user(del_id int)
  returns void as
$$
begin
  if (del_id = get_current_user_id()) then
    set role = 'app_admin';
    update my_table set deleted_at = now() where user_id = del_id;
  else
    raise exception 'mismatched IDs';
  end if;
  set role = 'app_user';
end;
$$ language 'plpgsql';
英文:

Why is this happening?

As per Table 287 (Policies Applied by Command Type), when you execute an UPDATE command, if you have WHERE or RETURNING in your query then SELECT policies are also applied to both existing and new rows. With RLS, you have your SELECT/USING policies evaluate to TRUE for any conditional UPDATE to happen.

In simpler terms, in order to update a specific user_id, you have to be able to read the row with that user_id. Otherwise, it would be 0 rows to update.

Your update query features where user_id = . More than that, policies are considered part of the query as well. So, as long as you have a reference to a field within your CHECK policies, UPDATE queries will always trigger SELECT/USING policies.

How to get rid of this workaround?

Semantically speaking, implementing soft delete introduces another logical layer that splits row state into two deleted states. This is the root for this messy interaction, and the best way of dealing with it would be to not use soft delete fields within RLS.

However, if you still want to do this, and don't like having that &lt; 1s condition in your policies — which makes sense, those would fail for updates that are taking longer than 1s to run — you can still achieve what you want, yet have somewhat clean policies.

The solution would be to create a specific soft delete function, that will switch current role to app_admin, soft delete the record, and then set role back to app_user. The function will have to check that target user_id matches current user_id, of course.

Something along these lines will do the trick, although it is highly not recommended

create or replace function soft_delete_user(del_id int)
  returns void as
$$
begin
  if (del_id = get_current_user_id()) then
    set role = &#39;app_admin&#39;;
    update my_table set deleted_at = now() where user_id = del_id;
  else
    raise exception &#39;mismatched IDs&#39;;
  end if;
  set role = &#39;app_user&#39;;
end;

$$ language &#39;plpgsql&#39;;

答案2

得分: 3

最近遇到了相同的问题,阅读了多篇推荐使用“存档表”策略的文章,出于以下原因,这种策略效果更好:

  • 无需使用部分(唯一)索引
  • 原生处理外键(在删除时级联/设置为空)
  • 使用insert into active_table select * from archive_table where id = ?比使用RLS和角色更容易恢复
  • 存档表也可以具有RLS
  • 使用在删除后的for each statement触发器以及它们的过渡表来高效实现

总的来说,提供deleted_at列所需的工作量可能比使用存档表更高。

英文:

Got the same issue recently and read multiple articles promoting an "archive table" strategy that works better anyway, for the following reasons:

  • no need to have partial (unique) indexes
  • native handling of foreign keys (on delete cascade/set null)
  • easier to restore using insert into active_table select * from archive_table where id = ? than juggling with RLS and roles
  • archive table can have RLS too
  • efficient implementation using an after-delete for each statement trigger and their transition table

All in all, the effort you have to provide with a deleted_at column might be higher than with an archive table.

huangapple
  • 本文由 发表于 2023年4月4日 15:50:31
  • 转载请务必保留本文链接:https://go.coder-hub.com/75926787.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定