Skip to main content

Describe the provider's deviations

Building a connector, step 7 (SCIM track, 2 of 3). Previous: Start a SCIM connector.

Every setting on this page has a default that follows the SCIM 2.0 standard (RFC 7643 and RFC 7644). Write a setting only when the vendor's documentation says its server behaves differently, and keep the link to that documentation in your README.md. Each table below says what the default is and which statement in the vendor's documentation calls for a change. The examples come from Owlie's own SCIM connectors for Snowflake, AWS IAM Identity Center, Databricks (account level) and GitHub Enterprise Managed Users.

In the Connector Builder, these settings are on the Connection, Users, Groups, Protocol and Errors tabs. Every field is also in the SCIM definition reference.

Requests​

http shapes every request.

FieldDefaultSet it when the vendor's docs say...
http.contentTypeapplication/scim+jsonthe server expects application/json as the Content-Type of a request body. AWS IAM Identity Center uses application/json. Owlie reads responses of either type, whatever this says.
http.headersnoneevery request must carry a fixed, non-secret header, such as an API version.

Owlie's GitHub connector sends "X-GitHub-Api-Version": "2022-11-28", and its Snowflake connector sends "Accept-Encoding": "utf-8" and "Accept-Charset": "utf-8". Static headers cannot replace Authorization, Content-Type, Content-Length, Host or Cookie, and never hold secrets: credentials belong to an authentication option.

A create (POST of a user or group) sends the attributes of the record's fields and the schemas attribute the standard requires: the resource's core schema (urn:ietf:params:scim:schemas:core:2.0:User or urn:ietf:params:scim:schemas:core:2.0:Group), then each extension schema a written field's path is under, such as urn:ietf:params:scim:schemas:extension:enterprise:2.0:User for a department. A PATCH always carries the standard PatchOp schema. An update written back with PUT (PATCH) keeps the schemas the server returned for the resource, adds the core schema first if that list lacks it, and adds each extension the body carries that the list does not name yet: one the update writes to, or extension data the server returned without listing its schema.

Pagination​

pagination decides how Owlie reads a list of users or groups page by page.

FieldDefaultSet it when the vendor's docs say...
pagination.stylestartIndex-countlists page another way (see the styles below).
pagination.pageSize100the largest page is smaller. Allowed: 1 to 1000. GitHub's connector uses 30.
pagination.startIndexParam, pagination.countParamstartIndex, countthe parameters have other names. countParam is also the page size parameter of the cursor style.
pagination.offsetParam, pagination.limitParamoffset, limitan offset-limit server names them differently.
pagination.cursorParamcursora cursor server names the request parameter differently.
pagination.nextCursorPathnextCursorthe next cursor comes back under another key of the list response.
pagination.emptyFirstCursorfalsethe first request must carry the cursor parameter, present but empty, to switch the server into cursor paging. AWS IAM Identity Center needs true.

The styles:

StyleEach request sendsOwlie stops when
startIndex-countstartIndex (starting at 1) and counta page is empty, or startIndex plus the page's itemsPerPage passes totalResults, or, without totalResults, a page is shorter than pageSize
offset-limitoffset (starting at 0) and limita page is empty, or the offset reaches totalResults, or, without it, a page is shorter than pageSize
cursorcount, and from the second page the cursorthe response has no next cursor, or a page is empty
noneno paging parametersafter the first response

Owlie refuses to read a broken listing as a finished one, because a Sync that ends early would look like every remaining user had been deleted. A list response with no Resources array fails the operation, unless totalResults is 0 (the standard lets an empty result leave Resources out). A cursor that comes back unchanged fails too.

Filtering​

Owlie filters when it looks up one user or group by an identifier other than its SCIM id, for incremental Sync, and to read a group's members with readMembers: "userFilter".

Filtering is required, and no setting turns it off: Owlie looks users up by their required userName identifier with a filter. A server that cannot filter on userName cannot back a SCIM connector.

FieldDefaultSet it when the vendor's docs say...
filtering.attributesany attributeonly some attributes can be filtered. List them as SCIM paths.
filtering.incremental.enabledfalsethe server supports a filter on the last-modified time.
filtering.incremental.lastModifiedPathmeta.lastModifiedthe last-modified time is at another path.
filtering.incremental.operatorgtthe server supports ge but not gt.

