İçeriğe geç

ADR-010: API Versioning and RLS

Geliştirme · 0.0.0-dev

Yayın

Doküman
0.0.0-dev
Uygulama
0.0.0

Bu sayfa

Uygulama
0.0.0
  • Status: Accepted
  • Date: 2026-08-18
  • Decision owners: API, database, security and release maintainers
  • Scope: PostgREST/Supabase contract authority, grants, RLS and safe routine execution

Munderecat will expose immutable corpus text, evolving annotations, curation workflows and policy-qualified lookup through Supabase/PostgREST. Those data classes do not share one lifecycle or visibility rule. Published core may be public, a user’s draft may be private, a review queue may be role-limited and provenance evidence may contain paths, selectors or diagnostics that must never reach a public client.

PostgreSQL object existence is not an API contract. A table can appear in an exposed schema while having an internal shape, a helper function can accidentally inherit PUBLIC EXECUTE, and a view can bypass the caller’s RLS when it runs with owner rights. Application-side filters and hidden frontend controls cannot repair those database authority mistakes.

Versioning has separate ambiguity. Code, database schema, public JSON shape, data release and policy can change independently. Returning a current row without identifying the effective data and policy can silently change an old URL or reference. Conversely, encoding every code deploy into an API path would create needless breaking versions.

ADR-003 supplies release manifests. ADR-006 separates private audit closure from public projections. ADR-007 defines accepted curation and atomic compare-and-apply. ADR-008 and ADR-009 require policy-bound, set-valued lookup states. ADR-021 defines non-login, non-bypass database capability roles and explicit grants. This ADR defines the API/RLS contract that must preserve those boundaries.

Munderecat records these identities independently:

Axis Meaning Example change
api_contract_release Public operations, request/response semantics and authorization Rename/remove a field
code_release Deployed application and pipeline code Internal optimizer fix
schema_release Exact database objects, owners, grants, RLS and definitions Add an index or policy
data_release Exact accepted core/annotation/relation roots Publish corrected corpus data
policy_release Versioned resolution/propagation/visibility decisions Change current-resolution policy

One release manifest binds the compatible tuple used by a deployment, but no axis borrows another’s identity. A code deploy with no contract change does not mint an API version. A new data release can use an existing API contract if its semantics and capability remain compatible. A contract cannot claim compatibility with a schema/data/policy combination that its release manifest did not validate.

Every successful public response identifies at least the exact API contract release and effective data release. Operations using resolution, alignment, repetition, dictionary or other policy also identify the effective policy release. Schema and code release IDs remain available in release/debug metadata subject to disclosure policy; clients do not infer them from timestamps or server headers.

The logical contract model has these records:

Record Meaning
api_contract Stable public major-line identity and compatibility promise
api_contract_release Immutable exact operation/schema release within one major line
api_operation Globally stable logical purpose independent of wire revision
api_operation_revision Immutable major/contract-bound method, input, output and authority semantics
access_policy Versioned role, row-visibility and action rules used by operations
exposure_manifest Exact allowlisted database relations/routines and grants
api_deprecation Announced support-window transition for one contract/operation
api_deployment Exact compatible contract/code/schema/data/policy tuple made visible

All are package-deterministic, version-controlled identities except an emergency deployment withdrawal, which is an ADR-007/release incident event and does not rewrite the withdrawn records. Identity facts include the complete canonical contract or policy document digest. Mutable labels, OpenAPI ordering, database OIDs and deployment times are not identity inputs.

An api_operation ID is globally unique and permanently denotes one logical capability, such as exact entity lookup. It may have revisions in several major lines only through explicit api_operation_revision records. A revision belongs to exactly one contract release and binds the complete request, response, authorization and error semantics. Changing those facts issues a new revision ID. Sharing the logical operation across majors does not imply wire compatibility, and one ID is never reused for a different purpose. Historical contract releases and revisions remain resolvable after deprecation or withdrawal even when execution is no longer available.

