Skip to content

Release history

This page is the canonical, complete reverse-chronological release history. Historical bodies are retained as published, including stale command spellings; current equivalents belong in the upgrade guide.

The current workspace release is 0.8.23-rc.2. Channel status, artifact verification, rollout classes, and the recovery boundary are in the upgrade guide.

v0.8.23-rc.2

Release identity and rollback boundary

v0.8.23-rc.2 is the exact manifest/tag identity. The release adds four store migrations, m0049 through m0052. Store migrations are forward-only: a binary older than this release refuses a store they have been applied to. [security].profile is new. Left unset it changes nothing a running cluster already did: no new refusal, no new warning, no banner. Declared as production it adds start-up refusals, and an Authority that declares it does not start until the configuration answers every one: both [security.integrity] keyrings named, naming different files, and owner-only; every Authority replica that is still confirming holding the same keys of each keyring; the newest sealed row of every table naming a key one of the keyrings holds or one whose loss has been declared; and [oidc].issuer with [oidc].client_id both set. The five [audit].*_key_path parameters go on being read under every profile, and gdsgate authority keys migrate-legacy brings the keys they name into the keyrings. Before upgrading, export the audit anchors and take a store backup. If rollback is required, restore the pre-upgrade store and its external keys rather than downgrading rows.

Added

  • ${file:PATH}, ${env:NAME} and ${exec:COMMAND ARG...} in place of a configuration value that is a secret or names one. Fifteen parameters accept the notation, and a bare value keeps the reading it always had, so a file written before the notation existed is unchanged. exec: runs without a shell, passes nothing in its arguments, and a non-zero exit refuses the start. A listener's certificate key is resolved again at each certificate rotation, not only at start-up. See Configuration → Secret references.
  • store_password_file: the state backend's password, kept out of store_url. A path or a secret reference, also set by GDSGATE_STORE_PASSWORD_FILE. PostgreSQL only.
  • [security].profile, the deployment profile a cluster is held to at start-up: dev, pilot, or production. Unset changes nothing. dev refuses nothing and prints one line naming the deployment a development one. pilot and production refuse a start that would keep the cluster's state in a file, serve a listener in the clear, or run all three roles off a certificate authority held in one process's memory; production refuses more. A refusal names every fault present at once. See Configuration → [security].
  • [security.integrity] with control_keyring and audit_keyring: the two keyrings a cluster's sealed rows go under, kept apart because the audit journal outlives the decisions the control keyring seals. Both fields accept the secret-reference notation. Unset, Authority goes on reading the five [audit].*_key_path parameters.
  • gdsgate authority keys, nine sub-actions over the two keyrings and the CA signing seeds kept beside them: status, verify, backup-manifest, accept-loss, rotate, checkpoint-keys, migrate-legacy, wrap-ca-seeds, and prune-ca-keys. None of them prints key material.
  • keys migrate-legacy brings the keys the five [audit].*_key_path parameters name into the two keyrings, as records that verify existing rows and seal nothing new. Nothing is re-sealed and no key is generated.
  • keys rotate <control|audit> --prepare | --activate | --retire moves a keyring on to a new key in three steps, with a rolling restart between the first two, since a keyring is read once at start-up. --activate is refused while an Authority replica that is still confirming has not published that it holds the prepared key. --retire is refused while a row still names the key, and always for the audit keyring. --prepare --kind signing rotates the checkpoint-signing key instead of the key that seals rows.
  • keys accept-loss --key-id --accepted-by records that a key is gone. gdsgate authority verify-audit then reports the anchors it sealed as not verified, naming the serial range, instead of failing the run.
  • Signed audit checkpoints. Authority signs the chain head into the store every [audit].checkpoint_interval_secs (3600 by default) whenever the audit keyring holds a signing key. keys checkpoint-keys prints the public halves, and verify-audit --checkpoint-keys <FILE> replays the chain and checks each checkpoint by signature, so a reviewer outside the deployment can verify the journal while holding no key material. verify-audit --keyring <REFERENCE> answers both halves of the check from a keyring named for that run.
  • gdsgate authority init --profile <dev|pilot|production>: the first run of an Authority. It writes the configuration, puts the two keyrings in place, and prints one JSON object of fingerprints, references and paths, with no key material in any field. Under production it refuses to mint a keyring on this host: name one already in place, or a wrapped one whose key --kms-key declares. gdsgate authority set-profile --profile --changed-by declares a different profile afterwards, writing the change to the audit chain before rewriting the one line in the file.
  • [[kms_keys]]: the external keys this deployment's wrapped material sits under. ${kms.<name>+file:PATH}, and the same prefix over env: and exec:, says the source holds ciphertext and names the key that opens it. provider = "exec" is in every build; vault (the transit engine of Vault and OpenBao), aws, and gcp need a build carrying kms-vault, kms-aws, or kms-gcp, and a row naming a kind the binary was not built with is refused by name. Credentials come from the process environment. A key answers unwrap, wrap and generate and nothing else, and no material crosses in an argument in either direction. See Configuration → [[kms_keys]].
  • [authority].ca_kms_key: the wrapping key CA signing seeds are written under. Every seed written from then on is ciphertext in the store, opened when a generation is loaded and never on the path that signs a certificate. keys wrap-ca-seeds converts the generations already stored, and a table converted only partway runs either way, because each row states which form its own seed is in.
  • keys prune-ca-keys removes the private keys of CA generations nothing will use again. Without --delete it lists them and removes nothing. Each removal is written to the audit chain before the row goes, and no command puts one back.

Changed

  • A sealed row is verified under the key its own fingerprint names, so a keyring holding several keys verifies every row any of them sealed. Approvals, revocations, delegations, inventory receipts and the audit anchors all select this way.
  • New rows go on being sealed under the five [audit].*_key_path keys until a keyring holds an active key of its own, so the five parameters stay in the configuration and keep being read after keys migrate-legacy.
  • Authority leaves closed the signing seed of a CA generation nothing can reach again: one that is neither of its authority's two newest, that no rotation role names, and whose validity window closed more than thirty days ago. Under [authority].ca_kms_key every generation opened at start-up is one call to the key service.
  • gdsgate authority init-anchor and the four other init-*-key commands refuse when the parameter they would write names an environment variable, a command, or a wrapped source instead of a file, since a key delivered that way is created where it lives. --path names a file to write.
  • gdsgate doctor reports which form each covered parameter is written in and where its value comes from, without reading the material behind it, and checks permissions only where the parameter names a file on disk. It also reports the declared profile, whether each [[kms_keys]] entry is one this build can use, and which of the five legacy key files a named keyring has not taken in yet.

Fixed

  • A password left in store_url is withheld from every place the connection string is printed: log lines, gdsgate doctor output, and the refusals the database driver raises with the string quoted inside them.
  • gdsgate doctor checks all five audit signing keys and all four listener keys. The delegation seal, the inventory seal, and [proxy].webapps_key_file had no row.

v0.8.23-rc.1

Release identity and rollback boundary

v0.8.23-rc.1 is the exact manifest/tag identity. The release changes certificate compatibility: a certificate issued under --as is refused by a connector or proxy older than this release, so roll the fleet together and upgrade proxies and connectors before the Authority that mints them. Store migrations are forward-only; an older binary refuses a store migrated by this release. Before upgrading, export the audit anchors and take a store backup. If rollback is required, restore the pre-upgrade store and its external keys rather than downgrading rows.

Added

  • routed_ranges on a kind = "web-egress" backend: the address ranges this way out may dial although no registry allocates them to the public internet. Default empty.
  • allow_agent_forward on a kind = "ssh" backend, default false, and the sshForwardAgent policy action. ssh -A requires both.
  • ssh:forward@<host> and ssh:agent@<host> in the capability vocabulary. ssh -L and ssh -R take the first, ssh -A the second, and a delegated session carries ssh:connect@<host> alongside either. Minting ssh:forward requires the delegator to hold both sshForwardLocal and sshForwardRemote.
  • An agent-bound session takes the onward hop of a jump-host backend where its grant and the policy permit it. gdsgate delegate --exec reached no such backend before. The hop needs a connector of this release or newer.

Changed

  • gdsgate --as <grant> narrows every operation in the session. A grant that names db:read refuses writes; one that names a single MCP tool refuses the others. Revoking the grant or letting it expire refuses the session's next operation.
  • A certificate issued under --as carries the delegation grant in its signed bytes. A connector or proxy older than this release refuses it; update them before the Authority that mints it. A certificate issued without --as is unchanged.
  • A delegated session forwards only what its grant names. ssh:connect names no forward, so a grant that opened ssh -L, ssh -R or ssh -A needs the new word added to it.
  • An agent-bound session forwards where its grant and the policy permit it. Such forwards need a connector of this release or newer.
  • A cached or held credential answers --as <grant> only when it is that grant. A credential minted for another grant, or one holding none, is replaced by a fresh certificate.
  • Controlled web egress dials an address only when it is globally routable unicast and outside every range the IANA special-purpose registries reserve. A deployment that relies on reaching a reserved range names it in routed_ranges.
  • ssh -A is refused unless the backend enables it and the policy permits it. A refused session receives no SSH_AUTH_SOCK.
  • A connector registration states the kind of every resource it declares. Upgrade proxies before authorities: a proxy older than v0.8.19 forwards no kinds, and its connectors' tunnels are dropped until it is upgraded.
  • A resource stays with the connector holding it. Replacement requires revoking that connector or removing the catalog row.

Fixed

  • A one-time step-up grant is spent by exactly one operation. Concurrent requests no longer both pass on a single approval.
  • Kubernetes watch requests are classified from the parsed query, so the policy question and the audit row name the verb the cluster runs.
  • ?watch=true on a named object is a get, as the API server reads it.
  • The Kubernetes path parser reads the legacy watch and proxy prefixes, treats HEAD as GET, and reads a namespace addressed as an object.
  • A person's grant is read from the signed field of their certificate, and a delegation's from its subject. An identity provider whose sub is spelled like a delegation subject no longer places the session in that grant.
  • gdsgate delegation status no longer fails on an ordinary certificate subject.

v0.8.23-alpha.12

Release identity and rollback boundary

v0.8.23-alpha.12 is the exact manifest/tag identity. The release changes certificate and PostgreSQL-wire compatibility: roll the fleet together and do not mix this binary with an older connector/proxy that cannot verify its certificates. Store migrations are forward-only; an older binary refuses a store migrated by this release. Before upgrading, export the audit anchors and take a store backup. If rollback is required, restore the pre-upgrade store and its external keys rather than downgrading rows.

