A working internal-tool that demonstrates the full Plinth stack on the canonical "engineer requests temporary production access; an approver decides; audit log captures everything" flow. Intended as proof that the bank-grade-foundation pitch works for a real, not-toy use case — clone, run, click around, read the audit log.
Every Plinth SDK is exercised:
| What you see | Plinth SDK |
|---|---|
| Server-rendered list table, status filter, URL-driven pagination | @plinth-dev/tables + sdk-go/paginate |
| New-request form with Zod validation + RFC 7807 error mapping | @plinth-dev/forms + sdk-go/errors |
| Approve/Deny buttons gated on Cerbos permissions | @plinth-dev/authz-react + @plinth-dev/authz + sdk-go/authz |
| Every state transition produces an audit event (CloudEvents 1.0) | sdk-go/audit |
| Distributed tracing across web → API | @plinth-dev/otel-web + sdk-go/otel |
| Non-throwing API client with discriminated-union responses | @plinth-dev/api-client |
| Fail-fast env validation at module load | @plinth-dev/env + sdk-go/vault |
example-access-requests/
├── access-requests-api/ # Go + chi + pgx — the API tier
└── access-requests-web/ # Next.js 16 + React 19 — the web tier
Both tiers were generated with plinth new access-requests and adapted to the AccessRequest domain. The same flow (plinth new <name>) is what you use to scaffold your own modules — see github.com/plinth-dev/cli.
┌─────────────────────┐
POST / │ │
──────────▶ │ pending │
│ │
└────────┬────────────┘
│
┌─────────────────┴─────────────────┐
│ │
POST /:id/approve POST /:id/deny
│ │
▼ ▼
┌────────────┐ ┌────────┐
│ approved │ │ denied │
│ + expires │ │+ reason│
└────────────┘ └────────┘
Approved and denied are terminal states — once decided, no further mutation. The repo guards transitions atomically (UPDATE … WHERE status = 'pending' RETURNING …) so concurrent decisions don't both succeed.
| Role | Permissions |
|---|---|
requester |
Create requests; read + list own requests |
approver |
Read + list all requests; approve/deny pending requests |
admin |
Same as approver (reserved for read-only auditors with broader future scope) |
Cerbos enforces all of the above — the service layer calls authz.CheckAction before every operation; the web tier renders Approve/Deny buttons via <Can action="decide">.
git clone https://github.com/plinth-dev/example-access-requests
cd example-access-requests
# API tier
cd access-requests-api
docker compose up -d # Postgres + Cerbos
make migrate-up # apply schema
make run # serve on :8080
cd ..
# Web tier (separate terminal)
cd access-requests-web
pnpm install
pnpm dev # serve on :3000Then open http://localhost:3000 and use one of the dev sign-in shortcuts.
- Sign in as alice (requester) — file a request: purpose
Investigate incident #1234, scopeAWS prod read-only, justificationCustomer reported timeouts; need to read CloudWatch. - Sign in as bob (approver) — see all pending requests; open alice's; approve with
expiresAt = now + 24h. - Sign in as alice again — your request is now
approvedwith bob's signature. - Inspect the audit log —
docker compose logs api | grep audit. Three CloudEvents:access_request.created,access_request.approved. The actor, resource, before/after, and trace ID are all populated.
The API and web tiers are independent runtimes that share only the JSON contract. Each is dropped onto the platform substrate (CloudNativePG + Cerbos + OpenTelemetry Collector) without modification — no adapter glue, just env vars.
┌───── Web (Next.js + React 19) ─────────┐
Browser ────▶ │ ServerTable, FormWrapper, <Can/> │
│ permissionMap → Cerbos via @authz │
│ traceparent propagation via @otel-web │
└────────────┬───────────────────────────┘
│ Bearer <userid>:<roles>
▼
┌───── API (Go + chi) ───────────────────┐
│ chi → otel HTTP middleware │
│ → auth (cookie shim or JWT) │
│ → errors (RFC 7807) │
│ → handlers → service │
│ service: authz.CheckAction → Cerbos │
│ repo (pgx) │
│ audit.Publish (non-blocking) │
└────────────┬────┬──────────────────────┘
│ │
┌───────────────┘ └────────────┐
▼ ▼
┌──────────────┐ ┌────────────────────┐
│ CloudNativePG│ │ Cerbos PDP │
│ (Postgres) │ │ /policies mount │
└──────────────┘ └────────────────────┘
The Auth shim is intentionally trivial — Bearer <userid>:<roles>, dev only. Replace with whatever your identity provider hands you (OIDC, JWT, Auth0, Clerk, Stack, etc.) before running anything that matters.
If you want to fork this as a starting point for a different internal tool:
db/migrations/— replaceaccess_requeststable with your schema.internal/repository/access_requests.go— rename the type + the SQL.internal/service/access_requests.go— change the methods, the audit action names, the Cerbos resource kind.cerbos/policies/access_request.yaml— same kind rename; redefine actions and rules.internal/handlers/access_requests.go— adjust routes + DTOs.cmd/server/main.go— rename the variables wiring the repo/svc/handlers.
Web tier: same pattern under access-requests-web/src/app/access-requests/.
Or, easier — plinth new your-thing to start from a fresh starter and copy ideas selectively.
plinth.run— full architecture reference, ADRs, tutorials.- Manifesto — the six commitments.
sdk-go/sdk-ts— the SDKs this example exercises.starter-web/starter-api— the templates this example was derived from.cli—plinth newfor scaffolding new modules.platform— Helm chart that brings up the substrate this example targets.
MIT — see the per-tier LICENSE files.