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.
Restricting access on data nodes
Two methods, both bidirectional with their corresponding Unrestrict*:
graph.RestrictAccessToTeam(reportNode, marketingTeam);
graph.RestrictAccessToUser(reportNode, individualWatcher);
// Convergent — call when the source revokes.
graph.UnrestrictAccessFromTeam(reportNode, marketingTeam);
graph.UnrestrictAccessFromUser(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.
To enforce per-schema defaults, mark the node type Protected under Settings → Schema. Without that, even an empty restriction list allows everyone.
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. CallUnrestrict*explicitly 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.