Public contracts use semantic major.minor.patch labels bound to immutable release IDs:

  • major changes when a client-visible field, type, cardinality, interpretation, authorization requirement, default release/policy behavior or error-state meaning is removed or changed incompatibly;
  • minor adds optional operations/fields or capabilities while preserving all existing accepted behavior; and
  • patch corrects non-semantic contract metadata or documentation without changing any accepted request, response, error or authorization behavior.

An implementation defect fix with unchanged declared contract produces a code_release, not an API patch. If the deployed observable contract changes, contract compatibility is evaluated from the old and new accepted behavior and receives the required API release; calling the change a bug fix cannot bypass that result.

F2-018 will define the project-wide compatibility matrix. Until then, uncertainty is breaking. A database migration, function replacement or TypeScript type change cannot declare compatibility by itself; the API contract diff and fixtures decide.

Each major has an explicit stable routing surface. Concrete path/schema/RPC naming is reserved for F2-020, but the routing mechanism must identify the major before operation dispatch. An unversioned convenience alias may exist only if it resolves to one declared supported major and returns the resolved exact contract release. It cannot silently move across majors.

Multiple majors coexist for a declared support window. Deprecation records identify the last supported contract, announcement time, replacement major, removal condition and machine-readable migration reference. Removing execution support is a release action; old contract metadata and permanent entity IDs remain inspectable.

Before dispatch, an operation resolves and pins one exact api_deployment, api_contract_release and api_operation_revision, then the deployment’s compatible code, schema, data and applicable policy releases. The complete tuple is fixed once at transaction start and used for the entire response. A request may pin a supported historical deployment or tuple, but independently supplied release IDs are accepted only when they exactly match a validated deployment compatibility record. Unsupported, withdrawn, incompatible, forbidden and unavailable tuples remain distinct outcomes; they never fall forward to current data.

All continuation state binds the exact deployment, API contract/revision, code, schema, data and applicable policy releases plus query shape and authorization subject. Pagination details belong to F2-020, but no continuation may mix releases or reuse a token under another subject/policy. A multi-call frontend flow that needs snapshot consistency carries the returned validated deployment tuple into later calls.

Current/recommended resolution is always an explicit operation or mode. Exact-ID lookup returns the requested historical entity and its lifecycle summary when authorized. It does not silently redirect to a superseding, translated or canonical-repeat target.

The public API is the exact public_surface partition of an immutable exposure_manifest, not every object present in PostgreSQL. The manifest has two disjoint, separately rooted inventories:

  • public_surface: PostgREST-exposed contract views/routine revisions, client grants and disclosure-approved OpenAPI/comments; and
  • private_dependency: non-exposed backing relations/columns, helper functions, owners, grants and RLS dependencies required to implement that surface.

The manifest root binds both partitions, but only public_surface generates public docs, OpenAPI, operation discovery and client types. No private dependency name or metadata is copied into those projections. Together the partitions bind:

  • exposed schema/profile and PostgREST configuration identity;
  • exact relation/view and routine signatures;
  • exact non-exposed backing relations/columns needed by invoker projections;
  • operation/contract IDs;
  • owners, invoker/definer modes and volatility settings;
  • schema, object, column, sequence and routine grants;
  • RLS enable/force flags and canonical policy-expression digests;
  • public comments/OpenAPI projection and disclosure classification; and
  • schema/data/policy compatibility roots.

Objects absent from the manifest are not supported, documented or granted. The PostgREST-exposed schema contains contract views and routines, not storage tables. Concrete internal schema names are deferred to the ER/schema tasks, but core, annotation, curation, provenance, cache and migration storage must remain outside every exposed schema. Supabase/PostgREST schema configuration is hash-bound to the manifest and cannot add another storage schema at runtime.

Invoker views may require narrowly declared USAGE and SELECT on non-exposed backing schemas/relations so caller RLS is evaluated. Those dependency grants are recorded in the manifest but do not create REST endpoints because the backing schema is not exposed. Helper routines, materialized views, caches and migration ledgers remain private.

PostgREST-exposed operations cannot rely on PostgreSQL function overloading. Every allowlisted callable signature resolves unambiguously by name and input shape; adding an overload that makes PostgREST dispatch ambiguous is a contract failure.

