Skip to Content
Framework GuidesExternal Access

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 metabase

From 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=require

The 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.tech wildcard and routes by SNI. The URL already carries ?sslmode=require; unencrypted connections are refused.
  • Every current client works. The route advertises the postgresql ALPN protocol that libpq 17 and newer offer on every TLS handshake, so psql 16, 17 and 18 all connect. (Without it, libpq 17+ gets no_application_protocol while 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

Nothing 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

KindLevelsWhat the weaker one means
databaseread-only, connectread-only joins the database’s <db>_ro group: CONNECT and SELECT, including on tables created later
bucketread, read-writeread 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 widened203.0.113.4 becomes 203.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 future

Rotate 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 exposeNamed principal
IdentityThe service’s main user — everyone shares itOne credential per consumer
Revoking one consumerRe-password everyoneRevoke that one
Read-onlyNot expressibleread-only / read
Source allowlistNonePer principal, enforced at the front door
ExpiryNoneOptional
PostgreSQL endpointThe pooled db.ghayma.tech:5432This 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 seeWhat it means
this credential is shown once and cannot be retrieved againNot 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.