@7365admin1/core 3.61.0 → 3.62.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,329 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PUT BACK WHAT ONE CONSOLE SAVE TOOK AWAY ON 2026-09-07 -- through the
4
+ * ordinary authenticated API, never the database.
5
+ *
6
+ * ## What happened, and what is still wrong
7
+ *
8
+ * "Org Owner" is a role with `type: "organization"` and NO `org`. An org-less
9
+ * role belongs to no single client, so every client ever invited with it shares
10
+ * the one document. On 2026-09-07 it was opened in the Seven365 staff console's
11
+ * role editor and saved. That editor renders `useAdminPermission` -- the eleven
12
+ * PLATFORM resources, 34 actions -- as one selectable list, so the save wrote
13
+ * exactly those 34 platform strings onto it and not one client module.
14
+ *
15
+ * The permission model reads an EMPTY list as "everything"
16
+ * (`layer-common utils/permission-spellings.ts grantsPermission`, mirrored here
17
+ * by `consoleRoleAllowsAll`). That role's list was empty before the save. So it
18
+ * granted everything right up to the moment it held a non-empty list -- and the
19
+ * list it then held contained no client module at all. Every holder, in every
20
+ * client, lost every module at once.
21
+ *
22
+ * BOTH ENDS OF THE CAUSE ARE CLOSED and neither of them repairs anything:
23
+ *
24
+ * - the console screen no longer lists client templates
25
+ * (`web-app-org pages/super-admin/role-permissions/index.vue`);
26
+ * - `role-scope.util.ts` refuses platform strings being ADDED to a client
27
+ * role, server-side, whatever editor asks.
28
+ *
29
+ * The document itself was never put back. Measured on staging 2026-09-09:
30
+ * "Org Owner" still holds those 34 strings, with 123 live members and 322
31
+ * invitations behind it.
32
+ *
33
+ * ## Why this is allowed to work at all
34
+ *
35
+ * `role-scope.util.ts` refuses ADDITIONS only -- "a permission string that is
36
+ * already stored on a role is always allowed through". This repair only ever
37
+ * REMOVES platform strings, so the guard that stops the damage recurring does
38
+ * not stand in the way of undoing it. Nothing here weakens that guard.
39
+ *
40
+ * ## What it will not do
41
+ *
42
+ * - **No database.** It holds no connection string and speaks only to the API,
43
+ * so every write is authorised, rate-limited and audited exactly like a write
44
+ * from the console.
45
+ * - **DRY RUN unless `--apply` is passed.** With no `--apply` it reads, decides
46
+ * and prints, and sends no PATCH at all.
47
+ * - **It repairs NOTHING on its own.** It detects and reports; a repair happens
48
+ * only for a role a person NAMES with `--repair-role <id>`. Restoring a role
49
+ * to `["*"]` asserts it granted everything before the save, and no stored
50
+ * document says what it held before -- so that assertion has to come from a
51
+ * person, not from a rule. See `planRepair` for the dry run that caught this.
52
+ * - **It never creates, deletes or repoints a role, member or invitation**, and
53
+ * never touches a role that carries an `org` or a `type: "admin"` role.
54
+ * - **Re-read immediately before each write**, so a role somebody repaired a
55
+ * second ago is skipped rather than overwritten.
56
+ *
57
+ * ## Why `["*"]` and not `[]`
58
+ *
59
+ * `[]` is what the role held before the save, and `["*"]` means the same thing
60
+ * to every consumer (`grantsPermission` short-circuits on both). `["*"]` is
61
+ * written instead because it is EXPLICIT: it is the shape
62
+ * `subscription.service.ts` and `defaultOwnerRoles` already seed for every
63
+ * owner role, it survives any future tightening of the empty-list rule, and it
64
+ * cannot be mistaken by a reader for "nobody has set this up yet". The grant is
65
+ * identical either way, so no holder gains or loses anything relative to the
66
+ * pre-incident state.
67
+ *
68
+ * ## Usage
69
+ *
70
+ * API=https://iservice365-staging-api.apptesting.work \
71
+ * SEVEN365_SID=<session id, never a literal on a command line> \
72
+ * node tools/repair-client-template-permissions/repair.mjs # DRY RUN
73
+ *
74
+ * ... same, plus --apply # WRITES
75
+ *
76
+ * ... plus --repair-role <24-hex id> the ONLY way anything is written.
77
+ * Repeatable. This is how "Org Owner" is
78
+ * repaired. Run the dry run first, read
79
+ * the REVIEW rows, then name the ids you
80
+ * have decided about.
81
+ *
82
+ * Identity: a Seven365 platform-staff account. `GET /api/roles` with no `org`
83
+ * returns the org-less set, and `PATCH /api/roles/permissions/id/:id` needs the
84
+ * console grant for roles and permissions.
85
+ *
86
+ * ⚠ Run the DRY RUN first and read the table. It names every role it would
87
+ * touch, and how many live members and invitations sit behind each one.
88
+ */
89
+
90
+ import { pathToFileURL } from "node:url";
91
+
92
+ const HEX24 = /^[0-9a-fA-F]{24}$/;
93
+
94
+ /**
95
+ * The permission strings that belong to the Seven365 console and nowhere else.
96
+ *
97
+ * Deliberately the SAME seven resources as `src/utils/role-scope.util.ts`
98
+ * `PLATFORM_ONLY_RESOURCES`, and for the same reason: `users`, `invitations`,
99
+ * `members` and `roles-and-permissions` exist in BOTH catalogues, so treating
100
+ * them as platform-only would classify an ordinary client role as corrupt.
101
+ *
102
+ * Written out rather than imported so the tool runs with no build step;
103
+ * `test/repair-client-template-permissions.test.mjs` fails if it ever drifts
104
+ * from the source of truth in `role-scope.util.ts`.
105
+ */
106
+ export const PLATFORM_ONLY_RESOURCES = [
107
+ "organizations",
108
+ "promo-codes",
109
+ "subscriptions",
110
+ "sp-approvals",
111
+ "marketplace-vendors",
112
+ "platform-terms",
113
+ "activity-history",
114
+ ];
115
+
116
+ /** Is this string a console-only grant? Resource half only -- see below. */
117
+ export function isPlatformOnly(permission) {
118
+ const resource = String(permission ?? "").split(":")[0];
119
+ return PLATFORM_ONLY_RESOURCES.includes(resource);
120
+ }
121
+
122
+ /**
123
+ * What should be done with this role.
124
+ *
125
+ * Pure: no network, no clock, no database. Every decision the tool takes is
126
+ * taken here, so the decision is what the tests exercise.
127
+ *
128
+ * @returns `{ skip: <reason> }` or `{ payload: { permissions: [...] }, ... }`
129
+ */
130
+ export function planRepair(role, namedForRepair = []) {
131
+ const id = role?._id?.toString?.() ?? "";
132
+ if (!HEX24.test(id)) return { skip: "role has no usable id" };
133
+
134
+ // A platform role is SUPPOSED to hold platform strings. Leave it alone.
135
+ if ((role?.type ?? "") === "admin") return { skip: "platform staff role" };
136
+
137
+ // A role carrying an org belongs to one client and was never the shared
138
+ // document this incident damaged.
139
+ if (role?.org?.toString?.()) return { skip: "role belongs to one organisation" };
140
+
141
+ const held = Array.isArray(role?.permissions) ? role.permissions : null;
142
+ if (held === null) return { skip: "permission list unreadable" };
143
+
144
+ // An empty list already means everything -- this role is in its pre-incident
145
+ // state and needs nothing. Fail safe: doing nothing is the correct answer.
146
+ if (held.length === 0) return { skip: "already grants everything (empty list)" };
147
+
148
+ if (held.includes("*")) return { skip: "already grants everything (wildcard)" };
149
+
150
+ const platform = held.filter(isPlatformOnly);
151
+
152
+ if (platform.length === 0) {
153
+ return { skip: "holds no platform strings - an ordinary client template" };
154
+ }
155
+
156
+ /*
157
+ * NOTHING IS REPAIRED AUTOMATICALLY, and that is a correction to this tool's
158
+ * first design.
159
+ *
160
+ * The first version repaired any role whose list was ENTIRELY platform
161
+ * strings, on the reasoning that a client role cannot legitimately hold only
162
+ * those. The reasoning is sound; the CONCLUSION does not follow. Restoring a
163
+ * role to `["*"]` asserts it granted EVERYTHING before the save, and no
164
+ * stored document says what it held before. The dry run over the real 462
165
+ * staging roles is what caught it: the rule wanted to give "Sub Member" --
166
+ * one platform string, no holders -- the run of the entire platform, which is
167
+ * plainly not what a role called Sub Member ever was.
168
+ *
169
+ * So the tool detects and reports, and repairs only what a person NAMES. That
170
+ * is the same discipline the guard it undoes was built on: this is a
171
+ * privilege-restoring write, and a privilege-restoring write nobody signed
172
+ * for is how the incident happened in the first place.
173
+ */
174
+
175
+ // A MIX is not proof of the incident. It could be a genuine client role that
176
+ // somebody also ticked a console box on, and rewriting it would take away
177
+ // grants nobody asked us to take away. Report it; do not touch it.
178
+ //
179
+ // Unless a human has NAMED this exact role id on the command line. That is
180
+ // how the real "Org Owner" gets repaired: its 34 strings are 19 platform-only
181
+ // plus 15 on the four SHARED resources (`users`, `invitations`, `members`,
182
+ // `roles-and-permissions`), so it is a mix by this rule and no automatic
183
+ // reading of the document can prove what it held before. A person has to
184
+ // decide, and naming the id is that decision being recorded.
185
+ if (!namedForRepair.includes(id)) {
186
+ return {
187
+ skip:
188
+ `holds ${platform.length} platform string(s) of ${held.length} ` +
189
+ `- needs a human to name it`,
190
+ review: true,
191
+ };
192
+ }
193
+
194
+ return {
195
+ payload: { permissions: ["*"] },
196
+ removing: held.length,
197
+ named: true,
198
+ };
199
+ }
200
+
201
+ /* ───────────────────────── I/O below this line ───────────────────────── */
202
+
203
+ async function main() {
204
+ const API = process.env.API ?? "";
205
+ const SID = process.env.SEVEN365_SID ?? "";
206
+ const apply = process.argv.includes("--apply");
207
+
208
+ // `--repair-role <id>` (repeatable) is a human saying "I have read this role
209
+ // and I am asking for it to be restored", for a mixed list the tool will not
210
+ // judge on its own. It never widens what the tool touches beyond an org-less,
211
+ // non-admin role -- every other refusal above still applies.
212
+ const namedForRepair = process.argv
213
+ .map((arg, i) => (arg === "--repair-role" ? process.argv[i + 1] : null))
214
+ .filter((id) => HEX24.test(id ?? ""));
215
+
216
+ if (!API || !SID) {
217
+ console.error("Set API and SEVEN365_SID. See the header of this file.");
218
+ process.exit(2);
219
+ }
220
+
221
+ const call = async (path, init = {}) => {
222
+ const res = await fetch(`${API}${path}`, {
223
+ ...init,
224
+ headers: {
225
+ Authorization: `Bearer ${SID}`,
226
+ "Content-Type": "application/json",
227
+ ...(init.headers ?? {}),
228
+ },
229
+ });
230
+ const text = await res.text();
231
+ let body = null;
232
+ try {
233
+ body = text ? JSON.parse(text) : null;
234
+ } catch {
235
+ body = text;
236
+ }
237
+ return { status: res.status, body };
238
+ };
239
+
240
+ console.log(apply ? "MODE: APPLY (writes)" : "MODE: DRY RUN (no writes)");
241
+ if (namedForRepair.length) {
242
+ console.log(`NAMED for repair by a human: ${namedForRepair.join(", ")}`);
243
+ }
244
+ console.log(`API: ${API}\n`);
245
+
246
+ // The org-less set. `GET /api/roles` with no `org` is exactly the query the
247
+ // console's own screen makes, so this sees what a staff account sees.
248
+ const listed = await call("/api/roles?limit=100");
249
+ if (listed.status !== 200) {
250
+ console.error(`GET /api/roles -> ${listed.status}`, listed.body);
251
+ process.exit(1);
252
+ }
253
+
254
+ const roles = listed.body?.items ?? [];
255
+ console.log(`org-less roles returned: ${roles.length}\n`);
256
+
257
+ const planned = [];
258
+ const review = [];
259
+ const skipped = [];
260
+
261
+ for (const role of roles) {
262
+ const plan = planRepair(role, namedForRepair);
263
+ if (plan.payload) planned.push({ role, plan });
264
+ else if (plan.review) review.push({ role, plan });
265
+ else skipped.push({ role, plan });
266
+ }
267
+
268
+ if (review.length) {
269
+ console.log("NEEDS A HUMAN - a mixed list is not proof of the incident:");
270
+ for (const { role, plan } of review) {
271
+ console.log(` ${role.name} (${role._id}) - ${plan.skip}`);
272
+ }
273
+ console.log("");
274
+ }
275
+
276
+ if (!planned.length) {
277
+ console.log("Nothing to repair. Every org-less client role already grants everything,");
278
+ console.log("or holds a list this tool will not judge.");
279
+ console.log(`\nskipped: ${skipped.length}`);
280
+ return;
281
+ }
282
+
283
+ console.log("WOULD REPAIR:" + (apply ? " (and will, --apply is set)" : ""));
284
+ for (const { role, plan } of planned) {
285
+ console.log(
286
+ ` ${role.name} (${role._id}) type=${role.type} ` +
287
+ `- removing ${plan.removing} strings, restoring ["*"]` +
288
+ (plan.named ? " [NAMED BY A HUMAN - mixed list]" : ""),
289
+ );
290
+ }
291
+ console.log("");
292
+
293
+ if (!apply) {
294
+ console.log("DRY RUN - nothing was written. Re-run with --apply to write.");
295
+ return;
296
+ }
297
+
298
+ for (const { role, plan } of planned) {
299
+ // Re-read immediately before writing: somebody may have repaired it a
300
+ // second ago, and a tool that overwrites a fresher value is not a repair.
301
+ const fresh = await call(`/api/roles/id/${role._id}`);
302
+ if (fresh.status !== 200) {
303
+ console.log(` SKIP ${role.name}: re-read returned ${fresh.status}`);
304
+ continue;
305
+ }
306
+ const recheck = planRepair(fresh.body, namedForRepair);
307
+ if (!recheck.payload) {
308
+ console.log(` SKIP ${role.name}: ${recheck.skip} (changed since the listing)`);
309
+ continue;
310
+ }
311
+
312
+ const res = await call(`/api/roles/permissions/id/${role._id}`, {
313
+ method: "PATCH",
314
+ body: JSON.stringify(plan.payload),
315
+ });
316
+ console.log(
317
+ res.status >= 200 && res.status < 300
318
+ ? ` OK ${role.name} -> ["*"]`
319
+ : ` FAIL ${role.name} -> ${res.status} ${JSON.stringify(res.body)}`,
320
+ );
321
+ }
322
+ }
323
+
324
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
325
+ main().catch((error) => {
326
+ console.error(error);
327
+ process.exit(1);
328
+ });
329
+ }