The OpenAPI document and generated client schema are projections of the manifest’s public_surface plus contract source. Comments cannot expose private table/column names or evidence metadata. Generated drift fails release rather than being patched manually.

ADR-021 capability roles remain authoritative. This ADR adds their API use, not new privilege inheritance:

Principal/capability Permitted API role
anon Explicit public-release reads/lookup only
authenticated Public reads plus policies for its own user-scoped rows and eligible curation calls
app_runtime Server-side execution of explicitly granted read/lookup operations
curation_executor Owner/executor capability for narrow accepted mutation routines
auditor Restricted read-only audit operations outside public clients
ci_migrator / prod_migrator Versioned schema change only; no application session
break_glass_owner External incident path only; never normal API execution
provider service_role Server/operator infrastructure only; never browser or curation authority

All project capability roles remain NOLOGIN, NOINHERIT, NOBYPASSRLS and otherwise restricted as ADR-021 declares. Provider-owned gateway roles are adapter principals, not permission templates. Their grants are explicit and machine-inventoried.

The Supabase service_role can bypass RLS in provider deployments. It is never embedded in browser bundles, docs, fixtures, logs, public artifacts or user-controlled workers. A service-role request does not count as ADR-007 review or curation authority. Server code does not use it for routine application reads or curation.

Project migrations explicitly revoke service_role schema/table/sequence privileges and all public or mutating RPC execution, including future default privileges. A separate provider-adapter allowlist may grant only exact non-domain infrastructure operations that cannot insert, update, delete, curate or publish corpus/annotation rows. Deployment fails if the managed provider restores broader project-object grants or prevents their removal; normative application code policy is not accepted as an ACL control.

Every schema migration starts from denial:

  • revoke schema/object privileges and routine execution from PUBLIC;
  • set default privileges for every creator/owner to grant nothing implicitly;
  • grant schema USAGE only to named principals that need allowlisted objects;
  • grant no table DML or sequence use to anon, authenticated or app_runtime;
  • grant API-view/backing-relation SELECT and routine EXECUTE only by exact manifest identity, dependency and signature; and
  • prohibit ALL TABLES, ALL FUNCTIONS and future-object wildcard grants in release migrations.

Client-facing writes occur only through reviewed routines. Sequence privileges and table ownership are never required by clients. Curation and runtime capabilities cannot own storage tables, alter policies, grant roles, create objects or become a migration owner.

A grant is necessary but never sufficient: RLS/policy and operation-level validation must also authorize the row/action. Conversely, RLS does not compensate for an accidentally granted mutating routine or security-definer helper.

Every storage relation reachable from an API operation receives an explicit classification and policy decision. Absence of a policy is deny, not public. Initial classes are:

Class Required behavior
Published global core Read only through an accepted release-bound projection
Published global annotation Read only when effective and disclosure-approved
User-owned draft/preference Forced RLS by authenticated subject with USING and WITH CHECK
Team/curation work Forced RLS plus current database-backed membership/authority
Private provenance/audit No anon/authenticated policy; restricted operation only
Derived cache/materialization Never directly exposed; projection inherits every source restriction
Migration/control ledger Operator/migrator only; no runtime operation

Underlying restricted tables enable and force RLS. Their owners are not runtime/curation roles, and every executing principal is NOBYPASSRLS. Provider superuser/bypass behavior is tested separately and never used as proof that ordinary policies work.

Published global projections may use a read policy equivalent to public visibility only after binding an accepted data release and effective publication state. USING (true) on a mutable storage table is forbidden. Rejected, pending, retired-private, embargoed or partially published rows do not become visible because neighboring rows are public.

User/team policies derive subject and membership from a trusted request identity plus current database state. Critical curation authority is rechecked in the database so stale JWT role claims cannot override revocation or freeze. Client-supplied actor_id, tenant_id, owner ID, review role or policy ID is data to validate, never authority.

RLS predicates are stable, bounded and free of caller-controlled dynamic SQL. Recursive policy dependencies, cross-table policy cycles and functions that read the same protected table without a reviewed security boundary are forbidden.

