From 24b5ce46e7af4edc708d0f278ed48161f442bb1f Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 06:00:09 +0800 Subject: [PATCH 1/2] feat(sdk): deployed-plane DependencyDB read via injected manifest (decision-18 PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Light up the deployed-plane branch of ms.DependencyDB so a deployed consumer reads a producer's exposed table through the SAME ms.DependencyDB(ctx, ref).Select(...).Result(ctx) API used on the dev plane (decision-18 §3). The platform ships the authorized dependency set down the trusted Lambda envelope as a manifest; the SDK looks the producer up, composes a sanitized dynamic SELECT against the platform-supplied physical relation name, and runs it on mod.DB's already-vended consumer-role pool inside a READ ONLY tx. The install-time GRANT SELECT stays the single DB-level authorizer — the manifest is advisory routing only. - runtime: LambdaRequest gains Dependencies []DependencyGrant (json:"dependencies,omitempty"); DependencyGrant is a type alias to db.DependencyGrant so the ctx seam can store the canonical type without a db->runtime import cycle. Threaded through InjectParams. - db: new DependencyGrant{Ref,Tables} (json ref/tables) + WithDependencies / DependenciesFrom ctx seam, parallel to WithSchema/WithCredential/WithPrefix. New TxReadOnly helper (db.Tx is read-write today): pgx READ ONLY tx with the app scope (search_path + ms.app_id) pinned SET LOCAL. - core/select.go: faithful PORT of api-platform internal/shared/database QueryDynamicSelect/DynamicSelect/buildDynamicSelect (shape-gate + pgx.Identifier.Sanitize + $n binding + limit+1 truncation). SDK cannot import api-platform. - core/dependency_db.go: replace the inLambda "dev-plane only" hard-reject with the deployed branch. Fail-closed matrix (decision-18 §5): manifest absent -> keep today's hard error (rollout gate, keeps #31 at 501 until PR1 ships); ref absent -> ErrProducerNotFound; table absent -> ErrNotExposed; 42P01/42501 -> ErrDependencyUnavailable. Conformance tests lock the two cross-repo contracts fail-closed on drift: (a) golden buildDynamicSelect (SQL text + args) byte-identical to api-platform; (b) parseProducerRef normalization equals the platform owner/slug manifest-key reconstruction. Plus the §5 sentinel matrix over a synthetic injected manifest and an integration test (build tag) for the live read + 42P01. No migration. gofmt/build/vet/test green. Merge-collision note: app-module-sdk feat/notify (other session) also edits internal/runtime/lambda.go + inject.go (adds an envelope field + ctx injection). This change keeps the LambdaRequest addition minimal + isolated (one alias line + one field + one InjectParams field + one InjectResources stash) so its later rebase is trivial; only one PR should add the field at a time. PR1 coordination note: the omitempty rollout gate means a manifest-supporting platform that resolves an EMPTY dependency set (e.g. the sole producer was uninstalled after install) sends no field -> the SDK sees nil -> the rollout-gate 501, not the 404 §5 expects for that window. Flagged for PR1 to weigh (non-omitempty empty array or accept the uninstall-window edge). Co-Authored-By: Claude Fable 5 --- db/dependency.go | 46 ++++ db/tx.go | 63 ++++++ internal/core/dependency_db.go | 196 +++++++++++++++--- ...dependency_db_deployed_integration_test.go | 99 +++++++++ internal/core/dependency_db_test.go | 176 ++++++++++++++++ internal/core/select.go | 189 +++++++++++++++++ internal/core/select_test.go | 118 +++++++++++ internal/runtime/inject.go | 17 +- internal/runtime/lambda.go | 24 ++- 9 files changed, 891 insertions(+), 37 deletions(-) create mode 100644 db/dependency.go create mode 100644 internal/core/dependency_db_deployed_integration_test.go create mode 100644 internal/core/select.go create mode 100644 internal/core/select_test.go diff --git a/db/dependency.go b/db/dependency.go new file mode 100644 index 0000000..0da79e7 --- /dev/null +++ b/db/dependency.go @@ -0,0 +1,46 @@ +package db + +import "context" + +// DependencyGrant is one authorized cross-module read target the platform +// resolves at INVOKE time and ships down the trusted Lambda envelope +// (decision 18 §3). It is advisory ROUTING only — never authority: the +// install-time GRANT SELECT on the consumer's r__ role is the +// single DB-level authorizer, and Postgres enforces it on the vended pool no +// matter what this manifest says. +// +// - Ref is the producer keyed by the SAME normalized form the SDK's +// parseProducerRef yields (the bare "slug" the platform reconstructs from +// the producer's owner/slug — see decision 18 §3 step 6). +// - Tables maps each exposed LOGICAL table name ("users") to the physical +// relation the platform computed via ids.PhysicalTableName +// ("m_users"). The SDK never derives the physical name; it reads it +// here. Only tables exposed + consented on the producer's running version +// appear. +// +// The JSON tags are the cross-repo wire contract with api-platform's local +// mirror (moduleinvoke.DependencyGrant — api-platform never imports the SDK). +// A rename on either side surfaces as a decode mismatch, not a silent drop. +type DependencyGrant struct { + Ref string `json:"ref"` + Tables map[string]string `json:"tables"` +} + +const dependenciesKey = contextKey("ms-dependency-manifest") + +// WithDependencies returns a context carrying the platform-resolved dependency +// manifest, parallel to WithSchema / WithCredential / WithPrefix. Set once by +// the Lambda invoke shim (runtime.InjectResources) from the trusted envelope; +// read by the deployed-plane DependencyDB branch. Never set from module input. +func WithDependencies(ctx context.Context, manifest []DependencyGrant) context.Context { + return context.WithValue(ctx, dependenciesKey, manifest) +} + +// DependenciesFrom reads the dependency manifest from the context. Returns nil +// when unset — which the deployed DependencyDB branch treats as "the platform +// does not inject a manifest yet" and fails closed to today's dev-plane-only +// error (the rollout gate: decision 18 §3 read step 1). +func DependenciesFrom(ctx context.Context) []DependencyGrant { + m, _ := ctx.Value(dependenciesKey).([]DependencyGrant) + return m +} diff --git a/db/tx.go b/db/tx.go index cb5c3df..04e3600 100644 --- a/db/tx.go +++ b/db/tx.go @@ -71,3 +71,66 @@ func Tx(ctx context.Context, pool *pgxpool.Pool, fn func(q Querier) error) error // to roll back, which is silent data loss from the caller's perspective. return tx.Commit(context.Background()) } + +// TxReadOnly runs fn inside a READ ONLY transaction (pgx.TxOptions with +// AccessMode: pgx.ReadOnly). Postgres rejects any write attempted inside fn +// with SQLSTATE 25006 regardless of what the connecting role is granted — the +// doubled enforcement the deployed cross-module read runs under (decision 18 +// §2 invariant 2: consumer-role connection + READ ONLY tx). The read executes +// AS whatever role owns pool, so the install-time GRANT is the ceiling. +// +// Unlike Tx, fn receives the raw pgx.Tx: the dynamic-SELECT executor needs +// tx.Query + pgx.CollectRows, which the Querier interface does not expose. The +// app schema (search_path + ms.app_id) is pinned transaction-local from ctx +// (WithSchema) so SET LOCAL auto-clears on COMMIT/ROLLBACK and RLS on the +// producer's exposed relation resolves to this tenant — SET LOCAL is legal in +// a read-only tx. The pool's AfterRelease hook is the defense-in-depth backstop. +func TxReadOnly(ctx context.Context, pool *pgxpool.Pool, fn func(tx pgx.Tx) error) error { + tx, err := pool.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly}) + if err != nil { + return fmt.Errorf("mirrorstack/db: failed to begin read-only transaction: %w", err) + } + + // Panic recovery before any query so a panic still rolls back. safeRollback + // swallows a rollback failure so it cannot mask the original panic. + defer func() { + if p := recover(); p != nil { + safeRollback(tx) + panic(p) + } + }() + + schema := SchemaFrom(ctx) + if schema != "" { + if err := setScopeLocalTx(ctx, tx, schema); err != nil { + safeRollback(tx) + return err + } + } + + if err := fn(tx); err != nil { + safeRollback(tx) + return err + } + + // Read-only tx: nothing to persist, but Commit still ends the tx cleanly + // and returns the connection to the pool. Background ctx so a canceled + // request ctx cannot turn the clean finish into a spurious error. + return tx.Commit(context.Background()) +} + +// setScopeLocalTx pins search_path + ms.app_id transaction-local on an +// already-begun tx, mirroring applyScope(local=true) but for a pgx.Tx obtained +// via pool.BeginTx (applyScope needs a *pgxpool.Conn). SET LOCAL / set_config +// is_local=true auto-clear on COMMIT/ROLLBACK. The schema is Sanitize()d before +// it reaches SQL text; the GUC value is bound as $1. +func setScopeLocalTx(ctx context.Context, tx pgx.Tx, schema string) error { + sanitized := pgx.Identifier{schema}.Sanitize() + if _, err := tx.Exec(ctx, "SET LOCAL search_path TO "+sanitized); err != nil { + return fmt.Errorf("mirrorstack/db: failed to set search_path: %w", err) + } + if _, err := tx.Exec(ctx, "SELECT set_config('ms.app_id', $1, true)", schema); err != nil { + return fmt.Errorf("mirrorstack/db: failed to set ms.app_id: %w", err) + } + return nil +} diff --git a/internal/core/dependency_db.go b/internal/core/dependency_db.go index 1d5aa47..74a072a 100644 --- a/internal/core/dependency_db.go +++ b/internal/core/dependency_db.go @@ -11,24 +11,37 @@ import ( "net/url" "os" "regexp" + "sort" "strings" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/mirrorstack-ai/app-module-sdk/db" "github.com/mirrorstack-ai/app-module-sdk/internal/runtime" ) // ms.DependencyDB — the RESTRICTED consumer accessor for reading a producer -// module's exposed tables (decision 17 §2, option (d): the SDK client of the -// platform read proxy). +// module's exposed tables. PLANE-TRANSPARENT: the same call works on both +// planes and the switch lives entirely inside result(ctx, runtime.IsLambda()). // // A consumer that declared ms.DependsOn("@owner/producer", n.Table("users")) -// reads those rows through the platform's read-exposed proxy — a STRUCTURED -// read (table + projection + equality/IN filters, never raw SQL) that the -// platform authorizes against the same consent+exposure catalog the -// install-time grant walk uses and executes as the consumer's own -// r__ role inside a READ ONLY transaction. There is deliberately -// NO raw SQL surface and NO pool here: the dev plane holds no socket to the -// platform DB, so a cross-plane SQL JOIN is structurally impossible — -// fetch the exposed rows, then join in application code. +// issues a STRUCTURED read (table + projection + equality/IN filters, never +// raw SQL), authorized against the same consent+exposure catalog the +// install-time grant walk uses and executed as the consumer's own +// r__ role inside a READ ONLY transaction: +// +// - DEV plane (decision 17 §2, option (d)): shipped over the platform's +// read-exposed proxy, authenticated by the live dev-tunnel session secret. +// The dev plane holds no socket to the platform DB, so a cross-plane SQL +// JOIN is structurally impossible — fetch, then join in app code. +// - DEPLOYED plane (decision 18 §3): the platform ships the authorized +// dependency set down the trusted Lambda envelope as a manifest; the SDK +// composes a sanitized dynamic SELECT against the platform-supplied +// physical relation name and runs it on mod.DB's already-vended +// consumer-role pool. Same fetch-then-join floor. +// +// Fetch-then-join-in-app-code is the PERMANENT contract on both planes. // // rows, err := ms.DependencyDB(ctx, "@owner/oauth-core"). // Select("users"). @@ -43,11 +56,10 @@ import ( // ErrDependencyUnavailable / ErrProducerNotFound to branch on the // decision-17 failure modes. // -// DEV-PLANE ONLY today: the proxy authenticates the LIVE dev-tunnel session -// (the CLI-minted MS_INTERNAL_SECRET). A deployed consumer reads a -// co-located producer directly via mod.DB (the GRANT SELECT path); wiring -// DependencyDB for deployed consumers (envelope-vended proxy credentials) is -// a documented follow-up. +// Rollout gate: a deployed consumer running under a platform that does not yet +// inject the dependency manifest keeps failing closed with the "dev-plane only" +// error, so a consumer route stays fail-closed (e.g. 501) until both the +// platform (decision 18 PR 1) and this SDK ship. // Sentinel errors mapping the read proxy's wire error codes onto the // decision-17 failure modes. Match with errors.Is; the returned errors wrap @@ -92,10 +104,11 @@ var dependencySQLName = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`) // Construction never fails — a bad ref or missing app scope is carried // forward and surfaced by Rows/Result, keeping call sites chainable. type Dependency struct { - consumer string // this module's Config.ID — the proxy verifies it IS the caller - producer string // producer ref resolved within the same app (slug | UUID | m) - appID string // trusted app scope from ctx (auth identity), never caller-supplied - err error // deferred construction error (bad ref, no app scope) + mod *Module // owner module — the deployed branch reads its consumer-role pool + consumer string // this module's Config.ID — the proxy verifies it IS the caller + producer string // producer ref resolved within the same app (slug | UUID | m) + appID string // trusted app scope from ctx (auth identity), never caller-supplied + err error // deferred construction error (bad ref, no app scope) } // DependencyDB returns a read handle on producerRef's exposed tables within @@ -110,7 +123,7 @@ type Dependency struct { // WhereIn/Limit). It is not a *pgx pool and never will be — see the package // comment for why raw cross-plane SQL cannot exist. func (m *Module) DependencyDB(ctx context.Context, producerRef string) *Dependency { - d := &Dependency{consumer: m.config.ID} + d := &Dependency{mod: m, consumer: m.config.ID} appID, err := appIDFromContext(ctx, "DependencyDB") if err != nil { d.err = err @@ -192,8 +205,10 @@ func (q *DependencyQuery) setErr(err error) *DependencyQuery { return q } -// Columns restricts the projection to the named columns (all visible -// columns when never called). Accumulates across calls; at most 64 names. +// Columns restricts the projection to the named columns. Accumulates across +// calls; at most 64 names. On the dev plane an empty projection returns all +// visible columns (the proxy resolves them); the deployed plane requires at +// least one column — the blessed dynamic-SELECT builder never emits SELECT *. func (q *DependencyQuery) Columns(cols ...string) *DependencyQuery { if q.err != nil { return q @@ -335,11 +350,7 @@ func (q *DependencyQuery) result(ctx context.Context, inLambda bool) (*Dependenc return nil, q.err } if inLambda { - // The proxy authenticates a LIVE dev-tunnel session; a deployed - // consumer has none. Deployed->deployed reads use the direct GRANT - // via mod.DB (decision 17 resolution matrix); vending proxy - // credentials to deployed consumers is a documented follow-up. - return nil, errors.New("mirrorstack: DependencyDB is dev-plane only — a deployed module reads a co-located producer's exposed tables via mod.DB (GRANT SELECT); cross-plane deployed reads are not supported") + return q.resultDeployed(ctx) } // The proxy binds the caller to its live tunnel session by the session's // InternalSecret — the exact value the CLI exports as MS_INTERNAL_SECRET @@ -405,6 +416,137 @@ func (q *DependencyQuery) result(ctx context.Context, inLambda bool) (*Dependenc return &DependencyResult{Rows: out.Rows, Truncated: out.Truncated}, nil } +// errDevPlaneOnly is the pre-decision-18 hard error. It is returned ONLY when +// no dependency manifest is present in ctx — an old platform that does not yet +// inject the manifest (the rollout gate: #31 stays 501 until BOTH sides ship). +// #31's crossReadStatus recognizes the deployed rejection by the literal +// "dev-plane only" substring, so that phrase MUST stay in the message. +var errDevPlaneOnly = errors.New("mirrorstack: DependencyDB is dev-plane only — a deployed module reads a co-located producer's exposed tables via mod.DB (GRANT SELECT); cross-plane deployed reads are not supported") + +// resultDeployed is the deployed-plane branch (decision 18 §3): resolve the +// producer + physical relation from the platform-injected manifest, then read +// it AS the module's own consumer-role pool inside a READ ONLY tx. The manifest +// is advisory routing only — Postgres enforces the install-time GRANT ceiling +// on the vended connection, so a wrong/forged manifest name cannot over-read +// (42501 → ErrDependencyUnavailable). Every degradation is a typed fail-closed +// sentinel; none returns silent-empty. +func (q *DependencyQuery) resultDeployed(ctx context.Context) (*DependencyResult, error) { + // Layer-1 fail-closed: manifest-absent + manifest-omission (decision 18 §5). + manifest := db.DependenciesFrom(ctx) + if manifest == nil { + // No manifest injected → old platform. Keep TODAY's hard error so #31 + // stays 501 until PR 1 (platform) also ships. (decision 18 §3 read step 1) + return nil, errDevPlaneOnly + } + grant, ok := lookupDependency(manifest, q.dep.producer) + if !ok { + // Ref absent from the manifest: producer uninstalled/yanked or never a + // declared dependency. Collapses to one anti-probing verdict (§2 inv 10). + return nil, fmt.Errorf("%w: producer %q is not an available dependency of this module", ErrProducerNotFound, q.dep.producer) + } + physical, ok := grant.Tables[q.table] + if !ok { + // Table not in the grant: not exposed on the running version, exposure + // removed, or consent removed — deliberately indistinguishable (§5). + return nil, fmt.Errorf("%w: %q is not exposed to this module by %q on its running version", ErrNotExposed, q.table, q.dep.producer) + } + + // Schema comes from the trusted envelope (SchemaFrom), never module input; + // the physical name comes only from the manifest. The shape-gate + + // Sanitize in buildDynamicSelect is the last-line defense on both. + ds := DynamicSelect{ + Schema: db.SchemaFrom(ctx), + Table: physical, + Columns: q.columns, + Filters: filtersToSelect(q.filters), + Limit: q.limit, + } + + // mod.DB's consumer-role pool: db.CredentialFrom(ctx) → r__, + // the same role the install-time GRANT SELECT targets. resolvePool refcount- + // pins it until release. + pool, release, err := q.dep.mod.resolvePool(ctx) + if err != nil { + return nil, fmt.Errorf("mirrorstack: DependencyDB: acquire consumer pool: %w", err) + } + defer release() + + var rows []map[string]any + var truncated bool + err = db.TxReadOnly(ctx, pool, func(tx pgx.Tx) error { + var e error + rows, truncated, e = queryDynamicSelect(ctx, tx, ds) + return e + }) + if err != nil { + // Layer-2 fail-closed: physical/grant state at read time (decision 18 §5). + return nil, mapDeployedReadError(err) + } + if rows == nil { + rows = []map[string]any{} + } + return &DependencyResult{Rows: rows, Truncated: truncated}, nil +} + +// lookupDependency finds the manifest entry for a producer ref. The manifest is +// keyed by the SAME normalized form parseProducerRef yields (the bare slug the +// platform reconstructs from owner/slug — decision 18 §3 step 6), and +// q.dep.producer is already that normalized form. A miss fails closed +// (ErrProducerNotFound), never over-reads — the conformance test locks this +// key contract against drift. +func lookupDependency(manifest []db.DependencyGrant, producer string) (db.DependencyGrant, bool) { + for _, g := range manifest { + if g.Ref == producer { + return g, true + } + } + return db.DependencyGrant{}, false +} + +// filtersToSelect converts the builder's column→value map into the ordered +// SelectFilter slice the dynamic SELECT composes. Columns are sorted so the +// generated SQL text + $n numbering are deterministic (AND-order is +// semantically irrelevant); a scalar becomes a one-value equality, an []any a +// multi-value IN. +func filtersToSelect(filters map[string]any) []SelectFilter { + if len(filters) == 0 { + return nil + } + cols := make([]string, 0, len(filters)) + for c := range filters { + cols = append(cols, c) + } + sort.Strings(cols) + out := make([]SelectFilter, 0, len(cols)) + for _, c := range cols { + if vs, ok := filters[c].([]any); ok { // WhereIn stored the []any as-is + out = append(out, SelectFilter{Column: c, Values: vs}) + continue + } + out = append(out, SelectFilter{Column: c, Values: []any{filters[c]}}) // Where scalar + } + return out +} + +// mapDeployedReadError maps the READ ONLY tx's Postgres errors onto the typed +// sentinels (decision 18 §5 layer 2). 42P01 (producer dropped/renamed the +// relation) and 42501 (GRANT revoked) both mean the dependency is not readable +// right now → ErrDependencyUnavailable, never silent-empty. Anything else +// (incl. 25006, a builder-bug write on the read-only tx) wraps generically — +// still an error, still fail-closed. +func mapDeployedReadError(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case "42P01": // undefined_table + return fmt.Errorf("%w: producer relation is not present (SQLSTATE %s)", ErrDependencyUnavailable, pgErr.Code) + case "42501": // insufficient_privilege + return fmt.Errorf("%w: read privilege was revoked (SQLSTATE %s)", ErrDependencyUnavailable, pgErr.Code) + } + } + return fmt.Errorf("mirrorstack: DependencyDB: deployed read failed: %w", err) +} + // mapReadExposedError translates the proxy's error envelope // ({"error":{"code","message"}}) into the typed sentinels. Fail-closed by // construction: anything unrecognized is still an error, never empty rows. diff --git a/internal/core/dependency_db_deployed_integration_test.go b/internal/core/dependency_db_deployed_integration_test.go new file mode 100644 index 0000000..cb1399d --- /dev/null +++ b/internal/core/dependency_db_deployed_integration_test.go @@ -0,0 +1,99 @@ +//go:build integration + +package core + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/mirrorstack-ai/app-module-sdk/auth" + "github.com/mirrorstack-ai/app-module-sdk/db" +) + +// TestDependencyDB_DeployedRead_Integration exercises the deployed branch +// (decision 18 §3) end to end against a real pool: it composes the dynamic +// SELECT from an injected manifest, runs it in a READ ONLY tx as the pool's +// role, and returns fetch-then-join rows + a Truncated flag. It also proves the +// §5 layer-2 fail-closed: dropping the physical relation surfaces 42P01 as +// ErrDependencyUnavailable, never a silent empty. +// +// Uses the SDK dev pool (resolvePool with no ctx credential) as the consumer +// role — the GRANT ceiling is exercised by the Phase-2 platform E2E +// (decision 18 §7 step 2), not here; this test locks the SDK read mechanics. +func TestDependencyDB_DeployedRead_Integration(t *testing.T) { + setup, err := db.Open(context.Background()) + if err != nil { + t.Skipf("skipping: cannot connect to postgres: %v", err) + } + t.Cleanup(setup.Close) + + m, err := New(Config{ID: "m1234abcd"}) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(m.Close) + + ctx := context.Background() + const schema = "app_dep_deployed_test" + const physical = "m81b3ac7081c1409495700c761e23b59e_users" + + mustExecRaw(t, setup, ctx, `DROP SCHEMA IF EXISTS `+schema+` CASCADE`) + mustExecRaw(t, setup, ctx, `CREATE SCHEMA `+schema) + t.Cleanup(func() { _, _ = setup.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schema+` CASCADE`) }) + mustExecRaw(t, setup, ctx, `CREATE TABLE `+schema+`."`+physical+`" (id bigint, email text, deleted_at timestamptz)`) + mustExecRaw(t, setup, ctx, `INSERT INTO `+schema+`."`+physical+`" (id, email) VALUES (9007199254740993, 'a@b.c'), (2, 'c@d.e')`) + + readCtx := auth.Set(ctx, auth.Identity{AppID: "app-uuid-1"}) + readCtx = db.WithSchema(readCtx, schema) + readCtx = db.WithDependencies(readCtx, []db.DependencyGrant{ + {Ref: "oauth-core", Tables: map[string]string{"users": physical}}, + }) + + // Limit(1) over 2 rows → truncated. Force the deployed branch via the seam. + res, err := m.DependencyDB(readCtx, "@mirrorstack/oauth-core"). + Select("users"). + Columns("id", "email", "deleted_at"). + Limit(1). + result(readCtx, true /* inLambda */) + if err != nil { + t.Fatalf("deployed read: %v", err) + } + if len(res.Rows) != 1 { + t.Fatalf("rows = %d, want 1 (cut at limit)", len(res.Rows)) + } + if !res.Truncated { + t.Errorf("truncated = false, want true (2 rows, limit 1)") + } + // pgx.RowToMap decodes bigint natively as int64 — full fidelity for a join + // key that would round-trip lossily as float64. #31's stringField reads it + // via its default fmt.Sprint branch. + switch v := res.Rows[0]["id"].(type) { + case int64: + if v != 9007199254740993 { + t.Errorf("id = %d, want 9007199254740993", v) + } + case json.Number: + if v.String() != "9007199254740993" { + t.Errorf("id = %s, want 9007199254740993", v) + } + default: + t.Errorf("id decoded as %T, want int64 (native) or json.Number", res.Rows[0]["id"]) + } + + // §5 layer 2: drop the relation → 42P01 → ErrDependencyUnavailable. + mustExecRaw(t, setup, ctx, `DROP TABLE `+schema+`."`+physical+`"`) + _, err = m.DependencyDB(readCtx, "@mirrorstack/oauth-core"). + Select("users").Columns("id").result(readCtx, true) + if !errors.Is(err, ErrDependencyUnavailable) { + t.Errorf("after DROP TABLE: err = %v, want ErrDependencyUnavailable (42P01)", err) + } +} + +func mustExecRaw(t *testing.T, d *db.DB, ctx context.Context, sql string) { + t.Helper() + if _, err := d.Exec(ctx, sql); err != nil { + t.Fatalf("setup exec %q: %v", sql, err) + } +} diff --git a/internal/core/dependency_db_test.go b/internal/core/dependency_db_test.go index 0b692d7..8acd4f0 100644 --- a/internal/core/dependency_db_test.go +++ b/internal/core/dependency_db_test.go @@ -10,7 +10,10 @@ import ( "strings" "testing" + "github.com/jackc/pgx/v5/pgconn" + "github.com/mirrorstack-ai/app-module-sdk/auth" + "github.com/mirrorstack-ai/app-module-sdk/db" ) // depTestModule builds a module + app-scoped ctx wired at a fake dispatch. @@ -419,3 +422,176 @@ func TestDependencyDB_PackageLevelPanicsBeforeInit(t *testing.T) { }() DependencyDB(context.Background(), "oauth-core") } + +// --------------------------------------------------------------------------- +// Deployed plane (decision 18 §3): the inLambda branch reads the injected +// manifest. These cover the §5 sentinel matrix cells that fail closed BEFORE +// touching a pool (manifest-absent, ref-absent, table-absent) plus the +// read-time SQLSTATE mapping. The happy-path read + live 42P01/42501 live in +// dependency_db_deployed_integration_test.go (build tag `integration`). +// --------------------------------------------------------------------------- + +// deployedCtx builds an app-scoped context carrying the given dependency +// manifest, as the Lambda invoke shim would (db.WithSchema + db.WithDependencies). +func deployedCtx(manifest []db.DependencyGrant) context.Context { + ctx := auth.Set(context.Background(), auth.Identity{AppID: "app-uuid-1", UserID: "u1", AppRole: auth.RoleAdmin}) + ctx = db.WithSchema(ctx, "app_283e0ef9_1a2b_3c4d_5e6f_0123456789ab") + if manifest != nil { + ctx = db.WithDependencies(ctx, manifest) + } + return ctx +} + +func TestDependencyDB_DeployedManifestAbsentIsRolloutGate(t *testing.T) { + m, err := New(Config{ID: "m1234abcd"}) + if err != nil { + t.Fatalf("New: %v", err) + } + ctx := deployedCtx(nil) // no db.WithDependencies → old-platform rollout gate + + _, err = m.DependencyDB(ctx, "@mirrorstack/oauth-core").Select("users").result(ctx, true) + if err == nil || !strings.Contains(err.Error(), "dev-plane only") { + t.Errorf("err = %v, want the today's hard error containing \"dev-plane only\" (#31 stays 501)", err) + } + // Must carry no typed sentinel — #31 recognizes it only by the substring. + for _, s := range []error{ErrProducerNotFound, ErrNotExposed, ErrDependencyUnavailable, ErrDependencyUnauthorized} { + if errors.Is(err, s) { + t.Errorf("rollout-gate error matched sentinel %v; must be a plain error", s) + } + } +} + +func TestDependencyDB_DeployedProducerNotInManifest(t *testing.T) { + m, _ := New(Config{ID: "m1234abcd"}) + // Manifest present but keyed for a DIFFERENT producer → ref absent. + ctx := deployedCtx([]db.DependencyGrant{ + {Ref: "some-other-mod", Tables: map[string]string{"widgets": "mdeadbeef_widgets"}}, + }) + + _, err := m.DependencyDB(ctx, "@mirrorstack/oauth-core").Select("users").result(ctx, true) + if !errors.Is(err, ErrProducerNotFound) { + t.Errorf("err = %v, want ErrProducerNotFound (ref absent from manifest)", err) + } +} + +func TestDependencyDB_DeployedTableNotExposed(t *testing.T) { + m, _ := New(Config{ID: "m1234abcd"}) + // Producer present, but the requested table is not in its exposed set. + ctx := deployedCtx([]db.DependencyGrant{ + {Ref: "oauth-core", Tables: map[string]string{"sessions": "m81b3ac70_sessions"}}, + }) + + _, err := m.DependencyDB(ctx, "@mirrorstack/oauth-core").Select("users").result(ctx, true) + if !errors.Is(err, ErrNotExposed) { + t.Errorf("err = %v, want ErrNotExposed (table not in manifest .Tables)", err) + } +} + +func TestMapDeployedReadError(t *testing.T) { + cases := []struct { + name string + err error + want error // sentinel it must errors.Is; nil = must NOT match any dep sentinel + matches bool + }{ + {"42P01 undefined_table", &pgconn.PgError{Code: "42P01"}, ErrDependencyUnavailable, true}, + {"42501 insufficient_privilege", &pgconn.PgError{Code: "42501"}, ErrDependencyUnavailable, true}, + {"25006 read_only_write is generic", &pgconn.PgError{Code: "25006"}, ErrDependencyUnavailable, false}, + {"non-pg error is generic", errors.New("boom"), ErrDependencyUnavailable, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := mapDeployedReadError(tc.err) + if got == nil { + t.Fatalf("mapDeployedReadError returned nil (never swallow a read error)") + } + if errors.Is(got, tc.want) != tc.matches { + t.Errorf("errors.Is(%v, ErrDependencyUnavailable) = %v, want %v", got, errors.Is(got, tc.want), tc.matches) + } + }) + } +} + +func TestFiltersToSelect_DeterministicSortedOrder(t *testing.T) { + // Map iteration is unordered; the SELECT's $n numbering must not be. Columns + // sort ascending; scalars become one-value equality, []any becomes IN. + got := filtersToSelect(map[string]any{ + "status": "active", + "id": []any{1, 2}, + "email": "a@b.c", + }) + want := []SelectFilter{ + {Column: "email", Values: []any{"a@b.c"}}, + {Column: "id", Values: []any{1, 2}}, + {Column: "status", Values: []any{"active"}}, + } + if len(got) != len(want) { + t.Fatalf("len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i].Column != want[i].Column { + t.Errorf("filter[%d].Column = %q, want %q (must be sorted)", i, got[i].Column, want[i].Column) + } + } +} + +// TestPlatformRefKeyConformance is the second cross-repo conformance lock +// (decision 18 §7 PR2): the manifest key the platform emits per producer must +// equal the SDK's parseProducerRef normalization of any ref form the consumer +// declares that producer by. The platform reverse-maps producerUUID → +// (owner, slug) and keys by the normalized ref (decision 18 §3 step 6); a +// consumer names the producer "@owner/slug[@constraint]" or bare "slug". If +// these two normalizations drift, the SDK lookup fails closed +// (ErrProducerNotFound) — never over-reads — but the read stops working, so we +// freeze the agreement here. +func TestPlatformRefKeyConformance(t *testing.T) { + // platformManifestKey models PR1's reconstruction: the manifest entry for a + // producer is keyed by the bare slug (what parseProducerRef yields for the + // "@owner/slug" a consumer declares). Modeled independently of + // parseProducerRef so a change to either surfaces here. + platformManifestKey := func(owner, slug string) string { return slug } + + const owner, slug = "mirrorstack", "oauth-core" + key := platformManifestKey(owner, slug) + + // Every slug-bearing consumer ref form must normalize to the platform key. + slugForms := []string{ + "@" + owner + "/" + slug, // canonical + "@" + owner + "/" + slug + "@^0.1", // + version constraint (the real #31 form) + slug, // bare slug + slug + "@^1", // bare + constraint + } + for _, ref := range slugForms { + got, err := parseProducerRef(ref) + if err != nil { + t.Errorf("parseProducerRef(%q): %v", ref, err) + continue + } + if got != key { + t.Errorf("parseProducerRef(%q) = %q, want platform key %q", ref, got, key) + } + } + + // ID-form refs (m, dashed UUID) pass through unchanged: the platform + // only ever emits slug keys, so a consumer that declares its dependency by + // ID form fails closed (ErrProducerNotFound) against a slug-keyed manifest. + // Documented, not a match — locking the pass-through so it can't silently + // start rewriting IDs into something that spuriously collides with a slug. + idForms := map[string]string{ + "m0e37bd82f0f5427a80549b6a5aebd3a8": "m0e37bd82f0f5427a80549b6a5aebd3a8", + "0e37bd82-f0f5-427a-8054-9b6a5aebd3a8": "0e37bd82-f0f5-427a-8054-9b6a5aebd3a8", + } + for ref, want := range idForms { + got, err := parseProducerRef(ref) + if err != nil { + t.Errorf("parseProducerRef(%q): %v", ref, err) + continue + } + if got != want { + t.Errorf("parseProducerRef(%q) = %q, want %q (unchanged pass-through)", ref, got, want) + } + if got == key { + t.Errorf("ID-form %q normalized to the slug key %q — must NOT collide", ref, key) + } + } +} diff --git a/internal/core/select.go b/internal/core/select.go new file mode 100644 index 0000000..ff3321e --- /dev/null +++ b/internal/core/select.go @@ -0,0 +1,189 @@ +package core + +// THE DEPLOYED-PLANE DYNAMIC-SELECT INTERPOLATION POINT (SDK side). +// +// This is a FAITHFUL PORT of api-platform's blessed dynamic SELECT +// (internal/shared/database/select.go — QueryDynamicSelect / DynamicSelect / +// buildDynamicSelect). The deployed cross-module read (decision 18 §3) needs a +// statement whose identifiers are only known at request time — a per-producer +// physical relation name (m_, supplied by the platform manifest, not +// derived here), a caller-chosen projection, and per-request filter columns. +// The SDK CANNOT import api-platform, so the one dynamic SELECT it is allowed to +// compose is ported here where it can be reviewed as a unit. The golden test in +// select_test.go locks (SQL text + args) byte-for-byte against the api-platform +// source so the two cannot drift. +// +// Injection is impossible by construction, on three independent layers, exactly +// as in the source: +// +// 1. manifest whitelist (PLATFORM-OWNED) — the physical Table name comes only +// from the trusted injected manifest (DependencyGrant.Tables), never module +// input; columns/filter columns are logical names the consumer declared. +// 2. name-shape gate (ENFORCED HERE) — every identifier must match +// selectIdentPattern (lowercase snake_case within Postgres's 63-byte budget, +// the only shape ExposeTable / the migration DSL can produce). Quotes, +// semicolons, dots, uppercase, spaces, overlong names all fail with +// errUnsafeSelectIdentifier before any SQL text is built. +// 3. identifier quoting (ENFORCED HERE) — every identifier still goes through +// pgx.Identifier.Sanitize() (double-quote + escape). +// +// Filter VALUES are never interpolated — always bound as $n positional +// parameters. The only non-identifier text is the LIMIT, a clamped int owned +// here. + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/jackc/pgx/v5" +) + +const ( + // defaultDynamicSelectLimit is applied when the caller sends no limit (or a + // non-positive one). Mirrors api-platform DefaultDynamicSelectLimit. + defaultDynamicSelectLimit = 200 + // maxDynamicSelectLimit is the hard row ceiling per dynamic read — a + // fetch-then-join-in-app-code surface, not a bulk-export channel. Mirrors + // api-platform MaxDynamicSelectLimit. + maxDynamicSelectLimit = 2000 +) + +// errUnsafeSelectIdentifier reports an identifier that failed the name-shape +// gate. Reaching it means an upstream validation layer was bypassed. +var errUnsafeSelectIdentifier = errors.New("mirrorstack: unsafe dynamic identifier") + +// selectIdentPattern is the only identifier shape a dynamic SELECT accepts: +// lowercase snake_case, starting with a letter, within Postgres's 63-byte +// budget. Byte-identical to api-platform's safeIdentifierPattern and to +// dependencySQLName; a physical relation name (m_
) also matches it. +var selectIdentPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`) + +// SelectFilter is one WHERE predicate: Column = value (one Values entry) or +// Column IN (...) (several). Values are always bound as $n parameters. +type SelectFilter struct { + Column string + Values []any +} + +// DynamicSelect describes the one dynamic statement shape composed here: a +// projection of whitelisted columns from one physical relation, ANDed +// equality/IN filters, and a row limit. The caller NEVER supplies SQL. +type DynamicSelect struct { + Schema string + Table string // physical relation name from the manifest, e.g. m_
+ Columns []string + Filters []SelectFilter + Limit int // <=0 → defaultDynamicSelectLimit; clamped to maxDynamicSelectLimit +} + +// queryDynamicSelect composes and executes q inside the caller-supplied READ +// ONLY transaction and returns the rows as column→value maps plus whether the +// read was cut at the limit. Pair it with db.TxReadOnly so Postgres itself +// rejects any write on the same tx (SQLSTATE 25006). pgx.RowToMap decodes +// numeric columns to their native Go types (int64 for bigint) so join keys keep +// full fidelity — the consumer's stringField reads them via its default branch. +func queryDynamicSelect(ctx context.Context, tx pgx.Tx, q DynamicSelect) (_ []map[string]any, truncated bool, _ error) { + sql, args, limit, err := buildDynamicSelect(q) + if err != nil { + return nil, false, err + } + + rows, err := tx.Query(ctx, sql, args...) + if err != nil { + return nil, false, err + } + out, err := pgx.CollectRows(rows, pgx.RowToMap) // closes rows; non-nil even when empty + if err != nil { + return nil, false, err + } + + // The SELECT asked for limit+1 rows; an overflow row means truncation. + if len(out) > limit { + out = out[:limit] + truncated = true + } + return out, truncated, nil +} + +// buildDynamicSelect composes the parameterized SELECT text. Every identifier +// is shape-gated then Sanitize()d; every filter value becomes a $n parameter. +// Returns the effective (clamped) limit — the statement asks for limit+1 rows +// so the executor can report truncation without a second COUNT query. This is +// the byte-for-byte port the golden test locks against api-platform. +func buildDynamicSelect(q DynamicSelect) (sql string, args []any, limit int, err error) { + if err := requireSafeSelectIdentifiers(q); err != nil { + return "", nil, 0, err + } + if len(q.Columns) == 0 { + return "", nil, 0, errors.New("mirrorstack: dynamic select requires at least one column") + } + + var sb strings.Builder + sb.WriteString("SELECT ") + for i, c := range q.Columns { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(pgx.Identifier{c}.Sanitize()) + } + sb.WriteString(" FROM ") + sb.WriteString(pgx.Identifier{q.Schema, q.Table}.Sanitize()) + + for i, f := range q.Filters { + if len(f.Values) == 0 { + return "", nil, 0, fmt.Errorf("mirrorstack: dynamic select filter %q has no values", f.Column) + } + if i == 0 { + sb.WriteString(" WHERE ") + } else { + sb.WriteString(" AND ") + } + ident := pgx.Identifier{f.Column}.Sanitize() + if len(f.Values) == 1 { + args = append(args, f.Values[0]) + fmt.Fprintf(&sb, "%s = $%d", ident, len(args)) + continue + } + placeholders := make([]string, len(f.Values)) + for j, v := range f.Values { + args = append(args, v) + placeholders[j] = fmt.Sprintf("$%d", len(args)) + } + fmt.Fprintf(&sb, "%s IN (%s)", ident, strings.Join(placeholders, ", ")) + } + + limit = clampDynamicSelectLimit(q.Limit) + fmt.Fprintf(&sb, " LIMIT %d", limit+1) + return sb.String(), args, limit, nil +} + +// requireSafeSelectIdentifiers runs the name-shape gate over every identifier. +func requireSafeSelectIdentifiers(q DynamicSelect) error { + idents := []string{q.Schema, q.Table} + idents = append(idents, q.Columns...) + for _, f := range q.Filters { + idents = append(idents, f.Column) + } + for _, id := range idents { + if !selectIdentPattern.MatchString(id) { + return fmt.Errorf("%w: %q", errUnsafeSelectIdentifier, id) + } + } + return nil +} + +// clampDynamicSelectLimit normalizes the caller's limit: <=0 → the default, +// above the ceiling → the ceiling. +func clampDynamicSelectLimit(n int) int { + switch { + case n <= 0: + return defaultDynamicSelectLimit + case n > maxDynamicSelectLimit: + return maxDynamicSelectLimit + default: + return n + } +} diff --git a/internal/core/select_test.go b/internal/core/select_test.go new file mode 100644 index 0000000..058f5ac --- /dev/null +++ b/internal/core/select_test.go @@ -0,0 +1,118 @@ +package core + +import ( + "errors" + "reflect" + "testing" +) + +// TestBuildDynamicSelect_GoldenMatchesApiPlatform is the cross-repo conformance +// GOLDEN: the ported buildDynamicSelect must emit byte-identical (SQL text + +// args) output to api-platform's internal/shared/database.QueryDynamicSelect +// (buildDynamicSelect) for a representative shape. The SDK cannot import +// api-platform, so this frozen string IS the contract — if either side's +// composition drifts (projection order, quoting, $n numbering, limit+1, +// WHERE/AND joins), this test fails closed rather than letting the deployed +// read silently diverge from the blessed platform builder. +// +// Shape mirrors the real oauth-google → oauth-core users read: a physical +// relation the platform computed via ids.PhysicalTableName +// (m_users), a projection, one equality filter, one IN filter, and a +// caller limit (asks for limit+1 to detect truncation). +func TestBuildDynamicSelect_GoldenMatchesApiPlatform(t *testing.T) { + q := DynamicSelect{ + Schema: "app_283e0ef9_1a2b_3c4d_5e6f_0123456789ab", + Table: "m81b3ac7081c1409495700c761e23b59e_users", + Columns: []string{"id", "display_name", "email"}, + Filters: []SelectFilter{ + {Column: "status", Values: []any{"active"}}, + {Column: "id", Values: []any{1, 2, 3}}, + }, + Limit: 500, + } + + const wantSQL = `SELECT "id", "display_name", "email" ` + + `FROM "app_283e0ef9_1a2b_3c4d_5e6f_0123456789ab"."m81b3ac7081c1409495700c761e23b59e_users" ` + + `WHERE "status" = $1 AND "id" IN ($2, $3, $4) LIMIT 501` + wantArgs := []any{"active", 1, 2, 3} + + gotSQL, gotArgs, limit, err := buildDynamicSelect(q) + if err != nil { + t.Fatalf("buildDynamicSelect: %v", err) + } + if gotSQL != wantSQL { + t.Errorf("SQL drift from api-platform QueryDynamicSelect:\n got: %s\nwant: %s", gotSQL, wantSQL) + } + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Errorf("args = %#v, want %#v", gotArgs, wantArgs) + } + if limit != 500 { + t.Errorf("effective limit = %d, want 500 (SQL asks for limit+1 = 501)", limit) + } +} + +func TestBuildDynamicSelect_LimitClampAndDefault(t *testing.T) { + cases := []struct { + name string + limit int + wantLimit int // effective; SQL asks for wantLimit+1 + }{ + {"zero → default", 0, defaultDynamicSelectLimit}, + {"negative → default", -5, defaultDynamicSelectLimit}, + {"in range kept", 750, 750}, + {"above ceiling → clamped", 9999, maxDynamicSelectLimit}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, limit, err := buildDynamicSelect(DynamicSelect{ + Schema: "app_x", Table: "m_users", Columns: []string{"id"}, Limit: tc.limit, + }) + if err != nil { + t.Fatalf("buildDynamicSelect: %v", err) + } + if limit != tc.wantLimit { + t.Errorf("effective limit = %d, want %d", limit, tc.wantLimit) + } + }) + } +} + +func TestBuildDynamicSelect_ShapeGateRejectsUnsafeIdentifiers(t *testing.T) { + base := DynamicSelect{Schema: "app_x", Table: "m_users", Columns: []string{"id"}} + + mutate := map[string]func(DynamicSelect) DynamicSelect{ + "schema with quote": func(q DynamicSelect) DynamicSelect { q.Schema = `app_x"; drop`; return q }, + "schema empty": func(q DynamicSelect) DynamicSelect { q.Schema = ""; return q }, + "table with semicolon": func(q DynamicSelect) DynamicSelect { q.Table = "m_users; drop table x"; return q }, + "table with dot": func(q DynamicSelect) DynamicSelect { q.Table = "public.users"; return q }, + "uppercase column": func(q DynamicSelect) DynamicSelect { q.Columns = []string{"Id"}; return q }, + "column with dash": func(q DynamicSelect) DynamicSelect { q.Columns = []string{"e-mail"}; return q }, + "filter col injection": func(q DynamicSelect) DynamicSelect { + q.Filters = []SelectFilter{{Column: "id = 1 OR", Values: []any{1}}} + return q + }, + } + for name, m := range mutate { + t.Run(name, func(t *testing.T) { + _, _, _, err := buildDynamicSelect(m(base)) + if !errors.Is(err, errUnsafeSelectIdentifier) { + t.Errorf("err = %v, want errUnsafeSelectIdentifier (fail closed before any SQL text)", err) + } + }) + } +} + +func TestBuildDynamicSelect_RequiresColumnsAndFilterValues(t *testing.T) { + if _, _, _, err := buildDynamicSelect(DynamicSelect{Schema: "app_x", Table: "m_users"}); err == nil { + t.Errorf("no columns: err = nil, want error") + } + // A filter with an empty Values list is a bug (an always-true/false shape); + // it must error, never emit a dangling WHERE. + _, _, _, err := buildDynamicSelect(DynamicSelect{ + Schema: "app_x", Table: "m_users", Columns: []string{"id"}, + Filters: []SelectFilter{{Column: "id", Values: nil}}, + }) + if err == nil { + t.Errorf("empty filter values: err = nil, want error") + } +} diff --git a/internal/runtime/inject.go b/internal/runtime/inject.go index 4a205c9..6dc7b6c 100644 --- a/internal/runtime/inject.go +++ b/internal/runtime/inject.go @@ -14,11 +14,12 @@ import ( // Used by both the Lambda handler and the task worker — any change here // applies to both paths automatically. type InjectParams struct { - Resources *Resources - UserID string - AppID string - AppRole string - AppSchema string + Resources *Resources + Dependencies []DependencyGrant + UserID string + AppID string + AppRole string + AppSchema string } // validRoles is the set of platform roles the SDK recognizes. Messages @@ -58,6 +59,12 @@ func InjectResources(ctx context.Context, p InjectParams) (context.Context, erro if p.AppSchema != "" { ctx = db.WithSchema(ctx, p.AppSchema) } + // Stash the deployed cross-module read manifest parallel to the schema / + // credential seams. omitempty means an old platform sends no field → nil → + // the deployed DependencyDB branch fails closed to its dev-plane-only error. + if len(p.Dependencies) > 0 { + ctx = db.WithDependencies(ctx, p.Dependencies) + } if p.UserID != "" || p.AppID != "" || p.AppRole != "" { ctx = auth.Set(ctx, auth.Identity{ UserID: p.UserID, diff --git a/internal/runtime/lambda.go b/internal/runtime/lambda.go index f2b37d0..68dce5a 100644 --- a/internal/runtime/lambda.go +++ b/internal/runtime/lambda.go @@ -29,6 +29,12 @@ type Resources struct { Storage *storage.Credential `json:"storage,omitempty"` } +// DependencyGrant is the deployed cross-module read manifest entry ridden down +// the envelope (decision 18 §3). Aliased to db.DependencyGrant so the ctx seam +// (db.WithDependencies) stores the canonical type without an import cycle +// (db must not import runtime); the wire tags live on db.DependencyGrant. +type DependencyGrant = db.DependencyGrant + // LambdaRequest is the payload format sent by the platform via Lambda Invoke SDK. type LambdaRequest struct { Method string `json:"method"` @@ -36,6 +42,13 @@ type LambdaRequest struct { Headers map[string]string `json:"headers"` Body string `json:"body"` Resources *Resources `json:"resources,omitempty"` + // Dependencies is the platform-resolved cross-module read manifest — one + // entry per installed declared producer, holding only exposed+consented + // tables at the running version (decision 18 §3). Advisory routing only; + // the install-time GRANT is the authorizer. omitempty keeps old-SDK + // backward-compat and old-platform absence (nil → deployed reads stay + // dev-plane-only, the rollout gate). + Dependencies []DependencyGrant `json:"dependencies,omitempty"` // Trusted fields — injected by platform, not from user headers UserID string `json:"userId,omitempty"` AppID string `json:"appId,omitempty"` @@ -125,11 +138,12 @@ func NewLambdaHandler(handler http.Handler) func(context.Context, json.RawMessag // InjectResources is the shared injection function used by both // Lambda and task worker paths — see inject.go. reqCtx, err := InjectResources(httpReq.Context(), InjectParams{ - Resources: req.Resources, - UserID: req.UserID, - AppID: req.AppID, - AppRole: req.AppRole, - AppSchema: req.AppSchema, + Resources: req.Resources, + Dependencies: req.Dependencies, + UserID: req.UserID, + AppID: req.AppID, + AppRole: req.AppRole, + AppSchema: req.AppSchema, }) if err != nil { return jsonError(400, err.Error()), nil From 457a2c4b00dd31bf084b3954d9fa97adcacb51ea Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 06:19:43 +0800 Subject: [PATCH 2/2] refactor: dedup deployed-read tenancy-scope + consumer field Two safe simplify follow-ups on the deployed-plane DependencyDB path, no behavior change: - TxReadOnly now acquires a *pgxpool.Conn and begins the READ ONLY tx on it (conn.BeginTx), reusing the single applyScope seam for search_path + the ms.app_id RLS GUC instead of a divergent setScopeLocalTx copy. The tenant-scoping SQL now lives in exactly one place (no RLS-drift footgun) and runs in one batched round trip instead of two sequential Execs. setScopeLocalTx is deleted. - Dependency.consumer was always == mod.config.ID; dropped the redundant field and read q.dep.mod.config.ID at the single dev-plane use site. Co-Authored-By: Claude Fable 5 --- db/tx.go | 42 ++++++++++++++++------------------ internal/core/dependency_db.go | 7 +++--- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/db/tx.go b/db/tx.go index 04e3600..563db27 100644 --- a/db/tx.go +++ b/db/tx.go @@ -82,16 +82,28 @@ func Tx(ctx context.Context, pool *pgxpool.Pool, fn func(q Querier) error) error // Unlike Tx, fn receives the raw pgx.Tx: the dynamic-SELECT executor needs // tx.Query + pgx.CollectRows, which the Querier interface does not expose. The // app schema (search_path + ms.app_id) is pinned transaction-local from ctx -// (WithSchema) so SET LOCAL auto-clears on COMMIT/ROLLBACK and RLS on the -// producer's exposed relation resolves to this tenant — SET LOCAL is legal in -// a read-only tx. The pool's AfterRelease hook is the defense-in-depth backstop. +// (WithSchema) via the shared applyScope so SET LOCAL auto-clears on +// COMMIT/ROLLBACK and RLS on the producer's exposed relation resolves to this +// tenant — SET LOCAL is legal in a read-only tx. The pool's AfterRelease hook +// is the defense-in-depth backstop. +// +// Acquire-a-conn (like Tx) rather than pool.BeginTx so the tenant-scoping SQL +// routes through the single applyScope seam (one batched round trip, one place +// the ms.app_id RLS GUC is composed) instead of a divergent second copy. func TxReadOnly(ctx context.Context, pool *pgxpool.Pool, fn func(tx pgx.Tx) error) error { - tx, err := pool.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly}) + conn, err := pool.Acquire(ctx) + if err != nil { + return fmt.Errorf("mirrorstack/db: failed to acquire connection: %w", err) + } + defer conn.Release() + + tx, err := conn.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly}) if err != nil { return fmt.Errorf("mirrorstack/db: failed to begin read-only transaction: %w", err) } - // Panic recovery before any query so a panic still rolls back. safeRollback + // Panic recovery must be deferred BEFORE applyScope so a panic during + // applyScope (or anywhere after Begin) still rolls back. safeRollback // swallows a rollback failure so it cannot mask the original panic. defer func() { if p := recover(); p != nil { @@ -102,7 +114,9 @@ func TxReadOnly(ctx context.Context, pool *pgxpool.Pool, fn func(tx pgx.Tx) erro schema := SchemaFrom(ctx) if schema != "" { - if err := setScopeLocalTx(ctx, tx, schema); err != nil { + // local=true: search_path and ms.app_id auto-clear on COMMIT/ROLLBACK. + // Same seam Tx uses — SET LOCAL is legal in a read-only tx. + if err := applyScope(ctx, conn, schema, true); err != nil { safeRollback(tx) return err } @@ -118,19 +132,3 @@ func TxReadOnly(ctx context.Context, pool *pgxpool.Pool, fn func(tx pgx.Tx) erro // request ctx cannot turn the clean finish into a spurious error. return tx.Commit(context.Background()) } - -// setScopeLocalTx pins search_path + ms.app_id transaction-local on an -// already-begun tx, mirroring applyScope(local=true) but for a pgx.Tx obtained -// via pool.BeginTx (applyScope needs a *pgxpool.Conn). SET LOCAL / set_config -// is_local=true auto-clear on COMMIT/ROLLBACK. The schema is Sanitize()d before -// it reaches SQL text; the GUC value is bound as $1. -func setScopeLocalTx(ctx context.Context, tx pgx.Tx, schema string) error { - sanitized := pgx.Identifier{schema}.Sanitize() - if _, err := tx.Exec(ctx, "SET LOCAL search_path TO "+sanitized); err != nil { - return fmt.Errorf("mirrorstack/db: failed to set search_path: %w", err) - } - if _, err := tx.Exec(ctx, "SELECT set_config('ms.app_id', $1, true)", schema); err != nil { - return fmt.Errorf("mirrorstack/db: failed to set ms.app_id: %w", err) - } - return nil -} diff --git a/internal/core/dependency_db.go b/internal/core/dependency_db.go index 74a072a..214d5db 100644 --- a/internal/core/dependency_db.go +++ b/internal/core/dependency_db.go @@ -104,8 +104,7 @@ var dependencySQLName = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`) // Construction never fails — a bad ref or missing app scope is carried // forward and surfaced by Rows/Result, keeping call sites chainable. type Dependency struct { - mod *Module // owner module — the deployed branch reads its consumer-role pool - consumer string // this module's Config.ID — the proxy verifies it IS the caller + mod *Module // owner module — its Config.ID IS the consumer; deployed branch reads its consumer-role pool producer string // producer ref resolved within the same app (slug | UUID | m) appID string // trusted app scope from ctx (auth identity), never caller-supplied err error // deferred construction error (bad ref, no app scope) @@ -123,7 +122,7 @@ type Dependency struct { // WhereIn/Limit). It is not a *pgx pool and never will be — see the package // comment for why raw cross-plane SQL cannot exist. func (m *Module) DependencyDB(ctx context.Context, producerRef string) *Dependency { - d := &Dependency{mod: m, consumer: m.config.ID} + d := &Dependency{mod: m} appID, err := appIDFromContext(ctx, "DependencyDB") if err != nil { d.err = err @@ -363,7 +362,7 @@ func (q *DependencyQuery) result(ctx context.Context, inLambda bool) (*Dependenc } payload := readExposedRequest{ - Module: q.dep.consumer, + Module: q.dep.mod.config.ID, Producer: q.dep.producer, Table: q.table, Columns: q.columns,