Firestore数据结构和多组织、多用户设置的规则,包括可变角色。

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

Firestore data structure and rules for multi organisation, multi user setup with variable roles

问题

我对NoSQL和Firebase/firestore不太熟悉(但我在传统SQL方面有坚实的背景),所以虽然我了解大多数有关数据结构选项和如何创建规则以管理读/写权限等方面的基础知识,但我很难提出关于数据结构和客户端规则简洁性的“优雅”解决方案。一个关键问题是,我试图避免在规则中使用任何get()读取操作,因为由于财务影响,我正在尝试避免...

我正在编写一个与电子商务相关的SaaS产品,其中...

该应用程序将拥有多个私有的“组织”,仅对将这些组织与之关联的用户可见。
该应用程序将有多个用户,这些用户可以具有对多个组织的不同级别的访问权限。例如,一个用户可以在一个组织上是“用户”,在另一个组织上是“管理员”。
每个用户将具有各种“角色”,以形式如“products.read”、“products.write”、“orders.read”、“order.write”等等的形式。

我从产品等一系列层次结构开始,它们都属于一个名为“组织”的根集合,并且我为每个用户添加了自定义声明,格式为{"orgs": ["12345"]}... 但我在权限/角色方面遇到了困难。我想我可以将一个“权限”或“访问”集合添加到组织的顶层,其中UID是键,每个用户可能拥有的角色将在此ID下... 但这只给我在规则方面提供了一个选项。我将不得不使用get()函数,因此我将不得不为每个访问检查付出双重代价,因为这将触发另一个读取操作。

是否有人了解可能不需要内部get()的解决方案?我确定这是一个常见的模式/问题,但在搜索时我没有找到相关信息,所以我猜想可能是我的思维不在正确的地方,所以决定向大家求助 Firestore数据结构和多组织、多用户设置的规则,包括可变角色。

非常感谢

英文:

I'm quite new to NoSQL and Firebase/firestore in general (I have a strong background in traditional SQL however), so whilst I'm understanding most of the basics regarding options for data structure and how to create rules to manage read/write permissions etc, I'm having a hard time coming up with an 'elegant' solution to this in regards to data structure and simplicity of the client-side rules I'll be using. A key point is I'm trying to negate the need for any get() reads in the rules, which due to financial implications, I'm trying to avoid...

I'm writing an e-commerce related SaaS products where...

The app will have multiple private 'organisations' that are only visible to users that have these organisations assosiated with them.
The app will have multiple users, who can, in turn have access varying levels of access to multiple organisations. For e.g, a user may be a 'user' on one organisation and an 'admin' of another.
Each user will have a variable number of 'roles', in the form of such things as 'products.read', 'products.write', 'orders.read', 'order.write' etc etc

I started with a hierachy of products etc that all fall under a root 'organisation' collection and I have custom claims added to each user in the format of {"orgs": ["12345"]}... but I'm stuck on the permissions/roles side. I figured I could add a 'permissions' or 'access' collection to the organisation top level where the UID is the key and each role that a user may have would be under this ID... but that gave me only one option when it comes to the rules side of things. I'd have to use the get() function and thus I'd have to double spend for each access check as that would trigger another read.

Does anybody have any insight into a possible solution to this that doesn't require an inner get()? I'm sure this is a common pattern/problem, but I've come up short when googling and I guess my head is not in the right place at the moment so figured I'd reach out Firestore数据结构和多组织、多用户设置的规则,包括可变角色。

Many thanks

答案1

得分: 2

正如您所指出的,将相关角色存储在基于组织的用户文档中(例如/organisations/{orgId}/privateUserData/{userId})将提供最佳解决方案之一。

在撰写本文时,Firestore 定价为每 100,000 次文档读取 0.06 美分(美元),超出每日 50,000 次免费配额。使用规则中的一个 get() 调用来保护数据,所有所需的相关数据都位于/organisations/{orgId}/privateUserData/{userId}中,最差情况下的费率为每 50,000 次受保护文档读取 0.06 美分(美元)。

“最差情况”是什么意思?只要不是逐个(例如按其 ID 直接)读取文档,您实际上不会以 1:1 的比例支付这些受保护文档的读取费用。如果您的查询一次返回 100 个文档,您只需支付 1 次 get() 调用的费用,因为该调用的结果会被缓存并重用于该查询中的所有验证。

