Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# Backend Server Configuration
# Copy this to .env and customize for your environment

# ── Tenant API Key Authentication ─────────────────────────────────────────────
# Per-tenant API keys are stored hashed in the `apiKey` table with scopes,
# expiry, and last-used tracking. Rotate keys per tenant without downtime.
# API_KEY below is a deprecated global fallback; it only applies when no
# tenant-scoped key matches and logs a deprecation warning on each use.
# API_KEY=

# Server Configuration
PORT=3000
NODE_ENV=development
Expand All @@ -10,6 +17,9 @@ NODE_ENV=development
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100

# Deprecated: single global API key fallback (see Tenant API Key Authentication)
# API_KEY=

# API endpoint rate limiter: 30 requests per minute (60000 ms)
API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX_REQUESTS=30
Expand All @@ -29,6 +39,16 @@ API_RATE_LIMIT_MAX_REQUESTS=30
#PAYLOAD_LIMIT_ADMIN=16kb
#PAYLOAD_LIMIT_WRITES=32kb

# ── Tenant API Key Scopes ─────────────────────────────────────────────────────
# Comma-separated list of scopes required per route tier. Requests must present
# an x-api-key whose tenant record includes the required scope, otherwise the
# middleware returns 401 SCOPE_INSUFFICIENT with a WWW-Authenticate header.
# API_KEY_SCOPES_READ=read
# API_KEY_SCOPES_WRITE=write
# API_KEY_SCOPES_ADMIN=admin
# API_KEY_EXPIRED_ERROR=API_KEY_EXPIRED
# API_KEY_SCOPE_ERROR=SCOPE_INSUFFICIENT

# ── Wallet signed-action nonces ───────────────────────────────────────────────
# WALLET_NONCE_TTL_SECONDS=300
# WALLET_NONCE_MAX_ACTIVE_PER_WALLET=10
Expand Down
17 changes: 17 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,23 @@ model AdminAuditLog {
@@index([actor])
}

model ApiKey {
id String @id @default(uuid())
tenantId String
hashedKey String @unique
scopes String // JSON-serialised string[]
expiresAt DateTime?
lastUsedAt DateTime?
revoked Boolean @default(false)
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([tenantId])
@@index([hashedKey])
@@index([expiresAt])
}

model ApiKeyAuditEvent {
id String @id @default(uuid())
actor String
Expand Down
76 changes: 76 additions & 0 deletions backend/scripts/canary-migration-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* # or from check:migrations:canary npm script
*
* Annotation opt-outs (add as a SQL comment in the migration file):
* -- migration-safety: allow-api-key-auth
* -- migration-safety: allow-tenant-scoped-auth
* -- migration-safety: allow-not-null-add
* -- migration-safety: allow-nonconcurrent-indexes
* -- migration-safety: allow-drop
Expand Down Expand Up @@ -208,6 +210,64 @@ const rules: Array<{
},
];

// ── API key / tenant scoping rules (Issue: cross-tenant replay) ──────────────

const apiKeyRules: Array<{
id: string;
severity: 'error' | 'warning';
annotation?: string;
description: string;
check: (content: string, lower: string) => Array<{ message: string; index: number }>;
}> = [
{
id: 'api-key-tenant-scope',
severity: 'error',
annotation: 'allow-api-key-auth',
description:
'apiKey table must be scoped per tenantId with hashedKey, scopes, expiresAt, lastUsedAt ' +
'to prevent cross-tenant replay and enable per-tenant rotation.',
check: (_, lower) => {
const matches: Array<{ message: string; index: number }> = [];
const re = /\bcreate\s+table\b[^;]{0,200}\bapi[_]?key\b/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(lower)) !== null) {
const statementEnd = lower.indexOf(';', m.index);
const windowEnd =
statementEnd === -1 ? m.index + 800 : Math.min(statementEnd, m.index + 800);
const slice = lower.slice(m.index, windowEnd);
const required = ['tenant_id', 'hashed_key', 'scopes', 'expires_at', 'last_used_at'];
const missing = required.filter((col) => !slice.includes(col));
if (missing.length > 0) {
matches.push({
message: `apiKey table missing tenant-scoped columns: ${missing.join(', ')} β€” cross-tenant replay risk`,
index: m.index,
});
}
}
return matches;
},
},
{
id: 'no-global-api-key-env',
severity: 'warning',
annotation: 'allow-tenant-scoped-auth',
description:
'Single global API_KEY env var allows cross-tenant replay; migrate to per-tenant keys with fallback deprecation log.',
check: (_, lower) => {
const matches: Array<{ message: string; index: number }> = [];
const re = /\bapi_key\b\s*=\s*['"`]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(lower)) !== null) {
matches.push({
message: `Hard-coded global API_KEY detected β€” use per-tenant hashedKey with fallback env var + deprecation log`,
index: m.index,
});
}
return matches;
},
},
];

