External Access
A connection says this app may use this service. External access says the other half: this system outside Ghayma may use this service — a BI tool, a partner’s importer, a CI job, your own laptop.
It is the same idea as a connection, minus the app. A named principal has a name, a level, and its own engine credential: its own PostgreSQL role, its own MongoDB user, its own S3 access key. It can carry a source-IP allowlist and an expiry, and it is rotated or revoked alone — nothing else that reaches the database notices.
Only databases and buckets have external principals. An auth app’s external access is a restricted project key — one credential, one revoke button — so there is no second credential type for it. The CLI says so rather than pretending:
❌ An auth app has no external principals — its external access IS its restricted project keys.
Create or revoke one in the console: Project → Settings → API keys (https://dash.ghayma.cloud).A Valkey database has no external principals yet. Adding one is refused with
400 and code: public_access_unavailable; reach it from your machine through
the tunnel instead (see Valkey).
Why not just hand out the database password
A database’s own login and a bucket’s own key are never handed out — not to an app, not to a person, not to a system outside Ghayma. One identity shared by every consumer would mean:
- Revoking one consumer means re-passwording all of them. They would all authenticate as the same user.
- A read-only partner is not expressible. The database’s own login owns everything.
- No source rule is possible. Nothing in the path could tell which consumer a connection belongs to.
A named principal is the only way in from outside.
Add a principal
From the CLI
ghayma access needs CLI v0.11.4 or newer. The full command reference is on
the Connections CLI page.
ghayma access add database pg-main --name metabase --level read-only --allow 10.0.0.0/8 --expires 90✅ Added 'metabase' to database 'pg-main' (read-only)
Allowed sources: 10.0.0.0/8
Expires: 2026-12-18
🔑 Credential for 'metabase' on database 'pg-main'
URI: postgresql://c_1a2b3c4d:****@pg-main-a1b2c3d4.db.ghayma.tech:5432/db_main?sslmode=require
Username: c_1a2b3c4d
Password: ****
⚠️ this credential is shown once and cannot be retrieved again — store it now.
Lost it? Mint a new one with: ghayma access rotate database pg-main metabaseFrom the console
Open the service — a database’s Settings tab, or a bucket’s page — and find
the Access card. It lists everything that may reach the service in one
table: the project’s apps (app rows) and the named principals (external
rows).
Everything that may reach this database: the apps of this project, and — where the platform supports it — named systems outside Ghayma with their own credential.
Add external access opens a small form: a name, a level, an optional list of allowed sources, and an expiry chosen from No expiry, 30 days, 90 days or 1 year. The credential appears in a panel above the table, with copy buttons and one line you should believe:
This credential is shown once and cannot be retrieved again.
Closing that panel is final — Closing this panel is final. A new secret needs a rotation.
The secret exists in exactly one response. Nothing reads it back: the listing has no field for it, the API has no route for it, and the console has no “show again”. If you lose it, rotate — that mints a new secret on the same identity, so only one string has to change wherever the consumer holds it.
What the principal is handed
PostgreSQL — a hostname of its own
postgresql://c_1a2b3c4d:****@pg-main-a1b2c3d4.db.ghayma.tech:5432/db_main?sslmode=requireThe host is this database’s own — <resource>.db.ghayma.tech, where
<resource> is the name the platform minted for it: pg-<name>-<id8>, the same
prefix that opens the internal hostname
ghayma db credentials prints.
- TLS terminates at the front door, which presents the publicly trusted
*.db.ghayma.techwildcard and routes by SNI. The URL already carries?sslmode=require; unencrypted connections are refused. - Every current client works. The route advertises the
postgresqlALPN protocol that libpq 17 and newer offer on every TLS handshake, so psql 16, 17 and 18 all connect. (Without it, libpq 17+ getsno_application_protocolwhile older clients are fine — that asymmetry was found and fixed on 2026-09-19.) - To verify the server’s identity too, swap the query for
sslmode=verify-full&sslrootcert=system— libpq 16 and newer read the OS trust store; older ones need a root certificate file instead (sslrootcert=/path/to/ISRG-Root-X1.pem).
The hostname exists only while a principal does. A PostgreSQL database’s per-database route is created when it gets its first active principal and removed when the last one goes. Revoking really does close the front door, not just change a password — which also means the host stops resolving to anything that answers.
MongoDB — the shape it always had
mongodb://c_1a2b3c4d:****@mg-main-a1b2c3d4.db.ghayma.tech:27017/db_main?authSource=admin&tls=true&replicaSet=rs0&directConnection=trueMongoDB has had a per-database external hostname all along. Every MongoDB
database runs as a single-node replica set, so the URL also carries
&replicaSet=rs0&directConnection=true. The username is a c_… user minted for
this one principal.
The route follows the principals, as it does on PostgreSQL: a MongoDB database answers on that hostname only while it has an active principal, and stops when the last one is revoked or expires.
Buckets — a key pair, not a URL
✅ Added 'backups' to bucket 'uploads' (read)
Allowed sources: any source
🔑 Credential for 'backups' on bucket 'uploads'
Endpoint: https://s3.ghayma.tech
Bucket: st-uploads-a1b2c3d4
Access key: GK31c2f218a4b7
Secret key: ****A bucket principal is an ordinary S3 identity: point any S3-compatible SDK at
https://s3.ghayma.tech with that access key and secret. Both addressing styles
work — path style (s3.ghayma.tech/<bucket>) and virtual-host style
(<bucket>.s3.ghayma.tech). The key reaches that bucket and nothing else,
and it can never delete the bucket or change its settings.
Unlike a database, a bucket needs no route to be reachable — the platform
already serves every bucket — so a bucket key works the moment it is minted, and
active principals change nothing about routing. Only an
allowlist adds anything in front.
Levels
| Kind | Levels | What the weaker one means |
|---|---|---|
database | read-only, connect | read-only joins the database’s <db>_ro group: CONNECT and SELECT, including on tables created later |
bucket | read, read-write | read allows GET and LIST; PUT and DELETE are refused |
They are the same levels a connection takes, read from the same table on the
server, so read-only means the same thing to a partner’s BI tool as it does to
one of your apps. See Read-only
connections.
The two surfaces default differently. The console’s add form starts at the
weakest level (read-only for a database, read for a bucket). ghayma access add without --level takes the kind’s full level (connect,
read-write) — the same default a connection has always had. Pass --level
explicitly and neither can surprise you.
Allowlists
A principal may carry a list of source CIDRs. Give it one and the platform enforces it at the front door, before any authentication — a refused address never reaches the engine and never gets to try a password.
ghayma access allow database pg-main metabase --set 203.0.113.0/24,198.51.100.7✅ Allowlist for 'metabase' on database 'pg-main': 198.51.100.7/32, 203.0.113.0/24- The list you pass is the whole truth. It replaces whatever was there.
- A bare address is accepted and widened —
203.0.113.4becomes203.0.113.4/32. Ranges are canonicalised (host bits masked off), deduped and sorted, so two spellings of one range cannot become two entries. - An empty list means any source:
--set ""clears it, and the credential is then the only gate. That is the default and is not an error. - At most 50 entries per principal.
The collapse rule
This is the one rule to understand before you rely on an allowlist. Quoting
ghayma access allow --help in full:
The filter lives at the front door and runs BEFORE any authentication, so it cannot tell which principal a connection belongs to. That gives one rule with no exception: while any principal of this resource is unrestricted, the platform enforces no IP filter at all — a narrower filter would lock that principal out. An allowlist starts biting once every principal in force has one.
So the enforced filter is the union of every principal’s list, and it collapses to nothing the moment one active principal has no list. Both the CLI and the console say so when it happens:
ℹ️ 'partner-x CI' has no allowlist, so no IP filter is enforced at the front door while it is in force.
Give every principal an allowlist, or expect none of them to be filtered.Revoked and expired principals do not count — only the ones in force.
What the front door compares
- Databases (PostgreSQL and MongoDB): the real client address. The edge speaks the PROXY protocol to the router, so no header is trusted and none can be forged.
- Buckets: the address as it arrives through the CDN, and fail-closed — a request that skips the CDN and hits the origin directly carries no trusted forwarded address and is refused. Both request forms (path style and virtual-host style) carry the same rule; an allowlist that covered only one would be no allowlist at all.
Allowlist changes converge in the background, on the same ~2 minute tick as the rest of the platform’s network reconciliation.
Expiry
--expires <days> on the CLI, or a preset in the console, sets a date after
which the principal stops working. There is no check at connect time — a
principal authenticates straight against the engine and never passes through the
platform API — so the expiry is enforced the only way it can be: a sweep
revokes the credential, within about two minutes of the time.
That makes an expiry permanent, not a pause. A principal whose date has passed
reads as expired and cannot be rotated or re-allowlisted; add a new one
instead.
An expiry already in the past is refused before anything is created:
❌ expires_at must be in the futureRotate and revoke
Rotate replaces the secret and moves nothing else — same name, same level, same allowlist, same identity at the engine. The consumer swaps one string.
ghayma access rotate database pg-main metabase⚠️ This replaces the secret 'metabase' uses for database 'pg-main' now: the old one stops working immediately and whatever holds it has to be updated. Continue? [y/N]Revoke deletes the engine credential outright:
ghayma access revoke database pg-main metabase⚠️ This deletes the credential 'metabase' uses for database 'pg-main' now: anything still connecting with it stops immediately, and it cannot be restored. Continue? [y/N]✅ Revoked 'metabase' on database 'pg-main' — its credential no longer works.Both ask first unless you pass --yes; in the console both arm for five seconds
before they fire (Issues a new secret now; the old one stops working
immediately. / Drops this credential for good. Anything using it loses access
now.). Revoking something already revoked is not an error, so a retry after
a lost response is safe.
A revoked principal keeps its row, marked revoked with the date. “Who could
reach this database, and when did it stop” is exactly the question an audit of
external access is asked, and a deleted row answers none of it. Creating,
rotating, revoking and re-allowlisting a principal are all recorded in the
project’s audit log — names, levels and CIDRs, never a secret.
Seeing who has access
ghayma access database pg-main🔐 Access to database 'pg-main'
Apps in this project
APP LEVEL
main connect
External principals
NAME LEVEL ALLOWLIST EXPIRES LAST USED STATUS
metabase read-only 10.0.0.0/8 2026-12-01 — active
partner-x CI connect any source — — active
legacy-etl read-only any source — — revoked
ℹ️ 'partner-x CI' has no allowlist, so no IP filter is enforced at the front door while it is in force.
Give every principal an allowlist, or expect none of them to be filtered.The console’s Access card is the same two halves in one table, plus who created each principal and when. There is no column for a secret anywhere, in either surface — there is no field to fill one from.
LAST USED is empty for databases in this release. A database principal’s
traffic goes straight to the engine and never passes through the platform API,
and nothing reads the front door’s logs yet — so the column stays — (the
console shows never used) rather than showing a number that would be wrong.
Reading the list needs the project read role; adding, rotating, revoking or re-allowlisting needs write. A principal belongs to no app, so no site is named anywhere in these commands. A collaborator restricted to some sites may manage the principals of a database or bucket only when it serves at least one site and every site it serves is one of theirs.
When something is refused
| What you see | What it means |
|---|---|
this credential is shown once and cannot be retrieved again | Not a refusal — the one warning worth acting on. Store the secret before the panel or the terminal scrolls away |
a principal needs a name: 1–64 characters (400) | The name was empty or too long. It is a display name only — the engine credential is named from the principal’s own id, so renaming moves no identity |
invalid level for database: "admin" (400) | A database takes read-only or connect; a bucket takes read or read-write. Nothing else |
invalid CIDR: "10.0.0" (400) | An entry that is neither an IP address nor a prefix. Bare addresses are fine — they widen to /32 or /128 |
invalid CIDR: at most 50 entries are allowed, got 63 (400) | One principal’s allowlist is capped at 50 entries. Use wider ranges |
expires_at must be in the future (400) | The expiry had already passed — a principal revoked before it was handed over |
resource not found in this project (404) | The database or bucket is not this project’s, or has been deleted. The same answer as “no such resource”, deliberately, so ids cannot be probed across projects |
this external access has been revoked (409) | Rotating or re-allowlisting something already revoked. A revoked principal stays revoked — ghayma access add a new one |
You need the write role on this project to change its external access. (403) | Read is enough to see the list, not to change it |
could not mint the credential; no external access was created (503) | The database or bucket could not be reached — stopped, restarting, unreachable. Nothing was created, so running the same command again once it answers is safe |
Troubleshooting
I lost the secret. Rotate. ghayma access rotate <kind> <name> <principal>
mints a new one on the same identity — same name, same level, same allowlist —
so only the secret has to change wherever the consumer holds it. There is no way
to read the old one back; that is the design, not a gap.
My allowlist isn’t being enforced. Check for an unrestricted principal on the
same service: while one is in force, no filter is enforced for any of them.
ghayma access <kind> <name> prints the note when this is the case. Give every
principal in force an allowlist, or revoke the one that has none.
The allowlist locked me out. The allowlist guards the service, not the
console or the CLI, so you can still fix it from anywhere: edit it with ghayma access allow … --set or the Edit allowlist action on the row. Adding your
current address is usually the fix; adding a principal with no allowlist also
works, but read the rule above first — it lifts the filter for the whole service.
PostgreSQL: the host doesn’t answer. A per-database PostgreSQL hostname
exists only while the database has an active principal. If every principal was
revoked or expired, the route is gone — add one and it comes back. Check the
status column: expired rows still look like rows.
PostgreSQL: no_application_protocol or a TLS handshake failure. Make sure
you are on the per-database host the principal was handed, and that the client
asks for TLS (sslmode=require; libpq’s default prefer negotiates it anyway).
The platform accepts the postgresql ALPN, so no client version needs a
workaround.
A bucket key gets 403 from outside the CDN. A bucket allowlist is enforced
fail-closed: a request that reaches the origin directly, bypassing the CDN, has
no trusted client address and is refused. Use the normal
https://s3.ghayma.tech endpoint.
Adding a principal returned 503. The engine could not mint. No principal was created and nothing was half-done — run the same command again once the database or bucket answers.
Related
- Connections — the same model for the project’s own apps
ghayma access— the full command reference- Project API Keys — an auth app’s external access
- Databases —
db credentials,db sites - Storage — buckets,
storage credentials, andstorage expose - Rotating Credentials — which credential to roll, and when
- Internal Networking — why a deployed app uses internal hostnames instead