Adding Native Permissions to a Custom Source

Mirror your source system's permission model in Guru by syncing users, groups, and per-record access for a custom source.

By default, you control who can see a custom source's records inside Guru, by granting groups access to the source. Native permissions flip that around: your integration tells Guru who can see each record in the source system, and Guru mirrors those rules automatically. A user only gets search results and answers from records they could open in the system the data came from.

This guide extends Creating a Custom Source. It assumes you have a source, its sourceId, and an object type id, and that you are already pushing records with an open/push/close sync. The examples continue the NetSuite customer sync from that guide, now restricted so each customer record is visible only to the right people.

Endpoints

All endpoints below are relative to the API root https://api.getguru.com/api/v1/ and use basic auth with your Guru user and API token.

EndpointPurpose
PUT /sources/{sourceId}/types/{objectTypeId}/records/{externalId}/nativepermissionsReplace the full permission list for one record.
GET /sources/{sourceId}/types/{objectTypeId}/records/{externalId}/nativepermissionsRead a record's permissions.
DELETE /sources/{sourceId}/types/{objectTypeId}/records/{externalId}/nativepermissionsRemove all permissions from a record.
PUT /sources/{sourceId}/nativegroups/{groupExternalId}Create or update a group, optionally with its complete member list.
POST /sources/{sourceId}/nativegroups/{groupExternalId}/membersUpdate a group's members with batched add/remove calls.
DELETE /sources/{sourceId}/nativegroups/{groupExternalId}Delete a group.
PUT /sources/{sourceId}/nativeusers/USEREXTERNALIDCreate or update a single user.
POST /sources/{sourceId}/nativeusersUpdate users in bulk with batched add/remove calls.
DELETE /sources/{sourceId}/nativeusers/USEREXTERNALIDDelete a single user.
PUT /sources/{sourceId}/tagconfigs/{tagConfigId}/hierarchies/{externalId}/nativepermissionsReplace the full permission list for one folder.
GET /sources/{sourceId}/tagconfigs/{tagConfigId}/hierarchies/{externalId}/nativepermissionsRead a folder's permissions.
DELETE /sources/{sourceId}/tagconfigs/{tagConfigId}/hierarchies/{externalId}/nativepermissionsRemove all permissions from a folder.

GET /sources/{sourceId}/nativeusers, GET /sources/{sourceId}/nativegroups, and GET /sources/{sourceId}/nativegroups/{groupExternalId}/members list what you have synced, using standard pagination.

For a source with a single object type, every /types/{objectTypeId}/records/... endpoint has a shorthand twin without the type segment: PUT /sources/{sourceId}/records/{externalId}/nativepermissions.

Enable native permissions when you create the source

Native permissions are switched on by one top-level field in the POST /sources request from Creating a Custom Source: useSourceNativePermissions. It sits beside definition and config, not inside them.

curl -X POST "https://api.getguru.com/api/v1/sources" \
  -u $GURU_USER:$GURU_TOKEN \
  -H "Content-Type: application/json" \
  -d '{
    "definition": {
      "type": "CUSTOM"
    },
    "useSourceNativePermissions": true,
    "config": { ... }
  }'
❗️

Set useSourceNativePermissions at creation

Set useSourceNativePermissions at creation time. On a source created without it, every endpoint in this guide fails with a 400 and the message Source does not use source native permissions.

How Guru models permissions

You sync four kinds of permission data, plus a fifth if the source has folders. Each has a sync name, which is how the status endpoint refers to it when you coordinate permission syncs with record syncs (covered at the end of this guide):

DataSync nameIdentified byWhat it represents
UserUSERexternalId, plus a required emailA user in the source system.
GroupGROUPexternalIdA group in the source system.
Group membershipGROUP_MEMBERThe group and user it connectsWhich users belong to a group. Not a standalone object; you sync it through the group's members endpoint.
Record permissionsOBJECT_ACCESSThe record's object type and externalIdThe complete list of who can see one record.
Folder permissionsTAG_ACCESS~{tagConfigId}The folder tag and the folder's externalIdWho can see one folder. Only on sources with folders; see Set permissions on folders.

Each entry in a record's permission list has a type:

TypeMeaning
USEROne user, given inline as user, can see the record.
GROUPEvery member of the group given inline as group can see the record.
ALL_MEMBERSEvery user of this source can see the record.

There is a single level of access, equivalent to read access: a permission entry means the user can find the record in search and receive answers drawn from it.

