Skip to content
Docs

Redis Wire Protocol for SQL Rows

BifrostQL can answer the Redis serialization protocol (RESP2 and RESP3) on a TCP port, so any tool that speaks Redis — redis-cli, StackExchange.Redis, any Redis client library — can read your tables as key-addressed rows. It is a protocol adapter: the wire is Redis, but every read still executes through the same transformer pipeline as GraphQL, so tenant isolation, soft-delete, and policy read guards are enforced on the wire.

This is a read-first, deliberately narrow front door. It is not a Redis server: it exposes a small key-space command surface mapped onto your relational rows and answers everything outside it with a clean, honest error. Writes exist but are off by default (see Optional writes).

Already have a running endpoint and want to validate redis-cli or a real StackExchange.Redis app end to end? Jump to the RESP Smoke Runbook.

Register the adapter with AddBifrostResp. Registering an IRespCredentialStore is a hard requirement, resolved fail-fast at startup, so an auth-required port can never come up without an identity source.

builder.Services.AddBifrostResp(o =>
{
o.Port = 6379; // default 6379 (the Redis port)
o.RequireAuthentication = true; // default; AUTH required before any identity-bearing command
o.Endpoint = "/graphql"; // which BifrostQL endpoint to read; null = the only one
// o.EnableWrites = true; // opt-in: SET/HSET/MSET/DEL through the mutation pipeline (default off)
});
// REQUIRED — the identity source AUTH authenticates against. No default registration.
builder.Services.AddSingleton<IRespCredentialStore, MyCredentialStore>();

RespWireOptions:

Option Default Meaning
Port 6379 TCP port the front door listens on.
RequireAuthentication true When true, a connection must complete AUTH (or inline HELLO … AUTH) before any identity-bearing command runs; until then those commands are refused with NOAUTH. There is no anonymous mode unless a deployment explicitly sets this false.
Endpoint null Registered BifrostQL endpoint path to read/write against; null selects the single registered endpoint.
EnableWrites false Master gate for the write surface (SET/HSET/MSET/DEL). Off by default: every write is refused with a clean -ERR and executes nothing until a deployment opts in.
MaxBulkLength 1048576 (1 MiB) DoS guard on the unauthenticated path: a bulk/inline length prefix beyond this is refused, never allocated.
MaxFrameLength 1048576 (1 MiB) DoS guard on the total byte length of one top-level frame, elements of nested aggregates included. The per-element caps bound only single parts; without this a frame built from individually legal parts could reach roughly 1 TiB, all of it retained until the frame completes. Charged per consumed byte and checked before any payload is allocated; reset at each top-level frame.
MaxAggregateElements 1048576 DoS guard: a declared array/map element count beyond this is refused, so a huge multibulk count cannot pre-allocate an unbounded array.
MaxNestingDepth 32 DoS guard: how deeply aggregates may nest before the decoder refuses to descend (prevents a stack-overflow teardown of the host).
MaxConnections 100 Concurrent connections admitted. The slot is reserved at ACCEPT — before the TLS handshake and before AUTH — so a peer that never completes a handshake cannot force work outside the cap.
AuthenticationTimeout 30s One cumulative budget for a connection to authenticate, never reset by traffic. Does not apply once authenticated, nor at all when RequireAuthentication is false (such a front door has no pre-auth phase; its connections live under IdleTimeout).
IdleTimeout 10m How long an authenticated connection may sit with no command. Timeout.InfiniteTimeSpan disables it.
MaxAuthAttemptsPerSource 100 Authentication attempts admitted from one source per AuthRateLimitWindow. The source is the peer’s address, or the connection itself when no address is known.
MaxAuthAttemptsPerAccount 10 Authentication attempts admitted against one account name per AuthRateLimitWindow, whatever their source. Bounds a distributed guess at one login.
AuthRateLimitWindow 1m The fixed window both authentication-attempt caps count over. Over either cap the attempt is refused before the credential is resolved, with one fixed message that never varies by account.
ScanCursorSecret null HMAC secret for SCAN cursors. Configure it to make cursors survive a restart and resolve across instances; absent one a per-instance random key is generated and the trade-off is logged at startup.
ScanCursorTtl 10m How long an issued SCAN cursor stays valid. An expired cursor is refused exactly like a forged one.

No STARTTLS. RESP has no in-protocol TLS. AUTH crosses the wire in the clear unless TLS is terminated at the listener or a proxy — the same operational contract as a real Redis. Do not send real secrets over an untrusted network without TLS termination.

