Curiosity

Permission-aware search

Two halves of the same problem: what is allowed to be returned (ingest-time ACLs) and who is asking (query-time enforcement). This tutorial walks both ends, end to end, using CreateUserAsync, CreateTeamAsync, RestrictAccessToTeam, RestrictAccessToUser, and CreateSearchAsUserAsync. All four are real methods in Curiosity.Library and the Mosaik graph API.

Estimated time: 30–45 minutes.

Why this is one topic

It's tempting to put ACLs in a connector and never think about them again — and tempting to filter results in the endpoint with an if statement. Both lead to leaks.

flowchart LR subgraph Ingest [Ingest time] Source[(Source<br/>with ACL)] -->|mirror permissions| ACL["RestrictAccessTo*<br/>Team / User"] end subgraph Query [Query time] User([User]) -->|JWT / session| API API -->|user UID| Search["CreateSearchAsUserAsync"] end ACL --> Graph[(Graph)] Search --> Graph Graph -->|filtered results| API API -->|allowed only| User

The model is ReBAC (relationship-based access control): permissions are edges in the graph from the data node to a _User or _AccessGroup node. The same graph engine that answers search also filters by those edges, so there's no "second filter pass" that can drift out of sync.

Step 1 — model your users and teams

The workspace ships with two internal node types that you address through extension methods on IWritableGraph:

Node type Represents How to create
_User An individual user await graph.CreateUserAsync(login, email, firstName, lastName)
_AccessGroup A team / group await graph.CreateTeamAsync(name, description)

Both are idempotent — re-running just refreshes properties on the existing node.

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", "Escalation team for hardware cases");

graph.AddUserToTeam(alice, tier2);
graph.AddUserToTeam(bob,   tier2);
graph.AddAdminToTeam(alice, tier2);   // Alice can manage the team itself

Step 2 — restrict data at ingest time

Mirror the source ACL onto each data node as it's created. Don't ingest first and lock down later — there is a window where search returns rows the source forbade.

var caseNode = graph.AddOrUpdate(new Nodes.SupportCase
{
    Id      = "CS-0142",
    Summary = "Screen flickers after waking from sleep",
    Content = sourceCase.Content,
    Time    = sourceCase.OpenedAt,
});

// Whole team can see this case.
graph.RestrictAccessToTeam(caseNode, tier2);

// Individual watchers from the source.
foreach (var watcherLogin in sourceCase.WatcherLogins)
{
    var watcher = await graph.CreateUserAsync(watcherLogin, /* ... */);
    graph.RestrictAccessToUser(caseNode, watcher);
}

await graph.CommitPendingAsync();

The semantics:

  • No restriction at all → governed by the workspace default for that node type. For unprotected schemas, that's "everyone". For protected schemas, that's "no one but admins".
  • Any team or user restriction → only listed teams' members + listed users can see the node.

Restrictions are additive. RestrictAccessToTeam(A); RestrictAccessToTeam(B); means "A or B can see this". You can't express "must be in A and B" with these calls — use a derived team for that.

For the full method list (including RemoveUserFromTeam, AddAdminToTeam, etc.) see Access control.

Step 3 — enforce at query time

Inside a custom endpoint, CurrentUser is the UID of the authenticated caller. Pass it through to CreateSearchAsUserAsync to get a result set that already has the ACL applied:

// Endpoint: search-cases
// Read Only: true

var request = SearchRequest.For(Body.FromJson<Req>().Query);
request.BeforeTypesFacet = new HashSet<string> { nameof(Nodes.SupportCase) };

var query = await Graph.CreateSearchAsUserAsync(request, CurrentUser, CancellationToken);
return Ok(query.Take(20).Emit());

Compare:

Call Sees Use when
Graph.CreateSearchAsync(request) Everything in the graph Admin scripts, debugging, scheduled tasks
Graph.CreateSearchAsUserAsync(req, uid) Only what uid is entitled to see Any endpoint that runs on behalf of a user

Both return IQuery, so the rest of the pipeline — facets, sort, paging, score — is identical.

Step 4 — handle "endpoint token" callers

Endpoint tokens (machine-to-machine) don't have a user UID; in that scope CurrentUser is default. Decide explicitly what that means for your endpoint:

if (CurrentUser == default)
{
    // Service account. Either return everything, or refuse — your call.
    return Forbid("This endpoint requires a user session.");
}

var query = await Graph.CreateSearchAsUserAsync(request, CurrentUser, CancellationToken);
return Ok(query.Take(20).Emit());

The "refuse" branch is the safer default for anything that touches restricted data.

Step 5 — verify with a fixture

A small fixture confirms ingest-time restrictions are wired correctly. Add this as an integration test that runs against a freshly-seeded workspace:

[Test]
public async Task Alice_sees_team_cases_Bob_does_not()
{
    // Arrange
    var alice    = await Graph.CreateUserAsync("alice", /* ... */);
    var bob      = await Graph.CreateUserAsync("bob",   /* ... */);
    var tier2    = await Graph.CreateTeamAsync("Tier-2");
    Graph.AddUserToTeam(alice, tier2);

    var caseNode = Graph.AddOrUpdate(new Nodes.SupportCase { Id = "CS-T", Summary = "test", Content = "test", Time = DateTimeOffset.UtcNow });
    Graph.RestrictAccessToTeam(caseNode, tier2);
    await Graph.CommitPendingAsync();

    var req = SearchRequest.For("test");
    req.BeforeTypesFacet = new HashSet<string> { nameof(Nodes.SupportCase) };

    // Act
    var aliceHits = (await Graph.CreateSearchAsUserAsync(req, alice.UID)).Emit().ToList();
    var bobHits   = (await Graph.CreateSearchAsUserAsync(req, bob.UID  )).Emit().ToList();

    // Assert
    Assert.That(aliceHits.Any(n => n.Key == "CS-T"), Is.True);
    Assert.That(bobHits  .Any(n => n.Key == "CS-T"), Is.False);
}

Run this on a CI workspace before every release — it catches the two failure modes that hurt most:

  1. The connector forgot to restrict a node (Bob gets to see it).
  2. A protected schema was downgraded to public (everyone gets to see it).

Common pitfalls

Symptom Cause
Empty results for everyone Protected schema with no RestrictAccessTo* calls — locked out by default.
Results leaked across teams Endpoint called CreateSearchAsync instead of CreateSearchAsUserAsync.
User can see ghost results after being removed RemoveUserFromTeam wasn't called when the source revoked them.
Endpoint-token caller sees more than expected CurrentUser is default for token callers — handle that branch explicitly.
Performance degrades after enabling restrictions The search index now has a permission edge per node — see Search optimization.
© 2026 Curiosity. All rights reserved.
Powered by Neko