Guru connects a synced user to a Guru account by email, and it lets you define things in whatever order your integration finds convenient:

  • References do not need to exist yet. You can add a user to a group, or grant a user access to a record, before that user has been synced. Guru stores the reference and connects it when the user arrives.
  • Anything described completely is created on the spot. A USER permission entry or group member that carries both externalId and email creates that user immediately; a GROUP entry creates the group. Include the email whenever you have it, and most integrations never need the user endpoints at all.

Groups and permission entries carry a read-only active flag in responses, and responses leave it out when it is false. It turns true once the entry maps to at least one real Guru user; an entry for a user whose email matches no one on your Guru team stays inactive until that person joins. The flag isn't reliably reset when users are later removed, so use it to confirm that new entries took effect, not to audit who still has access. Users themselves have no active flag.

Set permissions on a record

Set a record's permissions with one PUT per record, where $RECORD_EXTERNAL_ID is the record's externalId (4570 for the NetSuite customer in the running example). The body is the complete list: Guru adds what is new, removes what is missing, and leaves the rest. There is no partial update, so always send everything that should remain in effect.

Request

curl -X PUT "https://api.getguru.com/api/v1/sources/$SOURCE_ID/types/$OBJECT_TYPE_ID/records/$RECORD_EXTERNAL_ID/nativepermissions" \
  -u $GURU_USER:$GURU_TOKEN \
  -H "Content-Type: application/json" \
  -d '{
    "permissions": [
      {
        "type": "USER",
        "user": {"externalId": "[email protected]", "email": "[email protected]"}
      },
      {
        "type": "GROUP",
        "group": {"externalId": "sales-team"}
      }
    ]
  }'

Response

{
  "objectTypeId": "643fed7f-4982-4852-b0a1-b8f9a427fdf0",
  "objectExternalId": "4570",
  "permissions": [
    {
      "type": "USER",
      "user": {"externalId": "[email protected]"},
      "active": true
    },
    {
      "type": "GROUP",
      "group": {"externalId": "sales-team"}
    }
  ]
}

The user permission is active because the email matched a Guru user. The group has no active flag because it isn't active yet: it was just created by this call and has no members. It starts working as soon as you sync its members, which is the next step.

To make a record visible to everyone who uses the source, send {"type": "ALL_MEMBERS"} as a permission entry. This only behaves correctly if you sync the source's full user list (see below), because Guru needs to know who "all members" are.

To remove access, PUT the reduced list, or DELETE the same URL to remove every permission from the record. A record with no permission entries is visible to no one.

Sync a group's members

The simplest way to define a group is a single PUT with its complete membership, where $GROUP_EXTERNAL_ID is the group's id in your system (sales-team in this example):

curl -X PUT "https://api.getguru.com/api/v1/sources/$SOURCE_ID/nativegroups/$GROUP_EXTERNAL_ID" \
  -u $GURU_USER:$GURU_TOKEN \
  -H "Content-Type: application/json" \
  -d '{
    "members": [
      {"externalId": "[email protected]", "email": "[email protected]"},
      {"externalId": "[email protected]", "email": "[email protected]"}
    ]
  }'

The members list replaces the group's whole membership: anyone left out is removed. Leave members out of the body entirely to create or update the group without touching its membership.

For large groups, use the members endpoint instead. It batches changes under an update type:

TypeEffect
START_SYNCOpen a full membership sync. The response's currentSyncNumber identifies it.
ADD_USERSCreate or update the members in users.
REMOVE_USERSRemove the members in users.
COMPLETE_SYNCClose a full sync and remove every member that was not added during it.
CANCEL_SYNCClose a full sync without removing anything, when the sync could not finish normally.

A full membership sync follows the same bracket pattern as a record sync: open, push, close.

curl -X POST "https://api.getguru.com/api/v1/sources/$SOURCE_ID/nativegroups/$GROUP_EXTERNAL_ID/members" \
  -u $GURU_USER:$GURU_TOKEN \
  -H "Content-Type: application/json" \
  -d '{"type": "START_SYNC"}'

Read currentSyncNumber from the response, send one or more ADD_USERS batches with it, then close:

curl -X POST "https://api.getguru.com/api/v1/sources/$SOURCE_ID/nativegroups/$GROUP_EXTERNAL_ID/members" \
  -u $GURU_USER:$GURU_TOKEN \
  -H "Content-Type: application/json" \
  -d '{
    "type": "ADD_USERS",
    "syncNumber": 7,
    "users": [
      {"externalId": "[email protected]", "email": "[email protected]"},
      {"externalId": "[email protected]", "email": "[email protected]"}
    ]
  }'
