Nile公司如何使用Postgres行级安全提供多租户SaaS ?


多租户数据库中的授权是许多公司必须处理的事情,在以前的公司中,我看到授权可能以最常见的方式实现:附加WHERE user_id = $USER_ID到查询。这也是 Nile 开始的方式,但是随着我们添加更多功能,我们注意到我们被迫在WHERE代码中添加许多分支和重复。我们需要一种解决方案,让我们能够快速、自信地添加功能,并且在每个查询中使用自定义过滤器很容易出错,并且如果我们的数据模型发生变化,则很难改进。
一个解决方案是 Postgres行级安全性(RLS),这是一种基于每个用户过滤行的数据库级机制。我希望它能让我们更快地迭代并显着降低安全风险。

在我们选择使用单个多租户模式后,我们一直在寻找一种比动态查询更简洁、更不易出错且比外部授权系统更轻便的解决方案。
在这篇博文的其余部分,我将列出我在 Nile 研究和实施它的几周内发现的关于 RLS 的内容,以及它如何解决我们的问题(至少目前是这样),即快速、自信地建立授权,以及可维护的架构。

设置 RLS 的高级流程是:

  1. 像往常一样定义您的数据模型,但在每个表中包含一个租户标识符
  2. 在您的表上定义 RLS 策略(即:“仅返回当前租户的行”)
  3. 定义一个 db 用户(即app_user:),它具有您的应用程序与 db 交互所需的所有权限,但没有任何超级用户角色。在 Postgres 中,这是必要的,因为超级用户角色会绕过所有权限检查 ,包括 RLS(稍后会详细介绍)。

一个简单的组织访问控制
想象一下,您的 API 有一个/orgs端点,该端点应该只返回调用用户所属的组织。要通过 RLS 实现这一点,您需要这样定义表、策略和 db 用户:

CREATE
  TABLE
    users(
      id SERIAL PRIMARY KEY
    );

CREATE
  TABLE
    orgs(
      id SERIAL PRIMARY KEY
    );

CREATE
  TABLE
    org_members(
      user INTEGER REFERENCES users NOT NULL,
      org INTEGER REFERENCES orgs NOT NULL
    );

-- ** RLS setup **
ALTER TABLE
  orgs ENABLE ROW LEVEL SECURITY;

-- Create a function, current_app_user(),
-- that returns the user to authorize against.
CREATE
  FUNCTION current_app_user() RETURNS INTEGER AS $$ SELECT
    NULLIF(
      current_setting(
        'app.current_app_user',
        TRUE
      ),
      ''
    )::INTEGER $$ LANGUAGE SQL SECURITY DEFINER;

CREATE
  POLICY org_member_policy ON
  orgs
    USING(
    EXISTS(
      SELECT
        1
      FROM
        org_members
      WHERE
        user = current_app_user()
        AND org = id
    )
  );

-- Create the db user that'll be used in your application.
CREATE
  USER app_user;

GRANT ALL PRIVILEGES ON
ALL TABLES IN SCHEMA public TO app_user;

GRANT ALL PRIVILEGES ON
ALL SEQUENCES IN SCHEMA public TO app_user;

上述 RLS 策略只会对当前用户所属的组织返回 true。很简单。稍后,我们将看到事情如何变得更加复杂。
注意current_app_user()功能。在直接 db 访问的传统用例中,RLS 通过在表上定义策略来工作,这些策略根据当前 db 用户过滤行。

然而,对于 SaaS 应用程序,为每个应用程序用户定义一个新的数据库用户是很麻烦的。

对于应用程序用例,您可以使用 Postgres 的current_settings()函数(即:SET app.current_app_user = ‘usr_123’和SELECT current_settings(‘app.current_app_user))动态设置和检索用户。

RLS 的最大好处是,如果您定义的策略过于严格,或者忘记定义策略,数据库操作就会失败。与忘记添加WHERE 会泄漏数据的动态查询相比,这是安全性的一大胜利。

单模式多租户中动态查询的主要挑战之一是对表的更改通常需要触及许多不同的查询。RLS 解决了这个问题,因为策略与表而非查询相关联。修改表后,您需要做的就是更改其访问策略,该策略将应用于所有查询。

借助 RLS,随着多租户数据模型的发展,可以轻松添加更多访问规则。根据 Postgres 文档
“当多个策略应用于给定查询时,它们使用 OR(对于默认的许可策略)或使用 AND(对于限制性策略)进行组合。”
由于默认情况下策略与 OR 相结合,因此随着访问规则变得更加复杂,这使得定义更多策略变得非常容易。对于动态查询,这并不是那么简单,您可能必须定义自己的逻辑来组合访问规则。

我们现在没有将与我们的应用程序逻辑相关的过滤器与与多租户数据库设计相关的过滤器混合在相同的 WHERE 子句中,而是进行了清晰的分离:

  • 我们的应用程序应用用户通过 API 和其他应用程序逻辑请求的所有过滤器。
  • RLS 负责由于多租户数据库设计而需要的过滤器。

RLS不适合地方
每种技术都有其权衡和不应该使用它的情况。以下是我们认为 RLS 不太适合的两种情况:

1、多租户数据库中的 RLS 隔离了对数据库行的访问,但所有其他数据库资源仍然在租户之间共享。它无助于限制每个租户使用的磁盘空间、CPU 或数据库缓存。如果您需要在 db 级别进行更强的隔离,则需要寻找其他方案。

