@accounter/server
Version:
Accounter GraphQL server
261 lines (231 loc) • 8.65 kB
text/typescript
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;
`;
({
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 };
}
}