Guard gateway
The gateway is Guard's third level. Production keys live on the Porticol server and
nowhere else. An agent calls https://<gateway>/<workspace>/<host>/<path> with a
short-lived workspace token. The server decides the call by the workspace's team policy,
holds an ask for a person, injects the key, forwards the call and records it in the team
log with path gateway.
The gateway comes with the Business plan (guard_gateway). A Team organisation is
refused, and the answer names the upgrade.
Turn it on
Add these to the server's settings file, or set the matching environment variables:
gateway_enabled = true
gateway_address = "0.0.0.0:8081"
gateway_url = "https://gateway.example.com"
PORTICOL_GATEWAY_ENABLED=true
PORTICOL_GATEWAY_ADDRESS=0.0.0.0:8081
PORTICOL_GATEWAY_URL=https://gateway.example.com
Restart the server; the log says the Guard gateway listens on <address>. The gateway
listens on its own port and speaks plain HTTP, as the API does: TLS ends in front of it,
at Caddy or a load balancer. It is off by default. gateway_url is the address clients
reach it at; the hosts route answers it, so a desktop sends a production call there
instead of holding the key itself. Unset, it names none and laptops keep their own keys.
Add a host and its key
An organisation administrator sets the hosts the gateway reaches and the key each one gets:
PUT /api/v1/workspaces/{workspace}/gateway/hosts
{
"hosts": [
{
"host": "api.stripe.com",
"environment": "production",
"upstream": "https://api.stripe.com",
"header": "Authorization",
"format": "Bearer {key}",
"key": "sk_live_..."
}
]
}
GET on the same path lists the hosts and says "keySet": true; it never returns a key.
Each key is stored sealed under the installation key. An entry without a key keeps the
one already stored, so a list read back and sent again does not erase a secret it was
never shown. A host whose address, header or format changes carries its key again.
Removing a host from the list removes its key. Only an organisation administrator reads
or sets these routes; a role granted in one workspace is not enough.
Get a token and call it
POST /api/v1/workspaces/{workspace}/gateway/token
answers {"token": "...", "expiresAt": "..."}. The token lasts an hour and holds the
gateway scope alone. Send it on every call:
curl -X POST https://gateway.example.com/<workspace>/api.stripe.com/v1/refunds \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"amount": 100}'
Every call checks the token still stands: its issuer is still a member who may run a collection in the workspace, and the organisation's single sign-on rule is met. A rule's per-minute limit counts the calls already in the team log for that rule, per agent when the rule says so.
If the policy asks, the call waits up to a minute and then answers 403 with
{"status":"pending","approval_id":"..."}. Send the same request again with
X-Porticol-Approval: <id> once a person has allowed it. An approval lets one call
through. The answer masks the key if the upstream echoes it back. An approver signed in
to the portal finds the same call under Approvals, at /guard/approvals.
Allow only the gateway's address
The gateway has its own listener, so it can be firewalled apart from the API:
- On a DigitalOcean droplet, add a Cloud Firewall rule for the gateway's port from the addresses that should call it. Leave port 443 for the API and the portal.
- On your own server, the same rule belongs in the host's firewall or in front of the container.
- If a reverse proxy fronts both, give the gateway its own hostname and answer only the addresses allowed to use it. The API's network allowlist governs the API's port, not the gateway's.
A request without a usable workspace token is refused before anything is read and leaves
no entry. Every call that names a workspace with such a token is in the team log,
including the ones refused for an unknown host, the plan or the token, each under
gateway:unknown-host, gateway:plan or gateway:token. Self-hosted servers run the
same mode.