Browse Source

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
pull/1661/head
Bernd Storath 6 months ago
committed by GitHub
parent
commit
fd0e98d9c3
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      src/app/components/ui/UserMenu.vue
  2. 2
      src/app/middleware/auth.global.ts
  3. 2
      src/server/api/admin/general.get.ts
  4. 3
      src/server/api/admin/general.post.ts
  5. 2
      src/server/api/admin/hooks.get.ts
  6. 3
      src/server/api/admin/hooks.post.ts
  7. 3
      src/server/api/admin/interface/cidr.post.ts
  8. 2
      src/server/api/admin/interface/index.get.ts
  9. 3
      src/server/api/admin/interface/index.post.ts
  10. 2
      src/server/api/admin/userconfig.get.ts
  11. 3
      src/server/api/admin/userconfig.post.ts
  12. 10
      src/server/api/client/[clientId]/configuration.get.ts
  13. 9
      src/server/api/client/[clientId]/disable.post.ts
  14. 9
      src/server/api/client/[clientId]/enable.post.ts
  15. 9
      src/server/api/client/[clientId]/generateOneTimeLink.post.ts
  16. 9
      src/server/api/client/[clientId]/index.delete.ts
  17. 10
      src/server/api/client/[clientId]/index.get.ts
  18. 8
      src/server/api/client/[clientId]/index.post.ts
  19. 9
      src/server/api/client/[clientId]/qrcode.svg.get.ts
  20. 7
      src/server/api/client/index.get.ts
  21. 4
      src/server/api/client/index.post.ts
  22. 3
      src/server/api/me/index.post.ts
  23. 3
      src/server/api/me/password.post.ts
  24. 1
      src/server/api/session.get.ts
  25. 4
      src/server/api/session.post.ts
  26. 9
      src/server/api/wireguard/backup.get.ts
  27. 8
      src/server/api/wireguard/restore.put.ts
  28. 16
      src/server/database/migrations/0000_short_skin.sql
  29. 2
      src/server/database/migrations/0001_classy_the_stranger.sql
  30. 51
      src/server/database/migrations/meta/0000_snapshot.json
  31. 53
      src/server/database/migrations/meta/0001_snapshot.json
  32. 4
      src/server/database/migrations/meta/_journal.json
  33. 12
      src/server/database/repositories/client/schema.ts
  34. 17
      src/server/database/repositories/client/service.ts
  35. 2
      src/server/database/repositories/client/types.ts
  36. 8
      src/server/database/repositories/general/schema.ts
  37. 2
      src/server/database/repositories/oneTimeLink/schema.ts
  38. 8
      src/server/database/repositories/user/schema.ts
  39. 2
      src/server/routes/cnf/[oneTimeLink].ts
  40. 2
      src/server/routes/metrics/json.get.ts
  41. 2
      src/server/routes/metrics/prometheus.get.ts
  42. 34
      src/server/utils/WireGuard.ts
  43. 51
      src/server/utils/handler.ts
  44. 161
      src/shared/utils/permissions.ts

7
src/app/components/ui/UserMenu.vue

@ -37,7 +37,12 @@
Account Account
</NuxtLink> </NuxtLink>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem v-if="authStore.userData?.role === roles.ADMIN"> <DropdownMenuItem
v-if="
authStore.userData &&
hasPermissions(authStore.userData, 'admin', 'any')
"
>
<NuxtLink <NuxtLink
to="/admin" to="/admin"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"

2
src/app/middleware/auth.global.ts

@ -21,7 +21,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
// Check for admin access // Check for admin access
if (to.path.startsWith('/admin')) { if (to.path.startsWith('/admin')) {
if (userData.role !== roles.ADMIN) { if (userData && hasPermissions(userData, 'admin', 'any')) {
return abortNavigation('Not allowed to access Admin Panel'); return abortNavigation('Not allowed to access Admin Panel');
} }
} }

2
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(); const generalConfig = await Database.general.getConfig();
return generalConfig; return generalConfig;
}); });