要举例说明,假设您正在使用一个优化用于易用性的文档,其格式如下:

/organisations/{orgId}/privateUserData/{userId}: {
  uid: "<userId>", // 用于针对特定用户的集合组查询
  orgId: "<orgId>", // 用于针对特定组织的集合组查询
  disabled: false, // 用于暂停用户
  roles: {
    "products.read": true,
    "products.write": false, // 也可以省略,因为规则会假定为 false
    // ...
  },
  userProfile: { // 在 NoSQL 数据库中常见的数据重复
    img: "https://...",
    displayName: "示例用户",
    username: "exampleuser"
  }
}

然后,可以使用以下规则进行连接:

service cloud.firestore {
  // 注意:这不是 JavaScript - 这是安全规则语言

  // 如果用户已登录、存在于组织中并且未标记为禁用,则返回 true
  function isActiveOrgMember(orgId) {
    return request.auth.uid != null
      && get(/databases/$(database)/documents/organisations/$(orgId)/privateUserData/$(request.auth.uid)).data.get("disabled", false) != true;
  }

  // 如果已登录用户具有给定组织的命名角色,则返回 true
  // 预计在 isActiveOrgMember(orgId) 返回 true 后调用
  function hasOrgRole(orgId, permission) {
    return get(/databases/$(database)/documents/organisations/$(orgId)/privateUserData/$(request.auth.uid)).data.roles.get(permission, false)
  }

  match /databases/{database}/documents {
    match /organisations/{orgId} {
      // 任何成员都可以读取组织详细信息
      allow read: if isActiveOrgMember(orgId);

      // 只有管理员可以更新组织详细信息
      allow write: if isActiveOrgMember(orgId) && hasOrgRole(orgId, "orgadmin");

      match /privateUserData/{userId} {
        // 只有命名用户(以及组织管理员)可以读取自己的数据
        allow read: if isActiveOrgMember(orgId)
                    && (request.auth.uid == userId
                    || hasOrgRole(orgId, "orgadmin"));

        // 只有组织管理员可以更新用户的数据(Admin SDKs 也可以进行更改,因为它们绕过了安全规则)
        // TODO:添加数据验证(例如,管理员不应该能够编辑 userProfile 数据)
        allow write: if isActiveOrgMember(orgId)
                     && hasOrgRole(orgId, "orgadmin");
      }

      match /products/{productId} {
        // 任何活跃成员都可以读取产品文档,如果他们具有适当的角色
        allow read: if isActiveOrgMember(orgId)
                    && (hasOrgRole(orgId, "products.read")
                    || hasOrgRole(orgId, "orgadmin"));
        // 任何活跃成员都可以写入产品文档,如果他们具有适当的角色
        // TODO:添加数据验证(例如,应该存在某些键)
        allow write: if isActiveOrgMember(orgId)
                    && (hasOrgRole(orgId, "products.write") || hasOrgRole(orgId, "orgadmin"));
      }
    }
  }
}

使用上述设置:

  • 直接读取 /organisations/{orgId}/privateUserData/{userId} 将产生 1 次文档读取费用。这是因为 get() 调用检查的是正在读取的相同文档,因此只返回文档本身。
  • 直接读取 /organisations/{orgId} 将产生 2 次文档读取费用。这是因为 get() 调用需要获取用户的文档。
  • 直接读取 /organisations/{orgId}/products/{productId} 也将产生 2 次文档读取费用。这是因为 get() 调用仍然需要获取用户的文档。
  • 列出 /organisations/{orgId}/products 下前 100 个产品的查询将产生最多 101 次文档读取费用。最好的情况下,它只会产生 1 次文档读取费用,用于失败查询的角色检查。最差情况下,它将产生 101 次文档读取费用 - 1 次用于角色检查,以及每个返回的文档各 1 次。
英文:

As you have identified, storing relevant roles in a organisation-based user document (such as /organisations/{orgId}/privateUserData/{userId}) will offer one of the best solutions.

At the time of writing, Firestore pricing is $0.06c (USD) per 100,000 document reads (above the 50k/day free quota). Securing your data using one get() call in your rules, with all the relevant data needed being in /organisations/{orgId}/privateUserData/{userId}, would make the rate $0.06c (USD) per 50,000 secured document reads at worst.

