This page is for whoever maintains the plugin's code. It covers the plugin's tables and models, how the backend is organized, how the Microsoft Graph client behaves (paging, throttling, errors), what each log message means, and the open TODOs. It describes the code as it is now.
Other pages own the rest, so this page links to them and does not repeat them:
- How sync works: the sync flow, its timing, and the Registry objects the plugin creates.
- Configuration: what each configuration field means.
- Entra contract: the Graph calls, the permissions they need, and the attributes the plugin reads.
- Assumptions and known gaps: Missouri-specific hardcoded values and what each TODO does to the data.
Code is cited by repo-relative file and function name, not line number. Unless noted,
the file is Model/EntraSourceBackend.php.
| Path | What it holds |
|---|---|
Config/Schema/schema.xml |
The plugin's five tables, in AdoDB XML format |
Model/ |
One model per table, plus EntraSourceBackend.php (the OIS backend) and an empty EntraSourceAppModel.php |
Controller/ |
EntraSourcesController.php, EntraSourceExtensionPropertiesController.php, and an empty EntraSourceAppController.php |
View/ |
Form and index templates for the two controllers |
Lib/lang.php |
All plugin strings, in $cm_entra_source_texts['en_US'] |
Console/, Locale/, Test/, webroot/, View/Elements/, View/Helper/, View/Layouts/ |
Scaffolding only; each holds just a placeholder file named empty |
The table names below have no prefix. The foreign key constraints in schema.xml name
the prefixed tables (cm_...).
entra_sources, model EntraSource (Model/EntraSource.php). One row per
EntraSource, which is the plugin's configuration for one Organizational Identity Source
(OIS). The fields are described on Configuration. Columns that matter
to the code:
org_identity_source_id: the Registry OIS that owns this row. Unique indexentra_sources_i1.access_token_server_id: anOauth2Serverid (the column name says "server", butapiConnect()looks it up asOauth2Server.id).api_server_id: anHttpServerid, also looked up byapiConnect().use_source_groups,source_group_filter,max_inventory_cache,unix_cluster_id: configuration read by the backend throughgetConfig().inventory_cache_start: written byrecordInventoryStart()at the start of every full inventory and read byinventoryCacheValid(). This is sync state stored in the configuration row.
The model sets $cmPluginType = "orgidsource", declares $cmPluginHasMany for
OAuth2Server, HttpServer, and UnixCluster, and belongsTo OrgIdentitySource,
Server, and UnixCluster. The table has no server_id column; the backend uses the
Server association only as a path to the Oauth2Server and HttpServer models (for
example $EntraSource->Server->Oauth2Server->isExpired() in apiRequest()). It
hasMany EntraSourceRecord, EntraSourceGroup, and EntraSourceExtensionProperty,
all with dependent => true. cmPluginMenus() returns an empty array, so the plugin
adds no menu items.
entra_source_extension_properties, model EntraSourceExtensionProperty.
Configuration, not sync state. One row per user schema extension property to read from
Graph and map to an Org Identity Identifier:
entra_source_id: the owning EntraSource.property: the full Graph property name.retrieve()andsearch()add it to$select;resultToOrgIdentity()reads it from the user record.identifier_type: the Registry Identifier type the value is stored as.
The model also has findCoForRecord(), which the Registry's standard controller uses to
find the CO for a row.
entra_source_groups, model EntraSourceGroup. One row per source group per
EntraSource, written by synchronizeSourceGroups():
entra_source_id: the owning EntraSource.mail_nickname: the group's EntramailNickname. The code finds an existing row bymail_nicknameandentra_source_id, and it is the name of the matching CO Group.graph_id: the Entra group object id. Unique indexentra_source_groups_i2covers this column alone, not the pair withentra_source_id.gidnumber: the group's gidNumber, if Entra has one.
schema.xml also declares index entra_source_groups_i1 on org_identity_source_id,
a column this table does not have.
entra_source_records, model EntraSourceRecord. One row per Entra user the
plugin knows about, written by addSourceRecord():
entra_source_id: the owning EntraSource.graph_id: the Entra user object id. This is the key returned byinventory()and passed toretrieve(). It is indexed (entra_source_records_i2) but not unique.
No code deletes rows from this table except the cascade when an EntraSource is deleted
(see Source records are never removed).
The previous version of README.md said records are deleted when no longer part of any
group; that is not what the code does.
entra_source_group_memberships, model EntraSourceGroupMembership. One row per
(source group, source record) pair, written and deleted by
synchronizeTransitiveMembers():
entra_source_group_id,entra_source_record_id. Indexentra_source_group_memberships_i1covers both and is not unique; the code checks for an existing row before it saves.
retrieve() reads these rows to build memberOf. The model's $displayField is
mail_nickname, which is not a column of this table.
erDiagram
ORG_IDENTITY_SOURCE ||--|| ENTRA_SOURCE : "org_identity_source_id"
OAUTH2_SERVER ||--o{ ENTRA_SOURCE : "access_token_server_id"
HTTP_SERVER ||--o{ ENTRA_SOURCE : "api_server_id"
UNIX_CLUSTER |o--o{ ENTRA_SOURCE : "unix_cluster_id"
ENTRA_SOURCE ||--o{ ENTRA_SOURCE_EXTENSION_PROPERTY : "entra_source_id"
ENTRA_SOURCE ||--o{ ENTRA_SOURCE_GROUP : "entra_source_id"
ENTRA_SOURCE ||--o{ ENTRA_SOURCE_RECORD : "entra_source_id"
ENTRA_SOURCE_GROUP ||--o{ ENTRA_SOURCE_GROUP_MEMBERSHIP : "entra_source_group_id"
ENTRA_SOURCE_RECORD ||--o{ ENTRA_SOURCE_GROUP_MEMBERSHIP : "entra_source_record_id"
OrgIdentitySource, Oauth2Server, HttpServer, and UnixCluster are Registry (or
UnixCluster plugin) models. Every hasMany in the plugin models is dependent, and
CakePHP's delete() cascades by default, so deleting an EntraSourceGroup (as
synchronizeSourceGroups() does) also deletes its memberships.
Controller/EntraSourcesController.phpextends the Registry'sSOISController.beforeRender()builds the three select lists for the form, limited to the current CO:vv_access_token_server_ids(Oauth2Servers),vv_api_server_ids(HttpServers), andvv_unix_clusters.isAuthorized()grantsdelete,edit,index, andviewto CMP admins, and to CO admins when org identities are not pooled. There is noaddpermission and no add view.Controller/EntraSourceExtensionPropertiesController.phpextendsStandardController. The owning EntraSource comes from the named parameteresid(or the postedentra_source_id, or the edited row).beforeFilter()setsvv_entra_source,vv_esid, andvv_identifier_types(the CO's CoPerson Identifier types).paginationConditions()limits the index to one EntraSource, andperformRedirect()returns to that index. Permissions match the other controller, plusadd. IncalculateImpliedCoId(), the not-found exception message usesct.clusters.1and an undefined$cid, copied from another plugin.View/EntraSources/:fields.inc(the form),buttons.inc(adds the "Manage Schema Extension Properties" link usingop.manage-a), andedit.ctp.View/EntraSourceExtensionProperties/:fields.inc,index.ctp,add.ctp, andedit.ctp.- The
add.ctpandedit.ctpfiles are symlinks to../../../../../app/View/Standard/add.ctpandedit.ctp. Following the Registry convention, the standard template renders the plugin'sfields.inc. The links resolve only when the plugin is installed three levels below the Registry root (for examplelocal/Plugin/EntraSource/, so eachView/<Controller>/directory sits five levels down); in a standalone checkout they dangle. Lib/lang.php: every user-facing string. Keys start withct.(titles),er.(errors), orpl.(form labels and descriptions). The Registry merges$cm_entra_source_textsinto its own strings. Add new strings here, not in controllers or views.
Model/EntraSourceBackend.php defines EntraSourceBackend, which extends the Registry's
abstract OrgIdentitySourceBackend. The Registry calls its public OIS methods; the
protected helpers do the work. getConfig() (inherited) returns the EntraSource row.
The backend caches three things on the instance: $accessTokenServer and $apiServer
(set by apiConnect()), and $activeId (the EntraSource id, used by log()). Each
helper creates its own new EntraSource() model to reach the plugin tables.
| Method | Calls | Notes |
|---|---|---|
inventory() |
inventoryCacheValid(), inventoryFromCache(), recordInventoryStart(), apiConnect(), then inventoryBySourceGroups() or inventoryAllUsers() |
Returns a list of Entra user object ids. The cache check runs only when use_source_groups is on. |
retrieve($id) |
apiConnect(), inventory(), getExtensionProperties(), apiRequest() (users/{id}), addSourceRecord(), resultToOrgIdentity() |
Builds memberOf from stored memberships. The calls to synchronizeSourceGroups(), getFilteredSourceGroupsForSourceRecord(), and syncMembershipsSourceRecord() are commented out. |
search($attributes) |
apiConnect(), getExtensionProperties(), apiRequest() (/users filtered on mail), and when use_source_groups is on, synchronizeSourceGroups() and getFilteredSourceGroupsForSourceRecord(); then resultToOrgIdentity() |
Reads only $attributes['mail'] and uses only the first match. |
searchableAttributes() |
none | Returns mail, labeled with pl.entrasource.search.mail. |
groupableAttributes() |
none | Returns memberOf. |
resultToGroups($raw) |
none | Turns the memberOf list in the raw JSON from retrieve() into memberOf values. |
log($msg, $type, $scope) |
parent log() |
Not an OIS method; overrides logging (see Logging). |
What these methods mean for a sync, step by step, is on How sync works.
| Helper | Used by | Does |
|---|---|---|
apiConnect() |
inventory(), retrieve(), search(), apiRequest() |
Loads the two Server records and creates the HttpSocket. |
apiRequest() |
every Graph call | Token refresh, URL, headers, 429 retry, error check, JSON decode. |
inventoryCacheValid() |
inventory() |
True if inventory_cache_start plus max_inventory_cache minutes is in the future. |
inventoryFromCache() |
inventory(), inventoryBySourceGroups() |
Returns the graph_id of every EntraSourceRecord, not filtered by EntraSource. |
recordInventoryStart() |
inventory() |
Saves the current time to inventory_cache_start. |
inventoryAllUsers() |
inventory() |
Returns an empty array ("Not currently supported"). |
inventoryBySourceGroups() |
inventory() |
synchronizeSourceGroups(), then inventoryOneSourceGroup() for each stored group, then inventoryFromCache(). |
synchronizeSourceGroups() |
inventoryBySourceGroups(), search() |
Lists Graph groups, reconciles EntraSourceGroup rows, ensures the Registry objects for each group. |
inventoryOneSourceGroup() |
inventoryBySourceGroups() |
Pages through one group's transitive user members, then synchronizeTransitiveMembers(). |
synchronizeTransitiveMembers() |
inventoryOneSourceGroup() |
addSourceRecord() and membership rows for each member; deletes memberships of users no longer listed. Passes a third argument to addSourceRecord(), which takes two. |
addSourceRecord() |
synchronizeTransitiveMembers(), retrieve() |
Finds or creates the EntraSourceRecord for a Graph user id. |
getExtensionProperties() |
retrieve(), search() |
Returns the EntraSourceExtensionProperty rows for this EntraSource. |
getFilteredSourceGroupsForSourceRecord() |
search() |
Pages through a user's transitiveMemberOf, keeps groups that match a stored EntraSourceGroup. |
syncMembershipsSourceRecord() |
nothing (its call in retrieve() is commented out) |
Would reconcile one record's memberships from getFilteredSourceGroupsForSourceRecord() output. |
resultToOrgIdentity() |
retrieve(), search() |
Maps a Graph user record to Org Identity format. |
None of the helpers checks the return value of a model save() or delete().
apiConnect() reads access_token_server_id and api_server_id from the
configuration, loads the Oauth2Server and HttpServer rows into $accessTokenServer
and $apiServer, and creates $this->Http (a CakePHP HttpSocket). If either row is
missing it throws InvalidArgumentException with the Registry string er.notfound.
It holds no credentials of its own; the token, its expiry, and the Graph base URL all
come from those Registry records (see Configuration).
The public methods call it before any Graph request. apiRequest() calls it again after
it gets a new token, to reload $accessTokenServer with that token.
apiRequest($urlPath, $action = "get", $query = array(), $headers = array()) does, in
order:
- Token. Asks
Oauth2Server->isExpired()whether the token expires within 10 seconds ($deltat). If so, or if the answer isnull, callsOauth2Server->obtainToken()withclient_credentials. If the result has noaccess_tokenproperty, it logser.entrasource.access_token.unableand throwsRuntimeException. Otherwise it callsapiConnect()again. - Headers. Sends
Authorization: Bearer <token>, plus any$headerspassed in. Onlysearch()passes one (ConsistencyLevel: eventual). - URL. If
$urlPathstarts with the HttpServerserverurl, it is used as is. Otherwise the URL isserverurl . '/' . $urlPath. This lets callers pass an@odata.nextLink(an absolute URL) back in unchanged, as long asserverurlis a prefix of the URL Graph returns.search()passes/userswith a leading slash, so its URL has two slashes after the base. - Request and 429 loop. Calls
$this->Http->$action($url, $query, $options)in ado ... whileloop:- On HTTP 429 it logs the throttling messages, reads
Retry-Afterwith(int) $response->getHeader('Retry-After'), sleeps that many seconds, and sends the same request again. There is no retry limit. - The 5-second fallback is in a
catchblock around that read. CakePHP'sgetHeader()returnsnullfor a missing header rather than throwing, and the(int)cast turnsnullinto 0. So a 429 withoutRetry-Afterlogs a value of 0 and retries at once; the fallback runs only if the read throws. - Any other status that is not 200 logs
er.entrasource.api.codeand then the raw response body, and throwsRuntimeException. 201 or 204 would also count as failures, but every call the plugin makes is aGET.
- On HTTP 429 it logs the throttling messages, reads
- Decode. Returns
json_decode($response->body, true, 512, JSON_THROW_ON_ERROR), so a body that is not JSON throwsJsonException.
apiRequest() does not page. Each caller loops on @odata.nextLink: on the first
request it sends its own path and query (with $top=999); on later requests it passes
the nextLink URL as $urlPath with an empty query. The loops are in
synchronizeSourceGroups(), inventoryOneSourceGroup(), and
getFilteredSourceGroupsForSourceRecord(). synchronizeSourceGroups() and
getFilteredSourceGroupsForSourceRecord() also stop when a page has an empty value.
retrieve() and search() make one request each and do not page. Which endpoints these
are is on Entra contract.
Exceptions from apiConnect(), apiRequest(), and token refresh propagate unless a
caller catches them. Only one caller does.
| Caller | Catches? | Result of a failure |
|---|---|---|
inventoryOneSourceGroup() |
Yes, catch (Exception $e) around each page request |
Logs the exception and returns. That group is skipped: synchronizeTransitiveMembers() is not called, even if earlier pages succeeded, so the group's stored memberships stay as they were. The inventory continues with the next group. |
synchronizeSourceGroups() |
No | Propagates through inventoryBySourceGroups() and inventory() (and through retrieve(), which calls inventory()), or through search(). inventory_cache_start was already written, so later inventories use the cache until it expires. |
getFilteredSourceGroupsForSourceRecord() |
No | Propagates through search(). |
retrieve() (users/{id}) |
No | The retrieve fails. |
search() (/users) |
No | The search fails. |
What staff see when each of these fails is on Entra contract.
EntraSourceBackend::log() overrides the parent log(). On first use it caches the
EntraSource id from getConfig() in $activeId, then prepends
EntraSourceBackend ID <id>: to every message and passes it on. The default level is
LOG_ERR, and no call in the backend passes another level, so every plugin message,
including routine progress messages, is written at error level.
The parent call reaches CakePHP's CakeObject::log(), which calls CakeLog::write().
Where the line ends up is set by the Registry's CakeLog configuration, not by this
repository. In the stock app/Config/bootstrap.php of Registry 4.6.0, error-level
messages go to the error FileLog, which is error.log in the Registry's LOGS
directory (by default app/tmp/logs/error.log). A deployment can change that.
The controllers and models other than the backend do not log.
Messages built with _txt() come from Lib/lang.php. When a key is missing, the
Registry's _txt() returns the key itself, and that is what gets logged. Two keys the
backend uses are missing (see the throttling rows below).
Several messages append print_r() of an array or of an exception object, so one log
entry can span many lines.
In the table, <id> is the EntraSource id, <name> is a group's mailNickname, and
<n> is a number. Every line starts with EntraSourceBackend ID <id>: .
| Message text (after the prefix) | Logged by | Meaning |
|---|---|---|
inventory cache is valid |
inventory() |
Cache window still open; the inventory comes from stored records and no Graph call is made. |
inventory cache is invalid |
inventory() |
Cache window expired (or never set); a full inventory follows. |
inventory called |
inventory() |
A full inventory is starting. Logged after the cache check, so it also appears when use_source_groups is off. |
inventory is returning |
inventory() |
A full inventory finished without an uncaught exception. |
inventoring source group <name> |
inventoryOneSourceGroup() |
Starting one source group. ("inventoring" is spelled this way in the code.) |
inventoryOneSourceGroup caught exception: followed by the print_r() of the exception |
inventoryOneSourceGroup() |
A Graph call for this group failed; the group was skipped and its stored memberships were left as they were. The Microsoft Graph API returned code ... line and response body just before it give the cause. |
Microsoft Graph API returned throttling code 429 |
apiRequest() (er.entrasource.api.throttled) |
Graph throttled a request. |
Microsoft Graph API Retry-After header is <n> |
apiRequest() (er.entrasource.api.throttled.retry) |
Seconds Graph asked to wait. 0 means the header was missing. |
Could not determine Microsoft Graph API Retry-After header so using 5 |
apiRequest() (er.entrasource.api.throttled.retry.error) |
Reading the header threw; waiting 5 seconds. |
er.entrasource.api.throttled.retry.sleep |
apiRequest() |
Logged just before the sleep. The code uses this key, but Lib/lang.php defines er.entrasource.api.throttled.sleep ("Sleeping for %1$s seconds now"), so the key itself is logged. |
er.entrasource.api.throttled.retry.sleep.awake |
apiRequest() |
Logged after the sleep, before the retry. Same mismatch: Lib/lang.php defines er.entrasource.api.throttled.sleep.awake ("Done sleeping for %1$s seconds"). |
Microsoft Graph API returned code <n>, then a second line with the raw response body |
apiRequest() (er.entrasource.api.code) |
A Graph call failed with a status other than 200 and 429. The body usually holds Graph's error code and message. A RuntimeException follows. |
Unable to obtain new access token |
apiRequest() (er.entrasource.access_token.unable) |
Token refresh returned no access_token. A RuntimeException follows. |
Added EntraSourceGroup with mailNickname <name> |
synchronizeSourceGroups() |
New source group row. |
Updated EntraSourceGroup with mailNickname <name> |
synchronizeSourceGroups() |
Existing row had an empty graph_id or gidnumber and was rewritten. |
Deleted EntraSourceGroup with mailNickname <name> |
synchronizeSourceGroups() |
Group no longer in the Graph result or the hardcoded list; its row and memberships were deleted. |
Added CoGroup <name> |
synchronizeSourceGroups() |
New CO Group. |
Added UnixClusterGroup for <name> |
synchronizeSourceGroups() |
New UnixClusterGroup for the CO Group. |
Added CoGroupOisMapping for <name> |
synchronizeSourceGroups() |
New memberOf mapping. |
Added Identifier of type uid for CoGroup <name> / Updated Identifier of type uid for CoGroup <name> |
synchronizeSourceGroups() |
uid Identifier created, or reset to the mailNickname. |
Added Identifier for CoGroup <name> / Updated Identifier for CoGroup <name> |
synchronizeSourceGroups() |
gidnumber Identifier created or reset. The message does not name the type. |
Saved source record followed by print_r() of the row data |
addSourceRecord() |
New EntraSourceRecord. |
Saved EntraSourceGroupMembership followed by print_r() |
synchronizeTransitiveMembers() |
New membership row from an inventory. |
Deleted EntraSourceGroupMembership followed by print_r() |
synchronizeTransitiveMembers() |
User no longer a transitive member; row deleted. |
Added EntraSourceGroupMembership ... / Deleted EntraSourceGroupMembership ... |
syncMembershipsSourceRecord() |
Not logged today; the method is never called. |
retrieve called with id <graph id> / retrieve is returning |
retrieve() |
Start and successful end of a retrieve. |
search called with attributes followed by print_r() / search is returning |
search() |
Start of a search, and the end of one that found a candidate in scope. A search that finds nothing returns without the second line. |
A quick way to spot a skipped source group is to search the log for
inventoryOneSourceGroup caught exception; for throttling, throttling code 429; and
for any failed Graph call, Microsoft Graph API returned code.
Each TODO comment in the code, with the function it is in. What each one does to the data is on Assumptions and known gaps.
Model/EntraSource.php(class properties):TODO Remove assumption that UnixCluster plugin is enabled.See UnixCluster plugin is required.resultToOrgIdentity():TODO affiliation should be configurable.See Affiliation is always member.resultToOrgIdentity():TODO Name type should be configurable.See Name type is always official.resultToOrgIdentity():TODO EmailAddress type should be configurable.See Email type is always official and verified.resultToOrgIdentity():TODO Identifier type should be configurable.See upn identifier type.synchronizeSourceGroups():TODO Remove the assumption that we are using a source_group_filter.See All-users inventory is not implemented.synchronizeSourceGroups():TODO remove hardcoded extension for gidNumber.See gidNumber extension property name.synchronizeSourceGroups():TODO remove this hard-coded additional list of groups.See Three groups that are always added.synchronizeSourceGroups():TODO remove assumptions here.(uidIdentifier) andTODO remove assumptions here about Identifier and even the need for an Identifier.(gidnumberIdentifier). See uid and gidnumber Identifiers on CO Groups.
Other unfinished paths have no TODO comment, such as inventoryAllUsers() returning an
empty list and inventoryFromCache() not filtering by EntraSource. They are listed under
Unfinished or assumed behavior.
- Update the docs in the same pull request. When a change alters behavior, update
every affected page under
docs/in that pull request: the sync flow (How sync works), fields (Configuration), Graph calls and attributes (Entra contract), hardcoded values and TODOs (Assumptions and known gaps), symptoms (Troubleshooting), and this page (tables, functions, log messages). - No automated tests.
Test/holds only placeholders. Lint each changed PHP file withphp -l <file>. Behavior that talks to Graph or to the database can only be checked in a running Registry with a reachable Entra tenant. - Strings go in
Lib/lang.php. When you add a_txt()key, check that the key in the code and the key inLib/lang.phpmatch; a mismatch is not an error and just logs or shows the key. - Schema changes go in
Config/Schema/schema.xml. - Keep secrets out. Do not commit tenant ids, client ids, or client secrets.