Access Control Model: Deep Dive

This page provides a technical deep dive into the access control model of Curiosity Studio. It is intended for developers building connectors, custom endpoints, or understanding the security implications of the graph model.

A permission graph illustrating User, AccessGroup, and Resource nodes with directional edges and ACL indexing panel.

Overview: Relationship-Based Access Control (ReBAC)

Curiosity Studio employs a Relationship-Based Access Control (ReBAC) model. Unlike traditional Role-Based Access Control (RBAC) where permissions are assigned to roles and roles to users, ReBAC determines access based on the relationships between subjects (users) and objects (resources) in the graph.

As discussed in this Auth0 blog post on ReBAC, ReBAC is powerful because it allows for fine-grained, dynamic permissions that scale with your data model. Access is not a static list; it is a question answered by traversing the graph: "Is there a path from User U to Resource R via ownership or membership edges?"

Curiosity Studio Access Control

The Graph Model

The access control model relies on specific node types and edge types within the graph.

Nodes

  • _User: Represents a user in the system.
  • _AccessGroup: Represents a group of users and of other access groups (often displayed as a Team in the UI). A group placed inside another group is a nested group — see Nested Access Groups.
  • Resources: Any node (File, Folder, entity, etc.) can be a resource protected by this model.

Edges

  • Ownership: Defines who owns a resource.

    • _OwnedBy: Points from Resource to Owner (User or AccessGroup). This is the edge the access-control engine actually reads — every permission check (API, query engine, search) walks _OwnedBy outward from the resource.
    • _Owns: Points from Owner to Resource — the reverse of _OwnedBy. It is never consulted by a permission check. It exists purely so the graph can be walked the other way, from an owner to everything it owns — see Do you need the _Owns back-edge? below.
    • Note: Curiosity.Library's helper methods (RestrictAccessToTeam, RestrictAccessToUser, AddOwners) maintain both edges as a pair by default — you don't add either one by hand.
  • Membership: Defines who belongs to a group.

    • _MemberOf: Points from a User or AccessGroup to the AccessGroup it belongs to. An _AccessGroup with a _MemberOf edge to another _AccessGroup is a nested group.
    • _HasMember: Points from AccessGroup to its member (User or AccessGroup) — the reverse of _MemberOf.
    • _AdminOf / _HasAdmin: The same pair for group administrators. An admin counts as a member, and an admin of an owning group also counts as an owner of what the group owns, which is what edit rights are read from — see Admin rights through nesting.

Propagation Logic

Access is granted if a valid path exists between the resource and the user. The engine checks this by walking _OwnedBy edges outward from the resource — _Owns is never part of the check, since the engine only ever needs to answer "who owns this resource?", not "what does this owner have?".

  1. Direct Ownership: Resource -[_OwnedBy]-> User
  2. Group Ownership: Resource -[_OwnedBy]-> AccessGroup, and the user is a member of that group — directly, or through a group nested inside it at any depth (see Nested Access Groups).
  3. Resource-to-resource ownership: A resource can itself be _OwnedBy another resource (e.g. an email attachment owned by the parent email), which is in turn owned by a user or group — the engine follows this chain a couple of levels deep.

In essence, if you are a member of a team, you inherit the access rights (ownerships) of that team — and of every team that team is itself a member of.

Nested Access Groups

An access group can be a member of another access group. The edge pair is the same one a user uses — the child group has a _MemberOf edge to the parent, and the parent has a _HasMember edge back — so nothing about the model changes; the member is a group instead of a user.

flowchart BT uGrand["User<br/>(member of Grandchild A)"] -->|_MemberOf| grand["Grandchild A"] uChildB["User<br/>(member of Child B)"] -->|_MemberOf| childB["Child B"] grand -->|_MemberOf| childA["Child A"] childA -->|_MemberOf| parent["Parent"] childB -->|_MemberOf| parent report["Report"] -.->|_OwnedBy| parent design["Design doc"] -.->|_OwnedBy| childA sketch["Sketch"] -.->|_OwnedBy| grand budget["Budget"] -.->|_OwnedBy| childB