Client-facing views use PostgreSQL security-invoker behavior so underlying grants and RLS apply as the caller. A view owned by a privileged role cannot be used as a definer-style RLS bypass. Security-barrier behavior is enabled where predicate pushdown could reveal restricted values, and expressions/functions used by the projection are reviewed for leakage and side effects.

Materialized views and caches do not enforce source RLS automatically and are therefore private derived storage. They are refreshed from one bound release/policy, carry its visibility partition and are queried only through an invoker projection or secured RPC. Cache misses never fall back to broader unfiltered storage.

A projection cannot mix public and private roots in one JSON object and rely on omitted fields for safety. Its source query and operation contract select only disclosure-approved data before serialization.

SECURITY INVOKER is the default. A read RPC uses invoker rights unless it requires a narrow reviewed capability that cannot be represented safely with invoker views/RLS.

A SECURITY DEFINER routine is allowed only when its contract records:

  • one dedicated controlled NOLOGIN, NOBYPASSRLS owner that is not a storage-table owner and cannot create, grant or change roles;
  • SET search_path = pg_catalog, pg_temp with every project object fully qualified;
  • exact input/output types, volatility, parallel-safety and transaction behavior;
  • explicit REVOKE ALL ... FROM PUBLIC and exact signature-level execute grants;
  • no caller-controlled SET ROLE, search path, identifier, relation, predicate or SQL;
  • bounded statement/time/resource behavior and fail-closed exception handling; and
  • audit/provenance classification for arguments, actor, decision and result.

Dynamic SQL is forbidden in public definer routines unless a later reviewed operation has a finite identifier allowlist, uses identifier-safe formatting, binds all values and demonstrates that no equivalent static SQL exists. Text concatenation of client input into SQL is always forbidden.

Definer routines do not trust a client-provided actor or tenant. A non-public context helper verifies the trusted authenticator session_user, the gateway-selected request role and authenticated JWT subject from transaction-local request context before the definer transition; it accepts no client arguments. Gateway adapter configuration and the allowed authenticator identity are release-bound.

Custom request GUC/JWT JSON is not sole authority because a direct database session may set ordinary custom settings. Direct capability roles receive no execute grant on public curation routines, and critical authorization always joins current database-backed grants, membership, revocation and freeze state inside the same transaction.

Clients have no direct core or annotation DML. An eligible authenticated curator submits an ADR-007 proposal/review/apply operation through narrow routines. The apply routine:

  1. derives the authenticated subject and verifies current capability;
  2. locks the expected curation parent and affected entities;
  3. verifies frozen proposal/action bytes, approvals, idempotency key and scope;
  4. rechecks authority revocation, freeze epoch and bound release/policy roots;
  5. invokes domain validation and performs the complete append-only decision atomically;
  6. publishes effective state only when every check succeeds; and
  7. emits restricted audit evidence without returning private diagnostics.

Any mismatch writes no domain/effective row. A retry with the same idempotency key and same action returns the original result; the same key with different bytes fails. The routine cannot accept arbitrary table names, operations or bulk predicates.

Core release publication and migrations remain separate privileged workflows. A curation RPC cannot alter core text, schema, grants, RLS, API contracts or release manifests.

The trusted HTTP gateway authenticates tokens and selects the database request role. The database still verifies that the expected role and subject are present, validates token time/audience/issuer through the platform boundary, and checks mutable authorization in current tables. Anonymous requests have no fabricated subject.

Authorization applies before object resolution details are exposed. A caller cannot use exact-ID, count, pagination, error detail, timing-oriented alternate endpoints or OpenAPI metadata to enumerate private entities. F2-020 defines the concrete error envelope, but forbidden and absent resources must have a policy-controlled indistinguishable public shape while restricted audit retains the true reason.

An operation returning related entities re-applies visibility to every member after resolution. A visible source does not authorize a private supersession target, alignment, repeat, note, provenance record or dictionary decision.

