@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,162 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session, EnduserSession } from "../../../sdk"
|
|
4
|
+
import { assert, log_header } from "@tellescope/testing"
|
|
5
|
+
import { setup_tests, authenticate_enduser_via_token } from "../../setup"
|
|
6
|
+
import { PROVIDER_PERMISSIONS } from "@tellescope/constants"
|
|
7
|
+
|
|
8
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression test for F-0185 — `calendar_events.startLinkToken` privilege escalation.
|
|
12
|
+
*
|
|
13
|
+
* startLinkToken is a DOCUMENTED feature ("Video Call Start Links"): a tenant's staff / API-key
|
|
14
|
+
* integration PATCHes a CalendarEvent to set a random token, then sends the host user the
|
|
15
|
+
* `/video/startlink?token=...` link, which `session_for_start_link` exchanges for a scope-limited
|
|
16
|
+
* User (staff) authToken for the first `type:'user'` attendee, MFA bypassed. Nothing in the codebase
|
|
17
|
+
* reads the token value back (the redeem matches server-side; the integration holds its own copy), so
|
|
18
|
+
* the field is write-only-by-design.
|
|
19
|
+
*
|
|
20
|
+
* TWO escalation vectors let a patient (or a low-privilege staff user) obtain the host's session:
|
|
21
|
+
* WRITE — `enduserActions.update` + no `enduserUpdatesDisabled` let an enduser attendee PATCH
|
|
22
|
+
* `startLinkToken` to a value they choose, then redeem it.
|
|
23
|
+
* READ — no `redactions` let an enduser attendee (or a non-admin staff user with calendar read)
|
|
24
|
+
* read a LEGITIMATELY staff-set token and redeem it, with no write.
|
|
25
|
+
*
|
|
26
|
+
* Complete fix = TWO flags on schema.ts `startLinkToken`:
|
|
27
|
+
* enduserUpdatesDisabled: true (closes WRITE; preserves the staff/API-key write the feature needs)
|
|
28
|
+
* redactions: ['all'] (closes READ for endusers AND non-admin users; safe — nothing reads
|
|
29
|
+
* the value back). NOT `readonly` (would break the staff write — A2).
|
|
30
|
+
*
|
|
31
|
+
* Checks (collected, then asserted, so a RED run shows every failing vector):
|
|
32
|
+
* A1 enduser attendee PATCH of startLinkToken is REJECTED (WRITE gate)
|
|
33
|
+
* A2 a staff-set token still redeems for the host's session (feature preserved)
|
|
34
|
+
* A3 an enduser-set token (if the write lands) does NOT redeem (defence-in-depth)
|
|
35
|
+
* A4 an enduser attendee does NOT receive startLinkToken on read (READ gate, endusers)
|
|
36
|
+
* A5 a non-admin staff user does NOT receive startLinkToken on read (READ gate, users)
|
|
37
|
+
*
|
|
38
|
+
* No vendor, no Bedrock; the "host" is a throwaway user. Cleans up every created record.
|
|
39
|
+
*/
|
|
40
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
41
|
+
|
|
42
|
+
export const startlinktoken_escalation_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
43
|
+
log_header("F-0185: startLinkToken enduser-writable / readable → staff session escalation")
|
|
44
|
+
|
|
45
|
+
const businessId = sdk.userInfo.businessId
|
|
46
|
+
const userIds: string[] = []
|
|
47
|
+
const enduserIds: string[] = []
|
|
48
|
+
const eventIds: string[] = []
|
|
49
|
+
const roleIds: string[] = []
|
|
50
|
+
|
|
51
|
+
const checks: { label: string, pass: boolean, detail: string }[] = []
|
|
52
|
+
const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// Throwaway host staff user — the account an attacker would escalate INTO.
|
|
56
|
+
const host_user = await sdk.api.users.createOne({
|
|
57
|
+
email: `f0185-host-${RAND()}@tellescope.example`, fname: 'F0185', lname: 'Host',
|
|
58
|
+
verifiedEmail: true, notificationEmailsDisabled: true,
|
|
59
|
+
} as any)
|
|
60
|
+
userIds.push(host_user.id)
|
|
61
|
+
|
|
62
|
+
// The attacking patient, an attendee of the host's appointment.
|
|
63
|
+
const patient = await sdk.api.endusers.createOne({
|
|
64
|
+
email: `f0185-patient-${RAND()}@tellescope.example`, fname: 'F0185', lname: 'Patient',
|
|
65
|
+
})
|
|
66
|
+
enduserIds.push(patient.id)
|
|
67
|
+
|
|
68
|
+
const event = await sdk.api.calendar_events.createOne({
|
|
69
|
+
title: 'F-0185 appointment', durationInMinutes: 30, startTimeInMS: Date.now() + 3600_000,
|
|
70
|
+
attendees: [{ type: 'user', id: host_user.id }, { type: 'enduser', id: patient.id }],
|
|
71
|
+
} as any)
|
|
72
|
+
eventIds.push(event.id)
|
|
73
|
+
|
|
74
|
+
const patientSDK = new EnduserSession({ host, businessId })
|
|
75
|
+
await authenticate_enduser_via_token(sdk, patientSDK, { id: patient.id })
|
|
76
|
+
|
|
77
|
+
// ---- A1: WRITE gate — enduser attendee cannot PATCH startLinkToken ----
|
|
78
|
+
const attackerToken = `f0185-attacker-${RAND()}${RAND()}`
|
|
79
|
+
let wrote = false, writeErr = ''
|
|
80
|
+
try {
|
|
81
|
+
await patientSDK.api.calendar_events.updateOne(event.id, { startLinkToken: attackerToken } as any)
|
|
82
|
+
wrote = true
|
|
83
|
+
} catch (err: any) { writeErr = err?.message ?? String(err) }
|
|
84
|
+
record('A1: enduser cannot write startLinkToken', !wrote,
|
|
85
|
+
wrote ? 'PATCH ACCEPTED — patient set a staff start-link token' : `rejected (${writeErr})`)
|
|
86
|
+
|
|
87
|
+
// ---- A3: defence-in-depth — an enduser-set token must not redeem ----
|
|
88
|
+
// Only lands when A1 failed (pre-fix). Read back as admin to see if the value stuck.
|
|
89
|
+
const adminView = await sdk.api.calendar_events.getOne(event.id)
|
|
90
|
+
if ((adminView as any).startLinkToken === attackerToken) {
|
|
91
|
+
let redeemed: any = null
|
|
92
|
+
try { redeemed = await new Session({ host }).api.calendar_events.session_for_start_link({ token: attackerToken }) } catch {}
|
|
93
|
+
record('A3: enduser-set token does not mint a staff session', !redeemed?.authToken,
|
|
94
|
+
redeemed?.authToken ? `minted a STAFF authToken for host ${host_user.id}` : 'not redeemable')
|
|
95
|
+
} else {
|
|
96
|
+
record('A3: enduser-set token does not mint a staff session', true, '(n/a — write blocked, value never landed)')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- A2: feature pin — a STAFF-set token still redeems (write + redeem must both survive the fix) ----
|
|
100
|
+
const staffToken = `f0185-staff-${RAND()}${RAND()}`
|
|
101
|
+
let staffWriteErr = ''
|
|
102
|
+
try { await sdk.api.calendar_events.updateOne(event.id, { startLinkToken: staffToken } as any) }
|
|
103
|
+
catch (e: any) { staffWriteErr = e?.message ?? String(e) }
|
|
104
|
+
let legit: any = null
|
|
105
|
+
try { legit = await new Session({ host }).api.calendar_events.session_for_start_link({ token: staffToken }) }
|
|
106
|
+
catch (e: any) { legit = { error: e?.message ?? String(e) } }
|
|
107
|
+
record('A2: staff-set start link still works', !!legit?.authToken && legit.eventId === event.id,
|
|
108
|
+
`staffWrite=${staffWriteErr ? 'REJECTED(' + staffWriteErr + ')' : 'ok'} authToken=${!!legit?.authToken} eventId=${legit?.eventId} ${legit?.error ? '(' + legit.error + ')' : ''}`)
|
|
109
|
+
|
|
110
|
+
// ---- A4: READ gate (endusers) — patient attendee must not receive the token ----
|
|
111
|
+
let enduserRead: any = null
|
|
112
|
+
try { enduserRead = await patientSDK.api.calendar_events.getOne(event.id) } catch {}
|
|
113
|
+
record('A4: startLinkToken redacted from enduser reads',
|
|
114
|
+
!enduserRead || (enduserRead as any).startLinkToken === undefined,
|
|
115
|
+
`enduser read startLinkToken=${(enduserRead as any)?.startLinkToken ?? '(absent)'} — could redeem a staff-set token`)
|
|
116
|
+
|
|
117
|
+
// ---- A5: READ gate (non-admin users) — low-priv staff must not receive the token ----
|
|
118
|
+
const rbap = await sdk.api.role_based_access_permissions.createOne({
|
|
119
|
+
role: `F0185NonAdmin-${RAND()}`,
|
|
120
|
+
// Non-admin (non-Admin-role) staff with tenant-wide calendar READ — enough to load the event,
|
|
121
|
+
// so the assertion isolates redaction (not access). Spread a valid full permissions shape.
|
|
122
|
+
permissions: { ...PROVIDER_PERMISSIONS, calendar_events: { ...(PROVIDER_PERMISSIONS as any).calendar_events, read: 'All', readMany: 'All' } },
|
|
123
|
+
} as any).catch((e: any) => { console.warn('A5 role create failed:', e?.message ?? e); return null })
|
|
124
|
+
if (rbap) {
|
|
125
|
+
roleIds.push(rbap.id)
|
|
126
|
+
const nonAdmin = await sdk.api.users.createOne({
|
|
127
|
+
email: `f0185-nonadmin-${RAND()}@tellescope.example`, fname: 'F0185', lname: 'NonAdmin',
|
|
128
|
+
verifiedEmail: true, notificationEmailsDisabled: true, roles: [rbap.role],
|
|
129
|
+
} as any)
|
|
130
|
+
userIds.push(nonAdmin.id)
|
|
131
|
+
const { authToken } = await sdk.api.users.generate_auth_token({ id: nonAdmin.id })
|
|
132
|
+
const nonAdminSDK = new Session({ host, authToken })
|
|
133
|
+
let staffRead: any = null
|
|
134
|
+
try { staffRead = await nonAdminSDK.api.calendar_events.getOne(event.id) } catch {}
|
|
135
|
+
record('A5: startLinkToken redacted from non-admin user reads',
|
|
136
|
+
!staffRead || (staffRead as any).startLinkToken === undefined,
|
|
137
|
+
`non-admin user read startLinkToken=${(staffRead as any)?.startLinkToken ?? '(absent)'} — staff→host escalation via read`)
|
|
138
|
+
} else {
|
|
139
|
+
record('A5: startLinkToken redacted from non-admin user reads', true, '(n/a — could not create a non-admin role in this env)')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Report the full table before asserting (the harness exits on the first failed assert).
|
|
143
|
+
console.log('\n F-0185 check summary:')
|
|
144
|
+
for (const c of checks) console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.label} — ${c.detail}`)
|
|
145
|
+
console.log('')
|
|
146
|
+
for (const c of checks) assert(c.pass, `${c.label} — ${c.detail}`, c.label)
|
|
147
|
+
} finally {
|
|
148
|
+
for (const id of eventIds) { try { await sdk.api.calendar_events.deleteOne(id) } catch {} }
|
|
149
|
+
for (const id of enduserIds) { try { await sdk.api.endusers.deleteOne(id) } catch {} }
|
|
150
|
+
for (const id of userIds) { try { await sdk.api.users.deleteOne(id) } catch {} }
|
|
151
|
+
for (const id of roleIds) { try { await sdk.api.role_based_access_permissions.deleteOne(id) } catch {} }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (require.main === module) {
|
|
156
|
+
const sdk = new Session({ host })
|
|
157
|
+
const sdkNonAdmin = new Session({ host })
|
|
158
|
+
setup_tests(sdk, sdkNonAdmin)
|
|
159
|
+
.then(() => startlinktoken_escalation_tests({ sdk, sdkNonAdmin }))
|
|
160
|
+
.then(() => { console.log("✅ F-0185 startLinkToken escalation test suite completed successfully"); process.exit(0) })
|
|
161
|
+
.catch((error) => { console.error("❌ F-0185 startLinkToken escalation test suite failed:", error); process.exit(1) })
|
|
162
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
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 } 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
|
+
type Check = {
|
|
21
|
+
label: string,
|
|
22
|
+
pass: boolean,
|
|
23
|
+
detail: string,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Regression test for F-0191
|
|
28
|
+
* (security-audit/findings/F-0191-schema-adminonly-not-enforced.md).
|
|
29
|
+
*
|
|
30
|
+
* `adminOnly: true` on a CUSTOM action in schema.ts was documentation-only. The runtime gate
|
|
31
|
+
* (routing.ts:944) reads the flag off the options object built by getCustomEndpointFields, and
|
|
32
|
+
* api.ts:609 set `adminOnly: o.adminOnly` without ever consulting `action.adminOnly`. So every
|
|
33
|
+
* admin-only custom action had to declare the flag twice, and the copies desynced: 11 of 20
|
|
34
|
+
* declared custom actions were unenforced at runtime while both public doc artifacts
|
|
35
|
+
* (openapi.json and the unauthenticated GET /documentation feed, which read the SCHEMA flag)
|
|
36
|
+
* advertised "**Admin only.**".
|
|
37
|
+
*
|
|
38
|
+
* DEFAULT CRUD actions never had this bug — routing.ts:2434 etc. read
|
|
39
|
+
* model.defaultActions.<op>.adminOnly straight off the schema. The fix
|
|
40
|
+
* (`adminOnly: o.adminOnly ?? action.adminOnly`) makes custom actions behave the same way.
|
|
41
|
+
*
|
|
42
|
+
* Five of the 11 were de-flagged in the schema instead, because the flag itself was wrong and
|
|
43
|
+
* enforcing it would revert a deliberate decision: users.invite_user carries an explicit
|
|
44
|
+
* "no longer admin only, but controlled by users.create" and send_invitation_to_existing is its
|
|
45
|
+
* sibling; organizations.create_and_join is the self-service signup path and would 401 every new
|
|
46
|
+
* org (a fresh user has no roles — the handler is what grants Admin); sync_note_to_canvas is
|
|
47
|
+
* designed around `access: 'read'` for non-admin callers; background_errors.mark_read substitutes
|
|
48
|
+
* its own RBAC check that intentionally admits non-admins holding the verb.
|
|
49
|
+
*
|
|
50
|
+
* SAFETY: every probe fails EARLY inside its handler — after the adminOnly gate but before any
|
|
51
|
+
* vendor call. Pre-fix these requests really do reach the handler, and these handlers talk to
|
|
52
|
+
* Twilio, Athena and Canvas:
|
|
53
|
+
* - trigger_events : no `action` key + an absent triggerId => the loop `continue`s (inert)
|
|
54
|
+
* - athena-subscription: frequency 1 trips the `frequency < 5` guard on the handler's first line
|
|
55
|
+
* - sync-athena : 404s on the missing Athena integration (skipped if one exists)
|
|
56
|
+
* - link-twilio : takes NO parameters and can CREATE A REAL TWILIO SUBACCOUNT, so it is
|
|
57
|
+
* probed only when the org already has a twilioSID (handler short-circuits)
|
|
58
|
+
* - twilio-embed : no phoneNumberSid => 404 no-config or 400, both before any Twilio call
|
|
59
|
+
* - handle-autoreply : absent enduserId => `if (!enduser) return` (communications.ts:42)
|
|
60
|
+
* Input validation runs BEFORE the adminOnly gate (routing.ts:921 orders access, then validate),
|
|
61
|
+
* so every probe body must be schema-VALID or the test would prove nothing.
|
|
62
|
+
*
|
|
63
|
+
* RED (pre-fix): the six group-B rows fail — each reaches its handler and returns a non-401.
|
|
64
|
+
* GREEN (post-fix): all six return 401 "Admin access only"; the group-A controls are unchanged.
|
|
65
|
+
*/
|
|
66
|
+
export const schema_adminonly_enforcement_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
67
|
+
log_header("F-0191: schema-declared adminOnly must be enforced on custom actions")
|
|
68
|
+
|
|
69
|
+
const checks: Check[] = []
|
|
70
|
+
const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
|
|
71
|
+
|
|
72
|
+
// Raw axios: the SDK's parseError discards the HTTP status, and the status is the whole point.
|
|
73
|
+
const request = async (
|
|
74
|
+
authToken: string, method: 'get' | 'post', path: string, data?: any,
|
|
75
|
+
) => {
|
|
76
|
+
const r = await axios.request({
|
|
77
|
+
method,
|
|
78
|
+
url: `${host}/v1${path}`,
|
|
79
|
+
...method === 'get' ? { params: data } : { data },
|
|
80
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
81
|
+
validateStatus: () => true,
|
|
82
|
+
})
|
|
83
|
+
return { status: r.status, body: r.data }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isAdminDenial = (r: { status: number, body: any }) => (
|
|
87
|
+
r.status === 401 && r.body?.message === 'Admin access only'
|
|
88
|
+
)
|
|
89
|
+
const summarize = (r: { status: number, body: any }) => (
|
|
90
|
+
`${r.status} ${(JSON.stringify(r.body) ?? '').slice(0, 120)}`
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
let roleId = ''
|
|
94
|
+
let attackerId = ''
|
|
95
|
+
let enduserId = ''
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
/* ================= fixtures ================= */
|
|
99
|
+
|
|
100
|
+
const role = `f0191-staff-${RAND()}`
|
|
101
|
+
const rbap = await sdk.api.role_based_access_permissions.createOne({
|
|
102
|
+
role,
|
|
103
|
+
permissions: {
|
|
104
|
+
...PROVIDER_PERMISSIONS,
|
|
105
|
+
// grant exactly the verbs the actions under test declare, so a rejection proves the ADMIN
|
|
106
|
+
// gate fired rather than ordinary RBAC
|
|
107
|
+
automation_triggers: FULL_ACCESS,
|
|
108
|
+
organizations: FULL_ACCESS,
|
|
109
|
+
availability_blocks: FULL_ACCESS,
|
|
110
|
+
background_errors: FULL_ACCESS,
|
|
111
|
+
users: FULL_ACCESS,
|
|
112
|
+
},
|
|
113
|
+
} as any)
|
|
114
|
+
roleId = rbap.id
|
|
115
|
+
|
|
116
|
+
const attacker = await sdk.api.users.createOne({
|
|
117
|
+
email: `f0191-attacker-${RAND()}@tellescope.example`,
|
|
118
|
+
fname: 'F0191', lname: 'Staff',
|
|
119
|
+
verifiedEmail: true,
|
|
120
|
+
notificationEmailsDisabled: true,
|
|
121
|
+
roles: [role],
|
|
122
|
+
} as any)
|
|
123
|
+
attackerId = attacker.id
|
|
124
|
+
|
|
125
|
+
const attackerToken = (await sdk.api.users.generate_auth_token({ id: attackerId })).authToken
|
|
126
|
+
const adminToken = (await sdk.api.users.generate_auth_token({ id: sdk.userInfo.id })).authToken
|
|
127
|
+
|
|
128
|
+
const attackerSDK = new Session({ host, authToken: attackerToken })
|
|
129
|
+
await attackerSDK.refresh_session()
|
|
130
|
+
record(
|
|
131
|
+
'setup: attacker is a non-admin holding every verb under test',
|
|
132
|
+
attackerSDK.userInfo.id === attackerId && !(attackerSDK.userInfo.roles ?? []).includes('Admin'),
|
|
133
|
+
`id=${attackerSDK.userInfo.id} roles=${JSON.stringify(attackerSDK.userInfo.roles)}`,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const enduser = await sdk.api.endusers.createOne({ fname: 'F0191', lname: 'Patient' })
|
|
137
|
+
enduserId = enduser.id
|
|
138
|
+
|
|
139
|
+
// A guaranteed-valid, guaranteed-absent id: create then delete, so probes hit the handler's
|
|
140
|
+
// "not found" branch rather than tripping input validation (which runs BEFORE the admin gate).
|
|
141
|
+
const doomed = await sdk.api.endusers.createOne({ fname: 'F0191', lname: 'Absent' })
|
|
142
|
+
const ABSENT_ID = doomed.id
|
|
143
|
+
await sdk.api.endusers.deleteOne(ABSENT_ID)
|
|
144
|
+
|
|
145
|
+
/* ===== environment guards for the two probes input alone cannot make safe ===== */
|
|
146
|
+
|
|
147
|
+
let athenaIntegrationExists = false
|
|
148
|
+
try {
|
|
149
|
+
const existing = await sdk.api.integrations.load_redacted({})
|
|
150
|
+
athenaIntegrationExists = !!existing.integrations.find((i: any) => /athena/i.test(i.title ?? ''))
|
|
151
|
+
} catch { /* load not permitted — assume absent */ }
|
|
152
|
+
|
|
153
|
+
let orgHasTwilioSID = false
|
|
154
|
+
try {
|
|
155
|
+
const org = await sdk.api.organizations.getOne(sdk.userInfo.businessId)
|
|
156
|
+
orgHasTwilioSID = !!(org as any)?.twilioSID
|
|
157
|
+
} catch { /* ignore */ }
|
|
158
|
+
|
|
159
|
+
/* ===== GROUP B: the six genuine gaps — each must become 401 ===== */
|
|
160
|
+
|
|
161
|
+
const groupB: {
|
|
162
|
+
label: string, method: 'get' | 'post', path: string, data?: any, skip?: string,
|
|
163
|
+
}[] = [
|
|
164
|
+
{
|
|
165
|
+
label: 'automation_triggers.trigger_events',
|
|
166
|
+
method: 'post', path: '/automation-triggers/trigger-events',
|
|
167
|
+
// no `action` key: stays off the arbitrary-action-execution branch entirely
|
|
168
|
+
data: { triggers: [{ enduserId, automationTriggerId: ABSENT_ID }] },
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
label: 'organizations.add_athena_subscription',
|
|
172
|
+
method: 'post', path: '/organizations/athena-subscription',
|
|
173
|
+
data: { type: 'patients', frequency: 1 }, // trips `frequency < 5` on the handler's first line
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
label: 'organizations.sync_athena_subscription',
|
|
177
|
+
method: 'post', path: '/organizations/sync-athena-subscription',
|
|
178
|
+
data: { type: 'patients' },
|
|
179
|
+
skip: athenaIntegrationExists
|
|
180
|
+
? 'org HAS an Athena integration — probing would call Athena.process_subscription for real'
|
|
181
|
+
: undefined,
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
label: 'organizations.link_twilio',
|
|
185
|
+
method: 'post', path: '/organizations/link-twilio',
|
|
186
|
+
data: {},
|
|
187
|
+
skip: orgHasTwilioSID
|
|
188
|
+
? undefined
|
|
189
|
+
: 'org has no twilioSID — probing would CREATE A REAL TWILIO SUBACCOUNT',
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
label: 'organizations.load_twilio_embed',
|
|
193
|
+
method: 'get', path: '/organizations/twilio-embed',
|
|
194
|
+
data: { type: 'tfn-verification' }, // no phoneNumberSid => rejected before any Twilio call
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
label: 'availability_blocks.handle_autoreply',
|
|
198
|
+
method: 'post', path: '/handle-out-of-office-autoreply',
|
|
199
|
+
data: { channel: 'Email', enduserId: ABSENT_ID }, // communications.ts:42 `if (!enduser) return`
|
|
200
|
+
},
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
for (const probe of groupB) {
|
|
204
|
+
if (probe.skip) {
|
|
205
|
+
record(`B: ${probe.label} rejects a non-admin`, true, `SKIPPED (safety) — ${probe.skip}`)
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const attackerResult = await request(attackerToken, probe.method, probe.path, probe.data)
|
|
210
|
+
// baseline: an admin must NOT be admin-denied on the same path, so a blanket 401 can't pass
|
|
211
|
+
const baseline = await request(adminToken, probe.method, probe.path, probe.data)
|
|
212
|
+
const badProbe = isAdminDenial(baseline)
|
|
213
|
+
|
|
214
|
+
record(
|
|
215
|
+
`B: ${probe.label} rejects a non-admin`,
|
|
216
|
+
!badProbe && isAdminDenial(attackerResult),
|
|
217
|
+
`attacker=${summarize(attackerResult)} | admin baseline=${baseline.status}`
|
|
218
|
+
+ (badProbe ? ' BAD PROBE: admin was also admin-denied' : ''),
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* ===== GROUP A: de-flagged actions must NOT become admin-only ===== */
|
|
223
|
+
|
|
224
|
+
const groupA: { label: string, method: 'get' | 'post', path: string, data?: any }[] = [
|
|
225
|
+
{
|
|
226
|
+
label: 'organizations.sync_note_to_canvas',
|
|
227
|
+
method: 'post', path: '/organizations/sync-note-to-canvas',
|
|
228
|
+
data: { enduserId: ABSENT_ID, note: 'F-0191 control' }, // 404 Enduser not found
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
label: 'background_errors.mark_read',
|
|
232
|
+
method: 'post', path: '/background-errors/mark-read', data: {},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
label: 'users.send_invitation_to_existing',
|
|
236
|
+
method: 'post', path: '/invite-existing-user-to-organization',
|
|
237
|
+
// takes userId (not email); an absent id hits RecordNotFoundError, well short of any invite
|
|
238
|
+
data: { userId: ABSENT_ID },
|
|
239
|
+
},
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
for (const probe of groupA) {
|
|
243
|
+
const r = await request(attackerToken, probe.method, probe.path, probe.data)
|
|
244
|
+
record(
|
|
245
|
+
`A: ${probe.label} stays reachable by a non-admin (flag intentionally dropped)`,
|
|
246
|
+
!isAdminDenial(r),
|
|
247
|
+
`${summarize(r)} (expected a handler response, not 401 Admin access only)`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* ===== report: print full table first, then assert (harness exits on first failure) ===== */
|
|
252
|
+
console.log('\n F-0191 check summary:')
|
|
253
|
+
for (const c of checks) {
|
|
254
|
+
console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.label} — ${c.detail}`)
|
|
255
|
+
}
|
|
256
|
+
console.log('')
|
|
257
|
+
|
|
258
|
+
for (const c of checks) {
|
|
259
|
+
assert(c.pass, `${c.label} — ${c.detail}`, c.label)
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
if (enduserId) { try { await sdk.api.endusers.deleteOne(enduserId) } catch {} }
|
|
263
|
+
if (attackerId) { try { await sdk.api.users.deleteOne(attackerId) } catch {} }
|
|
264
|
+
if (roleId) { try { await sdk.api.role_based_access_permissions.deleteOne(roleId) } catch {} }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Allow running this test file independently
|
|
269
|
+
if (require.main === module) {
|
|
270
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
271
|
+
const sdk = new Session({ host })
|
|
272
|
+
const sdkNonAdmin = new Session({ host })
|
|
273
|
+
|
|
274
|
+
const runTests = async () => {
|
|
275
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
276
|
+
await schema_adminonly_enforcement_tests({ sdk, sdkNonAdmin })
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
runTests()
|
|
280
|
+
.then(() => {
|
|
281
|
+
console.log("✅ F-0191 schema adminOnly enforcement test suite completed successfully")
|
|
282
|
+
process.exit(0)
|
|
283
|
+
})
|
|
284
|
+
.catch((error) => {
|
|
285
|
+
console.error("❌ F-0191 schema adminOnly enforcement test suite failed:", error)
|
|
286
|
+
process.exit(1)
|
|
287
|
+
})
|
|
288
|
+
}
|