-
Notifications
You must be signed in to change notification settings - Fork 0
feat(staging): scaffold pre-prod environment and harden local/runtime flows #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
6126b70
3c14d5c
f8bac84
bd8768f
4b00e4b
df1c8c5
4e664f1
11180c9
4d055ad
8373a00
2271054
58f6f78
f870225
2cc13b6
b9945ca
197eb5f
e0ff5b9
f1fc21e
2f80be8
186b9e9
fa28b57
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -103,16 +103,24 @@ export function SettingsPage() { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const res = await fetch(`${GW}/account/export`, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: { Authorization: `Bearer ${token}` }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!res.ok) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const body = await res.json().catch(() => ({})); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| typeof body?.error === "string" ? body.error : `HTTP ${res.status}` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const blob = await res.blob(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const url = URL.createObjectURL(blob); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const a = document.createElement("a"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| a.href = url; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| a.download = `grainguard-export-${new Date().toISOString().slice(0, 10)}.json`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const disposition = res.headers.get("content-disposition") ?? ""; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const filenameMatch = disposition.match(/filename="?([^"]+)"?/i); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| a.download = filenameMatch?.[1] ?? `grainguard-export-${new Date().toISOString().slice(0, 10)}.json`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| a.click(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| URL.revokeObjectURL(url); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toast.success("Data exported"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toast.error("Export failed"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toast.error(e instanceof Error ? e.message : "Export failed"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+106
to
+123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Translate backend error codes before showing the toast. The gateway currently returns machine codes like 💡 Proposed fix if (!res.ok) {
const body = await res.json().catch(() => ({}));
- throw new Error(
- typeof body?.error === "string" ? body.error : `HTTP ${res.status}`
- );
+ const code = typeof body?.error === "string" ? body.error : null;
+ throw new Error(
+ code === "internal_error"
+ ? "Export failed. Please try again."
+ : code ?? `HTTP ${res.status}`
+ );
}📝 Committable suggestion
Suggested change
🧰 Tools🪛 GitHub Actions: CI[error] 117-117: ESLint (no-useless-escape): Unnecessary escape character: " 🪛 GitHub Check: TS Checks — dashboard[failure] 117-117: [failure] 117-117: 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -46,47 +46,68 @@ | |||||||||
| // If the user is the last admin, the entire tenant is deleted. | ||||||||||
| accountRouter.delete( | ||||||||||
| "/account/me", | ||||||||||
| apiRateLimiter, | ||||||||||
Check failureCode scanning / CodeQL Missing rate limiting High
This route handler performs
a database access Error loading related location Loading Copilot AutofixAI 4 months ago Copilot could not generate an autofix suggestion Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support. |
||||||||||
| authMiddleware, | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's look at the account.ts file to understand the router setup
wc -l apps/gateway/src/routes/account.tsRepository: pahuldeepp/GrainGuard- Length of output: 104 🏁 Script executed: # Read the relevant sections of account.ts
head -20 apps/gateway/src/routes/account.tsRepository: pahuldeepp/GrainGuard- Length of output: 823 🏁 Script executed: # Check lines around line 49-50
sed -n '40,60p' apps/gateway/src/routes/account.tsRepository: pahuldeepp/GrainGuard- Length of output: 662 🏁 Script executed: # Check lines around line 135-136
sed -n '125,145p' apps/gateway/src/routes/account.tsRepository: pahuldeepp/GrainGuard- Length of output: 905 🏁 Script executed: # Check the rateLimiting middleware to see what limiters are available
cat apps/gateway/src/middleware/rateLimiting.tsRepository: pahuldeepp/GrainGuard- Length of output: 2116 Replace redundant Line 8 already applies Replace with:
Suggested change-import { apiRateLimiter } from "../middleware/rateLimiting";
+import { apiRateLimiter, bulkRateLimiter, strictRateLimiter } from "../middleware/rateLimiting";
...
accountRouter.delete(
"/account/me",
- apiRateLimiter,
+ strictRateLimiter,
authMiddleware,
...
accountRouter.get(
"/account/export",
- apiRateLimiter,
+ bulkRateLimiter,
authMiddleware,📝 Committable suggestion
Suggested change
🧰 Tools🪛 GitHub Check: CodeQL[failure] 49-49: Missing rate limiting 🤖 Prompt for AI Agents |
||||||||||
| async (req: Request, res: Response) => { | ||||||||||
| const tenantId = req.user!.tenantId; | ||||||||||
| const userId = req.user!.sub; | ||||||||||
| const client = await pool.connect(); | ||||||||||
|
|
||||||||||
| try { | ||||||||||
| await client.query("BEGIN"); | ||||||||||
|
|
||||||||||
| // Check if user is the last admin | ||||||||||
| const { rows: admins } = await client.query( | ||||||||||
| "SELECT id FROM tenant_users WHERE tenant_id = $1 AND role = 'admin'", | ||||||||||
| [tenantId] | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| const { rows: userRows } = await client.query( | ||||||||||
| "SELECT id, role FROM tenant_users WHERE tenant_id = $1 AND auth_user_id = $2", | ||||||||||
| [tenantId, userId] | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| if (userRows.length === 0) { | ||||||||||
| await client.query("ROLLBACK"); | ||||||||||
| return res.status(404).json({ error: "user_not_found" }); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const isLastAdmin = | ||||||||||
| userRows[0].role === "admin" && | ||||||||||
| admins.length === 1; | ||||||||||
|
|
||||||||||
| if (isLastAdmin) { | ||||||||||
| // Delete the entire tenant and all associated data | ||||||||||
| await client.query("DELETE FROM tenant_invites WHERE tenant_id = $1", [tenantId]); | ||||||||||
| await client.query("DELETE FROM api_keys WHERE tenant_id = $1", [tenantId]); | ||||||||||
| await client.query("DELETE FROM alert_rules WHERE tenant_id = $1", [tenantId]); | ||||||||||
| await client.query("DELETE FROM audit_events WHERE tenant_id = $1", [tenantId]); | ||||||||||
| // Delete tenant-owned device data first because telemetry_readings | ||||||||||
| // references devices without ON DELETE CASCADE. | ||||||||||
| await client.query( | ||||||||||
| `DELETE FROM telemetry_readings tr | ||||||||||
| USING devices d | ||||||||||
| WHERE tr.device_id = d.id | ||||||||||
| AND d.tenant_id = $1`, | ||||||||||
| [tenantId] | ||||||||||
| ); | ||||||||||
| await client.query("DELETE FROM devices WHERE tenant_id = $1", [tenantId]); | ||||||||||
| await client.query("DELETE FROM tenant_users WHERE tenant_id = $1", [tenantId]); | ||||||||||
|
|
||||||||||
| const { rows: auditEventRows } = await client.query( | ||||||||||
| "SELECT COUNT(*)::int AS count FROM audit_events WHERE tenant_id = $1", | ||||||||||
| [tenantId] | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| // Most tenant-linked tables cascade from tenants, so deleting the | ||||||||||
| // tenant removes them automatically. Immutable audit_events are | ||||||||||
| // intentionally retained for compliance and cannot be deleted. | ||||||||||
| await client.query("DELETE FROM tenants WHERE id = $1", [tenantId]); | ||||||||||
|
|
||||||||||
| await client.query("COMMIT"); | ||||||||||
| return res.json({ deleted: true, scope: "tenant", message: "Tenant and all data deleted" }); | ||||||||||
| const immutableAuditEvents = auditEventRows[0]?.count ?? 0; | ||||||||||
| return res.json({ | ||||||||||
| deleted: true, | ||||||||||
| scope: "tenant", | ||||||||||
| message: | ||||||||||
| immutableAuditEvents > 0 | ||||||||||
| ? "Tenant deleted. Immutable audit events were retained for compliance." | ||||||||||
| : "Tenant and all mutable data deleted", | ||||||||||
| }); | ||||||||||
|
Comment on lines
+95
to
+109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update the delete UX/docs to match the retained-audit behavior. This branch now intentionally keeps 🤖 Prompt for AI Agents |
||||||||||
| } | ||||||||||
|
|
||||||||||
| // Just remove this user from the tenant | ||||||||||
|
|
@@ -111,35 +132,41 @@ | |||||||||
| // GDPR Article 20 — Right to Data Portability. Returns all user data as JSON. | ||||||||||
| accountRouter.get( | ||||||||||
| "/account/export", | ||||||||||
| apiRateLimiter, | ||||||||||
Check failureCode scanning / CodeQL Missing rate limiting High
This route handler performs
a database access Error loading related location Loading Copilot AutofixAI 4 months ago Copilot could not generate an autofix suggestion Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support. |
||||||||||
| authMiddleware, | ||||||||||
| async (req: Request, res: Response) => { | ||||||||||
| const tenantId = req.user!.tenantId; | ||||||||||
|
|
||||||||||
| const [tenantResult, usersResult, devicesResult, alertsResult, auditResult, keysResult] = | ||||||||||
| await Promise.all([ | ||||||||||
| pool.query("SELECT id, name, slug, plan, email, created_at FROM tenants WHERE id = $1", [tenantId]), | ||||||||||
| pool.query("SELECT id, email, role, created_at FROM tenant_users WHERE tenant_id = $1", [tenantId]), | ||||||||||
| pool.query("SELECT id, serial_number, created_at FROM devices WHERE tenant_id = $1", [tenantId]), | ||||||||||
| pool.query("SELECT id, name, metric, operator, threshold, enabled, created_at FROM alert_rules WHERE tenant_id = $1", [tenantId]), | ||||||||||
| pool.query("SELECT id, event_type, actor_id, resource_type, payload, created_at FROM audit_events WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT 1000", [tenantId]), | ||||||||||
| pool.query("SELECT id, name, created_at, expires_at, revoked_at FROM api_keys WHERE tenant_id = $1", [tenantId]), | ||||||||||
| ]); | ||||||||||
|
|
||||||||||
| const exportData = { | ||||||||||
| exportedAt: new Date().toISOString(), | ||||||||||
| tenant: tenantResult.rows[0] || null, | ||||||||||
| users: usersResult.rows, | ||||||||||
| devices: devicesResult.rows, | ||||||||||
| alertRules: alertsResult.rows, | ||||||||||
| auditEvents: auditResult.rows, | ||||||||||
| apiKeys: keysResult.rows, | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| res.setHeader("Content-Type", "application/json"); | ||||||||||
| res.setHeader( | ||||||||||
| "Content-Disposition", | ||||||||||
| `attachment; filename="grainguard-export-${tenantId}-${new Date().toISOString().slice(0, 10)}.json"` | ||||||||||
| ); | ||||||||||
| return res.json(exportData); | ||||||||||
| try { | ||||||||||
| const tenantId = req.user!.tenantId; | ||||||||||
|
|
||||||||||
| const [tenantResult, usersResult, devicesResult, alertsResult, auditResult, keysResult] = | ||||||||||
| await Promise.all([ | ||||||||||
| pool.query("SELECT id, name, slug, plan, email, created_at FROM tenants WHERE id = $1", [tenantId]), | ||||||||||
| pool.query("SELECT id, email, role, created_at FROM tenant_users WHERE tenant_id = $1", [tenantId]), | ||||||||||
| pool.query("SELECT id, serial_number, created_at FROM devices WHERE tenant_id = $1", [tenantId]), | ||||||||||
| pool.query("SELECT id, name, metric, operator, threshold, enabled, created_at FROM alert_rules WHERE tenant_id = $1", [tenantId]), | ||||||||||
| pool.query("SELECT id, event_type, actor_id, resource_type, payload, created_at FROM audit_events WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT 1000", [tenantId]), | ||||||||||
| pool.query("SELECT id, name, created_at, expires_at, revoked_at FROM api_keys WHERE tenant_id = $1", [tenantId]), | ||||||||||
|
Comment on lines
+137
to
+147
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Restrict this export to admins or narrow it to per-user data. This handler exports As per coding guidelines, "Verify tenantId, RBAC, and CSRF protections are applied consistently on all protected routes." 🤖 Prompt for AI Agents |
||||||||||
| ]); | ||||||||||
|
|
||||||||||
| const exportData = { | ||||||||||
| exportedAt: new Date().toISOString(), | ||||||||||
| tenant: tenantResult.rows[0] || null, | ||||||||||
| users: usersResult.rows, | ||||||||||
| devices: devicesResult.rows, | ||||||||||
| alertRules: alertsResult.rows, | ||||||||||
| auditEvents: auditResult.rows, | ||||||||||
| apiKeys: keysResult.rows, | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| res.setHeader("Content-Type", "application/json"); | ||||||||||
| res.setHeader( | ||||||||||
| "Content-Disposition", | ||||||||||
| `attachment; filename="grainguard-export-${tenantId}-${new Date().toISOString().slice(0, 10)}.json"` | ||||||||||
| ); | ||||||||||
| return res.json(exportData); | ||||||||||
|
Comment on lines
+160
to
+165
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mark the export response as non-cacheable. This attachment contains sensitive tenant data, but the response never sets 🛡️ Suggested header hardening res.setHeader("Content-Type", "application/json");
+ res.setHeader("Cache-Control", "private, no-store, max-age=0");
+ res.setHeader("Pragma", "no-cache");
res.setHeader(
"Content-Disposition",
`attachment; filename="grainguard-export-${tenantId}-${new Date().toISOString().slice(0, 10)}.json"`
);🤖 Prompt for AI Agents |
||||||||||
| } catch (err) { | ||||||||||
| console.error("[account] export error:", err); | ||||||||||
| return res.status(500).json({ error: "internal_error" }); | ||||||||||
| } | ||||||||||
| } | ||||||||||
Check failureCode scanning / CodeQL Missing rate limiting High
This route handler performs
a database access Error loading related location Loading This route handler performs a database access Error loading related location Loading This route handler performs a database access Error loading related location Loading This route handler performs a database access Error loading related location Loading This route handler performs a database access Error loading related location Loading This route handler performs a database access Error loading related location Loading |
||||||||||
| ); | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,9 +43,19 @@ ssoRouter.get( | |
| } | ||
|
|
||
| const orgId = rows[0].auth0_org_id; | ||
| const connections = await listOrgConnections(orgId); | ||
|
|
||
| return res.json({ configured: true, orgId, connections }); | ||
| try { | ||
| const connections = await listOrgConnections(orgId); | ||
|
|
||
| return res.json({ configured: true, orgId, connections }); | ||
| } catch (error) { | ||
| console.error("[sso] failed to list org connections:", error); | ||
| return res.json({ | ||
| configured: true, | ||
| orgId, | ||
| connections: [], | ||
| warning: "Auth0 management API is unavailable for the gateway right now.", | ||
| }); | ||
| } | ||
|
Comment on lines
+46
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Catching all errors masks configuration issues. The try/catch catches every error from Consider distinguishing transient API failures from configuration errors: Proposed fix to differentiate error types try {
const connections = await listOrgConnections(orgId);
return res.json({ configured: true, orgId, connections });
} catch (error) {
+ const message = error instanceof Error ? error.message : "";
+ // Configuration errors should fail loudly, not be masked
+ if (message.includes("SSO not configured") || message.includes("must be set")) {
+ console.error("[sso] configuration error:", error);
+ return res.status(500).json({ error: "sso_misconfigured" });
+ }
console.error("[sso] failed to list org connections:", error);
return res.json({
configured: true,
orgId,
connections: [],
warning: "Auth0 management API is unavailable for the gateway right now.",
});
}🤖 Prompt for AI Agents |
||
| } | ||
| ); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Load-test paths in Testing section are out of sync with the new committed scripts.
Line 191-Line 193 and Line 200 reference
tests/load/, but this PR adds load tests underscripts/load-tests/(graphql-stress.js,ingest-stress.js,mixed-stack-stress.js). Please align the README so users run the intended scripts.📌 Proposed README fix
📝 Committable suggestion
🤖 Prompt for AI Agents