Exact-ID lookup, current resolution and reference propagation are separate operations or explicit modes. Each binds the contract, data release and resolution/propagation policy. Set-valued, retired, unavailable, ambiguous and integrity-error results remain explicit.

RLS filtering cannot turn a multi-target result into a false exact singleton. If one required branch is hidden, the public result follows the operation’s fail-closed status and suppresses destinations as defined by the bound policy. Restricted audit may explain the hidden branch without changing the public semantics.

Historical data releases use their historical policy/projection records. A current RLS policy may further restrict access, but cannot broaden historical disclosure or silently substitute newer data.

An api_deployment binds exact hashes/roots for:

  • API contract and exposure manifest;
  • schema object definitions, owners, grants and default privileges;
  • RLS flags and policy expressions;
  • routine bodies, configuration and signatures;
  • OpenAPI/generated contract projection;
  • compatible code, schema, data and policy releases;
  • role-matrix and contract-test evidence; and
  • public disclosure projection plus restricted audit attestation.

Deployment first stages migrations and objects without public visibility. Activation acquires the singleton deployment lane lock and locks the authoritative active-deployment pointer. The request supplies its expected predecessor deployment ID/head. Inside the same compare-and-activate boundary it revalidates that predecessor, current release and curation heads, authority/revocation state, freeze epoch, disclosure policy, signatures, grants, role attributes, RLS/policies, routine owners/configuration, complete compatibility tuple and gateway exposure allowlist.

Only after every comparison succeeds does the transaction issue the deployment record and change the visibility pointer. Schema-cache reload/notification is bound to that exact activation and traffic does not route to the new surface until its receipt verifies. Any mismatch leaves the prior deployment authoritative and publishes no partial surface; a concurrent activation cannot pass against the same predecessor.

Rollback never edits an accepted schema/data/API release in place. Reactivating a prior deployment uses the same lane lock, expected-current compare-and-activate transaction and fresh authority, freeze, disclosure, grant/RLS and compatibility checks; historical prior approval is insufficient. Otherwise recovery issues a forward-fix release. A migration that removed old-major support cannot be called rollback-safe merely because application code was reverted.

The contract must support operations to:

  • discover supported major lines and exact contract releases;
  • read one release-bound chapter/core projection without annotations;
  • add authorized annotations without making core reconstruction dependent on them;
  • inspect an exact historical entity separately from current resolution;
  • execute authorized curation proposals/reviews/decisions without direct DML;
  • inspect public release/provenance summaries separately from restricted audit; and
  • retrieve deployment/contract/data/policy identity needed to reproduce a response.

F2-020 defines operation names, request/response envelopes, pagination, generated types, empty/pending/unavailable representation and performance budgets. It cannot weaken this ADR’s version, grant, RLS, identity or definer-routine boundaries.

