diff --git a/CHANGELOG.md b/CHANGELOG.md index 40fd82b0..1596e48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ below corresponds to one such version. cleared at authorize, and the query/activity logs (`query_executions` / `tool_calls`) are explicitly **retained** — never deleted by any default path — with a new `idx_query_executions_ts` index to keep newest-first reads fast as history grows. +- **Read-only datasource role is now a stated deploy obligation, not a suggestion (ACE-036).** The + self-host guide and `readonly-grants.md` now frame the SELECT-only role as the **required** + `DATASOURCE_URL` posture for a deploy (single-player stays *recommended*), spell out the app-role vs + operator/owner-role split, and clarify that the role guarantees integrity/confinement but **not** + availability/recon (per-statement timeout + row cap + recon denial are app-side). Docs only — no + behaviour or config change. ## [0.3.9] — 2026-07-10 diff --git a/deploy/README.md b/deploy/README.md index a7600c82..7eb09596 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,7 +15,7 @@ exposed ports. ## Deploy (the secure VM bundle — the default) ```bash git clone && cd /deploy -cp agami.env.example agami.env # then edit: PUBLIC_BASE_URL (your hostname), admin email + password, DATASOURCE_URL (use a read-only DB user — see Security notes) +cp agami.env.example agami.env # then edit: PUBLIC_BASE_URL (your hostname), admin email + password, DATASOURCE_URL (point at a read-only DB role — required; see Security notes) ln -s /path/to/your/agami-artifacts ./artifacts # or set AGAMI_ARTIFACTS_DIR in agami.env ./deploy.sh # validates agami.env, generates the signing secret, builds + starts ``` @@ -45,9 +45,14 @@ re-ingests the model on boot. **No rebuild, no new VM, no database access** — gated by per-user OAuth. Both need the HTTPS that Caddy provides. - `agami.env` holds the signing secret and DB creds — it stays on your host and is never committed. **No data ever leaves your environment.** -- **Use a read-only warehouse user** in `DATASOURCE_URL` — agami only runs read-only SELECTs and never needs - write access, so a read-only user is the safest thing to connect. Copy-paste `GRANT SELECT` SQL per database - is in [../plugins/agami/shared/readonly-grants.md](../plugins/agami/shared/readonly-grants.md). +- **Point `DATASOURCE_URL` at a read-only database role — this is the required posture for a deploy, not + just a nicety.** agami only ever runs read-only SELECTs against your datasource, and the read-only role is + the primary, non-bypassable control: a login that can only `SELECT` can't write, run DDL, `COPY`, reach + files/network, or touch another database even if the app-layer guard were bypassed. Copy-paste + `CREATE ROLE` / `GRANT SELECT` SQL per engine — and the app-role vs operator-role split — is in + [../plugins/agami/shared/readonly-grants.md](../plugins/agami/shared/readonly-grants.md). This is your + **datasource** role; agami's own store (`APP_DATABASE_URL`) is a *separate* database and keeps its normal + **read-write** user. - **Always deploy via `./deploy.sh`** (it runs the preflight). If you `docker compose up` directly without having run the preflight, `AGAMI_PUBLIC_HOST` is unset and Caddy fails to start (loudly, never insecurely) — run `python -m deploy_preflight agami.env` first. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 6d50353e..88ccbe4b 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -42,7 +42,8 @@ and the other tools just read the model. Nothing leaves your environment. | Variable | Required | Purpose | |----------|----------|---------| -| `AGAMI_DB_URL` | yes (server) | The store: `postgresql://…` in prod, `sqlite://…` for a small/local run. `APP_DATABASE_URL` is accepted as an alias for the cloud-platform convention (`AGAMI_DB_URL` wins if both are set). Unset ⇒ the local file path. | +| `AGAMI_DB_URL` | yes (server) | The **store** (agami's own metadata DB — the server migrates + writes it on boot): `postgresql://…` in prod, `sqlite://…` for a small/local run. `APP_DATABASE_URL` is accepted as an alias for the cloud-platform convention (`AGAMI_DB_URL` wins if both are set). Unset ⇒ the local file path. This is **not** your datasource — keep its user **read-write**. | +| `DATASOURCE_URL` | to query (or file creds) | The **datasource** agami runs generated SQL against, as a DSN. The env-DSN channel accepts the `postgres` / `postgresql`, `redshift`, `mysql` / `mariadb`, `snowflake`, `bigquery` / `bq`, and `sqlite` schemes; engines without a DSN scheme (SQL Server, Oracle, Databricks, Trino, DuckDB) connect via the file `credentials` channel instead. Whichever channel you use, it **must point at a read-only (SELECT-only) role** — that role is the primary, non-bypassable safety boundary; see [read-only grants](../plugins/agami/shared/readonly-grants.md) for the per-engine recipe and the app-role vs operator-role split. A **different** database from the store above; never point it at owner/admin creds. Several datasources ⇒ one `DATASOURCE_URL__` per datasource (token = the datasource id upper-cased, non-alphanumerics → `_`). | | `PUBLIC_BASE_URL` | yes (server) | Backs OAuth/MCP discovery + the `WWW-Authenticate` resource URL. Set it explicitly — it can't be auto-detected behind a proxy/LB; the server fails fast at startup if it's missing. | | `AGAMI_ORG_ID` | no | The single configured org id (default `local`). The server is single-tenant by default. | | `AGAMI_SIGNING_SECRET` | yes (auth) | ≥32-byte secret the server signs its session JWTs with — this is what turns on **real per-user login** (admin password + per-user `/mcp` OAuth). **Required for any internet-facing deploy;** the `/agami-deploy` bundle generates and persists it for you. Unset ⇒ a local bearer-presence default only (single-user, not per-user). | diff --git a/plugins/agami/shared/credentials-format.md b/plugins/agami/shared/credentials-format.md index c457cec9..983e0426 100644 --- a/plugins/agami/shared/credentials-format.md +++ b/plugins/agami/shared/credentials-format.md @@ -24,6 +24,8 @@ Resolution order when a skill needs to know which profile to use: agami only ever runs read-only SELECT queries, so the `user` you put here only needs read access. Connecting a **read-only database user** is the safest posture (especially against production) and never limits what agami can do. Copy-paste `CREATE USER` / `GRANT SELECT` SQL for every dialect is in [`readonly-grants.md`](readonly-grants.md). +For the **single-player** flow (this file) a read-only user is *recommended*. For a **hosted / self-host deploy** — where the datasource DSN travels in `DATASOURCE_URL`, not this file — a read-only role is the **required** posture, not just a nicety; the same [`readonly-grants.md`](readonly-grants.md) recipe covers both, and explains the app-role vs operator-role split. + ## Format ```ini diff --git a/plugins/agami/shared/readonly-grants.md b/plugins/agami/shared/readonly-grants.md index 1455bc7c..e6c517b3 100644 --- a/plugins/agami/shared/readonly-grants.md +++ b/plugins/agami/shared/readonly-grants.md @@ -1,8 +1,21 @@ # Read-only database user — copy-paste grants -agami only ever runs **read-only SELECT** queries (query generation refuses `INSERT` / `UPDATE` / `DELETE` / DDL, and the local MCP server double-checks every statement). So the safest thing to connect it to is a **read-only user** that can read your data and nothing else. It's optional — read-write credentials work too — but recommended, especially against a production database. +agami only ever runs **read-only SELECT** queries against your datasource: query generation refuses `INSERT` / `UPDATE` / `DELETE` / DDL, and the server's app-layer guard double-checks every statement. But that guard is defense-in-depth on top of the real boundary — **the database login agami connects as.** A role that can only `SELECT` is *physically* unable to write, run DDL, `COPY` to/from files, call server-side network functions, or reach another database, so a bug in the app guard cannot grant what the role does not have. That makes the read-only role the **primary, non-bypassable** safety control. -Create the user with one of the blocks below, then put **its** credentials in your profile: the `user` / `password` (or `url = …`) in `/local/credentials` for the single-player flow, or the `DATASOURCE_URL` in `agami.env` for a self-host deploy. +## Which posture applies to you + +- **Single-player (local `credentials`).** A read-only user is **strongly recommended** — especially against a production database — but read-write credentials also work; agami never uses the write. +- **Hosted / self-host deploy (`DATASOURCE_URL`).** A read-only role is a **requirement**, not a suggestion. The deployed server points `DATASOURCE_URL` at a **SELECT-only role**, and **any writes stay on a separate, privileged identity.** agami never migrates or loads your datasource — your own owner / ETL / migration role does that, and it must not be the login you hand agami. **Never point `DATASOURCE_URL` at owner or admin credentials.** + +> **agami's own store is a *different* database.** The read-only obligation here is about your **datasource** (`DATASOURCE_URL` — the data agami queries). agami's metadata store (`APP_DATABASE_URL` / `AGAMI_DB_URL`), which the server migrates on boot, is a **separate** database and needs its normal **read-write** user — do **not** make that one read-only. + +## What the role does and does not guarantee + +The read-only role is the **primary, non-bypassable** guarantee of **integrity and confinement**: SELECT-only means **no write / DDL / `COPY` / file access / server-side network call / cross-database reach** — and that holds even if the app-layer guard were bypassed. It does **not** bound a **runaway** query (a recursive CTE or cartesian join), and it does **not** stop schema/metadata **recon**. Those are the **app layer's** job, not the role's: the executor caps the **result-row count**, and query-resource limits (statement timeout) plus recon/error-text hardening are enforced above the grant, not by it. So don't read "read-only" as "time-bounded" — the role confines *what* a query can touch; bounding *how much* it consumes is handled app-side. + +## Creating the role + +Create the user/role with one of the blocks below, then put **its** credentials where your setup reads them: the `user` / `password` (or `url = …`) in `/local/credentials` for the single-player flow, or `DATASOURCE_URL` in `agami.env` for a deploy. Replace the `<…>` placeholders — ``, ``, ``, ``, ``, ``, ``, and the `agami_ro` user/role name — with your values (each block uses only some of them). For **multiple schemas**, repeat the `USAGE` + `SELECT` grants once per schema. @@ -19,6 +32,16 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA GRANT SELECT ON TABLES TO agami_ro; (Redshift uses the same statements. `` defaults to `public` if you didn't set one.) +> **Optional hardening (PostgreSQL ≤ 14 / Redshift).** A fresh role inherits `CREATE` on schema `public` +> and `TEMP` on the database from the built-in `PUBLIC` role — so, strictly, it could create *new* objects +> (it still can't read or modify your existing data). To make it pure SELECT-only, once per database: +> ```sql +> REVOKE CREATE ON SCHEMA public FROM PUBLIC; +> REVOKE TEMP ON DATABASE FROM PUBLIC; +> ``` +> Note these affect **every** non-owner role on that database, not just `agami_ro`. PostgreSQL 15+ already +> drops the public-schema `CREATE` default. Optional, not required (per the SELECT-only baseline). + ## MySQL / MariaDB ```sql @@ -109,6 +132,8 @@ bq add-iam-policy-binding --member="serviceAccount:agami-ro@.iam.gservi --role="roles/bigquery.dataViewer" : ``` +Here the two IAM roles above **are** the whole guarantee — BigQuery has no SQL-level role to scope further and no role-level statement timeout. Confinement comes from `dataViewer` (read-only); runaway bounding is app-side (and you can add a BigQuery custom quota / maximum-bytes-billed as extra defense). + ## SQLite / DuckDB -File-based — there's no user or role. Safety comes from agami's **read-only SQL guard** (it refuses anything that isn't a `SELECT`) plus **filesystem permissions**. DuckDB files are additionally opened in read-only mode; SQLite is not, so for a hard guarantee point agami at a **read-only copy** of the file, or mark the file read-only for the account agami runs as. +File-based — there's no user or role, so the **read-only file / read-only open is the whole guarantee** at this layer. Safety comes from agami's **read-only SQL guard** (it refuses anything that isn't a `SELECT`) plus **filesystem permissions**. DuckDB files are additionally opened in read-only mode; SQLite is not, so for a hard guarantee point agami at a **read-only copy** of the file, or mark the file read-only for the account agami runs as. (Runaway-query bounding is still app-side, as above — the file mode only stops writes.)