A client authenticates with AUTH <user> <pass> (or AUTH <pass>, which resolves the Redis-default default user), or inline via HELLO <proto> AUTH <user> <pass>. IRespCredentialStore.FindAsync(username) resolves the username to a RespLogin(PasswordHash, Principal):

  • PasswordHash — a one-way ASP.NET Core PasswordHasher<string> hash of the shared secret (API key, client secret, password), produced with the username as the user argument: new PasswordHasher<string>().HashPassword(username, secret). The store never holds the plaintext secret; the AUTH password is verified against the hash.
  • Principal — the ClaimsPrincipal that login maps to. This is the candidate identity only: it is still projected through IBifrostAuthContextFactory, the same fail-closed seam the HTTP GraphQL and pgwire gates use. A subject-less or unmapped-issuer principal is rejected there.

Fail-closed, always. An unknown username resolves to null and authentication fails — a store must never hand back an ambient or anonymous identity to stand in for a failed lookup. There is deliberately no default IRespCredentialStore registration, so a deployment can never come up authenticating everyone to nobody. A verified password only unlocks the candidate principal; the session becomes authenticated only if that principal projects to a non-empty Bifrost user context. The tenant/policy claims on the mapped principal are what scope every subsequent read. There is no anonymous access unless a deployment explicitly clears RequireAuthentication.

The credential lookup and password verification run the same work either way (a hash verification against a precomputed dummy hash for an unknown user), so the front door does not leak whether an account exists.

Command Behavior
PING [msg] +PONG, or echoes msg as a bulk string. Requires auth.
HELLO [proto [AUTH user pass] [SETNAME name]] Negotiates RESP 2 or 3, optionally authenticates inline, returns the server-info reply as a RESP3 map or a RESP2 flat pair array. On an auth-required front door, HELLO without an established identity and without inline AUTH is refused with NOAUTH — the protocol is not switched.
AUTH <pass> / AUTH <user> <pass> Authenticates the session (see above). A failed AUTH keeps the connection usable so the client can retry.
SELECT <index> BifrostQL exposes a single logical namespace, not Redis’ 16 numbered databases. Index 0 is accepted; any other index is refused honestly with ERR DB index is out of range. Requires auth.
INFO A minimal, parseable # Server section (redis_version, server_name, redis_mode, role, client id, proto). Requires auth.
ECHO <message> Returns the message verbatim (binary-safe — StackExchange.Redis uses ECHO as its connection tracer). Requires auth.
QUIT +OK, then closes the connection.
RESET Resets session state, +RESET.
COMMAND … Minimal empty-array reply so redis-cli’s COMMAND DOCS probe on connect proceeds. Requires auth.
CLIENT … Minimal +OK so redis-cli’s CLIENT SETINFO/SETNAME after HELLO is acknowledged. Requires auth.

The server identifies itself honestly as bifrostql (not a real Redis).

A data-command key addresses one row:

  • Single-column primary key: <table>:<pk> — e.g. users:42.
  • Composite primary key: <table>:<pk1>:<pk2>[:<pk3>…] — the primary-key values in schema primary-key order, e.g. order_items:1001:5.

The first segment is the table, matched case-insensitively against its database name or its GraphQL name. The remaining segments must match the primary-key column count exactly (a mismatch is a clean -ERR naming the expected columns) and each is coerced to its key column’s type (an unparseable integer/decimal segment is a clean -ERR, never executed). Values bind as query parameters — a segment is never concatenated into SQL. A key value that itself contains : cannot be addressed (it splits into the wrong segment count); callers must encode such values.

Command Behavior
GET <key> The row as a JSON bulk string, or Null when the key resolves to no visible row (missing or filtered out by tenant/policy — indistinguishable, so a hidden row’s existence never leaks).
MGET <key> [key …] A RESP array of JSON bulk strings / Nulls positionally aligned to the requested keys. Keys of one single-PK table are batched into a single _in intent; each key is still tenant-filtered.
EXISTS <key> [key …] The integer count of keys resolving to a visible row. A row the identity cannot see counts as not-existing, exactly like a missing key.
TYPE <key> string for a visible row (a Bifrost row is modeled as a JSON string value), or none for a missing/invisible key.

