mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(api): ✨ Overhaul Role API, add ability to edit roles and assign/unassign them from any user
This commit is contained in:
parent
7431c1e21d
commit
49c53de99e
164
api/api/v1/accounts/:id/roles/:role_id/index.test.ts
Normal file
164
api/api/v1/accounts/:id/roles/:role_id/index.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
191
api/api/v1/accounts/:id/roles/:role_id/index.ts
Normal file
191
api/api/v1/accounts/:id/roles/:role_id/index.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
api/api/v1/accounts/:id/roles/index.test.ts
Normal file
72
api/api/v1/accounts/:id/roles/index.test.ts
Normal 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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
75
api/api/v1/accounts/:id/roles/index.ts
Normal file
75
api/api/v1/accounts/:id/roles/index.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,23 +1,17 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { Role } from "@versia/kit/db";
|
import { Role } from "@versia/kit/db";
|
||||||
import {
|
import { RolePermissions } from "@versia/kit/tables";
|
||||||
ADMIN_ROLES,
|
|
||||||
DEFAULT_ROLES,
|
|
||||||
RolePermissions,
|
|
||||||
} from "@versia/kit/tables";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
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 role: Role;
|
||||||
let roleNotLinked: Role;
|
|
||||||
let higherPriorityRole: Role;
|
let higherPriorityRole: Role;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Create new role
|
// Create new role
|
||||||
role = await Role.insert({
|
role = await Role.insert({
|
||||||
name: "test",
|
name: "test",
|
||||||
permissions: DEFAULT_ROLES,
|
permissions: [RolePermissions.ManageRoles],
|
||||||
priority: 2,
|
priority: 2,
|
||||||
description: "test",
|
description: "test",
|
||||||
visible: true,
|
visible: true,
|
||||||
|
|
@ -26,25 +20,12 @@ beforeAll(async () => {
|
||||||
|
|
||||||
expect(role).toBeDefined();
|
expect(role).toBeDefined();
|
||||||
|
|
||||||
// Link role to user
|
|
||||||
await role.linkUser(users[0].id);
|
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
|
// Create a role with higher priority than the user's role
|
||||||
higherPriorityRole = await Role.insert({
|
higherPriorityRole = await Role.insert({
|
||||||
name: "higherPriorityRole",
|
name: "higherPriorityRole",
|
||||||
permissions: DEFAULT_ROLES,
|
permissions: [RolePermissions.ManageRoles],
|
||||||
priority: 3, // Higher priority than the user's role
|
priority: 3, // Higher priority than the user's role
|
||||||
description: "Higher priority role",
|
description: "Higher priority role",
|
||||||
visible: true,
|
visible: true,
|
||||||
|
|
@ -56,15 +37,14 @@ beforeAll(async () => {
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await role.delete();
|
await role.delete();
|
||||||
await roleNotLinked.delete();
|
|
||||||
await higherPriorityRole.delete();
|
await higherPriorityRole.delete();
|
||||||
await deleteUsers();
|
await deleteUsers();
|
||||||
});
|
});
|
||||||
|
|
||||||
// /api/v1/roles/:id
|
// /api/v1/roles/:id
|
||||||
describe(meta.route, () => {
|
describe("/api/v1/roles/:id", () => {
|
||||||
test("should return 401 if not authenticated", async () => {
|
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",
|
method: "GET",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -73,7 +53,7 @@ describe(meta.route, () => {
|
||||||
|
|
||||||
test("should return 404 if role does not exist", async () => {
|
test("should return 404 if role does not exist", async () => {
|
||||||
const response = await fakeRequest(
|
const response = await fakeRequest(
|
||||||
meta.route.replace(":id", "00000000-0000-0000-0000-000000000000"),
|
"/api/v1/roles/00000000-0000-0000-0000-000000000000",
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -85,90 +65,141 @@ describe(meta.route, () => {
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return the role", async () => {
|
test("should return role data", async () => {
|
||||||
const response = await fakeRequest(meta.route.replace(":id", role.id), {
|
const response = await fakeRequest(`/api/v1/roles/${role.id}`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
expect(response.status).toBe(200);
|
||||||
const output = await response.json();
|
const responseData = await response.json();
|
||||||
expect(output).toMatchObject({
|
expect(responseData).toMatchObject({
|
||||||
name: "test",
|
id: role.id,
|
||||||
permissions: DEFAULT_ROLES,
|
name: role.data.name,
|
||||||
priority: 2,
|
permissions: role.data.permissions,
|
||||||
description: "test",
|
priority: role.data.priority,
|
||||||
visible: true,
|
description: role.data.description,
|
||||||
|
visible: role.data.visible,
|
||||||
icon: expect.any(String),
|
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(
|
const response = await fakeRequest(
|
||||||
meta.route.replace(":id", roleNotLinked.id),
|
"/api/v1/roles/00000000-0000-0000-0000-000000000000",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
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);
|
expect(response.status).toBe(403);
|
||||||
const output = await response.json();
|
const output = await response.json();
|
||||||
expect(output).toMatchObject({
|
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 () => {
|
test("should return 403 if user tries to update role with permissions they do not have", async () => {
|
||||||
await role.update({
|
const response = await fakeRequest(`/api/v1/roles/${role.id}`, {
|
||||||
permissions: [RolePermissions.ManageRoles],
|
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(
|
const response = await fakeRequest(
|
||||||
meta.route.replace(":id", roleNotLinked.id),
|
"/api/v1/roles/00000000-0000-0000-0000-000000000000",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(404);
|
||||||
|
|
||||||
// Check if role was assigned
|
|
||||||
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(3);
|
|
||||||
expect(roles).toContainEqual({
|
|
||||||
id: roleNotLinked.id,
|
|
||||||
name: "test2",
|
|
||||||
permissions: ADMIN_ROLES,
|
|
||||||
priority: 0,
|
|
||||||
description: "test2",
|
|
||||||
visible: true,
|
|
||||||
icon: expect.any(String),
|
|
||||||
});
|
|
||||||
|
|
||||||
await role.update({
|
|
||||||
permissions: [],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should unassign role", async () => {
|
test("should delete role", async () => {
|
||||||
const response = await fakeRequest(meta.route.replace(":id", role.id), {
|
const newRole = await Role.insert({
|
||||||
|
name: "test2",
|
||||||
|
permissions: [RolePermissions.ManageRoles],
|
||||||
|
priority: 2,
|
||||||
|
description: "test",
|
||||||
|
visible: true,
|
||||||
|
icon: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fakeRequest(`/api/v1/roles/${newRole.id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||||
|
|
@ -177,38 +208,15 @@ describe(meta.route, () => {
|
||||||
|
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
|
|
||||||
// Check if role was unassigned
|
const deletedRole = await Role.fromId(newRole.id);
|
||||||
const response2 = await fakeRequest("/api/v1/roles", {
|
expect(deletedRole).toBeNull();
|
||||||
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 () => {
|
test("should return 403 if user tries to delete role with higher priority", async () => {
|
||||||
// Add MANAGE_ROLES permission to user
|
|
||||||
await role.update({
|
|
||||||
permissions: [RolePermissions.ManageRoles],
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fakeRequest(
|
const response = await fakeRequest(
|
||||||
meta.route.replace(":id", higherPriorityRole.id),
|
`/api/v1/roles/${higherPriorityRole.id}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||||
},
|
},
|
||||||
|
|
@ -218,11 +226,7 @@ describe(meta.route, () => {
|
||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
const output = await response.json();
|
const output = await response.json();
|
||||||
expect(output).toMatchObject({
|
expect(output).toMatchObject({
|
||||||
error: "Cannot assign role 'higherPriorityRole' with priority 3 to user with highest role priority 0",
|
error: `Cannot delete role 'higherPriorityRole' with priority 3: your highest role priority is 2`,
|
||||||
});
|
|
||||||
|
|
||||||
await role.update({
|
|
||||||
permissions: [],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -65,17 +65,24 @@ const routeGet = createRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const routePost = createRoute({
|
const routePatch = createRoute({
|
||||||
method: "post",
|
method: "patch",
|
||||||
path: "/api/v1/roles/{id}",
|
path: "/api/v1/roles/{id}",
|
||||||
summary: "Assign role to user",
|
summary: "Update role data",
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
middleware: [auth(meta.auth, meta.permissions)],
|
||||||
request: {
|
request: {
|
||||||
params: schemas.param,
|
params: schemas.param,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: Role.schema.partial(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
204: {
|
204: {
|
||||||
description: "Role assigned",
|
description: "Role updated",
|
||||||
},
|
},
|
||||||
401: {
|
401: {
|
||||||
description: "Unauthorized",
|
description: "Unauthorized",
|
||||||
|
|
@ -85,6 +92,14 @@ const routePost = createRoute({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
404: {
|
||||||
|
description: "Role not found",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
403: {
|
403: {
|
||||||
description: "Forbidden",
|
description: "Forbidden",
|
||||||
content: {
|
content: {
|
||||||
|
|
@ -99,14 +114,14 @@ const routePost = createRoute({
|
||||||
const routeDelete = createRoute({
|
const routeDelete = createRoute({
|
||||||
method: "delete",
|
method: "delete",
|
||||||
path: "/api/v1/roles/{id}",
|
path: "/api/v1/roles/{id}",
|
||||||
summary: "Remove role from user",
|
summary: "Delete role",
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
middleware: [auth(meta.auth, meta.permissions)],
|
||||||
request: {
|
request: {
|
||||||
params: schemas.param,
|
params: schemas.param,
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
204: {
|
204: {
|
||||||
description: "Role removed",
|
description: "Role deleted",
|
||||||
},
|
},
|
||||||
401: {
|
401: {
|
||||||
description: "Unauthorized",
|
description: "Unauthorized",
|
||||||
|
|
@ -116,6 +131,14 @@ const routeDelete = createRoute({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
404: {
|
||||||
|
description: "Role not found",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
403: {
|
403: {
|
||||||
description: "Forbidden",
|
description: "Forbidden",
|
||||||
content: {
|
content: {
|
||||||
|
|
@ -145,21 +168,25 @@ export default apiRoute((app) => {
|
||||||
return context.json(role.toApi(), 200);
|
return context.json(role.toApi(), 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.openapi(routePost, async (context) => {
|
app.openapi(routePatch, async (context) => {
|
||||||
const { user } = context.get("auth");
|
const { user } = context.get("auth");
|
||||||
const { id } = context.req.valid("param");
|
const { id } = context.req.valid("param");
|
||||||
|
const { permissions, priority, description, icon, name, visible } =
|
||||||
|
context.req.valid("json");
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
return context.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
|
|
||||||
const role = await Role.fromId(id);
|
const role = await Role.fromId(id);
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
return context.json({ error: "Role not found" }, 404);
|
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) =>
|
const userHighestRole = userRoles.reduce((prev, current) =>
|
||||||
prev.data.priority > current.data.priority ? 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) {
|
if (role.data.priority > userHighestRole.data.priority) {
|
||||||
return context.json(
|
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,
|
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);
|
return context.newResponse(null, 204);
|
||||||
});
|
});
|
||||||
|
|
@ -186,13 +237,15 @@ export default apiRoute((app) => {
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
return context.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
|
|
||||||
const role = await Role.fromId(id);
|
const role = await Role.fromId(id);
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
return context.json({ error: "Role not found" }, 404);
|
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) =>
|
const userHighestRole = userRoles.reduce((prev, current) =>
|
||||||
prev.data.priority > current.data.priority ? 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) {
|
if (role.data.priority > userHighestRole.data.priority) {
|
||||||
return context.json(
|
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,
|
403,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await role.unlinkUser(user.id);
|
await role.delete();
|
||||||
|
|
||||||
return context.newResponse(null, 204);
|
return context.newResponse(null, 204);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { Role } from "@versia/kit/db";
|
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 { config } from "~/packages/config-manager/index.ts";
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
||||||
import { meta } from "./index.ts";
|
import { meta } from "./index.ts";
|
||||||
|
|
@ -12,8 +12,8 @@ beforeAll(async () => {
|
||||||
// Create new role
|
// Create new role
|
||||||
role = await Role.insert({
|
role = await Role.insert({
|
||||||
name: "test",
|
name: "test",
|
||||||
permissions: ADMIN_ROLES,
|
permissions: [RolePermissions.ManageRoles],
|
||||||
priority: 0,
|
priority: 10,
|
||||||
description: "test",
|
description: "test",
|
||||||
visible: true,
|
visible: true,
|
||||||
icon: "test",
|
icon: "test",
|
||||||
|
|
@ -27,6 +27,7 @@ beforeAll(async () => {
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await deleteUsers();
|
await deleteUsers();
|
||||||
|
await role.delete();
|
||||||
});
|
});
|
||||||
|
|
||||||
// /api/v1/roles
|
// /api/v1/roles
|
||||||
|
|
@ -49,17 +50,18 @@ describe(meta.route, () => {
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
expect(response.ok).toBe(true);
|
||||||
const roles = await response.json();
|
const roles = await response.json();
|
||||||
expect(roles).toHaveLength(2);
|
expect(roles).toContainEqual({
|
||||||
expect(roles[0]).toMatchObject({
|
|
||||||
name: "test",
|
name: "test",
|
||||||
permissions: ADMIN_ROLES,
|
permissions: [RolePermissions.ManageRoles],
|
||||||
priority: 0,
|
priority: 10,
|
||||||
description: "test",
|
description: "test",
|
||||||
visible: true,
|
visible: true,
|
||||||
icon: expect.any(String),
|
icon: expect.any(String),
|
||||||
|
id: role.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(roles[1]).toMatchObject({
|
expect(roles).toContainEqual({
|
||||||
|
id: "default",
|
||||||
name: "Default",
|
name: "Default",
|
||||||
permissions: config.permissions.default,
|
permissions: config.permissions.default,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
|
@ -68,4 +70,88 @@ describe(meta.route, () => {
|
||||||
icon: null,
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
import { apiRoute, applyConfig, auth } from "@/api";
|
||||||
import { createRoute, z } from "@hono/zod-openapi";
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
import { Role } from "@versia/kit/db";
|
import { Role } from "@versia/kit/db";
|
||||||
|
import { RolePermissions } from "~/drizzle/schema";
|
||||||
import { ErrorSchema } from "~/types/api";
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -12,16 +13,22 @@ export const meta = applyConfig({
|
||||||
max: 20,
|
max: 20,
|
||||||
},
|
},
|
||||||
route: "/api/v1/roles",
|
route: "/api/v1/roles",
|
||||||
|
permissions: {
|
||||||
|
required: [],
|
||||||
|
methodOverrides: {
|
||||||
|
POST: [RolePermissions.ManageRoles],
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = createRoute({
|
const routeGet = createRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/api/v1/roles",
|
path: "/api/v1/roles",
|
||||||
summary: "Get user roles",
|
summary: "Get all roles",
|
||||||
middleware: [auth(meta.auth)],
|
middleware: [auth(meta.auth)],
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "User roles",
|
description: "List of all roles",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: z.array(Role.schema),
|
schema: z.array(Role.schema),
|
||||||
|
|
@ -39,19 +46,115 @@ const route = createRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
const routePost = createRoute({
|
||||||
app.openapi(route, async (context) => {
|
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");
|
const { user } = context.get("auth");
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
return context.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
|
const roles = await Role.getAll();
|
||||||
|
|
||||||
return context.json(
|
return context.json(
|
||||||
userRoles.map((r) => r.toApi()),
|
roles.map((r) => r.toApi()),
|
||||||
200,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,13 @@ type RoleType = InferSelectModel<typeof Roles>;
|
||||||
|
|
||||||
export class Role extends BaseInterface<typeof Roles> {
|
export class Role extends BaseInterface<typeof Roles> {
|
||||||
public static schema = z.object({
|
public static schema = z.object({
|
||||||
id: z.string(),
|
id: z.string().uuid(),
|
||||||
name: z.string(),
|
name: z.string().min(1).max(128),
|
||||||
permissions: z.array(z.nativeEnum(RolePermission)),
|
permissions: z.array(z.nativeEnum(RolePermission)),
|
||||||
priority: z.number(),
|
priority: z.number().int(),
|
||||||
description: z.string().nullable(),
|
description: z.string().min(0).max(1024).nullable(),
|
||||||
visible: z.boolean(),
|
visible: z.boolean(),
|
||||||
icon: z.string().nullable(),
|
icon: z.string().url().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
public static $type: RoleType;
|
public static $type: RoleType;
|
||||||
|
|
@ -70,6 +70,29 @@ export class Role extends BaseInterface<typeof Roles> {
|
||||||
return new Role(found);
|
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(
|
public static async getUserRoles(
|
||||||
userId: string,
|
userId: string,
|
||||||
isAdmin: boolean,
|
isAdmin: boolean,
|
||||||
|
|
|
||||||
22
utils/api.ts
22
utils/api.ts
|
|
@ -314,6 +314,17 @@ export const auth = (
|
||||||
// Only exists for type casting, as otherwise weird errors happen with Hono
|
// Only exists for type casting, as otherwise weird errors happen with Hono
|
||||||
const fakeResponse = context.json({});
|
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
|
// Permissions check
|
||||||
if (permissionData) {
|
if (permissionData) {
|
||||||
const permissionCheck = checkPermissions(
|
const permissionCheck = checkPermissions(
|
||||||
|
|
@ -326,6 +337,7 @@ export const auth = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Challenge check
|
||||||
if (challengeData && config.validation.challenges.enabled) {
|
if (challengeData && config.validation.challenges.enabled) {
|
||||||
const challengeCheck = await checkRouteNeedsChallenge(
|
const challengeCheck = await checkRouteNeedsChallenge(
|
||||||
challengeData,
|
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();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue