@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.
- package/CHANGELOG.md +11 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +4573 -4488
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4573 -4488
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/backfill-org-owner-roles.test.mjs +25 -11
- package/test/console-permission.test.mjs +37 -5
- package/test/org-default-roles.test.mjs +82 -39
- package/test/org-modules.test.mjs +162 -0
- package/test/org-role-selfheal.test.mjs +24 -16
- package/test/repair-client-template-permissions.test.mjs +331 -0
- package/test/staff-console-authz.test.mjs +22 -2
- package/tools/backfill-org-owner-roles/backfill.mjs +32 -2
- package/tools/repair-client-template-permissions/devtools-snippet.js +163 -0
- package/tools/repair-client-template-permissions/repair.mjs +329 -0
|
@@ -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
|
+
}
|