feat(api): Overhaul Role API, add ability to edit roles and assign/unassign them from any user

This commit is contained in:
Jesse Wierzbinski 2024-11-26 15:27:39 +01:00
parent 7431c1e21d
commit 49c53de99e
No known key found for this signature in database
10 changed files with 926 additions and 153 deletions

View file

@ -0,0 +1,164 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Role } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { fakeRequest, getTestUsers } from "~/tests/utils";
const { users, tokens, deleteUsers } = await getTestUsers(2);
let role: Role;
let higherPriorityRole: Role;
beforeAll(async () => {
// Create new role
role = await Role.insert({
name: "test",
permissions: [RolePermissions.ManageRoles],
priority: 2,
description: "test",
visible: true,
icon: "test",
});
expect(role).toBeDefined();
await role.linkUser(users[0].id);
// Create a role with higher priority than the user's role
higherPriorityRole = await Role.insert({
name: "higherPriorityRole",
permissions: [RolePermissions.ManageRoles],
priority: 3, // Higher priority than the user's role
description: "Higher priority role",
visible: true,
icon: "higherPriorityRole",
});
expect(higherPriorityRole).toBeDefined();
});
afterAll(async () => {
await role.delete();
await higherPriorityRole.delete();
await deleteUsers();
});
// /api/v1/accounts/:id/roles/:role_id
describe("/api/v1/accounts/:id/roles/:role_id", () => {
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/roles/${role.id}`,
{
method: "POST",
},
);
expect(response.status).toBe(401);
});
test("should return 404 if role does not exist", async () => {
const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/roles/00000000-0000-0000-0000-000000000000`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(404);
});
test("should return 404 if user does not exist", async () => {
const response = await fakeRequest(
`/api/v1/accounts/00000000-0000-0000-0000-000000000000/roles/${role.id}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(404);
});
test("should assign role to user", async () => {
const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/roles/${role.id}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(204);
// Check if role was assigned
const userRoles = await Role.getUserRoles(users[1].id, false);
expect(userRoles).toContainEqual(
expect.objectContaining({ id: role.id }),
);
});
test("should return 403 if user tries to assign role with higher priority", async () => {
const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/roles/${higherPriorityRole.id}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(403);
const output = await response.json();
expect(output).toMatchObject({
error: `Cannot assign role 'higherPriorityRole' with priority 3 to user: your highest role priority is 2`,
});
});
test("should remove role from user", async () => {
const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/roles/${role.id}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(204);
// Check if role was removed
const userRoles = await Role.getUserRoles(users[1].id, false);
expect(userRoles).not.toContainEqual(
expect.objectContaining({ id: role.id }),
);
});
test("should return 403 if user tries to remove role with higher priority", async () => {
await higherPriorityRole.linkUser(users[1].id);
const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/roles/${higherPriorityRole.id}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(403);
const output = await response.json();
expect(output).toMatchObject({
error: `Cannot remove role 'higherPriorityRole' with priority 3 from user: your highest role priority is 2`,
});
await higherPriorityRole.unlinkUser(users[1].id);
});
});

View file

@ -0,0 +1,191 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Role, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
auth: {
required: true,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/api/v1/accounts/:id/roles/:role_id",
permissions: {
required: [],
methodOverrides: {
POST: [RolePermissions.ManageRoles],
DELETE: [RolePermissions.ManageRoles],
},
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
role_id: z.string().uuid(),
}),
};
const routePost = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/roles/{role_id}",
summary: "Assign role to user",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
204: {
description: "Role assigned",
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User or role not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
403: {
description: "Forbidden",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
const routeDelete = createRoute({
method: "delete",
path: "/api/v1/accounts/{id}/roles/{role_id}",
summary: "Remove role from user",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
204: {
description: "Role removed",
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User or role not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
403: {
description: "Forbidden",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) => {
app.openapi(routePost, async (context) => {
const { user } = context.get("auth");
const { id, role_id } = context.req.valid("param");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const targetUser = await User.fromId(id);
const role = await Role.fromId(role_id);
if (!role) {
return context.json({ error: "Role not found" }, 404);
}
if (!targetUser) {
return context.json({ error: "User not found" }, 404);
}
// Priority check
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
const userHighestRole = userRoles.reduce((prev, current) =>
prev.data.priority > current.data.priority ? prev : current,
);
if (role.data.priority > userHighestRole.data.priority) {
return context.json(
{
error: `Cannot assign role '${role.data.name}' with priority ${role.data.priority} to user: your highest role priority is ${userHighestRole.data.priority}`,
},
403,
);
}
await role.linkUser(targetUser.id);
return context.newResponse(null, 204);
});
app.openapi(routeDelete, async (context) => {
const { user } = context.get("auth");
const { id, role_id } = context.req.valid("param");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const targetUser = await User.fromId(id);
const role = await Role.fromId(role_id);
if (!role) {
return context.json({ error: "Role not found" }, 404);
}
if (!targetUser) {
return context.json({ error: "User not found" }, 404);
}
// Priority check
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
const userHighestRole = userRoles.reduce((prev, current) =>
prev.data.priority > current.data.priority ? prev : current,
);
if (role.data.priority > userHighestRole.data.priority) {
return context.json(
{
error: `Cannot remove role '${role.data.name}' with priority ${role.data.priority} from user: your highest role priority is ${userHighestRole.data.priority}`,
},
403,
);
}
await role.unlinkUser(targetUser.id);
return context.newResponse(null, 204);
});
});

View file

@ -0,0 +1,72 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Role } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { fakeRequest, getTestUsers } from "~/tests/utils";
const { users, tokens, deleteUsers } = await getTestUsers(2);
let role: Role;
beforeAll(async () => {
// Create new role
role = await Role.insert({
name: "test",
permissions: [RolePermissions.ManageRoles],
priority: 2,
description: "test",
visible: true,
icon: "test",
});
expect(role).toBeDefined();
await role.linkUser(users[0].id);
});
afterAll(async () => {
await role.delete();
await deleteUsers();
});
describe("/api/v1/accounts/:id/roles", () => {
test("should return 404 if user does not exist", async () => {
const response = await fakeRequest(
"/api/v1/accounts/00000000-0000-0000-0000-000000000000/roles",
{
method: "GET",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(404);
const output = await response.json();
expect(output).toMatchObject({
error: "User not found",
});
});
test("should return a list of roles for the user", async () => {
const response = await fakeRequest(
`/api/v1/accounts/${users[0].id}/roles`,
{
method: "GET",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.ok).toBe(true);
const roles = await response.json();
expect(roles).toContainEqual({
id: role.id,
name: "test",
permissions: [RolePermissions.ManageRoles],
priority: 2,
description: "test",
visible: true,
icon: expect.any(String),
});
});
});

View file

@ -0,0 +1,75 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Role, User } from "@versia/kit/db";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/api/v1/accounts/:id/roles",
permissions: {
required: [],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({
method: "get",
path: "/api/v1/accounts/{id}/roles",
summary: "List user roles",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "List of roles",
content: {
"application/json": {
schema: z.array(Role.schema),
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) => {
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const targetUser = await User.fromId(id);
if (!targetUser) {
return context.json({ error: "User not found" }, 404);
}
const roles = await Role.getUserRoles(
targetUser.id,
targetUser.data.isAdmin,
);
return context.json(
roles.map((role) => role.toApi()),
200,
);
});
});

View file

@ -1,23 +1,17 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Role } from "@versia/kit/db";
import {
ADMIN_ROLES,
DEFAULT_ROLES,
RolePermissions,
} from "@versia/kit/tables";
import { RolePermissions } from "@versia/kit/tables";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, tokens, deleteUsers } = await getTestUsers(1);
const { users, tokens, deleteUsers } = await getTestUsers(2);
let role: Role;
let roleNotLinked: Role;
let higherPriorityRole: Role;
beforeAll(async () => {
// Create new role
role = await Role.insert({
name: "test",
permissions: DEFAULT_ROLES,
permissions: [RolePermissions.ManageRoles],
priority: 2,
description: "test",
visible: true,
@ -26,25 +20,12 @@ beforeAll(async () => {
expect(role).toBeDefined();
// Link role to user
await role.linkUser(users[0].id);
// Create new role
roleNotLinked = await Role.insert({
name: "test2",
permissions: ADMIN_ROLES,
priority: 0,
description: "test2",
visible: true,
icon: "test2",
});
expect(roleNotLinked).toBeDefined();
// Create a role with higher priority than the user's role
higherPriorityRole = await Role.insert({
name: "higherPriorityRole",
permissions: DEFAULT_ROLES,
permissions: [RolePermissions.ManageRoles],
priority: 3, // Higher priority than the user's role
description: "Higher priority role",
visible: true,
@ -56,15 +37,14 @@ beforeAll(async () => {
afterAll(async () => {
await role.delete();
await roleNotLinked.delete();
await higherPriorityRole.delete();
await deleteUsers();
});
// /api/v1/roles/:id
describe(meta.route, () => {
describe("/api/v1/roles/:id", () => {
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(meta.route.replace(":id", role.id), {
const response = await fakeRequest(`/api/v1/roles/${role.id}`, {
method: "GET",
});
@ -73,7 +53,7 @@ describe(meta.route, () => {
test("should return 404 if role does not exist", async () => {
const response = await fakeRequest(
meta.route.replace(":id", "00000000-0000-0000-0000-000000000000"),
"/api/v1/roles/00000000-0000-0000-0000-000000000000",
{
method: "GET",
headers: {
@ -85,90 +65,141 @@ describe(meta.route, () => {
expect(response.status).toBe(404);
});
test("should return the role", async () => {
const response = await fakeRequest(meta.route.replace(":id", role.id), {
test("should return role data", async () => {
const response = await fakeRequest(`/api/v1/roles/${role.id}`, {
method: "GET",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
});
expect(response.ok).toBe(true);
const output = await response.json();
expect(output).toMatchObject({
name: "test",
permissions: DEFAULT_ROLES,
priority: 2,
description: "test",
visible: true,
expect(response.status).toBe(200);
const responseData = await response.json();
expect(responseData).toMatchObject({
id: role.id,
name: role.data.name,
permissions: role.data.permissions,
priority: role.data.priority,
description: role.data.description,
visible: role.data.visible,
icon: expect.any(String),
});
});
test("should return 403 if user does not have MANAGE_ROLES permission", async () => {
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(`/api/v1/roles/${role.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "updatedName" }),
});
expect(response.status).toBe(401);
});
test("should return 404 if role does not exist", async () => {
const response = await fakeRequest(
meta.route.replace(":id", roleNotLinked.id),
"/api/v1/roles/00000000-0000-0000-0000-000000000000",
{
method: "POST",
method: "PATCH",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "updatedName" }),
},
);
expect(response.status).toBe(404);
});
test("should update role data", async () => {
const response = await fakeRequest(`/api/v1/roles/${role.id}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "updatedName" }),
});
expect(response.status).toBe(204);
const updatedRole = await Role.fromId(role.id);
expect(updatedRole?.data.name).toBe("updatedName");
});
test("should return 403 if user tries to update role with higher priority", async () => {
const response = await fakeRequest(
`/api/v1/roles/${higherPriorityRole.id}`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "updatedName" }),
},
);
expect(response.status).toBe(403);
const output = await response.json();
expect(output).toMatchObject({
error: "You do not have the required permissions to access this route. Missing: roles",
error: `Cannot edit role 'higherPriorityRole' with priority 3: your highest role priority is 2`,
});
});
test("should assign new role", async () => {
await role.update({
permissions: [RolePermissions.ManageRoles],
test("should return 403 if user tries to update role with permissions they do not have", async () => {
const response = await fakeRequest(`/api/v1/roles/${role.id}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
permissions: [RolePermissions.Impersonate],
}),
});
expect(response.status).toBe(403);
const output = await response.json();
expect(output).toMatchObject({
error: "You cannot add or remove the following permissions you do not yourself have: impersonate",
});
});
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(`/api/v1/roles/${role.id}`, {
method: "DELETE",
});
expect(response.status).toBe(401);
});
test("should return 404 if role does not exist", async () => {
const response = await fakeRequest(
meta.route.replace(":id", roleNotLinked.id),
"/api/v1/roles/00000000-0000-0000-0000-000000000000",
{
method: "POST",
method: "DELETE",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(204);
// Check if role was assigned
const response2 = await fakeRequest("/api/v1/roles", {
method: "GET",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
expect(response.status).toBe(404);
});
expect(response2.ok).toBe(true);
const roles = await response2.json();
// The default role will still be there
expect(roles).toHaveLength(3);
expect(roles).toContainEqual({
id: roleNotLinked.id,
test("should delete role", async () => {
const newRole = await Role.insert({
name: "test2",
permissions: ADMIN_ROLES,
priority: 0,
description: "test2",
permissions: [RolePermissions.ManageRoles],
priority: 2,
description: "test",
visible: true,
icon: expect.any(String),
icon: "test",
});
await role.update({
permissions: [],
});
});
test("should unassign role", async () => {
const response = await fakeRequest(meta.route.replace(":id", role.id), {
const response = await fakeRequest(`/api/v1/roles/${newRole.id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
@ -177,38 +208,15 @@ describe(meta.route, () => {
expect(response.status).toBe(204);
// Check if role was unassigned
const response2 = await fakeRequest("/api/v1/roles", {
method: "GET",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
});
expect(response2.ok).toBe(true);
const roles = await response2.json();
// The default role will still be there
expect(roles).toHaveLength(2);
expect(roles).not.toContainEqual({
name: "test",
permissions: ADMIN_ROLES,
priority: 0,
description: "test",
visible: true,
icon: "test",
});
});
test("should return 403 if user tries to add role with higher priority", async () => {
// Add MANAGE_ROLES permission to user
await role.update({
permissions: [RolePermissions.ManageRoles],
const deletedRole = await Role.fromId(newRole.id);
expect(deletedRole).toBeNull();
});
test("should return 403 if user tries to delete role with higher priority", async () => {
const response = await fakeRequest(
meta.route.replace(":id", higherPriorityRole.id),
`/api/v1/roles/${higherPriorityRole.id}`,
{
method: "POST",
method: "DELETE",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
@ -218,11 +226,7 @@ describe(meta.route, () => {
expect(response.status).toBe(403);
const output = await response.json();
expect(output).toMatchObject({
error: "Cannot assign role 'higherPriorityRole' with priority 3 to user with highest role priority 0",
});
await role.update({
permissions: [],
error: `Cannot delete role 'higherPriorityRole' with priority 3: your highest role priority is 2`,
});
});
});

View file

@ -65,17 +65,24 @@ const routeGet = createRoute({
},
});
const routePost = createRoute({
method: "post",
const routePatch = createRoute({
method: "patch",
path: "/api/v1/roles/{id}",
summary: "Assign role to user",
summary: "Update role data",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
body: {
content: {
"application/json": {
schema: Role.schema.partial(),
},
},
},
},
responses: {
204: {
description: "Role assigned",
description: "Role updated",
},
401: {
description: "Unauthorized",
@ -85,6 +92,14 @@ const routePost = createRoute({
},
},
},
404: {
description: "Role not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
403: {
description: "Forbidden",
content: {
@ -99,14 +114,14 @@ const routePost = createRoute({
const routeDelete = createRoute({
method: "delete",
path: "/api/v1/roles/{id}",
summary: "Remove role from user",
summary: "Delete role",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
204: {
description: "Role removed",
description: "Role deleted",
},
401: {
description: "Unauthorized",
@ -116,6 +131,14 @@ const routeDelete = createRoute({
},
},
},
404: {
description: "Role not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
403: {
description: "Forbidden",
content: {
@ -145,21 +168,25 @@ export default apiRoute((app) => {
return context.json(role.toApi(), 200);
});
app.openapi(routePost, async (context) => {
app.openapi(routePatch, async (context) => {
const { user } = context.get("auth");
const { id } = context.req.valid("param");
const { permissions, priority, description, icon, name, visible } =
context.req.valid("json");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
const role = await Role.fromId(id);
if (!role) {
return context.json({ error: "Role not found" }, 404);
}
// Priority check
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
const userHighestRole = userRoles.reduce((prev, current) =>
prev.data.priority > current.data.priority ? prev : current,
);
@ -167,13 +194,37 @@ export default apiRoute((app) => {
if (role.data.priority > userHighestRole.data.priority) {
return context.json(
{
error: `Cannot assign role '${role.data.name}' with priority ${role.data.priority} to user with highest role priority ${userHighestRole.data.priority}`,
error: `Cannot edit role '${role.data.name}' with priority ${role.data.priority}: your highest role priority is ${userHighestRole.data.priority}`,
},
403,
);
}
await role.linkUser(user.id);
// If updating role permissions, the user must already have the permissions they wish to add/remove
if (permissions) {
const userPermissions = user.getAllPermissions();
const hasPermissions = (
permissions as unknown as RolePermissions[]
).every((p) => userPermissions.includes(p));
if (!hasPermissions) {
return context.json(
{
error: `You cannot add or remove the following permissions you do not yourself have: ${permissions.join(", ")}`,
},
403,
);
}
}
await role.update({
permissions: permissions as unknown as RolePermissions[],
priority,
description,
icon,
name,
visible,
});
return context.newResponse(null, 204);
});
@ -186,13 +237,15 @@ export default apiRoute((app) => {
return context.json({ error: "Unauthorized" }, 401);
}
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
const role = await Role.fromId(id);
if (!role) {
return context.json({ error: "Role not found" }, 404);
}
// Priority check
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
const userHighestRole = userRoles.reduce((prev, current) =>
prev.data.priority > current.data.priority ? prev : current,
);
@ -200,13 +253,13 @@ export default apiRoute((app) => {
if (role.data.priority > userHighestRole.data.priority) {
return context.json(
{
error: `Cannot remove role '${role.data.name}' with priority ${role.data.priority} from user with highest role priority ${userHighestRole.data.priority}`,
error: `Cannot delete role '${role.data.name}' with priority ${role.data.priority}: your highest role priority is ${userHighestRole.data.priority}`,
},
403,
);
}
await role.unlinkUser(user.id);
await role.delete();
return context.newResponse(null, 204);
});

View file

@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Role } from "@versia/kit/db";
import { ADMIN_ROLES } from "@versia/kit/tables";
import { RolePermissions } from "@versia/kit/tables";
import { config } from "~/packages/config-manager/index.ts";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
@ -12,8 +12,8 @@ beforeAll(async () => {
// Create new role
role = await Role.insert({
name: "test",
permissions: ADMIN_ROLES,
priority: 0,
permissions: [RolePermissions.ManageRoles],
priority: 10,
description: "test",
visible: true,
icon: "test",
@ -27,6 +27,7 @@ beforeAll(async () => {
afterAll(async () => {
await deleteUsers();
await role.delete();
});
// /api/v1/roles
@ -49,17 +50,18 @@ describe(meta.route, () => {
expect(response.ok).toBe(true);
const roles = await response.json();
expect(roles).toHaveLength(2);
expect(roles[0]).toMatchObject({
expect(roles).toContainEqual({
name: "test",
permissions: ADMIN_ROLES,
priority: 0,
permissions: [RolePermissions.ManageRoles],
priority: 10,
description: "test",
visible: true,
icon: expect.any(String),
id: role.id,
});
expect(roles[1]).toMatchObject({
expect(roles).toContainEqual({
id: "default",
name: "Default",
permissions: config.permissions.default,
priority: 0,
@ -68,4 +70,88 @@ describe(meta.route, () => {
icon: null,
});
});
test("should create a new role", async () => {
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "newRole",
permissions: [RolePermissions.ManageRoles],
priority: 1,
description: "newRole",
visible: true,
icon: "https://example.com/icon.png",
}),
});
expect(response.ok).toBe(true);
const newRole = await response.json();
expect(newRole).toMatchObject({
name: "newRole",
permissions: [RolePermissions.ManageRoles],
priority: 1,
description: "newRole",
visible: true,
icon: expect.any(String),
});
// Cleanup
const createdRole = await Role.fromId(newRole.id);
expect(createdRole).toBeDefined();
await createdRole?.delete();
});
test("should return 403 if user tries to create a role with higher priority", async () => {
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "newRole",
permissions: [RolePermissions.ManageBlocks],
priority: 11,
description: "newRole",
visible: true,
icon: "https://example.com/icon.png",
}),
});
expect(response.status).toBe(403);
const output = await response.json();
expect(output).toMatchObject({
error: "You cannot create a role with higher priority than your own",
});
});
test("should return 403 if user tries to create a role with permissions they do not have", async () => {
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "newRole",
permissions: [RolePermissions.Impersonate],
priority: 1,
description: "newRole",
visible: true,
icon: "https://example.com/icon.png",
}),
});
expect(response.status).toBe(403);
const output = await response.json();
expect(output).toMatchObject({
error: "You cannot create a role with the following permissions you do not yourself have: impersonate",
});
});
});

View file

@ -1,6 +1,7 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Role } from "@versia/kit/db";
import { RolePermissions } from "~/drizzle/schema";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
@ -12,16 +13,22 @@ export const meta = applyConfig({
max: 20,
},
route: "/api/v1/roles",
permissions: {
required: [],
methodOverrides: {
POST: [RolePermissions.ManageRoles],
},
},
});
const route = createRoute({
const routeGet = createRoute({
method: "get",
path: "/api/v1/roles",
summary: "Get user roles",
summary: "Get all roles",
middleware: [auth(meta.auth)],
responses: {
200: {
description: "User roles",
description: "List of all roles",
content: {
"application/json": {
schema: z.array(Role.schema),
@ -39,19 +46,115 @@ const route = createRoute({
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const routePost = createRoute({
method: "post",
path: "/api/v1/roles",
summary: "Create a new role",
middleware: [auth(meta.auth, meta.permissions)],
request: {
body: {
content: {
"application/json": {
schema: Role.schema.omit({ id: true }),
},
},
},
},
responses: {
201: {
description: "Role created",
content: {
"application/json": {
schema: Role.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
403: {
description: "Forbidden",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) => {
app.openapi(routeGet, async (context) => {
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
const roles = await Role.getAll();
return context.json(
userRoles.map((r) => r.toApi()),
roles.map((r) => r.toApi()),
200,
);
}),
});
app.openapi(routePost, async (context) => {
const { user } = context.get("auth");
const { description, icon, name, permissions, priority, visible } =
context.req.valid("json");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
// Priority check
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
const userHighestRole = userRoles.reduce((prev, current) =>
prev.data.priority > current.data.priority ? prev : current,
);
if (priority > userHighestRole.data.priority) {
return context.json(
{
error: "You cannot create a role with higher priority than your own",
},
403,
);
}
// When adding new permissions, the user must already have the permissions they wish to add
if (permissions) {
const userPermissions = user.getAllPermissions();
const hasPermissions = (
permissions as unknown as RolePermissions[]
).every((p) => userPermissions.includes(p));
if (!hasPermissions) {
return context.json(
{
error: `You cannot create a role with the following permissions you do not yourself have: ${permissions.join(", ")}`,
},
403,
);
}
}
const newRole = await Role.insert({
description,
icon,
name,
permissions: permissions as unknown as RolePermissions[],
priority,
visible,
});
return context.json(newRole.toApi(), 201);
});
});

View file

@ -22,13 +22,13 @@ type RoleType = InferSelectModel<typeof Roles>;
export class Role extends BaseInterface<typeof Roles> {
public static schema = z.object({
id: z.string(),
name: z.string(),
id: z.string().uuid(),
name: z.string().min(1).max(128),
permissions: z.array(z.nativeEnum(RolePermission)),
priority: z.number(),
description: z.string().nullable(),
priority: z.number().int(),
description: z.string().min(0).max(1024).nullable(),
visible: z.boolean(),
icon: z.string().nullable(),
icon: z.string().url().nullable(),
});
public static $type: RoleType;
@ -70,6 +70,29 @@ export class Role extends BaseInterface<typeof Roles> {
return new Role(found);
}
public static async getAll(): Promise<Role[]> {
return (await Role.manyFromSql(undefined)).concat(
new Role({
id: "default",
name: "Default",
permissions: config.permissions.default,
priority: 0,
description: "Default role for all users",
visible: false,
icon: null,
}),
new Role({
id: "admin",
name: "Admin",
permissions: config.permissions.admin,
priority: 2 ** 31 - 1,
description: "Default role for all administrators",
visible: false,
icon: null,
}),
);
}
public static async getUserRoles(
userId: string,
isAdmin: boolean,

View file

@ -314,6 +314,17 @@ export const auth = (
// Only exists for type casting, as otherwise weird errors happen with Hono
const fakeResponse = context.json({});
// Authentication check
const authCheck = checkRouteNeedsAuth(auth, authData, context) as
| typeof fakeResponse
| AuthData;
if (authCheck instanceof Response) {
return authCheck;
}
context.set("auth", authCheck);
// Permissions check
if (permissionData) {
const permissionCheck = checkPermissions(
@ -326,6 +337,7 @@ export const auth = (
}
}
// Challenge check
if (challengeData && config.validation.challenges.enabled) {
const challengeCheck = await checkRouteNeedsChallenge(
challengeData,
@ -336,16 +348,6 @@ export const auth = (
}
}
const authCheck = checkRouteNeedsAuth(auth, authData, context) as
| typeof fakeResponse
| AuthData;
if (authCheck instanceof Response) {
return authCheck;
}
context.set("auth", authCheck);
await next();
});