Skip to content

MorphDB 0.8.0

Choose a tag to compare

@birdnamoo birdnamoo released this 22 Jul 04:00
· 145 commits to main since this release

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_EXPRESSION on 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.
  • /swagger is 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: the X-API-Key / JWT Authorization authentication handler, the
    /api/security/keys endpoints, the Development-only POST /api/dev/bootstrap, the
    _morph_api_keys control-plane table (existing databases drop it on start), the Jwt
    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}} is false and the
    user-bearing placeholders substitute NULL (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 on 127.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 to X-API-Key in
    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_FOUND and DUPLICATE_SLUG are 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 answered 500 INTERNAL_ERROR (or a bodyless 500)
    now answer: unknown column type on CREATE TABLE → 400 listing the supported types; unknown filter
    column → 400 COLUMN_NOT_FOUND naming it; a project id no project bears → 404; explicit null
    into a nullable:false column → 400 naming the column (physical 23502/23505 violations
    translate to the same VALIDATION_ERROR the app-layer validators produce). Anything genuinely
    unexpected is a logged 500 carrying the fixed INTERNAL_ERROR envelope.
  • An unknown filter operator is now a 400 listing the supported operators. It previously fell
    back to eq silently, 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_PROJECT no 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 filter is_active = true
    and so could not see them. The second declaration of any name therefore died on a raw 23505
    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 the is_active flag 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_NAME
    rather 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 answers TABLE_HAS_DEPENDENTS naming 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 (400 INVALID_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 of DdlBuilder
    (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_tables for a column
    called name, which it has never had (logical_name), so every create and every by-name read
    answered 42703 as 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/policies and the policy reads behind it were shipped and
    unreachable. Both name lookups now use logical_name and 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_TIMESTAMP as 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 inside AddMorphDbNpgsql, so code constructing a
    repository directly read multi-word columns as defaults — project_id came back Guid.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 refuses Guid.Empty outright
    instead of silently creating p_00000000 schemas, 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
    executedAt field the server never sends ("Invalid Date") and hid the totalGroups and
    scan-metadata fields it does. The scenario tests' typecheck errors had been pointing at exactly
    this — desk's vitest stayed green because mocks accept anything — and a desk typecheck job now
    runs in ci.yml on 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_FAILED 400s 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 409 DuplicateTable/DuplicateColumn and a version
    conflict is 409 ConcurrencyConflict, 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. The Archived/Archiving status 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 code PROJECT_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, and ConcurrencyException. None of their error codes ever reached the wire.