Skip to content

Commit

Permalink
Merge branch 'main' into fix/v1_permissions_createRole_transaction_error
Browse files Browse the repository at this point in the history
  • Loading branch information
Flo4604 authored Oct 8, 2024
2 parents 13371ce + edbeb2d commit 518f77a
Show file tree
Hide file tree
Showing 63 changed files with 2,440 additions and 4,788 deletions.
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
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
* @perkinsjr @chronark
* @perkinsjr @chronark @mcstepp @MichaelUnkey
/apps/www @perkinsjr @chronark @MichaelUnkey @mcstepp
/apps/dashboard @perkinsjr @chronark @mcstepp

2 changes: 1 addition & 1 deletion apps/api/src/pkg/hono/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function newApp() {
app.doc("/openapi.json", {
openapi: "3.0.0",
info: {
title: "Unkey Api",
title: "Unkey API",
version: "1.0.0",
},

Expand Down
174 changes: 174 additions & 0 deletions apps/api/src/routes/v1_keys_ami.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",
});
}
let meta = key.meta ? JSON.parse(key.meta) : undefined;
if (!meta || Object.keys(meta).length === 0) {
meta = undefined;
}

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,
});
});
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 }) => {
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))),
);

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);
});
});
});
Loading

0 comments on commit 518f77a

Please sign in to comment.