Implementations must prove at least:

  1. The exposure manifest’s disjoint public/private partitions equal the visible surface and backing dependency sets; OpenAPI/docs contain only the public partition and extra objects, overloads, grants or comments fail release.
  2. PUBLIC, anon, authenticated and app_runtime have zero unapproved schema, storage-table DML, sequence and routine privileges, including default privileges.
  3. The gateway exposes only the API schema; every backing schema is absent from PostgREST configuration and every reachable relation has explicit owner, grant, RLS enable/force state and policy digest.
  4. Role-matrix tests run each public operation as anonymous, two unrelated users, an eligible/ineligible curator, runtime, auditor and provider adapter and compare exact row/field visibility.
  5. Direct SQL attempts cannot forge subject/tenant/actor through parameters, custom GUCs, JWT JSON, SET ROLE, search path or function overloading.
  6. Every client-facing view uses invoker semantics and every cache/materialization remains private and release/visibility-bound.
  7. Definer-routine inspection verifies owner attributes, fixed safe search path, fully qualified objects, exact execute grants, no unsafe dynamic SQL and no storage ownership.
  8. Curation concurrency fixtures verify frozen bytes, approvals, parent/freeze/revocation, idempotency and all-or-nothing apply under the actual RLS role.
  9. Private provenance, diagnostics and hidden relation members cannot be inferred through response content, counts, errors, OpenAPI/comments or related-entity traversal.
  10. Contract snapshots separate logical operations from immutable revisions, prove additive minor behavior, reject incompatible same-major changes and keep supported old majors executable against declared deployment tuples.
  11. Every response/continuation fixture binds exact deployment, operation revision, code, schema, data and applicable policy; arbitrary combinations and unsupported pins never fall forward.
  12. Exact-ID, current-resolution, repetition and multilingual propagation fixtures retain ambiguity/set cardinality after visibility filtering and never expose partial targets.
  13. Service-role fixtures prove zero direct project-table DML, sequence and public/mutating RPC access under current and default privileges; only exact non-domain adapter operations may remain.
  14. Concurrent activation and rollback fixtures lock the active pointer, reject stale predecessors and recheck authority, freeze, disclosure, grants/RLS, compatibility and generated contracts before atomic visibility change.
  15. Browser/public artifacts and logs contain no service-role, migration, audit or break-glass credentials; live roles retain ADR-021 non-bypass/non-admin attributes.
  16. Core and annotation reconstruction hashes remain identical before and after API/RLS migrations, contract deployment, curation calls and rollback/forward-fix exercises.
  • PostgreSQL object drift cannot silently expand the public API.
  • Client code can distinguish contract changes from data/policy updates and reproduce a response against an exact release tuple.
  • RLS, grants and operation validation provide independent least-privilege layers.
  • Public reading stays simple while private drafts, curation and provenance retain separate authority.
  • Historical IDs and old supported majors remain explicit instead of being redirected by current recommendations.
  • Supabase/PostgREST remains usable without treating service-role bypass as application authorization.
  • Every migration must maintain object, grant, owner, RLS and exposure inventories.
  • Old major lines may require compatibility views/routines during their support window.
  • Role-matrix and anti-enumeration tests are broader than ordinary endpoint tests.
  • Definer routines require dedicated ownership, review and exact signature grants.
  • Release-pinned continuations and multi-call flows carry more explicit metadata.

RLS cannot define stable JSON contracts, prevent accidental function grants or hide storage churn. An explicit operation allowlist is required.

PostgREST and direct server paths could bypass that filter, and a missed query predicate would disclose rows. Database grants and RLS remain authoritative.

This bypasses RLS and turns every application bug into unrestricted database access. It is reserved for narrow infrastructure adapters and never constitutes curation authority.

Version by code release or database migration number

Bölüm başlığı “Version by code release or database migration number”

Neither identifies public semantics. Internal changes would create noise while policy or data semantic changes could remain invisible. Independent contract identity is required.

Existing clients and permanent references would silently change meaning. Incompatible behavior receives a new major and support-window record.

Claims can be stale and custom request settings are not sufficient authority for direct database sessions. Critical membership, revocation and freeze state is rechecked in the database.

This needlessly widens impact and can bypass caller RLS. Invoker is default; definer use is narrow, owned and explicitly justified.

  • F2-011 through F2-014 define concrete tables, schemas, entity catalog and permanence.
  • F2-017/F2-018 define migration/release signing and complete compatibility/approval rules.
  • F2-019 defines shared provenance/curation physical contracts.
  • F2-020 defines API/RPC naming, concrete version routing, pagination, error/state envelopes, generated clients and query budgets.
  • F3/F4 implement storage, grants, RLS, views, routines and contract inventory.
  • F8 implements web/API clients and browser E2E role/continuation behavior.
  • F9 certifies reference targets and closes any domain-specific direct-DML/RLS gaps.
  • Production provider adapters define secret brokering and gateway-specific claim verification without weakening this contract.

Revisit this ADR if:

  • managed Supabase cannot expose an allowlisted major surface without granting storage objects or bypassing declared RLS;
  • a valid client workflow requires direct DML that cannot be expressed as a narrow reviewed operation;
  • PostgreSQL invoker/definer or FORCE RLS behavior changes materially in the supported database version;
  • historical contract/data/policy pinning cannot be implemented without silent fallback; or
  • a provider forces a bypass credential into ordinary browser/runtime requests.