Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6126b70
feat(staging): scaffold pre-prod environment and harden local/runtime…
pahuldeepp Mar 29, 2026
3c14d5c
fix(dashboard): satisfy settings export lint
pahuldeepp Mar 29, 2026
f8bac84
merge: update staging bootstrap with latest master
pahuldeepp Mar 29, 2026
bd8768f
fix(perf): use k6-compatible catch syntax
pahuldeepp Mar 29, 2026
4b00e4b
fix(security): make account route rate limits explicit
pahuldeepp Mar 29, 2026
df1c8c5
fix(staging): unblock e2e and perf validation
pahuldeepp Mar 29, 2026
4e664f1
fix(ci): stabilize staging bootstrap checks
pahuldeepp Mar 29, 2026
11180c9
fix(staging): wire deployable core services
pahuldeepp Mar 29, 2026
4d055ad
fix(security): add rate limiting to billing routes; fix k6 optional-c…
pahuldeepp Mar 29, 2026
8373a00
fix(staging): support tls-backed postgres and pinned images
pahuldeepp Mar 29, 2026
2271054
fix(staging): use ecr images and guard read-model rls migration
pahuldeepp Mar 29, 2026
58f6f78
fix(staging): pin gateway and bff to staging image tag
pahuldeepp Mar 29, 2026
f870225
fix(staging): add safe runtime env defaults for stripe and tls
pahuldeepp Mar 29, 2026
2cc13b6
fix(staging): align probes with live gateway and bff endpoints
pahuldeepp Mar 29, 2026
b9945ca
fix(staging): make gateway probe timeout configurable
pahuldeepp Mar 29, 2026
197eb5f
fix(staging): expose gateway health before app routes
pahuldeepp Mar 29, 2026
e0ff5b9
feat(staging): deploy dashboard and allow elb origins
pahuldeepp Mar 29, 2026
f1fc21e
fix(staging): match wildcard elb origins correctly
pahuldeepp Mar 29, 2026
2f80be8
fix(staging): support insecure-origin auth bypass over http
pahuldeepp Mar 29, 2026
186b9e9
feat(prod): add gateway canary rollout with analysis
pahuldeepp Mar 29, 2026
fa28b57
merge master into codex/staging-bootstrap after PR #16
pahuldeepp Mar 29, 2026
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
79 changes: 45 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> Production-grade, polyglot microservices SaaS platform for grain and agri operations.

GrainGuard ingests high-volume device telemetry, computes spoilage risk scores, triggers automated alert workflows, and ships with full multi-tenant billing, SSO, team management, audit logging, observability, CI/CD, chaos testing, SLO monitoring, and operational runbooks.
GrainGuard ingests high-volume device telemetry, computes spoilage risk scores, triggers automated alert workflows, and ships with multi-tenant billing, SSO, team management, audit logging, observability, CI/CD, load testing, and operational runbooks.

---

Expand Down Expand Up @@ -81,6 +81,18 @@ Risk Engine (Python) ── Workflow Alerts (Node.js) ── RabbitMQ ── Job

---

## Current Deployment Status

| Area | State |
|------|-------|
| Local Docker stack | ✅ Validated end-to-end |
| GitOps apps in repo | ✅ `dev`, `staging`, and `prod` ArgoCD apps committed |
| Terraform environments in repo | ✅ `dev` and `staging` committed |
| Dedicated staging environment | 🟡 Scaffold committed; deploy/validate next |
| Production rollout strategy | 🟡 Safe rolling deploys now; canary planned for production |

---

## SaaS Features

| Feature | Status |
Expand Down Expand Up @@ -172,18 +184,22 @@ go run tools/publish-telemetry/main.go
# Go unit + integration tests
go test -race -count=1 ./...

# Go lint
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.0 run --timeout=5m

# k6 load tests (requires running stack)
k6 run tests/load/spike.js
k6 run tests/load/soak.js
k6 run tests/load/stress.js

