Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ test("sets new ratelimits", async (t) => {
where: (table, { eq }) => eq(table.identityId, identity.id),
});

console.log({ found });
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You left this here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no that's on purpose, I just added it, cause this test only fails on github, not local

expect(found.length).toBe(ratelimits.length);
for (const rl of ratelimits) {
expect(
Expand Down
1 change: 0 additions & 1 deletion apps/api/src/routes/v1_identities_updateIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/routes/v1_keys_createKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
201 changes: 201 additions & 0 deletions apps/api/src/routes/v1_keys_updateKey.happy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<V1KeysUpdateKeyRequest, V1KeysUpdateKeyResponse>({
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<V1KeysUpdateKeyRequest, V1KeysUpdateKeyResponse>({
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<V1KeysUpdateKeyRequest, V1KeysUpdateKeyResponse>({
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<V1KeysUpdateKeyRequest, V1KeysUpdateKeyResponse>({
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<V1KeysUpdateKeyRequest, V1KeysUpdateKeyResponse>({
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);

Expand Down
33 changes: 28 additions & 5 deletions apps/api/src/routes/v1_keys_updateKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Binary file added clickhouse
Binary file not shown.
2 changes: 1 addition & 1 deletion tools/local/src/cmd/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
});

Expand Down