Curiosity

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 Marketing and Jane see the report in CreateSearchAsUserAsync results.
  • Other users get the report omitted (not a permission-denied — silently absent).
  • Admin scripts using CreateSearchAsync still 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 report doesn't restrict the Author node it links to — they need their own ACL.
  • Updates don't auto-revoke. Running AddOrUpdate on an existing node doesn't clear its restrictions. Call Unrestrict* explicitly when the source revokes access.
  • Protected schemas need ACLs. If you mark a schema protected but the connector forgets to call RestrictAccessTo*, the data is ingested but nobody can see it.
© 2026 Curiosity. All rights reserved.