# Chaos tests (requires kubectl + live cluster)
bash tests/chaos/run-all.sh

# Replay + idempotency test
./scripts/replay/replay_test.sh
```

Note:
- The core load-test scripts above are committed in `tests/load/`.
- Cluster-level chaos automation is not currently committed on `master`; add or restore it before relying on README-driven chaos drills.
Comment on lines 190 to +201

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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 under scripts/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
 # k6 load tests (requires running stack)
-k6 run tests/load/spike.js
-k6 run tests/load/soak.js
-k6 run tests/load/stress.js
+k6 run scripts/load-tests/graphql-stress.js
+k6 run scripts/load-tests/ingest-stress.js
+k6 run scripts/load-tests/mixed-stack-stress.js

 # Replay + idempotency test
 ./scripts/replay/replay_test.sh
@@
 Note:
-- The core load-test scripts above are committed in `tests/load/`.
+- The core load-test scripts above are committed in `scripts/load-tests/`.
 - Cluster-level chaos automation is not currently committed on `master`; add or restore it before relying on README-driven chaos drills.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# k6 load tests (requires running stack)
k6 run tests/load/spike.js
k6 run tests/load/soak.js
k6 run tests/load/stress.js
# Chaos tests (requires kubectl + live cluster)
bash tests/chaos/run-all.sh
# Replay + idempotency test
./scripts/replay/replay_test.sh
```
Note:
- The core load-test scripts above are committed in `tests/load/`.
- Cluster-level chaos automation is not currently committed on `master`; add or restore it before relying on README-driven chaos drills.
# k6 load tests (requires running stack)
k6 run scripts/load-tests/graphql-stress.js
k6 run scripts/load-tests/ingest-stress.js
k6 run scripts/load-tests/mixed-stack-stress.js
# Replay + idempotency test
./scripts/replay/replay_test.sh
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 190 - 201, Update the README Testing section to point
to the new load-test locations and filenames: replace references to tests/load/
with scripts/load-tests/ and list the committed scripts graphql-stress.js,
ingest-stress.js, and mixed-stack-stress.js (e.g., k6 run
scripts/load-tests/graphql-stress.js, etc.); also update the Note bullet that
mentions tests/load/ to reference scripts/load-tests/ so the README and the new
PR-added scripts are in sync while leaving the existing replay path
(./scripts/replay/replay_test.sh) unchanged.


## Code Review Automation

This repository is preconfigured for CodeRabbit via [`/.coderabbit.yaml`](./.coderabbit.yaml).
Expand Down Expand Up @@ -215,35 +231,15 @@ Notes:

---

## Chaos Testing

Five experiments covering the critical failure modes:

| Experiment | What it kills | Pass condition |
|------------|--------------|----------------|
| `pod-kill` | gateway, bff, telemetry-service pods | Respawns within 30s |
| `kafka-consumer-pause` | read-model-builder + cdc-transformer | Lag ≤ 10 000 within 5 min |
| `redis-outage` | Redis | BFF falls back to DB, no panics |
| `projection-lag` | read-model-builder | Alert fires, lag recovers in 5 min |
| `network-partition` | telemetry-service → Kafka egress | Messages buffered, delivered after heal |

```bash
# Run all experiments
bash tests/chaos/run-all.sh

# Or trigger via GitHub Actions (manual dispatch)
# .github/workflows/chaos.yml — also runs weekly on Saturdays
```

---

## Operational Runbooks

