Skip to content

Latest commit

 

History

127 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

keeplane

Self-hosted credential custody and egress proxy. Hold third-party API credentials on behalf of your users, refresh and revoke them correctly, and make authenticated calls with them — without your application code ever touching a plaintext credential, and without a database dump yielding one.

One binary. Run it whole on a laptop with no configuration, or split it across machines when you need to. The code is identical either way.

make build
export KEEPLANE_UNSEAL_PASSPHRASE="something only you know"
./bin/keeplane doctor        # creates its key and database, then checks itself
./bin/keeplane serve         # every role, one process, no config file

Contents


What it is

An operator — you — runs keeplane. Your end users connect their GitHub, Slack or Stripe accounts to your product. keeplane holds those credentials, keeps them fresh, and lets your application make calls with them through a proxy that injects the credential on the way out.

Six things are in scope, and the rest is deliberately not:

  1. Run the OAuth or API-key authorization flow on a page you brand
  2. Store credentials under your root of trust, not a vendor's
  3. Refresh, rotate and revoke them correctly
  4. Proxy authenticated requests to providers, injecting credentials
  5. Enforce policy on every call — who, which scopes, which routes
  6. Record a tamper-evident audit of every credential use

Explicitly not: a unified data model across providers, a workflow engine, an identity provider, an observability backend, or a general secrets manager.

The property everything else serves

Only one component ever holds key material, and the compiler enforces it. The code that can decrypt lives beneath internal/credcore/internal/, where Go's internal rule refuses every importer outside credential-core. A proxy, a worker or an admin UI that tried to open the vault directly would not fail review — it would fail to build, and a committed test asserts exactly that.

So a database dump yields ciphertext and nothing else. The root key is a separate file the database holds no reference to.


The roles

One binary, selected with --role. In a single process they call each other through Go interfaces; split up, the same calls travel over a unix socket or mTLS, and no role's code knows which.

Role Owns Never does Docs
credential-core The root key, envelope encryption, rotation, revocation, cryptographic shredding, the audit log Expose a bulk read path. Return a secret from Resolve. Serve a public surface cred/README.md
catalog What a provider is — its hosts, routes, grant type — and what each tenant has registered against it. Compiles the snapshot the request path reads Accept a template, a parameterised host, or any code-shaped value. A profile is data, never code catalog/README.md
connect The OAuth and API-key flows an end user's browser sees; verify-before-active; re-auth links Store a credential itself. Accept a per-request redirect_uri connect/README.md
worker Leader-elected sweeps: token refresh, health checks, the audit sealer, retention Run on every replica. Retry an ambiguous token exchange. Serve anything at all — it binds no port docs/WORKER-DEPLOY.md
proxy The request path: route resolution, the egress guard, credential injection, streaming Read the catalog or a database per request. Follow a redirect. Take a host from a caller proxy/README.md
api The operator REST surface and admin UI; config apply; audit search; caller and certificate management. Hosts the catalog Return decrypted material — admin does not carry Unwrap docs/API-DEPLOY.md

api holds admin, which excludes Unwrap. A total compromise of the admin surface can destroy credentials, not read them. Destruction is recoverable through re-authorisation; disclosure is not.


