Skip to content

Repository files navigation

Go VersionPostgreSQLPhase 5CI

ControlPlane

A server-rendered Go web application for internal runbooks, incident documentation, and operational knowledge sharing. Built as a serious backend engineering project: HTML templates, forms, sessions, PostgreSQL, access control, approvals, audit history, and moderation — all without hiding behind a frontend SPA or a JSON-only API.

The project currently supports:

  • session-backed authentication with login/logout flows
  • server-rendered pages with a startup template cache
  • notebook drafts, revisions, and content versioning
  • tag-based categorization and search
  • approval workflow with submit → approve/reject state transitions
  • role and team-based access control (member, reviewer, admin)
  • transactional audit logging for all governance-critical actions
  • moderation flags for content governance
  • handler-level unit tests with a fake store abstraction
  • GitHub Actions CI pipeline with automated test and build verification

To make the implementation easier to follow, the sections below include code excerpts from the actual codebase so the architecture description maps directly to real files and logic.


Table of Contents


Why This Project Exists

This project is meant to teach the part of backend development that JSON APIs let you postpone:

  • server-rendered page architecture
  • form handling and validation
  • session-backed authentication
  • cookies and CSRF-aware flows
  • database-backed page rendering
  • role and team-based access control
  • auditability and operational governance

The goal is to end up with something that feels closer to an internal production tool than to a tutorial CRUD app.


Product Vision

ControlPlane is an internal notebook and runbook system for teams.

The intended workflow looks like this:

  1. A user signs in
  2. Creates or edits a draft runbook
  3. Assigns tags, ownership, and visibility scope
  4. Submits it for review
  5. A reviewer approves or rejects it
  6. The published version becomes visible to the right team
  7. Moderation and audit history capture the important actions along the way

This is not a blog, not a todo app, and not a public wiki. It is an operational knowledge system with governance.


Current Status

Phase 5 — Production Hardening (Complete)

Phase 5 test files, integration tests, rate limiting, and CI pipelines are now complete. The backend is fully hardened for production use.

PhaseDescriptionStatus
Phase 0Foundation, wiring, shared dependencies✅ Complete
Phase 1First server-rendered page, template cache✅ Complete
Phase 2Forms, validation, sessions, auth, context✅ Complete
Phase 3Notebooks, revisions, tags, search, DB-backed pages✅ Complete
Phase 4Approvals, audit, moderation, governance✅ Complete
Phase 5Tests, CI, production hardening✅ Complete
Phase 6Advanced: Metrics, Tracing, Docker/K8s, Shared Rate Limiting🚧 Planned

Architecture

The architecture is intentionally conservative and explicit:

  • main.go builds the long-lived dependencies once
  • an Application struct carries shared dependencies (config, DB pool, session manager, template cache)
  • app.routes() builds the router and middleware graph
  • handlers are methods on app
  • request-scoped data lives in *http.Request and Context, not on global variables
flowchart TD
main["main.go"] --> deps["Build Dependencies\nConfig | Logger | DB Pool\nSession Manager | Template Cache"]
deps --> app["Application struct"]
app --> router["app.routes()\nchi Router"]
router --> mw["Middleware Pipeline\nRequestID → RealIP → Logger\nRecoverer → LoadAndSave → authenticate"]
mw --> public["Public Routes\n/ | /login"]
mw --> protected["Protected Routes\n/dashboard | /notebooks/*\n/approvals/*"]
mw --> admin["Admin Routes\n/admin/audit"]
protected --> handlers["Handlers\nparse input → call store\nprepare data → render/redirect"]
handlers --> store["internal/data\nUserStore Interface\nPgxStore Implementation"]
store --> pg[("PostgreSQL\nusers | teams | notebooks\nrevisions | tags | audit")]
handlers --> tmpl["Template Cache\nhtml/template\nServer-side rendering"]
Loading

Request Lifecycle

Browser
→ GET /notebooks/1
→ chi router
→ RequestID → RealIP → Logger → Recoverer
→ scs LoadAndSave (session hydration)
→ authenticate (load user from session → context)
→ requireAuthentication (guard)
→ app.notebookView handler
→ store.ListNotebookRevisions + store.ListNotebookTags
→ app.render (template execution → buffered write)
→ HTML response