3
src/server/api/admin/general.post.ts

@ -1,7 +1,8 @@
import { GeneralUpdateSchema } from '#db/repositories/general/types'; import { GeneralUpdateSchema } from '#db/repositories/general/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.ADMIN, 'admin',
'any',
async ({ event }) => { async ({ event }) => {
const data = await readValidatedBody( const data = await readValidatedBody(
event, event,

2
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(); const hooks = await Database.hooks.get();
return hooks; return hooks;
}); });

3
src/server/api/admin/hooks.post.ts

@ -1,7 +1,8 @@
import { HooksUpdateSchema } from '#db/repositories/hooks/types'; import { HooksUpdateSchema } from '#db/repositories/hooks/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.ADMIN, 'admin',
'any',
async ({ event }) => { async ({ event }) => {
const data = await readValidatedBody( const data = await readValidatedBody(
event, event,

3
src/server/api/admin/interface/cidr.post.ts

@ -1,7 +1,8 @@
import { InterfaceCidrUpdateSchema } from '#db/repositories/interface/types'; import { InterfaceCidrUpdateSchema } from '#db/repositories/interface/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.ADMIN, 'admin',
'any',
async ({ event }) => { async ({ event }) => {
const data = await readValidatedBody( const data = await readValidatedBody(
event, event,

2
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(); const wgInterface = await Database.interfaces.get();
return { return {

3
src/server/api/admin/interface/index.post.ts

@ -1,7 +1,8 @@
import { InterfaceUpdateSchema } from '#db/repositories/interface/types'; import { InterfaceUpdateSchema } from '#db/repositories/interface/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.ADMIN, 'admin',
'any',
async ({ event }) => { async ({ event }) => {
const data = await readValidatedBody( const data = await readValidatedBody(
event, event,

2
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(); const userConfig = await Database.userConfigs.get();
return userConfig; return userConfig;
}); });

3
src/server/api/admin/userconfig.post.ts

@ -1,7 +1,8 @@
import { UserConfigUpdateSchema } from '#db/repositories/userConfig/types'; import { UserConfigUpdateSchema } from '#db/repositories/userConfig/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.ADMIN, 'admin',
'any',
async ({ event }) => { async ({ event }) => {
const data = await readValidatedBody( const data = await readValidatedBody(
event, event,

10
src/server/api/client/[clientId]/configuration.get.ts

@ -1,30 +1,36 @@
import { ClientGetSchema } from '#db/repositories/client/types'; import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.CLIENT, 'clients',
async ({ event }) => { 'view',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams( const { clientId } = await getValidatedRouterParams(
event, event,
validateZod(ClientGetSchema) validateZod(ClientGetSchema)
); );
const client = await Database.clients.get(clientId); const client = await Database.clients.get(clientId);
checkPermissions(client);
if (!client) { if (!client) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
statusMessage: 'Client not found', statusMessage: 'Client not found',
}); });
} }
const config = await WireGuard.getClientConfiguration({ clientId }); const config = await WireGuard.getClientConfiguration({ clientId });
const configName = client.name const configName = client.name
.replace(/[^a-zA-Z0-9_=+.-]/g, '-') .replace(/[^a-zA-Z0-9_=+.-]/g, '-')
.replace(/(-{2,}|-$)/g, '-') .replace(/(-{2,}|-$)/g, '-')
.replace(/-$/, '') .replace(/-$/, '')
.substring(0, 32); .substring(0, 32);
setHeader( setHeader(
event, event,
'Content-Disposition', 'Content-Disposition',
`attachment; filename="${configName || clientId}.conf"` `attachment; filename="${configName || clientId}.conf"`
); );
setHeader(event, 'Content-Type', 'text/plain'); setHeader(event, 'Content-Type', 'text/plain');
return config; return config;
} }

9
src/server/api/client/[clientId]/disable.post.ts

@ -1,12 +1,17 @@
import { ClientGetSchema } from '#db/repositories/client/types'; import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.CLIENT, 'clients',
async ({ event }) => { 'update',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams( const { clientId } = await getValidatedRouterParams(
event, event,
validateZod(ClientGetSchema) validateZod(ClientGetSchema)
); );
const client = await Database.clients.get(clientId);
checkPermissions(client);
await Database.clients.toggle(clientId, false); await Database.clients.toggle(clientId, false);
await WireGuard.saveConfig(); await WireGuard.saveConfig();
return { success: true }; return { success: true };

9
src/server/api/client/[clientId]/enable.post.ts

@ -1,12 +1,17 @@
import { ClientGetSchema } from '#db/repositories/client/types'; import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.CLIENT, 'clients',
async ({ event }) => { 'update',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams( const { clientId } = await getValidatedRouterParams(
event, event,
validateZod(ClientGetSchema) validateZod(ClientGetSchema)
); );
const client = await Database.clients.get(clientId);
checkPermissions(client);
await Database.clients.toggle(clientId, false); await Database.clients.toggle(clientId, false);
await WireGuard.saveConfig(); await WireGuard.saveConfig();
return { success: true }; return { success: true };

9
src/server/api/client/[clientId]/generateOneTimeLink.post.ts

@ -1,12 +1,17 @@
import { ClientGetSchema } from '#db/repositories/client/types'; import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.CLIENT, 'clients',
async ({ event }) => { 'update',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams( const { clientId } = await getValidatedRouterParams(
event, event,
validateZod(ClientGetSchema) validateZod(ClientGetSchema)
); );
const client = await Database.clients.get(clientId);
checkPermissions(client);
await Database.oneTimeLinks.generate(clientId); await Database.oneTimeLinks.generate(clientId);
return { success: true }; return { success: true };
} }

9
src/server/api/client/[clientId]/index.delete.ts

@ -1,12 +1,17 @@
import { ClientGetSchema } from '#db/repositories/client/types'; import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.CLIENT, 'clients',
async ({ event }) => { 'delete',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams( const { clientId } = await getValidatedRouterParams(
event, event,
validateZod(ClientGetSchema) validateZod(ClientGetSchema)
); );
const client = await Database.clients.get(clientId);
checkPermissions(client);
await Database.clients.delete(clientId); await Database.clients.delete(clientId);
await WireGuard.saveConfig(); await WireGuard.saveConfig();
return { success: true }; return { success: true };

10
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( export default definePermissionEventHandler(
actions.CLIENT, 'clients',
async ({ event }) => { 'view',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams( const { clientId } = await getValidatedRouterParams(
event, event,
validateZod(ClientGetSchema, event) validateZod(ClientGetSchema, event)
); );
const result = await Database.clients.get(clientId); const result = await Database.clients.get(clientId);
checkPermissions(result);
if (!result) { if (!result) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,

8
src/server/api/client/[clientId]/index.post.ts

@ -4,8 +4,9 @@ import {
} from '#db/repositories/client/types'; } from '#db/repositories/client/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.CLIENT, 'clients',
async ({ event }) => { 'update',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams( const { clientId } = await getValidatedRouterParams(
event, event,
validateZod(ClientGetSchema) validateZod(ClientGetSchema)
@ -16,6 +17,9 @@ export default definePermissionEventHandler(
validateZod(ClientUpdateSchema, event) validateZod(ClientUpdateSchema, event)
); );
const client = await Database.clients.get(clientId);
checkPermissions(client);
await Database.clients.update(clientId, data); await Database.clients.update(clientId, data);
await WireGuard.saveConfig(); await WireGuard.saveConfig();

9
src/server/api/client/[clientId]/qrcode.svg.get.ts

@ -1,12 +1,17 @@
import { ClientGetSchema } from '#db/repositories/client/types'; import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.CLIENT, 'clients',
async ({ event }) => { 'view',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams( const { clientId } = await getValidatedRouterParams(
event, event,
validateZod(ClientGetSchema) validateZod(ClientGetSchema)
); );
const client = await Database.clients.get(clientId);
checkPermissions(client);
const svg = await WireGuard.getClientQRCodeSVG({ clientId }); const svg = await WireGuard.getClientQRCodeSVG({ clientId });
setHeader(event, 'Content-Type', 'image/svg+xml'); setHeader(event, 'Content-Type', 'image/svg+xml');
return svg; return svg;

7
src/server/api/client/index.get.ts

@ -1,3 +1,6 @@
export default definePermissionEventHandler(actions.CLIENT, () => { export default definePermissionEventHandler('clients', 'custom', ({ user }) => {
return WireGuard.getClients(); if (user.role === roles.ADMIN) {
return WireGuard.getAllClients();
}
return WireGuard.getClientsForUser(user.id);
}); });

4
src/server/api/client/index.post.ts

@ -1,12 +1,14 @@
import { ClientCreateSchema } from '#db/repositories/client/types'; import { ClientCreateSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.CLIENT, 'clients',
'create',
async ({ event }) => { async ({ event }) => {
const { name, expiresAt } = await readValidatedBody( const { name, expiresAt } = await readValidatedBody(
event, event,
validateZod(ClientCreateSchema) validateZod(ClientCreateSchema)
); );
await Database.clients.create({ name, expiresAt }); await Database.clients.create({ name, expiresAt });
await WireGuard.saveConfig(); await WireGuard.saveConfig();
return { success: true }; return { success: true };

3
src/server/api/me/index.post.ts

@ -1,7 +1,8 @@
import { UserUpdateSchema } from '#db/repositories/user/types'; import { UserUpdateSchema } from '#db/repositories/user/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.CLIENT, 'me',
'update',
async ({ event, user }) => { async ({ event, user }) => {
const { name, email } = await readValidatedBody( const { name, email } = await readValidatedBody(
event, event,

3
src/server/api/me/password.post.ts

@ -1,7 +1,8 @@
import { UserUpdatePasswordSchema } from '#db/repositories/user/types'; import { UserUpdatePasswordSchema } from '#db/repositories/user/types';
export default definePermissionEventHandler( export default definePermissionEventHandler(
actions.CLIENT, 'me',
'update',
async ({ event, user }) => { async ({ event, user }) => {
const { newPassword, currentPassword } = await readValidatedBody( const { newPassword, currentPassword } = await readValidatedBody(
event, event,

1
src/server/api/session.get.ts

@ -16,6 +16,7 @@ export default defineEventHandler(async (event) => {
} }
return { return {
id: user.id,
role: user.role, role: user.role,
username: user.username, username: user.username,
name: user.name, name: user.name,

4
src/server/api/session.post.ts

@ -28,7 +28,9 @@ export default defineEventHandler(async (event) => {
userId: user.id, 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 }; return { success: true };
}); });

9
src/server/api/wireguard/backup.get.ts

@ -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;*/
}
);

8
src/server/api/wireguard/restore.put.ts

@ -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 };*/
}
);

16
src/server/database/migrations/0000_short_skin.sql

@ -1,5 +1,6 @@
CREATE TABLE `clients_table` ( CREATE TABLE `clients_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text NOT NULL, `name` text NOT NULL,
`ipv4_address` text NOT NULL, `ipv4_address` text NOT NULL,
`ipv6_address` text NOT NULL, `ipv6_address` text NOT NULL,
@ -14,19 +15,20 @@ CREATE TABLE `clients_table` (
`dns` text NOT NULL, `dns` text NOT NULL,
`enabled` integer NOT NULL, `enabled` integer NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) 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 --> statement-breakpoint
CREATE UNIQUE INDEX `clients_table_ipv4_address_unique` ON `clients_table` (`ipv4_address`);--> 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 UNIQUE INDEX `clients_table_ipv6_address_unique` ON `clients_table` (`ipv6_address`);--> statement-breakpoint
CREATE TABLE `general_table` ( CREATE TABLE `general_table` (
`id` integer PRIMARY KEY DEFAULT 1 NOT NULL, `id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
`setupStep` integer NOT NULL, `setup_step` integer NOT NULL,
`session_password` text NOT NULL, `session_password` text NOT NULL,
`session_timeout` integer NOT NULL, `session_timeout` integer NOT NULL,
`metricsPrometheus` integer NOT NULL, `metrics_prometheus` integer NOT NULL,
`metricsJson` integer NOT NULL, `metrics_json` integer NOT NULL,
`metricsPassword` text, `metrics_password` text,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) 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
); );
@ -61,10 +63,10 @@ CREATE TABLE `one_time_links_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`one_time_link` text NOT NULL, `one_time_link` text NOT NULL,
`expires_at` 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, `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 (`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 --> statement-breakpoint
CREATE UNIQUE INDEX `one_time_links_table_one_time_link_unique` ON `one_time_links_table` (`one_time_link`);--> statement-breakpoint CREATE UNIQUE INDEX `one_time_links_table_one_time_link_unique` ON `one_time_links_table` (`one_time_link`);--> statement-breakpoint

2
src/server/database/migrations/0001_classy_the_stranger.sql

@ -1,5 +1,5 @@
PRAGMA journal_mode=WAL;--> statement-breakpoint 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); VALUES (1, hex(randomblob(256)), 3600, 0, 0);
--> statement-breakpoint --> statement-breakpoint
INSERT INTO `interfaces_table` (`name`, `device`, `port`, `private_key`, `public_key`, `ipv4_cidr`, `ipv6_cidr`, `mtu`, `enabled`) INSERT INTO `interfaces_table` (`name`, `device`, `port`, `private_key`, `public_key`, `ipv4_cidr`, `ipv6_cidr`, `mtu`, `enabled`)

51
src/server/database/migrations/meta/0000_snapshot.json

@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "2c4694af-5916-430f-96d3-55aac2653e7e", "id": "b1dde023-d141-4eab-9226-89a832b2ed2b",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"clients_table": { "clients_table": {
@ -14,6 +14,13 @@
"notNull": true, "notNull": true,
"autoincrement": true "autoincrement": true
}, },
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": { "name": {
"name": "name", "name": "name",
"type": "text", "type": "text",
@ -138,7 +145,21 @@
"isUnique": true "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": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {}
@ -154,8 +175,8 @@
"autoincrement": false, "autoincrement": false,
"default": 1 "default": 1
}, },
"setupStep": { "setup_step": {
"name": "setupStep", "name": "setup_step",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
@ -175,22 +196,22 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"metricsPrometheus": { "metrics_prometheus": {
"name": "metricsPrometheus", "name": "metrics_prometheus",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"metricsJson": { "metrics_json": {
"name": "metricsJson", "name": "metrics_json",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"metricsPassword": { "metrics_password": {
"name": "metricsPassword", "name": "metrics_password",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
@ -415,8 +436,8 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"clientId": { "client_id": {
"name": "clientId", "name": "client_id",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
@ -449,12 +470,12 @@
} }
}, },
"foreignKeys": { "foreignKeys": {
"one_time_links_table_clientId_clients_table_id_fk": { "one_time_links_table_client_id_clients_table_id_fk": {
"name": "one_time_links_table_clientId_clients_table_id_fk", "name": "one_time_links_table_client_id_clients_table_id_fk",
"tableFrom": "one_time_links_table", "tableFrom": "one_time_links_table",
"tableTo": "clients_table", "tableTo": "clients_table",
"columnsFrom": [ "columnsFrom": [
"clientId" "client_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"

53
src/server/database/migrations/meta/0001_snapshot.json

@ -1,6 +1,6 @@
{ {
"id": "91d39ed5-2c45-4af6-ba39-4cd72ba71f6a", "id": "720d420c-361f-4427-a45b-db0ca613934d",
"prevId": "2c4694af-5916-430f-96d3-55aac2653e7e", "prevId": "b1dde023-d141-4eab-9226-89a832b2ed2b",
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"tables": { "tables": {
@ -14,6 +14,13 @@
"notNull": true, "notNull": true,
"autoincrement": true "autoincrement": true
}, },
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": { "name": {
"name": "name", "name": "name",
"type": "text", "type": "text",
@ -138,7 +145,21 @@
"isUnique": true "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": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {}
@ -154,8 +175,8 @@
"autoincrement": false, "autoincrement": false,
"default": 1 "default": 1
}, },
"setupStep": { "setup_step": {
"name": "setupStep", "name": "setup_step",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
@ -175,22 +196,22 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"metricsPrometheus": { "metrics_prometheus": {
"name": "metricsPrometheus", "name": "metrics_prometheus",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"metricsJson": { "metrics_json": {
"name": "metricsJson", "name": "metrics_json",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"metricsPassword": { "metrics_password": {
"name": "metricsPassword", "name": "metrics_password",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
@ -415,8 +436,8 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"clientId": { "client_id": {
"name": "clientId", "name": "client_id",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
@ -449,11 +470,11 @@
} }
}, },
"foreignKeys": { "foreignKeys": {
"one_time_links_table_clientId_clients_table_id_fk": { "one_time_links_table_client_id_clients_table_id_fk": {
"name": "one_time_links_table_clientId_clients_table_id_fk", "name": "one_time_links_table_client_id_clients_table_id_fk",
"tableFrom": "one_time_links_table", "tableFrom": "one_time_links_table",
"columnsFrom": [ "columnsFrom": [
"clientId" "client_id"
], ],
"tableTo": "clients_table", "tableTo": "clients_table",
"columnsTo": [ "columnsTo": [

4
src/server/database/migrations/meta/_journal.json

@ -5,14 +5,14 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1739191645161, "when": 1739266828300,
"tag": "0000_short_skin", "tag": "0000_short_skin",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "6", "version": "6",
"when": 1739191678456, "when": 1739266837347,
"tag": "0001_classy_the_stranger", "tag": "0001_classy_the_stranger",
"breakpoints": true "breakpoints": true
} }

12
src/server/database/repositories/client/schema.ts

@ -1,10 +1,16 @@
import { sql, relations } from 'drizzle-orm'; import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { oneTimeLink } from '../../schema'; import { oneTimeLink, user } from '../../schema';
export const client = sqliteTable('clients_table', { export const client = sqliteTable('clients_table', {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
userId: int('user_id')
.notNull()
.references(() => user.id, {
onDelete: 'restrict',
onUpdate: 'cascade',
}),
name: text().notNull(), name: text().notNull(),
ipv4Address: text('ipv4_address').notNull().unique(), ipv4Address: text('ipv4_address').notNull().unique(),
ipv6Address: text('ipv6_address').notNull().unique(), ipv6Address: text('ipv6_address').notNull().unique(),
@ -34,4 +40,8 @@ export const clientsRelations = relations(client, ({ one }) => ({
fields: [client.id], fields: [client.id],
references: [oneTimeLink.clientId], references: [oneTimeLink.clientId],
}), }),
user: one(user, {
fields: [client.userId],
references: [user.id],
}),
})); }));

17
src/server/database/repositories/client/service.ts

@ -18,6 +18,12 @@ function createPreparedStatement(db: DBType) {
findById: db.query.client findById: db.query.client
.findFirst({ where: eq(client.id, sql.placeholder('id')) }) .findFirst({ where: eq(client.id, sql.placeholder('id')) })
.prepare(), .prepare(),
findByUserId: db.query.client
.findMany({
where: eq(client.userId, sql.placeholder('userId')),
with: { oneTimeLink: true },
})
.prepare(),
toggle: db toggle: db
.update(client) .update(client)
.set({ enabled: sql.placeholder('enabled') as never as boolean }) .set({ enabled: sql.placeholder('enabled') as never as boolean })
@ -39,6 +45,15 @@ export class ClientService {
this.#statements = createPreparedStatement(db); 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() { async getAll() {
const result = await this.#statements.findAll.execute(); const result = await this.#statements.findAll.execute();
return result.map((row) => ({ return result.map((row) => ({
@ -97,6 +112,8 @@ export class ClientService {
.insert(client) .insert(client)
.values({ .values({
name, name,
// TODO: fix
userId: 1,
expiresAt: parsedExpiresAt, expiresAt: parsedExpiresAt,
privateKey, privateKey,
publicKey, publicKey,

2
src/server/database/repositories/client/types.ts

@ -14,7 +14,7 @@ export type CreateClientType = Omit<
export type UpdateClientType = Omit< export type UpdateClientType = Omit<
CreateClientType, CreateClientType,
'privateKey' | 'publicKey' | 'preSharedKey' 'privateKey' | 'publicKey' | 'preSharedKey' | 'userId'
>; >;
const name = z const name = z

8
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', { export const general = sqliteTable('general_table', {
id: int().primaryKey({ autoIncrement: false }).default(1), id: int().primaryKey({ autoIncrement: false }).default(1),
setupStep: int().notNull(), setupStep: int('setup_step').notNull(),
sessionPassword: text('session_password').notNull(), sessionPassword: text('session_password').notNull(),
sessionTimeout: int('session_timeout').notNull(), sessionTimeout: int('session_timeout').notNull(),
metricsPrometheus: int({ mode: 'boolean' }).notNull(), metricsPrometheus: int('metrics_prometheus', { mode: 'boolean' }).notNull(),
metricsJson: int({ mode: 'boolean' }).notNull(), metricsJson: int('metrics_json', { mode: 'boolean' }).notNull(),
metricsPassword: text(), metricsPassword: text('metrics_password'),
createdAt: text('created_at') createdAt: text('created_at')
.notNull() .notNull()

2
src/server/database/repositories/oneTimeLink/schema.ts

@ -7,7 +7,7 @@ export const oneTimeLink = sqliteTable('one_time_links_table', {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
oneTimeLink: text('one_time_link').notNull().unique(), oneTimeLink: text('one_time_link').notNull().unique(),
expiresAt: text('expires_at').notNull(), expiresAt: text('expires_at').notNull(),
clientId: int() clientId: int('client_id')
.notNull() .notNull()
.references(() => client.id, { onDelete: 'cascade', onUpdate: 'cascade' }), .references(() => client.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
createdAt: text('created_at') createdAt: text('created_at')

8
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 { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { client } from '../../schema';
export const user = sqliteTable('users_table', { export const user = sqliteTable('users_table', {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(), username: text().notNull().unique(),
@ -17,3 +19,7 @@ export const user = sqliteTable('users_table', {
.default(sql`(CURRENT_TIMESTAMP)`) .default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`), .$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
}); });
export const usersRelations = relations(user, ({ many }) => ({
clients: many(client),
}));

2
src/server/routes/cnf/[oneTimeLink].ts

@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => {
event, event,
validateZod(OneTimeLinkGetSchema) validateZod(OneTimeLinkGetSchema)
); );
const clients = await WireGuard.getClients(); const clients = await WireGuard.getAllClients();
const client = clients.find( const client = clients.find(
(client) => client.oneTimeLink?.oneTimeLink === oneTimeLink (client) => client.oneTimeLink?.oneTimeLink === oneTimeLink
); );

2
src/server/routes/metrics/json.get.ts

@ -3,7 +3,7 @@ export default defineMetricsHandler('json', async () => {
}); });
async function getMetricsJSON() { async function getMetricsJSON() {
const clients = await WireGuard.getClients(); const clients = await WireGuard.getAllClients();
let wireguardPeerCount = 0; let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0; let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0; let wireguardConnectedPeersCount = 0;

2
src/server/routes/metrics/prometheus.get.ts

@ -5,7 +5,7 @@ export default defineMetricsHandler('prometheus', async ({ event }) => {
async function getPrometheusResponse() { async function getPrometheusResponse() {
const wgInterface = await Database.interfaces.get(); const wgInterface = await Database.interfaces.get();
const clients = await WireGuard.getClients(); const clients = await WireGuard.getAllClients();
let wireguardPeerCount = 0; let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0; let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0; let wireguardConnectedPeersCount = 0;

34
src/server/utils/WireGuard.ts

@ -52,7 +52,39 @@ class WireGuard {
WG_DEBUG('Config synced successfully.'); 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 wgInterface = await Database.interfaces.get();
const dbClients = await Database.clients.getAll(); const dbClients = await Database.clients.getAll();
const clients = dbClients.map((client) => ({ const clients = dbClients.map((client) => ({

51
src/server/utils/handler.ts

@ -1,32 +1,65 @@
import type { EventHandlerRequest, EventHandlerResponse, H3Event } from 'h3'; import type { EventHandlerRequest, EventHandlerResponse, H3Event } from 'h3';
import type { UserType } from '#db/repositories/user/types'; 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< type PermissionHandler<
TReq extends EventHandlerRequest, TReq extends EventHandlerRequest,
TRes extends EventHandlerResponse, TRes extends EventHandlerResponse,
> = { (params: { event: H3Event<TReq>; user: UserType }): TRes }; Resource extends keyof Permissions,
> = {
(params: {
event: H3Event<TReq>;
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 = < export const definePermissionEventHandler = <
TReq extends EventHandlerRequest, TReq extends EventHandlerRequest,
TRes extends EventHandlerResponse, TRes extends EventHandlerResponse,
Resource extends keyof Permissions,
>( >(
action: Action, resource: Resource,
handler: PermissionHandler<TReq, TRes> action: Permissions[Resource]['action'],
handler: PermissionHandler<TReq, TRes, Resource>
) => { ) => {
return defineEventHandler(async (event) => { return defineEventHandler(async (event) => {
const user = await getCurrentUser(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({ throw createError({
statusCode: 403, statusCode: 500,
statusMessage: 'Forbidden', statusMessage: 'Permission was not checked',
}); });
} }
return await handler({ event, user }); return response;
}); });
}; };

161
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 = { type BrandedRole = {
ADMIN: 'ADMIN', readonly __role: unique symbol;
CLIENT: 'CLIENT', };
} as const;
export type Role = number & { readonly __role: unique symbol }; export type Role = number & BrandedRole;
export const roles = { export const roles = {
ADMIN: 1 as Role, ADMIN: 1 as Role,
CLIENT: 2 as Role, CLIENT: 2 as Role,
} as const; } 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 = { // TODO: https://github.com/nitrojs/nitro/issues/2758#issuecomment-2650531472
readonly [key in keyof typeof actions]: readonly (typeof roles)[keyof typeof roles][];
type BrandedNumber = {
toString: unknown;
toFixed: unknown;
toExponential: unknown;
toPrecision: unknown;
valueOf: unknown;
toLocaleString: unknown;
} & BrandedRole;
type SharedUserType =
| Pick<UserType, 'id' | 'role'>
| (Pick<UserType, 'id'> & { role: BrandedNumber });
type PermissionCheck<Key extends keyof Permissions> =
| boolean
| ((user: SharedUserType, data: Permissions[Key]['dataType']) => boolean);
type RolesWithPermissions = {
[R in Roles]: {
[Key in keyof Permissions]: {
[Action in Permissions[Key]['action']]: PermissionCheck<Key>;
};
};
}; };
export const MATRIX: MATRIX = { export type Permissions = {
[actions.ADMIN]: [roles.ADMIN] as const, clients: {
[actions.CLIENT]: [roles.CLIENT, roles.ADMIN] as const, dataType: ClientType;
} as const; 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<Resource extends keyof Permissions>(
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 (data === undefined) {
if (!MATRIX[action].includes(user.role)) {
return false; return false;
} }
return true;
}; return permission(user, data);
}
export function hasPermissionsWithData<Resource extends keyof Permissions>(
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;
},
};
}

Loading…
Cancel
Save