diff --git a/apps/api/src/routes/v1_identities_updateIdentity.happy.test.ts b/apps/api/src/routes/v1_identities_updateIdentity.happy.test.ts index 88260819a1..203100a548 100644 --- a/apps/api/src/routes/v1_identities_updateIdentity.happy.test.ts +++ b/apps/api/src/routes/v1_identities_updateIdentity.happy.test.ts @@ -124,6 +124,7 @@ test("sets new ratelimits", async (t) => { where: (table, { eq }) => eq(table.identityId, identity.id), }); + console.log({ found }); expect(found.length).toBe(ratelimits.length); for (const rl of ratelimits) { expect( diff --git a/apps/api/src/routes/v1_identities_updateIdentity.ts b/apps/api/src/routes/v1_identities_updateIdentity.ts index 6e2ff74b3e..03a44eafa0 100644 --- a/apps/api/src/routes/v1_identities_updateIdentity.ts +++ b/apps/api/src/routes/v1_identities_updateIdentity.ts @@ -253,7 +253,6 @@ export const registerV1IdentitiesUpdateIdentity = (app: App) => /** * Delete undesired ratelimits */ - for (const rl of deleteRatelimits) { await tx.delete(schema.ratelimits).where(eq(schema.ratelimits.id, rl.id)); auditLogs.push({ diff --git a/apps/api/src/routes/v1_keys_createKey.ts b/apps/api/src/routes/v1_keys_createKey.ts index 006feb7caf..8780846e95 100644 --- a/apps/api/src/routes/v1_keys_createKey.ts +++ b/apps/api/src/routes/v1_keys_createKey.ts @@ -569,7 +569,7 @@ async function getRoleIds( return roles.map((r) => r.id); } -async function upsertIdentity( +export async function upsertIdentity( db: Database, workspaceId: string, externalId: string, diff --git a/apps/api/src/routes/v1_keys_updateKey.happy.test.ts b/apps/api/src/routes/v1_keys_updateKey.happy.test.ts index 52e664995b..30c1d555e1 100644 --- a/apps/api/src/routes/v1_keys_updateKey.happy.test.ts +++ b/apps/api/src/routes/v1_keys_updateKey.happy.test.ts @@ -585,6 +585,207 @@ test("delete expires", async (t) => { expect(found?.expires).toBeNull(); }); +describe("externalId", () => { + test("set externalId connects the identity", async (t) => { + const h = await IntegrationHarness.init(t); + + const root = await h.createRootKey([`api.${h.resources.userApi.id}.update_key`]); + + const key = await h.createKey(); + const externalId = newId("test"); + + const res = await h.post({ + url: "/v1/keys.updateKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + keyId: key.keyId, + externalId, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + + const found = await h.db.primary.query.keys.findFirst({ + where: (table, { eq }) => eq(table.id, key.keyId), + with: { + identity: true, + }, + }); + expect(found).toBeDefined(); + expect(found!.identity).toBeDefined(); + expect(found!.identity!.externalId).toBe(externalId); + }); + + test("omitting the field does not disconnect the identity", async (t) => { + const h = await IntegrationHarness.init(t); + + const root = await h.createRootKey([`api.${h.resources.userApi.id}.update_key`]); + + const identityId = newId("test"); + const externalId = newId("test"); + await h.db.primary.insert(schema.identities).values({ + id: identityId, + workspaceId: h.resources.userWorkspace.id, + externalId, + }); + const key = await h.createKey({ identityId }); + const before = await h.db.primary.query.keys.findFirst({ + where: (table, { eq }) => eq(table.id, key.keyId), + with: { + identity: true, + }, + }); + expect(before?.identity).toBeDefined(); + + const res = await h.post({ + url: "/v1/keys.updateKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + keyId: key.keyId, + externalId: undefined, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + + const found = await h.db.primary.query.keys.findFirst({ + where: (table, { eq }) => eq(table.id, key.keyId), + with: { + identity: true, + }, + }); + expect(found).toBeDefined(); + expect(found!.identity).toBeDefined(); + expect(found!.identity!.externalId).toBe(externalId); + }); + + test("set ownerId connects the identity", async (t) => { + const h = await IntegrationHarness.init(t); + + const root = await h.createRootKey([`api.${h.resources.userApi.id}.update_key`]); + + const key = await h.createKey(); + const ownerId = newId("test"); + + const res = await h.post({ + url: "/v1/keys.updateKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + keyId: key.keyId, + ownerId, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + + const found = await h.db.primary.query.keys.findFirst({ + where: (table, { eq }) => eq(table.id, key.keyId), + with: { + identity: true, + }, + }); + expect(found).toBeDefined(); + expect(found!.identity).toBeDefined(); + expect(found!.identity!.externalId).toBe(ownerId); + }); + + test("set externalId=null disconnects the identity", async (t) => { + const h = await IntegrationHarness.init(t); + + const root = await h.createRootKey([`api.${h.resources.userApi.id}.update_key`]); + + const identityId = newId("test"); + await h.db.primary.insert(schema.identities).values({ + id: identityId, + workspaceId: h.resources.userWorkspace.id, + externalId: newId("test"), + }); + const key = await h.createKey({ identityId }); + const before = await h.db.primary.query.keys.findFirst({ + where: (table, { eq }) => eq(table.id, key.keyId), + with: { + identity: true, + }, + }); + expect(before?.identity).toBeDefined(); + + const res = await h.post({ + url: "/v1/keys.updateKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + keyId: key.keyId, + externalId: null, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + + const found = await h.db.primary.query.keys.findFirst({ + where: (table, { eq }) => eq(table.id, key.keyId), + with: { + identity: true, + }, + }); + expect(found).toBeDefined(); + expect(found!.identity).toBeNull(); + }); + + test("set ownerId=null disconnects the identity", async (t) => { + const h = await IntegrationHarness.init(t); + + const root = await h.createRootKey([`api.${h.resources.userApi.id}.update_key`]); + + const identityId = newId("test"); + await h.db.primary.insert(schema.identities).values({ + id: identityId, + workspaceId: h.resources.userWorkspace.id, + externalId: newId("test"), + }); + const key = await h.createKey({ identityId }); + const before = await h.db.primary.query.keys.findFirst({ + where: (table, { eq }) => eq(table.id, key.keyId), + with: { + identity: true, + }, + }); + expect(before?.identity).toBeDefined(); + + const res = await h.post({ + url: "/v1/keys.updateKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + keyId: key.keyId, + ownerId: null, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + + const found = await h.db.primary.query.keys.findFirst({ + where: (table, { eq }) => eq(table.id, key.keyId), + with: { + identity: true, + }, + }); + expect(found).toBeDefined(); + expect(found!.identity).toBeNull(); + }); +}); test("update should not affect undefined fields", async (t) => { const h = await IntegrationHarness.init(t); diff --git a/apps/api/src/routes/v1_keys_updateKey.ts b/apps/api/src/routes/v1_keys_updateKey.ts index 566176da56..df68785fca 100644 --- a/apps/api/src/routes/v1_keys_updateKey.ts +++ b/apps/api/src/routes/v1_keys_updateKey.ts @@ -6,6 +6,7 @@ import { UnkeyApiError, openApiErrorResponses } from "@/pkg/errors"; import { schema } from "@unkey/db"; import { eq } from "@unkey/db"; import { buildUnkeyQuery } from "@unkey/rbac"; +import { upsertIdentity } from "./v1_keys_createKey"; import { setPermissions } from "./v1_keys_setPermissions"; import { setRoles } from "./v1_keys_setRoles"; @@ -30,11 +31,24 @@ const route = createRoute({ description: "The name of the key", example: "Customer X", }), - ownerId: z.string().nullish().openapi({ - description: - "The id of the tenant associated with this key. Use whatever reference you have in your system to identify the tenant. When verifying the key, we will send this field back to you, so you know who is accessing your API.", - example: "user_123", - }), + ownerId: z + .string() + .nullish() + .openapi({ + deprecated: true, + description: `Deprecated, use \`externalId\` + The id of the tenant associated with this key. Use whatever reference you have in your system to identify the tenant. When verifying the key, we will send this field back to you, so you know who is accessing your API.`, + example: "user_123", + }), + externalId: z + .string() + .nullish() + .openapi({ + description: `The id of the tenant associated with this key. Use whatever reference you have in your system to identify the tenant. When verifying the key, we will send this back to you, so you know who is accessing your API. + Under the hood this upserts and connects an \`ìdentity\` for you. + To disconnect the key from an identity, set \`externalId: null\`.`, + example: "user_123", + }), meta: z .record(z.unknown()) .nullish() @@ -324,12 +338,21 @@ export const registerV1KeysUpdate = (app: App) => const authorizedWorkspaceId = auth.authorizedWorkspaceId; const rootKeyId = auth.key.id; + const externalId = typeof req.externalId !== "undefined" ? req.externalId : req.ownerId; + const identityId = + typeof externalId === "undefined" + ? undefined + : externalId === null + ? null + : (await upsertIdentity(db.primary, authorizedWorkspaceId, externalId)).id; + await db.primary .update(schema.keys) .set({ name: req.name, ownerId: req.ownerId, meta: typeof req.meta === "undefined" ? undefined : JSON.stringify(req.meta ?? {}), + identityId, expires: typeof req.expires === "undefined" ? undefined diff --git a/clickhouse b/clickhouse new file mode 100755 index 0000000000..9ceaa12e5e Binary files /dev/null and b/clickhouse differ diff --git a/tools/local/src/cmd/dashboard.ts b/tools/local/src/cmd/dashboard.ts index 69402334a8..5e02e9e00b 100644 --- a/tools/local/src/cmd/dashboard.ts +++ b/tools/local/src/cmd/dashboard.ts @@ -57,7 +57,7 @@ which you need in to copy in the next step.`, AGENT_TOKEN: "agent-auth-secret", }, Clickhouse: { - CLICKHOUSE_URL: "http://default:password@clickhouse:8123", + CLICKHOUSE_URL: "http://default:password@localhost:8123", }, });