Three rules describe the whole behaviour, and the access-control test suite pins each of them:

  • Membership flows upward. A member of Grandchild A is also a member of Child A and of Parent, to any depth.
  • Permissions flow downward. Whatever Parent owns is visible to the members of Parent, Child A, Child B and Grandchild A. Whatever Grandchild A owns is visible to the members of Grandchild A only — being a member of Parent grants nothing on the groups below it.
  • Nothing flows sideways. A member of Child A sees nothing Child B owns, and the other way round.

| Node | Owned by | Member of Parent | Member of Child A | Member of Grandchild A | Member of Child B | | --- | --- | --- | --- | --- | --- | | Report | Parent | yes | yes | yes | yes | | Design doc | Child A | no | yes | yes | no | | Sketch | Grandchild A | no | no | yes | no | | Budget | Child B | no | no | no | yes |

How the engine resolves nesting

The engine does not walk the group tree per node. The first time a user is checked, it computes that user's transitive access-group set once: the groups the user's own _MemberOf / _AdminOf edges point to, then, breadth-first, every group those groups are a member or admin of, until nothing new turns up. A visited set ends the walk, so a cycle (A is a member of B, B is a member of A) terminates and makes the two groups members of each other. There is no depth limit.

Every enforcement layer tests a group UID against that one set, so nesting is honoured the same way by:

  • node ownership — the node is _OwnedBy a group the set contains;
  • node-type restrictions and field-level restrictions — a user who reaches the restricted type's group only through a nested group is a member there too;
  • the query engine and the search engine, whose results go through the same access check.

The set is cached per user. It is dropped for that user whenever their _User node is committed, and for every user whenever any _AccessGroup node is committed — which is what adding or removing a membership edge does. A nesting change is in effect on the next access check, with nothing to reindex.

The inverse question, who can see this node, walks the other way: from the owning groups down every _HasMember / _HasAdmin edge, collecting users and queueing the nested groups it meets.

Admin rights through nesting

Membership decides what a user can see. What a user can edit is read from ownership, and an admin of an owning group counts as an owner. Nesting carries this down to any depth: when an owning group has a _HasAdmin edge to a nested group, everyone who belongs to that nested group edits what the owning group owns — its members and admins, and the members of the groups nested inside it. A nested group that is only a _HasMember of the owning group views, however deep the chain below it. Only the first hop decides between editing and viewing; the check reads it against the same transitive set as the read check.

The Access Debugger reports the editing chain when one exists and falls back to the viewing chain otherwise, so a user who reaches a node through both is explained by the one that grants more.

Where nesting is not followed

The transitive set is what access checks use. A few places that answer "who is in this group?" read the group's direct _HasMember edges to _User nodes only:

  • The Teams admin page and the groups/group/{uid}/users endpoints list, count, add and remove direct user members. A nested group does not appear in a group's member list, and its users are not counted as the group's users.
  • The is member / is admin checks behind the group management endpoints, the discoverability of a hidden group, and a search scoped to one team's content.
  • A vector embeddings index restricted to one access group admits direct members of that group only.

Add a user who has to pass one of those checks to the group directly.

Creating a nested group

There is no admin screen for it: the Teams page and the groups/group/{uid}/users endpoints manage a group's users, and the user helpers (AddUserToTeam, AddUserToTeamAsync) accept only a _User. A nested group is created by code, with the team helpers that mirror them.

From a data connector (Curiosity.Library): create both groups with CreateTeamAsync, then nest one in the other. The same methods exist on the Graph a workspace-hosted connector or scheduled task receives.

var engineering = await graph.CreateTeamAsync("Engineering");
var platform    = await graph.CreateTeamAsync("Platform", "Platform engineering");
var leads       = await graph.CreateTeamAsync("Engineering Leads");

// Platform is a member of Engineering: everything Engineering owns is visible to Platform's members.
graph.AddTeamToTeam(platform, engineering);

// Leads administers Engineering: its members and admins can edit what Engineering owns.
graph.AddAdminTeamToTeam(leads, engineering);

// Undo: drop the admin rights but keep the membership, or remove the team altogether.
graph.RemoveAdminTeamFromTeam(leads, engineering);
graph.RemoveTeamFromTeam(platform, engineering);

