Item Sharing—Access Control List
Item sharing to users, projects, organizations and publicly at scale
Role-Based Access Control helps us model permissions for what a user can do in general: Can you create a document, see analytics, etc.However, that system can't tell you what a user can do with this specific item. For collaborative platforms, you need to design a system that supports this at scale. This is called building an Access Control List.
§The Primitive: Grants
A grant links an entity to an item. An entity is a user, project, organization, or the public. An item is any resource on your platform that can be shared. There are many ways to design this data model, but it's possible to have one table that answers all questions about all items.
1CREATE TABLE item_access_grants (2 item_id STRING(1024) NOT NULL,3 item_type STRING(64) NOT NULL,4 entity_id STRING(1024) NOT NULL,5 entity_type STRING(64) NOT NULL,6 role STRING(64) NOT NULL,7 granted_by STRING(1024),8 created_ts TIMESTAMP NOT NULL,9 updated_ts TIMESTAMP NOT NULL10) PRIMARY KEY(item_id, item_type, entity_id, entity_type);With the table, we can easily express:
Marcelois aneditoron thisdocument(entity_type=user, role=editor)- Everyone in
Project Xcanviewthisdocument(entity_type=project, role=viewer) - Everyone in
Organization Acaneditthisdocument(entity_type=organization, role=editor) - This
documentiseditableto thepublic(entity_type=public, role=editor)
We can also easily support sharing an item with N users, M projects, and L organizations.
§Public Access as a grant, not a flag
Another common option is to have an is_public boolean on the item itself. It's my personal opinion that this is not the optimal choice, but rather having a __public__ sentinel entity to represent public access is better.
- Centralization: one table answers all access questions. You don't load the item itself just to check if it's public.
- No schema sprawl: you'd need that boolean column on every model that could possibly be public.
- No accidental data exposure: loading an item to check a boolean means you've already loaded data about that item — data you might not want to expose.
§Grant Resolution
When multiple grants exist that are applicable to the requesting user, there are a few viable options with trade-offs:
§Most specific wins
The closest relationship to the item takes precedence. A direct viewer grant won't be elevated by an org-wide editor grant. Most predictable, users can be intentionally restricted.It can surprise users who expect broader grants to elevate.
§Most permissive wins
If any grant gives you editor, you're editor. Simple to reason about, generous. You can never restrict access below what a broader grant allows.
§Most restrictive wins
Safe for compliance-heavy environments. Sharing becomes frustrating because restrictive org policies override direct shares.
At Gumloop, we choose most specific because it makes sharing behaviour most predictable. We built a resolver to centralize all this logic, and override it on a per-resource basis when needed.
1from typing import Optional2from .permission_enums import AclRole, GrantEntityType, PUBLIC_ENTITY_ID, ROLE_PRECEDENCE, STR_TO_ROLE3
4class ItemAccessResolver:5 """6 Base resolver for item-level access.7
8 Subclasses set ITEM_TYPE and VALID_ROLES.9 Resolution order (most-specific-entity-first):10 1. User-level grant → terminal, cannot be overridden11 2. Project-level grant → requires project membership12 3. Org-level grant → requires org membership13 4. Public access → public sentinel grant (__public__)14 5. Default deny15 """16
17 ITEM_TYPE: str = ""18 VALID_ROLES: list[AclRole] = [AclRole.OWNER, AclRole.EDITOR, AclRole.VIEWER]19
20 @classmethod21 def resolve(cls, item_id: str, user_id: str) -> Optional[AclRole]:22 grants = load_grants(item_id, cls.ITEM_TYPE, user_id)23 return cls.resolve_from_grants(grants, user_id)24
25 @classmethod26 def resolve_from_grants(cls, grants: list, user_id: str) -> Optional[AclRole]:27 if not grants:28 return None29
30 # User-level grant (most specific, terminal)31 for g in grants:32 if g.entity_type == GrantEntityType.USER and g.entity_id == user_id:33 return cls._validate_role(g.role)34
35 # Project-level grants (highest privilege wins)36 project_ids = get_user_project_ids(user_id)37 best = cls._best_matching_role(grants, GrantEntityType.PROJECT, project_ids)38 if best is not None:39 return best40
41 # Org-level grants (highest privilege wins)42 org_ids = get_user_org_ids(user_id)43 best = cls._best_matching_role(grants, GrantEntityType.ORGANIZATION, org_ids)44 if best is not None:45 return best46
47 # Public access sentinel48 for g in grants:49 if g.entity_type == GrantEntityType.PUBLIC and g.entity_id == PUBLIC_ENTITY_ID:50 return cls._validate_role(g.role)51
52 return None53
54 @classmethod55 def _best_matching_role(56 cls, grants: list, entity_type: GrantEntityType, member_ids: set[str],57 ) -> Optional[AclRole]:58 best: Optional[AclRole] = None59 for g in grants:60 if g.entity_type == entity_type and g.entity_id in member_ids:61 role = cls._validate_role(g.role)62 if role is not None and higher_precedence(role, best):63 best = role64 return best65
66 @classmethod67 def _validate_role(cls, role_str: str) -> Optional[AclRole]:68 role = STR_TO_ROLE.get(role_str)69 if role and role in cls.VALID_ROLES:70 return role71 return None72
73def higher_precedence(candidate: AclRole, current: Optional[AclRole]) -> bool:74 return current is None or ROLE_PRECEDENCE[candidate] > ROLE_PRECEDENCE[current]§Cascading Grants
Items live inside other items. A file lives inside a folder. If someone shares the folder, should the files inside it be accessible? Without cascading, you'd need to duplicate grants for every child item whenever a parent is shared — a UX nightmare and a consistency bug waiting to happen. Because ACL is resolved per-item and RBAC acts as a ceiling, you can have child items inherit grants from their parent without worrying about the child bypassing org-level restrictions.
§Full delegation
A file inside a shared folder. The file has no grants of its own — it entirely inherits from the folder. If the folder is public, the file is public. If a user is shared on the folder as viewer, they're viewer on the file.
1class FileResolver(ItemAccessResolver):2 """3 Full delegation — files inherit access entirely from their parent folder.4
5 No grants of their own. If the folder is public, the file is public.6 If the folder is shared with a user as editor, the file is too.7 """8
9 ITEM_TYPE = "file"10
11 @classmethod12 def resolve(cls, item_id: str, user_id: str) -> Optional[AclRole]:13 parent_folder_id = get_parent_folder_id(item_id)14 if parent_folder_id is None:15 return None16 return FolderResolver.resolve(parent_folder_id, user_id)§Hybrid
A form linked to a spreadsheet. The form has its own general access grants — org-wide visibility, public link — but user-level shares cascade from the parent spreadsheet. If someone is shared as editor on the spreadsheet, they get at least editor on the form too. The form can also be independently shared with the org or made public.
1class FormResolver(ItemAccessResolver):2 """3 Hybrid — forms have their own org/public grants, but cascade4 user-level grants from the parent spreadsheet.5
6 Resolution:7 1. Resolve the form's own grants normally.8 2. Resolve the parent spreadsheet's user-level grant.9 3. Take whichever is higher precedence.10 """11
12 ITEM_TYPE = "form"13
14 @classmethod15 def resolve(cls, item_id: str, user_id: str) -> Optional[AclRole]:16 own_role = super().resolve(item_id, user_id)17 parent_id = get_parent_spreadsheet_id(item_id)18 if parent_id is None:19 return own_role20
21 parent_grants = load_grants(parent_id, "spreadsheet", user_id)22
23 parent_role = None24 for g in parent_grants:25 if g.entity_type == GrantEntityType.USER and g.entity_id == user_id:26 parent_role = cls._validate_role(g.role)27 break28
29 if parent_role is None:30 return own_role31 if own_role is None:32 return parent_role33 return parent_role if higher_precedence(parent_role, own_role) else own_role§Standalone
Top-level items like folders and spreadsheets. Resolved purely from their own grants, no cascade. This is the base resolver shown above.
§The Right Question
When it comes to permissioning systems, my personal principal is: Make sure that the code asks the right question. With this system and the proper helpers to resolve permission, we can properly ask "What is this user's effective role on this specific agent, considering all grants that apply to them?"