-
Notifications
You must be signed in to change notification settings - Fork 0
feat(audit): add immutable audit logging for device operations #1
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
Merged
Merged
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }); | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
1 change: 1 addition & 0 deletions
1
apps/telemetry-service/migrations/000002_create_audit_events.down.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| DROP TABLE IF EXISTS audit_events; |
40 changes: 40 additions & 0 deletions
40
apps/telemetry-service/migrations/000002_create_audit_events.up.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
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 not shown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| }() | ||
|
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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Export
writePoolfor graceful shutdown cleanup.The dedicated write pool is correctly configured for audit inserts. However,
server.tshandlesSIGTERMand closespoolandredis, but thiswritePoolisn't exported and won't be cleaned up, potentially leaving connections open during shutdown.🛠️ Proposed fix
Then in
server.ts, add cleanup:📝 Committable suggestion
🤖 Prompt for AI Agents