The helpers refuse a team nested in itself and the built-in Public and Private groups, and they write the same _MemberOf / _HasMember (and _AdminOf / _HasAdmin) pair a user's membership uses — graph.Link(platform, engineering, "_MemberOf", "_HasMember") is the equivalent on a library version that predates them. A team's UID is derived from its name, so CreateTeamAsync is idempotent and a connector can run this on every sync to converge on the source system's group tree, calling RemoveTeamFromTeam when the source removes a nesting.

From server-side code (a custom endpoint, a scheduled task, a migration) the Graph has the same helpers by UID:

await graph.AddTeamToTeamAsync(platformUID, engineeringUID);
await graph.AddAdminTeamToTeamAsync(leadsUID, engineeringUID);
await graph.RemoveTeamFromTeamAsync(platformUID, engineeringUID);

If you write the edges yourself instead, always write both directions. The access check reads _MemberOf on the way up; the downward walks — who can see a node, the Access Debugger's explanation, the per-user cache invalidation when a group changes — read _HasMember / _HasAdmin. A half-written pair is a group that is nested from one side only.

Two things that do not produce nested groups:

  • SSO group mapping maps each identity-provider group claim to one workspace team and adds the user to it directly. A hierarchy in the identity provider is not reproduced; mirror it with a connector if you need it.
  • The workspace-configuration export / import. An access group travels as a declaration (name, description, hidden flag, scope) and never with its membership, nested or otherwise.
Debugging a nested chain

The Access Debugger follows nested groups to any depth and reports every hop — the group that owns the node, each group nested inside it, and the group the user belongs to directly — so a chain that stops short shows exactly where.

Special Access Groups

The system reserves two built-in access groups for special access scenarios. Connector code never references these groups directly — Curiosity.Library exposes dedicated wrappers (see Making Content Private below) that maintain the ownership edges for you.

Public Access Group

  • Behavior: Any resource that has an _OwnedBy edge pointing to the public group is considered Public. It is visible to all authenticated users (and potentially unauthenticated ones depending on deployment configuration).
  • Enforcement: The search engine and query engine treat this group as a "wildcard" that everyone is implicitly a member of.
  • How to set it: Content is public by default — uploads default to initiallyPrivate: false.

Private Access Group

  • Behavior: A system-managed group used to explicitly mark content as Private / restricted, ensuring it is not connected to the public group.
  • How to set it: Use the helper methods (MarkFileAsPrivate, MarkFolderAsPrivate, or initiallyPrivate: true on upload) rather than manipulating edges to this group directly.

Implementation: Data Connector

When building Data Connectors using Curiosity.Library, you interact with this model using helper methods on the Graph object.

Creating Teams and Users

// Create a Team (_AccessGroup)
var teamNode = await graph.CreateTeamAsync("Engineering Team", "All engineering staff");

// Create a User (_User)
var userNode = await graph.CreateUserAsync("jane.doe", "jane@example.com", "Jane", "Doe");

// Add User to Team
graph.AddUserToTeam(userNode, teamNode);
// This creates _MemberOf / _HasMember edges

Restricting Access

To restrict access to a specific team or user, you establish ownership edges.

var secretDoc = Node.FromKey(N.Document.Type, "secret-plans.pdf");

// Restrict to a Team
// Adds: secretDoc -[_OwnedBy]-> teamNode AND teamNode -[_Owns]-> secretDoc
graph.RestrictAccessToTeam(secretDoc, teamNode);

// Restrict to a specific User
// Adds: secretDoc -[_OwnedBy]-> userNode AND userNode -[_Owns]-> secretDoc
graph.RestrictAccessToUser(secretDoc, userNode);

Do you need the _Owns back-edge?

_Owns doesn't grant or check anything — as noted above, permission checks only ever read _OwnedBy. What _Owns powers is every place the workspace needs to answer "what does this owner have?" instead of "who owns this?", most notably:

  • The Access Group admin interface — the "Owned items" view on a team or user lists everything reachable via that owner's _Owns edges.
  • Per-owner content listings elsewhere in the UI (e.g. "My Files", chat/memory listings).
  • Cascading cleanup — when an owner node is deleted, its _Owns edges are what let the workspace find (and remove) content that only that owner had access to.

