UNPKG

@accounter/server

Version:
199 lines 9 kB
import { __decorate, __metadata } from "tslib"; import { randomUUID } from 'node:crypto'; import { Injectable, Scope } from 'graphql-modules'; import { sql } from '@pgtyped/runtime'; import { DBProvider } from '../../app-providers/db.provider.js'; import { suggestionDataSchema, } from '../../financial-entities/helpers/business-suggestion-data-schema.helper.js'; import { IngestReasonCode } from '../contracts.js'; import { withTenantContext } from '../helpers/email-ingestion-tenant-context.helper.js'; // --------------------------------------------------------------------------- // Queries // --------------------------------------------------------------------------- const getAliasByAlias = sql ` SELECT owner_id FROM accounter_schema.email_ingestion_alias_routing WHERE lower(alias) = $alias AND is_active = TRUE LIMIT 1 `; const insertIngestGrant = sql ` INSERT INTO accounter_schema.email_ingestion_grants (jti, owner_id, message_id, raw_message_hash, action, expires_at, business_id) VALUES ($jti, $ownerId, $messageId, $rawMessageHash, $action, $expiresAt, $businessId) RETURNING id, jti, owner_id, action, expires_at, business_id `; // Resolve the issuing business by a sender email listed in its // suggestion_data.emails. Mirrors BusinessesProvider.getBusinessByEmail, but // runs on a tenant-pinned client (the control plane has no auth session), so // businesses RLS scopes the match to the resolved tenant. const getBusinessByEmailForIngest = sql ` SELECT id, suggestion_data FROM accounter_schema.businesses WHERE suggestion_data->'emails' ? $email::text LIMIT 1 `; const getGrantByJtiForValidation = sql ` SELECT id, jti, owner_id, message_id, raw_message_hash, action, expires_at, consumed_at, business_id FROM accounter_schema.email_ingestion_grants WHERE jti = $jti LIMIT 1 `; const consumeGrantByJti = sql ` UPDATE accounter_schema.email_ingestion_grants SET consumed_at = NOW() WHERE jti = $jti AND consumed_at IS NULL RETURNING id `; // --------------------------------------------------------------------------- // Provider // --------------------------------------------------------------------------- let EmailIngestionControlProvider = class EmailIngestionControlProvider { dbProvider; constructor(dbProvider) { this.dbProvider = dbProvider; } /** * Resolve a recipient alias to the owning tenant. * Bypasses RLS via raw pool: alias lookup is a bootstrap step that runs * before any tenant context is known, so TenantAwareDBClient would throw * UNAUTHENTICATED. The alias_routing table has FOR SELECT USING (TRUE) to * explicitly allow cross-tenant reads at the DB policy level. */ async resolveAlias(alias) { const rows = await getAliasByAlias.run({ alias: alias.toLowerCase() }, this.dbProvider.pool); if (rows.length === 0) { return { found: false, reason: IngestReasonCode.UNKNOWN_ALIAS }; } return { found: true, tenantId: rows[0].owner_id }; } /** * Issue a short-lived, single-use ingest grant for the given tenant and message. * Returns the persisted grant together with decision/audit metadata. * The INSERT runs under the tenant's RLS context (see {@link withTenantContext}): * the grants table uses FORCE ROW LEVEL SECURITY with a tenant_isolation * WITH CHECK policy, so the raw pool cannot bypass it — the owner_id parameter * and the pinned business context must agree. */ async issueGrant(input) { const jti = randomUUID(); const decisionId = randomUUID(); const auditId = randomUUID(); const { tenantId, messageId, rawMessageHash, expiresAt, businessId } = input; const rows = await withTenantContext(this.dbProvider.pool, tenantId, client => insertIngestGrant.run({ jti, ownerId: tenantId, messageId, rawMessageHash, action: 'ingest', expiresAt, businessId: businessId ?? null, }, client)); const row = rows[0]; return { jti: row.jti, tenantId: row.owner_id, messageId, rawMessageHash, action: row.action, expiresAt: row.expires_at, decisionId, auditId, }; } /** * Recognize the issuing business behind an incoming email and load its * email-processing config. Runs on a client pinned to the resolved tenant so * the businesses RLS policy scopes the lookup to that tenant; returns a null * businessId (and empty config) when no email evidence is available or no * business matches, in which case the gateway applies default treatment. */ async recognizeBusiness(tenantId, issuerEmail) { if (!issuerEmail) { return { businessId: null, config: {} }; } return withTenantContext(this.dbProvider.pool, tenantId, async (client) => { const rows = await getBusinessByEmailForIngest.run({ email: issuerEmail }, client); if (rows.length === 0) { return { businessId: null, config: {} }; } const business = rows[0]; let config = {}; if (business.suggestion_data) { const parsed = suggestionDataSchema.safeParse(business.suggestion_data); if (parsed.success) { if (parsed.data.emailListener) { config = parsed.data.emailListener; } } else { console.error(`Invalid suggestion_data schema for business [${business.id}]: ${JSON.stringify(parsed.error.issues)}`); } } return { businessId: business.id, config }; }); } /** * Validate a presented grant against the stored record and atomically consume it. * Checks: existence, expiry, consumed state, action scope, tenant binding, and message binding. * The consume UPDATE (SET consumed_at = NOW() WHERE consumed_at IS NULL) is atomic — * if a concurrent request consumed the grant first the UPDATE returns 0 rows and * the method returns GRANT_INVALID, preventing double-use. * Runs under the claimed tenant's RLS context: the grants table uses FORCE ROW * LEVEL SECURITY, so the raw pool cannot read/update it without a pinned * business context. Pinning to input.tenantId means a grant owned by another * tenant is filtered out by the USING policy and surfaces as GRANT_INVALID; * the explicit owner_id check below remains as defense-in-depth. */ async validateAndConsumeGrant(input) { return withTenantContext(this.dbProvider.pool, input.tenantId, async (client) => { const rows = await getGrantByJtiForValidation.run({ jti: input.jti }, client); if (rows.length === 0) { return { valid: false, reason: IngestReasonCode.GRANT_INVALID }; } const grant = rows[0]; if (grant.consumed_at !== null) { return { valid: false, reason: IngestReasonCode.GRANT_INVALID }; } if (grant.expires_at <= new Date()) { return { valid: false, reason: IngestReasonCode.GRANT_INVALID }; } if (grant.action !== 'ingest') { return { valid: false, reason: IngestReasonCode.GRANT_INVALID }; } if (grant.owner_id !== input.tenantId) { return { valid: false, reason: IngestReasonCode.TENANT_MISMATCH }; } if (grant.message_id !== input.messageId) { return { valid: false, reason: IngestReasonCode.GRANT_INVALID }; } if (grant.raw_message_hash !== input.rawMessageHash) { return { valid: false, reason: IngestReasonCode.GRANT_INVALID }; } // Atomic consume-once: if this returns 0 rows a concurrent request beat us to it. const consumed = await consumeGrantByJti.run({ jti: input.jti }, client); if (consumed.length === 0) { return { valid: false, reason: IngestReasonCode.GRANT_INVALID }; } return { valid: true, grant: { jti: grant.jti, tenantId: grant.owner_id, action: grant.action, expiresAt: grant.expires_at, businessId: grant.business_id ?? null, }, }; }); } }; EmailIngestionControlProvider = __decorate([ Injectable({ scope: Scope.Singleton, global: true, }), __metadata("design:paramtypes", [DBProvider]) ], EmailIngestionControlProvider); export { EmailIngestionControlProvider }; //# sourceMappingURL=email-ingestion-control.provider.js.map