@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/CHANGELOG.md +6 -0
- package/dist/index.d.ts +26 -1
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/role-read-scope.e2e.test.mjs +166 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|