curl -X POST "https://api.getguru.com/api/v1/sources/$SOURCE_ID/nativegroups/$GROUP_EXTERNAL_ID/members" \
  -u $GURU_USER:$GURU_TOKEN \
  -H "Content-Type: application/json" \
  -d '{"type": "COMPLETE_SYNC", "syncNumber": 7}'

Closing with a syncNumber that is not the open sync returns a 409, and so does an ADD_USERS or REMOVE_USERS batch that carries one. This protects two overlapping sync runs from reconciling each other's work.

You do not need the full bracket for day-to-day changes. ADD_USERS and REMOVE_USERS calls sent outside a full sync (no syncNumber) apply immediately, which suits event-driven integrations that mirror membership changes as they happen.

Sync users

Most integrations skip this section: users are created automatically wherever you include an email, and that is usually enough. Sync users directly when either of these applies:

  • You use ALL_MEMBERS permissions. Guru can only expand "all members" to the users you have told it about, so sync the source's complete user list.
  • You cannot always include emails inline. If group membership from your source system arrives as bare user ids, sync users separately so Guru can attach emails to those ids.

Choose each user's externalId to match whatever id your source system uses when reporting membership and permissions. If that system identifies people by email, use the email as the externalId too.

Manage single users with PUT and DELETE on /sources/{sourceId}/nativeusers/USEREXTERNALID; the PUT body is the user object and its email is required. Bulk changes use POST /sources/{sourceId}/nativeusers, which takes the same update types as the group members endpoint: START_SYNC, ADD_USERS, REMOVE_USERS, COMPLETE_SYNC, CANCEL_SYNC. One difference: its START_SYNC response returns the sync number as objectType.currentSyncNumber, not a top-level currentSyncNumber.

Completing a full user sync deletes every user not added during it, so reserve the full bracket for runs that really push the complete list. A deleted user loses all access at once, and their USER permission entries are removed from records. Their group memberships stay listed, pointing at the missing user, and take effect again if you sync a user with the same externalId later. Deleting a single user with DELETE works the same way.

Set permissions on folders

If your source organizes records into folders (see Organizing a Custom Source into Folders), native permissions can cover the folder tree too, so each user sees only the folders they could see in the source system.

What folder permissions control

Folder permissions decide which folders a user can see. They don't decide which records a user can find: that comes only from record permissions.

A folder permission controlsA folder permission does not control
Whether the folder appears when the user browses the folder tree.Whether the user can find records in the folder.
Whether the folder's name appears as a filter value in the user's search.Access to the folder's subfolders, which need their own.
Whether the user can scope a Knowledge Agent to the folder.

So a record the user has record permission for shows up in their search even when its folder is hidden from them, and a record without record permission stays hidden even when its folder is visible. To mirror a source system where access flows from folders, set the folder's permission list on the folder and the same list on every record in it. There is no inheritance: set a list on each folder, subfolders included.

Enable folder permissions

Folder permissions are switched on when you create the source, alongside useSourceNativePermissions. List each folder tag to permission in permissionedObjectTags, which sits inside config.specification next to application, and refers to the object type and the tag by their externalId:

{
  "definition": {"type": "CUSTOM"},
  "useSourceNativePermissions": true,
  "config": {
    "type": "CUSTOM",
    "name": "Acme Policies",
    "trackStatus": true,
    "specification": {
      "application": {
        "objectTypes": [ ... ]
      },
      "permissionedObjectTags": [
        {"objectType": {"externalId": "document"}, "tagConfig": {"externalId": "folder"}}
      ]
    }
  }
}

Each permissioned folder tag adds one more tracked sync to the source, named TAG_ACCESS~{tagConfigId}.

Set a folder's permissions

Set a folder's permissions with one PUT per folder, where $FOLDER_EXTERNAL_ID is the folder's id (f-hr in this example). The entries are the same USER, GROUP, and ALL_MEMBERS entries a record takes, and the body is again the complete list: Guru adds what is new, removes what is missing, and leaves the rest.

curl -X PUT "https://api.getguru.com/api/v1/sources/$SOURCE_ID/tagconfigs/$FOLDER_TAG_CONFIG_ID/hierarchies/$FOLDER_EXTERNAL_ID/nativepermissions" \
  -u $GURU_USER:$GURU_TOKEN \
  -H "Content-Type: application/json" \
  -d '{
    "permissions": [
      {
        "type": "USER",
        "user": {"externalId": "[email protected]", "email": "[email protected]"}
      }
    ]
  }'
{
  "configId": "612b1b8d-a00c-450a-8e4a-a5d64232ed3c",
  "hierarchyExternalId": "f-hr",
  "permissions": [
    {
      "type": "USER",
      "user": {"externalId": "[email protected]"}
    }
  ]
}