How the pieces connect

  ACTORS      end user's           your                an operator            elapsed
              browser              application         (CLI / admin UI)       time
                 │                    │                     │                   │
                 ▼                    ▼                     ▼                   ▼
            ┌─────────┐          ┌─────────┐           ┌─────────┐        ┌─────────┐
  ROLES     │ connect │          │  proxy  │           │   api   │        │ worker  │
            └──┬───┬──┘          └──┬───┬──┘           └──┬───┬──┘        └──┬───┬──┘
               │   │                │   │                 │   │              │   │
               │   │ catalog.Reader │   │  snapshot poll  │   │catalog.Writer│   │ Reader
               │   └────────────────┼───┴────────┬────────┼───┴──────────────┼───┘
               │                    │            ▼        │                  │
               │                    │      ┌───────────┐  │                  │
               │                    │      │  catalog  │◀─┘                  │
               │                    │      └─────┬─────┘                     │
               │                    │            │                           │
        cred:lifecycle       cred:data-plane     │            cred:admin      │ cred:lifecycle
               │                    │            │                 │         │
               ▼                    ▼            ▼                 ▼         ▼
            ┌───────────────────────────────────────────────────────────────────┐
            │                        credential-core                             │
            │   Resolve · Unwrap · SecureSave · Renew · Rotate · Revoke · Shred  │
            │        the only holder of the unwrap identity                      │
            └───────────────────────────────┬───────────────────────────────────┘
                                            │  internal/ — compiler-enforced
                                            ▼
                                 root key file (or KMS)  +  store

  OUTBOUND    connect ──► a provider's token endpoint
              worker  ──► a provider's token endpoint
              proxy   ──► a provider's API
              all three leave the host only through the egress guard, and only
              to a host literally named in an approved profile

Four properties are visible here, and each is a decision:

  • Only credential-core touches key material. Every other arrow into it is an interface call.
  • The proxy never reads the catalog on the request path. It polls a digest and swaps an in-memory snapshot, so a control-plane outage cannot fail a request in flight.
  • No role reads another role's tables. Cross-role reads go through the contracts, which is what lets identical code run in every topology.
  • The database is one file or one cluster, not a shared bus.

The same picture, three ways

The arrows never change; only what they are made of changes.

Topology What an arrow is When
Single binary a Go interface call on a pointer the default; a laptop, a small deployment
Separate processes, one host HTTP over a unix socket, caller named by the kernel (SO_PEERCRED) you want OS-level isolation between roles
Separate nodes HTTP/2 over TLS 1.3 with RequireAndVerifyClientCert roles on different machines

Chosen per dependency in keeplane.toml — see Single binary or many.

The composition root picks per dependency. No service's own code knows which, which is why one test body can assert identical behaviour across all three.


What is built today

Role Stages Status
credential-core V1–V14 built — both drivers, four topologies
catalog C1–C8 built — both drivers, three topologies
connect N1–N8 built — both drivers, three topologies
worker W1–W8 built — both drivers, three topologies
proxy P1–P8 built — both drivers, three topologies
api A1–A8 built — both drivers, three topologies

Smoke suites accumulate; each service's last stage adds one, and none regresses.

keeplane smoke --suite s1   # credential-core: save → reveal → rotate → revoke → shred
keeplane smoke --suite s2   # catalog: publish → register → snapshot → poll
keeplane smoke --suite s3   # connect: start → callback → verify → store → reauth
keeplane smoke --suite s4   # worker: connect → discover → refresh → seal → verify the chain
keeplane smoke --suite s5   # proxy: inject → stream → refuse → swap under load
keeplane smoke --suite s6   # api: plan → refuse → apply → list → audit → verify

Suites are numbered by service rather than by build order, so one never has to be renumbered when the order changes — as it already did once, when the proxy took s5 before the worker's s4 existed.

577 test functions, passing on SQLite and Postgres, clean under -race.


Try it in five minutes

No configuration, no third-party account, no browser.

make build
export KEEPLANE_UNSEAL_PASSPHRASE="something only you know"

# 1. Start every role, plus a fake provider on 127.0.0.1:9099.
#    The sandbox is opt-in and never part of "all".
./bin/keeplane serve --role=all,sandbox &

# 2. What providers does this build know?
./bin/keeplane catalog list

# 3. Register an integration. The client secret goes under custody first;
#    the catalog only ever stores a reference to it.
./bin/keeplane cred save --tenant default --ref sandbox-secret --value 'sandbox-client-secret'
./bin/keeplane catalog integrations add --tenant default --ref sandbox \
    --profile sandbox --client-id demo --secret-ref default/sandbox-secret

# 4. Run a real OAuth flow. The sandbox redirects straight back, so curl
#    completes what a browser would.
URL=$(./bin/keeplane connect start --ref demo --integration sandbox | grep -o 'http[^ ]*')
curl -sL "$URL" | grep -o '<h1>.*</h1>'          # <h1>Connected</h1>