| Runbook | Trigger |
|---------|---------|
| [Postgres Backup / Restore](docs/runbooks/postgres-backup-restore.md) | Backup verification, restore drill, data recovery |
| [Postgres Failover](docs/runbooks/postgres-failover.md) | Primary down, replica lag high |
| [Kafka Loss](docs/runbooks/kafka-loss.md) | Broker down, under-replicated partitions |
| [DLQ Spike](docs/runbooks/dlq-spike.md) | `DLQMessagesAccumulating` alert |
| [Redis Backup / Restore](docs/runbooks/redis-backup-restore.md) | Cache restore drill, persistence recovery |
| [Redis Failover](docs/runbooks/redis-failover.md) | Cache miss 100%, lock timeouts |
| [Projection Lag](docs/runbooks/projection-lag.md) | `ProjectionLagHigh` alert |
| [gRPC Outage](docs/runbooks/grpc-outage.md) | Circuit breaker open, 503 upstream |
Expand All @@ -261,6 +257,8 @@ terraform apply -var="db_password=yourpassword"

Provisions: VPC · EKS · RDS Postgres · Elasticache Redis · MSK Kafka · DynamoDB · ECR · Secrets Manager

Today, `dev` and `staging` Terraform environments are committed in-repo. The next step is to deploy and validate `staging` before treating the rollout path as production-ready.

---

## Kubernetes (GitOps)
Expand All @@ -278,6 +276,14 @@ helm diff upgrade grainguard k8s/helm/grainguard \

ArgoCD watches `k8s/argocd/apps/` and auto-syncs on every push to master.

Committed applications today:
- `grainguard-dev` -> `grainguard-dev`
- `grainguard-staging` -> `grainguard-staging`
- `grainguard-prod` -> `grainguard-prod`

Recommended next environment:
- `grainguard-staging` -> deploy and validate ingress, TLS, DNS, secrets, restore drills, and production-like auth/billing flows before first prod rollout

---

## Architecture Decision Records
Expand All @@ -303,21 +309,26 @@ ArgoCD watches `k8s/argocd/apps/` and auto-syncs on every push to master.
|-------|------|--------|
| R1 — Core loop | Ingest, CQRS, outbox, saga | ✅ Done |
| R2 — CDC + Search | Debezium, Elasticsearch, RabbitMQ | ✅ Done |
| R3 — Reliability | Helm, ArgoCD, k6 load tests, chaos tests | ✅ Done |
| R3 — Reliability baseline | Helm, ArgoCD scaffolding, k6 load tests, runbooks | ✅ Done |
| R4 — Observability | SLOs, burn-rate alerts, Grafana dashboard, runbooks | ✅ Done |
| R5 — Security | CSRF, rate limiting, audit logging, RBAC, API keys | ✅ Done |
| R6 — SaaS billing | Stripe, tenant onboarding, team management, SSO, webhooks | ✅ Done |
| R7 — DB migrations | Flyway/Knex migration framework, schema versioning | 🔜 Next |
| R8 — Secret management | HashiCorp Vault / AWS Secrets Manager integration | 🔜 Planned |
| R7 — Staging environment | Dedicated Argo app, Terraform env, deployed validation | 🟡 Scaffolded |
| R8 — Production hardening | Canary rollout, restore proof, deployed auth/webhook validation | 🔜 Next |

---

## Load test results
## Latest Local Validation

Latest mixed read/write validation on `master` (local Docker stack):

- Kafka ingest: **1,700 events/sec**
- Gateway p95 latency: **5.89ms**
- Read model builder: **2,500–3,000 events/sec** sustained
- **35,077** total requests
- **438 req/s** aggregate throughput
- **0%** HTTP failure rate
- Gateway GraphQL p95: **11.5 ms**
- Ingest p95: **10.8 ms**
- Kafka consumer groups drained back to **0 lag** after the run

---

*Built to demonstrate end-to-end DDIA patterns, distributed systems, GitOps, SRE practices, and production multi-tenant SaaS architecture.*
*Built to demonstrate end-to-end DDIA patterns, distributed systems, GitOps, SRE practices, and production-style multi-tenant SaaS architecture.*
14 changes: 11 additions & 3 deletions apps/dashboard/src/features/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Comment thread
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Translate backend error codes before showing the toast.

The gateway currently returns machine codes like internal_error on export failures, so this branch will show "internal_error" directly to end users. Map known codes to friendly copy and keep raw values out of the toast.

