@7365admin1/core 3.54.0 → 3.56.0
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/CHANGELOG.md +49 -0
- package/dist/index.d.ts +17 -7
- package/dist/index.js +4434 -4375
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +146 -87
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/notification-matrix.e2e.test.mjs +420 -0
- package/test/e2e/notification-preference.e2e.test.mjs +21 -8
- package/test/e2e/onboarding-org-save-diagnosis.e2e.test.mjs +76 -3
- package/test/e2e/safety-notification.e2e.test.mjs +1 -1
- package/test/role-scope-separation.test.mjs +195 -0
- package/test/role-scope.test.mjs +55 -3
- package/tools/role-scope-lockout-check/check.js +316 -0
- package/tools/role-scope-lockout-check/platform-only-permissions.json +21 -0
- package/tools/role-scope-lockout-check/srv.js +39 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
PLATFORM_ONLY_PERMISSIONS,
|
|
7
|
+
platformPermissionsAdded,
|
|
8
|
+
refuseSharedClientTemplate,
|
|
9
|
+
requireNoPlatformGrant,
|
|
10
|
+
roleScope,
|
|
11
|
+
} from "./.build/utils/role-scope.util.mjs";
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* The 2026-09-07 outage, reproduced as data.
|
|
15
|
+
*
|
|
16
|
+
* "Org Owner": `type: "organization"`, `org: ""`, 117 live members, 321
|
|
17
|
+
* invitations. The console editor pre-ticks its own catalogue for a
|
|
18
|
+
* `default: true` role and saves it -- 34 platform strings, no client module.
|
|
19
|
+
*/
|
|
20
|
+
const ORG_OWNER = { type: "organization", org: "", permissions: ["*"] };
|
|
21
|
+
|
|
22
|
+
/** Exactly what `useAdminPermission` expands to: 24 + members 5 + roles 5. */
|
|
23
|
+
const CONSOLE_SAVE = [
|
|
24
|
+
"organizations:see-all-organizations",
|
|
25
|
+
"organizations:see-organization-details",
|
|
26
|
+
"promo-codes:create-promo-code",
|
|
27
|
+
"promo-codes:see-promo-code-details",
|
|
28
|
+
"promo-codes:edit-promo-code-details",
|
|
29
|
+
"promo-codes:change-promo-code-status",
|
|
30
|
+
"promo-codes:delete-promo-code",
|
|
31
|
+
"users:see-all-users",
|
|
32
|
+
"users:see-user-details",
|
|
33
|
+
"invitations:create-invitation",
|
|
34
|
+
"invitations:view-invitations",
|
|
35
|
+
"invitations:cancel-invitation",
|
|
36
|
+
"subscriptions:see-all-subscriptions",
|
|
37
|
+
"subscriptions:see-subscription-details",
|
|
38
|
+
"subscriptions:manage-subscription",
|
|
39
|
+
"sp-approvals:see-all-sp-approvals",
|
|
40
|
+
"sp-approvals:approve-sp",
|
|
41
|
+
"sp-approvals:reject-sp",
|
|
42
|
+
"sp-approvals:delete-sp-approval",
|
|
43
|
+
"marketplace-vendors:see-all-marketplace-vendors",
|
|
44
|
+
"marketplace-vendors:see-marketplace-vendor-details",
|
|
45
|
+
"platform-terms:see-platform-terms",
|
|
46
|
+
"platform-terms:edit-platform-terms",
|
|
47
|
+
"activity-history:see-activity-history",
|
|
48
|
+
"members:view-members",
|
|
49
|
+
"members:assign-member-role",
|
|
50
|
+
"members:suspend-member",
|
|
51
|
+
"members:activate-member",
|
|
52
|
+
"members:delete-member",
|
|
53
|
+
"roles-and-permissions:add-role",
|
|
54
|
+
"roles-and-permissions:see-all-roles",
|
|
55
|
+
"roles-and-permissions:see-role-details",
|
|
56
|
+
"roles-and-permissions:update-role",
|
|
57
|
+
"roles-and-permissions:delete-role",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
test("the save that caused the outage is 34 strings", () => {
|
|
61
|
+
assert.equal(CONSOLE_SAVE.length, 34);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a role's scope is read off type and org, and nothing else", () => {
|
|
65
|
+
assert.equal(roleScope({ type: "admin", org: "" }), "platform");
|
|
66
|
+
assert.equal(roleScope({ type: "admin", org: "6512ab" }), "platform");
|
|
67
|
+
assert.equal(roleScope(ORG_OWNER), "client-template");
|
|
68
|
+
assert.equal(roleScope({ type: "security_agency", org: "" }), "client-template");
|
|
69
|
+
assert.equal(roleScope({ type: "organization", org: "6512ab" }), "client");
|
|
70
|
+
assert.equal(roleScope({ type: "", org: "6512ab" }), "client");
|
|
71
|
+
// ObjectId, not a string
|
|
72
|
+
assert.equal(
|
|
73
|
+
roleScope({ type: "organization", org: { toString: () => "6512ab" } }),
|
|
74
|
+
"client",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("THE OUTAGE: a console save cannot strip a shared template", () => {
|
|
79
|
+
assert.throws(
|
|
80
|
+
() => refuseSharedClientTemplate(ORG_OWNER),
|
|
81
|
+
/shared by every client/,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("POSITIVE CONTROL: the writes that must still work are not refused", () => {
|
|
86
|
+
// a real platform staff role -- the console's own
|
|
87
|
+
assert.doesNotThrow(() =>
|
|
88
|
+
refuseSharedClientTemplate({ type: "admin", org: "", permissions: [] }),
|
|
89
|
+
);
|
|
90
|
+
// a client's own role, in its own org
|
|
91
|
+
assert.doesNotThrow(() =>
|
|
92
|
+
refuseSharedClientTemplate({ type: "organization", org: "6512ab" }),
|
|
93
|
+
);
|
|
94
|
+
assert.doesNotThrow(() =>
|
|
95
|
+
refuseSharedClientTemplate({ type: "security_agency", org: "6512ab" }),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a client editor cannot grant a platform resource", () => {
|
|
100
|
+
assert.throws(
|
|
101
|
+
() =>
|
|
102
|
+
requireNoPlatformGrant({ type: "organization", org: "6512ab", permissions: [] }, [
|
|
103
|
+
"members:view-members",
|
|
104
|
+
"organizations:see-all-organizations",
|
|
105
|
+
]),
|
|
106
|
+
/Seven365 console permissions/,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("the console CAN still grant them on a platform role", () => {
|
|
111
|
+
assert.doesNotThrow(() =>
|
|
112
|
+
requireNoPlatformGrant({ type: "admin", org: "", permissions: [] }, CONSOLE_SAVE),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("NEVER refuses a save that works today: held strings pass through", () => {
|
|
117
|
+
// 114 org-owned roles hold a `subscriptions:*` string. Re-saving one is not
|
|
118
|
+
// an addition, so it is allowed even though the resource name collides.
|
|
119
|
+
const held = ["subscriptions:see-all-subscriptions", "members:view-members"];
|
|
120
|
+
assert.doesNotThrow(() =>
|
|
121
|
+
requireNoPlatformGrant({ type: "organization", org: "6512ab", permissions: held }, held),
|
|
122
|
+
);
|
|
123
|
+
assert.deepEqual(platformPermissionsAdded(held, held), []);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("the ORG spelling of subscriptions is not a platform grant", () => {
|
|
127
|
+
// `useOrgPermission` spells them differently. Refusing the bare resource
|
|
128
|
+
// would have locked 114 roles out of their own editor.
|
|
129
|
+
const org = [
|
|
130
|
+
"subscriptions:management-subscription",
|
|
131
|
+
"subscriptions:view-subscription-details",
|
|
132
|
+
];
|
|
133
|
+
assert.deepEqual(platformPermissionsAdded(org, []), []);
|
|
134
|
+
assert.doesNotThrow(() =>
|
|
135
|
+
requireNoPlatformGrant({ type: "organization", org: "6512ab", permissions: [] }, org),
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("the four shared resource names are deliberately NOT refused", () => {
|
|
140
|
+
for (const permission of [
|
|
141
|
+
"users:see-all-users",
|
|
142
|
+
"invitations:view-invitations",
|
|
143
|
+
"members:view-members",
|
|
144
|
+
"roles-and-permissions:add-role",
|
|
145
|
+
"roles:add-role",
|
|
146
|
+
]) {
|
|
147
|
+
assert.equal(
|
|
148
|
+
PLATFORM_ONLY_PERMISSIONS.has(permission),
|
|
149
|
+
false,
|
|
150
|
+
permission + " must stay grantable on a client role",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("POSITIVE CONTROL: the refusal set is not empty and does bite", () => {
|
|
156
|
+
// 19, not the console catalogue's 34: `users` (2), `invitations` (3),
|
|
157
|
+
// `members` (5) and `roles-and-permissions` (5) are shared resource names and
|
|
158
|
+
// are deliberately left grantable on a client role.
|
|
159
|
+
assert.equal(PLATFORM_ONLY_PERMISSIONS.size, 19);
|
|
160
|
+
const added = platformPermissionsAdded(CONSOLE_SAVE, []);
|
|
161
|
+
assert.equal(added.length, 19);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("an empty or absent permission list is never a platform grant", () => {
|
|
165
|
+
assert.doesNotThrow(() =>
|
|
166
|
+
requireNoPlatformGrant({ type: "organization", org: "6512ab" }, []),
|
|
167
|
+
);
|
|
168
|
+
assert.doesNotThrow(() =>
|
|
169
|
+
requireNoPlatformGrant({ type: "organization", org: "6512ab" }, null),
|
|
170
|
+
);
|
|
171
|
+
assert.doesNotThrow(() =>
|
|
172
|
+
requireNoPlatformGrant({ type: "organization", org: "6512ab" }, ["*"]),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("the lockout check's copy of the platform list cannot drift", () => {
|
|
177
|
+
/*
|
|
178
|
+
* `tools/role-scope-lockout-check/check.js` is plain CommonJS run against a
|
|
179
|
+
* live database, so it cannot import the TypeScript guard. It reads a JSON
|
|
180
|
+
* copy instead. Two copies of a permission catalogue is what drifted last
|
|
181
|
+
* time (`useAdminPermission` was 107 lines apart between two apps), so the
|
|
182
|
+
* copy is asserted here rather than trusted.
|
|
183
|
+
*/
|
|
184
|
+
const onDisk = JSON.parse(
|
|
185
|
+
readFileSync(
|
|
186
|
+
new URL(
|
|
187
|
+
"../tools/role-scope-lockout-check/platform-only-permissions.json",
|
|
188
|
+
import.meta.url,
|
|
189
|
+
),
|
|
190
|
+
"utf8",
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
assert.deepEqual(onDisk, [...PLATFORM_ONLY_PERMISSIONS].sort());
|
|
195
|
+
});
|
package/test/role-scope.test.mjs
CHANGED
|
@@ -112,6 +112,21 @@ test("/api/roles/v2 is gated too — the same hole with /v2 on the URL", () => {
|
|
|
112
112
|
);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The four true WRITES now go through `requireRoleWrite`, which is
|
|
117
|
+
* `requireRoleOrg` plus one refusal: a role with no `org` and a non-admin type
|
|
118
|
+
* is a template every client shares, and the console must not rewrite it. That
|
|
119
|
+
* is the 2026-09-07 outage. `getDeletionPreview` is a read and deliberately
|
|
120
|
+
* stays on `requireRoleOrg` — see `role-scope-separation.test.mjs`.
|
|
121
|
+
*/
|
|
122
|
+
const WRITE_GATE = {
|
|
123
|
+
updateRole: /await requireRoleWrite\(req, existing/,
|
|
124
|
+
updatePermissionsById: /await requireRoleWrite\(req, existing/,
|
|
125
|
+
deleteRole: /await requireRoleWrite\(req, existing/,
|
|
126
|
+
deleteWithReassignments: /await requireRoleWrite\(req, existing/,
|
|
127
|
+
getDeletionPreview: /await requireRoleOrg\(req, existing\.org\)/,
|
|
128
|
+
};
|
|
129
|
+
|
|
115
130
|
test("every role write scopes on the STORED role's organisation", () => {
|
|
116
131
|
for (const name of MUST_SCOPE) {
|
|
117
132
|
const body = fn(controller, name);
|
|
@@ -123,14 +138,14 @@ test("every role write scopes on the STORED role's organisation", () => {
|
|
|
123
138
|
);
|
|
124
139
|
assert.match(
|
|
125
140
|
body,
|
|
126
|
-
|
|
141
|
+
WRITE_GATE[name],
|
|
127
142
|
`${name} must scope on the stored role's org`,
|
|
128
143
|
);
|
|
129
144
|
|
|
130
145
|
// and it must not take the organisation from the caller instead
|
|
131
146
|
assert.doesNotMatch(
|
|
132
147
|
body,
|
|
133
|
-
/
|
|
148
|
+
/requireRole(Org|Write)\(req, (req\.body|req\.query|req\.params)/,
|
|
134
149
|
`${name} must not scope on an org the caller supplied`,
|
|
135
150
|
);
|
|
136
151
|
}
|
|
@@ -147,7 +162,7 @@ test("the gate runs before the write, not after it", () => {
|
|
|
147
162
|
|
|
148
163
|
for (const [name, call] of Object.entries(writes)) {
|
|
149
164
|
const body = fn(controller, name);
|
|
150
|
-
const gate = body.
|
|
165
|
+
const gate = body.search(/requireRole(Org|Write)\(req/);
|
|
151
166
|
const write = body.indexOf(call);
|
|
152
167
|
assert.ok(gate !== -1 && write !== -1 && gate < write,
|
|
153
168
|
`${name}: the gate must run before ${call}`);
|
|
@@ -163,6 +178,43 @@ test("an org-less role is platform-staff only", () => {
|
|
|
163
178
|
assert.match(helper.slice(0, 500), /requireOrgAccess\(req, orgId\)/);
|
|
164
179
|
});
|
|
165
180
|
|
|
181
|
+
test("the write gate refuses a shared client template before anything else", () => {
|
|
182
|
+
// The whole of the 2026-09-07 fix. `requireRoleWrite` must refuse a template
|
|
183
|
+
// FIRST; if it fell through to `requireRoleOrg` an org-less role would reach
|
|
184
|
+
// the staff console gate again, which is what let the console rewrite it.
|
|
185
|
+
const helper = controller.slice(
|
|
186
|
+
controller.indexOf("async function requireRoleWrite"),
|
|
187
|
+
);
|
|
188
|
+
const body = helper.slice(0, 500);
|
|
189
|
+
|
|
190
|
+
const refuse = body.indexOf("refuseSharedClientTemplate(role)");
|
|
191
|
+
const fallthrough = body.indexOf("requireRoleOrg(req");
|
|
192
|
+
|
|
193
|
+
assert.ok(refuse !== -1, "requireRoleWrite must refuse shared templates");
|
|
194
|
+
assert.ok(fallthrough !== -1, "and must still apply the org rule to the rest");
|
|
195
|
+
assert.ok(refuse < fallthrough, "the refusal must come first");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("the permission-writing paths refuse a platform grant", () => {
|
|
199
|
+
for (const name of ["createRole", "updateRole", "updatePermissionsById"]) {
|
|
200
|
+
assert.match(
|
|
201
|
+
fn(controller, name),
|
|
202
|
+
/requireNoPlatformGrant\(/,
|
|
203
|
+
`${name} must refuse console permissions on a client role`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("READS are NOT routed through the write gate", () => {
|
|
209
|
+
// 117 live members read the org-less "Org Owner" on every session. Refusing
|
|
210
|
+
// them is the same outage from the other direction.
|
|
211
|
+
const helper = controller.slice(
|
|
212
|
+
controller.indexOf("async function requireRoleRead"),
|
|
213
|
+
);
|
|
214
|
+
assert.doesNotMatch(helper.slice(0, 400), /requireRoleWrite|refuseSharedClientTemplate/);
|
|
215
|
+
assert.match(helper.slice(0, 400), /holdsRole\(callerId\(req\), roleId\)/);
|
|
216
|
+
});
|
|
217
|
+
|
|
166
218
|
test("no new handler arrives on this controller without a decision", () => {
|
|
167
219
|
const known = new Set([...MUST_SCOPE, ...OPEN_BY_DESIGN]);
|
|
168
220
|
const unknown = exported(controller).filter((name) => !known.has(name));
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable no-console */
|
|
3
|
+
/**
|
|
4
|
+
* Pre-merge lockout check for a ROLE-SCOPE change.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS, AND WHY IT IS NOT `scope-lockout-check`
|
|
7
|
+
*
|
|
8
|
+
* `organization-api tools/scope-lockout-check` (org-api #470) is the same idea
|
|
9
|
+
* for a different question. It measures which SITES an account reaches, and it
|
|
10
|
+
* refuses to run anywhere but the LEGACY database -- it looks for
|
|
11
|
+
* `site-collaborations` and `service-provider-groups` and exits 2 if they are
|
|
12
|
+
* absent. Roles and members live in API-core's database, which has neither, so
|
|
13
|
+
* pointing that script at this question produces the exact mistake it was
|
|
14
|
+
* written to prevent. This is its sibling: same three rules, different subject.
|
|
15
|
+
*
|
|
16
|
+
* The rules, kept from #470 because they are what makes a measurement worth
|
|
17
|
+
* anything:
|
|
18
|
+
*
|
|
19
|
+
* 1. Enumerate from the ACCOUNT side. Every `members` document, grouped by
|
|
20
|
+
* `type`. Never filter the denominator through the rule being measured --
|
|
21
|
+
* a population skipped before it is counted cannot show up as a loser.
|
|
22
|
+
* 2. Report PER TYPE. One total over a mixed population hides a whole account
|
|
23
|
+
* type going to zero. That is what failed on 2026-09-06.
|
|
24
|
+
* 3. Refuse the wrong database, loudly, rather than produce a number.
|
|
25
|
+
*
|
|
26
|
+
* WHAT IT MEASURES -- two things, and only one of them is supposed to be zero:
|
|
27
|
+
*
|
|
28
|
+
* ACCESS what each member's role GRANTS, before and after. This change adds
|
|
29
|
+
* no read gate and rewrites no document, so every row must be
|
|
30
|
+
* identical. Any non-zero refusal blocks the merge.
|
|
31
|
+
* WRITES which roles the API will no longer SAVE, and how many members and
|
|
32
|
+
* invitations sit behind each. That is the intended cost of the
|
|
33
|
+
* change: reported as a number somebody signed off, not asserted away.
|
|
34
|
+
*
|
|
35
|
+
* READ ONLY -- `find` and `listCollections`. It writes nothing and refuses
|
|
36
|
+
* anything that looks like production.
|
|
37
|
+
*
|
|
38
|
+
* MONGO_URI="mongodb://..." node tools/role-scope-lockout-check/check.js
|
|
39
|
+
*
|
|
40
|
+
* With no MONGO_URI it reads the sibling checkout's `iservice365-API-core/.env`,
|
|
41
|
+
* which is where the staging string is kept locally and is never committed.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const fs = require("fs");
|
|
45
|
+
const path = require("path");
|
|
46
|
+
const { MongoClient } = require("mongodb");
|
|
47
|
+
|
|
48
|
+
/** A URI or database name matching any of these is refused outright. */
|
|
49
|
+
const PRODUCTION_MARKERS = [/prod/i, /-prd/i, /live/i];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The collections this measurement is meaningless without -- rule 3.
|
|
53
|
+
*
|
|
54
|
+
* `roles` and `members` are the subject; `verifications` carries the
|
|
55
|
+
* invitations that name a role, which is how the population behind an
|
|
56
|
+
* un-editable template gets counted.
|
|
57
|
+
*/
|
|
58
|
+
const REQUIRED_COLLECTIONS = ["roles", "members", "verifications"];
|
|
59
|
+
|
|
60
|
+
const PLATFORM_STAFF_ROLE_TYPE = "admin";
|
|
61
|
+
|
|
62
|
+
function fail(message) {
|
|
63
|
+
console.error("\n BLOCKED " + message + "\n");
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function envUri() {
|
|
68
|
+
if (process.env.MONGO_URI) return process.env.MONGO_URI;
|
|
69
|
+
|
|
70
|
+
const envPath = path.join(
|
|
71
|
+
__dirname,
|
|
72
|
+
"..",
|
|
73
|
+
"..",
|
|
74
|
+
"..",
|
|
75
|
+
"iservice365-API-core",
|
|
76
|
+
".env",
|
|
77
|
+
);
|
|
78
|
+
if (!fs.existsSync(envPath)) {
|
|
79
|
+
fail("no MONGO_URI, and no " + envPath + ". Set MONGO_URI to STAGING.");
|
|
80
|
+
}
|
|
81
|
+
const line = fs
|
|
82
|
+
.readFileSync(envPath, "utf8")
|
|
83
|
+
.split(/\r?\n/)
|
|
84
|
+
.find((l) => l.startsWith("MONGO_URI="));
|
|
85
|
+
if (!line) fail("MONGO_URI is not in " + envPath);
|
|
86
|
+
return line.slice("MONGO_URI=".length).replace(/^"|"$/g, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What a role GRANTS, as the apps read it.
|
|
91
|
+
*
|
|
92
|
+
* `layer-common utils/permission-spellings.ts:100` and its server twin
|
|
93
|
+
* `console-permission.util.ts consoleRoleAllowsAll`: an EMPTY list means
|
|
94
|
+
* everything, `"*"` means everything, and only a non-empty enumerated list is
|
|
95
|
+
* enforced. Getting that backwards IS the outage, so it is written once here
|
|
96
|
+
* and used for both the before and the after column.
|
|
97
|
+
*/
|
|
98
|
+
function grants(role) {
|
|
99
|
+
const held = Array.isArray(role && role.permissions) ? role.permissions : [];
|
|
100
|
+
if (held.length === 0) return "ALL";
|
|
101
|
+
if (held.includes("*")) return "ALL";
|
|
102
|
+
return held.slice().sort().join("|");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** platform / client-template / client -- the same rule as `role-scope.util.ts`. */
|
|
106
|
+
function roleScope(role) {
|
|
107
|
+
if (((role && role.type) || "") === PLATFORM_STAFF_ROLE_TYPE) return "platform";
|
|
108
|
+
return role && role.org && role.org.toString() ? "client" : "client-template";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function table(rows, columns) {
|
|
112
|
+
const widths = columns.map((c) =>
|
|
113
|
+
Math.max(c.length, ...rows.map((r) => String(r[c] === undefined ? "" : r[c]).length)),
|
|
114
|
+
);
|
|
115
|
+
const line = (cells) =>
|
|
116
|
+
" " + cells.map((c, i) => String(c).padEnd(widths[i])).join(" ");
|
|
117
|
+
console.log(line(columns));
|
|
118
|
+
console.log(line(widths.map((w) => "-".repeat(w))));
|
|
119
|
+
rows.forEach((r) =>
|
|
120
|
+
console.log(line(columns.map((c) => (r[c] === undefined ? "" : r[c])))),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
(async () => {
|
|
125
|
+
const uri = envUri();
|
|
126
|
+
|
|
127
|
+
for (const marker of PRODUCTION_MARKERS) {
|
|
128
|
+
if (marker.test(uri)) fail("this URI looks like PRODUCTION: " + marker);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const direct = await require("./srv").toDirectUri(uri);
|
|
132
|
+
const client = new MongoClient(direct, { serverSelectionTimeoutMS: 20000 });
|
|
133
|
+
await client.connect();
|
|
134
|
+
const db = client.db();
|
|
135
|
+
|
|
136
|
+
const present = (await db.listCollections().toArray()).map((c) => c.name);
|
|
137
|
+
for (const name of REQUIRED_COLLECTIONS) {
|
|
138
|
+
if (!present.includes(name)) {
|
|
139
|
+
fail(
|
|
140
|
+
"this is the WRONG DATABASE -- `" +
|
|
141
|
+
name +
|
|
142
|
+
"` is missing. Roles and members live in API-core's database.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (PRODUCTION_MARKERS.some((m) => m.test(db.databaseName))) {
|
|
147
|
+
fail("the database NAME looks like PRODUCTION: " + db.databaseName);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log(
|
|
151
|
+
"\n database: " + db.databaseName + " (" + present.length + " collections)",
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const roles = await db.collection("roles").find({}).toArray();
|
|
155
|
+
const byId = new Map(roles.map((r) => [r._id.toString(), r]));
|
|
156
|
+
const live = roles.filter((r) => (r.status || "active") === "active");
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
* RULE 1 -- every `members` document, no filter. A member with no role, one
|
|
160
|
+
* whose role id resolves to nothing, and one of a type nobody expected are
|
|
161
|
+
* all counted: those are exactly the rows a measurement that filters first
|
|
162
|
+
* would drop, and one of them is always the population that breaks.
|
|
163
|
+
*/
|
|
164
|
+
const members = await db.collection("members").find({}).toArray();
|
|
165
|
+
|
|
166
|
+
const accessRows = new Map();
|
|
167
|
+
for (const member of members) {
|
|
168
|
+
const type = member.type ? String(member.type) : "(no type)";
|
|
169
|
+
let row = accessRows.get(type);
|
|
170
|
+
if (!row) {
|
|
171
|
+
row = {
|
|
172
|
+
type,
|
|
173
|
+
accounts: 0,
|
|
174
|
+
"grants ALL": 0,
|
|
175
|
+
enumerated: 0,
|
|
176
|
+
"no role": 0,
|
|
177
|
+
REFUSED: 0,
|
|
178
|
+
};
|
|
179
|
+
accessRows.set(type, row);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
row.accounts += 1;
|
|
183
|
+
|
|
184
|
+
const role = member.role ? byId.get(member.role.toString()) : null;
|
|
185
|
+
if (!role) {
|
|
186
|
+
row["no role"] += 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const before = grants(role);
|
|
191
|
+
/*
|
|
192
|
+
* The after column. This change adds no read gate, rewrites no document and
|
|
193
|
+
* touches no stored `permissions` array, so the two are read from the same
|
|
194
|
+
* document by the same rule. It is COMPUTED rather than assumed so that a
|
|
195
|
+
* later change which does touch a stored list shows up here as a non-zero
|
|
196
|
+
* REFUSED row instead of passing on the strength of an argument.
|
|
197
|
+
*/
|
|
198
|
+
const after = grants(role);
|
|
199
|
+
|
|
200
|
+
if (before === "ALL") row["grants ALL"] += 1;
|
|
201
|
+
else row.enumerated += 1;
|
|
202
|
+
if (before !== after) row.REFUSED += 1;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log("\n ACCESS -- what every account's role grants, before vs after\n");
|
|
206
|
+
const rows = [...accessRows.values()].sort((a, b) => b.accounts - a.accounts);
|
|
207
|
+
table(rows, ["type", "accounts", "grants ALL", "enumerated", "no role", "REFUSED"]);
|
|
208
|
+
|
|
209
|
+
const totalRefused = rows.reduce((n, r) => n + r.REFUSED, 0);
|
|
210
|
+
console.log(
|
|
211
|
+
"\n " +
|
|
212
|
+
members.length +
|
|
213
|
+
" accounts across " +
|
|
214
|
+
rows.length +
|
|
215
|
+
" types. Refusals: " +
|
|
216
|
+
totalRefused,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
/*
|
|
220
|
+
* The write half. Counted, not asserted to be zero -- refusing these saves is
|
|
221
|
+
* the change -- with the population behind each role so the cost is explicit.
|
|
222
|
+
*/
|
|
223
|
+
const memberCount = new Map();
|
|
224
|
+
members.forEach((m) => {
|
|
225
|
+
if (!m.role) return;
|
|
226
|
+
const key = m.role.toString();
|
|
227
|
+
memberCount.set(key, (memberCount.get(key) || 0) + 1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const inviteCount = new Map();
|
|
231
|
+
const verifications = await db
|
|
232
|
+
.collection("verifications")
|
|
233
|
+
.find({}, { projection: { metadata: 1 } })
|
|
234
|
+
.toArray();
|
|
235
|
+
for (const v of verifications) {
|
|
236
|
+
const role = v && v.metadata && v.metadata.role;
|
|
237
|
+
if (!role) continue;
|
|
238
|
+
const key = role.toString();
|
|
239
|
+
inviteCount.set(key, (inviteCount.get(key) || 0) + 1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const templates = live.filter((r) => roleScope(r) === "client-template");
|
|
243
|
+
|
|
244
|
+
console.log("\n WRITES -- roles the API will no longer save (the intended cost)\n");
|
|
245
|
+
table(
|
|
246
|
+
templates
|
|
247
|
+
.map((r) => ({
|
|
248
|
+
name: String(r.name === undefined ? "" : r.name).slice(0, 34),
|
|
249
|
+
type: r.type || "",
|
|
250
|
+
perms: (r.permissions || []).length,
|
|
251
|
+
grants: grants(r) === "ALL" ? "ALL" : "enumerated",
|
|
252
|
+
members: memberCount.get(r._id.toString()) || 0,
|
|
253
|
+
invitations: inviteCount.get(r._id.toString()) || 0,
|
|
254
|
+
}))
|
|
255
|
+
.sort((a, b) => b.members - a.members),
|
|
256
|
+
["name", "type", "perms", "grants", "members", "invitations"],
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
/*
|
|
260
|
+
* The one assertion on the permission guard: no role as it stands today may
|
|
261
|
+
* fail its OWN re-save. The guard refuses ADDITIONS only, so a role already
|
|
262
|
+
* holding a platform string keeps its editor. A non-zero count here would
|
|
263
|
+
* mean the guard locks somebody out of a role they can edit today, which is
|
|
264
|
+
* the failure mode this whole file exists to catch.
|
|
265
|
+
*/
|
|
266
|
+
const platformOnly = new Set(
|
|
267
|
+
JSON.parse(
|
|
268
|
+
fs.readFileSync(
|
|
269
|
+
path.join(__dirname, "platform-only-permissions.json"),
|
|
270
|
+
"utf8",
|
|
271
|
+
),
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
const wouldFailResave = live.filter((r) => {
|
|
275
|
+
if (roleScope(r) === "platform") return false;
|
|
276
|
+
const held = new Set(r.permissions || []);
|
|
277
|
+
return (r.permissions || []).some((p) => platformOnly.has(p) && !held.has(p));
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/*
|
|
281
|
+
* And the reverse reading of the same data, which is the number a lead
|
|
282
|
+
* actually wants: client roles that hold a platform string at all. Those
|
|
283
|
+
* saves keep working; the count says how big a catalogue merge would be.
|
|
284
|
+
*/
|
|
285
|
+
const holdPlatformString = live.filter(
|
|
286
|
+
(r) =>
|
|
287
|
+
roleScope(r) !== "platform" &&
|
|
288
|
+
(r.permissions || []).some((p) => platformOnly.has(p)),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
console.log(
|
|
292
|
+
"\n client roles holding a platform string: " +
|
|
293
|
+
holdPlatformString.length +
|
|
294
|
+
" of those, unable to re-save: " +
|
|
295
|
+
wouldFailResave.length,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
await client.close();
|
|
299
|
+
|
|
300
|
+
if (totalRefused > 0) {
|
|
301
|
+
fail(
|
|
302
|
+
rows
|
|
303
|
+
.filter((r) => r.REFUSED > 0)
|
|
304
|
+
.map((r) => r.type + ": " + r.REFUSED)
|
|
305
|
+
.join(", ") + " -- an account type loses access. This blocks the merge.",
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (wouldFailResave.length > 0) {
|
|
309
|
+
fail(
|
|
310
|
+
wouldFailResave.length +
|
|
311
|
+
" roles could no longer be re-saved as they stand. This blocks the merge.",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
console.log("\n PASS zero refusals in every account type.\n");
|
|
316
|
+
})().catch((e) => fail(e.message));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[
|
|
2
|
+
"activity-history:see-activity-history",
|
|
3
|
+
"marketplace-vendors:see-all-marketplace-vendors",
|
|
4
|
+
"marketplace-vendors:see-marketplace-vendor-details",
|
|
5
|
+
"organizations:see-all-organizations",
|
|
6
|
+
"organizations:see-organization-details",
|
|
7
|
+
"platform-terms:edit-platform-terms",
|
|
8
|
+
"platform-terms:see-platform-terms",
|
|
9
|
+
"promo-codes:change-promo-code-status",
|
|
10
|
+
"promo-codes:create-promo-code",
|
|
11
|
+
"promo-codes:delete-promo-code",
|
|
12
|
+
"promo-codes:edit-promo-code-details",
|
|
13
|
+
"promo-codes:see-promo-code-details",
|
|
14
|
+
"sp-approvals:approve-sp",
|
|
15
|
+
"sp-approvals:delete-sp-approval",
|
|
16
|
+
"sp-approvals:reject-sp",
|
|
17
|
+
"sp-approvals:see-all-sp-approvals",
|
|
18
|
+
"subscriptions:manage-subscription",
|
|
19
|
+
"subscriptions:see-all-subscriptions",
|
|
20
|
+
"subscriptions:see-subscription-details"
|
|
21
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// This machine's DNS resolver REFUSES SRV lookups (querySrv ECONNREFUSED), so
|
|
2
|
+
// `mongodb+srv://` URIs cannot connect. Resolve the SRV+TXT records via a public
|
|
3
|
+
// resolver and hand the driver a plain `mongodb://` URI instead. Read-only helper.
|
|
4
|
+
const { Resolver } = require("dns");
|
|
5
|
+
|
|
6
|
+
function resolveVia (server, method, name) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const r = new Resolver();
|
|
9
|
+
r.setServers([server]);
|
|
10
|
+
r[method](name, (err, res) => (err ? reject(err) : resolve(res)));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Turn a mongodb+srv:// URI into a direct mongodb:// URI. Passes others through. */
|
|
15
|
+
async function toDirectUri (uri, server = "8.8.8.8") {
|
|
16
|
+
if (!uri.startsWith("mongodb+srv://")) return uri;
|
|
17
|
+
const rest = uri.slice("mongodb+srv://".length);
|
|
18
|
+
const at = rest.lastIndexOf("@");
|
|
19
|
+
const creds = at === -1 ? "" : rest.slice(0, at + 1);
|
|
20
|
+
const after = rest.slice(at + 1);
|
|
21
|
+
const slash = after.search(/[/?]/);
|
|
22
|
+
const host = slash === -1 ? after : after.slice(0, slash);
|
|
23
|
+
const tail = slash === -1 ? "" : after.slice(slash);
|
|
24
|
+
|
|
25
|
+
const srv = await resolveVia(server, "resolveSrv", `_mongodb._tcp.${host}`);
|
|
26
|
+
const txt = await resolveVia(server, "resolveTxt", host).catch(() => []);
|
|
27
|
+
const hosts = srv.map((s) => `${s.name}:${s.port}`).join(",");
|
|
28
|
+
|
|
29
|
+
const [pathPart, queryPart = ""] = tail.startsWith("/")
|
|
30
|
+
? [tail.slice(1).split("?")[0], tail.split("?")[1] || ""]
|
|
31
|
+
: ["", tail.replace(/^\?/, "")];
|
|
32
|
+
const opts = new URLSearchParams(queryPart);
|
|
33
|
+
for (const [k, v] of new URLSearchParams(txt.flat().join("&"))) if (!opts.has(k)) opts.set(k, v);
|
|
34
|
+
opts.set("tls", "true");
|
|
35
|
+
|
|
36
|
+
return `mongodb://${creds}${hosts}/${pathPart}?${opts.toString()}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { toDirectUri };
|