GET the same URL to read a folder's permissions, using standard pagination, or DELETE it to remove every permission from the folder. A folder with no permission entries is visible to no one.

Push a folder before you set its permissions. If a folder is removed and later pushed again, send its permissions again too.

Closing the TAG_ACCESS sync doesn't remove anything, as with record permissions. To revoke access, PUT the reduced list or DELETE the folder's permissions.

Sync folder permissions

Folder permissions have their own tracked sync, TAG_ACCESS~{tagConfigId}, separate from the folder sync that pushes the tree. Open and close it with the status endpoint, PUT /sources/{sourceId}/types/TAG_ACCESS~{tagConfigId}/status, or list TAG_ACCESS~{tagConfigId} in dependentObjectTypeIds on the folder sync or the record sync so it follows that sync's lifecycle (see Coordinate permissions with a record sync). The folder sync itself runs as described in Organizing a Custom Source into Folders, with the bare tag config id.

Coordinate permissions with a record sync

Guru tracks a sync status per object type, and enabling native permissions adds four more tracked syncs to your source, one for each kind of permission data introduced in How Guru models permissions, under its sync name: USER, GROUP, GROUP_MEMBER, and OBJECT_ACCESS. A source with folder permissions also has a TAG_ACCESS~{tagConfigId} sync for each permissioned folder tag. These statuses feed the source's sync reporting, and each one starts out waiting for its initial sync. The source is not considered fully synced until every tracked sync, permissions included, has completed at least once.

You could drive each permission sync individually: the status endpoint that brackets a record sync accepts any of the four permission types in place of an object type id (PUT /sources/{sourceId}/types/OBJECT_ACCESS/status). In practice you rarely want four extra open/close brackets, because permissions describe records and you sync both in one run anyway. That is what dependentObjectTypeIds is for: it names other tracked syncs that should follow this one's lifecycle. Dependents follow only the calls that list them: when you open the record sync with dependents, they are marked as syncing, and when you close it with COMPLETE, or fail it with FAIL, the dependents listed on that closing call complete or fail with it. List the same dependents on both calls. A dependent failed this way takes the same statusReason as the record sync; see Close the sync for FAIL and its optional fields.

curl -X PUT "https://api.getguru.com/api/v1/sources/$SOURCE_ID/types/$OBJECT_TYPE_ID/status" \
  -u $GURU_USER:$GURU_TOKEN \
  -H "Content-Type: application/json" \
  -d '{
    "statusAction": "START_INITIAL",
    "maxSyncTimeInMinutes": 60,
    "dependentObjectTypeIds": ["USER", "GROUP", "GROUP_MEMBER", "OBJECT_ACCESS"]
  }'

Close it with the same list:

curl -X PUT "https://api.getguru.com/api/v1/sources/$SOURCE_ID/types/$OBJECT_TYPE_ID/status" \
  -u $GURU_USER:$GURU_TOKEN \
  -H "Content-Type: application/json" \
  -d '{
    "statusAction": "COMPLETE",
    "syncNumber": '"$SYNC_NUMBER"',
    "dependentObjectTypeIds": ["USER", "GROUP", "GROUP_MEMBER", "OBJECT_ACCESS"]
  }'
❗️

List dependents on the closing call too

A COMPLETE without dependentObjectTypeIds closes only the record sync, and the permission syncs stay in SYNCING.

A run like this reads as one unit: open the record sync with the permission types as dependents, push each record's content and then its permissions (plus any group or user updates), and close the record sync once with the same dependents. Listing all four dependents on the first sync is the simplest way to move every permission sync out of its initial state; on later runs, list the ones your integration actually maintains.

Dependent status is bookkeeping only. Being a dependent never assigns a sync number and never removes anything: a full-sync bracket with delete-on-complete reconciliation still requires its own START_SYNC/COMPLETE_SYNC calls on the users or group members endpoint, and record permissions have no reconciliation at all, since each PUT already replaces that record's complete list.

📘

USER as the primary is the exception

Calling the status endpoint with USER as the primary object type is the one exception: it is a full equivalent of the bulk user endpoint's sync types. START/COMPLETE/FAIL on /types/USER/status behave like START_SYNC/COMPLETE_SYNC/CANCEL_SYNC, including the delete-what-was-not-pushed reconciliation on complete. As a dependent, USER gets status tracking only, like everything else.