@openparachute/hub 0.6.3 → 0.6.4-rc.2
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 +1 -1
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +125 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +187 -1
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +27 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +54 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from "../api-account.ts";
|
|
31
31
|
import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
|
|
32
32
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
33
|
+
import { recordTokenMint } from "../jwt-sign.ts";
|
|
33
34
|
import {
|
|
34
35
|
CHANGE_PASSWORD_MAX_ATTEMPTS,
|
|
35
36
|
CHANGE_PASSWORD_WINDOW_MS,
|
|
@@ -257,6 +258,63 @@ describe("POST /account/change-password", () => {
|
|
|
257
258
|
}
|
|
258
259
|
});
|
|
259
260
|
|
|
261
|
+
// Item F / hub#469 — a successful self-service password change revokes the
|
|
262
|
+
// user's still-active tokens (so a token minted under the admin's temp
|
|
263
|
+
// password dies with the rotation). Mirrors `resetUserPassword`'s admin-reset
|
|
264
|
+
// revoke. Tokens belonging to OTHER users are untouched.
|
|
265
|
+
test("self-change revokes the user's active tokens, leaves other users' tokens (item F)", async () => {
|
|
266
|
+
const { userId, cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw", {
|
|
267
|
+
passwordChanged: false,
|
|
268
|
+
});
|
|
269
|
+
const other = await createUser(harness.db, "other", "other-strong-passphrase", {
|
|
270
|
+
passwordChanged: true,
|
|
271
|
+
allowMulti: true,
|
|
272
|
+
});
|
|
273
|
+
// Seed one active token for the changing user + one for another user.
|
|
274
|
+
recordTokenMint(harness.db, {
|
|
275
|
+
jti: "tok-self-1",
|
|
276
|
+
createdVia: "cli_mint",
|
|
277
|
+
subject: userId,
|
|
278
|
+
userId,
|
|
279
|
+
clientId: "parachute-account",
|
|
280
|
+
scopes: ["vault:work:read"],
|
|
281
|
+
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
|
282
|
+
});
|
|
283
|
+
recordTokenMint(harness.db, {
|
|
284
|
+
jti: "tok-other-1",
|
|
285
|
+
createdVia: "cli_mint",
|
|
286
|
+
subject: other.id,
|
|
287
|
+
userId: other.id,
|
|
288
|
+
clientId: "parachute-account",
|
|
289
|
+
scopes: ["vault:work:read"],
|
|
290
|
+
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const { body, headers } = formBody({
|
|
294
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
295
|
+
current_password: "old-default-pw",
|
|
296
|
+
new_password: "user-chosen-strong-passphrase",
|
|
297
|
+
new_password_confirm: "user-chosen-strong-passphrase",
|
|
298
|
+
next: "/account/",
|
|
299
|
+
});
|
|
300
|
+
const req = new Request("http://hub.test/account/change-password", {
|
|
301
|
+
method: "POST",
|
|
302
|
+
headers: { ...headers, cookie },
|
|
303
|
+
body,
|
|
304
|
+
});
|
|
305
|
+
const res = await handleAccountChangePasswordPost(req, { db: harness.db });
|
|
306
|
+
expect(res.status).toBe(302);
|
|
307
|
+
|
|
308
|
+
const selfTok = harness.db
|
|
309
|
+
.query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM tokens WHERE jti = ?")
|
|
310
|
+
.get("tok-self-1");
|
|
311
|
+
const otherTok = harness.db
|
|
312
|
+
.query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM tokens WHERE jti = ?")
|
|
313
|
+
.get("tok-other-1");
|
|
314
|
+
expect(selfTok?.revoked_at).not.toBeNull(); // changing user's token revoked
|
|
315
|
+
expect(otherTok?.revoked_at).toBeNull(); // other user's token untouched
|
|
316
|
+
});
|
|
317
|
+
|
|
260
318
|
test("non-admin user with no next defaults to /account/ (no admin-shell flash)", async () => {
|
|
261
319
|
// Without this rewrite, a friend's change-password POST would 302 to
|
|
262
320
|
// /admin/vaults, the SPA would load, the 403 from
|
|
@@ -743,7 +801,7 @@ describe("handleAccountHomeGet", () => {
|
|
|
743
801
|
|
|
744
802
|
test("302 → /login when no session cookie is present", async () => {
|
|
745
803
|
const req = new Request(`${HUB_ORIGIN}/account/`);
|
|
746
|
-
const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
804
|
+
const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
747
805
|
expect(res.status).toBe(302);
|
|
748
806
|
const location = res.headers.get("location") ?? "";
|
|
749
807
|
// Round-trip /account/ as the `next` param so post-login lands back.
|
|
@@ -762,7 +820,7 @@ describe("handleAccountHomeGet", () => {
|
|
|
762
820
|
const session = createSession(harness.db, { userId: friend.id });
|
|
763
821
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
764
822
|
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
765
|
-
const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
823
|
+
const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
766
824
|
expect(res.status).toBe(200);
|
|
767
825
|
expect(res.headers.get("content-type")).toContain("text/html");
|
|
768
826
|
const html = await res.text();
|
|
@@ -782,7 +840,7 @@ describe("handleAccountHomeGet", () => {
|
|
|
782
840
|
const session = createSession(harness.db, { userId: admin.id });
|
|
783
841
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
784
842
|
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
785
|
-
const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
843
|
+
const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
786
844
|
expect(res.status).toBe(200);
|
|
787
845
|
const html = await res.text();
|
|
788
846
|
expect(html).toContain("Welcome, admin");
|
|
@@ -813,8 +871,71 @@ describe("handleAccountHomeGet", () => {
|
|
|
813
871
|
harness.db.exec("PRAGMA foreign_keys = ON");
|
|
814
872
|
}
|
|
815
873
|
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
816
|
-
const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
874
|
+
const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
817
875
|
expect(res.status).toBe(302);
|
|
818
876
|
expect(res.headers.get("location")).toBe("/login");
|
|
819
877
|
});
|
|
878
|
+
|
|
879
|
+
test("renders the per-vault usage stat when usage resolves", async () => {
|
|
880
|
+
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
881
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
882
|
+
allowMulti: true,
|
|
883
|
+
passwordChanged: true,
|
|
884
|
+
assignedVaults: ["alice"],
|
|
885
|
+
});
|
|
886
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
887
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
888
|
+
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
889
|
+
const res = await handleAccountHomeGet(req, {
|
|
890
|
+
db: harness.db,
|
|
891
|
+
hubOrigin: HUB_ORIGIN,
|
|
892
|
+
resolveVaultPort: () => 1940,
|
|
893
|
+
// Stub the fetch: resolves to a known stat.
|
|
894
|
+
fetchUsage: async () => ({ notes: 7, totalBytes: 2 * 1024 * 1024 }),
|
|
895
|
+
});
|
|
896
|
+
expect(res.status).toBe(200);
|
|
897
|
+
const html = await res.text();
|
|
898
|
+
expect(html).toContain('data-testid="vault-usage"');
|
|
899
|
+
expect(html).toContain("7 notes · 2.0 MB");
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test("omits the usage stat gracefully when the fetch fails (null)", async () => {
|
|
903
|
+
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
904
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
905
|
+
allowMulti: true,
|
|
906
|
+
passwordChanged: true,
|
|
907
|
+
assignedVaults: ["alice"],
|
|
908
|
+
});
|
|
909
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
910
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
911
|
+
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
912
|
+
const res = await handleAccountHomeGet(req, {
|
|
913
|
+
db: harness.db,
|
|
914
|
+
hubOrigin: HUB_ORIGIN,
|
|
915
|
+
resolveVaultPort: () => 1940,
|
|
916
|
+
fetchUsage: async () => null,
|
|
917
|
+
});
|
|
918
|
+
expect(res.status).toBe(200);
|
|
919
|
+
const html = await res.text();
|
|
920
|
+
// Tile still renders; just no usage stat.
|
|
921
|
+
expect(html).toContain("<strong>alice</strong>");
|
|
922
|
+
expect(html).not.toContain('data-testid="vault-usage"');
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test("renders the 'Configure / back up this vault ↗' deep-link button for an assigned vault", async () => {
|
|
926
|
+
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
927
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
928
|
+
allowMulti: true,
|
|
929
|
+
passwordChanged: true,
|
|
930
|
+
assignedVaults: ["alice"],
|
|
931
|
+
});
|
|
932
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
933
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
934
|
+
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
935
|
+
const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
936
|
+
expect(res.status).toBe(200);
|
|
937
|
+
const html = await res.text();
|
|
938
|
+
expect(html).toContain('data-testid="vault-admin-button"');
|
|
939
|
+
expect(html).toContain('action="/account/vault-admin-token/alice"');
|
|
940
|
+
});
|
|
820
941
|
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin API tests for `/api/invites*` (`api-invites.ts`).
|
|
3
|
+
*
|
|
4
|
+
* - host:admin gate (403 without the scope)
|
|
5
|
+
* - POST create → 201 with single-emit token + URL; defaults applied
|
|
6
|
+
* - GET list → status-annotated, raw token NEVER present
|
|
7
|
+
* - DELETE /:id → revoke; 409 when already terminal; 404 unknown
|
|
8
|
+
*/
|
|
9
|
+
import type { Database } from "bun:sqlite";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { handleCreateInvite, handleListInvites, handleRevokeInvite } from "../api-invites.ts";
|
|
15
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
16
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
17
|
+
import { createUser } from "../users.ts";
|
|
18
|
+
|
|
19
|
+
const ISSUER = "https://hub.test";
|
|
20
|
+
const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
21
|
+
|
|
22
|
+
interface Harness {
|
|
23
|
+
db: Database;
|
|
24
|
+
manifestPath: string;
|
|
25
|
+
cleanup: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeHarness(): Harness {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-invites-"));
|
|
30
|
+
const db = openHubDb(hubDbPath(dir));
|
|
31
|
+
const manifestPath = join(dir, "services.json");
|
|
32
|
+
writeFileSync(manifestPath, JSON.stringify({ services: [] }));
|
|
33
|
+
return {
|
|
34
|
+
db,
|
|
35
|
+
manifestPath,
|
|
36
|
+
cleanup: () => {
|
|
37
|
+
db.close();
|
|
38
|
+
rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let harness: Harness;
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
harness = makeHarness();
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
harness.cleanup();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function makeAdminBearer(scopes = [HOST_ADMIN_SCOPE]): Promise<string> {
|
|
52
|
+
const user = await createUser(harness.db, "operator", "operator-password-1", {
|
|
53
|
+
allowMulti: true,
|
|
54
|
+
passwordChanged: true,
|
|
55
|
+
});
|
|
56
|
+
const minted = await signAccessToken(harness.db, {
|
|
57
|
+
sub: user.id,
|
|
58
|
+
scopes,
|
|
59
|
+
audience: "hub",
|
|
60
|
+
clientId: "parachute-hub-spa",
|
|
61
|
+
issuer: ISSUER,
|
|
62
|
+
ttlSeconds: 600,
|
|
63
|
+
});
|
|
64
|
+
return minted.token;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function deps() {
|
|
68
|
+
return { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function withBearer(path: string, bearer: string, init: RequestInit = {}): Request {
|
|
72
|
+
const headers = new Headers(init.headers ?? {});
|
|
73
|
+
headers.set("authorization", `Bearer ${bearer}`);
|
|
74
|
+
return new Request(`${ISSUER}${path}`, { ...init, headers });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("/api/invites auth", () => {
|
|
78
|
+
test("403 without host:admin scope", async () => {
|
|
79
|
+
const bearer = await makeAdminBearer(["other:scope"]);
|
|
80
|
+
const res = await handleListInvites(withBearer("/api/invites", bearer), deps());
|
|
81
|
+
expect(res.status).toBe(403);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("POST /api/invites", () => {
|
|
86
|
+
test("201 with single-emit token + URL; defaults (write/provision/7d)", async () => {
|
|
87
|
+
const bearer = await makeAdminBearer();
|
|
88
|
+
const res = await handleCreateInvite(
|
|
89
|
+
withBearer("/api/invites", bearer, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "content-type": "application/json" },
|
|
92
|
+
body: JSON.stringify({}),
|
|
93
|
+
}),
|
|
94
|
+
deps(),
|
|
95
|
+
);
|
|
96
|
+
expect(res.status).toBe(201);
|
|
97
|
+
const body = (await res.json()) as {
|
|
98
|
+
invite: { id: string; status: string; role: string; provision_vault: boolean };
|
|
99
|
+
token: string;
|
|
100
|
+
url: string;
|
|
101
|
+
};
|
|
102
|
+
expect(body.token.length).toBeGreaterThan(40);
|
|
103
|
+
expect(body.url).toBe(`${ISSUER}/account/setup/${body.token}`);
|
|
104
|
+
expect(body.invite.role).toBe("write");
|
|
105
|
+
expect(body.invite.provision_vault).toBe(true);
|
|
106
|
+
expect(body.invite.status).toBe("pending");
|
|
107
|
+
// The id is the sha256 hash — never the raw token.
|
|
108
|
+
expect(body.invite.id).not.toBe(body.token);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("400 on a bad role", async () => {
|
|
112
|
+
const bearer = await makeAdminBearer();
|
|
113
|
+
const res = await handleCreateInvite(
|
|
114
|
+
withBearer("/api/invites", bearer, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "content-type": "application/json" },
|
|
117
|
+
body: JSON.stringify({ role: "admin" }),
|
|
118
|
+
}),
|
|
119
|
+
deps(),
|
|
120
|
+
);
|
|
121
|
+
expect(res.status).toBe(400);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("400 rejects a shared-vault invite (provision_vault=false + vault_name)", async () => {
|
|
125
|
+
// Defense in depth (FIX-1): assigning a redeemer to a PRE-EXISTING vault
|
|
126
|
+
// as owner-admin is a cross-tenant breach; shared-vault invites aren't
|
|
127
|
+
// supported yet, so the create handler refuses this combination outright.
|
|
128
|
+
const bearer = await makeAdminBearer();
|
|
129
|
+
const res = await handleCreateInvite(
|
|
130
|
+
withBearer("/api/invites", bearer, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "content-type": "application/json" },
|
|
133
|
+
body: JSON.stringify({ provision_vault: false, vault_name: "someoneelse" }),
|
|
134
|
+
}),
|
|
135
|
+
deps(),
|
|
136
|
+
);
|
|
137
|
+
expect(res.status).toBe(400);
|
|
138
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
139
|
+
expect(body.error).toBe("invalid_request");
|
|
140
|
+
expect(body.error_description).toContain("shared-vault");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("account-only invite (provision_vault=false, NO vault_name) is still allowed", async () => {
|
|
144
|
+
const bearer = await makeAdminBearer();
|
|
145
|
+
const res = await handleCreateInvite(
|
|
146
|
+
withBearer("/api/invites", bearer, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { "content-type": "application/json" },
|
|
149
|
+
body: JSON.stringify({ provision_vault: false }),
|
|
150
|
+
}),
|
|
151
|
+
deps(),
|
|
152
|
+
);
|
|
153
|
+
expect(res.status).toBe(201);
|
|
154
|
+
const body = (await res.json()) as {
|
|
155
|
+
invite: { provision_vault: boolean; vault_name: string | null };
|
|
156
|
+
};
|
|
157
|
+
expect(body.invite.provision_vault).toBe(false);
|
|
158
|
+
expect(body.invite.vault_name).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("GET /api/invites", () => {
|
|
163
|
+
test("lists invites; raw token NEVER present in the wire shape", async () => {
|
|
164
|
+
const bearer = await makeAdminBearer();
|
|
165
|
+
const created = await handleCreateInvite(
|
|
166
|
+
withBearer("/api/invites", bearer, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "content-type": "application/json" },
|
|
169
|
+
body: JSON.stringify({ vault_name: "maya" }),
|
|
170
|
+
}),
|
|
171
|
+
deps(),
|
|
172
|
+
);
|
|
173
|
+
const createdBody = (await created.json()) as { token: string };
|
|
174
|
+
const list = await handleListInvites(withBearer("/api/invites", bearer), deps());
|
|
175
|
+
const body = (await list.json()) as { invites: { id: string }[] };
|
|
176
|
+
expect(body.invites.length).toBe(1);
|
|
177
|
+
// The raw token must not be recoverable from the list.
|
|
178
|
+
const json = JSON.stringify(body);
|
|
179
|
+
expect(json).not.toContain(createdBody.token);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("DELETE /api/invites/:id", () => {
|
|
184
|
+
test("revokes a pending invite; 409 if already revoked; 404 unknown", async () => {
|
|
185
|
+
const bearer = await makeAdminBearer();
|
|
186
|
+
const created = await handleCreateInvite(
|
|
187
|
+
withBearer("/api/invites", bearer, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { "content-type": "application/json" },
|
|
190
|
+
body: JSON.stringify({}),
|
|
191
|
+
}),
|
|
192
|
+
deps(),
|
|
193
|
+
);
|
|
194
|
+
const { invite } = (await created.json()) as { invite: { id: string } };
|
|
195
|
+
|
|
196
|
+
const ok = await handleRevokeInvite(
|
|
197
|
+
withBearer(`/api/invites/${invite.id}`, bearer, { method: "DELETE" }),
|
|
198
|
+
invite.id,
|
|
199
|
+
deps(),
|
|
200
|
+
);
|
|
201
|
+
expect(ok.status).toBe(200);
|
|
202
|
+
|
|
203
|
+
const again = await handleRevokeInvite(
|
|
204
|
+
withBearer(`/api/invites/${invite.id}`, bearer, { method: "DELETE" }),
|
|
205
|
+
invite.id,
|
|
206
|
+
deps(),
|
|
207
|
+
);
|
|
208
|
+
expect(again.status).toBe(409);
|
|
209
|
+
|
|
210
|
+
const unknown = await handleRevokeInvite(
|
|
211
|
+
withBearer("/api/invites/deadbeef", bearer, { method: "DELETE" }),
|
|
212
|
+
"deadbeef",
|
|
213
|
+
deps(),
|
|
214
|
+
);
|
|
215
|
+
expect(unknown.status).toBe(404);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -317,6 +317,32 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
317
317
|
}
|
|
318
318
|
});
|
|
319
319
|
|
|
320
|
+
// Item C — case-insensitive non-requestable guard. An uppercase casing of a
|
|
321
|
+
// host-level scope must NOT bypass the non-requestable membership check (which
|
|
322
|
+
// was exact-string before). `PARACHUTE:HOST:AUTH` is now correctly rejected.
|
|
323
|
+
test("400 invalid_scope when minting an uppercase host scope (item C)", async () => {
|
|
324
|
+
const h = makeHarness();
|
|
325
|
+
try {
|
|
326
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
327
|
+
try {
|
|
328
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
329
|
+
for (const variant of ["PARACHUTE:HOST:AUTH", "Parachute:Host:Admin"]) {
|
|
330
|
+
const resp = await handleApiMintToken(
|
|
331
|
+
jsonRequest({ scope: variant }, { authorization: `Bearer ${op.token}` }),
|
|
332
|
+
{ db, issuer: ISSUER },
|
|
333
|
+
);
|
|
334
|
+
expect(resp.status).toBe(400);
|
|
335
|
+
const body = (await resp.json()) as { error: string };
|
|
336
|
+
expect(body.error).toBe("invalid_scope");
|
|
337
|
+
}
|
|
338
|
+
} finally {
|
|
339
|
+
db.close();
|
|
340
|
+
}
|
|
341
|
+
} finally {
|
|
342
|
+
h.cleanup();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
320
346
|
test("400 invalid_scope when multi-scope includes a non-requestable", async () => {
|
|
321
347
|
const h = makeHarness();
|
|
322
348
|
try {
|
|
@@ -416,13 +442,13 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
416
442
|
}
|
|
417
443
|
});
|
|
418
444
|
|
|
419
|
-
//
|
|
420
|
-
// the
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
test("bare vault:admin (no name)
|
|
445
|
+
// Item B / hub#451 — bare `vault:admin` (no vault name) is NOT mintable on
|
|
446
|
+
// the headless path. The unnamed broad-admin form has no resource pin; the
|
|
447
|
+
// mint endpoint refuses it with 400 `invalid_scope` (even for a full host:admin
|
|
448
|
+
// operator). The legitimate path for a vault admin token is a resource-narrowed
|
|
449
|
+
// `vault:<name>:admin`. The OAuth flow still accepts bare `vault:admin` and
|
|
450
|
+
// narrows it via the picker — that path is unaffected (see oauth-handlers).
|
|
451
|
+
test("bare vault:admin (no name) → 400 (non-requestable headlessly, item B / #451)", async () => {
|
|
426
452
|
const h = makeHarness();
|
|
427
453
|
try {
|
|
428
454
|
const { db, userId } = await bootstrap(h.dir);
|
|
@@ -432,9 +458,31 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
432
458
|
jsonRequest({ scope: "vault:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
433
459
|
{ db, issuer: ISSUER },
|
|
434
460
|
);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
461
|
+
expect(resp.status).toBe(400);
|
|
462
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
463
|
+
expect(body.error).toBe("invalid_scope");
|
|
464
|
+
expect(body.error_description).toContain("vault:admin");
|
|
465
|
+
} finally {
|
|
466
|
+
db.close();
|
|
467
|
+
}
|
|
468
|
+
} finally {
|
|
469
|
+
h.cleanup();
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// Item B does NOT touch unnamed vault:read / vault:write — those carry no
|
|
474
|
+
// admin authority and remain mintable (regression guard for the narrow scope
|
|
475
|
+
// of the bare-admin block).
|
|
476
|
+
test("bare vault:read still mints headlessly (item B is admin-only)", async () => {
|
|
477
|
+
const h = makeHarness();
|
|
478
|
+
try {
|
|
479
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
480
|
+
try {
|
|
481
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
482
|
+
const resp = await handleApiMintToken(
|
|
483
|
+
jsonRequest({ scope: "vault:read" }, { authorization: `Bearer ${op.token}` }),
|
|
484
|
+
{ db, issuer: ISSUER },
|
|
485
|
+
);
|
|
438
486
|
expect(resp.status).toBe(200);
|
|
439
487
|
const body = (await resp.json()) as { token: string };
|
|
440
488
|
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
@@ -745,6 +793,122 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
745
793
|
h.cleanup();
|
|
746
794
|
}
|
|
747
795
|
});
|
|
796
|
+
|
|
797
|
+
// Item A (subject-pin) — audit-attribution forgery. A non-operator
|
|
798
|
+
// (vault-admin-only) bearer may NOT override the minted token's `sub`:
|
|
799
|
+
// forging a foreign subject would mis-attribute the registry + revocation
|
|
800
|
+
// rows. It may still mint under its OWN sub (subject omitted / equal).
|
|
801
|
+
test("subject override by non-operator bearer → 403 (forgery blocked)", async () => {
|
|
802
|
+
const h = makeHarness();
|
|
803
|
+
try {
|
|
804
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
805
|
+
try {
|
|
806
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
807
|
+
const resp = await handleApiMintToken(
|
|
808
|
+
jsonRequest(
|
|
809
|
+
{ scope: "vault:work:read", subject: "someone-else" },
|
|
810
|
+
{ authorization: `Bearer ${bearer}` },
|
|
811
|
+
),
|
|
812
|
+
{ db, issuer: ISSUER },
|
|
813
|
+
);
|
|
814
|
+
expect(resp.status).toBe(403);
|
|
815
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
816
|
+
expect(body.error).toBe("insufficient_scope");
|
|
817
|
+
expect(body.error_description).toContain("non-operator");
|
|
818
|
+
} finally {
|
|
819
|
+
db.close();
|
|
820
|
+
}
|
|
821
|
+
} finally {
|
|
822
|
+
h.cleanup();
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("subject equal to own sub by non-operator bearer → 200 (no forgery)", async () => {
|
|
827
|
+
const h = makeHarness();
|
|
828
|
+
try {
|
|
829
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
830
|
+
try {
|
|
831
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
832
|
+
const resp = await handleApiMintToken(
|
|
833
|
+
jsonRequest(
|
|
834
|
+
{ scope: "vault:work:read", subject: userId },
|
|
835
|
+
{ authorization: `Bearer ${bearer}` },
|
|
836
|
+
),
|
|
837
|
+
{ db, issuer: ISSUER },
|
|
838
|
+
);
|
|
839
|
+
expect(resp.status).toBe(200);
|
|
840
|
+
const body = (await resp.json()) as { jti: string };
|
|
841
|
+
const row = db
|
|
842
|
+
.query<{ subject: string }, [string]>("SELECT subject FROM tokens WHERE jti = ?")
|
|
843
|
+
.get(body.jti);
|
|
844
|
+
expect(row?.subject).toBe(userId);
|
|
845
|
+
} finally {
|
|
846
|
+
db.close();
|
|
847
|
+
}
|
|
848
|
+
} finally {
|
|
849
|
+
h.cleanup();
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Item A (subject-pin) — the operator carve-out: a host operator
|
|
855
|
+
// (parachute:host:auth / parachute:host:admin) MAY override `sub` to stamp a
|
|
856
|
+
// service-account subject. This is the documented service-account override
|
|
857
|
+
// that the non-operator pin above must NOT break.
|
|
858
|
+
describe("subject override — operator carve-out (item A)", () => {
|
|
859
|
+
test("host:auth operator overrides subject → 200, registry row carries override", async () => {
|
|
860
|
+
const h = makeHarness();
|
|
861
|
+
try {
|
|
862
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
863
|
+
try {
|
|
864
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
865
|
+
const resp = await handleApiMintToken(
|
|
866
|
+
jsonRequest(
|
|
867
|
+
{ scope: "vault:work:read", subject: "svc-account" },
|
|
868
|
+
{ authorization: `Bearer ${op.token}` },
|
|
869
|
+
),
|
|
870
|
+
{ db, issuer: ISSUER },
|
|
871
|
+
);
|
|
872
|
+
expect(resp.status).toBe(200);
|
|
873
|
+
const body = (await resp.json()) as { jti: string; token: string };
|
|
874
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
875
|
+
expect(validated.payload.sub).toBe("svc-account");
|
|
876
|
+
const row = db
|
|
877
|
+
.query<{ subject: string }, [string]>("SELECT subject FROM tokens WHERE jti = ?")
|
|
878
|
+
.get(body.jti);
|
|
879
|
+
expect(row?.subject).toBe("svc-account");
|
|
880
|
+
} finally {
|
|
881
|
+
db.close();
|
|
882
|
+
}
|
|
883
|
+
} finally {
|
|
884
|
+
h.cleanup();
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("host:admin operator overrides subject → 200", async () => {
|
|
889
|
+
const h = makeHarness();
|
|
890
|
+
try {
|
|
891
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
892
|
+
try {
|
|
893
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
894
|
+
const resp = await handleApiMintToken(
|
|
895
|
+
jsonRequest(
|
|
896
|
+
{ scope: "vault:work:admin", subject: "svc-account" },
|
|
897
|
+
{ authorization: `Bearer ${op.token}` },
|
|
898
|
+
),
|
|
899
|
+
{ db, issuer: ISSUER },
|
|
900
|
+
);
|
|
901
|
+
expect(resp.status).toBe(200);
|
|
902
|
+
const body = (await resp.json()) as { token: string };
|
|
903
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
904
|
+
expect(validated.payload.sub).toBe("svc-account");
|
|
905
|
+
} finally {
|
|
906
|
+
db.close();
|
|
907
|
+
}
|
|
908
|
+
} finally {
|
|
909
|
+
h.cleanup();
|
|
910
|
+
}
|
|
911
|
+
});
|
|
748
912
|
});
|
|
749
913
|
|
|
750
914
|
describe("capability attenuation — entry gate + regression", () => {
|
|
@@ -1051,6 +1215,91 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
1051
1215
|
});
|
|
1052
1216
|
});
|
|
1053
1217
|
|
|
1218
|
+
// Item D / hub#450 — vault-existence check on vault:<name>:admin mints.
|
|
1219
|
+
describe("vault-existence check on vault:<name>:admin (item D / #450)", () => {
|
|
1220
|
+
test("vault:typo:admin for an unknown vault → 400 when knownVaultNames is wired", async () => {
|
|
1221
|
+
const h = makeHarness();
|
|
1222
|
+
try {
|
|
1223
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
1224
|
+
try {
|
|
1225
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
1226
|
+
const resp = await handleApiMintToken(
|
|
1227
|
+
jsonRequest({ scope: "vault:typo:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
1228
|
+
{ db, issuer: ISSUER, knownVaultNames: new Set(["work", "default"]) },
|
|
1229
|
+
);
|
|
1230
|
+
expect(resp.status).toBe(400);
|
|
1231
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
1232
|
+
expect(body.error).toBe("invalid_scope");
|
|
1233
|
+
expect(body.error_description).toContain("typo");
|
|
1234
|
+
} finally {
|
|
1235
|
+
db.close();
|
|
1236
|
+
}
|
|
1237
|
+
} finally {
|
|
1238
|
+
h.cleanup();
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
test("vault:work:admin for a KNOWN vault → 200", async () => {
|
|
1243
|
+
const h = makeHarness();
|
|
1244
|
+
try {
|
|
1245
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
1246
|
+
try {
|
|
1247
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
1248
|
+
const resp = await handleApiMintToken(
|
|
1249
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
1250
|
+
{ db, issuer: ISSUER, knownVaultNames: new Set(["work", "default"]) },
|
|
1251
|
+
);
|
|
1252
|
+
expect(resp.status).toBe(200);
|
|
1253
|
+
const body = (await resp.json()) as { scope: string };
|
|
1254
|
+
expect(body.scope).toBe("vault:work:admin");
|
|
1255
|
+
} finally {
|
|
1256
|
+
db.close();
|
|
1257
|
+
}
|
|
1258
|
+
} finally {
|
|
1259
|
+
h.cleanup();
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
test("read/write for an unknown vault are NOT existence-checked (admin-only gate)", async () => {
|
|
1264
|
+
const h = makeHarness();
|
|
1265
|
+
try {
|
|
1266
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
1267
|
+
try {
|
|
1268
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
1269
|
+
const resp = await handleApiMintToken(
|
|
1270
|
+
jsonRequest({ scope: "vault:typo:read" }, { authorization: `Bearer ${op.token}` }),
|
|
1271
|
+
{ db, issuer: ISSUER, knownVaultNames: new Set(["work"]) },
|
|
1272
|
+
);
|
|
1273
|
+
expect(resp.status).toBe(200);
|
|
1274
|
+
} finally {
|
|
1275
|
+
db.close();
|
|
1276
|
+
}
|
|
1277
|
+
} finally {
|
|
1278
|
+
h.cleanup();
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
test("knownVaultNames omitted → existence check skipped (back-compat)", async () => {
|
|
1283
|
+
const h = makeHarness();
|
|
1284
|
+
try {
|
|
1285
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
1286
|
+
try {
|
|
1287
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
1288
|
+
const resp = await handleApiMintToken(
|
|
1289
|
+
jsonRequest({ scope: "vault:typo:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
1290
|
+
{ db, issuer: ISSUER },
|
|
1291
|
+
);
|
|
1292
|
+
// No knownVaultNames → the documented "caller responsible" fallback.
|
|
1293
|
+
expect(resp.status).toBe(200);
|
|
1294
|
+
} finally {
|
|
1295
|
+
db.close();
|
|
1296
|
+
}
|
|
1297
|
+
} finally {
|
|
1298
|
+
h.cleanup();
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1054
1303
|
test("405 on non-POST", async () => {
|
|
1055
1304
|
const h = makeHarness();
|
|
1056
1305
|
try {
|