@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.
@@ -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
+ });
@@ -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
- /await requireRoleOrg\(req, existing\.org\)/,
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
- /requireRoleOrg\(req, (req\.body|req\.query|req\.params)/,
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.indexOf("requireRoleOrg");
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 };