# 5. The credential is under custody — access and refresh, separately.
./bin/keeplane connect status --ref demo
./bin/keeplane cred list

# 6. Now USE it, without ever holding it. Issue a caller for your application,
#    then make the call through the proxy. A running proxy picks up a new
#    caller within one refresh interval, so give it a second.
TOKEN=$(./bin/keeplane proxy caller issue --name demo-app --refs demo | grep -o 'kpc_[A-Za-z0-9_-]*')
sleep 3
curl -s -H "Authorization: Bearer $TOKEN" -H "X-Keeplane-Ref: demo" \
     http://127.0.0.1:8081/p/sandbox/api/whoami
{"expires_at":"2026-08-31T05:49:28.207162Z","subject":"sandbox-user"}

That call reached the provider with a real credential attached. $TOKEN is not that credential — it identifies the application, reaches only the connections it was issued for, and is worth nothing at the provider.

The same request can name an operation from the connector instead of a path, which is the shape a workflow engine usually wants:

curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "X-Keeplane-Ref: demo" \
     -H 'Content-Type: application/json' -d '{}' \
     http://127.0.0.1:8081/execute/sandbox/operations/whoami

POST /execute/{app}/operations/{operation} with path_params, query_params and data. It reaches nothing /p/ cannot — it builds a path from the same route table and hands it to the same matcher — and the provider's method comes from the route, never from the caller. On by default; [proxy.execute] enabled = false closes it.

Or all of it in one command: ./bin/keeplane smoke --suite s3, then --suite s5.

Full walkthroughs: cred/README.md for custody, catalog/README.md for providers and integrations, connect/README.md for the flows themselves, and proxy/README.md for using a credential without holding it.


Single binary or many, by config

The topology is one setting per dependency. No code changes, no build flags, no separate binaries — every service consumes interfaces, so none of them can tell whether it got a pointer or a client, and the composition root reads the answer from keeplane.toml.

One box — the default

Set nothing. Every role runs in one process and talks to the others through method calls.

keeplane serve --role=all

Split — add a dial target

# On the credential-core node: serve a listener and declare who may call it.
[vault.listen]
transport = "unix"                       # or "tcp" with TLS, for another host
socket    = "/run/keeplane/credcore.sock"

[[vault.peers]]
identity = "uid:1002"                    # or a certificate CN over TCP
role     = "data-plane"                  # Resolve and Unwrap; nothing else
tenants  = ["*"]
# On the proxy node: reach it instead of building one.
[vault.client]
addr = "/run/keeplane/credcore.sock"     # or "https://credcore.internal:8443"
# cert/key/ca as well, for an https target — a network peer is identified by a
# certificate, and there is no equivalent of "same uid" across a host boundary.

[proxy.listen]
addr = "0.0.0.0:8081"
keeplane serve --role=proxy              # no passphrase, no root key

Issue at least one caller before starting a proxy on a non-loopback address — it refuses to bind one with none, since an open proxy that trusts nobody is a proxy that will shortly trust the first thing that asks.

INFO credential-core is remote  addr=/run/keeplane/credcore.sock role_wanted=data-plane
INFO proxy listening            at=0.0.0.0:8081

That node has no root key and never unseals one. It is the difference between a split that is real and one that is only drawn in a diagram, and it is asserted by TestRemoteVaultNeedsNoRootKey.

The three dial targets

Setting What this process reaches remotely
[vault.client] addr credential-core — credentials, the audit trail, the seal chain
[catalog.client] addr the catalog — profiles, integrations, the compiled snapshot
[connect.client] addr connect — connection listing, re-auth marking, state expiry

Mix freely. A worker node might dial credential-core and connect while hosting nothing; an api node hosts the catalog and dials the rest.

Three things to know before splitting

The database is shared, and stays local. Splitting means splitting the root key and the process boundary, not the store — every role owns tables in it. Each node opens the same database directly.

