toga-ai 1.0.822 → 1.0.824
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knowledge/1.0/apps/tools/features/compass-user-persona-admin.md +24 -5
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -0
- package/knowledge/2.0/apps/_underscore/workflows/rotating-public-tls-certificates.md +178 -0
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/compass-usa/features/people-file-user-lifecycle.md +72 -11
- package/package.json +1 -1
|
@@ -6,8 +6,8 @@ project: Tools
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
10
|
-
owners: [ajean]
|
|
9
|
+
updated: 2026-09-16
|
|
10
|
+
owners: [ajean, bala]
|
|
11
11
|
files:
|
|
12
12
|
- tools/_/app/compass.php
|
|
13
13
|
- tools/mvc/compass/users/get.php
|
|
@@ -101,6 +101,10 @@ conditions of the decision below — **do not remove one without re-opening it**
|
|
|
101
101
|
(the same human on two `Users` rows).
|
|
102
102
|
5. The four `ensureCrossRegionUsersActive()` addresses are **blocked as both source and target**
|
|
103
103
|
(`isCrossRegionProtectedEmail()`), because that hardcoded worker2 list matches on email.
|
|
104
|
+
**⚠ Out of date as of 2026-09-16:** worker2 split that list into six addresses
|
|
105
|
+
(`ALWAYS_ACTIVE_EMAILS_BOTH` + `ALWAYS_ACTIVE_EMAILS_CA`), so the two new
|
|
106
|
+
`@compassdigital.io` Canada-protected addresses are **not** blocked here yet. See
|
|
107
|
+
[PEOPLE-File User Lifecycle](../../../clients/compass-usa/features/people-file-user-lifecycle.md).
|
|
104
108
|
6. The **audit row is written before the write**, on its own connection (see the transaction
|
|
105
109
|
gotcha below).
|
|
106
110
|
|
|
@@ -191,9 +195,14 @@ names, reads `Core.ClientEmailDomains` / `Core.Clients`, and inserts into `Logs_
|
|
|
191
195
|
~178,615 of 178,616 `Client_Compass` rows, so the client id cannot be derived from the user row.
|
|
192
196
|
See [Compass Canada profile](../../../clients/compass-canada/profile.md).
|
|
193
197
|
- **A cross-region user still needs a code change and a deploy.** The only durable way to hold one
|
|
194
|
-
active is the hardcoded
|
|
198
|
+
active is the hardcoded list in `ensureCrossRegionUsersActive()` in
|
|
195
199
|
`worker2/Worker/Client/Compass/PeopleFile.php`. This tool does not change that — and because that
|
|
196
|
-
list matches on **email**, those
|
|
200
|
+
list matches on **email**, those addresses are blocked from this tool's email edit entirely.
|
|
201
|
+
**As of 2026-09-16 that list is six addresses, not four**: `ALWAYS_ACTIVE_EMAILS_BOTH` (the
|
|
202
|
+
original four, held active in both tenants) plus `ALWAYS_ACTIVE_EMAILS_CA` (two
|
|
203
|
+
`@compassdigital.io` US staff held active in `Client_CompassCanada` only).
|
|
204
|
+
`isCrossRegionProtectedEmail()` here still only knows the original four, so the two new addresses
|
|
205
|
+
can be edited in this tool and silently lose their protection.
|
|
197
206
|
- **Every user search is a full table scan.** `Client_Compass.Users` has no index on `email`,
|
|
198
207
|
`c_hrEmpUsername`, `c_hrEmpPersonnelNbr` or `isActive` (176k rows; `EXPLAIN` reports `type=ALL`,
|
|
199
208
|
no possible keys). Same on `Client_CompassCanada`. Keep result sets bounded and do not add
|
|
@@ -235,12 +244,22 @@ names, reads `Core.ClientEmailDomains` / `Core.Clients`, and inserts into `Logs_
|
|
|
235
244
|
(`_underscore/Model/Compass/SalesOrder.php:287-305`): it joins
|
|
236
245
|
`INNER JOIN Contacts ON Contacts.id = Users.id`, which should be `Users.contactId`.
|
|
237
246
|
- **worker2** — scope `ensureCrossRegionUsersActive()` to a `Users.id` list instead of matching on
|
|
238
|
-
email; that would also lift this tool's block on those
|
|
247
|
+
email; that would also lift this tool's block on those addresses.
|
|
248
|
+
- **tools** — extend `isCrossRegionProtectedEmail()` to the two `ALWAYS_ACTIVE_EMAILS_CA` addresses
|
|
249
|
+
added in worker2 on 2026-09-16, or the block silently under-covers the protected set.
|
|
239
250
|
- **ops** — rotate every secret in `tools/config.production.ini`; consider a least-privilege
|
|
240
251
|
Compass account.
|
|
241
252
|
|
|
242
253
|
## Change history
|
|
243
254
|
|
|
255
|
+
- 2026-09-16 — Recorded that worker2's cross-region protected list **grew from four addresses to
|
|
256
|
+
six** (`ALWAYS_ACTIVE_EMAILS_BOTH` + `ALWAYS_ACTIVE_EMAILS_CA`), so this tool's
|
|
257
|
+
`isCrossRegionProtectedEmail()` block now under-covers it: the two new `@compassdigital.io`
|
|
258
|
+
Canada-protected addresses can still be email-edited here and would silently drop out of the
|
|
259
|
+
protected set, because the worker2 match is on email. No `tools` code changed. Detail:
|
|
260
|
+
[PEOPLE-File User Lifecycle](../../../clients/compass-usa/features/people-file-user-lifecycle.md).
|
|
261
|
+
(bala)
|
|
262
|
+
|
|
244
263
|
- 2026-09-04 — Added **email, phone and cost-center editing** (PR tools#14,
|
|
245
264
|
`feature/compass-user-field-edits`, **open / not merged / not deployed**): new `App_Compass`
|
|
246
265
|
setters plus the `normaliseEmail` / `normalisePhone` / `emailDomainIsRegistered` /
|
|
@@ -61,4 +61,5 @@
|
|
|
61
61
|
| [USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)](features/usps-dpv-deliverability.md) | **USPS returning HTTP 200 with a populated address is NOT evidence that the address is deliverable.** The authoritative signal is USPS's **DPV (Delivery Point V |
|
|
62
62
|
| [Refreshing a Local Dev Database from Beta (dev-sandbox)](workflows/local-db-refresh-from-beta.md) | How to reset a local 2.0 dev database from the **beta / dev-sandbox** environment: dump each schema (`Core`, `Client_<Id>`, `Logs_<Id>`, …) from the beta host, |
|
|
63
63
|
| [Deleting a shared branch does not remove bad commits — a stale local clone merges them back](workflows/recreated-shared-branch-stale-local-remerge.md) | **Deleting and recreating a shared environment branch removes only the *ref*.** Every teammate who still has that branch checked out locally keeps the full pre- |
|
|
64
|
+
| [Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk)](workflows/rotating-public-tls-certificates.md) | How to replace the public TLS certificates that terminate HTTPS for TOGa front-end domains (togahub, togacommerce, togadesk, togaretail, togasupply, togaview, t |
|
|
64
65
|
| [Running a 2.0 App Locally (browser, end-to-end via api2)](workflows/running-a-2.0-app-locally.md) | The full dependency chain required to run a 2.0 client app **through the browser**, end-to-end, against a **local `api2`** (e.g. |
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: workflow
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-16
|
|
10
|
+
owners: ["rgirish"]
|
|
11
|
+
files: []
|
|
12
|
+
related:
|
|
13
|
+
- 2.0/standards/ssl-certificate-trust-model.md
|
|
14
|
+
- 2.0/apps/saml/workflows/rotating-the-saml-sp-certificate.md
|
|
15
|
+
- 1.0/apps/tools/features/cloudfront-client-setup.md
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Summary
|
|
19
|
+
|
|
20
|
+
How to replace the public TLS certificates that terminate HTTPS for TOGa front-end domains
|
|
21
|
+
(togahub, togacommerce, togadesk, togaretail, togasupply, togaview, togaiq) before they
|
|
22
|
+
expire. This is the browser-facing TLS cert on CloudFront and ALBs — **not** the SAML SP
|
|
23
|
+
credential (that is a pinned bilateral cert, see
|
|
24
|
+
[Rotating the SAML SP Certificate](../../saml/workflows/rotating-the-saml-sp-certificate.md)).
|
|
25
|
+
|
|
26
|
+
This runbook was written from the 2026-09-16 rotation in AWS account **654654170868**, where
|
|
27
|
+
one IMPORTED multi-domain cert (14 SANs, copied into us-east-1, us-west-2 and eu-west-1) served
|
|
28
|
+
4 CloudFront distributions and 12 ALB listeners and expired the same day.
|
|
29
|
+
|
|
30
|
+
**The three things that bite you:**
|
|
31
|
+
1. **Elastic Beanstalk keeps its own saved cert setting**, separate from the ALB listener. Fixing
|
|
32
|
+
the listener is not enough — the next deploy puts the old cert straight back.
|
|
33
|
+
2. **CloudFront holds exactly ONE cert per distribution.** There is no staging. The swap *is* the
|
|
34
|
+
cutover, and it takes 5-15 minutes.
|
|
35
|
+
3. **An ACM wildcard matches exactly one label.** `*.togasupply.com` covers
|
|
36
|
+
`elite.togasupply.com` but **not** `compass.beta.togasupply.com`.
|
|
37
|
+
|
|
38
|
+
**Root cause to avoid repeating:** an **IMPORTED** ACM cert never auto-renews. Always replace with
|
|
39
|
+
**Amazon-issued, DNS-validated** ACM certs, which renew themselves.
|
|
40
|
+
|
|
41
|
+
## When to run this
|
|
42
|
+
|
|
43
|
+
- A cert is nearing expiry (ACM emails, or your own audit finds it).
|
|
44
|
+
- You are moving off an imported cert onto Amazon-issued certs.
|
|
45
|
+
- Any time you suspect a cert is expired — run the audit in step 1 regardless; it is cheap.
|
|
46
|
+
|
|
47
|
+
## Step 1 — Audit EVERYTHING, not just the cert you know about
|
|
48
|
+
|
|
49
|
+
Do **not** just follow the expiring cert's `InUseBy` list. Enumerate **all** CloudFront
|
|
50
|
+
distributions and **all** ALB listeners in **every** region, resolve each attached cert's
|
|
51
|
+
`NotAfter`, and flag anything expiring before your cutoff.
|
|
52
|
+
|
|
53
|
+
This is how the 2026-09-16 rotation found a **second** cert that had already expired six months
|
|
54
|
+
earlier (2026-03-24) and was still the only cert on 2 ALB listeners and 2 CloudFront
|
|
55
|
+
distributions — 4 live togadesk hosts had been serving cert errors since March and nobody noticed.
|
|
56
|
+
|
|
57
|
+
Also audit **every Elastic Beanstalk environment's saved config**, including suspended ones. A
|
|
58
|
+
suspended environment with no live listener can still hold a stale cert ARN that comes back when
|
|
59
|
+
it is rebuilt.
|
|
60
|
+
|
|
61
|
+
**Check each CloudFront distribution's origins before you plan work on it.** One distribution in
|
|
62
|
+
this rotation had a real alias and live DNS but all 3 origins pointed at load balancers that no
|
|
63
|
+
longer existed — a dead leftover. Do not spend change-window time on it.
|
|
64
|
+
|
|
65
|
+
## Step 2 — Decide the cert shape: one cert per domain
|
|
66
|
+
|
|
67
|
+
Replace a bundled multi-domain cert with **one cert per domain** (root + wildcard, e.g.
|
|
68
|
+
`togahub.com` + `*.togahub.com`). Reasons:
|
|
69
|
+
|
|
70
|
+
- A 14-SAN request exceeds ACM's default 10-domain quota.
|
|
71
|
+
- One domain's problem no longer takes down all seven.
|
|
72
|
+
- Each distribution only needs the domain it actually serves.
|
|
73
|
+
|
|
74
|
+
All new certs must be **Amazon-issued and DNS-validated** so they auto-renew. This is case 2 of
|
|
75
|
+
the [SSL Certificate Trust-Model Strategy](../../../standards/ssl-certificate-trust-model.md):
|
|
76
|
+
AWS terminates the TLS, so use ACM public, non-exportable, auto-renewing.
|
|
77
|
+
|
|
78
|
+
## Step 3 — Request the certs in the right regions
|
|
79
|
+
|
|
80
|
+
Region rules:
|
|
81
|
+
|
|
82
|
+
- **CloudFront only reads certs from `us-east-1`.** No exceptions.
|
|
83
|
+
- **An ALB reads certs from its own region.** A togahub ALB in us-west-2 needs a us-west-2 cert;
|
|
84
|
+
the same domain on an eu-west-1 ALB needs a separate eu-west-1 cert.
|
|
85
|
+
|
|
86
|
+
Check ACM first — unused per-domain certs may already exist (3 did in this rotation). Then request
|
|
87
|
+
only what is missing.
|
|
88
|
+
|
|
89
|
+
## Step 4 — Validate by DNS in Route 53
|
|
90
|
+
|
|
91
|
+
Add the validation CNAMEs to Route 53. Certs for the **same domain in different regions share ONE
|
|
92
|
+
validation record** — the CNAME name and value are identical, so 6 certs across 3 regions needed
|
|
93
|
+
only 5 CNAMEs.
|
|
94
|
+
|
|
95
|
+
Validation completes in about 2 minutes once the records are live. Wait for `ISSUED` before
|
|
96
|
+
touching anything.
|
|
97
|
+
|
|
98
|
+
## Step 5 — Stage the ALB listeners first (safe, additive)
|
|
99
|
+
|
|
100
|
+
`aws elbv2 add-listener-certificates` is **purely additive**. A listener holds many certs and picks
|
|
101
|
+
by SNI, so adding the new cert alongside the old one changes nothing for live traffic. Do this
|
|
102
|
+
ahead of the change window for every listener.
|
|
103
|
+
|
|
104
|
+
## Step 6 — THE ONE PEOPLE MISS: update the Elastic Beanstalk saved config
|
|
105
|
+
|
|
106
|
+
Adding a cert to an ALB listener does **not** update the EB environment's saved setting
|
|
107
|
+
`aws:elbv2:listener:443 / SSLCertificateArns`. After all 12 listeners were fixed and verified
|
|
108
|
+
healthy in this rotation, 12 EB environments still named the **old** cert in their saved config.
|
|
109
|
+
|
|
110
|
+
Nothing breaks right away — the listener serves traffic. But the next **deploy, restart, rebuild or
|
|
111
|
+
scaling event** re-applies the saved config and puts the expired cert back. A listener-level audit
|
|
112
|
+
does not catch this.
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
aws elasticbeanstalk update-environment \
|
|
116
|
+
--environment-name <env> \
|
|
117
|
+
--option-settings "Namespace=aws:elbv2:listener:443,OptionName=SSLCertificateArns,Value=<new-arn>"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Observed behaviour: this cleanly **replaced** the old cert on the listener (leaving only the new
|
|
121
|
+
one), and every environment stayed Green/Ok through the update — no traffic disruption.
|
|
122
|
+
|
|
123
|
+
**Gotcha:** an EB environment whose CloudFormation stack is in `DELETE_FAILED` cannot be updated at
|
|
124
|
+
all — `update-environment` is refused. That needs separate stack cleanup.
|
|
125
|
+
|
|
126
|
+
## Step 7 — Check alias coverage before any CloudFront swap
|
|
127
|
+
|
|
128
|
+
CloudFront **rejects** an update if any alias on the distribution is not covered by the new cert.
|
|
129
|
+
Check every alias against the new cert's SANs **programmatically** — this is where the one-label
|
|
130
|
+
wildcard rule bites: `*.togasupply.com` does not cover `compass.beta.togasupply.com`. Beta and
|
|
131
|
+
gamma environments generally need their own certs for this reason.
|
|
132
|
+
|
|
133
|
+
## Step 8 — Swap CloudFront, smallest blast radius first
|
|
134
|
+
|
|
135
|
+
`ViewerCertificate.ACMCertificateArn` is a **single string**, not a list. There is no
|
|
136
|
+
"add new alongside old" for CloudFront: the swap is the cutover, and it deploys in 5-15 minutes.
|
|
137
|
+
|
|
138
|
+
Order used, lowest risk first:
|
|
139
|
+
|
|
140
|
+
1. Distributions that are **already broken** (nothing to lose).
|
|
141
|
+
2. Single-alias distributions.
|
|
142
|
+
3. Mid-size (9 aliases).
|
|
143
|
+
4. The largest last (the 21-alias distribution fronting 21 named enterprise clients).
|
|
144
|
+
|
|
145
|
+
Back up each distribution's config before every swap — `aws cloudfront get-distribution-config`,
|
|
146
|
+
and keep the **ETag**; you need it to update.
|
|
147
|
+
|
|
148
|
+
**Hard edge:** once the old cert's expiry moment has passed there is no useful CloudFront rollback,
|
|
149
|
+
because rolling back restores an expired cert. Only forward.
|
|
150
|
+
|
|
151
|
+
## Step 9 — Verify on the live hosts
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
echo | openssl s_client -servername HOST -connect HOST:443 2>/dev/null \
|
|
155
|
+
| openssl x509 -noout -issuer -enddate
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Also run it **without** `-servername` to check the no-SNI / default-cert path — that path can still
|
|
159
|
+
be serving the old cert after everything else looks fine.
|
|
160
|
+
|
|
161
|
+
Finish by re-running the step 1 audit: every CloudFront distribution, every ALB listener, and every
|
|
162
|
+
EB saved config should name a cert with a future `NotAfter`.
|
|
163
|
+
|
|
164
|
+
## Gotchas
|
|
165
|
+
|
|
166
|
+
- **IMPORTED certs never auto-renew.** If the audit shows `Type: IMPORTED`, it is a future outage.
|
|
167
|
+
- **A cert copied into 3 regions is 3 separate certs** — all 3 expire at once, and all 3 need
|
|
168
|
+
replacing.
|
|
169
|
+
- **EB saved config is the silent landmine.** See step 6.
|
|
170
|
+
- **CloudFront takes one cert; ALBs take many.** Two completely different risk profiles in the same
|
|
171
|
+
rotation — plan them separately.
|
|
172
|
+
- **Wildcards match one label only.** Verify alias coverage with code, not by eye.
|
|
173
|
+
- **Expired certs hide.** Sweep the whole account; do not trust `InUseBy`.
|
|
174
|
+
|
|
175
|
+
## Change history
|
|
176
|
+
- 2026-09-16 — Created from the account 654654170868 rotation: replaced an expiring 14-SAN IMPORTED
|
|
177
|
+
cert with 6 per-domain Amazon-issued DNS-validated certs, relinked 12 ALB listeners, 6 CloudFront
|
|
178
|
+
distributions and 11 EB environment configs; documented the EB saved-config trap. (rgirish)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -19,7 +19,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
19
19
|
|
|
20
20
|
## 2.0 framework
|
|
21
21
|
|
|
22
|
-
- **_underscore** (_Underscore) _(framework core)_ —
|
|
22
|
+
- **_underscore** (_Underscore) _(framework core)_ — 86 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
23
23
|
- **worker2** (Worker) — 69 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
24
24
|
- **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
25
25
|
- **dbchanges2** (Database Changes) _(framework core)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: compass-usa
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-16
|
|
10
10
|
owners: [bala, ajean]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Client/Compass/PeopleFile.php
|
|
@@ -85,6 +85,39 @@ insert-refresh time the **new row already has its id** and the **old row is stil
|
|
|
85
85
|
deactivation-time hook would be worse — by then the new row's relationship to the old one has to be
|
|
86
86
|
rediscovered, and the deactivation path has no model hooks at all (see below).
|
|
87
87
|
|
|
88
|
+
### Always-active protection is per-tenant, and has two layers
|
|
89
|
+
|
|
90
|
+
A small hardcoded set of people must stay active regardless of what HR sends. Since **2026-09-16**
|
|
91
|
+
that set is split into two class constants on `_Worker_Client_Compass_PeopleFile`:
|
|
92
|
+
|
|
93
|
+
- **`ALWAYS_ACTIVE_EMAILS_BOTH`** — the original four addresses (two `@compass-usa.com`, two
|
|
94
|
+
`@compass-canada.com`). Held active in **both** `Client_Compass` and `Client_CompassCanada`.
|
|
95
|
+
- **`ALWAYS_ACTIVE_EMAILS_CA`** — two `@compassdigital.io` **Compass USA** staff (usernames
|
|
96
|
+
`shuttk01` / `racerk01` in `Client_Compass`). Held active in **`Client_CompassCanada` only**.
|
|
97
|
+
They are allowed to go inactive in the US tenant when the PEOPLE file drops them; that is the
|
|
98
|
+
requirement, not a bug.
|
|
99
|
+
|
|
100
|
+
`isEmailAlwaysActive(string $email, string $tenantKey): bool` is the single decision point — a
|
|
101
|
+
lowercased match that returns true for the BOTH list in either tenant, and for the CA list only when
|
|
102
|
+
`$tenantKey === 'CA'`. Two layers consume it:
|
|
103
|
+
|
|
104
|
+
1. **The inactivation loop** in `executeForTenant()` skips any user whose `Users.email` is
|
|
105
|
+
protected for that tenant, so the batch `UPDATE ... SET isActive = 0` never lists them.
|
|
106
|
+
2. **`ensureCrossRegionUsersActive()`**, which runs after both tenants have executed and force-sets
|
|
107
|
+
`isActive = 1` — in both databases for the BOTH list, and in `Client_CompassCanada` only for the
|
|
108
|
+
CA list.
|
|
109
|
+
|
|
110
|
+
**Today only layer 2 actually protects the CA-only pair.** In `Client_CompassCanada` both of those
|
|
111
|
+
users have `c_hrEmpUsername = NULL`, and `loadLookups()` keys its map on username — so they never
|
|
112
|
+
enter the map and can never reach the inactivation batch on the Canada side. The loop guard is the
|
|
113
|
+
defence for the day HR gives them a Canada username, not what is holding them active now.
|
|
114
|
+
(Read-only prod check, 2026-09-16.)
|
|
115
|
+
|
|
116
|
+
**The email match works across a case difference.** `Client_Compass` stores these two addresses
|
|
117
|
+
UPPERCASE while `Client_CompassCanada` stores them lowercase. `Users.email` uses
|
|
118
|
+
`utf8mb4_0900_ai_ci`, a case-insensitive collation, so `WHERE email = '...'` still hits — each
|
|
119
|
+
address resolves to exactly **one** row per database.
|
|
120
|
+
|
|
88
121
|
## Gotchas
|
|
89
122
|
|
|
90
123
|
- **⚠ Deactivation is raw bulk SQL — model hooks never fire.** User inactivation is executed as
|
|
@@ -157,16 +190,26 @@ rediscovered, and the deactivation path has no model hooks at all (see below).
|
|
|
157
190
|
makes the [Tools Compass admin
|
|
158
191
|
pages](../../../1.0/apps/tools/features/compass-user-persona-admin.md) a one-way-durable tool.
|
|
159
192
|
- **`ensureCrossRegionUsersActive()` runs `UPDATE Users SET isActive = 1 WHERE email = X` with no
|
|
160
|
-
`isActive` filter**, so it reactivates **every** row carrying
|
|
161
|
-
|
|
162
|
-
resolves to exactly **one**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
admin](../../../1.0/apps/tools/features/compass-user-persona-admin.md) blocks
|
|
169
|
-
from being edited at all.
|
|
193
|
+
`isActive` filter**, so it reactivates **every** row carrying a protected address, dead duplicates
|
|
194
|
+
included. Verified on prod 2026-09-02 for the original four, and 2026-09-16 for the two CA-only
|
|
195
|
+
addresses: each resolves to exactly **one** row per database, so nothing is being resurrected
|
|
196
|
+
today — but the query would happily do it if a duplicate appeared. That hardcoded list is also
|
|
197
|
+
still the **only** durable way to hold a cross-region user active, and changing it needs a code
|
|
198
|
+
change and a deploy per request. **Recommended (not done): scope it to a `Users.id` list instead
|
|
199
|
+
of matching on email** — matching on email means an email edit silently moves a user in or out of
|
|
200
|
+
the protected set, which is why the [Tools Compass
|
|
201
|
+
admin](../../../1.0/apps/tools/features/compass-user-persona-admin.md) blocks the protected
|
|
202
|
+
addresses from being edited at all.
|
|
203
|
+
- **⚠ The Tools admin's edit block covers only the original four addresses, not the two CA-only
|
|
204
|
+
ones.** The protected set grew to six in worker2 on 2026-09-16; the `tools` block list was not
|
|
205
|
+
changed and was not re-checked. Until it is, someone can edit the email of a CA-protected user in
|
|
206
|
+
[Tools Compass admin](../../../1.0/apps/tools/features/compass-user-persona-admin.md) and silently
|
|
207
|
+
drop them out of the protected set — because the match is on email, not id.
|
|
208
|
+
- **⚠ Adding a new "always keep this person active" rule means touching exactly two places.**
|
|
209
|
+
`executeForTenant()`'s inactivation loop is the only `isActive = 0` writer in the whole class, and
|
|
210
|
+
`ensureCrossRegionUsersActive()` is the post-run sweep. Guard both and the requirement is covered;
|
|
211
|
+
guard only the loop and the post-run sweep still decides the final state, guard only the sweep and
|
|
212
|
+
the user flickers inactive between the two steps.
|
|
170
213
|
|
|
171
214
|
## Remediation — BUILT 2026-08-20
|
|
172
215
|
|
|
@@ -197,6 +240,24 @@ question is now contained rather than blocking.
|
|
|
197
240
|
|
|
198
241
|
## Change history
|
|
199
242
|
|
|
243
|
+
- 2026-09-16 — **Made the always-active protection tenant-aware.** Two Compass USA staff
|
|
244
|
+
(`@compassdigital.io`, usernames `shuttk01` / `racerk01`) exist in **both** tenants, but the
|
|
245
|
+
PEOPLE file lists them only as US rows (`c_erpSystemId` 1001), so they were routed to
|
|
246
|
+
`Client_Compass` alone and had no protection on the Canada side. Requirement: they may go inactive
|
|
247
|
+
in the USA per the file, but must **always** stay active in Canada. Split the previously
|
|
248
|
+
undocumented single hardcoded list into `ALWAYS_ACTIVE_EMAILS_BOTH` (the original four) and
|
|
249
|
+
`ALWAYS_ACTIVE_EMAILS_CA` (the two new ones), added
|
|
250
|
+
`isEmailAlwaysActive(string $email, string $tenantKey): bool` as the single lowercased decision
|
|
251
|
+
point, and wired it into **both** the `executeForTenant()` inactivation loop (skip protected
|
|
252
|
+
users) and `ensureCrossRegionUsersActive()` (both DBs for BOTH, `Client_CompassCanada` only for
|
|
253
|
+
CA). Prod read-only findings: in `Client_CompassCanada` both users have `c_hrEmpUsername = NULL`,
|
|
254
|
+
so they never enter `loadLookups()`'s username-keyed map and **cannot** reach the Canada
|
|
255
|
+
inactivation batch at all — the post-run sweep is what actually protects them today, and the loop
|
|
256
|
+
guard is defence for the day they get a Canada username. Also confirmed the two tenants store
|
|
257
|
+
these addresses in different case (US uppercase, CA lowercase) and the email match still works
|
|
258
|
+
because `Users.email` uses the case-insensitive `utf8mb4_0900_ai_ci` collation, one row per
|
|
259
|
+
address per database. Not committed or pushed — worker2 working tree only. (bala)
|
|
260
|
+
|
|
200
261
|
- 2026-09-04 — Prod-verified the **full index list** on `Users` in both tenants and recorded the
|
|
201
262
|
load-bearing consequence: **there is no `UNIQUE KEY` on `email`** (and no index on
|
|
202
263
|
`c_hrEmpUsername` / `c_hrEmpPersonnelNbr` / `isActive`), so the database silently accepts duplicate
|
package/package.json
CHANGED