@7365admin1/core 3.52.15 → 3.52.16

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.52.15",
4
+ "version": "3.52.16",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -0,0 +1,166 @@
1
+ // End-to-end proof that one client cannot read another client's permission
2
+ // model, and that a member reading their OWN role still works.
3
+ //
4
+ // `GET /api/roles/id/:id` was `requireAuth` and nothing more. The response is a
5
+ // role's `name` and its whole `permissions` array, and the id is in the URL, so
6
+ // any signed-in account could walk another organisation's roles and learn
7
+ // exactly what that client's people are allowed to do.
8
+ //
9
+ // The obvious fix — scope by the role's organisation, the helper the five write
10
+ // paths already use — breaks a real, large population: staging carries roles
11
+ // with NO organisation, one of which ("Org Owner") is held by 119 ordinary
12
+ // tenant members who read it on every session. So the gate allows the caller's
13
+ // OWN role first and falls through to the organisation rule otherwise. Case 4
14
+ // below is that exact row.
15
+ //
16
+ // Everything it talks to is created and thrown away by the harness: an
17
+ // in-process MongoDB replica set, a loopback Redis, a loopback mail sink. No
18
+ // staging or production database is touched. No identity is printed.
19
+ //
20
+ // Run with: yarn test:e2e
21
+
22
+ import { after, before, describe, it } from "node:test";
23
+ import assert from "node:assert/strict";
24
+ import { ObjectId } from "mongodb";
25
+
26
+ import { startHarness } from "./harness.mjs";
27
+
28
+ const PASSWORD = "Roles-Passw0rd!";
29
+ const ALICE = "alice-roles@e2e.example.com"; // member of org A, holds A's role
30
+ const BOB = "bob-roles@e2e.example.com"; // member of org A, holds a different role
31
+ const MALLORY = "mallory-roles@e2e.example.com"; // member of org B — an outsider to A
32
+
33
+ describe("role reads are scoped, and a member can still read their own role", { concurrency: 1 }, () => {
34
+ let h;
35
+ const id = {};
36
+ const result = [];
37
+
38
+ const record = (name, fn) =>
39
+ it(name, async () => {
40
+ try {
41
+ await fn();
42
+ result.push(["PASS", name]);
43
+ } catch (error) {
44
+ result.push(["FAIL", name]);
45
+ throw error;
46
+ }
47
+ });
48
+
49
+ before(async () => {
50
+ h = await startHarness();
51
+ Object.assign(id, await seed(h));
52
+ }, { timeout: 300000 });
53
+
54
+ after(async () => {
55
+ if (h) await h.stop();
56
+ console.log("\n--- role read scoping: per-case result ---");
57
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
58
+ });
59
+
60
+ let aliceSid;
61
+ let bobSid;
62
+ let mallorySid;
63
+
64
+ record("0. baseline — everyone can sign in", async () => {
65
+ aliceSid = await h.login(ALICE, PASSWORD);
66
+ bobSid = await h.login(BOB, PASSWORD);
67
+ mallorySid = await h.login(MALLORY, PASSWORD);
68
+ assert.ok(aliceSid && bobSid && mallorySid);
69
+ });
70
+
71
+ record("1. the caller HOLDS the role -> 200", async () => {
72
+ const res = await h.api(`/roles/id/${id.roleA}`, { sid: aliceSid });
73
+ assert.equal(res.status, 200);
74
+ assert.equal(res.body?.name, "Estate Manager");
75
+ });
76
+
77
+ record("2. the caller's ORGANISATION owns the role -> 200", async () => {
78
+ // Bob is a live member of org A but holds a different role, so only the
79
+ // organisation arm can be answering here.
80
+ const res = await h.api(`/roles/id/${id.roleA}`, { sid: bobSid });
81
+ assert.equal(res.status, 200);
82
+ assert.equal(res.body?.name, "Estate Manager");
83
+ });
84
+
85
+ record("3. neither held nor shared organisation -> 401", async () => {
86
+ const res = await h.api(`/roles/id/${id.roleA}`, { sid: mallorySid });
87
+ assert.equal(res.status, 401);
88
+ assert.notEqual(JSON.stringify(res.body ?? ""), JSON.stringify({ name: "Estate Manager" }));
89
+ });
90
+
91
+ record("4. the ORG-LESS role — its holder reads it, an outsider cannot", async () => {
92
+ // This is the shape that made the organisation-only fix unshippable: a role
93
+ // with no `org`, held by ordinary tenant members. Bob holds it.
94
+ const holder = await h.api(`/roles/id/${id.roleOrgless}`, { sid: bobSid });
95
+ assert.equal(holder.status, 200, "the holder of an org-less role must still read it");
96
+ assert.equal(holder.body?.name, "Org Owner");
97
+
98
+ const outsider = await h.api(`/roles/id/${id.roleOrgless}`, { sid: mallorySid });
99
+ assert.equal(outsider.status, 401, "a non-holder from another org must be refused");
100
+ });
101
+
102
+ record("5. an unauthenticated read is still refused", async () => {
103
+ const res = await h.api(`/roles/id/${id.roleA}`);
104
+ assert.equal(res.status, 401);
105
+ });
106
+
107
+ record("6. a revoked membership stops conferring the read", async () => {
108
+ await h.db
109
+ .collection("members")
110
+ .updateOne({ _id: id.bobOrglessMember }, { $set: { status: "deleted" } });
111
+
112
+ const res = await h.api(`/roles/id/${id.roleOrgless}`, { sid: bobSid });
113
+ assert.equal(res.status, 401, "a soft-deleted members row must not still hold the role");
114
+
115
+ await h.db
116
+ .collection("members")
117
+ .updateOne({ _id: id.bobOrglessMember }, { $set: { status: "active" } });
118
+ });
119
+ });
120
+
121
+ async function seed(h) {
122
+ const now = new Date().toISOString();
123
+ const hashed = await h.hashPassword(PASSWORD);
124
+
125
+ const orgs = await h.db.collection("organizations").insertMany([
126
+ { name: "Org A", status: "active", createdAt: now },
127
+ { name: "Org B", status: "active", createdAt: now },
128
+ ]);
129
+ const orgA = orgs.insertedIds[0];
130
+ const orgB = orgs.insertedIds[1];
131
+
132
+ const roles = await h.db.collection("roles").insertMany([
133
+ { name: "Estate Manager", type: "organization", org: orgA, permissions: ["view-dashboard"], status: "active", createdAt: now },
134
+ { name: "Estate Officer", type: "organization", org: orgA, permissions: ["view-dashboard"], status: "active", createdAt: now },
135
+ { name: "Neighbour Role", type: "organization", org: orgB, permissions: ["view-dashboard"], status: "active", createdAt: now },
136
+ // no `org` at all — the staging "Org Owner" shape
137
+ { name: "Org Owner", type: "organization", permissions: ["view-dashboard"], status: "active", createdAt: now },
138
+ ]);
139
+ const roleA = roles.insertedIds[0];
140
+ const roleA2 = roles.insertedIds[1];
141
+ const roleB = roles.insertedIds[2];
142
+ const roleOrgless = roles.insertedIds[3];
143
+
144
+ const users = await h.db.collection("users").insertMany([
145
+ { email: ALICE, name: "Alice", status: "active", password: hashed, createdAt: now },
146
+ { email: BOB, name: "Bob", status: "active", password: hashed, createdAt: now },
147
+ { email: MALLORY, name: "Mallory", status: "active", password: hashed, createdAt: now },
148
+ ]);
149
+
150
+ const members = await h.db.collection("members").insertMany([
151
+ { user: users.insertedIds[0], org: orgA, role: roleA, type: "organization", status: "active", createdAt: now },
152
+ { user: users.insertedIds[1], org: orgA, role: roleA2, type: "organization", status: "active", createdAt: now },
153
+ { user: users.insertedIds[1], org: orgA, role: roleOrgless, type: "organization", status: "active", createdAt: now },
154
+ { user: users.insertedIds[2], org: orgB, role: roleB, type: "organization", status: "active", createdAt: now },
155
+ ]);
156
+
157
+ return {
158
+ orgA,
159
+ orgB,
160
+ roleA,
161
+ roleA2,
162
+ roleB,
163
+ roleOrgless,
164
+ bobOrglessMember: members.insertedIds[2],
165
+ };
166
+ }