Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Who Am I route #2159

Merged
merged 19 commits into from
Oct 7, 2024
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
5 changes: 5 additions & 0 deletions .changeset/late-colts-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"api": minor
---

add /v1/keys.whoami route
34 changes: 34 additions & 0 deletions apps/api/src/routes/v1_keys_whoAmI.error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { newId } from "@unkey/id";
import { KeyV1 } from "@unkey/keys";
import { IntegrationHarness } from "src/pkg/testutil/integration-harness";
import { expect, test } from "vitest";

import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoami";

test("when the key does not exist", async (t) => {
const h = await IntegrationHarness.init(t);
const apiId = newId("api");
const key = new KeyV1({ prefix: "test", byteLength: 16 }).toString();

const root = await h.createRootKey([`api.${apiId}.read_key`]);

const res = await h.post<V1KeysWhoAmIRequest, V1KeysWhoAmIResponse>({
url: "/v1/keys.whoami",
headers: {
Authorization: `Bearer ${root.key}`,
"Content-Type": "application/json",
},
body: {
key: key,
},
});

expect(res.status).toEqual(404);
expect(res.body).toMatchObject({
error: {
code: "NOT_FOUND",
docs: "https://unkey.dev/docs/api-reference/errors/code/NOT_FOUND",
message: "Key not found",
},
});
});
86 changes: 86 additions & 0 deletions apps/api/src/routes/v1_keys_whoAmI.happy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { schema } from "@unkey/db";
import { sha256 } from "@unkey/hash";
import { newId } from "@unkey/id";
import { KeyV1 } from "@unkey/keys";
import { IntegrationHarness } from "src/pkg/testutil/integration-harness";

import { randomUUID } from "node:crypto";
import { expect, test } from "vitest";
import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoami";

test("returns 200", async (t) => {
const h = await IntegrationHarness.init(t);
const root = await h.createRootKey(["api.*.read_key"]);

const key = new KeyV1({ byteLength: 16 }).toString();
const hash = await sha256(key);
const meta = JSON.stringify({ hello: "world" });

const keySchema = {
id: newId("test"),
keyAuthId: h.resources.userKeyAuth.id,
workspaceId: h.resources.userWorkspace.id,
start: "test",
name: "test",
remaining: 100,
enabled: true,
environment: "test",
hash: hash,
meta: meta,
createdAt: new Date(),
};
await h.db.primary.insert(schema.keys).values(keySchema);

const res = await h.post<V1KeysWhoAmIRequest, V1KeysWhoAmIResponse>({
url: "/v1/keys.whoami",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${root.key}`,
},
body: {
key: key,
},
});
expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200);

expect(res.body.id).toEqual(keySchema.id);
expect(res.body.name).toEqual(keySchema.name);
expect(res.body.remaining).toEqual(keySchema.remaining);
expect(res.body.name).toEqual(keySchema.name);
expect(res.body.meta).toEqual(JSON.parse(keySchema.meta));
expect(res.body.enabled).toEqual(keySchema.enabled);
expect(res.body.environment).toEqual(keySchema.environment);
});

test("returns identity", async (t) => {
const h = await IntegrationHarness.init(t);

const identity = {
id: newId("identity"),
externalId: randomUUID(),
workspaceId: h.resources.userWorkspace.id,
};
await h.db.primary.insert(schema.identities).values(identity);

const { key } = await h.createKey({ identityId: identity.id });
const root = await h.createRootKey([
`api.${h.resources.userApi.id}.read_api`,
`api.${h.resources.userApi.id}.read_key`,
]);

const res = await h.post<V1KeysWhoAmIRequest, V1KeysWhoAmIResponse>({
url: "/v1/keys.whoami",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${root.key}`,
},
body: {
key: key,
},
});

expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200);
expect(res.body.identity).toBeDefined();
expect(res.body.identity!.id).toEqual(identity.id);
expect(res.body.identity!.externalId).toEqual(identity.externalId);
});
76 changes: 76 additions & 0 deletions apps/api/src/routes/v1_keys_whoAmI.security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { randomUUID } from "node:crypto";
import { runCommonRouteTests } from "@/pkg/testutil/common-tests";
import { IntegrationHarness } from "src/pkg/testutil/integration-harness";

import { describe, expect, test } from "vitest";
import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoami";

runCommonRouteTests<V1KeysWhoAmIRequest>({
prepareRequest: async (h) => {
const { key } = await h.createKey();
return {
method: "POST",
url: "/v1/keys.whoami",
headers: {
"Content-Type": "application/json",
},
body: {
key: key,
},
};
},
});

describe("correct permissions", () => {
describe.each([
{ name: "legacy", roles: ["*"] },
{ name: "legacy and more", roles: ["*", randomUUID()] },
{ name: "wildcard api", roles: ["api.*.read_key", "api.*.read_api"] },
{
name: "wildcard mixed",
roles: ["api.*.read_key", (apiId: string) => `api.${apiId}.read_api`],
},
{
name: "wildcard mixed 2",
roles: ["api.*.read_api", (apiId: string) => `api.${apiId}.read_key`],
},
{ name: "wildcard and more", roles: ["api.*.read_key", "api.*.read_api", randomUUID()] },
{
name: "specific apiId",
roles: [
(apiId: string) => `api.${apiId}.read_key`,
(apiId: string) => `api.${apiId}.read_api`,
],
},
{
name: "specific apiId and more",
roles: [
(apiId: string) => `api.${apiId}.read_key`,
(apiId: string) => `api.${apiId}.read_api`,
randomUUID(),
],
},
])("$name", ({ roles }) => {
chronark marked this conversation as resolved.
Show resolved Hide resolved
test("returns 200", async (t) => {
const h = await IntegrationHarness.init(t);
const { key } = await h.createKey();

const root = await h.createRootKey(
roles.map((role) => (typeof role === "string" ? role : role(h.resources.userApi.id))),
);
chronark marked this conversation as resolved.
Show resolved Hide resolved

const res = await h.post<V1KeysWhoAmIRequest, V1KeysWhoAmIResponse>({
url: "/v1/keys.whoami",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${root.key}`,
},
body: {
key: key,
},
});

expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toEqual(200);
});
});
});
174 changes: 174 additions & 0 deletions apps/api/src/routes/v1_keys_whoAmI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import type { App } from "@/pkg/hono/app";
import { createRoute, z } from "@hono/zod-openapi";

import { rootKeyAuth } from "@/pkg/auth/root_key";
import { UnkeyApiError, openApiErrorResponses } from "@/pkg/errors";
import { sha256 } from "@unkey/hash";
import { buildUnkeyQuery } from "@unkey/rbac";

const route = createRoute({
tags: ["keys"],
operationId: "whoami",
method: "post",
path: "/v1/keys.whoami",
security: [{ bearerAuth: [] }],
request: {
body: {
required: true,
content: {
"application/json": {
schema: z.object({
key: z.string().min(1).openapi({
description: "The actual key to fetch",
example: "sk_123",
}),
}),
},
},
},
},

responses: {
200: {
description: "The configuration for a single key",
content: {
"application/json": {
schema: z.object({
id: z.string().openapi({
description: "The ID of the key",
example: "key_123",
}),
name: z.string().optional().openapi({
description: "The name of the key",
example: "API Key 1",
}),
remaining: z.number().int().optional().openapi({
description: "The remaining number of requests for the key",
example: 1000,
}),
identity: z
.object({
id: z.string().openapi({
description: "The identity ID associated with the key",
example: "id_123",
}),
externalId: z.string().openapi({
description: "The external identity ID associated with the key",
example: "ext123",
}),
})
.optional()
.openapi({
description: "The identity object associated with the key",
}),
meta: z
.record(z.unknown())
.optional()
.openapi({
description: "Metadata associated with the key",
example: { role: "admin", plan: "premium" },
}),
createdAt: z.number().int().openapi({
description: "The timestamp in milliseconds when the key was created",
example: 1620000000000,
}),
enabled: z.boolean().openapi({
description: "Whether the key is enabled",
example: true,
}),
environment: z.string().optional().openapi({
description: "The environment the key is associated with",
example: "production",
}),
}),
},
},
},
...openApiErrorResponses,
},
});

export type Route = typeof route;
export type V1KeysWhoAmIRequest = z.infer<
(typeof route.request.body.content)["application/json"]["schema"]
>;
export type V1KeysWhoAmIResponse = z.infer<
(typeof route.responses)[200]["content"]["application/json"]["schema"]
>;

export const registerV1KeysWhoAmI = (app: App) =>
app.openapi(route, async (c) => {
const { key: secret } = c.req.valid("json");
const { cache, db } = c.get("services");
const hash = await sha256(secret);
const { val: data, err } = await cache.keyByHash.swr(hash, async () => {
const dbRes = await db.readonly.query.keys.findFirst({
where: (table, { eq, and, isNull }) => and(eq(table.hash, hash), isNull(table.deletedAt)),
with: {
keyAuth: {
with: {
api: true,
},
},
identity: true,
},
});

if (!dbRes) {
return null;
}

return {
key: {
...dbRes,
},
api: dbRes.keyAuth.api,
identity: dbRes.identity,
} as any; // this was necessary so that we don't need to return the workspace and other types defined in keyByHash
});

if (err) {
throw new UnkeyApiError({
code: "INTERNAL_SERVER_ERROR",
message: `unable to load key: ${err.message}`,
});
}
if (!data) {
throw new UnkeyApiError({
code: "NOT_FOUND",
message: "Key not found",
});
}
const { api, key } = data;
const auth = await rootKeyAuth(
c,
buildUnkeyQuery(({ or }) => or("*", "api.*.read_key", `api.${api.id}.read_key`)),
);

if (key.workspaceId !== auth.authorizedWorkspaceId) {
throw new UnkeyApiError({
code: "NOT_FOUND",
message: "Key not found",
});
}
chronark marked this conversation as resolved.
Show resolved Hide resolved
let meta = key.meta ? JSON.parse(key.meta) : undefined;
if (!meta || Object.keys(meta).length === 0) {
meta = undefined;
}
chronark marked this conversation as resolved.
Show resolved Hide resolved
chronark marked this conversation as resolved.
Show resolved Hide resolved

return c.json({
id: key.id,
name: key.name ?? undefined,
remaining: key.remaining ?? undefined,
identity: data.identity
? {
id: data.identity.id,
externalId: data.identity.externalId,
}
: undefined,
meta: meta,
createdAt: key.createdAt.getTime(),
enabled: key.enabled,
environment: key.environment ?? undefined,
});
});
2 changes: 2 additions & 0 deletions apps/api/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { registerV1KeysGetVerifications } from "./routes/v1_keys_getVerification
import { registerV1KeysUpdate } from "./routes/v1_keys_updateKey";
import { registerV1KeysUpdateRemaining } from "./routes/v1_keys_updateRemaining";
import { registerV1KeysVerifyKey } from "./routes/v1_keys_verifyKey";
import { registerV1KeysWhoAmI } from "./routes/v1_keys_whoami";
import { registerV1Liveness } from "./routes/v1_liveness";
import { registerV1RatelimitLimit } from "./routes/v1_ratelimit_limit";

Expand Down Expand Up @@ -67,6 +68,7 @@ registerV1Liveness(app);

// keys
registerV1KeysGetKey(app);
registerV1KeysWhoAmI(app);
registerV1KeysDeleteKey(app);
registerV1KeysCreateKey(app);
registerV1KeysVerifyKey(app);
Expand Down
Loading
Loading