Skip to content

Commit 719abc4

Browse files
committed
Add Postgres signed authorization registry
1 parent 5b1d66e commit 719abc4

15 files changed

Lines changed: 2331 additions & 321 deletions

Cargo.lock

Lines changed: 1061 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ opentelemetry = "0.31"
2929
opentelemetry_sdk = { version = "0.31", features = ["rt-tokio"] }
3030
opentelemetry-otlp = { version = "0.31", features = ["grpc-tonic"] }
3131
anyhow = "1"
32+
sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "migrate", "macros", "derive"] }
33+
chrono = { version = "0.4.44", features = ["serde"] }
34+
sha2 = "0.11.0"
35+
p256 = { version = "0.13.2", features = ["ecdsa", "pkcs8"] }
3236

3337
[dev-dependencies]
3438
rcgen = { version = "0.14", features = ["x509-parser"] }

README.md

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
An mTLS HTTP/2 CONNECT proxy that authorizes connections based on custom X.509 certificate extensions.
44

5-
The proxy accepts incoming mTLS connections, extracts a custom extension value from the client certificate, and checks it against a TOML policy file to decide whether the client may connect to the requested destination. If allowed, it opens a raw TCP connection to the destination and tunnels data bidirectionally. The client is responsible for establishing its own TLS session to the destination through the tunnel.
5+
The proxy accepts incoming mTLS connections, extracts a custom extension value from the client certificate, and checks a PostgreSQL-backed signed permission registry to decide whether the client may connect to the requested destination. If allowed, it opens a raw TCP connection to the destination and tunnels data bidirectionally. The client is responsible for establishing its own TLS session to the destination through the tunnel.
66

77
## Building
88

@@ -22,6 +22,9 @@ sudo apt-get install libtss2-dev swtpm tpm2-tools pkg-config
2222
```bash
2323
./examples/generate-certs.sh # server CA + gateway cert under certs/
2424
cp config.example.toml config.toml # edit to taste
25+
docker compose -f docker-compose.postgres.yml up -d
26+
export AGENT_GATEWAY_DATABASE_URL=postgres://agent_gateway_admin:agent_gateway_dev@localhost:5432/agent_gateway
27+
cargo run -- --config config.toml migrate
2528
```
2629

2730
`generate-certs.sh` only creates **server** TLS material (`server-ca.pem`, `server.pem`, ...). Each agent platform enrolls with `./examples/connect.sh`, which starts a local `swtpm`, creates a persistent P-256 signing key in that simulated TPM, and issues `machine-client.pem` for the TPM public key. The gateway needs the generated `machine-client-ca.pem` in `certs/client-ca-bundle.pem` before it can trust that sidecar.
@@ -30,11 +33,12 @@ Typical first-time flow:
3033

3134
1. `./examples/generate-certs.sh` and `cp config.example.toml config.toml`.
3235
2. `./examples/connect.sh --gateway 127.0.0.1:8443 --gateway-ca certs/server-ca.pem` creates `machine-client-ca.pem` under `~/.local/share/agent-gateway/` (or `$XDG_DATA_HOME`). Append that file to `client_ca_path` (for example, `cat ~/.local/share/agent-gateway/machine-client-ca.pem >> certs/client-ca-bundle.pem`).
33-
3. Start the gateway (`cargo run -- --config config.toml`), then return to the terminal running `connect.sh` and press Enter to start the sidecar and Claude.
36+
3. Insert a trusted principal signing key, its delegation scope, and signed permission rows into Postgres.
37+
4. Start the gateway (`cargo run -- --config config.toml`), then return to the terminal running `connect.sh` and press Enter to start the sidecar and Claude.
3438

3539
On later runs, start the gateway first, run `connect.sh`, and press Enter after confirming the machine CA is still registered. If `connect.sh` finds an existing simulated TPM key but the saved `machine-client.pem` was issued for a different public key, it reissues `machine-client.pem` for the current TPM key using the existing machine client CA. `--regenerate-certs` creates a fresh simulated TPM state and machine client CA, so the new CA must be appended to `client_ca_path`.
3640

37-
Pass a custom policy extension value: `connect.sh ... --extension-value agent-beta`.
41+
Pass a custom policy extension value: `connect.sh ... --extension-value agent-beta`. The extension value must match `permission_registry.subject_identity` in an active signed permission row.
3842

3943
The simulated TPM state lives under `~/.local/share/agent-gateway/swtpm/` unless
4044
`XDG_DATA_HOME` is set. By default, the sidecar uses TCTI
@@ -56,13 +60,17 @@ Copy `config.example.toml` to `config.toml` and edit it. Key sections:
5660
| `tls_key_path` | yes | PEM private key for the server cert |
5761
| `client_ca_path` | yes | PEM bundle of per-machine client CAs (append each `machine-client-ca.pem`) |
5862

59-
**`[policy]`** -- Maps certificate extension values to allowed destinations.
63+
**`[policy]`** -- Configures the certificate identity extension and Postgres registry access.
6064

6165
| Field | Description |
6266
|---|---|
6367
| `client_ext_oid` | OID of the custom X.509 extension to extract (dotted notation) |
64-
| `rules[].extension_value` | Value to match in the extension |
65-
| `rules[].allowed_destinations` | List of `host` or `host:port` entries. Port defaults to `443` if omitted. |
68+
| `database_url` | Postgres URL for the authorization registry. Prefer `database_url_env` outside local development. |
69+
| `database_url_env` | Environment variable containing the Postgres URL. |
70+
| `max_connections` | Maximum Postgres pool connections. Defaults to `5`. |
71+
| `connect_timeout_ms` | Postgres connection timeout. Defaults to `5000`. |
72+
| `pool_acquire_timeout_ms` | Pool acquire timeout per authorization check. Defaults to `1000`. |
73+
| `query_timeout_ms` | Query timeout per registry lookup. Defaults to `500`. |
6674

6775
**`[observability]`** -- Logging and tracing.
6876

@@ -85,8 +93,60 @@ continues the same trace.
8593
agent_gateway --config config.toml
8694
```
8795

96+
Run migrations explicitly before starting the gateway:
97+
98+
```bash
99+
agent_gateway --config config.toml migrate
100+
```
101+
102+
Gateway startup verifies the authorization registry schema version and fails fast if the database is not migrated. The runtime gateway database role should be read-only for authorization tables; use a separate admin role for migrations and registry writes.
103+
88104
Shut down cleanly with `Ctrl-C`.
89105

106+
Register a demo principal signing key with:
107+
108+
```bash
109+
./examples/register-principal-key.sh org-alice
110+
```
111+
112+
The script creates a P-256 private key under `certs/principals/`, stores the public key in `principal_signing_keys`, and uses the friendly `key_id` (`org-alice`, `org-bob`, etc.) for the registry row.
113+
114+
## Authorization Registry
115+
116+
The gateway authorizes a CONNECT only when all of these checks pass:
117+
118+
1. The mTLS client certificate has the configured UTF8String identity extension.
119+
2. The requested authority normalizes to a `host:port` destination.
120+
3. Postgres contains an active `permission_registry` row for that identity and destination.
121+
4. The row's `signature` verifies over the canonical permission row fields with the referenced active principal signing key.
122+
5. `principal_key_permissions` confirms that the signing key was allowed to delegate that identity/destination scope.
123+
124+
### Database Structure
125+
126+
The authorization registry has three main tables:
127+
128+
| Table | Key Columns | Purpose |
129+
|---|---|---|
130+
| `principal_signing_keys` | `key_id`, `algorithm`, `public_key_spki_der`, `pubkey_sha256`, `not_before`, `not_after`, `revoked_at` | Stores trusted P-256 public keys that may sign permissions. |
131+
| `principal_key_permissions` | `signing_key_id`, `subject_identity`, `destination`, `not_before`, `not_after`, `revoked_at` | Defines what each signing key is allowed to delegate. |
132+
| `permission_registry` | `permission_id`, `signing_key_id`, `subject_identity`, `destination`, `not_before`, `not_after`, `revoked_at`, `signature` | Stores signed permissions that authorize a subject identity to reach a normalized destination. |
133+
134+
`principal_key_permissions.signing_key_id` and `permission_registry.signing_key_id` both reference `principal_signing_keys.key_id`. A permission is usable only when the permission row is active, the signing key is active, the signature verifies over the canonical row fields, and the signing key has a matching delegation scope row.
135+
136+
The signed bytes are the following UTF-8 text, with fields in this exact order and timestamps formatted as UTC RFC 3339 with six fractional digits:
137+
138+
```text
139+
agent-gateway-permission-v1
140+
permission_id=perm-1
141+
signing_key_id=org-alice
142+
subject_identity=agent-alpha
143+
destination=api.example.com:443
144+
not_before=2026-05-01T00:00:00.000000Z
145+
not_after=2026-06-01T00:00:00.000000Z
146+
```
147+
148+
Destination strings are normalized with the same rules used for CONNECT requests: hostnames are lowercased, omitted ports default to `443`, and IPv6 destinations use bracketed `host:port` form.
149+
90150
## Client requirements
91151

92152
Clients must:
@@ -116,7 +176,7 @@ Clients must:
116176

117177
### Client certificate extension
118178

119-
The extension value is matched exactly (case-sensitive) against policy rules. The extension must be an X.509 extension at the configured OID containing a single DER-encoded ASN.1 UTF8String.
179+
The extension value is matched exactly (case-sensitive) against signed permission rows. The extension must be an X.509 extension at the configured OID containing a single DER-encoded ASN.1 UTF8String.
120180

121181
Example certificate generation with `rcgen`:
122182

@@ -136,4 +196,4 @@ params.custom_extensions.push(
136196
cargo test
137197
```
138198

139-
38 integration tests cover policy evaluation, destination normalization (including IPv6 and default port), config validation, TLS PKI generation, and proxy request parsing.
199+
Database-backed policy and e2e tests require `TEST_DATABASE_URL` to point at a Postgres database that the test process can migrate and write to. The tests cover policy evaluation, signed-permission verification, signer delegation scope enforcement, destination normalization, config validation, TLS PKI generation, and proxy request parsing.

config.example.toml

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,13 @@ client_ca_path = "certs/client-ca-bundle.pem"
1010
log_level = "info"
1111
otlp_endpoint = "http://localhost:4317"
1212

13-
# Custom extension OID to extract from client certs
1413
[policy]
14+
# Custom extension OID to extract from client certs.
1515
client_ext_oid = "1.3.6.1.4.1.57264.1.1"
16-
17-
[[policy.rules]]
18-
extension_value = "agent-alpha"
19-
# Port defaults to 443 when omitted.
20-
allowed_destinations = ["api.example.com", "db.internal.com:5432", "api.anthropic.com:443"]
21-
22-
[[policy.rules]]
23-
extension_value = "agent-beta"
24-
allowed_destinations = ["api.example.com:443"] # explicit port also works
16+
# Use database_url_env for production so credentials are not stored in config.
17+
database_url_env = "AGENT_GATEWAY_DATABASE_URL"
18+
# database_url = "postgres://agent_gateway:change-me@localhost:5432/agent_gateway"
19+
max_connections = 5
20+
connect_timeout_ms = 5000
21+
pool_acquire_timeout_ms = 1000
22+
query_timeout_ms = 500

docker-compose.postgres.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
services:
2+
postgres:
3+
image: postgres:16
4+
environment:
5+
POSTGRES_DB: agent_gateway
6+
POSTGRES_USER: agent_gateway_admin
7+
POSTGRES_PASSWORD: agent_gateway_dev
8+
ports:
9+
- "5432:5432"
10+
healthcheck:
11+
test: ["CMD-SHELL", "pg_isready -U agent_gateway_admin -d agent_gateway"]
12+
interval: 5s
13+
timeout: 5s
14+
retries: 12
15+
volumes:
16+
- agent-gateway-postgres:/var/lib/postgresql/data
17+
18+
volumes:
19+
agent-gateway-postgres:

examples/register-principal-key.sh

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
usage() {
5+
echo "Usage: $0 KEY_ID [PRIVATE_KEY_PATH] [VALID_DAYS]" >&2
6+
echo "Example: $0 org-alice certs/principals/org-alice.pem 365" >&2
7+
}
8+
9+
if [[ $# -lt 1 || $# -gt 3 ]]; then
10+
usage
11+
exit 2
12+
fi
13+
14+
KEY_ID="$1"
15+
PRIVATE_KEY="${2:-certs/principals/${KEY_ID}.pem}"
16+
VALID_DAYS="${3:-365}"
17+
DATABASE_URL="${AGENT_GATEWAY_DATABASE_URL:-${DATABASE_URL:-}}"
18+
19+
if [[ -z "$DATABASE_URL" ]]; then
20+
echo "Set AGENT_GATEWAY_DATABASE_URL or DATABASE_URL" >&2
21+
exit 2
22+
fi
23+
24+
command -v openssl >/dev/null || { echo "openssl is required" >&2; exit 1; }
25+
command -v psql >/dev/null || { echo "psql is required" >&2; exit 1; }
26+
27+
mkdir -p "$(dirname "$PRIVATE_KEY")"
28+
if [[ ! -f "$PRIVATE_KEY" ]]; then
29+
openssl ecparam -name prime256v1 -genkey -noout -out "$PRIVATE_KEY"
30+
chmod 600 "$PRIVATE_KEY"
31+
fi
32+
33+
tmpdir="$(mktemp -d)"
34+
trap 'rm -rf "$tmpdir"' EXIT
35+
36+
public_der="$tmpdir/public.der"
37+
pubkey_hash="$tmpdir/pubkey.sha256"
38+
openssl ec -in "$PRIVATE_KEY" -pubout -outform DER -out "$public_der" 2>/dev/null
39+
openssl dgst -sha256 -binary "$public_der" > "$pubkey_hash"
40+
41+
hex_file() {
42+
od -An -tx1 -v "$1" | tr -d ' \n'
43+
}
44+
45+
PUBLIC_KEY_HEX="$(hex_file "$public_der")"
46+
PUBKEY_SHA256_HEX="$(hex_file "$pubkey_hash")"
47+
48+
psql "$DATABASE_URL" \
49+
--set=ON_ERROR_STOP=1 \
50+
--set=key_id="$KEY_ID" \
51+
--set=public_key_spki_der="$PUBLIC_KEY_HEX" \
52+
--set=pubkey_sha256="$PUBKEY_SHA256_HEX" \
53+
--set=valid_days="$VALID_DAYS" <<'SQL'
54+
WITH input AS (
55+
SELECT
56+
:'key_id'::text AS key_id,
57+
decode(:'public_key_spki_der', 'hex') AS public_key_spki_der,
58+
decode(:'pubkey_sha256', 'hex') AS pubkey_sha256,
59+
:'valid_days'::int AS valid_days
60+
)
61+
INSERT INTO principal_signing_keys (
62+
key_id, algorithm, public_key_spki_der, pubkey_sha256,
63+
not_before, not_after, revoked_at
64+
)
65+
SELECT
66+
key_id, 'ecdsa_p256_sha256', public_key_spki_der, pubkey_sha256,
67+
now(), now() + make_interval(days => valid_days), NULL
68+
FROM input
69+
ON CONFLICT (key_id) DO UPDATE SET
70+
public_key_spki_der = EXCLUDED.public_key_spki_der,
71+
pubkey_sha256 = EXCLUDED.pubkey_sha256,
72+
not_before = now(),
73+
not_after = EXCLUDED.not_after,
74+
revoked_at = NULL,
75+
updated_at = now()
76+
RETURNING key_id, encode(pubkey_sha256, 'hex') AS pubkey_sha256, not_after;
77+
SQL
78+
79+
echo "Private key: $PRIVATE_KEY"
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
CREATE TABLE agent_gateway_schema_version (
2+
version INTEGER PRIMARY KEY,
3+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
4+
);
5+
6+
INSERT INTO agent_gateway_schema_version (version) VALUES (1);
7+
8+
CREATE TABLE principal_signing_keys (
9+
key_id TEXT PRIMARY KEY,
10+
algorithm TEXT NOT NULL CHECK (algorithm = 'ecdsa_p256_sha256'),
11+
public_key_spki_der BYTEA NOT NULL,
12+
pubkey_sha256 BYTEA NOT NULL UNIQUE CHECK (length(pubkey_sha256) = 32),
13+
not_before TIMESTAMPTZ NOT NULL,
14+
not_after TIMESTAMPTZ NOT NULL,
15+
revoked_at TIMESTAMPTZ,
16+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
17+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
18+
CHECK (key_id <> ''),
19+
CHECK (not_before < not_after)
20+
);
21+
22+
CREATE TABLE principal_key_permissions (
23+
id BIGSERIAL PRIMARY KEY,
24+
signing_key_id TEXT NOT NULL REFERENCES principal_signing_keys(key_id),
25+
subject_identity TEXT NOT NULL CHECK (subject_identity <> ''),
26+
destination TEXT NOT NULL CHECK (destination <> ''),
27+
not_before TIMESTAMPTZ NOT NULL,
28+
not_after TIMESTAMPTZ NOT NULL,
29+
revoked_at TIMESTAMPTZ,
30+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
31+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
32+
CHECK (not_before < not_after)
33+
);
34+
35+
CREATE TABLE permission_registry (
36+
permission_id TEXT PRIMARY KEY,
37+
signing_key_id TEXT NOT NULL REFERENCES principal_signing_keys(key_id),
38+
subject_identity TEXT NOT NULL CHECK (subject_identity <> ''),
39+
destination TEXT NOT NULL CHECK (destination <> ''),
40+
not_before TIMESTAMPTZ NOT NULL,
41+
not_after TIMESTAMPTZ NOT NULL,
42+
revoked_at TIMESTAMPTZ,
43+
signature BYTEA NOT NULL,
44+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
45+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
46+
CHECK (permission_id <> ''),
47+
CHECK (not_before < not_after)
48+
);
49+
50+
CREATE INDEX permission_registry_active_lookup_idx
51+
ON permission_registry (subject_identity, destination, not_after DESC)
52+
WHERE revoked_at IS NULL;
53+
54+
CREATE INDEX principal_key_permissions_active_scope_idx
55+
ON principal_key_permissions (signing_key_id, subject_identity, destination)
56+
WHERE revoked_at IS NULL;

0 commit comments

Comments
 (0)