diff --git a/.changeset/witty-spoons-flow.md b/.changeset/witty-spoons-flow.md new file mode 100644 index 0000000000..3195287c57 --- /dev/null +++ b/.changeset/witty-spoons-flow.md @@ -0,0 +1,5 @@ +--- +"@unkey/api": minor +--- + +feat: add permissions diff --git a/apps/api/src/routes/v1_keys_createKey.happy.test.ts b/apps/api/src/routes/v1_keys_createKey.happy.test.ts index aac372967f..226b555218 100644 --- a/apps/api/src/routes/v1_keys_createKey.happy.test.ts +++ b/apps/api/src/routes/v1_keys_createKey.happy.test.ts @@ -222,6 +222,45 @@ describe("roles", () => { expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + const key = await h.db.primary.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); + } + }); + test("upserts the specified roles", async (t) => { + const h = await IntegrationHarness.init(t); + const roles = ["r1", "r2"]; + + const root = await h.createRootKey([ + `api.${h.resources.userApi.id}.create_key`, + "rbac.*.create_role", + ]); + + const res = await h.post({ + url: "/v1/keys.createKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + apiId: h.resources.userApi.id, + roles, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + const key = await h.db.primary.query.keys.findFirst({ where: (table, { eq }) => eq(table.id, res.body.keyId), with: { @@ -286,6 +325,45 @@ describe("permissions", () => { }); }); +test("upserts the specified permissions", async (t) => { + const h = await IntegrationHarness.init(t); + const permissions = ["p1", "p2"]; + + const root = await h.createRootKey([ + `api.${h.resources.userApi.id}.create_key`, + "rbac.*.create_permission", + ]); + + const res = await h.post({ + url: "/v1/keys.createKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + apiId: h.resources.userApi.id, + permissions, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + + const key = await h.db.primary.query.keys.findFirst({ + where: (table, { eq }) => eq(table.id, res.body.keyId), + with: { + permissions: { + with: { + permission: true, + }, + }, + }, + }); + expect(key).toBeDefined(); + expect(key!.permissions.length).toBe(2); + for (const p of key!.permissions!) { + expect(permissions).include(p.permission.name); + } +}); describe("with encryption", () => { test("encrypts a key", async (t) => { const h = await IntegrationHarness.init(t); diff --git a/apps/api/src/routes/v1_keys_createKey.security.test.ts b/apps/api/src/routes/v1_keys_createKey.security.test.ts index 4d37e873ef..a376b768e4 100644 --- a/apps/api/src/routes/v1_keys_createKey.security.test.ts +++ b/apps/api/src/routes/v1_keys_createKey.security.test.ts @@ -109,3 +109,59 @@ test("cannot encrypt without permissions", async (t) => { t.expect(res.status, `expected 403, received: ${JSON.stringify(res, null, 2)}`).toBe(403); t.expect(res.body.error.code).toEqual("INSUFFICIENT_PERMISSIONS"); }); + +test("cannot create role without permissions", async (t) => { + const h = await IntegrationHarness.init(t); + + await h.db.primary + .update(schema.keyAuth) + .set({ + storeEncryptedKeys: true, + }) + .where(eq(schema.keyAuth.id, h.resources.userKeyAuth.id)); + + const root = await h.createRootKey([`api.${h.resources.userApi.id}.create_key`]); + + const res = await h.post({ + url: "/v1/keys.createKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + apiId: h.resources.userApi.id, + roles: ["r1"], + }, + }); + + t.expect(res.status, `expected 403, received: ${JSON.stringify(res, null, 2)}`).toBe(403); + t.expect(res.body.error.code).toEqual("INSUFFICIENT_PERMISSIONS"); +}); + +test("cannot create permission without permissions", async (t) => { + const h = await IntegrationHarness.init(t); + + await h.db.primary + .update(schema.keyAuth) + .set({ + storeEncryptedKeys: true, + }) + .where(eq(schema.keyAuth.id, h.resources.userKeyAuth.id)); + + const root = await h.createRootKey([`api.${h.resources.userApi.id}.create_key`]); + + const res = await h.post({ + url: "/v1/keys.createKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + apiId: h.resources.userApi.id, + permissions: ["p1"], + }, + }); + + t.expect(res.status, `expected 403, received: ${JSON.stringify(res, null, 2)}`).toBe(403); + t.expect(res.body.error.code).toEqual("INSUFFICIENT_PERMISSIONS"); +}); diff --git a/apps/api/src/routes/v1_keys_createKey.ts b/apps/api/src/routes/v1_keys_createKey.ts index 80d57c0dea..3b66c3875c 100644 --- a/apps/api/src/routes/v1_keys_createKey.ts +++ b/apps/api/src/routes/v1_keys_createKey.ts @@ -10,7 +10,7 @@ import { schema } from "@unkey/db"; import { sha256 } from "@unkey/hash"; import { newId } from "@unkey/id"; import { KeyV1 } from "@unkey/keys"; -import { buildUnkeyQuery } from "@unkey/rbac"; +import { type RBAC, buildUnkeyQuery } from "@unkey/rbac"; const route = createRoute({ tags: ["keys"], @@ -336,8 +336,8 @@ export const registerV1KeysCreateKey = (app: App) => const externalId = req.externalId ?? req.ownerId; const [permissionIds, roleIds, identity] = await Promise.all([ - getPermissionIds(db.readonly, authorizedWorkspaceId, req.permissions ?? []), - getRoleIds(db.readonly, authorizedWorkspaceId, req.roles ?? []), + getPermissionIds(auth, rbac, db.readonly, authorizedWorkspaceId, req.permissions ?? []), + getRoleIds(auth, rbac, db.readonly, authorizedWorkspaceId, req.roles ?? []), externalId ? upsertIdentity(db.primary, authorizedWorkspaceId, externalId) : Promise.resolve(null), @@ -536,6 +536,8 @@ export const registerV1KeysCreateKey = (app: App) => }); async function getPermissionIds( + auth: { permissions: Array }, + rbac: RBAC, db: Database, workspaceId: string, permissionNames: Array, @@ -551,21 +553,42 @@ async function getPermissionIds( name: true, }, }); - if (permissions.length < permissionNames.length) { - const missingPermissions = permissionNames.filter( - (name) => !permissions.some((permission) => permission.name === name), - ); + if (permissions.length === permissionNames.length) { + return permissions.map((r) => r.id); + } + + const { val, err } = rbac.evaluatePermissions( + buildUnkeyQuery(({ or }) => or("*", "rbac.*.create_permission")), + auth.permissions, + ); + if (err) { throw new UnkeyApiError({ - code: "PRECONDITION_FAILED", - message: `Permissions ${JSON.stringify( - missingPermissions, - )} are missing, please create them first`, + code: "INTERNAL_SERVER_ERROR", + message: `Failed to evaluate permissions: ${err.message}`, }); } - return permissions.map((r) => r.id); + if (!val.valid) { + throw new UnkeyApiError({ code: "INSUFFICIENT_PERMISSIONS", message: val.message }); + } + + const missingPermissions = permissionNames.filter( + (name) => !permissions.some((permission) => permission.name === name), + ); + + const newPermissions = missingPermissions.map((name) => ({ + id: newId("permission"), + workspaceId, + name, + })); + + await db.insert(schema.permissions).values(newPermissions); + + return [...permissions, ...newPermissions].map((permission) => permission.id); } async function getRoleIds( + auth: { permissions: Array }, + rbac: RBAC, db: Database, workspaceId: string, roleNames: Array, @@ -581,14 +604,35 @@ async function getRoleIds( name: true, }, }); - if (roles.length < roleNames.length) { - const missingRoles = roleNames.filter((name) => !roles.some((role) => role.name === name)); + if (roles.length === roleNames.length) { + return roles.map((r) => r.id); + } + + const { val, err } = rbac.evaluatePermissions( + buildUnkeyQuery(({ or }) => or("*", "rbac.*.create_role")), + auth.permissions, + ); + if (err) { throw new UnkeyApiError({ - code: "PRECONDITION_FAILED", - message: `Roles ${JSON.stringify(missingRoles)} are missing, please create them first`, + code: "INTERNAL_SERVER_ERROR", + message: `Failed to evaluate permissions: ${err.message}`, }); } - return roles.map((r) => r.id); + if (!val.valid) { + throw new UnkeyApiError({ code: "INSUFFICIENT_PERMISSIONS", message: val.message }); + } + + const missingRoles = roleNames.filter((name) => !roles.some((role) => role.name === name)); + + const newRoles = missingRoles.map((name) => ({ + id: newId("role"), + workspaceId, + name, + })); + + await db.insert(schema.roles).values(newRoles); + + return [...roles, ...newRoles].map((role) => role.id); } export async function upsertIdentity( diff --git a/packages/api/src/client.ts b/packages/api/src/client.ts index fbb7ecac55..dda18e504d 100644 --- a/packages/api/src/client.ts +++ b/packages/api/src/client.ts @@ -227,6 +227,84 @@ export class Unkey { body: req, }); }, + addPermissions: async ( + req: paths["/v1/keys.addPermissions"]["post"]["requestBody"]["content"]["application/json"], + ): Promise< + Result< + paths["/v1/keys.addPermissions"]["post"]["responses"]["200"]["content"]["application/json"] + > + > => { + return await this.fetch({ + path: ["v1", "keys.addPermissions"], + method: "POST", + body: req, + }); + }, + setPermissions: async ( + req: paths["/v1/keys.setPermissions"]["post"]["requestBody"]["content"]["application/json"], + ): Promise< + Result< + paths["/v1/keys.setPermissions"]["post"]["responses"]["200"]["content"]["application/json"] + > + > => { + return await this.fetch({ + path: ["v1", "keys.setPermissions"], + method: "POST", + body: req, + }); + }, + removePermissions: async ( + req: paths["/v1/keys.removePermissions"]["post"]["requestBody"]["content"]["application/json"], + ): Promise< + Result< + paths["/v1/keys.removePermissions"]["post"]["responses"]["200"]["content"]["application/json"] + > + > => { + return await this.fetch({ + path: ["v1", "keys.removePermissions"], + method: "POST", + body: req, + }); + }, + addRoles: async ( + req: paths["/v1/keys.addRoles"]["post"]["requestBody"]["content"]["application/json"], + ): Promise< + Result< + paths["/v1/keys.addRoles"]["post"]["responses"]["200"]["content"]["application/json"] + > + > => { + return await this.fetch({ + path: ["v1", "keys.addRoles"], + method: "POST", + body: req, + }); + }, + removeRoles: async ( + req: paths["/v1/keys.removeRoles"]["post"]["requestBody"]["content"]["application/json"], + ): Promise< + Result< + paths["/v1/keys.removeRoles"]["post"]["responses"]["200"]["content"]["application/json"] + > + > => { + return await this.fetch({ + path: ["v1", "keys.removeRoles"], + method: "POST", + body: req, + }); + }, + setRoles: async ( + req: paths["/v1/keys.setRoles"]["post"]["requestBody"]["content"]["application/json"], + ): Promise< + Result< + paths["/v1/keys.setRoles"]["post"]["responses"]["200"]["content"]["application/json"] + > + > => { + return await this.fetch({ + path: ["v1", "keys.setRoles"], + method: "POST", + body: req, + }); + }, update: async ( req: paths["/v1/keys.updateKey"]["post"]["requestBody"]["content"]["application/json"], ): Promise< @@ -361,6 +439,88 @@ export class Unkey { }, }; } + public get permissions() { + return { + createRole: async ( + req: paths["/v1/permissions.createRole"]["post"]["requestBody"]["content"]["application/json"], + ): Promise< + Result< + paths["/v1/permissions.createRole"]["post"]["responses"]["200"]["content"]["application/json"] + > + > => { + return await this.fetch({ + path: ["v1", "permissions.createRole"], + method: "POST", + body: req, + }); + }, + getRole: async ( + req: paths["/v1/permissions.getRole"]["get"]["parameters"]["query"], + ): Promise< + Result< + paths["/v1/permissions.getRole"]["get"]["responses"]["200"]["content"]["application/json"] + > + > => { + return await this.fetch({ + path: ["v1", "permissions.getRole"], + method: "GET", + query: req, + }); + }, + deleteRole: async ( + req: paths["/v1/permissions.deleteRole"]["post"]["requestBody"]["content"]["application/json"], + ): Promise< + Result< + paths["/v1/permissions.deleteRole"]["post"]["responses"]["200"]["content"]["application/json"] + > + > => { + return await this.fetch({ + path: ["v1", "permissions.deleteRole"], + method: "POST", + body: req, + }); + }, + createPermission: async ( + req: paths["/v1/permissions.createPermission"]["post"]["requestBody"]["content"]["application/json"], + ): Promise< + Result< + paths["/v1/permissions.createPermission"]["post"]["responses"]["200"]["content"]["application/json"] + > + > => { + return await this.fetch({ + path: ["v1", "permissions.createPermission"], + method: "POST", + body: req, + }); + }, + getPermission: async ( + req: paths["/v1/permissions.getPermission"]["get"]["parameters"]["query"], + ): Promise< + Result< + paths["/v1/permissions.getPermission"]["get"]["responses"]["200"]["content"]["application/json"] + > + > => { + return await this.fetch({ + path: ["v1", "permissions.getPermission"], + method: "GET", + query: req, + }); + }, + deletePermission: async ( + req: paths["/v1/permissions.deletePermission"]["post"]["requestBody"]["content"]["application/json"], + ): Promise< + Result< + paths["/v1/permissions.deletePermission"]["post"]["responses"]["200"]["content"]["application/json"] + > + > => { + return await this.fetch({ + path: ["v1", "permissions.deletePermission"], + method: "POST", + body: req, + }); + }, + }; + } public get ratelimits() { return { limit: async (