RestrictAccessToTeam, RestrictAccessToUser, and AddOwners always add both edges, so most connector code never has to think about this. The lower-level AddOrUpdateWithOwnership / TryAddWithOwnership overloads — used when you're creating or updating a node and assigning its owners in the same call — accept an optional addOwnsEdge parameter (default true) so you can skip the reverse edge when your use case doesn't need it:

// Default: both _OwnedBy (node -> team) and _Owns (team -> node) are written.
graph.TryAddWithOwnership(node, teamNode);

// Opt out of the reverse _Owns edge. The node is still only visible to
// teamNode's members — permissions are unaffected — but it won't show up
// under teamNode in the Access Group interface's "Owned items" list, "My
// Files"-style listings, or cascading-delete cleanup.
graph.TryAddWithOwnership(node, addOwnsEdge: false, teamNode);

Only reach for addOwnsEdge: false when you've confirmed the use case genuinely doesn't need reverse lookups for that content — e.g. very high-volume ingestion of internal/ephemeral nodes you'll never enumerate "by owner". When in doubt, leave the default in place.

Making Content Private

Public content carries an _OwnedBy edge to the public access group. To revoke it and make a file private, use the wrapper — it removes the edge for you, so connector code never handles the group UID:

var fileNode = Node.FromUID("some-file-uid", "_FileEntry");

// Removes the _OwnedBy edge to the public access group
graph.MarkFileAsPrivate(fileNode);

Folders have the matching graph.MarkFolderAsPrivate(folderNode), and the upload/create helpers accept initiallyPrivate: true to skip the public edge from the start.

Enforcement

Access control is enforced at multiple layers of the stack.

1. APIs

When fetching a node by ID or traversing the graph via the API, the system checks if the requesting user has a valid path to the target node. If no path exists (and the node is not public), the API returns a 403 Forbidden or 404 Not Found.

2. Query Engine

Graph queries (GQL) are executed within the context of the user's permissions. The engine implicitly filters the graph traversal. If a user tries to match a pattern involving nodes they cannot see, those nodes are excluded from the result set.

3. Search Engine

The search index stores Access Control Lists (ACLs) alongside document content. These ACLs are derived from the graph relationships (users and groups that have access).

  • Indexing Time: When a document is ingested, its ownerships are resolved and indexed.
  • Query Time: When a user searches, their query is augmented with a filter: (access_groups:Public OR access_groups:UserID OR access_groups:{User'sTeamIDs}). The team IDs are the user's transitive set, so a team reached through a nested group counts.
  • Result: Users never see search results for content they don't have access to.

4. Exports and Downloads

A CSV, JSON Lines or Excel download of a query or a node type is filtered like any other read: node-level access control decides which rows the reader gets, and field-level access control decides which cells are filled in. A field the reader may not see is blanked rather than omitted, so the column shape of an export does not change from one reader to the next.

Exports previously bypassed field-level access control entirely, returning every field of every row the reader could see. An integration that parses an export and depends on a restricted field should run under a token that is allowed to read it — see what one user sees of another for the user node type, where this is most visible.

Key Points & Best Practices

  • Consistency: Always maintain bidirectional edges (_Owns / _OwnedBy) if manipulating the graph manually. Curiosity.Library methods handle this for you, and only skip the _Owns back-edge (via addOwnsEdge: false on AddOrUpdateWithOwnership / TryAddWithOwnership) when you've confirmed the use case doesn't need reverse lookups — see Do you need the _Owns back-edge?.
  • Orphaned Resources: A resource with no _OwnedBy edges might be effectively invisible to everyone except admins, or might fall back to default visibility rules depending on the system configuration. Always assign an owner (User, Team, or Public).
  • Nested Teams: Nest a team inside another when its members should see everything the outer team owns — a department team holding its project teams, for instance. Keep the tree shallow enough that an admin can read it without the Access Debugger. See Nested Access Groups.
  • Group Cycles: The engine handles cycles (Team A is a member of Team B, which is a member of Team A — the two see everything either owns), but avoid them to keep your permission model understandable.
  • Least Privilege: Start by restricting access to the specific owner (User) or a small Team. Only grant Public access when explicitly intended.
© 2026 Curiosity. All rights reserved.
Powered by Neko