What do you mean "at worst"? As long as you aren't reading documents 1-by-1 (e.g. directly by their id), you won't actually pay for these secured document reads at a 1:1 rate. If your query returns 100 documents in one go, you only pay for the 1 get() call as the result of that call is cached and reused for all validations in that query.


To apply that with an example, let's assume you are using a document optimized for ease of use that looks like:

/organisations/{orgId}/privateUserData/{userId}: {
  uid: &quot;&lt;userId&gt;&quot;, // for collection group queries targetting a particular user
  orgId: &quot;&lt;orgId&gt;&quot;, // for collection group queries targetting a particular organisation
  disabled: false, // for suspending users,
  roles: {
    &quot;products.read&quot;: true,
    &quot;products.write&quot;: false, // can also omit, as rules will assume false
    // ...
  },
  userProfile: { // duplication of data in NoSQL databases is common
    img: &quot;https://...&quot;,
    displayName: &quot;Example User&quot;,
    username: &quot;exampleuser&quot;
  }
}

We can then wire it up with these rules:

service cloud.firestore {
  // note: this is not JavaScript - it is Security Rules Language

  // True if the user is signed in, they exist in the organisation, and are not marked disabled
  function isActiveOrgMember(orgId) {
    return request.auth.uid != null
      &amp;&amp; get(/databases/$(database)/documents/organisations/$(orgId)/privateUserData/$(request.auth.uid)).data.get(&quot;disabled&quot;, false) != true;
  }
  
  // True if the signed in user has the named role for the given organisation,
  // expected to be called after isActiveOrgMember(orgId) has returned true.
  function hasOrgRole(orgId, permission) {
    return get(/databases/$(database)/documents/organisations/$(orgId)/privateUserData/$(request.auth.uid)).data.roles.get(permission, false)
  }
 
  match /databases/{database}/documents {
    match /organisations/{orgId} {
      // Any member can read the org details
      allow read: if isActiveOrgMember(orgId);

      // Only admins can update org details
      allow write: if isActiveOrgMember(orgId) &amp;&amp; hasOrgRole(orgId, &quot;orgadmin&quot;);

      match /privateUserData/{userId} {
        // Only the named user (along with org admins) can read their own data
        allow read: if isActiveOrgMember(orgId)
                    &amp;&amp; (request.auth.uid == userId
                    || hasOrgRole(orgId, &quot;orgadmin&quot;));

        // Only org admins can update a user&#39;s data (the Admin SDKs
        // can also make changes because they bypass security rules)
        // TODO: Add data validation (e.g. admins should not be able
        // edit userProfile data)
        allow write: if isActiveOrgMember(orgId)
                     &amp;&amp; hasOrgRole(orgId, &quot;orgadmin&quot;);
      }

      match /products/{productId} {
        // Any active member can read products documents if they have
        // the appropriate roles
        allow read: if isActiveOrgMember(orgId)
                    &amp;&amp; (hasOrgRole(orgId, &quot;products.read&quot;)
                    || hasOrgRole(orgId, &quot;orgadmin&quot;));
        // Any active member can write to products documents if they
        // have the appropriate roles
        // TODO: Add data validation (e.g. certain keys should be present)
        allow write: if isActiveOrgMember(orgId)
                    &amp;&amp; (hasOrgRole(orgId, &quot;products.write&quot;) || hasOrgRole(orgId, &quot;orgadmin&quot;));
      }
    }
  }
}

With the above setup:

  • a read of /organisations/{orgId}/privateUserData/{userId} directly will incur a document read cost of 1. This is because the get() call is checking the same document being read and thus just returns the document as-is.
  • a read of /organisations/{orgId} directly will incur a document read cost of 2. This is because the get() call needs to fetch the user's document.
  • a read of /organisations/{orgId}/products/{productId} directly will also incur a document read cost of 2. This is because the get() call still needs to fetch the user's document.
  • a list query of the top 100 products under /organisations/{orgId}/products will incur a document read cost of up to 101. At best, it will only cost 1 document read for the roles check that it uses to fail the query. At worst, it will cost 101 document reads - 1 for the roles check and 1 for each returned document.

huangapple
  • 本文由 发表于 2023年2月24日 07:12:02
  • 转载请务必保留本文链接:https://go.coder-hub.com/75551243.html
匿名

发表评论

匿名网友

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

确定