Files
buildfor_life_budget/src/lib/server/authorization.ts
T
grabowski 0bfbcef043 Add accountant role and financial_exported audit event
- New 'accountant' role in companyRoleEnum (orthogonal like 'hr')
- meetsMinRole and requireCompanyRole now exclude accountant from
  hierarchy along with hr
- Settings UI exposes accountant in the role checkbox lists for both
  add-member and edit-member forms
- New 'financial_exported' value added to companyLogEventEnum, ready
  for the upcoming export feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:39:18 +07:00

111 lines
3.0 KiB
TypeScript

import { error } from '@sveltejs/kit';
import { db } from './db/index.js';
import { companyMembers } from './db/schema.js';
import { and, eq } from 'drizzle-orm';
import { ROLE_HIERARCHY, type CompanyRole } from '$lib/types/index.js';
export function requireAuth(locals: App.Locals): NonNullable<App.Locals['user']> {
if (!locals.user) {
error(401, 'Authentication required');
}
return locals.user;
}
export function requireSystemAdmin(locals: App.Locals): NonNullable<App.Locals['user']> {
const user = requireAuth(locals);
if (!user.isSystemAdmin) {
error(403, 'System admin access required');
}
return user;
}
export async function getCompanyRoles(
userId: string,
companyId: string
): Promise<CompanyRole[] | null> {
const result = await db
.select({ roles: companyMembers.roles })
.from(companyMembers)
.where(and(eq(companyMembers.userId, userId), eq(companyMembers.companyId, companyId)))
.limit(1);
if (result.length === 0) return null;
return result[0].roles as CompanyRole[];
}
/** Does the role set include the target role? */
export function hasRole(roles: CompanyRole[], target: CompanyRole): boolean {
return roles.includes(target);
}
/** Does any hierarchical role in the set meet or exceed the minimum rank? `hr` and `accountant` do not count. */
export function meetsMinRole(
roles: CompanyRole[],
min: Exclude<CompanyRole, 'hr' | 'accountant'>
): boolean {
const minRank = ROLE_HIERARCHY[min];
for (const r of roles) {
if (r === 'hr' || r === 'accountant') continue;
const rank = ROLE_HIERARCHY[r as Exclude<CompanyRole, 'hr' | 'accountant'>];
if (rank >= minRank) return true;
}
return false;
}
/**
* Ensure the caller meets the minimum hierarchical role (admin>manager>user>viewer).
* System admins bypass. Returns the caller's full role set.
*/
export async function requireCompanyRole(
locals: App.Locals,
companyId: string,
minRole: Exclude<CompanyRole, 'hr' | 'accountant'>
): Promise<{ user: NonNullable<App.Locals['user']>; roles: CompanyRole[] }> {
const user = requireAuth(locals);
if (user.isSystemAdmin) {
return { user, roles: ['admin'] };
}
const roles = await getCompanyRoles(user.id, companyId);
if (!roles || roles.length === 0) {
error(403, 'Not a member of this company');
}
if (!meetsMinRole(roles, minRole)) {
error(403, `Requires ${minRole} role or higher`);
}
return { user, roles };
}
/**
* Ensure the caller has ANY of the listed roles. Useful for orthogonal roles like `hr`.
* System admins bypass.
*/
export async function requireCompanyRoleAny(
locals: App.Locals,
companyId: string,
anyOf: CompanyRole[]
): Promise<{ user: NonNullable<App.Locals['user']>; roles: CompanyRole[] }> {
const user = requireAuth(locals);
if (user.isSystemAdmin) {
return { user, roles: ['admin'] };
}
const roles = await getCompanyRoles(user.id, companyId);
if (!roles || roles.length === 0) {
error(403, 'Not a member of this company');
}
const has = roles.some((r) => anyOf.includes(r));
if (!has) {
error(403, `Requires one of: ${anyOf.join(', ')}`);
}
return { user, roles };
}