Browse Source

wip: add abac

pull/1660/head
Bernd Storath 6 months ago
parent
commit
40368b5b06
  1. 10
      src/server/api/client/[clientId]/configuration.get.ts
  2. 9
      src/server/api/client/[clientId]/disable.post.ts
  3. 9
      src/server/api/client/[clientId]/enable.post.ts
  4. 9
      src/server/api/client/[clientId]/generateOneTimeLink.post.ts
  5. 9
      src/server/api/client/[clientId]/index.delete.ts
  6. 10
      src/server/api/client/[clientId]/index.get.ts
  7. 8
      src/server/api/client/[clientId]/index.post.ts
  8. 9
      src/server/api/client/[clientId]/qrcode.svg.get.ts
  9. 7
      src/server/api/client/index.get.ts
  10. 4
      src/server/api/client/index.post.ts
  11. 16
      src/server/database/migrations/0000_short_skin.sql
  12. 2
      src/server/database/migrations/0001_classy_the_stranger.sql
  13. 51
      src/server/database/migrations/meta/0000_snapshot.json
  14. 53
      src/server/database/migrations/meta/0001_snapshot.json
  15. 4
      src/server/database/migrations/meta/_journal.json
  16. 12
      src/server/database/repositories/client/schema.ts
  17. 14
      src/server/database/repositories/client/service.ts
  18. 2
      src/server/database/repositories/client/types.ts
  19. 8
      src/server/database/repositories/general/schema.ts
  20. 2
      src/server/database/repositories/oneTimeLink/schema.ts
  21. 8
      src/server/database/repositories/user/schema.ts
  22. 34
      src/server/utils/WireGuard.ts
  23. 51
      src/server/utils/handler.ts
  24. 115
      src/shared/utils/permissions.ts

10
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;
}

9
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 };

9
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 };

9
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 };
}

9
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 };

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(
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,

8
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();

9
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;

7
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);
});

4
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 };

16
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

2
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`)

51
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"

53
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": [

4
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
}

12
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],
}),
}));

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

@ -18,6 +18,9 @@ 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')) })
.prepare(),
toggle: db
.update(client)
.set({ enabled: sql.placeholder('enabled') as never as boolean })
@ -39,6 +42,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 +109,8 @@ export class ClientService {
.insert(client)
.values({
name,
// TODO: fix
userId: 1,
expiresAt: parsedExpiresAt,
privateKey,
publicKey,

2
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

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', {
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()

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 }),
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')

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 { 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),
}));

34
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) => ({

51
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<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 = <
TReq extends EventHandlerRequest,
TRes extends EventHandlerResponse,
Resource extends keyof Permissions,
>(
action: Action,
handler: PermissionHandler<TReq, TRes>
resource: Resource,
action: Permissions[Resource]['action'],
handler: PermissionHandler<TReq, TRes, Resource>
) => {
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;
});
};

115
src/shared/utils/permissions.ts

@ -1,9 +1,5 @@
// TODO: implement ABAC
export const actions = {
ADMIN: 'ADMIN',
CLIENT: 'CLIENT',
} as const;
import type { ClientType } from '#db/repositories/client/types';
import type { UserType } from '#db/repositories/user/types';
export type Role = number & { readonly __role: unique symbol };
@ -12,20 +8,105 @@ export const roles = {
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');
}
type MATRIX = {
readonly [key in keyof typeof actions]: readonly (typeof roles)[keyof typeof roles][];
return roleKey as Roles;
}
type PermissionCheck<Key extends keyof Permissions> =
| boolean
| ((user: UserType, 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 = {
[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';
};
};
export const checkPermissions = (action: Action, user: { role: Role }) => {
if (!MATRIX[action].includes(user.role)) {
export const ROLES = {
ADMIN: {
clients: {
view: true,
create: true,
update: true,
delete: true,
custom: true,
},
},
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,
},
},
} as const satisfies RolesWithPermissions;
export function hasPermissions<Resource extends keyof Permissions>(
user: UserType,
resource: Resource,
action: Permissions[Resource]['action'],
data?: Permissions[Resource]['dataType']
) {
const permission = ROLES[roleToKey(user.role)][resource][action];
if (typeof permission === 'boolean') {
return permission;
}
if (data === undefined) {
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[roleToKey(user.role)][resource][action] === 'boolean';
},
get checked() {
return checked;
},
};
}

Loading…
Cancel
Save