From f801f82faae2a5cef57ad71b8aa7b223d370dc2a Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 25 Aug 2025 17:33:03 +0200 Subject: [PATCH] Implemented permissions and user administration (#2) Reviewed-on: https://git.maschek.info/Patrick/Stundenaufzeichnung/pulls/2 --- src/lib/db_types.ts | 1 + src/lib/permissions.config.json | 33 +++++ src/lib/permissions.ts | 153 ++++++++++++++++++++ src/lib/server/auth.ts | 6 +- src/lib/server/database.ts | 102 ++++++++----- src/lib/server/session_store.ts | 8 ++ src/routes/+layout.server.ts | 10 ++ src/routes/+layout.svelte | 44 +++--- src/routes/user/+page.server.ts | 107 ++++++++++++++ src/routes/user/+page.svelte | 206 +++++++++++++++++++++++++++ src/routes/useradmin/+page.server.ts | 26 ++++ src/routes/useradmin/+page.svelte | 37 +++++ 12 files changed, 677 insertions(+), 56 deletions(-) create mode 100644 src/lib/permissions.config.json create mode 100644 src/lib/permissions.ts create mode 100644 src/routes/+layout.server.ts create mode 100644 src/routes/user/+page.server.ts create mode 100644 src/routes/user/+page.svelte create mode 100644 src/routes/useradmin/+page.server.ts create mode 100644 src/routes/useradmin/+page.svelte diff --git a/src/lib/db_types.ts b/src/lib/db_types.ts index 3b1f9bf..0909611 100644 --- a/src/lib/db_types.ts +++ b/src/lib/db_types.ts @@ -5,6 +5,7 @@ export interface UserEntry { address: string; username: string; password: string; + permissions: number; created: string; } diff --git a/src/lib/permissions.config.json b/src/lib/permissions.config.json new file mode 100644 index 0000000..0844928 --- /dev/null +++ b/src/lib/permissions.config.json @@ -0,0 +1,33 @@ +{ + "USERADMIN": { + "size": 8, + "permissions": { + "VIEW": { + "position": 0, + "name": "Anzeigen" + }, + "ADD": { + "position": 1, + "name": "Anlegen" + }, + "DELETE": { + "position": 2, + "name": "Entfernen" + }, + "EDIT": { + "position": 3, + "name": "Bearbeiten" + }, + "ADMIN": { + "position": 4, + "name": "Administration" + } + }, + "meta_permissions": { + "MANAGE": { + "permissions": ["VIEW", "ADD", "DELETE", "EDIT"], + "name": "Verwalten" + } + } + } +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..19a0a30 --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,153 @@ + +interface GroupPermissionDetail { + position: number + name: string +} + +interface GroupMetaPermissionDetail { + permissions: Array + name: string +} + +type GroupPermissionsDef = Record +type GroupMetaPermissionsDef = Record + +interface GroupDef { + size: number + permissions: GroupPermissionsDef + meta_permissions?: GroupMetaPermissionsDef +} + +type PermissionDef = Record + +interface PermissionDescription { + key: string + value: number + name: string +} + + +const _display_names = new Map() + + +import raw from "./permissions.config.json" +const config = raw as const + +const __validate_config = (config: unknown): config is PermissionDef => { + const error = (message: string) => new Error("Failed to parse permissions.config.json: " + message) + + if (typeof config !== "object" || config === null) throw error("configuration is not an object or null.") + + for (const [group, definition] of Object.entries(config)) { + if (typeof definition !== "object") + throw error(`definition for ${group} is not an object (is ${typeof definition})`) + if (definition == null) + throw error(`definition for ${group} is null`) + + if (typeof definition.size !== "number") + throw error(`type of size in group ${group} is not number (is ${typeof definition.size})`) + + if (typeof definition.permissions !== "object") + throw error(`definition of permissions for group ${group} is not an object`) + + for (const [name, detail] of Object.entries(definition.permissions)) { + if (typeof detail !== "object" || detail == null) + throw error(`definition for permission ${name} has to be an object`) + + if (typeof detail.position !== "number") + throw error(`position of ${name} in group ${group} is not a number (is ${typeof detail.position})`) + if (detail.position >= definition.size) + throw error(`position ${position} of permission ${name} in group ${group} is out of bounds (size is ${definition.size})`) + } + + if (definition.meta_permissions !== undefined) { + const permissions = Object.keys(definition.permissions) + + if (typeof definition.meta_permissions !== "object") + throw error(`meta_permissions of group ${group} is not an object (is ${typeof definition.meta_permissions})`) + for (const [name, parts] of Object.entries(definition.meta_permissions)) { + if (Object.keys(definition.permissions).includes(name)) + throw error(`meta permission ${name} uses the same name as the permission`) + if (typeof parts !== "object" || parts == null) + throw error(`definition of meta permission ${name} in group ${group} is not an object`) + if (typeof parts.permissions !== "object" || !(parts.permissions instanceof Array)) + throw error(`definition of meta permission ${name} has to include a key "permissions" with a value of type Array`) + for (const partial of (parts.permissions as Array)) { + if (!permissions.includes(partial)) + throw error(`permission ${partial} of definition of meta permission ${name} in group ${group} is not a permission of this group`) + } + } + } + } + + return true +} + +type PermissionGroups = keyof typeof config + +function toPermissionObj(config: PermissionDef): Record> { + let curr_pos = 0 + const obj: Record> = {} + + for (const [name, definition] of Object.entries(config)) { + obj[name] = {} + for (const [permission, detail] of Object.entries(definition.permissions)) { + const mask = 1 << (curr_pos + detail.position) + obj[name][permission] = mask + _display_names.set(mask, detail.name) + } + for (const [meta, detail] of Object.entries(definition?.meta_permissions ?? {})) { + let mask = 0; + for (const permission of detail.permissions) { + mask |= 1 << definition.permissions[permission].position + } + + obj[name][meta] = mask << curr_pos + _display_names.set(mask, detail.name) + } + + curr_pos += definition.size + } + return obj +} + +__validate_config(config) + +const _deconstruct = (permission: number): Array => { + + const parts: Array = [] + let mask = 1 + while (permission >= mask) { + const perm = permission & mask + if (perm > 0) parts.push(perm) + + mask <<= 1 + } + + return parts +} + +const _to_display_name = (permission: number): string => { + const name = _display_names.get(permission) + if (name != undefined) return name + + const names = _deconstruct(permission).map((value) => _display_names.get(value)).filter((value) => value != undefined) + + return names.join(" | ") +} + +export default { + ...toPermissionObj(config), + + ALL: (permission_group: Record): number => Object.values(permission_group).reduce((pv: number, cv: number) => pv | cv), + + has: (user_permissions: number, permissions: number): boolean => (user_permissions & permissions) == permissions, + any: (user_permissions: number, permissions: number): boolean => (user_permissions & permissions) > 0, + + iterate: (permission_group: Record): Array => Object.entries(permission_group).map(([key, value]) => ({ key: key, value: value, name: _to_display_name(value)})), + is_meta: (permission: number) => permission != 0 && ((permission & (permission - 1)) != 0), + + deconstruct: _deconstruct, + display_name: _to_display_name +} + diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index c66da1a..ae8c08d 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,6 +1,6 @@ import Bun from 'bun'; -import { User, get_user_by_name } from "$lib/server/database"; +import { User, get_user_by_name, update_user_password } from "$lib/server/database"; export async function authorize_password(username: string, password: string): Promise { @@ -12,4 +12,8 @@ export async function authorize_password(username: string, password: string): Pr return res ? user : null; } +export async function change_password(user_id: number, new_password: string): Promise { + const hash = await Bun.password.hash(new_password, { algorithm: "bcrypt", cost: 11}); + return update_user_password(user_id, hash) +} diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index a6b0fb2..0d50315 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -4,6 +4,8 @@ import { Database, SQLiteError } from "bun:sqlite"; import type { UserEntry, RecordEntry, EstimatesEntry } from "$lib/db_types"; import { calculateDuration, parseDate, toInt, isTimeValidHHMM } from "$lib/util"; +import Logs from "$lib/server/log" + const DATABASES_PATH: string = (process.env.APP_USER_DATA_PATH ?? ".") + "/databases/"; const USER_DATABASE_PATH: string = DATABASES_PATH + "users.sqlite"; @@ -20,6 +22,7 @@ const USER_DATABASE_SETUP: string[] = [ address TEXT, username TEXT, password TEXT, + permissions INTEGER DEFAULT 0, created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL );`, @@ -47,8 +50,11 @@ const USER_DATABASE_SETUP: string[] = [ const USER_DATABASE_ADD_USER: string = "INSERT INTO users (name, gender, address, username, password) VALUES ($name, $gender, $address, $username, $password);"; -const USER_DATABASE_GET_USER: string = - "SELECT * FROM users;"; +const USER_DATABASE_GET_ALL_USER: string = + "SELECT id, username, name FROM users;"; + +const USER_DATABASE_GET_USER_BY_ID: string = + "SELECT * FROM users WHERE id = $id;" const USER_DATABASE_GET_USER_BY_NAME: string = "SELECT * FROM users WHERE username = $username;" @@ -56,6 +62,9 @@ const USER_DATABASE_GET_USER_BY_NAME: string = const USER_DATABASE_EMPTY: string = "SELECT EXISTS (SELECT 1 FROM users);" +const USER_DATABASE_UPDATE_PASSWORD: string = + "UPDATE users SET password=$password WHERE id=$id;" + /*const USER_DATABASE_ADD_ACCESS_TOKEN: string = "INSERT INTO access_tokens (user_id, token, expiry_date) VALUES ($user_id, $token, $expiry_date);" @@ -82,7 +91,7 @@ const ENTRY_DATABASE_SETUP: string[] = [ record_id INTEGER NOT NULL, date VARCHAR(10), start VARCHAR(5), - end VARCHAR(5), + end VARCHAR(5), comment TEXT, modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, FOREIGN KEY(record_id) REFERENCES records(id) @@ -179,7 +188,8 @@ export class User { name: string; address: string; username: string; - password: string + password: string; + permissions: number; created: string; private _database: Database; @@ -191,10 +201,24 @@ export class User { this.address = user.address; this.username = user.username; this.password = user.password; + this.permissions = user.permissions; this.created = user.created; this._database = db; } + toUserEntry(): UserEntry { + return { + id: this.id, + gender: this.gender, + name: this.name, + address: this.address, + username: this.username, + password: this.password, + permissions: this.permissions, + created: this.created, + } + } + get_months(): { year: string, month: string }[] { const query = this._database.query(ENTRY_DATABASE_GET_MONTHS); const res = query.all(); @@ -378,51 +402,30 @@ export async function create_user(user: { name: string, gender: string, address: throw e; } } - -function _get_user(): UserEntry | null { +export function get_all_user(): { id: number, username: string, name: string }[] { try { - const statement = user_database.prepare(USER_DATABASE_GET_USER); - const result = statement.get() as UserEntry; - - return result; - + const query = user_database.prepare(USER_DATABASE_GET_ALL_USER) + const user = query.all() as { id: number, username: string, name: string }[] + + return user } catch (e) { - if (e instanceof SQLiteError) { - return null; - } - - throw e; + throw e } - } -export function get_user(id: number): User | null { - - const user = _get_user(); - if (user == null) { - return null; - } - - const db_name = get_user_db_name(user); +export function get_user_entry_by_id(id: number): UserEntry | null { try { + const query = user_database.query(USER_DATABASE_GET_USER_BY_ID) + const user = query.get({ id: id }) as UserEntry | null; - fs.mkdir(DATABASES_PATH, { recursive: true }); - - let userdb = new Database(db_name, { create: true, strict: true }); - - if (!is_db_initialized(userdb)) { - setup_db(userdb, ENTRY_DATABASE_SETUP); - } - - return new User(user, userdb); + return user } catch (e) { - console.log(e); - - return null; + Logs.db.error(`Encountered exception when retrievieng user ${id} from database: ${e.message}`) } - + + return null } export function get_user_by_name(username: string): User | null { @@ -459,3 +462,26 @@ export function do_users_exist(): any { // sqlite trims the first "SELECT " and ";" from the query string return (answer as any)?.[USER_DATABASE_EMPTY.slice(7, -1)]; } + +export function updateUser(data: {id: number, gender?: string, name?: string, address?: string, username?: string, permissions?: number }) { + let changed: Array = [] + if (data.gender) changed.push("gender=$gender") + if (data.name) changed.push("name=$name") + if (data.address) changed.push("address=$address") + if (data.username) changed.push("username=$username") + if (data.permissions) changed.push("permissions=$permissions") + + const update_query = "UPDATE users SET " + changed.join(", ") + " WHERE id=$id;" + + const query = user_database.prepare(update_query) + const result = query.run(data) + + return get_user_by_name(data?.name ?? "") // GET USER BY lastRowId from result +} + +export function update_user_password(user_id: number, password: string) { + const query = user_database.prepare(USER_DATABASE_UPDATE_PASSWORD) + const result = query.run({ password: password, id: user_id }) + + return result.changes > 0 +} diff --git a/src/lib/server/session_store.ts b/src/lib/server/session_store.ts index d27b6a8..cdd5fac 100644 --- a/src/lib/server/session_store.ts +++ b/src/lib/server/session_store.ts @@ -104,6 +104,13 @@ function logout_user_session(token: string): boolean { return true; } +function reload_user_data(user: User) { + const session_info = active_users.get(user.id) + if (!session_info) return + + session_info.user = user +} + async function __clean_session_store() { let cleaned_user_sessions = 0 @@ -135,6 +142,7 @@ export default class SessionStore { static issue_access_token_for_user = issue_access_token_for_user; static get_user_by_access_token = get_user_by_access_token; static logout_user_session = logout_user_session; + static reload_user_data = reload_user_data; } setInterval(__clean_session_store, 15*60*1000); diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..bcf4e5d --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,10 @@ +import type { LayoutServerLoad } from "./$types" + +import Permissions from "$lib/permissions" + +export const load: LayoutServerLoad = ({ locals }) => { + return { + loggedInAs: locals.user?.toUserEntry(), + isAdmin: Permissions.any(locals.user?.permissions ?? 0, Permissions.ALL(Permissions.USERADMIN)) + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d237206..1f5b3f5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,24 +1,34 @@ {#snippet nav(classlist: string)} -
-

Navigation

- -
+
+

Navigation

+ +
{/snippet}