Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
51 changes: 51 additions & 0 deletions apps/gateway/src/lib/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Pool } from "pg";

// Write pool — points to primary DB, not read replica
const writePool = new Pool({
host: process.env.WRITE_DB_HOST || "postgres",
port: parseInt(process.env.WRITE_DB_PORT || "5432"),
database: process.env.WRITE_DB_NAME || "grainguard",
user: process.env.WRITE_DB_USER || "postgres",
password: process.env.WRITE_DB_PASSWORD || "postgres",
max: 5,
});

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

Export writePool for graceful shutdown cleanup.

The dedicated write pool is correctly configured for audit inserts. However, server.ts handles SIGTERM and closes pool and redis, but this writePool isn't exported and won't be cleaned up, potentially leaving connections open during shutdown.

🛠️ Proposed fix
-const writePool = new Pool({
+export const writePool = new Pool({
   host:     process.env.WRITE_DB_HOST     || "postgres",
   port:     parseInt(process.env.WRITE_DB_PORT || "5432"),
   database: process.env.WRITE_DB_NAME     || "grainguard",
   user:     process.env.WRITE_DB_USER     || "postgres",
   password: process.env.WRITE_DB_PASSWORD || "postgres",
   max: 5,
 });

Then in server.ts, add cleanup:

import { writePool } from "./lib/audit";

process.on("SIGTERM", async () => {
  await redis.quit();
  await pool.end();
  await writePool.end();
  process.exit(0);
});
📝 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
const writePool = new Pool({
host: process.env.WRITE_DB_HOST || "postgres",
port: parseInt(process.env.WRITE_DB_PORT || "5432"),
database: process.env.WRITE_DB_NAME || "grainguard",
user: process.env.WRITE_DB_USER || "postgres",
password: process.env.WRITE_DB_PASSWORD || "postgres",
max: 5,
});
export const writePool = new Pool({
host: process.env.WRITE_DB_HOST || "postgres",
port: parseInt(process.env.WRITE_DB_PORT || "5432"),
database: process.env.WRITE_DB_NAME || "grainguard",
user: process.env.WRITE_DB_USER || "postgres",
password: process.env.WRITE_DB_PASSWORD || "postgres",
max: 5,
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/lib/audit.ts` around lines 4 - 11, The write pool
(writePool) is not exported so server shutdown logic can't close it; export the
existing writePool constant (created with new Pool) from the audit module and
update the server shutdown handler to import writePool and call its end() (await
writePool.end()) alongside pool.end() and redis.quit() on SIGTERM to ensure
graceful cleanup of DB connections.


export type AuditEventType =
| "device.created"
| "device.creation_failed"
| "device.telemetry_queried"
| "auth.unauthorized"
| "admin.action";

export interface AuditEvent {
eventType: AuditEventType;
actorId: string;
tenantId: string;
resourceType: string;
resourceId?: string;
payload?: Record<string, any>;
ipAddress?: string;
userAgent?: string;
}

export async function logAuditEvent(event: AuditEvent): Promise<void> {
try {
await writePool.query(
`INSERT INTO audit_events
(event_type, actor_id, tenant_id, resource_type, resource_id, payload, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
event.eventType,
event.actorId,
event.tenantId,
event.resourceType,
event.resourceId || null,
JSON.stringify(event.payload || {}),
event.ipAddress || null,
event.userAgent || null,
]
);
} catch (err) {
console.error("[audit] failed to log event:", event.eventType, err);
}
}
21 changes: 20 additions & 1 deletion apps/gateway/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createDevice } from "./services/device";
import { getDeviceLatestTelemetry } from "./services/device-query";
import { redis } from "./cache/redis";
import { pool } from "./database/db";
import { logAuditEvent } from "./lib/audit";
import { metricsHandler, requestLatency } from "./observability/metrics";
import { requestIdMiddleware } from "./middleware/requestId";
import { authMiddleware } from "./middleware/auth";
Expand Down Expand Up @@ -156,10 +157,28 @@ app.post(
userId,
authHeader
);