💡 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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`;
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");
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const code = typeof body?.error === "string" ? body.error : null;
throw new Error(
code === "internal_error"
? "Export failed. Please try again."
: code ?? `HTTP ${res.status}`
);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
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`;
a.click();
URL.revokeObjectURL(url);
toast.success("Data exported");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Export failed");
🧰 Tools
🪛 GitHub Actions: CI

[error] 117-117: ESLint (no-useless-escape): Unnecessary escape character: "

🪛 GitHub Check: TS Checks — dashboard

[failure] 117-117:
Unnecessary escape character: "


[failure] 117-117:
Unnecessary escape character: "

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/features/settings/SettingsPage.tsx` around lines 106 -
123, The catch currently shows raw backend error codes (e.g., "internal_error")
to users; add a small translation layer and use it before calling toast.error.
Implement a helper like translateExportError(errorCode: string): string (add
near SettingsPage or above the export handler) that maps known codes (e.g.,
"internal_error" -> "An internal server error occurred. Please try again
later.", "not_authenticated" -> "Please sign in to export data.", etc.) and
returns a friendly default for unknown values; then replace the toast.error call
in the catch block to use toast.error(translateExportError(e instanceof Error ?
e.message : "")) so users see human-friendly messages instead of raw machine
codes.

}
}

Expand Down
23 changes: 18 additions & 5 deletions apps/gateway/src/routes/__tests__/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,19 +75,32 @@ describe("DELETE /account/me", () => {
.mockResolvedValueOnce(undefined as any) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: "a1" }] } as any) // only admin
.mockResolvedValueOnce({ rows: [{ id: "u1", role: "admin" }] } as any) // user
.mockResolvedValueOnce(undefined as any) // DELETE invites
.mockResolvedValueOnce(undefined as any) // DELETE api_keys
.mockResolvedValueOnce(undefined as any) // DELETE alert_rules
.mockResolvedValueOnce(undefined as any) // DELETE audit_events
.mockResolvedValueOnce(undefined as any) // DELETE telemetry_readings
.mockResolvedValueOnce(undefined as any) // DELETE devices
.mockResolvedValueOnce(undefined as any) // DELETE tenant_users
.mockResolvedValueOnce({ rows: [{ count: 0 }] } as any) // COUNT audit_events
.mockResolvedValueOnce(undefined as any) // DELETE tenants
.mockResolvedValueOnce(undefined as any); // COMMIT

const res = await request(app).delete("/account/me");
expect(res.status).toBe(200);
expect(res.body.scope).toBe("tenant");
});

it("reports retained immutable audit events when present", async () => {
mockPool.query
.mockResolvedValueOnce(undefined as any) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: "a1" }] } as any) // only admin
.mockResolvedValueOnce({ rows: [{ id: "u1", role: "admin" }] } as any) // user
.mockResolvedValueOnce(undefined as any) // DELETE telemetry_readings
.mockResolvedValueOnce(undefined as any) // DELETE devices
.mockResolvedValueOnce({ rows: [{ count: 3 }] } as any) // COUNT audit_events
.mockResolvedValueOnce(undefined as any) // DELETE tenants
.mockResolvedValueOnce(undefined as any); // COMMIT

const res = await request(app).delete("/account/me");
expect(res.status).toBe(200);
expect(res.body.message).toContain("Immutable audit events");
});
});

describe("GET /account/export", () => {
Expand Down
12 changes: 12 additions & 0 deletions apps/gateway/src/routes/__tests__/sso.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ jest.mock("../../lib/auth0Management", () => ({

import { ssoRouter } from "../sso";
import { writePool as pool } from "../../database/db";
import { listOrgConnections } from "../../lib/auth0Management";

const app = express();
app.use(express.json());
app.use(ssoRouter);

const mockPool = pool as unknown as { query: jest.Mock };
const mockListOrgConnections = listOrgConnections as jest.Mock;

describe("GET /tenants/me/sso", () => {
it("returns unconfigured state when no org exists", async () => {
Expand All @@ -44,6 +46,16 @@ describe("GET /tenants/me/sso", () => {
expect(res.body.configured).toBe(true);
expect(res.body.connections).toHaveLength(1);
});

it("returns a soft warning when Auth0 management is unavailable", async () => {
mockPool.query.mockResolvedValue({ rows: [{ auth0_org_id: "org-123" }] } as any);
mockListOrgConnections.mockRejectedValueOnce(new Error("SSO not configured"));
const res = await request(app).get("/tenants/me/sso");
expect(res.status).toBe(200);
expect(res.body.configured).toBe(true);
expect(res.body.connections).toEqual([]);
expect(res.body.warning).toContain("Auth0 management API");
});
});

describe("POST /tenants/me/sso/org", () => {
Expand Down
97 changes: 62 additions & 35 deletions apps/gateway/src/routes/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,47 +46,68 @@
// If the user is the last admin, the entire tenant is deleted.
accountRouter.delete(
"/account/me",
apiRateLimiter,
Comment thread Fixed

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.

Copilot Autofix

AI 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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.ts

Repository: pahuldeepp/GrainGuard-

Length of output: 104


🏁 Script executed:

# Read the relevant sections of account.ts
head -20 apps/gateway/src/routes/account.ts

Repository: pahuldeepp/GrainGuard-

Length of output: 823


🏁 Script executed:

# Check lines around line 49-50
sed -n '40,60p' apps/gateway/src/routes/account.ts

Repository: pahuldeepp/GrainGuard-

Length of output: 662


🏁 Script executed:

# Check lines around line 135-136
sed -n '125,145p' apps/gateway/src/routes/account.ts

Repository: pahuldeepp/GrainGuard-

Length of output: 905


🏁 Script executed:

# Check the rateLimiting middleware to see what limiters are available
cat apps/gateway/src/middleware/rateLimiting.ts

Repository: pahuldeepp/GrainGuard-

Length of output: 2116


Replace redundant apiRateLimiter applications with dedicated route-level limiters.

Line 8 already applies apiRateLimiter to all account routes via accountRouter.use(). Reapplying it on the DELETE and GET /export endpoints at lines 49-50 and 135-136 is redundant and prevents these sensitive GDPR endpoints from using tighter rate limits.

Replace with:

  • strictRateLimiter (100 req/60s) for DELETE /account/me (account erasure)
  • bulkRateLimiter (10 req/60s) for GET /account/export (data portability)
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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
apiRateLimiter,
authMiddleware,
strictRateLimiter,
authMiddleware,
🧰 Tools
🪛 GitHub Check: CodeQL

[failure] 49-49: Missing rate limiting
This route handler performs a database access, but is not rate-limited.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/routes/account.ts` around lines 49 - 50, The accountRouter
currently applies apiRateLimiter globally via accountRouter.use(), but the
DELETE /account/me and GET /account/export routes still re-apply apiRateLimiter;
replace those redundant uses with route-level stricter limiters: remove
apiRateLimiter from the DELETE handler for the account erasure route and attach
strictRateLimiter (100 req/60s) to that route, and remove apiRateLimiter from
the GET export handler and attach bulkRateLimiter (10 req/60s) to the export
route; locate the routes by the DELETE /account/me and GET /account/export
handlers and the global accountRouter.use() and swap the middleware references
accordingly so global apiRateLimiter remains in place for other endpoints.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Update the delete UX/docs to match the retained-audit behavior.

