Attributes, membership and writes
Building a connector, step 7 (LDAP track, 2 of 4). Previous: Start an LDAP connector.
With the entities located, decide what Owlie reads from each entry, how group membership is stored, and which changes Owlie may make. Writes are declared, never implied: a definition that declares none gives a read-only connector.
Attributes
An entity's attributes object is keyed by LDAP attribute name. Each entry describes one attribute:
| Field | Default | What it does |
|---|---|---|
type | string | How the value is read: string; number; boolean (the directory's TRUE and FALSE); binary, read as base64; guid, a 16-byte GUID read as its standard text form; sid, a Windows security identifier read as S-1-5-.... |
multi | false | Read every value as a list. Otherwise only the first value is read. |
title | The label Owlie shows for the attribute. | |
sensitive | false | Mark the value as secret or personal data, which Owlie masks in logs. |
readOnly | false | Read by Sync, never written by provisioning. Use it for values the directory maintains, such as memberOf, createTimestamp or objectSid. |
baseline | What a new account gets for this attribute. See below. |
"attributes": {
"uid": { "title": "Username" },
"mail": { "title": "Email", "baseline": "identity.email" },
"cn": { "title": "Name", "baseline": "identity.display_name" },
"memberOf": { "title": "Direct groups", "multi": true, "readOnly": true }
}
dn is not an attribute you declare: every record carries it. The identifier and the rdnAttribute
are always read. On an entity with write, every attribute that is not readOnly and not the
identifier is writable; on an entity without write, every attribute is read-only.
What a new account gets
baseline sets an account attribute's value when Owlie creates the account. It is either an OXL
expression over the identity, such as "identity.email" (expression reference
lists the identity's fields), or a username generator:
"uid": {
"title": "Username",
"baseline": {
"usernameGenerator": {
"steps": [
{ "blocks": [{ "type": "field", "field": "email", "modifiers": [{ "type": "splitAt", "delimiter": "@", "keep": "before" }, { "type": "lowercase" }] }] },
{ "blocks": [{ "type": "field", "field": "email", "modifiers": [{ "type": "splitAt", "delimiter": "@", "keep": "before" }, { "type": "lowercase" }] }, { "type": "increment", "startFrom": 1, "padding": 1 }] }
],
"maxAttempts": 20
}
}
}
This generator tries the local part of the email address (ada for ada@northwind.example), then
ada1, ada2 and so on. When the directory refuses a create because a value is taken, Owlie creates
the account again with the generator's next candidate. The engine counts two answers as "already
taken": entry already exists (result 68) and a constraint violation (result 19), which is how Active
Directory enforces a unique userPrincipalName and how OpenLDAP's unique overlay answers. A
constraint violation is not counted when the create carried a sensitive attribute, or when the
message carries Active Directory's password-policy code 0000052D.
A retry changes only the generator's attribute and any attribute whose baseline reads generated,
the value the generator produced. Every other attribute, and a DN built from them, stays the same, so
a collision on one of them repeats on every candidate and the create fails after the last one. Two
rules follow:
- One generator per connector. Only one attribute may have a
usernameGenerator; Owlie refuses to provision with two. - Derive other unique values from the generated one. When another attribute must be unique too,
such as the
rdnAttributethat names the entry, give it a baseline that readsgenerated, for example"cn": { "baseline": "generated" }. Acntaken from the display name collides for two people with the same name in the same container.
Every block and modifier a generator can use is in the LDAP definition reference.
Baselines apply to account attributes only, and a readOnly attribute cannot have one. Never write a
customer's email domain or company name into a baseline: build values from the identity.
Membership
The group entity's membership says where the directory records who belongs to a group. It brings
the member_of Grant kind: an account (the source) is a member of a group (the target), and the
account holds what the group holds.
| Field | Default | What it does |
|---|---|---|
attribute | The attribute that holds the membership, such as member or uniqueMember on the group, or memberOf on the account. | |
direction | member-on-group | member-on-group: the group lists its members' DNs. memberOf-on-account: each account lists its groups' DNs. |
writable | false | Let Owlie add and remove memberships. Allowed only with member-on-group, because directories maintain memberOf themselves from the groups' member lists. |
nested | false | Groups can be members of groups. Sync reports the groups inside each group as its members, and Owlie works out the memberships they pass on. |
With member-on-group, Sync reads each group's member attribute in full and reports the values that
are accounts in scope: a value outside the account bases is left out without a read, and the rest are
confirmed by the directory with the account entity's filter, in one search per account container (up
to 100 members each). A group inside the group is therefore not reported as an account. With
memberOf-on-account, Sync searches the accounts whose attribute names the group.
With nested on, Sync reports each group's direct members only: the accounts in it, then the groups
in it. It finds them with paged searches for entries whose memberOf names the group (the membership
attribute itself with memberOf-on-account), one where accounts live and one where groups live, with
each entity's bases, scope, object classes and filter. A group inside a group is a group member of
member_of, and Owlie works out the rest: when an account is in Backend and Backend is in
Engineering, the account's access shows Engineering as inherited through Backend, not as a membership
of its own. Removing the account's direct membership of Backend then removes exactly that membership.
A member outside the searched bases or filters is not reported, and for a nested group that has a
consequence: the memberships its members reach through it are not shown either. A group filter that
leaves out Active Directory's built-in groups ((!(isCriticalSystemObject=TRUE))) hides Domain Admins
when it sits inside SQL-Admins, so no Domain Admin shows SQL-Admins access through it. Make the group
bases and filter cover every group that nests inside another.
nested needs a directory that keeps memberOf on accounts and groups. Active Directory does; so does
OpenLDAP with its memberof overlay. Because an install can turn nested on, a connector whose
nested is install-editable always declares member_of with group members allowed.
When writable is on too, membership writes accept a group member: its DN is written to the target
group's member attribute. Owlie's own provisioning writes an account's memberships. An install with
nested off refuses a group member, because Sync would never see the result.
Active Directory keeps an account's primary group (primaryGroupID, usually Domain Users) out of
member and memberOf, so that membership is not reported in either mode.
Writes
An entity's write object turns on provisioning for it. user and group can have one; lookup
entities cannot.
| Field | What it does |
|---|---|
write present | Enables create and update for the entity. |
write.delete | true also enables delete. The default is false. |
write.base | The DN new entries are created under. The default is the entity's first search base. |
write.dn | A DN template for new entries, such as CN={cn},OU={department},DC=corp,DC=northwind,DC=example. Each {attribute} takes the new entry's value for that attribute, escaped. An empty template is the same as none. |
What each operation does:
- Create needs a value for the
rdnAttribute, and the entity needs at least one object class. The new entry is named<rdnAttribute>=<value>underwrite.base, or by thewrite.dntemplate, where a missing value refuses the create. It gets the entity's object classes, and every writable attribute the input carries becomes an attribute value. Owlie reads the entry back and records it. - Update replaces each writable attribute the input carries. An empty value removes the attribute.
Update never changes the
rdnAttribute, so it never renames an entry. - Delete removes the entry. An entry that is already gone counts as deleted.
When Owlie revokes an account that has write, the connector's default is to disable it, or to
delete it when the account has write.delete and no lifecycle. Either way Owlie also removes the
memberships it manages. Give an account with write a lifecycle or write.delete, so Owlie has a way
to take the account away. An account without write is Grants-only and is
never disabled or deleted.
Lifecycle
The account's lifecycle enables enable and disable, and decides whether Owlie reads an account as
disabled, both when Sync reads accounts and when Owlie looks one account up. There are two forms.
With a lifecycle, every account record carries a read-only account_disabled value: true when the
lifecycle attribute says the account is disabled. The engine reads the lifecycle attribute on every
account read, even when the account's attributes leave it out, and uses each install's own lifecycle
values. The sync rule the engine derives, Account is active when, is account_disabled != true,
so a disabled directory account syncs as disabled. account_disabled is reserved: an attribute cannot
use that name. Without a lifecycle, every account reads as active.
A value lifecycle sets one attribute to one value to disable an account:
"lifecycle": {
"attribute": "pwdAccountLockedTime",
"disabledValue": "000001010000Z"
}
| Field | What it does |
|---|---|
attribute | The attribute that records the state. |
disabledValue | Written on disable. An account whose attribute holds this value reads as disabled. |
enabledValue | Optional. Written on enable and on create. Absent or empty, enable removes the attribute and a new account is created without it. |
This example is OpenLDAP's password policy lock. It stops the account binding only when the directory
runs the ppolicy overlay with a default policy that has pwdLockout: TRUE. A directory without
ppolicy can use another attribute, such as employeeType with enabledValue active and
disabledValue disabled; that records the state for Owlie and anything that reads the attribute,
but does not by itself stop a bind.
A flag lifecycle changes one bit of an integer attribute and keeps the other bits. It is how Active Directory disables an account:
"lifecycle": { "attribute": "userAccountControl", "disabledFlag": 2, "newAccountValue": 512 }
| Field | What it does |
|---|---|
attribute | The integer attribute. |
disabledFlag | The bit that means disabled, a single bit such as 2. Disable sets it and enable clears it. |
newAccountValue | The value a new account starts from. The account is created with this value plus the disabled bit, gets its password, and is then enabled, unless the create input says enabled: false. |
Active Directory refuses to enable an account before it has a password its policy accepts, which is why a new account starts disabled.
Passwords
password writes the password value the provisioning request carries, on create and on update. On
create it is written right after the entry is added. It is never read back. password requires the
account to have write.
The engine writes a password only when the request carries one. The definition cannot give password
a baseline, so a create without one adds the entry with no password. To give every new account a
random password, an integration's admin adds a password attribute on the resource's
Provisioning tab under Baseline attributes, with an expression such as password(20)
(Expressions). With a flag lifecycle, create
still runs its enable step; a domain whose password policy requires a password refuses that, and the
create fails with the entry already in the directory. In a test run, send enabled: false in the
create input to leave the new account disabled.
| Field | Default | What it does |
|---|---|---|
attribute | The attribute to write, such as userPassword. | |
encoding | text | text writes the password as it is. unicodePwd writes Active Directory's format: the password in double quotes, encoded as UTF-16LE. |
Grants-only connectors
A connector without write on the account is Grants-only, whatever else it declares: accounts are
created outside Owlie, and Owlie never creates, updates, enables, disables or deletes them through
provisioning, even when the account has a lifecycle. Owlie waits for Sync to find an account before
it adds memberships. Revoking an account removes the memberships Owlie manages and leaves the account
as it is. The account's lifecycle then only decides whether Owlie reads the account as disabled, and
user.enable and user.disable run only when something calls them directly, such as a test run.
To have Owlie disable leavers, give the account write as well as a lifecycle. write also turns on
create and update for the account.
Already present and already absent
Before a membership write, the engine looks up both the member (an account, or with nested a group)
and the target group, and a lookup that finds nothing fails the write. It then adds or removes the
member's DN on the group's member attribute and reads the directory's own answer:
- an add answered "attribute value exists" (result 20), or Active Directory's 68, is already present;
- a remove answered "no such attribute value" (result 16) is already absent;
- a remove answered with Active Directory's 53 is already absent only when a read of the group shows the member is not in its own member list;
- every other answer fails the write.
The Grant write contract explains why only the write's own answer counts. An override that replaces the membership handlers takes on that contract.
Active Directory
Everything an Active Directory install needs is part of the engine's vocabulary, so an Active Directory connector is a definition with no code:
| Need | In the definition |
|---|---|
| The account's stable id | "identifier": { "attribute": "objectGUID", "type": "guid" } |
| Security identifiers | An attribute with "type": "sid", such as objectSid |
| Disable and enable | A flag lifecycle on userAccountControl, disabledFlag 2, newAccountValue 512 |
| Passwords | "password": { "attribute": "unicodePwd", "encoding": "unicodePwd" } |
| Nested groups | membership.nested |
| Incremental sync | A counter on uSNChanged with source invocationId (below) |
Duplicate userPrincipalName on create | Counted as "already taken", so Owlie retries with the generator's next candidate. Only the generator's attribute and attributes that read generated change (above). |
| Sync reading disabled accounts | The flag lifecycle: each account record carries account_disabled (Lifecycle) |
| Unlock and move | Engine-run custom operations (below) |
The LDAP directory example has a complete Active Directory definition.
Incremental sync
incremental lets Sync read only the accounts that changed since the last run. It applies to the
account entity only; groups and lookup entities are always read in full.
| Field | Default | What it does |
|---|---|---|
attribute | modifyTimestamp | The attribute that changes whenever an entry changes. |
kind | timestamp | timestamp for a GeneralizedTime such as modifyTimestamp; counter for an integer that only grows, such as Active Directory's uSNChanged. |
overlapSeconds | 300 | For a timestamp: how far before the last marker the next run starts reading, 0 to 86400, so a change committed during the last run is not missed. |
source | For a counter only: invocationId ties the marker to the domain controller that issued it. |
A full Sync records the newest value it saw as the marker. With a timestamp, the next run reads
every account whose attribute is at or after the marker minus the overlap. With a counter, it reads
every account whose value is above the marker. An incremental run reads only accounts that changed
inside the search bases, so it never sees an account that was deleted or moved out of them. Only the
next full Sync notices that such an account is gone.
Active Directory numbers uSNChanged separately on each domain controller. With source
invocationId, the marker records the invocationId of the domain controller that answered, read
through the root DSE's dsServiceName. A marker from a different controller, or one that names none,
restarts from zero, which reads every account once, instead of skipping or replaying another
controller's numbers. Point the install's URL at one domain controller.
Custom operations the engine runs
Two kinds of custom operation are directory writes the engine performs itself, with no script:
"custom_ops": {
"unlockAccount": {
"title": "Unlock account",
"description": "Clears the lockout Active Directory applies after too many failed sign-ins.",
"ldap": { "replace": { "lockoutTime": "0" } }
},
"moveToOu": {
"title": "Move to organizational unit",
"ldap": { "move": { "param": "targetOuDn" } }
}
}
ldap.replacesets each named attribute to its value on the entry the operation is run on.nullremoves the attribute.ldap.movemoves the entry, keeping its RDN, under the container DN given in the parameter thatparamnames. Without aparams_schema, the engine declares one that requires that parameter.
Each operation also takes title, description and params_schema. A custom operation is an action on a
person's account, so it always runs on the account entity. The key starts with a letter and uses letters, digits, _ and -; the operation's
handler is <account entity>.op.<key>, such as user.op.unlockAccount. Moving an entry changes its DN, so
the rename behavior on the previous page applies, and the account
search bases must cover the destination or Sync will not find the account there.
Next: Install-editable values