UNPKG

@accounter/server

Version:
206 lines • 8.4 kB
import { __decorate, __metadata } from "tslib"; import DataLoader from 'dataloader'; import { GraphQLError } from 'graphql'; import { Injectable, Scope } from 'graphql-modules'; import { sql } from '@pgtyped/runtime'; import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js'; import { AuditLogsProvider } from '../../common/providers/audit-logs.provider.js'; import { AuthContextProvider } from './auth-context.provider.js'; import { Auth0ManagementProvider } from './auth0-management.provider.js'; // 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(items, limit, mapper) { const results = new Array(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 ` 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 ` 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 ` 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 ` DELETE FROM accounter_schema.business_users WHERE user_id = $userId AND business_id = $ownerId RETURNING user_id; `; let BusinessUsersProvider = class BusinessUsersProvider { db; authContextProvider; auth0ManagementProvider; auditLogsProvider; constructor(db, authContextProvider, auth0ManagementProvider, auditLogsProvider) { this.db = db; this.authContextProvider = authContextProvider; this.auth0ManagementProvider = auth0ManagementProvider; this.auditLogsProvider = auditLogsProvider; } async batchBusinessUsersByAuth0Ids(auth0UserIds) { const businessUsers = await getBusinessUsersByAuth0Ids.run({ auth0UserIds: auth0UserIds ? [...auth0UserIds] : [], }, this.db); return auth0UserIds.map(auth0UserId => businessUsers.filter(t => t.auth0_user_id === auth0UserId)); } getBusinessUsersByAuth0IdsLoader = new DataLoader((auth0UserIds) => 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. */ async getMembershipsByAuth0UserId(auth0UserId) { const rows = await this.getBusinessUsersByAuth0IdsLoader.load(auth0UserId); return rows.map(row => ({ businessId: row.business_id, roleId: row.role_id, })); } async insertBusinessUser(params) { 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. */ async listBusinessUsers() { 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 = 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. */ async removeBusinessUser(userId) { 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; }); } async requireBusinessOwner() { 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 }; } }; BusinessUsersProvider = __decorate([ Injectable({ scope: Scope.Operation, global: true, }), __metadata("design:paramtypes", [TenantAwareDBClient, AuthContextProvider, Auth0ManagementProvider, AuditLogsProvider]) ], BusinessUsersProvider); export { BusinessUsersProvider }; //# sourceMappingURL=business-users.provider.js.map