MorphDB 0.8.0
Security note: before this release, a security policy's expression reached the SQL WHERE
clause unvalidated — row-level security was an injection path. 0.8.0 gates every such path
(INVALID_EXPRESSIONon create, update, and again at evaluation). If you use security
policies, upgrade.
This is a breaking release. The highlights:
- The error and write contracts are now fail-loud: unknown operators and unknown columns are
refused with typed 4xx answers instead of silently altering or dropping your data. - A deleted name can be created again: drop-and-rebuild no longer locks the name forever.
- The authentication machinery is gone: the service never enforced it and a production image
could never mint a key. Access control belongs to the deployment (bind privately, or front with
an authenticating proxy). Client credential options were removed with it. /swaggeris served in every environment — the machine-readable contract is no longer a 404
in the deployed image.- The development compose binds to 127.0.0.1 by default.
Compatibility: Formbase.* 0.3.0 pairs with MorphDB 0.7.x; the 0.4.0 pairing release for
MorphDB 0.8.x follows.
Removed — the authentication machinery
- The service no longer carries authentication it never enforced. A production image had no way
to mint an API key — the only issuing endpoint was Development-mode-only, and the key-management
endpoints demanded a role only an existing key could grant — so every working deployment already
ran unauthenticated, and the machinery's only effect was to advertise a boundary that did not
exist. Removed: theX-API-Key/ JWTAuthorizationauthentication handler, the
/api/security/keysendpoints, the Development-onlyPOST /api/dev/bootstrap, the
_morph_api_keyscontrol-plane table (existing databases drop it on start), theJwt
configuration section, and the client options and methods that carried credentials
(MorphDBClientOptions.ApiKey/JwtToken,SetApiKey,SetJwtToken). Access control is the
deployment's job: bind the service privately, or put an authenticating proxy in front. Desk's
credential storage (the connection dialog's API-key field and the encrypted store behind it),
its API-keys management tab, and the credential options of the TypeScript and Python reference
SDKs went with it. - The role gates fell with it. The security-policy, encryption-rotation and diagnostics
endpoints — previously[Authorize]-gated behind a role no production caller could hold, and so
unreachable — now answer like every other endpoint. Row-level security still evaluates: an HTTP
request runs in its project's anonymous context, so{{is_authenticated}}isfalseand the
user-bearing placeholders substituteNULL(fail-closed; a{{user_id}}policy matches no rows
over HTTP).
Changed — safe defaults
- The composes bind to loopback. The README quick-start and the repository's development
compose publish every port on127.0.0.1— an unauthenticated service must not land on all
interfaces by default. To serve other machines, front it with a reverse proxy (or your app) and
bind that. - The service states its posture once per start: a single startup log line says that nothing
authenticates and access control belongs to the deployment. Ghost references toX-API-Keyin
the API reference, client README, desk user guide and philosophy docs left with the machinery.
Changed — the contract is served, not shipped dark
/swagger(OpenAPI document and UI) is served in every environment. It was registered
unconditionally but exposed only in Development, so the deployed image answered 404 for its own
machine-readable contract.
Changed — error and write contracts
PROJECT_NOT_FOUNDandDUPLICATE_SLUGare typed exceptions (ProjectNotFoundException,
DuplicateSlugException) instead of code-string matches on the base exception, and the global
handler maps them (404 / 409). Wire responses on the project endpoints are unchanged; the floor
improves — these escaping on any other path answered 500, now 404/409.- A caller's mistake now answers 4xx with a code and a hint — never a 500, and never an empty
body. A global exception handler is the single authority for what an escaped exception becomes
on the wire; live-probed paths that previously answered500 INTERNAL_ERROR(or a bodyless 500)
now answer: unknown column type on CREATE TABLE → 400 listing the supported types; unknown filter
column → 400COLUMN_NOT_FOUNDnaming it; a project id no project bears → 404; explicit null
into anullable:falsecolumn → 400 naming the column (physical23502/23505violations
translate to the sameVALIDATION_ERRORthe app-layer validators produce). Anything genuinely
unexpected is a logged 500 carrying the fixedINTERNAL_ERRORenvelope. - An unknown filter operator is now a 400 listing the supported operators. It previously fell
back toeqsilently, so a typo became a different query with no signal. - Writes naming a column the table does not declare are rejected (400
UNKNOWN_COLUMN) instead
of silently dropped. This applies to every write door — data insert/update, batch, seed,
upsert, bulk import rows, and GraphQL mutations — because the write paths that previously built
their own SQL now all go through the write pipeline (which also means virtual constraints and
system-column transformers apply uniformly; batch/seed/upsert rows now get UUIDv7 ids,
timestamps and versions from the pipeline rather than database defaults). Callers that want the
old dropping behaviour opt in explicitly with?ignoreUnknown=true. MISSING_PROJECTno longer advertises an API key the server never asks for; it says to send
X-Project-Id.- Error text no longer carries internal identifiers. CHECK-expression rejections quote the
expression as the caller wrote it (previously the physically-renamed form), and not-found
messages no longer embed project GUIDs.
Fixed
-
A deleted logical name could never be created again. DELETE drops the physical object and
keeps the metadata row as a tombstone, but uniqueness was a plain table-level constraint that
counts tombstones as occupants — while the lookups guarding creation filteris_active = true
and so could not see them. The second declaration of any name therefore died on a raw23505
that escaped as a 500, permanently: drop-and-rebuild, the standard schema-evolution path, was a
one-way door from the second declaration onward. Uniqueness is now a partial index over the live
rows on every soft-deleted control-plane table — tables (logical and physical name), columns,
indexes, views and security policies — so a tombstone releases the name it no longer uses while
two live objects still cannot share one. Existing databases are migrated on start; a control
plane older than theis_activeflag gets it before the indexes are built, so the bootstrap
cannot crash-loop. A unique violation on a control-plane insert now answers 409
DUPLICATE_NAMErather than a 500. -
Deleting a table left its columns, indexes and relations marked live. The delete drops the
physical table — and with it every index on it — but only the table's own metadata row was
retired, so the control plane went on describing parts of a table that no longer existed, and a
relation kept pointing at a table that was gone. A delete now retires the table's columns,
its indexes, and every relation touching either end, in one statement with the table itself. -
Deleting a table that another table references answered a bare 500. The drop carries no
CASCADE on purpose — tearing down another table's foreign key is not something deleting this one
should decide — but PostgreSQL's refusal escaped untranslated, quoting a physical table name the
caller is not meant to know exists. It now answersTABLE_HAS_DEPENDENTSnaming the table by the
logical name the caller gave it. -
A security policy's expression reached the WHERE clause unvalidated. Policy expressions are
spliced into ordinary queries, which makes them caller-authored strings that reach SQL verbatim —
the same category as a CHECK predicate or an index predicate, both of which were already gated
while this path shipped open. A statement separator, a comment opener, an unbalanced parenthesis
or an unterminated quote is now refused (400INVALID_EXPRESSION) when the policy is created or
updated, and again on the substituted text at evaluation time, so a row stored before this
release fails the read rather than being emitted. The validator itself moved out ofDdlBuilder
(it stopped being about DDL) and is now the one gate every such path calls. -
Security policies never worked at all. Both name lookups asked
_morph_tablesfor a column
calledname, which it has never had (logical_name), so every create and every by-name read
answered42703as a 500; and the row type was a positional record, which Dapper cannot
materialise under the assembly's snake_case convention, so every read by id or table failed on
materialisation.POST /security/policiesand the policy reads behind it were shipped and
unreachable. Both name lookups now uselogical_nameand exclude deleted tables, and the row
type maps by property as the rest of the assembly does. The service has integration coverage for
the first time. -
CURRENT_TIMESTAMPas a column default failed the CREATE TABLE. SQL's clock keywords take no
parentheses, so the function-default check never saw them: they were quoted as string literals —
DEFAULT 'CURRENT_TIMESTAMP'— which no temporal column can cast. The keywords
(CURRENT_TIMESTAMP,CURRENT_DATE,CURRENT_TIME,LOCALTIMESTAMP,LOCALTIME) are now
recognised on date/time/datetime columns; on a text column the same word remains an ordinary
string literal. -
MorphDB.Npgsql's snake_case mapping only applied if you booted DI. The Dapper flag the
assembly's SQL is written against was set insideAddMorphDbNpgsql, so code constructing a
repository directly read multi-word columns as defaults —project_idcame backGuid.Empty
with no error, and Dapper's per-query deserializer cache kept the wrong mapping alive even after
DI later set the flag. The convention is now a module initializer: it holds before the first
query this assembly issues, whoever issues it. Provisioning also refusesGuid.Emptyoutright
instead of silently creatingp_00000000schemas, and a created project that cannot be read
back is an error rather than a null. -
Desk described request shapes the server never accepted. Its lookup, rollup and formula
column-config types (relationId/sourceColumnName,expression/outputType) matched nothing
in the API — creating any of those columns from desk could not have worked. The types now mirror
the server's records. Its aggregation-result type was likewise wrong: the panel rendered an
executedAtfield the server never sends ("Invalid Date") and hid thetotalGroupsand
scan-metadata fields it does. The scenario tests' typecheck errors had been pointing at exactly
this — desk'sviteststayed green because mocks accept anything — and a desk typecheck job now
runs inci.ymlon every push, where before it ran only on release tags. -
The batch-family endpoints reported every failure — including our own — as a 400 whose message
was the raw exception text. Batch, bulk, transaction, aggregation and audit actions ended in
catch (Exception) { return BadRequest(ex.Message); }: a caller could not tell a service defect
from their own bad request (and retrying "their" error retried our bug), and internal exception
text — driver messages, physical identifiers — was copied onto the wire. These actions now answer
the way the data endpoints always did: what the service layer legitimately throws keeps its
documented status (validation → 400, missing table/record → 404, where it was previously a
400, bad argument → 400), and anything unexpected is a logged 500 with a fixed message.
Per-item errors inside batch/seed responses follow the same rule. The audit endpoints' catch-all
AUDIT_QUERY_FAILED/AUDIT_STATS_FAILED400s are gone with that branch. -
The schema API's documented 409s were unreachable. The controller caught exception types
nothing throws (DuplicateException,ConcurrencyException), so creating a table or column under
a taken name came back 400 "SchemaError" — indistinguishable from a malformed request — and a
stale-version update escaped as an unhandled 500. The catches now name the types the schema
layer actually throws; a duplicate name is 409DuplicateTable/DuplicateColumnand a version
conflict is 409ConcurrencyConflict, as the endpoints always advertised.
Removed
POST /api/projects/{id}/archive. It promised "read-only mode" and no code anywhere enforced
it: the endpoint set a status nothing ever read, and its implementation carried a TODO for a
backup process the constitution rules out (operational orchestration is a non-goal). A dead
promise is worse than an absent feature. TheArchived/Archivingstatus values remain readable
for databases that already carry them.ISchemaMapping/ISchemaMappingCache— interfaces nothing implemented or consumed.
Removed (earlier in this cycle)
- Exception types no release ever threw, dead since their introduction:
ProjectIsolationException(error codePROJECT_ISOLATION_VIOLATION— nor its pre-0.7.0 name
TENANT_ISOLATION_VIOLATION; the 0.7.0 migration table maps the rename for completeness, but the
code never appeared in a response),DataValidationException,CircularReferenceException,
DuplicateException, andConcurrencyException. None of their error codes ever reached the wire.