Merge pull request 'Enforce tenant isolation for document credit storage' (#422) from codex/credit-ledger-tenant-rls into main
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m1s
Deploy to Coolify / deploy (push) Successful in 1m13s
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (pull_request) Successful in 7s
masterplan-lock / check (push) Successful in 16s
CI (SIAX Cloud) / security (push) Successful in 26s
Security Scan (SIAX Cloud) / security (push) Successful in 27s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 50s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m2s
CI (SIAX Cloud) / security (pull_request) Successful in 28s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / quality (pull_request) Successful in 1m33s
CI (SIAX Cloud) / quality (push) Successful in 1m41s

This commit was merged in pull request #422.
This commit is contained in:
2026-11-02 06:41:36 +00:00
5 changed files with 138 additions and 37 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
import type { RepositorySql } from '../repositories/db';
import { withCreditTenant } from './credit-tenant-scope';
export type CreditOperation = 'verified_payment' | 'reserve' | 'settle' | 'release';
export interface CreditCommand {
@@ -37,7 +38,7 @@ export class PostgresCreditLedger {
)
throw new Error('Invalid credit command');
return this.sql.begin(async (tx) => {
return withCreditTenant(this.sql, tenantId, async (tx) => {
await tx`INSERT INTO opt_credit_accounts (tenant_id) VALUES (${tenantId}) ON CONFLICT DO NOTHING`;
const accounts = await tx<{ available: string; reserved: string }[]>`
SELECT available, reserved FROM opt_credit_accounts WHERE tenant_id = ${tenantId} FOR UPDATE`;
+21 -20
View File
@@ -1,5 +1,6 @@
import type { RepositorySql } from '../repositories/db';
import type { CreditPurchaseSnapshot } from './pay0-credit-verification';
import { withCreditTenant } from './credit-tenant-scope';
interface PurchaseRow {
tenant_id: string;
@@ -37,28 +38,28 @@ export class PostgresCreditPurchaseStore {
) {
throw new Error('Invalid credit purchase snapshot');
}
const rows = await this.sql<PurchaseRow[]>`
INSERT INTO opt_credit_purchases (tenant_id, purchase_id, checkout_session_id, amount_minor, currency, credits)
VALUES (${snapshot.tenantId}, ${snapshot.purchaseId}, ${snapshot.checkoutSessionId}, ${snapshot.amountMinor}, ${snapshot.currency}, ${snapshot.credits})
ON CONFLICT (tenant_id, purchase_id) DO NOTHING RETURNING *`;
if (rows[0]) return fromRow(rows[0]);
const existing = await this.get(snapshot.tenantId, snapshot.purchaseId);
if (
!existing ||
existing.checkoutSessionId !== snapshot.checkoutSessionId ||
existing.amountMinor !== snapshot.amountMinor ||
existing.currency !== snapshot.currency ||
existing.credits !== snapshot.credits
) {
throw new Error('Purchase id reused for a different price snapshot');
}
return existing;
return withCreditTenant(this.sql, snapshot.tenantId, async (tx) => {
const rows = await tx<PurchaseRow[]>`
INSERT INTO opt_credit_purchases (tenant_id, purchase_id, checkout_session_id, amount_minor, currency, credits)
VALUES (${snapshot.tenantId}, ${snapshot.purchaseId}, ${snapshot.checkoutSessionId}, ${snapshot.amountMinor}, ${snapshot.currency}, ${snapshot.credits})
ON CONFLICT (tenant_id, purchase_id) DO NOTHING RETURNING *`;
if (rows[0]) return fromRow(rows[0]);
const existingRows = await tx<PurchaseRow[]>`
SELECT * FROM opt_credit_purchases WHERE tenant_id = ${snapshot.tenantId} AND purchase_id = ${snapshot.purchaseId}`;
const existing = existingRows[0] ? fromRow(existingRows[0]) : undefined;
if (!existing || existing.checkoutSessionId !== snapshot.checkoutSessionId || existing.amountMinor !== snapshot.amountMinor
|| existing.currency !== snapshot.currency || existing.credits !== snapshot.credits) {
throw new Error('Purchase id reused for a different price snapshot');
}
return existing;
});
}
async get(tenantId: string, purchaseId: string): Promise<CreditPurchaseSnapshot | undefined> {
const rows = await this.sql<
PurchaseRow[]
>`SELECT * FROM opt_credit_purchases WHERE tenant_id = ${tenantId} AND purchase_id = ${purchaseId}`;
return rows[0] ? fromRow(rows[0]) : undefined;
if (typeof purchaseId !== 'string' || !purchaseId.trim()) throw new Error('Invalid credit purchase id');
return withCreditTenant(this.sql, tenantId, async (tx) => {
const rows = await tx<PurchaseRow[]>`SELECT * FROM opt_credit_purchases WHERE tenant_id = ${tenantId} AND purchase_id = ${purchaseId}`;
return rows[0] ? fromRow(rows[0]) : undefined;
});
}
}
@@ -0,0 +1,33 @@
import type postgres from 'postgres';
import type { RepositorySql } from '../repositories/db';
/** A scoped transaction is not a user authorization or payment verification. */
export async function withCreditTenant<T>(
sql: RepositorySql,
tenantId: string,
action: (tx: postgres.TransactionSql) => Promise<T>,
): Promise<T> {
if (typeof tenantId !== 'string' || !tenantId.trim() || tenantId.length > 256) throw new Error('Invalid credit tenant');
return sql.begin(async (tx) => {
const roles = await tx<{ rolsuper: boolean; rolbypassrls: boolean }[]>`
SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`;
if (!roles[0] || roles[0].rolsuper || roles[0].rolbypassrls) throw new Error('Credits require an RLS-enforced database role');
// Do not silently run against pre-migration credit tables. Policy bodies
// themselves remain a trusted migration/database-administrator boundary.
const gates = await tx<{ enabled: boolean }[]>`
SELECT COUNT(*) = 4 AS enabled
FROM (VALUES
('opt_credit_accounts', 'opt_credit_accounts_tenant'),
('opt_credit_reservations', 'opt_credit_reservations_tenant'),
('opt_credit_events', 'opt_credit_events_tenant'),
('opt_credit_purchases', 'opt_credit_purchases_tenant')
) AS expected(table_name, policy_name)
JOIN pg_class AS c ON c.oid = to_regclass(expected.table_name)
WHERE c.relrowsecurity AND c.relforcerowsecurity
AND (SELECT COUNT(*) FROM pg_policy AS p WHERE p.polrelid = c.oid) = 1
AND EXISTS (SELECT 1 FROM pg_policy AS p WHERE p.polrelid = c.oid AND p.polname = expected.policy_name)`;
if (gates[0]?.enabled !== true) throw new Error('Credit tenant isolation migration is not active');
await tx`SELECT set_config('app.opt_credit_tenant_id', ${tenantId}, true)`;
return action(tx);
}) as Promise<T>;
}