toga-ai 1.0.864 → 1.0.865
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/architecture.md +23 -7
- package/knowledge/2.0/apps/api2/architecture.md +2 -2
- package/knowledge/2.0/apps/apiproxy/INDEX.md +5 -0
- package/knowledge/2.0/apps/apiproxy/architecture.md +150 -0
- package/knowledge/2.0/apps/saml/architecture.md +12 -3
- package/knowledge/INDEX.md +1 -0
- package/knowledge/registry.json +7 -0
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-22
|
|
10
10
|
owners: ["jcardinal", "rgirish", "mhammontree", "ajean"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/_underscore.php
|
|
@@ -253,12 +253,28 @@ const DATABASE = _underscore::DB_CLIENT; // or DB_CORE
|
|
|
253
253
|
const TABLE = 'TableName';
|
|
254
254
|
```
|
|
255
255
|
|
|
256
|
-
Columns are declared as `public` properties set to field-type constants
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
`
|
|
260
|
-
|
|
261
|
-
|
|
256
|
+
Columns are declared as `public` properties set to field-type constants:
|
|
257
|
+
|
|
258
|
+
- **Numeric:** `FIELD_PRIMARYKEY`, `FIELD_FOREIGNKEY` (array form, see below), `FIELD_INTEGER`,
|
|
259
|
+
`FIELD_AUTOINCREMENT` (see [autoincrement-record-numbering](./features/autoincrement-record-numbering.md)),
|
|
260
|
+
`FIELD_BOOLEAN`, `FIELD_DECIMAL`.
|
|
261
|
+
- **String — several apply an on-write/on-read transform, so pick the specific type instead of a
|
|
262
|
+
plain `FIELD_CHAR` plus manual formatting:** `FIELD_CHAR` (plain), `FIELD_CHAR_CAPS` (forced
|
|
263
|
+
uppercase), `FIELD_CHAR_UCWORDS` (title-case), `FIELD_CHAR_HTMLENTITY` (HTML-escaped —
|
|
264
|
+
XSS-relevant), `FIELD_CHAR_PASSWORD` (hashed — never store a plaintext password in a plain
|
|
265
|
+
`FIELD_CHAR`), `FIELD_CHAR_PHONE` (phone-formatted), `FIELD_CHAR_SERIALIZED` (PHP-serialized),
|
|
266
|
+
`FIELD_CHAR_UUID`, `FIELD_CHAR_ZIPCODE` (ZIP-formatted), `FIELD_LIST` (enum/list value — see the
|
|
267
|
+
`FIELD_LIST` enum-validation caveat in `rules/common/security.md`).
|
|
268
|
+
- **Date/time:** `FIELD_DATE`, `FIELD_DATETIME`, `FIELD_DATETIME_CREATED` (auto-set on insert),
|
|
269
|
+
`FIELD_DATETIME_UPDATED` (auto-set on save), `FIELD_TIME`, `FIELD_YEAR`.
|
|
270
|
+
- **Binary/file:** `FIELD_BLOB`, `FIELD_STORAGE` (S3 or filesystem — see the storage-write gotcha
|
|
271
|
+
below and [field-storage-row-hydration](./features/field-storage-row-hydration.md)).
|
|
272
|
+
- **Calculated:** `FIELD_SQL` (see below).
|
|
273
|
+
|
|
274
|
+
**Field options** (the `FIELDOPT_*` array keys that tune a field): `FIELDOPT_DEFAULT` (default
|
|
275
|
+
value), `FIELDOPT_FOREIGNKEY_MODEL` (target model class), `FIELDOPT_AUTOINCREMENT_PREFIX` /
|
|
276
|
+
`FIELDOPT_AUTOINCREMENT_FIRSTNUMBER` / `FIELDOPT_AUTOINCREMENT_SUFFIX` (numbering scheme, see the
|
|
277
|
+
autoincrement feature), and `FIELDOPT_SQL_STORED` / `FIELDOPT_SQL_TYPE` (calculated fields, below).
|
|
262
278
|
|
|
263
279
|
**Foreign keys** are arrays with the target model:
|
|
264
280
|
|
|
@@ -62,9 +62,9 @@ third party → apiproxy (region failover) → EB ALB → Apache → index.php
|
|
|
62
62
|
|
|
63
63
|
One ~2,000-line `execute()` then `processRoutePairs()`:
|
|
64
64
|
|
|
65
|
-
1. **Parse/normalize** — reads "now" from Core (`SELECT NOW()`) for token expiry; parses method/headers/route (strips `/v2`)/body; builds Core-metadata lookups (`Parameters`, `Records`, `InherentRecordChildren`, `RecordFields`).
|
|
65
|
+
1. **Parse/normalize** — reads "now" from Core (`SELECT NOW()`) for token expiry; parses method/headers/route (strips `/v2`)/body; builds Core-metadata lookups (`Parameters`, `Records`, `InherentRecordChildren`, `RecordFields`). A legacy **`apiKey` compatibility patch** rewrites a payload `apiKey` field into `api`/`secret` before auth, so old integrations that send one `apiKey` value still authenticate.
|
|
66
66
|
2. **Options** (`fields`, `where`, `join`/`ojoin`, `sort`, `group`, `distinct`, `page`, `recordsPerPage` default 25 / max 10000, `depth` default 3, `calcDepth` default 1) — query params or one base64(JSON) `_` blob.
|
|
67
|
-
3. **Authentication** — `transactionId` required and **globally unique** (checked vs Core + client logs; reuse → `EV-5`). Auth routes under `/v2/auth/*`: `api`, `refresh`, `oauth`, `oauth/refresh`, `login` (email/pw; AD via LDAP bind for AD-configured clients), `public`, `delegator`/`encrypted`. JWTs HS256, `sub` access/refresh (access 3600s, refresh 30d); signing secret **rotated** with current+previous accepted across the boundary. The JWT `id` claim carries the full identity used for ACL. **`/v2/auth/oauth` credential transport:** OAuth 2.0 `client_credentials`. `client_id` + `client_secret` may arrive as **HTTP Basic** *or* POST body fields — **Basic takes priority** when both present. `scope` is **required**; `grant_type` is **optional**, defaults to `client_credentials` (accepted: `client_credentials`, `password`, `refresh_token`). Mints a JWT; subsequent requests carry `Authorization: Bearer`. (`V2.php` L414-460.)
|
|
67
|
+
3. **Authentication** — `transactionId` required and **globally unique** (checked vs Core + client logs; reuse → `EV-5`). Auth routes under `/v2/auth/*`: `api`, `refresh`, `oauth`, `oauth/refresh`, `login` (email/pw; AD via LDAP bind for AD-configured clients), `public`, `delegator`/`encrypted`. JWTs HS256, `sub` access/refresh (access 3600s, refresh 30d); signing secret **rotated** with current+previous accepted across the boundary. The JWT `id` claim carries the full identity used for ACL — shape: `id.core.roles[]` (core role ids); `id.client.{uuid, slug, roles[], personas[]}`; `id.client.user.uuid` / `id.client.api.uuid` (whichever authenticated); `id.app.uuid` (from the `Domain`); `id.delegator` (present only when acting on behalf of another client/user). **`/v2/auth/oauth` credential transport:** OAuth 2.0 `client_credentials`. `client_id` + `client_secret` may arrive as **HTTP Basic** *or* POST body fields — **Basic takes priority** when both present. `scope` is **required**; `grant_type` is **optional**, defaults to `client_credentials` (accepted: `client_credentials`, `password`, `refresh_token`). Mints a JWT; subsequent requests carry `Authorization: Bearer`. (`V2.php` L414-460.)
|
|
68
68
|
4. **Response envelope** — `{transactionId, timestamp, authority, audience, isSuccess, status, error, messages[], meta{}, data{}}`; `isSuccess` = 2xx status.
|
|
69
69
|
5. **Transaction logging** — every request logged (to client/core Logs DB, or a JSONL line shipped by CloudWatch when `[api] log_filepath` is set).
|
|
70
70
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: API Proxy (apiproxy) Architecture
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: apiproxy
|
|
5
|
+
project: API Proxy
|
|
6
|
+
client: shared
|
|
7
|
+
type: architecture
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-22
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- apiproxy/Controller/Index.php
|
|
13
|
+
- apiproxy/_.php
|
|
14
|
+
- apiproxy/index.php
|
|
15
|
+
- apiproxy/Config/production.ini
|
|
16
|
+
- apiproxy/.htaccess
|
|
17
|
+
- apiproxy/.platform/httpd/conf.d/long_gateway_timeout.conf
|
|
18
|
+
related: []
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
Thin, stateless reverse-proxy / failover router that fronts every public TOGa API tier. All external API traffic hits apiproxy first; it forwards the request verbatim to a configured backend and retries the next backend on a 5xx, so integrators never see a single-region/single-tier failure. Built on `_underscore` but DB-free in the request path. Open for the config-group resolution, the 5xx-only failover contract, and the two-tier timeout ceiling.
|
|
22
|
+
|
|
23
|
+
## Summary
|
|
24
|
+
|
|
25
|
+
`apiproxy` is the public edge for the TOGa 2.0 API. A request (e.g. `GET https://api.togahub.com/sales-orders/<uuid>`) lands here; Apache rewrites all to `index.php`, `_underscore` boots and — because no real controller matches the incoming URI — dispatch falls through to the default `['_Controller_Index','api']`. `api()` **is** the entire proxy: it picks a config group from hostname + region, walks an ordered list of `attempts_*` from the INI, and cURLs `endpoint + REQUEST_URI` forwarding the original method/headers/body. The first non-5xx response is relayed back unchanged; a 5xx moves to the next attempt. `/health` short-circuits to `200 OK` without proxying.
|
|
26
|
+
|
|
27
|
+
Runtime: PHP 8.1+, AWS Elastic Beanstalk (Amazon Linux, Apache/httpd → PHP-FPM). `_underscore` is **pulled at deploy, not vendored** (cloned by a prebuild hook). **No database is used in the request path** despite the framework supporting one — keep it that way.
|
|
28
|
+
|
|
29
|
+
**Critical rules:** (1) **Failover fires on HTTP 5xx only** (`floor(code/100) == 5`). Any 2xx/3xx/4xx is final and returned immediately, including the last attempt's 5xx if all fail — changing this trigger changes what counts as "try the next backend." (2) **Every fronted hostname/region needs a matching config group** (`<SERVER_NAME>:<region>` preferred, `<SERVER_NAME>` fallback); a missing group makes `api()` **throw**. (3) **`/health` must keep returning 200 without proxying** — it is the EB/LB probe. (4) **Keep the proxy stateless and DB-free in the request path.**
|
|
30
|
+
|
|
31
|
+
## Dependencies
|
|
32
|
+
|
|
33
|
+
- **`_underscore` (framework core)** — apiproxy is a minimal app over the framework boot/route/config layers (`_Config`, `_Environment`, `_Cloud`, `_Route`). Read its architecture first. Cloned at build from branch `_<ENVIRONMENT>`, not vendored.
|
|
34
|
+
- **`api2` / cXML tiers (downstream)** — apiproxy forwards to the real API backends (`api1/api2/api3.togahub.com`, `api-writer.togahub.com`, `cxml*.togahub.com`, `webhook.togahub.com`, etc.). api2's own architecture doc names apiproxy as the front edge; the timeout chain is shared across both tiers.
|
|
35
|
+
|
|
36
|
+
## Request lifecycle
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
client → LB (3600s) → apiproxy [Apache → PHP-FPM] → cURL → api2/cxml [Apache → PHP-FPM] → DB
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
1. **`.htaccess`** — `RewriteRule ^(.*) index.php` sends every non-file/non-dir request to the front controller; direct access to `.php`/`.ini` is denied.
|
|
43
|
+
2. **`index.php`** → `require '_underscore.php'` — version check, autoloader, `_Config`, `_Environment`, `_underscore::initialize()`, `_Route`.
|
|
44
|
+
3. **Routing (key fact)** — `_underscore::DEFAULT_CONTROLLER_METHOD = ['_Controller_Index','api']` (in `_.php`). The incoming URI (`/sales-orders/...`) matches no real controller/method, so `_Route` falls back to this default. The proxy has **no per-route controllers on purpose** — everything funnels into `Index::api()`, which is why the full original `REQUEST_URI` survives to forward downstream.
|
|
45
|
+
4. **`_Controller_Index::api()`** — the whole proxy lives here (`Controller/Index.php`), plus the `/health` short-circuit.
|
|
46
|
+
|
|
47
|
+
## Config-group resolution
|
|
48
|
+
|
|
49
|
+
`api()` selects which backend list to use by building a config-group name and looking it up in `Config/<environment>.ini` (`<environment>` = `_Environment::$name`). It tries two names, region-specific first:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
1. "<SERVER_NAME>:<region>" e.g. api.togahub.com:us-east-1 (preferred)
|
|
53
|
+
2. "<SERVER_NAME>" e.g. api.togahub.com (fallback)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `SERVER_NAME` = the Host the request arrived on.
|
|
57
|
+
- `region` = `_Cloud::getRegion()`: in **production** it reads `storage/region.txt` (written at deploy by `.platform` hook `041-write-region.sh` from IMDS); in **debug mode** (`debug_mode = 1`) it returns `_Environment::$name` itself.
|
|
58
|
+
- Locally the dev INI uses `[apiproxy:dev-<name>]` because `SERVER_NAME` is `apiproxy` and the debug "region" resolves to the env name. Production defines real groups: `[api.togahub.com:us-east-1]`, `[cxml.togahub.com:us-west-2]`, `[webhook.togahub.com]`, etc.
|
|
59
|
+
- **Neither group found → `api()` throws.** Adding a fronted host or region means adding a matching group to every relevant `Config/*.ini`.
|
|
60
|
+
|
|
61
|
+
## Retry / failover mechanism
|
|
62
|
+
|
|
63
|
+
Each config group is a flat, ordered list of attempts keyed with underscore-numbered keys:
|
|
64
|
+
|
|
65
|
+
```ini
|
|
66
|
+
attempts_0_read_endpoint = https://api1.togahub.com
|
|
67
|
+
attempts_0_write_endpoint = https://api-writer.togahub.com
|
|
68
|
+
attempts_0_delay = 0 ; microseconds to usleep BEFORE this attempt
|
|
69
|
+
attempts_1_read_endpoint = https://api1.togahub.com
|
|
70
|
+
attempts_1_write_endpoint = https://api-writer.togahub.com
|
|
71
|
+
attempts_1_delay = 2000000 ; 2 seconds
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
How `api()` consumes them:
|
|
75
|
+
|
|
76
|
+
- **Read vs write:** `GET` uses `*_read_endpoint`; every other method (`POST`/`PUT`/`DELETE`/...) uses `*_write_endpoint`. Reads spread across read replicas (`api1`/`api2`/`api3`); writes always go to the dedicated writer.
|
|
77
|
+
- The loop reads `attempts_0_*`, `attempts_1_*`, ... until a missing key stops it. **Empty list → throws.**
|
|
78
|
+
- Before each attempt, if `delay > 0` it `usleep()`s (back-off; the first attempt's delay is always `0`).
|
|
79
|
+
- Builds `destinationUrl = endpoint + REQUEST_URI` and cURLs it, forwarding the original **method, body (`php://input`), and headers**.
|
|
80
|
+
- **Failover trigger: a 5xx only.** Non-5xx (2xx/3xx/4xx) is final and returned at once. If the last attempt is still 5xx, that 5xx is returned.
|
|
81
|
+
- Region-ordered endpoint lists differ per region so each region prefers its local tier first (e.g. `us-east-1` prefers `api1`, `us-west-2` prefers `api2`).
|
|
82
|
+
|
|
83
|
+
### Header handling
|
|
84
|
+
|
|
85
|
+
- **Request → backend:** all incoming headers forwarded except `EXCLUDE_HEADERS_REQUEST` (`Host`).
|
|
86
|
+
- **Backend → client:** all returned headers relayed except `EXCLUDE_HEADERS_RESPONSE` (`Transfer-Encoding`, `Content-Length`, `Connection`, `Server`, `Date`) — dropped so the proxy/Apache set them correctly for the relayed body.
|
|
87
|
+
|
|
88
|
+
### Health check
|
|
89
|
+
|
|
90
|
+
`GET /health` (any path reducing to `health`) short-circuits to `200 OK` without proxying — the EB/ALB liveness probe. Keep it working.
|
|
91
|
+
|
|
92
|
+
## Timeouts (read before debugging 504s)
|
|
93
|
+
|
|
94
|
+
A request crosses **two** Apache→PHP-FPM tiers, each with its own timeout ceiling:
|
|
95
|
+
|
|
96
|
+
| Layer | Setting | Where |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| Load balancer idle timeout | 3600s | EB / ALB config |
|
|
99
|
+
| apiproxy Apache→FPM proxy | `ProxyTimeout 1800` / `Timeout 1800` | `.platform/httpd/conf.d/long_gateway_timeout.conf` |
|
|
100
|
+
| apiproxy PHP max execution | `time_limit = 900` | `Config/*.ini` `[_underscore]` |
|
|
101
|
+
| apiproxy → backend cURL | `CURLOPT_TIMEOUT = 900` (connect 10s) | `Controller/Index.php` |
|
|
102
|
+
| backend (api2/cxml) Apache→FPM | `ProxyTimeout 1800` / `Timeout 1800` | `api2/.platform/httpd/conf.d/long_gateway_timeout.conf` |
|
|
103
|
+
|
|
104
|
+
- **The "exactly 60 seconds" 504:** EB's Amazon Linux PHP platform proxies to PHP-FPM via `mod_proxy_fcgi`, whose default `ProxyTimeout` is **60s**. If `long_gateway_timeout.conf` is missing for a tier, that tier 504s at exactly 60s regardless of LB/PHP/cURL limits. This file must exist in **every** httpd-platform tier handling long requests (apiproxy, api2, saml, worker2). On any 60s 504, check for this file first.
|
|
105
|
+
- **Effective single-attempt ceiling is ~900s** — the cURL `CURLOPT_TIMEOUT`, not 1800s. Apache allows 1800 but cURL aborts the upstream call at 900. Raise `CURLOPT_TIMEOUT` in `Index.php` if a single backend call legitimately needs longer.
|
|
106
|
+
- **Retry amplification:** because a 5xx triggers failover, a slow request that *eventually* 5xxs can burn several multi-minute attempts back-to-back. A slow-but-successful request does not retry (non-5xx is final), so normal large-record reads are safe.
|
|
107
|
+
|
|
108
|
+
## Deployment (Elastic Beanstalk)
|
|
109
|
+
|
|
110
|
+
`.platform/` hooks and `.ebextensions/` configure the instance:
|
|
111
|
+
|
|
112
|
+
- **`.ebextensions/php_include_underscore.config`** — sets PHP `include_path` to `.:/var/www/html/_underscore` so the framework is importable.
|
|
113
|
+
- **`.ebextensions/git.php` + `.platform/hooks/prebuild/git.sh`** — at build, clones the shared `_underscore` repo (branch `_<ENVIRONMENT>`, e.g. `_production`) into place, strips its `.git`, swaps it in. **Framework source is NOT vendored — pulled at deploy.** Env-specific overrides come from `git.<ENVIRONMENT>.json` if present.
|
|
114
|
+
- **`.platform/hooks/postdeploy/015_install_composer.sh`** — installs Composer and runs `composer install` (currently no runtime deps; `composer.json` `require` is empty).
|
|
115
|
+
- **`.platform/hooks/_shared/040-write-instance-id.sh` & `041-write-region.sh`** — write `storage/instance-id.txt` and `storage/region.txt` from IMDSv2. `_Cloud::getRegion()`/`getInstanceId()` read these. Symlinked into `prestart` and `postdeploy`.
|
|
116
|
+
- **`.platform/httpd/conf.d/long_gateway_timeout.conf`** — the timeout fix above.
|
|
117
|
+
|
|
118
|
+
## Local development
|
|
119
|
+
|
|
120
|
+
- Config chosen by `_Environment::$name`. The dev file (`Config/dev-<name>.ini`) sets `debug_mode = 1` and points its attempts at local backends (`http://api2`, `http://cxml2`).
|
|
121
|
+
- `cache_folder` / `model_storage_folder` in the dev INI use Windows paths (`C:\CACHE`, `C:\STORAGE`).
|
|
122
|
+
- To front a new host/region locally, add a `[<SERVER_NAME>:<env-name>]` (or bare `[<SERVER_NAME>]`) group with `attempts_*` keys.
|
|
123
|
+
|
|
124
|
+
## File map
|
|
125
|
+
|
|
126
|
+
| Path | Purpose |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `index.php` | Front controller — just requires the framework |
|
|
129
|
+
| `_.php` | Project class `_underscore` — namespace, defaults, `DEFAULT_CONTROLLER_METHOD` |
|
|
130
|
+
| `Controller/Index.php` | **The entire proxy logic** (`api()`) + `/health` |
|
|
131
|
+
| `Controller/Error.php` | Error route controller (sets HTTP code) |
|
|
132
|
+
| `Config/production.ini` | Production config groups (real hostnames/regions/endpoints) |
|
|
133
|
+
| `Config/dev-*.ini` | Per-developer local config |
|
|
134
|
+
| `.htaccess` | Rewrite-all-to-`index.php` + deny direct `.php`/`.ini` |
|
|
135
|
+
| `.platform/`, `.ebextensions/` | EB deploy configuration (see Deployment) |
|
|
136
|
+
| `Layout/`, `View/`, `Theme/`, `Component/`, `Model/`, `Assets/` | Framework scaffolding — largely unused; placeholder `.gitkeep`s / boilerplate |
|
|
137
|
+
|
|
138
|
+
## When making changes here
|
|
139
|
+
|
|
140
|
+
- **Adding a fronted hostname/region:** add a matching config group (`<host>:<region>` and/or `<host>`) with `attempts_*` keys to every relevant `Config/*.ini`. Missing group → `api()` throws.
|
|
141
|
+
- **Changing failover behavior:** the only retry trigger is a 5xx (`floor(code/100) == 5`). That check is the lever for what counts as "try the next backend."
|
|
142
|
+
- **Long-running requests / 504s:** verify `long_gateway_timeout.conf` exists for the tier, then check `CURLOPT_TIMEOUT` (900s) and PHP `time_limit` (900s). The LB is at 3600s and is rarely the cause.
|
|
143
|
+
- **Don't break `/health`** — the LB health probe.
|
|
144
|
+
- **Keep it stateless and DB-free in the request path** unless there is a strong reason not to.
|
|
145
|
+
|
|
146
|
+
## Security notes
|
|
147
|
+
|
|
148
|
+
- **No DB and no per-request secrets in the proxy path** — apiproxy forwards the client's own `Authorization` header downstream unchanged; it does not mint or hold tokens. Auth stays a backend (api2) concern.
|
|
149
|
+
- **Deploy-time credential exposure to check:** the `git.php` / `git.<ENVIRONMENT>.json` clone config supplies whatever credential `prebuild/git.sh` uses to pull `_underscore` at build. Sibling 2.0 tiers (api2) ship a **committed plaintext GitHub PAT** in their per-env `git.*.json`; if apiproxy's clone config carries a token the same way, treat it as compromised, rotate it, and move it to SSM / EB env properties. Verify before touching deploy config. (Location/remediation only — never record a token value.)
|
|
150
|
+
- **`_underscore` is cloned from a moving branch** (`_<ENVIRONMENT>`), not pinned to a commit, so two deploys of the same apiproxy commit can behave differently — check the framework branch state when triaging an "it worked yesterday" regression.
|
|
@@ -6,7 +6,7 @@ project: SAML SSO Gateway
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-22
|
|
10
10
|
owners: ["rgirish", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- saml/index.php
|
|
@@ -65,6 +65,13 @@ Legacy `/?meta`, `/?acs`, `/?sls` query-style paths are rewritten automatically.
|
|
|
65
65
|
|
|
66
66
|
Identity mapping lives in `_underscore/Model/<ClientIdentifier>/ClientAuthentication.php`. Each class extends `_Model_Core_ClientAuthentication` + uses `_Trait_ClientAuthentication`. To onboard a new SSO client, add the class in `_underscore` — not in this repo.
|
|
67
67
|
|
|
68
|
+
## Runtime & config
|
|
69
|
+
|
|
70
|
+
- **PHP 8.1+ in production**, on Amazon Linux with **Apache/httpd fronting PHP-FPM**. `composer.json` pins `platform.php` to **`7.2.33` for dependency resolution only** — that is not the runtime version; do not treat it as the target.
|
|
71
|
+
- **Config files are `Config/<environment>.ini`, selected by `_Environment::$name`.** Production sets `time_limit = 300`, `debug_mode = 0`. Many per-developer/per-stage configs exist (`alpha`, `beta`, `gamma`, `dev-*`, `rohan-mac`).
|
|
72
|
+
- **SP host per environment is a framework constant**, not local config: `_Model_Core_ClientAuthentication::SAML_ASSERTION_CONSUMER_SERVICE_HOST_URL_BY_ENVIRONMENT` — `dev → http://saml`, `beta`/`production → https://saml.togahub.com`. This is the ACS host IdPs POST to; it must match what `/meta` advertises.
|
|
73
|
+
- **Apache `.platform` configs (keep these):** `enforce_https.conf` redirects any request with `X-Forwarded-Proto != https` to HTTPS — **SAML must run over TLS only; do not weaken it**. `long_gateway_timeout.conf` sets `ProxyTimeout 1800` / `Timeout 1800` (same override as `apiproxy`). `php_include_underscore.config` adds `/var/www/html/_underscore` to PHP `include_path`.
|
|
74
|
+
|
|
68
75
|
## Deployment
|
|
69
76
|
|
|
70
77
|
- **EB env:** `saml-production` (us-east-1). DNS: `saml.togahub.com → saml-production.us-east-1.elasticbeanstalk.com`.
|
|
@@ -82,8 +89,10 @@ Identity mapping lives in `_underscore/Model/<ClientIdentifier>/ClientAuthentica
|
|
|
82
89
|
## Gotchas
|
|
83
90
|
|
|
84
91
|
- **`_Database::register()` auto-starts a lazy transaction (since Apr 2 2026).** Any code that registers DBs then writes must call `_Database::transactionCommit()` before exiting — otherwise MySQL silently rolls back all writes on connection close. `/acs` calls `transactionCommit()` after `getAuthenticatedSsoUser()` succeeds and before the redirect. See `_underscore/Database.php:48`.
|
|
85
|
-
- **SAML signature verification is commented out — repo's top security priority.** The IdP X509 cert verification block in `/acs` is disabled, so the gateway accepts any well-formed status-success `SAMLResponse` without proving it was signed by the expected IdP. The only remaining gate is the encrypted RelayState, which authenticates the *originating TOGa request* — not the *asserting IdP*. Threat: an attacker who can craft/replay a `SAMLResponse` (or a malicious/compromised IdP) can forge an identity assertion for any user. Re-enabling per-IdP X509 signature verification is the single highest-impact security fix.
|
|
86
|
-
- **Plaintext secrets in source — move to AWS SSM Parameter Store.** `saml/Config/production.ini`
|
|
92
|
+
- **SAML signature verification is commented out — repo's top security priority.** The IdP X509 cert verification block in `/acs` is disabled, so the gateway accepts any well-formed status-success `SAMLResponse` without proving it was signed by the expected IdP. The only remaining gate is the encrypted RelayState, which authenticates the *originating TOGa request* — not the *asserting IdP*. Threat: an attacker who can craft/replay a `SAMLResponse` (or a malicious/compromised IdP) can forge an identity assertion for any user. Re-enabling per-IdP X509 signature verification is the single highest-impact security fix. The per-client IdP verification certs already exist in source as base64 blobs (Compass, Office Depot, Prudential, RUMCSI, Agilant) — they are what a re-enabled verifier would load; verification is per-client and each IdP's signing cert must match.
|
|
93
|
+
- **Plaintext secrets in source — move to AWS SSM Parameter Store.** Checked-in credentials, all live and to be rotated: `saml/Config/production.ini` holds a **DB password, an AWS access key id + secret, and NetSuite credentials** in cleartext; `saml/Controller/Index.php` hardcodes the **Sentry DSN**; `.ebextensions/git.json` carries a **GitHub PAT** (same leak class as `apiproxy`). Migrate to AWS SSM Parameter Store (SecureString), load at runtime; source tree should reference parameter names only, never literal credential values.
|
|
94
|
+
- **Stale test scaffolding in `/acs` — remove it.** A large commented-out block in `saml/Controller/Index.php` hardcodes captured real `SAMLResponse` payloads and a synthetic `RelayState` for Office Depot / Compass testing. It is inert (commented out) but captured assertions living in source are a confidentiality/hygiene risk — delete it.
|
|
95
|
+
- **RelayState time check bounds the future, not the past.** `MAX_TIME_TO_LIVE` (300s) sanity-checks how far in the *future* a RelayState timestamp may be. It does not clearly bound replay of an *old* captured RelayState — review before relying on it as replay protection.
|
|
87
96
|
- **`/meta` cert lines must be `trim()`-ed (CRLF).** The `.crt` files in `_underscore/Assets/ssl` are CRLF-encoded; cert-format code does `explode("\n", ...)` and indents each line, so without a per-line `trim()` a stray `\r` ends up on every base64 line inside `<ds:X509Certificate>`. Trim each line in both signing and encryption blocks.
|
|
88
97
|
- **SP encryption certs load from Secrets Manager at request time.** `/acs` holds no encryption key material on disk (`_underscore/Assets/ssl/` no longer carries the SP encryption key). Rotation = edit the `ENCRYPTION_SECRET_NAMES` list + the secrets themselves, not a redeploy of on-disk `.crt`/`.key` files.
|
|
89
98
|
- **Committed AWS key must be rotated.** A hardcoded AWS key was removed from `_underscore/Cloud.php`; because it was committed to git history it is compromised — rotate it. Still open: plaintext creds in `production.ini` and the hardcoded Sentry DSN.
|
package/knowledge/INDEX.md
CHANGED
|
@@ -25,6 +25,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
25
25
|
- **dbchanges2** (Database Changes) _(framework core)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
26
26
|
- **toga2-supply** (TOGa Supply) — 9 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
|
|
27
27
|
- **saml** (SAML SSO Gateway) — 6 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
|
|
28
|
+
- **apiproxy** (API Proxy) — 1 doc(s) → [2.0/apps/apiproxy/INDEX.md](2.0/apps/apiproxy/INDEX.md)
|
|
28
29
|
- **toga2-view** (TOGa View Frontend) — 12 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
|
|
29
30
|
- **toga2-hub** (TOGa Hub) — 2 doc(s) → [2.0/apps/toga2-hub/INDEX.md](2.0/apps/toga2-hub/INDEX.md)
|
|
30
31
|
- **talos** (TOGa IQ) — 8 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
|
package/knowledge/registry.json
CHANGED
package/package.json
CHANGED