# Installing, running, and deploying proxy-monster A policy-enforcing data-plane proxy for SQL. Clients speak the native MySQL/PostgreSQL wire protocol to a proxy; a control plane authorizes and masks every statement; a tamper-evident audit trail records each decision. This guide covers every config variable (with examples and defaults), then two deployments: [running locally](#running-locally) for development, and [deploying on AWS](#deploying-on-aws-ecs-fargate--s3--aurora) (ECS Fargate + S3 - Aurora). It's a generic reference — substitute your own hosts, ARNs, region, and IdP. What the components are and how they communicate is [ARCHITECTURE.md](./ARCHITECTURE.md). ## What you are installing Five long-running server components — the proxy (one per datasource), the control plane, the web console, auditmon, and the control-plane store — plus pmon, the static binary each user runs locally to reach a proxy. You bring the target databases the proxy protects, which may be MySQL or PostgreSQL; the control-plane store is PostgreSQL only. What each one does, how they talk, which ports and protocols to open, and which paths need to be public is [ARCHITECTURE.md](./ARCHITECTURE.md); read that first if the layout is new to you. This guide is the config surface and the two deployments. ## Configuration `required` = must be set · `optional` = has a default · `dev-only` = never in production. Examples are generic. ### Control plane The control-plane store is PostgreSQL only — there is no MySQL-store option, and `PM_DB_URL` must be a PostgreSQL JDBC URL. This is independent of the target databases the proxy protects, which may be MySQL or PostgreSQL and are configured per proxy under `PM_TARGET_*`. - `PM_DB_URL` / `PM_DB_USER` / `PM_DB_PASSWORD` — _required_. The PostgreSQL control-plane store (JDBC URL + credentials). The URL must begin `jdbc:postgresql://` — `Db.kt` hardcodes `org.postgresql.Driver`, which rejects any other scheme, and `Main.kt` builds the pool and migrates before it opens a port, so a non-PostgreSQL URL fails startup rather than degrading. Example: `jdbc:postgresql://pm.cluster-xxxx.ap-northeast-2.rds.amazonaws.com:5432/proxymonster` - `PM_HTTP_PORT` / `PM_GRPC_PORT` — _optional_. HTTP (web/auth/MCP) and gRPC (proxies) listen ports. Defaults: `8080` · `9090`. - `PM_CP_CHILD_HTTP_PORT` / `PM_CP_CHILD_GRPC_PORT` — _optional_. The image's entrypoint, `cp-go`, owns the two public ports and runs the Kotlin control plane as a child on these loopback ports, forwarding to it. Defaults: `18090` · `18091`; change them only if something else in the container uses them. - `PM_OIDC_ISSUER` / `_CLIENT_ID` / `_CLIENT_SECRET` / `_REDIRECT_URI` — _required for SSO_ (all four). Redirect must equal `/auth/oidc/callback`. Example issuer `https://your-org.okta.com`, redirect `https://console.example.com/auth/oidc/callback`. - `PM_OIDC_SCOPES` / `_GROUP_MAP` / `_GROUP_PREFIX` — _optional_. Scopes (default `openid profile email groups offline_access`); map IdP groups → local groups/roles (explicit map to target `system:*`). Example: `PM_OIDC_GROUP_MAP=okta-admins=system:admin,okta-devs=developers` - `PM_OIDC_USER_ATTRIBUTES` — _optional_. id_token claims copied onto the Cedar `User` as `:|User>` pairs ([docs/auth-model.md](./docs/auth-model.md#user-attributes)). Example: `PM_OIDC_USER_ATTRIBUTES=manager:User,department:String` - `PM_SESSION_SECRET` — _required (prod)_. Signs web sessions; ≥32 chars, non-default (enforced when debug is off). Example: `$(openssl rand -base64 48)` - `PM_RESULT_KEY` — _required for approval-result storage_. Encrypts saved query results (approval flow); 32-byte key, base64. Unset refuses approver-exec result storage fail-closed. Example: `$(openssl rand -base64 32)` - `PM_SECRET_TOKEN` — _required_. The single shared secret gating all proxy↔CP gRPC + the HTTP ingest routes. Same value on the CP and every proxy; unset ⇒ the gate is open (dev only). Example: `$(openssl rand -hex 32)` **Holding this is equivalent to administering policy.** `Register` lets a caller set a datasource's tags, every table and column under it inherits them, and a tag the shipped presets key on changes what those presets grant — `system:catalog` matches an enabled bare-principal unmasked-read permit. Registration is keyed on the datasource name with no per-datasource binding, so one proxy's secret can retag a datasource it does not front. Handle it like `PM_SESSION_SECRET`, and do not give it to anything you would not let edit policy ([ARCHITECTURE.md](./ARCHITECTURE.md#what-pm_secret_token-authorizes)). - `PM_MCP_RESOURCE` — _optional_. Public MCP resource URL; required only if MCP is used. Example: `https://console.example.com/mcp` - `PM_INSTANCE_NAME` — _optional_. Short name for this deployment, used in the MCP install name `pmon-` and told to agents so several installed instances stay distinct. Lowercase letters, digits, and inner hyphens, at most 40; any other value fails boot. Default: the first DNS label of `PM_MCP_RESOURCE`'s host (`https://hr-pmon.example.com/mcp` → `hr-pmon`), or `local` for an IP or `localhost`. It is public: `GET /api/instance` serves it with the server version, the MCP URL, and the install name. - `PM_INSTANCE_DESCRIPTION` — _optional_. One line telling users and agents what this instance holds, at most 500 characters. Default empty. Example: `HR and payroll databases` - `PM_TRUSTED_PROXIES` — _optional, set behind an LB_. Comma-separated socket-peer addresses or CIDR blocks of the edges trusted to assert forwarded headers about a request: `X-Forwarded-For` for client-IP attestation, `X-Forwarded-Proto` for the SCIM TLS gate, and `X-Forwarded-Host` for the host the `/mcp` check compares. Empty (default) ignores all three and uses the socket's own facts, so SCIM then requires direct HTTPS and `/mcp` sees the proxy's authority. Example: `10.20.1.15,10.20.2.15`, or `10.20.0.0/16` for an autoscaled edge whose address is not knowable in advance. A block must cover only hops you operate — anything inside it can assert its own client IP, so prefer the narrowest prefix that covers the edge. Entries that are neither an address nor a valid block are ignored and logged at startup. An edge you list here must **overwrite** the forwarded headers it asserts, from its own view of the connection — never relay a client-supplied value. An edge that passes an inbound `X-Forwarded-Proto: https` through lets a plaintext request satisfy the SCIM TLS gate, sending the standing `PM_SCIM_TOKEN` in the clear. ALB and CloudFront overwrite by default; a hand-rolled nginx or Envoy hop needs `proxy_set_header X-Forwarded-Proto $scheme` (or the equivalent), not a pass-through of the request header. - `PM_WEB_URL` — _optional_. The web console, e.g. `http://web:3000`. When set, `cp-go` serves `/api/`, `/auth/`, `/oauth/`, `/mcp`, and `/.well-known/oauth-` itself and forwards every other path there, on every port it serves HTTP on, so a load balancer can send it everything. Unset (default), `cp-go` serves only those paths and the load balancer routes the rest to web. - `PM_TAILNET_*` — _optional_. Put the console on a tailnet: `cp-go` joins it as a node and serves the console on ports 443 and 80, with no separate Tailscale task or TLS edge. It is on when a login is set; unset (default) keeps `cp-go` off the tailnet, and the HTTP and gRPC ports work the same either way. - `PM_TAILNET_CLIENT_ID` and `PM_TAILNET_AUDIENCE` — log in without a stored key, through a Tailscale federated identity that trusts the cloud ID token `cp-go` obtains (on ECS, from STS `GetWebIdentityToken`, so the task role needs that permission for the audience). Or set `PM_TAILNET_AUTH_KEY`. The node keeps its state in memory and is ephemeral, so each start logs in again. If the tailnet requires device approval, set the client id as `?ephemeral=true&preauthorized=true`; otherwise the node waits for approval until `PM_CP_START_TIMEOUT` and `cp-go` exits. - `PM_TAILNET_TAGS` — the node's tags, e.g. `tag:console`. Required to host a Service or to log in by federated identity. - `PM_TAILNET_SERVICE` — a [Tailscale Service](https://tailscale.com/kb/1552/tailscale-services) to host, e.g. `svc:console`; the tailnet policy must define it and auto-approve it for the node's tag. Unset, `cp-go` serves on the node's own name. A Service keeps its name and address across deploys; a node's name does not always, since a new node can get a `-1` suffix until the old ephemeral node is removed. - `PM_TAILNET_CERT_ARN` — an ACM certificate for the console's hostname, issued with export enabled; the task role needs `acm:ExportCertificate` on it. `cp-go` exports it at start and daily, so a renewal is served without a restart. Point the hostname's DNS at the Service's or the node's address. Unset, Tailscale's certificate for the tailnet name (`..ts.net`, or the node's) is used: HTTPS must be enabled for the tailnet, the name appears in public certificate logs, and the console's public URL settings must use that name. - `PM_TAILNET_HOSTNAME` — the node's name. Default `pmon-`, the name the MCP server is installed under (`PM_INSTANCE_NAME`). Port 80 redirects to https on a name the certificate covers. The tailnet client's own address is the requester IP, and any `X-Forwarded-For` the client sends is ignored: on a Service `cp-go` takes the address from the PROXY v2 header Tailscale adds, and on the node it is the connection's own. - `PM_WEB_SESSION_ABSOLUTE` / `_IDLE` / `_SLIDE` / `_IDLE_WARN_LEAD` / `_ABSOLUTE_WARN_LEAD` / `_HEARTBEAT` — _optional_. Two-clock console session timings. Defaults: `2h` · `15m` · `2m` · `1m` · `5m` · `90s`. - `PM_SESSION_WINDOW` / `PM_IDP_RECHECK_INTERVAL` — _optional_. Daemon renewal window; IdP group re-read cadence. Defaults: `2h` · `300` (s). Raising `PM_SESSION_WINDOW` does not lengthen a live `pmon` session — `pmon` never calls the renew route, so a login lasts one wire token TTL and then needs a fresh `pmon login`. For long-running sessions raise that TTL with `pmon login --ttl` (default 12h, clamped server-side to 24h) instead. - `PM_QUERY_TIMEOUT` — _optional_. CP-side ceiling on async execute. Bare integer seconds (no unit). Default `600`. - `PM_OAUTH_ACCESS_TTL` / `PM_OAUTH_REFRESH_TTL` — _optional_. MCP OAuth token lifetimes (seconds). Defaults `600` · `21600`. - `PM_ELEVATED_SCOPE_TTL` — _optional_. How long a pmon login's scopes beyond `mcp:read` and `mcp:query` last after its browser approval (seconds, clamped to 60–86400). Default `3600`. - `PM_SCIM_TOKEN` — _optional_. SCIM provisioning bearer (OIDC just-in-time is the default). - `PM_SLACK_BOT_TOKEN` / `PM_SLACK_APP_TOKEN` — _optional_. Slack notifications for the approval workflow ([docs/notifications.md](./docs/notifications.md)). The bot token (`xoxb-`) authorizes the Web API and the app token (`xapp-`) opens the Socket Mode WebSocket. The workspace whose button clicks are honored is derived from the bot token itself (`auth.test`), not configured — a token belongs to exactly one workspace. **Both tokens are required** — either absent leaves the whole layer inert and the workflow unchanged, never a half-configured mode. Socket Mode means the CP dials out; no inbound ingress is added. Set `PM_WEB_ORIGIN` too, or the message's "open request" link points at the control plane rather than the console. Replies in a request's threads become comments only when the app also has the `channels:history`, `groups:history`, and `im:history` scopes and the `message.channels`, `message.groups`, and `message.im` events ([docs/notifications.md](./docs/notifications.md#threads)). - `PM_NOTIFY_STATEMENT` — _optional_. How much of a requester's SQL a notification may carry: `auto` (default) shows a statement only when a disclosure hint clears it, hiding one whose predicate compares a literal against a classified column; `full` shows it to pending approvers regardless, then hides a flagged one once the task is handled; `omit` never shows SQL. A statement's literals can be the very values a policy protects — masking acts on results, not predicates — so `omit` is the setting for data that must not leave in query text. Approve-and-run is offered only when the message carries the whole statement, whatever the mode. (Legacy `truncated` boots as `auto` with a warning.) - `PM_NOTIFY_LOCALE` — _optional_. Fallback language (`en` · `ko`) for a recipient who has not set one in the console. Default `en`. - `PM_SLACK_CHANNELS` — _optional_. Posts requests to Slack channels by datasource tag: `tag=channel id`, comma-separated, first match wins (e.g. `service:billing=C0123ABC,system:production=C0789GHI`). Invite the bot to each channel ([docs/notifications.md](./docs/notifications.md#channels)). - `PM_SLACK_MODE` — _optional_. `both` (default) posts a matched request to its channel and DMs; `channel` posts it to the channel only. An unmatched request always goes to DMs. - `PM_AUTH_DEBUG` / `PM_DEV` / `PM_OAUTH_DEBUG_AUTO_CONSENT` — _dev-only_. Defaults: `PM_AUTH_DEBUG=true`, `PM_DEV=false`, `PM_OAUTH_DEBUG_AUTO_CONSENT=true`. The CP refuses to boot when debug auth is on and (real OIDC is configured or a non-default `PM_SESSION_SECRET` is set), unless `PM_DEV` is explicit — the "forgot to unset debug in prod" guard. ### Proxy — one set per datasource - `PM_ENGINE` — _optional_. The target database's engine, and so the wire dialect this proxy speaks: `postgres`, `mysql`, or `athena`. Default `mysql`. Unrelated to `PM_DB_URL`, which is always PostgreSQL. - `PM_DATASOURCE_NAME` / `PM_DATASOURCE_TAGS` — _name required_. Logical id (must match the CP's) + the tags policy keys off. Example: `analytics-prod` · `system:production`. These reach Cedar as written and every table and column under the datasource inherits them, so a name the shipped presets key on decides for the whole datasource — set the posture here and leave column classification to the console. - `PM_DATASOURCE_DESCRIPTION` — _optional_. One line saying what the datasource holds, at most 500 characters, shown to agents over MCP. Sent at every registration, so unsetting it clears the stored value. Default empty. - `PM_PROXY_PORT` — _optional_. The wire port clients connect to. Defaults: `6033` (MySQL) · `6432` (PG). - `PM_TARGET_HOST` / `_PORT` / `_DB` / `_USER` / `_PASSWORD` — _optional with local defaults_ (`localhost` · engine default port · `acme` · `acme` · `acme`). In production set all five to the target DB this proxy fronts (VPC peering in the AWS layout below). Example: `prod-db.prod-vpc.internal` · `5432` · `appdb` · `pmproxy` - `PM_TARGET_TLS` — _optional_. TLS on the proxy's own connection to the target DB: `disable` (default); `require` (encrypt, no certificate check, so no protection from an active man-in-the-middle); `verify-ca` (check the chain only); or `verify-full` (check the chain and that the certificate names `PM_TARGET_HOST`). Use `verify-full` for RDS/Aurora and Azure, and for Cloud SQL on shared or customer-managed CA with `PM_TARGET_HOST` set to the instance's DNS name (`...sql.goog`). Use `verify-ca` for Cloud SQL on the default per-instance CA, whose certificate names no DNS host. - `PM_TARGET_CA` — _optional_. The CAs to trust, as a comma-separated list: `system` (the OS trust store), `rds` (the bundled [RDS global CA bundle](https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem)), or a path to a PEM file (relative paths resolve against the proxy's working directory; write a file literally named `rds` as `./rds`). Default `system,rds` for `verify-full`. `verify-ca` requires it and accepts only files: without the hostname check, the CA must sign nothing but this target, as a Cloud SQL per-instance server CA does (download it with `gcloud sql instances describe --format='value(serverCaCert.cert)'`). - `PM_CONTROL_PLANE_GRPC` — _optional_. CP gRPC address `host:port`. Default `localhost:9090`. Production example: `pm-cp.pm.internal:9090` - `PM_ADVERTISE_ADDR` — _optional, no default_. The client-facing `host:port` a wire client dials to reach _this_ proxy — distinct from `PM_TARGET_*` (the upstream target DB). The proxy registers it with the CP, which hands it to pmon as each datasource's connect address. The proxy cannot guess it, so there is no default: leave it unset and pmon discovers the datasource but cannot broker it (`pmon status` lists it with the reason "no advertised proxy address"). It must parse as `host:port` with a port in 1-65535 or the proxy refuses to start. Example: `127.0.0.1:6033` locally · `analytics-prod.pm.example.com:6432` in production - Athena (`PM_ENGINE=athena`) authenticates through the AWS SDK's own credential chain (`AWS_PROFILE`, instance role, …), so `PM_TARGET_HOST`, `PM_TARGET_USER`, and `PM_TARGET_PASSWORD` are unused; `PM_TARGET_DB` is the default database. It also reads `AWS_REGION`, `PM_ATHENA_WORKGROUP` (default `primary`), `PM_ATHENA_DEFAULT_CATALOG` (default `AwsDataCatalog`), and `PM_ATHENA_CONTEXT_PATH` (default `:memory:`; an absolute path in a private directory persists the enforcement-context cache across restarts). The SDK's `AWS_ENDPOINT_URL` (with `AWS_CA_BUNDLE` for a private CA) points the proxy at an Athena emulator instead of the regional endpoint; it must be an HTTPS origin. Wire TLS is required: Athena clients speak HTTPS to the proxy. Default port `6443`. - `PM_TLS_CERT` / `PM_TLS_KEY` — _optional (both or neither; one alone ⇒ refuses to start)_. TLS cert+key file paths for the wire listener. Wire TLS is optional — the cleaner pattern is a private transport (peered VPC, PrivateLink, or a VPN/tailnet) and plaintext wire (unset). Set them only for an untrusted client↔proxy path (or an encrypt-everywhere policy); then the _proxy_ terminates TLS — a generic NLB/ALB can't, because SQL negotiates TLS in-band. With TLS on, the proxy advertises its certificate chain at registration. pmon uses that chain as its trust pool and verifies the advertised hostname; a self-signed leaf works as its own anchor, so nothing has to enter the client's system trust store. ACM Private CA (exportable, unlike public ACM certs) via Secrets Manager is one convenient source. Rotating the cert re-advertises the new chain on the next register, so clients pick it up without redistribution. - `PM_SECRET_TOKEN` — _required_. The shared gRPC secret; must equal the CP's. - `PM_PROXY_PROTOCOL_PORT` / `PM_TRUSTED_PROXIES` — _optional (the port needs at least one trusted proxy, or the proxy refuses to start)_. A second wire port for an edge that relays clients through a hop that would otherwise replace their address, such as a load balancer without client-IP preservation. Every connection there must open with a PROXY protocol v1 or v2 header and come from a socket peer in `PM_TRUSTED_PROXIES` (comma-separated addresses or CIDR blocks); the header's source address is then the client address the CP audits. A connection from any other peer is closed at accept, and one without a header fails its handshake. The plain wire port never reads a header, so a client there cannot assert an address. The port must differ from `PM_PROXY_PORT`, and an entry that is neither an address nor a block refuses to start. Example: `16033` · `10.20.0.0/16` #### Target DB service-account grants In addition to its application permissions, the MySQL `PM_TARGET_USER` needs `SELECT` on `mysql.func` to inventory loadable functions. Grant it to the existing account, replacing the user and host below: ```sql GRANT SELECT ON mysql.func TO 'pmproxy'@''; ``` Stored functions must also be visible to that account in `information_schema.ROUTINES` through its routine privileges. The routines are read together with the columns, on registration and on every per-connection schema refresh, so a failing routine read fails the whole catalog read. PostgreSQL routines are read from `pg_catalog.pg_proc` and `pg_catalog.pg_namespace`, which are publicly readable by default. If catalog access is hardened, retain `SELECT` on both for `PM_TARGET_USER`. ### Web console - `PM_CP_URL` — _optional_. The CP's HTTP base URL. The web server forwards the CP's paths (`/api`, `/auth`, …) there (`web/src/proxy.ts`), reading it per request. Needed only when those paths reach the web server, as in `next dev`; behind the ALB layout below they go to the CP directly. Default `http://127.0.0.1:41390`. `PM_PROXY_TARGET` is its deprecated name, still read when `PM_CP_URL` is unset. Example: `http://pm-cp.pm.internal:8080` - `NEXT_PUBLIC_API_URL` — _optional_. Public API base; default `""` (same-origin). - `AUTH_RECHECK_MS` / `AUTH_SETTLE_GRACE_MS` / `AUTH_CALLBACK_PATH` / `AUTH_COMPLETE_MESSAGE_TYPE` — _optional_. Client SessionGuard tuning (re-auth cadence, popup callback). - `PM_WEB_DEV_ORIGINS` — _dev-only_. HMR cross-origin allowlist for local dev. ### pmon — client-side, per user pmon takes no proxy address of its own: its daemon discovers every datasource the logged-in principal may connect to and dials each one's `PM_ADVERTISE_ADDR`. - server URL — _required once per server_. `pmon server set [name] --url ` saves a control-plane base URL under a name (when omitted, the instance name the server advertises, or `default` on an older server); `pmon login --url [name]` does the same and logs in. Later commands name the server, or address the default server (the first one created; `pmon server default ` changes it). Example: `pmon login --url https://console.example.com` - `--ttl` (on `pmon login`) — _optional_. Wire-token lifetime in seconds. Default `43200` (12h). - `--scopes` (on `pmon login`) — _optional_. Comma-separated scopes the login grants, replacing the default `mcp:read,mcp:query`. Scopes beyond that pair last `PM_ELEVATED_SCOPE_TTL`. - local broker port — _not configurable_. The daemon assigns each datasource the next free loopback port at or above `6100` and persists it, so a datasource keeps the same port across restarts. Ports are unique across servers. `pmon status` prints every server's assignments; `pmon show [server] [--url | --jdbc | --go-dsn | --cli]` prints one ready connection string (`--url` is the default). ### auditmon — YAML file + env overlays Secret-free YAML (path via `AUDITMON_CONFIG`); every field also overridable by an `AUDITMON_MONITOR_*` env var. The DB DSN is referenced by env-var name. ```yaml # auditmon.yaml monitor: db_dsn_env: AUDITMON_DB_DSN # read-only DSN bucket: pm-audit-worm # S3 Object-Lock bucket endpoint: https://s3.ap-northeast-2.amazonaws.com poll_interval: 90s sign_interval: 1h full_verify_interval: 1h signer: type: kms # prod signer (dev: filekey + key_path) key_id: alias/pm-audit-signer rules: mass_export: { window: 10m, heuristic_max_broad_reads: 50, default: { rows: 100000 } } bulk_pii: { window: 5m, max_pii_decisions: 200, max_distinct_pii_columns: 20 } off_hours: { business_hours: "09:00-19:00 Asia/Seoul", applies_to: [pii_read, write] } repeated_deny: { window: 5m, max_deny: 20 } alerts: dedup_window: 15m sinks: - { type: webhook, url_env: SECOPS_WEBHOOK_URL, min_severity: warn } ``` - `AUDITMON_DB_DSN` — _required_. Read-only Postgres DSN (named by `monitor.db_dsn_env`). Example: `postgres://audit_reader:****@pm-ro.cluster-xxxx.ap-northeast-2.rds.amazonaws.com:5432/proxymonster?sslmode=require` - `AWS_REGION` + task IAM role — _required_. For S3 + KMS. On ECS use the task role, not static `AWS_*` keys. ## Running locally One task brings up the whole stack — the PostgreSQL control-plane store, the control plane, a sample MySQL and PostgreSQL target database with a wire proxy in front of each, and the web console: ```sh mise run dev ``` That is the fast path, and what most contributors want. It prints the URL and port of every piece it started; Ctrl-C stops them and leaves the datastores running (`mise run down` stops those too). The rest of this section is the same walkthrough component by component, for when you want to run one piece by hand, change a variable, or understand what `dev` does. Each step names only the vars that matter locally; the full reference is [Configuration](#configuration). `mise tasks ls` lists the per-component tasks (`up`, `control-plane`, `proxy-mysql`, `proxy-postgres`, `web`) that mirror these steps. Getting from a clean checkout to a masked or denied query is [First login and first query](#first-login-and-first-query); an OAuth-authenticated MCP client is [Connect an MCP client](#connect-an-mcp-client-optional). ### Prerequisites - JDK, Gradle, Go, Node, pnpm — all pinned in `mise.toml` (temurin-24, Gradle 8.14.5, Go 1.23, Node 24.22.2, pnpm 10.22.0 — matching `web/Dockerfile`'s base image + corepack pin). Run `mise install` from the repo root, or use your own matching toolchain. JDK 24 is the floor: the Gradle build compiles with `--release 24` and targets JVM 24, so an older JDK fails with `release version 24 not supported`. - Docker — for the three local datastores (below). - A TLS cert/key pair — only if you want the wire proxy's client-facing TLS on (recommended once you connect a real SQL client off-loopback; skippable for a pure loopback smoke test — see [Run a wire proxy](#run-a-wire-proxy)). A self-signed pair is enough when clients connect through pmon, which pins the advertised leaf rather than checking a CA. A client that dials the proxy directly instead verifies per its own TLS settings, so give that one a cert it trusts. ### Datastores ``` mise run up ``` Three containers (`docker-compose.yml`, repo root), started and waited on until each reports healthy. Re-running it against a healthy stack is a no-op. One is proxy-monster's own store; the other two are sample target databases. - pm-postgres (`:5442`) — the control-plane store (catalog, policy, roles, audit). PostgreSQL because that is the only store engine there is. Empty on first boot; control-plane migrates it (Flyway) on startup. - target-mysql (`:31307`, db `acme` / user `acme` / password `acme`) — a sample target database on the primary target engine, pre-seeded (`deploy/seed/target-seed-mysql.sql`) with a small OLTP schema (`users`, `orders`, `payments`, `addresses`, ...) carrying realistic PII columns (`email`, `phone`, `name`, `ssn`, `card_number`, ...) to classify/mask against. - target-postgres (`:5433`, db `acme` / user `acme` / password `acme`, trust auth) — the same schema (`deploy/seed/target-seed.sql`) as a sample target database on the experimental target engine. A separate container from pm-postgres, and unrelated to it. These are not the datasources proxy-monster protects by default — they're just sample targets to point a proxy at in [Run a wire proxy](#run-a-wire-proxy). ### Run control-plane ``` mise run control-plane ``` That is `PM_DB_URL="jdbc:postgresql://localhost:5442/proxymonster" ./gradlew --no-daemon :control-plane:run`, and it starts the datastores first if they are not up. Flyway migrates the schema automatically on boot. The defaults you're implicitly relying on here (`Config.kt`): | Var | Default | What it is | | --- | --- | --- | | `PM_HTTP_PORT` | `8080` | the web-facing JSON API | | `PM_GRPC_PORT` | `9090` | the proxy-facing gRPC surface (register, decide, catalog push) — never expose publicly | | `PM_DB_USER` / `PM_DB_PASSWORD` | `proxymonster` / `proxymonster` | matches `docker-compose.yml` | | `PM_AUTH_DEBUG` | `true` | enables `/auth/debug`, a login as ANY principal with any roles — trusted machine only | | `PM_SECRET_TOKEN` | unset → gate OPEN | the shared proxy↔control-plane secret; set it once you're off a trusted loopback | | `PM_MCP_RESOURCE` | `http://127.0.0.1:8080/mcp` | MCP resource URI; its origin doubles as the co-hosted OAuth issuer | Full descriptions and every other variable are in [Configuration](#configuration). Confirm it's up: `curl http://localhost:8080/health` → `{"status":"ok",...}` (a `diagnostics` array may list non-fatal notes like "system:admin role has no active assignee" — expected until you log in once, per [First login and first query](#first-login-and-first-query)). ### Run a wire proxy Run one proxy per datasource. There is no "create datasource" step. The data-plane proxy is the Go `goproxy` module. A proxy instance registers itself with control-plane on boot (by name), introspects its own target connection, and pushes the resulting catalog — control-plane never holds credentials to, or opens a connection to, any target datasource ([docs/datasource-registration.md](docs/datasource-registration.md)). Point a proxy at a target and it appears in the admin UI on its own within a few seconds. MySQL, fronting target-mysql from [Datastores](#datastores) — `mise run proxy-mysql`, which is: ``` PM_ENGINE=mysql \ PM_DATASOURCE_NAME=acme-mysql \ PM_CONTROL_PLANE_GRPC=127.0.0.1:9090 \ PM_PROXY_PORT=6033 \ PM_TARGET_HOST=127.0.0.1 PM_TARGET_PORT=31307 PM_TARGET_DB=acme PM_TARGET_USER=acme PM_TARGET_PASSWORD=acme \ go run ./goproxy/cmd/goproxy ``` Postgres, fronting target-postgres — `mise run proxy-postgres`, which is: ``` PM_ENGINE=postgres \ PM_DATASOURCE_NAME=acme-target \ PM_CONTROL_PLANE_GRPC=127.0.0.1:9090 \ PM_PROXY_PORT=6432 \ PM_TARGET_HOST=127.0.0.1 PM_TARGET_PORT=5433 PM_TARGET_DB=acme PM_TARGET_USER=acme PM_TARGET_PASSWORD=acme \ go run ./goproxy/cmd/goproxy ``` Both run from the repo root: the committed `go.work` makes `./goproxy/cmd/goproxy` resolve there, so no `cd` is needed. Neither task sets `PM_ADVERTISE_ADDR`, so the datasource registers without a client-facing address — enough for the web console and the walkthrough below, which reach the proxy over the control plane's own gRPC channel. Add `PM_ADVERTISE_ADDR=127.0.0.1:6033` (matching `PM_PROXY_PORT`) when you want pmon to broker the datasource; without it `pmon status` lists it with the reason "no advertised proxy address". `PM_DATASOURCE_NAME` is the stable identity Cedar policies key on (`Datasource::"acme-target"`, etc.) — pick any name; it doesn't have to match the target's real db name. Optional: `PM_DATASOURCE_TAGS` (comma-separated tags — the recognized posture tags are `system:development` and `system:production`, per [docs/policy-store.md](docs/policy-store.md); the list is otherwise free-form: any name that is not an invented `system:` one reaches Cedar as itself, so a policy may match it and every table and column under the datasource inherits it. It is the same tag vocabulary column classifications use, so reusing one of those names here decides for the whole datasource: `pii` both masks every column under the shipped presets and hands cleartext on every column to any role a `Tag::"pii"` permit grants. Name a datasource tag for the datasource — `analytics-prod`, `pci-zone` — not for a column classification). Leave it unset and the datasource carries no posture, which is the production floor either way — Cedar's deny-by-default plus the shipped forbids give an untagged datasource that floor with no posture tag required. Tag it `system:development` to opt a datasource into the permissive development preset. Client-facing TLS (`PM_TLS_CERT` + `PM_TLS_KEY`, both-or-neither) is recommended but not required for this loopback walkthrough — without it the proxy runs plaintext, which is fine on `127.0.0.1` but must never be exposed beyond a fully trusted network (the wire token rides the password field in the clear). Confirm registration with `GET /api/datasources`, which should list both, each with a non-null `catalogSyncedAt` and `lastSeenAt`. It needs a session, so log in first and reuse the cookie: ``` curl -c /tmp/pm.jar -X POST http://localhost:8080/auth/debug \ -H 'content-type: application/json' \ -d '{"principal":"you@example.com","roles":["system:development-viewer"]}' curl -b /tmp/pm.jar http://localhost:8080/api/datasources ``` The roles you log in with are the ones the response reflects: a row's connection material (`targetInfo`, `dbName`, `advertiseAddr`, `advertiseCertChain`) needs `datasource.connect` on that datasource, so a role that does not grant it sees the row's identity only. Easier from the console — see the [login step](#first-login-and-first-query). ### Run the web UI ``` mise run web ``` → http://localhost:41300. That is `pnpm -C web install` then `pnpm -C web dev` with `PM_CP_URL=http://127.0.0.1:8080`. The web server forwards the CP's paths to `PM_CP_URL`, which defaults to `http://127.0.0.1:41390` (`web/src/proxy.ts`) while control-plane defaults to `:8080` — so one has to point at the other, and the task does that for you. Running web by hand, either set the same variable or run control-plane with `PM_HTTP_PORT=41390`: ``` PM_CP_URL=http://127.0.0.1:8080 pnpm -C web dev ``` Serving over a real network (not just `localhost`)? Next.js blocks cross-origin dev requests by default. Add your hostname via `web/.env.local` (gitignored, never committed) rather than editing `next.config.ts`: ``` # web/.env.local PM_WEB_DEV_ORIGINS=your-real-hostname.example.com ``` Comma-separate multiple hosts. `next.config.ts`'s `allowedDevOrigins` reads this at boot. ### Connect an MCP client (optional) `/mcp` is a Streamable HTTP MCP resource server co-hosted in control-plane — no separate process. It exposes the same Cedar-authorized operations as the REST API: access-control administration (datasource/catalog/classification, policy, role, assignment, user/group, mask function), running SQL with the stored, masked result, and the query-approval workflow (request, inbox, approve, reject, execute, result). It also browses the audit log, opens and decides JIT access requests and grants, and mints and revokes the caller's tokens. Every MCP call is OAuth 2.1-authenticated, then authorized through the same Cedar action and validators as the REST surface — OAuth scopes are only a consent ceiling, never an extra source of permission. Control-plane serves the MCP resource, OAuth discovery (RFC 8414/9728), the `/oauth/authorize` + `/oauth/token` + `/oauth/revoke` endpoints, and — since it's the OIDC relying party too — `/auth/oidc/callback`, all from one origin: `PM_MCP_RESOURCE`'s origin is always the OAuth issuer, derived automatically, never separately configured. For a loopback-only local client, the control-plane you already started is enough — just add `PM_MCP_RESOURCE`: ``` PM_MCP_RESOURCE="http://127.0.0.1:8080/mcp" mise run control-plane ``` With the local `PM_AUTH_DEBUG=true` default, `/oauth/authorize` uses a debug principal and auto-consents (`PM_OAUTH_DEBUG_AUTO_CONSENT`, default `true`) with no Okta round-trip — it still mints normal audience-bound OAuth bearer tokens, so discovery and client behavior stay identical to production. That flow selects a principal but assigns it no roles, so the token authorizes nothing until its principal holds one — a management tool needs an `admin.*` grant, and a query or approval tool the same grant its console route does; otherwise a call answers `common.forbidden`. Sign that principal in through `/login` (debug login, with `system:admin`) first, or authorize with `?principal=` naming a principal that already has the role. The console's **Connect an agent** page (identity menu → `/connect`) gives each signed-in user one block to paste to their agent (Claude Code or Codex CLI), built from `GET /api/mcp/connect`. The agent installs the server and starts the OAuth sign-in, which the user approves in the browser. The block carries only what installing needs, the install name and URL; the server describes itself and the user's datasources in its MCP `instructions` on every connection, so that survives agent restarts. The server is installed as `pmon-`, so a user with several proxy-monster instances gets one distinctly named entry per instance, and pasting an instance's block again replaces its entry. The block carries no secret. A second block, **Already use pmon? Share its login**, installs the same server as a local stdio command, `pmon mcp `, which relays to `/mcp` with an MCP token minted from the user's pmon login (see [pmon/README.md](./pmon/README.md#mcp)). One `pmon login` then serves both SQL clients and agents, and it works when the agent runs on a remote machine, because the device login completes in any browser. This is also how Claude Desktop connects: its local stdio servers run on the user's machine. claude.ai and ChatGPT are not supported: their connectors reach MCP servers from the vendor's cloud, while a proxy-monster server accepts direct connections from the user's machine only. The `get_pmon_guide` MCP tool gives an agent the same pmon setup for its instance, plus one `pmon show` line per datasource the user may connect to. For the local defaults the instance is `local`, so the Claude Code commands are: ``` claude mcp add --scope user --transport http pmon-local http://127.0.0.1:8080/mcp claude mcp login pmon-local claude mcp get pmon-local ``` Codex CLI: `codex mcp add pmon-local --url http://127.0.0.1:8080/mcp` starts the login itself. `claude mcp login` needs a real interactive terminal — it holds a local callback listener open while your browser completes the redirect, so it can't run from a non-interactive script or a background job. Run it yourself directly; it opens the authorize URL, debug mode auto-consents, and the browser redirects straight back with no clicking required. For a network-reachable deployment, route the exact same origin for all of `/mcp`, `/.well-known/oauth-protected-resource*`, `/.well-known/oauth-authorization-server`, `/oauth/*`, and `/auth/oidc/*` to control-plane — this is one canonical HTTPS host, not a separate authorization server. Set `PM_MCP_RESOURCE="https:///mcp"` to that public origin. With `PM_AUTH_DEBUG=false` it must be HTTPS, and you additionally need a non-default 32+ character `PM_SESSION_SECRET`, all `PM_OIDC_*` values, and an OIDC redirect URI exactly equal to `https:///auth/oidc/callback`. If a reverse proxy terminates TLS in front of control-plane (as any real deployment's will), the host the target DB sees must equal the host `PM_MCP_RESOURCE` declares: control-plane's `/mcp` guard compares that host strictly, and only the host. The port is never compared — behind a TLS-terminating edge the target DB is reached on its own cleartext port, and a client's `Host` omits the port whenever it is the scheme default, so requiring one would reject every such request. It reads the literal `Host` (or HTTP/2 `:authority`) the target DB receives, plus `X-Forwarded-Host` when the socket peer is listed in `PM_TRUSTED_PROXIES`; there is no `ForwardedHeaders`/`XForwardedHeaders` plugin, so nothing else is derived from `X-Forwarded-*`. A proxy that forwards the original client-facing hostname satisfies this already, whether or not it keeps the port. One that substitutes its own target-DB address in `Host` — an AWS ALB does this unless `preserve_host_header` is enabled — must either be fixed to preserve it or send `X-Forwarded-Host` from a trusted peer, or every `/mcp` call gets a fail-closed `403 mcp.invalid_host` instead of the expected `401` OAuth challenge. Configure the MCP client with the resource URL only — never a separate authorization-server URL, there isn't one — and it discovers OAuth through protected-resource metadata automatically. ### First login and first query With `PM_AUTH_DEBUG=true` (the default), open http://localhost:41300/login and use the debug-login option to sign in as any principal (e.g. `you@example.com`) with whatever roles you want — no IdP needed. Those roles are stored as real assignments, so the session authorizes exactly like an SSO one: the flag decides who you are, never what you may do. Sign in with `system:admin` for the admin UI, or with a narrow role to see precisely what that role sees. A clean principal has zero usable query access until roles are assigned (deny-by-default). Migrations do ship SYSTEM roles and preset policies (e.g. `system:development-*` for a `system:development`-tagged datasource — [docs/policy-store.md](docs/policy-store.md)); for a first custom grant, create at minimum: 1. A role: `POST /api/roles {"name": "analyst"}`. 2. A role assignment: `POST /api/role-assignments {"principal": "you@example.com", "roleId": }`. 3. Cedar policies granting it something. A policy row holds exactly one `permit`/`forbid` statement: `cedarSrc` is validated as a single Cedar policy, so a body carrying two or more statements is rejected with HTTP 400 and an "unexpected token permit" parse error. Send three separate `POST /api/policies {"name": ..., "cedarSrc": ..., "enabled": true}` calls, each under its own name (names are unique per row): `analyst-connect`: ``` permit(principal in Role::"analyst", action in [Action::"datasource.connect", Action::"stmt.cat.read"], resource == Datasource::"acme-mysql"); ``` `analyst-read-unmasked`: ``` permit(principal in Role::"analyst", action == Action::"result.read.unmasked", resource in Datasource::"acme-mysql") unless { resource in Tag::"pii" }; ``` `analyst-read-masked`: ``` permit(principal in Role::"analyst", action == Action::"result.read.masked", resource in Datasource::"acme-mysql") when { resource in Tag::"pii" }; ``` Per-column reads resolve unmasked first, then masked, then deny, so the `unless`/`when` pair is what makes a classified column mask instead of coming back in the clear: an unqualified `result.read.unmasked` grant wins over the masked one for every column, tagged or not. Scope it and the same role reads ordinary columns plainly while `pii`-tagged ones come back masked. The Admin UI (`/admin/policies` for roles/assignments, `/admin/policies/cedar-policies` for Cedar policy text) does all three interactively if you'd rather not hand-curl it. To see that masking, classify a column — create a mask fn (`POST /api/mask-fns {"name":"fixed","kind":"FIXED"}`) and tag a column with it: ``` PUT /api/datasources/{id}/classification {"schema":"acme","table":"payments","column":"card_number","tags":["pii"],"maskFnId":} ``` These API calls authorize as the principal you logged in as — the address you typed at the login screen, or the one in the `/auth/debug` body if you are driving the walkthrough with `curl`. Assign the role to that principal. ### Verify it works In the web UI's Query page, pick a datasource and run `select * from payments`. With `card_number` classified `pii` and the tag-scoped policy pair from [First login and first query](#first-login-and-first-query) in place, expect a `MASK` decision, `card_number` rendered `####` (the `FIXED` mask) and the other columns plain. Classify nothing and the same query is a plain `ALLOW`. Run a query against a nonexistent table to see a fail-closed `DENY`. The Logs tab (first, non-closable tab in the result strip) shows a plain console-style line per statement. ### Troubleshooting - A proxy exits with "could not register + push catalog after N attempts" — control-plane isn't reachable at `PM_CONTROL_PLANE_GRPC` (default `localhost:9090`), or its gRPC port differs from what you passed. The proxy refuses to serve without its catalog. - Two proxies registering under the same `PM_DATASOURCE_NAME` silently merge into one datasource row (whichever pushed last "wins" the catalog) — a known, bounded residual of the shared-secret trust model ([docs/datasource-registration.md](docs/datasource-registration.md)), not a bug. Use distinct names. - `/health`'s `diagnostics` mentions "system:admin role has no active assignee" — nobody has logged in yet to claim the seeded admin role/group (`V8__seed.sql`); harmless before your first login, self-resolves after. Under real OIDC (not debug auth), admin membership comes from the IdP's group claim via `PM_OIDC_GROUP_MAP`, not this endpoint. - The admin UI 403s, or a datasource row arrives without its target info, with `PM_AUTH_DEBUG=true` — the flag authenticates you, it does not grant anything. Sign in at `/login` with the roles you need (`system:admin` for admin, a `system:development-*` role for a `system:development`-tagged datasource). Signing in with a narrow role and seeing exactly what it permits is the point; a request carrying no session at all holds no roles and is denied accordingly. - Port already in use — this doc's ports (`5442`/`31307`/`5433` for Docker, `8080`/`9090` for control-plane, `6432`/`6033` for the two proxies, `41300` for web) are this doc's own choices, not hardcoded anywhere; override any of them via the env vars listed above if they clash with something already running. ## Deploying on AWS (ECS Fargate + S3 + Aurora) proxy-monster runs in its own VPC, VPC-peered to the (separate) production and development database VPCs — the common enterprise layout. Compute on Fargate, one ECS service per component (the proxy fans out to one service per datasource). An ALB for HTTP, an NLB for the SQL wire + internal gRPC. Aurora for the store; S3 Object Lock for the WORM trail. ### VPC topology - pm-vpc (e.g. `10.20.0.0/16`): all ECS services, the ALB (public subnets), the NLB, and the Aurora cluster. - Peering to `prod-db-vpc` and `dev-db-vpc`: a peering connection to each, a route for `PM_TARGET_HOST` traffic, and the target-DB security group opened to the _proxy_ subnets' CIDRs only. CIDR blocks must not overlap. - A `system:production`-tagged proxy targets a DB in `prod-db-vpc`; a `system:development` proxy targets `dev-db-vpc` — same image, different `PM_TARGET_*` + peering route + tag. - VPC endpoints in pm-vpc: S3 gateway (auditmon WORM), interface endpoints for KMS + Secrets Manager; a NAT only for the outbound OIDC IdP call. ### Managed services - Aurora PostgreSQL (Serverless v2 or provisioned) — the CP store, which is always PostgreSQL regardless of what the proxied target databases run. `PM_DB_URL` → writer endpoint. Give auditmon the reader endpoint + a `SELECT`-only role. - S3 bucket, Object Lock — auditmon's WORM store. Retention is the bucket's own default policy; the monitor sets none per object. Reached via the S3 gateway endpoint. - KMS — an asymmetric key for auditmon's anchor signer (`signer.type: kms`). - ACM / wire TLS — a public ACM cert on the ALB (console HTTPS). For the SQL wire, prefer a private transport (internal NLB via PrivateLink or the peered VPC) and skip TLS. If you require wire encryption, the _proxy_ terminates it with an ACM Private CA cert (exportable) via Secrets Manager — public ACM can't be used (no key export) and a generic NLB/ALB can't terminate the in-band SQL TLS. ### ECS services and load balancing | Component | ECS service | Exposed via | Reachability | | --- | --- | --- | --- | | Web | 1 service | ALB → `/` | public (console users) | | CP · HTTP | 1+ service | ALB → `/api /auth` (+ `/oauth /mcp` if used) | private unless pmon-login/MCP used | | CP · gRPC | same tasks | internal NLB / Service Connect | internal only | | Proxy | 1 / datasource | NLB TCP listener per datasource | internal / peered clients | | auditmon | 1 service | none (no inbound) | outbound: Aurora reader, S3, KMS | - ALB (public ACM cert), path-routed on one console host: `/` → Web; `/api/* /auth/*` (+ `/oauth/* /mcp` when used) → CP. Set `PM_OIDC_REDIRECT_URI=https:///auth/oidc/callback`, `PM_MCP_RESOURCE=https:///mcp`, and `PM_TRUSTED_PROXIES` to the ALB peer address(es) the CP sees. - NLB (TCP passthrough): one listener per datasource → proxy; an internal listener (or Service Connect) for CP gRPC — never public. ### Health checks and probes The images are deploy-target agnostic — the same env contracts run on EKS, with Kubernetes `Secret`s in place of ECS task secrets and a `livenessProbe`/`readinessProbe` in place of the ECS task-definition and target-group health checks. - Control plane — `GET /health` on `PM_HTTP_PORT` (`8080`) returns `{"status":"ok"}` once the HTTP listener is up. Flyway has already migrated by then; a migration failure stops the process before this point. It's a liveness signal (process + listener up), not a deep readiness check. On EKS use an `httpGet` probe against `/health` — kubelet reaches it over the pod network, so the image needs nothing extra installed. - Proxy — no HTTP surface (a raw wire broker). `goproxy/Dockerfile`'s `HEALTHCHECK` opens a bare TCP connection to `PM_PROXY_PORT`; on EKS use a `tcpSocket` probe against the same port. This proves only that the process accepts connections — not that `PM_TARGET_HOST` or the control plane are reachable; those surface as per-session query errors, not an unready process. - Web — `GET /` on `41300` counts as alive for any response, including the redirect to `/login`. There is no dedicated web health endpoint. ### Secrets Every secret lives in Secrets Manager and is injected as an ECS task _secret_ (never plaintext env): `PM_DB_PASSWORD`, `PM_SESSION_SECRET`, `PM_RESULT_KEY`, `PM_SECRET_TOKEN`, `PM_OIDC_CLIENT_SECRET`, each proxy's `PM_TARGET_PASSWORD`, the auditmon DSN + webhook URL, and the wire TLS cert/key material (issued by ACM Private CA). Non-secret config (hosts, ports, issuer, redirect URI) stays in plain `environment`. ### Build and push images Four images. proxy and control-plane build from the repo root — they pull in sibling modules (`mysqlwire` + `analyzer` for the proxy, both resolved through `replace` directives in `goproxy/go.mod`; `:engine`/`:auth`/`:analyzer`/`:proto` for the control plane, whose image also compiles the cgo `c-shared` analyzer native lib). web and auditmon are self-contained and build from their own directory. The control-plane and proxy images build for both `linux/amd64` and `linux/arm64` (Graviton/Fargate) via `docker buildx build --platform linux/amd64,linux/arm64`, with no extra flags. Published images are on Docker Hub, built for both platforms by `.github/workflows/server-images.yml` on every `server-v*` tag — pull these rather than building your own. `latest` tracks the newest release; a version tag pins one: ```bash docker pull ridi/pm-goproxy:0.1.0 docker pull ridi/pm-control-plane:0.1.0 docker pull ridi/pm-web:0.1.0 docker pull ridi/pm-auditmon:0.1.0 ``` The same images are mirrored on ECR Public, which is worth preferring from AWS — pulls do not count against Docker Hub's rate limits and need no account: ```bash docker pull public.ecr.aws/w1t1s2q1/pm-goproxy:0.1.0 docker pull public.ecr.aws/w1t1s2q1/pm-control-plane:0.1.0 docker pull public.ecr.aws/w1t1s2q1/pm-web:0.1.0 docker pull public.ecr.aws/w1t1s2q1/pm-auditmon:0.1.0 ``` To build them yourself — note the context differs per image, and is not interchangeable: ```bash # proxy + control-plane — build context = repo root docker build -f goproxy/Dockerfile -t pm-goproxy:dev . docker build -f control-plane/Dockerfile -t pm-control-plane:dev . # web + auditmon — build context = their own directory docker build -t pm-web:dev web/ docker build -t pm-auditmon:dev auditmon/ ``` To host them in your own registry, retag and push there; the ECS task definitions below reference `.dkr.ecr…` for exactly that case. pmon is not containerized — it's a client binary users install (`brew install ridi-oss/tap/pmon`, or `go build -o pmon ./pmon`). ### ECS task definitions control-plane (excerpt): ```json { "family": "pm-cp", "cpu": "1024", "memory": "2048", "networkMode": "awsvpc", "requiresCompatibilities": ["FARGATE"], "executionRoleArn": "arn:aws:iam:::role/pm-cp-exec", "taskRoleArn": "arn:aws:iam:::role/pm-cp-task", "containerDefinitions": [ { "name": "cp", "image": ".dkr.ecr.ap-northeast-2.amazonaws.com/pm-control-plane:0.1.0", "portMappings": [ { "containerPort": 8080 }, { "containerPort": 9090, "name": "grpc" } ], "environment": [ { "name": "PM_HTTP_PORT", "value": "8080" }, { "name": "PM_GRPC_PORT", "value": "9090" }, { "name": "PM_DB_URL", "value": "jdbc:postgresql://pm.cluster-xxxx.ap-northeast-2.rds.amazonaws.com:5432/proxymonster" }, { "name": "PM_OIDC_ISSUER", "value": "https://your-org.okta.com" }, { "name": "PM_OIDC_REDIRECT_URI", "value": "https://console.example.com/auth/oidc/callback" }, { "name": "PM_MCP_RESOURCE", "value": "https://console.example.com/mcp" }, { "name": "PM_TRUSTED_PROXIES", "value": "10.20.1.15,10.20.2.15" } ], "secrets": [ { "name": "PM_DB_PASSWORD", "valueFrom": "arn:aws:secretsmanager:...:secret:pm/db-password" }, { "name": "PM_SESSION_SECRET", "valueFrom": "arn:aws:secretsmanager:...:secret:pm/session-secret" }, { "name": "PM_RESULT_KEY", "valueFrom": "arn:aws:secretsmanager:...:secret:pm/result-key" }, { "name": "PM_SECRET_TOKEN", "valueFrom": "arn:aws:secretsmanager:...:secret:pm/grpc-token" }, { "name": "PM_OIDC_CLIENT_SECRET", "valueFrom": "arn:aws:secretsmanager:...:secret:pm/oidc-client-secret" } ] } ] } ``` proxy (excerpt, one per datasource; TLS material from Secrets Manager, written to file by the entrypoint): ```json { "family": "pm-proxy-analytics-prod", "cpu": "512", "memory": "1024", "networkMode": "awsvpc", "requiresCompatibilities": ["FARGATE"], "containerDefinitions": [ { "name": "proxy", "image": ".dkr.ecr.ap-northeast-2.amazonaws.com/pm-goproxy:0.1.0", "portMappings": [{ "containerPort": 6432 }], "environment": [ { "name": "PM_ENGINE", "value": "postgres" }, { "name": "PM_DATASOURCE_NAME", "value": "analytics-prod" }, { "name": "PM_DATASOURCE_TAGS", "value": "system:production" }, { "name": "PM_PROXY_PORT", "value": "6432" }, // the NLB listener a client dials for THIS datasource — omit it and pmon cannot broker this proxy { "name": "PM_ADVERTISE_ADDR", "value": "analytics-prod.pm.example.com:6432" }, { "name": "PM_TARGET_HOST", "value": "prod-db.prod-vpc.internal" }, { "name": "PM_TARGET_PORT", "value": "5432" }, { "name": "PM_TARGET_DB", "value": "appdb" }, { "name": "PM_CONTROL_PLANE_GRPC", "value": "pm-cp.pm.internal:9090" }, { "name": "PM_TLS_CERT", "value": "/etc/pm/tls/wire.crt" }, { "name": "PM_TLS_KEY", "value": "/etc/pm/tls/wire.key" } ], "secrets": [ { "name": "PM_TARGET_PASSWORD", "valueFrom": "arn:aws:secretsmanager:...:secret:pm/target-analytics-prod" }, { "name": "PM_SECRET_TOKEN", "valueFrom": "arn:aws:secretsmanager:...:secret:pm/grpc-token" }, { "name": "WIRE_TLS_CRT", "valueFrom": "arn:aws:secretsmanager:...:secret:pm/wire-tls-crt" }, { "name": "WIRE_TLS_KEY", "valueFrom": "arn:aws:secretsmanager:...:secret:pm/wire-tls-key" } ] // entrypoint writes $WIRE_TLS_CRT/$WIRE_TLS_KEY to the PM_TLS_CERT/KEY paths, then execs the proxy } ] } ``` auditmon (excerpt; task role grants S3 + KMS + Aurora-reader): ```json { "family": "pm-auditmon", "cpu": "512", "memory": "1024", "taskRoleArn": "arn:aws:iam:::role/pm-auditmon-task", // kms:Sign, s3:PutObject(+lock), rds-db:connect "containerDefinitions": [ { "name": "auditmon", "image": ".dkr.ecr.ap-northeast-2.amazonaws.com/pm-auditmon:0.1.0", "environment": [ { "name": "AWS_REGION", "value": "ap-northeast-2" }, { "name": "AUDITMON_CONFIG", "value": "/etc/auditmon/auditmon.yaml" } ], "secrets": [ { "name": "AUDITMON_DB_DSN", "valueFrom": "arn:aws:secretsmanager:...:secret:pm/auditmon-dsn" }, { "name": "SECOPS_WEBHOOK_URL", "valueFrom": "arn:aws:secretsmanager:...:secret:pm/secops-webhook" } ] } ] } ``` web (excerpt; no secrets). It needs no `PM_CP_URL`: the ALB sends the CP's paths to the CP, so they never reach the web container: ```json { "family": "pm-web", "cpu": "512", "memory": "1024", "networkMode": "awsvpc", "requiresCompatibilities": ["FARGATE"], "executionRoleArn": "arn:aws:iam:::role/pm-web-exec", "containerDefinitions": [ { "name": "web", "image": ".dkr.ecr.ap-northeast-2.amazonaws.com/pm-web:0.1.0", "portMappings": [{ "containerPort": 41300 }] // no "environment": the console's own API calls are browser-side, path-routed // to the CP by the ALB // no "secrets": the web holds none — it forwards to the CP, which owns identity + policy } ] } ``` ### Create the resources A starting-point AWS CLI sequence with the recommended settings. Substitute account, region, subnet, and ARN placeholders; run behind your own IaC in practice. ```bash # --- Aurora PostgreSQL (Serverless v2), encrypted, master password in Secrets Manager --- aws rds create-db-cluster --db-cluster-identifier pm \ --engine aurora-postgresql --engine-version 16.4 \ --serverless-v2-scaling-configuration MinCapacity=0.5,MaxCapacity=8 \ --master-username pmadmin --manage-master-user-password \ --db-subnet-group-name pm-db-subnets --vpc-security-group-ids sg-pmdb \ --storage-encrypted --backup-retention-period 14 # --- S3 WORM bucket: Object Lock (compliance) + default retention + block public --- aws s3api create-bucket --bucket pm-audit-worm --object-lock-enabled-for-bucket \ --region ap-northeast-2 --create-bucket-configuration LocationConstraint=ap-northeast-2 aws s3api put-object-lock-configuration --bucket pm-audit-worm \ --object-lock-configuration 'ObjectLockEnabled=Enabled,Rule={DefaultRetention={Mode=COMPLIANCE,Days=730}}' aws s3api put-public-access-block --bucket pm-audit-worm \ --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true # --- KMS asymmetric key for auditmon anchor signing --- aws kms create-key --key-spec ECC_NIST_P256 --key-usage SIGN_VERIFY --description "pm-audit anchor signer" aws kms create-alias --alias-name alias/pm-audit-signer --target-key-id # --- All secrets in Secrets Manager (KMS-encrypted at rest) --- for s in db-password session-secret result-key grpc-token oidc-client-secret \ target-analytics-prod auditmon-dsn secops-webhook wire-tls-crt wire-tls-key; do aws secretsmanager create-secret --name pm/$s --secret-string "REPLACE_ME" done # --- ACM: public cert for the ALB; ACM Private CA cert (exportable) for the proxy wire --- aws acm request-certificate --domain-name console.example.com --validation-method DNS aws acm-pca issue-certificate --certificate-authority-arn --csr fileb://wire.csr \ --signing-algorithm SHA256WITHRSA --validity Value=365,Type=DAYS # export → pm/wire-tls-* # --- VPC peering to a target-DB VPC + route --- aws ec2 create-vpc-peering-connection --vpc-id vpc-pm --peer-vpc-id vpc-proddb aws ec2 create-route --route-table-id rtb-pm-private \ --destination-cidr-block 10.30.0.0/16 --vpc-peering-connection-id pcx-xxxx # --- ECS cluster + a service (proxy shown; repeat per component/datasource) --- aws ecs create-cluster --cluster-name pm --capacity-providers FARGATE aws ecs register-task-definition --cli-input-json file://pm-proxy-analytics-prod.json aws ecs create-service --cluster pm --service-name pm-proxy-analytics-prod \ --task-definition pm-proxy-analytics-prod --desired-count 2 --launch-type FARGATE \ --network-configuration 'awsvpcConfiguration={subnets=[subnet-pm-a,subnet-pm-b],securityGroups=[sg-pm-proxy]}' \ --load-balancers targetGroupArn=,containerName=proxy,containerPort=6432 # --- NLB (L4 passthrough) for the wire; the ALB (HTTP path-routing) is created similarly --- aws elbv2 create-load-balancer --name pm-wire --type network --scheme internal --subnets subnet-pm-a subnet-pm-b aws elbv2 create-target-group --name pm-proxy-analytics-prod --protocol TCP --port 6432 --vpc-id vpc-pm --target-type ip aws elbv2 create-listener --load-balancer-arn --protocol TCP --port 6432 \ --default-actions Type=forward,TargetGroupArn= ``` ### Production checklist - `PM_AUTH_DEBUG` / `PM_DEV` unset — real OIDC only. - CP gRPC never on a public listener; wire TLS at the proxy for any untrusted client path. - auditmon: `signer.type: kms`, Object-Lock bucket whose retention outlives your audit window. - Non-default `PM_SESSION_SECRET` (≥32) + strong `PM_RESULT_KEY`; all secrets via Secrets Manager; task IAM roles over static `AWS_*` keys. - Target-DB security groups admit only the proxy subnets over the VPC peering.