A process presents ONE identity, so it gets ONE role. With a local credential-core, the proxy holds data-plane and api holds admin inside the same binary. Reaching a remote one, the role is whatever that node's peer entry grants — so co-locating the proxy and api on a split node means one credential covering both. Give each split role its own node, or accept the union knowingly. Over a unix socket the identity is the uid, so processes sharing a user share a role.

api hosts the catalog. An apply and its snapshot rebuild commit together; splitting them would make that atomicity a distributed problem for no benefit.

Caller management needs api and the proxy together. The proxy's caller routes are declared but not served, so [proxy.client] does not exist yet — api issues and revokes callers in-process.

Full worked configurations: docs/DEPLOYMENT.md.


Two audiences, two surfaces

keeplane is built for one operator serving many tenants — a SaaS vendor whose customers each connect their own accounts at Asana, Airtable and the rest.

Operator Tenant
onboard an app into the catalogue ✅ CLI and API
approve a new egress host
see the apps on offer ✅ any tenant's view ✅ their own
connect an app, re-authorise, revoke ✅ their own only, over /v1/tenant/*
use the admin console /admin/ ❌ — the SaaS builds its own
execute a request ✅ their own only

Publishing is the offer. An app the operator publishes is in every tenant's marketplace immediately — there is no per-tenant entitlement step, and no operator action when a new customer connects their tenth app:

# the operator, once
keeplane catalog publish asana.json --approve --by ops
keeplane proxy caller issue --tenant '*' --name crm --refs asana
# any tenant thereafter, over HTTP, with no operator involved
curl -X POST :8079/v1/tenant/apps    -H "Authorization: Bearer $CRM" -H "X-Keeplane-Tenant: hdfc" -d '{}'
curl -X POST :8079/v1/tenant/connect -H "Authorization: Bearer $CRM" -H "X-Keeplane-Tenant: hdfc" \
     -d '{"app":"asana"}'      # → a URL to open in the tenant's browser

A tenant that has not connected an app still sees it, and is refused at the request path by the absence of a credential rather than by a permission table. What that looks like end to end, with the rows written at every step: docs/REQUEST-LIFECYCLE.md.

The operator console

keeplane serve --role=api also serves a server-rendered admin console — the same process, the same port, the same identities and capability checks as the API, and no credential of its own.

/admin/ overview
/admin/connections connections, with revoke
/admin/integrations what each tenant may reach
/admin/audit the audit trail, keyset-paged
/admin/callers caller tokens
/admin/config compose, review and apply configuration — needs plan
/admin/operators operators
/admin/certificates issued certificates

It loads nothing — no font, no icon, no analytics, script-src 'none' — because the list of providers a company uses is commercially sensitive before it is technically sensitive, and because the console has to work during the incident that made you open it. Viewing a list and changing it are separate capabilities: a viewer sees the operators, and the add and disable forms are not rendered — a forged POST is 403.

To reach it from a browser you need TLS and a client certificate. Navigation cannot set an Authorization header, so OIDC, the static token and the bootstrap grant are reachable by curl and by nothing you can type in a URL bar:

keeplane pki issue --identity api   --kind server --host 127.0.0.1,localhost
keeplane pki issue --identity alice --kind client --use console   # prints the rest
[api.listen]
addr      = "127.0.0.1:8079"
tls_cert  = "./pki/api.crt"
tls_key   = "./pki/api.key"
client_ca = "./pki/ca.crt"     # without tls_cert this is refused at start-up

Then https://127.0.0.1:8079/admin/. An empty directory to a console in a browser, start to finish: docs/ADMIN-CONSOLE-QUICKSTART.md.


Deploying it

Never deployed this before? Start with one of the two step-by-step guides. They assume no prior knowledge, and each is self-contained — you need no other document to get from an empty machine to a real proxied request against Asana and Airtable:

For
docs/INTEGRATION-SETUP-SINGLE-POSTGRES.md one box, Postgres, both connectors, both connection kinds, reads and writes. ~45 minutes
docs/INTEGRATION-SETUP-MULTI-POSTGRES.md four nodes: Postgres, a keyed vault node, an api node, and a proxy node holding no key. mTLS throughout. ~90 minutes
docs/ADMIN-CONSOLE-QUICKSTART.md just the console: an empty directory to the admin UI in a browser, with the certificate steps. ~10 minutes

Then docs/DEPLOYMENT.md — the whole system on one box and across several, with the peer declarations and certificates each needs. Then the per-role documents below.

For credential-core specifically, docs/VAULT-DEPLOY.md — topologies, dedicated users and file modes, a systemd unit, certificates and their rotation, connection limits, backups, and an incident runbook.

Two things worth knowing before anything else:

  • A failed unseal is never fixed by deleting the key file. It means the wrong passphrase or a corrupted file. Deleting it makes doctor go green instantly and permanently orphans every credential.
  • Back the root key up separately from the database. Storing them together recreates exactly the situation this design exists to prevent.

Documentation

Per role — how to run it, use it, and what it guarantees:

cred/README.md credential-core: getting started, configuration, using it from an application, the CLI, across hosts, the audit trail, unseal recovery
catalog/README.md catalog: providers, integrations, writing your own profile, the egress approval gate, the sandbox
connect/README.md connect: the OAuth flow, api-key intake, connection statuses, re-authorisation, the two listeners

credential-core, in depth:

docs/VAULT-DEPLOY.md production deployment and the incident runbook

catalog, in depth:

docs/CATALOG-DEPLOY.md the listener and its two roles, getting profiles into production, rolling a definition forward, the tamper halt

connect, in depth:

docs/CONNECT-DEPLOY.md running it: public_url and provider registration, TLS termination, the two listeners, scaling out, what to watch

worker, in depth:

docs/WORKER-DEPLOY.md running it: no listener, leases and failover, what to watch, dead letters, the unwrap ceiling, the incident runbook

api, in depth:

docs/API-DEPLOY.md running the operator surface: the bootstrap token, operators and RBAC, the identity mechanisms, the admin console and how to reach it from a browser, what to watch

proxy, in depth:

docs/PROXY-DEPLOY.md running it: issuing callers, what the errors mean, what to watch, sizing the unwrap ceiling, scaling out

Cross-cutting:

docs/INTEGRATION-SETUP-SINGLE-POSTGRES.md from an empty machine to a real Asana and Airtable request on one box with Postgres — assumes no prior knowledge
docs/INTEGRATION-SETUP-MULTI-POSTGRES.md the same, split across four nodes with mTLS, where the proxy node holds no key material
docs/ADMIN-CONSOLE-QUICKSTART.md an empty directory to the admin console in a browser: the CA, the client certificate, the keychain steps, and what each page should show
docs/API.md the API reference: every HTTP surface, who may call it, request and response bodies, and error codes
docs/REQUEST-LIFECYCLE.md one real Airtable request from an empty database to the provider and back, with the rows written at every step and what a second, unconnected tenant gets
docs/DEPLOYMENT.md the whole system: one box production-shaped, what each role needs, and exactly which topologies the binary reaches today
docs/END-TO-END-WALKTHROUGH.md the whole system from an empty directory, as an iPaaS would use it: connector documents, credentials, both connection kinds, the proxied request, and what every step persists
docs/PACKAGE-LAYOUT.md where code goes: the three tiers, naming rules, what is inside the custody boundary, migration ranges
examples/README.md the Python and Java clients, and what a client should and should not do
api/openapi.yaml the wire contract, generated from the server's own types

Development

make test            # unit and integration tests
make race            # the same, under the race detector
make test-postgres   # the same assertions against Postgres
make verify          # keeplane-testclient against keeplane.toml
make openapi-check   # the published contract still matches the server's types

Every change runs on both drivers. Anything concurrent runs under -race with -count above 1 — a single pass proves nothing about a race.

About

Open-source, self-hosted integration control plane. Manages the full lifecycle of your customers' third-party credentials — OAuth, refresh, proxy, policy, and audit — so you don't build it or rent it.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages