@tellescope/sdk 1.256.12 → 1.256.14
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/lib/cjs/sdk.d.ts +1 -0
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +1 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/organization_signup.test.d.ts +20 -0
- package/lib/cjs/tests/api_tests/organization_signup.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/organization_signup.test.js +272 -0
- package/lib/cjs/tests/api_tests/organization_signup.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
- package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +336 -0
- package/lib/cjs/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +354 -0
- package/lib/cjs/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
- package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +389 -0
- package/lib/cjs/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
- package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +324 -0
- package/lib/cjs/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
- package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +282 -0
- package/lib/cjs/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js +329 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
- package/lib/cjs/tests/setup_isolated.d.ts +68 -0
- package/lib/cjs/tests/setup_isolated.d.ts.map +1 -0
- package/lib/cjs/tests/setup_isolated.js +232 -0
- package/lib/cjs/tests/setup_isolated.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +260 -208
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
- package/lib/cjs/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
- package/lib/cjs/tests/unit_tests/do_not_send_address.test.js +89 -0
- package/lib/cjs/tests/unit_tests/do_not_send_address.test.js.map +1 -0
- package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
- package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
- package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js +140 -0
- package/lib/cjs/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
- package/lib/esm/enduser.d.ts +1 -0
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +4 -2
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +1 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/session.d.ts +1 -0
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/organization_signup.test.d.ts +20 -0
- package/lib/esm/tests/api_tests/organization_signup.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/organization_signup.test.js +264 -0
- package/lib/esm/tests/api_tests/organization_signup.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts +30 -0
- package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js +332 -0
- package/lib/esm/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js +350 -0
- package/lib/esm/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts +46 -0
- package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js +382 -0
- package/lib/esm/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts +43 -0
- package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js +317 -0
- package/lib/esm/tests/api_tests/security/F-0192-automated-actions-process-gate.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts +43 -0
- package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js +277 -0
- package/lib/esm/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.js.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts +5 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js +324 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_from_queue.test.js.map +1 -0
- package/lib/esm/tests/setup_isolated.d.ts +68 -0
- package/lib/esm/tests/setup_isolated.d.ts.map +1 -0
- package/lib/esm/tests/setup_isolated.js +221 -0
- package/lib/esm/tests/setup_isolated.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +260 -208
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts +2 -0
- package/lib/esm/tests/unit_tests/do_not_send_address.test.d.ts.map +1 -0
- package/lib/esm/tests/unit_tests/do_not_send_address.test.js +85 -0
- package/lib/esm/tests/unit_tests/do_not_send_address.test.js.map +1 -0
- package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts +17 -0
- package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.d.ts.map +1 -0
- package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js +113 -0
- package/lib/esm/tests/unit_tests/openapi_spec_freshness.test.js.map +1 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +4 -0
- package/src/tests/api_tests/organization_signup.test.ts +240 -0
- package/src/tests/api_tests/out_of_office_endpoint.test.ts +162 -0
- package/src/tests/api_tests/security/F-0179-booking-info-idor-token-mint.test.ts +225 -0
- package/src/tests/api_tests/security/F-0185-startlinktoken-enduser-staff-escalation.test.ts +162 -0
- package/src/tests/api_tests/security/F-0191-schema-adminonly-enforcement.test.ts +288 -0
- package/src/tests/api_tests/security/F-0192-automated-actions-process-gate.test.ts +238 -0
- package/src/tests/api_tests/security/F-0196-add-to-journey-foreign-enduserid.test.ts +208 -0
- package/src/tests/api_tests/tickets_bulk_assign_from_queue.test.ts +279 -0
- package/src/tests/setup_isolated.ts +170 -0
- package/src/tests/tests.ts +27 -0
- package/src/tests/unit_tests/do_not_send_address.test.ts +60 -0
- package/src/tests/unit_tests/openapi_spec_freshness.test.ts +88 -0
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import axios from "axios"
|
|
4
|
+
|
|
5
|
+
import { Session } from "../../../sdk"
|
|
6
|
+
import {
|
|
7
|
+
assert,
|
|
8
|
+
log_header,
|
|
9
|
+
} from "@tellescope/testing"
|
|
10
|
+
import { PROVIDER_PERMISSIONS, PLACEHOLDER_ID } from "@tellescope/constants"
|
|
11
|
+
import { setup_tests } from "../../setup"
|
|
12
|
+
|
|
13
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
14
|
+
|
|
15
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
16
|
+
|
|
17
|
+
const ALL = 'All' as const
|
|
18
|
+
const FULL_ACCESS = { create: ALL, read: ALL, update: ALL, delete: ALL }
|
|
19
|
+
|
|
20
|
+
const PROCESS_PATH = '/v1/automated-actions/process'
|
|
21
|
+
|
|
22
|
+
// A single collected check. Everything is gathered first and asserted at the end, because the
|
|
23
|
+
// testing harness exits the process on the first failed assert — collecting first means a RED run
|
|
24
|
+
// prints the full evidence table (and reaches `finally` cleanup) before the first assert exits.
|
|
25
|
+
type Check = {
|
|
26
|
+
label: string,
|
|
27
|
+
pass: boolean,
|
|
28
|
+
detail: string,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Regression test for F-0192
|
|
33
|
+
* (security-audit/findings/F-0192-automated-actions-process-fire-replay.md).
|
|
34
|
+
*
|
|
35
|
+
* `automated_actions.process` (api.ts:3902) is the WORKER's action runner — its schema description
|
|
36
|
+
* reads "Used by worker for new action types". It was registered with only
|
|
37
|
+
* `{ requiresSubscription: true }`, so the framework gated it on the ordinary
|
|
38
|
+
* `automated_actions.update` verb, which the default provider role holds ('Assigned'). The handler
|
|
39
|
+
* then dispatched `automatedAction.action` through a 14-branch if-chain reaching real vendors:
|
|
40
|
+
* chargebeeChargeCardOnFile (card charge), belugaAutoRx / belugaTriggerRefill / mdiTriggerRefill
|
|
41
|
+
* (prescriptions and refills), createScriptSureDraft (medication draft), healthiePushForms /
|
|
42
|
+
* metriportPushFormResponse (PHI export). It checked neither the row's `status` nor whether it had
|
|
43
|
+
* already run, so any row was replayable: MDI advances to the next scheduled refill each call,
|
|
44
|
+
* Chargebee charges again. And there was no storeUserLog, so the action was attributed to the
|
|
45
|
+
* worker rather than the caller.
|
|
46
|
+
*
|
|
47
|
+
* The fix has three parts:
|
|
48
|
+
* 1. `adminOnly: true` on the api.ts registration (routing.ts:944 reads the flag from that
|
|
49
|
+
* options object — a schema-only flag is inert, which is the separate finding F-0191).
|
|
50
|
+
* 2. A single-fire claim for human callers: an atomic findOneAndUpdate with `status: 'active'` in
|
|
51
|
+
* the filter, stamping `finished` + `processedAt` before the vendor dispatch. The worker
|
|
52
|
+
* (`wasAutomated`) is exempt because its own claim in
|
|
53
|
+
* worker/modules/database.ts:114 is already atomic and it needs the row to stay 'active'
|
|
54
|
+
* across the call so a retryable failure (worker/modules/automations.ts:2053) can be
|
|
55
|
+
* re-claimed after the lock times out.
|
|
56
|
+
* 3. storeUserLog on the human path, recording the caller and the automatedActionId.
|
|
57
|
+
*
|
|
58
|
+
* The seeded row uses `setEnduserStatus`, which is NOT one of the API's if-chain branches, so it
|
|
59
|
+
* falls through to `{ success: false, unimplemented: true }` (api.ts) — no vendor call, no Bedrock,
|
|
60
|
+
* no PHI export. `processAfter` is set an hour out so the live worker cannot race the row (the same
|
|
61
|
+
* trick mdi_trigger_refill / beluga_trigger_refill use).
|
|
62
|
+
*
|
|
63
|
+
* RED (pre-fix): A1 fails (a non-admin holding only `automated_actions.update` reaches the handler)
|
|
64
|
+
* and C1 fails (the second call on the same row executes again instead of being rejected).
|
|
65
|
+
* B1 (admin positive control) passes pre-fix and must keep passing.
|
|
66
|
+
* GREEN (post-fix): all five checks pass.
|
|
67
|
+
*/
|
|
68
|
+
export const automated_actions_process_gate_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
69
|
+
log_header("F-0192: automated_actions.process must be admin-only and single-fire")
|
|
70
|
+
|
|
71
|
+
const checks: Check[] = []
|
|
72
|
+
const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
|
|
73
|
+
|
|
74
|
+
// Raw axios: the SDK's parseError discards the HTTP status, and the whole point here is WHICH
|
|
75
|
+
// status the gate returns.
|
|
76
|
+
const post = async (authToken: string, automatedActionId: string) => {
|
|
77
|
+
const r = await axios.request({
|
|
78
|
+
method: 'post',
|
|
79
|
+
url: `${host}${PROCESS_PATH}`,
|
|
80
|
+
data: { automatedActionId },
|
|
81
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
82
|
+
validateStatus: () => true,
|
|
83
|
+
})
|
|
84
|
+
return { status: r.status, body: r.data }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let roleId = ''
|
|
88
|
+
let attackerId = ''
|
|
89
|
+
let enduserId = ''
|
|
90
|
+
const automatedActionIds: string[] = []
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
/* ================= fixtures ================= */
|
|
94
|
+
|
|
95
|
+
// A non-admin role that explicitly HOLDS automated_actions update — the point of the finding is
|
|
96
|
+
// that holding the declared verb was enough to fire vendor actions.
|
|
97
|
+
const role = `f0192-automations-${RAND()}`
|
|
98
|
+
const rbap = await sdk.api.role_based_access_permissions.createOne({
|
|
99
|
+
role,
|
|
100
|
+
permissions: { ...PROVIDER_PERMISSIONS, automated_actions: FULL_ACCESS },
|
|
101
|
+
} as any)
|
|
102
|
+
roleId = rbap.id
|
|
103
|
+
|
|
104
|
+
// Throwaway staff user — never mutate an existing user's roles. verifiedEmail must be set at
|
|
105
|
+
// create time (it is rejected on update), otherwise calls are blocked on email verification
|
|
106
|
+
// rather than on the gate under test.
|
|
107
|
+
const attacker = await sdk.api.users.createOne({
|
|
108
|
+
email: `f0192-attacker-${RAND()}@tellescope.example`,
|
|
109
|
+
fname: 'F0192', lname: 'Staff',
|
|
110
|
+
verifiedEmail: true,
|
|
111
|
+
notificationEmailsDisabled: true,
|
|
112
|
+
roles: [role],
|
|
113
|
+
} as any)
|
|
114
|
+
attackerId = attacker.id
|
|
115
|
+
|
|
116
|
+
// Admin-minted token, not .authenticate — /login is IP rate-limited and the suite shares an IP.
|
|
117
|
+
const attackerToken = (await sdk.api.users.generate_auth_token({ id: attackerId })).authToken
|
|
118
|
+
const adminToken = (await sdk.api.users.generate_auth_token({ id: sdk.userInfo.id })).authToken
|
|
119
|
+
|
|
120
|
+
const attackerSDK = new Session({ host, authToken: attackerToken })
|
|
121
|
+
await attackerSDK.refresh_session()
|
|
122
|
+
record(
|
|
123
|
+
'setup: attacker session is a non-admin holding automated_actions.update',
|
|
124
|
+
attackerSDK.userInfo.id === attackerId && !(attackerSDK.userInfo.roles ?? []).includes('Admin'),
|
|
125
|
+
`id=${attackerSDK.userInfo.id} roles=${JSON.stringify(attackerSDK.userInfo.roles)}`,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const enduser = await sdk.api.endusers.createOne({ fname: 'F0192', lname: 'Patient' })
|
|
129
|
+
enduserId = enduser.id
|
|
130
|
+
|
|
131
|
+
// `setEnduserStatus` is handled by the worker, NOT by the API's process if-chain, so it falls
|
|
132
|
+
// through to `unimplemented: true`. processAfter is an hour out so the live worker cannot claim
|
|
133
|
+
// the row before the explicit calls below.
|
|
134
|
+
const seedAction = async () => {
|
|
135
|
+
const a = await sdk.api.automated_actions.createOne({
|
|
136
|
+
enduserId,
|
|
137
|
+
journeyId: PLACEHOLDER_ID,
|
|
138
|
+
automationStepId: PLACEHOLDER_ID,
|
|
139
|
+
event: { type: 'onJourneyStart', info: {} },
|
|
140
|
+
action: { type: 'setEnduserStatus', info: { status: 'Working' } },
|
|
141
|
+
status: 'active',
|
|
142
|
+
processAfter: Date.now() + 60 * 60 * 1000,
|
|
143
|
+
} as any)
|
|
144
|
+
automatedActionIds.push(a.id)
|
|
145
|
+
return a
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ===== A: the privilege gap — a non-admin with the declared verb must be rejected ===== */
|
|
149
|
+
|
|
150
|
+
const attackerRow = await seedAction()
|
|
151
|
+
const attackerAttempt = await post(attackerToken, attackerRow.id)
|
|
152
|
+
|
|
153
|
+
// Baseline on the same path with an admin token, so a blanket 401 (expired subscription,
|
|
154
|
+
// bad route, broken fixture) cannot masquerade as a working gate.
|
|
155
|
+
const baselineRow = await seedAction()
|
|
156
|
+
const baseline = await post(adminToken, baselineRow.id)
|
|
157
|
+
const badProbe = baseline.status === 401 || baseline.status === 403
|
|
158
|
+
|
|
159
|
+
record(
|
|
160
|
+
'A1: a non-admin holding automated_actions.update cannot call process',
|
|
161
|
+
!badProbe && (attackerAttempt.status === 401 || attackerAttempt.status === 403),
|
|
162
|
+
`attacker=${attackerAttempt.status} ${JSON.stringify(attackerAttempt.body)} baseline(admin)=${baseline.status}`
|
|
163
|
+
+ (badProbe ? ' BAD PROBE: admin baseline was also rejected' : ''),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
// The attacker's row must be untouched — the gate has to fire before the claim and the dispatch.
|
|
167
|
+
const attackerRowAfter = await sdk.api.automated_actions.getOne(attackerRow.id)
|
|
168
|
+
record(
|
|
169
|
+
'A2: a rejected non-admin call leaves the row unfired (status still active)',
|
|
170
|
+
attackerRowAfter.status === 'active',
|
|
171
|
+
`status=${attackerRowAfter.status} (expected active)`,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
/* ===== B: positive control — an admin can still call it ===== */
|
|
175
|
+
|
|
176
|
+
record(
|
|
177
|
+
'B1: an admin can still process a pending row',
|
|
178
|
+
baseline.status === 200 && baseline.body?.unimplemented === true,
|
|
179
|
+
`status=${baseline.status} body=${JSON.stringify(baseline.body)}`,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
/* ===== C: the replay gate — the same row must not fire twice ===== */
|
|
183
|
+
|
|
184
|
+
const replay = await post(adminToken, baselineRow.id)
|
|
185
|
+
record(
|
|
186
|
+
'C1: processing the same row twice is rejected (no replay)',
|
|
187
|
+
replay.status === 409,
|
|
188
|
+
`second call status=${replay.status} body=${JSON.stringify(replay.body)} (expected 409)`,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const baselineRowAfter = await sdk.api.automated_actions.getOne(baselineRow.id)
|
|
192
|
+
record(
|
|
193
|
+
'C2: a processed row is marked finished so neither a replay nor the worker re-runs it',
|
|
194
|
+
baselineRowAfter.status === 'finished',
|
|
195
|
+
`status=${baselineRowAfter.status} (expected finished)`,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
/* ===== report: print full table first, then assert (harness exits on first failure) ===== */
|
|
199
|
+
console.log('\n F-0192 check summary:')
|
|
200
|
+
for (const c of checks) {
|
|
201
|
+
console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.label} — ${c.detail}`)
|
|
202
|
+
}
|
|
203
|
+
console.log('')
|
|
204
|
+
|
|
205
|
+
for (const c of checks) {
|
|
206
|
+
assert(c.pass, `${c.label} — ${c.detail}`, c.label)
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
// Reverse order. The automated_actions go first: they hang off enduserId with
|
|
210
|
+
// onDependencyDelete 'delete', so deleting the enduser would take them with it.
|
|
211
|
+
for (const id of automatedActionIds) { try { await sdk.api.automated_actions.deleteOne(id) } catch {} }
|
|
212
|
+
if (enduserId) { try { await sdk.api.endusers.deleteOne(enduserId) } catch {} }
|
|
213
|
+
if (attackerId) { try { await sdk.api.users.deleteOne(attackerId) } catch {} }
|
|
214
|
+
if (roleId) { try { await sdk.api.role_based_access_permissions.deleteOne(roleId) } catch {} }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Allow running this test file independently
|
|
219
|
+
if (require.main === module) {
|
|
220
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
221
|
+
const sdk = new Session({ host })
|
|
222
|
+
const sdkNonAdmin = new Session({ host })
|
|
223
|
+
|
|
224
|
+
const runTests = async () => {
|
|
225
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
226
|
+
await automated_actions_process_gate_tests({ sdk, sdkNonAdmin })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
runTests()
|
|
230
|
+
.then(() => {
|
|
231
|
+
console.log("✅ F-0192 automated_actions.process gate test suite completed successfully")
|
|
232
|
+
process.exit(0)
|
|
233
|
+
})
|
|
234
|
+
.catch((error) => {
|
|
235
|
+
console.error("❌ F-0192 automated_actions.process gate test suite failed:", error)
|
|
236
|
+
process.exit(1)
|
|
237
|
+
})
|
|
238
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
assert_checks,
|
|
5
|
+
Check,
|
|
6
|
+
setup_isolated_tenant,
|
|
7
|
+
} from "../../setup_isolated"
|
|
8
|
+
|
|
9
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
10
|
+
|
|
11
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
12
|
+
|
|
13
|
+
// far enough out that a locally running worker never claims the rows we inspect
|
|
14
|
+
const FUTURE_START = () => new Date(Date.now() + 60 * 60 * 1000)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Regression test for F-0196
|
|
18
|
+
* (security-audit/findings/F-0196-add-to-journey-plants-foreign-enduserid.md).
|
|
19
|
+
*
|
|
20
|
+
* `endusers.add_to_journey` inserted an `automated_actions` row for EACH caller-supplied `enduserId`
|
|
21
|
+
* with only mongoId-shape validation and no tenant-membership check. The preceding
|
|
22
|
+
* `DB.endusers.updateMany` is scoped but its result was discarded, and
|
|
23
|
+
* `DB.automated_actions.insertMany` is a direct write that bypasses the `ensureValidDependentKeys`
|
|
24
|
+
* foreign-key check the generic CRUD route gets. The resulting row is stamped
|
|
25
|
+
* `businessId: <caller's tenant>` while carrying `enduserId: <a victim in another tenant>`.
|
|
26
|
+
*
|
|
27
|
+
* The worker then dereferences that enduser with the raw driver and no `businessId`
|
|
28
|
+
* (worker/modules/automations.ts, the F-0074/F-0111/F-0159 family), so the victim's record loads
|
|
29
|
+
* under the attacker's automation: `pushFormsToPortal` copies the victim's PHI into the attacker's
|
|
30
|
+
* `form_responses` (readable by their staff) and `iterableCustomEvent` writes an attacker-authored
|
|
31
|
+
* note into the victim's chart. F-0196 is the reachability that made those three exploitable.
|
|
32
|
+
*
|
|
33
|
+
* There are TWO independent plant sites and a fix to only one is a half fix:
|
|
34
|
+
* - inline branch, taken when `automationStepId` is supplied (api.ts add_to_journey handler)
|
|
35
|
+
* - module branch, taken when it is not: `handle_automation_event`'s
|
|
36
|
+
* `validEnduserIds = conditionsQuery ? <scoped find> : event.enduserIds` passes caller ids
|
|
37
|
+
* straight through for a step with NO conditions.
|
|
38
|
+
* Both are covered below (A1/A2).
|
|
39
|
+
*
|
|
40
|
+
* Design notes:
|
|
41
|
+
* - Two provisioned tenants, so the victim is a real foreign enduser and we can also assert their
|
|
42
|
+
* record was not mutated. Both must come from provisioning: `add_to_journey` is
|
|
43
|
+
* `requiresSubscription: true`, and a `create_and_join` org is born with an expired subscription.
|
|
44
|
+
* - The steps use `setEnduserStatus` — no vendor, no Bedrock — and `startAt` is an hour out so a
|
|
45
|
+
* running worker cannot process the rows before we read them.
|
|
46
|
+
* - `throttle` is deliberately omitted: it is the only way add_to_journey's per-pair rate limit can
|
|
47
|
+
* throw.
|
|
48
|
+
* - Everything is observable through `automated_actions` reads, so the worker is never needed.
|
|
49
|
+
*
|
|
50
|
+
* RED (pre-fix): A1, A2 and B2 fail — the victim's id appears in the attacker tenant's
|
|
51
|
+
* automated_actions. GREEN (post-fix): the foreign id is silently dropped and the caller's own
|
|
52
|
+
* endusers still enroll normally.
|
|
53
|
+
*/
|
|
54
|
+
export const add_to_journey_foreign_enduserid_checks = async (): Promise<Check[]> => {
|
|
55
|
+
const checks: Check[] = []
|
|
56
|
+
const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
|
|
57
|
+
|
|
58
|
+
let attacker: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
|
|
59
|
+
let victimTenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
/* ================= fixtures: two isolated tenants ================= */
|
|
63
|
+
|
|
64
|
+
;[attacker, victimTenant] = await Promise.all([
|
|
65
|
+
setup_isolated_tenant({ name: `F0196 attacker ${RAND()}`, users: [{ isAdmin: true }], apiKeys: 0 }),
|
|
66
|
+
setup_isolated_tenant({ name: `F0196 victim ${RAND()}`, users: [{ isAdmin: true }], apiKeys: 0 }),
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
const victim = await victimTenant.sdk.api.endusers.createOne({ fname: 'F0196', lname: 'Victim' })
|
|
70
|
+
const own = await attacker.sdk.api.endusers.createOne({ fname: 'F0196', lname: 'OwnPatient' })
|
|
71
|
+
|
|
72
|
+
record(
|
|
73
|
+
'setup: two separate tenants, victim belongs to the other one',
|
|
74
|
+
attacker.businessId !== victimTenant.businessId && !!victim.id && !!own.id,
|
|
75
|
+
`attacker=${attacker.businessId} victim tenant=${victimTenant.businessId} victimEnduser=${victim.id}`,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
// one journey + onJourneyStart step per branch, so the per-(enduser,journey) throttle bucket and
|
|
79
|
+
// the row sets stay independent
|
|
80
|
+
const makeJourney = async (title: string) => {
|
|
81
|
+
const journey = await attacker!.sdk.api.journeys.createOne({ title: `${title} ${RAND()}` })
|
|
82
|
+
const step = await attacker!.sdk.api.automation_steps.createOne({
|
|
83
|
+
journeyId: journey.id,
|
|
84
|
+
// benign and worker-local: no vendor, no Bedrock. NO `conditions`, which is precisely the
|
|
85
|
+
// vulnerable branch in handle_automation_event.
|
|
86
|
+
action: { type: 'setEnduserStatus', info: { status: 'F0196' } },
|
|
87
|
+
events: [{ type: 'onJourneyStart', info: {} }],
|
|
88
|
+
} as any)
|
|
89
|
+
return { journey, step }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const inline = await makeJourney('F0196 inline')
|
|
93
|
+
const modulePath = await makeJourney('F0196 module')
|
|
94
|
+
const mixed = await makeJourney('F0196 mixed')
|
|
95
|
+
|
|
96
|
+
/** every automated_actions row the attacker tenant can see for a journey */
|
|
97
|
+
const rowsFor = async (journeyId: string) => (
|
|
98
|
+
await attacker!.sdk.api.automated_actions.getSome({ filter: { journeyId } } as any)
|
|
99
|
+
)
|
|
100
|
+
const enduserIdsFor = async (journeyId: string) => (await rowsFor(journeyId)).map((r: any) => r.enduserId)
|
|
101
|
+
|
|
102
|
+
/* ===== A: the foreign id must never be planted, on EITHER branch ===== */
|
|
103
|
+
|
|
104
|
+
// A1 — inline branch: automationStepId supplied
|
|
105
|
+
const inlineResult = await attacker.sdk.api.endusers.add_to_journey({
|
|
106
|
+
enduserIds: [victim.id],
|
|
107
|
+
journeyId: inline.journey.id,
|
|
108
|
+
automationStepId: inline.step.id,
|
|
109
|
+
startAt: FUTURE_START(),
|
|
110
|
+
} as any).then(() => 'resolved').catch((e: any) => `rejected: ${e?.message || e}`)
|
|
111
|
+
|
|
112
|
+
const inlineIds = await enduserIdsFor(inline.journey.id)
|
|
113
|
+
record(
|
|
114
|
+
'A1: inline branch (automationStepId) does not plant a foreign enduserId',
|
|
115
|
+
!inlineIds.includes(victim.id),
|
|
116
|
+
`call=${inlineResult}; automated_actions enduserIds=${JSON.stringify(inlineIds)} (must not contain ${victim.id})`,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// A2 — module branch: no automationStepId, so it routes through handle_automation_event
|
|
120
|
+
const moduleResult = await attacker.sdk.api.endusers.add_to_journey({
|
|
121
|
+
enduserIds: [victim.id],
|
|
122
|
+
journeyId: modulePath.journey.id,
|
|
123
|
+
startAt: FUTURE_START(),
|
|
124
|
+
} as any).then(() => 'resolved').catch((e: any) => `rejected: ${e?.message || e}`)
|
|
125
|
+
|
|
126
|
+
const moduleIds = await enduserIdsFor(modulePath.journey.id)
|
|
127
|
+
record(
|
|
128
|
+
'A2: module branch (no automationStepId) does not plant a foreign enduserId',
|
|
129
|
+
!moduleIds.includes(victim.id),
|
|
130
|
+
`call=${moduleResult}; automated_actions enduserIds=${JSON.stringify(moduleIds)} (must not contain ${victim.id})`,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// A3 — the victim's own record must be untouched (the scoped updateMany should already ensure it)
|
|
134
|
+
const victimAfter = await victimTenant.sdk.api.endusers.getOne(victim.id)
|
|
135
|
+
const victimJourneys = (victimAfter as any)?.journeys ?? {}
|
|
136
|
+
record(
|
|
137
|
+
'A3: the victim enduser was not enrolled in the attacker\'s journey',
|
|
138
|
+
!victimJourneys[inline.journey.id] && !victimJourneys[modulePath.journey.id],
|
|
139
|
+
`victim journeys=${JSON.stringify(victimJourneys)}`,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
/* ===== B: legitimate use must keep working ===== */
|
|
143
|
+
|
|
144
|
+
// B1 — positive control on both branches, so a blanket failure can't masquerade as a fix
|
|
145
|
+
await attacker.sdk.api.endusers.add_to_journey({
|
|
146
|
+
enduserIds: [own.id],
|
|
147
|
+
journeyId: inline.journey.id,
|
|
148
|
+
automationStepId: inline.step.id,
|
|
149
|
+
startAt: FUTURE_START(),
|
|
150
|
+
} as any)
|
|
151
|
+
await attacker.sdk.api.endusers.add_to_journey({
|
|
152
|
+
enduserIds: [own.id],
|
|
153
|
+
journeyId: modulePath.journey.id,
|
|
154
|
+
startAt: FUTURE_START(),
|
|
155
|
+
} as any)
|
|
156
|
+
|
|
157
|
+
const inlineOwn = await enduserIdsFor(inline.journey.id)
|
|
158
|
+
const moduleOwn = await enduserIdsFor(modulePath.journey.id)
|
|
159
|
+
record(
|
|
160
|
+
'B1: the caller\'s OWN enduser still enrolls on both branches',
|
|
161
|
+
inlineOwn.includes(own.id) && moduleOwn.includes(own.id),
|
|
162
|
+
`inline=${JSON.stringify(inlineOwn)} module=${JSON.stringify(moduleOwn)} (both must contain ${own.id})`,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
// B2 — a mixed list must enroll the visible id and drop only the foreign one: proves an
|
|
166
|
+
// intersect, not an all-or-nothing rejection
|
|
167
|
+
const mixedResult = await attacker.sdk.api.endusers.add_to_journey({
|
|
168
|
+
enduserIds: [own.id, victim.id],
|
|
169
|
+
journeyId: mixed.journey.id,
|
|
170
|
+
automationStepId: mixed.step.id,
|
|
171
|
+
startAt: FUTURE_START(),
|
|
172
|
+
} as any).then(() => 'resolved').catch((e: any) => `rejected: ${e?.message || e}`)
|
|
173
|
+
|
|
174
|
+
const mixedIds = await enduserIdsFor(mixed.journey.id)
|
|
175
|
+
record(
|
|
176
|
+
'B2: a mixed list enrolls the visible enduser and drops only the foreign one',
|
|
177
|
+
mixedIds.includes(own.id) && !mixedIds.includes(victim.id),
|
|
178
|
+
`call=${mixedResult}; enduserIds=${JSON.stringify(mixedIds)} (want ${own.id}, not ${victim.id})`,
|
|
179
|
+
)
|
|
180
|
+
} finally {
|
|
181
|
+
// Teardown happens here, BEFORE anything asserts — assert calls process.exit and would otherwise
|
|
182
|
+
// strand these tenants permanently (nothing can delete an organization record).
|
|
183
|
+
for (const t of [attacker, victimTenant]) {
|
|
184
|
+
if (t) { try { await t.teardown() } catch (e) { console.error('teardown failed', e) } }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return checks
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Asserting wrapper, for standalone runs and for sequential use in the suite. */
|
|
192
|
+
export const add_to_journey_foreign_enduserid_tests = async () => {
|
|
193
|
+
assert_checks('F-0196 add_to_journey foreign enduserId', await add_to_journey_foreign_enduserid_checks())
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Allow running this test file independently
|
|
197
|
+
if (require.main === module) {
|
|
198
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
199
|
+
add_to_journey_foreign_enduserid_tests()
|
|
200
|
+
.then(() => {
|
|
201
|
+
console.log("✅ F-0196 add_to_journey foreign enduserId test suite completed successfully")
|
|
202
|
+
process.exit(0)
|
|
203
|
+
})
|
|
204
|
+
.catch((error) => {
|
|
205
|
+
console.error("❌ F-0196 add_to_journey foreign enduserId test suite failed:", error)
|
|
206
|
+
process.exit(1)
|
|
207
|
+
})
|
|
208
|
+
}
|