UNPKG

@accounter/server

Version:
261 lines (231 loc) • 8.65 kB
import DataLoader from 'dataloader'; import { GraphQLError } from 'graphql'; import { Injectable, Scope } from 'graphql-modules'; import { sql } from '@pgtyped/runtime'; import type { AuthContext, BusinessMembership, TenantContext } from '../../../shared/types/auth.js'; import type { NoOptionalField } from '../../../shared/types/index.js'; import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js'; import { AuditLogsProvider } from '../../common/providers/audit-logs.provider.js'; import type { IDeleteBusinessUserQuery, IGetBusinessUsersByAuth0IdsQuery, IInsertBusinessUserParams, IInsertBusinessUserQuery, IListBusinessUsersQuery, } from '../types.js'; import { AuthContextProvider } from './auth-context.provider.js'; import { Auth0ManagementProvider } from './auth0-management.provider.js'; // Validated auth context: business owner with a resolved tenant business id. type ValidatedAuthContext = NoOptionalField<AuthContext, 'user'> & { tenant: NoOptionalField<TenantContext, 'businessId'>; }; /** A user linked to a business, enriched with their Auth0 identity. */ export type BusinessUserRecord = { id: string; email: string; name: string | null; roleId: string; createdAt: Date; }; // Cap on parallel Auth0 Management API lookups, to stay well within Auth0's // rate limits even when a business has many users. const AUTH0_LOOKUP_CONCURRENCY = 5; /** * Map over `items` running at most `limit` mappers concurrently. * A small pool-based limiter so we don't fan out unbounded external calls * (and without pulling in an extra dependency). */ async function mapWithConcurrency<T, R>( items: readonly T[], limit: number, mapper: (item: T) => Promise<R>, ): Promise<R[]> { const results = new Array<R>(items.length); let nextIndex = 0; const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { while (nextIndex < items.length) { const current = nextIndex++; results[current] = await mapper(items[current]); } }); await Promise.all(workers); return results; } const getBusinessUsersByAuth0Ids = sql<IGetBusinessUsersByAuth0IdsQuery>` SELECT user_id, auth0_user_id, business_id, role_id FROM accounter_schema.business_users WHERE auth0_user_id IN $$auth0UserIds ORDER BY updated_at DESC; `; const insertBusinessUser = sql<IInsertBusinessUserQuery>` INSERT INTO accounter_schema.business_users (user_id, auth0_user_id, business_id, role_id) VALUES ($userId, $auth0UserId, $ownerId, $roleId) ON CONFLICT (user_id, business_id) DO NOTHING RETURNING *; `; // Strictly tenant-scoped: only rows for the current business are returned. // `business_users` does not store email/name (identity lives in Auth0); the // linked invitation email is selected as a fallback for users that have not // yet completed Auth0 login. const listBusinessUsers = sql<IListBusinessUsersQuery>` SELECT bu.user_id, bu.auth0_user_id, bu.role_id, bu.created_at, ( SELECT i.email FROM accounter_schema.invitations i WHERE i.user_id = bu.user_id AND i.business_id = bu.business_id ORDER BY i.created_at DESC LIMIT 1 ) AS fallback_email FROM accounter_schema.business_users bu WHERE bu.business_id = $ownerId ORDER BY bu.created_at DESC; `; // Strictly tenant-scoped: the business id guard prevents removing a membership // that belongs to another tenant even if a valid user id is supplied. const deleteBusinessUser = sql<IDeleteBusinessUserQuery>` DELETE FROM accounter_schema.business_users WHERE user_id = $userId AND business_id = $ownerId RETURNING user_id; `; @Injectable({ scope: Scope.Operation, global: true, }) export class BusinessUsersProvider { constructor( private db: TenantAwareDBClient, private authContextProvider: AuthContextProvider, private auth0ManagementProvider: Auth0ManagementProvider, private auditLogsProvider: AuditLogsProvider, ) {} private async batchBusinessUsersByAuth0Ids(auth0UserIds: readonly string[]) { const businessUsers = await getBusinessUsersByAuth0Ids.run( { auth0UserIds: auth0UserIds ? [...auth0UserIds] : [], }, this.db, ); return auth0UserIds.map(auth0UserId => businessUsers.filter(t => t.auth0_user_id === auth0UserId), ); } public getBusinessUsersByAuth0IdsLoader = new DataLoader((auth0UserIds: readonly string[]) => this.batchBusinessUsersByAuth0Ids(auth0UserIds), ); /** * Resolve every business membership for an authenticated Auth0 user. * Reuses the batching loader; returns an empty array when the user has no * memberships. */ public async getMembershipsByAuth0UserId(auth0UserId: string): Promise<BusinessMembership[]> { const rows = await this.getBusinessUsersByAuth0IdsLoader.load(auth0UserId); return rows.map(row => ({ businessId: row.business_id, roleId: row.role_id, })); } public async insertBusinessUser(params: IInsertBusinessUserParams) { return insertBusinessUser.run(params, this.db); } /** * List all users linked to the current business. * Restricted to business owners and strictly scoped to the caller's tenant. * Email/name are enriched from Auth0 (the identity system of record), with the * linked invitation email used as a fallback when an Auth0 lookup is unavailable. */ public async listBusinessUsers(): Promise<BusinessUserRecord[]> { const authContext = await this.requireBusinessOwner(); const rows = await listBusinessUsers.run({ ownerId: authContext.tenant.businessId }, this.db); return mapWithConcurrency(rows, AUTH0_LOOKUP_CONCURRENCY, async row => { let email = row.fallback_email ?? null; let name: string | null = null; if (row.auth0_user_id) { const profile = await this.auth0ManagementProvider.getUserProfileById(row.auth0_user_id); if (profile) { email = profile.email ?? email; name = profile.name ?? null; } } return { id: row.user_id, email: email ?? '', name, roleId: row.role_id, createdAt: row.created_at, }; }); } /** * Remove a user's membership from the current business. * Restricted to business owners and strictly scoped to the caller's tenant, so * a membership belonging to another business can never be removed. Deleting the * relation does not touch the user's global Auth0 identity. * Owners cannot remove themselves, which would cause an immediate self-lockout * (and orphan the tenant if they were the sole owner). * Returns false when no matching membership exists in the current business. */ public async removeBusinessUser(userId: string): Promise<boolean> { const authContext = await this.requireBusinessOwner(); if (userId === authContext.user.userId) { throw new GraphQLError('Cannot remove yourself from the business', { extensions: { code: 'CANNOT_REMOVE_SELF' }, }); } return this.db.transaction(async client => { const rows = await deleteBusinessUser.run( { userId, ownerId: authContext.tenant.businessId }, client, ); const removed = rows.length > 0; if (!removed) { return false; } await this.auditLogsProvider.log( { ownerId: authContext.tenant.businessId, userId: authContext.user.userId, auth0UserId: authContext.user.auth0UserId ?? undefined, action: 'BUSINESS_USER_REMOVED', entity: 'BusinessUser', entityId: userId, details: { removed: true, }, }, client, ); return true; }); } private async requireBusinessOwner(): Promise<ValidatedAuthContext> { const authContext = await this.authContextProvider.getAuthContext(); if (!authContext) { throw new GraphQLError('Authentication required', { extensions: { code: 'UNAUTHENTICATED' }, }); } const { user, tenant, ...restOfAuthContext } = authContext; if (!tenant?.businessId) { throw new GraphQLError('Authentication required', { extensions: { code: 'UNAUTHENTICATED' }, }); } if (!user) { throw new GraphQLError('Authentication required', { extensions: { code: 'UNAUTHENTICATED' }, }); } if (user.roleId !== 'business_owner') { throw new GraphQLError('Requires role: business_owner', { extensions: { code: 'FORBIDDEN' }, }); } return { ...restOfAuthContext, user, tenant }; } }