filtering.attributes is an allowlist. Owlie refuses to send a filter on a path outside it rather than dropping the filter, because some servers answer a filter they do not support with an empty result, which would read as "not found" and lead to duplicate accounts. Snowflake's connector allows userName and displayName; GitHub's allows userName, externalId, id and displayName. The list can be install-editable (Map fields).

The definition must stay consistent with it. Owlie refuses a definition where:

  • an identifier field's path is not in filtering.attributes (identifiers are looked up by filter);
  • incremental Sync is on and its lastModifiedPath is not in the list;
  • groups.membership.readMembers is userFilter and groups.membership.userFilterPath is not in the list.

With incremental Sync on, the user entity supports incremental runs. Each run asks for users whose last-modified time is after the latest one the previous run saw, using the operator you set. Groups are always read in full.

PATCH​

patch decides how Owlie sends updates.

FieldDefaultSet it when the vendor's docs say...
patch.supportedtruethe server does not accept PATCH.
patch.selectorPathstruePATCH does not accept a path with a filter, such as emails[type eq "work"].value. GitHub's connector sets false.
patch.opCaseloweroperation names must be capitalized: Add, Remove, Replace.
patch.valueStylepath-valueeach operation must carry the value as a nested object instead of a path.

With path-value, an update is one operation per field. With object, the operation has no path and nests the value. The same change in each style:

path-value: { "op": "replace", "path": "name.givenName", "value": "Maya" }
object: { "op": "replace", "value": { "name": { "givenName": "Maya" } } }

When PATCH is not available, Owlie updates by reading the resource, merging the change into it and writing it back with PUT, sending If-Match when the server returned an ETag. With selectorPaths set to false, only the fields whose path has a filter go that way; the others still use PATCH. Enabling and disabling a user follows the same rule. Group membership never goes through a merged PUT, because a PUT of a group can replace its whole member list: without PATCH, set groups.membership.mode to replaceGroupMembers (Owlie refuses patchMembers without PATCH).

Users​

FieldDefaultSet it when the vendor's docs say...
users.endpoint/Usersthe Users resource is at another path below the base URL.
users.idPathida user's server-assigned id is at another attribute.
users.activeAttribute.pathactiveaccount state is another attribute.
users.activeAttribute.enabledValuetrueenabling writes another value.
users.activeAttribute.disabledValuefalsedisabling writes another value.

Owlie writes enabledValue and disabledValue exactly as you give them. Databricks is the example of a vendor that deviates: its connector writes [{ "value": "true" }] and [{ "value": "false" }].

Reading the state back works two ways:

  • One user. When Owlie looks up a single user, it reads the user as disabled when the attribute equals disabledValue.
  • Sync. Sync ignores activeAttribute. It reads every listed user as active unless the record's active field is false (Two field names carry meaning).

So with an activeAttribute whose disabled value is not the boolean false, Sync reads disabled users as active. Tell the integration's admins in your README.md to change the rule on the account's Sync job: under Derive State, Account is active when takes an expression over the record, such as status != 'suspended' for a field named status.

Only the standard id is guaranteed never to change or be reused, so the account's id is marked provider-stable only when users.idPath is id. The field list is on the next page.

Groups​

FieldDefaultSet it when the vendor's docs say...
groups.enabledtruethe SCIM endpoint has no groups, or you govern users only.
groups.endpoint/Groupsthe Groups resource is at another path.
groups.idPathida group's id is at another attribute.
groups.manage.createfalseOwlie may create groups.
groups.manage.updatefalseOwlie may rename groups and change their fields.
groups.manage.deletefalseOwlie may delete groups.

With groups.enabled set to false, the connector has no group entity and no membership Grant kind. Snowflake's and Databricks' connectors allow creating and deleting groups but not updating them.

Membership​

Membership is the member_of Grant kind: a user is a member of a group. Owlie adds and removes one member per write.

FieldDefaultSet it when the vendor's docs say...
groups.membership.modepatchMembersmembers cannot be changed with PATCH. replaceGroupMembers reads the group and writes its whole member list back with PUT, without If-Match: a change made at the vendor between the read and the write is lost.
groups.membership.addStylepath-valuean add operation takes another shape (below).
groups.membership.membersPathmembersthe member list is another attribute of the group.
groups.membership.memberValueida member's value holds the user's userName or externalId instead of the SCIM id.
groups.membership.readMembersfetchGroupgroup reads return members differently (below).
groups.membership.userFilterPathgroups.valuewith userFilter, the user attribute that lists a user's groups has another path.
groups.membership.listExcludedAttributes["members"] with fetchGroup, otherwise nonegroup listings should leave out other attributes, or none.