This branch now intentionally keeps audit_events, but the dashboard confirmation in apps/dashboard/src/features/settings/SettingsPage.tsx still says audit logs will be permanently deleted. Please update that copy in the same PR so the erasure flow does not promise behavior the backend no longer performs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/routes/account.ts` around lines 95 - 109, The backend now
retains immutable audit_events when deleting a tenant (see the deletion response
constructed in account route: return res.json(...) in the handler that performs
DELETE FROM tenants), so update the dashboard confirmation copy to match: in the
SettingsPage component (SettingsPage.tsx) locate the tenant erasure
confirmation/confirmation modal text (the string that currently says audit logs
will be permanently deleted) and change it to indicate audit logs are retained
for compliance (e.g., “Audit logs will be retained and cannot be deleted for
compliance”); update any related help text or docs strings in the same component
to reflect retained-audit behavior so the UI matches the account.ts response.

}

// Just remove this user from the tenant
Expand All @@ -111,35 +132,41 @@
// GDPR Article 20 — Right to Data Portability. Returns all user data as JSON.
accountRouter.get(
"/account/export",
apiRateLimiter,
Comment thread Fixed

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.

Copilot Autofix

AI 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Restrict this export to admins or narrow it to per-user data.

This handler exports tenant_users, devices, alert_rules, audit_events, and api_keys for the whole tenant, so any authenticated member can download other users’ emails and tenant-wide operational data. Either add an admin check here or scope the export to the requesting user’s own records.

As per coding guidelines, "Verify tenantId, RBAC, and CSRF protections are applied consistently on all protected routes."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/routes/account.ts` around lines 136 - 146, This route is
returning tenant-wide sensitive data; enforce RBAC by checking the authenticated
user's role (req.user and req.user.role) before running the tenant-wide queries:
if req.user.role !== 'admin' return 403, otherwise continue; alternatively, for
non-admins scope each query to the requesting user by replacing tenant-wide
queries for tenant_users, devices, alert_rules, audit_events, and api_keys with
user-scoped queries (filter by req.user.id or actor_id where appropriate) so
only the requester’s records are exported; update the code around the existing
tenantId and Promise.all block that runs pool.query for tenant_users, devices,
alert_rules, audit_events, and api_keys to enforce the chosen behavior.

]);

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Mark the export response as non-cacheable.