2、我们当前的访问策略相当简单 - 租户彼此隔离,并且在租户内,您拥有具有额外访问权限的管理员。更成熟的访问控制策略(如 RBAC/ABAC)需要自己的架构设计,与 RLS 集成可能更具挑战性,甚至更难以实现高性能。


RLS案例:博客就绪型和生产就绪型
递归权限策略
假设您要添加管理员用户类型并实施以下访问规则:

  1. 用户可以阅读、更新和删除他们自己的用户配置文件。
  2. 用户可以读取属于同一租户的其他用户的配置文件。
  3. 具有管理员访问权限的用户可以读取、更新和删除属于同一租户的其他用户。

前两个用例可以通过简单的 RLS 策略实现,但第三个用例则不行。
这是因为我们必须查询用户表,以查看相关的用户是否是管理员(即:SELECT 1 FROM users WHERE id = current_app_user() AND is_admin = TRUE)。
由于查询一个表会触发它的RLS策略检查,在用户的RLS策略中执行这个查询会触发用户的RLS策略检查,从而调用这个查询,而这个查询会触发RLS策略检查,导致无限循环。
Postgres会抓住这个错误,而不是超时,但是你应该确保测试你的策略,这样在运行时就不会发生这种情况。
你可以通过定义一个具有SECURITY DEFINER权限的函数来避免这个问题,该函数将在RLS策略中使用。

根据Postgres的文档:
通过使用SECURITY DEFINER,您允许用户绕过安全策略并使用超级用户权限,而不管他们的真实身份,因此您必须小心。我建议在使用此功能之前查看 Postgres 文档的“安全地编写 SECURITY DEFINER 函数 ”部分。

以下是如何实现满足上述三个用例的 RLS 策略的示例:

CREATE
  TABLE
    users(
      id SERIAL PRIMARY KEY,
      is_admin BOOLEAN
    );

ALTER TABLE
  users ENABLE ROW LEVEL SECURITY;

-- Users can do anything to themselves.
CREATE
  POLICY self_policy ON
  users
    USING(
    id = current_app_user()
  );

CREATE
  FUNCTION is_user_admin(
    _user_id INTEGER
  ) RETURNS bool AS $$ SELECT
    EXISTS(
      SELECT
        1
      FROM
        users
      WHERE
        id = _user_id
        AND is_admin = TRUE
    ) $$ LANGUAGE SQL SECURITY DEFINER;

CREATE
  FUNCTION do_users_share_org(
    _user_id_1 INTEGER,
    _user_id_2 INTEGER
  ) RETURNS bool AS $$ SELECT
    EXISTS(
      SELECT
        1
      FROM
        org_members om1,
        org_members om2
      WHERE
        om1.user != om2.user
        AND om1.org = om2.org
        AND om1.user = _user_id_1
        AND om2.user = _user_id_2
    ) $$ LANGUAGE SQL SECURITY INVOKER;

-- Non-admins can only read users in their orgs.
CREATE
  POLICY read_in_shared_orgs_policy ON
  users FOR SELECT
      USING(
      do_users_share_org(
        current_app_user(),
        id
      )
    );

CREATE
  POLICY admin_policy ON
  users
    USING(
    do_users_share_org(
      current_app_user(),
      id
    )
    AND is_user_admin(
      current_app_user()
    )
  );

请注意使用do_users_share_org() SECURITY INVOKER函数。根据Postgres的文档。

"SECURITY INVOKER表示该函数将以调用它的用户的权限来执行。

在我们的例子中,这是app_user(他不绕过RLS),所以我们只是为了重用而定义这些函数。

日志
在将任何功能交付生产之前设置日志记录非常重要。对于无法直接记录实际策略的执行情况的 RLS 尤其如此 。对于每个请求,在以下情况下记录用于 RLS 的用户和租户 ID 会很有帮助:

  • 从 auth 标头中解析它们
  • 从线程本地存储中设置和获取它们
  • 在数据库连接中设置它们
  • 这使得在线程本地存储中重置它们时更容易识别与线程本地存储相关的错误

在数据库中启用更详细的日志记录也是一个好主意,至少最初是这样,以查看实际插入/检索的值。如果策略返回的结果太少/太多,或者插入意外失败,则更容易找出问题所在。

测试
在多租户 SaaS 中,保证每个租户的安全至关重要。我们有一套广泛的集成测试来测试每种访问模式,以确保没有任何泄漏。测试启动 Postgres Testcontainer 并调用相关的 API 端点,检查是否始终强制执行正确的访问。
为了最大限度地减少大量集成测试的执行时间,我们避免在测试之间设置和拆除数据库,并注释测试运行的顺序以确保结果是确定性的,即使在测试之间没有完全清理。随着我们的扩展,我们将研究其他选项,例如基于属性的测试和并行化我们的测试。
在我们的集成测试中,从动态查询到 RLS 的切换是无缝的。我们所要做的就是确保我们的测试使用app_user不绕过 RLS 的新创建的。

总结
每个现代 SaaS 产品都是多租户的,但好的产品还具有可扩展性、成本效益和可维护性。可扩展性和成本效益是精心系统设计的结果。可维护性包括设计考虑因素,例如DRY 原则(不要重复自己)和关注点分离,这可以减少出错的可能性,并且更容易进行测试和故障排除。
正如我们所展示的,带有 RLS 的单模式多租户数据库勾选了可扩展、经济高效和可维护架构的所有复选框。此博客包含您开始使用自己的多租户 SaaS 架构所需的一切。