addStyle shapes the PATCH operation that adds a member:

addStyleAdd operationUsed by
path-value{ "op": "add", "path": "members", "value": [{ "value": "<member>" }] }the standard
object{ "op": "add", "value": { "members": [{ "value": "<member>" }] } }Databricks
bare-array{ "op": "add", "value": [{ "value": "<member>" }] }Snowflake

A remove is always { "op": "remove", "path": "members[value eq \"<member>\"]" }, whatever the addStyle.

readMembers decides how Sync finds each group's members:

readMembersHow members are readUse it when
fetchGroupGroup listings leave members out (excludedAttributes=members), and Sync reads each group to get its members.a read of one group returns its members (the default)
includedGroup listings keep members (no excludedAttributes), and Sync still reads each group to get its members, so it saves no requests.a read of one group returns its members, and the server does not accept excludedAttributes
userFilterSync lists the users whose userFilterPath equals the group's id: GET /Users?filter=groups.value eq "<group id>".a group read does not return its members. AWS IAM Identity Center's connector uses this.

memberValue must name a user field (userName or externalId) that exists in users.fields and is readable. With the default id, Owlie already has the user's SCIM id from Sync, so it skips the user lookup and sends the PATCH directly. That matters for a remove: when the user no longer exists at the vendor, the remove still reaches the group, and only the server's own answer decides whether the member was already gone. With userName or externalId, Owlie first reads the user, by its SCIM id when it has one and otherwise by a filter on another identifier, and takes the member value from that record. A read that finds no one fails the write.

Errors and "already done"​

errors tells Owlie what the server's statuses mean.

FieldDefaultSet it when the vendor's docs say...
errors.conflictStatus409a duplicate (an existing user, an existing member) is answered with another status.
errors.notFoundStatus404a missing resource or member is answered with another status.
errors.rateLimitStatus429rate limiting is answered with another status. Setting it to 401 or 403 has no effect: those always mean the credentials were refused.
errors.idempotency.assignExistingMembersuccessadding a member who is already there must fail instead.
errors.idempotency.revokeMissingMembersuccessremoving a member who is not there must fail instead.
errors.idempotency.deleteMissingUsersuccessdeleting a user who does not exist must fail instead.

Owlie maps the other statuses itself: 401 and 403 fail with an authentication error; a rateLimitStatus answer, any 5xx and 412 (the resource changed since it was read) are reported as retryable; 400 fails as an invalid value. On create, conflictStatus means the account already exists; on any other operation it is a conflict.

What counts as "already there" and "already gone"​

For a membership write, Owlie reports that the Grant was already present or already absent only on the write's own evidence, as the Grant write contract requires:

  • Add: the PATCH answers errors.conflictStatus.
  • Remove: the PATCH answers errors.notFoundStatus, and a read of the group then succeeds. The group read is needed because a not-found answer to PATCH /Groups/<id> also means the group itself is gone.
  • With replaceGroupMembers: the group read that the PUT would be built from already has, or already lacks, the member.

Those answers become "already present" or "already absent" only while the matching errors.idempotency setting is success, the default. Set assignExistingMember or revokeMissingMember to fail when the vendor answers those statuses for other reasons too, so Owlie cannot read them as proof: the write then fails with the vendor's message instead of being recorded as done. Any other status fails the write.

A group that no longer exists, or a user looked up by userName or externalId who cannot be found, fails the write instead of reading as "already gone".

When no setting fits​

If the vendor needs something no setting expresses, replace that one operation with your own code. On the Handlers tab, find the operation and select Override. The builder creates the handler file, such as handlers/user/create.ts (a membership write is handlers/grants/member_of/add.ts or remove.ts), starting from a handler that calls the engine's own:

import type { Override } from "../../types.gen.ts";

const handler: Override<"user.create"> = async (input, ctx) => {
return ctx.engineDefault(input);
};

export default handler;

ctx.engineDefault runs the engine's handler for the same operation with the same context, so you can change what goes in or what comes out, or replace the call outright. The rest of the connector stays the engine's. An override of a membership write takes on the Grant write contract. Handler signatures and the ctx object are in Handler API.

Next: Map fields and install-editable values