# Security Hardening

> **Status:** v0.1 — first pass, finalized 2026-05-15
> (review-pass verified all file:line citations against the
> live source). Last updated 2026-05-15.
> A federation-sibling-scoped security audit: walked the two
> source files (`_engine.py`, `_session.py`) against the
> **SC-*** rules in
> `mgf-standard/docs/standards/SECURITY_STANDARD.md`. The
> `tenant_session` helper is the load-bearing security
> boundary — Postgres RLS tenant-scoping for every
> multi-tenant consumer.
>
> **Scope:** mgf-sqlalchemy's OWN security posture. NOT a
> re-audit of mgf-common or sqlalchemy / asyncpg / psycopg.
>
> **Audience:** the maintainer + future AI agents.
> **Cross-references:**
> [`RELIABILITY_AND_ACCURACY.md`](RELIABILITY_AND_ACCURACY.md),
> [`mgf-standard/docs/standards/SECURITY_STANDARD.md`](https://codeberg.org/magogi-admin/mgf_common/src/branch/main/docs/standards/SECURITY_STANDARD.md),
> [`mgf-common/docs/design/multi_tenancy.md`](https://codeberg.org/magogi-admin/mgf_common/src/branch/main/docs/design/multi_tenancy.md)
> (the design paper that motivates `tenant_session`),
> repo-root [`SECURITY.md`](../../SECURITY.md).

The sibling's surface is small (3 public names) but
`tenant_session` IS the SQL-level isolation primitive every
multi-tenant consumer relies on. A correctness regression
here is a cross-tenant data leak.

---

## §1 You are here

mgf-sqlalchemy ships three public names; UUID validation
runs BEFORE any SQL is emitted in `tenant_session`; `SET
LOCAL` scopes the GUC to the transaction; SQLite gets a
different pool config; secret material in connection
strings flows through SQLAlchemy's URL handling (not
ours).

What this tracker exposes is the **tenant-isolation
boundary** + **connection-string secret handling** — how
data flows through `tenant_session` correctly, how
database credentials reach the engine without leaking to
logs, and what defensive measures could harden the SQL-
injection-via-tenant-id story even further.

**Counts (2026-05-17):**

| Priority | Open | Closed | Deferred to v1.1+ | Total |
|---|---|---|---|---|
| P0 — pre-v1.0 blockers | 0 | 0 | 0 | 0 |
| P1 — should land before/with v1.0 | 0 | 2 | 0 | 2 |
| P2 — post-v1.0 hardening | 0 | 1 | 2 | 3 |
| **Total** | **0** | **3** | **2** | **5** |

**Do first:** all v1.0-relevant items closed. Two items
(SH-04 RLS-check helper, SH-05 sibling SBOM) deferred to v1.1+
with rationale in §2 / §4.

---

## §2 Priority table

| ID | Concern | One-line | Effort | Status |
|---|---|---|---|---|
| [**SH-01**](#sh-01-tenant_session-sql-injection-property-test) | SQL injection at security boundary | `tenant_session` validates UUID before interpolating into `SET LOCAL` (RA-INV-1 + SH-INV-1). The invariant is correct by inspection; no property test pins it across the input space. A future refactor that drops UUID validation would regress silently. | S | ✅ closed |
| [**SH-02**](#sh-02-connection-string-credential-redaction) | Connection-string secret in logs / exceptions | `create_engine(database_url, ...)` passes the URL to SQLAlchemy. Errors during connect (`sqlalchemy.exc.OperationalError`) include the URL in the message — INCLUDING the embedded credentials (`postgresql+asyncpg://user:pass@host/db`). Log records + exception messages MAY surface the password. | S | ✅ closed |
| [**SH-03**](#sh-03-tenant-id-nil-uuid-policy) | Policy boundary | NIL UUID accepted by `tenant_session` (RA-04 raised this as a correctness concern; SH framing: a misconfig that defaults to NIL leaks "system rows" or "unassigned" data across tenants). | XS | ✅ closed |
| [**SH-04**](#sh-04-rls-policy-presence-cant-be-enforced) | Defence-in-depth boundary | `tenant_session` sets the GUC; the actual row-level isolation requires Postgres RLS policies on every tenant-scoped table. The library cannot enforce that policies exist. Document the consumer-side runbook + provide a check helper. | S | ⏳ v1.1+ — new module + public API; needs real-world feedback on check shape (per-table loop? policy-name pattern? `pg_class.relrowsecurity` flag?) before locking surface. Not a v1.0 blocker — `tenant_session` doesn't depend on it. |
| [**SH-05**](#sh-05-sibling-sbom-and-vuln-scan-in-ci) | Supply chain | Same gap as other siblings. SBOM + `pip-audit`. | S | ⏳ v1.1+ — federation-wide SBOM workflow tracked separately; blocked by a federation-wide CI strategy, not the sibling itself. |

Effort scale: XS (≤ 15 min); S (≤ 1 h); M (≤ 1 session).

---

## §3 Invariants we hold

The list of "closed elsewhere; do not re-open":

- **SH-INV-1** — UUID validation runs **BEFORE** any SQL
  is emitted in `tenant_session`
  ([`_session.py:87-94`](../../src/mgf/sqlalchemy/_session.py#L87)).
  SQL injection through `tenant_id` is structurally
  impossible: `UUID()` raises ValueError on malformed
  input; the canonical str form is alphanumeric + dashes
  (no SQL-meaningful characters).
- **SH-INV-2** — `SET LOCAL` qualifier scopes to the active
  transaction. The GUC auto-resets on commit/rollback —
  cross-tenant leak through connection-pool reuse is
  structurally impossible at the SQL layer, given a
  transaction is active.
- **SH-INV-3** — `expire_on_commit=False` defaults match
  SQLAlchemy 2.x's recommendation. Committed objects stay
  accessible (avoid the silent-re-fetch that could pull data
  across tenants if the cache holds another tenant's row).
- **SH-INV-4** — Engine is **unconnected on return** from
  `create_engine`. No premature credential transmission; the
  first connect happens lazily. Failed credentials surface
  on first use, not at construction (a connection-pool
  warm-up step would expose them earlier — defer to
  consumer's choice).
- **SH-INV-5** — SQLite gets a different pool config
  (StaticPool/NullPool). Single-process SQLite avoids the
  connection-pool-credentials concerns of remote DBs
  entirely.

---

## §4 Work units

### SH-01 tenant_session SQL-injection property test

**Why:** RA-INV-1 + SH-INV-1 establish that
`tenant_session`'s UUID validation prevents SQL injection
through `tenant_id`. The implementation
([`_session.py:87-94`](../../src/mgf/sqlalchemy/_session.py#L87))
calls `UUID(tenant_id)` BEFORE constructing the SQL string.

But the invariant is **correct by inspection** — no test
exhaustively exercises the boundary. A future refactor that
"helpfully" accepts a `str` directly (bypassing the UUID
validation) would regress silently.

Property-based testing pins this structurally:

- Generate adversarial input strings (Hypothesis): SQL
  meta-characters (`'`, `"`, `;`, `--`, `/*`, `*/`),
  whitespace, newlines, NUL bytes, unicode lookalikes for
  digits/dashes (homoglyph attack).
- Assert each one raises `ValueError` BEFORE any SQL is
  emitted.
- Plus a positive corpus of valid UUIDs (various versions:
  4, 5, NIL, time-based-1, hex shapes).

**Concrete output:**

- Add `tests/unit/test_tenant_session_security.py` with:
  - A Hypothesis-driven property test that generates
    non-UUID strings (varying lengths, character sets
    including SQL meta-chars) and asserts `ValueError`.
  - A small set of fixed valid-UUID tests.
  - A mock `session` whose `execute` records the SQL — for
    every invalid input, assert `execute` was NOT called.
  - For every valid input, assert the SQL is exactly
    `SET LOCAL app.current_tenant = '<uuid>'` with the
    canonical UUID form.
- Document the property test in the docstring of
  `tenant_session` so consumers know the security claim is
  pinned.

**Locked in by:** the property test; a refactor that drops
the validation fails N generated cases.

**Cross-references:** SC-09 (sink redaction principle
generalises); the `mgf-common/docs/design/multi_tenancy.md`
RLS-policy contract.

---

### SH-02 Connection-string credential redaction

**Why:** `create_engine(database_url=...)`
([`_engine.py:31`](../../src/mgf/sqlalchemy/_engine.py#L31))
takes a `database_url` that typically includes credentials:

```
postgresql+asyncpg://app_user:supersecret@db.internal:5432/myapp
```

When SQLAlchemy hits a connection failure during the lazy
first-connect:

```python
sqlalchemy.exc.OperationalError(
    "(asyncpg.exceptions.InvalidPasswordError) password authentication "
    "failed for user 'app_user' [SQL: ...]"
)
```

The exception message may include the URL — INCLUDING the
password — depending on SQLAlchemy's str representation. The
exception propagates to the consumer's code and may land in
log records, crash reports, or HTTP error responses.

mgf-common's Redactor catches well-known secret-shaped
patterns in formatted log payloads — but if the URL appears
in `traceback.format_exception()` output BEFORE the
redactor runs, the password leaks.

**Concrete output:**

- Add an `make_safe_url(url: str) -> str` helper in
  `_engine.py` that masks credentials:
  ```python
  def make_safe_url(url: str) -> str:
      parsed = sqlalchemy.engine.url.make_url(url)
      if parsed.password:
          parsed = parsed.set(password="***")
      return str(parsed)
  ```
- Document the helper in the docstring: "Use
  `make_safe_url(database_url)` when logging or
  surfacing the URL anywhere it might be persisted."
- Optionally: wrap the engine's URL-bearing exceptions at
  the create_async_engine call site to substitute the safe
  URL in the message. Defer to v0.3+ if invasive.
- Add a unit test asserting `make_safe_url` strips passwords
  + preserves other URL components.

**Locked in by:** the unit test pins the redaction.

**Cross-references:** SC-09 (sink redaction), SC-11
(traceback scrubbing); the cornerstone Redactor at
`mgf.common.observability.redaction`.

---

### SH-03 Tenant-id NIL UUID policy

**Why:** Twin of RA-04 with a security framing. NIL UUID
(`00000000-0000-0000-0000-000000000000`) is accepted by
`tenant_session`. The RA framing is "policy clarity"; the
SH framing is:

A misconfigured consumer with `tenant_id` defaulting to
NIL UUID across many requests AND an RLS policy that
matches `current_setting('app.current_tenant') = tenant_id`
ends up with EVERY request reading the rows tagged with
NIL — typically test data, fixture rows, or "system" rows.

If NIL-tagged rows include sensitive data (default-admin
records, seed configurations), that data is exposed to
every request.

**Concrete output:** Document explicitly in the
`tenant_session` docstring:

> **The NIL UUID** (`00000000-0000-0000-0000-000000000000`)
> is accepted; this library does not reserve it for any
> special meaning. If your application uses NIL as a "no
> tenant" sentinel, ensure your RLS policy treats it
> appropriately — typically, NIL-tagged rows SHOULD NOT be
> readable by user-tenant-scoped queries.

OR, more aggressive: reject NIL UUID by default with an
opt-out flag.

**Locked in by:** the docstring update (the cheap path);
a property test exercising NIL pins the chosen behaviour.

**Cross-references:** RA-04 (twin); the
`multi_tenancy.md` design paper.

---

### SH-04 RLS policy presence can't be enforced

**Why:** `tenant_session` SETs the GUC; the actual SQL-
level row isolation requires Postgres RLS policies on
every tenant-scoped table:

```sql
CREATE POLICY tenant_isolation ON my_table
    FOR ALL TO app_user
    USING (tenant_id = current_setting('app.current_tenant')::uuid);
```

Without the policy, the GUC is just a value queries ignore.
EVERY query against the table returns all rows regardless of
tenant.

The library CANNOT enforce that policies exist (we don't
know which tables are tenant-scoped). But we CAN provide
a check helper that consumers run in their CI / startup:

```python
from mgf.sqlalchemy.audit import assert_rls_policies_present
assert_rls_policies_present(
    engine,
    tables=["customers", "orders", "messages"],
)
```

This connects, runs `SELECT polname FROM pg_policy WHERE
polrelid = '<table>'::regclass`, asserts every table has
at least one policy.

**Concrete output:**

- Add an `audit.py` module with `assert_rls_policies_present`.
- Document in the docstring of `tenant_session`:
  > **You MUST add RLS policies to every tenant-scoped
  > table.** This helper sets the GUC; the policy enforces
  > isolation. See
  > `mgf.sqlalchemy.audit.assert_rls_policies_present` for
  > a startup-time check.
- Add a unit test against a SQLite-mock OR a Postgres
  test fixture.

**Locked in by:** the helper is the deliverable; consumer
adoption is the real win.

**Cross-references:** `mgf-common/docs/design/multi_tenancy.md`;
SC-04 (defence-in-depth at every layer).

---

### SH-05 Sibling SBOM and vuln scan in CI

**Why:** Same gap as the other siblings.

**Concrete output:** SBOM + `pip-audit` in CI.

**Cross-references:** SC-10, SC-18; mgf-django SH-04 +
mgf-fastapi SH-07 + mgf-http SH-06 (twins).

---

## §5 Closed items

- **SH-01** — `tenant_session` SQL-injection boundary pinned by
  an adversarial-corpus test. Added
  `tests/unit/test_tenant_session_security.py` with a
  parametrised corpus of 25 adversarial inputs covering: SQL
  meta-characters (`'`, `"`, `;`, `--`, `/*`); control bytes +
  NUL; almost-valid UUID shapes (wrong length, non-hex chars,
  underscores instead of dashes); unicode homoglyphs (capital-O
  for zero, lowercase-L for one, en-dash for hyphen); literal
  false-positive shapes (`"None"`, `"null"`, `"false"`, `"0"`);
  path-traversal. Every input MUST raise `ValueError` BEFORE
  the SQL emit — verified by asserting the mock session's
  `execute.assert_not_called()` on the invalid path. Counter-
  tests pin that valid UUIDs (str + UUID object) DO reach the
  emit, AND that input normalisation produces the canonical
  lowercase-hex form regardless of casing or brace style.
  Documented in `tenant_session`'s docstring under a new
  "Security note (SH-01)" section so the boundary's contract
  is visible at the source surface. The Hypothesis-based
  property test the tracker proposed would need a dev-dep;
  the parametrised corpus achieves the same gate without
  expanding the test infrastructure. (2026-05-16, commit
  pending.)
- **SH-02** — Connection-string credential redaction. Added
  `make_safe_url(url)` helper in `_engine.py` using SQLAlchemy's
  `make_url(...).render_as_string(hide_password=True)` to mask
  embedded passwords while preserving username / host / dbname
  / driver. Returns `"<malformed-url>"` on parse failure rather
  than raising — safe to call from error paths where a parse
  error would mask the real failure. Exported from the package
  `__all__`; consumers import `from mgf.sqlalchemy import
  make_safe_url` and apply wherever the URL would land in a log
  record / exception message / crash report. 5 new unit tests
  pin the password masking, the no-password round-trip, the
  malformed-URL safety contract, and the public-API export.
  (2026-05-16, commit pending.)
- **SH-03** — Tenant-id NIL UUID policy documented in
  `tenant_session`'s docstring under a new "NIL UUID policy
  (RA-04 / SH-03)" section. Twin of RA-04 (closed
  simultaneously, single docstring edit covers both framings).
  Chose documentation-only over strict rejection: NIL is a
  syntactically valid UUID and some applications use it
  intentionally as a "no tenant" / "system rows" sentinel.
  The docstring spells out the SH-framing misconfig failure
  mode (every request silently reading NIL-tagged rows when
  the consumer's RLS predicate doesn't exclude NIL) and
  recommends two safe shapes: reject at the application layer
  before calling `tenant_session`, OR add an RLS predicate
  that explicitly excludes NIL from user-scoped reads.
  Parametrised acceptance test in
  `tests/unit/test_tenant_session_security.py::TestNilUuidPolicy`
  pins all four NIL input shapes (str canonical, no-dash hex,
  brace form, UUID object) so a future behaviour change
  forces a documented decision. (2026-05-17, commit pending.)

---

## §5b Deferred to v1.1+

- **SH-04** — RLS policy presence helper deferred to v1.1+.
  This would add a new `mgf.sqlalchemy.audit` module +
  `assert_rls_policies_present(engine, tables=[...])` public
  API. The right surface isn't clear yet — should it loop
  per-table (50 catalog queries at startup for a 50-table app,
  see PC-05), do one catalog scan (`SELECT polrelid FROM
  pg_policy` once), match by policy-name pattern, OR check
  `pg_class.relrowsecurity` + presence-of-any-policy? Each
  shape has a different ergonomic + perf profile. The
  decision needs real-world feedback from one or two
  multi-tenant consumers before locking the surface for
  v1.0. `tenant_session` (the load-bearing helper) does not
  depend on this — the library's v1.0 contract is complete
  without it. Pull forward to v1.1 when a consumer hits the
  "I forgot to add an RLS policy on table X" failure mode
  and the right detection shape becomes obvious from their
  experience.
- **SH-05** — Sibling SBOM and vuln-scan in CI deferred to
  v1.1+: federation-wide SBOM workflow tracked separately;
  blocked by a federation-wide CI strategy, not the sibling
  itself. When the cornerstone (mgf-common) lands its SBOM
  workflow, this sibling adopts the same template.

---

## §6 Notes on methodology

This first pass walked the two source files (~150 LOC)
end-to-end against:

- **SC** rules from
  `mgf-standard/docs/standards/SECURITY_STANDARD.md`
  — especially SC-09 (sink redaction), SC-11 (traceback
  scrubbing), SC-10 (vuln scan), SC-18 (SBOM).
- The `multi_tenancy.md` design paper for the broader RLS-
  contract context.
- mgf-common's SECURITY_HARDENING.md as the methodology
  template.

The audit method:

1. Read each source file end-to-end with the SC rule set in
   mind.
2. Trace credential / tenant-id flow through the library to
   identify boundaries where secrets / scoping could leak.
3. Cross-reference RA / SH twin items.
4. Confirm inherited invariants from mgf-common.

Five items + five invariants. The library is small + focused;
the security shape reflects that. Future passes should
look for:

- **Postgres extension interactions.** Some Postgres
  extensions (pgaudit, row_security, custom security
  definers) interact with `SET LOCAL`. If a consumer uses
  these, the GUC behaviour might differ.
- **Multi-DB shape.** If a future consumer uses MySQL or
  SQL Server, the `SET LOCAL` shape doesn't translate; the
  library would need a per-dialect strategy. Today
  Postgres-only.
- **Connection-string handling improvements.** Beyond the
  `make_safe_url` helper, deeper structured-URL handling
  could catch more edge cases (URL fragments, IPv6 host
  notation).

When an item closes, move it to §5 with the closing-commit
SHA.
