From fd0e98d9c3c57fc0a8c17b896d46b6de6ab95324 Mon Sep 17 00:00:00 2001 From: Bernd Storath <32197462+kaaax0815@users.noreply.github.com> Date: Tue, 11 Feb 2025 13:00:50 +0100 Subject: [PATCH] Feat: Permission System (#1660) * wip: add abac * wip: add admin abac * add me abac * fix type issue * move from role check avoid authStore.userData?.role === roles.ADMIN --- src/app/components/ui/UserMenu.vue | 7 +- src/app/middleware/auth.global.ts | 2 +- src/server/api/admin/general.get.ts | 2 +- src/server/api/admin/general.post.ts | 3 +- src/server/api/admin/hooks.get.ts | 2 +- src/server/api/admin/hooks.post.ts | 3 +- src/server/api/admin/interface/cidr.post.ts | 3 +- src/server/api/admin/interface/index.get.ts | 2 +- src/server/api/admin/interface/index.post.ts | 3 +- src/server/api/admin/userconfig.get.ts | 2 +- src/server/api/admin/userconfig.post.ts | 3 +- .../client/[clientId]/configuration.get.ts | 10 +- .../api/client/[clientId]/disable.post.ts | 9 +- .../api/client/[clientId]/enable.post.ts | 9 +- .../[clientId]/generateOneTimeLink.post.ts | 9 +- .../api/client/[clientId]/index.delete.ts | 9 +- src/server/api/client/[clientId]/index.get.ts | 10 +- .../api/client/[clientId]/index.post.ts | 8 +- .../api/client/[clientId]/qrcode.svg.get.ts | 9 +- src/server/api/client/index.get.ts | 7 +- src/server/api/client/index.post.ts | 4 +- src/server/api/me/index.post.ts | 3 +- src/server/api/me/password.post.ts | 3 +- src/server/api/session.get.ts | 1 + src/server/api/session.post.ts | 4 +- src/server/api/wireguard/backup.get.ts | 9 - src/server/api/wireguard/restore.put.ts | 8 - .../database/migrations/0000_short_skin.sql | 16 +- .../migrations/0001_classy_the_stranger.sql | 2 +- .../migrations/meta/0000_snapshot.json | 51 ++++-- .../migrations/meta/0001_snapshot.json | 53 ++++-- .../database/migrations/meta/_journal.json | 4 +- .../database/repositories/client/schema.ts | 12 +- .../database/repositories/client/service.ts | 17 ++ .../database/repositories/client/types.ts | 2 +- .../database/repositories/general/schema.ts | 8 +- .../repositories/oneTimeLink/schema.ts | 2 +- .../database/repositories/user/schema.ts | 8 +- src/server/routes/cnf/[oneTimeLink].ts | 2 +- src/server/routes/metrics/json.get.ts | 2 +- src/server/routes/metrics/prometheus.get.ts | 2 +- src/server/utils/WireGuard.ts | 34 +++- src/server/utils/handler.ts | 51 +++++- src/shared/utils/permissions.ts | 161 ++++++++++++++++-- 44 files changed, 441 insertions(+), 130 deletions(-) delete mode 100644 src/server/api/wireguard/backup.get.ts delete mode 100644 src/server/api/wireguard/restore.put.ts diff --git a/src/app/components/ui/UserMenu.vue b/src/app/components/ui/UserMenu.vue index 31547319..77542d53 100644 --- a/src/app/components/ui/UserMenu.vue +++ b/src/app/components/ui/UserMenu.vue @@ -37,7 +37,12 @@ Account - + { // Check for admin access if (to.path.startsWith('/admin')) { - if (userData.role !== roles.ADMIN) { + if (userData && hasPermissions(userData, 'admin', 'any')) { return abortNavigation('Not allowed to access Admin Panel'); } } diff --git a/src/server/api/admin/general.get.ts b/src/server/api/admin/general.get.ts index 965cebe2..075eefce 100644 --- a/src/server/api/admin/general.get.ts +++ b/src/server/api/admin/general.get.ts @@ -1,4 +1,4 @@ -export default definePermissionEventHandler(actions.ADMIN, async () => { +export default definePermissionEventHandler('admin', 'any', async () => { const generalConfig = await Database.general.getConfig(); return generalConfig; }); diff --git a/src/server/api/admin/general.post.ts b/src/server/api/admin/general.post.ts index 4a28cf29..414af6d7 100644 --- a/src/server/api/admin/general.post.ts +++ b/src/server/api/admin/general.post.ts @@ -1,7 +1,8 @@ import { GeneralUpdateSchema } from '#db/repositories/general/types'; export default definePermissionEventHandler( - actions.ADMIN, + 'admin', + 'any', async ({ event }) => { const data = await readValidatedBody( event, diff --git a/src/server/api/admin/hooks.get.ts b/src/server/api/admin/hooks.get.ts index ec493ce0..035cc252 100644 --- a/src/server/api/admin/hooks.get.ts +++ b/src/server/api/admin/hooks.get.ts @@ -1,4 +1,4 @@ -export default definePermissionEventHandler(actions.ADMIN, async () => { +export default definePermissionEventHandler('admin', 'any', async () => { const hooks = await Database.hooks.get(); return hooks; }); diff --git a/src/server/api/admin/hooks.post.ts b/src/server/api/admin/hooks.post.ts index e3c02bea..1acb94c2 100644 --- a/src/server/api/admin/hooks.post.ts +++ b/src/server/api/admin/hooks.post.ts @@ -1,7 +1,8 @@ import { HooksUpdateSchema } from '#db/repositories/hooks/types'; export default definePermissionEventHandler( - actions.ADMIN, + 'admin', + 'any', async ({ event }) => { const data = await readValidatedBody( event, diff --git a/src/server/api/admin/interface/cidr.post.ts b/src/server/api/admin/interface/cidr.post.ts index 2d48d874..95e239cf 100644 --- a/src/server/api/admin/interface/cidr.post.ts +++ b/src/server/api/admin/interface/cidr.post.ts @@ -1,7 +1,8 @@ import { InterfaceCidrUpdateSchema } from '#db/repositories/interface/types'; export default definePermissionEventHandler( - actions.ADMIN, + 'admin', + 'any', async ({ event }) => { const data = await readValidatedBody( event, diff --git a/src/server/api/admin/interface/index.get.ts b/src/server/api/admin/interface/index.get.ts index f060637f..7161aecc 100644 --- a/src/server/api/admin/interface/index.get.ts +++ b/src/server/api/admin/interface/index.get.ts @@ -1,4 +1,4 @@ -export default definePermissionEventHandler(actions.ADMIN, async () => { +export default definePermissionEventHandler('admin', 'any', async () => { const wgInterface = await Database.interfaces.get(); return { diff --git a/src/server/api/admin/interface/index.post.ts b/src/server/api/admin/interface/index.post.ts index 24fd041a..d6beedbe 100644 --- a/src/server/api/admin/interface/index.post.ts +++ b/src/server/api/admin/interface/index.post.ts @@ -1,7 +1,8 @@ import { InterfaceUpdateSchema } from '#db/repositories/interface/types'; export default definePermissionEventHandler( - actions.ADMIN, + 'admin', + 'any', async ({ event }) => { const data = await readValidatedBody( event, diff --git a/src/server/api/admin/userconfig.get.ts b/src/server/api/admin/userconfig.get.ts index 81211580..b41a81bd 100644 --- a/src/server/api/admin/userconfig.get.ts +++ b/src/server/api/admin/userconfig.get.ts @@ -1,4 +1,4 @@ -export default definePermissionEventHandler(actions.ADMIN, async () => { +export default definePermissionEventHandler('admin', 'any', async () => { const userConfig = await Database.userConfigs.get(); return userConfig; }); diff --git a/src/server/api/admin/userconfig.post.ts b/src/server/api/admin/userconfig.post.ts index 623dd463..ff150b0c 100644 --- a/src/server/api/admin/userconfig.post.ts +++ b/src/server/api/admin/userconfig.post.ts @@ -1,7 +1,8 @@ import { UserConfigUpdateSchema } from '#db/repositories/userConfig/types'; export default definePermissionEventHandler( - actions.ADMIN, + 'admin', + 'any', async ({ event }) => { const data = await readValidatedBody( event, diff --git a/src/server/api/client/[clientId]/configuration.get.ts b/src/server/api/client/[clientId]/configuration.get.ts index e20645b5..7faa531a 100644 --- a/src/server/api/client/[clientId]/configuration.get.ts +++ b/src/server/api/client/[clientId]/configuration.get.ts @@ -1,30 +1,36 @@ import { ClientGetSchema } from '#db/repositories/client/types'; export default definePermissionEventHandler( - actions.CLIENT, - async ({ event }) => { + 'clients', + 'view', + async ({ event, checkPermissions }) => { const { clientId } = await getValidatedRouterParams( event, validateZod(ClientGetSchema) ); const client = await Database.clients.get(clientId); + checkPermissions(client); + if (!client) { throw createError({ statusCode: 404, statusMessage: 'Client not found', }); } + const config = await WireGuard.getClientConfiguration({ clientId }); const configName = client.name .replace(/[^a-zA-Z0-9_=+.-]/g, '-') .replace(/(-{2,}|-$)/g, '-') .replace(/-$/, '') .substring(0, 32); + setHeader( event, 'Content-Disposition', `attachment; filename="${configName || clientId}.conf"` ); + setHeader(event, 'Content-Type', 'text/plain'); return config; } diff --git a/src/server/api/client/[clientId]/disable.post.ts b/src/server/api/client/[clientId]/disable.post.ts index d4746e2d..2e1a58c0 100644 --- a/src/server/api/client/[clientId]/disable.post.ts +++ b/src/server/api/client/[clientId]/disable.post.ts @@ -1,12 +1,17 @@ import { ClientGetSchema } from '#db/repositories/client/types'; export default definePermissionEventHandler( - actions.CLIENT, - async ({ event }) => { + 'clients', + 'update', + async ({ event, checkPermissions }) => { const { clientId } = await getValidatedRouterParams( event, validateZod(ClientGetSchema) ); + + const client = await Database.clients.get(clientId); + checkPermissions(client); + await Database.clients.toggle(clientId, false); await WireGuard.saveConfig(); return { success: true }; diff --git a/src/server/api/client/[clientId]/enable.post.ts b/src/server/api/client/[clientId]/enable.post.ts index d4746e2d..2e1a58c0 100644 --- a/src/server/api/client/[clientId]/enable.post.ts +++ b/src/server/api/client/[clientId]/enable.post.ts @@ -1,12 +1,17 @@ import { ClientGetSchema } from '#db/repositories/client/types'; export default definePermissionEventHandler( - actions.CLIENT, - async ({ event }) => { + 'clients', + 'update', + async ({ event, checkPermissions }) => { const { clientId } = await getValidatedRouterParams( event, validateZod(ClientGetSchema) ); + + const client = await Database.clients.get(clientId); + checkPermissions(client); + await Database.clients.toggle(clientId, false); await WireGuard.saveConfig(); return { success: true }; diff --git a/src/server/api/client/[clientId]/generateOneTimeLink.post.ts b/src/server/api/client/[clientId]/generateOneTimeLink.post.ts index dc4287ad..618addf5 100644 --- a/src/server/api/client/[clientId]/generateOneTimeLink.post.ts +++ b/src/server/api/client/[clientId]/generateOneTimeLink.post.ts @@ -1,12 +1,17 @@ import { ClientGetSchema } from '#db/repositories/client/types'; export default definePermissionEventHandler( - actions.CLIENT, - async ({ event }) => { + 'clients', + 'update', + async ({ event, checkPermissions }) => { const { clientId } = await getValidatedRouterParams( event, validateZod(ClientGetSchema) ); + + const client = await Database.clients.get(clientId); + checkPermissions(client); + await Database.oneTimeLinks.generate(clientId); return { success: true }; } diff --git a/src/server/api/client/[clientId]/index.delete.ts b/src/server/api/client/[clientId]/index.delete.ts index 686a39ac..171ad9e2 100644 --- a/src/server/api/client/[clientId]/index.delete.ts +++ b/src/server/api/client/[clientId]/index.delete.ts @@ -1,12 +1,17 @@ import { ClientGetSchema } from '#db/repositories/client/types'; export default definePermissionEventHandler( - actions.CLIENT, - async ({ event }) => { + 'clients', + 'delete', + async ({ event, checkPermissions }) => { const { clientId } = await getValidatedRouterParams( event, validateZod(ClientGetSchema) ); + + const client = await Database.clients.get(clientId); + checkPermissions(client); + await Database.clients.delete(clientId); await WireGuard.saveConfig(); return { success: true }; diff --git a/src/server/api/client/[clientId]/index.get.ts b/src/server/api/client/[clientId]/index.get.ts index 9bdd8302..13941671 100644 --- a/src/server/api/client/[clientId]/index.get.ts +++ b/src/server/api/client/[clientId]/index.get.ts @@ -1,13 +1,17 @@ -import { ClientGetSchema } from '~~/server/database/repositories/client/types'; +import { ClientGetSchema } from '#db/repositories/client/types'; export default definePermissionEventHandler( - actions.CLIENT, - async ({ event }) => { + 'clients', + 'view', + async ({ event, checkPermissions }) => { const { clientId } = await getValidatedRouterParams( event, validateZod(ClientGetSchema, event) ); + const result = await Database.clients.get(clientId); + checkPermissions(result); + if (!result) { throw createError({ statusCode: 404, diff --git a/src/server/api/client/[clientId]/index.post.ts b/src/server/api/client/[clientId]/index.post.ts index 78ff38e3..21bdd4e2 100644 --- a/src/server/api/client/[clientId]/index.post.ts +++ b/src/server/api/client/[clientId]/index.post.ts @@ -4,8 +4,9 @@ import { } from '#db/repositories/client/types'; export default definePermissionEventHandler( - actions.CLIENT, - async ({ event }) => { + 'clients', + 'update', + async ({ event, checkPermissions }) => { const { clientId } = await getValidatedRouterParams( event, validateZod(ClientGetSchema) @@ -16,6 +17,9 @@ export default definePermissionEventHandler( validateZod(ClientUpdateSchema, event) ); + const client = await Database.clients.get(clientId); + checkPermissions(client); + await Database.clients.update(clientId, data); await WireGuard.saveConfig(); diff --git a/src/server/api/client/[clientId]/qrcode.svg.get.ts b/src/server/api/client/[clientId]/qrcode.svg.get.ts index 381054cd..19ad85e6 100644 --- a/src/server/api/client/[clientId]/qrcode.svg.get.ts +++ b/src/server/api/client/[clientId]/qrcode.svg.get.ts @@ -1,12 +1,17 @@ import { ClientGetSchema } from '#db/repositories/client/types'; export default definePermissionEventHandler( - actions.CLIENT, - async ({ event }) => { + 'clients', + 'view', + async ({ event, checkPermissions }) => { const { clientId } = await getValidatedRouterParams( event, validateZod(ClientGetSchema) ); + + const client = await Database.clients.get(clientId); + checkPermissions(client); + const svg = await WireGuard.getClientQRCodeSVG({ clientId }); setHeader(event, 'Content-Type', 'image/svg+xml'); return svg; diff --git a/src/server/api/client/index.get.ts b/src/server/api/client/index.get.ts index 9608b72b..296e0003 100644 --- a/src/server/api/client/index.get.ts +++ b/src/server/api/client/index.get.ts @@ -1,3 +1,6 @@ -export default definePermissionEventHandler(actions.CLIENT, () => { - return WireGuard.getClients(); +export default definePermissionEventHandler('clients', 'custom', ({ user }) => { + if (user.role === roles.ADMIN) { + return WireGuard.getAllClients(); + } + return WireGuard.getClientsForUser(user.id); }); diff --git a/src/server/api/client/index.post.ts b/src/server/api/client/index.post.ts index aa461746..ea4940fe 100644 --- a/src/server/api/client/index.post.ts +++ b/src/server/api/client/index.post.ts @@ -1,12 +1,14 @@ import { ClientCreateSchema } from '#db/repositories/client/types'; export default definePermissionEventHandler( - actions.CLIENT, + 'clients', + 'create', async ({ event }) => { const { name, expiresAt } = await readValidatedBody( event, validateZod(ClientCreateSchema) ); + await Database.clients.create({ name, expiresAt }); await WireGuard.saveConfig(); return { success: true }; diff --git a/src/server/api/me/index.post.ts b/src/server/api/me/index.post.ts index 324b4552..10b338ef 100644 --- a/src/server/api/me/index.post.ts +++ b/src/server/api/me/index.post.ts @@ -1,7 +1,8 @@ import { UserUpdateSchema } from '#db/repositories/user/types'; export default definePermissionEventHandler( - actions.CLIENT, + 'me', + 'update', async ({ event, user }) => { const { name, email } = await readValidatedBody( event, diff --git a/src/server/api/me/password.post.ts b/src/server/api/me/password.post.ts index 50a593a4..b7fad3d3 100644 --- a/src/server/api/me/password.post.ts +++ b/src/server/api/me/password.post.ts @@ -1,7 +1,8 @@ import { UserUpdatePasswordSchema } from '#db/repositories/user/types'; export default definePermissionEventHandler( - actions.CLIENT, + 'me', + 'update', async ({ event, user }) => { const { newPassword, currentPassword } = await readValidatedBody( event, diff --git a/src/server/api/session.get.ts b/src/server/api/session.get.ts index 6623c86f..e86b6845 100644 --- a/src/server/api/session.get.ts +++ b/src/server/api/session.get.ts @@ -16,6 +16,7 @@ export default defineEventHandler(async (event) => { } return { + id: user.id, role: user.role, username: user.username, name: user.name, diff --git a/src/server/api/session.post.ts b/src/server/api/session.post.ts index 4cfbf35d..fa1c1372 100644 --- a/src/server/api/session.post.ts +++ b/src/server/api/session.post.ts @@ -28,7 +28,9 @@ export default defineEventHandler(async (event) => { userId: user.id, }); - SERVER_DEBUG(`New Session: ${data.id}`); + // TODO: create audit log? + + SERVER_DEBUG(`New Session: ${data.id} for ${user.id} (${user.username})`); return { success: true }; }); diff --git a/src/server/api/wireguard/backup.get.ts b/src/server/api/wireguard/backup.get.ts deleted file mode 100644 index 648b95e9..00000000 --- a/src/server/api/wireguard/backup.get.ts +++ /dev/null @@ -1,9 +0,0 @@ -export default definePermissionEventHandler( - actions.ADMIN, - async (/*{ event }*/) => { - /*const config = await WireGuard.backupConfiguration(); - setHeader(event, 'Content-Disposition', 'attachment; filename="wg0.json"'); - setHeader(event, 'Content-Type', 'text/json'); - return config;*/ - } -); diff --git a/src/server/api/wireguard/restore.put.ts b/src/server/api/wireguard/restore.put.ts deleted file mode 100644 index c7a95ba5..00000000 --- a/src/server/api/wireguard/restore.put.ts +++ /dev/null @@ -1,8 +0,0 @@ -export default definePermissionEventHandler( - actions.ADMIN, - async (/*{ event }*/) => { - /*const { file } = await readValidatedBody(event, validateZod(fileType)); - await WireGuard.restoreConfiguration(file); - return { success: true };*/ - } -); diff --git a/src/server/database/migrations/0000_short_skin.sql b/src/server/database/migrations/0000_short_skin.sql index 921c4b5c..38283554 100644 --- a/src/server/database/migrations/0000_short_skin.sql +++ b/src/server/database/migrations/0000_short_skin.sql @@ -1,5 +1,6 @@ CREATE TABLE `clients_table` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer NOT NULL, `name` text NOT NULL, `ipv4_address` text NOT NULL, `ipv6_address` text NOT NULL, @@ -14,19 +15,20 @@ CREATE TABLE `clients_table` ( `dns` text NOT NULL, `enabled` integer NOT NULL, `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, - `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE cascade ON DELETE restrict ); --> statement-breakpoint CREATE UNIQUE INDEX `clients_table_ipv4_address_unique` ON `clients_table` (`ipv4_address`);--> statement-breakpoint CREATE UNIQUE INDEX `clients_table_ipv6_address_unique` ON `clients_table` (`ipv6_address`);--> statement-breakpoint CREATE TABLE `general_table` ( `id` integer PRIMARY KEY DEFAULT 1 NOT NULL, - `setupStep` integer NOT NULL, + `setup_step` integer NOT NULL, `session_password` text NOT NULL, `session_timeout` integer NOT NULL, - `metricsPrometheus` integer NOT NULL, - `metricsJson` integer NOT NULL, - `metricsPassword` text, + `metrics_prometheus` integer NOT NULL, + `metrics_json` integer NOT NULL, + `metrics_password` text, `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL ); @@ -61,10 +63,10 @@ CREATE TABLE `one_time_links_table` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `one_time_link` text NOT NULL, `expires_at` text NOT NULL, - `clientId` integer NOT NULL, + `client_id` integer NOT NULL, `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, - FOREIGN KEY (`clientId`) REFERENCES `clients_table`(`id`) ON UPDATE cascade ON DELETE cascade + FOREIGN KEY (`client_id`) REFERENCES `clients_table`(`id`) ON UPDATE cascade ON DELETE cascade ); --> statement-breakpoint CREATE UNIQUE INDEX `one_time_links_table_one_time_link_unique` ON `one_time_links_table` (`one_time_link`);--> statement-breakpoint diff --git a/src/server/database/migrations/0001_classy_the_stranger.sql b/src/server/database/migrations/0001_classy_the_stranger.sql index f305f0e7..a4bdf0f1 100644 --- a/src/server/database/migrations/0001_classy_the_stranger.sql +++ b/src/server/database/migrations/0001_classy_the_stranger.sql @@ -1,5 +1,5 @@ PRAGMA journal_mode=WAL;--> statement-breakpoint -INSERT INTO `general_table` (`setupStep`, `session_password`, `session_timeout`, `metricsPrometheus`, `metricsJson`) +INSERT INTO `general_table` (`setup_step`, `session_password`, `session_timeout`, `metrics_prometheus`, `metrics_json`) VALUES (1, hex(randomblob(256)), 3600, 0, 0); --> statement-breakpoint INSERT INTO `interfaces_table` (`name`, `device`, `port`, `private_key`, `public_key`, `ipv4_cidr`, `ipv6_cidr`, `mtu`, `enabled`) diff --git a/src/server/database/migrations/meta/0000_snapshot.json b/src/server/database/migrations/meta/0000_snapshot.json index 4d28e70c..1b217e6c 100644 --- a/src/server/database/migrations/meta/0000_snapshot.json +++ b/src/server/database/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "2c4694af-5916-430f-96d3-55aac2653e7e", + "id": "b1dde023-d141-4eab-9226-89a832b2ed2b", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "clients_table": { @@ -14,6 +14,13 @@ "notNull": true, "autoincrement": true }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "name": { "name": "name", "type": "text", @@ -138,7 +145,21 @@ "isUnique": true } }, - "foreignKeys": {}, + "foreignKeys": { + "clients_table_user_id_users_table_id_fk": { + "name": "clients_table_user_id_users_table_id_fk", + "tableFrom": "clients_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} @@ -154,8 +175,8 @@ "autoincrement": false, "default": 1 }, - "setupStep": { - "name": "setupStep", + "setup_step": { + "name": "setup_step", "type": "integer", "primaryKey": false, "notNull": true, @@ -175,22 +196,22 @@ "notNull": true, "autoincrement": false }, - "metricsPrometheus": { - "name": "metricsPrometheus", + "metrics_prometheus": { + "name": "metrics_prometheus", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "metricsJson": { - "name": "metricsJson", + "metrics_json": { + "name": "metrics_json", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "metricsPassword": { - "name": "metricsPassword", + "metrics_password": { + "name": "metrics_password", "type": "text", "primaryKey": false, "notNull": false, @@ -415,8 +436,8 @@ "notNull": true, "autoincrement": false }, - "clientId": { - "name": "clientId", + "client_id": { + "name": "client_id", "type": "integer", "primaryKey": false, "notNull": true, @@ -449,12 +470,12 @@ } }, "foreignKeys": { - "one_time_links_table_clientId_clients_table_id_fk": { - "name": "one_time_links_table_clientId_clients_table_id_fk", + "one_time_links_table_client_id_clients_table_id_fk": { + "name": "one_time_links_table_client_id_clients_table_id_fk", "tableFrom": "one_time_links_table", "tableTo": "clients_table", "columnsFrom": [ - "clientId" + "client_id" ], "columnsTo": [ "id" diff --git a/src/server/database/migrations/meta/0001_snapshot.json b/src/server/database/migrations/meta/0001_snapshot.json index 07cb02f8..f04104fa 100644 --- a/src/server/database/migrations/meta/0001_snapshot.json +++ b/src/server/database/migrations/meta/0001_snapshot.json @@ -1,6 +1,6 @@ { - "id": "91d39ed5-2c45-4af6-ba39-4cd72ba71f6a", - "prevId": "2c4694af-5916-430f-96d3-55aac2653e7e", + "id": "720d420c-361f-4427-a45b-db0ca613934d", + "prevId": "b1dde023-d141-4eab-9226-89a832b2ed2b", "version": "6", "dialect": "sqlite", "tables": { @@ -14,6 +14,13 @@ "notNull": true, "autoincrement": true }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "name": { "name": "name", "type": "text", @@ -138,7 +145,21 @@ "isUnique": true } }, - "foreignKeys": {}, + "foreignKeys": { + "clients_table_user_id_users_table_id_fk": { + "name": "clients_table_user_id_users_table_id_fk", + "tableFrom": "clients_table", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users_table", + "columnsTo": [ + "id" + ], + "onUpdate": "cascade", + "onDelete": "restrict" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} @@ -154,8 +175,8 @@ "autoincrement": false, "default": 1 }, - "setupStep": { - "name": "setupStep", + "setup_step": { + "name": "setup_step", "type": "integer", "primaryKey": false, "notNull": true, @@ -175,22 +196,22 @@ "notNull": true, "autoincrement": false }, - "metricsPrometheus": { - "name": "metricsPrometheus", + "metrics_prometheus": { + "name": "metrics_prometheus", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "metricsJson": { - "name": "metricsJson", + "metrics_json": { + "name": "metrics_json", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "metricsPassword": { - "name": "metricsPassword", + "metrics_password": { + "name": "metrics_password", "type": "text", "primaryKey": false, "notNull": false, @@ -415,8 +436,8 @@ "notNull": true, "autoincrement": false }, - "clientId": { - "name": "clientId", + "client_id": { + "name": "client_id", "type": "integer", "primaryKey": false, "notNull": true, @@ -449,11 +470,11 @@ } }, "foreignKeys": { - "one_time_links_table_clientId_clients_table_id_fk": { - "name": "one_time_links_table_clientId_clients_table_id_fk", + "one_time_links_table_client_id_clients_table_id_fk": { + "name": "one_time_links_table_client_id_clients_table_id_fk", "tableFrom": "one_time_links_table", "columnsFrom": [ - "clientId" + "client_id" ], "tableTo": "clients_table", "columnsTo": [ diff --git a/src/server/database/migrations/meta/_journal.json b/src/server/database/migrations/meta/_journal.json index 4029303b..3ce83e4a 100644 --- a/src/server/database/migrations/meta/_journal.json +++ b/src/server/database/migrations/meta/_journal.json @@ -5,14 +5,14 @@ { "idx": 0, "version": "6", - "when": 1739191645161, + "when": 1739266828300, "tag": "0000_short_skin", "breakpoints": true }, { "idx": 1, "version": "6", - "when": 1739191678456, + "when": 1739266837347, "tag": "0001_classy_the_stranger", "breakpoints": true } diff --git a/src/server/database/repositories/client/schema.ts b/src/server/database/repositories/client/schema.ts index 6a38667e..882f49e6 100644 --- a/src/server/database/repositories/client/schema.ts +++ b/src/server/database/repositories/client/schema.ts @@ -1,10 +1,16 @@ import { sql, relations } from 'drizzle-orm'; import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core'; -import { oneTimeLink } from '../../schema'; +import { oneTimeLink, user } from '../../schema'; export const client = sqliteTable('clients_table', { id: int().primaryKey({ autoIncrement: true }), + userId: int('user_id') + .notNull() + .references(() => user.id, { + onDelete: 'restrict', + onUpdate: 'cascade', + }), name: text().notNull(), ipv4Address: text('ipv4_address').notNull().unique(), ipv6Address: text('ipv6_address').notNull().unique(), @@ -34,4 +40,8 @@ export const clientsRelations = relations(client, ({ one }) => ({ fields: [client.id], references: [oneTimeLink.clientId], }), + user: one(user, { + fields: [client.userId], + references: [user.id], + }), })); diff --git a/src/server/database/repositories/client/service.ts b/src/server/database/repositories/client/service.ts index ddd10729..10a75d2a 100644 --- a/src/server/database/repositories/client/service.ts +++ b/src/server/database/repositories/client/service.ts @@ -18,6 +18,12 @@ function createPreparedStatement(db: DBType) { findById: db.query.client .findFirst({ where: eq(client.id, sql.placeholder('id')) }) .prepare(), + findByUserId: db.query.client + .findMany({ + where: eq(client.userId, sql.placeholder('userId')), + with: { oneTimeLink: true }, + }) + .prepare(), toggle: db .update(client) .set({ enabled: sql.placeholder('enabled') as never as boolean }) @@ -39,6 +45,15 @@ export class ClientService { this.#statements = createPreparedStatement(db); } + async getForUser(userId: ID) { + const result = await this.#statements.findByUserId.execute({ userId }); + return result.map((row) => ({ + ...row, + createdAt: new Date(row.createdAt), + updatedAt: new Date(row.updatedAt), + })); + } + async getAll() { const result = await this.#statements.findAll.execute(); return result.map((row) => ({ @@ -97,6 +112,8 @@ export class ClientService { .insert(client) .values({ name, + // TODO: fix + userId: 1, expiresAt: parsedExpiresAt, privateKey, publicKey, diff --git a/src/server/database/repositories/client/types.ts b/src/server/database/repositories/client/types.ts index 68663209..51c759ec 100644 --- a/src/server/database/repositories/client/types.ts +++ b/src/server/database/repositories/client/types.ts @@ -14,7 +14,7 @@ export type CreateClientType = Omit< export type UpdateClientType = Omit< CreateClientType, - 'privateKey' | 'publicKey' | 'preSharedKey' + 'privateKey' | 'publicKey' | 'preSharedKey' | 'userId' >; const name = z diff --git a/src/server/database/repositories/general/schema.ts b/src/server/database/repositories/general/schema.ts index 3945573d..e1f4932e 100644 --- a/src/server/database/repositories/general/schema.ts +++ b/src/server/database/repositories/general/schema.ts @@ -4,14 +4,14 @@ import { sqliteTable, text, int } from 'drizzle-orm/sqlite-core'; export const general = sqliteTable('general_table', { id: int().primaryKey({ autoIncrement: false }).default(1), - setupStep: int().notNull(), + setupStep: int('setup_step').notNull(), sessionPassword: text('session_password').notNull(), sessionTimeout: int('session_timeout').notNull(), - metricsPrometheus: int({ mode: 'boolean' }).notNull(), - metricsJson: int({ mode: 'boolean' }).notNull(), - metricsPassword: text(), + metricsPrometheus: int('metrics_prometheus', { mode: 'boolean' }).notNull(), + metricsJson: int('metrics_json', { mode: 'boolean' }).notNull(), + metricsPassword: text('metrics_password'), createdAt: text('created_at') .notNull() diff --git a/src/server/database/repositories/oneTimeLink/schema.ts b/src/server/database/repositories/oneTimeLink/schema.ts index dd69c2fd..9eab8bce 100644 --- a/src/server/database/repositories/oneTimeLink/schema.ts +++ b/src/server/database/repositories/oneTimeLink/schema.ts @@ -7,7 +7,7 @@ export const oneTimeLink = sqliteTable('one_time_links_table', { id: int().primaryKey({ autoIncrement: true }), oneTimeLink: text('one_time_link').notNull().unique(), expiresAt: text('expires_at').notNull(), - clientId: int() + clientId: int('client_id') .notNull() .references(() => client.id, { onDelete: 'cascade', onUpdate: 'cascade' }), createdAt: text('created_at') diff --git a/src/server/database/repositories/user/schema.ts b/src/server/database/repositories/user/schema.ts index 71da8582..73cac6ef 100644 --- a/src/server/database/repositories/user/schema.ts +++ b/src/server/database/repositories/user/schema.ts @@ -1,6 +1,8 @@ -import { sql } from 'drizzle-orm'; +import { sql, relations } from 'drizzle-orm'; import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { client } from '../../schema'; + export const user = sqliteTable('users_table', { id: int().primaryKey({ autoIncrement: true }), username: text().notNull().unique(), @@ -17,3 +19,7 @@ export const user = sqliteTable('users_table', { .default(sql`(CURRENT_TIMESTAMP)`) .$onUpdate(() => sql`(CURRENT_TIMESTAMP)`), }); + +export const usersRelations = relations(user, ({ many }) => ({ + clients: many(client), +})); diff --git a/src/server/routes/cnf/[oneTimeLink].ts b/src/server/routes/cnf/[oneTimeLink].ts index 1b7c695b..79592533 100644 --- a/src/server/routes/cnf/[oneTimeLink].ts +++ b/src/server/routes/cnf/[oneTimeLink].ts @@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => { event, validateZod(OneTimeLinkGetSchema) ); - const clients = await WireGuard.getClients(); + const clients = await WireGuard.getAllClients(); const client = clients.find( (client) => client.oneTimeLink?.oneTimeLink === oneTimeLink ); diff --git a/src/server/routes/metrics/json.get.ts b/src/server/routes/metrics/json.get.ts index ff757e8c..ae9b2879 100644 --- a/src/server/routes/metrics/json.get.ts +++ b/src/server/routes/metrics/json.get.ts @@ -3,7 +3,7 @@ export default defineMetricsHandler('json', async () => { }); async function getMetricsJSON() { - const clients = await WireGuard.getClients(); + const clients = await WireGuard.getAllClients(); let wireguardPeerCount = 0; let wireguardEnabledPeersCount = 0; let wireguardConnectedPeersCount = 0; diff --git a/src/server/routes/metrics/prometheus.get.ts b/src/server/routes/metrics/prometheus.get.ts index bf4d9e80..837a094e 100644 --- a/src/server/routes/metrics/prometheus.get.ts +++ b/src/server/routes/metrics/prometheus.get.ts @@ -5,7 +5,7 @@ export default defineMetricsHandler('prometheus', async ({ event }) => { async function getPrometheusResponse() { const wgInterface = await Database.interfaces.get(); - const clients = await WireGuard.getClients(); + const clients = await WireGuard.getAllClients(); let wireguardPeerCount = 0; let wireguardEnabledPeersCount = 0; let wireguardConnectedPeersCount = 0; diff --git a/src/server/utils/WireGuard.ts b/src/server/utils/WireGuard.ts index 2b37b2d1..8616ba2e 100644 --- a/src/server/utils/WireGuard.ts +++ b/src/server/utils/WireGuard.ts @@ -52,7 +52,39 @@ class WireGuard { WG_DEBUG('Config synced successfully.'); } - async getClients() { + async getClientsForUser(userId: ID) { + const wgInterface = await Database.interfaces.get(); + + const dbClients = await Database.clients.getForUser(userId); + + const clients = dbClients.map((client) => ({ + ...client, + latestHandshakeAt: null as Date | null, + endpoint: null as string | null, + transferRx: null as number | null, + transferTx: null as number | null, + })); + + // Loop WireGuard status + const dump = await wg.dump(wgInterface.name); + dump.forEach( + ({ publicKey, latestHandshakeAt, endpoint, transferRx, transferTx }) => { + const client = clients.find((client) => client.publicKey === publicKey); + if (!client) { + return; + } + + client.latestHandshakeAt = latestHandshakeAt; + client.endpoint = endpoint; + client.transferRx = transferRx; + client.transferTx = transferTx; + } + ); + + return clients; + } + + async getAllClients() { const wgInterface = await Database.interfaces.get(); const dbClients = await Database.clients.getAll(); const clients = dbClients.map((client) => ({ diff --git a/src/server/utils/handler.ts b/src/server/utils/handler.ts index bddf2a45..60167956 100644 --- a/src/server/utils/handler.ts +++ b/src/server/utils/handler.ts @@ -1,32 +1,65 @@ import type { EventHandlerRequest, EventHandlerResponse, H3Event } from 'h3'; import type { UserType } from '#db/repositories/user/types'; -import type { SetupStepType } from '../database/repositories/general/types'; +import type { SetupStepType } from '#db/repositories/general/types'; +import { + type Permissions, + hasPermissionsWithData, +} from '#shared/utils/permissions'; type PermissionHandler< TReq extends EventHandlerRequest, TRes extends EventHandlerResponse, -> = { (params: { event: H3Event; user: UserType }): TRes }; + Resource extends keyof Permissions, +> = { + (params: { + event: H3Event; + user: UserType; + /** + * check if user has permissions to access the resource + * + * see: {@link hasPermissionsWithData} + */ + checkPermissions: (data?: Permissions[Resource]['dataType']) => true; + }): TRes; +}; /** - * check if the user has the permission to perform the action + * get current user */ export const definePermissionEventHandler = < TReq extends EventHandlerRequest, TRes extends EventHandlerResponse, + Resource extends keyof Permissions, >( - action: Action, - handler: PermissionHandler + resource: Resource, + action: Permissions[Resource]['action'], + handler: PermissionHandler ) => { return defineEventHandler(async (event) => { const user = await getCurrentUser(event); - if (!checkPermissions(action, user)) { + + const permissions = hasPermissionsWithData(user, resource, action); + + // if no data is required, check permissions + if (permissions.isBoolean()) { + permissions.check(); + } + + const response = await handler({ + event, + user, + checkPermissions: permissions.check, + }); + + // if data is required, make sure permissions were checked + if (!permissions.checked) { throw createError({ - statusCode: 403, - statusMessage: 'Forbidden', + statusCode: 500, + statusMessage: 'Permission was not checked', }); } - return await handler({ event, user }); + return response; }); }; diff --git a/src/shared/utils/permissions.ts b/src/shared/utils/permissions.ts index 3b7c5762..912b5ac1 100644 --- a/src/shared/utils/permissions.ts +++ b/src/shared/utils/permissions.ts @@ -1,31 +1,158 @@ -// TODO: implement ABAC +import type { ClientType } from '#db/repositories/client/types'; +import type { UserType } from '#db/repositories/user/types'; -export const actions = { - ADMIN: 'ADMIN', - CLIENT: 'CLIENT', -} as const; +type BrandedRole = { + readonly __role: unique symbol; +}; -export type Role = number & { readonly __role: unique symbol }; +export type Role = number & BrandedRole; export const roles = { ADMIN: 1 as Role, CLIENT: 2 as Role, } as const; -export type Action = keyof typeof actions; +type Roles = keyof typeof roles; + +/** + * convert role to key + * @example roleToKey(roles.ADMIN) => 'ADMIN' + */ +function roleToKey(role: Role) { + const roleKey = Object.keys(roles).find( + (key) => roles[key as Roles] === role + ); + + if (roleKey === undefined) { + throw new Error('Invalid role'); + } + + return roleKey as Roles; +} -type MATRIX = { - readonly [key in keyof typeof actions]: readonly (typeof roles)[keyof typeof roles][]; +// TODO: https://github.com/nitrojs/nitro/issues/2758#issuecomment-2650531472 + +type BrandedNumber = { + toString: unknown; + toFixed: unknown; + toExponential: unknown; + toPrecision: unknown; + valueOf: unknown; + toLocaleString: unknown; +} & BrandedRole; + +type SharedUserType = + | Pick + | (Pick & { role: BrandedNumber }); + +type PermissionCheck = + | boolean + | ((user: SharedUserType, data: Permissions[Key]['dataType']) => boolean); + +type RolesWithPermissions = { + [R in Roles]: { + [Key in keyof Permissions]: { + [Action in Permissions[Key]['action']]: PermissionCheck; + }; + }; }; -export const MATRIX: MATRIX = { - [actions.ADMIN]: [roles.ADMIN] as const, - [actions.CLIENT]: [roles.CLIENT, roles.ADMIN] as const, -} as const; +export type Permissions = { + clients: { + dataType: ClientType; + action: 'view' | 'create' | 'update' | 'delete' | 'custom'; + }; + admin: { + dataType: never; + action: 'any'; + }; + me: { + dataType: UserType; + action: 'update'; + }; +}; + +export const ROLES = { + ADMIN: { + clients: { + view: true, + create: true, + update: true, + delete: true, + custom: true, + }, + admin: { + any: true, + }, + me: { + update: (loggedIn, toChange) => loggedIn.id === toChange.id, + }, + }, + CLIENT: { + clients: { + view: (user, client) => user.id === client.userId, + create: false, + update: (user, client) => user.id === client.userId, + delete: (user, client) => user.id === client.userId, + custom: true, + }, + admin: { + any: false, + }, + me: { + update: (loggedIn, toChange) => loggedIn.id === toChange.id, + }, + }, +} as const satisfies RolesWithPermissions; + +export function hasPermissions( + user: SharedUserType, + resource: Resource, + action: Permissions[Resource]['action'], + data?: Permissions[Resource]['dataType'] +) { + const permission = (ROLES as RolesWithPermissions)[ + // TODO: remove typecast + roleToKey(user.role as Role) + ][resource][action]; + + if (typeof permission === 'boolean') { + return permission; + } -export const checkPermissions = (action: Action, user: { role: Role }) => { - if (!MATRIX[action].includes(user.role)) { + if (data === undefined) { return false; } - return true; -}; + + return permission(user, data); +} + +export function hasPermissionsWithData( + user: UserType, + resource: Resource, + action: Permissions[Resource]['action'] +) { + let checked = false; + return { + check(data?: Permissions[Resource]['dataType']) { + checked = true; + const isAllowed = hasPermissions(user, resource, action, data); + + if (!isAllowed) { + throw new Error('Permission denied'); + } + + return isAllowed; + }, + isBoolean() { + return ( + typeof (ROLES as RolesWithPermissions)[roleToKey(user.role)][resource][ + action + ] === 'boolean' + ); + }, + get checked() { + return checked; + }, + }; +}