Skip to content

Commit

Permalink
feat: add rbac to more api routes (#1009)
Browse files Browse the repository at this point in the history
* feat: add rbac to more api routes

* docs: wip

* docs: rbac wip

* Remove the lock icon for consistent look

---------

Co-authored-by: James Perkins <[email protected]>
  • Loading branch information
chronark and perkinsjr authored Feb 20, 2024
1 parent 3b8fa27 commit dc4ec69
Show file tree
Hide file tree
Showing 28 changed files with 2,263 additions and 375 deletions.
68 changes: 68 additions & 0 deletions apps/api/src/integration/sdk/create_and_verify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { IntegrationHarness } from "@/pkg/testutil/integration-harness";
import { type Flatten, Unkey, or } from "@unkey/api/src/index"; // use unbundled raw esm typescript
import { schema } from "@unkey/db";
import { newId } from "@unkey/id";
import { expect, test } from "vitest";

test("create with roles and permissions", async () => {
using h = new IntegrationHarness();
await h.seed();

type Resources = {
domain: "create" | "delete" | "read";
dns: {
record: "create" | "read" | "delete";
};
};
type Permissions = Flatten<Resources, ".">;

const roleId = newId("role");
await h.db.insert(schema.roles).values({
id: roleId,
name: "domain.manager",
workspaceId: h.resources.userWorkspace.id,
});

for (const name of ["domain.create", "dns.record.create", "domain.delete"]) {
const permissionId = newId("permission");
await h.db.insert(schema.permissions).values({
id: permissionId,
name,
workspaceId: h.resources.userWorkspace.id,
});

await h.db.insert(schema.rolesPermissions).values({
roleId,
permissionId,
workspaceId: h.resources.userWorkspace.id,
});
}

const { key: rootKey } = await h.createRootKey([`api.${h.resources.userApi.id}.create_key`]);

const sdk = new Unkey({
baseUrl: h.baseUrl,
rootKey: rootKey,
});

const create = await sdk.keys.create({
apiId: h.resources.userApi.id,
roles: ["domain.manager"],
});
expect(create.error).toBeUndefined();
const key = create.result!.key;

const verify = await sdk.keys.verify<Permissions>({
apiId: h.resources.userApi.id,
key,
authorization: {
permissions: or("domain.create", "dns.record.create"),
},
});

expect(verify.error).toBeUndefined();
expect(verify.result).toBeDefined();
console.log("res", verify.result);

expect(verify.result!.valid).toBe(true);
});
2 changes: 1 addition & 1 deletion apps/api/src/pkg/cache/zone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class ZoneCache<TNamespaces extends Record<string, unknown>> implements C
private createCacheKey<TName extends keyof TNamespaces>(
namespace: TName,
key: string,
cacheBuster = "v0",
cacheBuster = "v1",
): URL {
return new URL(
`https://${this.config.domain}/cache/${cacheBuster}/${String(namespace)}/${key}`,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/pkg/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ export type CacheNamespaces = {
key: Key;
api: Api;
permissions: string[];
roles: string[];
} | null;
keyByHash: {
key: Key;
api: Api;
permissions: string[];
roles: string[];
} | null;
apiById: Api | null;
keysByOwnerId: {
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/pkg/keys/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export class KeyService {
key: dbRes,
api: dbRes.keyAuth.api,
permissions: Array.from(permissions.values()),
roles: dbRes.roles.map((r) => r.role.name),
};
});

Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/routes/legacy_keys_deleteKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export const registerLegacyKeysDelete = (app: App) =>
permission: true,
},
},
roles: {
with: {
role: true,
},
},
keyAuth: {
with: {
api: true,
Expand All @@ -60,6 +65,7 @@ export const registerLegacyKeysDelete = (app: App) =>
key: dbRes,
api: dbRes.keyAuth.api,
permissions: dbRes.permissions.map((p) => p.permission.name),
roles: dbRes.roles.map((r) => r.role.name),
};
});

Expand Down
42 changes: 0 additions & 42 deletions apps/api/src/routes/legacy_keys_getKey.test.ts

This file was deleted.

100 changes: 0 additions & 100 deletions apps/api/src/routes/legacy_keys_getKey.ts

This file was deleted.

7 changes: 7 additions & 0 deletions apps/api/src/routes/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ export const keySchema = z
description: "All roles this key belongs to",
example: ["admin", "finance"],
}),
permissions: z
.array(z.string())
.optional()
.openapi({
description: "All permissions this key has",
example: ["domain.dns.create_record", "finance.read_receipt"],
}),
enabled: z.boolean().optional().openapi({
description: "Sets if key is enabled or disabled. Disabled keys are not valid.",
example: true,
Expand Down
52 changes: 52 additions & 0 deletions apps/api/src/routes/v1_keys_createKey.happy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, expect, test } from "vitest";

import { sha256 } from "@unkey/hash";

import { db } from "@/pkg/global";
import { RouteHarness } from "@/pkg/testutil/route-harness";
import { schema } from "@unkey/db";
import { newId } from "@unkey/id";
import {
V1KeysCreateKeyRequest,
V1KeysCreateKeyResponse,
Expand Down Expand Up @@ -161,3 +164,52 @@ describe("with prefix", () => {
expect(key!.start.startsWith("prefix_")).toBe(true);
});
});

describe("roles", () => {
test("connects the specified roles", async () => {
using h = new RouteHarness();
await h.seed();
h.useRoutes(registerV1KeysCreateKey);

const roles = ["r1", "r2"];
await h.db.insert(schema.roles).values(
roles.map((name) => ({
id: newId("role"),
name,
workspaceId: h.resources.userWorkspace.id,
})),
);

const root = await h.createRootKey([`api.${h.resources.userApi.id}.create_key`]);

const res = await h.post<V1KeysCreateKeyRequest, V1KeysCreateKeyResponse>({
url: "/v1/keys.createKey",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${root.key}`,
},
body: {
apiId: h.resources.userApi.id,
roles,
},
});

expect(res.status).toEqual(200);

const key = await db.query.keys.findFirst({
where: (table, { eq }) => eq(table.id, res.body.keyId),
with: {
roles: {
with: {
role: true,
},
},
},
});
expect(key).toBeDefined();
expect(key!.roles.length).toBe(2);
for (const r of key!.roles!) {
expect(roles).include(r.role.name);
}
});
});
6 changes: 6 additions & 0 deletions apps/api/src/routes/v1_keys_deleteKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ export const registerV1KeysDeleteKey = (app: App) =>
permission: true,
},
},
roles: {
with: {
role: true,
},
},
keyAuth: {
with: {
api: true,
Expand All @@ -77,6 +82,7 @@ export const registerV1KeysDeleteKey = (app: App) =>
key: dbRes,
api: dbRes.keyAuth.api,
permissions: dbRes.permissions.map((p) => p.permission.name),
roles: dbRes.roles.map((r) => r.role.name),
};
});

Expand Down
6 changes: 4 additions & 2 deletions apps/api/src/routes/v1_keys_getKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const registerV1KeysGetKey = (app: App) =>
where: (table, { eq, and, isNull }) => and(eq(table.id, keyId), isNull(table.deletedAt)),
with: {
permissions: { with: { permission: true } },
roles: { with: { role: true } },
keyAuth: {
with: {
api: true,
Expand All @@ -59,6 +60,7 @@ export const registerV1KeysGetKey = (app: App) =>
key: dbRes,
api: dbRes.keyAuth.api,
permissions: dbRes.permissions.map((p) => p.permission.name),
roles: dbRes.roles.map((p) => p.role.name),
};
});

Expand Down Expand Up @@ -114,8 +116,8 @@ export const registerV1KeysGetKey = (app: App) =>
refillInterval: data.key.ratelimitRefillInterval,
}
: undefined,
roles: [],
// roles: data.key.roles?.map((r) => r.role) ?? undefined,
roles: data.roles,
permissions: data.permissions,
enabled: data.key.enabled,
});
});
Loading

0 comments on commit dc4ec69

Please sign in to comment.