Access Control
Curiosity uses ReBAC (relationship-based access control) — permissions are edges in the graph between data nodes and the internal _User / _AccessGroup nodes. Connectors mirror source permissions onto the data at ingest time; the query side (endpoints, search) enforces them automatically via CreateSearchAsUserAsync.
For the query-time half of this pattern, see Permission-aware search.
Internal node types
| Type | Represents | Created via |
|---|---|---|
_User |
An individual user | await graph.CreateUserAsync(login, email, firstName, lastName) |
_AccessGroup |
A team / group | await graph.CreateTeamAsync(name, description = null) |
Both calls are idempotent — re-running just refreshes properties on the existing node. The internal types are protected: you can't delete them, but you can manage members and admins through the API.
Managing users and teams
var alice = await graph.CreateUserAsync("alice", "alice@example.com", "Alice", "Anders");
var bob = await graph.CreateUserAsync("bob", "bob@example.com", "Bob", "Boniface");
var tier2 = await graph.CreateTeamAsync("Tier-2 Support", "Hardware escalation team");
graph.AddUserToTeam(alice, tier2);
graph.AddUserToTeam(bob, tier2);
graph.AddAdminToTeam(alice, tier2); // Alice can manage Tier-2 members
// Convergent membership: revoke when the source revokes.
graph.RemoveUserFromTeam(bob, tier2);
Use connectorName to mirror upstream group memberships idempotently — RemoveUserFromTeam is the lever for permission revocations.
A team can also be a member of another team:
var support = await graph.CreateTeamAsync("Support");
// Tier-2 is a member of Support: everything Support owns is visible to Tier-2's members.
graph.AddTeamToTeam(tier2, support);
graph.AddAdminTeamToTeam(tier2, support); // ...and Tier-2's members can edit it
graph.RemoveTeamFromTeam(tier2, support); // when the source removes the nesting
Membership flows upward and permissions flow downward, never sideways — see Nested Access Groups for the rules and for the places that only count direct members.
Restricting access on data nodes
Two methods, each with a corresponding RemoveAccessFrom* to revoke:
graph.RestrictAccessToTeam(reportNode, marketingTeam);
graph.RestrictAccessToUser(reportNode, individualWatcher);
// Convergent — call when the source revokes.
graph.RemoveAccessFromTeam(reportNode, marketingTeam);
graph.RemoveAccessFromUser(reportNode, individualWatcher);
Semantics:
- No restriction at all → default for the node type. For unprotected schemas, that's "everyone in the workspace". For protected schemas, that's "admins only".
- Any restriction present → only listed teams' members and listed users can see the node. Restrictions are additive ("A or B"), not intersected.
- An exclusive owner present → only the exclusive owners count. The restrictions above stay on the node and apply again once the exclusive owners are removed (see below).
To enforce per-schema defaults, mark the node type Protected under Settings → Schema. Without that, even an empty restriction list allows everyone.
Overriding the source with an exclusive owner
Some restrictions must win over whatever the source says, such as a legal hold or an embargo placed in Curiosity Studio rather than in the source system. Make the team an exclusive owner:
graph.RestrictAccessExclusivelyToTeam(contractNode, legalHoldTeam);
// Later, when the hold is lifted: the node's other owners decide again.
graph.RemoveExclusiveAccessRestrictions(contractNode);
While a node has an exclusive owner, its RestrictAccessToTeam / RestrictAccessToUser owners and the public group are ignored. A connector that resyncs permissions with ClearPermissions or ClearPermissionsExcept on every run does not remove an exclusive owner, so the hold survives the sync. RestrictAccessExclusivelyToTeam takes addOwnsEdge: false to skip the team's reverse _Owns edge, as described in Do you need the _Owns back-edge?.
RemoveExclusiveAccessRestrictions needs a server that supports it; an older server rejects the operation as an unknown graph operation.
Restricting a file as it is uploaded
UploadFileAsync followed by RestrictAccessToTeam leaves the file visible to everyone until the
restriction is committed. Pass the owners to the upload instead, and the server writes the file and
its _OwnedBy edges together:
var finance = await graph.CreateTeamAsync("Finance");
await graph.UploadFileWithOwnershipAsync(stream, "budget.xlsx", "SharePoint", new[] { finance });
UploadFileToFolderWithOwnershipAsync and UploadFileWithIdentifierAndOwnershipAsync do the same for
the folder and identifier uploads. A file that belongs to a record, such as an e-mail attachment, can
be linked to it in the same write with UploadFileAsAttachmentAsync(stream, fileName, sourceName, parentNode).
That writes only the _AttachmentOf / _HasAttachment edges, so restrict the file separately if it
needs restricting. See Graph class.
Complete example
using var graph = Graph.Connect(/* endpoint, token, connectorName */);
// 1. Make sure team/user records exist.
var team = await graph.CreateTeamAsync("Marketing");
var jane = await graph.CreateUserAsync("janedoe", "janedoe@example.com", "Jane", "Doe");
// 2. Ingest the data node.
var report = graph.AddOrUpdate(new Report
{
Id = "RPT-Q4",
Title = "Q4 Marketing Plan",
});
// 3. Mirror source ACLs.
graph.RestrictAccessToTeam(report, team);
graph.RestrictAccessToUser(report, jane);
await graph.CommitPendingAsync();
After this:
- Members of
Marketingand Jane see the report inCreateSearchAsUserAsyncresults. - Other users get the report omitted (not a permission-denied — silently absent).
- Admin scripts using
CreateSearchAsyncstill see everything, which is the right default for batch jobs.
Pitfalls
- Reading from a connector token doesn't enforce ACLs. The connector role is intentionally permissive so ingestion can see what it needs to. Don't repurpose a connector token for user-facing queries.
- Restrictions stick to the node, not the edge. Restricting
reportdoesn't restrict theAuthornode it links to — they need their own ACL. - Updates don't auto-revoke. Running
AddOrUpdateon an existing node doesn't clear its restrictions. CallRemoveAccessFromTeam/RemoveAccessFromUserexplicitly when the source revokes access. Protectedschemas need ACLs. If you mark a schema protected but the connector forgets to callRestrictAccessTo*, the data is ingested but nobody can see it.
Cross-links
- Permission-aware search — query-side enforcement with
CreateSearchAsUserAsync. - Access control model — admin-side reference.
- Schemas — marking a schema protected.
- Ingestion — where the restriction calls usually live.