Two Kinds of State

ConceptScopeStorageExample
Applicationserver lifetimeprocess memoryDB pool, session manager, template cache
Sessionmany requestsDB/cookie-backeduserID, flash message
Contextone requestmemorycurrent loaded user pointer

Tech Stack

CategoryTechnology
LanguageGo 1.25
HTTP Routerchi/v5
DatabasePostgreSQL via pgx/v5 with pgxpool
Sessionsscs/v2 with pgxstore backend
Templateshtml/template (server-rendered)
Query Gensqlc for typed query generation
Authbcrypt password hashing via golang.org/x/crypto
CIGitHub Actions

Project Structure

ControlPlane/
├── cmd/web/
│ ├── main.go # Entry point, dependency wiring, server startup
│ ├── routes.go # Route declarations, middleware graph, page handlers
│ ├── middleware.go # authenticate, requireAuthentication, requireRole
│ ├── helpers.go # render, newTemplateData, logAuditEvent, serverError
│ ├── context.go # Typed request-context helpers (user get/set)
│ ├── auth.go # Login/logout handlers
│ ├── notebooks.go # Notebook CRUD, search, view handlers
│ ├── approvals.go # Approval queue, approve/reject handlers
│ ├── test_support_test.go # Shared test infrastructure (fakeStore, helpers)
│ ├── handlers_test.go # Handler unit tests
│ ├── auth_handlers_test.go # Auth handler tests
│ ├── notebook_handlers_test.go # Notebook handler tests
│ ├── approval_handlers_test.go # Approval handler tests
│ ├── middleware_test.go # Middleware unit tests
│ ├── helpers_test.go # Helper function tests
│ └── context_test.go # Context helper tests
├── internal/data/
│ ├── store.go # Domain types, UserStore interface
│ ├── pgxstore.go # PostgreSQL implementation of UserStore
│ ├── queries/ # Raw SQL for sqlc
│ │ ├── notebooks.sql
│ │ ├── approvals.sql
│ │ ├── approved.sql
│ │ ├── audit.sql
│ │ ├── teams.sql
│ │ └── moderation.sql
│ └── generated/ # sqlc-generated Go code
│ ├── db.go
│ ├── models.go
│ ├── notebooks.sql.go
│ ├── approved.sql.go
│ └── audit.sql.go
├── migrations/
│ ├── 0001_initial_schema.sql # Core tables, indexes, constraints
│ └── 0002_create_audit_logs.sql # Audit log table
├── ui/templates/ # Server-rendered HTML templates
├── docs/ # Architecture and study documentation
│ ├── Control Plane Notebook Architecture.md
│ ├── Control Plane Notebook Reading Map.md
│ ├── Control Plane Notebook vs Ledger API.md
│ ├── GO_BACKEND_WIRING_RECIPE_PHASE_0_TO_5.md
│ ├── Phase 0-1 Wiring Recipe.md
│ ├── Phase 2-5 Wiring Recipe.md
│ └── how_to_phase_5.md
├── .github/workflows/
│ └── go.yml # GitHub Actions CI pipeline
├── sqlc.yaml # sqlc configuration
├── go.mod
├── go.sum
├── .env
└── .gitignore

Key Components

Application Struct — Dependency Injection

The Application struct is dependency injection in plain Go. It avoids globals, keeps handlers testable, and makes shared dependencies explicit.

typeApplicationstruct {
configConfigconn*pgxpool.Poollogger*slog.Loggerstore data.UserStoresessionManager*scs.SessionManagertemplateCachemap[string]*template.Template
}

UserStore Interface — Data Access Layer

All database operations are abstracted behind an interface, enabling fake implementations for testing:

typeUserStoreinterface {
GetUserByEmail(ctx context.Context, emailstring) (User, error)
GetUser(ctx context.Context, idint) (User, error)
CheckPassword(userUser, passwordstring) boolListNotebooks(ctx context.Context) ([]Notebook, error)
CreateDraft(ctx context.Context, paramsCreateDraftParams) (int, error)
CreateNotebookRevision(ctx context.Context, paramsCreateNotebookRevisionParams) (int, error)
ListNotebookRevisions(ctx context.Context, notebookIDint) ([]NotebookRevision, error)
ListNotebookTags(ctx context.Context, notebookIDint) ([]Tag, error)
InsertAuditLog(ctx context.Context, paramsInsertAuditLogParams) errorApproveRevisionTx(ctx context.Context, revisionID, notebookID, reviewerIDint) errorListSubmittedRevisions(ctx context.Context) ([]NotebookRevision, error)
UpdateRevisionStatus(ctx context.Context, paramsUpdateRevisionStatusParams) error// ... and more
}

Middleware Pipeline

Middleware order is intentional and matters:

r.Use(middleware.RequestID) // assign unique request IDr.Use(middleware.RealIP) // resolve client IP behind proxiesr.Use(middleware.Logger) // structured request loggingr.Use(middleware.Recoverer) // panic recoveryr.Use(app.sessionManager.LoadAndSave) // session hydration/commitr.Use(app.authenticate) // load current user into context

Then route-level guards:

r.Use(app.requireAuthentication) // block anonymous accessr.Use(app.requireRole("reviewer")) // enforce role-based accessr.Use(app.requireRole("admin")) // admin-only routes

Transactional Approval Workflow

Approval and audit happen atomically in a single database transaction to prevent partial writes:

func (s*PgxStore) ApproveRevisionTx(ctx context.Context, revisionID, notebookID, reviewerIDint) error {
tx, err:=s.DB.Begin(ctx)
iferr!=nil {
returnerr
}
defertx.Rollback(ctx)
// 1. Update revision status to 'approved'_, err=tx.Exec(ctx, updateRevisionQuery, revisionID)
// 2. Set notebook's current published revision_, err=tx.Exec(ctx, updateNotebookQuery, revisionID, notebookID)
// 3. Insert audit event_, err=tx.Exec(ctx, auditQuery, reviewerID, "revision_approved", "notebook_revision", revisionID)
returntx.Commit(ctx)
}

Database Schema

The relational model is designed around identity, content versioning, and governance:

