@oneuptime/common 12.0.7 → 12.0.8
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/Models/DatabaseModels/TelemetryEntity.ts +45 -0
- package/Models/DatabaseModels/TelemetryEntityRelationship.ts +26 -0
- package/Models/DatabaseModels/WorkflowLog.ts +38 -0
- package/Server/API/UserAPI.ts +16 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.ts +41 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.ts +17 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Middleware/MasterAdminAuthorization.ts +11 -6
- package/Server/Services/TelemetryEntityRelationshipService.ts +3 -0
- package/Server/Services/TelemetryEntityService.ts +114 -1
- package/Server/Types/Workflow/Components/API/Utils.ts +44 -1
- package/Server/Types/Workflow/TriggerCode.ts +12 -0
- package/Server/Types/Workflow/Workflow.ts +5 -0
- package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +689 -0
- package/Server/Utils/VM/VMAPI.ts +56 -8
- package/Tests/App/Dashboard/MonitorCriteriaAttributeFilter.test.tsx +574 -0
- package/Tests/Server/API/UserProjectsAPI.test.ts +478 -4
- package/Tests/Server/Services/TelemetryEntityManualCreate.test.ts +248 -0
- package/Tests/Server/Types/Workflow/Components/ApiComponentHeaders.test.ts +192 -0
- package/Tests/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.test.ts +152 -0
- package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +322 -0
- package/Tests/Server/Utils/VM/VMAPISubstitution.test.ts +243 -0
- package/Tests/Types/SerializableObjectDictionaryImportCycle.test.ts +197 -0
- package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +140 -0
- package/Tests/Types/Workflow/StepTrace.test.ts +235 -0
- package/Tests/Types/Workflow/TemplateSyntax.test.ts +379 -0
- package/Tests/Types/Workflow/Templates.test.ts +403 -0
- package/Tests/UI/Components/CustomTimeRangeModal.test.tsx +459 -0
- package/Tests/UI/Components/DictionaryAttributeFilterRow.test.tsx +477 -0
- package/Tests/UI/Components/Forms/AnchoredFieldPopupKeyboard.test.tsx +382 -0
- package/Tests/UI/Components/Forms/ColorPickerPicking.test.tsx +569 -0
- package/Tests/UI/Components/Forms/ValidationJSON.test.ts +241 -0
- package/Tests/UI/Components/LogTimeRangePicker.test.tsx +42 -0
- package/Tests/UI/Components/ModalPortalDismissal.test.tsx +90 -0
- package/Tests/UI/Components/TelemetryTimeRangePicker.test.tsx +49 -7
- package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +325 -0
- package/Tests/UI/Components/Workflow/GraphLint.test.ts +821 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +287 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +185 -0
- package/Tests/UI/Components/Workflow/ModelSchema.test.ts +174 -0
- package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +152 -0
- package/Tests/UI/Components/Workflow/Utils.test.ts +192 -0
- package/Tests/UI/Monitor/MonitorStepCriteriaView.test.tsx +432 -0
- package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +514 -0
- package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +240 -0
- package/Tests/Utils/Schema/AnalyticsModelSchema.test.ts +469 -0
- package/Tests/Utils/Schema/ModelSchema.test.ts +268 -0
- package/Tests/Utils/Telemetry/EntityKeyNonTelemetry.test.ts +193 -0
- package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +31 -0
- package/Types/SerializableObjectDictionary.ts +133 -39
- package/Types/Telemetry/EntitySource.ts +40 -0
- package/Types/Telemetry/EntityType.ts +27 -0
- package/Types/Telemetry/EntityTypeGroups.ts +65 -0
- package/Types/Workflow/Component.ts +29 -0
- package/Types/Workflow/Components/API.ts +35 -0
- package/Types/Workflow/Components/BaseModel.ts +7 -3
- package/Types/Workflow/StepTrace.ts +181 -0
- package/Types/Workflow/TemplateSyntax.ts +499 -0
- package/Types/Workflow/Templates.ts +316 -0
- package/UI/Components/Date/CustomTimeRangeModal.tsx +278 -0
- package/UI/Components/Date/TimeRangePickerDropdown.tsx +250 -0
- package/UI/Components/Dictionary/Dictionary.tsx +23 -6
- package/UI/Components/Forms/Fields/ColorPicker.tsx +66 -10
- package/UI/Components/Forms/Fields/IconPicker.tsx +48 -10
- package/UI/Components/Forms/Types/Field.ts +7 -0
- package/UI/Components/Forms/Validation.ts +63 -1
- package/UI/Components/Input/Input.tsx +31 -3
- package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +11 -216
- package/UI/Components/Modal/Modal.tsx +37 -4
- package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +11 -210
- package/UI/Components/Workflow/ArgumentsForm.tsx +194 -7
- package/UI/Components/Workflow/Component.tsx +28 -26
- package/UI/Components/Workflow/ComponentSettingsModal.tsx +69 -8
- package/UI/Components/Workflow/GraphLint.ts +591 -0
- package/UI/Components/Workflow/ModelColumnEditor.tsx +396 -0
- package/UI/Components/Workflow/ModelSchema.ts +196 -0
- package/UI/Components/Workflow/RunForm.tsx +41 -7
- package/UI/Components/Workflow/RunStatusWatcher.ts +122 -0
- package/UI/Components/Workflow/StepTraceViewer.tsx +186 -0
- package/UI/Components/Workflow/Utils.ts +93 -1
- package/UI/Components/Workflow/Workflow.tsx +77 -4
- package/UI/Types/LayeredDismissal.ts +31 -0
- package/UI/Types/UseAnchoredFieldPopup.ts +99 -0
- package/Utils/Monitor/NetworkDeviceRoleUtil.ts +533 -0
- package/Utils/Monitor/NetworkTopologyUtil.ts +45 -7
- package/Utils/Telemetry/EntityKey.ts +66 -0
- package/build/dist/Models/DatabaseModels/TelemetryEntity.js +47 -0
- package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js +27 -0
- package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkflowLog.js +39 -0
- package/build/dist/Models/DatabaseModels/WorkflowLog.js.map +1 -1
- package/build/dist/Server/API/UserAPI.js +16 -1
- package/build/dist/Server/API/UserAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/MasterAdminAuthorization.js +11 -6
- package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
- package/build/dist/Server/Services/TelemetryEntityRelationshipService.js +3 -0
- package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +1 -1
- package/build/dist/Server/Services/TelemetryEntityService.js +91 -1
- package/build/dist/Server/Services/TelemetryEntityService.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Utils.js +28 -0
- package/build/dist/Server/Types/Workflow/Components/API/Utils.js.map +1 -1
- package/build/dist/Server/Types/Workflow/TriggerCode.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +507 -0
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -0
- package/build/dist/Server/Utils/VM/VMAPI.js +51 -4
- package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
- package/build/dist/Types/SerializableObjectDictionary.js +133 -39
- package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
- package/build/dist/Types/Telemetry/EntitySource.js +39 -0
- package/build/dist/Types/Telemetry/EntitySource.js.map +1 -0
- package/build/dist/Types/Telemetry/EntityType.js +27 -0
- package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
- package/build/dist/Types/Telemetry/EntityTypeGroups.js +57 -0
- package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -0
- package/build/dist/Types/Workflow/Component.js +21 -0
- package/build/dist/Types/Workflow/Component.js.map +1 -1
- package/build/dist/Types/Workflow/Components/API.js +35 -0
- package/build/dist/Types/Workflow/Components/API.js.map +1 -1
- package/build/dist/Types/Workflow/Components/BaseModel.js +7 -3
- package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
- package/build/dist/Types/Workflow/StepTrace.js +104 -0
- package/build/dist/Types/Workflow/StepTrace.js.map +1 -0
- package/build/dist/Types/Workflow/TemplateSyntax.js +326 -0
- package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -0
- package/build/dist/Types/Workflow/Templates.js +218 -0
- package/build/dist/Types/Workflow/Templates.js.map +1 -0
- package/build/dist/UI/Components/Date/CustomTimeRangeModal.js +126 -0
- package/build/dist/UI/Components/Date/CustomTimeRangeModal.js.map +1 -0
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +123 -0
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -0
- package/build/dist/UI/Components/Dictionary/Dictionary.js +22 -6
- package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
- package/build/dist/UI/Components/Forms/Fields/ColorPicker.js +52 -13
- package/build/dist/UI/Components/Forms/Fields/ColorPicker.js.map +1 -1
- package/build/dist/UI/Components/Forms/Fields/IconPicker.js +28 -12
- package/build/dist/UI/Components/Forms/Fields/IconPicker.js.map +1 -1
- package/build/dist/UI/Components/Forms/Validation.js +44 -0
- package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
- package/build/dist/UI/Components/Input/Input.js +12 -4
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +5 -108
- package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
- package/build/dist/UI/Components/Modal/Modal.js +31 -5
- package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +5 -105
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js +140 -11
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/Component.js +20 -19
- package/build/dist/UI/Components/Workflow/Component.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +31 -3
- package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/GraphLint.js +396 -0
- package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +225 -0
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ModelSchema.js +118 -0
- package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -0
- package/build/dist/UI/Components/Workflow/RunForm.js +22 -6
- package/build/dist/UI/Components/Workflow/RunForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +76 -0
- package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -0
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js +76 -0
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -0
- package/build/dist/UI/Components/Workflow/Utils.js +73 -1
- package/build/dist/UI/Components/Workflow/Utils.js.map +1 -1
- package/build/dist/UI/Components/Workflow/Workflow.js +51 -6
- package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
- package/build/dist/UI/Types/LayeredDismissal.js +21 -0
- package/build/dist/UI/Types/LayeredDismissal.js.map +1 -0
- package/build/dist/UI/Types/UseAnchoredFieldPopup.js +74 -1
- package/build/dist/UI/Types/UseAnchoredFieldPopup.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +386 -0
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +35 -6
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/build/dist/Utils/Telemetry/EntityKey.js +52 -0
- package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import CommonAPI from "../../../Server/API/CommonAPI";
|
|
2
2
|
import UserAPI from "../../../Server/API/UserAPI";
|
|
3
3
|
import MasterAdminAuthorization from "../../../Server/Middleware/MasterAdminAuthorization";
|
|
4
|
+
import ProjectMiddleware from "../../../Server/Middleware/ProjectAuthorization";
|
|
5
|
+
import UserMiddleware from "../../../Server/Middleware/UserAuthorization";
|
|
4
6
|
import TeamMemberService from "../../../Server/Services/TeamMemberService";
|
|
7
|
+
import JSONWebToken from "../../../Server/Utils/JsonWebToken";
|
|
5
8
|
import {
|
|
6
9
|
ExpressRequest,
|
|
7
10
|
ExpressResponse,
|
|
@@ -15,7 +18,9 @@ import TeamMember from "../../../Models/DatabaseModels/TeamMember";
|
|
|
15
18
|
import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
16
19
|
import { DEFAULT_LIMIT } from "../../../Types/Database/LimitMax";
|
|
17
20
|
import Dictionary from "../../../Types/Dictionary";
|
|
21
|
+
import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
|
|
18
22
|
import { JSONObject } from "../../../Types/JSON";
|
|
23
|
+
import JSONWebTokenData from "../../../Types/JsonWebTokenData";
|
|
19
24
|
import ObjectID from "../../../Types/ObjectID";
|
|
20
25
|
import PositiveNumber from "../../../Types/PositiveNumber";
|
|
21
26
|
import UserType from "../../../Types/UserType";
|
|
@@ -47,10 +52,25 @@ import {
|
|
|
47
52
|
* (so the Owners-team guard runs before anything is deleted) with the caller's
|
|
48
53
|
* own props rather than root.
|
|
49
54
|
*
|
|
50
|
-
* Both are guarded by
|
|
55
|
+
* Both are guarded by a master-admin middleware, which is the only thing
|
|
51
56
|
* standing between "list one user's projects" and "read across every tenant on
|
|
52
57
|
* the instance". That the routes carry it is asserted directly, because
|
|
53
58
|
* nothing else in these tests would notice its absence.
|
|
59
|
+
*
|
|
60
|
+
* The two carry DIFFERENT master-admin middlewares, and which one each carries
|
|
61
|
+
* is the security boundary of this file:
|
|
62
|
+
*
|
|
63
|
+
* - /projects reads, so it takes
|
|
64
|
+
* isAuthorizedMasterAdminOrMasterApiKeyMiddleware — a master-admin session
|
|
65
|
+
* OR the instance-wide master API key, so scripts can call it.
|
|
66
|
+
* - /remove-from-project writes, so it stays on
|
|
67
|
+
* isAuthorizedMasterAdminMiddleware — session only. A leaked static key must
|
|
68
|
+
* not be able to strip somebody's project access headlessly.
|
|
69
|
+
*
|
|
70
|
+
* A find/replace that "helpfully" widened the second one would leave every
|
|
71
|
+
* assertion about grouping, paging and deleting green, so the middlewares are
|
|
72
|
+
* asserted by identity, and the whole UserAPI route surface is swept to prove
|
|
73
|
+
* the read is the ONLY route the key can reach.
|
|
54
74
|
*/
|
|
55
75
|
|
|
56
76
|
jest.mock("../../../Server/Utils/Express", () => {
|
|
@@ -78,10 +98,17 @@ jest.mock("../../../Server/Utils/Response", () => {
|
|
|
78
98
|
const PROJECTS_ROUTE: string = "/user/:userId/projects";
|
|
79
99
|
const REMOVE_ROUTE: string = "/user/:userId/remove-from-project";
|
|
80
100
|
|
|
101
|
+
const AUTH_STATUS_ROUTE: string = "/user/:userId/authentication-status";
|
|
102
|
+
const SET_PASSWORD_ROUTE: string = "/user/:userId/set-password";
|
|
103
|
+
const RESET_LINK_ROUTE: string = "/user/:userId/send-password-reset-link";
|
|
104
|
+
|
|
81
105
|
const USER_ID: string = "00000000-0000-4000-8000-000000000001";
|
|
82
106
|
const PROJECT_ONE_ID: string = "00000000-0000-4000-8000-0000000000a1";
|
|
83
107
|
const PROJECT_TWO_ID: string = "00000000-0000-4000-8000-0000000000a2";
|
|
84
108
|
|
|
109
|
+
const MASTER_API_KEY: string = "8e1a3a52-6d64-4f1f-9a2e-5f0f9c1d2b34";
|
|
110
|
+
const OTHER_API_KEY: string = "1c9d7f40-2b83-4a55-8e70-6d4b9a0c3e12";
|
|
111
|
+
|
|
85
112
|
type MembershipSpec = {
|
|
86
113
|
id: string;
|
|
87
114
|
projectId: string;
|
|
@@ -206,9 +233,9 @@ describe("POST /user/:userId/projects", () => {
|
|
|
206
233
|
}).not.toThrow();
|
|
207
234
|
});
|
|
208
235
|
|
|
209
|
-
test("only a master admin can reach it", () => {
|
|
236
|
+
test("only a master admin — by session or by master API key — can reach it", () => {
|
|
210
237
|
expect(mockRouter.match("POST", PROJECTS_ROUTE).middleware).toBe(
|
|
211
|
-
MasterAdminAuthorization.
|
|
238
|
+
MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware,
|
|
212
239
|
);
|
|
213
240
|
});
|
|
214
241
|
|
|
@@ -706,10 +733,19 @@ describe("POST /user/:userId/remove-from-project", () => {
|
|
|
706
733
|
}).not.toThrow();
|
|
707
734
|
});
|
|
708
735
|
|
|
709
|
-
|
|
736
|
+
/*
|
|
737
|
+
* Session only — deliberately NOT the middleware its read-only sibling
|
|
738
|
+
* carries. This route deletes, and the master API key is a static credential
|
|
739
|
+
* that lives in a config table and in whatever script holds it.
|
|
740
|
+
*/
|
|
741
|
+
test("only a master admin SESSION can reach it, never the master API key", () => {
|
|
710
742
|
expect(mockRouter.match("POST", REMOVE_ROUTE).middleware).toBe(
|
|
711
743
|
MasterAdminAuthorization.isAuthorizedMasterAdminMiddleware,
|
|
712
744
|
);
|
|
745
|
+
|
|
746
|
+
expect(mockRouter.match("POST", REMOVE_ROUTE).middleware).not.toBe(
|
|
747
|
+
MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware,
|
|
748
|
+
);
|
|
713
749
|
});
|
|
714
750
|
|
|
715
751
|
test("removes every membership in ONE delete, scoped to the user and project", async () => {
|
|
@@ -850,3 +886,441 @@ describe("POST /user/:userId/remove-from-project", () => {
|
|
|
850
886
|
expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
|
|
851
887
|
});
|
|
852
888
|
});
|
|
889
|
+
|
|
890
|
+
/*
|
|
891
|
+
* The gate and the handler together, driven the way a real request arrives:
|
|
892
|
+
* through the route's middleware first, and only into the handler if the
|
|
893
|
+
* middleware lets it past.
|
|
894
|
+
*
|
|
895
|
+
* The describes above call handlerFunction directly, which is the right way to
|
|
896
|
+
* test grouping and paging but says nothing about who may call it — and this is
|
|
897
|
+
* a route that reads across EVERY tenant on the instance. Two failure modes
|
|
898
|
+
* live in the gap and neither is visible from either side alone:
|
|
899
|
+
*
|
|
900
|
+
* - a caller who should be refused reaching the read anyway. Asserted as
|
|
901
|
+
* "TeamMemberService.findBy was never called", not merely as "an error was
|
|
902
|
+
* returned": returning 401 after already reading every membership on the
|
|
903
|
+
* instance would satisfy the weaker assertion.
|
|
904
|
+
* - the master-key path authorizing but the handler then failing, because the
|
|
905
|
+
* key branch calls next() WITHOUT setting userAuthorization, userType or
|
|
906
|
+
* tenantId on the request. A handler that read any of them would work under
|
|
907
|
+
* a session and break under a key, and no unit test that hand-builds a
|
|
908
|
+
* request would notice.
|
|
909
|
+
*/
|
|
910
|
+
describe("POST /user/:userId/projects — the master API key path", () => {
|
|
911
|
+
let findBySpy: jest.SpyInstance;
|
|
912
|
+
|
|
913
|
+
type MiddlewareCallResult = {
|
|
914
|
+
// Whether the gate let the request through to the read.
|
|
915
|
+
reachedHandler: boolean;
|
|
916
|
+
errorSentToClient: NotAuthorizedException | undefined;
|
|
917
|
+
// The request object as the handler saw it, to inspect what the gate set.
|
|
918
|
+
request: ExpressRequest;
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
async function callThroughMiddleware(data: {
|
|
922
|
+
headers: Dictionary<string | Array<string> | undefined>;
|
|
923
|
+
params?: Dictionary<string> | undefined;
|
|
924
|
+
query?: Dictionary<string> | undefined;
|
|
925
|
+
}): Promise<MiddlewareCallResult> {
|
|
926
|
+
const req: ExpressRequest = {
|
|
927
|
+
params: data.params || { userId: USER_ID },
|
|
928
|
+
query: data.query || {},
|
|
929
|
+
body: {},
|
|
930
|
+
headers: data.headers,
|
|
931
|
+
} as unknown as ExpressRequest;
|
|
932
|
+
|
|
933
|
+
const res: ExpressResponse = {
|
|
934
|
+
send: jest.fn(),
|
|
935
|
+
json: jest.fn(),
|
|
936
|
+
status: jest.fn().mockReturnThis(),
|
|
937
|
+
} as unknown as ExpressResponse;
|
|
938
|
+
|
|
939
|
+
const route: {
|
|
940
|
+
middleware: (
|
|
941
|
+
req: ExpressRequest,
|
|
942
|
+
res: ExpressResponse,
|
|
943
|
+
next: NextFunction,
|
|
944
|
+
) => void | Promise<void>;
|
|
945
|
+
handlerFunction: (
|
|
946
|
+
req: ExpressRequest,
|
|
947
|
+
res: ExpressResponse,
|
|
948
|
+
next: NextFunction,
|
|
949
|
+
) => void | Promise<void>;
|
|
950
|
+
} = mockRouter.match("POST", PROJECTS_ROUTE);
|
|
951
|
+
|
|
952
|
+
const middlewareNext: jest.Mock = jest.fn();
|
|
953
|
+
|
|
954
|
+
await route.middleware(req, res, middlewareNext as unknown as NextFunction);
|
|
955
|
+
|
|
956
|
+
const reachedHandler: boolean = middlewareNext.mock.calls.length > 0;
|
|
957
|
+
|
|
958
|
+
if (reachedHandler) {
|
|
959
|
+
await route.handlerFunction(
|
|
960
|
+
req,
|
|
961
|
+
res,
|
|
962
|
+
jest.fn() as unknown as NextFunction,
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const errorCall: Array<unknown> | undefined = (
|
|
967
|
+
Response.sendErrorResponse as unknown as {
|
|
968
|
+
mock: { calls: Array<Array<unknown>> };
|
|
969
|
+
}
|
|
970
|
+
).mock.calls[0];
|
|
971
|
+
|
|
972
|
+
return {
|
|
973
|
+
reachedHandler: reachedHandler,
|
|
974
|
+
errorSentToClient: errorCall?.[2] as NotAuthorizedException | undefined,
|
|
975
|
+
request: req,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
beforeAll(() => {
|
|
980
|
+
mockRouter.routes.length = 0;
|
|
981
|
+
new UserAPI();
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
beforeEach(() => {
|
|
985
|
+
jest.clearAllMocks();
|
|
986
|
+
|
|
987
|
+
findBySpy = jest.spyOn(TeamMemberService, "findBy").mockResolvedValue([
|
|
988
|
+
buildMembership({
|
|
989
|
+
id: "m1",
|
|
990
|
+
projectId: PROJECT_ONE_ID,
|
|
991
|
+
projectName: "Acme Production",
|
|
992
|
+
teamId: "team-owners",
|
|
993
|
+
teamName: "Owners",
|
|
994
|
+
}),
|
|
995
|
+
buildMembership({
|
|
996
|
+
id: "m2",
|
|
997
|
+
projectId: PROJECT_ONE_ID,
|
|
998
|
+
projectName: "Acme Production",
|
|
999
|
+
teamId: "team-eng",
|
|
1000
|
+
teamName: "Engineering",
|
|
1001
|
+
}),
|
|
1002
|
+
buildMembership({
|
|
1003
|
+
id: "m3",
|
|
1004
|
+
projectId: PROJECT_TWO_ID,
|
|
1005
|
+
projectName: "Beta Staging",
|
|
1006
|
+
teamId: "team-ops",
|
|
1007
|
+
teamName: "Ops",
|
|
1008
|
+
}),
|
|
1009
|
+
]);
|
|
1010
|
+
|
|
1011
|
+
/*
|
|
1012
|
+
* Stand in for the GlobalConfig lookup — only this one key is live. Spying
|
|
1013
|
+
* here rather than on getApiKey keeps the real header parsing and the real
|
|
1014
|
+
* UUID guard in the test.
|
|
1015
|
+
*/
|
|
1016
|
+
jest.spyOn(ProjectMiddleware, "isMasterApiKey").mockImplementation(((
|
|
1017
|
+
apiKey: ObjectID,
|
|
1018
|
+
): Promise<boolean> => {
|
|
1019
|
+
return Promise.resolve(apiKey.toString() === MASTER_API_KEY);
|
|
1020
|
+
}) as never);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
afterEach(() => {
|
|
1024
|
+
jest.restoreAllMocks();
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
describe("when the master API key is presented", () => {
|
|
1028
|
+
test("a request carrying nothing but the key gets the grouped list", async () => {
|
|
1029
|
+
const result: MiddlewareCallResult = await callThroughMiddleware({
|
|
1030
|
+
headers: { apikey: MASTER_API_KEY },
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
expect(result.reachedHandler).toBe(true);
|
|
1034
|
+
expect(Response.sendErrorResponse).not.toHaveBeenCalled();
|
|
1035
|
+
// Two projects out of three memberships — the grouping still happens.
|
|
1036
|
+
expect(projectNamesSent()).toEqual(["Acme Production", "Beta Staging"]);
|
|
1037
|
+
expect(sentCount()).toBe(2);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
/*
|
|
1041
|
+
* The handler must not depend on anything the key branch leaves unset. If
|
|
1042
|
+
* this ever fails, the route works for the Admin Dashboard and 500s for
|
|
1043
|
+
* every script — the exact split a route-registration test cannot see.
|
|
1044
|
+
*/
|
|
1045
|
+
test("works even though the key branch sets no session state on the request", async () => {
|
|
1046
|
+
const result: MiddlewareCallResult = await callThroughMiddleware({
|
|
1047
|
+
headers: { apikey: MASTER_API_KEY },
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
const request: Dictionary<unknown> =
|
|
1051
|
+
result.request as unknown as Dictionary<unknown>;
|
|
1052
|
+
|
|
1053
|
+
expect(request["userAuthorization"]).toBeUndefined();
|
|
1054
|
+
expect(request["userType"]).toBeUndefined();
|
|
1055
|
+
expect(request["tenantId"]).toBeUndefined();
|
|
1056
|
+
|
|
1057
|
+
// ...and the read happened anyway.
|
|
1058
|
+
expect(findBySpy).toHaveBeenCalledTimes(1);
|
|
1059
|
+
expect(Response.sendJsonArrayResponse).toHaveBeenCalledTimes(1);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test("still reads as root, so it can see projects the key's user is not in", async () => {
|
|
1063
|
+
await callThroughMiddleware({ headers: { apikey: MASTER_API_KEY } });
|
|
1064
|
+
|
|
1065
|
+
const props: DatabaseCommonInteractionProps = (
|
|
1066
|
+
findBySpy.mock.calls[0]![0] as { props: DatabaseCommonInteractionProps }
|
|
1067
|
+
).props;
|
|
1068
|
+
|
|
1069
|
+
expect(props.isRoot).toBe(true);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("pages over projects on the key path too", async () => {
|
|
1073
|
+
await callThroughMiddleware({
|
|
1074
|
+
headers: { apikey: MASTER_API_KEY },
|
|
1075
|
+
query: { skip: "1", limit: "1" },
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
expect(projectNamesSent()).toEqual(["Beta Staging"]);
|
|
1079
|
+
// count stays the size of the whole grouped list, not of the page.
|
|
1080
|
+
expect(sentCount()).toBe(2);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
/*
|
|
1084
|
+
* A key caller has no tenant, and this endpoint must not acquire one from a
|
|
1085
|
+
* stray header: the handler passes literal `{ isRoot: true }` props, so
|
|
1086
|
+
* there is no props.tenantId for the root tenant-scoping to apply. Worth
|
|
1087
|
+
* pinning because "send projectid with the key" is a natural thing for a
|
|
1088
|
+
* caller to try, and silently getting one project's worth of an answer
|
|
1089
|
+
* would be worse than an error.
|
|
1090
|
+
*/
|
|
1091
|
+
test("a projectid header does not narrow the read", async () => {
|
|
1092
|
+
await callThroughMiddleware({
|
|
1093
|
+
headers: { apikey: MASTER_API_KEY, projectid: PROJECT_ONE_ID },
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
const findBy: { query: Dictionary<unknown> } = findBySpy.mock
|
|
1097
|
+
.calls[0]![0] as { query: Dictionary<unknown> };
|
|
1098
|
+
|
|
1099
|
+
expect(Object.keys(findBy.query)).toEqual(["userId"]);
|
|
1100
|
+
expect(projectNamesSent()).toEqual(["Acme Production", "Beta Staging"]);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
test("validates the user id before reading, key or no key", async () => {
|
|
1104
|
+
const result: MiddlewareCallResult = await callThroughMiddleware({
|
|
1105
|
+
headers: { apikey: MASTER_API_KEY },
|
|
1106
|
+
params: { userId: "not-a-uuid" },
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
expect(result.reachedHandler).toBe(true);
|
|
1110
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
describe("when the credential cannot authorize the request", () => {
|
|
1115
|
+
/*
|
|
1116
|
+
* Every one of these must stop AT the gate. The assertion that matters is
|
|
1117
|
+
* findBy — a refusal issued after the cross-tenant read has already run is
|
|
1118
|
+
* not a refusal.
|
|
1119
|
+
*/
|
|
1120
|
+
const unusableCredentials: Array<[string, string | Array<string>]> = [
|
|
1121
|
+
["a well-formed key that is not the master key", OTHER_API_KEY],
|
|
1122
|
+
["an empty apikey header", ""],
|
|
1123
|
+
["a whitespace-only apikey header", " "],
|
|
1124
|
+
["a non-UUID apikey header", "not-a-uuid"],
|
|
1125
|
+
["a truncated UUID", "8e1a3a52-6d64-4f1f-9a2e"],
|
|
1126
|
+
[
|
|
1127
|
+
"a UUID with non-hex characters",
|
|
1128
|
+
"8e1a3a52-6d64-4f1f-9a2e-5f0f9c1d2bZZ",
|
|
1129
|
+
],
|
|
1130
|
+
["a SQL injection attempt", "' OR 1=1 --"],
|
|
1131
|
+
["duplicate apikey headers", [MASTER_API_KEY, OTHER_API_KEY]],
|
|
1132
|
+
];
|
|
1133
|
+
|
|
1134
|
+
test.each(unusableCredentials)(
|
|
1135
|
+
"refuses %s without reading anything",
|
|
1136
|
+
async (_label: string, apiKeyHeader: string | Array<string>) => {
|
|
1137
|
+
const result: MiddlewareCallResult = await callThroughMiddleware({
|
|
1138
|
+
headers: { apikey: apiKeyHeader },
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
expect(result.reachedHandler).toBe(false);
|
|
1142
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
1143
|
+
expect(Response.sendJsonArrayResponse).not.toHaveBeenCalled();
|
|
1144
|
+
|
|
1145
|
+
expect(result.errorSentToClient).toBeInstanceOf(NotAuthorizedException);
|
|
1146
|
+
expect(result.errorSentToClient?.message).toBe(
|
|
1147
|
+
"Unauthorized: Access token is required.",
|
|
1148
|
+
);
|
|
1149
|
+
},
|
|
1150
|
+
);
|
|
1151
|
+
|
|
1152
|
+
test("refuses a request with no credential at all", async () => {
|
|
1153
|
+
const result: MiddlewareCallResult = await callThroughMiddleware({
|
|
1154
|
+
headers: {},
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
expect(result.reachedHandler).toBe(false);
|
|
1158
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
/*
|
|
1162
|
+
* Fail closed. A GlobalConfig lookup that throws must not be read as "the
|
|
1163
|
+
* key checked out"; the middleware swallows it and falls through to the
|
|
1164
|
+
* session check, which has nothing to work with here.
|
|
1165
|
+
*/
|
|
1166
|
+
test("refuses when the key lookup itself throws", async () => {
|
|
1167
|
+
jest
|
|
1168
|
+
.spyOn(ProjectMiddleware, "isMasterApiKey")
|
|
1169
|
+
.mockRejectedValue(new Error("database is down") as never);
|
|
1170
|
+
|
|
1171
|
+
const result: MiddlewareCallResult = await callThroughMiddleware({
|
|
1172
|
+
headers: { apikey: MASTER_API_KEY },
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
expect(result.reachedHandler).toBe(false);
|
|
1176
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
/*
|
|
1180
|
+
* The UUID guard has to run BEFORE the lookup: masterApiKey is a Postgres
|
|
1181
|
+
* uuid column, and a non-UUID raises 22P02 as a raw QueryFailedError, which
|
|
1182
|
+
* is not a OneUptime Exception and so escapes the error translator as a 500.
|
|
1183
|
+
*/
|
|
1184
|
+
test("never reaches the key lookup with a malformed value", async () => {
|
|
1185
|
+
await callThroughMiddleware({ headers: { apikey: "garbage" } });
|
|
1186
|
+
|
|
1187
|
+
expect(ProjectMiddleware.isMasterApiKey).not.toHaveBeenCalled();
|
|
1188
|
+
});
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
describe("session access is unchanged by the widening", () => {
|
|
1192
|
+
/*
|
|
1193
|
+
* The Admin Dashboard reaches this route with a cookie session and no key.
|
|
1194
|
+
* Accepting the key must not have cost it that.
|
|
1195
|
+
*/
|
|
1196
|
+
test("a master admin session still gets the grouped list", async () => {
|
|
1197
|
+
jest
|
|
1198
|
+
.spyOn(UserMiddleware, "getAccessTokenFromExpressRequest")
|
|
1199
|
+
.mockReturnValue("a.master.admin.token" as never);
|
|
1200
|
+
|
|
1201
|
+
jest.spyOn(JSONWebToken, "decode").mockReturnValue({
|
|
1202
|
+
isMasterAdmin: true,
|
|
1203
|
+
} as unknown as JSONWebTokenData as never);
|
|
1204
|
+
|
|
1205
|
+
const result: MiddlewareCallResult = await callThroughMiddleware({
|
|
1206
|
+
headers: {},
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
expect(result.reachedHandler).toBe(true);
|
|
1210
|
+
expect(projectNamesSent()).toEqual(["Acme Production", "Beta Staging"]);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
test("an ordinary user session is still refused", async () => {
|
|
1214
|
+
jest
|
|
1215
|
+
.spyOn(UserMiddleware, "getAccessTokenFromExpressRequest")
|
|
1216
|
+
.mockReturnValue("an.ordinary.user.token" as never);
|
|
1217
|
+
|
|
1218
|
+
jest.spyOn(JSONWebToken, "decode").mockReturnValue({
|
|
1219
|
+
isMasterAdmin: false,
|
|
1220
|
+
} as unknown as JSONWebTokenData as never);
|
|
1221
|
+
|
|
1222
|
+
const result: MiddlewareCallResult = await callThroughMiddleware({
|
|
1223
|
+
headers: {},
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
expect(result.reachedHandler).toBe(false);
|
|
1227
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
1228
|
+
expect(result.errorSentToClient?.message).toBe(
|
|
1229
|
+
"Unauthorized: Only master admins can perform this action.",
|
|
1230
|
+
);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
/*
|
|
1234
|
+
* An unusable key must not cost a caller their valid session. This
|
|
1235
|
+
* middleware falls through rather than failing fast, unlike the
|
|
1236
|
+
* presence-based routing in UserAuthorization.
|
|
1237
|
+
*/
|
|
1238
|
+
test("an unusable key alongside a master admin session still passes", async () => {
|
|
1239
|
+
jest
|
|
1240
|
+
.spyOn(UserMiddleware, "getAccessTokenFromExpressRequest")
|
|
1241
|
+
.mockReturnValue("a.master.admin.token" as never);
|
|
1242
|
+
|
|
1243
|
+
jest.spyOn(JSONWebToken, "decode").mockReturnValue({
|
|
1244
|
+
isMasterAdmin: true,
|
|
1245
|
+
} as unknown as JSONWebTokenData as never);
|
|
1246
|
+
|
|
1247
|
+
const result: MiddlewareCallResult = await callThroughMiddleware({
|
|
1248
|
+
headers: { apikey: "" },
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
expect(result.reachedHandler).toBe(true);
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
/*
|
|
1257
|
+
* The rule the widening has to keep: on UserAPI, the master API key reaches
|
|
1258
|
+
* reads and nothing else.
|
|
1259
|
+
*
|
|
1260
|
+
* Asserted as a sweep over the whole registered surface rather than route by
|
|
1261
|
+
* route, so a NEW master-admin route added later has to make a deliberate
|
|
1262
|
+
* choice — adding one that accepts the key fails this test until somebody
|
|
1263
|
+
* writes it into the expected list and, one hopes, thinks about why.
|
|
1264
|
+
*/
|
|
1265
|
+
describe("UserAPI master-admin route surface", () => {
|
|
1266
|
+
type RouteSummary = { method: string; uri: string; middleware: unknown };
|
|
1267
|
+
|
|
1268
|
+
function routesGuardedBy(middleware: unknown): Array<string> {
|
|
1269
|
+
return (mockRouter.routes as unknown as Array<RouteSummary>)
|
|
1270
|
+
.filter((route: RouteSummary) => {
|
|
1271
|
+
return route.middleware === middleware;
|
|
1272
|
+
})
|
|
1273
|
+
.map((route: RouteSummary) => {
|
|
1274
|
+
return `${route.method} ${route.uri}`;
|
|
1275
|
+
})
|
|
1276
|
+
.sort();
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
beforeAll(() => {
|
|
1280
|
+
mockRouter.routes.length = 0;
|
|
1281
|
+
new UserAPI();
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
test("the projects read is the ONLY route a static key can reach", () => {
|
|
1285
|
+
expect(
|
|
1286
|
+
routesGuardedBy(
|
|
1287
|
+
MasterAdminAuthorization.isAuthorizedMasterAdminOrMasterApiKeyMiddleware,
|
|
1288
|
+
),
|
|
1289
|
+
).toEqual([`POST ${PROJECTS_ROUTE}`]);
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
test("every master-admin route that writes stays session-only", () => {
|
|
1293
|
+
expect(
|
|
1294
|
+
routesGuardedBy(
|
|
1295
|
+
MasterAdminAuthorization.isAuthorizedMasterAdminMiddleware,
|
|
1296
|
+
),
|
|
1297
|
+
).toEqual(
|
|
1298
|
+
[
|
|
1299
|
+
`GET ${AUTH_STATUS_ROUTE}`,
|
|
1300
|
+
`POST ${REMOVE_ROUTE}`,
|
|
1301
|
+
`POST ${RESET_LINK_ROUTE}`,
|
|
1302
|
+
`POST ${SET_PASSWORD_ROUTE}`,
|
|
1303
|
+
].sort(),
|
|
1304
|
+
);
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
/*
|
|
1308
|
+
* Spelled out one by one as well as swept, because these are the routes where
|
|
1309
|
+
* a leaked static key would stop being a disclosure and start being an
|
|
1310
|
+
* account takeover: removing somebody's access, setting their password, or
|
|
1311
|
+
* mailing a reset link to their inbox.
|
|
1312
|
+
*/
|
|
1313
|
+
test.each([
|
|
1314
|
+
["POST", REMOVE_ROUTE],
|
|
1315
|
+
["POST", SET_PASSWORD_ROUTE],
|
|
1316
|
+
["POST", RESET_LINK_ROUTE],
|
|
1317
|
+
["GET", AUTH_STATUS_ROUTE],
|
|
1318
|
+
])(
|
|
1319
|
+
"%s %s does not accept the master API key",
|
|
1320
|
+
(method: string, uri: string) => {
|
|
1321
|
+
expect(mockRouter.match(method, uri).middleware).toBe(
|
|
1322
|
+
MasterAdminAuthorization.isAuthorizedMasterAdminMiddleware,
|
|
1323
|
+
);
|
|
1324
|
+
},
|
|
1325
|
+
);
|
|
1326
|
+
});
|