@stratum-hq/control-plane 0.2.4 → 0.4.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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/dist/__tests__/admin-scope-query-string.test.d.ts +2 -0
  4. package/dist/__tests__/admin-scope-query-string.test.d.ts.map +1 -0
  5. package/dist/__tests__/admin-scope-query-string.test.js +69 -0
  6. package/dist/__tests__/admin-scope-query-string.test.js.map +1 -0
  7. package/dist/__tests__/configdiff-roles-scope.test.d.ts +2 -0
  8. package/dist/__tests__/configdiff-roles-scope.test.d.ts.map +1 -0
  9. package/dist/__tests__/configdiff-roles-scope.test.js +246 -0
  10. package/dist/__tests__/configdiff-roles-scope.test.js.map +1 -0
  11. package/dist/__tests__/default-deny.test.d.ts +2 -0
  12. package/dist/__tests__/default-deny.test.d.ts.map +1 -0
  13. package/dist/__tests__/default-deny.test.js +92 -0
  14. package/dist/__tests__/default-deny.test.js.map +1 -0
  15. package/dist/__tests__/tenant-create-scope.test.d.ts +2 -0
  16. package/dist/__tests__/tenant-create-scope.test.d.ts.map +1 -0
  17. package/dist/__tests__/tenant-create-scope.test.js +129 -0
  18. package/dist/__tests__/tenant-create-scope.test.js.map +1 -0
  19. package/dist/__tests__/tenant-move-scope.test.d.ts +2 -0
  20. package/dist/__tests__/tenant-move-scope.test.d.ts.map +1 -0
  21. package/dist/__tests__/tenant-move-scope.test.js +113 -0
  22. package/dist/__tests__/tenant-move-scope.test.js.map +1 -0
  23. package/dist/__tests__/test-helpers.d.ts.map +1 -1
  24. package/dist/__tests__/test-helpers.js +13 -0
  25. package/dist/__tests__/test-helpers.js.map +1 -1
  26. package/dist/app.d.ts.map +1 -1
  27. package/dist/app.js +3 -0
  28. package/dist/app.js.map +1 -1
  29. package/dist/middleware/authorize.d.ts.map +1 -1
  30. package/dist/middleware/authorize.js +4 -1
  31. package/dist/middleware/authorize.js.map +1 -1
  32. package/dist/middleware/tenant-scope.d.ts +70 -6
  33. package/dist/middleware/tenant-scope.d.ts.map +1 -1
  34. package/dist/middleware/tenant-scope.js +160 -30
  35. package/dist/middleware/tenant-scope.js.map +1 -1
  36. package/dist/routes/abac.js +1 -1
  37. package/dist/routes/abac.js.map +1 -1
  38. package/dist/routes/api-keys.d.ts.map +1 -1
  39. package/dist/routes/api-keys.js +5 -1
  40. package/dist/routes/api-keys.js.map +1 -1
  41. package/dist/routes/audit-logs.d.ts.map +1 -1
  42. package/dist/routes/audit-logs.js +3 -0
  43. package/dist/routes/audit-logs.js.map +1 -1
  44. package/dist/routes/config-diff.d.ts.map +1 -1
  45. package/dist/routes/config-diff.js +8 -1
  46. package/dist/routes/config-diff.js.map +1 -1
  47. package/dist/routes/config.js +1 -1
  48. package/dist/routes/config.js.map +1 -1
  49. package/dist/routes/consent.js +1 -1
  50. package/dist/routes/consent.js.map +1 -1
  51. package/dist/routes/maintenance.d.ts.map +1 -1
  52. package/dist/routes/maintenance.js +3 -0
  53. package/dist/routes/maintenance.js.map +1 -1
  54. package/dist/routes/permissions.js +1 -1
  55. package/dist/routes/permissions.js.map +1 -1
  56. package/dist/routes/regions.d.ts.map +1 -1
  57. package/dist/routes/regions.js +3 -0
  58. package/dist/routes/regions.js.map +1 -1
  59. package/dist/routes/roles.d.ts.map +1 -1
  60. package/dist/routes/roles.js +49 -6
  61. package/dist/routes/roles.js.map +1 -1
  62. package/dist/routes/tenants.d.ts.map +1 -1
  63. package/dist/routes/tenants.js +14 -4
  64. package/dist/routes/tenants.js.map +1 -1
  65. package/dist/routes/webhooks.js +1 -1
  66. package/dist/routes/webhooks.js.map +1 -1
  67. package/package.json +8 -2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christian Crank
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @stratum-hq/control-plane
2
+
3
+ The [Stratum](https://github.com/stratum-hq/Stratum) control plane — a [Fastify](https://fastify.dev) REST API server that exposes tenant, config, permission, API-key, webhook, audit, consent, region, and ABAC management over HTTP. Pair it with `@stratum-hq/sdk` for polyglot stacks or service separation.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @stratum-hq/control-plane
9
+ ```
10
+
11
+ Most deployments run it as a standalone service rather than importing it. A container image and Compose file ship with the [Stratum repo](https://github.com/stratum-hq/Stratum).
12
+
13
+ ## Running
14
+
15
+ ```bash
16
+ DATABASE_URL=postgres://stratum:stratum_dev@localhost:5432/stratum \
17
+ JWT_SECRET=your-secret \
18
+ node dist/index.js
19
+ ```
20
+
21
+ On startup the server runs database migrations, then listens on `PORT` (default `3001`). It handles `SIGTERM`/`SIGINT` for graceful shutdown. OpenAPI docs are served via `@fastify/swagger-ui`.
22
+
23
+ ## Configuration
24
+
25
+ | Variable | Default | Description |
26
+ |----------|---------|-------------|
27
+ | `PORT` | `3001` | Listen port |
28
+ | `DATABASE_URL` | `postgres://stratum:stratum_dev@localhost:5432/stratum` | PostgreSQL connection string |
29
+ | `JWT_SECRET` | dev fallback | JWT signing secret — **required** in production/staging (server refuses to start without it) |
30
+ | `ALLOWED_ORIGINS` | `localhost:3000,3300` | Comma-separated CORS allowlist |
31
+ | `RATE_LIMIT_MAX` | `100` | Requests per window |
32
+ | `RATE_LIMIT_WINDOW` | `1 minute` | Rate-limit window |
33
+ | `REDIS_URL` | — | Optional; enables distributed rate limiting |
34
+ | `NODE_ENV` | `development` | Environment |
35
+
36
+ Security middleware (`@fastify/helmet`, CORS, rate limiting) is enabled by default.
37
+
38
+ ## API Surface
39
+
40
+ All routes are versioned under `/api/v1`:
41
+
42
+ | Prefix | Resource |
43
+ |--------|----------|
44
+ | `/api/v1/tenants` | Tenant CRUD + hierarchy |
45
+ | `/api/v1/tenants/:id/config` | Config entries |
46
+ | `/api/v1/tenants/:id/permissions` | Permission policies |
47
+ | `/api/v1/tenants/:tenantId/abac-policies` | ABAC policies |
48
+ | `/api/v1/tenants/:tenantId/consent` | GDPR consent records |
49
+ | `/api/v1/api-keys` | API-key management |
50
+ | `/api/v1/webhooks` | Webhook subscriptions |
51
+ | `/api/v1/audit-logs` | Audit log queries |
52
+ | `/api/v1/regions` | Multi-region config |
53
+ | `/api/v1/roles` | RBAC roles |
54
+ | `/api/v1/config` | Config diff |
55
+ | `/api/v1/maintenance` | Retention / purge tasks |
56
+
57
+ A `/health` endpoint reports server and Redis status.
58
+
59
+ ## Links
60
+
61
+ - Documentation: https://docs.stratum-hq.org
62
+ - GitHub: https://github.com/stratum-hq/Stratum
63
+
64
+ ## License
65
+
66
+ MIT © Christian Crank
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=admin-scope-query-string.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-scope-query-string.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/admin-scope-query-string.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const test_helpers_js_1 = require("./test-helpers.js");
5
+ /**
6
+ * Admin-scope enforcement must depend on the resolved request PATH only, not on
7
+ * the raw URL. A query string appended to an admin-only route must not change
8
+ * which scope the route requires.
9
+ */
10
+ (0, vitest_1.describe)("admin scope is enforced independent of the query string", () => {
11
+ let app;
12
+ let stratum;
13
+ const id = test_helpers_js_1.SAMPLE_TENANT.id;
14
+ (0, vitest_1.beforeEach)(async () => {
15
+ stratum = (0, test_helpers_js_1.createMockStratum)();
16
+ stratum.getTenant.mockResolvedValue(test_helpers_js_1.SAMPLE_TENANT);
17
+ stratum.purgeTenant.mockResolvedValue(undefined);
18
+ // getTenantContext is not stubbed by the shared helper; add it locally.
19
+ stratum.getTenantContext = vitest_1.vi
20
+ .fn()
21
+ .mockResolvedValue({ tenant: test_helpers_js_1.SAMPLE_TENANT });
22
+ app = await (0, test_helpers_js_1.buildTestApp)(stratum);
23
+ });
24
+ (0, vitest_1.afterEach)(async () => {
25
+ await app.close();
26
+ });
27
+ (0, vitest_1.it)("write-scope key is refused on an admin route (baseline, no query string)", async () => {
28
+ (0, test_helpers_js_1.setupWriteApiKey)(stratum);
29
+ const res = await app.inject({
30
+ method: "POST",
31
+ url: `/api/v1/tenants/${id}/purge`,
32
+ headers: (0, test_helpers_js_1.authHeaders)(),
33
+ });
34
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
35
+ (0, vitest_1.expect)(stratum.purgeTenant).not.toHaveBeenCalled();
36
+ });
37
+ (0, vitest_1.it)("write-scope key stays refused on an admin route carrying a query string", async () => {
38
+ (0, test_helpers_js_1.setupWriteApiKey)(stratum);
39
+ const res = await app.inject({
40
+ method: "POST",
41
+ url: `/api/v1/tenants/${id}/purge?probe=1`,
42
+ headers: (0, test_helpers_js_1.authHeaders)(),
43
+ });
44
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
45
+ (0, vitest_1.expect)(stratum.purgeTenant).not.toHaveBeenCalled();
46
+ });
47
+ (0, vitest_1.it)("read-scope key stays refused on an admin GET route carrying a query string", async () => {
48
+ (0, test_helpers_js_1.setupReadOnlyApiKey)(stratum);
49
+ const res = await app.inject({
50
+ method: "GET",
51
+ url: `/api/v1/tenants/${id}/context?probe=1`,
52
+ headers: (0, test_helpers_js_1.authHeaders)(),
53
+ });
54
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
55
+ (0, vitest_1.expect)(stratum
56
+ .getTenantContext).not.toHaveBeenCalled();
57
+ });
58
+ (0, vitest_1.it)("admin-scope key is still allowed on an admin route with a query string", async () => {
59
+ (0, test_helpers_js_1.setupAdminApiKey)(stratum);
60
+ const res = await app.inject({
61
+ method: "POST",
62
+ url: `/api/v1/tenants/${id}/purge?probe=1`,
63
+ headers: (0, test_helpers_js_1.authHeaders)(),
64
+ });
65
+ (0, vitest_1.expect)(res.statusCode).toBe(204);
66
+ (0, vitest_1.expect)(stratum.purgeTenant).toHaveBeenCalled();
67
+ });
68
+ });
69
+ //# sourceMappingURL=admin-scope-query-string.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-scope-query-string.test.js","sourceRoot":"","sources":["../../src/__tests__/admin-scope-query-string.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=configdiff-roles-scope.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"configdiff-roles-scope.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/configdiff-roles-scope.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,246 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const test_helpers_js_1 = require("./test-helpers.js");
5
+ /**
6
+ * Regression tests for cross-tenant authorization on the config-diff and role
7
+ * administration routes.
8
+ *
9
+ * Both route groups were declared "global" for the default-deny enforcer, which
10
+ * is behavior-preserving but performs no per-tenant check. That left a
11
+ * tenant-scoped API key able to reach tenants outside its own subtree:
12
+ * - config diff compares two tenants read from the query string;
13
+ * - role routes read/mutate roles owned by an arbitrary tenant.
14
+ *
15
+ * A scoped key must be confined to its own tenant and descendants. Global
16
+ * operator keys (tenant_id === null) keep unrestricted access.
17
+ */
18
+ const ATTACKER = "11111111-1111-4111-8111-111111111111";
19
+ const ATTACKER_CHILD = "22222222-2222-4222-8222-222222222222";
20
+ const VICTIM = "99999999-9999-4999-8999-999999999999";
21
+ // ancestry_path holds a tenant's ANCESTORS and excludes self, so a root is "".
22
+ const TENANTS = {
23
+ [ATTACKER]: { id: ATTACKER, ancestry_path: "", status: "active" },
24
+ [ATTACKER_CHILD]: { id: ATTACKER_CHILD, ancestry_path: ATTACKER, status: "active" },
25
+ [VICTIM]: { id: VICTIM, ancestry_path: "", status: "active" },
26
+ };
27
+ const ROLE_OWN = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
28
+ const ROLE_FOREIGN = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
29
+ const ROLE_GLOBAL = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
30
+ const ROLES = {
31
+ [ROLE_OWN]: { id: ROLE_OWN, name: "own", description: null, scopes: ["read"], tenant_id: ATTACKER },
32
+ [ROLE_FOREIGN]: { id: ROLE_FOREIGN, name: "foreign", description: null, scopes: ["read"], tenant_id: VICTIM },
33
+ [ROLE_GLOBAL]: { id: ROLE_GLOBAL, name: "global", description: null, scopes: ["read"], tenant_id: null },
34
+ };
35
+ const OWN_KEY = "own-key-id";
36
+ const FOREIGN_KEY = "foreign-key-id";
37
+ const KEYS = {
38
+ [OWN_KEY]: { id: OWN_KEY, tenant_id: ATTACKER },
39
+ [FOREIGN_KEY]: { id: FOREIGN_KEY, tenant_id: VICTIM },
40
+ };
41
+ let app;
42
+ let stratum;
43
+ function wireCommonMocks() {
44
+ stratum.getTenant.mockImplementation(async (id) => {
45
+ const row = TENANTS[id];
46
+ if (!row)
47
+ throw new Error(`Tenant ${id} not found`);
48
+ return row;
49
+ });
50
+ stratum.diffConfig.mockResolvedValue({ added: {}, removed: {}, changed: {} });
51
+ stratum.getRole.mockImplementation(async (id) => ROLES[id] ?? null);
52
+ // Without a tenant filter, listing returns every tenant's roles: this is the leak.
53
+ stratum.listRoles.mockImplementation(async (tenantId) => {
54
+ const all = Object.values(ROLES);
55
+ if (!tenantId)
56
+ return all;
57
+ return all.filter((r) => r.tenant_id === tenantId || r.tenant_id === null);
58
+ });
59
+ stratum.createRole.mockImplementation(async (input) => ({
60
+ id: "new-role",
61
+ description: null,
62
+ ...input,
63
+ }));
64
+ stratum.updateRole.mockImplementation(async (id) => ROLES[id] ?? null);
65
+ stratum.deleteRole.mockResolvedValue(true);
66
+ stratum.assignRoleToKey.mockResolvedValue(true);
67
+ stratum.removeRoleFromKey.mockResolvedValue(true);
68
+ stratum.getApiKey.mockImplementation(async (id) => KEYS[id] ?? null);
69
+ }
70
+ async function diff(tenantA, tenantB) {
71
+ return app.inject({
72
+ method: "GET",
73
+ url: `/api/v1/config/diff?tenant_a=${tenantA}&tenant_b=${tenantB}`,
74
+ headers: (0, test_helpers_js_1.authHeaders)(),
75
+ });
76
+ }
77
+ (0, vitest_1.describe)("config diff scopes both compared tenants to the caller's subtree", () => {
78
+ (0, vitest_1.beforeEach)(async () => {
79
+ stratum = (0, test_helpers_js_1.createMockStratum)();
80
+ stratum.validateApiKey.mockResolvedValue({
81
+ key_id: "attacker-key",
82
+ tenant_id: ATTACKER,
83
+ scopes: ["read", "write", "admin"],
84
+ rate_limit_max: null,
85
+ rate_limit_window: null,
86
+ });
87
+ wireCommonMocks();
88
+ app = await (0, test_helpers_js_1.buildTestApp)(stratum);
89
+ });
90
+ (0, vitest_1.it)("refuses diffing against an unrelated tenant", async () => {
91
+ const res = await diff(ATTACKER, VICTIM);
92
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
93
+ (0, vitest_1.expect)(stratum.diffConfig).not.toHaveBeenCalled();
94
+ });
95
+ (0, vitest_1.it)("refuses when the first tenant is unrelated", async () => {
96
+ const res = await diff(VICTIM, ATTACKER);
97
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
98
+ (0, vitest_1.expect)(stratum.diffConfig).not.toHaveBeenCalled();
99
+ });
100
+ (0, vitest_1.it)("allows diffing entirely within the caller's subtree", async () => {
101
+ const res = await diff(ATTACKER, ATTACKER_CHILD);
102
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
103
+ (0, vitest_1.expect)(stratum.diffConfig).toHaveBeenCalledWith(ATTACKER, ATTACKER_CHILD);
104
+ });
105
+ });
106
+ (0, vitest_1.describe)("role routes scope to the caller's subtree", () => {
107
+ (0, vitest_1.beforeEach)(async () => {
108
+ stratum = (0, test_helpers_js_1.createMockStratum)();
109
+ stratum.validateApiKey.mockResolvedValue({
110
+ key_id: "attacker-key",
111
+ tenant_id: ATTACKER,
112
+ scopes: ["read", "write", "admin"],
113
+ rate_limit_max: null,
114
+ rate_limit_window: null,
115
+ });
116
+ wireCommonMocks();
117
+ app = await (0, test_helpers_js_1.buildTestApp)(stratum);
118
+ });
119
+ (0, vitest_1.it)("does not enumerate other tenants' roles when listing without a filter", async () => {
120
+ const res = await app.inject({ method: "GET", url: "/api/v1/roles", headers: (0, test_helpers_js_1.authHeaders)() });
121
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
122
+ const ids = res.json().map((r) => r.id);
123
+ (0, vitest_1.expect)(ids).not.toContain(ROLE_FOREIGN);
124
+ (0, vitest_1.expect)(ids).toContain(ROLE_OWN);
125
+ });
126
+ (0, vitest_1.it)("refuses listing a foreign tenant's roles by explicit filter", async () => {
127
+ const res = await app.inject({
128
+ method: "GET",
129
+ url: `/api/v1/roles?tenant_id=${VICTIM}`,
130
+ headers: (0, test_helpers_js_1.authHeaders)(),
131
+ });
132
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
133
+ (0, vitest_1.expect)(stratum.listRoles).not.toHaveBeenCalled();
134
+ });
135
+ (0, vitest_1.it)("refuses reading a foreign tenant's role", async () => {
136
+ const res = await app.inject({ method: "GET", url: `/api/v1/roles/${ROLE_FOREIGN}`, headers: (0, test_helpers_js_1.authHeaders)() });
137
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
138
+ });
139
+ (0, vitest_1.it)("allows reading a role in the caller's own tenant", async () => {
140
+ const res = await app.inject({ method: "GET", url: `/api/v1/roles/${ROLE_OWN}`, headers: (0, test_helpers_js_1.authHeaders)() });
141
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
142
+ });
143
+ (0, vitest_1.it)("refuses updating a foreign tenant's role", async () => {
144
+ const res = await app.inject({
145
+ method: "PATCH",
146
+ url: `/api/v1/roles/${ROLE_FOREIGN}`,
147
+ headers: { ...(0, test_helpers_js_1.authHeaders)(), "content-type": "application/json" },
148
+ payload: { name: "hijacked" },
149
+ });
150
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
151
+ (0, vitest_1.expect)(stratum.updateRole).not.toHaveBeenCalled();
152
+ });
153
+ (0, vitest_1.it)("refuses deleting a foreign tenant's role", async () => {
154
+ const res = await app.inject({ method: "DELETE", url: `/api/v1/roles/${ROLE_FOREIGN}`, headers: (0, test_helpers_js_1.authHeaders)() });
155
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
156
+ (0, vitest_1.expect)(stratum.deleteRole).not.toHaveBeenCalled();
157
+ });
158
+ (0, vitest_1.it)("refuses creating a role in a foreign tenant", async () => {
159
+ const res = await app.inject({
160
+ method: "POST",
161
+ url: "/api/v1/roles",
162
+ headers: { ...(0, test_helpers_js_1.authHeaders)(), "content-type": "application/json" },
163
+ payload: { name: "planted", scopes: ["read"], tenant_id: VICTIM },
164
+ });
165
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
166
+ (0, vitest_1.expect)(stratum.createRole).not.toHaveBeenCalled();
167
+ });
168
+ (0, vitest_1.it)("refuses assigning a foreign tenant's role to an own key", async () => {
169
+ const res = await app.inject({
170
+ method: "POST",
171
+ url: `/api/v1/roles/assign/${OWN_KEY}`,
172
+ headers: { ...(0, test_helpers_js_1.authHeaders)(), "content-type": "application/json" },
173
+ payload: { role_id: ROLE_FOREIGN },
174
+ });
175
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
176
+ (0, vitest_1.expect)(stratum.assignRoleToKey).not.toHaveBeenCalled();
177
+ });
178
+ (0, vitest_1.it)("refuses assigning a role to a key in another tenant", async () => {
179
+ const res = await app.inject({
180
+ method: "POST",
181
+ url: `/api/v1/roles/assign/${FOREIGN_KEY}`,
182
+ headers: { ...(0, test_helpers_js_1.authHeaders)(), "content-type": "application/json" },
183
+ payload: { role_id: ROLE_OWN },
184
+ });
185
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
186
+ (0, vitest_1.expect)(stratum.assignRoleToKey).not.toHaveBeenCalled();
187
+ });
188
+ (0, vitest_1.it)("refuses removing a role from a key in another tenant", async () => {
189
+ const res = await app.inject({
190
+ method: "DELETE",
191
+ url: `/api/v1/roles/assign/${FOREIGN_KEY}`,
192
+ headers: (0, test_helpers_js_1.authHeaders)(),
193
+ });
194
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
195
+ (0, vitest_1.expect)(stratum.removeRoleFromKey).not.toHaveBeenCalled();
196
+ });
197
+ (0, vitest_1.it)("allows assigning an own role to an own key", async () => {
198
+ const res = await app.inject({
199
+ method: "POST",
200
+ url: `/api/v1/roles/assign/${OWN_KEY}`,
201
+ headers: { ...(0, test_helpers_js_1.authHeaders)(), "content-type": "application/json" },
202
+ payload: { role_id: ROLE_OWN },
203
+ });
204
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
205
+ (0, vitest_1.expect)(stratum.assignRoleToKey).toHaveBeenCalledWith(OWN_KEY, ROLE_OWN);
206
+ });
207
+ });
208
+ (0, vitest_1.describe)("global operator keys keep full access", () => {
209
+ (0, vitest_1.beforeEach)(async () => {
210
+ stratum = (0, test_helpers_js_1.createMockStratum)();
211
+ stratum.validateApiKey.mockResolvedValue({
212
+ key_id: "global-key",
213
+ tenant_id: null,
214
+ scopes: ["read", "write", "admin"],
215
+ rate_limit_max: null,
216
+ rate_limit_window: null,
217
+ });
218
+ wireCommonMocks();
219
+ app = await (0, test_helpers_js_1.buildTestApp)(stratum);
220
+ });
221
+ (0, vitest_1.it)("still diffs any two tenants", async () => {
222
+ const res = await diff(ATTACKER, VICTIM);
223
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
224
+ (0, vitest_1.expect)(stratum.diffConfig).toHaveBeenCalledWith(ATTACKER, VICTIM);
225
+ });
226
+ (0, vitest_1.it)("still enumerates every tenant's roles", async () => {
227
+ const res = await app.inject({ method: "GET", url: "/api/v1/roles", headers: (0, test_helpers_js_1.authHeaders)() });
228
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
229
+ (0, vitest_1.expect)(res.json().map((r) => r.id)).toContain(ROLE_FOREIGN);
230
+ });
231
+ (0, vitest_1.it)("still reads any tenant's role", async () => {
232
+ const res = await app.inject({ method: "GET", url: `/api/v1/roles/${ROLE_FOREIGN}`, headers: (0, test_helpers_js_1.authHeaders)() });
233
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
234
+ });
235
+ (0, vitest_1.it)("still assigns any role to any key", async () => {
236
+ const res = await app.inject({
237
+ method: "POST",
238
+ url: `/api/v1/roles/assign/${FOREIGN_KEY}`,
239
+ headers: { ...(0, test_helpers_js_1.authHeaders)(), "content-type": "application/json" },
240
+ payload: { role_id: ROLE_FOREIGN },
241
+ });
242
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
243
+ (0, vitest_1.expect)(stratum.assignRoleToKey).toHaveBeenCalledWith(FOREIGN_KEY, ROLE_FOREIGN);
244
+ });
245
+ });
246
+ //# sourceMappingURL=configdiff-roles-scope.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"configdiff-roles-scope.test.js","sourceRoot":"","sources":["../../src/__tests__/configdiff-roles-scope.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=default-deny.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"default-deny.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/default-deny.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const vitest_1 = require("vitest");
7
+ const fastify_1 = __importDefault(require("fastify"));
8
+ const auth_js_1 = require("../middleware/auth.js");
9
+ const authorize_js_1 = require("../middleware/authorize.js");
10
+ const tenant_scope_js_1 = require("../middleware/tenant-scope.js");
11
+ const error_handler_js_1 = require("../middleware/error-handler.js");
12
+ const test_helpers_js_1 = require("./test-helpers.js");
13
+ /**
14
+ * Control-plane authorization must be default-deny: a route that does not
15
+ * declare a tenant scope is refused, so a route added without a guard fails
16
+ * closed instead of silently serving data.
17
+ */
18
+ const SCOPED_TENANT = "11111111-1111-4111-8111-111111111111";
19
+ const OTHER_TENANT = "99999999-9999-4999-8999-999999999999";
20
+ let stratum;
21
+ (0, vitest_1.beforeEach)(() => {
22
+ stratum = (0, test_helpers_js_1.createMockStratum)();
23
+ stratum.validateApiKey.mockResolvedValue({
24
+ key_id: "scoped-key",
25
+ tenant_id: SCOPED_TENANT,
26
+ scopes: ["read", "write", "admin"],
27
+ rate_limit_max: null,
28
+ rate_limit_window: null,
29
+ });
30
+ // Both tenants are roots (empty ancestry), so neither is a descendant of the
31
+ // other: a scoped key may reach only its own.
32
+ stratum.getTenant.mockImplementation(async (id) => ({ id, ancestry_path: "", status: "active" }));
33
+ });
34
+ /**
35
+ * Build an app wired exactly like the production control plane: authenticate,
36
+ * authorize by scope, then enforce tenant scope. The caller registers routes.
37
+ */
38
+ async function buildApp(register) {
39
+ const app = (0, fastify_1.default)({ logger: false });
40
+ app.addHook("preHandler", (0, auth_js_1.createAuthMiddleware)(stratum));
41
+ app.addHook("preHandler", (0, authorize_js_1.createAuthorizeMiddleware)());
42
+ app.addHook("preHandler", (0, tenant_scope_js_1.createTenantScopeEnforcer)(stratum));
43
+ app.setErrorHandler(error_handler_js_1.errorHandler);
44
+ await register(app);
45
+ await app.ready();
46
+ return app;
47
+ }
48
+ (0, vitest_1.describe)("default-deny authorization", () => {
49
+ (0, vitest_1.it)("denies a route that declares no tenant scope", async () => {
50
+ const app = await buildApp((app) => {
51
+ // A control-plane route that ships without any tenant-scope declaration.
52
+ app.get("/api/v1/unguarded/:id", async (_req, reply) => reply.status(200).send({ reached: true }));
53
+ });
54
+ const res = await app.inject({
55
+ method: "GET",
56
+ url: `/api/v1/unguarded/${OTHER_TENANT}`,
57
+ headers: (0, test_helpers_js_1.authHeaders)(),
58
+ });
59
+ (0, vitest_1.expect)(res.statusCode).toBe(403);
60
+ });
61
+ (0, vitest_1.it)("admits a route explicitly declared as global", async () => {
62
+ const app = await buildApp((app) => {
63
+ (0, tenant_scope_js_1.declareTenantScope)(app, "global");
64
+ app.get("/api/v1/ops", async (_req, reply) => reply.status(200).send({ ok: true }));
65
+ });
66
+ const res = await app.inject({
67
+ method: "GET",
68
+ url: "/api/v1/ops",
69
+ headers: (0, test_helpers_js_1.authHeaders)(),
70
+ });
71
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
72
+ });
73
+ (0, vitest_1.it)("enforces a declared extractor: own tenant allowed, others denied", async () => {
74
+ const app = await buildApp((app) => {
75
+ (0, tenant_scope_js_1.declareTenantScope)(app, tenant_scope_js_1.fromParamId);
76
+ app.get("/api/v1/tenants/:id/thing", async (_req, reply) => reply.status(200).send({ ok: true }));
77
+ });
78
+ const own = await app.inject({
79
+ method: "GET",
80
+ url: `/api/v1/tenants/${SCOPED_TENANT}/thing`,
81
+ headers: (0, test_helpers_js_1.authHeaders)(),
82
+ });
83
+ const other = await app.inject({
84
+ method: "GET",
85
+ url: `/api/v1/tenants/${OTHER_TENANT}/thing`,
86
+ headers: (0, test_helpers_js_1.authHeaders)(),
87
+ });
88
+ (0, vitest_1.expect)(own.statusCode).toBe(200);
89
+ (0, vitest_1.expect)(other.statusCode).toBe(403);
90
+ });
91
+ });
92
+ //# sourceMappingURL=default-deny.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"default-deny.test.js","sourceRoot":"","sources":["../../src/__tests__/default-deny.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=tenant-create-scope.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tenant-create-scope.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/tenant-create-scope.test.ts"],"names":[],"mappings":""}