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
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:
@@ -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`;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user