This attachment contains sensitive tenant data, but the response never sets Cache-Control: no-store. Browsers and intermediaries can retain the file unless caching is explicitly disabled.

🛡️ 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
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/routes/account.ts` around lines 161 - 166, The export
response currently sets Content-Type and Content-Disposition but does not
prevent caching; update the handler around where res.setHeader(...) and return
res.json(exportData) are called to add cache-busting headers (e.g. set
"Cache-Control: no-store, no-cache, must-revalidate", "Pragma: no-cache", and
"Expires: 0") before returning; locate the logic in the account export route
(the block that calls res.setHeader(...) and res.json(exportData)) and insert
these headers so the exported tenant file is not stored by browsers or
intermediaries.

} catch (err) {
console.error("[account] export error:", err);
return res.status(500).json({ error: "internal_error" });
}
}

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.
);
16 changes: 13 additions & 3 deletions apps/gateway/src/routes/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Catching all errors masks configuration issues.

The try/catch catches every error from listOrgConnections, including configuration errors thrown by getManagementToken() (e.g., missing AUTH0_DOMAIN, AUTH0_MANAGEMENT_CLIENT_ID, AUTH0_MANAGEMENT_CLIENT_SECRET). These are operator misconfigurations that should surface clearly, not be hidden behind a generic "unavailable" warning.

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
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/routes/sso.ts` around lines 46 - 58, The catch in the route
swallowing every error from listOrgConnections hides operator configuration
problems (e.g. errors from getManagementToken when AUTH0_* env vars are
missing); update error handling so configuration errors surface: have
getManagementToken (or listOrgConnections) throw a distinct error type or
include a recognisable property/message (e.g. ConfigError or error.code ===
'CONFIG_MISSING') and in the sso route catch distinguish those and rethrow or
return a 5xx response indicating misconfiguration, while still converting
transient Auth0 API failures into the existing configured:true with warning
response; reference listOrgConnections and getManagementToken when adding the
new error type check and branching logic.

}
);

Expand Down
Loading
Loading