From 15d5871ec416328b26a95101e168e9cacf1c404b Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:05:47 +0300 Subject: [PATCH] feat(provision): provision the SemiPlot pen catalogue (#9) SemiPlot is about to let an operator edit a pen, and the database could not hold one: the SemiPlot role could only read, and semiplot_tags had one group name per pen, no stored scale and no stored visibility. The role semiplot_reader becomes semiplot, and its password moves to --plot-password / SEMIBASE_PLOT_PASSWORD. It reads the archive as before and writes its own configuration, and nothing else: a column-level UPDATE on the eight settings columns of semiplot_tags, full DML on semiplot_groups and semiplot_pen_groups, SELECT on semiplot_meta. It never inserts, deletes or re-keys a pen, because a pen's id is the SCADA variable number and belongs to SCADA. semiplot_tags gains unit, format, enabled_on_start and a paired scale_min/scale_max; groups move into semiplot_groups and semiplot_pen_groups, so a pen can sit in several. semiplot_meta records the schema version. New pens come from the keys SCADA writes. semiplot_register_new_pens() adds a hidden pen with default settings for every key in public.trends that has none; the viewer calls it at start and from its editor. It runs as semiplot_registrar, a NOLOGIN role holding only what the body needs, pins search_path to pg_catalog, pg_temp and names its relations by schema, so semiplot cannot make it register a key of its own choosing, and scada_writer, which owns trends, cannot reach superuser through it. What scada_writer can still do as the roles that read its table is stated as the trust model in provisioning.md#trust. The tail check proves the boundary by execution on every run: the allowed writes succeed, the insert, delete and key change are refused, a temporary trends shadow registers nothing, and PUBLIC holds no EXECUTE on the function. CI exercises the function body over real trends rows on PostgreSQL 17 and 14. Claude-Session: https://claude.ai/code/session_01PfZJa7neMMKPzycFR6iuhD --- .env.example | 2 +- .github/scripts/wait-for-postgres.sh | 23 + .github/workflows/ci.yml | 60 +- CLAUDE.md | 68 +- README.md | 4 +- cmd/semibase/main.go | 29 +- cmd/semibase/main_test.go | 4 +- docs/architecture/README.md | 12 +- docs/architecture/overview.md | 34 +- docs/architecture/provisioning.md | 288 +++++- docs/deployment.md | 144 ++- .../20260916-semiplot-role-and-pen-schema.md | 977 ++++++++++++++++++ embed.go | 9 + internal/provision/check.go | 426 ++++++++ internal/provision/check_test.go | 207 ++++ internal/provision/create.go | 272 +++++ internal/provision/create_test.go | 61 ++ internal/provision/provision.go | 386 +------ internal/provision/provision_test.go | 71 -- internal/provision/schema.go | 90 ++ internal/provision/schema_test.go | 246 +++++ sql/semiplot_groups.sql | 12 + sql/semiplot_meta.sql | 11 + sql/semiplot_register.sql | 21 + sql/semiplot_tags.sql | 24 +- 25 files changed, 2899 insertions(+), 582 deletions(-) create mode 100644 .github/scripts/wait-for-postgres.sh create mode 100644 docs/plans/completed/20260916-semiplot-role-and-pen-schema.md create mode 100644 internal/provision/check.go create mode 100644 internal/provision/check_test.go create mode 100644 internal/provision/create.go create mode 100644 internal/provision/create_test.go create mode 100644 internal/provision/schema.go create mode 100644 internal/provision/schema_test.go create mode 100644 sql/semiplot_groups.sql create mode 100644 sql/semiplot_meta.sql create mode 100644 sql/semiplot_register.sql diff --git a/.env.example b/.env.example index e317823..73ae99e 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,4 @@ # Precedence: command-line flag > process environment > this file. SEMIBASE_SUPER_PASSWORD= SEMIBASE_WRITER_PASSWORD= -SEMIBASE_READER_PASSWORD= +SEMIBASE_PLOT_PASSWORD= diff --git a/.github/scripts/wait-for-postgres.sh b/.github/scripts/wait-for-postgres.sh new file mode 100644 index 0000000..fbdb2e8 --- /dev/null +++ b/.github/scripts/wait-for-postgres.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Waits for the PostgreSQL inside a container to answer. A timeout fails here with a named +# error rather than falling through, which would hand the next step a diagnosis of its own. +set -euo pipefail + +container=${1:-} +[ -n "$container" ] || { echo "usage: wait-for-postgres.sh " >&2; exit 2; } + +for _ in $(seq 60); do + if docker exec "$container" pg_isready -h 127.0.0.1 -q 2>/dev/null; then + exit 0 + fi + if [ "$(docker inspect -f '{{.State.Running}}' "$container")" != true ]; then + echo "::error::$container stopped before its PostgreSQL answered" + docker logs "$container" + exit 1 + fi + sleep 2 +done + +echo "::error::PostgreSQL in $container did not answer within 120 s" +docker logs "$container" +exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d23e61..a6b71b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: linux: runs-on: ubuntu-latest + env: + SEMIBASE_SUPER_PASSWORD: ci-super + SEMIBASE_WRITER_PASSWORD: ci-writer services: postgres: image: postgres:17-alpine @@ -61,24 +64,57 @@ jobs: # finds every object in place and still exits on a passing access check. - name: Provision the service container env: - SEMIBASE_SUPER_PASSWORD: ci-super - SEMIBASE_WRITER_PASSWORD: ci-writer - SEMIBASE_READER_PASSWORD: ci-reader + SEMIBASE_PLOT_PASSWORD: ci-plot run: | go build -o semibase ./cmd/semibase ./semibase site --database semiplot_dev --expected-major 17 ./semibase site --database semiplot_dev --expected-major 17 + # Every other step runs semiplot_register_new_pens() over an empty + # public.trends, so its INSERT adds nothing. + - name: Register pens from the keys SCADA wrote + env: + PGHOST: localhost + PGDATABASE: semiplot_dev + run: | + set -euo pipefail + as_writer() { PGUSER=scada_writer PGPASSWORD=ci-writer psql -v ON_ERROR_STOP=1 -qtAc "$1"; } + as_plot() { PGUSER=semiplot PGPASSWORD=ci-plot psql -v ON_ERROR_STOP=1 -qtAc "$1"; } + as_writer "CREATE TABLE public.tp2026m01d01 PARTITION OF public.trends + FOR VALUES FROM ('2026-01-01') TO ('2026-01-02'); + INSERT INTO public.trends (id, l, t, v, q) VALUES + (0, 0, '2026-01-01 00:00:00', 1.5, 192), (0, 0, '2026-01-01 00:00:01', 1.6, 192), + (13, 0, '2026-01-01 00:00:00', 20, 192), (40, 1, '2026-01-01 00:01:00', 7, 192)" + first=$(as_plot 'SELECT semiplot_register_new_pens()') + second=$(as_plot 'SELECT semiplot_register_new_pens()') + pens=$(as_plot "SELECT string_agg(concat_ws(' ', id, name, color, enabled_on_start), ',' + ORDER BY id) FROM semiplot_tags") + want='0 0 #4E79A7 f,13 13 #F28E2B f,40 40 #59A14F f' + if [[ "$first" != 3 || "$second" != 0 || "$pens" != "$want" ]]; then + echo "::error::first call '$first' (want 3), second '$second' (want 0), pens '$pens' (want '$want')" + exit 1 + fi + + - name: Provision a PostgreSQL 14 container, where the REVOKE bites + env: + SEMIBASE_PLOT_PASSWORD: ci-plot + run: | + set -euo pipefail + docker run -d --name semibase-pg14 -e POSTGRES_PASSWORD=ci-super \ + -p 15434:5432 postgres:14-alpine + bash .github/scripts/wait-for-postgres.sh semibase-pg14 + # ./semibase is the binary the service-container step built + ./semibase bench --host 127.0.0.1 --port 15434 --database semiplot_dev --expected-major 14 + # The socket path, which nothing else covers: the same binary, run the way # the official image runs an init script. During init the server has # listen_addresses empty and only /var/run/postgresql answers, superuser # auth is local trust, and the script's own "set -e" makes the entrypoint - # abort on failure. So the container reaching a state where semiplot_reader + # abort on failure. So the container reaching a state where semiplot # selects over TCP proves bench completed over the socket. - name: Provision over a unix socket, as a postgres image init script env: - SEMIBASE_WRITER_PASSWORD: ci-writer - SEMIBASE_READER_PASSWORD: ci-reader + SEMIBASE_PLOT_PASSWORD: ci-plot # postgres:17-alpine is musl. The runner has a C toolchain, so an # unset CGO_ENABLED defaults to 1 and links against glibc; the image # then reports the binary as "not found", which reads as a missing @@ -110,15 +146,11 @@ jobs: # nothing is published: the host side of a published port is bound by # docker-proxy before postgres listens, so it answers a probe too early docker run -d --name semibase-initdb -e POSTGRES_PASSWORD=ci-super \ - -e SEMIBASE_WRITER_PASSWORD -e SEMIBASE_READER_PASSWORD semibase-initdb + -e SEMIBASE_WRITER_PASSWORD -e SEMIBASE_PLOT_PASSWORD semibase-initdb # TCP inside the container opens only once the init scripts have run - for _ in $(seq 60); do - if docker exec semibase-initdb pg_isready -h 127.0.0.1 -q 2>/dev/null; then break; fi - if [ "$(docker inspect -f '{{.State.Running}}' semibase-initdb)" != true ]; then break; fi - sleep 2 - done - if ! docker exec -e PGPASSWORD=ci-reader semibase-initdb psql -h 127.0.0.1 \ - -U semiplot_reader -d semiplot_dev -tAc 'select count(*) from semiplot_tags'; then + bash .github/scripts/wait-for-postgres.sh semibase-initdb + if ! docker exec -e PGPASSWORD=ci-plot semibase-initdb psql -h 127.0.0.1 \ + -U semiplot -d semiplot_dev -tAc 'select count(*) from semiplot_tags'; then echo "::error::provisioning over the unix socket did not complete" docker logs semibase-initdb exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 5b4cb3b..0bc0ace 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ provisions and configures the instance that hosts the Simple-Scada 2 archive: th CLI binary, `semibase.exe`, a container image carrying its Linux build, plus the instance's architecture docs. Deployment target: Windows. Language: Go (module `github.com/Semiteq/SemiBase`), driver -`pgx/v5`, `sql/semiplot_tags.sql` and `sql/trends.sql` embedded via `go:embed`. Entry point: +`pgx/v5`, the four `sql/semiplot_*.sql` files and `sql/trends.sql` embedded via `go:embed`. Entry point: `cmd/semibase`. The module is pure `pgx` and compiles for any GOOS; Linux builds serve the containerised test bench of consumers (SemiPlot runs `bench` against an ephemeral `postgres:17` container). All commands run from the repository root. @@ -81,14 +81,21 @@ golangci-lint is pinned to 2.12.2 (`winget install GolangCI.golangci-lint --vers config in `.golangci.yml`. CI (`.github/workflows/ci.yml`) runs `go build`, `go test -race`, and the same lint on `windows-latest` and `ubuntu-latest` for every push and pull request; the Linux job also provisions a `postgres:17-alpine` service container by running `site` -twice — the tuning path and the idempotency check in one step — provisions a second container -with `bench` as a `postgres` image init script over the unix socket, and builds the container -image, so a broken `Dockerfile` fails on the pull request rather than at tag time. CI pushes -nothing. +twice — the tuning path and the idempotency check in one step. +It then runs `bench` against a `postgres:14-alpine` container — the version floor, and the only +place `REVOKE CREATE ON SCHEMA public FROM PUBLIC` bites, because 15 and later withhold that grant +by engine default — provisions a third container with `bench` as a `postgres` image init script +over the unix socket, and builds the container image, so a broken `Dockerfile` fails on the pull +request rather than at tag time. CI pushes nothing. Between the 17 and the 14 steps, the +`Register pens from the keys SCADA wrote` step writes `trends` rows as `scada_writer`, calls +`semiplot_register_new_pens()` twice as `semiplot` and requires 3, then 0, and the three rows it +added: the only place the function body runs over a non-empty archive. It needs `psql` on the +runner; to run it locally, point `psql` at a container. Unit tests live beside the source (`cmd/semibase/*_test.go`, `internal/provision/*_test.go`), -table-driven. There are no database-touching tests in the repository; the integration check is -the reader-access check both commands run at the tail, against a live server. +table-driven. There are no database-touching tests in the repository; the integration checks are +the `semiplot` access check both commands run at the tail and the four CI database steps above, +against a live server. ## Format @@ -106,24 +113,51 @@ gofmt -w . # run before presenting changes; gofmt is authoritative ``` `site` and `bench` differ in one thing: `site` applies the `ALTER SYSTEM` memory constants, -`bench` does not. Both create the database, the roles, the grants, `semiplot_tags` and -`public.trends`, and both end by actually reading `public.trends` as `semiplot_reader` and -checking that the same role holds no `INSERT`. A failed check is a non-zero exit. +`bench` does not. Both create the database, the roles, the grants, the four SemiPlot tables +(`semiplot_tags`, `semiplot_groups`, `semiplot_pen_groups`, `semiplot_meta`), the +`semiplot_register_new_pens()` function and `public.trends`, and both end by actually reading +`public.trends` and `semiplot_meta.schema_version` as `semiplot`, actually calling +`semiplot_register_new_pens()`, proving it ignores a temporary `trends` of its caller, and writing +`semiplot_groups`, `semiplot_pen_groups` and the settings columns of `semiplot_tags` as `semiplot` +in a rolled-back transaction, requiring 42501 for `INSERT`, `DELETE` and `UPDATE ... SET id` on +`semiplot_tags` in the same transaction, and checking that `PUBLIC` holds no `EXECUTE` on +`semiplot_register_new_pens()`, and that `semiplot` holds no `INSERT`, `UPDATE` or `DELETE` +on the archive or on `semiplot_meta` and no `CREATE` on schema `public` (revoked from `PUBLIC` by +`create`, since 14 still grants it). A failed check is a non-zero exit. + +The `semiplot` grant on `semiplot_tags` is column-level: `SELECT` and `UPDATE` on the eight settings +columns, never `id`, so `has_table_privilege(..., 'UPDATE')` answers false for that table and no +check may ask it. + +Four roles take part: the superuser the tool connects as (it owns the `semiplot_*` tables), +`scada_writer`, `semiplot`, and `semiplot_registrar`, a `NOLOGIN` role that owns +`semiplot_register_new_pens()` and holds only what its body needs. The function is `SECURITY +DEFINER` with `search_path = pg_catalog, pg_temp` and a schema-qualified body; the reasons are in +`docs/architecture/provisioning.md#registering-new-pens`. `scada_writer` is trusted: the grants bound +the operator acting through `semiplot`, not the SCADA (`docs/architecture/provisioning.md#trust`). +`semiplot_meta.schema_version` is `1` in +this release and is a floor: a viewer refuses a database below the version it needs and accepts one +above. Passwords come from flags, env, or a `.env` file in the working directory (flag > env > `.env`; template `.env.example`): `SEMIBASE_SUPER_PASSWORD`, `SEMIBASE_WRITER_PASSWORD`, -`SEMIBASE_READER_PASSWORD`. The writer and reader passwords are needed only on a first run, -which creates those roles; `public.trends` is created under `SET ROLE scada_writer` on the -superuser connection, so the table's owner is the role the SCADA writes with and no -`pg_hba.conf` line has to admit a `scada_writer` login. +`SEMIBASE_PLOT_PASSWORD`. The writer and `semiplot` passwords are needed on a first run, which +creates those roles. Otherwise the superuser password alone carries a run, and the `semiplot` password only decides +whether the run also tests that role's TCP login. `public.trends` is created under +`SET ROLE scada_writer` on the superuser connection, so the table's owner is the role the SCADA +writes with and no `pg_hba.conf` line has to admit a `scada_writer` login. ## Layout ``` cmd/semibase/ CLI entry point: command dispatch, flags, usage text -internal/provision/ the phases behind the two commands: config (ALTER SYSTEM), create - (db/roles/grants/semiplot_tags/trends), check (reader access) -sql/ embedded DDL: semiplot_tags (ours) and trends (the vendor's shape) +internal/provision/ one file per phase behind the two commands: config.go (ALTER SYSTEM), + create.go (db/roles/grants/trends), schema.go (the semiplot schema files, + their grants, the pen-registration function's grant and its owner's + registrarStatements), check.go (semiplot access). provision.go + holds Options, Site/Bench and the connection plumbing they share +sql/ embedded DDL: the four semiplot_* files (ours: the tables, the + pen-registration function, the version stamp) and trends (the vendor's shape) docs/ human docs in Russian (enter at docs/readme.md) docs/architecture/ agent-facing design docs in English docs/plans/ dated implementation plans diff --git a/README.md b/README.md index 073b8b9..eddc79f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ SemiBase — разворачиваемый PostgreSQL-сервис для ус # установка pgsql winget install --id PostgreSQL.PostgreSQL.17 --exact -# конфигурация сервера, архивная база, роли, права, таблица архива +# конфигурация сервера, архивная база, роли `scada_writer`, `semiplot` и +# `semiplot_registrar`, права, таблица архива, четыре таблицы SemiPlot и функция +# регистрации новых перьев .\semibase.exe site ``` diff --git a/cmd/semibase/main.go b/cmd/semibase/main.go index e74665c..7c06e82 100644 --- a/cmd/semibase/main.go +++ b/cmd/semibase/main.go @@ -24,13 +24,16 @@ Usage: Commands: site an installation machine: apply the memory tuning (ALTER SYSTEM + reload), - then create the archive database, the roles, the access chain, - semiplot_tags and public.trends + then create the archive database, the roles, the access chain, public.trends, + the four semiplot_* tables and semiplot_register_new_pens() bench a throwaway container: the same, without the tuning version print the build revision -Both commands end by reading public.trends as semiplot_reader and checking that the -same role cannot write it; a failed check is a non-zero exit. +Both commands end by checking the semiplot role in a transaction that is rolled back: +it reads public.trends and semiplot_meta, registers new pens through +semiplot_register_new_pens(), updates pen settings and groups, and is refused an insert, +a delete or a key change on semiplot_tags; it writes neither the archive nor +semiplot_meta and holds no CREATE on schema public. A failed check is a non-zero exit. Every step checks before it creates; re-running either command is safe. Passwords of existing roles change only when the corresponding flag or variable is set. @@ -39,11 +42,13 @@ Passwords come from flags, environment variables, or a .env file in the working directory (flag wins over environment, environment wins over .env): --super-password SEMIBASE_SUPER_PASSWORD superuser --writer-password SEMIBASE_WRITER_PASSWORD scada_writer - --reader-password SEMIBASE_READER_PASSWORD semiplot_reader + --plot-password SEMIBASE_PLOT_PASSWORD semiplot -A first run creates both roles and fails without their passwords. Once the roles exist, -the superuser password alone carries a run; the reader password then only decides -whether the run also tests the semiplot_reader login, which it does over TCP. +A first run creates scada_writer and semiplot and fails without their passwords; it also +creates semiplot_registrar, which owns the registration function and never logs in, so +it has no password. Once the roles exist, +the superuser password alone carries a run; the semiplot password then only decides +whether the run also tests the semiplot login, which it does over TCP. Run 'semibase --help' for the command's flags. ` @@ -161,8 +166,8 @@ func newFlagSet(command string, options *provision.Options) *flag.FlagSet { "superuser password (env SEMIBASE_SUPER_PASSWORD)") flags.StringVar(&options.WriterPassword, "writer-password", "", "scada_writer password (env SEMIBASE_WRITER_PASSWORD)") - flags.StringVar(&options.ReaderPassword, "reader-password", "", - "semiplot_reader password (env SEMIBASE_READER_PASSWORD)") + flags.StringVar(&options.PlotPassword, "plot-password", "", + "semiplot password (env SEMIBASE_PLOT_PASSWORD)") flags.IntVar(&options.ExpectedMajor, "expected-major", 0, "required server major version; 0 accepts any major >= 14") return flags @@ -189,8 +194,8 @@ func resolvePasswordsFromEnvironment(options *provision.Options) { if options.WriterPassword == "" { options.WriterPassword = os.Getenv("SEMIBASE_WRITER_PASSWORD") } - if options.ReaderPassword == "" { - options.ReaderPassword = os.Getenv("SEMIBASE_READER_PASSWORD") + if options.PlotPassword == "" { + options.PlotPassword = os.Getenv("SEMIBASE_PLOT_PASSWORD") } } diff --git a/cmd/semibase/main_test.go b/cmd/semibase/main_test.go index 8a36eb7..9ce3346 100644 --- a/cmd/semibase/main_test.go +++ b/cmd/semibase/main_test.go @@ -18,7 +18,7 @@ import ( var passwordEnvironmentNames = []string{ "SEMIBASE_SUPER_PASSWORD", "SEMIBASE_WRITER_PASSWORD", - "SEMIBASE_READER_PASSWORD", + "SEMIBASE_PLOT_PASSWORD", } // callers must not use t.Parallel: the streams are package-level @@ -59,7 +59,7 @@ func TestFlagUsageHidesPasswordValues(t *testing.T) { secrets := map[string]string{ "SEMIBASE_SUPER_PASSWORD": "hunter2-super", "SEMIBASE_WRITER_PASSWORD": "hunter2-writer", - "SEMIBASE_READER_PASSWORD": "hunter2-reader", + "SEMIBASE_PLOT_PASSWORD": "hunter2-plot", } for name, value := range secrets { t.Setenv(name, value) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 6905cef..f9c2441 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -8,8 +8,9 @@ Declarative architecture docs (English, present tense). These describe the syste - [overview.md](./overview.md) — purpose, consumers, components, provisioning order. - [configuration.md](./configuration.md) — every setting changed from the PostgreSQL default, with why. -- [provisioning.md](./provisioning.md) — the tool's two commands, idempotency rules, the - reader-access chain, and the stated assumption about the SCADA meeting an existing `trends`. +- [provisioning.md](./provisioning.md) — the tool's two commands, idempotency rules, the four + roles, the SemiPlot configuration schema, the access + chain, and the stated assumption about the SCADA meeting an existing `trends`. ## Locked decisions @@ -20,10 +21,11 @@ Declarative architecture docs (English, present tense). These describe the syste | Provisioning tool | Go single binary `semibase.exe` (`pgx/v5`, embedded SQL); no runtime or `psql` on the target machine | | Instance configuration | Fixed `ALTER SYSTEM` deltas applied by `semibase site` via `pg_reload_conf()`, never by editing `postgresql.conf`; `shared_buffers` takes effect at the next service restart, reported through `pg_settings.pending_restart` | | Hardware floor | Installation machines guarantee 8 GB RAM and 50 GB database disk; memory settings are fixed constants sized to that floor, identical on every machine | -| Roles | `scada_writer` (SCADA), `semiplot_reader` (viewers, `SELECT` only); `semiplot_*` objects owned by `postgres` — a dedicated owner role appears when a tag-editing mechanism exists | +| Roles | Four: the superuser the tool connects as, `scada_writer` (SCADA), `semiplot` (viewers), and `semiplot_registrar`, a `NOLOGIN` role that owns `semiplot_register_new_pens()` so its `SECURITY DEFINER` body never runs as the superuser. `semiplot` holds `SELECT` on the archive; `SELECT` on `semiplot_tags` and `UPDATE` on its settings columns, never on `id`; full row access to `semiplot_groups` and `semiplot_pen_groups`; `SELECT` on `semiplot_meta`; `EXECUTE` on the function; and no `CREATE` on schema `public` — revoked from `PUBLIC` by `create`, since PostgreSQL 14 still grants it by default. The superuser owns the tables, because it is the connection that creates them. One viewer role rather than a second write-only one: both passwords would share one plaintext file, so the split would separate nothing | | Command surface | Two commands, `site` and `bench`, named for the situation rather than for the tool's internal steps; they differ in one thing, the memory tuning, which only `site` applies | -| Reader access | `ALTER DEFAULT PRIVILEGES FOR ROLE scada_writer` set **before** `public.trends` is created, and before the writer first runs | -| Objects we add | `semiplot_tags` and the vendor-shaped `public.trends` — no triggers, functions, scheduled jobs, or extensions; `messages` is not created, nothing we ship reads it | +| Archive read access | `ALTER DEFAULT PRIVILEGES FOR ROLE scada_writer` set **before** `public.trends` is created, and before the writer first runs. The `semiplot_*` tables are granted table by table instead, since the superuser creates them | +| Objects we add | `semiplot_tags`, `semiplot_groups`, `semiplot_pen_groups`, `semiplot_meta`, the `semiplot_register_new_pens()` function the viewer calls, and the vendor-shaped `public.trends` — no triggers, scheduled jobs, extensions, or other functions; `messages` is not created, the SCADA makes it | +| SemiPlot schema version | `semiplot_meta.schema_version`, `1` in this release, written by `provision` alone. A floor, not an equality: a viewer refuses a database below the version it needs and accepts one above, because the versions this repository issues grow by addition. | | Archive schema | Owned by Simple-Scada 2 and documented in the SemiPlot repository. This tool creates `public.trends` once, with the vendor's shape, connected as `scada_writer`, and never alters it afterwards; day partitions and every later change to the schema remain the vendor's | | Distribution | Binaries as GitHub release assets; the Linux binary also as `ghcr.io/semiteq/semibase`, `FROM scratch`, one file, pushed after the release exists; benches track `:latest` and a prerelease never moves it | | Backup method | `UNDECIDED` — commissioning-time, needs the customer's archive size | diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 95dd089..cc0f09a 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -16,26 +16,33 @@ its own retention setting. | Consumer | Role | Access | | --- | --- | --- | | Simple-Scada 2 (archives into PostgreSQL) | `scada_writer` | Owns and writes `trends`, creates its day partitions, creates and writes `messages`; executes retention | -| SemiPlot (trend viewer) | `semiplot_reader` | `SELECT` on `trends` and `semiplot_tags` — nothing else | +| SemiPlot (trend viewer) | `semiplot` | `SELECT` on `trends` and `messages`; read and edit a pen's settings in `semiplot_tags`, never its `id`, and never add or delete a pen by hand; `EXECUTE` on `semiplot_register_new_pens()`, which adds a pen for every key SCADA has written; read and write `semiplot_groups` and `semiplot_pen_groups`; `SELECT` on `semiplot_meta` — nothing else | -The `semiplot_*` objects are owned by `postgres`; commissioning fills `semiplot_tags` as the -superuser. A dedicated owner role appears when a tag-editing mechanism exists to hold it. +Two more roles take part. The superuser the tool connects as (`--superuser`, default `postgres`) +owns the four `semiplot_*` tables, because it is the connection that applies their DDL. +`semiplot_registrar`, which logs in nowhere, owns `semiplot_register_new_pens()`, so the function +runs with that role's narrow grants rather than as the superuser +(`provisioning.md#registering-new-pens`). `semiplot` edits +pen settings and groups and can create nothing beside the four tables, because `create` revokes `CREATE` on schema `public` +from `PUBLIC` and the tail check reads it back. -The reader credential grants process-history reads and nothing more, which is what makes a -plaintext password in a client configuration file an acceptable risk. +The `semiplot` credential grants process-history reads and pen-catalogue edits, and nothing more, +which is what makes a plaintext password in a client configuration file an acceptable risk. A bug +in the viewer can corrupt the pen catalogue, which is recoverable; it cannot touch `trends`. ## Components | Component | Responsibility | | --- | --- | -| `cmd/semibase`, `internal/provision` | `semibase.exe` — idempotent provisioning in two commands, `site` and `bench`: instance tuning (`site` only), database, roles, grants, `semiplot_tags`, `public.trends`, and the reader-access checks that end every run | -| `sql/semiplot_tags.sql` | DDL for the one object we invented, embedded into the binary | +| `cmd/semibase`, `internal/provision` | `semibase.exe` — idempotent provisioning in two commands, `site` and `bench`: instance tuning (`site` only), database, roles, grants, the four SemiPlot tables, `semiplot_register_new_pens()`, `public.trends`, and the `semiplot` access checks that end every run | +| `sql/semiplot_tags.sql`, `sql/semiplot_register.sql`, `sql/semiplot_groups.sql`, `sql/semiplot_meta.sql` | DDL for the objects we invented — the pen catalogue, the function that registers new pens, the groups and their membership, and the schema version — embedded into the binary | | `sql/trends.sql` | The vendor's archive-table shape, transcribed and embedded; applied under `SET ROLE scada_writer`, so the table's owner is the writer | | `Dockerfile` | `ghcr.io/semiteq/semibase` — the Linux binary alone on `scratch`, for consumers that layer it into a bench image | | `docs/architecture/` | The instance as it is: configuration deltas, provisioning order, ownership | Nothing else is added to the database. Specifically, and deliberately: no summary tables, no -triggers, no functions, no scheduled jobs, no extensions. The SCADA's own archive layers already +triggers, no scheduled jobs, no extensions, and no function but `semiplot_register_new_pens()`, +which runs only when the viewer calls it (`provisioning.md#registering-new-pens`). The SCADA's own archive layers already provide the coarse resolutions; a second copy would duplicate them and add a process that can silently stop. @@ -45,16 +52,19 @@ The order matters, because the default privileges have to be in place before any `scada_writer` owns is created. 1. Install the engine: `winget install --id PostgreSQL.PostgreSQL.17 --exact`. -2. `semibase site` — tuning, then the archive database, both roles, default privileges, - `semiplot_tags` and `public.trends`; it ends by proving the reader reads `trends` and cannot - write it, and warns while `shared_buffers` waits for a service restart. +2. `semibase site` — tuning, then the archive database, the three created roles, default privileges, + `public.trends`, the four SemiPlot tables and `semiplot_register_new_pens()`; it ends by proving `semiplot` reads `trends`, writes + what it owns, cannot add, delete or re-key a pen and cannot write the archive, and warns while `shared_buffers` waits for a service + restart. 3. Restart the PostgreSQL service or reboot the machine, so `shared_buffers` takes effect. 4. Point the Simple-Scada project at the database and start it once. It is expected to find `trends` in place, write into it, and create the day partitions and `messages` itself — an **unverified** assumption, with the experiment that settles it, in `provisioning.md` ("Assumption: the SCADA meeting an existing `trends`"). Set `log_statement = 'all'` for that first start and read back the DDL the SCADA issued. -5. Fill `semiplot_tags` with the variables to be trended. +5. Start the viewer. Pens appear in `semiplot_tags` on their own, one per variable the SCADA has + written to `trends`, with default settings the viewer's editor then changes. A person never + inserts a pen, and only the superuser can delete one. 6. Write the SemiPlot connection file, including the source time zone. A bench replaces step 2 with `semibase bench` and has no step 3 or 4: the consumer's seeder plays diff --git a/docs/architecture/provisioning.md b/docs/architecture/provisioning.md index 3c18cd7..0bb6451 100644 --- a/docs/architecture/provisioning.md +++ b/docs/architecture/provisioning.md @@ -7,7 +7,9 @@ matters, is a liability. The binary is self-contained: `pgx/v5` speaks the wire protocol itself, over TCP or a unix socket, so neither `psql.exe` nor any runtime needs to be present on the target machine. -`sql/semiplot_tags.sql` and `sql/trends.sql` are embedded at build time. Installing the PostgreSQL +The SQL is embedded at build time: `sql/trends.sql` for the archive table, and +`sql/semiplot_tags.sql`, `sql/semiplot_register.sql`, `sql/semiplot_groups.sql` and +`sql/semiplot_meta.sql` for the SemiPlot configuration schema. Installing the PostgreSQL engine itself is one documented `winget` line, deliberately outside the tool — OS package management is not database provisioning. @@ -39,7 +41,7 @@ the release job reads the built image back and fails if the manifest says anythi `latest` is the tag consumers track, and that is deliberate. A delivered installation updates neither the database service nor the viewer on its own, so the only pair ever newly deployed is -the newest SemiBase with the reader as it stands; a bench pinned to an old image would test a +the newest SemiBase with the viewer as it stands; a bench pinned to an old image would test a pair nobody runs. The `vX.Y.Z` tag is immutable and exists to place blame and to reproduce a failing pair. @@ -85,18 +87,22 @@ a test of what a site runs: | Step | `site` | `bench` | | --- | --- | --- | | `ALTER SYSTEM` constants from `configuration.md`, applied with `pg_reload_conf()` | yes | no — the constants are sized for an installation machine and mean nothing to a container | -| Archive database, both roles, grants, default privileges, `semiplot_tags` | yes | yes | +| Archive database, the three created roles, grants, default privileges | yes | yes | | `public.trends` with the `tpdefault` partition, created as `scada_writer` | yes | yes | -| Reader-access checks at the tail | yes | yes | +| The four SemiPlot tables and `semiplot_register_new_pens()`, after `public.trends` because the function body names it | yes | yes | +| The `semiplot` access checks at the tail | yes | yes | | Pending-restart warning at the tail | yes | yes — `bench` tunes nothing, but it runs against existing servers too, and one a `site` run tuned can still be waiting for its restart | `version` (or `--version`) prints the build revision: the ldflags value (`-X main.revision=...`) when a release sets it, otherwise the VCS revision from `debug.ReadBuildInfo` with a `-dirty` suffix for a modified tree. -Every step checks before it creates, so re-running any command is safe. Role passwords are set +Every step is written to survive a re-run, most of them by checking before they create, so +re-running any command is safe. + +Role passwords are set only when the corresponding flag or environment variable is present (`SEMIBASE_SUPER_PASSWORD`, -`SEMIBASE_WRITER_PASSWORD`, `SEMIBASE_READER_PASSWORD`); a `.env` +`SEMIBASE_WRITER_PASSWORD`, `SEMIBASE_PLOT_PASSWORD`); a `.env` file in the working directory is read as the lowest-precedence source (flag > environment > `.env`; `.env.example` is the template, `.env` is gitignored). A run without passwords leaves existing credentials untouched. Flag usage output shows only the environment variable names, @@ -122,7 +128,7 @@ The socket form is what lets `bench` run as an init script in the official `post whose entrypoint serves `/docker-entrypoint-initdb.d/` from a temporary server with `listen_addresses` set to empty — reachable over the socket only. The Linux CI job runs exactly that: it layers the freshly built binary onto `postgres:17-alpine` behind such an init script and -asserts the container reaches a state where the reader can query. +asserts the container reaches a state where `semiplot` can query. Messages name the socket file rather than a URL — `/var/run/postgresql/.s.PGSQL.5432 (scada_archive)`. The database is in parentheses because a @@ -143,6 +149,10 @@ The package that runs the SQL owns its guards; the CLI is a thin dispatcher. - **Literal escaping.** `connect` issues `SET standard_conforming_strings = on` for the session, which makes the quote-doubling `escapeLiteral` sufficient in every server mode, including backslash-containing passwords. +- **Object resolution.** `connect` also issues `SET search_path = public` for the session. The + `semiplot_*` objects are named unqualified in the embedded DDL and in the tail check's + `has_table_privilege` arguments, so without the setting a `semiplot_tags` in another schema on + the superuser's `search_path` would take the grants and the probes. - **Cancellable execution.** Every phase honors its context: cancellation stops the in-flight query through pgx instead of killing the process mid-DDL. The context itself — `signal.NotifyContext(..., os.Interrupt)` plus the five-minute per-command timeout @@ -150,19 +160,184 @@ The package that runs the SQL owns its guards; the CLI is a thin dispatcher. - **Pending-restart reporting.** The tool never touches the service manager. The tuning phase applies its deltas with `pg_reload_conf()` and reads `pg_settings.pending_restart` back from the server; both commands read it again as the **last** statement of the tail, after the - access checks, so the last line either prints names a pending `shared_buffers`. The operator + access checks, so a pending `shared_buffers` is named in the run's last line. The operator restarts the service or reboots the machine — the one manual step, and the tool says so instead of doing it. -## The reader-access chain +## The roles + +Four roles take part, and the tool creates three of them. -`semiplot_reader` needs `SELECT` on `trends` and on every day partition the SCADA creates later — -objects that do not exist when the grant has to be decided. A plain `GRANT` cannot be issued on a -table that is not there, so the create phase sets: +| Role | Created by | What it holds | +| --- | --- | --- | +| The superuser (`--superuser`, default `postgres`) | the engine install | Every connection this tool makes. It owns the four `semiplot_*` tables, because it is the connection that applies their DDL | +| `semiplot_registrar` | `semibase` | `NOLOGIN`. Owns `semiplot_register_new_pens()` and holds what its body needs: `SELECT` on `public.trends`, `SELECT (id)` and `INSERT (id, name, color, enabled_on_start)` on `semiplot_tags` ([Registering new pens](#registering-new-pens)) | +| `scada_writer` | `semibase` | Owns `public.trends`, its day partitions and `messages`, and writes them. `CREATE` on schema `public`. Nothing on any `semiplot_*` table | +| `semiplot` | `semibase` | `SELECT` on `trends` and `messages`; `SELECT` on `semiplot_tags` and `UPDATE` on its eight settings columns, never on `id`; `SELECT, INSERT, UPDATE, DELETE` on `semiplot_groups` and `semiplot_pen_groups`; `SELECT` on `semiplot_meta`; `EXECUTE` on `semiplot_register_new_pens()`. No `CREATE` on schema `public` | + +`semiplot` edits its own pen catalogue and cannot touch the archive. Both facts are grants on +individual tables, so the second does not weaken when the first is given: the viewer's editor +saves a pen through the same connection it reads history on, and a bug in it cannot corrupt +`trends`. + +A `semiplot_tags` row is keyed by `id`, the SCADA variable number. SemiPlot owns the settings in the +row and none of the keys: its editor changes a pen and never adds one, deletes one or moves one onto +another variable. The grant on that table is therefore column-level, +`GRANT SELECT, UPDATE (name, unit, format, color, line_style, enabled_on_start, scale_min, scale_max)`, +and a table-level `UPDATE` would not do: it would let `UPDATE semiplot_tags SET id = ...` re-key a +pen. A column grant is not a table grant, so `has_table_privilege('semiplot', 'semiplot_tags', +'UPDATE')` answers false, and no check asks it. A pen row is added only by +`semiplot_register_new_pens()` ([Registering new pens](#registering-new-pens)), and only the +superuser deletes a pen. + +The missing `CREATE` is issued rather than inherited. PostgreSQL 15 dropped the default that +grants `CREATE` on schema `public` to `PUBLIC`, but 14 is this tool's floor and still carries it, +so `create` issues `REVOKE CREATE ON SCHEMA public FROM PUBLIC` and grants it back to +`scada_writer` alone, and the tail check reads `has_schema_privilege` back. Without the `REVOKE` +the property held on 15 and later by engine default and not at all on 14. + +The `Provision a PostgreSQL 14 container, where the REVOKE bites` CI step is what gates the +statement. On `postgres:17-alpine` the tail check reads `has_schema_privilege` false with or +without it, so every other step in the file stays green when the `REVOKE` is deleted; on +`postgres:14-alpine` `semiplot` holds `CREATE` through `PUBLIC` and the run exits 1. Measured +both ways. + +A second, write-only role would keep the long-lived read connection free of any write privilege. +It was rejected: both passwords would sit in the same plaintext `connection.yaml` on the same +machine, so the separation separates nothing that an attacker who reads one file does not already +have. + +### Trust + +`scada_writer` is the SCADA itself, on the same machine, and it is trusted. It owns `public.trends` +and can already rewrite or drop every sample in it. A role that reads a table runs code its owner +attaches to it: a row-level-security policy on `trends`, or a view `scada_writer` puts in its +place, calls its functions as the reading role. So `scada_writer` acts as `semiplot` whenever +`semiplot` reads `trends`, and as `semiplot_registrar` whenever `semiplot_register_new_pens()` runs. +Measured on 14 and 17: through a policy, one `SELECT semiplot_register_new_pens()` inserted a pen +and re-granted the function to `PUBLIC`, and one plain read by `semiplot` renamed every pen and +deleted every group. No per-function setting closes this: `SET row_security = off` is answered by +replacing the table with a view. + +The grants enforce one boundary: the SemiPlot operator, acting through `semiplot`, updates pen +settings and groups and never inserts, deletes or re-keys a `semiplot_tags` row. `semiplot_registrar` +still earns its place against `scada_writer`: under a superuser owner the same view code ran as the +superuser and reached the whole cluster; under `semiplot_registrar` it reaches `semiplot_tags` +(`SELECT (id)` and `INSERT` on four columns) and the function the role owns. + +## The SemiPlot configuration schema + +Four tables and one function, applied on every run after `public.trends` exists and in the order +their references need. `semiplot_register.sql` follows `semiplot_tags.sql`, because PostgreSQL +checks a `LANGUAGE sql` body against the relations it names when the function is created: a missing +`trends` or `semiplot_tags` fails the statement. A membership row names a pen, so +`semiplot_tags.sql` also runs before `semiplot_groups.sql`, and `semiplot_meta.sql` follows them. + +| Table | Holds | +| --- | --- | +| `semiplot_tags` | One pen: `id` matching `trends.id`, `name`, `unit`, `format` as a .NET numeric format string the viewer applies and the server does not validate, `color` as `#RRGGBB`, `line_style` (0 interpolated, 1 stepped), `enabled_on_start`, and the `scale_min`/`scale_max` pair that bounds the pen's own Y axis. Both bounds `NULL` means autoscale | +| `semiplot_groups` | A group name. `GENERATED ALWAYS AS IDENTITY` rather than `serial`, so `semiplot` needs no `USAGE` on a sequence to insert one | +| `semiplot_pen_groups` | Membership, `(pen_id, group_id)`. A pen may sit in several groups and in none: a pen with no row here is legal and the viewer shows it ungrouped | +| `semiplot_meta` | One row: `schema_version`. A `singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton)` admits no second row, and the value is written by one `INSERT ... ON CONFLICT (singleton) DO UPDATE`, so a failure cannot leave the table empty and a re-run cannot leave two rows | + +These four are granted table by table, not through default privileges: the superuser connection +creates them, so no runtime owner exists whose future tables a default-privileges rule could cover, +and one explicit `GRANT` per table says what the role holds. `semiplotTables` in +`internal/provision` is the one list carrying both the table names and the grant each gets, from +three named privilege sets: read-only, full DML, and the `semiplot_tags` column grant built from +`plotEditableTagColumns`. `TestTagsUpdateGrantCoversEverySettingsColumn` derives that column list +from `sql/semiplot_tags.sql` minus `id`, so a column added to the table fails the suite instead of +arriving uneditable. + +### Registering new pens + +The archive carries no variable catalogue of its own, so `trends.id` is the only reliable source of +pen keys. `semiplot_register_new_pens()` (`sql/semiplot_register.sql`) inserts one `semiplot_tags` +row for every key in `trends` that has none and returns how many it added. The viewer calls it at +start and from its pen editor's refresh button. A new row is named by its number, takes a colour +from a fixed twelve-colour palette by `id % 12`, which assumes SCADA variable numbers are +non-negative (a negative `id` gets a `NULL` colour, and the viewer picks one), starts with +`enabled_on_start = false`, so a SCADA +with 500 variables does not draw 500 lines at the next start, and autoscales. A variable SCADA +stops writing keeps its row, because its history is still in `trends`. A trigger on `trends` was +rejected: it would run on every sample SCADA writes, on the one table SemiPlot does not own. + +The function is `SECURITY DEFINER`, so `semiplot` adds a pen through it and holds no `INSERT` on +`semiplot_tags`: the role can add a pen only for a variable SCADA has written, never for an id it +picks. Two properties keep that true. + +- **The owner is `semiplot_registrar`, not the superuser.** The body runs with its owner's + privileges while it reads `public.trends`, a table `scada_writer` owns and can rename, replace + with a view, or retype. Under a superuser owner, a view `scada_writer` puts in its place runs + `scada_writer`'s code as the superuser (measured: a probe function in such a view reported + `running as postgres (super=t)`). The superuser creates the function, because only it can, and + `create` then issues `ALTER FUNCTION semiplot_register_new_pens() OWNER TO semiplot_registrar`. + That role logs in nowhere and holds `SELECT` on `public.trends`, `INSERT` on the four columns the + body writes, and `SELECT (id)`, which `ON CONFLICT (id)` needs to infer the primary key (measured: + without it the insert is refused with 42501). `scada_writer` still runs its code as this role + ([Trust](#trust)). +- **The body reads no schema its caller controls.** `pg_temp` is searched first for relations + unless a `search_path` names it, and every role may create a temporary table. Under + `SET search_path = public`, `semiplot` created a temporary `trends` holding ids 7 and 424242, + and the function registered both (measured). So the function sets + `search_path = pg_catalog, pg_temp`, leaving out `public`, where `scada_writer` holds `CREATE`, and + its body names `public.trends` and `public.semiplot_tags` by schema. + +The tail check replays the temporary-table attack by execution +([What the tail checks prove](#what-the-tail-checks-prove), check 3). The owner is the +`ALTER FUNCTION` that `create` issues in the same run, which either succeeds or aborts it. + +PostgreSQL grants `EXECUTE` on a new function to `PUBLIC`, so the grant statement is `REVOKE ALL +ON FUNCTION semiplot_register_new_pens() FROM PUBLIC; GRANT EXECUTE ON FUNCTION +semiplot_register_new_pens() TO semiplot`. + +The key scan is a loose index scan: a recursive CTE that asks each step for the smallest `id` +above the last one, `ORDER BY id LIMIT 1`. `trends` carries the primary key `(id, l, t)` on every +partition, so each step is a `Merge Append` of one index-only probe per partition, and the cost +grows with keys times partitions, not with rows. Measured as `semiplot` with psql `\timing`, +50 keys at `l = 0` every second and `l = 1` every minute: + +| Server | Archive | First call (adds 50) | Second call (adds 0) | +| --- | --- | --- | --- | +| `postgres:17.11-alpine` | 1 day partition plus `tpdefault`, 4,392,000 rows | 13.1 ms | 1.9 ms | +| `postgres:17.11-alpine` | the same plus 89 day partitions at one row a minute, 91 partitions, 10,800,000 rows | 119.0 ms | 64.8 ms | +| `postgres:14.24-alpine` | 1 day partition plus `tpdefault`, 4,320,000 rows | 8.7 ms | 2.4 ms | + +`EXPLAIN ANALYZE` of the key scan alone shows the same plan on both versions: one index-only scan +per partition per step, 0.8 ms on 17 and 1.4 ms on 14 over the one-day archive. + +The measured archives are small, and the cost is linear in keys times partitions. A linear +extrapolation from the 91-partition row, not a measurement: 500 keys over 365 day partitions is 40 +times the probes, about 2.6 s for a call that adds nothing and about 4.8 s for one that adds every +key, which is past one second at every viewer start and refresh. Whether a site reaches that +depends on the retention depth, which is `UNDECIDED` ([README.md](./README.md)); the call stays +inside the `semiplot` role's 30 s `statement_timeout` either way. + +### The schema version is a floor + +This release writes `1`. A viewer states the minimum version it needs and refuses a database +storing a **lower** one; a **higher** one is accepted, because the versions this repository issues +grow by addition and a database carrying more than a viewer needs still carries what it needs. +When `semiplot_markers` arrives it bumps the number to 2, and a viewer that reads only the pen +tables has to keep working against it, which an equality check would break. + +A removal is not signalled by this number. An older viewer meeting a dropped column gets 42703, +which SemiPlot maps to an unexpected-shape failure naming the column. + +`provision` is the only writer of `semiplot_meta`: a viewer that could raise the number would claim +a shape the database does not carry. The stamp is an upsert carrying +`WHERE semiplot_meta.schema_version < EXCLUDED.schema_version`, so an older binary re-run over a +newer database cannot write the number down. + +## The SemiPlot read chain + +`semiplot` needs `SELECT` on `trends` and on every day partition the SCADA creates later — objects +that do not exist when the grant has to be decided. A plain `GRANT` cannot be issued on a table that +is not there, so the create phase sets: ```sql ALTER DEFAULT PRIVILEGES FOR ROLE scada_writer IN SCHEMA public - GRANT SELECT ON TABLES TO semiplot_reader; + GRANT SELECT ON TABLES TO semiplot; ``` This is in place **before** `public.trends` is created and before the writer first starts; tables @@ -171,8 +346,11 @@ statement ran, the repair is a one-time `GRANT SELECT ON ALL TABLES IN SCHEMA pu tables that exist plus the default-privileges statement for the partitions still to come, then another run of the tool — the tail check detects that state and prints all three. -On PostgreSQL 15 and later the `public` schema no longer grants `CREATE` to everyone, so -`create` grants it to `scada_writer` explicitly. On 14 the grant is redundant and harmless. +`create` issues `REVOKE CREATE ON SCHEMA public FROM PUBLIC` and then grants `CREATE` to +`scada_writer` explicitly, so both statements say the same thing on every accepted major. On +PostgreSQL 15 and later the `public` schema no longer grants `CREATE` to everyone and the `REVOKE` +is redundant; on 14 it is the statement that makes "`semiplot` holds no `CREATE` on schema +`public`" true rather than aspirational. ## The archive table @@ -180,13 +358,13 @@ On PostgreSQL 15 and later the `public` schema no longer grants `CREATE` to ever shape, transcribed from a customer archive dump. Three properties of how it is created carry the weight: -- **The role, more than the shape.** The table is created as `scada_writer`, because the reader's +- **The role, more than the shape.** The table is created as `scada_writer`, because the `semiplot` `SELECT` has to arrive through the default privileges set for that role a moment earlier. A - superuser-owned table would give the reader access for a different reason than a site gets it, + superuser-owned table would give `semiplot` access for a different reason than a site gets it, and a bench would then test a different thing from production. The role is assumed with `SET ROLE` on the superuser connection, not through a `scada_writer` login: measured on `postgres:17-alpine`, both routes leave the same `relowner` and the same `relacl` - (`{scada_writer=arwdDxtm/scada_writer,semiplot_reader=r/scada_writer}`), while a login also has + (`{scada_writer=arwdDxtm/scada_writer,semiplot=r/scada_writer}`), while a login also has to be admitted by `pg_hba.conf` — and `local all all peer`, the default on Debian, Ubuntu and RHEL, refuses it on a unix socket. Creating the table therefore needs no writer password; the writer password is needed only on a first run, to create the role itself. @@ -220,22 +398,68 @@ replaced by what was observed and `sql/trends.sql` is reconsidered. Both commands end with them, and a failure is a non-zero exit — the run did not reach the state it promises. They are knowable at exit precisely because this tool creates `public.trends` itself: -1. **`semiplot_reader` reads `public.trends`.** The read itself, not a catalog bit: - `SET ROLE semiplot_reader` on the superuser connection, then +1. **`semiplot` reads `public.trends`.** The read itself, not a catalog bit: `SET ROLE semiplot` on + the superuser connection, then `SELECT count(*) FROM (SELECT 1 FROM public.trends LIMIT 1) probe`. `has_table_privilege` cannot carry this check — after `REVOKE USAGE ON SCHEMA public FROM public` it still answers - `t` while every read the reader issues fails with `permission denied for schema public` + `t` while every read the role issues fails with `permission denied for schema public` (measured). The catalog bit is asked only when the read has already failed, to split a missing - table grant from a schema the reader cannot enter, and each answer prints its own repair. + table grant from a schema the role cannot enter, and each answer prints its own repair. `SET ROLE` rather than a login for the `pg_hba.conf` reason above. -2. **The `semiplot_reader` login reads it too**, over TCP, when the reader password is present in +2. **`semiplot` reads `semiplot_meta.schema_version`**, as the role, and the run prints the value. + This is the first question a viewer asks a database, and the `GRANT SELECT ON semiplot_meta` + that answers it is the only grant no other check exercises: each writable table's `UPDATE` probe + carries a `WHERE`, so its read privilege is proved as a side effect, while `semiplot_meta` is + granted `SELECT` alone. Removing that grant used to leave every check passing. +3. **`semiplot` registers new pens, writes what it owns, and cannot add, delete or re-key a pen + by hand.** The transaction opens with `SELECT semiplot_register_new_pens()` as the role, and the + run prints how many keys in `trends` have no pen yet; a failed call is reported as it is, since a + 42501 may come from inside the body rather than from `EXECUTE`. Then the role plays the temporary-table + attack: it creates a temporary `trends` holding id `-1`, grants the function's owner `SELECT` on + it, calls the function again, and the run fails if `public.semiplot_tags` now holds pen `-1`. + With `SET search_path = public` and the body's `public.` qualifiers removed in + `sql/semiplot_register.sql`, the run exits 1 here; with only the `SET` changed, the qualified + body still reads `public.trends` and the check passes (both measured on 17). The qualifiers are + what keeps the shadow out. + Then `INSERT`, `UPDATE` and `DELETE` on `semiplot_groups` and `semiplot_pen_groups`, and one + `UPDATE` setting all eight settings columns of `semiplot_tags` to themselves (`WHERE id = -1`, + which matches no row), issued as the role, in the + order the foreign keys need. The membership `INSERT` pairs the probe group with whatever pen + exists (`INSERT ... SELECT tag.id, grp.id FROM semiplot_tags tag, semiplot_groups grp ... LIMIT + 1`): the role cannot create a pen for it, and on an empty catalogue the statement inserts no row + and still needs the privilege. Then, in the same transaction, `INSERT INTO semiplot_tags`, + `DELETE FROM semiplot_tags` and `UPDATE semiplot_tags SET id = id WHERE false`, each inside its + own savepoint because a refused statement aborts the transaction around it; each must fail with + 42501. The whole transaction is rolled back: these are the tables an operator fills, and a probe + row must not survive the check that wrote it. A refused positive write names the table and the + operation and prints the `GRANT` that table gets, the column list for `semiplot_tags`. A + negative write that succeeds, or fails with a class 23 integrity error and so got past the + privilege check, names the grant as too wide and prints the `REVOKE` for the role, `PUBLIC` or a + membership. Any other error, a timeout or a lost connection, is reported as it is, because it + says nothing about the grant (`refusalVerdict`). + Both halves are statements rather than catalog bits for the reason check 1 states, and because + `has_table_privilege` cannot see a column grant. +4. **`PUBLIC` holds no `EXECUTE` on `semiplot_register_new_pens()`**, read through + `has_function_privilege('public', 'semiplot_register_new_pens()', 'EXECUTE')`. The role's own call + in check 3 succeeds whether or not `PUBLIC` holds it, so this one is a catalog question. Deleting + the `REVOKE` from the grant statement makes a fresh database exit 1 here (measured). +5. **`semiplot` holds no `INSERT`, `UPDATE` or `DELETE` on `public.trends`**, nor on + `public.messages` where the SCADA has created it. This is the invariant the writing role does not + weaken, and it stays a catalog question: a refused statement and a statement that fails on its + own terms look alike, and an `UPDATE` cannot even be spelled against a table whose columns this + tool does not define. A failure names the privilege found and the `REVOKE` that removes it. +6. **`semiplot` holds none of the three on `semiplot_meta`** — the schema version is written by + `provision` and by nothing else. A catalog question, for the same reason. +7. **`semiplot` holds no `CREATE` on schema `public`**, read through `has_schema_privilege`, so the + role adds nothing beside the tables granted to it by name. On 14 this is a property `create` + establishes with a `REVOKE`; on 15 and later the engine default already carries it and the check + reads the same answer either way. +8. **The `semiplot` login reads `trends` too**, over TCP, when the `semiplot` password is present in the run. This is the half `SET ROLE` cannot reach: `pg_hba.conf` admitting the role and the - password a consumer will carry. It is skipped, with a printed note, when no reader password + password a consumer will carry. It is skipped, with a printed note, when no password was given, and on a socket host — `peer` is the platform default there and no consumer reads over the socket. -3. `semiplot_reader` does not hold `INSERT` on `public.trends` — the reader is read-only. This one - stays a catalog question: a failed `INSERT` proves nothing about the next one. -4. No setting waits for a service restart (`pg_settings.pending_restart`) — a warning, not a +9. No setting waits for a service restart (`pg_settings.pending_restart`) — a warning, not a failure, so a pending `shared_buffers` never blocks the access checks. Both commands read it, last, after the access checks. @@ -251,7 +475,7 @@ Two rules make the bench exercise what production exercises: - `public.trends` is created **as `scada_writer`**, by both commands, so the default-privileges chain is a daily-tested path instead of a commissioning-day surprise. -- Bench integration tests read **as `semiplot_reader`**, never as a superuser, so a broken grant +- Bench integration tests read **as `semiplot`**, never as a superuser, so a broken grant fails a test today. The consumer's seeder fills the archive and creates the day partitions its data needs; it and the @@ -260,7 +484,7 @@ empty `public.trends` in it. ## Passwords -The tool takes passwords through flags or environment variables and never stores them. The reader -credential ends up in plaintext in client configuration files by design — it grants reading -process history and nothing more. The writer credential is recorded wherever the installation -keeps its commissioning records, outside this repository. +The tool takes passwords through flags or environment variables and never stores them. The +`semiplot` credential ends up in plaintext in client configuration files by design — it grants +reading process history and editing the pen catalogue, and nothing more. The writer credential is +recorded wherever the installation keeps its commissioning records, outside this repository. diff --git a/docs/deployment.md b/docs/deployment.md index 41a211d..184c7c3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -31,50 +31,114 @@ SemiBase разворачивается одной утилитой — `semibas | Команда | Действие | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `site` | Машина установки: настройки сервера через `ALTER SYSTEM`, затем архивная база, роли, права доступа, таблицы `semiplot_tags` и `public.trends` | +| `site` | Машина установки: настройки сервера через `ALTER SYSTEM`, затем архивная база, роли, права доступа, четыре таблицы SemiPlot и `public.trends` | | `bench` | Временный контейнер (тестовый стенд, база для разработки): то же самое без настроек сервера — константы памяти рассчитаны на машину установки и контейнеру ничего не дают | | `version` | Печать ревизии сборки | -Обе команды заканчиваются проверкой: `semiplot_reader` читает `public.trends` и не может в неё -писать. Ненулевой код возврата означает, что заявленное состояние не достигнуто. +Обе команды заканчиваются проверкой: `semiplot` читает `public.trends` и версию схемы, вызывает +функцию регистрации новых перьев, пишет свои собственные таблицы и не может ни писать в архив, ни +менять версию схемы, ни создавать объекты в схеме `public`. Ненулевой код возврата означает, что заявленное состояние не достигнуто. При обращении за поддержкой приложите вывод `semibase version` — он однозначно называет сборку утилиты на машине. ## Пароли -Пароли передаются флагами, переменными окружения или файлом `.env` в рабочем каталоге. +Пароли утилита берёт из флагов, переменных окружения или файла `.env` в рабочем каталоге. Приоритет: флаг, затем переменная окружения, затем `.env`. Образец файла — `.env.example` в корне репозитория. -| Роль | Флаг | Переменная окружения | -| ----------------- | ------------------- | -------------------------- | -| `postgres` | `--super-password` | `SEMIBASE_SUPER_PASSWORD` | -| `scada_writer` | `--writer-password` | `SEMIBASE_WRITER_PASSWORD` | -| `semiplot_reader` | `--reader-password` | `SEMIBASE_READER_PASSWORD` | +| Роль | Флаг | Переменная окружения | +| -------------- | ------------------- | -------------------------- | +| `postgres` | `--super-password` | `SEMIBASE_SUPER_PASSWORD` | +| `scada_writer` | `--writer-password` | `SEMIBASE_WRITER_PASSWORD` | +| `semiplot` | `--plot-password` | `SEMIBASE_PLOT_PASSWORD` | ## Роли -| Роль | Кто использует | Доступ | -| ----------------- | -------------------------- | --------------------------------------------------- | -| `scada_writer` | Simple-Scada 2 | Владеет архивными таблицами и пишет в них, создаёт дневные партиции, выполняет ретеншн | -| `semiplot_reader` | SemiPlot и другие читатели | Только чтение `trends` и `semiplot_tags` | +Ролей четыре. Суперпользователя создаёт установщик СУБД, три остальные — утилита. -Объекты `semiplot_*` принадлежат `postgres`; заполнение `semiplot_tags` на пусконаладке -выполняется от суперпользователя. Таблицу `public.trends` утилита создаёт от имени -`scada_writer` — владельцем должна быть та же роль, от которой пишет SCADA, иначе читатель -получит доступ не тем путём, каким он работает в эксплуатации. Роль берётся командой -`SET ROLE` на соединении суперпользователя, отдельный вход под `scada_writer` не выполняется: -владелец и права на таблицу получаются те же, а требований к `pg_hba.conf` не добавляется. +| Роль | Кто использует | Доступ | +| -------------- | -------------------------- | --------------------------------------------------- | +| `postgres` | Утилита развёртывания | Суперпользователь: подключается сама утилита, ей же принадлежат таблицы `semiplot_*` | +| `scada_writer` | Simple-Scada 2 | Владеет архивными таблицами и пишет в них, создаёт дневные партиции, выполняет ретеншн | +| `semiplot` | SemiPlot и другие клиенты | Чтение `trends` и `messages`; чтение `semiplot_tags` и изменение настроек пера в ней, но не его `id`; чтение и запись `semiplot_groups` и `semiplot_pen_groups`; чтение `semiplot_meta`; вызов функции `semiplot_register_new_pens()` | +| `semiplot_registrar` | Никто не входит под ней | Владеет функцией `semiplot_register_new_pens()`: чтение `trends`, добавление строк в `semiplot_tags` | + +Роль `semiplot` ведёт собственный каталог перьев: редактор SemiPlot сохраняет перо по тому же +соединению, по которому читает историю. Права выданы по таблицам, поэтому писать в архив она +не может — эту проверку утилита выполняет на каждом запуске. + +Строка `semiplot_tags` привязана к номеру переменной SCADA. SemiPlot меняет в ней только +настройки пера: имя, единицы, формат, цвет, стиль линии, показ при старте и границы шкалы. +Добавить перо напрямую, удалить его или сменить ему `id` роль не может, и утилита на каждом запуске +проверяет, что эти три команды ей запрещены. Новые перья добавляет только функция +`semiplot_register_new_pens()`, и только для переменных, которые SCADA уже записала в `trends`. + +Таблицы `semiplot_*` принадлежат суперпользователю: их создаёт то соединение, которое выполняет +DDL. Права `CREATE` в схеме `public` у роли `semiplot` нет — утилита забирает его у `PUBLIC` +на каждом запуске, потому что PostgreSQL 14 всё ещё выдаёт его всем по умолчанию, — поэтому +ничего сверх этих таблиц роль не добавит. + +Таблицу `public.trends` утилита создаёт от имени `scada_writer`: владельцем должна быть та же +роль, от которой пишет SCADA, иначе `semiplot` получит доступ не тем путём, каким он работает +в эксплуатации. Утилита берёт роль командой `SET ROLE` на соединении суперпользователя и не +входит отдельно под `scada_writer`: владелец и права на таблицу выходят те же, а `pg_hba.conf` +править не придётся. Пароли ролей нужны только на первом запуске — том, который эти роли создаёт. Когда роли уже -есть, достаточно пароля суперпользователя; пароль читателя тогда лишь включает -дополнительную проверку входа под `semiplot_reader` (только по TCP). +есть, достаточно пароля суперпользователя; пароль `semiplot` тогда лишь включает +дополнительную проверку входа под этой ролью (только по TCP). + +## Таблицы SemiPlot + +Утилита создаёт четыре таблицы и задаёт их схему. SemiPlot меняет настройки перьев в +`semiplot_tags` и ведёт группы в `semiplot_groups` и `semiplot_pen_groups`, `semiplot_meta` пишет +только сама утилита. + +| Таблица | Что хранит | +| --------------------- | ------------------------------------------------------------------------------------------- | +| `semiplot_tags` | Перо: `id` из `trends`, имя, единицы, формат числа, цвет `#RRGGBB`, стиль линии, показ при старте, границы шкалы | +| `semiplot_groups` | Имена групп перьев | +| `semiplot_pen_groups` | Принадлежность пера группам. Перо может входить в несколько групп и ни в одну | +| `semiplot_meta` | Одну строку: версию схемы | + +Перья появляются в `semiplot_tags` сами. SemiPlot при запуске и по кнопке обновления в редакторе +перьев вызывает функцию `semiplot_register_new_pens()`: она добавляет по строке на каждый номер +переменной из `trends`, у которого строки ещё нет, и возвращает число добавленных. Новое перо +называется своим номером, получает цвет из фиксированной палитры, по умолчанию скрыто при старте +и масштабируется автоматически. Перо переменной, которую SCADA перестала писать, остаётся: её +история по-прежнему лежит в `trends`. Номера переменных SCADA считаются неотрицательными: перо с +отрицательным номером остаётся без цвета, и цвет выбирает SemiPlot. + +Функцией владеет отдельная роль `semiplot_registrar`, под которой нельзя войти. Функция +выполняется с её правами, а не с правами суперпользователя: чтение `trends` и добавление строк в +`semiplot_tags`, больше ничего. Вызвать функцию может только `semiplot`: право `EXECUTE`, которое +PostgreSQL выдаёт `PUBLIC` на любую новую функцию, утилита забирает. На архиве из 50 переменных за +один день первый вызов занимает около 13 мс, повторный — около 2 мс. Время растёт пропорционально +числу переменных, умноженному на число дневных партиций. Для 500 переменных за год это оценка, а +не замер: около 2,6 с на вызов, который ничего не добавляет, и около 4,8 с на вызов, который +добавляет все переменные. + +Права ограничивают оператора SemiPlot, но не SCADA. Под ролью `scada_writer` работает сама +Simple-Scada 2 на той же машине, и ей доверяют: она владеет `trends` и и так может переписать или +удалить любую запись архива. Кто читает таблицу, тот выполняет код, который к ней добавил её +владелец: политика защиты строк на `trends` или представление на её месте вызывает свои функции с +правами читающей роли. Поэтому `scada_writer` может действовать от имени `semiplot`, когда та +читает `trends`, и от имени `semiplot_registrar`, когда выполняется функция. Права `semiplot` +держат другую границу: оператор SemiPlot меняет настройки перьев и группы, но не может добавить, +удалить или перенумеровать строку `semiplot_tags`. Отдельная роль-владелец всё равно нужна: если бы +функцией владел суперпользователь, `scada_writer` через представление получила бы права +суперпользователя на весь кластер. Под `semiplot_registrar` ей доступны только чтение `id` и +добавление строк в `semiplot_tags` и сама функция. + +Версия схемы — нижняя граница, а не точное совпадение: SemiPlot отказывается работать +с базой, версия которой ниже нужной ей, и принимает базу с версией выше. Этот выпуск пишет `1`. ## Порядок ввода в эксплуатацию -Порядок важен: права на чтение выставляются до создания `public.trends`, поэтому утилита -отрабатывает до первого запуска SCADA. +Порядок важен: утилита выставляет права на чтение до того, как создаёт `public.trends`, поэтому +она отрабатывает до первого запуска SCADA. 1. Установить СУБД: @@ -90,29 +154,39 @@ winget install --id PostgreSQL.PostgreSQL.17 --exact .\semibase.exe site ``` -На выходе утилита гарантирует: настройки сервера применены, архивная база, обе роли, права -и права по умолчанию созданы, `semiplot_tags` и `public.trends` (с партицией `tpdefault`) -на месте, `semiplot_reader` читает `public.trends` и не может в неё писать. Отдельной -проверки после этого не требуется — она уже выполнена, её результат в коде возврата. +На выходе утилита гарантирует: настройки сервера применены, архивная база, три роли, права +и права по умолчанию созданы, четыре таблицы SemiPlot, функция `semiplot_register_new_pens()` и +`public.trends` (с партицией `tpdefault`) на месте, `semiplot` читает `public.trends`, вызывает +функцию регистрации перьев, пишет свои таблицы, не может добавить, +удалить или перенумеровать перо и не может писать в архив. Отдельной проверки после этого не требуется — она уже выполнена, её результат в коде +возврата. 4. Перезапустить службу PostgreSQL или перезагрузить машину. Все настройки, кроме `shared_buffers`, применяются сразу; `shared_buffers` — только после перезапуска. Утилита предупреждает об этом в последних строках вывода, пока перезапуск не сделан. -5. Запустить проект Simple-Scada против базы. Ожидается, что таблицу `trends` он застанет +5. Запустить проект Simple-Scada против базы. Мы рассчитываем, что таблицу `trends` он застанет готовой, оставит её как есть и дальше создаст дневные партиции и `messages` сам. **Это предположение не проверено на реальном проекте.** На первом запуске включите `log_statement = 'all'` и прочитайте в журнале PostgreSQL, какие DDL-команды SCADA выдала: если она создаёт или изменяет `trends`, порядок ввода в эксплуатацию нужно пересмотреть. -6. Заполнить `semiplot_tags` перечнем переменных для трендов. - -7. Записать файл подключения SemiPlot, включая часовой пояс источника. - -Если проверка в конце сообщает, что `semiplot_reader` не имеет доступа к `trends`, значит -таблица была создана раньше, чем выставлены права по умолчанию. Способ починки утилита -называет в тексте ошибки. +6. Запустить SemiPlot. Перья появляются в `semiplot_tags` сами: по одному на каждую переменную, + которую SCADA уже записала в `trends`, с настройками по умолчанию. Переменные, появившиеся + позже, добавляет следующий запуск SemiPlot или кнопка обновления в редакторе перьев. Дальше их меняют в + редакторе SemiPlot. Вручную перья не добавляют, а удалить перо может только суперпользователь. + +7. Записать файл подключения SemiPlot `connection.yaml`: роль `semiplot`, её пароль и часовой + пояс источника. + +Если проверка в конце сообщает, что `semiplot` не имеет доступа к `trends`, значит таблица была +создана раньше, чем выставлены права по умолчанию. Способ починки утилита называет в тексте +ошибки. Если проверка сообщает, что роль не может писать свою таблицу или, наоборот, держит +лишнее право на архив, в тексте ошибки названы таблица, операция и команда `GRANT` или `REVOKE`, +которая это чинит. Если роль может добавить, удалить или перенумеровать перо, в тексте ошибки +названа команда `REVOKE`, которая забирает лишнее право у `semiplot`, у `PUBLIC` или у роли, в +которую входит `semiplot`. ## Основные флаги diff --git a/docs/plans/completed/20260916-semiplot-role-and-pen-schema.md b/docs/plans/completed/20260916-semiplot-role-and-pen-schema.md new file mode 100644 index 0000000..af81cb2 --- /dev/null +++ b/docs/plans/completed/20260916-semiplot-role-and-pen-schema.md @@ -0,0 +1,977 @@ +# One SemiPlot role that may write its own tables, and the pen schema its editor needs + +## Overview + +SemiPlot is about to let an operator edit a pen: its display name, colour, units, line style, scale +bounds, visibility and group membership. Two things stop it today, and both live here. + +**The SemiPlot role may not write anything.** `provision` creates `scada_writer` and `semiplot_reader` +(the role constants in `internal/provision/provision.go`), and the second held `SELECT` and nothing +else. The viewer needs to write its own configuration tables while remaining unable to touch `trends` +or `messages`. + +**The pen table cannot hold a pen.** `semiplot_tags` carries `id`, `name`, `group_name`, `unit`, +`color` and `line_style` — the before-state, kept as +`internal/provision/testdata/semiplot_tags_v0.3.0.sql`. `group_name` is one string per pen, so a pen +cannot sit in two groups; there is no place for the pen's own scale bounds and no stored visibility. + +This change gives SemiPlot one role that may write its own tables and no others, brings `semiplot_tags` +to the shape one pen row needs, moves groups into their own tables, and records a schema version so a +viewer built against the other shape says so instead of failing on a missing column. + +**One role, not two.** A second write-only role would let the viewer's long-lived read connection stay +free of any write privilege, and against a threat model where the two passwords live apart that is +worth having. They do not live apart: both would sit in the same plaintext `connection.yaml` on the +same machine, so whoever reads one reads the other, and the separation separates nothing. What a second +role would actually buy is that a bug in the viewer cannot corrupt its own pen catalogue through the +connection it reads history on — a recoverable bug, not a breach. The invariant that matters is +untouched by this decision, because PostgreSQL grants per table: SemiPlot cannot write `trends` or +`messages`, and the tail check goes on proving it. + +**The role is renamed.** `semiplot_reader` describes a role that now writes, so it becomes `semiplot`, +and `--reader-password` / `SEMIBASE_READER_PASSWORD` become `--plot-password` / +`SEMIBASE_PLOT_PASSWORD`. Leaving either name in place would recreate the naming defect this change +exists to remove. Renaming costs almost nothing today — there is no installer and no deployed +installation — and it gets more expensive every month, because `ALTER ROLE ... RENAME` leaves a dead +user name in every `connection.yaml` that already exists. + +Both halves ship in one branch and one release. The grants are on the tables this change adds, so a +release carrying only the role change would grant on objects that do not exist; and every consumer +tracks `ghcr.io/semiteq/semibase:latest`, so the image should move under them once rather than twice. + +## Context (from discovery) + +Files and components involved: + +- `internal/provision/` — the role constants and the `Options` struct carrying one password per role + (`provision.go`); `create` (`create.go`) builds the role list, sets the SemiPlot role's timeouts and + applies the files `semiplotSchemaFiles` names; `migrateSemiplotSchema` (`schema.go`) runs the + migration, the version stamp and `semiplotGrantStatements` in one transaction. `check` (`check.go`) + runs the access chain. `runAs` (`provision.go`) is the `SET ROLE` helper the assertions use, and + `ensureRole` (`create.go`) creates a role or re-sets its password, refusing to create one it has no + password for. Inside this repository the plan cites symbols and section anchors rather than line + numbers: four review rounds found the numbers stale, and a symbol survives the next commit. The + `SemiPlot/` citations keep their lines, because no commit here can move them. The names here are the + pre-change ones where task 1 renamed them. +- `sql/semiplot_tags.sql` — the pen catalogue, `CREATE TABLE IF NOT EXISTS` with eight columns and + two named `CHECK` constraints. It arrived with six columns and no constraint. +- `embed.go` — five `go:embed` directives: `SemiplotTagsSQL`, `SemiplotGroupsSQL`, `SemiplotMetaSQL`, + `SemiplotMigrateSQL` and `TrendsSQL`. It arrived with the first and the last. +- `cmd/semibase/main.go` — `newFlagSet` and `resolvePasswordsFromEnvironment`, one entry per password; + `.env.example` lists the three variables. +- `.github/workflows/ci.yml`, the `Provision the service container` step — the Linux job runs `site` + twice in a row against a live `postgres:17-alpine` service. **This is the idempotency gate and it + already exists**: any statement that cannot run twice fails the pull request. Its limit, found in + review and closed by the `Provision over the shape the previous release left` step added afterwards: + the container is **fresh**, so the migration's `DO` block enters no branch on either run — no + `group_name` column has ever existed there and no `semiplot_reader` role does either. The migration's + top-level statements do run there; what the double run proves nothing about is that block's body. +- `docs/architecture/provisioning.md` — the agent-facing design, with `## Invariants the provision + package owns`, `## The SemiPlot read chain`, `## What the tail checks prove` and `## Passwords`. +- `docs/deployment.md` — the human-facing Russian document, with `## Пароли` and `## Роли`. + +Related patterns found: + +- Every statement `create` issues is written to be safe on a re-run: `CREATE TABLE IF NOT EXISTS`, + `ensureRole` checking before creating, `GRANT` being idempotent by nature. +- The tail check proves a privilege by exercising it, not by reading a catalogue bit. The comment above + `assertPlotReads` states why: `has_table_privilege` answers from one bit and says yes for a role that + `REVOKE USAGE ON SCHEMA public` has locked out. +- `SET ROLE` rather than a login, because `pg_hba.conf` need not admit these roles; the login is checked + separately where it can be (`checkPlotLogin`). + +Dependencies identified: + +- None inside this repository. The consumers are `Semiteq/SemiPlot#65` and `#67`, which move after the + release this change produces. + +## Development Approach + +- **testing approach**: Regular (code first, then tests), matching the repository. +- complete each task fully before moving to the next +- `go build ./...`, `go test ./...`, `go vet ./...` and `golangci-lint run` all clean before the next task +- `gofmt -w .` before presenting any change +- **update this plan when scope changes during implementation** + +## Testing Strategy + +- **unit tests** beside the source, table-driven (`internal/provision/provision_test.go`, + `cmd/semibase/*_test.go`). These cover statement construction and the option surface: the SQL text a + helper builds, the rename decision, the flag and environment resolution for the renamed password. +- **there are no database-touching tests in this repository**, and this change does not add the first + one. The integration check is the tail check both commands run against a live server, and this change + extends it: the role must prove it can write `semiplot_tags` and prove it cannot write `trends`. +- **the idempotency gate is CI's double `site` run**, the `Provision the service container` step, + against a live `postgres:17-alpine`. Every statement this change adds must survive it, which is why + none of them may be a bare `ALTER TABLE ... ADD CONSTRAINT` and why the rename is guarded. +- **the upgrade gate is a second CI step**, added after review: the double run above is against a + fresh database, where the migration's `DO` block enters no branch, so it cannot fail on a wrong + column or table name inside one — PL/pgSQL parses a branch when that branch first runs. The + `Provision over the shape the previous release left` step stamps + `internal/provision/testdata/semiplot_tags_v0.3.0.sql` and rows that have to move, renames the role + back with an MD5-hashed password, requires the no-password run to refuse, runs `site` twice with the + password and reads the result back, deletes a pen and a group inside a rolled-back transaction — + the only thing that executes `ON DELETE CASCADE` — and ends with the schema-version refusal of item + 2c, stamping `schema_version = 2` and granting `CREATE` back to `PUBLIC`, requiring a non-zero exit + and `PUBLIC` still holding `CREATE`, then restoring both. Four unit tests carry what SQL text alone + can carry: the dropped and the added constraint name in one statement must match + (`TestSemiplotMigrateReplacesEachConstraintByItsOwnName`); the create file and the migration must + state the same **normalised `CHECK` body**, not merely the same constraint name, because the bodies + are written twice and a rule changed in one file keeps its name + (`TestEveryCreatedConstraintIsMigrated`); every `CHECK` in the create file must carry a name, or the + body-parity test cannot see it (`TestEveryCheckInSemiplotTagsIsNamed`); and the same parity must hold + for columns (`TestEveryCreatedColumnReachesAnUpgradedDatabase`). The four are listed with their + reasoning at `docs/architecture/provisioning.md#what-proves-the-migration`. +- **the version floor is a third CI step**, `Provision a PostgreSQL 14 container, where the REVOKE + bites`. `REVOKE CREATE ON SCHEMA public FROM PUBLIC` does nothing on 15 and later, so on the + `postgres:17-alpine` service container above the statement can be deleted with every step still + green. On `postgres:14-alpine` its absence makes the tail check exit 1. + +## Acceptance Evidence + +Each item is a command with the result it must produce, against the measured before-state. + +1. **A fresh database gets the whole shape, twice.** + + ```bash + docker run -d --name sb -e POSTGRES_PASSWORD=pw -p 15432:5432 postgres:17-alpine + go build -o semibase ./cmd/semibase + SEMIBASE_SUPER_PASSWORD=pw SEMIBASE_WRITER_PASSWORD=w SEMIBASE_PLOT_PASSWORD=p \ + ./semibase site --port 15432 --database semiplot_dev --expected-major 17 + # and again, unchanged + ``` + + Both runs exit 0. This is the fresh path: the migration's top-level statements run on it too, and + only its `DO` block finds no `group_name` and does nothing, so neither run proves anything about + that block's body. + This is what CI does in the `Provision the service container` step; running it by hand is the + local form of the same gate. + Today (2026-09-16): the second run exits 0 with the current statements, and that is the property this + change must not lose. + +2. **An existing v0.3.0 database is renamed once and stays renamed.** + + Against a database provisioned by v0.3.0, one `site` run **carrying `SEMIBASE_PLOT_PASSWORD`** leaves + `semiplot` present, `semiplot_reader` absent, and the role's password equal to what the run supplied — + proved by the tail check's own login step (`checkPlotLogin`), which connects over TCP with that + password. A second run finds `semiplot` already there and renames + nothing. Without that password the run is the refusal of item 2a instead, and changes nothing. + +2a. **A rename that would wipe an MD5-hashed password is refused.** + + On a cluster storing `semiplot_reader`'s password as MD5 — which a cluster upgraded from PostgreSQL 13 + or earlier still does by default — a `site` run carrying no `semiplot` password exits 1 naming + `--plot-password` and `SEMIBASE_PLOT_PASSWORD`, and leaves `semiplot_reader` un-renamed. This is the + branch's most operator-visible new failure mode. The CI upgrade step gates it on both halves: the + non-zero exit, and the captured stderr naming `SEMIBASE_PLOT_PASSWORD`, because the exit code alone + does not distinguish this refusal from a regression in the tuning phase. + +2b. **`semiplot` holds no `CREATE` on schema `public`, on the version where that is not free.** + + PostgreSQL 15 dropped the default that grants `CREATE` on schema `public` to `PUBLIC`; 14, this + repository's floor, still carries it. So the property is gated on `postgres:14-alpine`, in the + `Provision a PostgreSQL 14 container, where the REVOKE bites` CI step: `bench` against a fresh 14 + container exits 0 with the `REVOKE`, and exits 1 at `assertPlotCannotCreateInPublic` without it. + Measured both ways on `postgres:14.24-alpine`. On `postgres:17-alpine` the same binary exits 0 either + way, which is why the 17 service container cannot carry this item. + +2c. **A database stamped past this build's version is refused, before the run touches it.** + + `provision`'s migration states each constraint rather than testing for it, so an older binary over a + newer database would replace that release's `CHECK` with its own while `semiplot_meta` went on + reporting the higher number. `assertSchemaVersionIsNotNewer` is therefore the first statement + `create` issues on the archive connection, ahead of `REVOKE CREATE ON SCHEMA public FROM PUBLIC`. + Gated in the CI upgrade step on both halves: with `schema_version` stamped at `2` and `CREATE` + granted back to `PUBLIC`, the run exits non-zero with a stderr naming both numbers, and + `has_schema_privilege('public', 'public', 'CREATE')` still reads `t` afterwards. Measured on + `postgres:17.11-alpine`: `t` before the refused run, `t` after it; before the guard moved, the same + run left it `f`. + +2d. **The whole upgrade path converges in one run, on the version floor. Hand-verified, not gated.** + + Items 2, 2a, 2b and 2c each gate one half on the server that can carry it: the rename, the MD5 + refusal and the migration on `postgres:17-alpine`, the `REVOKE` on `postgres:14-alpine` over a + fresh database. Nothing runs all four together. Measured by hand on `postgres:14.24-alpine`, + 2026-09-16, against a database provisioned by the `v0.3.0` tag build: the refusal without + `SEMIBASE_PLOT_PASSWORD`, then one `site` carrying it exits 0, `semiplot_groups` holds `Etch` and + `Vacuum`, `semiplot_pen_groups` three rows, `semiplot_meta` one row at `1`, + `has_schema_privilege('public', 'public', 'CREATE')` moves `t` to `f`, and a second run exits 0 + changing nothing. + + No CI step covers the combination, and this plan does not add one. A fourth container step would + duplicate the `Provision over the shape the previous release left` block against a second server to + gate an interaction the code does not have: the `REVOKE` is one unconditional statement issued + before the migration transaction opens, and no branch of the rename, the refusal or the migration + reads the server version. The two halves are gated where each one bites; the cost of gating their + product is a container and forty duplicated lines for no new failure mode. + +3. **An existing v0.3.0 database migrates, and migrates only once.** + + ```sql + -- against a database provisioned by semibase v0.3.0, before the run + INSERT INTO semiplot_tags (id, name, group_name, unit, color, line_style) + VALUES (1, 'Chamber pressure', 'Vacuum', 'Pa', '#3C7DD9', 0), + (2, 'RF forward', 'Vacuum', 'W', '#D97B3C', 0), + (3, 'Ungrouped signal', NULL, NULL, NULL, 1); + ``` + + After one `site`: `semiplot_groups` holds exactly one row, `Vacuum`; `semiplot_pen_groups` holds two, + for pens 1 and 2; pen 3 has no group row; `group_name` is gone from `semiplot_tags`. After a second + `site`: the same, and exit 0. The second run is the one that fails on a migration written without a + guard, because `SELECT DISTINCT group_name` then reads a column that no longer exists. + +4. **A colour the format does not accept is reported and nulled, not fatal.** + + With `INSERT INTO semiplot_tags (id, name, color) VALUES (4, 'Legacy', 'red')` present before the run, + `site` exits 0, prints how many colours it cleared and their ids, and leaves `semiplot_tags.color` at + `NULL` for pen 4. A `CHECK` added without this step aborts the whole migration with a bare SQL error + and no remedy, on a database an operator filled by hand during commissioning. + +5. **The role may write its own tables.** + + The tail check exercises it: as `semiplot`, `INSERT` then `DELETE` a row in `semiplot_tags` inside a + transaction that rolls back, and the same for `semiplot_groups` and `semiplot_pen_groups`. A failure + is a non-zero exit naming the table and the operation. The check exercises the privilege rather than + reading `has_table_privilege`, for the reason the comment above `assertPlotReads` states. + +6. **The role may not touch the archive.** + + The tail check keeps asserting `semiplot` holds no `INSERT` on `public.trends`, and extends it to + `UPDATE` and `DELETE`, and to `messages` when that relation exists. A non-zero exit names the + privilege found. This is the invariant the one-role decision does not weaken, and it is the one that + matters. + +7. **The role may not write the schema version.** + + `INSERT INTO semiplot_meta` as `semiplot` is refused with `42501`. `semiplot_meta` is written by + `provision` and by nothing else. + +8. **The schema version is readable and correct.** + + `SELECT schema_version FROM semiplot_meta` returns exactly one row holding `1`, readable as + `semiplot`. A second `site` run leaves one row, not two, which the `singleton` primary key and the + single upsert statement together guarantee. + +9. **The option surface carries the renamed password, and the retired one does not fall through.** + + `go test ./cmd/semibase/...` covers flag, environment and `.env` resolution for `--plot-password` + with the precedence the others follow, and `./semibase site --help` lists the flag naming its + environment variable and never its value, which is the rule `newFlagSet` states in its own comment. + A run supplying only the retired + `SEMIBASE_READER_PASSWORD` fails with the message `ensureRole` already produces, naming the role it + cannot create, rather than silently proceeding with an empty password. + +10. **Nothing else moved.** + + `go build ./...`, `go test ./...`, `go vet ./...` and `golangci-lint run` are clean; + `gofmt -l .` prints nothing. + +## Progress Tracking + +- mark completed items with `[x]` immediately when done +- add newly discovered tasks with a plus prefix +- document blockers with a warning prefix +- update this plan if the implementation deviates from the scope above + +## Solution Overview + +**The role is renamed before it is ensured, and the rename is guarded.** `ALTER ROLE semiplot_reader +RENAME TO semiplot` runs only when `semiplot_reader` exists and `semiplot` does not. When neither +exists, `ensureRole` creates `semiplot` as it creates any role. When both exist, the run stops and says +so: two roles claiming one purpose is a state this tool cannot resolve for the operator. The rename runs +ahead of `ensureRole`, so the password the run supplies is applied to the renamed role in the same pass. + +That ordering is not enough for the one PostgreSQL wrinkle here: `ALTER ROLE ... RENAME TO` clears an +**MD5-hashed** password, because an MD5 verifier is salted with the role name, and a cluster upgraded +from PostgreSQL 13 or earlier still defaults to `password_encryption = md5`. The version floor does not +close it — the floor is the *server* version, not the setting. So when `pg_authid.rolpassword` for +`semiplot_reader` begins `md5` and the run carries no `semiplot` password to put back, the rename is +**refused** rather than performed, naming `--plot-password` and `SEMIBASE_PLOT_PASSWORD`. The +alternative is a run that wipes the password, reports the credentials untouched, exits 0 and leaves a +role no consumer can log in as. + +**Two SQL files describe the shape, one describes the move.** `semiplot_tags.sql` states the tables as +they should be on a fresh database, all columns present. `semiplot_migrate.sql` brings an existing +database to that shape and is guarded at every statement. Splitting them keeps each file answering one +question, and both run on every `site`, as the embedded SQL already does. + +**Every added statement survives a second run.** `ADD COLUMN IF NOT EXISTS` and `CREATE TABLE IF NOT +EXISTS` carry their own guard. `ADD CONSTRAINT` does not, so each constraint is named and dropped by +that name in the same `ALTER TABLE` that adds it — PostgreSQL applies the pair as one action, and the +file then states the constraint rather than testing for it. The group move and the `DROP COLUMN` are +wrapped in a single `DO` block testing `information_schema.columns` for `group_name`, so the second +run finds the column gone and does nothing. CI runs `site` twice in one job, so a statement that +cannot repeat fails the pull request rather than an installation — but that run is against a fresh +database, so it enters no branch of the `DO` block and proves nothing about the block's body. The +upgrade step described in Testing Strategy is what executes it. + +**An unparseable colour is cleared, not tolerated and not fatal.** The `color` column is free text today +and an operator may have typed `red` during commissioning. Adding `CHECK (color ~ '^#[0-9A-Fa-f]{6}$')` +validates existing rows, so that database stops provisioning with a bare SQL error naming no remedy. The +migration therefore sets any non-matching colour to `NULL` before adding the constraint, and reports how +many rows and which ids. `NULL` is a state the column, the constraint and the viewer already handle — it +means the viewer picks the colour — while `red` is a state nothing handles. The alternative, adding the +constraint `NOT VALID`, leaves the unparseable value in a column every consumer believes is validated, +which is the worse of the two. + +**The schema version is 1, and it is a floor.** `semiplot_meta` holds one row with one `schema_version` +integer, and this release writes `1`. A viewer states the minimum it needs and refuses a database whose +stored version is **lower**; a higher one is accepted, because the versions this repository issues grow +by addition and a database carrying more than a viewer needs still carries what it needs. +`Semiteq/SemiBase#10` adds `semiplot_markers` and bumps the version to 2; a viewer that needs only the +pen tables must keep working against it, which an equality check would break. A removal is a different +matter and is not signalled by this number: an older viewer meeting a dropped column gets 42703, which +SemiPlot maps to an unexpected-shape failure naming the column +(`SemiPlot.DataSource.Postgres/ArchiveExceptionMapper.cs:61`), and that is what the `group_name` drop in +this change produces. The row is written with one `INSERT ... ON CONFLICT (singleton) DO UPDATE`, so a +failure cannot leave the table empty and a second run cannot leave it with two rows. + +**The tail check gains a write chain beside the read one.** The existing pair proves the role reads +`trends` and holds no `INSERT` on it. The new pair proves the same role writes every `semiplot_*` +configuration table and holds no write on `trends` or `messages`. The write assertion exercises the +privilege inside a transaction that rolls back, rather than reading `has_table_privilege`, because a +catalogue bit says yes for a role locked out of the schema — the reason already written above +`assertPlotReads`. The two cannot-write assertions stay catalogue questions: a refused statement +and a statement that fails on its own terms look alike, and an `UPDATE` cannot be spelled against +`messages`, whose columns this tool does not define. + +## Technical Details + +**The tables, as a fresh database gets them:** + +```sql +CREATE TABLE IF NOT EXISTS semiplot_tags ( + id integer PRIMARY KEY, -- matches trends.id + name text NOT NULL, + unit text, + color text, + line_style smallint NOT NULL DEFAULT 0, + enabled_on_start boolean NOT NULL DEFAULT true, + scale_min double precision, + scale_max double precision, + CONSTRAINT semiplot_tags_scale_paired CHECK ( + (scale_min IS NULL) = (scale_max IS NULL) + AND (scale_min IS NULL OR scale_min < scale_max)), + CONSTRAINT semiplot_tags_color_hex CHECK (color IS NULL OR color ~ '^#[0-9A-Fa-f]{6}$') +); + +CREATE TABLE IF NOT EXISTS semiplot_groups ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS semiplot_pen_groups ( + pen_id integer NOT NULL REFERENCES semiplot_tags (id) ON DELETE CASCADE, + group_id integer NOT NULL REFERENCES semiplot_groups (id) ON DELETE CASCADE, + PRIMARY KEY (pen_id, group_id) +); + +CREATE TABLE IF NOT EXISTS semiplot_meta ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + schema_version integer NOT NULL +); +``` + +`GENERATED ALWAYS AS IDENTITY` rather than `serial`, so the role needs no `USAGE` on a sequence to +insert a group. + +Every constraint carries its own name, because the migration drops and re-adds each one by that name and +an inline two-column `CHECK` would auto-name to `semiplot_tags_check`, which the migration could not +name. + +The scale rule pairs the two bounds rather than only ordering them. `scale_min < scale_max` alone admits +one bound set and the other `NULL`, because a `CHECK` passes on `NULL`, and the consumer has no +representation for that row: `SemiPlot.Core/Trends/PenScaleSettings.cs:3-10` is a `ScaleMode` plus two +non-nullable doubles. A half-set pair would make each viewer build invent its own rule. + +**Column meanings.** `unit` is free text (`Pa`, `C`, `mbar`, `sccm`, `W`, `%`). `color` is `#RRGGBB`. +`line_style` is 0 interpolated, 1 stepped. `enabled_on_start` is whether the viewer draws the pen when +it starts, and nothing more: which pens a running window shows is the state of that window, not of the +archive, so two stations may show different sets and neither writes the other's. +`scale_min` and `scale_max` bound the pen's own Y axis, and both `NULL` means autoscale. A pen with no +row in `semiplot_pen_groups` is legal and the viewer shows it ungrouped. + +**Grants:** + +| Table | `semiplot` | `scada_writer` | +| --- | --- | --- | +| `semiplot_tags` | `SELECT, INSERT, UPDATE, DELETE` | none | +| `semiplot_groups` | `SELECT, INSERT, UPDATE, DELETE` | none | +| `semiplot_pen_groups` | `SELECT, INSERT, UPDATE, DELETE` | none | +| `semiplot_meta` | `SELECT` | none | +| `trends`, `messages` | `SELECT` | owner | + +`semiplot` gets no `CREATE` on schema `public`. Grants are explicit per table, because these tables are +created by `provision` rather than at runtime by their owner, so default privileges buy nothing and say +less. + +**Order inside `create`:** the rename runs before the roles are ensured; the roles exist before the +database; the tables exist before their grants; the migration runs after the tables are created, so a +fresh database runs it and finds nothing to do. The rest of the ordering — where the schema-version +refusal sits, what runs inside the migration transaction, and why each is there — is +`docs/architecture/provisioning.md`, which this plan points at rather than copies. + +## What Goes Where + +- **Implementation Steps** (`[ ]`): the rename, the DDL, the migration, the grants, the tail check, the + tests and the documentation — all inside this repository. +- **Post-Completion** (no checkboxes): the release, and the consumer work that waits on it. + +## Implementation Steps + +### Task 1: Rename the role and its password across the option surface + +**Files:** +- Modify: `internal/provision/provision.go` +- Modify: `cmd/semibase/main.go` +- Modify: `.env.example` +- Modify: `cmd/semibase/main_test.go` +- Modify: `internal/provision/provision_test.go` +- Modify: `cmd/semibase/env_test.go` +- Modify: `.github/workflows/ci.yml` (added during implementation: the live-postgres steps pass the + renamed variable and connect as the renamed role) + +- [x] rename the `ReaderRole` constant and its value to `semiplot` (`provision.go`), and every + identifier carrying the old word: the option field, `readerProbe`, `assertReaderReads`, + `assertReaderCannotWrite`, `checkReaderLogin`, `diagnoseFailedRead` +- [x] rename the flag and the environment variable to `--plot-password` and `SEMIBASE_PLOT_PASSWORD` + (`newFlagSet` and `resolvePasswordsFromEnvironment`, `.env.example`), keeping the rule that usage + shows the variable and never a value +- [x] add the guarded `ALTER ROLE ... RENAME TO` ahead of the role loop: rename only when the old name + exists and the new one does not, and stop with a named error when both exist +- [x] write tests for the rename decision: old only, new only, neither, both +- [x] write tests for flag, environment and `.env` precedence for the renamed password +- [x] run `go test ./...` - must pass before task 2 + +### Task 2: State the full table shape and the new tables + +**Files:** +- Modify: `sql/semiplot_tags.sql` +- Create: `sql/semiplot_groups.sql` +- Create: `sql/semiplot_meta.sql` +- Modify: `embed.go` +- Modify: `internal/provision/provision.go` +- Modify: `internal/provision/provision_test.go` (added during implementation: the embedded-SQL tests + live beside the existing `TestEmbeddedTrendsSQL`) + +- [x] give `semiplot_tags` its three new columns and its two named constraints, and drop `group_name` + from it, so a fresh database never has the column +- [x] add `semiplot_groups` and `semiplot_pen_groups` in one file, named for the pair +- [x] add `semiplot_meta` with its `singleton` primary key and its `schema_version` column, holding `1` +- [x] embed both new files beside the existing two +- [x] apply them in `create` next to the existing `semiplot_tags.sql` application + + only `semiplot_groups.sql` ended up there. `semiplot_meta.sql` was moved into the migration + transaction in review, and `TestSemiplotSchemaFileOrder` now fails if it returns to + `semiplotSchemaFiles`. The reasoning is + `docs/architecture/provisioning.md#migrating-a-database-an-earlier-release-provisioned`. +- [x] write tests asserting every embedded file is non-empty and names the objects it should + + the goldens that satisfied this were replaced in review by assertions over the objects + rather than over the file text: `TestSemiplotTablesMatchTheEmbeddedFiles` and + `TestSemiplotGroupsUseAnIdentityColumn`. +- [x] run `go test ./...` - must pass before task 3 + +### Task 3: Migrate an existing database, guarded at every statement + +**Files:** +- Create: `sql/semiplot_migrate.sql` +- Modify: `embed.go` +- Modify: `internal/provision/provision.go` +- Modify: `internal/provision/provision_test.go` + +Proved against a live `postgres:17-alpine`: a database provisioned by v0.3.0 and filled with four legacy +pens took one `site` run, which printed `semiplot_tags color cleared to NULL where it was not #RRGGBB: +ids 4 (1 total)`, moved `Vacuum` and `RF` into `semiplot_groups`, wrote three `semiplot_pen_groups` rows, +left pen 3 ungrouped and dropped `group_name`. A second run changed nothing and exited 0. + +- [x] `ADD COLUMN IF NOT EXISTS` for `enabled_on_start`, `scale_min` and `scale_max` +- [x] make each constraint survive a second run, because `ADD CONSTRAINT` has no `IF NOT EXISTS` and + returns 42710 on a second run + + the shipped form is not the `DO` block testing `pg_constraint` this step first described: a + name guard finds the name present and skips, so a *changed* `CHECK` would never reach a + database carrying the old one. `DROP CONSTRAINT IF EXISTS x, ADD CONSTRAINT x` in one + `ALTER TABLE`, which PostgreSQL applies as one action, repeats and replaces alike +- [x] clear every `color` the pattern rejects to `NULL` before the constraint is added, and report the + count and the ids through the existing `ok` output + + the clearing is `provision`'s own statement rather than a line of the migration: the ids reach + `ok` only through Go. One pattern serves all three places, and a test pins that. +- [x] wrap the group move and the `DROP COLUMN group_name` in one `DO` block testing + `information_schema.columns`, so the second run finds nothing to do +- [x] write the schema version with one `INSERT ... ON CONFLICT (singleton) DO UPDATE`, so no failure + leaves the table empty and no re-run leaves it with two rows + + the upsert sits in `sql/semiplot_meta.sql` and is the only writer of `semiplot_meta` across + every applied file, which `TestSchemaVersionIsWrittenByOneRaisingStatement` pins. Where that + file is applied from, and why it is not applied beside the other create files, is + `docs/architecture/provisioning.md#migrating-a-database-an-earlier-release-provisioned` — + this plan does not restate it, because three review rounds corrected the restatement. +- [x] write tests for the statement text and for the version this release defines +- [x] run `go test ./...` - must pass before task 4 + +### Task 4: Grant the new tables to the role + +**Files:** +- Modify: `internal/provision/provision.go` +- Modify: `internal/provision/provision_test.go` + +Proved against a live `postgres:17-alpine`: as `semiplot`, `INSERT`/`UPDATE`/`DELETE` succeeded on +`semiplot_tags`, `semiplot_groups` and `semiplot_pen_groups`; `SELECT semiplot_meta` returned +`schema_version` 1 while `INSERT` and `UPDATE` on it were denied; `SELECT public.trends` worked and +`INSERT`, `UPDATE`, `DELETE` on it were denied; `CREATE TABLE public....` was denied for schema +`public`; `scada_writer` was denied both `SELECT` and `INSERT` on `semiplot_tags`. The catalogue +agrees: `semiplot=arwd` on the three tables, `semiplot=r` on `semiplot_meta` and on `trends`, and +`nspacl` on `public` carries `scada_writer=C` and no `semiplot`. + +- [x] grant `SELECT, INSERT, UPDATE, DELETE` on the three configuration tables to `semiplot` +- [x] grant `SELECT` only on `semiplot_meta` +- [x] grant nothing new on `trends` or `messages`, and no `CREATE` on schema `public` +- [x] write tests for the grant statements, one case per table + + the privileges moved into `semiplotTables`, which now carries one grant string per table: + one list states which tables exist and what the role holds on each, so the report and the + grants cannot drift apart. +- [x] run `go test ./...` - must pass before task 5 + +### Task 5: Extend the tail check with the write chain + +**Files:** +- Modify: `internal/provision/provision.go` +- Modify: `internal/provision/provision_test.go` + +Proved against a live `postgres:17-alpine`: a clean `site` run printed `semiplot inserts, updates and +deletes its own tables: semiplot_tags, semiplot_groups, semiplot_pen_groups`, `semiplot holds no INSERT, +UPDATE or DELETE on public.trends, public.messages` and the same line for `semiplot_meta`, and the three +probe tables were empty afterwards. Six mutations were then made one at a time and every one failed with +a non-zero exit naming the table and the operation: `REVOKE INSERT ON semiplot_tags`, `REVOKE UPDATE ON +semiplot_groups` and `REVOKE DELETE ON semiplot_pen_groups` against the check path, and `GRANT INSERT ON +public.trends`, `GRANT UPDATE ON public.messages` and `GRANT DELETE ON semiplot_meta` against a whole +`site` run. + +- [x] assert the role writes each configuration table, as `SET ROLE` inside a transaction that rolls + back, through the existing `runAs` helper +- [x] extend the archive assertion from `INSERT` to `INSERT`, `UPDATE` and `DELETE`, and to `messages` + when that relation exists +- [x] assert the role cannot write `semiplot_meta` + + the two negative halves stay catalogue questions, which is the reasoning already written above + `assertPlotReads`: a refused statement and a statement that fails on its own terms look alike, + and an `UPDATE` cannot be spelled against `messages`, whose columns this tool does not define. + A `42501` on `INSERT INTO semiplot_meta` was confirmed by hand instead (acceptance item 7). +- [x] name the table and the operation in every failure, so a missing or excess grant is readable + + the two messages are built by `writeProbeFailure` and `excessPrivilegeFailure`, so the text is + pinned by a test rather than by a live run +- [x] write tests for the failure messages +- [x] run `go test ./...` - must pass before task 6 + +### Task 6: Verify acceptance criteria + +Run against `postgres:17.11-alpine` in three throwaway containers, all removed afterwards. Port 15533 +and 15633 rather than the 15432 the acceptance items name: a native PostgreSQL already listens on +15432 on this machine and Windows lets a second socket bind the same port, so the first run reached +the wrong server. + +- [x] run acceptance items 1 to 3 against a live `postgres:17-alpine`, including the second `site` run + and a database first provisioned by v0.3.0 + + item 1: a fresh `semiplot_dev` provisioned twice, both runs exit 0, the second printing + `role semiplot exists, password updated` and `public.trends exists, left untouched` + + items 2 and 3: v0.3.0 (tag build, from a worktree) provisioned `scada_archive`, the three pens + of the acceptance INSERT were added by hand, then one HEAD `site` printed `role semiplot_reader + renamed to semiplot` and `semiplot logs in and reads public.trends` with the supplied password. + After it: `semiplot_groups` one row `Vacuum`, `semiplot_pen_groups` two rows for pens 1 and 2, + pen 3 none, no `group_name` column, `semiplot_reader` absent. A second run renamed nothing and + left all three counts unchanged, exit 0 + + the both-roles-exist stop was seen too: a cluster carrying `semiplot` and `semiplot_reader` + fails with `both semiplot_reader and semiplot exist: ... Drop the one no connection.yaml names` +- [x] run acceptance item 4 with a hand-filled `red` present before the migration + + `INSERT INTO semiplot_tags (id, name, color) VALUES (4, 'Legacy', 'red')` before the run; the + run exited 0 printing `semiplot_tags color cleared to NULL where it was not #RRGGBB: ids 4 + (1 total). The viewer picks a color for those pens until one is set.` and pen 4's color is NULL +- [x] run acceptance items 5 to 8 and record what the tail check printed + + `semiplot reads public.trends`; `semiplot inserts, updates and deletes its own tables: + semiplot_tags, semiplot_groups, semiplot_pen_groups`; `semiplot holds no INSERT, UPDATE or + DELETE on public.trends, public.messages` (both named once a `messages` owned by `scada_writer` + exists, `public.trends` alone before that); `semiplot holds no INSERT, UPDATE or DELETE on + semiplot_meta`; `semiplot logs in and reads public.trends` + + item 6 bites: with `GRANT INSERT ON public.trends TO semiplot` the run exits 1 with + `semiplot holds INSERT on public.trends and must not write it. Repair with REVOKE INSERT ON + public.trends FROM semiplot, then re-run this command` + + item 7: `INSERT INTO semiplot_meta` as `semiplot` answers `ERROR: 42501: permission denied for + table semiplot_meta` + + item 8: `SELECT schema_version FROM semiplot_meta` as `semiplot` returns one row holding `1`, + and still one row after the second `site` + + item 9: `site --help` lists `-plot-password string / semiplot password (env + SEMIBASE_PLOT_PASSWORD)` and no value; `TestPlotPasswordPrecedenceAcrossDotEnv` and the two + `TestParseOptions` plot cases pass; a run carrying only `SEMIBASE_READER_PASSWORD` stops with + `role semiplot does not exist and no password was given to create it` +- [x] `go build ./...`, `go test ./...`, `go vet ./...`, `golangci-lint run`, `gofmt -l .` + + all clean, `golangci-lint` printing `0 issues.` and `gofmt -l .` nothing +- [x] prove two guards by mutation: remove the `information_schema.columns` test from the group move and + confirm the second `site` run fails; remove the rename guard and confirm the second run fails. + Restore both. + + group move without its guard: first run 0, second `error: applying semiplot_migrate.sql: ERROR: + column "group_name" does not exist (SQLSTATE 42703)`, exit 1 + + rename without its guard: first run 0, second `error: ALTER ROLE semiplot_reader RENAME TO + semiplot: ERROR: role "semiplot_reader" does not exist (SQLSTATE 42704)`, exit 1 + + both restored by `git checkout --`; the restored tree is clean and green +- [x] correct two claims this pass found untrue of the branch: the Solution Overview said both new + assertions exercise the privilege in a rolled-back transaction, and acceptance item 2 named + `checkReaderLogin` after task 1 renamed it + +### Task 7: Update documentation + +**Files:** +- Modify: `docs/architecture/provisioning.md` +- Modify: `docs/architecture/overview.md` (added during implementation: its consumer table named the + retired role and its component table named one SQL file) +- Modify: `docs/architecture/README.md` (added during implementation: the Roles and Objects-we-add + locked decisions named the retired role and one table) +- Modify: `docs/deployment.md` +- Modify: `README.md` +- Modify: `CLAUDE.md` + +- [x] `docs/architecture/provisioning.md`: three roles, what each may touch, the rename and its guard, + the new tables, the migration and its guards, and the write half of the tail check + + three new sections: `## The roles` with its rename table, `## The SemiPlot configuration + schema` with the four tables, the version floor and the migration guards, and the tail-check + list grown from four items to six +- [x] `docs/deployment.md` (Russian): the password table under `## Пароли` and the role table under + `## Роли` + + both rewritten, plus two new sections an operator needs: `## Таблицы SemiPlot` and + `## Обновление ранее развёрнутой базы`, which states what one `site` run does to a v0.3.0 + database and that the connection file has to name `semiplot` afterwards +- [x] `README.md` and `CLAUDE.md`: both commands now create four tables, and the SemiPlot role is + `semiplot` + + `README.md` stays Russian: this repository's `CLAUDE.md` calls only `docs/architecture/` + English, and the file has been Russian since it was written +- [x] archiving deferred to delivery: the plan stays in `docs/plans/` until the operator has tested + the branch, so nothing was moved + +### Task 8: Narrow the `semiplot` grant on `semiplot_tags` to its settings columns (➕) + +A `semiplot_tags` row is keyed by `id`, the SCADA variable number. SemiPlot owns the settings in the row +and none of the keys: its pen editor changes a pen's settings, and never adds a pen, deletes one, or moves +one onto another SCADA variable. Today `semiplotTables` (`internal/provision/schema.go:20-28`) marks the +table `writable`, so `semiplotGrantStatements()` issues `SELECT, INSERT, UPDATE, DELETE`. A table-level +`UPDATE` would still let `UPDATE semiplot_tags SET id = 99` re-key an ungrouped pen, so the grant is +column-level: `GRANT SELECT, UPDATE (name, unit, format, color, line_style, enabled_on_start, scale_min, +scale_max) ON semiplot_tags TO semiplot`. `semiplot_groups` and `semiplot_pen_groups` keep full DML: +groups are SemiPlot's own data. Rows in `semiplot_tags` are created by Task 9's function, never by the +role directly. + +Two consequences to respect. A column grant is not a table grant, so `has_table_privilege(semiplot, +'semiplot_tags', 'UPDATE')` answers false and no check may ask it for this table; privileges stay proved by +execution under `SET ROLE` in a rolled-back transaction. And the column list is a second copy of the list +`TestSemiplotTagsColumns` pins, so a test derives one from the other. + +**Files:** +- Modify: `internal/provision/schema.go` +- Modify: `internal/provision/check.go` +- Modify: `internal/provision/schema_test.go` +- Modify: `internal/provision/check_test.go` +- Modify: `docs/architecture/provisioning.md`, `docs/architecture/overview.md`, `docs/deployment.md`, + `README.md`, `CLAUDE.md` + + `README.md` was left unchanged by this task: its one line about provisioning lists the roles and + the tables `site` creates, and a column-level grant changes neither + +- [x] replace `semiplotTables`' `writable bool` with the grant each table gets, and emit the column-level + grant for `semiplot_tags` from a named list of its settings columns +- [x] write `TestTagsUpdateGrantCoversEverySettingsColumn`: the grant's column list equals the columns + `columnNamesOf` parses out of `sql/semiplot_tags.sql`, minus `id` — a column added to the table + later fails this test instead of arriving silently uneditable + + a `REVOKE ALL ON FROM semiplot` ahead of each grant was added here and cut in + review: on a fresh server the role holds nothing to revoke + +- [x] reshape `plotWriteProbes()` (`check.go:24-46`): drop the `semiplot_tags` INSERT and DELETE probes, + keep the `UPDATE ... SET name = ... WHERE id = -1` probe, and rewrite the `semiplot_pen_groups` + INSERT probe so it no longer inserts pen `-1`, which no longer exists in the transaction: + `INSERT INTO semiplot_pen_groups (pen_id, group_id) SELECT tag.id, grp.id FROM semiplot_tags tag, + semiplot_groups grp WHERE grp.name = '' LIMIT 1` +- [x] add the negative half, executed under `SET ROLE` in the same rolled-back transaction: `INSERT INTO + semiplot_tags`, `DELETE FROM semiplot_tags` and `UPDATE semiplot_tags SET id = id WHERE false` each + fail with 42501, and a success is a check failure naming the grant that is too wide + + each refused statement runs in its own savepoint, because a 42501 aborts the transaction + around it; the failure prescribes `REVOKE ... FROM semiplot` first, then the same revoke on + `PUBLIC` or a membership (corrected in review: the `REVOKE ALL` wrapper that would have + cleared the role's own grant was cut, so the grant by name is the first suspect) +- [x] correct the `ok` line (`check.go:198`), `writeProbeFailure`'s repair text (it must prescribe the + column list, not the wide grant back), and rewrite `TestSemiplotGrantStatements`, + `TestPlotWriteProbesCoverTheWritableTables` and `TestPlotWriteProbeOrderSatisfiesTheForeignKeys` +- [x] correct the role row at `provisioning.md:174` and the write-probe item at `:316-323`, the role row at + `overview.md:19`, the role row at `deployment.md:65`, and `CLAUDE.md` +- [x] rewrite the commissioning step at `overview.md:61-62` and `deployment.md:137-138`: pens appear in + `semiplot_tags` on their own when the viewer starts (Task 9); a person never inserts them, and only + the superuser can delete one +- [x] run `gofmt -l .`, `go vet ./...`, `go test ./...`, then on a bench container, as `semiplot`: the + eight-column UPDATE succeeds, and `SET id`, INSERT and DELETE on `semiplot_tags` each fail 42501 + + `postgres:17-alpine` on port 15833, two `bench` runs exit 0; as `semiplot`: `UPDATE 1`, then + 42501 three times. A table-level `GRANT INSERT, DELETE, UPDATE ... TO semiplot` left by hand + was gone after the next run; `GRANT INSERT` and `GRANT UPDATE (id)` to `PUBLIC` each made the + run exit 1 naming the grant as too wide. Container removed + +### Task 9: Register new pens from the keys SCADA writes (➕) + +Nothing creates a `semiplot_tags` row today but a person. The archive carries no variable catalogue of its +own, so `trends.id` is the only reliable source of keys. SemiBase provides one function that mirrors the +keys SCADA already writes into the pen catalogue with default settings. The viewer calls it at start and +from its pen editor's refresh button. The role gets `EXECUTE` on the function and no `INSERT` on the +table, so SemiPlot can add a pen only for a variable SCADA has actually written, never for an id somebody +typed. A trigger on `trends` is rejected: it would fire on every sample SCADA writes and hang our code on +the one table SemiPlot does not own. + +Superseded: the block below is the function as first shipped. The review fix at the end of this task +replaced it; `sql/semiplot_register.sql` holds the current body. + +```sql +CREATE OR REPLACE FUNCTION semiplot_register_new_pens() RETURNS integer +LANGUAGE sql SECURITY DEFINER SET search_path = public AS $$ + WITH RECURSIVE keys AS ( + (SELECT id FROM trends ORDER BY id LIMIT 1) + UNION ALL + SELECT (SELECT t.id FROM trends t WHERE t.id > keys.id ORDER BY t.id LIMIT 1) + FROM keys WHERE keys.id IS NOT NULL + ), added AS ( + INSERT INTO semiplot_tags (id, name, color, enabled_on_start) + SELECT id, id::text, + (ARRAY['#4E79A7','#F28E2B','#E15759','#76B7B2','#59A14F','#EDC948', + '#B07AA1','#FF9DA7','#9C755F','#17BECF','#D62728','#9467BD'])[id % 12 + 1], + false + FROM keys WHERE id IS NOT NULL + ON CONFLICT (id) DO NOTHING + RETURNING 1 + ) + SELECT count(*)::integer FROM added; +$$; +REVOKE ALL ON FUNCTION semiplot_register_new_pens() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION semiplot_register_new_pens() TO semiplot; +``` + +The recursive CTE is a loose index scan over `trends`' primary key `(id, l, t)` (`sql/trends.sql:29`): one +index probe per distinct key per partition, not a scan of the rows. A new pen is named by its number, +drawn from a fixed palette by `id`, starts hidden (`enabled_on_start = false`, so a SCADA with 500 +variables does not draw 500 lines at the next start) and autoscales. A variable SCADA stops writing keeps +its row: its history is still in `trends`. PostgreSQL grants `EXECUTE` on a new function to `PUBLIC`, so the +`REVOKE` is load-bearing. + +**Files:** +- Create: `sql/semiplot_register.sql` +- Modify: `embed.go` +- Modify: `internal/provision/schema.go` +- Modify: `internal/provision/check.go` +- Modify: `internal/provision/schema_test.go`, `internal/provision/check_test.go` +- Modify: `docs/architecture/provisioning.md`, `docs/deployment.md` +- Modify: `internal/provision/create.go` (added during implementation: `public.trends` is now created + ahead of the SemiPlot schema files) +- Modify: `docs/architecture/overview.md`, `CLAUDE.md` (added during implementation: the overview said + the tool adds no functions, and both listed the SQL files) + +- [x] add `sql/semiplot_register.sql` with the function above, embed it, and apply it after + `semiplot_tags.sql` in `semiplotSchemaFiles`; a second `site` run replaces the function in place + + PostgreSQL checks a `LANGUAGE sql` body against the relations it names at `CREATE FUNCTION` + (measured: `relation "trends" does not exist`), and `create` used to create `public.trends` + last. `ensureArchiveTable` now runs right after the default privileges and ahead of the + schema files, so the default privileges still precede the table +- [x] issue the `REVOKE ... FROM PUBLIC` and `GRANT EXECUTE ... TO semiplot` with the other grants + + one simple-protocol string, the fifth entry of `semiplotGrantStatements()`, run on every run: + a new function is executable by `PUBLIC`, and `CREATE OR REPLACE` keeps whatever grants the + function already carries, so a hand `GRANT ... TO PUBLIC` lasts until the next run's `REVOKE` + (corrected in review) +- [x] check phase, under `SET ROLE semiplot` in the rolled-back transaction: `SELECT + semiplot_register_new_pens()` succeeds; and `has_function_privilege('public', + 'semiplot_register_new_pens()', 'EXECUTE')` is false + + the call opens the write-probe transaction and the run prints how many keys have no pen yet; + the `PUBLIC` question is its own step, `assertPublicCannotRegisterPens`. Mutation: with the + `REVOKE` deleted from the grant statement, `bench` on a fresh database exits 1 naming + `PUBLIC holds EXECUTE on semiplot_register_new_pens()`; a hand `GRANT EXECUTE ... TO PUBLIC` + is gone after the next run +- [x] measure the loose scan on the bench archive (`converge` fills one day of 50 pens) and record the + time in `provisioning.md`; a scan past one second is a finding to report, not to optimise here + + filled with `generate_series` as `scada_writer` rather than `converge`, which lives in + SemiPlot: one day partition, 50 ids, `l = 0` every second and `l = 1` every minute, 4,392,000 + rows. As `semiplot`: 13.1 ms first call (50 added), 1.9 ms second (0) on `postgres:17.11`; + 8.7 ms and 2.4 ms on `postgres:14.24`. With 91 partitions and 10,800,000 rows on 17: 119.0 ms + and 64.8 ms. Both versions plan one index-only probe per partition per step. No finding on + the measured archives; the linear extrapolation to 500 keys over 365 partitions, about 2.6 s + and 4.8 s, is past one second and recorded in `provisioning.md` (corrected in review) +- [x] tests: the schema file order, the grant statements, and the check phase's new probes + + as of the last review round: `TestSemiplotSchemaFileOrder`, `TestSemiplotGrantStatements`, + `TestRegisterFunctionIgnoresTheCallersSchemas`, `TestRegistrarStatements`, + `TestRegistrarInsertGrantMatchesTheFunctionBody`, `TestRefusalVerdict`, + `TestPlotTagsUpdateProbeWritesEverySettingsColumn`, `TestPublicRegistersPensFailure`. The + first-shipped `TestRegisterFunctionRunsAsItsOwnerOverPublicOnly`, + `TestRegisterNewPensProbeCallsTheFunction` and `TestRegisterNewPensFailure` were renamed or + deleted in review +- [x] document the function in `provisioning.md` beside the configuration schema, and the + pens-appear-on-their-own behaviour in `deployment.md` + + `### Registering new pens` under the configuration schema, tail check 4 added and the rest + renumbered; `overview.md` no longer says the tool adds no functions +- [x] run `gofmt -l .`, `go vet ./...`, `go test ./...`, then rebuild `semibase:local` as SemiPlot's + `docs/plans/20260917-pen-catalogue-and-groups.md` describes + + all clean, `golangci-lint run` `0 issues.`; `docker run --rm semibase:local version` prints + `local` + +- [x] + review fix: the function as first shipped ran as the superuser with `SET search_path = + public`. Two attacks worked on a fresh `postgres:17-alpine`: `semiplot` shadowed `trends` with + a temporary table and registered ids 7 and 424242, and `scada_writer` replaced `trends` with a + view whose function reported `running as postgres (super=t)`. The function now belongs to the + `NOLOGIN` role `semiplot_registrar` (`SELECT` on `public.trends`, `SELECT (id)` and `INSERT` on + the four columns it writes), sets `search_path = pg_catalog, pg_temp` and names its relations + by schema; the tail check replays the temporary-table attack. The shadow table now registers + nothing. The view attack still runs `scada_writer`'s code, now as `semiplot_registrar + (super=f)`: its reach falls from the whole cluster to `semiplot_tags` (`SELECT (id)` and + `INSERT` on four columns) and the function, not to nothing. `scada_writer` is trusted and can act as `semiplot` and as `semiplot_registrar` + through a policy or a view on the `trends` it owns; the grants bound the operator acting + through `semiplot`. `provisioning.md#registering-new-pens` and `provisioning.md#trust` hold the + reasoning +- [x] + review fix: the `rolsuper` tail check is removed. It ran right after the `ALTER FUNCTION ... + OWNER TO` in the same invocation, which succeeds or aborts the run, so it guarded an + unreachable state; the checks after it are renumbered. A failed pen-registration call no + longer prescribes `GRANT EXECUTE`: a 42501 raised inside the body is not a missing `EXECUTE` +- [x] + review fix: the CI step `Register pens from the keys SCADA wrote` runs the function body over + three keys written as `scada_writer` and requires 3, then 0, and each row's name, colour and + `enabled_on_start = false` +- [x] + review fix: the temporary-trends replay stays although `create` writes the function from + embedded bytes that `TestRegisterFunctionIgnoresTheCallersSchemas` pins. It is the only + execution proof that `semiplot` cannot make the function add a key absent from `public.trends`, + and it tests the real server's name resolution, which a byte pin cannot; the `rolsuper` check + only re-read one statement's result from the same run. Measured on 17: the qualifiers alone + keep the shadow out, so a mutant changing only the `SET` passes and one that also drops the + `public.` qualifiers exits 1 + +SemiPlot's integration suite proves the same behaviour against the bench seeder's archive; see +SemiPlot's `docs/plans/20260924-pen-and-group-editor.md`. + +## Post-Completion + +*No checkboxes: these need action outside this repository.* + +**Prerequisites in the consumer, before this releases** + +`SemiPlot/bench/Dockerfile:3` and `SemiPlot/SemiPlot.Tests.Integration/DockerCli.cs:8` both name +`ghcr.io/semiteq/semibase:latest`. The moment this change is tagged, SemiPlot's `master` pulls it with no +commit of its own. Three things then break, in this order: + +1. **The role name.** The bench passes `SEMIBASE_READER_PASSWORD`, which no longer exists, so + `ensureRole` refuses to create `semiplot` with no password, the init script exits, `initdb` aborts + and the published port never opens. Every container test times out before reaching anything else. + The variable and the `user:` value are named in + `SemiPlot.Tests.Integration/PostgresContainerFixture.cs:93-94`, `SemiPlot.AppHost/AppHost.cs:32-33`, + `SemiPlot.Tools.ArchiveSeeder/BenchRoles.cs` and `ConfigFiles/connection/connection.yaml:4`. +2. **The dropped column.** `SemiPlot.DataSource.Postgres/ArchiveStatements.cs:22` selects `group_name`, + `SemiPlot.Tools.ArchiveSeeder/TagCatalogWriter.cs:12` inserts it, and + `SemiPlot.Tests.Integration/PostgresCatalogReadTests.cs:33` inserts it in its own fixture rows. +3. **Nothing reads the new shape.** That is `Semiteq/SemiPlot#65`. + +Pinning both references to `v0.3.0` is a one-commit pull request in SemiPlot and it has to land first. +The pin moves to the new version inside `#65`, together with the role rename and the new catalogue read. + +**The release** + +A tag and its annotation, per the release convention in `CLAUDE.md`. The tag push builds and pushes +`:latest` and `:vX.Y.Z`. + +**What waits on it** + +- `Semiteq/SemiPlot#65` — read units, per-pen scale, visibility and multiple groups from the new shape, + move the pin and the role name. +- `Semiteq/SemiPlot#67` — the pen editor, writing over the same role's connection. +- `Semiteq/SemiBase#10` — `semiplot_markers`, which needs the same grants and bumps the schema version + to 2. It is not part of this change. + +**Known consequences to watch** + +- Last write wins, and nothing else is possible with this schema. There is no `updated_at` and no row + version, so two operator stations editing one pen resolve by whoever saved last. + `Semiteq/SemiPlot#67` leaves the question open; optimistic concurrency would be another migration here. +- A database provisioned by this release and then read by a viewer built against `v0.3.0` answers 42703 + on `group_name`. SemiPlot maps that to an unexpected-shape failure naming the column, which is + readable. The reverse — a new viewer against an old database — is the case `semiplot_meta` exists for. +- An operator who filled `color` by hand with a name rather than a hex triplet loses that value. The run + says so, with the ids, and the viewer picks a colour for those pens until someone sets one. + +### Cut after the fact, 2026-09-17 + +The migration apparatus described above was deleted. No database provisioned by an earlier +release exists: `docs/architecture/provisioning.md` on master already states that the only pair +ever newly deployed is the newest SemiBase with the viewer as it stands, the SCADA-meets-existing- +`trends` case is still marked Unverified, the release downloads add up to one author testing, and +every bench is a throwaway container. The rename (`semiplot_reader` to `semiplot`), +`sql/semiplot_migrate.sql`, the colour clearing, the newer-database refusal, the migration parity +tests, the `v0.3.0` fixture and the CI upgrade step all defended a database that was never +provisioned. `semiplot_meta` stays as a plain stamp, moved into `semiplotSchemaFiles` as the third +file, because the viewer is the half of a delivered pair that does get updated. The pre-cut state +is commit `c788577`: everything named above is one `git show` away. + +## Verify it yourself + +**Executed by exec:** + +- branch: semiplot-role-and-pen-schema +- commits: 14, 27 files changed, +3112 / -568 + +Measured on 2026-09-16 from a clean tree, each command run from the repository root: + +```bash +go build ./... +go test ./... -count=1 # cmd/semibase ok, internal/provision ok +go vet ./... +gofmt -l . # prints nothing +golangci-lint run # 0 issues +``` + +The acceptance items are in `## Acceptance Evidence` above and every one of them was run +against a live server during the run. Three checks are worth repeating by hand, because they +are the ones a green suite cannot show you: + +1. **The upgrade path is gated, not hand-verified.** `.github/workflows/ci.yml` stamps the + shape the previous release left onto the database the fresh step provisioned, then runs + `site` over it. Prove the gate bites: delete the `INSERT INTO semiplot_pen_groups` from the + `DO` body of `sql/semiplot_migrate.sql`, rebuild, and run that step's `run:` block. It exits + 3 with `memberships: got '0', want '3'` and `ungrouped pens: got '1,2,3,4,5', want '3,5'`. +2. **`REVOKE CREATE ON SCHEMA public FROM PUBLIC` is gated only on the version floor.** Delete + the `revokePublicCreate` call from `create` and run `bench` twice: against + `postgres:17-alpine` it exits 0, against `postgres:14-alpine` it exits 1 at + `assertPlotCannotCreateInPublic`. PostgreSQL 15 withholds that grant by engine default, so + the 14 step is the only place the statement can be proved to matter. +3. **The refusals refuse before they change anything.** Stamp a database at + `schema_version = 2`, grant `CREATE ON SCHEMA public TO PUBLIC`, and run the binary. It + exits 1 naming both versions, and `has_schema_privilege('public','public','CREATE')` reads + `t` both before and after. An earlier revision of this branch read `t` then `f`: it revoked + the privilege and then refused to do the work the revoke was for. + +What no command here proves is what the consumer does with the shape this produces. That is +`Semiteq/SemiPlot#65`, and the prerequisites it must carry are in `## Post-Completion`. + +### Tasks 8 and 9: the narrowed grant and pen registration + +Executed on the same branch, `semiplot-role-and-pen-schema`, commits `222c014` to `3be448d`. Run it on a +throwaway container (never port 15432, where a native PostgreSQL 14 listens): + +```bash +cd /c/Users/admin/projects/SemiBase +go test ./... -count=1 +docker run -d --name sb17 -e POSTGRES_PASSWORD=super -p 15901:5432 postgres:17-alpine +go build -o semibase ./cmd/semibase +SEMIBASE_SUPER_PASSWORD=super SEMIBASE_WRITER_PASSWORD=writer SEMIBASE_PLOT_PASSWORD=plot \ + ./semibase bench --host localhost --port 15901 --database semiplot_dev +``` + +Every line prints `[ OK ]`, including tail check 3's four steps: registration, the temporary-trends replay, +the allowed writes, and the refused `semiplot_tags` writes. + +1. **SemiPlot cannot manage SCADA's keys.** As `semiplot`: `UPDATE semiplot_tags SET name = name` succeeds; + `INSERT INTO semiplot_tags (id, name) VALUES (1, 'x')`, `DELETE FROM semiplot_tags` and + `UPDATE semiplot_tags SET id = id` each fail with `permission denied` (42501). On `v0.3.0` the INSERT and + DELETE succeeded. +2. **A key SCADA writes becomes a hidden pen.** As `scada_writer`, create a day partition and write rows for + ids 0, 13, 40 (the CI step "Register pens from the keys SCADA wrote" in `.github/workflows/ci.yml` has the + exact statements). As `semiplot`: `SELECT semiplot_register_new_pens()` returns 3, a second call 0, and + `SELECT id, name, color, enabled_on_start FROM semiplot_tags ORDER BY id` shows + `0 0 #4E79A7 f`, `13 13 #F28E2B f`, `40 40 #59A14F f`. +3. **`semiplot` cannot register a key it invents.** As `semiplot`: + `CREATE TEMP TABLE trends (id integer, l smallint, t timestamp); INSERT INTO trends VALUES (424242, 0, now()); + SELECT semiplot_register_new_pens();` returns 0 and no pen `424242` appears. Before `bf031e8` the same + statements registered it. +4. **The function does not run as a superuser.** `SELECT proowner::regrole, prosecdef, proconfig FROM pg_proc + WHERE proname = 'semiplot_register_new_pens'` shows `semiplot_registrar`, `t`, + `{"search_path=pg_catalog, pg_temp"}`; `SELECT rolsuper, rolcanlogin FROM pg_roles WHERE rolname = + 'semiplot_registrar'` shows `f f`. + +What no test here can show: the trust model. `scada_writer` owns `public.trends` and can act as `semiplot` or +`semiplot_registrar` through a policy or view on it; that is documented in +`docs/architecture/provisioning.md#trust`, not prevented. Remove the container: `docker rm -f sb17`. diff --git a/embed.go b/embed.go index 08ed30e..52458fb 100644 --- a/embed.go +++ b/embed.go @@ -5,5 +5,14 @@ import _ "embed" //go:embed sql/semiplot_tags.sql var SemiplotTagsSQL string +//go:embed sql/semiplot_register.sql +var SemiplotRegisterSQL string + +//go:embed sql/semiplot_groups.sql +var SemiplotGroupsSQL string + +//go:embed sql/semiplot_meta.sql +var SemiplotMetaSQL string + //go:embed sql/trends.sql var TrendsSQL string diff --git a/internal/provision/check.go b/internal/provision/check.go new file mode 100644 index 0000000..96a1b84 --- /dev/null +++ b/internal/provision/check.go @@ -0,0 +1,426 @@ +package provision + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type plotWriteProbe struct { + table string + operation string + statement string +} + +const writeProbeName = "semibase write probe" + +// SCADA ids are non-negative, so no pen and no trends key is -1 +const absentKey = -1 + +// a membership row pairs any existing pen with the probe group, so an empty pen catalogue inserts +// nothing and still needs the privilege +func plotWriteProbes() []plotWriteProbe { + probeGroup := fmt.Sprintf("SELECT id FROM semiplot_groups WHERE name = '%s'", writeProbeName) + return []plotWriteProbe{ + {"semiplot_tags", "UPDATE", fmt.Sprintf("UPDATE semiplot_tags SET %s WHERE id = %d", + selfAssignments(plotEditableTagColumns), absentKey)}, + {"semiplot_groups", "INSERT", fmt.Sprintf( + "INSERT INTO semiplot_groups (name) VALUES ('%s')", writeProbeName)}, + {"semiplot_groups", "UPDATE", fmt.Sprintf( + "UPDATE semiplot_groups SET name = '%s' WHERE name = '%s'", writeProbeName, writeProbeName)}, + {"semiplot_pen_groups", "INSERT", fmt.Sprintf( + "INSERT INTO semiplot_pen_groups (pen_id, group_id) SELECT tag.id, grp.id "+ + "FROM semiplot_tags tag, semiplot_groups grp WHERE grp.name = '%s' LIMIT 1", writeProbeName)}, + {"semiplot_pen_groups", "UPDATE", fmt.Sprintf( + "UPDATE semiplot_pen_groups SET group_id = group_id WHERE group_id IN (%s)", probeGroup)}, + {"semiplot_pen_groups", "DELETE", fmt.Sprintf( + "DELETE FROM semiplot_pen_groups WHERE group_id IN (%s)", probeGroup)}, + {"semiplot_groups", "DELETE", fmt.Sprintf( + "DELETE FROM semiplot_groups WHERE name = '%s'", writeProbeName)}, + } +} + +func plotForbiddenTagWrites() []plotWriteProbe { + return []plotWriteProbe{ + {"semiplot_tags", "INSERT", fmt.Sprintf( + "INSERT INTO semiplot_tags (id, name) VALUES (%d, '%s')", absentKey, writeProbeName)}, + {"semiplot_tags", "DELETE", fmt.Sprintf("DELETE FROM semiplot_tags WHERE id = %d", absentKey)}, + {"semiplot_tags", "UPDATE (id)", "UPDATE semiplot_tags SET id = id WHERE false"}, + } +} + +func selfAssignments(columns []string) string { + assignments := make([]string, 0, len(columns)) + for _, column := range columns { + assignments = append(assignments, column+" = "+column) + } + return strings.Join(assignments, ", ") +} + +var forbiddenWriteOperations = []string{"INSERT", "UPDATE", "DELETE"} + +const scadaMessages = "public.messages" // the SCADA's alone, so it is asserted only where it already exists + +const ( + insufficientPrivilege = "42501" + integrityConstraintViolationClass = "23" +) + +func writeProbeFailure(probe plotWriteProbe, cause error) error { + return fmt.Errorf("%s cannot %s %s: %w. Repair with GRANT %s ON %s TO %s, then re-run this command", + PlotRole, probe.operation, probe.table, cause, plotGrantsOn(probe.table), probe.table, PlotRole) +} + +// REVOKE UPDATE on the table clears the column grants too, and the next run grants them back +func tooWideTagGrantFailure(probe plotWriteProbe) error { + privilege, _, _ := strings.Cut(probe.operation, " ") + return fmt.Errorf("%s may %s %s and must not: it edits a pen's settings and never adds, deletes or "+ + "re-keys a pen. The grant is too wide. %s", + PlotRole, probe.operation, probe.table, revokeRepair(privilege, probe.table)) +} + +func revokeRepair(privilege, relation string) string { + return fmt.Sprintf("Repair with REVOKE %s ON %s FROM %s, or, if the privilege comes through PUBLIC or a "+ + "role %s is a member of, revoke it there or drop the membership; then re-run this command", + privilege, relation, PlotRole, PlotRole) +} + +// a class 23 error is raised past the privilege check, so the statement was allowed to run +func refusalVerdict(probe plotWriteProbe, probeErr error) error { + var pgErr *pgconn.PgError + switch { + case probeErr == nil: + return tooWideTagGrantFailure(probe) + case hasSQLState(probeErr, insufficientPrivilege): + return nil + case errors.As(probeErr, &pgErr) && strings.HasPrefix(pgErr.Code, integrityConstraintViolationClass): + return fmt.Errorf("the %s %s probe passed the privilege check and failed on a constraint: %w. %w", + probe.table, probe.operation, probeErr, tooWideTagGrantFailure(probe)) + default: + return fmt.Errorf("running the %s %s probe: %w", probe.table, probe.operation, probeErr) + } +} + +func hasSQLState(err error, code string) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == code +} + +func excessPrivilegeFailure(relation, operation string) error { + return fmt.Errorf("%s holds %s on %s and must not write it. %s", + PlotRole, operation, relation, revokeRepair(operation, relation)) +} + +func createInPublicFailure() error { + return fmt.Errorf("%s holds CREATE on schema public and must not: it would add objects beside the "+ + "tables granted to it. This run already revoked CREATE from PUBLIC, so the privilege is held by "+ + "name or through a role %s is a member of, %s among them. Repair with REVOKE CREATE ON SCHEMA "+ + "public FROM %s, or drop the membership that carries it; then re-run this command", + PlotRole, PlotRole, WriterRole, PlotRole) +} + +// the cheapest statement that still needs SELECT on public.trends and USAGE on the schema it +// lives in, whatever the table's size +const plotProbe = "SELECT count(*) FROM (SELECT 1 FROM " + archiveTable + " LIMIT 1) probe" + +// docs/architecture/provisioning.md#what-the-tail-checks-prove +func (o Options) check(ctx context.Context) error { + if err := o.Validate(); err != nil { + return err + } + step("Phase check: the %s access chain", PlotRole) + + archive, err := o.connect(ctx, o.Database, o.SuperUser, o.SuperPassword) + if err != nil { + return err + } + defer archive.Close(ctx) + + if err := assertPlotReads(ctx, archive); err != nil { + return err + } + if err := assertPlotReadsTheSchemaVersion(ctx, archive); err != nil { + return err + } + if err := assertPlotWriteAccess(ctx, archive); err != nil { + return err + } + if err := assertPublicCannotRegisterPens(ctx, archive); err != nil { + return err + } + if err := assertPlotCannotWriteTheArchive(ctx, archive); err != nil { + return err + } + if err := assertPlotCannotWriteTheSchemaVersion(ctx, archive); err != nil { + return err + } + if err := assertPlotCannotCreateInPublic(ctx, archive); err != nil { + return err + } + if err := o.checkPlotLogin(ctx); err != nil { + return err + } + // last, so a pending shared_buffers is the last thing the run prints + return reportPendingRestart(ctx, archive) +} + +// the read itself, never has_table_privilege: the catalog bit says yes for a role that +// REVOKE USAGE ON SCHEMA public has locked out of the schema the table lives in (measured) +func assertPlotReads(ctx context.Context, archive *pgx.Conn) error { + var probeErr error + if err := runAs(ctx, archive, PlotRole, func() error { + var probed int + probeErr = archive.QueryRow(ctx, plotProbe).Scan(&probed) + return nil + }); err != nil { + return err + } + if probeErr != nil { + return diagnoseFailedRead(ctx, archive, probeErr) + } + ok("%s reads public.trends", PlotRole) + return nil +} + +func assertPlotReadsTheSchemaVersion(ctx context.Context, archive *pgx.Conn) error { + var version int + var probeErr error + if err := runAs(ctx, archive, PlotRole, func() error { + probeErr = archive.QueryRow(ctx, "SELECT schema_version FROM semiplot_meta").Scan(&version) + return nil + }); err != nil { + return err + } + if probeErr != nil { + return fmt.Errorf("%s cannot read the schema version from semiplot_meta: %w. Repair with "+ + "GRANT %s ON semiplot_meta TO %s, then re-run this command", + PlotRole, probeErr, plotReadGrants, PlotRole) + } + ok("%s reads semiplot_meta.schema_version = %d", PlotRole, version) + return nil +} + +func diagnoseFailedRead(ctx context.Context, archive *pgx.Conn, probeErr error) error { + var granted bool + if err := archive.QueryRow(ctx, + "SELECT has_table_privilege($1, $2, 'SELECT')", PlotRole, archiveTable).Scan(&granted); err != nil { + return fmt.Errorf("%s cannot read public.trends (%w) and its SELECT privilege could not be read: %w", + PlotRole, probeErr, err) + } + if !granted { + return fmt.Errorf("%s cannot read public.trends: it holds no SELECT on the table, so the table was "+ + "created before the default privileges were set (%w). Repair with GRANT SELECT ON ALL TABLES IN "+ + "SCHEMA public TO %s for the tables that exist, then %s for the partitions still to come, then "+ + "re-run this command", PlotRole, probeErr, PlotRole, defaultPrivilegesStatement()) + } + return fmt.Errorf("%s holds SELECT on public.trends and still cannot read it: %w. The usual cause is a "+ + "revoked schema privilege; repair with GRANT USAGE ON SCHEMA public TO %s, then re-run this command", + PlotRole, probeErr, PlotRole) +} + +func assertPlotWriteAccess(ctx context.Context, archive *pgx.Conn) error { + return runAs(ctx, archive, PlotRole, func() error { + transaction, err := archive.Begin(ctx) + if err != nil { + return fmt.Errorf("opening the %s write probe transaction: %w", PlotRole, err) + } + defer func() { _ = transaction.Rollback(ctx) }() + + if err := assertPlotRegistersNewPens(ctx, transaction); err != nil { + return err + } + if err := assertTemporaryTrendsAreIgnored(ctx, transaction); err != nil { + return err + } + if err := assertPlotWritesItsTables(ctx, transaction); err != nil { + return err + } + return assertPlotCannotEditPenKeys(ctx, transaction) + }) +} + +const registerNewPensProbe = "SELECT " + registerNewPensFunction + +func assertPlotRegistersNewPens(ctx context.Context, transaction pgx.Tx) error { + var unregistered int + if err := transaction.QueryRow(ctx, registerNewPensProbe).Scan(&unregistered); err != nil { + return fmt.Errorf("calling %s as %s: %w", registerNewPensFunction, PlotRole, err) + } + ok("%s runs %s: %d keys in %s have no pen yet", PlotRole, registerNewPensFunction, unregistered, archiveTable) + return nil +} + +// docs/architecture/provisioning.md#registering-new-pens +func assertTemporaryTrendsAreIgnored(ctx context.Context, transaction pgx.Tx) error { + shadow := []string{ + "CREATE TEMPORARY TABLE trends (id integer) ON COMMIT DROP", + "GRANT SELECT ON pg_temp.trends TO " + RegistrarRole, + fmt.Sprintf("INSERT INTO trends (id) VALUES (%d)", absentKey), + registerNewPensProbe, + } + for _, statement := range shadow { + if _, err := transaction.Exec(ctx, statement); err != nil { + return fmt.Errorf("replaying the temporary-trends attack as %s (%s): %w", PlotRole, statement, err) + } + } + var registered bool + if err := transaction.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM public.semiplot_tags WHERE id = $1)", + absentKey).Scan(®istered); err != nil { + return fmt.Errorf("reading back the shadow key: %w", err) + } + if registered { + return fmt.Errorf("%s registered pen %d from a temporary trends %s created: a caller can add any "+ + "pen it names. This is a build defect in sql/semiplot_register.sql, whose body must name %s "+ + "by schema; no grant repairs it", registerNewPensFunction, absentKey, PlotRole, archiveTable) + } + ok("%s reads %s, not a temporary trends of its caller", registerNewPensFunction, archiveTable) + return nil +} + +func assertPlotWritesItsTables(ctx context.Context, transaction pgx.Tx) error { + for _, probe := range plotWriteProbes() { + if _, err := transaction.Exec(ctx, probe.statement); err != nil { + return writeProbeFailure(probe, err) + } + } + ok("%s inserts, updates and deletes semiplot_groups and semiplot_pen_groups, and updates the "+ + "settings columns of semiplot_tags", PlotRole) + return nil +} + +func assertPlotCannotEditPenKeys(ctx context.Context, transaction pgx.Tx) error { + for _, probe := range plotForbiddenTagWrites() { + if err := assertRefused(ctx, transaction, probe); err != nil { + return err + } + } + ok("%s cannot insert, delete or re-key a pen in semiplot_tags", PlotRole) + return nil +} + +// semiplot's own call succeeds either way, so this one is a catalog question +func assertPublicCannotRegisterPens(ctx context.Context, archive *pgx.Conn) error { + var granted bool + if err := archive.QueryRow(ctx, "SELECT has_function_privilege('public', $1, 'EXECUTE')", + registerNewPensFunction).Scan(&granted); err != nil { + return fmt.Errorf("checking the PUBLIC EXECUTE privilege on %s: %w", registerNewPensFunction, err) + } + if granted { + return publicRegistersPensFailure() + } + ok("PUBLIC holds no EXECUTE on %s", registerNewPensFunction) + return nil +} + +func publicRegistersPensFailure() error { + return fmt.Errorf("PUBLIC holds EXECUTE on %s and must not: every role that connects could add pens. "+ + "Repair with REVOKE ALL ON FUNCTION %s FROM PUBLIC, then re-run this command", + registerNewPensFunction, registerNewPensFunction) +} + +// a savepoint per statement, because a refused statement aborts the transaction around it +func assertRefused(ctx context.Context, transaction pgx.Tx, probe plotWriteProbe) error { + savepoint, err := transaction.Begin(ctx) + if err != nil { + return fmt.Errorf("opening a savepoint for the %s %s probe: %w", probe.table, probe.operation, err) + } + defer func() { _ = savepoint.Rollback(ctx) }() + + _, probeErr := savepoint.Exec(ctx, probe.statement) + return refusalVerdict(probe, probeErr) +} + +func assertPlotCannotWriteTheArchive(ctx context.Context, archive *pgx.Conn) error { + if err := assertNoWritePrivilege(ctx, archive, archiveTable); err != nil { + return err + } + asserted := []string{archiveTable} + + messages, err := relationExists(ctx, archive, scadaMessages) + if err != nil { + return err + } + if messages { + if err := assertNoWritePrivilege(ctx, archive, scadaMessages); err != nil { + return err + } + asserted = append(asserted, scadaMessages) + } + ok("%s holds no INSERT, UPDATE or DELETE on %s", PlotRole, strings.Join(asserted, ", ")) + return nil +} + +func assertPlotCannotCreateInPublic(ctx context.Context, archive *pgx.Conn) error { + var granted bool + if err := archive.QueryRow(ctx, + "SELECT has_schema_privilege($1, 'public', 'CREATE')", PlotRole).Scan(&granted); err != nil { + return fmt.Errorf("checking the %s CREATE privilege on schema public: %w", PlotRole, err) + } + if granted { + return createInPublicFailure() + } + ok("%s holds no CREATE on schema public", PlotRole) + return nil +} + +func assertPlotCannotWriteTheSchemaVersion(ctx context.Context, archive *pgx.Conn) error { + if err := assertNoWritePrivilege(ctx, archive, "semiplot_meta"); err != nil { + return err + } + ok("%s holds no INSERT, UPDATE or DELETE on semiplot_meta", PlotRole) + return nil +} + +func assertNoWritePrivilege(ctx context.Context, archive *pgx.Conn, relation string) error { + for _, operation := range forbiddenWriteOperations { + var granted bool + if err := archive.QueryRow(ctx, "SELECT has_table_privilege($1, $2, $3)", + PlotRole, relation, operation).Scan(&granted); err != nil { + return fmt.Errorf("checking the %s %s privilege on %s: %w", PlotRole, operation, relation, err) + } + if granted { + return excessPrivilegeFailure(relation, operation) + } + } + return nil +} + +func (o Options) checkPlotLogin(ctx context.Context) error { + switch { + case o.PlotPassword == "": + note("no %s password given - the login was not tested, only the privileges behind it.", PlotRole) + return nil + case isSocketHost(o.Host): + note("socket host - the %s login was not tested; a consumer connects over TCP.", PlotRole) + return nil + } + + plot, err := o.connect(ctx, o.Database, PlotRole, o.PlotPassword) + if err != nil { + return err + } + defer plot.Close(ctx) + + var probed int + if err := plot.QueryRow(ctx, plotProbe).Scan(&probed); err != nil { + return fmt.Errorf("%s reads public.trends over its own login: %w", PlotRole, err) + } + ok("%s logs in and reads public.trends", PlotRole) + return nil +} + +func reportPendingRestart(ctx context.Context, conn *pgx.Conn) error { + pending, err := pendingRestartSettings(ctx, conn) + if err != nil { + return err + } + if len(pending) > 0 { + warn("settings waiting for a service restart: %s - restart the PostgreSQL service or reboot the machine.", + strings.Join(pending, ", ")) + } + return nil +} diff --git a/internal/provision/check_test.go b/internal/provision/check_test.go new file mode 100644 index 0000000..9f0e903 --- /dev/null +++ b/internal/provision/check_test.go @@ -0,0 +1,207 @@ +package provision + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgconn" +) + +// a probe against semiplot_meta or public.trends would write what the run then asserts the role +// cannot write +func TestPlotWriteProbesCoverTheWritableTables(t *testing.T) { + want := map[string][]string{ + "semiplot_tags": {"UPDATE"}, + "semiplot_groups": {"INSERT", "UPDATE", "DELETE"}, + "semiplot_pen_groups": {"INSERT", "UPDATE", "DELETE"}, + } + exercised := map[string][]string{} + for _, probe := range plotWriteProbes() { + exercised[probe.table] = append(exercised[probe.table], probe.operation) + } + if len(exercised) != len(want) { + t.Fatalf("the probes touch %d tables, want %d", len(exercised), len(want)) + } + for table, operations := range want { + t.Run(table, func(t *testing.T) { + got := slices.Sorted(slices.Values(exercised[table])) + if !slices.Equal(got, slices.Sorted(slices.Values(operations))) { + t.Errorf("the probes issue %v against %s, want %v", got, table, operations) + } + }) + } +} + +func TestPlotTagsUpdateProbeWritesEverySettingsColumn(t *testing.T) { + want := "UPDATE semiplot_tags SET name = name, unit = unit, format = format, color = color, " + + "line_style = line_style, enabled_on_start = enabled_on_start, scale_min = scale_min, " + + "scale_max = scale_max WHERE id = -1" + probes := plotWriteProbes() + if got := probes[probePosition(t, probes, "semiplot_tags", "UPDATE")].statement; got != want { + t.Errorf("the semiplot_tags probe is %q, want %q", got, want) + } +} + +func probePosition(t *testing.T, probes []plotWriteProbe, table, operation string) int { + t.Helper() + for i, probe := range probes { + if probe.table == table && probe.operation == operation { + return i + } + } + t.Fatalf("no %s probe against %s", operation, table) + return -1 +} + +func TestPlotForbiddenTagWritesCoverTheWithheldWrites(t *testing.T) { + want := []string{"INSERT INTO semiplot_tags ", "DELETE FROM semiplot_tags ", "UPDATE semiplot_tags SET id = "} + probes := plotForbiddenTagWrites() + if len(probes) != len(want) { + t.Fatalf("%d refused-write probes, want %d", len(probes), len(want)) + } + for i, prefix := range want { + if !strings.HasPrefix(probes[i].statement, prefix) { + t.Errorf("refused-write probe %d is %q, want it to start with %q", i, probes[i].statement, prefix) + } + } +} + +// a membership row names a group, so the group is inserted before it and deleted after it +func TestPlotWriteProbeOrderSatisfiesTheForeignKeys(t *testing.T) { + probes := plotWriteProbes() + position := func(table, operation string) int { + return probePosition(t, probes, table, operation) + } + if position("semiplot_groups", "INSERT") > position("semiplot_pen_groups", "INSERT") { + t.Error("a membership row is written before the group it names") + } + if position("semiplot_pen_groups", "DELETE") > position("semiplot_groups", "DELETE") { + t.Error("the group is removed before the membership row that names it") + } +} + +func TestWriteProbeFailure(t *testing.T) { + probe := plotWriteProbe{"semiplot_groups", "UPDATE", "UPDATE semiplot_groups SET name = name"} + + want := "semiplot cannot UPDATE semiplot_groups: permission denied. " + + "Repair with GRANT SELECT, INSERT, UPDATE, DELETE ON semiplot_groups TO semiplot, then re-run this command" + if got := writeProbeFailure(probe, errors.New("permission denied")); got.Error() != want { + t.Errorf("writeProbeFailure() = %q, want %q", got, want) + } + + tags := writeProbeFailure(plotWriteProbe{"semiplot_tags", "UPDATE", ""}, errors.New("permission denied")) + wantTags := "GRANT SELECT, UPDATE (name, unit, format, color, line_style, enabled_on_start, scale_min, " + + "scale_max) ON semiplot_tags TO semiplot" + if !strings.Contains(tags.Error(), wantTags) { + t.Errorf("the semiplot_tags repair does not prescribe the column grant: %q", tags) + } +} + +func TestTooWideTagGrantFailure(t *testing.T) { + cases := []struct { + operation string + named []string + }{ + {"INSERT", []string{"semiplot may INSERT semiplot_tags", "REVOKE INSERT ON semiplot_tags FROM semiplot"}}, + {"UPDATE (id)", []string{"semiplot may UPDATE (id) semiplot_tags", "REVOKE UPDATE ON semiplot_tags FROM"}}, + } + for _, c := range cases { + t.Run(c.operation, func(t *testing.T) { + message := tooWideTagGrantFailure(plotWriteProbe{"semiplot_tags", c.operation, ""}).Error() + for _, named := range append(c.named, "too wide", "PUBLIC", "membership") { + if !strings.Contains(message, named) { + t.Errorf("the message does not name %q: %q", named, message) + } + } + }) + } +} + +func TestRefusalVerdict(t *testing.T) { + probe := plotWriteProbe{"semiplot_tags", "INSERT", "INSERT INTO semiplot_tags (id, name) VALUES (-1, 'x')"} + tooWide := tooWideTagGrantFailure(probe).Error() + cases := []struct { + name string + probeErr error + wantPass bool + wantTooWide bool + }{ + {"accepted", nil, false, true}, + {"refused", &pgconn.PgError{Code: "42501", Message: "permission denied"}, true, false}, + {"unique violation", &pgconn.PgError{Code: "23505", Message: "duplicate key"}, false, true}, + {"check violation", &pgconn.PgError{Code: "23514", Message: "violates check"}, false, true}, + {"statement timeout", &pgconn.PgError{Code: "57014", Message: "canceling statement"}, false, false}, + {"connection lost", errors.New("unexpected EOF"), false, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + verdict := refusalVerdict(probe, c.probeErr) + if c.wantPass { + if verdict != nil { + t.Fatalf("refusalVerdict() = %q, want nil", verdict) + } + return + } + if verdict == nil { + t.Fatal("refusalVerdict() = nil, want a failure") + } + if got := strings.Contains(verdict.Error(), tooWide); got != c.wantTooWide { + t.Errorf("names the grant too wide = %t, want %t: %q", got, c.wantTooWide, verdict) + } + if c.probeErr != nil && !errors.Is(verdict, c.probeErr) { + t.Errorf("the verdict does not wrap the probe error: %q", verdict) + } + }) + } +} + +func TestPublicRegistersPensFailure(t *testing.T) { + message := publicRegistersPensFailure().Error() + for _, named := range []string{"PUBLIC holds EXECUTE on semiplot_register_new_pens()", + "REVOKE ALL ON FUNCTION semiplot_register_new_pens() FROM PUBLIC"} { + if !strings.Contains(message, named) { + t.Errorf("the message does not name %q: %q", named, message) + } + } +} + +// has_table_privilege and has_schema_privilege both answer through role membership, so a repair +// line prescribing only the direct REVOKE sends the operator into a no-op they can repeat forever +func TestExcessPrivilegeFailure(t *testing.T) { + want := "semiplot holds INSERT on public.trends and must not write it. " + + "Repair with REVOKE INSERT ON public.trends FROM semiplot, or, if the privilege comes through " + + "PUBLIC or a role semiplot is a member of, revoke it there or drop the membership; " + + "then re-run this command" + if got := excessPrivilegeFailure("public.trends", "INSERT"); got.Error() != want { + t.Errorf("excessPrivilegeFailure() = %q, want %q", got, want) + } +} + +// check runs only after create, which has just revoked CREATE from PUBLIC, so the one repair this +// message must not prescribe is that same REVOKE +func TestCreateInPublicFailure(t *testing.T) { + message := createInPublicFailure().Error() + if strings.Contains(message, "FROM PUBLIC") { + t.Errorf("the repair prescribes the revoke create has already issued: %q", message) + } + for _, named := range []string{"REVOKE CREATE ON SCHEMA public FROM semiplot", "membership", WriterRole} { + if !strings.Contains(message, named) { + t.Errorf("the message does not name %q: %q", named, message) + } + } +} + +// the relations the tail check asserts the SemiPlot role cannot write, which is also the set no +// grant this package issues to that role may name +var archiveRelations = []string{archiveTable, scadaMessages} + +// a relation in both lists would be granted by one half of the run and refused by the other +func TestArchiveRelationsAreDisjointFromTheGrantedTables(t *testing.T) { + for _, relation := range archiveRelations { + if slices.Contains(semiplotTableNames(), relation) { + t.Errorf("%s is both asserted unwritable and granted by this package", relation) + } + } +} diff --git a/internal/provision/create.go b/internal/provision/create.go new file mode 100644 index 0000000..4f8467e --- /dev/null +++ b/internal/provision/create.go @@ -0,0 +1,272 @@ +package provision + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + + semibase "github.com/Semiteq/SemiBase" +) + +// only the database name and the password reach DDL text here; role names are constants +func grantConnectStatement(database, role string) string { + return fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s", pgx.Identifier{database}.Sanitize(), role) +} + +func createDatabaseStatement(database string) string { + return "CREATE DATABASE " + pgx.Identifier{database}.Sanitize() +} + +func createRoleStatement(name, password string) string { + return fmt.Sprintf("CREATE ROLE %s LOGIN PASSWORD '%s'", name, escapeLiteral(password)) +} + +func alterRolePasswordStatement(name, password string) string { + return fmt.Sprintf("ALTER ROLE %s PASSWORD '%s'", name, escapeLiteral(password)) +} + +func defaultPrivilegesStatement() string { + return fmt.Sprintf("ALTER DEFAULT PRIVILEGES FOR ROLE %s IN SCHEMA public GRANT SELECT ON TABLES TO %s", + WriterRole, PlotRole) +} + +func (o Options) create(ctx context.Context) error { + if err := o.Validate(); err != nil { + return err + } + step("Phase create: database %s, roles, access chain, archive table", o.Database) + + super, err := o.connect(ctx, "postgres", o.SuperUser, o.SuperPassword) + if err != nil { + return err + } + defer super.Close(ctx) + + if err := o.assertServerVersion(ctx, super); err != nil { + return err + } + + roles := []struct { + name string + password string + }{ + {WriterRole, o.WriterPassword}, + {PlotRole, o.PlotPassword}, + } + for _, role := range roles { + if err := ensureRole(ctx, super, role.name, role.password); err != nil { + return err + } + } + if err := ensureRegistrarRole(ctx, super); err != nil { + return err + } + + if err := o.ensureDatabase(ctx, super); err != nil { + return err + } + for _, role := range roles { + grant := grantConnectStatement(o.Database, role.name) + if _, err := super.Exec(ctx, grant); err != nil { + return fmt.Errorf("%s: %w", grant, err) + } + } + + plotSettings := []string{ + fmt.Sprintf("ALTER ROLE %s SET statement_timeout = '30s'", PlotRole), + fmt.Sprintf("ALTER ROLE %s SET idle_in_transaction_session_timeout = '60s'", PlotRole), + } + for _, statement := range plotSettings { + if _, err := super.Exec(ctx, statement); err != nil { + return fmt.Errorf("%s: %w", statement, err) + } + } + ok("%s timeouts set (statement 30s, idle-in-transaction 60s)", PlotRole) + + archive, err := o.connect(ctx, o.Database, o.SuperUser, o.SuperPassword) + if err != nil { + return err + } + defer archive.Close(ctx) + + if err := revokePublicCreate(ctx, archive); err != nil { + return err + } + if _, err := archive.Exec(ctx, "GRANT CREATE ON SCHEMA public TO "+WriterRole); err != nil { + return fmt.Errorf("granting CREATE on schema public: %w", err) + } + + // the writer creates public.trends below and its partitions later, so SELECT on them + // can only be granted ahead of time, through default privileges of their owner + defaultPrivileges := defaultPrivilegesStatement() + if _, err := archive.Exec(ctx, defaultPrivileges); err != nil { + return fmt.Errorf("%s: %w", defaultPrivileges, err) + } + ok("default privileges set - tables %s creates are readable by %s", WriterRole, PlotRole) + + if err := ensureArchiveTable(ctx, archive); err != nil { + return err + } + + for _, file := range semiplotSchemaFiles { + if _, err := archive.Exec(ctx, file.sql); err != nil { + return fmt.Errorf("applying %s: %w", file.name, err) + } + } + for _, statement := range append(registrarStatements(), semiplotGrantStatements()...) { + if _, err := archive.Exec(ctx, statement); err != nil { + return fmt.Errorf("%s: %w", statement, err) + } + } + ok("SemiPlot configuration tables in place: %s", strings.Join(semiplotTableNames(), ", ")) + ok("%s in place, owned by %s, executable by %s and not by PUBLIC", + registerNewPensFunction, RegistrarRole, PlotRole) + return nil +} + +// docs/architecture/provisioning.md#the-roles +func revokePublicCreate(ctx context.Context, archive *pgx.Conn) error { + if _, err := archive.Exec(ctx, "REVOKE CREATE ON SCHEMA public FROM PUBLIC"); err != nil { + return fmt.Errorf("revoking CREATE on schema public from PUBLIC: %w", err) + } + ok("PUBLIC holds no CREATE on schema public") + return nil +} + +func ensureArchiveTable(ctx context.Context, archive *pgx.Conn) error { + exists, err := relationExists(ctx, archive, archiveTable) + if err != nil { + return err + } + if exists { + return inspectArchiveTable(ctx, archive) + } + return createArchiveTable(ctx, archive) +} + +// docs/architecture/provisioning.md#the-archive-table +func createArchiveTable(ctx context.Context, archive *pgx.Conn) error { + err := runAs(ctx, archive, WriterRole, func() error { + if _, err := archive.Exec(ctx, semibase.TrendsSQL); err != nil { + return fmt.Errorf("applying trends.sql as %s: %w", WriterRole, err) + } + return nil + }) + if err != nil { + return err + } + ok("public.trends created as %s, partitioned by t, with the tpdefault partition", WriterRole) + return nil +} + +// an existing table is never altered - it may be one the SCADA has since changed - but the run +// still promises the state this tool describes, so what it promises is read back. +func inspectArchiveTable(ctx context.Context, archive *pgx.Conn) error { + ok("public.trends exists, left untouched") + + defaultPartition, err := relationExists(ctx, archive, "public.tpdefault") + if err != nil { + return err + } + if !defaultPartition { + return fmt.Errorf("public.trends exists without its default partition public.tpdefault: a row "+ + "whose timestamp no day partition covers is rejected at write time. Repair with "+ + "CREATE TABLE public.tpdefault PARTITION OF public.trends DEFAULT, issued as %s", WriterRole) + } + + var strayRows int64 + if err := archive.QueryRow(ctx, "SELECT count(*) FROM public.tpdefault").Scan(&strayRows); err != nil { + return fmt.Errorf("counting public.tpdefault rows: %w", err) + } + if strayRows > 0 { + warn("default partition tpdefault holds %d rows - a day partition was missing at write time.", strayRows) + return nil + } + ok("default partition tpdefault is present and empty") + return nil +} + +func (o Options) assertServerVersion(ctx context.Context, conn *pgx.Conn) error { + var versionNumber int + if err := conn.QueryRow(ctx, "SELECT current_setting('server_version_num')::int").Scan(&versionNumber); err != nil { + return fmt.Errorf("reading server version: %w", err) + } + major := versionNumber / 10000 + if o.ExpectedMajor > 0 && major != o.ExpectedMajor { + return fmt.Errorf("server major version is %d, expected %d", major, o.ExpectedMajor) + } + if versionNumber < versionFloor { + return fmt.Errorf("server version %d is below the floor %d (PostgreSQL 14, date_bin)", versionNumber, versionFloor) + } + ok("server version %d (major %d)", versionNumber, major) + return nil +} + +func roleExists(ctx context.Context, conn *pgx.Conn, name string) (bool, error) { + var exists bool + if err := conn.QueryRow(ctx, + "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", name).Scan(&exists); err != nil { + return false, fmt.Errorf("checking role %s: %w", name, err) + } + return exists, nil +} + +func ensureRole(ctx context.Context, conn *pgx.Conn, name, password string) error { + exists, err := roleExists(ctx, conn, name) + if err != nil { + return err + } + switch { + case !exists && password == "": + return fmt.Errorf("role %s does not exist and no password was given to create it", name) + case !exists: + if _, err := conn.Exec(ctx, createRoleStatement(name, password)); err != nil { + return fmt.Errorf("creating role %s: %w", name, err) + } + ok("role %s created", name) + case password != "": + if _, err := conn.Exec(ctx, alterRolePasswordStatement(name, password)); err != nil { + return fmt.Errorf("updating role %s password: %w", name, err) + } + ok("role %s exists, password updated", name) + default: + ok("role %s exists, credentials untouched", name) + } + return nil +} + +// docs/architecture/provisioning.md#registering-new-pens +func ensureRegistrarRole(ctx context.Context, conn *pgx.Conn) error { + exists, err := roleExists(ctx, conn, RegistrarRole) + if err != nil { + return err + } + if exists { + ok("role %s exists", RegistrarRole) + return nil + } + if _, err := conn.Exec(ctx, "CREATE ROLE "+RegistrarRole+" NOLOGIN"); err != nil { + return fmt.Errorf("creating role %s: %w", RegistrarRole, err) + } + ok("role %s created, without a login", RegistrarRole) + return nil +} + +func (o Options) ensureDatabase(ctx context.Context, conn *pgx.Conn) error { + var exists bool + if err := conn.QueryRow(ctx, + "SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1)", o.Database).Scan(&exists); err != nil { + return fmt.Errorf("checking database %s: %w", o.Database, err) + } + if exists { + ok("database %s exists", o.Database) + return nil + } + if _, err := conn.Exec(ctx, createDatabaseStatement(o.Database)); err != nil { + return fmt.Errorf("creating database %s: %w", o.Database, err) + } + ok("database %s created", o.Database) + return nil +} diff --git a/internal/provision/create_test.go b/internal/provision/create_test.go new file mode 100644 index 0000000..f53bc06 --- /dev/null +++ b/internal/provision/create_test.go @@ -0,0 +1,61 @@ +package provision + +import ( + "strings" + "testing" + + semibase "github.com/Semiteq/SemiBase" +) + +// these pin the exact DDL text: a hostile database name comes out as one +// double-quoted identifier, inert as SQL. dropping Sanitize fails them +func TestGrantConnectStatement(t *testing.T) { + hostile := `archive"; DROP DATABASE postgres; --` + want := `GRANT CONNECT ON DATABASE "archive""; DROP DATABASE postgres; --" TO scada_writer` + if got := grantConnectStatement(hostile, WriterRole); got != want { + t.Errorf("grantConnectStatement(%q) = %q, want %q", hostile, got, want) + } +} + +func TestCreateDatabaseStatement(t *testing.T) { + hostile := `archive"; DROP DATABASE postgres; --` + want := `CREATE DATABASE "archive""; DROP DATABASE postgres; --"` + if got := createDatabaseStatement(hostile); got != want { + t.Errorf("createDatabaseStatement(%q) = %q, want %q", hostile, got, want) + } +} + +func TestRoleStatementsEscapeThePassword(t *testing.T) { + password := `p'w\d` + if got, want := createRoleStatement(WriterRole, password), + `CREATE ROLE scada_writer LOGIN PASSWORD 'p''w\d'`; got != want { + t.Errorf("createRoleStatement = %q, want %q", got, want) + } + if got, want := alterRolePasswordStatement(WriterRole, password), + `ALTER ROLE scada_writer PASSWORD 'p''w\d'`; got != want { + t.Errorf("alterRolePasswordStatement = %q, want %q", got, want) + } +} + +// the archive table is the vendor's shape, transcribed by hand from a dump, and the whole +// statement text is pinned rather than a few fragments of it: a consumer that reads id, l, t, +// v and q gets wrong charts rather than an error when a type, a default or a column drifts. +func TestEmbeddedTrendsSQL(t *testing.T) { + want := "CREATE TABLE public.trends ( " + + "id integer DEFAULT 0 NOT NULL, " + + "l smallint DEFAULT 0 NOT NULL, " + + "t timestamp(3) without time zone NOT NULL, " + + "v double precision, " + + "q integer NOT NULL " + + ") PARTITION BY RANGE (t); " + + "ALTER TABLE ONLY public.trends ADD CONSTRAINT tpk PRIMARY KEY (id, l, t); " + + "CREATE TABLE public.tpdefault PARTITION OF public.trends DEFAULT;" + got := sqlStatementsOf(semibase.TrendsSQL) + if got != want { + t.Errorf("trends.sql statements =\n%q\nwant\n%q", got, want) + } + // unqualified as well as public.messages: the guard is about the object, not the spelling + if strings.Contains(got, "messages") { + t.Error("trends.sql creates messages; nothing we ship reads it") + } +} diff --git a/internal/provision/provision.go b/internal/provision/provision.go index 6bc4f6e..974d16e 100644 --- a/internal/provision/provision.go +++ b/internal/provision/provision.go @@ -10,17 +10,19 @@ import ( "strings" "github.com/jackc/pgx/v5" - - semibase "github.com/Semiteq/SemiBase" ) const ( WriterRole = "scada_writer" - ReaderRole = "semiplot_reader" + PlotRole = "semiplot" + // docs/architecture/provisioning.md#registering-new-pens + RegistrarRole = "semiplot_registrar" ) const versionFloor = 140000 +const archiveTable = "public.trends" + type Options struct { Host string Port int @@ -28,7 +30,7 @@ type Options struct { SuperUser string SuperPassword string WriterPassword string - ReaderPassword string + PlotPassword string ExpectedMajor int } @@ -74,9 +76,7 @@ func isWindowsDrivePath(host string) bool { return len(host) >= 3 && host[0] >= 'A' && host[0] <= 'Z' && host[1] == ':' && host[2] == '\\' } -// a socket directory cannot ride in the URL authority: its slashes would end the authority -// and the rest of the path would be read as the URL path. libpq and pgx take the socket -// directory from the host query parameter instead, percent-encoded like any query value +// docs/architecture/provisioning.md#the-connection-target func (o Options) connectionString(database, user, password string) string { target := url.URL{ Scheme: "postgres", @@ -99,9 +99,7 @@ func (o Options) Endpoint() string { return o.endpoint(o.Database) } -// the socket file is /.s.PGSQL., so the database cannot follow it behind a -// slash: that would name a path under the socket file and send a reader of the message -// looking for a directory that can never exist +// docs/architecture/provisioning.md#the-connection-target func (o Options) endpoint(database string) string { if isSocketHost(o.Host) { return fmt.Sprintf("%s/.s.PGSQL.%d (%s)", strings.TrimRight(o.Host, `/\`), o.Port, database) @@ -114,10 +112,13 @@ func (o Options) connect(ctx context.Context, database, user, password string) ( if err != nil { return nil, fmt.Errorf("connecting to %s as %s: %w", o.endpoint(database), user, err) } - // escapeLiteral relies on this; USERSET, so no privilege is needed - if _, err := conn.Exec(ctx, "SET standard_conforming_strings = on"); err != nil { - _ = conn.Close(ctx) - return nil, fmt.Errorf("setting standard_conforming_strings: %w", err) + // both are USERSET, so no privilege is needed. escapeLiteral relies on the first, and the + // second pins where an unqualified semiplot_* name resolves. + for _, setting := range []string{"SET standard_conforming_strings = on", "SET search_path = public"} { + if _, err := conn.Exec(ctx, setting); err != nil { + _ = conn.Close(ctx) + return nil, fmt.Errorf("%s: %w", setting, err) + } } return conn, nil } @@ -128,109 +129,6 @@ func escapeLiteral(value string) string { return strings.ReplaceAll(value, "'", "''") } -// only the database name and the password reach DDL text here; role names are -// package constants, never user input -func grantConnectStatement(database, role string) string { - return fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s", pgx.Identifier{database}.Sanitize(), role) -} - -func createDatabaseStatement(database string) string { - return "CREATE DATABASE " + pgx.Identifier{database}.Sanitize() -} - -func createRoleStatement(name, password string) string { - return fmt.Sprintf("CREATE ROLE %s LOGIN PASSWORD '%s'", name, escapeLiteral(password)) -} - -func alterRolePasswordStatement(name, password string) string { - return fmt.Sprintf("ALTER ROLE %s PASSWORD '%s'", name, escapeLiteral(password)) -} - -func defaultPrivilegesStatement() string { - return fmt.Sprintf("ALTER DEFAULT PRIVILEGES FOR ROLE %s IN SCHEMA public GRANT SELECT ON TABLES TO %s", - WriterRole, ReaderRole) -} - -func (o Options) create(ctx context.Context) error { - if err := o.Validate(); err != nil { - return err - } - step("Phase create: database %s, roles, access chain, archive table", o.Database) - - super, err := o.connect(ctx, "postgres", o.SuperUser, o.SuperPassword) - if err != nil { - return err - } - defer super.Close(ctx) - - if err := o.assertServerVersion(ctx, super); err != nil { - return err - } - - roles := []struct { - name string - password string - }{ - {WriterRole, o.WriterPassword}, - {ReaderRole, o.ReaderPassword}, - } - for _, role := range roles { - if err := ensureRole(ctx, super, role.name, role.password); err != nil { - return err - } - } - - if err := o.ensureDatabase(ctx, super); err != nil { - return err - } - for _, role := range roles { - grant := grantConnectStatement(o.Database, role.name) - if _, err := super.Exec(ctx, grant); err != nil { - return fmt.Errorf("%s: %w", grant, err) - } - } - - readerSettings := []string{ - fmt.Sprintf("ALTER ROLE %s SET statement_timeout = '30s'", ReaderRole), - fmt.Sprintf("ALTER ROLE %s SET idle_in_transaction_session_timeout = '60s'", ReaderRole), - } - for _, statement := range readerSettings { - if _, err := super.Exec(ctx, statement); err != nil { - return fmt.Errorf("%s: %w", statement, err) - } - } - ok("%s timeouts set (statement 30s, idle-in-transaction 60s)", ReaderRole) - - archive, err := o.connect(ctx, o.Database, o.SuperUser, o.SuperPassword) - if err != nil { - return err - } - defer archive.Close(ctx) - - // PostgreSQL 15+ no longer grants CREATE on schema public to everyone; on 14 this is redundant - if _, err := archive.Exec(ctx, "GRANT CREATE ON SCHEMA public TO "+WriterRole); err != nil { - return fmt.Errorf("granting CREATE on schema public: %w", err) - } - - // the writer creates public.trends below and its partitions later, so SELECT on them - // can only be granted ahead of time, through default privileges of their owner - defaultPrivileges := defaultPrivilegesStatement() - if _, err := archive.Exec(ctx, defaultPrivileges); err != nil { - return fmt.Errorf("%s: %w", defaultPrivileges, err) - } - ok("default privileges set - tables %s creates are readable by %s", WriterRole, ReaderRole) - - if _, err := archive.Exec(ctx, semibase.SemiplotTagsSQL); err != nil { - return fmt.Errorf("applying semiplot_tags.sql: %w", err) - } - if _, err := archive.Exec(ctx, "GRANT SELECT ON semiplot_tags TO "+ReaderRole); err != nil { - return fmt.Errorf("granting SELECT on semiplot_tags: %w", err) - } - ok("semiplot_tags in place, readable by %s", ReaderRole) - - return ensureArchiveTable(ctx, archive) -} - func relationExists(ctx context.Context, conn *pgx.Conn, name string) (bool, error) { var exists bool if err := conn.QueryRow(ctx, "SELECT to_regclass($1) IS NOT NULL", name).Scan(&exists); err != nil { @@ -239,9 +137,7 @@ func relationExists(ctx context.Context, conn *pgx.Conn, name string) (bool, err return exists, nil } -// runAs assumes role for the statements fn issues on conn, and drops back afterwards. The -// superuser connection is short-lived, but a leaked role would silently change what every -// later statement on it is allowed to do. +// a leaked role would silently change what every later statement on the connection is allowed to do func runAs(ctx context.Context, conn *pgx.Conn, role string, fn func() error) error { if _, err := conn.Exec(ctx, "SET ROLE "+role); err != nil { return fmt.Errorf("assuming the %s role: %w", role, err) @@ -252,253 +148,3 @@ func runAs(ctx context.Context, conn *pgx.Conn, role string, fn func() error) er } return err } - -func ensureArchiveTable(ctx context.Context, archive *pgx.Conn) error { - exists, err := relationExists(ctx, archive, "public.trends") - if err != nil { - return err - } - if exists { - return inspectArchiveTable(ctx, archive) - } - return createArchiveTable(ctx, archive) -} - -// public.trends is created as scada_writer, so the table's owner is the role the SCADA writes -// with and the reader's SELECT arrives through the default privileges set for that role a -// moment earlier. A superuser-owned table would hand the reader access for a different reason, -// and a bench would then test something production does not do. -// -// The role is assumed with SET ROLE on the superuser connection rather than through a -// scada_writer login. Both produce the same owner and the same relacl - measured, not assumed - -// but a login also has to be admitted by pg_hba.conf, and "local all all peer", the default on -// Debian, Ubuntu and RHEL, refuses it on a unix socket. -func createArchiveTable(ctx context.Context, archive *pgx.Conn) error { - err := runAs(ctx, archive, WriterRole, func() error { - if _, err := archive.Exec(ctx, semibase.TrendsSQL); err != nil { - return fmt.Errorf("applying trends.sql as %s: %w", WriterRole, err) - } - return nil - }) - if err != nil { - return err - } - ok("public.trends created as %s, partitioned by t, with the tpdefault partition", WriterRole) - return nil -} - -// an existing table is never altered - it may be one the SCADA has since changed - but the run -// still promises the state this tool describes, so what it promises is read back. -func inspectArchiveTable(ctx context.Context, archive *pgx.Conn) error { - ok("public.trends exists, left untouched") - - defaultPartition, err := relationExists(ctx, archive, "public.tpdefault") - if err != nil { - return err - } - if !defaultPartition { - return fmt.Errorf("public.trends exists without its default partition public.tpdefault: a row "+ - "whose timestamp no day partition covers is rejected at write time. Repair with "+ - "CREATE TABLE public.tpdefault PARTITION OF public.trends DEFAULT, issued as %s", WriterRole) - } - - // on the create path this is always zero; here the table can be months old, and a non-empty - // tpdefault is the only signal that a day partition was missing at write time - var strayRows int64 - if err := archive.QueryRow(ctx, "SELECT count(*) FROM public.tpdefault").Scan(&strayRows); err != nil { - return fmt.Errorf("counting public.tpdefault rows: %w", err) - } - if strayRows > 0 { - warn("default partition tpdefault holds %d rows - a day partition was missing at write time.", strayRows) - return nil - } - ok("default partition tpdefault is present and empty") - return nil -} - -func (o Options) assertServerVersion(ctx context.Context, conn *pgx.Conn) error { - var versionNumber int - if err := conn.QueryRow(ctx, "SELECT current_setting('server_version_num')::int").Scan(&versionNumber); err != nil { - return fmt.Errorf("reading server version: %w", err) - } - major := versionNumber / 10000 - if o.ExpectedMajor > 0 && major != o.ExpectedMajor { - return fmt.Errorf("server major version is %d, expected %d", major, o.ExpectedMajor) - } - if versionNumber < versionFloor { - return fmt.Errorf("server version %d is below the floor %d (PostgreSQL 14, date_bin)", versionNumber, versionFloor) - } - ok("server version %d (major %d)", versionNumber, major) - return nil -} - -func ensureRole(ctx context.Context, conn *pgx.Conn, name, password string) error { - var exists bool - if err := conn.QueryRow(ctx, - "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", name).Scan(&exists); err != nil { - return fmt.Errorf("checking role %s: %w", name, err) - } - switch { - case !exists && password == "": - return fmt.Errorf("role %s does not exist and no password was given to create it", name) - case !exists: - if _, err := conn.Exec(ctx, createRoleStatement(name, password)); err != nil { - return fmt.Errorf("creating role %s: %w", name, err) - } - ok("role %s created", name) - case password != "": - if _, err := conn.Exec(ctx, alterRolePasswordStatement(name, password)); err != nil { - return fmt.Errorf("updating role %s password: %w", name, err) - } - ok("role %s exists, password updated", name) - default: - ok("role %s exists, credentials untouched", name) - } - return nil -} - -func (o Options) ensureDatabase(ctx context.Context, conn *pgx.Conn) error { - var exists bool - if err := conn.QueryRow(ctx, - "SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1)", o.Database).Scan(&exists); err != nil { - return fmt.Errorf("checking database %s: %w", o.Database, err) - } - if exists { - ok("database %s exists", o.Database) - return nil - } - if _, err := conn.Exec(ctx, createDatabaseStatement(o.Database)); err != nil { - return fmt.Errorf("creating database %s: %w", o.Database, err) - } - ok("database %s created", o.Database) - return nil -} - -// the cheapest statement that still needs SELECT on public.trends and USAGE on the schema it -// lives in, whatever the table's size -const readerProbe = "SELECT count(*) FROM (SELECT 1 FROM public.trends LIMIT 1) probe" - -// the tail of every run. The tool created public.trends itself a moment ago, so the reader's -// access to it is knowable at exit instead of after the SCADA's first start. -func (o Options) check(ctx context.Context) error { - if err := o.Validate(); err != nil { - return err - } - step("Phase check: the reader access chain") - - archive, err := o.connect(ctx, o.Database, o.SuperUser, o.SuperPassword) - if err != nil { - return err - } - defer archive.Close(ctx) - - if err := assertReaderReads(ctx, archive); err != nil { - return err - } - if err := assertReaderCannotWrite(ctx, archive); err != nil { - return err - } - if err := o.checkReaderLogin(ctx); err != nil { - return err - } - // last, so a pending shared_buffers is the last thing the run prints - return reportPendingRestart(ctx, archive) -} - -// the load-bearing check: the read itself, issued as the reader. has_table_privilege answers -// from one catalog bit and stops there - it says yes for a reader that REVOKE USAGE ON SCHEMA -// public has locked out of the schema the table lives in, a state this tool has to fail on. -// SET ROLE rather than a login, for the pg_hba reason createArchiveTable states; the login is -// checked separately, where it can be. -func assertReaderReads(ctx context.Context, archive *pgx.Conn) error { - var probeErr error - if err := runAs(ctx, archive, ReaderRole, func() error { - var probed int - probeErr = archive.QueryRow(ctx, readerProbe).Scan(&probed) - return nil - }); err != nil { - return err - } - if probeErr != nil { - return diagnoseFailedRead(ctx, archive, probeErr) - } - ok("%s reads public.trends", ReaderRole) - return nil -} - -// the catalog bit earns its place here and only here: it separates a missing table grant, whose -// repair is the default-privileges chain, from a schema the reader cannot enter -func diagnoseFailedRead(ctx context.Context, archive *pgx.Conn, probeErr error) error { - var granted bool - if err := archive.QueryRow(ctx, - "SELECT has_table_privilege($1, 'public.trends', 'SELECT')", ReaderRole).Scan(&granted); err != nil { - return fmt.Errorf("%s cannot read public.trends (%w) and its SELECT privilege could not be read: %w", - ReaderRole, probeErr, err) - } - if !granted { - return fmt.Errorf("%s cannot read public.trends: it holds no SELECT on the table, so the table was "+ - "created before the default privileges were set (%w). Repair with GRANT SELECT ON ALL TABLES IN "+ - "SCHEMA public TO %s for the tables that exist, then %s for the partitions still to come, then "+ - "re-run this command", ReaderRole, probeErr, ReaderRole, defaultPrivilegesStatement()) - } - return fmt.Errorf("%s holds SELECT on public.trends and still cannot read it: %w. The usual cause is a "+ - "revoked schema privilege; repair with GRANT USAGE ON SCHEMA public TO %s, then re-run this command", - ReaderRole, probeErr, ReaderRole) -} - -// the reader is read-only. No probe can prove the absence of a privilege the way a read proves -// its presence, so this one stays a catalog question. -func assertReaderCannotWrite(ctx context.Context, archive *pgx.Conn) error { - var canInsert bool - if err := archive.QueryRow(ctx, - "SELECT has_table_privilege($1, 'public.trends', 'INSERT')", ReaderRole).Scan(&canInsert); err != nil { - return fmt.Errorf("checking reader INSERT privilege: %w", err) - } - if canInsert { - return fmt.Errorf("%s holds INSERT on public.trends - the reader must be read-only", ReaderRole) - } - ok("%s does not hold INSERT", ReaderRole) - return nil -} - -// what SET ROLE cannot reach: pg_hba admitting the reader, and the password a consumer will -// carry. It needs that password, and it is skipped on a socket host - "local all all peer" is -// the platform default there, no consumer reads over the socket, and the socket path exists so -// this tool can run as an init script inside a postgres image. -func (o Options) checkReaderLogin(ctx context.Context) error { - switch { - case o.ReaderPassword == "": - note("no %s password given - the login was not tested, only the privileges behind it.", ReaderRole) - return nil - case isSocketHost(o.Host): - note("socket host - the %s login was not tested; a consumer connects over TCP.", ReaderRole) - return nil - } - - reader, err := o.connect(ctx, o.Database, ReaderRole, o.ReaderPassword) - if err != nil { - return err - } - defer reader.Close(ctx) - - var probed int - if err := reader.QueryRow(ctx, readerProbe).Scan(&probed); err != nil { - return fmt.Errorf("%s reads public.trends over its own login: %w", ReaderRole, err) - } - ok("%s logs in and reads public.trends", ReaderRole) - return nil -} - -// both commands report it: bench tunes nothing, but it can run against a server a previous -// site run tuned and nobody restarted -func reportPendingRestart(ctx context.Context, conn *pgx.Conn) error { - pending, err := pendingRestartSettings(ctx, conn) - if err != nil { - return err - } - if len(pending) > 0 { - warn("settings waiting for a service restart: %s - restart the PostgreSQL service or reboot the machine.", - strings.Join(pending, ", ")) - } - return nil -} diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go index c1d29bf..93e8180 100644 --- a/internal/provision/provision_test.go +++ b/internal/provision/provision_test.go @@ -1,13 +1,10 @@ package provision import ( - "strings" "testing" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" - - semibase "github.com/Semiteq/SemiBase" ) func TestValidate(t *testing.T) { @@ -38,36 +35,6 @@ func TestValidate(t *testing.T) { } } -// these pin the exact DDL text: a hostile database name comes out as one -// double-quoted identifier, inert as SQL. dropping Sanitize fails them -func TestGrantConnectStatement(t *testing.T) { - hostile := `archive"; DROP DATABASE postgres; --` - want := `GRANT CONNECT ON DATABASE "archive""; DROP DATABASE postgres; --" TO scada_writer` - if got := grantConnectStatement(hostile, WriterRole); got != want { - t.Errorf("grantConnectStatement(%q) = %q, want %q", hostile, got, want) - } -} - -func TestCreateDatabaseStatement(t *testing.T) { - hostile := `archive"; DROP DATABASE postgres; --` - want := `CREATE DATABASE "archive""; DROP DATABASE postgres; --"` - if got := createDatabaseStatement(hostile); got != want { - t.Errorf("createDatabaseStatement(%q) = %q, want %q", hostile, got, want) - } -} - -func TestRoleStatementsEscapeThePassword(t *testing.T) { - password := `p'w\d` - if got, want := createRoleStatement(WriterRole, password), - `CREATE ROLE scada_writer LOGIN PASSWORD 'p''w\d'`; got != want { - t.Errorf("createRoleStatement = %q, want %q", got, want) - } - if got, want := alterRolePasswordStatement(WriterRole, password), - `ALTER ROLE scada_writer PASSWORD 'p''w\d'`; got != want { - t.Errorf("alterRolePasswordStatement = %q, want %q", got, want) - } -} - // the backslash cases pin the standard_conforming_strings contract func TestEscapeLiteral(t *testing.T) { tests := []struct { @@ -246,41 +213,3 @@ func TestEndpointUsesTheConfiguredDatabase(t *testing.T) { t.Errorf("Endpoint() = %q, want %q", got, want) } } - -// the file's header prose names the objects the DDL deliberately leaves alone, so a claim -// about what the DDL creates has to be made against the statements only -func sqlStatementsOf(sql string) string { - kept := make([]string, 0, strings.Count(sql, "\n")) - for line := range strings.SplitSeq(sql, "\n") { - if strings.HasPrefix(strings.TrimSpace(line), "--") { - continue - } - kept = append(kept, line) - } - return strings.Join(strings.Fields(strings.Join(kept, " ")), " ") -} - -// the archive table is the vendor's shape, transcribed by hand from a dump, and the whole -// statement text is pinned rather than a few fragments of it: a consumer that reads id, l, t, -// v and q gets wrong charts rather than an error when a type, a default or a column drifts. -// Reformatting the file is meant to fail this - re-read sql/semiplot_dev.sql in the consumer -// repository before changing the golden text. -func TestEmbeddedTrendsSQL(t *testing.T) { - want := "CREATE TABLE public.trends ( " + - "id integer DEFAULT 0 NOT NULL, " + - "l smallint DEFAULT 0 NOT NULL, " + - "t timestamp(3) without time zone NOT NULL, " + - "v double precision, " + - "q integer NOT NULL " + - ") PARTITION BY RANGE (t); " + - "ALTER TABLE ONLY public.trends ADD CONSTRAINT tpk PRIMARY KEY (id, l, t); " + - "CREATE TABLE public.tpdefault PARTITION OF public.trends DEFAULT;" - got := sqlStatementsOf(semibase.TrendsSQL) - if got != want { - t.Errorf("trends.sql statements =\n%q\nwant\n%q", got, want) - } - // unqualified as well as public.messages: the guard is about the object, not the spelling - if strings.Contains(got, "messages") { - t.Error("trends.sql creates messages; nothing we ship reads it") - } -} diff --git a/internal/provision/schema.go b/internal/provision/schema.go new file mode 100644 index 0000000..4dc957d --- /dev/null +++ b/internal/provision/schema.go @@ -0,0 +1,90 @@ +package provision + +import ( + "fmt" + "strings" + + semibase "github.com/Semiteq/SemiBase" +) + +// applied in this order, after public.trends exists: the function body is checked against the pen +// table and trends when it is created, and a membership row references a pen +var semiplotSchemaFiles = []struct { + name string + sql string +}{ + {"semiplot_tags.sql", semibase.SemiplotTagsSQL}, + {"semiplot_register.sql", semibase.SemiplotRegisterSQL}, + {"semiplot_groups.sql", semibase.SemiplotGroupsSQL}, + {"semiplot_meta.sql", semibase.SemiplotMetaSQL}, +} + +var plotEditableTagColumns = []string{ + "name", + "unit", + "format", + "color", + "line_style", + "enabled_on_start", + "scale_min", + "scale_max", +} + +const ( + plotReadGrants = "SELECT" + plotWriteGrants = "SELECT, INSERT, UPDATE, DELETE" +) + +var plotTagsGrants = "SELECT, UPDATE (" + strings.Join(plotEditableTagColumns, ", ") + ")" + +// docs/architecture/provisioning.md#the-semiplot-configuration-schema +var semiplotTables = []struct { + name string + grants string +}{ + {"semiplot_tags", plotTagsGrants}, + {"semiplot_groups", plotWriteGrants}, + {"semiplot_pen_groups", plotWriteGrants}, + {"semiplot_meta", plotReadGrants}, +} + +func semiplotTableNames() []string { + names := make([]string, 0, len(semiplotTables)) + for _, table := range semiplotTables { + names = append(names, table.name) + } + return names +} + +func plotGrantsOn(table string) string { + for _, candidate := range semiplotTables { + if candidate.name == table { + return candidate.grants + } + } + return "" +} + +const registerNewPensFunction = "semiplot_register_new_pens()" + +// a new function is executable by PUBLIC +func semiplotGrantStatements() []string { + statements := make([]string, 0, len(semiplotTables)+1) + for _, table := range semiplotTables { + statements = append(statements, fmt.Sprintf("GRANT %s ON %s TO %s", table.grants, table.name, PlotRole)) + } + return append(statements, fmt.Sprintf("REVOKE ALL ON FUNCTION %s FROM PUBLIC; GRANT EXECUTE ON FUNCTION %s TO %s", + registerNewPensFunction, registerNewPensFunction, PlotRole)) +} + +var registrarInsertColumns = []string{"id", "name", "color", "enabled_on_start"} + +// ON CONFLICT (id) needs SELECT on id to infer the primary key +func registrarStatements() []string { + return []string{ + fmt.Sprintf("GRANT SELECT ON %s TO %s", archiveTable, RegistrarRole), + fmt.Sprintf("GRANT SELECT (id), INSERT (%s) ON semiplot_tags TO %s", + strings.Join(registrarInsertColumns, ", "), RegistrarRole), + fmt.Sprintf("ALTER FUNCTION %s OWNER TO %s", registerNewPensFunction, RegistrarRole), + } +} diff --git a/internal/provision/schema_test.go b/internal/provision/schema_test.go new file mode 100644 index 0000000..ea11569 --- /dev/null +++ b/internal/provision/schema_test.go @@ -0,0 +1,246 @@ +package provision + +import ( + "slices" + "strings" + "testing" + + semibase "github.com/Semiteq/SemiBase" +) + +func sqlStatementsOf(sql string) string { + kept := make([]string, 0, strings.Count(sql, "\n")) + for line := range strings.SplitSeq(sql, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "--") { + continue + } + kept = append(kept, line) + } + return strings.Join(strings.Fields(strings.Join(kept, " ")), " ") +} + +// columnNamesOf returns the column names of a CREATE TABLE body in declaration order, +// leaving out the table constraints. +func columnNamesOf(sql string) []string { + body := sqlStatementsOf(sql) + open := strings.Index(body, "(") + if open < 0 { + return nil + } + body = body[open+1:] + names := make([]string, 0, strings.Count(body, ",")+1) + depth, start := 0, 0 + for i, character := range body { + switch character { + case '(': + depth++ + case ')': + if depth == 0 { + return appendColumnName(names, body[start:i]) + } + depth-- + case ',': + if depth == 0 { + names = appendColumnName(names, body[start:i]) + start = i + 1 + } + } + } + return names +} + +func appendColumnName(names []string, definition string) []string { + fields := strings.Fields(definition) + if len(fields) == 0 || strings.EqualFold(fields[0], "CONSTRAINT") { + return names + } + return append(names, fields[0]) +} + +// the viewer names these columns in its own SELECT, so the name set is the contract; the order is +// pinned too, to make an inserted or dropped column fail here rather than pass unnoticed. neither +// this package nor a test in SemiPlot sees both halves, so the want list is spelled out here +func TestSemiplotTagsColumns(t *testing.T) { + want := []string{ + "id", + "name", + "unit", + "format", + "color", + "line_style", + "enabled_on_start", + "scale_min", + "scale_max", + } + got := columnNamesOf(semibase.SemiplotTagsSQL) + if len(got) != len(want) { + t.Fatalf("semiplot_tags.sql declares %d columns %v, want %d %v", len(got), got, len(want), want) + } + for i, name := range want { + t.Run(name, func(t *testing.T) { + if got[i] != name { + t.Errorf("column %d is %q, want %q", i, got[i], name) + } + }) + } +} + +// serial would make the group id a sequence the semiplot role needs USAGE on to insert a group, +// which no grant this package issues carries +func TestSemiplotGroupsUseAnIdentityColumn(t *testing.T) { + if strings.Contains(sqlStatementsOf(semibase.SemiplotGroupsSQL), "serial") { + t.Error("semiplot_groups.sql uses serial; the identity column needs no sequence grant") + } +} + +func TestMembershipForeignKeysCascade(t *testing.T) { + statements := sqlStatementsOf(semibase.SemiplotGroupsSQL) + for _, reference := range []string{ + "REFERENCES semiplot_tags (id) ON DELETE CASCADE", + "REFERENCES semiplot_groups (id) ON DELETE CASCADE", + } { + t.Run(reference, func(t *testing.T) { + if !strings.Contains(statements, reference) { + t.Errorf("semiplot_groups.sql does not declare %s", reference) + } + }) + } + if keys, cascades := strings.Count(statements, "REFERENCES "), + strings.Count(statements, "ON DELETE CASCADE"); keys != cascades { + t.Errorf("%d foreign keys against %d ON DELETE CASCADE clauses", keys, cascades) + } +} + +func TestSemiplotSchemaFileOrder(t *testing.T) { + want := []string{"semiplot_tags.sql", "semiplot_register.sql", "semiplot_groups.sql", "semiplot_meta.sql"} + if len(semiplotSchemaFiles) != len(want) { + t.Fatalf("semiplotSchemaFiles holds %d files, want %d", len(semiplotSchemaFiles), len(want)) + } + for i, file := range semiplotSchemaFiles { + if file.name != want[i] { + t.Errorf("semiplotSchemaFiles[%d] = %q, want %q", i, file.name, want[i]) + } + } +} + +// semiplotTables is what the run reports and what the grants will name, so it has to +// stay the set the embedded files actually create +func TestSemiplotTablesMatchTheEmbeddedFiles(t *testing.T) { + var builder strings.Builder + for _, file := range semiplotSchemaFiles { + builder.WriteString(sqlStatementsOf(file.sql)) + builder.WriteString(" ") + } + created := builder.String() + for _, table := range semiplotTables { + t.Run(table.name, func(t *testing.T) { + if !strings.Contains(created, "CREATE TABLE IF NOT EXISTS "+table.name+" (") { + t.Errorf("no embedded file creates %s", table.name) + } + }) + } + if got, want := strings.Count(created, "CREATE TABLE"), len(semiplotTables); got != want { + t.Errorf("the embedded files create %d tables, semiplotTables names %d", got, want) + } +} + +func TestSemiplotGrantStatements(t *testing.T) { + want := []struct { + object string + statement string + }{ + {"semiplot_tags", "GRANT SELECT, UPDATE (name, unit, format, color, line_style, enabled_on_start, " + + "scale_min, scale_max) ON semiplot_tags TO semiplot"}, + {"semiplot_groups", "GRANT SELECT, INSERT, UPDATE, DELETE ON semiplot_groups TO semiplot"}, + {"semiplot_pen_groups", "GRANT SELECT, INSERT, UPDATE, DELETE ON semiplot_pen_groups TO semiplot"}, + {"semiplot_meta", "GRANT SELECT ON semiplot_meta TO semiplot"}, + {"semiplot_register_new_pens", "REVOKE ALL ON FUNCTION semiplot_register_new_pens() FROM PUBLIC; " + + "GRANT EXECUTE ON FUNCTION semiplot_register_new_pens() TO semiplot"}, + } + got := semiplotGrantStatements() + if len(got) != len(want) { + t.Fatalf("semiplotGrantStatements() returned %d statements, want %d", len(got), len(want)) + } + for i, expected := range want { + t.Run(expected.object, func(t *testing.T) { + if got[i] != expected.statement { + t.Errorf("semiplotGrantStatements()[%d] = %q, want %q", i, got[i], expected.statement) + } + }) + } +} + +// docs/architecture/provisioning.md#registering-new-pens +func TestRegisterFunctionIgnoresTheCallersSchemas(t *testing.T) { + statements := sqlStatementsOf(semibase.SemiplotRegisterSQL) + for _, clause := range []string{ + "CREATE OR REPLACE FUNCTION " + registerNewPensFunction + " RETURNS integer", + "SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS", + } { + if !strings.Contains(statements, clause) { + t.Errorf("semiplot_register.sql does not declare %q", clause) + } + } + for _, relation := range []string{"trends", "semiplot_tags"} { + named, qualified := strings.Count(statements, relation), strings.Count(statements, "public."+relation) + if named == 0 || named != qualified { + t.Errorf("semiplot_register.sql names %s %d times, %d of them as public.%s", relation, named, + qualified, relation) + } + } +} + +func TestRegistrarStatements(t *testing.T) { + want := []string{ + "GRANT SELECT ON public.trends TO semiplot_registrar", + "GRANT SELECT (id), INSERT (id, name, color, enabled_on_start) ON semiplot_tags TO semiplot_registrar", + "ALTER FUNCTION semiplot_register_new_pens() OWNER TO semiplot_registrar", + } + if got := registrarStatements(); !slices.Equal(got, want) { + t.Errorf("registrarStatements() = %q, want %q", got, want) + } +} + +func TestRegistrarInsertGrantMatchesTheFunctionBody(t *testing.T) { + const insert = "INSERT INTO public.semiplot_tags (" + _, columns, found := strings.Cut(sqlStatementsOf(semibase.SemiplotRegisterSQL), insert) + if !found { + t.Fatalf("semiplot_register.sql has no %q", insert) + } + columns, _, _ = strings.Cut(columns, ")") + if got := strings.Split(columns, ", "); !slices.Equal(got, registrarInsertColumns) { + t.Errorf("the function body inserts %v, the registrar's INSERT grant covers %v", got, + registrarInsertColumns) + } +} + +// a column added to semiplot_tags later fails here instead of arriving silently uneditable +func TestTagsUpdateGrantCoversEverySettingsColumn(t *testing.T) { + var settings []string + for _, column := range columnNamesOf(semibase.SemiplotTagsSQL) { + if column != "id" { + settings = append(settings, column) + } + } + if !slices.Equal(plotEditableTagColumns, settings) { + t.Errorf("the semiplot_tags UPDATE grant covers %v, the table's settings columns are %v", + plotEditableTagColumns, settings) + } +} + +// the invariant the one-role decision does not weaken: nothing this package grants reaches the +// archive, and no semiplot_* table reaches the writer role. +func TestSemiplotGrantsLeaveTheArchiveAlone(t *testing.T) { + forbidden := append([]string{WriterRole, "CREATE"}, archiveRelations...) + for _, relation := range archiveRelations { + _, bare, _ := strings.Cut(relation, ".") + forbidden = append(forbidden, bare) + } + for _, statement := range semiplotGrantStatements() { + for _, word := range forbidden { + if strings.Contains(statement, word) { + t.Errorf("%q names %s; the SemiPlot grants cover semiplot_* tables only", statement, word) + } + } + } +} diff --git a/sql/semiplot_groups.sql b/sql/semiplot_groups.sql new file mode 100644 index 0000000..070a6d8 --- /dev/null +++ b/sql/semiplot_groups.sql @@ -0,0 +1,12 @@ +-- docs/architecture/provisioning.md#the-semiplot-configuration-schema + +CREATE TABLE IF NOT EXISTS semiplot_groups ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS semiplot_pen_groups ( + pen_id integer NOT NULL REFERENCES semiplot_tags (id) ON DELETE CASCADE, + group_id integer NOT NULL REFERENCES semiplot_groups (id) ON DELETE CASCADE, + PRIMARY KEY (pen_id, group_id) +); diff --git a/sql/semiplot_meta.sql b/sql/semiplot_meta.sql new file mode 100644 index 0000000..f01f3af --- /dev/null +++ b/sql/semiplot_meta.sql @@ -0,0 +1,11 @@ +-- docs/architecture/provisioning.md#the-schema-version-is-a-floor + +CREATE TABLE IF NOT EXISTS semiplot_meta ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + schema_version integer NOT NULL +); + +INSERT INTO semiplot_meta (singleton, schema_version) +VALUES (true, 1) +ON CONFLICT (singleton) DO UPDATE SET schema_version = EXCLUDED.schema_version +WHERE semiplot_meta.schema_version < EXCLUDED.schema_version; diff --git a/sql/semiplot_register.sql b/sql/semiplot_register.sql new file mode 100644 index 0000000..aec5a44 --- /dev/null +++ b/sql/semiplot_register.sql @@ -0,0 +1,21 @@ +-- docs/architecture/provisioning.md#registering-new-pens + +CREATE OR REPLACE FUNCTION semiplot_register_new_pens() RETURNS integer +LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$ + WITH RECURSIVE keys AS ( + (SELECT id FROM public.trends ORDER BY id LIMIT 1) + UNION ALL + SELECT (SELECT t.id FROM public.trends t WHERE t.id > keys.id ORDER BY t.id LIMIT 1) + FROM keys WHERE keys.id IS NOT NULL + ), added AS ( + INSERT INTO public.semiplot_tags (id, name, color, enabled_on_start) + SELECT id, id::text, + (ARRAY['#4E79A7','#F28E2B','#E15759','#76B7B2','#59A14F','#EDC948', + '#B07AA1','#FF9DA7','#9C755F','#17BECF','#D62728','#9467BD'])[id % 12 + 1], + false + FROM keys WHERE id IS NOT NULL + ON CONFLICT (id) DO NOTHING + RETURNING 1 + ) + SELECT count(*)::integer FROM added; +$$; diff --git a/sql/semiplot_tags.sql b/sql/semiplot_tags.sql index bb48695..7df8c32 100644 --- a/sql/semiplot_tags.sql +++ b/sql/semiplot_tags.sql @@ -1,13 +1,17 @@ --- The one object SemiBase adds to the archive database: the mapping from the --- archive's variable number to a human-readable pen definition. The archive --- itself has no such mapping. Filled during commissioning; optionally seeded --- from the vendor's variables_data table when the operator has created it. +-- docs/architecture/provisioning.md#the-semiplot-configuration-schema CREATE TABLE IF NOT EXISTS semiplot_tags ( - id integer PRIMARY KEY, -- matches trends.id - name text NOT NULL, - group_name text, - unit text, - color text, - line_style smallint NOT NULL DEFAULT 0 + id integer PRIMARY KEY, + name text NOT NULL, + unit text, + format text, + color text, + line_style smallint NOT NULL DEFAULT 0, + enabled_on_start boolean NOT NULL DEFAULT true, + scale_min double precision, + scale_max double precision, + CONSTRAINT semiplot_tags_scale_paired CHECK ( + (scale_min IS NULL) = (scale_max IS NULL) + AND (scale_min IS NULL OR scale_min < scale_max)), + CONSTRAINT semiplot_tags_color_hex CHECK (color IS NULL OR color ~ '^#[0-9A-Fa-f]{6}$') );