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.
| Field | Default | Set it when the vendor's docs say... |
|---|---|---|
http.contentType | application/scim+json | the 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.headers | none | every 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.
| Field | Default | Set it when the vendor's docs say... |
|---|---|---|
pagination.style | startIndex-count | lists page another way (see the styles below). |
pagination.pageSize | 100 | the largest page is smaller. Allowed: 1 to 1000. GitHub's connector uses 30. |
pagination.startIndexParam, pagination.countParam | startIndex, count | the parameters have other names. countParam is also the page size parameter of the cursor style. |
pagination.offsetParam, pagination.limitParam | offset, limit | an offset-limit server names them differently. |
pagination.cursorParam | cursor | a cursor server names the request parameter differently. |
pagination.nextCursorPath | nextCursor | the next cursor comes back under another key of the list response. |
pagination.emptyFirstCursor | false | the 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:
| Style | Each request sends | Owlie stops when |
|---|---|---|
startIndex-count | startIndex (starting at 1) and count | a page is empty, or startIndex plus the page's itemsPerPage passes totalResults, or, without totalResults, a page is shorter than pageSize |
offset-limit | offset (starting at 0) and limit | a page is empty, or the offset reaches totalResults, or, without it, a page is shorter than pageSize |
cursor | count, and from the second page the cursor | the response has no next cursor, or a page is empty |
none | no paging parameters | after 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.
| Field | Default | Set it when the vendor's docs say... |
|---|---|---|
filtering.attributes | any attribute | only some attributes can be filtered. List them as SCIM paths. |
filtering.incremental.enabled | false | the server supports a filter on the last-modified time. |
filtering.incremental.lastModifiedPath | meta.lastModified | the last-modified time is at another path. |
filtering.incremental.operator | gt | the 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
lastModifiedPathis not in the list; groups.membership.readMembersisuserFilterandgroups.membership.userFilterPathis 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.
| Field | Default | Set it when the vendor's docs say... |
|---|---|---|
patch.supported | true | the server does not accept PATCH. |
patch.selectorPaths | true | PATCH does not accept a path with a filter, such as emails[type eq "work"].value. GitHub's connector sets false. |
patch.opCase | lower | operation names must be capitalized: Add, Remove, Replace. |
patch.valueStyle | path-value | each 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
| Field | Default | Set it when the vendor's docs say... |
|---|---|---|
users.endpoint | /Users | the Users resource is at another path below the base URL. |
users.idPath | id | a user's server-assigned id is at another attribute. |
users.activeAttribute.path | active | account state is another attribute. |
users.activeAttribute.enabledValue | true | enabling writes another value. |
users.activeAttribute.disabledValue | false | disabling 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'sactivefield isfalse(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
| Field | Default | Set it when the vendor's docs say... |
|---|---|---|
groups.enabled | true | the SCIM endpoint has no groups, or you govern users only. |
groups.endpoint | /Groups | the Groups resource is at another path. |
groups.idPath | id | a group's id is at another attribute. |
groups.manage.create | false | Owlie may create groups. |
groups.manage.update | false | Owlie may rename groups and change their fields. |
groups.manage.delete | false | Owlie 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.
| Field | Default | Set it when the vendor's docs say... |
|---|---|---|
groups.membership.mode | patchMembers | members 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.addStyle | path-value | an add operation takes another shape (below). |
groups.membership.membersPath | members | the member list is another attribute of the group. |
groups.membership.memberValue | id | a member's value holds the user's userName or externalId instead of the SCIM id. |
groups.membership.readMembers | fetchGroup | group reads return members differently (below). |
groups.membership.userFilterPath | groups.value | with userFilter, the user attribute that lists a user's groups has another path. |
groups.membership.listExcludedAttributes | ["members"] with fetchGroup, otherwise none | group listings should leave out other attributes, or none. |
addStyle shapes the PATCH operation that adds a member:
addStyle | Add operation | Used 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:
readMembers | How members are read | Use it when |
|---|---|---|
fetchGroup | Group 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) |
included | Group 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 |
userFilter | Sync 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.
| Field | Default | Set it when the vendor's docs say... |
|---|---|---|
errors.conflictStatus | 409 | a duplicate (an existing user, an existing member) is answered with another status. |
errors.notFoundStatus | 404 | a missing resource or member is answered with another status. |
errors.rateLimitStatus | 429 | rate limiting is answered with another status. Setting it to 401 or 403 has no effect: those always mean the credentials were refused. |
errors.idempotency.assignExistingMember | success | adding a member who is already there must fail instead. |
errors.idempotency.revokeMissingMember | success | removing a member who is not there must fail instead. |
errors.idempotency.deleteMissingUser | success | deleting 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 toPATCH /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.