@7365admin1/core 3.55.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 +37 -0
- package/dist/index.js +4418 -4361
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +130 -73
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/onboarding-org-save-diagnosis.e2e.test.mjs +76 -3
- 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,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 };
|