// ── Main checker ─────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -236,6 +296,22 @@ export function checkMigrationFile(filePath: string): MigrationCheckResult {
const lower = content.toLowerCase();
const issues: MigrationIssue[] = [];

for (const rule of apiKeyRules) {
if (rule.annotation && hasAnnotation(content, rule.annotation)) {
continue;
}

const findings = rule.check(content, lower);
for (const finding of findings) {
issues.push({
severity: rule.severity,
rule: rule.id,
message: finding.message,
line: findLineNumber(content, finding.index),
});
}
}

for (const rule of rules) {
// Check annotation opt-out
if (rule.annotation && hasAnnotation(content, rule.annotation)) {
Expand Down
51 changes: 51 additions & 0 deletions backend/scripts/check-migrations.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,54 @@ const searchRoots = [
path.join(repoRoot, 'contracts', 'mock-strategy', 'migrations'),
];

// ─── API key per-tenant migration safety rules ───────────────────────────────
//
// The apiKey table stores per-tenant credentials with hashedKey, scopes,
// expiresAt and lastUsedAt. Migrations that touch this table must not
// introduce a NOT NULL column without a DEFAULT (breaks the legacy single-key
// fallback during canary rollout) and must not drop the legacy API_KEY env
// fallback column in the same migration that adds the new one.
const API_KEY_TABLE_PATTERN = /\bapi_?key\b/i;
const API_KEY_REQUIRED_COLUMNS = ['hashedkey', 'scopes', 'expiresat', 'lastusedat'];

function checkApiKeyMigration(content, file) {
const results = [];
const lowered = content.toLowerCase();

if (!API_KEY_TABLE_PATTERN.test(lowered)) {
return results;
}

// The migration must declare the per-tenant credential columns.
const missing = API_KEY_REQUIRED_COLUMNS.filter((col) => !lowered.includes(col));
if (missing.length > 0) {
results.push({
file,
severity: 'error',
message:
`apiKey migration is missing required column(s): ${missing.join(', ')}. ` +
'Per-tenant keys need hashedKey, scopes[], expiresAt and lastUsedAt for rotation and audit.',
});
}

// Dropping the legacy fallback in the same migration removes the env-var
// fallback path and breaks existing single-key deployments.
const dropsLegacyFallback =
/\bdrop\s+column\b[^;]{0,120}\b(api_?key|legacy_?key|env_?key)\b/i.test(content);
if (dropsLegacyFallback) {
results.push({
file,
severity: 'error',
message:
'Dropping the legacy API_KEY fallback column in the same migration that adds per-tenant keys ' +
'breaks existing single-key deployments. Keep the fallback until all tenants have migrated.',
});
}

return results;
}


const files = searchRoots.flatMap((root) => findMigrationFiles(root));

if (files.length === 0) {
Expand Down Expand Up @@ -148,6 +196,9 @@ function checkFile(file) {
}
}

// ── 5b. API key per-tenant migration safety ───────────────────────────────
results.push(...checkApiKeyMigration(content, file));

// ── 6. Schema change references indexed columns but declares no index ──────
const addsIndexedColumns = /(_id|status|created_at|updated_at|tenant_id)/i.test(content);
const hasIndex = /\bindex\b|\bcreate\s+index\b/i.test(content);
Expand Down
Loading