Row → JSON shape. A found row is a JSON object mapping each column’s database name to its value, in schema ordinal order. A SQL NULL is a JSON null. Values serialize with System.Text.Json Web defaults: numbers/booleans as JSON scalars, dates as ISO-8601 strings, byte arrays as base64. The JSON text is returned as a RESP bulk string.

The hash commands project a row as a Redis field/value hash. They reuse the same single-row read path, key parser, and composite-PK mapping as GET — they differ only in wire shape and in exposing the visible column set (the columns the transformer pipeline actually returned for this identity; a policy-denied or crypto-masked column is simply absent, never re-added and never unmasked).

Command Behavior
HGETALL <key> The row’s visible columns as a field/value hash. RESP3: a Map (%) of column→value. RESP2: a flat array (*) of alternating field, value bulk strings (Redis’ HGETALL wire shape). A missing or tenant-hidden key returns an empty hash — indistinguishable, no leak.
HGET <key> <field> A single visible column’s value as a bulk string. Returns Null when the key resolves to no visible row, when <field> is not a column the pipeline returned for this identity (unknown field or existing-but-denied column — indistinguishable), or when the column’s value is SQL NULL.

The front door speaks both and honors what the client negotiates with HELLO:

  • HGETALL returns a native RESP3 Map (%) when the client sent HELLO 3, and a RESP2 flat array (*) of alternating field/value otherwise.
  • Null is the RESP3 Null (_) on a RESP3 session and the RESP2 null bulk ($-1) otherwise.
  • HELLO itself replies as a map (RESP3) or a flat pair array (RESP2).
SCAN <cursor> MATCH <table>:* [COUNT n] [TYPE t]

Cursor-paginated primary-key enumeration of one table. The reply is the Redis SCAN 2-element array [<next-cursor>, [<table>:<pk…> keys]]; iteration begins at cursor 0 and ends when the returned cursor is 0.

  • MATCH is required, and only <table>:* is supported. A partial glob (users:1*), a missing MATCH, or an unknown table is a clean -ERR — never a silent over-broad enumeration.
  • COUNT is a page-size hint, defaulting to 10 and capped at 1000. A non-positive or unparseable COUNT is a syntax error.
  • TYPE is accepted and ignored — a Bifrost row has no single Redis type, so filtering by it would be dishonest.
  • The cursor is opaque, integrity-protected, and encodes only a primary-key position — never scope, tenant, policy, or row content. Its MAC binds the resolved table, the MATCH pattern, and the caller’s identity, all re-derived from the live request, so a cursor cannot be replayed against another table or by another principal. Forgery, tampering, cross-context replay, and expiry all produce the same clean -ERR and execute nothing — an unusable cursor is refused explicitly, never silently restarted from the beginning.

Enumeration runs through the query pipeline under the session identity, so only primary keys of rows the identity may see are ever emitted — on every page.

The write surface (SET/HSET/MSET/DEL) is off by default. Until a deployment sets RespWireOptions.EnableWrites = true, every write command is refused with ERR write commands are disabled and executes nothing — no intent is ever built, and the disabled surface cannot be probed for schema shape. This is the highest-risk surface, so it stays dark unless deliberately turned on; enabling it is logged at startup as a posture change.

When enabled, writes route only through IMutationIntentExecutor — the full mutation transformer chain (tenant scoping, the audit actor, soft-delete, field-encryption-on-write, CDC/history hooks) applies and cannot be skipped. The adapter supplies only the positional primary key from the key plus the session identity; it builds no WHERE predicate of its own, so the pipeline narrows scope from the identity and a write targeting a row outside the caller’s tenant/policy scope affects zero rows.

Command Behavior
SET <key> <json> Update-only. Sets the addressed row’s columns from a JSON object (the same column-name → value shape GET returns). It does not insert a missing row — an addressed-but-absent or out-of-scope key is narrowed out by the pipeline and the write is a no-op. The primary key comes from the key, not the body; a PK column in the body must equal the key value (a conflict is a clean -ERR) and is never a SET column. Requires at least one non-PK column. Reply is always +OK (a no-op update still replies OK, exactly as Redis SET does not report prior existence).
HSET <key> <field> <value> [field value …] Updates the named columns of the addressed row. Each field is validated against the table’s columns (unknown → clean -ERR); a primary-key column cannot be set via HSET (the PK comes from the key). Reply is the integer count of fields written (the number of field/value pairs supplied) — distinct from Redis’ “new fields” count, since a row’s columns pre-exist.
MSET <key> <json> [key json …] SET semantics for many keys: each pair is an update-only write (PK from the key, JSON body of non-PK columns). All pairs are validated up front — one malformed pair rejects the whole command with nothing written. Execution is one batch per table: each table’s writes commit or roll back as a unit, and a batch reaching the table’s bulk-batch-threshold rides the set-based fast path. Reply deviates from Redis deliberately: instead of a blind +OK, it is the integer count of keys that actually updated a row — a missing or scoped-away key is silently uncounted, indistinguishable from absent. A key repeated in one MSET resolves by the table’s batch-duplicate-policy (default last-wins: later pairs merge per column over earlier ones, one write reaches the database).
DEL <key> [key …] Deletes the addressed rows, one delete intent per key. The pipeline decides hard vs soft delete (a table with soft-delete metadata is soft-deleted by its transformer — the adapter never special-cases it). Every key is validated up front, so one malformed key rejects the whole command with no partial delete. Reply is the integer count of keys that actually deleted a row (a key whose row is missing or out of scope is a no-op and is not counted).

Any command the front door does not implement gets the Redis-compatible unknown-command error, naming the command and echoing its arguments so a client gets the same actionable guidance a real Redis sends:

ERR unknown command 'LPUSH', with args beginning with: 'x', 'y'

The command name is quoted verbatim and never dispatched, so an unsupported command can neither hang the connection loop nor leak internals — the connection stays usable and you can issue a supported command on the same session.

Explicit non-goals (each returns the clean unknown-command -ERR, never a partial or faked result): TTL / expiry (EXPIRE, TTL, PERSIST), pub/sub (SUBSCRIBE, PUBLISH), Lua scripting (EVAL), cluster / sentinel commands, transactions (MULTI/EXEC), and the Redis native collection operations (list LPUSH/LRANGE, set, sorted-set, stream commands). BifrostQL exposes your relational rows as key-addressed values, not native Redis data structures.

An unexpected server-side execution fault (e.g. a policy read-deny deeper in the pipeline) is answered with a sanitized ERR internal error; the real exception is logged server-side and never crosses the wire.

Everything above rolls up to a short list a security reviewer can check:

  • Fail-closed identity. No credential store means the port does not start; an unknown user or unmapped issuer is rejected, never defaulted to an ambient identity. No anonymous access unless RequireAuthentication is explicitly cleared.
  • Same pipeline as GraphQL. Reads execute through IQueryIntentExecutor, so every registered filter transformer and column read guard applies unconditionally — tenant filter, soft-delete, and policy read guards hold on the wire. A row the identity cannot see is indistinguishable from a missing key, so existence never leaks.
  • Writes fail-closed and pipeline-bound. Off by default; when enabled they route only through IMutationIntentExecutor, and the adapter never builds its own predicate — out-of-scope writes affect zero rows structurally.
  • Parameterized always. Table names are validated against the schema and key/column values bind as parameters through the intent executors, never concatenated into SQL.
  • Sanitized errors. An out-of-subset command is a clean -ERR and the connection survives; an internal fault is a generic ERR internal error, no DB internals on the wire.
  • Bounded decoder. The unauthenticated codec caps bulk length, aggregate element count, and nesting depth, so a hostile frame cannot exhaust memory or crash the host.
Terminal window
redis-cli -h localhost -p 6379 --user <login> --pass <secret>
PING -- +PONG (connection is live)
GET <table>:<pk> -- the row as a JSON string
HGETALL <table>:<pk> -- the row's visible columns as a field/value hash
SCAN 0 MATCH <table>:* COUNT 100 -- enumerate ONLY your tenant's keys
LPUSH x y -- clean ERR unknown command …, connection stays live
var config = new ConfigurationOptions
{
EndPoints = { { "localhost", 6379 } },
User = "<login>",
Password = "<secret>",
AbortOnConnectFail = false,
};
await using var mux = await ConnectionMultiplexer.ConnectAsync(config);
var db = mux.GetDatabase();
Console.WriteLine(db.Ping()); // round-trips
foreach (var e in await db.HashGetAllAsync("<table>:<pk>")) // your tenant's row
Console.WriteLine($"{e.Name}={e.Value}");
var scan = (RedisResult[])await db.ExecuteAsync("SCAN", "0", "MATCH", "<table>:*", "COUNT", "100");

The full step-by-step validation — including confirming tenant isolation with a second login — is the RESP Smoke Runbook.