@axiom-lattice/pg-stores 5.0.3 → 5.1.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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +95 -0
- package/dist/index.d.mts +178 -2
- package/dist/index.d.ts +178 -2
- package/dist/index.js +1243 -197
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1167 -124
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/PostgreSQLInviteLinkStore.test.ts +63 -0
- package/src/__tests__/PostgreSQLOidcIdentityClaimStore.test.ts +123 -0
- package/src/__tests__/PostgreSQLOidcProviderConfigStore.test.ts +233 -0
- package/src/__tests__/PostgreSQLPlatformInvitationStore.test.ts +222 -0
- package/src/__tests__/PostgreSQLPlatformMemberStore.test.ts +146 -0
- package/src/__tests__/PostgreSQLPlatformSettingsStore.test.ts +115 -0
- package/src/__tests__/PostgreSQLUserExternalIdentityStore.test.ts +125 -0
- package/src/__tests__/PostgreSQLUserStore.test.ts +75 -0
- package/src/createPgStoreConfig.ts +34 -0
- package/src/index.ts +16 -0
- package/src/migrations/invite_link_migration.ts +35 -0
- package/src/migrations/oidc_identity_claim_invite_migration.ts +29 -0
- package/src/migrations/oidc_identity_claim_migration.ts +39 -0
- package/src/migrations/oidc_provider_allowed_hosts_migration.ts +19 -0
- package/src/migrations/oidc_provider_config_migration.ts +27 -0
- package/src/migrations/platform_invitation_migration.ts +22 -0
- package/src/migrations/platform_member_migration.ts +21 -0
- package/src/migrations/platform_settings_migration.ts +21 -0
- package/src/migrations/user_email_lower_unique_migration.ts +22 -0
- package/src/migrations/user_external_identity_migration.ts +27 -0
- package/src/stores/PostgreSQLInviteLinkStore.ts +134 -0
- package/src/stores/PostgreSQLOidcIdentityClaimStore.ts +251 -0
- package/src/stores/PostgreSQLOidcProviderConfigStore.ts +222 -0
- package/src/stores/PostgreSQLPlatformInvitationStore.ts +133 -0
- package/src/stores/PostgreSQLPlatformMemberStore.ts +105 -0
- package/src/stores/PostgreSQLPlatformSettingsStore.ts +98 -0
- package/src/stores/PostgreSQLUserExternalIdentityStore.ts +134 -0
- package/src/stores/PostgreSQLUserStore.ts +3 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import type { PlatformMember } from "@axiom-lattice/protocols";
|
|
4
|
+
import { createPlatformMembersTable } from "../migrations/platform_member_migration";
|
|
5
|
+
import { PostgreSQLPlatformMemberStore } from "../stores/PostgreSQLPlatformMemberStore";
|
|
6
|
+
|
|
7
|
+
const databaseUrl = process.env.TEST_DATABASE_URL;
|
|
8
|
+
const describeIfDatabase = databaseUrl ? describe : describe.skip;
|
|
9
|
+
|
|
10
|
+
function member(
|
|
11
|
+
userId: string,
|
|
12
|
+
overrides: Partial<PlatformMember> = {},
|
|
13
|
+
): PlatformMember {
|
|
14
|
+
return {
|
|
15
|
+
userId,
|
|
16
|
+
role: "admin",
|
|
17
|
+
grantedBy: "owner-1",
|
|
18
|
+
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
19
|
+
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describeIfDatabase("PostgreSQLPlatformMemberStore", () => {
|
|
25
|
+
let pool: Pool;
|
|
26
|
+
let store: PostgreSQLPlatformMemberStore;
|
|
27
|
+
const prefix = `platform-${randomUUID()}`;
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
pool = new Pool({ connectionString: databaseUrl });
|
|
31
|
+
const client = await pool.connect();
|
|
32
|
+
try {
|
|
33
|
+
await createPlatformMembersTable.up(client);
|
|
34
|
+
} finally {
|
|
35
|
+
client.release();
|
|
36
|
+
}
|
|
37
|
+
store = new PostgreSQLPlatformMemberStore({ pool });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
await pool.query(
|
|
42
|
+
`DELETE FROM lattice_platform_members WHERE user_id LIKE $1`,
|
|
43
|
+
[`${prefix}%`],
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(async () => {
|
|
48
|
+
await pool.query(
|
|
49
|
+
`DELETE FROM lattice_platform_members WHERE user_id LIKE $1`,
|
|
50
|
+
[`${prefix}%`],
|
|
51
|
+
);
|
|
52
|
+
await pool.end();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("upserts, reads and lists platform members by user id", async () => {
|
|
56
|
+
await store.upsert(member(`${prefix}-b`, { role: "auditor" }));
|
|
57
|
+
await store.upsert(member(`${prefix}-a`, { role: "owner" }));
|
|
58
|
+
|
|
59
|
+
await expect(store.get(`${prefix}-a`)).resolves.toMatchObject({
|
|
60
|
+
userId: `${prefix}-a`,
|
|
61
|
+
role: "owner",
|
|
62
|
+
grantedBy: "owner-1",
|
|
63
|
+
});
|
|
64
|
+
await expect(store.get("missing")).resolves.toBeNull();
|
|
65
|
+
|
|
66
|
+
const listed = (await store.list()).filter((entry) =>
|
|
67
|
+
entry.userId.startsWith(prefix),
|
|
68
|
+
);
|
|
69
|
+
expect(listed.map((entry) => entry.userId)).toEqual([
|
|
70
|
+
`${prefix}-a`,
|
|
71
|
+
`${prefix}-b`,
|
|
72
|
+
]);
|
|
73
|
+
expect(listed.map((entry) => entry.role)).toEqual(["owner", "auditor"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("persists role changes with last-write-wins semantics", async () => {
|
|
77
|
+
const userId = `${prefix}-update`;
|
|
78
|
+
await store.upsert(member(userId, { role: "admin" }));
|
|
79
|
+
|
|
80
|
+
const updated = await store.upsert(
|
|
81
|
+
member(userId, {
|
|
82
|
+
role: "owner",
|
|
83
|
+
grantedBy: "owner-2",
|
|
84
|
+
grantedAt: new Date("2026-02-01T00:00:00.000Z"),
|
|
85
|
+
updatedAt: new Date("2026-02-01T00:00:00.000Z"),
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(updated).toMatchObject({
|
|
90
|
+
userId,
|
|
91
|
+
role: "owner",
|
|
92
|
+
grantedBy: "owner-2",
|
|
93
|
+
});
|
|
94
|
+
await expect(store.get(userId)).resolves.toMatchObject({
|
|
95
|
+
role: "owner",
|
|
96
|
+
grantedBy: "owner-2",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const listed = (await store.list()).filter((entry) =>
|
|
100
|
+
entry.userId.startsWith(prefix),
|
|
101
|
+
);
|
|
102
|
+
expect(listed).toHaveLength(1);
|
|
103
|
+
|
|
104
|
+
const raw = await pool.query<{
|
|
105
|
+
role: string;
|
|
106
|
+
granted_by: string;
|
|
107
|
+
}>(
|
|
108
|
+
`SELECT role, granted_by FROM lattice_platform_members WHERE user_id = $1`,
|
|
109
|
+
[userId],
|
|
110
|
+
);
|
|
111
|
+
expect(raw.rows[0]).toEqual({ role: "owner", granted_by: "owner-2" });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("round-trips grant and update timestamps", async () => {
|
|
115
|
+
const userId = `${prefix}-timestamps`;
|
|
116
|
+
const grantedAt = new Date("2026-03-04T05:06:07.000Z");
|
|
117
|
+
const updatedAt = new Date("2026-03-05T06:07:08.000Z");
|
|
118
|
+
|
|
119
|
+
await store.upsert(member(userId, { grantedAt, updatedAt }));
|
|
120
|
+
|
|
121
|
+
const stored = await store.get(userId);
|
|
122
|
+
expect(stored?.grantedAt).toBeInstanceOf(Date);
|
|
123
|
+
expect(stored?.grantedAt.getTime()).toBe(grantedAt.getTime());
|
|
124
|
+
expect(stored?.updatedAt).toBeInstanceOf(Date);
|
|
125
|
+
expect(stored?.updatedAt.getTime()).toBe(updatedAt.getTime());
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("omits grantedBy when it is not provided", async () => {
|
|
129
|
+
const userId = `${prefix}-no-granter`;
|
|
130
|
+
const { grantedBy: _grantedBy, ...withoutGrantedBy } = member(userId);
|
|
131
|
+
|
|
132
|
+
await store.upsert(withoutGrantedBy);
|
|
133
|
+
|
|
134
|
+
const stored = await store.get(userId);
|
|
135
|
+
expect(stored?.grantedBy).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("deletes members", async () => {
|
|
139
|
+
const userId = `${prefix}-delete`;
|
|
140
|
+
await store.upsert(member(userId));
|
|
141
|
+
|
|
142
|
+
await expect(store.delete(userId)).resolves.toBe(true);
|
|
143
|
+
await expect(store.get(userId)).resolves.toBeNull();
|
|
144
|
+
await expect(store.delete(userId)).resolves.toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import { createPlatformSettingsTable } from "../migrations/platform_settings_migration";
|
|
4
|
+
import { PostgreSQLPlatformSettingsStore } from "../stores/PostgreSQLPlatformSettingsStore";
|
|
5
|
+
|
|
6
|
+
const databaseUrl = process.env.TEST_DATABASE_URL;
|
|
7
|
+
const describeIfDatabase = databaseUrl ? describe : describe.skip;
|
|
8
|
+
|
|
9
|
+
describeIfDatabase("PostgreSQLPlatformSettingsStore", () => {
|
|
10
|
+
let pool: Pool;
|
|
11
|
+
let store: PostgreSQLPlatformSettingsStore;
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
pool = new Pool({ connectionString: databaseUrl });
|
|
15
|
+
const client = await pool.connect();
|
|
16
|
+
try {
|
|
17
|
+
await createPlatformSettingsTable.up(client);
|
|
18
|
+
await client.query("DELETE FROM lattice_platform_settings");
|
|
19
|
+
} finally {
|
|
20
|
+
client.release();
|
|
21
|
+
}
|
|
22
|
+
store = new PostgreSQLPlatformSettingsStore({ pool });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterAll(async () => {
|
|
26
|
+
await pool.query("DELETE FROM lattice_platform_settings");
|
|
27
|
+
await pool.end();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns null before anything is saved", async () => {
|
|
31
|
+
await expect(store.get()).resolves.toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("creates and CAS-updates the default row", async () => {
|
|
35
|
+
const first = await store.save(
|
|
36
|
+
{ auth: { emailCompletionEnabled: true } },
|
|
37
|
+
0,
|
|
38
|
+
"admin-1",
|
|
39
|
+
);
|
|
40
|
+
expect(first.version).toBe(1);
|
|
41
|
+
expect(first.auth).toEqual({ emailCompletionEnabled: true });
|
|
42
|
+
expect(first.updatedBy).toBe("admin-1");
|
|
43
|
+
|
|
44
|
+
await expect(
|
|
45
|
+
store.save({ auth: { emailCompletionEnabled: false } }, 0, "admin-2"),
|
|
46
|
+
).rejects.toMatchObject({
|
|
47
|
+
code: "PLATFORM_SETTINGS_VERSION_CONFLICT",
|
|
48
|
+
actualVersion: 1,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const second = await store.save(
|
|
52
|
+
{ rateLimits: { maxActiveRunTasksPerTenant: null } },
|
|
53
|
+
1,
|
|
54
|
+
"admin-2",
|
|
55
|
+
);
|
|
56
|
+
expect(second.version).toBe(2);
|
|
57
|
+
expect(second.auth).toBeUndefined();
|
|
58
|
+
expect(second.rateLimits).toEqual({ maxActiveRunTasksPerTenant: null });
|
|
59
|
+
|
|
60
|
+
const reloaded = await store.get();
|
|
61
|
+
expect(reloaded).toMatchObject({
|
|
62
|
+
version: 2,
|
|
63
|
+
rateLimits: { maxActiveRunTasksPerTenant: null },
|
|
64
|
+
updatedBy: "admin-2",
|
|
65
|
+
});
|
|
66
|
+
expect(reloaded?.updatedAt).toBeInstanceOf(Date);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("is idempotent when the migration runs twice", async () => {
|
|
70
|
+
const client = await pool.connect();
|
|
71
|
+
try {
|
|
72
|
+
await expect(createPlatformSettingsTable.up(client)).resolves.toBeUndefined();
|
|
73
|
+
await expect(createPlatformSettingsTable.up(client)).resolves.toBeUndefined();
|
|
74
|
+
} finally {
|
|
75
|
+
client.release();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("allows exactly one concurrent create at version 0", async () => {
|
|
80
|
+
await pool.query("DELETE FROM lattice_platform_settings");
|
|
81
|
+
|
|
82
|
+
const first = new PostgreSQLPlatformSettingsStore({ pool });
|
|
83
|
+
const second = new PostgreSQLPlatformSettingsStore({ pool });
|
|
84
|
+
|
|
85
|
+
const results = await Promise.allSettled([
|
|
86
|
+
first.save({ auth: { emailCompletionEnabled: true } }, 0, "admin-1"),
|
|
87
|
+
second.save({ auth: { emailCompletionEnabled: false } }, 0, "admin-2"),
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
const fulfilled = results.filter((r) => r.status === "fulfilled");
|
|
91
|
+
const rejected = results.filter((r) => r.status === "rejected");
|
|
92
|
+
expect(fulfilled).toHaveLength(1);
|
|
93
|
+
expect(rejected).toHaveLength(1);
|
|
94
|
+
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({
|
|
95
|
+
code: "PLATFORM_SETTINGS_VERSION_CONFLICT",
|
|
96
|
+
actualVersion: 1,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const persisted = await first.get();
|
|
100
|
+
expect(persisted?.version).toBe(1);
|
|
101
|
+
expect(persisted?.updatedAt).toBeInstanceOf(Date);
|
|
102
|
+
expect(["admin-1", "admin-2"]).toContain(persisted?.updatedBy);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("omits empty and whitespace-only updatedBy", async () => {
|
|
106
|
+
await pool.query("DELETE FROM lattice_platform_settings");
|
|
107
|
+
|
|
108
|
+
const first = await store.save({ invite: { defaultMaxUses: 2 } }, 0, " ");
|
|
109
|
+
expect(first.updatedBy).toBeUndefined();
|
|
110
|
+
expect((await store.get())?.updatedBy).toBeUndefined();
|
|
111
|
+
|
|
112
|
+
const second = await store.save({ invite: { defaultMaxUses: 3 } }, 1, "");
|
|
113
|
+
expect(second.updatedBy).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import { ExternalIdentityConflictError } from "@axiom-lattice/protocols";
|
|
4
|
+
import { createUserExternalIdentityTable } from "../migrations/user_external_identity_migration";
|
|
5
|
+
import { PostgreSQLUserExternalIdentityStore } from "../stores/PostgreSQLUserExternalIdentityStore";
|
|
6
|
+
|
|
7
|
+
const databaseUrl = process.env.TEST_DATABASE_URL;
|
|
8
|
+
const describeIfDatabase = databaseUrl ? describe : describe.skip;
|
|
9
|
+
|
|
10
|
+
describeIfDatabase("PostgreSQLUserExternalIdentityStore", () => {
|
|
11
|
+
let pool: Pool;
|
|
12
|
+
let store: PostgreSQLUserExternalIdentityStore;
|
|
13
|
+
const providerId = `oidc-${randomUUID()}`;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
pool = new Pool({ connectionString: databaseUrl });
|
|
17
|
+
const client = await pool.connect();
|
|
18
|
+
try {
|
|
19
|
+
await createUserExternalIdentityTable.up(client);
|
|
20
|
+
} finally {
|
|
21
|
+
client.release();
|
|
22
|
+
}
|
|
23
|
+
store = new PostgreSQLUserExternalIdentityStore({ pool });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await pool.query(`DELETE FROM lattice_user_identities WHERE provider_id = $1`, [providerId]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await pool.query(`DELETE FROM lattice_user_identities WHERE provider_id = $1`, [providerId]);
|
|
32
|
+
await pool.end();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("links, reads, lists and unlinks", async () => {
|
|
36
|
+
await store.link({
|
|
37
|
+
userId: "user-1",
|
|
38
|
+
providerId,
|
|
39
|
+
issuer: "https://idp.example.com",
|
|
40
|
+
subject: "sub-1",
|
|
41
|
+
email: "user@example.com",
|
|
42
|
+
linkedAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const found = await store.getByProviderSubject(
|
|
46
|
+
providerId,
|
|
47
|
+
"https://idp.example.com",
|
|
48
|
+
"sub-1",
|
|
49
|
+
);
|
|
50
|
+
expect(found).toMatchObject({
|
|
51
|
+
userId: "user-1",
|
|
52
|
+
providerId,
|
|
53
|
+
issuer: "https://idp.example.com",
|
|
54
|
+
subject: "sub-1",
|
|
55
|
+
email: "user@example.com",
|
|
56
|
+
});
|
|
57
|
+
expect(found?.linkedAt).toBeInstanceOf(Date);
|
|
58
|
+
|
|
59
|
+
await store.unlink(providerId, "https://idp.example.com", "sub-1");
|
|
60
|
+
expect(
|
|
61
|
+
await store.getByProviderSubject(providerId, "https://idp.example.com", "sub-1"),
|
|
62
|
+
).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("lists all identities for a user ordered by linked_at ascending", async () => {
|
|
66
|
+
await store.link({
|
|
67
|
+
userId: "user-list",
|
|
68
|
+
providerId,
|
|
69
|
+
issuer: "https://idp-a.example.com",
|
|
70
|
+
subject: "sub-1",
|
|
71
|
+
linkedAt: new Date("2026-02-01T00:00:00.000Z"),
|
|
72
|
+
});
|
|
73
|
+
await store.link({
|
|
74
|
+
userId: "user-list",
|
|
75
|
+
providerId,
|
|
76
|
+
issuer: "https://idp-b.example.com",
|
|
77
|
+
subject: "sub-1",
|
|
78
|
+
linkedAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const listed = await store.listByUser("user-list");
|
|
82
|
+
expect(listed).toHaveLength(2);
|
|
83
|
+
expect(listed.map((item) => item.issuer)).toEqual([
|
|
84
|
+
"https://idp-b.example.com",
|
|
85
|
+
"https://idp-a.example.com",
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("isolates the same subject across issuers", async () => {
|
|
90
|
+
await store.link({
|
|
91
|
+
userId: "user-a",
|
|
92
|
+
providerId,
|
|
93
|
+
issuer: "https://idp-a.example.com",
|
|
94
|
+
subject: "sub-1",
|
|
95
|
+
linkedAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
96
|
+
});
|
|
97
|
+
await store.link({
|
|
98
|
+
userId: "user-b",
|
|
99
|
+
providerId,
|
|
100
|
+
issuer: "https://idp-b.example.com",
|
|
101
|
+
subject: "sub-1",
|
|
102
|
+
linkedAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
103
|
+
});
|
|
104
|
+
expect(
|
|
105
|
+
await store.getByProviderSubject(providerId, "https://idp-a.example.com", "sub-1"),
|
|
106
|
+
).toMatchObject({ userId: "user-a" });
|
|
107
|
+
expect(
|
|
108
|
+
await store.getByProviderSubject(providerId, "https://idp-b.example.com", "sub-1"),
|
|
109
|
+
).toMatchObject({ userId: "user-b" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("is idempotent for the same user and rejects a different user", async () => {
|
|
113
|
+
const base = {
|
|
114
|
+
providerId,
|
|
115
|
+
issuer: "https://idp.example.com",
|
|
116
|
+
subject: "sub-1",
|
|
117
|
+
linkedAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
118
|
+
};
|
|
119
|
+
await store.link({ ...base, userId: "user-1" });
|
|
120
|
+
await expect(store.link({ ...base, userId: "user-1" })).resolves.toBeUndefined();
|
|
121
|
+
await expect(store.link({ ...base, userId: "user-2" })).rejects.toBeInstanceOf(
|
|
122
|
+
ExternalIdentityConflictError,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import { MigrationManager } from "../migrations/migration";
|
|
4
|
+
import { createUsersTable } from "../migrations/user_migrations";
|
|
5
|
+
import { addUserStatusColumn } from "../migrations/user_status_migration";
|
|
6
|
+
import { createUserEmailLowerUniqueIndex } from "../migrations/user_email_lower_unique_migration";
|
|
7
|
+
import { PostgreSQLUserStore } from "../stores/PostgreSQLUserStore";
|
|
8
|
+
|
|
9
|
+
const databaseUrl = process.env.TEST_DATABASE_URL;
|
|
10
|
+
const describeIfDatabase = databaseUrl ? describe : describe.skip;
|
|
11
|
+
|
|
12
|
+
describeIfDatabase("PostgreSQLUserStore", () => {
|
|
13
|
+
let pool: Pool;
|
|
14
|
+
let store: PostgreSQLUserStore;
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
pool = new Pool({ connectionString: databaseUrl });
|
|
18
|
+
const manager = new MigrationManager(pool);
|
|
19
|
+
manager.register(createUsersTable);
|
|
20
|
+
manager.register(addUserStatusColumn);
|
|
21
|
+
manager.register(createUserEmailLowerUniqueIndex);
|
|
22
|
+
await manager.migrate();
|
|
23
|
+
store = new PostgreSQLUserStore({ pool });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterAll(async () => {
|
|
27
|
+
await pool.end();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("matches email case-insensitively", async () => {
|
|
31
|
+
const id = randomUUID();
|
|
32
|
+
try {
|
|
33
|
+
await store.createUser(id, {
|
|
34
|
+
email: `User-${id}@Example.COM`,
|
|
35
|
+
name: "User",
|
|
36
|
+
status: "active",
|
|
37
|
+
});
|
|
38
|
+
await expect(store.getUserByEmail(` user-${id}@example.com `)).resolves.toMatchObject({ id });
|
|
39
|
+
} finally {
|
|
40
|
+
await pool.query("DELETE FROM lattice_users WHERE id = $1", [id]);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("creates the case-insensitive unique email index", async () => {
|
|
45
|
+
const result = await pool.query(
|
|
46
|
+
`SELECT indexname
|
|
47
|
+
FROM pg_indexes
|
|
48
|
+
WHERE schemaname = current_schema()
|
|
49
|
+
AND tablename = 'lattice_users'
|
|
50
|
+
AND indexname = 'idx_lattice_users_email_lower'`,
|
|
51
|
+
);
|
|
52
|
+
expect(result.rows).toHaveLength(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rejects an email that differs only by case", async () => {
|
|
56
|
+
const firstId = randomUUID();
|
|
57
|
+
const secondId = randomUUID();
|
|
58
|
+
try {
|
|
59
|
+
await store.createUser(firstId, {
|
|
60
|
+
email: `Case-${firstId}@Example.COM`,
|
|
61
|
+
name: "First",
|
|
62
|
+
status: "active",
|
|
63
|
+
});
|
|
64
|
+
await expect(
|
|
65
|
+
store.createUser(secondId, {
|
|
66
|
+
email: `case-${firstId}@example.com`,
|
|
67
|
+
name: "Second",
|
|
68
|
+
status: "active",
|
|
69
|
+
}),
|
|
70
|
+
).rejects.toThrow();
|
|
71
|
+
} finally {
|
|
72
|
+
await pool.query("DELETE FROM lattice_users WHERE id = ANY($1)", [[firstId, secondId]]);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -11,6 +11,12 @@ import { PostgreSQLProjectStore } from "./stores/PostgreSQLProjectStore";
|
|
|
11
11
|
import { PostgreSQLUserStore } from "./stores/PostgreSQLUserStore";
|
|
12
12
|
import { PostgreSQLTenantStore } from "./stores/PostgreSQLTenantStore";
|
|
13
13
|
import { PostgreSQLUserTenantLinkStore } from "./stores/PostgreSQLUserTenantLinkStore";
|
|
14
|
+
import { PostgreSQLUserExternalIdentityStore } from "./stores/PostgreSQLUserExternalIdentityStore";
|
|
15
|
+
import { PostgreSQLOidcProviderConfigStore } from "./stores/PostgreSQLOidcProviderConfigStore";
|
|
16
|
+
import { PostgreSQLPlatformMemberStore } from "./stores/PostgreSQLPlatformMemberStore";
|
|
17
|
+
import { PostgreSQLPlatformInvitationStore } from "./stores/PostgreSQLPlatformInvitationStore";
|
|
18
|
+
import { PostgreSQLOidcIdentityClaimStore } from "./stores/PostgreSQLOidcIdentityClaimStore";
|
|
19
|
+
import { PostgreSQLInviteLinkStore } from "./stores/PostgreSQLInviteLinkStore";
|
|
14
20
|
import { PostgreSQLWorkflowTrackingStore } from "./stores/PostgreSQLWorkflowTrackingStore";
|
|
15
21
|
import { PostgreSQLEvalStore } from "./stores/PostgreSQLEvalStore";
|
|
16
22
|
import { ThreadMessageQueueStore } from "./stores/ThreadMessageQueueStore";
|
|
@@ -67,6 +73,16 @@ import { addApiKeyGrants } from "./migrations/add_api_key_grants";
|
|
|
67
73
|
import { createOpenAuditTable } from "./migrations/create_open_audit_table";
|
|
68
74
|
import { addShareOpenGrants } from "./migrations/add_share_open_grants";
|
|
69
75
|
import { addShareKind } from "./migrations/add_share_kind";
|
|
76
|
+
import { createUserExternalIdentityTable } from "./migrations/user_external_identity_migration";
|
|
77
|
+
import { createUserEmailLowerUniqueIndex } from "./migrations/user_email_lower_unique_migration";
|
|
78
|
+
import { createOidcProviderConfigsTable } from "./migrations/oidc_provider_config_migration";
|
|
79
|
+
import { createPlatformMembersTable } from "./migrations/platform_member_migration";
|
|
80
|
+
import { createPlatformInvitationsTable } from "./migrations/platform_invitation_migration";
|
|
81
|
+
import { createOidcIdentityClaimsTable } from "./migrations/oidc_identity_claim_migration";
|
|
82
|
+
import { createInviteLinksTable } from "./migrations/invite_link_migration";
|
|
83
|
+
import { addOidcIdentityClaimInviteFields } from "./migrations/oidc_identity_claim_invite_migration";
|
|
84
|
+
import { createPlatformSettingsTable } from "./migrations/platform_settings_migration";
|
|
85
|
+
import { addOidcProviderAllowedHosts } from "./migrations/oidc_provider_allowed_hosts_migration";
|
|
70
86
|
import { taskMigrations } from "./migrations/task_migration";
|
|
71
87
|
import { createMenuItemsTable } from "./migrations/menu_items_migration";
|
|
72
88
|
import { addFileContentType } from "./migrations/menu_items_add_file_type";
|
|
@@ -124,6 +140,7 @@ import { PostgreSQLLeaseStore } from "./stores/PostgreSQLLeaseStore";
|
|
|
124
140
|
import { PostgreSQLOutboxStore } from "./stores/PostgreSQLOutboxStore";
|
|
125
141
|
import { PostgreSQLPluginDeploymentStore } from "./stores/PostgreSQLPluginDeploymentStore";
|
|
126
142
|
import { PostgreSQLAgentRunLedger } from "./stores/PostgreSQLAgentRunLedger";
|
|
143
|
+
import { PostgreSQLPlatformSettingsStore } from "./stores/PostgreSQLPlatformSettingsStore";
|
|
127
144
|
import { PostgreSQLChannelReplyJobStore } from "./stores/PostgreSQLChannelReplyJobStore";
|
|
128
145
|
import { PostgreSQLCheckpointStorage } from "./stores/PostgreSQLCheckpointStorage";
|
|
129
146
|
import { PostgreSQLStore } from "./stores/PostgreSQLStore";
|
|
@@ -226,6 +243,16 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
226
243
|
mm.register(agentRunProjectTaskRunningIndexMigration); // v204
|
|
227
244
|
mm.register(addModelProviderKindDefault); // v207
|
|
228
245
|
mm.register(addShareKind); // v208
|
|
246
|
+
mm.register(createUserExternalIdentityTable); // v209
|
|
247
|
+
mm.register(createUserEmailLowerUniqueIndex); // v210
|
|
248
|
+
mm.register(createOidcProviderConfigsTable); // v211
|
|
249
|
+
mm.register(createPlatformMembersTable); // v212
|
|
250
|
+
mm.register(createPlatformInvitationsTable); // v213
|
|
251
|
+
mm.register(createOidcIdentityClaimsTable); // v214
|
|
252
|
+
mm.register(createInviteLinksTable); // v215
|
|
253
|
+
mm.register(addOidcIdentityClaimInviteFields); // v216
|
|
254
|
+
mm.register(createPlatformSettingsTable); // v217
|
|
255
|
+
mm.register(addOidcProviderAllowedHosts); // v218
|
|
229
256
|
|
|
230
257
|
await mm.migrate();
|
|
231
258
|
|
|
@@ -247,6 +274,13 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
247
274
|
user: new PostgreSQLUserStore(opts),
|
|
248
275
|
tenant: new PostgreSQLTenantStore(opts),
|
|
249
276
|
userTenantLink: new PostgreSQLUserTenantLinkStore(opts),
|
|
277
|
+
userExternalIdentity: new PostgreSQLUserExternalIdentityStore(opts),
|
|
278
|
+
oidcProviderConfig: new PostgreSQLOidcProviderConfigStore(opts),
|
|
279
|
+
platformMember: new PostgreSQLPlatformMemberStore(opts),
|
|
280
|
+
platformInvitation: new PostgreSQLPlatformInvitationStore(opts),
|
|
281
|
+
oidcIdentityClaim: new PostgreSQLOidcIdentityClaimStore(opts),
|
|
282
|
+
inviteLink: new PostgreSQLInviteLinkStore(opts),
|
|
283
|
+
platformSettings: new PostgreSQLPlatformSettingsStore(opts),
|
|
250
284
|
channelBinding: new ChannelBindingStore(opts),
|
|
251
285
|
channelInstallation: new PostgreSQLChannelInstallationStore(opts),
|
|
252
286
|
thread: new PostgreSQLThreadStore(opts),
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,22 @@ export * from "./stores/PostgreSQLProjectStore";
|
|
|
24
24
|
export * from "./stores/PostgreSQLUserStore";
|
|
25
25
|
export * from "./stores/PostgreSQLTenantStore";
|
|
26
26
|
export * from "./stores/PostgreSQLUserTenantLinkStore";
|
|
27
|
+
export * from "./migrations/user_external_identity_migration";
|
|
28
|
+
export * from "./migrations/user_email_lower_unique_migration";
|
|
29
|
+
export * from "./stores/PostgreSQLUserExternalIdentityStore";
|
|
30
|
+
export * from "./migrations/oidc_provider_config_migration";
|
|
31
|
+
export * from "./stores/PostgreSQLOidcProviderConfigStore";
|
|
32
|
+
export * from "./migrations/platform_member_migration";
|
|
33
|
+
export * from "./stores/PostgreSQLPlatformMemberStore";
|
|
34
|
+
export * from "./migrations/platform_invitation_migration";
|
|
35
|
+
export * from "./stores/PostgreSQLPlatformInvitationStore";
|
|
36
|
+
export * from "./migrations/oidc_identity_claim_migration";
|
|
37
|
+
export * from "./stores/PostgreSQLOidcIdentityClaimStore";
|
|
38
|
+
export * from "./migrations/invite_link_migration";
|
|
39
|
+
export * from "./migrations/oidc_identity_claim_invite_migration";
|
|
40
|
+
export * from "./stores/PostgreSQLInviteLinkStore";
|
|
41
|
+
export * from "./migrations/platform_settings_migration";
|
|
42
|
+
export * from "./stores/PostgreSQLPlatformSettingsStore";
|
|
27
43
|
export * from "./migrations/migration";
|
|
28
44
|
export * from "./migrations/thread_migrations";
|
|
29
45
|
export * from "./migrations/thread_tenant_migration";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Migration } from "./migration";
|
|
2
|
+
|
|
3
|
+
/** Creates durable bearer invitation links. */
|
|
4
|
+
export const createInviteLinksTable: Migration = {
|
|
5
|
+
version: 215,
|
|
6
|
+
name: "create_invite_links",
|
|
7
|
+
up: async (client) => {
|
|
8
|
+
await client.query(`
|
|
9
|
+
CREATE TABLE IF NOT EXISTS lattice_invite_links (
|
|
10
|
+
id UUID PRIMARY KEY,
|
|
11
|
+
scope VARCHAR(16) NOT NULL,
|
|
12
|
+
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
|
13
|
+
token_encrypted TEXT NOT NULL,
|
|
14
|
+
email VARCHAR(320),
|
|
15
|
+
platform_role VARCHAR(16),
|
|
16
|
+
tenant_id VARCHAR(255),
|
|
17
|
+
tenant_role VARCHAR(16),
|
|
18
|
+
created_by VARCHAR(255),
|
|
19
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
20
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
21
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
22
|
+
max_uses INTEGER NOT NULL,
|
|
23
|
+
used_count INTEGER NOT NULL DEFAULT 0,
|
|
24
|
+
revoked_at TIMESTAMPTZ
|
|
25
|
+
)
|
|
26
|
+
`);
|
|
27
|
+
await client.query(`
|
|
28
|
+
CREATE INDEX IF NOT EXISTS lattice_invite_links_created
|
|
29
|
+
ON lattice_invite_links (created_at DESC)
|
|
30
|
+
`);
|
|
31
|
+
},
|
|
32
|
+
down: async (client) => {
|
|
33
|
+
await client.query("DROP TABLE IF EXISTS lattice_invite_links");
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Migration } from "./migration";
|
|
2
|
+
|
|
3
|
+
/** Adds invite-link request context to pending identity claims. */
|
|
4
|
+
export const addOidcIdentityClaimInviteFields: Migration = {
|
|
5
|
+
version: 216,
|
|
6
|
+
name: "add_oidc_identity_claim_invite_fields",
|
|
7
|
+
up: async (client) => {
|
|
8
|
+
await client.query(`
|
|
9
|
+
ALTER TABLE lattice_oidc_identity_claims
|
|
10
|
+
ADD COLUMN IF NOT EXISTS source VARCHAR(32),
|
|
11
|
+
ADD COLUMN IF NOT EXISTS invite_link_id UUID,
|
|
12
|
+
ADD COLUMN IF NOT EXISTS requested_user_id VARCHAR(255),
|
|
13
|
+
ADD COLUMN IF NOT EXISTS requested_platform_role VARCHAR(16),
|
|
14
|
+
ADD COLUMN IF NOT EXISTS requested_tenant_id VARCHAR(255),
|
|
15
|
+
ADD COLUMN IF NOT EXISTS requested_tenant_role VARCHAR(16)
|
|
16
|
+
`);
|
|
17
|
+
},
|
|
18
|
+
down: async (client) => {
|
|
19
|
+
await client.query(`
|
|
20
|
+
ALTER TABLE lattice_oidc_identity_claims
|
|
21
|
+
DROP COLUMN IF EXISTS source,
|
|
22
|
+
DROP COLUMN IF EXISTS invite_link_id,
|
|
23
|
+
DROP COLUMN IF EXISTS requested_user_id,
|
|
24
|
+
DROP COLUMN IF EXISTS requested_platform_role,
|
|
25
|
+
DROP COLUMN IF EXISTS requested_tenant_id,
|
|
26
|
+
DROP COLUMN IF EXISTS requested_tenant_role
|
|
27
|
+
`);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Migration } from "./migration";
|
|
2
|
+
|
|
3
|
+
/** Creates pending enterprise-SSO identity claims. */
|
|
4
|
+
export const createOidcIdentityClaimsTable: Migration = {
|
|
5
|
+
version: 214,
|
|
6
|
+
name: "create_oidc_identity_claims",
|
|
7
|
+
up: async (client) => {
|
|
8
|
+
await client.query(`
|
|
9
|
+
CREATE TABLE IF NOT EXISTS lattice_oidc_identity_claims (
|
|
10
|
+
id UUID PRIMARY KEY,
|
|
11
|
+
provider_id VARCHAR(64) NOT NULL,
|
|
12
|
+
issuer TEXT NOT NULL,
|
|
13
|
+
subject TEXT NOT NULL,
|
|
14
|
+
email VARCHAR(320) NOT NULL,
|
|
15
|
+
name TEXT,
|
|
16
|
+
picture TEXT,
|
|
17
|
+
status VARCHAR(16) NOT NULL,
|
|
18
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
19
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
20
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
21
|
+
reviewed_by VARCHAR(255),
|
|
22
|
+
reviewed_at TIMESTAMPTZ,
|
|
23
|
+
linked_user_id VARCHAR(255)
|
|
24
|
+
)
|
|
25
|
+
`);
|
|
26
|
+
await client.query(`
|
|
27
|
+
CREATE UNIQUE INDEX IF NOT EXISTS lattice_oidc_identity_claims_pending_subject
|
|
28
|
+
ON lattice_oidc_identity_claims (provider_id, issuer, subject)
|
|
29
|
+
WHERE status = 'pending'
|
|
30
|
+
`);
|
|
31
|
+
await client.query(`
|
|
32
|
+
CREATE INDEX IF NOT EXISTS lattice_oidc_identity_claims_status_created
|
|
33
|
+
ON lattice_oidc_identity_claims (status, created_at)
|
|
34
|
+
`);
|
|
35
|
+
},
|
|
36
|
+
down: async (client) => {
|
|
37
|
+
await client.query("DROP TABLE IF EXISTS lattice_oidc_identity_claims");
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Migration } from "./migration";
|
|
2
|
+
|
|
3
|
+
/** Adds the per-provider private-network issuer allowlist to OIDC providers. */
|
|
4
|
+
export const addOidcProviderAllowedHosts: Migration = {
|
|
5
|
+
version: 218,
|
|
6
|
+
name: "add_oidc_provider_allowed_hosts",
|
|
7
|
+
up: async (client) => {
|
|
8
|
+
await client.query(`
|
|
9
|
+
ALTER TABLE lattice_oidc_provider_configs
|
|
10
|
+
ADD COLUMN IF NOT EXISTS allowed_private_hosts JSONB
|
|
11
|
+
`);
|
|
12
|
+
},
|
|
13
|
+
down: async (client) => {
|
|
14
|
+
await client.query(`
|
|
15
|
+
ALTER TABLE lattice_oidc_provider_configs
|
|
16
|
+
DROP COLUMN IF EXISTS allowed_private_hosts
|
|
17
|
+
`);
|
|
18
|
+
},
|
|
19
|
+
};
|