Added

  • HTTP(S) applications are a lane of their own: a kind = "web" backend is reverse-proxied with a policy decision on every request, and policy is written over named path families ([[connector.backends.routes]]). gdsgate web proxy <resource> --listen opens one locally, and a resource of this kind also answers by name under gdsgate up. See User guide → Web applications and The web lane.
  • httpRequest, decided for every request on the web lane over WebApp or WebEgress. See Policy → HTTP(S) application routes.
  • kind = "web-egress": a forward proxy over a list of names, pointed at a delegated program through $HTTP_PROXY. Addresses outside the public internet are refused. inspect = true terminates TLS beside the cage and decides every request inside it. See Reading what a way out carries.
  • web:tunnel, the capability verb for a channel the gateway does not read: CONNECT out of the zone and a WebSocket upgrade into an application, neither covered by web:write.
  • WebSocket upgrades where a backend sets websocket = true. The channel ends when the certificate that admitted the session expires.
  • Recording of the web exchange at a level chosen per direction: nothing, fingerprints, redacted bodies, or bodies verbatim. Credential headers are withheld at every level. See Recording the exchange.
  • identity = "signed" puts a short-lived signed assertion in Gdsgate-Identity, audience-bound to the resource, and stops the caller's own Authorization from travelling. Applications verify it against the JWKS at /.well-known/gdsgate/jwks.json, rotated by gdsgate authority rotate-jwt-ca. See Telling the application who came.
  • Clientless browser entry: with webapps_zone, webapps_addr and a wildcard certificate for that zone, a browser reaches an application at https://<application>.<zone> after signing in at the identity provider. The session appears in gdsgate session list. See The clientless browser entry.
  • The browser entry and the public listener take a client's address from a PROXY protocol header (versions 1 and 2) from fronts named in [proxy].webapps_proxy_protocol_from and [proxy].public_proxy_protocol_from. Both are empty by default; with a list set, a connection from an unlisted address, or from a listed one without a header, is closed. See Behind a front.
  • [transport].front_sni: the name a cluster answers to on a shared front, bound into every TLS-terminating role's leaf and requested by nodes on the reverse tunnel and on renewal. Authority advertises it to joining nodes. See The name a shared front routes on.
  • A catalogue entry's native_name is answered for by the resident client where the local [client].intercept_names also names it, with TLS terminated by that machine's own root (gdsgate web trust). See Resident client → An application's own name.
  • context.presented is "key" or "cookie" on connect and httpRequest, and session and decision rows carry it on every lane. See Policy → Who arrived with what.
  • CockroachDB backends: kind = "cockroach", default port 26257, served as PostgreSQL is, auth_mode = "session_role" and enforce_query_categories included. gdsgate doctor's session-role grant probe does not cover it.
  • read_only = true on a session-role profile, checked by gdsgate doctor against every database the session can reach, and settable_parameters, which widens what a restricted client may SET. See [[connector.backends]].
  • Three records report a database write that contradicts the gateway's decision: db.write_in_read_only_session, db.write_refused, db.write_misclassified. See Operations → Database observations and gateway decisions.
  • authority audit list --operator <NAME> records, as asserted, the person a control plane is reading for. Refused on --route store.
  • One-time step-up grants for kubeRequest, viewRecordings and issueJoinToken. See gdsgate step-up.
  • authority policy push warns about a connect rule that names no kind of resource, and authority policy validate --output <FILE> writes the migrated policy with every change listed. See gdsgate authority policy validate.
  • Raw-TCP sessions are recorded as session.open / session.close with kind tcp, and the close record carries bytes_up, bytes_down and reason.
  • Audit rows carry an event code, shown as a column by authority audit list, filtered with --code <CODE> and reported as code under --json. See gdsgate authority audit list.
  • max_request_bytes bounds the request body a web or web-egress backend carries. Default 0, no ceiling.

Changed

  • Roll the fleet together: an access certificate now states what its holder proved, a key or a browser session cookie, and a connector or proxy older than this release cannot verify a certificate issued by it.
  • The five session-level Cedar actions are one, connect, with the resource kind named by its entity type. k8sAccess and k8sReadOnly are kubeRequest, with the read-only distinction in context.read_only; Server is SshHost; a catalogue-wide decision is taken over Catalog::"all"; an MCP backend opens over McpServer. Existing policies are migrated on push and on read, and the rewriter reports what it will not touch. See Policy → One connect, seven kinds of resource.
  • A policy decision is recorded as policy.decision, with the schema action in the detail under cedar_action, so --action kubeRequest becomes --code POL-0201. Decisions name the rules they came from (deciding, deciding_at) and the session they were taken in (session_uid); the thirteen control-plane actions that write no decision row carry the same fields on the row of the thing they gate. Details are objects with named fields, printed as key=value pairs on a branch line. See Operations → What lands in audit.
  • Session records use one pair of names, session.open and session.close, with the protocol in kind; k8s.request is kube.request; Kubernetes requests and sftp.* records carry session_uid.
  • Control-plane event names carry a dot: editPolicy is policy.edit, manageCatalog is catalog.manage, rotateCA is ca.rotate. Event codes did not move, so a filter on --code spans both spellings.
  • The Authorization class holds policy decisions only. session.no_route moved to SessionActivity, and the seven MCP descriptor records to a new class, ResourceIntegrity (--class resource-integrity). A filter written on --class authorization to catch either must move. Rows already on the chain keep the class they were sealed with.
  • The CEF export carries the event code as Signature ID, the event class as an extension key, the row's millisecond as rt, the running version as Device Version, and the detail as the last field. Severity is ranked from the outcome (Success 1, Allow 3, Failure 5, Deny 7). A SIEM rule that read the outcome from the seventh header field must read the outcome extension key. See Administration → Audit export.
  • Policy denies on the Kubernetes, MCP and model lanes, and a refused database statement, are sealed as deny rather than failure. A backend refusing its own caller stays a failure, as does a gate that could not be asked. Rows already on the chain keep their outcome. See Operations → What an outcome means.
  • The web lane streams a request body instead of holding it: the 32 MiB limit that answered 413 is gone, replaced by max_request_bytes. A recorded request with a body is written as a head, its chunks and an end, making the gdsgate.http.exchange format version 2; recordings show renders version 1 unchanged.
  • A client whose request body ends before its declared length is answered 400 rather than 502.
  • The settings advertised to a joining node no longer carry a client-facing TLS server name.
  • context.db_role is present only when the client named a session role; a rule reading it needs context has db_role.
  • The model lane refuses a connect-level authorization; every model call is decided by llmCall.
  • A restricted database session may set a fixed set of parameters and no others, and statements are judged individually, inside a DO body too. Categories follow what a statement does: SELECT … INTO is ddl, a locking clause is write, COPY … TO STDOUT is read. See Policy → Per-statement database authorization.
  • authority bootstrap-admin renews the identity it holds instead of enrolling a second one, keeping its registry row. An empty directory still enrolls, and so does an expired identity.
  • Listings size their columns to their contents, and an audit row's detail is a branch line instead of a column; read it back with --json.

Fixed

  • A resource served as a protocol the gateway does not know is refused instead of being decided as an SSH host; the refusal names the version mismatch.
  • The browser entry refuses a name served as something other than a web application before the person is sent to their identity provider: a 404, and no decision row.
  • A connector that serves only Kubernetes now evaluates kubeRequest on every apiserver call; previously the session-open decision stood for the whole session. This is stricter: calls are denied wherever no kubeRequest rule permits them, so review the cluster's rules before upgrading such a connector. See Policy → kubeRequest and context.read_only.
  • A local forward is judged by policy on every connector. This is stricter: ssh -L on a connector that held no other line to the control plane now needs a sshForwardLocal rule, which the baseline policy grants to nobody. -R is unchanged. See Configuration → Forward gating.
  • A connector serving only raw TCP, only MCP, or only an SSH resource it terminates itself admits workload identities; such sessions were previously refused. People were unaffected. See Policy → Authorizing a workload.
  • Client-chosen field lengths no longer decide whether other people's audit rows are written: request paths, SQL statements and parameters, model, tool and resource names, an SFTP rename's old name and a pinned MCP revision are cut before the row is built, and a cut row names the field. actor and target are bounded, and Authority refuses a submitted row over the bound.
  • A connector no longer retries indefinitely against an Authority that commits none of a batch: "none accepted" counts as no progress, with an error line when the retry budget runs out.
  • SSH sessions, remote commands and sftp through a jump host answer clients that block until a request is confirmed (Go's x/crypto/ssh, libssh, paramiko). env and x11-req are refused rather than ignored. OpenSSH was unaffected. See User guide → SFTP.
  • Answers reach the caller whole on the model, Kubernetes and SSH lanes, including kubectl exec output.
  • A Kubernetes request stopped by an unreachable gate is recorded with the status the client received, and a Kubernetes exec that opened is recorded as a success.
  • A recording left on the connector's disk is no longer reported as uploaded. A pushed CA trust bundle is announced by what it changed and warns when no key in it could be read.
  • gdsgate session kill reports only sessions it terminated, a health report Authority turned down is not logged as reported, and gdsgate login notifies a resident client, so gdsgate status reports the new identity at once.
  • authority policy edit reads a document in the retired vocabulary the way authority policy validate does, listing what the migration will change rather than marking every such line an error. Page Up, Page Down, Ctrl-Home and Ctrl-End move in it.
  • gdsgate login --browser reports the identity provider's refusal in the provider's own words and answers the browser instead of waiting out the login.
  • A browser login refused for requesting offline_access is retried without it, and the identity it obtains is reported as not renewable.

v0.8.22

Added

  • The administrative catalog listing says whether a connector is serving each resource. It is filled by the gateway as it relays; asked of Authority directly it stays unknown, which is not the same as no.
  • authority list-resources --json: one object per resource, serving among them.
  • authority list-serving asks the gateway which resources have a tunnel behind them at that moment, for a control plane that administers a cluster with a certificate and has no person to sign in as.

Changed

  • The release publishes one binary, the static …-x86_64-unknown-linux-musl. The glibc build is gone; install the static one in its place.
  • mimalloc is the global allocator, in every build and for every role.
  • Names resolve through getent when the C library does not answer them, so a host's own sources (mDNS, a directory service) are reachable from a static build.
  • The server image carries the released binary. sha256sum /usr/local/bin/gdsgate inside the image matches the line in the signed SHA256SUMS.

v0.8.21

Added

  • Authority.ListAuditEvents serves the audit chain over the gateway under the caller's own identity, the way the node registry and the resource catalogue are already served, so reading the chain no longer requires access to the database. The action is viewAudit, taken over AuditLog::"chain", and it is granted on its own rather than as part of viewNodes or viewRecordings. A read of the chain is itself written to the chain, allowed or refused. Pages are keyed on (time, serial), so a chain that grows while it is being read does not repeat or skip a row. gdsgate authority audit list takes the same --route as every other administrative command, with the database available as --route store where the caller has it.
  • gdsgate authority audit list --json: one JSON object per row, the same object on both --route values. The column layout stays the default and is unchanged. A class or outcome code this build does not know is kept as class(200) and not dropped; a detail the server cut is reported as a boolean; a field nobody filled in is an absent key. Hashes are not included; gdsgate authority verify-audit is what proves the chain.
  • gdsgate doctor reports whether its sandbox readings hold for other runs. The board's properties are measured inside the doctor process, and on a host that restricts unprivileged user namespaces what a caged program gets depends on the confinement profile the run carries. The sandbox.userns_confinement row reports which case this host is in: unrestricted, a profile that names this program, a profile inherited from whatever started this one, or a run with no profile at all, whose cage loses the privileges it needs. Where a profile is wanted and missing, the row says what it has to grant.

Changed

  • ["*"] in a model backend's allow-lists means every element. It is a value in allowed_request_headers and allowed_query_params rather than a name to match, so a backend that has to pass on whatever a particular client sends no longer needs that client's headers enumerated. It goes alone in its list: a list holding both * and names is refused at start-up, naming the list. Under it every header travels except those the lane strips from every call and credential_header itself. This widens what reaches the provider: in substitution mode the lane presents the resource's credential in place of the caller's, and under * a credential the caller sends under any other header name travels with the call. The connector states this at start-up, per resource. Where that is not wanted, name the headers instead; the enumerated list behaves as before.

Fixed

  • An SFTP upload is on the host when the close answers. The server flushes the file before it acknowledges SSH_FXP_CLOSE, so a transfer that reported success is one the next reader finds whole. A failed flush is answered as a failure.
  • Every exit path of gdsgate join reads the piped token to the end. A run refused from its arguments and a --print pass drain the standard input before they leave, so a caller piping a token is answered by the exit code rather than by a broken pipe. What is drained is zeroized and reaches nothing that is printed.

v0.8.19

Added

  • gdsgate status reports whether anything is serving a resource, in a new SERVING column beside ACCESS. ACCESS ok means the cluster would admit you, and says nothing about whether there is anything on the far side. yes: a connector's tunnel was registered for it when the catalog was last read. no: declared, and nothing behind it; the row is dimmed, the line under the table says why, and no command is offered for it. -: nobody answered the question, because the listing did not come through a gateway, which is not a synonym for no. Only a gateway can answer it, since the catalog holds no routing table, so it fills ResourceEntry.serving in as the listing passes through, and Authority answers SERVING_UNKNOWN on its own lane. An unexpected no usually means ordering: a connector states what it serves when its tunnel opens, so a resource declared after it last registered is not among what it offered. Restart the connector. See Resident client → Declared, and nothing serving it.
  • [[delegation_profiles]] saves a delegation under a name: the capabilities, the cage, the paths that agent's toolchain reads, the variable it wants its credential in. --profile <name> takes them from the row. --exec may be given without a value, and the program then comes from the row; without --exec the same row mints the grant and prints its id. The row also carries environment, which is what makes a caged agent usable: --sandbox basic scrubs the environment to an allowlist, and until now there was no way to pass a credential through it. A value is ${env:NAME}, ${file:/path}, or the string itself. The two references keep the secret out of a file that is read by everything which loads the configuration; one that resolves to nothing refuses the run before the grant is minted. See CLI → Saved delegations.
  • A --sandbox basic cage carries a /proc of its own. It denied /proc wholesale, because that is where /proc/<pid>/environ lives, and a runtime that reads /proc/self/maps for its allocator aborts on start, which is most of what a coding agent is built on. A procfs shows the PID namespace it was mounted in, so the cage mounts one inside its own: the program gets /proc/self, and your processes do not exist in there. own-proc yes|no is on the run's report line. It is not a sandbox property and is not promised by a profile, so a host that will not mount one still delivers basic in full.

Changed

  • The gdsgate status header is four lines. identity carries the identity, the level and when the catalog was last read; names and access are the two settings that can be half working; state is dimmed and collects the state file, and at the transparent level the interface and what it has refused. The level, catalog, interface and refused lines are gone as lines of their own. An error line is never dimmed and never folded into another one.
  • Who on this machine reached a resource moved behind --explain. It explains a session that already happened and decides nothing about the next one, so it is no longer part of the short board.
  • The names reason under the table states the cause and lists each remedy as its own action. It previously restated the header line and ran cause, symptom and three ways out together in one sentence, so the short board could carry no remedy for it.
  • gdsgate doctor names a socket it can actually open, once per socket. The sandbox section tested that the path existed while promising a socket "whose permissions admit it". On a host where the account is in the docker group it named three where there is one: containerd's is root:root 0660, and /var/run/docker.sock is the same inode as /run/docker.sock, because /var/run is a symlink to /run.

v0.8.18

Added

  • gdsgate authority list-offered reports what your connectors say they serve, and what the catalog does not know about yet. A connector states its backends when its tunnel opens, and that statement is kept and readable. Each line carries the offering node, the resource id, the kind, when the statement last arrived, and declared=yes|no. declared=no means the connector serves it and this cluster routes nothing to it, until you declare it with authority set-resource. The resource id and its kind are the connector's own words: a join token is issued for a role and not for a machine, so nothing verified them. The list follows the connector's running process, so a backend added to [[connector.backends]] appears after that connector restarts, and a connector that has been off for ten minutes drops out of the listing. Decided by Cedar viewNodes, the same action as authority list-nodes, and it takes --route gateway like the rest of the administrative surface. See CLI → authority list-offered.

v0.8.17

Changed

  • Operations on a certificate authority are audited under CaOperation. A rotation and a refusal of one go on the chain in the class behind gdsgate authority audit list --class ca-operation, and no longer appear under --class admin-action; a filter or forwarding rule written on the latter to catch rotations has to move. The class holds operations whose object is an authority, and never the certificates an authority signs, which happen on every session and stay with the thing signed. Rows already on the chain keep the class they were sealed with, so verify-audit and existing exports are unaffected.
  • Enumerations in help text and refusal messages name the model lane. The set-resource refusal for an unknown kind, the step-up refusal listing the actions a one-time grant can be minted for, and the help for --action and --port all name llm. --action also states what it does not derive: a Kubernetes gate runs per apiserver request and spends no one-time grant, so that resource needs the flag given explicitly.

Added

  • The user guide covers model services, in a section beside the other six lanes: how a model service is reached, and that there is no gdsgate llm subcommand, because the caller is a program that already speaks a provider's API and is handed an address. gdsgate ls lists model services with everything else, and the decision is taken per call, so a grant can be revoked between two calls of one program. See User guide → Model services.
  • docs/config.example.toml documents nine more sections: [identity], [workload], [mcp], [mfa], [audit], [recording], [admin], [policy.editor] and [doctor], each with its keys, its defaults and what leaving it unset costs.

v0.8.16

Added

  • A cage for the programs a connector runs on its own node. A backend's command MCP server and its decider_command are programs out of the configuration file, executed on the protected node as the connector's own user. [connector.backends.cage] declares what confines them, in the vocabulary a service manager's unit file uses: private_network, protect_home, private_tmp, read_only_paths, read_write_paths, restrict_address_families, memory_max, tasks_max, cpu_quota. One table covers both programs. The filesystem is an allow-list with no switch to turn it off, so the connector's key, this node's Kubernetes token, every other backend's secrets and /proc are not there. Absent, a backend has the posture it has always had, and every start logs which of the two it is in. See Configuration → The cage.
  • report_denied_paths = true puts every open the program makes to the connector, which warns about the ones outside the cage and names the path, so an allow-list can be filled in by somebody who did not write the server. It decides nothing; the boundary stays Landlock's. It costs a round trip per open, so it is a diagnostic and not a posture.
  • Under a cage the server runs in a private PID namespace whose first process is the connector's, so the whole descendant tree dies with the session, including a descendant that called setsid. PDEATHSIG is set on every link of that chain and the run refuses to start without it, which covers a connector killed outright.

Changed

  • What the cage turned out to be rides the session's own record. mcp.session.close carries a cage= field beside transport=, measured inside the program's process rather than read back off the configuration. A promise the host did not keep stops the program and fails the session unless the backend sets allow_degraded.
  • The model lane is in the lane lists. kind = "llm" was described on the configuration and AI-agent pages and missing from every place that enumerates what the gateway fronts. The component table, the diagram, the workload lanes and the front page name it, and the per-operation authorization section counts four lanes rather than three.

v0.8.15

Added

  • AI agents: documentation for running an AI agent through the gateway, covering the two options of gdsgate delegate --exec, the seven usable combinations of the nine, and one worked example of the same program under each sandbox profile, with the security limitations stated alongside the properties.
  • Documentation for the model lane: kind = "llm" in the backend reference with its keys (allowed_models, allowed_request_headers, allowed_query_params, record_request / record_response), the substitution and pass-through modes, mandatory token accounting and the four recording levels. The Cedar llmCall action and the LlmService / LlmModel entity types are in Policy, and docs/config.example.toml carries a commented model backend.
  • A registry of agent profiles: gdsgate delegate --exec translates the endpoints it opens into what a particular agent reads, the environment variable its vendor documents for a model address and a configuration file in the shape its vendor documents for MCP servers. The profile is chosen by the file name the program was invoked under, or named with --agent-profile; --list-agent-profiles prints the ones a run knows. Profiles are [[agent_profiles]] rows in the configuration, read exactly as the shipped rows are, with a configured row replacing a shipped one of the same name, so a renamed vendor variable is corrected without a release. Every row carries source and checked, and a row missing either is refused at startup. A program with no profile is not guessed at: the run lists the endpoints it handed over and where a profile would go. Ships profiles for claude, codex, opencode, openclaw and pi, each checked against that agent's documentation on 2026-08-03. See CLI → Agent profiles.
  • gdsgate delegate --exec --model <gateway|direct|off> states where the program obtains model access: through this gateway as a delegated llm:call@<service> capability, from a provider of its own with its own credential, or not at all. Left unstated it follows the grant, so a run that worked before still works. Stating it produces refusals instead of changing the run: --model gateway over a grant that names no model service is refused, as are direct and off over a grant that names one. --model direct requires --sandbox none, because a cage has no route to a provider, and --allow-degraded does not waive it.
  • --sandbox none reports what it does not enforce before the program starts, naming what stays reachable: the delegator's own credential by its path, their ~/.ssh, ~/.aws and ~/.pgpass, the environment of every process they run, and a direct network route to the same resources around the gateway. It also reports what the model channel is worth: configured but not enforced, or not configured at all where no profile could hand the program the address. Printed on the error stream.

Changed

  • gdsgate delegate --exec no longer writes a credential to disk. The key and the certificate live in the gdsgate process that serves the endpoints, and the bundle directory under the system temporary directory is gone. GDSGATE_KEYSTORE_ROOT is no longer set for the program and is removed from its environment. --bind-key still writes a bundle.
  • --renewable --exec is rolled forward by gdsgate itself, since the program has no bundle to roll forward from, so it needs a registration listener: such a run with no [enroll].endpoint is refused before the grant is minted. --renewable --bind-key is unchanged.
  • A caged --exec run's scratch directory (the program's $HOME and $TMPDIR) is named after the grant, so two concurrent runs keep their working state apart.

v0.8.14

Added

  • A model service is a resource of the catalogue. kind = "llm" stands beside postgres, mcp and the rest: delegated as llm:call@<service>, decided by Cedar, audited, revoked, discovered by gdsgate ls and recorded. addr on this kind is a base URL rather than a host:port, and what stands behind it is the operator's business: a provider's own API, or a model gateway of their own in front of several. gdsgate does not ship provider adapters.
    • A decision per call. The connector terminates HTTP on this lane, reads the model name out of the request body and evaluates the Cedar llmCall action before anything leaves for the provider. The model is an entity (LlmModel::"<service>.<model>") under its LlmService, so a rule can cover a whole service or one model. allowed_models is a per-resource ceiling under that decision, the counterpart of allowed_tools.
    • Token accounting is not optional. Every call is accounted for in the audit chain, and there is no setting that turns it off. Where the provider reports usage the row carries the input and output counts; where it reports none, or where the answer could not be scanned for them, the row says so rather than showing a zero.
    • An llm backend refuses to serve a session it cannot decide for.
  • The lane presents the resource's own credential in place of the caller's. With credential_file set it substitutes: the organisation's key goes to the provider, and the caller's headers travel by allow-list, allowed_request_headers on top of the content-negotiation defaults (accept, accept-encoding, content-type), and allowed_query_params for the query string, empty by default, so a call carrying any query string is refused, because some services take a key there. credential_header and credential_format shape what is presented, and a header the lane writes itself is refused at startup. With credential_file unset the lane is pass-through: TLS is terminated, the credential stays the caller's, and the decision and the accounting are still the gateway's. Substitution keeps the provider key out of the sandbox; pass-through does not.
  • A model whose service does not serve it is refused before the call leaves, and the refusal names the model instead of the connection.
  • A model exchange is recorded the way session content is recorded. Prompts and answers go to the recording store, streamed, with a SHA-256 sealed into the chain and a recording://<uid> reference, gated by the same viewRecordings action as an SSH cast. The two directions are configured independently, record_request and record_response, each off, digest (the default), redacted through the existing secret redactor, or full, and any composition is valid: off / full keeps what a model sent without keeping what it was sent, redacted / off keeps what left the perimeter with secrets stripped. digest is the default because prompt bodies are the most sensitive content this gateway carries. Neither key touches token accounting. The exchange's fingerprint is over the bytes rather than over the meaning, and gdsgate doctor reports what each model resource writes, as does the connector at startup, as a warning where bodies are stored.

Changed

  • An inline provider key in the configuration is refused at startup. The key belongs in a file named by credential_file. credential is kept in the schema so that a configuration written that way is refused rather than landing in pass-through, with the caller's own key travelling to the provider.

v0.8.13

Added

  • gdsgate delegate --exec can confine the program it runs: --sandbox none | basic | strict bounds what the program can read and reach on the host while it holds the grant. It is a second boundary; policy at the gateway decides what the grant may do and is unaffected, so a program that escapes still cannot exceed its capabilities. none is the default, and a run that predates the flag behaves as it did.
    • basic promises seven properties: a deny-by-default filesystem (Landlock, ABI 3 or newer), no IP network beyond the endpoints served to the run (an empty network namespace), an environment rebuilt from an allow-list, inherited descriptors closed on exec, no_new_privs, the program killed if gdsgate dies (PR_SET_PDEATHSIG), and the program's whole process tree dying with the run through a private PID namespace whose first process is gdsgate's own. setsid does not escape it.
    • strict promises those seven plus two: no socket in an address family the network namespace does not confine (a seccomp filter, which closes the host daemon reachable by socket path under basic), and memory, process-count and CPU limits over the whole tree (cgroup v2, which needs a subtree delegated to this account).
    • Each profile refuses to run where the host cannot deliver a property it promises; --allow-degraded runs anyway, loudly. gdsgate doctor reports what the host can enforce before a run.
    • On the filesystem allow-list: the system runtime, a minimal /etc, the usual character devices, the working directory, a private directory that becomes $HOME and $TMPDIR, the program's own installation prefix, and anything named with --sandbox-allow. Not on it: the real home directory, /proc, and the credential bundle.
    • Signal semantics are unchanged, and how the program ended reaches the caller unchanged.
  • The resources a grant names are reachable inside the cage and no credential is: --exec serves one loopback endpoint per resource inside the run's own network namespace, reachable from there and nowhere else, and passes the list in $GDSGATE_ENDPOINTS. The key and the certificate stay outside the cage. The endpoints are ports, not socket files on a host path, which every process of the account could reach.
  • The grant's window bounds the run as well as its requests: a caged --exec run ends when its grant does.
  • Every --exec run reports the boundary it received, and the gateway seals one row against the grant. The row carries what was measured inside the sandbox rather than the profile requested: all nine properties on every row, and whether --allow-degraded let the run continue. --sandbox none reports too. It is a ClientAssertion (claimSandbox): the gateway verifies that the caller owns the grant being described and nothing more, since it cannot inspect a kernel on another machine, and outcome is Success where the boundary matched the profile requested and Failure where it fell short. A run whose row cannot be sealed is refused: the program is killed and the grant revoked. See CLI → --sandbox basic and --sandbox strict.

v0.8.12

Added

  • resources/read and prompts/get are decided and recorded: mcpReadResource against McpResource::"<server>.<uri>" and mcpGetPrompt against McpPrompt::"<server>.<name>", both sub-resources of their McpServer, both under the enforce_tool_policy opt-in, fail-closed on a policy deny or an unreachable Authority. A resource entity carries scheme beside uri. Both produce mcp.resource_read / mcp.prompt_get with the same attribution, session id and payload handling as a tool call. New RPCs: AuthorizeMcpResourceRead, AuthorizeMcpPromptGet.
  • A resources/read whose URI is not already canonical is refused rather than rewritten (file:///etc/../etc/shadow, %2e%2e, FILE://, // runs), so policy and the backend cannot resolve different strings. The client is told which rule the URI broke.
  • A delegation can name a resource or a prompt: mcp:resource:<name>@<server> and mcp:prompt:<name>@<server> beside the tool form, which may also be written mcp:tool:<name>@<server>. Existing grants are unaffected, since a token without a kind means a tool. A resource is named as the server's own resources/list names the row instead of by its URI, and a client that reads a URI it never listed is refused. The kind is part of the match, and --can is checked against the delegator's own rights per kind.
  • Server-to-client asks are gated: sampling/createMessage, elicitation/create and roots/list, in both the pre-2026-07-28 dialect and the inputRequests field the 2026-07-28 revision moved them to. An ask is carried only where the backend's allowed_input_requests names it, and that list is empty by default; allow_elicitation_url separately permits a server-chosen link. Every ask is recorded with its payload redacted, capped and escaped. The revision's own rules are enforced first: an ask may ride only on the answer to prompts/get, resources/read or tools/call, and only for a capability the client declared on that request.
  • An MCP server can be a program the connector runs: command (absolute path plus arguments) in place of addr, with optional command_env. Setting both, or neither, is refused at startup. Every gate applies unchanged, and mcp.session.close records the transport (stdio or socket). The program runs on the connector's node as the connector's own user; its environment is cleared and rebuilt from PATH, HOME, LANG, LC_ALL, LC_CTYPE, TZ, USER plus command_env, which scrubs the environment and does not restrict the files it can open. A session that ends closes the child's stdin and signals its process group. See Running the MCP server as a child process.
  • A remote MCP server can sit behind the gateway: url speaks Streamable HTTP, one HTTP POST per message, mutually exclusive with addr and command. Every gate is the same implementation. Mcp-Method, Mcp-Name and MCP-Protocol-Version are mirrored out of the body and never invented, and Mcp-Param-* headers are rebuilt from the x-mcp-header annotations in a tool's inputSchema, so a tool must have been listed before it can be called. Redirects are not followed, https is verified against the platform roots plus ca_path, and a credential comes from credential_file. allowed_backend_hosts constrains a url as it constrains an addr. See A remote MCP server over HTTP.
  • The MCP endpoint speaks Streamable HTTP, protocol revision 2026-07-28: point a client at http://127.0.0.1:<port>/. For a client that starts a program instead, gdsgate mcp bridge <host:port> is a byte pipe, and every request through it is authorized at the connector exactly as one sent to the URL. The endpoint refuses any request carrying an Origin header, and any whose Host is not its own loopback address, which closes the DNS-rebinding case. Per the revision, a header that disagrees with the body is refused 400 with JSON-RPC error -32020, GET and DELETE are answered 405, and an Mcp-Session-Id is ignored. The previous newline-delimited framing still answers on the same port.
  • MCP tool descriptors can be pinned. pin_tool_descriptors = true on an MCP resource takes the first descriptor seen for each tool as its baseline (mcp.descriptor_pinned), records a later one that differs (mcp.descriptor_changed), and refuses the tools/call, not the listing, until the change is re-approved through gdsgate request-access / gdsgate approve (mcp.descriptor_repinned). The flag lives on the catalog row rather than on the connector's backend entry, and a connector that still sets it under [[connector.backends]] fails to start. The baseline is sealed with the operator's inventory key ([audit].inventory_key_path); with pinning on and no key configured, every call on that resource is refused. Trust is on first use: this detects a server that changed after approval, not one hostile from the start. A server's instructions are pinned the same way and refuse nothing. See Pinning tool descriptors.
  • refuse_unfiltered_tool_listing = true answers a caller with a JSON-RPC error when the per-caller viewTools decision cannot be obtained, instead of forwarding the listing unfiltered. The default remains to forward and record mcp.tool_listing_undecided with the cause, the posture and the number of tool names at stake; the call gate is fail-closed either way. Requires enforce_tool_policy. See When Authority cannot answer.
  • An external decider can be consulted before an MCP call leaves: decider_url (one POST per call) or decider_command (a program on this node, fed the request on stdin), synchronously, on all three data paths. The contract is allow|block, so it can only add a refusal, and it is never asked about a call another gate already refused. It sees what the chain sees, with the same redaction and size cap. decider_fail_closed (default true) covers every shape of no answer, and every consultation writes mcp.decider_hook. decider_command costs about 1.9 ms per call against 0.35 ms for a loopback POST. decider_ca_path verifies an https decider against an operator's own CA. See The external decider.
  • MCP sessions are bracketed by mcp.session.open / mcp.session.close with a session_uid that every mcp.tool_call and mcp.input_request row carries. A backend that cannot be reached is recorded as a session.open with a failure outcome and no paired close.
  • A permitted tool call is attributed to the caller, with the session certificate's subject and, for a delegated or workload identity, the owner it names. params.arguments rides in the event detail as JSON, through the same secret redaction, capped at 4 KiB and marked when cut.
  • A session pins the protocol revision to the first one a request names; a later request naming another is refused, and the revision is recorded on mcp.session.close. Client metadata is read from params._meta only. In the 2026-07-28 revision both io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities are required, so a request missing either is refused with -32602.
  • A forwarded line must parse as one JSON-RPC request object: a batch array, a malformed line, and a line repeating a JSON object key are answered with a JSON-RPC error and reach no backend.
  • Backends are enumerated in either era: the discovery probe opens with server/discover and falls back to initialize only when that is refused. A server's handshake instructions are relayed unchanged, and their presence and size are recorded as mcp.instructions.

Changed

  • Wire change: a JSON-RPC error from the MCP gateway carries code -31001 instead of -32000, which is inside the range JSON-RPC reserves. Anything matching on the number rather than the message needs updating; one code covers both protocol eras.
  • Wire change: Authority.AuthorizeMcpViewTools writes a chain row on a first sighting or a change, so a follower replica answers Unavailable and the connector fails over. Resources that are not pinned are unaffected. A connector that enforces tool policy reports descriptors on every listing.

v0.8.11

Added

  • gdsgate authority administrative calls take one of three routes, selected with --route: gateway, the Proxy's client-facing listener under the caller's own identity token, with Cedar deciding over that identity and the audit row naming it and the address it called from; certificate, a direct dial to [admin].endpoint over the control-plane certificate; or emergency, the socket on the Authority host. auto, the default, takes certificate where the configuration names an [admin] section and gateway otherwise, so an existing workstation keeps its route with no flag. policy pull and policy push always resolve through the gateway. See Administering from outside the control zone.
  • Two Cedar actions, issueJoinToken and viewNodes, decide minting a join token and reading the node and workload registries; both were previously gated only by a name on the caller's certificate, so no policy could narrow them. Declaring a resource, removing one, reading the catalog whole, ending a node's or workload's identity, forgetting a long-revoked registry row, importing an identity and rotating a certificate authority also answer under an administrator's own identity over the gateway route: twelve routes in total, new on the wire contract. The equivalent calls over the control-plane certificate are unchanged, and one request cannot mix the two. See manageCatalog.
  • authority --operator <NAME> records, on the certificate route, the person an administrative call is made for. It is stored in its own audit column, printed and exported as unverified, and read by no authorization decision. A name outside 128 bytes of plain ASCII is refused, and any peer other than the control-plane certificate that sends one is refused. gdsgate authority audit list prints the chain, newest first, filtered by class, actor, action prefix, target or time. See Operations → Actor attribution.
  • An emergency administrative socket on the Authority host ([authority].emergency_socket), off unless a path is named, created owner-only in a directory Authority locks down 0700. A call arriving there carries no identity of its own, and a token or asserted operator name is refused. It is decided by Cedar over User::"gdsgate-break-glass", the principal every accepted policy must keep able to edit the policy, and carries policy pull, policy push, the catalog calls and the registry listings, refusing every other method by name. gdsgate doctor reports whether the socket or its directory is reachable by another account. See Lock-out and recovery.
  • One-time step-up grants can gate a certificate-authority rotation (rotateCA), an administrative action taken by a person (enrollNode, disableUser, manageCatalog, issueJoinToken, viewNodes) and a proposed policy edit's meta-invariant check, each tied to the rotation, action or proposal it was minted for and spent once. context.step_up_grant carries { method, for_action }, so a rule can gate on the action a grant was minted for. See One-time step-up grants.

Changed

  • The two control-plane groups are renamed: Group::"admin" is Group::"gdsgate-admin" and Group::"provisioner" is Group::"gdsgate-provisioner". Both live inside a gdsgate- namespace an identity token may never carry, since a sub or groups claim starting with gdsgate- is refused, so no identity provider can put a person into either. A policy naming a retired group id is refused, on push and on the seed file, with the replacement named. A policy naming a group inside the namespace that the cluster does not populate is refused as well. See Why the machine groups carry the gdsgate- prefix.
  • The starting policy grants its group of people manageCatalog, viewNodes, disableUser and issueJoinToken for EnrolledNode::"gdsgate-connector", EnrolledNode::"gdsgate-proxy" and EnrolledNode::"gdsgate-workload", naming the roles the join-token rule admits. rotateCA and enrollNode stay with the certificate identities and ship as commented examples. See The administrative actions people get.
  • Every manageCatalog and viewNodes audit row, and every row an administrative action leaves behind (identity.revoke, identity.delete, identity.import, enroll.token.create, workload.revoke, rotateCA), carries the address the call was made from as src_ip=.

Fixed

  • A one-time step-up grant is spent only by the request or action it names; a request some other rule would have allowed leaves the grant untouched. Which rules read the grant is worked out once when the policy loads.
  • An authorization request that opens a session is accepted only for a connect-level action (sshConnect, dbConnect, k8sAccess, tcpConnect), paired with the resource entity type the schema binds it to; any other pairing is refused as malformed.
  • A one-time step-up grant is minted only for an action a gate spends one on. A Kubernetes session is gated on the reusable step-up window (context.step_up_satisfied), and gdsgate step-up on one is refused saying so.
  • A policy that gates editPolicy on a step-up can be pushed: the meta-invariant probe checks a candidate against the factors its author presented instead of a blank context.
  • A policy requiring a second factor for an operator action (killSession, viewRecordings, editPolicy, approvePolicyEdit, approveRequest, the view catalog listing, viewTools, mintOnwardSshCert, sshForwardLocal / sshForwardRemote, revoke --owner, the dbConnect gate) or an administrative action (enrollNode, disableUser, manageCatalog, issueJoinToken, viewNodes, rotateCA) now sees the caller's real mfa_* / step_up_* / totp_* / webauthn_* status; all of them previously built their Cedar context with every step-up channel pinned to false, so such a rule refused every call. A policy that mentions no factor is unaffected. The control-plane certificate and the emergency socket keep the conservative context, since neither has a factor to present.

v0.8.10

Added

  • Catalog administration over gRPC: UpsertResource, DeleteResource and ListResourcesAdmin, each under the control-plane certificate, a Cedar decision, and an audit row on allow and deny. Upsert and delete are idempotent and take effect with no restart. The commands are authority set-resource, authority remove-resource and authority list-resources, which take --hostname, a repeatable --alias and --port under the same rules a [[discovery.resources]] entry is held to. [discovery] remains the file-declared path, and the startup seed writes the file over the stored rows and removes nothing. See Admin guide → Resource catalog lifecycle.
  • manageCatalog, the Cedar action for the write side of the catalog, kept apart from the session actions on the same resource types and granted in the starting policy to the control-plane certificate identity alone. The catalog row also carries min_approvers (store migration m0038), the narrowest level of the JIT approval cascade. See Policy → Administrative actions.
  • gdsgate authority policy template prints the canonical starting policy on stdout, offline, with no store and no running Authority. It is how a first policy reaches a cluster, since policy push is gated by the policy it would replace. See Policy → The starting policy.
  • A container image (packaging/docker/Dockerfile) carrying the binary and a trust store: an unprivileged uid, pre-created state directories, no capabilities. A release tag publishes gdsgate:<tag>, and gdsgate:latest for a final release, signed by digest with the key that signs SHA256SUMS. packaging/systemd/gdsgate-{auth,proxy,connector}.service run each role under DynamicUser=yes with a read-only filesystem apart from its state directory, and packaging/config/*.toml.example carry the settings a deployment sets. Five HMAC key paths, the audit export, the metrics listener, the OTLP endpoint and the admin identity take an environment override. See Admin guide → As a container and → As a systemd unit.
  • gdsgate ca fingerprint computes the transport CA's SHA-256 fingerprint, matching what a connecting client compares against, without shelling out to openssl.
  • A second control-plane role, CN=gdsgate-provisioner speaking as Group::"provisioner", which declares catalog resources, mints join tokens and reads the node registry, and holds no enrollNode, disableUser, rotateCA or viewRecordings and no join token for a control-plane role. gdsgate authority bootstrap-provisioner issues and renews that identity without opening the cluster's database. See Policy → The starting policy.
  • --if-missing on gdsgate authority bootstrap-admin and the five authority init-* key commands: what exists is left alone and reported under exit code 3. For bootstrap-admin, "already there" means an identity this cluster would still accept. See CLI → Exit codes.
  • profile labels every Prometheus series, structured log line and exported trace of the process. Unset by default; must be non-empty, at most 64 bytes, drawn from A-Z a-z 0-9 . _ -, and a service refuses to start on anything else. See Operations → Metrics and health.
  • A person's sign-in is recorded the first time each day their identity token verifies, naming them and the issuer, with a floor of one row per person per day. The row carries no token, email, group membership, amr or address. See Operations → Authentication identity.
  • Admission control for the join relay under [proxy]: join_max_connections, join_accept_rate_per_ip, join_accept_burst, join_peek_timeout_secs and join_idle_timeout_secs. Unlike other limits in that section, an omitted key keeps the built-in figure rather than meaning unlimited. gdsgate doctor reports the limits in force and whether a provision token crossing the relay travels in cleartext.
  • gdsgate join <domain> configures and registers a node in one command, reading a one-time token from standard input and writing /etc/gdsgate/connector.toml and the transport identity. Every address comes from the registration answer rather than from a convention, including the route to Authority and the renewal address with its scheme. --if-missing reconciles instead of installing over an existing node and spends no token when the identity is current. packaging/install-connector.sh and its --kubernetes --image <ref> form call it for a host or for a Secret plus a single-replica StatefulSet.
  • A node may claim a name ([enroll].node_name), recorded and printed as a claim; deduplication stays on the key fingerprint the registration proved. authority delete-node removes the row of a node revoked more than a week ago, keeping the revocation receipt, and writes the removal to the audit chain.
  • [proxy].public_join carries a node's join on the client-facing listener, so a node needs one address for its whole life. Registration is terminated at the gateway and forwarded to Authority; renewal is relayed untouched, because Authority authenticates the node's own certificate. The relayed surface runs two lanes told apart by TLS server name, each under its own ceiling (public_renewal_max_connections, public_admin_max_connections), and [admin].transport = "fronted" puts an administrative dial on that name. A separate call budget covers the two unauthenticated RPCs (public_anonymous_rate_per_ip, public_anonymous_burst).
  • gdsgate authority migrate brings the store's schema up to date and exits, opening the store directly with no running Authority, and applies nothing on a second run; --check reports only and exits non-zero while migrations are pending. store_auto_migrate (top-level, true by default) decides whether a starting service may change the schema itself. See Operations → Store schema.

Changed

  • Removing a resource from the catalog ends access to it: Authority resolves every request against the catalog before consulting the policy and refuses one with no row, covering certificate issuance, SSH forwards, the model-B onward certificate, database connect and its statement categories, Kubernetes requests, MCP tool calls, and the workload equivalents. The refusal reads apart from a policy deny (resource is not in the catalogue), and a live session is not torn down. An emptied catalog no longer reopens the first-provisioning window, which is keyed to a marker written by the first catalog write and never cleared (store migration m0039). See Admin guide → What removing a resource does.
  • Policy lock-out recovery is [policy.editor].break_glass_policy_file, a file on the Authority host that a start applies past the editPolicy gate. A restart against [policy].path does not recover a lock-out: that path only seeds a store holding no policy version. See Admin guide → Lock-out and recovery.
  • The Proxy presents a publicly trusted certificate on its client-facing listener and verifies a peer's chain against the host's own trust store, so connecting needs no anchor and no fingerprint where a public certificate is in force.
  • gdsgate doctor reports the store's schema against the binary and applies no migrations. A migration run against PostgreSQL takes a database lock, so two instances starting together do not collide. A store carrying migrations recorded by a newer build stops a service start, authority migrate and doctor alike.
  • A first contact that fails names the repair it needs (plaintext, an untrusted certificate, one for another name, one outside its window, nothing listening, a connection closed mid-handshake) instead of one transport error. A --config path that does not exist is an error for every command; only the discovered ~/.gdsgate/config.toml may be absent. A listing command that fails exits non-zero.
  • gdsgate doctor names an unrecognised configuration key by its full path and fails on it, instead of the key being dropped by the lenient parser.
  • A cleartext URL in [enroll].renew_endpoint or [admin].endpoint is refused at start-up: both are mutual-TLS calls whose scheme decides whether TLS runs at all. gdsgate doctor carries a finding for each (config.enroll.renew_scheme, config.admin.endpoint_scheme).
  • The browser listener re-reads its certificate pair ([proxy].web_cert_file / web_key_file) while it runs, as the client-facing and registration listeners already did, so renewing it needs no restart. SIGHUP re-reads all three immediately, and naming one half of a web_* pair without the other is refused at start-up. See Renewing a listener certificate.

v0.8.6

Added

  • [connector].authority_transport picks the route a connector's Authority control-plane calls take, independently of the data-plane reverse tunnel: direct (the default) opens a second connection straight to Authority; fronted sends the same calls to the address already used for the Proxy, carrying the role-specific TLS server name auth.gdsgate.internal so a front can route them apart; tunneled closes the connector's Authority egress entirely and rides the reverse tunnel, relayed by the Proxy without terminating the TLS, so Authority still authenticates the connector's certificate. Registration and token-less renewal, which happen before the tunnel exists, go to [endpoints].proxy_join. Authority advertises the route it runs to every node it issues an identity to.
  • gdsgate up, the resident client: one long-lived process that gives every catalogued resource a local address, keeps the generated ssh / kubectl / environment configuration in step with it, obtains access certificates ahead of the connections that need them, and holds the identity all of that runs under. gdsgate status prints what is published and the command that reaches each resource; gdsgate down stops it. up returns the terminal and reports the level, the resources published and its journal (~/.gdsgate/gdsgated.log); gdsgate up --foreground keeps the client in the starting process, which is what the shipped systemd units use.
  • [client].access selects how a resource is addressed: basic (a port on 127.0.0.1), direct (a loopback address per resource on the protocol's port), transparent (a virtual address on an interface the client creates, CAP_NET_ADMIN on Linux), or auto, which probes for the most capable level the host allows and reports where it stopped. [client].resolver decides independently whether .gds names are served through the hosts file or the system resolver. An address stays with its resource across restarts and is held rather than reassigned when the resource leaves the catalog.
  • gdsgate connect <host> asks a gateway for its endpoints, trust anchor, browser origin and addressing defaults and writes ~/.gdsgate/config.toml and transport-ca.pem. Trust is settled first: the anchor's fingerprint is confirmed interactively or passed with --fingerprint, and the settings are fetched again over a connection verified against it. There is no trust-on-first-use. See gdsgate connect.
  • packaging/polkit/40-gdsgate-resolver.rules grants members of one group the three resolver actions [client].resolver = "system" uses (set-dns-servers, set-domains, revert), so the mode works under a service running as the person rather than as root. See Names under the system unit.
  • gdsgate authority migrate brings the store's schema up to date and exits, with --check reporting only and exiting non-zero while migrations are pending. store_auto_migrate decides whether a starting service may change the schema itself. See Operations → Store schema.
  • A [[discovery.resources]] entry declares hostname, aliases and port, and a resource created through the catalog API carries the same addressing. gdsgate ls shows the name and port each resource is addressed by.

Changed

  • A node renews its identity in the background for as long as it runs, authenticating with its current certificate instead of a token, and a revoked node or workload is cut off from its live tunnel and running sessions rather than only refused at its next renewal. The transport certificate's lifetime is configurable ([transport].cert_ttl_secs, minimum 120 seconds, default a day). Tunnel eviction is keyed on the node's identity rather than the key it dialled in with, and every identity.renew audit row names the node it renewed for.
  • gdsgate login stores the refresh token beside the identity, and the resident client renews it silently for as long as that token lives; an interactive login is needed again only after it expires. A gateway refusal reaches the client as a stated reason and remedy in gdsgate status, in the client's log, as a desktop notification ([client].notify), and, for SSH, in the connection itself. step_up_on_denial = true raises the passkey ceremony a refusal offers. gdsgate completion <bash|zsh|fish> also completes resource names, read from the resident client's local state.
  • Running the client under sudo files its state under the invoking account's home directory rather than root's, and refuses a stored login belonging to another account even when running as root. The systemd unit no longer assumes a /home/%i layout and runs ProtectSystem=full.
  • GDSGATE_KEYSTORE_ROOT is honoured by every credential write, including the access certificate gdsgate ssh and gdsgate db login mint. gdsgate doctor folds identical findings into one line with a count, names the members under --explain, and keys its run-to-run comparison to the subject of a finding instead of the check id.
  • gdsgate doctor reports the store's schema against the binary and applies no migrations. A migration run against PostgreSQL takes a database lock, so two instances starting together do not collide, and a store carrying migrations recorded by a newer build stops a service start, authority migrate and doctor alike.

v0.8.1

A person can hand an autonomous agent a narrowed subset of their own access, as capabilities: db:read@prod-pg, ssh:connect@bastion, kube:read@staging, mcp:search@corp. gdsgate delegate mints the agent its own short-lived certificate bound to the grant: --bind-key <dir> writes the credential bundle for the agent to present, and --exec <prog> runs a program for the life of the grant and revokes it when the program exits. A grant is always a subset of the delegator's own rights, and every action the agent takes is re-checked against it on top of the normal policy decision. gdsgate delegations lists your grants; gdsgate revoke <id> cuts one off on its next use. Run any command with --as <grant-id> to narrow your own session the same way. gdsgate delegation status [--json] reads the local credential bundle and reports the grant's id, owner, capabilities and remaining validity. See Concepts → Delegation, User guide → Delegating access and Integrations → Delegated agents.

By default a delegation certificate lives as long as the grant and cannot be renewed, so expiry alone revokes it. gdsgate delegate --renewable instead mints a short-lived certificate the connector rolls forward with gdsgate delegation renew within the grant window, bounded by the grant's own TTL and still cut off by a revoke. Grants are sealed with a dedicated HMAC key kept outside the database (gdsgate authority init-delegation-key, [audit].delegation_key_path), and delegation is refused fail-closed until it is set.

gdsgate revoke --all revokes every delegation grant you own; gdsgate revoke --owner <user> (administrative, gated by the disableUser policy action) revokes every grant a given user has ever handed out, without enumerating grant ids. Either moves the owner's revocation epoch forward, so grants minted after the owner is re-onboarded are unaffected, effective on each delegate's next action, and shown as revoked in gdsgate delegations.

v0.8.0

Not wire-compatible with v0.6.x on the certificate-verify path

Access and workload certificates gain an owner field in their signed body, so a v0.6.x connector or proxy cannot verify a certificate a v0.8.0 Authority issues, and vice versa. Upgrade Authority, the proxies and the connectors together: a mixed v0.6.x / v0.8.0 fleet cannot open sessions across the version boundary. The store gains four forward-only migrations (m0030m0033); a v0.8.0 store cannot be downgraded to v0.6.x. See Upgrading from v0.6.x.

Added

  • A machine (a CI job, a service, an autonomous agent) can get its own short-lived identity without a human in the loop. gdsgate machine-id proves what the workload is with a platform token (a GitLab CI id-token) or an admin-minted provision token, and receives a certificate named by a SPIFFE ID (spiffe://<trust-domain>/<issuer>/...); the private key is generated locally and never leaves the host. Off by default, turned on with [workload]. See Integrations → Workload identity.
  • A workload certificate carries no per-resource scope: each connection it opens (SSH, PostgreSQL / MySQL, Kubernetes, MCP, or raw TCP) is authorized on its own against the Cedar Agent principal, which gains an optional owner (the human or service the workload acts for) and spiffe_id; every action is attributed to that owner in the audit log. On an [[connector.backends]] backend with enforce_tool_policy = true, a workload's MCP calls are further gated per tool, deny-by-default. See Policy → Authorizing a workload.
  • A workload bootstrapped from a provision token is tracked in a registry and renews by presenting its current certificate plus a proof of possession, with no new token needed. gdsgate authority list-workloads lists the registry; gdsgate authority revoke-workload <spiffe-id> refuses an identity at its next renewal. A workload bootstrapped from a platform token stays stateless, re-running the bootstrap to stay fresh.
  • A dedicated SPIFFE certificate authority signs workload certificates, separate from the user-SSH, onward-SSH and transport authorities, carried in the trust bundle so connectors and proxies verify workload certificates offline, and rotated the same way the others are (gdsgate authority rotate-spiffe-ca).

Limitations

  • A workload can open a session on every lane, but the derived actions on top of an SSH session, jump-host onward certificates (model B) and -L / -R port forwards, refuse a workload certificate fail-closed; use a human identity for those.
  • A GitLab CI id-token and an admin provision token are the supported bootstrap proofs today. Other platform issuers are additive [[workload.issuers]] entries, documented as they are validated.

Upgrading from v0.6.x

v0.8.0 is forward-only. Because the certificate body changed, a v0.6.x connector or proxy cannot verify a v0.8.0-issued certificate, so plan the upgrade as a coordinated roll of the whole fleet:

  1. Stop every gdsgate service. Clients can stay up; their certificates remain valid until they expire.
  2. Back up the state store. v0.8.0 applies migrations m0030m0033 on first start and they do not roll back; a downgrade means restoring this backup.
  3. To adopt workload identity, enable it. Set the cluster's SPIFFE trust domain and, for a platform issuer, one [[workload.issuers]] entry:

    [workload]
    trust_domain = "gds.example"
    
    [[workload.issuers]]
    slug = "gitlab"
    issuer = "https://gitlab.example"
    audience = "https://gate.example.com"
    path_template = "project/{project_path}/ref/{ref}"
    groups = ["ci"]
    owner_claim = "user_login"
    

    For per-workload revocation, wire the revocation-receipt key (the same key the per-node registry uses) with gdsgate authority init-revocation-key and [audit].revocation_key_path. Skip this step to run without workload identity; the gateway then behaves as v0.6.x did for human access. 4. Start Authority, then the proxies and connectors, on the v0.8.0 binary. Migrations apply automatically on first boot; existing Cedar policies keep validating, since the schema only grew. Add permit rules for the Agent principal only when you adopt workload identity.

v0.6.2

Compatible with v0.6.1

A backward-compatible upgrade: no wire, store, or schema change. Both items are client-side, and only behaviour changes.

Changed

  • gdsgate doctor scopes its checks to the role the config declares. A connector/proxy config no longer reports the Authority security matrix (audit anchoring, JIT, revocation, policy, OIDC, WebAuthn, HA) or client login caches; an Authority/all config no longer reports client login caches. Daemon-wide observability (metrics, OpenTelemetry tracing, the periodic health check) is reported for any daemon, including a connector/proxy. The output reads in a clearer hierarchy on a terminal: bold section headings, the check name at full weight with its value/status dimmed beside it, the config key or command in a remedy emphasised, and a warning carrying its own orange ! marker, distinct from a neutral # note. The plain == … == / ! / backtick form is preserved in a pipe or CI.
  • gdsgate doctor flags any state path on an ephemeral filesystem. The reboot-survival check that already covered the node state_dir also covers the Authority store (a file SQLite store_url) and every configured path (policy, certs, audit keys, admin identity): a file on /tmp, /dev/shm, or /run survives a restart but not a reboot, so it warns instead of reading as present and persistent.

Fixed

  • The connector detects a half-open reverse tunnel. The connector's tunnel to the proxy notices a wedged connection (a half-open TCP drop with no FIN) within 30 to 60 seconds instead of hanging on the OS TCP timeout, through HTTP/2 keepalive on the gRPC tunnel, a read deadline on the inbound stream and TCP keepalive on the WebSocket socket, then drops into the existing backoff reconnect. Client-side only; no wire change.

v0.6.1

Compatible with v0.6.0

A backward-compatible upgrade: no store migration, and the wire stays compatible, so a mixed v0.6.0 / v0.6.1 fleet interoperates and shares one database. The new ReportHealth / ListNodeHealth RPCs are additive, so an older peer does not call them. Behaviour changes only; see the upgrade notes below.

Added

  • gdsgate doctor, a read-only health checklist for the local config and state. It groups findings into configuration (file paths, secret permissions, semantic invariants, connector-backend validity), features (what is enabled and what is silently off), and state (store persistence and CA consistency, node identity freshness, client login caches); every warning or failure carries a fix. --json for machines; exit code 2 on failure, so it works as a preflight or healthcheck. Nothing is mutated.
  • Cluster health from the periodic doctor check. With [doctor].check_interval_secs (default 300s) each daemon re-runs the checklist on a timer. A proxy or connector pushes the summary to Authority, read the fleet-wide view with gdsgate authority list-nodes --health, and, when [telemetry].metrics_listen is set, publishes it locally as gdsgate_doctor_* gauges. See Operations → Surviving a redeploy.

Changed

  • gdsgate doctor flags drift since the last run. Each run snapshots its results to ~/.gdsgate/last-doctor.json; the next run annotates any check whose status changed (↓ regressed / ↑ recovered) and counts regressions.
  • renew_endpoint defaults to the Authority control-plane. A split-deploy node with [enroll].renew_endpoint unset derives it from https://<endpoints.authority> (logged), so token-less mTLS renewal works without separate setup instead of silently falling back to the one-time join token.
  • The client recognises a rejected stale access certificate. When the cluster CA has rotated, or the certificate was revoked, a cached access certificate is refused at session open; the client prints a guided message and clears the stale cache (a re-login or retry mints a fresh one) instead of reporting "no connector serves that resource". A planned rotation prints a one-line notice on the next fresh issue. No wire change.
  • The default state backend is a persistent file rather than in-memory. With store_url unset, gdsgate all / authority resolves to a file SQLite at ~/.gdsgate/state/store.db (created on first start, next to the enrolled node identity), so a restart keeps the same transport/SSH CA and registered nodes stay trusted. sqlite::memory: is an explicit opt-in for ephemeral runs; Authority logs a warning when the store is in-memory.
  • Authority reconciles an inconsistent CA store on startup. If ca_rotation_state references a generation absent from ca_private_key, losing the active signer is fatal, since the private key is unrecoverable, and Authority exits with a guided recovery message instead of minting a fresh CA that would invalidate every enrolled node. Losing only a retiring or candidate generation is auto-healed: the rotation state is reset to active-only Standby with a WARN, since those are held in the trust bundle by public key alone.

Upgrade notes

These releases keep wire and store-schema compatibility with v0.6.x. Only behaviour changes:

  • Default store, only if store_url was unset. A gdsgate all / authority that ran without store_url used an ephemeral in-memory store on v0.6.0; it now persists to ~/.gdsgate/state/store.db. The first start after upgrading is a cold start with a fresh CA, since nothing was persisted before. Deployments that set store_url explicitly are unaffected.
  • A pre-existing inconsistent store is reconciled instead of silently regenerated. Where v0.6.0 minted a fresh CA, this release either stops with a recovery recipe (active generation lost) or auto-heals with a WARN (only a retiring or candidate generation lost). A healthy, consistent store is unaffected.

v0.6.0

Not wire-compatible with v0.5.x on the session-open path

The proxy-to-client and connector-to-proxy session-open messages changed (ServerMessage.opened is now a SessionOpened carrying the session id and a recording flag; ServedResource gains recorded). Upgrade the proxy, connectors and clients together: a mixed v0.5.x / v0.6.0 fleet cannot open data-plane sessions across the version boundary. No store migration; the control-plane (auth) RPCs are unchanged.

Changed

  • Lean client console output. The client subcommands narrate themselves on stderr in a compact dialect ( in progress, done, # a note, a failure) instead of timestamped tracing INFO lines. A terminal gets glyphs and a little colour; a pipe, ProxyCommand or CI gets plain ASCII, and NO_COLOR is honoured. login shows ✓ authenticated as <user> (group: …); ssh / db login show ✓ certificate issued · ttl <n> · <resource>; a data-plane connection shows # connected · session <id> · recording. Client diagnostic logs default to warn; RUST_LOG still raises them, and -q/--quiet silences the narration. Daemons are unchanged (structured logs at INFO). Command data on stdout is untouched, so scripts and proxy-ssh keep working.

Fixed

  • No spurious disconnect banner after ssh … --exec. A native ssh session through the gateway no longer prints Received disconnect … : session ended / Disconnected from … when the remote command finishes: the session ends on the client's own close rather than a server-initiated disconnect. Session recordings still finalize on a normal exit, and an abruptly closed client no longer leaves a stray process on the connector host.

v0.5.2

Compatible with v0.5.x

A maintenance release covering internal testing and tooling only. No behaviour, API, wire, or store change.

v0.5.1

Compatible with v0.5.0

A backward-compatible upgrade: no store migrations, no new RPCs, the wire stays compatible. Trace export is opt-in and off by default.

Added

  • OpenTelemetry trace export. Set [telemetry].otel_endpoint to an OpenTelemetry collector and a service exports traces over OTLP/HTTP, off by default and outbound: the service dials the collector and pushes spans, so it binds nothing for this. The W3C traceparent header is propagated across the proxy-to-auth, connector-to-auth and connector-to-proxy gRPC seams, so a session open and the authorization decision it triggers stay in one trace. Authorization spans carry action, resource, decision, and session_uid where they apply. See Operations → Distributed tracing.
  • Per-request authorization metric. gdsgate_request_authorize_total{kind,decision} counts the per-request gates (Kubernetes requests, DB connects, and per-statement SQL, kind = kube / db_connect / db_query), distinct from the issuance-time gdsgate_authorize_total. See Operations → Metrics and health.

v0.5.0

Compatible with v0.4.x

A backward-compatible upgrade: no store migrations, no new RPCs, and the wire stays compatible, so a mixed v0.4.x / v0.5.0 fleet interoperates and the store needs no change. The Cedar schema gains additive AccessRequest attributes (target_project plus the target's labels as tags); existing policies are unaffected.

Added

  • Operational metrics and health endpoints. Set [telemetry].metrics_listen and a service exposes Prometheus /metrics plus liveness /healthz and readiness /readyz on that address (off by default). The metrics cover authorization decisions, proxy sessions, and connector reconnects. See Operations → Metrics and health. OpenTelemetry trace export is not wired yet.
  • Approver scoping by project and label. An approveRequest policy can scope approver groups by the target resource's project (resource.target_project) and labels (resource.getTag(...) / hasTag(...)), not only its environment. See Policy → Approving access requests.

Changed

  • Kubernetes 403s carry a remediation hint. When a per-request policy denies a kubectl call that a step-up or approval could clear, the connector's 403 names the gdsgate command to run, in the body and an X-Gdsgate-Remediation header. A deny that nothing can clear stays a plain 403. kubectl cannot remediate inline: run the command, then re-issue the request.

v0.4.0

Not compatible with v0.3.x

v0.4.0 expands the Cedar schema (new context attributes for IdP-login and gateway step-up MFA, and a requested_action on the AccessRequest entity) and adds control-plane RPCs for MFA, WebAuthn, and step-up that a v0.3.x client, Connector, or Proxy does not speak. Mixed v0.3.x / v0.4.0 nodes are not supported. The store gains six forward-only migrations (m0024m0029); a v0.4.0 store cannot be downgraded to v0.3.x. See Upgrading from v0.3.x.

Added

  • Gateway MFA step-up. A policy can demand a step-up the gateway performs itself, separate from MFA done at the identity provider. The context carries two channels: context.mfa_satisfied reflects an MFA login at the IdP (read from the token's amr / auth_time), and context.step_up_satisfied reflects a gateway step-up. A sensitive action can require the gateway step-up even when the user already used MFA at the IdP.
  • TOTP and WebAuthn step-up factors. TOTP enrolls with gdsgate mfa enroll / confirm and steps up with gdsgate mfa verify; WebAuthn passkeys register with gdsgate mfa register-passkey and step up with gdsgate mfa step-up. Each factor has its own Cedar channel (context.totp_satisfied, context.webauthn_satisfied, each with an *_age_seconds), so a policy gates on any factor (step_up_satisfied) or a specific one. Configure the factors under [mfa] / [mfa.webauthn].
  • WebAuthn passkeys on the gateway origin. The passkey ceremony runs on the gateway's own browser origin (set [proxy].public_origin), so a credential is bound to the gateway domain instead of a workstation. The relying party is configured under [mfa.webauthn] (rp_id, origins); a localhost loopback remains for development.
  • One-time step-up grants. For an action that must be re-authorised on every access, a policy can require a single-use grant bound to (user, resource, action) that the gateway consumes on the allow. A user mints one with gdsgate step-up <resource> (passkey ceremony) or --code <totp>; the minting factor is available to the policy, so a rule can demand a specific factor.
  • Inline remediation on a curable denial. When an access attempt is denied for a reason the user can fix, a missing step-up or a pending JIT approval, the client surfaces the remedy, performs it (a step-up ceremony, or files and waits on an access request), and retries the original access in one command. --no-remediate opts out, --step-up-factor forces a factor, and --wait-timeout bounds the wait for an approval.
  • MFA at per-request gates. The IdP-login MFA time is signed into the access certificate, so the per-tool MCP, per-request Kubernetes, and per-statement database gates honour mfa_satisfied without a separate step-up. A curable MCP or database deny returns the step-up or approval that would satisfy the policy, so the client raises the matching ceremony and retries.
  • Restricted-mode database hardening. Restricted mode refuses PostgreSQL fast-path function calls and COPY … TO/FROM PROGRAM, closing two paths around the per-statement category gate.
  • JSON logs. GDSGATE_LOG_FORMAT=json emits one JSON object per line for a log shipper; the default stays the human-readable format.

Changed

  • A JIT access request can no longer be self-approved, enforced in Cedar and at the store. Approver groups can be scoped to the request's target environment.
  • A repeated access request for the same resource and action resumes the open one instead of creating a duplicate, and the request list shows only the requester's own requests and those a caller may approve.

Upgrading from v0.3.x

v0.4.0 is forward-only. Run it as a fresh cluster where you can; an in-place upgrade is:

  1. Stop every gdsgate service. Clients can stay up; their certificates remain valid until they expire.
  2. Back up the state store. v0.4.0 applies migrations m0024m0029 on first start and they do not roll back; a downgrade means restoring this backup.
  3. For WebAuthn step-up, configure the relying party. Point the ceremony at the gateway's own origin so passkeys bind to the gateway domain:

    [proxy]
    public_origin = "gate.example.com"
    
    [mfa.webauthn]
    rp_id = "gate.example.com"
    origins = ["https://gate.example.com"]
    

    Skip this if you use only the TOTP factor, which needs no configuration, or no gateway step-up at all. 4. Start Authority. The migrations apply automatically on first boot. Existing Cedar policies keep validating, since the schema only grew. Add gates on the MFA and step-up channels only when you adopt them. 5. Roll every Connector and Proxy to a v0.4.0 binary.

v0.3.0

Not compatible with v0.2.x

v0.3.0 expands the Cedar schema (new actions dbQuery, mcpCallTool, viewTools, killSession; new entity types Tool and Session) and adds control-plane RPCs that a v0.2.x Connector or Proxy does not speak. Mixed v0.2.x / v0.3.0 nodes are not supported. The store gains three forward-only migrations (m0021m0023); a v0.3.0 store cannot be downgraded to v0.2.x. See Upgrading from v0.2.x.

Added

  • Live resource discovery. A connector enumerates the databases behind a PostgreSQL catalog entry and publishes them, so each appears in gdsgate ls as a connectable <host>.<db> resource, filtered per identity by the Cedar view action. Each snapshot is owned by exactly one connector, carries a freshness window so stale entries drop out, and is sealed with an integrity receipt held outside the state store. Set [audit] inventory_key_path and run gdsgate authority init-inventory-key to enable the receipt; discovery is opt-in per backend.
  • Per-database connect. A discovered database is directly connectable as Database::"<host>.<db>", so a policy can grant a single database on a host or every database under it.
  • Per-statement database authorization. The Cedar dbQuery action gates a session by statement category (read, write, DDL, DCL) enforced at the connector on every statement. A session opens with the categories the policy permits for its role and is held to them for its lifetime.
  • Session-role impersonation. A client may connect to PostgreSQL as a named role; the policy decides which roles each principal may assume.
  • Per-tool MCP authorization. The Cedar mcpCallTool action authorizes every tools/call against a Tool entity (tool names match directly, including like wildcards); viewTools filters tools/list responses per identity. An operator-set is_destructive marker, driven by [mcp] destructive_patterns, lets a policy block destructive tools. The connector's static allowed_tools list remains as a second gate.
  • Live MCP tool discovery. An MCP backend's tools are probed and used to filter tools/list responses per identity (viewTools). Tools are runtime operations rather than connect targets, so they are not listed in gdsgate ls; only the MCP server is.
  • Live session monitoring and control. gdsgate session list shows the active sessions and gdsgate session watch streams opens and closes as they happen; gdsgate session kill ends a session by id or by --principal / --resource. Termination is authorized by the Cedar killSession action over a Session entity and is audited.
  • Live recording streaming. gdsgate authority recordings live tails an in-progress SSH or kubectl exec recording in real time, gated by the same viewRecordings action as the finished-recording viewer.
  • Proxy admission control. The [proxy] section caps concurrent sessions, per-tunnel streams, public connections, and the per-IP accept rate, and can pace per-session bandwidth. Every limit defaults to 0 (unlimited).
  • OIDC signing-algorithm allow-list. [oidc] id_token_signing_algs restricts which id-token signatures Authority accepts (default: every asymmetric algorithm; symmetric HS* is always refused).

Changed

  • Authority and Proxy authenticate the caller before routing a request, and an unroutable attempt is audited instead of dropped silently.
  • Decision audit records use the short Cedar action name, so a filter on action = "dbConnect" matches both the decision and the session events.
  • The connector restricts backend dial targets to a configured allow-list.
  • Dependency crates are quiet in the log by default: gdsgate* at INFO, everything else at WARN.

Fixed

  • SSH session recordings upload to the central store in central mode; they previously fell back to the on-connector copy every time.
  • MCP JSON-RPC input is capped per line, closing a memory-exhaustion path on an unterminated message.
  • Non-UTF-8 SQL captures are annotated with a stable digest instead of dropping the query-log entry.

Upgrading from v0.2.x

v0.3.0 is forward-only. Run it as a fresh cluster where you can; an in-place upgrade is:

  1. Stop every gdsgate service. Clients can stay up; their certificates remain valid until they expire.
  2. Back up the state store. v0.3.0 applies migrations m0021m0023 on first start and they do not roll back; a downgrade means restoring this backup.
  3. For live discovery, generate the inventory key:

    gdsgate --config auth.toml auth init-inventory-key
    

    It writes a hex-encoded 32-byte key with mode 0o600 and refuses to overwrite an existing file. Wire the path into Authority's config and back the file up alongside the database:

    [audit]
    inventory_key_path = "/var/lib/gdsgate/inventory.key"
    

    Without it, published inventory snapshots are stored receipt-less and read back without integrity verification. Skip this step if you do not use discovery. 4. Start Authority. The migrations apply automatically on first boot. Existing Cedar policies keep validating, since the schema only grew. Add permit rules for dbQuery, mcpCallTool, viewTools, or killSession only when you adopt those features. 5. Roll every Connector and Proxy to a v0.3.0 binary.

v0.2.0

Not compatible with v0.1.x

v0.2.0 changes the wire format of the trust-bundle gRPC message, the Cedar schema (three actions removed, one entity type removed), and introduces eight forward-only schema migrations on the state store. Earlier clients, connectors and proxies will not interoperate with a v0.2.0 Authority, and a v0.2.0 store cannot be downgraded to v0.1.x. See Upgrading from v0.1.x below.

Added

  • High availability with split-brain protection. Several Authority instances can share one state store. They elect a single audit-chain writer through a database lease that carries a monotonic fencing token; if a formerly leading instance is partitioned away and another takes over, the ex-leader's subsequent writes are refused at the chain-commit transaction itself, even before its own controller has observed the loss. See Admin guide → High availability.
  • External audit anchor. Each batch commit to the audit chain is sealed under an HMAC key that lives on the Authority host's filesystem rather than in the database. A separate gdsgate authority verify-audit command replays the chain off-band and cross-checks every anchor, surfacing a database-side truncate or rewrite that the in-database hash chain alone could not detect. See Operations → Audit.
  • Per-node revocation receipts. Revoking a registered node writes an HMAC-signed receipt next to the revocation marker. A database rollback that tries to un-revoke a node by restoring an earlier row is detected on the next renewal and refused. The receipt key, like the audit-anchor key, lives on the Authority host.
  • Typed certificate authorities. Trust bundles carry the kind of each CA (User SSH / Onward SSH / HostTls transport) alongside its public key, and verification refuses to trial a key from the wrong family. Every authority also carries an explicit validity window, so a calendar-expired CA is dropped from the bundle even if it is still in the database.
  • Source-IP enforcement is signed end-to-end. When the Proxy passes the client's TCP-peer address to the Connector for pin_src_ip enforcement, it signs that value with a short-lived key the Connector verifies, so the source IP cannot be spoofed from a compromised Proxy.
  • Hardened OIDC. The id-token validator hot-swaps its JWKS when the identity provider rotates keys (an on-demand refetch fires on the first unknown kid, so a stale key never causes an outage), refuses non-HTTPS discovery and JWKS URLs, rejects mixed-signing-algorithm metadata, and binds the PKCE nonce end-to-end through the browser-login path.
  • Cedar policy editing with quorum and break-glass recovery. Policy edits can require approval from a configurable number of reviewers ([policy.editor] min_approvers) before they become active, and the schema's hard meta-invariants refuse a push that would lock break-glass out or self-lock the proposer. A configurable break-glass policy file ([policy.editor] break_glass_policy_file) at boot restores access if an active version ever fences the editor out.
  • HTTP hardening for the cluster's outbound calls. Authority's calls to the identity provider's discovery and JWKS endpoints, and to any external target it dials, run through a hardened HTTP client with TLS-only enforcement, redirect handling, body caps and request timeouts.
  • Kubernetes proxy hardening. Port-forward is opt-in per backend (allow_port_forward = true in the connector's [[connector.backends]]), WebSocket message and frame sizes are capped 32x below the upstream default, sessions are torn down when the carrying certificate expires mid-stream, request paths are canonicalised before authorisation, and audit-field injection through user-controlled byte values is closed by percent-encoding the four framing characters at the producer.
  • Constant-time digest compare and zeroized secrets. Recording digest mismatches and similar comparisons are constant-time, and the in-memory SSH seed and PKCS#8 DER for newly minted keys are wiped on drop.
  • Per-peer-IP enrollment rate-limit. The plaintext enrollment listener runs a token bucket per source IP before the gRPC handler sees the request, so a busy attacker cannot starve a real joiner.
  • Improved registration-token registry. Registration tokens filter by role inside the conditional UPDATE that consumes them; a role-mismatched request fails as Unknown without burning the token, so a typo, or an adversary who learned the secret but not its bound role, does not lock the legitimate joiner out.
  • Tighter admin-action surface. Three Cedar actions (rotateCA, enrollNode, disableUser) are enforced through admin gRPC instead of any direct-store path. Admin commands require a bootstrap-admin identity loaded from [admin].identity_dir; see Admin guide → First-boot bootstrap.

Changed

  • The Cedar schema this release ships contains exactly the actions the cluster authorises today. dbQuery, dbWrite and mcpCallTool, plus the Tool entity type, appeared in earlier drafts and are removed from the schema for this release; the corresponding per-statement database authorisation and per-tool MCP authorisation return in a later one.

MCP per-tool authorisation

For 0.2.0, MCP sessions are authorised at connect-level through the tcpConnect action over a TcpService resource (the proxy maps kind = "mcp" to the same action as raw TCP). Until mcpCallTool returns to the schema, per-tool restrictions live on the connector's static [[connector.backends]] allowed_tools = […] allow-list on the MCP backend.

Upgrading from v0.1.x

v0.2.0 is forward-only. You cannot run a v0.2.0 Authority against an older client, connector or proxy, nor a v0.1.x Authority against a database that v0.2.0 has migrated. Plan an upgrade as a fresh cluster (new store, new registration) when possible; an in-place upgrade requires:

  1. Stop every gdsgate service in the cluster. Clients can stay up; their certificates remain valid until they expire.
  2. Back up the state store. v0.2.0 applies forward-only schema migrations on first start; a downgrade requires restoring the backup.
  3. Generate the new HMAC keys the v0.2.0 audit anchor and per-node revocation receipt need:

    gdsgate --config auth.toml auth init-anchor
    gdsgate --config auth.toml auth init-revocation-key
    

    Both write a hex-encoded 32-byte key with mode 0o600 and refuse to overwrite an existing file. Back the resulting key files up alongside the database; losing them disables verification of anchored events and revocation receipts.

  4. Wire the resulting paths into Authority's config:

    [audit]
    anchor_key_path = "/var/lib/gdsgate/anchor.key"
    revocation_key_path = "/var/lib/gdsgate/revocation.key"
    
  5. Provision an admin identity if you do not already have one:

    gdsgate --config auth.toml auth bootstrap-admin \
      --output-dir /var/lib/gdsgate/admin --ttl 300
    

    Then point your operator workstation's config at it ([admin].identity_dir, [admin].endpoint).

  6. Drop dbQuery / dbWrite / mcpCallTool references from your Cedar policies before pushing them: these actions and the Tool entity type are not part of the 0.2.0 schema. Connect-level authorisation is unchanged, so a database session goes through dbConnect and an MCP session goes through tcpConnect over a TcpService, as raw TCP does. For MCP, express per-tool restrictions through the connector's static [[connector.backends]] allowed_tools = […] allow-list until the per-tool action returns to the Cedar schema.

  7. Start Authority. The schema migrations apply automatically on the first boot.

  8. Roll every Connector and Proxy to a v0.2.0 binary. Their trust bundles are re-fetched with the new typed-key wire format on the next RenewIdentity.

To run several Authority instances behind one store, enable HA in the config ([ha] enabled = true, one unique owner per instance); see Admin guide → High availability for the lease tunables.

v0.1.4

  • Kubernetes exec / attach session recording. The connector's Kubernetes proxy demuxes the v5.channel.k8s.io (and v4) WebSocket subprotocol that kubectl 1.30 and newer negotiates on pods/.../exec upgrades and writes the captured stdout and stderr as a regular asciicast v2 cast, with the same disk and central storage as SSH session recordings and the same gdsgate authority recordings list/show viewer. Each session lands as kube.session.close in the audit chain with the recording URI plus per-channel byte counters (stdin_bytes=… stdout_bytes=… stderr_bytes=… error_bytes=… resize_events=…) and the negotiated subprotocol. Legacy SPDY/3.1 exec stays as a transparent byte bridge with raw bytes_up=… bytes_down=…, with no demux and no recording.
  • [recording] capture_stdin = false default. The session recorder drops stdin chunks by default for both SSH and kube exec/attach. Echo-off password prompts (sudo, ssh, passwd, any read -s) hand the typed bytes to the server through stdin without echoing them back, so a stdin-on cast holds those passwords in plain text. Enable per cluster with [recording] capture_stdin = true in the connector's config where the deeper audit trail is worth the PII risk.
  • Graceful WebSocket close. The Kubernetes exec bridge forwards Close frames symmetrically between client and apiserver and waits for both halves to close before dropping the sockets, so kubectl no longer reports websocket: close 1006 (abnormal closure) on every exec.
  • Central-store upload race fixed. The central recording sink retries Authority.StoreRecording with capped exponential backoff (200/400/800/1600/3200 ms) on the well-known "no session.close event found" precondition, and the kube path emits the close-audit event before uploading; previously Authority rejected the upload because the matching session.close was not on the chain yet. gdsgate authority recordings show also queries Authority by session_uid regardless of the URI scheme in the audit row, so casts uploaded centrally are reachable from any client that holds viewRecordings.

v0.1.3

  • SSH session recording. Every SSH session through gdsgate is recorded as an asciicast v2 stream. Two storage modes are selectable via [recording] mode = "disk" | "central": disk keeps the cast on the connector, central uploads it to Authority at session close, with a local DR copy. Each session-close audit event carries the recording URI and byte count.
  • Recording viewer. gdsgate authority recordings list returns recent session-close events; gdsgate authority recordings show <session_uid> prints the cast, which pipes into asciinema play to replay. Gated by the viewRecordings Cedar permission.
  • SFTP audit. File-transfer activity over SFTP is auditable in both model A and model B: each open/close/remove/rename/mkdir/rmdir lands in the audit chain; byte counts are aggregated per handle and flushed on close.
  • gdsgate ssh --exec. One-shot SSH: mints the access certificate and immediately runs the native ssh with the ProxyCommand wiring passed inline, so the gdsgate ssh-config / ~/.ssh/config step is not required for the simple case. Arguments after -- are forwarded to ssh. See CLI → gdsgate ssh.
  • Token-less mTLS identity renewal. A node whose transport leaf is within its renew window refreshes it over mTLS instead of burning a fresh registration token. Opt in per node with [enroll].renew_endpoint, Authority's mTLS control plane URL; a near-expiry node without that field falls back to the legacy token-based path, so the upgrade is non-breaking. The peer is identified by the verified leaf's CN (gdsgate-connector / gdsgate-proxy).
  • Per-node revocation registry. Every enrolled node lives as a row in Authority's store (role, CN, public-key fingerprint, enrolled-at). Two new sub-actions of gdsgate authority operate against the shared store_url: authority list-nodes prints the rows; authority revoke-node <id> flips the row to revoked, so the node can no longer renew over mTLS. The already-issued leaf stays chain-valid until its not_after, so pair revoke with short certificate TTLs for prompt eviction.
  • HostTls (transport) CA rotation. The transport CA is operator-rotatable on the same paced double-signing controller as the User SSH and Onward SSH CAs. gdsgate authority rotate-host-tls-ca records the request; the running Authority promotes the new generation after propagation_secs and retires the old after retire_secs. Nodes pick up the new trust bundle on their next mTLS renew, so schedule the rotation window accordingly. There is no auto-schedule for HostTls; the cadence is operator-timed.
  • Kubernetes exec / attach / port-forward. kubectl exec, kubectl attach, and kubectl port-forward flow through the connector: when a request carries an Upgrade: header, the proxy opens the raw HTTP/1.1 connection to the API and bridges the upgraded client and upstream sockets after both sides reach 101. Impersonation still applies: Authorization and Impersonate-* headers are stripped on the upgrade path too. The audit chain records k8s.request on 101 and k8s.exec.close with bytes_up / bytes_down. Stream-level recording of the exec stdout is not captured, only the audit metadata.
  • Source-IP pinning, opt-in per resource. A certificate can bind the client TCP peer it was issued for, and the connector rejects it at session-open if the actual peer does not match, on top of issue-time CIDR gating. Opt in per resource by tagging the catalogue entry with pin_src_ip = "true"; unlabelled resources keep issuing un-pinned certificates. See Configuration → discovery rules.

v0.1.2

Remote policy editing: the Cedar-declared editPolicy action becomes operational, and a built-in TUI editor lands in the binary.

Remote policy editing:

  • gdsgate authority policy pull / push go through Proxy to Authority and are gated by the Cedar editPolicy action over the verified caller. push strict-validates locally first, the server strict-validates again before storing, and the new version is hot-reloaded cluster-wide, with no Authority restart. Every version is retained for history and rollback. Allow, deny, and invalid-policy attempts are recorded in the audit chain as AdminAction.
  • The [policy].path file becomes a seed: it loads the policy into the store on first boot, and from that point on the store is the source of truth. Restart with the file only to recover from a lock-out, where the file replaces the active version on the next start.

Interactive policy editor (gdsgate authority policy edit):

  • Full-screen terminal editor over tui-textarea plus a custom Cedar renderer. Pulls the active policy on entry, validates and pushes cluster-wide on Ctrl-S.
  • Live lint: every keystroke re-runs the strict Cedar validator; the first error message shows in the status bar and the offending line is painted red in the buffer.
  • Syntax highlighting for keywords, strings, comments, and entity-type names.
  • Context-aware completion (Tab): inside a Type::"…" literal only ids of that type from the schema and live catalog; after resource. / principal. / context. only the attributes Cedar declares for that base (resource also offers getTag / hasTag); elsewhere entity types, actions, keywords and live resource ids. The popup narrows as you keep typing.
  • Search (Ctrl-F), Cedar formatter (Ctrl-L), selection and clipboard (Shift with arrows, Ctrl-A / Ctrl-C / Ctrl-X / Ctrl-V), undo and redo (Ctrl-Z / Ctrl-R), dirty-buffer quit confirmation (Ctrl-Q).

The full CLI surface is on the CLI page; the editor's keybindings live at authority policy edit. For grants, lock-out recovery, and operational notes see Admin guide → Policy lifecycle.

v0.1

The first gdsgate release: a single binary that gates identity-aware, audited, least-privilege access to SSH, databases, Kubernetes, MCP, and TCP services through one proxy.

Access and identity:

  • Single binary, four roles (Authority / Proxy / Connector / client) by subcommand.
  • OIDC login, device flow and Authorization Code with PKCE; identity tokens verified against the provider's JWKS (RS256), claims mapped to a principal and its groups.
  • Cedar authorisation over (principal, action, resource), with the action derived from the resource's kind; deny-all by default.
  • Short-lived access certificates issued on Allow; expiry is the revocation mechanism.

Resources:

  • SSH model A (connector-terminated, recorded) and model B (jump host with per-connection downstream certificate), PostgreSQL / MySQL (forwarded with a query-log tap), Kubernetes (identity impersonation), MCP (tools/call allow-list), raw TCP, declared per connector in [[connector.backends]].
  • Native client experience: gdsgate login, then ssh, psql / mysql, kubectl, redis-cli through the gateway.
  • SSH -L and -R with two-layer gating: a per-backend allow-list and the Cedar sshForwardLocal / sshForwardRemote actions.
  • Persistent connector-side SSH host key, so a client's known_hosts entry keeps verifying across sessions.

Transport and trust:

  • Node registration with one-time tokens; internal mutual TLS and public TLS off a persisted transport CA; the connector reverse tunnel (gRPC, WebSocket fallback, HTTP CONNECT proxy support) so backends need no inbound access.
  • Three certificate authorities (transport, User SSH, Onward SSH), all persisted, the latter two paced-double-signing rotatable at runtime.
  • Tamper-evident, hash-chained, durable audit (persist-before-grant) with JSON / Splunk HEC / CEF export.

Discovery and JIT:

  • Resource catalog seeded from [discovery], surfacing in gdsgate ls and supplying live attributes (environment, labels) to Cedar.
  • Just-in-time access via gdsgate request-access / gdsgate approve, with a per-resource, per-environment and global approvers cascade.

Supply chain:

Validation:

  • The full multi-node topology has been exercised end to end against a real Keycloak identity provider, covering group-scoped access over databases, SSH (both models), Kubernetes, raw TCP, and MCP (allow for one group, deny for another); node registration with mutual TLS; public-TLS verification; network-zone isolation; least-privilege containers.

Targets: x86_64-unknown-linux-gnu and a static x86_64-unknown-linux-musl.