toga-ai 1.0.807 → 1.0.809
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/2.0/apps/_underscore/features/acl-permission-chain.md +51 -1
- package/knowledge/2.0/apps/_underscore/features/cloud-s3-helpers.md +1 -0
- package/knowledge/2.0/apps/api2/INDEX.md +1 -0
- package/knowledge/2.0/apps/api2/features/api-key-authentication-and-scoping.md +113 -0
- package/knowledge/2.0/apps/api2/features/request-logging.md +17 -1
- package/knowledge/2.0/apps/worker2/INDEX.md +1 -0
- package/knowledge/2.0/apps/worker2/features/cross-account-aws-access.md +40 -1
- package/knowledge/2.0/apps/worker2/features/s3-folder-retention-cleanup.md +130 -0
- package/knowledge/INDEX.md +3 -3
- package/knowledge/clients/compass-usa/INDEX.md +1 -0
- package/knowledge/clients/compass-usa/profile.md +14 -1
- package/knowledge/clients/compass-usa/workflows/granting-api-access.md +142 -0
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: ["jcardinal", "mhammontree", "tcox", "bala", "apeterson", "rgirish"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -46,6 +46,14 @@ AclRecordPermissions → AclLogicGroups → AclLogicGroupExpressions →
|
|
|
46
46
|
`sqlExpression` (TEXT). Unique on `(recordId, slug)`. The conventional "always allow" row is
|
|
47
47
|
`slug = 'all'`, `sqlExpression = '1'`.
|
|
48
48
|
|
|
49
|
+
> **A half-built chain fails with a NAMED message — search for it before guessing.** `V2.php`
|
|
50
|
+
> tracks two flags as it walks a permission, `$aclLogicGroupsDefined` and
|
|
51
|
+
> `$aclLogicGroupExpressionsDefined` (set ~L3930 and ~L5614). When the first is false the request is
|
|
52
|
+
> rejected with the exact message **`No ACL Logic Groups defined for ACL Record Permissions`**
|
|
53
|
+
> (~L4437 / ~L5845); a missing expression binding fails on the second flag the same way. So an
|
|
54
|
+
> `AclRecordPermissions` row with no `AclLogicGroups` row returns UNAUTHORIZED carrying that string
|
|
55
|
+
> — which means the **chain**, not the grant, is the problem.
|
|
56
|
+
|
|
49
57
|
**Field visibility — `AclFieldPermissions`.** Record-level access alone does **not** expose
|
|
50
58
|
fields. Each field needs a row: `recordFieldId`, `roleId`, `isWritable` (0 = read-only, 1 =
|
|
51
59
|
writable). Without it the field is rejected on write **and** is unreadable:
|
|
@@ -314,6 +322,13 @@ Measured 2026-09-08: production `Core.Records` holds **328 rows, max id 358**; d
|
|
|
314
322
|
> divergence recorded as an open question in the
|
|
315
323
|
> [non-prod drift doc](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md), and it is why
|
|
316
324
|
> the hardcoded-literal rule above is scoped to **established** rows only.)
|
|
325
|
+
>
|
|
326
|
+
> **One client/pair can match completely, and that still is not portability.** Checked 2026-09-10
|
|
327
|
+
> for Compass: `Core.Records` ids, every `Core.RecordFields` id for the nine records in an ACL grant
|
|
328
|
+
> script, plus `Client_Compass.AclRecordExpressions` and `CustomRecordFields` ids, were **identical
|
|
329
|
+
> between production and client-sandbox**, so the prod-written script ran unchanged on
|
|
330
|
+
> client-sandbox. Treat that as a **per-client, per-environment-pair** measurement you re-take each
|
|
331
|
+
> time — never as licence to skip resolving ids to names.
|
|
317
332
|
|
|
318
333
|
**3. In PRODUCTION you cannot join `Core` and `Client_<Tenant>` in one statement — so the diff query
|
|
319
334
|
that works in sandbox breaks in prod.** Prod splits them across `prod-core` and `prod-client`; both
|
|
@@ -686,6 +701,31 @@ consequence, and the reason the "mirror a sibling field" practice works: **grant
|
|
|
686
701
|
`Base` also grants it to the worker.** Conversely, when auditing what an end customer can reach,
|
|
687
702
|
remember the reverse — a grant added "for the worker" on `Base` reaches customers too.
|
|
688
703
|
|
|
704
|
+
### ⚠ That union is `Apis_Roles` and NOTHING else — and `appId` does not scope an API key
|
|
705
|
+
|
|
706
|
+
Two facts verified in `api2/Component/Api/V2/V2.php` on 2026-09-10, both of which decide how you
|
|
707
|
+
scope a machine consumer:
|
|
708
|
+
|
|
709
|
+
1. **Roles are ASSIGNED, not merged (~L1639).** On API-key auth, `_Model_Client_Apis_Role` is
|
|
710
|
+
searched for the `apiId` and the result is assigned straight to `$id->client->roles` — the
|
|
711
|
+
`array_merge($id->client->roles, $clientRoles)` line immediately above it is **commented out**.
|
|
712
|
+
Core roles are then derived from those client roles' non-NULL `coreRoleId` values, so a new role
|
|
713
|
+
with `coreRoleId` NULL is perfectly fine for records whose `Core.Records.aclDatabase` is
|
|
714
|
+
`CLIENT`. A key's power is exactly its `Apis_Roles` rows.
|
|
715
|
+
2. **`appId` scoping is void for an API key (~L1518 / L1961 / L3201).** `$this->app` is only set
|
|
716
|
+
when the JWT carries an app uuid, i.e. the domain/user login flow. API-key auth never sets it,
|
|
717
|
+
and every permission filter starts `(!isset($this->app) || is_null($perm->appId) || …)` — so
|
|
718
|
+
**every `appId`-scoped `AclRecordPermissions` row applies to an API key**. A grant deliberately
|
|
719
|
+
narrowed to one app (e.g. `appId = 5`, TOGa Commerce) is fully reachable by any key holding that
|
|
720
|
+
role.
|
|
721
|
+
|
|
722
|
+
> **Consequence: the only way to limit a machine consumer is to give it its OWN role.** Wiring
|
|
723
|
+
> several `Apis` rows to the same shared roles means the key identifies the caller but constrains
|
|
724
|
+
> nothing. Worked example (first per-key role, Compass 2026-09-10):
|
|
725
|
+
> [Giving a Compass API consumer its own scoped key](../../../../clients/compass-usa/workflows/granting-api-access.md),
|
|
726
|
+
> and the auth/token mechanics in
|
|
727
|
+
> [API-Key Authentication](../../api2/features/api-key-authentication-and-scoping.md).
|
|
728
|
+
|
|
689
729
|
## Client-DB grant migrations are re-runnable NO-OPs, and that is the design
|
|
690
730
|
|
|
691
731
|
A `NOT EXISTS`-guarded `AclFieldPermissions` insert that finds its grants already present inserts
|
|
@@ -715,6 +755,16 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
|
|
|
715
755
|
and every repo is on the **same branch** so the generated model matches the DB.
|
|
716
756
|
|
|
717
757
|
## Change history
|
|
758
|
+
- 2026-09-14 — Added the **API-key scoping** rules, from building the first per-key Compass API role.
|
|
759
|
+
Three durable points, all verified in `V2.php`: a half-built chain fails with the exact message
|
|
760
|
+
**`No ACL Logic Groups defined for ACL Record Permissions`** (flags `$aclLogicGroupsDefined` /
|
|
761
|
+
`$aclLogicGroupExpressionsDefined`, ~L3930/L4437/L5614/L5845); an API key's roles are **assigned**
|
|
762
|
+
from `Apis_Roles` and merge with nothing (~L1639, the `array_merge` is commented out), with core
|
|
763
|
+
roles derived from `coreRoleId`; and **`appId` does not scope an API key** because `$this->app` is
|
|
764
|
+
only set on the domain/user login flow (~L1518/L1961), so every `appId`-scoped grant applies — the
|
|
765
|
+
only real lever is a dedicated role per key. Also recorded that Compass's `Core.Records` /
|
|
766
|
+
`RecordFields` / `AclRecordExpressions` / `CustomRecordFields` ids matched prod and client-sandbox
|
|
767
|
+
exactly, as a per-client measurement and not portability. No code changed. (bala)
|
|
718
768
|
- 2026-09-08 — Added the **cross-environment ACL comparison** rules, from a read-only Adyen
|
|
719
769
|
"works in sandbox, not in prod" investigation. Three durable points: **pin the environment from
|
|
720
770
|
the JWT first** (a shared API domain resolves its tenant from the token, so `api.beta.togahub.com`
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
| Doc | Summary |
|
|
4
4
|
|-----|---------|
|
|
5
5
|
| [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. |
|
|
6
|
+
| [API-Key Authentication (/v2/auth/api) and what a key can actually reach](features/api-key-authentication-and-scoping.md) | A machine caller authenticates with `POST /v2/auth/api` using a `Client_<Client>.Apis` row and gets a bearer token. |
|
|
6
7
|
| [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. |
|
|
7
8
|
| [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli |
|
|
8
9
|
| [cXML ShipNotice Gateway (ASN ingestion, carrier resolution, per-client provisioning)](features/cxml-shipnotice-gateway.md) | `_Component_Api_Cxml` accepts a supplier `ShipNoticeRequest` and translates it into a `POST /v2/advance-shipping-notices` on the V2 JSON engine. |
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: API-Key Authentication (/v2/auth/api) and what a key can actually reach
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: api2
|
|
5
|
+
project: API
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-14
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- api2/Component/Api/V2/V2.php
|
|
13
|
+
related:
|
|
14
|
+
- ../architecture.md
|
|
15
|
+
- ./request-logging.md
|
|
16
|
+
- ../../_underscore/features/acl-permission-chain.md
|
|
17
|
+
- ../../../../clients/compass-usa/workflows/granting-api-access.md
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Summary
|
|
21
|
+
|
|
22
|
+
A machine caller authenticates with `POST /v2/auth/api` using a `Client_<Client>.Apis` row and gets
|
|
23
|
+
a bearer token. **The key's power is exactly the union of its `Apis_Roles` rows and nothing else.**
|
|
24
|
+
Two consequences that surprise people: roles are **assigned, not merged**, so an API key never picks
|
|
25
|
+
up any other role; and `appId` scoping on an ACL grant **does not constrain an API key at all**,
|
|
26
|
+
because API-key auth never sets `$this->app`. If you want one consumer limited to a few reads, the
|
|
27
|
+
only working lever is giving that key **its own role** (see
|
|
28
|
+
[granting Compass API access](../../../../clients/compass-usa/workflows/granting-api-access.md) for
|
|
29
|
+
a worked example).
|
|
30
|
+
|
|
31
|
+
## The authentication call
|
|
32
|
+
|
|
33
|
+
- **Route:** `POST {base}/auth/api`. The public caller base is `https://api.togahub.com/v2`.
|
|
34
|
+
- **`transactionId` goes in the QUERY STRING**, not the body, and must be globally unique
|
|
35
|
+
(reuse → `EV-5`).
|
|
36
|
+
- **Body is exactly three fields:**
|
|
37
|
+
- `client` — the `Core.Clients` uuid
|
|
38
|
+
- `api` — the `Client_<Client>.Apis` row uuid
|
|
39
|
+
- `secret` — that row's secret
|
|
40
|
+
- **Response:** `data.tokens.access` and `data.tokens.refresh`, with
|
|
41
|
+
`expiresIn.access = 3600` (1 hour) and `expiresIn.refresh = 2592000` (30 days). Subsequent calls
|
|
42
|
+
carry `Authorization: Bearer <access>`.
|
|
43
|
+
|
|
44
|
+
> **⚠ Do not copy the `authority` field into caller documentation.** The response envelope's
|
|
45
|
+
> `authority` reports the **internal** server that handled the request (observed: `api1.togahub.com`,
|
|
46
|
+
> `api-writer.togahub.com`). Callers must always use `https://api.togahub.com/v2`.
|
|
47
|
+
|
|
48
|
+
### `maxAuthsPerHour` throttles authentication, not traffic
|
|
49
|
+
|
|
50
|
+
`Apis.maxAuthsPerHour` (client keys are typically `255`) is enforced at ~L1131 of `V2.php` by
|
|
51
|
+
counting `Logs_<Client>.Api` rows with `isAuthRequest = 1` in the last hour. Over the limit returns
|
|
52
|
+
a message telling the caller to re-use tokens. Tell integrators to authenticate once, cache the
|
|
53
|
+
access token for the hour, and use the refresh token — not to call `/auth/api` per request.
|
|
54
|
+
|
|
55
|
+
## How an API key's authorization is decided
|
|
56
|
+
|
|
57
|
+
At `V2.php` ~L1639, once the `Apis` row authenticates:
|
|
58
|
+
|
|
59
|
+
1. `_Model_Client_Apis_Role` is searched for that `apiId`.
|
|
60
|
+
2. Those `roleId`s are **assigned** to `$id->client->roles` — the `array_merge(...)` line directly
|
|
61
|
+
above it is **commented out**. So the key holds those roles and only those roles.
|
|
62
|
+
3. Core roles are then derived by selecting `Client_<Client>.Roles.coreRoleId` for those client
|
|
63
|
+
roles, skipping NULLs.
|
|
64
|
+
|
|
65
|
+
**A new client role with `coreRoleId` NULL is fine** for any record whose `Core.Records.aclDatabase`
|
|
66
|
+
is `CLIENT`. Core roles only matter for records flagged `CORE` (the permission scan at ~L3159+).
|
|
67
|
+
|
|
68
|
+
### ⚠ `appId` cannot scope an API key — every appId-scoped grant applies
|
|
69
|
+
|
|
70
|
+
`$this->app` is only set when the JWT carries an app uuid (`V2.php` L1518 / L1961), which is the
|
|
71
|
+
**domain/user login** flow. On API-key auth it is never set, and every permission filter reads:
|
|
72
|
+
|
|
73
|
+
```php
|
|
74
|
+
(!isset($this->app) || is_null($perm->appId) || $perm->appId == $this->app->id)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
With `$this->app` unset the first clause short-circuits **true**, so **all** `appId`-scoped
|
|
78
|
+
`AclRecordPermissions` rows for the key's roles apply. A grant that was deliberately narrowed to one
|
|
79
|
+
app (e.g. `appId = 5`, TOGa Commerce) is fully available to any API key holding that role. Scope a
|
|
80
|
+
machine consumer with a **dedicated role**, never with `appId`.
|
|
81
|
+
|
|
82
|
+
### The grant chain still applies
|
|
83
|
+
|
|
84
|
+
An `AclRecordPermissions` row on its own grants nothing — the request fails with
|
|
85
|
+
`No ACL Logic Groups defined for ACL Record Permissions`. Full rules, tables and checklist:
|
|
86
|
+
[ACL Permission Chain](../../_underscore/features/acl-permission-chain.md).
|
|
87
|
+
|
|
88
|
+
## Auditing what a key actually did
|
|
89
|
+
|
|
90
|
+
`Logs_<Client>.Api` carries `apiId`, `dtStamp`, `method`, `route`, `responseCode`, `queryString`,
|
|
91
|
+
`requestPayload` and `responsePayload`. **Filter on `apiId`** to get one consumer's exact calls and
|
|
92
|
+
responses — this is how you answer "what did this integrator really use". Note the client-vs-core
|
|
93
|
+
log split (failed writes and pre-scope auth failures land in core `Logs`):
|
|
94
|
+
[V2 Request Logging](./request-logging.md).
|
|
95
|
+
|
|
96
|
+
## Gotchas
|
|
97
|
+
|
|
98
|
+
- **Roles are replaced, not merged.** Auditing a key means reading `Apis_Roles` and nothing else.
|
|
99
|
+
- **`appId` is not a security boundary for machine callers.** See above.
|
|
100
|
+
- **A shared role across keys means the key identifies the caller but does not limit it.** If
|
|
101
|
+
several `Apis` rows point at the same roles, every one of them can do everything those roles
|
|
102
|
+
allow, regardless of what the integration's published documentation says.
|
|
103
|
+
- **Never record a secret value in a doc or a committed SQL file.** Document only where it lives
|
|
104
|
+
(the `Client_<Client>.Apis` row) and hand it over out of band.
|
|
105
|
+
|
|
106
|
+
## Change history
|
|
107
|
+
- 2026-09-14 — Created from the Compass Refresh-team API build. Recorded the `/auth/api` call shape
|
|
108
|
+
(`transactionId` in the query string; body `{client, api, secret}`), the 1 hour / 30 day token
|
|
109
|
+
lifetimes, the `maxAuthsPerHour` auth-only throttle, and two authorization facts verified in
|
|
110
|
+
`V2.php`: API-key roles are **assigned** from `Apis_Roles` (the `array_merge` is commented out,
|
|
111
|
+
~L1639), and `$this->app` is never set for key auth so **every `appId`-scoped ACL row applies**.
|
|
112
|
+
Also noted the `authority` response field exposes the internal host and must not be published to
|
|
113
|
+
callers. (bala)
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: ["mhammontree", "dfranks", "bala", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -15,6 +15,7 @@ files:
|
|
|
15
15
|
- _underscore/Model/Core/Logs/Api.php
|
|
16
16
|
related:
|
|
17
17
|
- ../architecture.md
|
|
18
|
+
- ./api-key-authentication-and-scoping.md
|
|
18
19
|
- ./api-payload-interceptors.md
|
|
19
20
|
- ./v2-api-error-codes.md
|
|
20
21
|
- ./v2-deadlock-retry.md
|
|
@@ -362,6 +363,16 @@ fact AIG **does** post live entitlements — the writes were **failing (HTTP 500
|
|
|
362
363
|
core `Logs`**, not `Logs_Aig`. See
|
|
363
364
|
[AIG entitlement intake → Intake is a live API feed](../../../../clients/aig/features/entitlement-intake.md).
|
|
364
365
|
|
|
366
|
+
### Auditing ONE API consumer — filter `Logs_<Client>.Api` on `apiId`
|
|
367
|
+
|
|
368
|
+
`Logs_<Client>.Api` carries `apiId` alongside `dtStamp`, `method`, `route`, `responseCode`,
|
|
369
|
+
`queryString`, `requestPayload` and `responsePayload`. Filtering on a single `apiId` is how you
|
|
370
|
+
answer "what did this integrator actually call, and what did it get back" — it was used to verify
|
|
371
|
+
the new Compass Refresh key's routes and to lift real sample responses for its reference document.
|
|
372
|
+
`isAuthRequest = 1` rows are that key's `/auth/api` calls, and are what `Apis.maxAuthsPerHour`
|
|
373
|
+
counts. The blind spots above still apply — the key's **failed writes land in core `Logs`**. See
|
|
374
|
+
[API-Key Authentication](./api-key-authentication-and-scoping.md).
|
|
375
|
+
|
|
365
376
|
## Querying prod `Logs.Api` without timing out (narrow first, fetch payloads last)
|
|
366
377
|
|
|
367
378
|
Prod `Logs.Api` is large enough that **any `LIKE` over `requestPayload` / `responsePayload`
|
|
@@ -426,6 +437,11 @@ re-send.
|
|
|
426
437
|
> replay operationally, not by pasting payloads into the KB.
|
|
427
438
|
|
|
428
439
|
## Change history
|
|
440
|
+
- 2026-09-14 — Added the per-consumer audit note: filter `Logs_<Client>.Api` on **`apiId`** to get
|
|
441
|
+
one API key's exact routes, payloads and response codes (`isAuthRequest = 1` rows are its
|
|
442
|
+
`/auth/api` calls, the ones `Apis.maxAuthsPerHour` counts). Used to verify the new Compass Refresh
|
|
443
|
+
key. Cross-linked the new
|
|
444
|
+
[API-Key Authentication](./api-key-authentication-and-scoping.md) doc. (bala)
|
|
429
445
|
- 2026-09-10 — Sharpened the client-vs-core routing on the auth codes: a **401 always lands in the
|
|
430
446
|
base `Logs`** (decided in the auth chain ~L2088 before routing, client unresolved) while a **403
|
|
431
447
|
lands in `Logs_<Client>`** (client already known) — verified on Prudential 31 Aug–1 Sep, where
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
| [Platform Cache Cleanup (_Worker_Platform_Cache — Clean + Truncate)](features/platform-cache-cleanup.md) | `_Worker_Platform_Cache` owns maintenance of the shared **Cache** cluster that backs api2's [multi-client data retrieval](../../api2/features/cross-client-data- |
|
|
47
47
|
| [QA/QC Branch Automation (auto-mirror + rebuild-from-ledger revert)](features/qa-qc-branch-automation.md) | Two CTO-approved (AGREE) pieces of GitHub branch automation, centralized in **worker2** rather than per-repo GitHub Actions. |
|
|
48
48
|
| [QA/QC Review Pipeline (ClickUp review chain, QC batch + defect/client-review)](features/qa-qc-review-pipeline.md) | The ClickUp-driven review + promotion chain for the platform-wide QA/QC dev process (per the approved `QA-QC Plan.txt`), moving a task through **Development → Q |
|
|
49
|
+
| [S3 folder retention cleanup worker action (CleanS3Folder)](features/s3-folder-retention-cleanup.md) | `_Worker_Infrastructure_Aws_Cleanup::CleanS3Folder` (action path `Infrastructure/Aws/Cleanup/CleanS3Folder`) deletes S3 objects under **one folder** (key prefix |
|
|
49
50
|
| [Service Request → Sales Order → Purchase Order generation (Sync/ServiceRequest)](features/service-request-sales-order-generation.md) | `_Worker_Sync_ServiceRequest` turns a **Service Request into a Sales Order, and then into one Purchase Order per vendor**, for **any** tenant. |
|
|
50
51
|
| [SSO Stability Monitor (Monitor/Operations/SsoStability)](features/sso-stability-monitor.md) | `_Worker_Monitor_Operations::SsoStability(): string` is the **reporter half** of the SAML uptime monitor. |
|
|
51
52
|
| [Startech Webhook Handler (worker2)](features/startech-webhook-handler.md) | Receives inbound webhook events from Startech (Easeedesk) and creates or updates the corresponding ticket in TOGA 2.0. |
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: [jcardinal, bala]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Component/Aws/Workloads/Workloads.php
|
|
@@ -171,8 +171,40 @@ that account is the
|
|
|
171
171
|
`agilant-worker-alpha` and `agilant-worker-beta` as **separate EB environments that have no role
|
|
172
172
|
registry at all**. An account-level EC2 scan would sweep them in and misreport them.
|
|
173
173
|
|
|
174
|
+
### `agilant-as2` is in the LEGACY account 502614707982 — S3 work on it is cross-account
|
|
175
|
+
|
|
176
|
+
The `agilant-as2` bucket (the Office Depot AS2/EDI drop bucket, `us-west-2`) is **not** in the
|
|
177
|
+
868 production hub where worker2 runs. Verified via `aws s3api list-buckets` per profile: only
|
|
178
|
+
account **502614707982** lists it. So any S3 work on `agilant-as2` is **cross-account**.
|
|
179
|
+
|
|
180
|
+
**The fix (same model as this component):** pass the bucket's **owner** account id as
|
|
181
|
+
`awsAccountId` so the worker assumes `WorkloadsRuntime` **in that account** and touches the
|
|
182
|
+
bucket **same-account**. Then an identity policy on the owner account's `WorkloadsRuntime`
|
|
183
|
+
role is enough — **no bucket policy is needed**. Every `WorkloadsRuntime` trusts the 868
|
|
184
|
+
instance role, so assuming into 502 from 654 works.
|
|
185
|
+
|
|
186
|
+
**Diagnostic tell — the AccessDenied wording tells you the account.** The SDK error
|
|
187
|
+
`AccessDenied ... is not authorized to perform: s3:ListBucket ... because no resource-based
|
|
188
|
+
policy allows the s3:ListBucket action` is AWS's **cross-account** signal. A same-account
|
|
189
|
+
identity-policy gap instead says *"no identity-based policy"*. Seeing **"no resource-based
|
|
190
|
+
policy"** means the resource lives in another account — assume into the owner account.
|
|
191
|
+
|
|
192
|
+
**IAM ARN gotcha (bucket-level vs object-level).** `s3:ListBucket` and
|
|
193
|
+
`s3:GetBucketLocation` are **bucket-level** → the `Resource` must be the **bucket** ARN
|
|
194
|
+
`arn:aws:s3:::agilant-as2` with **no** `/*`. `s3:DeleteObject` is **object-level** → the
|
|
195
|
+
`Resource` must end in `/*` (scope it, e.g. `arn:aws:s3:::agilant-as2/OUTBOX/processed/*`).
|
|
196
|
+
Putting `ListBucket` on the `/*` ARN is the classic mistake and returns exactly this
|
|
197
|
+
AccessDenied.
|
|
198
|
+
|
|
199
|
+
First S3 consumer of this pattern: the
|
|
200
|
+
[S3 folder retention cleanup action](./s3-folder-retention-cleanup.md).
|
|
201
|
+
|
|
174
202
|
## Gotchas / known issues
|
|
175
203
|
|
|
204
|
+
- **`agilant-as2` is cross-account (owner 502614707982), not in the 868 hub.** Assume into the
|
|
205
|
+
owner account; the `AccessDenied ... no resource-based policy` wording is the cross-account
|
|
206
|
+
tell, and `ListBucket`/`GetBucketLocation` need the bare bucket ARN while `DeleteObject`
|
|
207
|
+
needs the `/*` ARN. See [the section above](#agilant-as2-is-in-the-legacy-account-502614707982--s3-work-on-it-is-cross-account).
|
|
176
208
|
- **Do not assume the 1.0 worker fleet is in the 868 production hub.** `AWS_WORKER_QUEUE_URL`
|
|
177
209
|
(654654170868) is the SQS queue only; the instances are in the legacy account 502614707982,
|
|
178
210
|
`us-west-2`. See [the section above](#which-account-the-10-worker-fleet-lives-in-easy-and-expensive-to-get-wrong).
|
|
@@ -191,6 +223,13 @@ registry at all**. An account-level EC2 scan would sweep them in and misreport t
|
|
|
191
223
|
see [creating-worker-actions.md](./creating-worker-actions.md).
|
|
192
224
|
|
|
193
225
|
## Change history
|
|
226
|
+
- 2026-09-14 — Recorded that **`agilant-as2` is owned by the legacy account 502614707982**
|
|
227
|
+
(`us-west-2`), not the 868 production hub — so S3 work on it is cross-account: pass the
|
|
228
|
+
owner account id as `awsAccountId` and no bucket policy is needed. Added the diagnostic tell
|
|
229
|
+
(`AccessDenied ... no resource-based policy` = cross-account; *no identity-based policy* =
|
|
230
|
+
same-account) and the IAM ARN gotcha (`ListBucket`/`GetBucketLocation` need the bare bucket
|
|
231
|
+
ARN, `DeleteObject` needs the `/*` ARN). Found while building the
|
|
232
|
+
[S3 folder retention cleanup action](./s3-folder-retention-cleanup.md). (jcardinal)
|
|
194
233
|
- 2026-08-24 — Pinned down **which account the 1.0 `worker` fleet actually runs in**:
|
|
195
234
|
**502614707982** (`us-west-2`, EB env `agilant-worker`), **not** the 654654170868 production hub
|
|
196
235
|
that `App_Worker::AWS_WORKER_QUEUE_URL` implies — that account holds only the SQS queue the 1.0
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: S3 folder retention cleanup worker action (CleanS3Folder)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: worker2
|
|
5
|
+
project: Worker
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-14
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- worker2/Worker/Infrastructure/Aws/Cleanup.php
|
|
13
|
+
- dbchanges2/Core/2026-09-14a - Insert S3 Folder Cleanup CronJob.sql
|
|
14
|
+
related:
|
|
15
|
+
- ./creating-worker-actions.md
|
|
16
|
+
- ./cross-account-aws-access.md
|
|
17
|
+
- ../../_underscore/features/cloud-s3-helpers.md
|
|
18
|
+
- ./platform-cache-cleanup.md
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Summary
|
|
22
|
+
|
|
23
|
+
`_Worker_Infrastructure_Aws_Cleanup::CleanS3Folder` (action path
|
|
24
|
+
`Infrastructure/Aws/Cleanup/CleanS3Folder`) deletes S3 objects under **one folder** (key
|
|
25
|
+
prefix) that are older than a retention window. **Both the folder and the days-to-keep are
|
|
26
|
+
cron parameters**, so one action + one `Core.CronJobs` row cleans any folder with **no code
|
|
27
|
+
change**. First use case: keep 30 days of `s3://agilant-as2/OUTBOX/processed/`.
|
|
28
|
+
|
|
29
|
+
> This is the **S3** folder cleanup. It is separate from the DB "Cleanup cron" — see
|
|
30
|
+
> [platform-cache-cleanup](./platform-cache-cleanup.md) for that one.
|
|
31
|
+
|
|
32
|
+
## Key files / entry points
|
|
33
|
+
|
|
34
|
+
- `worker2/Worker/Infrastructure/Aws/Cleanup.php` — `_Worker_Infrastructure_Aws_Cleanup::CleanS3Folder`.
|
|
35
|
+
- `dbchanges2/Core/2026-09-14a - Insert S3 Folder Cleanup CronJob.sql` — the seed
|
|
36
|
+
`Core.CronJobs` row (seed rows live in dbchanges2, **never** a `.sql` in worker2).
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
1. Parses an `s3://bucket/prefix/` URI into bucket + prefix.
|
|
41
|
+
2. Assumes credentials via
|
|
42
|
+
`_Component_Aws_Workloads::assumeCredentials($awsAccountId)` and builds **one**
|
|
43
|
+
`Aws\S3\S3Client` reused for both list and delete. It uses **no static keys** and does
|
|
44
|
+
**not** use the `_Cloud` static-key helpers — this is a role-based cross-account path (see
|
|
45
|
+
[cross-account-aws-access](./cross-account-aws-access.md)).
|
|
46
|
+
3. Paginates `listObjectsV2`; keeps only keys whose `LastModified` is older than
|
|
47
|
+
`now - daysToKeep`.
|
|
48
|
+
4. Batch-deletes via `deleteObjects` (max 1000 keys per call).
|
|
49
|
+
5. Returns a JSON summary on **every** path: `bucket`, `prefix`, `region`, `daysToKeep`,
|
|
50
|
+
`cutoffUtc`, `dryRun`, `scanned`, `eligible`, `deleted`, `failed`, `failedSample`,
|
|
51
|
+
`complete` — a proof-of-work return, per the
|
|
52
|
+
[creating-worker-actions](./creating-worker-actions.md) null-return rule.
|
|
53
|
+
|
|
54
|
+
### Self-chunking — carry the S3 list position, not a page number
|
|
55
|
+
|
|
56
|
+
A non-scheduler action is hard-capped at **300s** (the watchdog only honours
|
|
57
|
+
`CronJobs.maxExecutionTime` for scheduler-fired jobs, and it does not stop the process — see
|
|
58
|
+
[creating-worker-actions](./creating-worker-actions.md#-the-real-time-budget-is-300s--and-for-a-hand-triggered-action-it-cannot-be-raised)).
|
|
59
|
+
The action works a ~240s slice, then re-enqueues itself with `_Worker::runTask(self::ACTION, …)`.
|
|
60
|
+
|
|
61
|
+
The cursor it carries in the `resume` param is the **S3 `continuationToken`**, not a page
|
|
62
|
+
counter:
|
|
63
|
+
|
|
64
|
+
- **Why a keyset-style cursor is required:** S3 lists keys in **name order, not date order**.
|
|
65
|
+
A chunk can burn its whole 240s budget paging through recent keys before it reaches any old
|
|
66
|
+
object, and delete nothing that pass. Re-listing from page 1 each chunk (an earlier draft
|
|
67
|
+
did this) then **stalls the sweep forever with no alert** — every chunk re-scans the same
|
|
68
|
+
front of the list. Carrying the `continuationToken` guarantees the sweep advances through
|
|
69
|
+
the whole prefix.
|
|
70
|
+
- **Backstop:** `MAX_CHUNK_ATTEMPTS = 100`.
|
|
71
|
+
- **Single-attempt hand-off:** `runTask` mints a fresh uuid per call, so a *retried* hand-off
|
|
72
|
+
would fork the chain — do not retry it (same rule as
|
|
73
|
+
[creating-worker-actions](./creating-worker-actions.md#do-not-retry-the-chunk-hand-off--a-failed-hand-off-must-break-the-chain)).
|
|
74
|
+
- **Normalize `resume` with a full round trip** — `json_encode` then `json_decode(true)` —
|
|
75
|
+
because the dispatcher casts only the top level of parameters, so a nested object arrives as
|
|
76
|
+
`stdClass`.
|
|
77
|
+
|
|
78
|
+
### Destructive-delete safety guards (reviewed by CTO + cso)
|
|
79
|
+
|
|
80
|
+
This deletes production S3, so the action refuses to run unless the target is provably safe:
|
|
81
|
+
|
|
82
|
+
- Refuses a missing `s3://` scheme.
|
|
83
|
+
- Refuses an **empty prefix** (never a whole-bucket sweep).
|
|
84
|
+
- Forces the prefix to end in `/`.
|
|
85
|
+
- Refuses a prefix shallower than `MIN_PREFIX_DEPTH = 2` folder levels — so a mistyped row
|
|
86
|
+
cannot hit a top-level or a live feed like `OfficeDepot/`.
|
|
87
|
+
- `daysToKeep` must be `1..3650`.
|
|
88
|
+
- Skips the folder-placeholder key.
|
|
89
|
+
- Validates `awsAccountId` against a **3-account allowlist** (`654654170868` prod,
|
|
90
|
+
`502614707982` legacy, `975050298201` non-prod) **before** assume-role, because
|
|
91
|
+
WorkloadsRuntime reaches all three with broad permissions.
|
|
92
|
+
- Logs each failed `deleteObjects` key — `Errors[]` returns HTTP 200 and does **not** throw on
|
|
93
|
+
per-key failure — and carries a capped `failedSample`.
|
|
94
|
+
|
|
95
|
+
## Scheduling
|
|
96
|
+
|
|
97
|
+
The seed `Core.CronJobs` row is
|
|
98
|
+
`dbchanges2/Core/2026-09-14a - Insert S3 Folder Cleanup CronJob.sql`:
|
|
99
|
+
|
|
100
|
+
- action `Infrastructure/Aws/Cleanup/CleanS3Folder`
|
|
101
|
+
- schedule `0 2 * * *` (daily, Central), `maxExecutionTime` 300
|
|
102
|
+
- parameters
|
|
103
|
+
`{"folder":"s3://agilant-as2/OUTBOX/processed/","daysToKeep":30,"awsAccountId":"502614707982","awsRegion":"us-west-2"}`
|
|
104
|
+
- guarded `INSERT ... SELECT ... FROM DUAL WHERE NOT EXISTS`, fresh random v4 uuid.
|
|
105
|
+
|
|
106
|
+
`awsAccountId` is the **legacy** account `502614707982` because `agilant-as2` lives there — see
|
|
107
|
+
[cross-account-aws-access](./cross-account-aws-access.md). Ship the worker2 code deploy and
|
|
108
|
+
this dbchanges2 migration **together**.
|
|
109
|
+
|
|
110
|
+
## Client variations
|
|
111
|
+
|
|
112
|
+
None — shared infrastructure, not client-specific.
|
|
113
|
+
|
|
114
|
+
## Gotchas / known issues
|
|
115
|
+
|
|
116
|
+
- **S3 lists by name, not date** — the whole reason the cursor must be a `continuationToken`
|
|
117
|
+
and not a page number (see self-chunking above).
|
|
118
|
+
- `deleteObjects` returns HTTP 200 with per-key `Errors[]` — it does not throw on a partial
|
|
119
|
+
failure, so you must read `Errors[]` yourself.
|
|
120
|
+
- `agilant-as2` is in **us-west-2** and is owned by a **different AWS account** — pass the
|
|
121
|
+
right `awsRegion` and `awsAccountId` (see related docs).
|
|
122
|
+
|
|
123
|
+
## Change history
|
|
124
|
+
- 2026-09-14 — Created. Added the generic `CleanS3Folder` action (folder + days-to-keep are
|
|
125
|
+
cron params, one row per folder, no code change), its self-chunking sweep carrying the S3
|
|
126
|
+
`continuationToken` (S3 lists by name not date, so a page-number cursor stalls the sweep),
|
|
127
|
+
and the destructive-delete guards (allowlist accounts, min prefix depth, prefix must end in
|
|
128
|
+
`/`, never whole-bucket). Seed cron row in dbchanges2 `Core/2026-09-14a`. (jcardinal)
|
|
129
|
+
</content>
|
|
130
|
+
</invoke>
|
package/knowledge/INDEX.md
CHANGED
|
@@ -19,9 +19,9 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
19
19
|
## 2.0 framework
|
|
20
20
|
|
|
21
21
|
- **_underscore** (_Underscore) _(framework core)_ — 83 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
22
|
-
- **worker2** (Worker) —
|
|
23
|
-
- **api2** (API) —
|
|
24
|
-
- **dbchanges2** (Database Changes) _(framework core)_ —
|
|
22
|
+
- **worker2** (Worker) — 69 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
23
|
+
- **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
24
|
+
- **dbchanges2** (Database Changes) _(framework core)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
25
25
|
- **toga2-supply** (TOGa Supply) — 9 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
|
|
26
26
|
- **saml** (SAML SSO Gateway) — 6 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
|
|
27
27
|
- **toga2-view** (TOGa View Frontend) — 12 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
| [Compass USA — reversing the 199-item catalog fold (MITS outage recovery)](workflows/catalog-fold-reversal.md) | 2.0 | The **root cause** of the Compass MITS transmission outage (the `assetType`-NULL and duplicate-vendor symptoms) was an ad-hoc cross-client **duplicate-items mer |
|
|
28
28
|
| [Changing what is IN a Compass kit (adding / swapping a BundleItems line)](workflows/changing-a-kit-line-item.md) | 2.0 | "Add this fee/component to every kit that has X" is a recurring Compass request, and it looks like one `INSERT` into `Client_Compass.BundleItems`. |
|
|
29
29
|
| [Compass Cross-Kit Bundle Corruption — Detection & Repair](workflows/cross-kit-bundle-corruption.md) | 2.0 | A frontend regression in `toga2-commerce`'s edit-order bundle submission mis-attributed bundle (kit) line items and **fees/warranties** to the **wrong kit**, pe |
|
|
30
|
+
| [Giving a Compass API consumer its own scoped key (one role per key)](workflows/granting-api-access.md) | 2.0 | "Give team X API access" on Compass used to mean handing out another key wired to the same two shared roles. |
|
|
30
31
|
| [Granting a Compass user the same toga25-supply navigation as another user (role vs. SurfaceOverride)](workflows/granting-navigation-access.md) | 2.0 | "Give user X the same menu items user Y has" is the **navigation analog** of the recurring [bundle/persona grant](./granting-persona-bundle-access.md) request. |
|
|
31
32
|
| [Granting a Compass user access to a bundle (persona grant) and the SuperUser role](workflows/granting-persona-bundle-access.md) | 2.0 | "Give user X sight of kit N" is a **recurring** Compass request, usually paired with "and make them a super user". |
|
|
32
33
|
| [Compass Office Depot Duplicate PO-Line Cleanup (dual-catalog SKU)](workflows/odp-duplicate-po-line-cleanup.md) | 1.0 | The NetSuite fulfillment-sales-order importer duplicated Office Depot purchase-order lines on Compass USA because it reconciled the fulfillment SO against the e |
|
|
@@ -20,7 +20,7 @@ project: _Underscore
|
|
|
20
20
|
client: compass-usa
|
|
21
21
|
type: profile
|
|
22
22
|
status: active
|
|
23
|
-
updated: 2026-09-
|
|
23
|
+
updated: 2026-09-14
|
|
24
24
|
owners: [jcardinal, bala, tcox, apeterson, dfranks, akhokhani, ajean]
|
|
25
25
|
files: []
|
|
26
26
|
related:
|
|
@@ -32,6 +32,7 @@ related:
|
|
|
32
32
|
- workflows/granting-persona-bundle-access.md
|
|
33
33
|
- workflows/changing-a-kit-line-item.md
|
|
34
34
|
- workflows/granting-navigation-access.md
|
|
35
|
+
- workflows/granting-api-access.md
|
|
35
36
|
- features/mits-sales-order-transmission-alerting.md
|
|
36
37
|
- features/oneuptime-ma-refresh-order-monitor.md
|
|
37
38
|
- workflows/catalog-fold-reversal.md
|
|
@@ -206,6 +207,18 @@ separate, related client (see its own profile).
|
|
|
206
207
|
the ⚠ rule that **deactivation is raw bulk SQL, so no model hook ever fires on it**.
|
|
207
208
|
|
|
208
209
|
## Notes
|
|
210
|
+
- **⚠ Compass API keys: four of the five are NOT scoped (2026-09-10).** Agilant, MITS Service Hub,
|
|
211
|
+
Compass Group and Office Depot (Cxml) all share the same two roles — Base (1) and API (3) — so each
|
|
212
|
+
of them can create, update and delete across **291 records**, whatever its published integration
|
|
213
|
+
document says (MITS's lists 5 read lookups). `appId` cannot fix this: an API key never gets
|
|
214
|
+
`$this->app` set, so every appId-scoped grant applies to it. The **'Compass Refresh Team'** key
|
|
215
|
+
(prod `Apis.id 5`) is the first with its **own read-only role** ('Refresh API', prod roleId 17,
|
|
216
|
+
9 records / 0 writable) and is the template for the next consumer:
|
|
217
|
+
[Giving a Compass API consumer its own scoped key](workflows/granting-api-access.md).
|
|
218
|
+
**Open:** the generated secret is still in plain text inside
|
|
219
|
+
`dbchanges2/Client_Compass/2026-09-10 - RefreshTeamApiAccess.sql` (strip or rotate it), the
|
|
220
|
+
negative test was never run, the script was applied straight to prod, and the four older keys are
|
|
221
|
+
not migrated.
|
|
209
222
|
- **Item modal "Restrict to Persona" needed a Surface opt-in that was never written (2026-09-04).**
|
|
210
223
|
Core seeds the item modal's More-Info `restrictToPersona` field **OFF** for every client
|
|
211
224
|
(surface 19 / element 57), expecting a per-client override — Compass USA had **none**, so the
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Giving a Compass API consumer its own scoped key (one role per key)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: dbchanges2
|
|
5
|
+
project: Database Changes
|
|
6
|
+
client: compass-usa
|
|
7
|
+
type: workflow
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-14
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- dbchanges2/Client_Compass/2026-09-10 - RefreshTeamApiAccess.sql
|
|
13
|
+
related:
|
|
14
|
+
- ../profile.md
|
|
15
|
+
- ./granting-navigation-access.md
|
|
16
|
+
- ../../../2.0/apps/api2/features/api-key-authentication-and-scoping.md
|
|
17
|
+
- ../../../2.0/apps/_underscore/features/acl-permission-chain.md
|
|
18
|
+
- ../../../2.0/apps/api2/features/request-logging.md
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Summary
|
|
22
|
+
|
|
23
|
+
"Give team X API access" on Compass used to mean handing out another key wired to the same two
|
|
24
|
+
shared roles. **Every pre-existing Compass key can read and write almost everything**, so the key
|
|
25
|
+
identified the caller but did not limit it. The Refresh-team key (built 2026-09-10) is the first
|
|
26
|
+
Compass key with **one dedicated, read-only role of its own**, and its migration
|
|
27
|
+
`dbchanges2/Client_Compass/2026-09-10 - RefreshTeamApiAccess.sql` is the template for the next
|
|
28
|
+
consumer: copy it and change the record list.
|
|
29
|
+
|
|
30
|
+
Why a dedicated role is the only lever: an API key's roles come **only** from `Apis_Roles`, and
|
|
31
|
+
`appId` scoping does not bind a key at all — see
|
|
32
|
+
[API-Key Authentication](../../../2.0/apps/api2/features/api-key-authentication-and-scoping.md).
|
|
33
|
+
|
|
34
|
+
## ⚠ The four older Compass keys are NOT scoped
|
|
35
|
+
|
|
36
|
+
All four pre-existing `Client_Compass.Apis` rows — **Agilant**, **MITS Service Hub**,
|
|
37
|
+
**Compass Group**, **Office Depot (Cxml)** — are wired in `Apis_Roles` to the **same two roles**:
|
|
38
|
+
|
|
39
|
+
| Role | id | Record permissions | Writable |
|
|
40
|
+
|---|---|---|---|
|
|
41
|
+
| Base | 1 | 123 | 115 |
|
|
42
|
+
| API | 3 | 291 | **all 291** |
|
|
43
|
+
|
|
44
|
+
So MITS, whose published integration document lists **5 read lookups**, can in fact create, update
|
|
45
|
+
and delete across 291 records. Nothing in the database constrains any of those four keys to its
|
|
46
|
+
documented calls. Anyone auditing Compass API access needs to start from this fact. Migrating those
|
|
47
|
+
four keys onto per-key roles (starting with MITS) is **intended but not done**.
|
|
48
|
+
|
|
49
|
+
## The Refresh-team key (the worked example)
|
|
50
|
+
|
|
51
|
+
Landed on **production** 2026-09-10:
|
|
52
|
+
|
|
53
|
+
- `Client_Compass.Apis` row **'Compass Refresh Team'** (prod `id = 5`).
|
|
54
|
+
- `Apis_Roles` binds it to exactly **one** new client role, **'Refresh API'** (prod `roleId = 17`).
|
|
55
|
+
- Read-only: **9 records**, **83 field permissions**, **18 custom-field permissions**,
|
|
56
|
+
**0 writable anywhere**.
|
|
57
|
+
- Records granted (`Core.Records.id`): **2** users, **11** locations, **37** contacts,
|
|
58
|
+
**38** contact-email-addresses, **39** contact-phone-numbers, **40** personas,
|
|
59
|
+
**201** user-personas, **273** user-roles, **274** roles.
|
|
60
|
+
- Field grants were **cloned from the shared API role (roleId 3)** for those same records, minus the
|
|
61
|
+
two fields listed below.
|
|
62
|
+
|
|
63
|
+
The consumer calls four requests: auth, cost center by number (`/locations`), user by email
|
|
64
|
+
(`/users`), and user by Compass username (`/users`).
|
|
65
|
+
|
|
66
|
+
## Steps
|
|
67
|
+
|
|
68
|
+
1. Create the `Client_Compass.Roles` row for the consumer (one role, one key). `coreRoleId` may stay
|
|
69
|
+
NULL — every record here has `Core.Records.aclDatabase = 'CLIENT'`.
|
|
70
|
+
2. Insert the `Client_Compass.Apis` row with a generated uuid and secret, then the single
|
|
71
|
+
`Apis_Roles` row binding it to that new role only.
|
|
72
|
+
3. For each record the consumer needs, build the **whole** ACL chain — `AclRecordPermissions` +
|
|
73
|
+
`AclLogicGroups` + `AclLogicGroupExpressions` + `AclRecordExpressions`. A permission row alone
|
|
74
|
+
fails the request with `No ACL Logic Groups defined for ACL Record Permissions`
|
|
75
|
+
([ACL Permission Chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md)).
|
|
76
|
+
4. Clone the field grants from role 3 for those records, with `isWritable = 0`, **dropping**:
|
|
77
|
+
- `Users.password` — `recordFieldId 611`
|
|
78
|
+
- `Users.ldapDn` — `recordFieldId 1329`
|
|
79
|
+
5. Reuse `AclRecordExpressions.id = 4` on record 2 (Users). That expression is the row filter that
|
|
80
|
+
**hides every user whose email ends in `@togatech.com`**, so internal TOGa staff accounts never
|
|
81
|
+
appear in an API user lookup. Any new role reading Users should reuse it.
|
|
82
|
+
6. Verify before handover (see below), then hand the uuid and secret to the consumer out of band.
|
|
83
|
+
7. Produce the consumer's reference document (see below).
|
|
84
|
+
|
|
85
|
+
### ⚠ Do not clone `AclLogicGroup id 20` wholesale
|
|
86
|
+
|
|
87
|
+
Logic group **20** (role 3, Users) also links `AclRecordExpressions id 16` — but expression 16
|
|
88
|
+
belongs to `recordId 22`, not 2. It is a **stray mis-linked row** in the shared role. It was
|
|
89
|
+
deliberately **not** copied into the Refresh role. Read what an expression actually points at before
|
|
90
|
+
mirroring it.
|
|
91
|
+
|
|
92
|
+
## Verification
|
|
93
|
+
|
|
94
|
+
- Counts: 9 records / 83 field permissions / 18 custom-field permissions / 0 writable — all passed
|
|
95
|
+
on prod.
|
|
96
|
+
- `Apis_Roles` for the new `apiId` returns **exactly one** `roleId`.
|
|
97
|
+
- Call each intended route with the new key, then read `Logs_Compass.Api` filtered on the new
|
|
98
|
+
`apiId` to confirm the exact routes and response payloads
|
|
99
|
+
([V2 Request Logging](../../../2.0/apps/api2/features/request-logging.md)).
|
|
100
|
+
|
|
101
|
+
## Environment note — Compass Core ids matched prod and client-sandbox
|
|
102
|
+
|
|
103
|
+
Checked 2026-09-10 for this client: `Core.Records` ids, every `Core.RecordFields` id for records
|
|
104
|
+
2/11/37/38/39/40/201/273/274, `Client_Compass.AclRecordExpressions` ids and
|
|
105
|
+
`Client_Compass.CustomRecordFields` ids were **identical between production and client-sandbox**. So
|
|
106
|
+
this ACL script, written against prod ids, runs unchanged on client-sandbox for Compass.
|
|
107
|
+
|
|
108
|
+
> This contradicts the usual assumption that `RecordFields` ids are environment-specific. Treat it
|
|
109
|
+
> as a **Compass prod to client-sandbox** observation, not a general rule — re-check per client and
|
|
110
|
+
> per environment before reusing a script elsewhere. The general rule (resolve ids to names before
|
|
111
|
+
> diffing environments) still stands.
|
|
112
|
+
|
|
113
|
+
## The consumer-facing reference document
|
|
114
|
+
|
|
115
|
+
Each API consumer gets a Word reference document, mirroring the existing MITS one. It is generated
|
|
116
|
+
with `python-docx` using the MITS `.docx` as the style template, and covers the four requests, real
|
|
117
|
+
sample responses lifted from `Logs_Compass.Api`, Postman links, token lifetimes, and a plain-English
|
|
118
|
+
"what your key can access" section. **Credentials in the document are placeholders only.** The file
|
|
119
|
+
lives outside the repos (the developer's local Downloads) — the binary is not stored in git or the
|
|
120
|
+
knowledge base.
|
|
121
|
+
|
|
122
|
+
## Open items (as of 2026-09-14)
|
|
123
|
+
|
|
124
|
+
- **⚠ The generated secret is sitting in plain text inside
|
|
125
|
+
`dbchanges2/Client_Compass/2026-09-10 - RefreshTeamApiAccess.sql`, which is a git repo.** Strip it
|
|
126
|
+
before commit or rotate the secret. Never commit a secret value.
|
|
127
|
+
- The **negative test has not been run** — calling a route outside the grant (e.g. `/items`) with the
|
|
128
|
+
Refresh key to confirm `UNAUTHORIZED`.
|
|
129
|
+
- The script was run **directly on production**; the intended client-sandbox dry run was skipped.
|
|
130
|
+
- Migrating the four older keys (Agilant, MITS Service Hub, Compass Group, Office Depot (Cxml)) off
|
|
131
|
+
the shared Base + API roles onto per-key roles is **not started**.
|
|
132
|
+
|
|
133
|
+
## Change history
|
|
134
|
+
- 2026-09-14 — Created. Built the first per-key Compass API role: `Apis` row 'Compass Refresh Team'
|
|
135
|
+
(prod id 5) bound to the single read-only role 'Refresh API' (prod roleId 17) over 9 records / 83
|
|
136
|
+
field grants / 18 custom-field grants, 0 writable, cloned from role 3 minus `Users.password` (611)
|
|
137
|
+
and `Users.ldapDn` (1329). Recorded that the four older Compass keys all share Base (1) + API (3)
|
|
138
|
+
and are therefore fully writable across 291 records; that `AclRecordExpressions id 4` is the
|
|
139
|
+
`@togatech.com` staff-hiding row filter to reuse; that logic group 20 links a stray expression
|
|
140
|
+
(id 16, wrong record) not to be cloned; and that Compass Core metadata ids matched prod and
|
|
141
|
+
client-sandbox. Open: secret still in the SQL file, negative test not run, prod-only run, older
|
|
142
|
+
keys not migrated. (bala)
|
package/package.json
CHANGED