logAuditEvent({
eventType: "device.created",
actorId: userId,
tenantId,
resourceType: "device",
resourceId: result?.deviceId || serialNumber,
payload: { serialNumber, requestId },
ipAddress: req.ip,
userAgent: req.headers["user-agent"],
});
return res.json(result);
} catch (err) {
console.error(err);
logAuditEvent({
eventType: "device.creation_failed",
actorId: req.user?.sub || "unknown",
tenantId: req.user?.tenantId || "00000000-0000-0000-0000-000000000000",
resourceType: "device",
payload: { serialNumber: req.body?.serialNumber, error: String(err) },
ipAddress: req.ip,
userAgent: req.headers["user-agent"],
});
return res.status(500).json({ error: "Failed to create device" });
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS audit_events;
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
-- Audit event log — immutable append-only record of all privileged actions
-- No UPDATE or DELETE grants — only INSERT and SELECT
CREATE TABLE IF NOT EXISTS audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
actor_id TEXT NOT NULL,
tenant_id UUID NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
payload JSONB NOT NULL DEFAULT '{}',
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_events_tenant_id ON audit_events (tenant_id, created_at DESC);
CREATE INDEX idx_audit_events_actor_id ON audit_events (actor_id, created_at DESC);
CREATE INDEX idx_audit_events_type ON audit_events (event_type, created_at DESC);

-- Immutable: only INSERT allowed for app role
REVOKE UPDATE, DELETE ON audit_events FROM PUBLIC;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

COMMENT ON TABLE audit_events IS
'Immutable audit log. Append-only. Every privileged action writes here.';

-- Enforce true immutability via trigger (blocks even table owner)
CREATE OR REPLACE FUNCTION audit_events_immutable()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'audit_events is immutable — UPDATE and DELETE are not allowed';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER audit_events_no_update
BEFORE UPDATE ON audit_events
FOR EACH ROW EXECUTE FUNCTION audit_events_immutable();

CREATE TRIGGER audit_events_no_delete
BEFORE DELETE ON audit_events
FOR EACH ROW EXECUTE FUNCTION audit_events_immutable();
Binary file added cmd
Binary file not shown.
84 changes: 84 additions & 0 deletions libs/audit/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package audit

import (
"context"
"encoding/json"
"log"
"time"

"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)

type EventType string

const (
EventDeviceCreated EventType = "device.created"
EventDeviceProvisioned EventType = "device.provisioned"
EventDeviceProvisionFailed EventType = "device.provision_failed"
EventTenantSwitched EventType = "tenant.switched"
EventTelemetryRecorded EventType = "telemetry.recorded"
EventSagaStarted EventType = "saga.started"
EventSagaCompleted EventType = "saga.completed"
EventSagaFailed EventType = "saga.failed"
EventAdminAction EventType = "admin.action"
)

type Event struct {
EventType EventType
ActorID string
TenantID uuid.UUID
ResourceType string
ResourceID string
Payload map[string]any
IPAddress string
UserAgent string
}

type Logger struct {
pool *pgxpool.Pool
}

func NewLogger(pool *pgxpool.Pool) *Logger {
return &Logger{pool: pool}
}

func (l *Logger) Log(ctx context.Context, event Event) {
// Fire and forget — use Background() to decouple from request lifecycle
// The caller context may be cancelled after the request completes
go func() {
if err := l.write(context.Background(), event); err != nil {
log.Printf("[audit] failed to write event=%s actor=%s err=%v",
event.EventType, event.ActorID, err)
}
}()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func (l *Logger) LogSync(ctx context.Context, event Event) error {
return l.write(ctx, event)
}

func (l *Logger) write(ctx context.Context, event Event) error {
payload, err := json.Marshal(event.Payload)
if err != nil {
payload = []byte("{}")
}

ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

_, err = l.pool.Exec(ctx,
`INSERT INTO audit_events
(event_type, actor_id, tenant_id, resource_type, resource_id, payload, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
string(event.EventType),
event.ActorID,
event.TenantID,
event.ResourceType,
event.ResourceID,
string(payload),
event.IPAddress,
event.UserAgent,
)
return err
}
7 changes: 7 additions & 0 deletions openapitools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.20.0"
}
}
Loading