erDiagram
users ||--o{ memberships : "belongs to"
teams ||--o{ memberships : "has"
users ||--o{ notebook_revisions : "authors"
notebooks ||--o{ notebook_revisions : "has versions"
notebooks ||--o{ notebook_tags : "tagged with"
tags ||--o{ notebook_tags : "applied to"
notebook_revisions ||--o{ approvals : "reviewed in"
users ||--o{ approvals : "reviews"
notebooks ||--o{ moderation_flags : "flagged in"
users ||--o{ audit_events : "performed by"
users {
bigserial id PK
text email UK
bytea password_hash
timestamptz created_at
}
teams {
bigserial id PK
text name UK
}
notebooks {
bigserial id PK
text title
text content
text slug UK
text visibility
boolean is_published
bigint current_published_revision_id FK
bigint team_id FK
}
notebook_revisions {
bigserial id PK
bigint notebook_id FK
bigint author_id FK
text title
text body
text status
}
tags {
bigserial id PK
text name UK
}
approvals {
bigserial id PK
bigint notebook_revision_id FK
bigint reviewer_id FK
text decision
}
audit_events {
bigserial id PK
bigint actor_id FK
text event_type
text entity_type
bigint entity_id
jsonb details
}
moderation_flags {
bigserial id PK
bigint notebook_id FK
bigint moderator_id FK
text reason
timestamptz resolved_at
}
Loading

Why Split notebooks and notebook_revisions?

notebooks is the stable identity — who owns it, what's the slug, what's published. notebook_revisions is the editable content history — what title/body did this version have, who authored it, what's the workflow status.

Without this split, drafts and approvals become fragile because you keep overwriting the same row.


API Routes

Public Routes

MethodPathHandlerDescription
GET/homeLanding page
GET/loginloginFormLogin form
POST/loginloginSubmitAuthenticate user

Authenticated Routes

MethodPathHandlerDescription
GET/dashboarddashboardUser dashboard
POST/logoutlogoutDestroy session
GET/notebookslistNotebooksList notebooks and draft/published status
GET/notebooks/newnotebookCreateFormCreate draft form
POST/notebooks/newnotebookCreateSubmitSubmit new draft
GET/notebooks/{id}notebookViewView notebook with revision & tags
GET/notebooks/{id}/editnotebookEditFormEdit draft form
POST/notebooks/{id}/editnotebookEditSubmitSubmit draft edit
POST/notebooks/{id}/submitnotebookSubmitForApprovalSubmit latest draft revision for review
POST/notebooks/{id}/flagnotebookFlagSubmitCreate a moderation flag
GET/notebooks/searchnotebooksSearchSearch notebooks

Reviewer Routes (role: reviewer)

MethodPathHandlerDescription
GET/approvalsapprovalQueueSubmitted revisions queue
POST/approvals/approveapproveRevisionSubmitApprove and publish revision
POST/approvals/rejectrejectRevisionSubmitReject revision
GET/moderationmoderationQueueModeration flag queue
POST/moderation/resolveresolveModerationFlagSubmitResolve a moderation flag

Admin Routes (role: admin)

MethodPathHandlerDescription
GET/admin/auditadminAuditAudit event log

Testing

Tests use a fake store pattern — a lightweight in-memory implementation of the UserStore interface — to verify handler behavior without touching the database.

Test Architecture

fakeStore (implements UserStore)
└── in-memory maps for users, notebooks, revisions, tags
└── call tracking flags (e.g. createDraftCalled, approveCalled)
newTestApplication(store, templates)
└── builds Application with fake store, real scs session manager, discard logger
serveWithSession(app, handler, request)
└── wraps handler with LoadAndSave for valid session context

Test Coverage

FileCoverage AreaTests
context_test.goContext user get/set helpers2
helpers_test.goreadIDParam, template render2
middleware_test.goAuth redirect, role enforcement3
auth_handlers_test.goLogin form, credentials, logout4
notebook_handlers_test.goCreate draft, view, validation4
approval_handlers_test.goApprove/reject transactions2
handlers_test.goFull handler integration tests10+

Running Tests

go test -v ./...

CI/CD Pipeline

GitHub Actions runs on every push to main and on pull requests:

name: CI Pipelineon:
push:
branches: ["main"]pull_request:
branches: ["main"]jobs:
test-and-build:
runs-on: ubuntu-lateststeps:
- name: Check out repository codeuses: actions/checkout@v4
- name: Set up Gouses: actions/setup-go@v5with:
go-version: '1.25.0'
- name: Run Go testsrun: go test -v ./...
- name: Build applicationrun: go build -v ./...

Local Development

Prerequisites

  • Go 1.25+
  • PostgreSQL

Environment Variables

The app has development defaults that match docker-compose.test.yml. You can override them in your shell:

ENV=devDATABASE_URL=postgres://testuser:testpass@localhost:5433/controlplane_test?sslmode=disableCSRF_SECRET=12345678901234567890123456789012

Setup

# Clone the repository
git clone https://github.com/SShogun/ControlPlane.git
cd ControlPlane
# Start the local PostgreSQL database
docker compose -f docker-compose.test.yml up -d
# Apply migrations and seed demo users/data
go run ./cmd/seed
# Optional if modifying SQL query files# sqlc generate# Run the application
go run ./cmd/web
# Run tests
go test -v ./...

The app listens on :6767 by default.

Screenshots & Demo

Below are quick screenshots of the running application (placeholder SVGs provided under ui/screenshots/). Replace these with real PNG/GIF exports for richer visuals in the repo.

  • Landing page: Landing
  • Dashboard (charlie): Dashboard
  • Create new draft: Create Draft
  • Approval queue: Approval Queue
  • Audit logs: Audit Logs

Demo video / context:

The author recorded a 6-minute walkthrough — see the post on X:

Demo Walkthrough on X

How to replace placeholders:

  1. Capture screenshots (PNG or GIF) of the running app.
  2. Save them under ui/screenshots/ using the same numeric prefixes (e.g. 01-landing.png).
  3. Update README image links if you prefer .png files.

Development Roadmap

gantt
title ControlPlane Build Phases
dateFormat YYYY-MM-DD
axisFormat %b
section Phase 0
Foundation & wiring :done, p0, 2026-01-01, 14d
section Phase 1
Template cache & first page :done, p1, after p0, 14d
section Phase 2
Auth, sessions, forms, CSRF :done, p2, after p1, 21d
section Phase 3
Notebooks, revisions, tags, search :done, p3, after p2, 21d
section Phase 4
Approvals, audit, moderation :done, p4, after p3, 21d
section Phase 5
Tests & CI :done, p5, after p4, 14d
Production hardening :done, p5b, after p5, 14d
section Phase 6 (Advanced)
Metrics & Tracing : p6a, after p5b, 14d
K8s & Shared Limiting : p6b, after p6a, 14d
Loading

Phase 0 — Foundation ✅

  • Application struct and dependency injection
  • PostgreSQL connection pool with pgxpool
  • Session manager setup with scs and pgxstore
  • Router wiring with chi

Phase 1 — First Page ✅

  • Template cache built at startup
  • Buffered render helper
  • Home page with session-backed flash messages
  • End-to-end request flow proof

Phase 2 — Auth & Forms ✅

  • Login/logout with bcrypt password verification
  • Session token renewal after authentication
  • Request-context helpers for current user
  • authenticate and requireAuthentication middleware
  • requireRole middleware for role-based access
  • Post/Redirect/Get pattern for form submissions

Phase 3 — Notebook Domain ✅

  • Notebook CRUD with draft creation
  • Document/revision split for content versioning
  • Tag system with normalized join table
  • Notebook view with latest revision and tags
  • Search endpoint

Phase 4 — Governance ✅

  • Approval queue for submitted revisions
  • Approve/reject workflow with state transitions
  • Transactional approval + audit in single DB transaction
  • Audit event logging for all governance actions
  • Moderation flags table
  • Admin audit page

Phase 5 — Production Hardening ✅

  • Test infrastructure: fakeStore, newTestApplication, serveWithSession
  • Context helper tests
  • Helper function tests
  • Middleware tests (auth redirect, role enforcement)
  • Auth handler tests (login, logout, credentials)
  • Notebook handler tests (create, view, validation)
  • Approval handler tests (approve/reject transactions)
  • GitHub Actions CI pipeline
  • CI pipeline refinement and edge cases
  • Structured error handling improvements
  • Request logging and observability
  • Graceful shutdown

Phase 6 — Advanced Production Ops 🚧 (Future)

  • Prometheus metrics (/metrics) exposing request volume, latency, and domain events.
  • OpenTelemetry distributed tracing across HTTP handlers and DB operations.
  • Production Dockerfile with /healthz and /readyz endpoints.
  • Kubernetes manifests (Deployment, Service, ConfigMap, probes).
  • Redis-backed distributed rate limiting for replicas.

Learning Goals

This repository is deliberately being used to practice:

  • dependency injection in Go without frameworks
  • request lifecycle design (middleware → handler → render)
  • middleware composition and ordering
  • relational schema design with proper normalization
  • production-shaped SQL habits (transactions, indexes, constraints)
  • testable handler design with interface-based fakes
  • server-rendered backend architecture
  • CI/CD pipeline design

Docs

Project documentation lives in docs/:

DocumentDescription
Architecture GuideSystem design and component relationships
Reading MapRecommended study order
Notebook vs Ledger APIDesign comparison
Phase 0-1 Wiring RecipeFoundation build guide
Phase 2-5 Wiring RecipeFull feature build guide
Full Wiring Recipe (Phase 0-5)Complete systems walkthrough
How to Phase 5Testing and hardening guide

Built with intent, not with scaffolding.

About

A server-rendered Go app for internal runbooks, incident documentation, and team knowledge workflows with sessions, approvals, audit history, and PostgreSQL

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages