diff --git a/docs/pages/control-plane/api/access.mdx b/docs/pages/control-plane/api/access.mdx new file mode 100644 index 0000000..e39bdd8 --- /dev/null +++ b/docs/pages/control-plane/api/access.mdx @@ -0,0 +1,128 @@ +--- +title: "Principals, Roles & Grants" +description: "The iron-control access model over the API: principals, roles, grants, and API keys." +--- + +# Principals, Roles & Grants + +These resources implement the [access model](/control-plane/policies): which identity may use which credential. They share the standard [conventions](/control-plane/api/overview#conventions) for namespaces, foreign IDs, pagination, and upsert. + +## Principals + +A principal is an identity (an application, service, or proxy owner) that can be granted secrets and assigned to proxies. + +Attributes: `namespace`, `foreign_id`, `name`, `labels`. Only `name` and `labels` are mutable after create. + +### Create + +`POST /api/v1/principals` + +```json +{ "data": { "foreign_id": "api-service", "name": "API Service", "labels": { "tier": "backend" } } } +``` + +Returns `201` with the principal's `id` (`prn_...`). + +### Operations + +| Method | Path | Notes | +| ------ | ---- | ----- | +| `GET` | `/api/v1/principals?namespace=default` | List. | +| `GET` | `/api/v1/principals/:id` | Fetch by OID. | +| `GET` | `/api/v1/principals/lookup/:namespace/:foreign_id` | Fetch by namespace and foreign id. | +| `GET` | `/api/v1/principals/:id/effective_config` | [Effective config](/control-plane/api/proxies#effective-config) the principal resolves to. | +| `GET` | `/api/v1/principals/:principal_id/grants` | List grants made directly to the principal. | +| `PUT`/`PATCH` | `/api/v1/principals/:id` | [Upsert](/control-plane/api/overview#upsert-with-put-and-patch). | + +## Roles + +A role is a reusable bundle of [grants](#grants). A principal's effective secrets are the union of its own direct grants and the grants of every role it holds. A principal may only be assigned roles in its own namespace. + +Attributes: `namespace`, `foreign_id`, `name`, `labels`. + +### Create + +`POST /api/v1/roles` + +```json +{ "data": { "foreign_id": "infra", "name": "Infra", "labels": { "kind": "shared" } } } +``` + +Returns `201` with the role's `id` (`role_...`). + +### Operations + +| Method | Path | Notes | +| ------ | ---- | ----- | +| `GET` | `/api/v1/roles?namespace=default` | List. | +| `GET` | `/api/v1/roles/:id` | Fetch by OID. | +| `GET` | `/api/v1/roles/lookup/:namespace/:foreign_id` | Fetch by namespace and foreign id. | +| `GET` | `/api/v1/roles/:role_id/grants` | List grants attached to the role. | +| `PUT`/`PATCH` | `/api/v1/roles/:id` | [Upsert](/control-plane/api/overview#upsert-with-put-and-patch). | +| `DELETE` | `/api/v1/roles/:id` | Delete (`204`). Cascades to the role's grants and assignments. | + +### Role Assignments + +Assign and unassign roles on a principal. The endpoints are nested under the principal; the role is identified by its OID. + +```json +// POST /api/v1/principals/:principal_id/roles +{ "data": { "role_id": "role_..." } } +``` + +| Method | Path | Notes | +| ------ | ---- | ----- | +| `GET` | `/api/v1/principals/:principal_id/roles` | List assigned roles. | +| `POST` | `/api/v1/principals/:principal_id/roles` | Assign a role. Cross-namespace or duplicate assignment returns `422`. | +| `DELETE` | `/api/v1/principals/:principal_id/roles/:id` | Unassign. `204`, or `404` if not assigned. | + +## Grants + +A grant attaches exactly one secret to one **grantee** (a principal or a role). A principal receives a secret if it is granted directly or through any role it holds. Its proxies then receive that secret on [sync](/control-plane/api/proxies#proxy-sync). + +### Create + +`POST /api/v1/grants` with exactly one grantee (`principal_id` or `role_id`) and exactly one secret reference (`static_secret_id`, `gcp_auth_secret_id`, `oauth_token_secret_id`, `pg_dsn_secret_id`, or `hmac_secret_id`): + +```json +{ "data": { "principal_id": "prn_...", "static_secret_id": "ssr_..." } } +``` + +Returns `201` with the grant's `id` (`grant_...`) and the one grantee key and one secret key that were set. A missing grantee or secret returns `404`. Supplying no grantee or no secret returns `422`. + +### Operations + +| Method | Path | Notes | +| ------ | ---- | ----- | +| `GET` | `/api/v1/grants/:id` | Fetch one. | +| `GET` | `/api/v1/principals/:principal_id/grants` | List a principal's **direct** grants (paginated). | +| `GET` | `/api/v1/roles/:role_id/grants` | List a role's grants (paginated). | +| `DELETE` | `/api/v1/grants/:id` | Revoke (`204`). | + +The principal grants endpoint lists only direct grants, not those resolved through roles. For everything a principal resolves to, use [effective config](/control-plane/api/proxies#effective-config). + +## API Keys + +API keys belong to the authenticated user and authenticate API requests. They are scoped to the current user: listing and fetching only ever return your own keys. + +### Create + +`POST /api/v1/api_keys` + +```json +{ "data": { "name": "CI Runner" } } +``` + +Returns `201`. The plaintext `token` (`iak_...`) is included **only** in this response: + +```json +{ "data": { "id": "ak_...", "name": "CI Runner", "token": "iak_0a1b2c3d..." } } +``` + +### Operations + +| Method | Path | Notes | +| ------ | ---- | ----- | +| `GET` | `/api/v1/api_keys` | List your keys (paginated). Tokens are never returned. | +| `GET` | `/api/v1/api_keys/:id` | Fetch one (no token). | +| `DELETE` | `/api/v1/api_keys/:id` | Revoke (soft delete, `204`). Revoking the key used for the current request returns `422`. | diff --git a/docs/pages/control-plane/api/mcp-policies.mdx b/docs/pages/control-plane/api/mcp-policies.mdx deleted file mode 100644 index cc56688..0000000 --- a/docs/pages/control-plane/api/mcp-policies.mdx +++ /dev/null @@ -1,192 +0,0 @@ ---- -title: "MCP Policies API" -description: "Manage iron-proxy MCP tool allowlists over the control plane API." ---- - -# MCP Policies API - -MCP policies enforce default-deny tool allowlists on Streamable HTTP MCP servers. See [MCP Interception](/policies/mcp-interception) for the runtime semantics, JSON-RPC handling, and `tools/list` filtering behavior. - -Each policy targets a subset of the fleet by tag, scopes which requests it applies to with `rules`, and lists the tools an agent is allowed to call along with optional argument matchers. - -Base path: `/v1/policies/mcp` - -## The MCP Policy Object - -```json -{ - "id": "mpol_01H...", - "name": "github-mcp", - "active": true, - "priority": 0, - "match_tags": ["production"], - "rules": [ - { "host": "mcp.github.com", "paths": ["/mcp", "/mcp/*"], "methods": ["POST"] } - ], - "tools": [ - { "name": "search_repositories", "matchers": [] }, - { - "name": "create_issue", - "matchers": [ - { "path": "owner", "equals": "ironsh" }, - { "path": "repo", "in": ["iron-proxy", "tunis-v2"] } - ] - } - ], - "created_at": "2026-05-08T12:00:00Z", - "updated_at": "2026-05-08T12:00:00Z" -} -``` - -### Fields - -| Field | Type | Description | -|---|---|---| -| `id` | string | Server-assigned opaque ID, prefixed with `mpol_`. | -| `name` | string | URL-safe name, unique within the organization. Must match `[a-z0-9]+(-[a-z0-9]+)*`. | -| `active` | boolean | When `false`, the policy is stored but not delivered to proxies. Defaults to `true`. | -| `priority` | integer | Tie-breaker when more than one policy applies. Lower wins. Required. | -| `match_tags` | string[] | Tags a proxy must carry for the policy to apply. Empty applies to every proxy. | -| `rules` | object[] | Request rules that scope which traffic the MCP interceptor handles. Same shape as [network policy rules](/control-plane/api/network-policies#rule-object). | -| `tools` | object[] | Allowlisted tools. Tools not listed are denied. | -| `created_at` | string | RFC 3339 timestamp. | -| `updated_at` | string | RFC 3339 timestamp. | - -### Tool Object - -```json -{ - "name": "create_issue", - "matchers": [ - { "path": "owner", "equals": "ironsh" }, - { "path": "repo", "in": ["iron-proxy", "tunis-v2"] }, - { "path": "title", "matches": "^\\[bot\\]" } - ] -} -``` - -| Field | Type | Description | -|---|---|---| -| `name` | string | The MCP tool name. Required. | -| `matchers` | object[] | Argument constraints. All matchers must pass for a `tools/call` to be allowed. Empty allows any arguments. | - -### Matcher Object - -A matcher selects a value from `params.arguments` by `path` (dotted notation) and applies exactly one of `equals`, `in`, or `matches`. - -| Field | Type | Description | -|---|---|---| -| `path` | string | Dotted path into the tool arguments. Required. | -| `equals` | any | Argument at `path` must equal this value. | -| `in` | any[] | Argument at `path` must be one of these values. | -| `matches` | string | Argument at `path`, stringified, must match this regular expression. | - -Set exactly one of `equals`, `in`, `matches` per matcher. Sending more than one returns `422` with code `validation_error`. - -## List MCP Policies - -```http -GET /v1/policies/mcp -``` - -Returns every MCP policy in the calling organization, ordered by `priority` ascending. - -### Query Parameters - -| Name | Type | Description | -|---|---|---| -| `name` | string | Exact match on policy name. | -| `active` | boolean | When supplied, returns only policies with that active state. | - -```sh -curl https://api.iron.sh/v1/policies/mcp \ - -H "Authorization: Bearer $IRON_API_KEY" -``` - -## Create an MCP Policy - -```http -POST /v1/policies/mcp -``` - -### Request Body - -| Field | Type | Required | -|---|---|---| -| `name` | string | Yes | -| `priority` | integer | Yes | -| `active` | boolean | No | -| `match_tags` | string[] | No | -| `rules` | object[] | No | -| `tools` | object[] | No | - -### Example - -```sh -curl https://api.iron.sh/v1/policies/mcp \ - -H "Authorization: Bearer $IRON_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "linear-mcp", - "priority": 42, - "match_tags": ["production", "linear"], - "rules": [ - { "host": "mcp.linear.app", "paths": ["/mcp"], "methods": ["POST"] } - ], - "tools": [ - { - "name": "create_issue", - "matchers": [ - { "path": "team", "in": ["eng", "infra"] }, - { "path": "title", "matches": "^\\[bot\\]" } - ] - } - ] - }' -``` - -Returns `201 Created` with the new policy in `data`. - -## Retrieve an MCP Policy - -```http -GET /v1/policies/mcp/:id -``` - -Returns `200 OK` with the policy in `data`, or `404 Not Found` with code `mcp_policy_not_found`. - -## Update an MCP Policy - -```http -PUT /v1/policies/mcp/:id -``` - -`GET` the policy, modify the fields you want to change, and `PUT` the full representation back. Send the same fields you would on create. - -### Example - -```sh -curl -X PUT https://api.iron.sh/v1/policies/mcp/mpol_01H... \ - -H "Authorization: Bearer $IRON_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "github-mcp", - "priority": 0, - "active": false, - "match_tags": ["production"], - "rules": [ - { "host": "mcp.github.com", "paths": ["/mcp", "/mcp/*"], "methods": ["POST"] } - ], - "tools": [ - { "name": "search_code", "matchers": [{ "path": "repo", "equals": "console" }] } - ] - }' -``` - -## Delete an MCP Policy - -```http -DELETE /v1/policies/mcp/:id -``` - -Returns `204 No Content`. Connected proxies stop applying the policy within seconds. diff --git a/docs/pages/control-plane/api/network-policies.mdx b/docs/pages/control-plane/api/network-policies.mdx deleted file mode 100644 index 9181734..0000000 --- a/docs/pages/control-plane/api/network-policies.mdx +++ /dev/null @@ -1,181 +0,0 @@ ---- -title: "Network Policies API" -description: "Manage iron-proxy egress allowlists over the control plane API." ---- - -# Network Policies API - -Network policies are named egress allowlists. Each policy targets a subset of the fleet by tag and lists the hosts, paths, and methods those proxies are allowed to reach. See [Policies](/control-plane/policies) for the conceptual overview. - -Base path: `/v1/policies/network` - -## The Network Policy Object - -```json -{ - "id": "npol_01H8XYZ...", - "name": "default-egress", - "active": true, - "priority": 0, - "match_tags": ["production"], - "rules": [ - { "host": "*.example.com", "paths": [], "methods": [] } - ], - "version": 1, - "created_at": "2026-05-08T12:00:00Z", - "updated_at": "2026-05-08T12:00:00Z" -} -``` - -### Fields - -| Field | Type | Description | -|---|---|---| -| `id` | string | Server-assigned opaque ID, prefixed with `npol_`. | -| `name` | string | URL-safe name, unique within the organization. Must match `[a-z0-9]+(-[a-z0-9]+)*`. | -| `active` | boolean | When `false`, the policy is stored but not delivered to proxies. Defaults to `true`. | -| `priority` | integer | Tie-breaker when more than one policy applies to the same proxy. Lower wins. Required. | -| `match_tags` | string[] | Tags a proxy must carry for the policy to apply. Empty applies to every proxy. Defaults to `[]`. | -| `rules` | object[] | Egress rules. See [Rule object](#rule-object). Defaults to `[]`. | -| `version` | integer | Schema version for the rule body. Defaults to `1`. | -| `created_at` | string | RFC 3339 timestamp. | -| `updated_at` | string | RFC 3339 timestamp. | - -### Rule Object - -```json -{ "host": "api.example.com", "paths": ["/v1/*"], "methods": ["GET", "POST"] } -``` - -| Field | Type | Description | -|---|---|---| -| `host` | string | Hostname or wildcard, e.g. `api.example.com` or `*.example.com`. Required. | -| `paths` | string[] | Path patterns. Empty allows any path. | -| `methods` | string[] | HTTP methods. Empty allows any method. | - -## List Network Policies - -```http -GET /v1/policies/network -``` - -Returns every network policy in the calling organization, ordered by `priority` ascending. - -### Query Parameters - -| Name | Type | Description | -|---|---|---| -| `name` | string | Exact match on policy name. | -| `active` | boolean | When supplied, returns only policies with that active state. | - -### Example - -```sh -curl https://api.iron.sh/v1/policies/network \ - -H "Authorization: Bearer $IRON_API_KEY" -``` - -```json -{ - "data": [ - { - "id": "npol_01H...", - "name": "default-egress", - "active": true, - "priority": 0, - "match_tags": ["production"], - "rules": [{ "host": "*.example.com", "paths": [], "methods": [] }], - "version": 1, - "created_at": "2026-05-08T12:00:00Z", - "updated_at": "2026-05-08T12:00:00Z" - } - ] -} -``` - -## Create a Network Policy - -```http -POST /v1/policies/network -``` - -### Request Body - -| Field | Type | Required | -|---|---|---| -| `name` | string | Yes | -| `priority` | integer | Yes | -| `active` | boolean | No | -| `match_tags` | string[] | No | -| `rules` | object[] | No | -| `version` | integer | No | - -### Example - -```sh -curl https://api.iron.sh/v1/policies/network \ - -H "Authorization: Bearer $IRON_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "api-egress", - "priority": 42, - "match_tags": ["production", "api"], - "rules": [ - { "host": "api.example.com", "paths": ["/v1/*"], "methods": ["GET", "POST"] } - ] - }' -``` - -Returns `201 Created` with the new policy in `data`. - -## Retrieve a Network Policy - -```http -GET /v1/policies/network/:id -``` - -```sh -curl https://api.iron.sh/v1/policies/network/npol_01H... \ - -H "Authorization: Bearer $IRON_API_KEY" -``` - -Returns `200 OK` with the policy in `data`, or `404 Not Found` with code `network_policy_not_found`. - -## Update a Network Policy - -```http -PUT /v1/policies/network/:id -``` - -`GET` the policy, modify the fields you want to change, and `PUT` the full representation back. Send the same fields you would on create. - -### Example - -```sh -curl -X PUT https://api.iron.sh/v1/policies/network/npol_01H... \ - -H "Authorization: Bearer $IRON_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "default-egress", - "priority": 0, - "active": false, - "match_tags": ["staging"], - "rules": [ - { "host": "staging.example.com", "paths": ["*"], "methods": ["GET"] } - ], - "version": 1 - }' -``` - -## Delete a Network Policy - -```http -DELETE /v1/policies/network/:id -``` - -```sh -curl -X DELETE https://api.iron.sh/v1/policies/network/npol_01H... \ - -H "Authorization: Bearer $IRON_API_KEY" -``` - -Returns `204 No Content`. Connected proxies stop applying the policy within seconds. diff --git a/docs/pages/control-plane/api/overview.mdx b/docs/pages/control-plane/api/overview.mdx index 7af8588..2d891c1 100644 --- a/docs/pages/control-plane/api/overview.mdx +++ b/docs/pages/control-plane/api/overview.mdx @@ -1,88 +1,150 @@ --- title: "API Overview" -description: "Base URL, authentication, and conventions for the iron.sh control plane API." +description: "Base URL, authentication, conventions, and the shared building blocks of the iron-control JSON API." --- # API Overview -The control plane exposes a JSON API for managing policies, proxies, and other fleet resources. Anything you can do in the control plane UI under **Policies** is available over the API. +iron-control exposes a JSON API under `/api/v1`. Operators use it to manage credentials, principals, roles, grants, and proxies. Anything the read-only console shows, the API can create and change. + +Every resource endpoint authenticates with an API key. The single exception is [`POST /api/v1/proxy/sync`](/control-plane/api/proxies#proxy-sync), which iron-proxy instances call with their own proxy token. ## Base URL -All API requests are made against the `api` subdomain of your control plane: +All requests go to the `/api/v1` path on your control plane. The hosted default is: ``` -https://api.iron.sh/v1 +https://api.iron.sh/api/v1 ``` -Self-hosted deployments substitute their own host. The API path layout (`/v1/...`) is identical. +Self-hosted deployments substitute their own host. The path layout is identical. ## Authentication -The API uses bearer tokens. Create an API key in the control plane UI under **API Keys**, then include it in the `Authorization` header on every request: +Send your API key as a bearer token: -```http -Authorization: Bearer +``` +Authorization: Bearer iak_<64 lowercase hex chars> ``` -API keys are scoped to the organization that created them. Requests with a missing or invalid token return `401 Unauthorized`. +API keys have the form `iak_` followed by 64 lowercase hex characters. Create one with [`POST /api/v1/api_keys`](/control-plane/api/access#api-keys). The plaintext token is shown only once, when the key is created (or, for the bootstrap key, logged once at startup). Tokens are stored as SHA-256 hashes and cannot be recovered. A missing or invalid token returns `401`: -## Content Type +```json +{ "error": { "message": "invalid or missing API key" } } +``` -`POST`, `PUT`, and `PATCH` requests must send `Content-Type: application/json`. The API rejects other content types with `415 Unsupported Media Type`: +iron-proxy instances authenticate to proxy sync with their own token (`iprx_` followed by 64 lowercase hex characters), issued once when the proxy is created. An invalid proxy token returns `401` with `"invalid or missing proxy token"`. -```json -{ - "error": { - "code": "unsupported_media_type", - "message": "Content-Type must be application/json" +## Conventions + +- **Request bodies** wrap attributes in a top-level `data` object. A missing `data` key returns `400`. +- **Single-resource responses** wrap the resource in `data`. +- **List responses** include `data` (an array) and `meta` (pagination): + + ```json + { + "data": [ /* ... */ ], + "meta": { "page": 1, "limit": 50, "total": 100, "total_pages": 2 } } -} -``` + ``` + +- **Pagination** uses `page` (default `1`) and `limit` (default `50`, max `200`). Values are clamped into range. A non-integer value returns `400`. +- **Namespaced list filtering** (secrets, principals, roles) requires a `namespace` query parameter and accepts an optional `labels[key]=value` filter that matches by JSONB containment: all supplied pairs must be present. Label values must be scalars. +- **Object IDs** are prefixed by type: `ssr_` (static secret), `gas_` (GCP auth secret), `ots_` (OAuth token secret), `pgs_` (PG DSN secret), `hms_` (HMAC secret), `bcr_` (broker credential), `prn_` (principal), `role_` (role), `grant_` (grant), `ak_` (API key), `prx_` (proxy). Treat them as opaque. +- **`namespace`** defaults to `"default"` when omitted on create. Once set, `namespace` and `foreign_id` are immutable. +- **`namespace` and `foreign_id`** must be URL-safe: only `A-Z a-z 0-9 - . _ ~`. `foreign_id` is optional and, when set, must be unique within its namespace. A `foreign_id` may not start with the resource's id prefix, so it can never be mistaken for an OID. +- **`labels`** is an arbitrary string-keyed object, defaulting to `{}`. +- **Timestamps** are ISO 8601 UTC. + +### Upsert With PUT And PATCH + +For resources with a `foreign_id` (secrets, principals, roles), `PUT`/`PATCH /api/v1//:id` is an **upsert**, and `:id` may be an OID or a `foreign_id`: -## Identifiers +- **`:id` is an OID** (starts with the resource's prefix, such as `ssr_`): updates that record. `404` if it does not exist, since an OID is server-assigned. +- **`:id` is anything else**: it is treated as a `foreign_id` within the body's `namespace` (default `"default"`). The record is updated if it exists, created if it does not. Creation responds `201`; update responds `200`. -Every resource is addressed by an opaque, prefixed ID. Network policies start with `npol_`, secret policies with `spol_`, and MCP policies with `mpol_`. Treat IDs as opaque strings: do not parse, generate, or assume anything about their format beyond the prefix. +This makes provisioning idempotent. `PUT /api/v1/roles/infra` with `{"data":{"namespace":"acme", ...}}` converges the `acme/infra` role in one call whether or not it already exists. ## Errors -Errors return a JSON body with a stable `code` and a human-readable `message`: +Errors return an `error` object with a `message` and, for validation failures, a `details` map of field name to messages: ```json { "error": { - "code": "validation_error", - "message": "Name has already been taken" + "message": "validation failed", + "details": { + "base": ["must define one of inject_config or replace_config"], + "name": ["can't be blank"] + } } } ``` -Common codes across policy endpoints: +| Status | Meaning | +| ------ | ------- | +| `200` | OK | +| `201` | Created | +| `204` | No Content (successful `DELETE`) | +| `400` | Bad Request (missing `data`, bad pagination or label query) | +| `401` | Unauthorized (missing or invalid token) | +| `404` | Not Found | +| `409` | Conflict (for example, deleting a broker credential still in use) | +| `422` | Unprocessable Entity (validation failed) | -| Status | Code | Meaning | -|---|---|---| -| 401 | `unauthorized` | Missing or invalid API key. | -| 404 | `network_policy_not_found`, `secret_policy_not_found`, `mcp_policy_not_found` | The ID does not exist or belongs to a different organization. | -| 409 | `network_policy_name_taken`, `secret_policy_name_taken`, `mcp_policy_name_taken` | A policy with that name already exists in the organization. | -| 415 | `unsupported_media_type` | `Content-Type` is not `application/json`. | -| 422 | `validation_error` | The request body failed validation. The `message` lists the failing fields. | +## Shared Building Blocks -## Server-Owned Fields +Two structures appear across many resources: secret sources and request rules. -The API ignores fields that are owned by the server: `id`, `organization_id`, `created_by_id`, `created_at`, `updated_at`. Sending them in `POST` or `PATCH` bodies is harmless: the values you supply are dropped and the server fills the canonical values in the response. +### Secret Sources -## Listing and Filtering +A secret source describes where a credential value is resolved from. It appears as the `source` of a static secret, the `keyfile` of a GCP auth secret, the `dsn` of a PG DSN secret, and each entry in an OAuth or HMAC secret's `credentials` map. -List endpoints return all matching resources in a single response under `data`, ordered by `priority` ascending: +```json +{ "source_type": "env", "config": { "var": "GITHUB_TOKEN" } } +``` + +`source_type` is required and immutable. `config` is an object whose allowed keys depend on the type. Unknown keys are rejected. Every type also accepts the optional keys `json_key` (extract one field from a JSON value) and `ttl` (cache lifetime). + +| `source_type` | Required `config` keys | Type-specific optional keys | Notes | +| ------------- | ---------------------- | --------------------------- | ----- | +| `env` | `var` | | Reads a process environment variable. | +| `aws_sm` | `secret_id` | `region` | AWS Secrets Manager. | +| `aws_ssm` | `name` | `region`, `with_decryption` | AWS SSM Parameter Store. | +| `1password` | `secret_ref` | `token_env` | 1Password CLI or service account. | +| `1password_connect` | `secret_ref` | `host_env`, `token_env` | 1Password Connect server. | +| `control_plane` | (none) | | Value stored inline in iron-control. See below. | +| `token_broker` | `credential_id` | `credential_namespace` | A managed [broker credential](/control-plane/api/secrets#broker-credentials). See below. | + +`control_plane` is special. The value is stored in iron-control itself. Supply it as a top-level `secret` field on the source (not inside `config`), and leave `config` empty: ```json -{ "data": [ { "id": "npol_...", "name": "default-egress", ... } ] } +{ "source_type": "control_plane", "secret": "the-actual-secret-value", "config": {} } ``` -Most list endpoints accept `name` and `active` query parameters as exact-match filters. +The `secret` field is encrypted at rest, is write-only, and is never returned in any response. It is only permitted for `control_plane` sources. + +`token_broker` is also resolved by iron-control, not the proxy. `credential_id` names a [broker credential](/control-plane/api/secrets#broker-credentials), and at sync time iron-control substitutes that credential's current access token, delivered inline exactly like a `control_plane` value. The reference never reaches the proxy. With a `foreign_id`, `credential_namespace` is required; with an opaque id it must be omitted. + +### Request Rules + +A rule scopes a credential to matching outbound requests. Rules appear as the `rules` array of static, GCP auth, OAuth, and HMAC secrets. + +```json +{ "host": "api.github.com", "http_methods": ["GET", "POST"], "paths": ["/repos/*"] } +``` + +| Field | Type | Notes | +| ----- | ---- | ----- | +| `host` | string | Hostname to match. Exactly one of `host` or `cidr` is required. | +| `cidr` | string | CIDR block to match, such as `10.0.0.0/8`. | +| `http_methods` | array of strings | Each is one of `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `CONNECT`, or `*`. | +| `paths` | array of strings | Each must start with `/`. Globs such as `/repos/*` are allowed. | + +Rules are positional. A `position` (0-based, from array order) is returned in responses but is not part of the request. On update, the supplied `rules` array fully replaces the existing rules. -## Resources +## Reference Pages -- [Network Policies](/control-plane/api/network-policies) -- [Secret Policies](/control-plane/api/secret-policies) -- [MCP Policies](/control-plane/api/mcp-policies) +- [Secrets](/control-plane/api/secrets): the five credential types plus broker credentials. +- [Principals, Roles & Grants](/control-plane/api/access): the access model and API keys. +- [Proxies & Sync](/control-plane/api/proxies): proxy registration, sync, and effective config. diff --git a/docs/pages/control-plane/api/proxies.mdx b/docs/pages/control-plane/api/proxies.mdx new file mode 100644 index 0000000..ce6e89b --- /dev/null +++ b/docs/pages/control-plane/api/proxies.mdx @@ -0,0 +1,147 @@ +--- +title: "Proxies & Sync" +description: "Register iron-proxy instances, assign them an identity, and the proxy sync and effective config endpoints." +--- + +# Proxies & Sync + +A proxy represents an iron-proxy instance. It may be assigned a [principal](/control-plane/api/access#principals), in which case it receives config for the secrets granted to that principal. A proxy can also boot **unassigned**: it authenticates and syncs normally but receives an empty config until a principal is assigned. The principal can be assigned, swapped, or cleared at any time without reissuing the token. + +A proxy's `status` is `assigned` when it currently holds a principal and `unassigned` otherwise. `principal_assigned_at` records when the current assignment was made. + +## Create + +`POST /api/v1/proxies` + +```json +{ "data": { "name": "Edge Proxy - US", "principal_id": "prn_..." } } +``` + +Returns `201`. The plaintext proxy `token` (`iprx_...`) is included **only** in this response. Save it immediately; the proxy uses it to authenticate to [sync](#proxy-sync): + +```json +{ + "data": { + "id": "prx_...", + "name": "Edge Proxy - US", + "principal_id": "prn_...", + "status": "assigned", + "principal_assigned_at": "2026-06-01T10:00:00Z", + "token": "iprx_0a1b2c3d..." + } +} +``` + +`name` is required. `principal_id` is optional: omit it for an unassigned proxy. When supplied, a missing principal returns `404`. + +## Assign, Swap, Or Clear The Principal + +`PATCH /api/v1/proxies/:id` (or `PUT`) + +```json +{ "data": { "principal_id": "prn_..." } } +``` + +Assigns the principal when unassigned, or swaps it when already assigned. The token is unchanged; the proxy picks up the new config on its next [sync](#proxy-sync). Send `"principal_id": null` to unassign. Omitting `principal_id` leaves the assignment unchanged, and `name` may also be updated. Returns `200` with the updated proxy. + +## Other Operations + +| Method | Path | Notes | +| ------ | ---- | ----- | +| `GET` | `/api/v1/proxies` | List. Optional `principal_id` filter; paginated. Tokens are never returned. | +| `GET` | `/api/v1/proxies/:id` | Fetch one (no token). | +| `DELETE` | `/api/v1/proxies/:id` | Deregister (`204`). | + +Deleting a principal does not delete its proxies. They become unassigned and can be reassigned. + +## Proxy Sync + +`POST /api/v1/proxy/sync` + +Called by iron-proxy instances to fetch their configuration. **Authentication is the proxy bearer token** (`Authorization: Bearer iprx_...`), not an API key. + +The proxy sends the config hash it currently holds. If it matches the freshly computed hash, the server returns only the hash, so the proxy skips re-applying. Otherwise the full payload is returned. + +Request: + +```json +{ "config_hash": "sha256:0a1b2c3d..." } +``` + +`config_hash` is optional. It is an opaque, deterministic fingerprint of the config that the proxy treats as an ETag. + +Response when the hash matches (no payload): + +```json +{ "config_hash": "sha256:..." } +``` + +Response when the hash differs (full payload): + +```json +{ + "config_hash": "sha256:...", + "status": "assigned", + "principal_id": "prn_...", + "secrets": [ + { + "source": { "type": "env", "var": "GITHUB_TOKEN" }, + "inject": { "header": "Authorization", "formatter": "Bearer {{ .Value }}" }, + "rules": [ { "host": "api.github.com", "methods": ["GET", "POST"], "paths": ["/repos/*"] } ] + } + ], + "transforms": [ + { + "name": "gcp_auth", + "config": { + "keyfile": { "type": "aws_sm", "secret_id": "gcp-sa-keyfile", "region": "us-west-2" }, + "scopes": ["https://www.googleapis.com/auth/cloud-platform"], + "rules": [ { "host": "googleapis.com", "methods": ["*"], "paths": ["/v1/*"] } ] + } + } + ], + "postgres": [ + { "id": "pgs_...", "foreign_id": "analytics-pg", "dsn": { "type": "env", "var": "PG_ANALYTICS_DSN" }, "database": "analytics", "role": "readonly" } + ] +} +``` + +The payload differs from the REST representation in a few ways: + +- `status` is `assigned` or `unassigned`, and `principal_id` is the assigned principal (or `null`). An unassigned proxy gets a valid response with empty `secrets` and `transforms`. These fields appear only in the full payload. +- The config hash incorporates the principal assignment, so assigning, swapping, or clearing the principal always changes the hash. A swap is a full replacement: the proxy drops the previously delivered config rather than merging. +- The delivered config covers the principal's **effective grants**: secrets granted directly plus those granted to any [role](/control-plane/api/access#roles) it holds. A secret reachable through more than one path appears once. +- `secrets` carries one entry per granted static secret that has a source. `transforms` carries one `gcp_auth` per granted GCP auth secret, one `hmac_sign` per granted HMAC secret, and a single bundled `oauth_token` transform whose `config.tokens` lists every granted OAuth token secret. +- `postgres` carries one entry per granted [PG DSN secret](/control-plane/api/secrets#pg-dsn-secrets), keyed by `foreign_id` (the key a [proxy-local listener](/postgres) binds to). +- Each source is flattened: its `config` keys are merged up and tagged with `type`. A `control_plane` source delivers its decrypted value inline as `value`. +- Rules use `methods` here, versus `http_methods` in the REST API. + +## Effective Config + +`GET /api/v1/principals/:id/effective_config` +`GET /api/v1/principals/lookup/:namespace/:foreign_id/effective_config` + +The config a principal resolves to, in the same shape iron-proxy receives on [sync](#proxy-sync), for operator inspection. Use it to verify a grant change before pointing a live proxy at the principal. + +Unlike proxy sync, this endpoint never reveals live secrets and does no hash negotiation: + +- Inline `control_plane` source values are redacted to `"[redacted]"`. Every other source type carries only a reference (an env var name, an AWS secret id), so it passes through unchanged. +- There is no `config_hash`, `status`, or `principal_id` field. +- The response carries a content-derived `ETag` and `Cache-Control: no-store`. + +```json +{ + "data": { + "id": "prn_...", + "secrets": [ + { + "source": { "type": "control_plane", "value": "[redacted]" }, + "replace": { "proxy_value": "__DB_PASSWORD__" }, + "rules": [ { "host": "db.internal", "methods": ["*"] } ] + } + ], + "transforms": [], + "postgres": [] + } +} +``` diff --git a/docs/pages/control-plane/api/secret-policies.mdx b/docs/pages/control-plane/api/secret-policies.mdx deleted file mode 100644 index 80594c1..0000000 --- a/docs/pages/control-plane/api/secret-policies.mdx +++ /dev/null @@ -1,235 +0,0 @@ ---- -title: "Secret Policies API" -description: "Manage iron-proxy secret injection and replacement over the control plane API." ---- - -# Secret Policies API - -Secret policies tell matching proxies how to apply a credential at the egress boundary, so workloads never see the real value. See [Credential Proxying](/credential-proxying/overview) for the runtime semantics, and [Static Secrets](/credential-proxying/static-secrets) for source backends. - -Each policy is either in `inject` mode (the proxy adds a header or query parameter to matching requests) or `replace` mode (the proxy swaps a placeholder token in the URL path for the real value). The two modes are mutually exclusive: a policy carries either an `inject_config` or a `replace_config`, never both. - -Base path: `/v1/policies/secrets` - -## The Secret Policy Object - -Inject mode: - -```json -{ - "id": "spol_01H...", - "name": "openai-key", - "active": true, - "priority": 0, - "match_tags": ["production"], - "mode": "inject", - "rules": [ - { "host": "api.openai.com", "paths": ["/v1/*"], "methods": ["POST"] } - ], - "source": { "type": "env", "var": "OPENAI_API_KEY" }, - "inject_config": { - "header": "Authorization", - "formatter": "Bearer {{ .Value }}" - }, - "created_at": "2026-05-08T12:00:00Z", - "updated_at": "2026-05-08T12:00:00Z" -} -``` - -Replace mode: - -```json -{ - "id": "spol_01H...", - "name": "telegram-bot", - "active": true, - "priority": 0, - "match_tags": ["production"], - "mode": "replace", - "rules": [ - { "host": "api.telegram.org", "paths": ["/bot*"], "methods": [] } - ], - "source": { "type": "env", "var": "TELEGRAM_BOT_TOKEN" }, - "replace_config": { "proxy_value": "PROXY-BOT-TOKEN" }, - "created_at": "2026-05-08T12:00:00Z", - "updated_at": "2026-05-08T12:00:00Z" -} -``` - -### Fields - -| Field | Type | Description | -|---|---|---| -| `id` | string | Server-assigned opaque ID, prefixed with `spol_`. | -| `name` | string | URL-safe name, unique within the organization. Must match `[a-z0-9]+(-[a-z0-9]+)*`. | -| `active` | boolean | When `false`, the policy is stored but not delivered to proxies. Defaults to `true`. | -| `priority` | integer | Tie-breaker when more than one policy applies. Lower wins. Required. | -| `match_tags` | string[] | Tags a proxy must carry for the policy to apply. Empty applies to every proxy. | -| `mode` | string | `"inject"` or `"replace"`. | -| `rules` | object[] | Request rules that scope which traffic the credential applies to. Same shape as [network policy rules](/control-plane/api/network-policies#rule-object). | -| `source` | object | Where the proxy reads the real credential from. See [Source object](#source-object). | -| `inject_config` | object | Present only in `inject` mode. See [Inject config](#inject-config). | -| `replace_config` | object | Present only in `replace` mode. See [Replace config](#replace-config). | -| `created_at` | string | RFC 3339 timestamp. | -| `updated_at` | string | RFC 3339 timestamp. | - -### Source Object - -The `source` object tells the proxy where to resolve the real credential at startup. The exact set of fields depends on the source `type`. See [Static Secrets / Secret Sources](/credential-proxying/static-secrets#secret-sources) for the full list of supported backends. - -| Field | Type | Description | -|---|---|---| -| `type` | string | One of `env`, `aws_sm`, `aws_ssm`. Defaults to `env`. | -| `var` | string | Environment variable name. Required when `type` is `env`. | -| `secret_id` | string | AWS Secrets Manager ARN. Required when `type` is `aws_sm`. | -| `name` | string | Parameter Store name. Required when `type` is `aws_ssm`. | -| `region` | string | AWS region override. | -| `json_key` | string | JSON key to extract from a JSON-encoded secret value. | -| `ttl` | string | Cache TTL like `15m`, `1h`. Format: digits followed by `h`, `m`, or `s`. | -| `with_decryption` | boolean | Decrypt SecureString parameters (Parameter Store only). | - -### Inject Config - -Used when `mode` is `"inject"`. Set either `header` or `query_param`, not both. - -| Field | Type | Description | -|---|---|---| -| `header` | string | Header name to set. | -| `query_param` | string | Query parameter name to append. | -| `formatter` | string | Go template that produces the header value. Receives `.Value` and a `base64` helper. Required for non-trivial header formats. | - -### Replace Config - -Used when `mode` is `"replace"`. The proxy looks for `proxy_value` in the URL path and replaces it with the resolved secret before forwarding. - -| Field | Type | Description | -|---|---|---| -| `proxy_value` | string | URL-safe placeholder that appears in workload requests. Must be alphanumeric, `-._~`, or `%XX` escapes. Required. | - -## List Secret Policies - -```http -GET /v1/policies/secrets -``` - -Returns every secret policy in the calling organization, ordered by `priority` ascending. - -### Query Parameters - -| Name | Type | Description | -|---|---|---| -| `name` | string | Exact match on policy name. | -| `active` | boolean | When supplied, returns only policies with that active state. | - -```sh -curl https://api.iron.sh/v1/policies/secrets \ - -H "Authorization: Bearer $IRON_API_KEY" -``` - -## Create a Secret Policy - -```http -POST /v1/policies/secrets -``` - -### Request Body - -| Field | Type | Required | -|---|---|---| -| `name` | string | Yes | -| `priority` | integer | Yes | -| `mode` | string | Yes (`inject` or `replace`) | -| `source` | object | Yes | -| `inject_config` | object | When `mode` is `inject` | -| `replace_config` | object | When `mode` is `replace` | -| `active` | boolean | No | -| `match_tags` | string[] | No | -| `rules` | object[] | No | - -Submitting a payload with both `inject_config` and `replace_config` is allowed: the server clears the config that does not match `mode`. Submit only the one for the mode you set to keep request bodies obvious. - -### Example: Bearer Token Injection - -```sh -curl https://api.iron.sh/v1/policies/secrets \ - -H "Authorization: Bearer $IRON_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "stripe-key", - "priority": 42, - "mode": "inject", - "match_tags": ["production", "payments"], - "rules": [ - { "host": "api.stripe.com", "paths": ["/v1/*"], "methods": ["POST"] } - ], - "source": { "type": "env", "var": "STRIPE_API_KEY" }, - "inject_config": { - "header": "Authorization", - "formatter": "Bearer {{ .Value }}" - } - }' -``` - -### Example: Path Token Replacement - -```sh -curl https://api.iron.sh/v1/policies/secrets \ - -H "Authorization: Bearer $IRON_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "telegram-bot", - "priority": 50, - "mode": "replace", - "rules": [ - { "host": "api.telegram.org", "paths": ["/bot*"], "methods": [] } - ], - "source": { "type": "env", "var": "TELEGRAM_BOT_TOKEN" }, - "replace_config": { "proxy_value": "PROXY-BOT-TOKEN" } - }' -``` - -Returns `201 Created` with the new policy in `data`. - -## Retrieve a Secret Policy - -```http -GET /v1/policies/secrets/:id -``` - -Returns `200 OK` with the policy in `data`, or `404 Not Found` with code `secret_policy_not_found`. - -## Update a Secret Policy - -```http -PUT /v1/policies/secrets/:id -``` - -`GET` the policy, modify the fields you want to change, and `PUT` the full representation back. Send the same fields you would on create. To switch between modes, change `mode` and supply the matching `inject_config` or `replace_config`: the server clears the other config block automatically. - -### Example: Switch from Inject to Replace - -```sh -curl -X PUT https://api.iron.sh/v1/policies/secrets/spol_01H... \ - -H "Authorization: Bearer $IRON_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "openai-key", - "priority": 0, - "active": true, - "match_tags": ["production"], - "mode": "replace", - "rules": [ - { "host": "api.openai.com", "paths": ["/v1/*"], "methods": ["POST"] } - ], - "source": { "type": "env", "var": "OPENAI_API_KEY" }, - "replace_config": { "proxy_value": "proxy-openai-token" } - }' -``` - -## Delete a Secret Policy - -```http -DELETE /v1/policies/secrets/:id -``` - -Returns `204 No Content`. Connected proxies stop applying the policy within seconds. diff --git a/docs/pages/control-plane/api/secrets.mdx b/docs/pages/control-plane/api/secrets.mdx new file mode 100644 index 0000000..89a63c2 --- /dev/null +++ b/docs/pages/control-plane/api/secrets.mdx @@ -0,0 +1,281 @@ +--- +title: "Secrets" +description: "The five grantable credential types in iron-control, plus managed broker credentials." +--- + +# Secrets + +iron-control stores five grantable credential types, one per [credential proxying](/credential-proxying/overview) mechanism, plus broker credentials that it refreshes itself. Each type shares the [secret source](/control-plane/api/overview#secret-sources) and [request rule](/control-plane/api/overview#request-rules) building blocks and the standard [conventions](/control-plane/api/overview#conventions) for namespaces, foreign IDs, pagination, and upsert. + +All five grantable types support the same operations. Replace `` with the path segment in the table: + +| Credential | Path segment | ID prefix | +| ---------- | ------------ | --------- | +| [Static secret](#static-secrets) | `static_secrets` | `ssr_` | +| [GCP auth secret](#gcp-auth-secrets) | `gcp_auth_secrets` | `gas_` | +| [OAuth token secret](#oauth-token-secrets) | `oauth_token_secrets` | `ots_` | +| [PG DSN secret](#pg-dsn-secrets) | `pg_dsn_secrets` | `pgs_` | +| [HMAC secret](#hmac-secrets) | `hmac_secrets` | `hms_` | + +``` +GET /api/v1/?namespace=default list (namespace required) +POST /api/v1/ create +GET /api/v1//:id fetch by OID +GET /api/v1//lookup/:namespace/:foreign_id fetch by foreign id +PUT /api/v1//:id upsert by OID or foreign_id +PATCH /api/v1//:id upsert by OID or foreign_id +DELETE /api/v1//:id delete (204) +``` + +Deleting a secret cascades to its sources, rules, and any grants that reference it. The granted principals and roles are not deleted. A `control_plane` source's `secret` value is never returned in any response. + +## Static Secrets + +A static secret injects or replaces a fixed credential value on matching requests. It has one [source](/control-plane/api/overview#secret-sources), a list of [rules](/control-plane/api/overview#request-rules), and exactly one of `inject_config` or `replace_config`. + +`inject_config` adds the value to a request header or query parameter: + +```json +{ "header": "Authorization", "formatter": "Bearer {{ .Value }}" } +``` + +Supply exactly one of `header` or `query_param`. The `formatter` template is optional. + +`replace_config` swaps a known placeholder out of proxied traffic for the real value: + +```json +{ "proxy_value": "__GITHUB_TOKEN__", "match_headers": ["X-Token"], "require": true } +``` + +`proxy_value` is required. `match_headers`, `match_body`, `match_path`, `match_query`, and `require` are optional. Both config objects reject unknown keys. + +### Create + +`POST /api/v1/static_secrets` + +```json +{ + "data": { + "foreign_id": "github-token", + "name": "GitHub Token", + "inject_config": { "header": "Authorization", "formatter": "Bearer {{ .Value }}" }, + "source": { "source_type": "env", "config": { "var": "GITHUB_TOKEN" } }, + "rules": [ + { "host": "api.github.com", "http_methods": ["GET", "POST"], "paths": ["/repos/*"] } + ] + } +} +``` + +Returns `201` with the created resource, including its `id` (`ssr_...`), the echoed `source` and `rules` (each rule gains a `position`), and timestamps. On update, `source` and `rules` are replaced wholesale. + +## GCP Auth Secrets + +A GCP auth secret mints short-lived GCP OAuth2 access tokens and injects them as `Authorization: Bearer`. Define exactly one credential mechanism: a `keyfile` [secret source](/control-plane/api/overview#secret-sources) (the service-account JSON), or a `credentials_provider` of `{ "type": "workload_identity" }`. + +| Field | In requests | Notes | +| ----- | ----------- | ----- | +| `scopes` | required | Non-empty array of GCP OAuth scopes. | +| `keyfile` | conditional | A secret source. Define exactly one of `keyfile` or `credentials_provider`. | +| `credentials_provider` | conditional | `{ "type": "workload_identity" }`. | +| `subject` | optional | Email for domain-wide delegation. Only allowed with `keyfile`. | +| `rules` | optional | Array of [rules](/control-plane/api/overview#request-rules). | + +### Create + +`POST /api/v1/gcp_auth_secrets` + +```json +{ + "data": { + "foreign_id": "sa-prod", + "scopes": ["https://www.googleapis.com/auth/cloud-platform"], + "keyfile": { "source_type": "aws_sm", "config": { "secret_id": "gcp-sa-keyfile", "region": "us-west-2" } }, + "rules": [ { "host": "googleapis.com", "http_methods": ["*"], "paths": ["/v1/*"] } ] + } +} +``` + +## OAuth Token Secrets + +An OAuth token secret mints OAuth2 access tokens for a single grant and injects them as a bearer header. Each credential field and each token-endpoint header is its own [secret source](/control-plane/api/overview#secret-sources). At least one [rule](/control-plane/api/overview#request-rules) is required. + +| Field | In requests | Notes | +| ----- | ----------- | ----- | +| `grant` | required | One of `refresh_token`, `client_credentials`, `password`, `jwt_bearer`. | +| `token_endpoint` | required | Token endpoint URL. | +| `audience` | conditional | Required when `grant` is `jwt_bearer`. | +| `scopes` | optional | Array of strings. | +| `header`, `value_prefix` | optional | Header to inject into, and a value prefix such as `Bearer`. | +| `credentials` | required | Map of credential field to [secret source](/control-plane/api/overview#secret-sources). Fields depend on `grant` (below). | +| `token_endpoint_headers` | optional | Map of header name to secret source. | +| `rules` | required | At least one rule. | + +Credential fields per grant: + +| `grant` | Required fields | Optional fields | +| ------- | --------------- | --------------- | +| `refresh_token` | `refresh_token`, `client_id` | `client_secret` | +| `client_credentials` | `client_id`, `client_secret` | | +| `password` | `username`, `password`, `client_id` | `client_secret` | +| `jwt_bearer` | `issuer`, `subject`, `private_key` | `private_key_id` | + +Supplying a field the grant does not use, or omitting a required one, is a validation error. + +### Create + +`POST /api/v1/oauth_token_secrets` + +```json +{ + "data": { + "foreign_id": "slack-app", + "grant": "refresh_token", + "token_endpoint": "https://slack.com/api/oauth.v2.access", + "scopes": ["chat:write"], + "header": "Authorization", + "value_prefix": "Bearer", + "credentials": { + "client_id": { "source_type": "aws_ssm", "config": { "name": "/slack/client_id" } }, + "client_secret": { "source_type": "aws_ssm", "config": { "name": "/slack/client_secret", "with_decryption": true } }, + "refresh_token": { "source_type": "control_plane", "secret": "xoxe-1-...", "config": {} } + }, + "rules": [ { "host": "slack.com", "http_methods": ["POST"], "paths": ["/api/*"] } ] + } +} +``` + +Responses echo each source as `{ source_type, config }` and never include the underlying `secret`. + +## PG DSN Secrets + +A PG DSN secret is a Postgres upstream credential: a connection string resolved from a single [secret source](/control-plane/api/overview#secret-sources), plus an optional `SET ROLE` for the upstream session. It is delivered to iron-proxy keyed by `foreign_id`, and a [proxy-local listener](/postgres) binds to it by that key. Because the binding key must exist, `foreign_id` is **required** here, unlike the other secret types. + +There are no request rules: a Postgres listener matches by port, not by request. Listener bind address and client auth are [proxy-host deployment concerns](/control-plane/enrollment#managed-postgresql-listener), not modeled here. + +| Field | In requests | Notes | +| ----- | ----------- | ----- | +| `foreign_id` | required | Unique per namespace. Immutable. The listener binding key. | +| `database` | optional | Upstream database name, overriding the one in the DSN. | +| `role` | optional | Upstream `SET ROLE` applied to the session. | +| `dsn` | required | A secret source resolving to the connection string. | + +### Create + +`POST /api/v1/pg_dsn_secrets` + +```json +{ + "data": { + "foreign_id": "analytics-pg", + "name": "Analytics DB", + "database": "analytics", + "role": "readonly", + "dsn": { "source_type": "env", "config": { "var": "PG_ANALYTICS_DSN" } } + } +} +``` + +## HMAC Secrets + +An HMAC secret signs matching outbound requests with an HMAC over a templated message and injects the signature (and any companion values) as headers. The HMAC key is the required `secret` credential; additional named credentials are available to the templates as `.Credentials.`. At least one [rule](/control-plane/api/overview#request-rules) is required. Each granted HMAC secret is delivered as its own `hmac_sign` transform. + +| Field | In requests | Notes | +| ----- | ----------- | ----- | +| `timestamp_format` | required | One of `unix_seconds`, `unix_millis`, `unix_nanos`, `rfc3339`. | +| `signature_algorithm` | required | One of `sha256`, `sha512`, `sha1`. | +| `signature_key_encoding` | required | Key byte encoding: `raw`, `base64`, or `hex`. | +| `signature_output_encoding` | required | Signature encoding: `base64` or `hex`. | +| `signature_message` | required | Template for the signed message. Has `.Timestamp`, `.Body`, `.Credentials.`. | +| `allow_chunked_body` | optional | Defaults to `false`. | +| `headers` | required | Non-empty array of `{ "name", "value" }` injected headers; values are templates. | +| `credentials` | required | Map of credential name to source. Must include `secret`. | +| `rules` | required | At least one rule. | + +### Create + +`POST /api/v1/hmac_secrets` + +```json +{ + "data": { + "foreign_id": "webhook-hmac", + "timestamp_format": "unix_seconds", + "signature_algorithm": "sha256", + "signature_key_encoding": "hex", + "signature_output_encoding": "base64", + "signature_message": "{{ .Timestamp }}.{{ .Body }}", + "headers": [ + { "name": "X-Signature", "value": "{{ .Signature }}" }, + { "name": "X-Timestamp", "value": "{{ .Timestamp }}" } + ], + "credentials": { + "secret": { "source_type": "aws_sm", "config": { "secret_id": "webhook-hmac-key", "region": "us-west-2" } } + }, + "rules": [ { "host": "hooks.example.com", "http_methods": ["POST"], "paths": ["/webhooks/*"] } ] + } +} +``` + +## Broker Credentials + +A broker credential is an OAuth credential whose refresh-token lifecycle iron-control manages itself. It runs the refresh loop, mints fresh access tokens before they expire, and delivers the current access token to iron-proxy inline through [proxy sync](/control-plane/api/proxies#proxy-sync) wherever a [`token_broker` source](/control-plane/api/overview#secret-sources) references it. + +A broker credential is not granted directly. It is referenced by a `token_broker` source on a grantable secret (usually a static secret), which carries the rules and injection config. The `refresh_token` never leaves iron-control. The `client_secret` and token-endpoint header values are encrypted at rest and never returned; `client_id` is not secret and is returned. + +| Field | In requests | Notes | +| ----- | ----------- | ----- | +| `token_endpoint` | required | OAuth token endpoint for refresh. | +| `client_id` | required | OAuth client id. Returned in responses. | +| `client_secret` | optional | Write-only, encrypted. Omit for public clients. | +| `scopes` | optional | Array of strings. | +| `token_endpoint_headers` | optional | Map of header name to value, write-only and encrypted. Only the names are returned. | +| `refresh_token` | optional | Write-only seed. Supplying a value (re)bootstraps the credential and clears any dead state. | +| `early_refresh_slack_seconds` | optional | Refresh this many seconds before expiry. Default `300`. | +| `early_refresh_fraction` | optional | Refresh once this fraction of lifetime remains. In `[0, 1)`. Default `0.2`. | +| `max_refresh_interval_seconds` | optional | Refresh at least this often. Default `86400`. | +| `refresh_timeout_seconds` | optional | Per-attempt timeout. Default `30`. | + +Read-only fields returned but never accepted: `status` (`bootstrapping`, `live`, or `dead`), `token_endpoint_header_names`, `expires_at`, `last_refresh`, `next_attempt_at`, `dead`, `dead_reason`, `failure_count`. The minted access token, the refresh token, the client secret, and the header values are never returned. + +### Create + +`POST /api/v1/broker_credentials` + +```json +{ + "data": { + "foreign_id": "gmail", + "token_endpoint": "https://oauth2.googleapis.com/token", + "scopes": ["https://www.googleapis.com/auth/gmail.readonly"], + "client_id": "1234.apps.googleusercontent.com", + "client_secret": "GOCSPX-...", + "refresh_token": "1//0g..." + } +} +``` + +Returns `201` with `status: "bootstrapping"`. Reference it from a grantable secret's `token_broker` source, then grant that secret to a principal: + +```json +{ + "data": { + "foreign_id": "gmail-auth", + "inject_config": { "header": "Authorization", "formatter": "Bearer {{ .Value }}" }, + "source": { "source_type": "token_broker", "config": { "credential_id": "bcr_..." } }, + "rules": [ { "host": "gmail.googleapis.com" } ] + } +} +``` + +### Re-Authenticating A Dead Credential + +When a refresh fails unrecoverably (for example the IdP returns `invalid_grant` because the refresh token was revoked), `status` becomes `dead` and the credential stops minting tokens. Supply a fresh `refresh_token` via `PUT`/`PATCH` to clear the dead state and reschedule it: + +```json +{ "data": { "refresh_token": "1//0gNEW..." } } +``` + +### Operations + +Standard CRUD under `/api/v1/broker_credentials`, with one difference: `DELETE` returns `409` if any `token_broker` source still references the credential. Remove those references first. diff --git a/docs/pages/control-plane/enrollment.mdx b/docs/pages/control-plane/enrollment.mdx index fa5b41a..4fb4b8d 100644 --- a/docs/pages/control-plane/enrollment.mdx +++ b/docs/pages/control-plane/enrollment.mdx @@ -1,86 +1,105 @@ --- -title: "Enrollment" -description: "Connect an iron-proxy instance to the control plane using an enrollment token." +title: "Connecting Proxies" +description: "Register an iron-proxy with iron-control, point it at the control plane with its token, and assign it an identity." --- -# Enrollment +# Connecting Proxies -A new iron-proxy joins the control plane by running a one-time `init` command with an enrollment token. The proxy registers itself, picks up the tags configured on the token, pulls its initial configuration, and begins reporting audit events. After enrollment, the proxy stores a long-lived credential on disk and does not need the enrollment token again. +An iron-proxy connects to iron-control in **managed mode**. You register the proxy in the control plane, which issues it a token, then start the proxy with that token. From boot on, the proxy fetches its configuration from the control plane and holds no local secrets or policy. -## Enroll a Proxy +A proxy is tied to an **identity**, called a [principal](/control-plane/policies). The proxy receives exactly the credentials its principal is granted. You can assign, swap, or clear the principal at any time without touching the proxy or reissuing its token. -::::steps -### Create an Enrollment Token +## Connect A Proxy -In the control plane UI, open the **Proxies** tab and click **Add Proxy**. +These steps use the [JSON API](/control-plane/api/overview). The console is read-only, so proxies and principals are created over the API. Set `IRON_CONTROL_URL` to your control plane (the hosted default is `https://api.iron.sh`) and `IRON_API_KEY` to an [API key](/control-plane/api/overview#authentication). -The Add Proxy dialog, showing Tags, Key Expiry, and Max Uses fields +::::steps -Fill in: +### Create A Principal -- **Tags.** Labels that will be assigned to any proxy registered with this token. Type a tag and press comma or enter to add it. -- **Key Expiry.** How long the token can be redeemed before it is no longer valid. -- **Max Uses.** How many proxies this token can register. `0` means unlimited. +A principal is the identity the proxy runs as. Create one (or reuse an existing one): -Click **Add Proxy**. The control plane displays the enrollment token once. +```sh +curl -X POST "$IRON_CONTROL_URL/api/v1/principals" \ + -H "Authorization: Bearer $IRON_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ "data": { "foreign_id": "checkout-service", "name": "Checkout Service" } }' +``` -Success message showing the enrollment token with its tags and expiry +The response includes the principal id (`prn_...`). Grant it the credentials it should use through [grants](/control-plane/policies#grants). -Copy the token immediately. It will not be shown again. +### Register The Proxy -### Run `init` on the Host +Create a proxy record. Pass the `principal_id` to assign it now, or omit it to register an unassigned proxy you assign later: -On the machine where iron-proxy will run, execute: +```sh +curl -X POST "$IRON_CONTROL_URL/api/v1/proxies" \ + -H "Authorization: Bearer $IRON_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ "data": { "name": "Edge Proxy - US", "principal_id": "prn_..." } }' +``` -```bash -sudo ./iron-proxy init \ - -enrollment-token 1a46ef8a2f34650f6b3fba5f20990dde0910060d9faab3dfba9ab168cf4f3f43 +The response contains the proxy's token (`iprx_...`): + +```json +{ + "data": { + "id": "prx_...", + "name": "Edge Proxy - US", + "principal_id": "prn_...", + "status": "assigned", + "token": "iprx_0a1b2c3d..." + } +} ``` -The `init` subcommand exchanges the enrollment token for a long-lived proxy credential, registers the proxy with the control plane along with the tags configured on the token, pulls the initial policy, installs the iron-proxy systemd services, and starts the `iron-proxy` service. When it returns, the proxy is already running and connected to the control plane. +:::warning +The token is shown **only** in this create response. Copy it immediately. It is stored as a hash and cannot be recovered. If you lose it, deregister the proxy and create a new one. +::: -`sudo` is required because the state directory, iptables rules, and systemd units are all system-wide. +### Start The Proxy In Managed Mode -### Verify It Registered +On the host, start iron-proxy with the token. Managed mode is enabled by the presence of a control plane token, supplied either with the `-token` flag or the `IRON_PROXY_TOKEN` environment variable. `IRON_CONTROL_PLANE_URL` points at the control plane and defaults to `https://api.iron.sh`: -Back in the **Proxies** tab, the new proxy appears in the list with its tags and `Active` status. +```sh +export IRON_PROXY_TOKEN=iprx_0a1b2c3d... +export IRON_CONTROL_PLANE_URL=https://api.iron.sh # your host, if self-hosted +iron-proxy +``` -The Proxies list showing the newly enrolled proxy with its tags and status +On boot the proxy registers, fetches the configuration for its assigned principal, and begins adding the granted credentials to matching requests. No YAML credential config is needed: in managed mode the control plane is the source of truth. :::: -## Customizing the Generated Config +## Assigning And Swapping Identity -`init` writes a config file to the proxy's state directory. It is preconfigured to connect to the control plane and apply the policy you just pulled, and will work as-is for most deployments. +A proxy can boot **unassigned**. It authenticates and syncs normally but receives an empty configuration until a principal is assigned. Assign or swap the principal with a `PATCH`, and the proxy picks up the new config on its next sync. The token never changes: -Anything in the standard [iron-proxy configuration](/reference/configuration) can be edited directly in the file: listen ports, upstream resolvers, log format, extra transforms. After editing, restart the service to pick up the changes: - -```bash -sudo systemctl restart iron-proxy +```sh +curl -X PATCH "$IRON_CONTROL_URL/api/v1/proxies/prx_..." \ + -H "Authorization: Bearer $IRON_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ "data": { "principal_id": "prn_..." } }' ``` -Control-plane-managed fields (policy, secret mappings, tags) are refreshed from the control plane on every reconnect, so local edits to those fields will be overwritten. Edit them in the control plane UI instead. - -## Tags - -Each proxy carries a set of colon-separated labels that identify it and its workload. Tags can be bare strings like `dev` or namespaced like `env:prod`. Every audit event is indexed by the proxy's tags, so fleet-wide queries like "show every request from `env:prod` `service:checkout`" are cheap. - -Tags are configured on the enrollment token in the control plane UI. Every proxy registered with the token inherits that tag set. +Send `"principal_id": null` to unassign. A swap is a full replacement: the proxy drops the previously delivered config rather than merging. Deleting a principal does not delete its proxies; they become unassigned and can be reassigned. -Changing a proxy's tags after enrollment is not yet exposed in the UI. For now, re-enroll the proxy with a fresh enrollment token to assign a new tag set. +## Fetching Configuration -## Enrollment Tokens +The proxy keeps its config current by calling [`POST /api/v1/proxy/sync`](/control-plane/api/proxies#proxy-sync) with its own token. It sends the content hash of the config it currently holds. If that matches what the control plane computes, it gets back only the hash and applies nothing. Otherwise it gets the full payload. This is how a grant change or a rotated secret reaches the fleet without restarts. -Each enrollment is authorized by a short-lived credential that exists only for the handshake. Once the proxy has its long-lived credential, the token is discarded. +## Managed PostgreSQL Listener -`Max Uses` controls how many proxies a single token can register: +A proxy in managed mode runs a [PostgreSQL listener](/postgres) for any [PG DSN secrets](/control-plane/api/secrets#pg-dsn-secrets) its principal is granted. The upstream databases, connection strings, and roles come from the control plane. The listener's own bind address and the client credential are local deployment concerns, set with environment variables: -- **1** is the tightest setting: the token can enroll exactly one proxy, then is dead. Prefer this for provisioning flows that hand out a token per host. -- **A small integer** fits short-lived autoscaling groups where a handful of proxies will come up from the same enrollment token. -- **0** (unlimited) fits base images or long-lived pools. Pair with a short `Key Expiry` so the blast radius of a leaked token stays bounded. +| Variable | Notes | +|---|---| +| `IRON_PROXY_PG_LISTEN` | Address and port the listener binds, e.g. `:5432`. | +| `IRON_PROXY_PG_CLIENT_USER` | The username clients present to the proxy. | +| `IRON_PROXY_PG_CLIENT_PASSWORD` | The password clients present to the proxy. | -## Troubleshooting +When all three are set and the principal is granted at least one PG DSN secret, the proxy starts the listener and routes clients to upstreams by database name. If a local YAML `postgres` block is also present, the synced upstreams layer on top of it, reusing its bind address and client credential. -**Token expired.** The token's `Key Expiry` window has passed. Generate a new enrollment token and retry. +## Per-Tenant And Per-User Proxies -**Max uses exceeded.** The enrollment token has already been redeemed `Max Uses` times. Generate a new token or increase `Max Uses` on the next one. +To scope a proxy to a single tenant, customer, or user, give it a principal that represents that identity. Create one principal per tenant (`foreign_id: "tenant-acme"`), grant it only that tenant's credentials, and assign it to the proxies that run on the tenant's behalf. Each proxy then reaches only what its tenant is granted, and the proxy's [audit log](/guides/otel-export) is a per-identity record of every request it made. Shared baseline credentials are best modeled as a [role](/control-plane/policies#roles) assigned to every principal. diff --git a/docs/pages/control-plane/overview.mdx b/docs/pages/control-plane/overview.mdx index 310254a..1b3e519 100644 --- a/docs/pages/control-plane/overview.mdx +++ b/docs/pages/control-plane/overview.mdx @@ -1,115 +1,62 @@ --- title: "Overview" -description: "Centralized policy, audit, and fleet management for iron-proxy. Hosted or self-hosted." +description: "iron-control is the control plane for iron-proxy: it stores credentials, decides which proxy may use which credential on which requests, and hands each proxy its configuration." --- # Overview -A single iron-proxy is a default-deny firewall for one workload. A fleet of them is a distributed system, and distributed systems need a control plane. The iron-proxy control plane is that layer. - -Policies are authored once and converge across every connected proxy in seconds. New proxies enroll on boot with a short-lived token and pull their config on first connect. There is nothing baked into the image and nothing to template per host. Every intercepted request from every proxy streams into a unified audit store you can query by workload, destination, policy decision, or injected secret. - -## Architecture - - - - - - - - - - - - - - Operator - authors policy - - - Control Plane - Policies · Enrollment · Audit Store - hosted or self-hosted - - - - - - - - - live config push - - - - - - - audit events - - - - iron-proxy - env:dev - - - iron-proxy - env:prod · ci - - - iron-proxy - tenant:acme - - - - - - - - - - - workload - CI runner - - - workload - build container - - - workload - agent sandbox - - - - - -The control plane is the single source of truth. Operators publish policies once; the control plane fans them out to every connected proxy in seconds. Each proxy enforces locally against its workload's egress and streams audit events back. New proxies enroll with a short-lived token and pick up the right policies based on the tags they carry. +**iron-control** is the control plane for iron-proxy. It stores your credentials, decides which proxy may use which credential on which requests, and hands each proxy its configuration. Proxies hold no secrets of their own and no local policy: they register, fetch their config from iron-control, and apply it. + +Where a standalone proxy reads its setup from a [local YAML file](/reference/configuration), a proxy connected to iron-control gets everything from the control plane. Credentials and access rules live in one place, and changes reach every proxy on its next sync. + +## How It Fits Together + +``` + operator ──▶ console / JSON API ──▶ iron-control ──▶ Postgres (encrypted secrets) + ▲ + │ POST /api/v1/proxy/sync (iprx_ token) + │ + workload ──▶ iron-proxy ─────────────────┘ + │ + └──▶ upstream APIs (credentials added per request rules) +``` + +Operators manage credentials, principals, roles, and grants through the API or the console. Each iron-proxy signs in with its own token, fetches the configuration for its assigned identity, and adds the granted credentials to matching outbound requests. The credentials themselves stay in the control plane. + +## The Model + +iron-control has a small, composable model. Four concepts cover the whole system. + +**Credentials.** Each secret is a typed record. The five grantable kinds map to the [credential proxying](/credential-proxying/overview) mechanisms: [static secrets](/credential-proxying/static-secrets), [GCP service-account auth](/credential-proxying/gcp-auth), [OAuth tokens](/credential-proxying/oauth-token), [HMAC signing keys](/credential-proxying/hmac-sign), and [Postgres connection strings](/postgres). A value is either kept inline and encrypted, or pulled from an external store such as AWS Secrets Manager, AWS SSM, or 1Password. + +**Principals.** A principal is an identity that a proxy runs as: an application, a service, a tenant. A proxy is assigned one principal and receives the credentials that principal can use. + +**Roles.** A role bundles credentials so they can be assigned together. A principal can hold any number of roles, and its effective credentials are its own direct grants plus the grants of every role it holds. + +**Grants.** A grant gives one credential to a principal or a role. Each grant carries [request rules](/control-plane/api/overview#request-rules) for host, methods, and paths, so a credential is only added to the requests it is meant for. ## What You Get -**Policy as the source of truth.** Allowlists, secret mappings, and transform rules live in the control plane and are versioned there. Proxies are stateless clients; rebuild a host and it pulls the current policy on reconnect. There is no per-host YAML to drift. +**Credentials as the source of truth.** Secrets, the identities that may use them, and the requests they apply to all live in the control plane. Proxies are stateless clients. Rebuild a host and it pulls the current config on reconnect. There is no per-host YAML to drift. -**Live updates, no restarts.** Publish a policy change and every connected proxy applies it within seconds. Tightening an allowlist mid-incident or rotating a secret mapping is a single API call, not a fleet-wide redeploy. +**Live updates, no restarts.** Change a grant or rotate a secret and the next [sync](/control-plane/api/overview) delivers it. Each config carries a content hash that works like an ETag, so a proxy that already holds the current config gets an empty response and applies nothing. -**Zero-config enrollment.** A proxy starts with `IRON_ENROLLMENT_TOKEN=…` and nothing else. Tokens are short-lived and single-use by default, so the same image safely boots in CI, in a sandbox, or in a long-lived VM. See [Enrollment](./enrollment) for the token model. +**Secrets never reach the workload, or even the proxy host.** Inline values are encrypted at rest and delivered only to the assigned proxy. For external stores, only a reference travels. For [broker credentials](/control-plane/api/secrets#broker-credentials), iron-control runs the OAuth refresh loop itself and sends only the short-lived access token, so the long-lived refresh token never leaves the control plane. -**Fleet-wide audit search.** Every request across every proxy lands in one queryable store, indexed by host, workload tag, destination, decision, and the secrets that were proxied in. No per-host log shipping, no Loki cluster to operate. +**One identity, audited at the edge.** Each proxy emits a structured [audit log](/guides/otel-export) of every request, including which credential was injected. Tag those logs by principal to get a per-identity record of everything a workload did. -## Deployment Options +## Hosted Or Self-Hosted -The control plane is available as a hosted cloud service and as a self-hosted on-prem instance. Both run the same software and expose the same API, UI, and proxy protocol, so moving between them is a config change rather than a rewrite. +iron-control is available as a hosted cloud service and as a self-hosted instance you run inside your own infrastructure. Both run the same software and expose the same API, console, and proxy protocol, so moving between them is a configuration change rather than a rewrite. -**Hosted.** Enroll proxies against a tenant endpoint and get policy management, live updates, enrollment, and audit search without operating anything yourself. Suited to teams that want a production-ready control plane without data-residency or network-isolation requirements. +**Hosted.** Point proxies at a tenant endpoint and get credential management and live updates without operating anything yourself. Suited to teams that want a production-ready control plane without data-residency or network-isolation requirements. -**Self-hosted.** Runs entirely inside your own infrastructure, with source available. Policy data and audit logs stay on your network, and the deployment can be fully air-gapped. Suited to regulated environments, on-prem-only networks, or any setting where a managed service is not an option. +**Self-hosted.** iron-control is open source under Apache 2.0, published to Docker Hub as [`ironsh/iron-control`](https://hub.docker.com/r/ironsh/iron-control). It runs entirely inside your network: credentials and configuration never leave your infrastructure, and the deployment can be fully air-gapped. See [Self-Hosted](/control-plane/self-hosted) to run it. ## Next Steps -[Book a demo](https://cal.com/matt-slipper-ironsh/15min) for a walkthrough of policy authoring, live updates, enrollment, and audit search against a real fleet. +- [Connect a proxy](/control-plane/enrollment): register an iron-proxy and assign it an identity. +- [Access model](/control-plane/policies): principals, roles, and grants in depth. +- [Control plane API](/control-plane/api/overview): the full JSON API reference. + +[Book a demo](https://cal.com/matt-slipper-ironsh/15min) for a walkthrough against a real fleet. diff --git a/docs/pages/control-plane/policies.mdx b/docs/pages/control-plane/policies.mdx index 1034c65..b8c44e5 100644 --- a/docs/pages/control-plane/policies.mdx +++ b/docs/pages/control-plane/policies.mdx @@ -1,118 +1,84 @@ --- -title: "Policies" -description: "Create named egress allowlists and apply them to proxies by tag." +title: "Access Model" +description: "How iron-control decides which proxy may use which credential on which requests: principals, roles, grants, and request rules." --- -# Policies +# Access Model -A policy is a named allowlist that tells matching proxies which hosts, paths, and methods they can reach. Policies are authored in the control plane UI and delivered to every proxy whose tags match the policy's match set. Changes propagate within seconds of saving, without restarts. +iron-control's access model answers one question: which proxy may use which credential, on which requests. It is built from four pieces. -Proxies are default-deny. Anything not covered by an active, matching policy is rejected. +- A **credential** is a stored secret. See the [credential types](/control-plane/api/secrets). +- A **principal** is an identity a proxy runs as. +- A **grant** gives one credential to a principal (or a role), scoped by request rules. +- A **role** bundles grants so they can be assigned to many principals at once. -## Create a Policy +A proxy is assigned a principal. Its configuration is that principal's **effective grants**: every credential granted to it directly, plus every credential granted to any role it holds. -::::steps -### Fill Out Policy Metadata +## Principals -In the control plane UI, open the **Policies** tab and click **Add Policy**. +A principal represents an application, a service, or a tenant: whatever identity a proxy acts as. Create one per identity you want to scope credentials to. -The New Policy form with Name, Priority, Status, and Match Tags fields +```sh +curl -X POST "$IRON_CONTROL_URL/api/v1/principals" \ + -H "Authorization: Bearer $IRON_API_KEY" -H "Content-Type: application/json" \ + -d '{ "data": { "foreign_id": "checkout-service", "name": "Checkout Service" } }' +``` -Fill in: +A proxy is bound to a principal when you [register or update it](/control-plane/enrollment#assigning-and-swapping-identity). One principal can back many proxies. -- **Name.** A human-readable identifier like `production-egress` or `ci-npm`. -- **Priority.** An integer used to break ties when a single proxy matches more than one policy. Lower numbers win. Defaults to `10`. -- **Status.** Set to `Active` to apply the policy to matching proxies. Any other status is a no-op, so you can draft a policy in the UI and hold it until ready. -- **Match Tags.** The tags a proxy must carry for the policy to apply to it. Type a tag and press comma or enter to add it. Leave empty to apply the policy to every proxy in the fleet. +## Grants -### Add Egress Rules +A grant attaches exactly one credential to one grantee. The grantee is a principal or a role. -Each rule describes a class of requests the policy allows. Click **Add Rule** once per rule. +```sh +# Grant a static secret directly to a principal. +curl -X POST "$IRON_CONTROL_URL/api/v1/grants" \ + -H "Authorization: Bearer $IRON_API_KEY" -H "Content-Type: application/json" \ + -d '{ "data": { "principal_id": "prn_...", "static_secret_id": "ssr_..." } }' +``` -An egress rule with Host, Paths, and Methods fields +Each credential carries its own [request rules](/control-plane/api/overview#request-rules) for host, methods, and paths, so a granted credential is only added to the requests it is meant for. Scope rules to the narrowest set of destinations that works: the blast radius of a credential is exactly the union of its rules. -Each rule has: +Revoke a grant by deleting it. Deleting a credential cascades to its grants, but never deletes the principals or roles that held them. -- **Host** (required). A hostname or glob. `api.example.com` matches that host exactly. `*.example.com` matches any subdomain. -- **Paths.** Zero or more path patterns. Leave empty to allow any path on the host. Add multiple paths by pressing comma or enter between entries. -- **Methods.** The HTTP methods the rule accepts. Check **All** for every method, or pick a subset. +## Roles -Rules inside a policy are OR-combined: a request is allowed if it matches any rule in the policy. +A role is a reusable bundle of grants. Grant credentials to a role, then assign the role to any number of principals. A principal's effective credentials are its direct grants plus the grants of every role it holds, deduplicated. -### Save +```sh +# Create a role, grant it shared infrastructure credentials, assign it. +curl -X POST "$IRON_CONTROL_URL/api/v1/roles" \ + -H "Authorization: Bearer $IRON_API_KEY" -H "Content-Type: application/json" \ + -d '{ "data": { "foreign_id": "infra", "name": "Shared Infra" } }' -Save the policy. Every connected proxy with a matching tag set applies the change within seconds. Denied requests show up in the audit trail immediately. +curl -X POST "$IRON_CONTROL_URL/api/v1/grants" \ + -H "Authorization: Bearer $IRON_API_KEY" -H "Content-Type: application/json" \ + -d '{ "data": { "role_id": "role_...", "static_secret_id": "ssr_..." } }' -:::: +curl -X POST "$IRON_CONTROL_URL/api/v1/principals/prn_.../roles" \ + -H "Authorization: Bearer $IRON_API_KEY" -H "Content-Type: application/json" \ + -d '{ "data": { "role_id": "role_..." } }' +``` -## Add a Secret +Use a role for any credential set shared across identities, such as common package registries or an internal API every service calls. A principal may only be assigned roles in its own namespace. -Secrets let matching proxies inject or replace credentials at the network edge so the workload never sees the real value. Secrets are authored in the **Secrets** tab and are independent of policies: they have their own match-tag set and their own request rules, and apply to any proxy that matches, regardless of which policies that proxy carries. +## Namespaces And Foreign IDs -Click **Add Secret** to start the four-step wizard. +Every credential, principal, and role lives in a **namespace** (default `"default"`) and may carry a **foreign ID**: your own stable identifier, unique within the namespace. Both are immutable once set. -::::steps -### Pick the Proxies +Foreign IDs make provisioning idempotent. `PUT /api/v1/roles/infra` with a namespace converges that role whether or not it already exists, so you can drive iron-control from infrastructure-as-code without tracking server-assigned ids. Namespaces give each tenant or environment its own isolated set of names. See [API conventions](/control-plane/api/overview#conventions) for the full rules. -Step 1 of the secret wizard: a tags input asking which proxies should hold this secret +## Inspecting What A Principal Resolves To -Type a tag and press comma or enter to add it. The credential is only loaded onto proxies that carry every tag in the set. Use the same tagging conventions you use for policy match tags: `env:prod`, `tenant:acme-corp`, `service:checkout`. Leave the field empty to load the secret onto every proxy in the fleet. +Before assigning a principal to a live proxy, check exactly what it would receive. The [effective config](/control-plane/api/proxies#effective-config) endpoint returns the resolved configuration, in the same shape a proxy gets on sync, with live secret values redacted: -### Pick the Requests +```sh +curl "$IRON_CONTROL_URL/api/v1/principals/prn_.../effective_config" \ + -H "Authorization: Bearer $IRON_API_KEY" +``` -Step 2: a rule with Host, Path, and Methods fields scoping which requests get the secret +This is the safe way to verify a change. Inline secret values show as `[redacted]`; every external reference (an env var name, an AWS secret id) passes through unchanged so you can confirm the wiring. -Within the matched proxies, only requests that match one of these rules get the credential. Anything else passes through untouched. Each rule has the same fields as an egress rule: +## Per-Tenant And Per-User Access -- **Host.** A hostname or glob, e.g. `api.openai.com` or `*.example.com`. -- **Path.** Zero or more path patterns; leave empty to apply to any path. `/*` matches anything. -- **Methods.** The HTTP methods the rule applies to. `ALL` covers every method. - -Click **+ Add rule** for additional rules. Rules are OR'd: a request matches if it matches any rule. - -Always scope as narrowly as you can. The blast radius of a misconfigured secret is exactly the union of these rules. - -### Pick the Injection Style - -Step 3: presets for Bearer token, Basic auth, Custom, and Replace path token, with header and format fields - -Choose how the proxy rewrites the request in flight. Four presets cover the common cases: - -- **Bearer token.** Adds `Authorization: Bearer `. Standard for OpenAI, Anthropic, and most modern APIs. -- **Basic auth.** Adds `Authorization: Basic base64(user:password)`. Use this for GitHub PAT auth and other Basic-auth services. -- **Custom.** Inject into any header or query parameter, with a Go-template `Format` field that has access to `.Value` and a `base64` helper. Use this when none of the presets fit. -- **Replace path token.** Swaps a placeholder you control out of the URL path for the real value before forwarding. Useful for upstreams like Telegram that put the credential in the path. - -The **Preview** at the bottom of the step shows exactly what the upstream will see, so you can verify the format before saving. - -### Pick the Source - -Step 4: a Source dropdown with Environment variable selected and a Variable field - -Select where the proxy should resolve the real value from at startup. The value never appears in the control plane: it is read locally on the proxy host. - -iron-proxy supports several backends here, including environment variables, AWS Secrets Manager, AWS Systems Manager Parameter Store, and 1Password. See [Static Secrets](/credential-proxying/static-secrets) for the full set of source backends, the fields each one takes, TTL behavior, and credential-chain notes. - -:::: - -## Match Tags - -A policy is pinned to a subset of the fleet by its match tags. A proxy is subject to a policy when it carries every tag in the policy's match set. A proxy can be matched by multiple policies at once, and their allowed rules combine. - -Because tags are assigned at [enrollment](./enrollment), you can roll out a new policy without touching its match set: enroll hosts with the right tag and they inherit the policy automatically. - -## Priority - -When two matching policies disagree about the same host and method, the policy with the lower priority number wins, so `priority: 1` beats `priority: 10`. Most fleets don't need priority tuning: start with the default and adjust only if you see policies stepping on each other. - -## Per-User and Per-Tenant Policies - -To scope a policy to a single user, customer, or tenant, encode the identity in tags. When you generate an [enrollment token](./enrollment) for a proxy that will run on behalf of a specific identity, include that identity in the token's tag set: `tenant:acme-corp`, `user:alice@example.com`, `customer-id:c_8f3a2b`. Every proxy registered with the token inherits those tags, and every audit event the proxy emits is indexed by them. - -Put that identity tag in the policy's match set. A policy with match tags `tenant:acme-corp` only applies to proxies that carry the `tenant:acme-corp` tag, so you can give each tenant a different egress allowlist by writing one policy per tenant. The same pattern works for per-user sandboxes (`user:alice@example.com`) or per-customer workloads in a multi-tenant runtime. - -Layered tagging gets you fleet-wide baselines plus per-identity overlays. A proxy can match multiple policies at once and the allowed rules combine, so a baseline policy with no match tags (applies to everyone) can grant common destinations like package registries, while per-tenant policies layer on the destinations only that tenant should reach. For compliance reporting, filter the audit trail by the same tenant or user tag to get a per-identity record of every request that proxy made. - -## Rolling Out Safely - -Match tags make it cheap to canary a policy. Tag a single proxy with something like `canary:true`, point the new policy at `canary:true`, and watch the audit trail for denies before broadening the match set to include production tags. Audit search filtered by `decision: denied` shows exactly which requests the new policy would block. +To give each tenant a different credential set, model the tenant as a principal. Create one principal per tenant, grant it only that tenant's credentials, and assign it to the proxies that act for that tenant. Layer shared baselines as roles assigned to every principal, and tenant-specific credentials as direct grants. Because a principal resolves to the union of its direct grants and its roles, you get fleet-wide baselines plus per-tenant overlays without duplicating credentials. See [Connecting Proxies](/control-plane/enrollment#per-tenant-and-per-user-proxies) for the proxy side. diff --git a/docs/pages/control-plane/self-hosted.mdx b/docs/pages/control-plane/self-hosted.mdx index b45d737..eaeeb11 100644 --- a/docs/pages/control-plane/self-hosted.mdx +++ b/docs/pages/control-plane/self-hosted.mdx @@ -1,28 +1,124 @@ --- title: "Self-Hosted" -description: "Run the iron-proxy control plane inside your own infrastructure." +description: "iron-control is open source and published to Docker Hub. Run it inside your own infrastructure: image, requirements, bootstrap, and encryption keys." --- # Self-Hosted -The control plane runs inside your own network with the same software, API, and proxy protocol as the hosted version. Policy data, enrollment tokens, and audit logs never leave your infrastructure, and the deployment can be fully air-gapped. Source is available. +iron-control is open source under the Apache 2.0 license. The production container image is published to Docker Hub as [`ironsh/iron-control`](https://hub.docker.com/r/ironsh/iron-control), rebuilt on every change, and the source lives at [github.com/ironsh/iron-control](https://github.com/ironsh/iron-control). Self-hosting is fully self-serve: pull the image, point it at a PostgreSQL database, and run it. -A complete deployment bundle (container images, manifests, and an installation guide) is available on request. The page below covers the system requirements so you can size the host before reaching out. +Self-hosted runs the same software, API, console, and proxy protocol as the hosted service. Your credentials and configuration never leave your infrastructure, and the deployment can be fully air-gapped. + +## The Image + +```sh +docker pull ironsh/iron-control:latest +``` + +Tags: `latest` tracks the most recent build, and each build is also tagged `sha-` if you want to pin. It is a standard Rails production container that listens on port 80. Database migrations run automatically on startup. ## System Requirements -The control plane is a single Docker container plus a database. There are no other required services. +iron-control is a single container plus a PostgreSQL database. There are no other required services. + +**Compute.** A single container is enough for typical fleets. We recommend 2 vCPUs and 2 GB of memory to start. The container is stateless, so you can run several replicas behind a load balancer for availability. + +**Database.** PostgreSQL. iron-control stores credentials, principals, roles, grants, and proxy registrations here. Inline secret values are encrypted at rest. A managed instance (RDS, Cloud SQL, Neon) or a self-managed cluster both work. On boot, iron-control creates and migrates the databases it needs, so the configured role must be able to create databases. + +**Networking.** Inbound: HTTPS on a single port serves the console, the operator API, and the proxy sync endpoint. Outbound: only what your credentials require (for example, the OAuth token endpoints that [broker credentials](/control-plane/api/secrets#broker-credentials) refresh against, and any external secret stores you reference). iron-control does not phone home. + +**TLS.** A certificate proxies and operators can validate. Terminate at an ingress or load balancer in front of the container. + +Audit logs are not stored in iron-control. Each [iron-proxy emits its own structured audit log](/guides/otel-export), which you ship to your own backend. + +## Configuration + +iron-control is configured entirely through environment variables, grouped below. The image already sets `RAILS_ENV=production`, so you do not set that yourself. + +### Database + +| Variable | Notes | +| -------- | ----- | +| `IRON_CONTROL_DB_HOST` | Database host. Defaults to `localhost`. | +| `IRON_CONTROL_DB_PORT` | Database port. Defaults to `5432`. | +| `IRON_CONTROL_DATABASE_PASSWORD` | Password for the `iron_control` database role. | +| `IRON_CONTROL_DATABASE_URL` | Full connection URL. An alternative to the discrete variables above. | + +### First Boot + +iron-control needs an authenticated user and an API key before any API endpoint will respond. Bootstrap a fresh deployment by setting these on startup: + +| Variable | Required | Description | +| -------- | -------- | ----------- | +| `IRON_CONTROL_INITIAL_USER_EMAIL` | yes | Email for the initial user. | +| `IRON_CONTROL_INITIAL_USER_PASSWORD` | yes | Password for the initial user (minimum 12 characters). | +| `IRON_CONTROL_INITIAL_API_KEY` | no | Plaintext API key for the initial user, of the form `iak_` followed by 64 lowercase hex characters. If omitted, one is generated and logged once at startup. | + +Bootstrap runs on every boot but is a no-op once any user exists, so it is safe to leave these set across rolling restarts. Concurrent replicas racing the first boot are serialized with a Postgres advisory lock, so exactly one user is created. On Kubernetes, source these from a `Secret`, not a `ConfigMap`. + +### Encryption Keys + +iron-control encrypts secrets stored inline (the `control_plane` [secret source](/control-plane/api/overview#secret-sources), broker-credential refresh tokens, and client secrets) using application-level encryption. Configure the keys: + +| Variable | Required | Description | +| -------- | -------- | ----------- | +| `IRON_CONTROL_AR_ENCRYPTION_PRIMARY_KEY` | yes (production) | Primary key for non-deterministic encryption. | +| `IRON_CONTROL_AR_ENCRYPTION_DETERMINISTIC_KEY` | yes (production) | Key for deterministic encryption. | +| `IRON_CONTROL_AR_ENCRYPTION_KEY_DERIVATION_SALT` | yes (production) | Salt for deriving per-attribute keys. | + +In production, iron-control refuses to boot if any of the three is missing. Generate values with the command in the repository README and store them in your secret manager. + +:::warning +Rotating any of these keys makes previously encrypted data unreadable. Treat them as long-lived secrets and back them up alongside your other production credentials. +::: + +### Rails Settings + +iron-control is a Rails application, so it also takes the standard Rails production settings. + +| Variable | Required | Description | +| -------- | -------- | ----------- | +| `SECRET_KEY_BASE` | yes | Secret used to sign sessions and cookies. The published image does not include the master key for its baked-in credentials, so set this rather than relying on `RAILS_MASTER_KEY`. Generate one with `openssl rand -hex 64`. | +| `IRON_CONTROL_SOLID_QUEUE_IN_PUMA` | recommended | Runs the background job worker inside the web process. iron-control refreshes [broker-credential](/control-plane/api/secrets#broker-credentials) tokens with background jobs, so set this to `true` for a single-container deployment. Otherwise run a separate Solid Queue worker. | +| `RAILS_LOG_LEVEL` | no | Log verbosity. Defaults to `info`. | +| `RAILS_MAX_THREADS` | no | Puma threads per worker, and the database connection pool size. Defaults to `5`. | +| `WEB_CONCURRENCY` | no | Number of Puma worker processes. Defaults to `1`. | +| `IRON_CONTROL_JOB_CONCURRENCY` | no | Number of Solid Queue worker processes. Defaults to `1`. | -**Compute.** A single host or pod is enough for fleets up to a few hundred proxies. We recommend 4 vCPUs and 8 GB of memory. The container is stateless, so additional replicas can sit behind a load balancer without coordination if you need to scale beyond that. +If you build your own image from source with your own `config/master.key`, you can set `RAILS_MASTER_KEY` instead of `SECRET_KEY_BASE`. With the published image, use `SECRET_KEY_BASE`. -**Database.** PostgreSQL 14 or newer. Stores policies, enrollment tokens, proxy registrations, and tags. A managed instance (RDS, Cloud SQL, Neon, etc.) or a self-managed cluster both work. Plan for ~1 GB of storage for fleets up to a few thousand proxies; audit data goes elsewhere. +## Example: Docker Compose -**Audit log store (optional).** Audit events can be written to any OTLP-compatible log backend: Grafana Loki, Tempo, Honeycomb, Datadog, an OpenTelemetry collector fanning out to your own pipeline, or the bundled local store for evaluation. See [Configuring OTEL Export](/guides/otel-export) for the export format. If you skip this, audit search is limited to recent events held in memory. +This is a minimal, illustrative setup. Put it behind a TLS-terminating proxy or load balancer for production. See the [repository README](https://github.com/ironsh/iron-control) for additional options. -**Networking.** Inbound: HTTPS on a single port serves both the operator UI/API and the enrolled proxy connections. Outbound: only what you choose to allow (e.g. SSO, your audit backend). The control plane does not phone home. +```yaml +services: + db: + image: postgres:17 + environment: + POSTGRES_USER: iron_control + POSTGRES_PASSWORD: ${IRON_CONTROL_DATABASE_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data -**TLS.** A certificate the proxies and operators can validate. Bring your own cert and key, or terminate at an ingress. + control: + image: ironsh/iron-control:latest + depends_on: [db] + ports: + - "80:80" + environment: + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + IRON_CONTROL_SOLID_QUEUE_IN_PUMA: "true" + IRON_CONTROL_DB_HOST: db + IRON_CONTROL_DATABASE_PASSWORD: ${IRON_CONTROL_DATABASE_PASSWORD} + IRON_CONTROL_INITIAL_USER_EMAIL: admin@example.com + IRON_CONTROL_INITIAL_USER_PASSWORD: ${INITIAL_USER_PASSWORD} + IRON_CONTROL_AR_ENCRYPTION_PRIMARY_KEY: ${AR_PRIMARY_KEY} + IRON_CONTROL_AR_ENCRYPTION_DETERMINISTIC_KEY: ${AR_DETERMINISTIC_KEY} + IRON_CONTROL_AR_ENCRYPTION_KEY_DERIVATION_SALT: ${AR_SALT} -## Get the Deployment Bundle +volumes: + pgdata: +``` -The self-hosted bundle ships under a source-available license with a short evaluation agreement. [Get in touch](https://cal.com/matt-slipper-ironsh/15min) and we'll walk through requirements, send the bundle, and stay on for the first install. +Once it is up, the initial API key is in the startup logs (or whatever you set `IRON_CONTROL_INITIAL_API_KEY` to). Point your proxies at this control plane and start [connecting them](/control-plane/enrollment). diff --git a/docs/pages/credential-proxying/static-secrets.mdx b/docs/pages/credential-proxying/static-secrets.mdx index 8378633..0932740 100644 --- a/docs/pages/credential-proxying/static-secrets.mdx +++ b/docs/pages/credential-proxying/static-secrets.mdx @@ -330,5 +330,5 @@ When the same secret is served by many proxies, give each proxy host its own rol - [OAuth2 Token Injection](/credential-proxying/oauth-token): mint bearers from refresh tokens, client credentials, or JWT assertions. - [HMAC Request Signing](/credential-proxying/hmac-sign): sign requests with HMAC instead of attaching a static credential. -- [Secret Policies API](/control-plane/api/secret-policies): manage `secrets` entries through the control plane instead of YAML. +- [Control plane secrets](/control-plane/api/secrets#static-secrets): manage static secrets centrally through iron-control instead of YAML. - [Configuration reference](/reference/configuration#secrets): the canonical schema for the `secrets` transform. diff --git a/docs/pages/index.mdx b/docs/pages/index.mdx index 547d18d..226d07c 100644 --- a/docs/pages/index.mdx +++ b/docs/pages/index.mdx @@ -11,6 +11,8 @@ The allowlist is keyed on host. Domain globs (`*.anthropic.com`) and CIDRs both Credentials stay in the proxy process. Static API keys swap in for placeholders the workload sends. OAuth bearer tokens, AWS SigV4 and HMAC signatures, and GCP service-account tokens are minted per request from longer-lived material the workload never sees. See [credential proxying](/credential-proxying/overview) for the full set. +Credentials are not only for HTTP. iron-proxy can also sit in front of [PostgreSQL](/postgres): it holds the database connection string and pins every client session to a role the workload cannot escape, so untrusted code runs under row-level security without ever seeing the real DSN. + Every request, allowed or blocked, becomes a JSON log line: host, method, path, status, duration, the policy that decided, the secrets injected. Pipe it to your SIEM, your warehouse, or `jq`. One YAML file configures all of it. The same binary runs as a sidecar, a systemd unit, a daemon, or a CI step. @@ -41,4 +43,4 @@ If you want to see what a real config looks like before you commit, browse the [ ## Open Source, With A Managed Option -iron-proxy is [open source](https://github.com/ironsh/iron-proxy) and runs anywhere you can run a Linux process. A managed [control plane](/control-plane/overview) for policy, secrets, and audit aggregation across a fleet is in active development. +iron-proxy is [open source](https://github.com/ironsh/iron-proxy) and runs anywhere you can run a Linux process. To manage a fleet, [iron-control](/control-plane/overview) is the control plane: it stores your credentials, decides which proxy may use which credential on which requests, and delivers each proxy its config with no per-host YAML to maintain. Available hosted or self-hosted. diff --git a/docs/pages/postgres.mdx b/docs/pages/postgres.mdx new file mode 100644 index 0000000..caae5c6 --- /dev/null +++ b/docs/pages/postgres.mdx @@ -0,0 +1,103 @@ +--- +title: "PostgreSQL Proxy" +description: "iron-proxy can sit in front of PostgreSQL, hold the database credential, and pin every client session to a role so untrusted code runs under row-level security it cannot escape." +--- + +# PostgreSQL Proxy + +iron-proxy includes a PostgreSQL listener: a man-in-the-middle proxy for the Postgres wire protocol. It does for databases what [credential proxying](/credential-proxying/overview) does for HTTP. The client connects to the proxy with a credential the proxy manages, the proxy holds the real database connection string, and every session is pinned to a Postgres role the client cannot change. + +This solves identity-aware database access without touching application code. Point an untrusted workload at the proxy, and every query it runs executes under a fixed role, so [row-level security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) (RLS) policies on that role apply to everything the workload does. + +## How It Works + +The listener accepts client connections, authenticates them against a proxy-managed credential, then opens its own authenticated connection to the upstream database. The proxy terminates the upstream's authentication itself, handling MD5 and SCRAM-SHA-256. On that upstream session it issues `SET ROLE ""` before handing the connection back. From that point the relay is essentially transparent: the client uses Postgres normally, and every query runs as the configured role. + +``` +client ──▶ iron-proxy ──▶ SET ROLE "" ──▶ upstream PostgreSQL + │ │ + │ └─ holds the real DSN; client never sees it + └─ authenticates with the proxy-managed client credential +``` + +The client never holds the real connection string, and it cannot change the role it was assigned. If the workload is compromised, there is no database credential to steal and no way to break out of the role. + +## Configuration + +The proxy runs a single PostgreSQL listener, configured under the top-level `postgres` key. One bind address serves many upstream databases. When the `postgres` block is omitted, no listener runs. + +```yaml +postgres: + listen: ":5432" + # The single credential clients present to the proxy, shared across every + # upstream below. Routing is by database name, so per-database client + # credentials would add nothing. + client: + user: app_user + password_env: PG_PROXY_PASSWORD + upstreams: + # Clients connect with dbname=appdb to reach this upstream. + - database: appdb + # The DSN is loaded from any registered secret source and passed + # verbatim to the Postgres driver. URL or keyword/value form both work. + dsn: + type: env + var: PG_APPDB_DSN + # The role the proxy SETs at session start. Every query the client + # issues runs as this role on the upstream database. + role: tenant_role + # Clients connect with dbname=analyticsdb to reach this upstream. + - database: analyticsdb + dsn: + type: env + var: PG_ANALYTICS_DSN + role: analytics_role +``` + +| Field | Notes | +|---|---| +| `listen` | TCP address and port the listener binds. | +| `client.user` | The username every client must present. | +| `client.password_env` | Environment variable holding the password every client must present. | +| `upstreams[].database` | Routing key. The client selects this upstream by sending this name as its database. | +| `upstreams[].dsn` | A [secret source](/credential-proxying/overview#secret-sources) resolving to the upstream connection string. | +| `upstreams[].role` | Optional. The role the proxy `SET`s on the upstream session. Omit for no role pinning. | + +The `dsn` is a standard iron-proxy secret source, so the connection string can come from an environment variable, AWS Secrets Manager, AWS SSM, or 1Password. See [Secret Sources](/credential-proxying/overview#secret-sources) for the full set. + +## Database Routing + +A single listener fronts many databases. The client picks one by the database name it sends in its startup message (`psql`'s `dbname=`). The proxy looks up the matching upstream, then authenticates against that upstream's own credentials before dialing it. + +The client must name a database explicitly. A database with no matching upstream is rejected. Each upstream's `database` must equal the database its DSN actually connects to (the `dbname` in the DSN), otherwise the client would land on a different database than the one it named, and the proxy rejects the connection. + +## What The Proxy Rejects + +To keep a client from escaping its assigned role, the proxy rejects any statement that would mutate the role. Rejections come back as a synthetic `ErrorResponse`, so the connection stays usable. Every check parses the SQL with `libpg_query` and walks the syntax tree, so it catches a role-mutating construct anywhere in the query: inside CTEs, subqueries, function arguments, and so on. + +Rejected: + +- `SET ROLE`, `RESET ROLE`, `SET SESSION AUTHORIZATION`, `RESET SESSION AUTHORIZATION`. Direct mutation of the session role. +- `SELECT set_config('role', ...)`, `SELECT pg_catalog.set_config(...)`, and the same for `session_authorization`. Function-call bypasses. +- `DO $$ ... $$` blocks. The PL/pgSQL body is opaque to the SQL parser, so the proxy rejects it rather than risk an embedded role change. +- Multi-statement Simple Queries. Kept as a single-statement guard. + +Some elevation paths cannot be caught at the wire. Defend against these at the database with your role and grant configuration: + +- User-defined `SECURITY DEFINER` functions that elevate internally. +- Dynamic SQL via PL/pgSQL `EXECUTE` with a non-literal argument. +- Functions that wrap `set_config` under a different name. + +## Connection Pooler Requirement + +If PgBouncer or any other pooler sits between iron-proxy and PostgreSQL, it **must** be configured with `pool_mode = session`. Transaction-mode and statement-mode pooling silently rebind backends between queries, which makes the role the proxy set on one backend invisible to the next query running on another backend. That defeats RLS without surfacing any error. This constraint is enforced by your deployment configuration: the proxy does not probe for it at runtime. + +## Limitations + +- Client-to-proxy TLS is not supported. The proxy refuses `SSLRequest`. Secure the client-to-proxy hop with network controls (a private network or localhost). +- `CancelRequest` is not forwarded. +- One shared client credential per listener. There are no per-user client credentials. Identity is expressed by the upstream role, not the client login. + +## With The Control Plane + +Everything above configures a standalone proxy through its YAML file. To manage Postgres upstreams centrally across a fleet, define them in [iron-control](/control-plane/overview) as **PG DSN secrets** and grant them to a principal. The control plane delivers them to each proxy, keyed by name, where the local listener binds to them. See [PG DSN secrets](/control-plane/api/secrets#pg-dsn-secrets) in the control plane API. diff --git a/docs/public/images/control-plane-add-proxy.png b/docs/public/images/control-plane-add-proxy.png deleted file mode 100644 index 20e9877..0000000 Binary files a/docs/public/images/control-plane-add-proxy.png and /dev/null differ diff --git a/docs/public/images/control-plane-bootstrap-token.png b/docs/public/images/control-plane-bootstrap-token.png deleted file mode 100644 index 2b747e1..0000000 Binary files a/docs/public/images/control-plane-bootstrap-token.png and /dev/null differ diff --git a/docs/public/images/control-plane-egress-rules.png b/docs/public/images/control-plane-egress-rules.png deleted file mode 100644 index b53ecaf..0000000 Binary files a/docs/public/images/control-plane-egress-rules.png and /dev/null differ diff --git a/docs/public/images/control-plane-new-policy.png b/docs/public/images/control-plane-new-policy.png deleted file mode 100644 index 6f28854..0000000 Binary files a/docs/public/images/control-plane-new-policy.png and /dev/null differ diff --git a/docs/public/images/control-plane-proxies-list.png b/docs/public/images/control-plane-proxies-list.png deleted file mode 100644 index 219c6cc..0000000 Binary files a/docs/public/images/control-plane-proxies-list.png and /dev/null differ diff --git a/docs/public/images/control-plane-secret-step1-tags.png b/docs/public/images/control-plane-secret-step1-tags.png deleted file mode 100644 index a508046..0000000 Binary files a/docs/public/images/control-plane-secret-step1-tags.png and /dev/null differ diff --git a/docs/public/images/control-plane-secret-step2-rules.png b/docs/public/images/control-plane-secret-step2-rules.png deleted file mode 100644 index bdc0d1d..0000000 Binary files a/docs/public/images/control-plane-secret-step2-rules.png and /dev/null differ diff --git a/docs/public/images/control-plane-secret-step3-injection.png b/docs/public/images/control-plane-secret-step3-injection.png deleted file mode 100644 index c559e5c..0000000 Binary files a/docs/public/images/control-plane-secret-step3-injection.png and /dev/null differ diff --git a/docs/public/images/control-plane-secret-step4-source.png b/docs/public/images/control-plane-secret-step4-source.png deleted file mode 100644 index 37f91f3..0000000 Binary files a/docs/public/images/control-plane-secret-step4-source.png and /dev/null differ diff --git a/vocs.config.tsx b/vocs.config.tsx index f1c8474..98ad4a6 100644 --- a/vocs.config.tsx +++ b/vocs.config.tsx @@ -176,6 +176,7 @@ export default defineConfig({ { text: "LLM Judge", link: "/policies/llm-judge" }, { text: "Header Allowlist", link: "/policies/header-allowlist" }, { text: "MCP Interception", link: "/policies/mcp-interception" }, + { text: "PostgreSQL Proxy", link: "/postgres" }, ], }, { @@ -200,8 +201,8 @@ export default defineConfig({ collapsed: false, items: [ { text: "Overview", link: "/control-plane/overview" }, - { text: "Enrollment", link: "/control-plane/enrollment" }, - { text: "Policies", link: "/control-plane/policies" }, + { text: "Connecting Proxies", link: "/control-plane/enrollment" }, + { text: "Access Model", link: "/control-plane/policies" }, { text: "Self-Hosted", link: "/control-plane/self-hosted" }, ], }, @@ -226,9 +227,9 @@ export default defineConfig({ collapsed: true, items: [ { text: "Overview", link: "/control-plane/api/overview" }, - { text: "Network Policies", link: "/control-plane/api/network-policies" }, - { text: "Secret Policies", link: "/control-plane/api/secret-policies" }, - { text: "MCP Policies", link: "/control-plane/api/mcp-policies" }, + { text: "Secrets", link: "/control-plane/api/secrets" }, + { text: "Principals, Roles & Grants", link: "/control-plane/api/access" }, + { text: "Proxies & Sync", link: "/control-plane/api/proxies" }, ], }, ],