Skip to content

Commit

Permalink
feat: update externalId to (dis-)connect identities (#2128)
Browse files Browse the repository at this point in the history
* feat: update externalId to (dis-)connect identities

* feat(dashboard.ts): update Clickhouse URL to use localhost instead of 'clickhouse' host

* Update apps/api/src/routes/v1_keys_updateKey.happy.test.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* test(v1_identities_updateIdentity.happy.test.ts): add console.log for found variable
refactor(v1_identities_updateIdentity.ts): remove unnecessary empty line

* [autofix.ci] apply automated fixes

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Sep 24, 2024
1 parent d9a71e8 commit fc41961
Show file tree
Hide file tree
Showing 7 changed files with 232 additions and 8 deletions.
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 });
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

0 comments on commit fc41961

Please sign in to comment.