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).Why not just hand out the database password
Before this, reaching a database from outside meant ghayma db expose and giving out the
database’s main user — the same identity to everyone. That has three
consequences you cannot design around:
- Revoking one consumer means re-passwording all of them. They all
authenticate as
u_<name>. - A read-only partner is not expressible. The main user owns everything.
- No source rule is possible. Nothing in the path can tell which database, or which consumer, a connection belongs to.
A named principal fixes all three, and the shared-credential expose stays for what already uses it.
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. It is
not the pooled db.ghayma.tech endpoint, which keeps working exactly as it
did for the shared-credential expose.
- 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=trueNothing about the URL moved: MongoDB has had a per-database external hostname
all along. A replica-set database adds &replicaSet=rs0&directConnection=true.
The only difference is the username — a c_… user minted for this one
principal, in place of the database’s shared user.
A MongoDB database that was already exposed keeps its route whether or not it has principals; only the allowlist follows them.
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 — the same bar as exposing the database in the first place. External access is project-scoped, never site-scoped: a principal belongs to no app, so no site is named anywhere in these commands.
The shared-credential expose is still there
ghayma db expose and
ghayma storage expose
are untouched, and so is everything already connecting through them. They are
now the legacy way to reach a service from outside:
| Shared-credential expose | Named principal | |
|---|---|---|
| Identity | The service’s main user — everyone shares it | One credential per consumer |
| Revoking one consumer | Re-password everyone | Revoke that one |
| Read-only | Not expressible | read-only / read |
| Source allowlist | None | Per principal, enforced at the front door |
| Expiry | None | Optional |
| PostgreSQL endpoint | The pooled db.ghayma.tech:5432 | This database’s <resource>.db.ghayma.tech:5432 |
Prefer a named principal for anything new. Use the expose only for a consumer that already has the shared credential and cannot be moved yet.
The two are independent. Exposing a PostgreSQL database does not create its per-database hostname, and adding a principal does not turn the pooled endpoint on. Each is its own switch.
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 and not an old pooled URL, 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 expose,db credentials,db sites - Storage — buckets, their own key, and
storage expose - Rotating Credentials — which credential to roll, and when
- Internal Networking — why a deployed app uses internal hostnames instead