think
16px
820px

Converting prod (dms.val.id) from Enterprise to Cloud

Written 2026-08-18. Target: run PT Virtue Digital Indonesia and PT Sakti Anugerah Sentosa as
two tenants of one deployment, each signing under its own name.

Status of the facts below: verified against the live prod database and the code at 4176f322,
not recalled. Where something is a judgement rather than a measurement, it says so.


Why conversion is the right mechanism

The in-house signing CA lives inside the tenant schema, not the deployment:

Cloud   (VM2):   t_demo.ca_certs        ← one CA per tenant
Prod    (today): public.ca_certs        ← one CA, deployment-wide

So in Cloud each tenant has its own CA and therefore its own signing Organization. Virtue signs
as Virtue, Sakti signs as Sakti, one stack. On today's prod that is structurally impossible:
PUT /signing-identity regenerates the single CA and relabels every future signature.

The editions share one application migration set (migrations.Uppublic,
migrations.UpInSchemat_<id>), which is why conversion is a schema rename and not a data
migration. It is catalogue-only and costs the same at any database size.


The finding that removes the biggest risk: the URL does not change

tenantFromHost (go/internal/httpapi/tenancy.go:57) strips .<TENANT_BASE_DOMAIN> from the Host
header, and the remaining single label is the tenant id.

So with:

TENANT_BASE_DOMAIN=val.id
obscura-convert -tenant-id dms

dms.val.id resolves to tenant dmsthe existing production URL, unchanged. That means:

  • no DNS move
  • no new TLS certificate
  • no Casdoor redirect-URI reconfiguration
  • no user-visible change at all for Virtue's 14 users

This is worth stating plainly because the standard runbook (step 8, "point DNS/vhost at
<id>.<base domain>") assumes you are moving the address. Choosing the base domain to match what
you already serve means you are not.

⚠️ One consequence to accept deliberately: with TENANT_BASE_DOMAIN=val.id, any <label>.val.id
that reaches this deployment is treated as a tenant lookup and 404s if no such tenant exists. That is
harmless as long as the edge nginx only routes the hosts you intend. It is not harmless if you later
point an unrelated val.id subdomain at this box and wonder why it 404s.

PT Sakti then becomes sakti.val.id — or whatever label you pick — on the same deployment.


What the conversion does NOT give you

Be clear on this before committing, because two of these are the actual work.

Per tenant after conversion?
In-house signing CA (ca_certs) ✅ yes — this is the whole point
Documents, folders, ACLs, retention, audit chain ✅ yes — separate schemas
Modules (control.tenant_modules) ✅ yes — ticked per tenant
Peruri contract (control.tenant_esign_identity) ✅ yes — optional per tenant, else inherit
Authentication (Casdoor / AUTH_MODE) NO — deployment-wide
SMTP relay ❌ deployment-wide (one relay, admin row)
Blob master key, secure-folder KEK, backup age identity ❌ deployment-wide

🔴 The SSO problem — decide this before you start

OIDC_ISSUER / OIDC_CLIENT_ID are deployment-level config with no tenant scoping anywhere in
the auth adapters
. Prod runs AUTH_MODE=oidc against https://x056.sso.val.id.

After conversion, PT Sakti's staff would authenticate against Virtue's Casdoor. For a
prime/sub relationship that may be acceptable, or may be exactly the thing the client objects to.

Two workable answers:

  1. AUTH_MODE=both — Virtue keeps Casdoor SSO; Sakti's users get local accounts + passkeys.
    Simplest, no Casdoor work, and passkeys are already the working step-up path on prod.
    (Recall: idp step-up is broken against Casdoor — it emits no auth_time — so SSO users on
    prod already rely on passkeys for step-up. This does not make that worse.)
  2. One Casdoor, two organisations — Casdoor supports multiple orgs behind one client. Obscura
    still sees a single issuer, so the separation is Casdoor's to enforce, not Obscura's.

Recommendation: option 1. It requires no identity-provider work and keeps the two companies'
credentials genuinely separate, which is the thing you are actually being asked for.

✅ The vector extension — the tool already handles it (corrected 2026-08-18)

An earlier draft of this plan listed ALTER EXTENSION vector SET SCHEMA public as a manual step.
That is wrong for the current build. The rehearsal showed obscura-convert doing it itself:

✓ renamed schema public → t_dms (162 tables)
✓ recreated an empty public schema
✓ moved the vector extension back to public (shared by every tenant)

Verified after the rehearsal: vector sits in public, not t_dms. The plan step is removed.
The risk was real for older builds — it is closed in this one. Nothing to do.


Measured scope

prod today
Documents 234
Objects in MinIO 2 816
Users 14
Schema version 202 (matches build 4176f322)
Control schema absent — confirms it is genuinely Enterprise

The blob step is the only part proportional to data, and at 2 816 objects it is minutes. This is a
small deployment; the risk here is procedural, not volumetric.


Backup and rehearsal — DONE 2026-08-18

Backup at dms1:/home/vdi/preconvert-backup-20260818T075643Z (1.4 GB):

obscura.dump / obscura.sql.gz 11 MB each, full database, custom + plain
miniodata.tar.gz 1.4 GB, every object
secrets.tar.gz blob_age.key, securefolder_kek.key, licence, oidc secret
prod.env, docker-compose.vm1.yml, DEPLOYED_COMMIT, SCHEMA_VERSION config + provenance
SHA256SUMS checksums for all of the above

Verified by real restore, not by reading a file size — restored into a scratch database and
compared against live: documents 234=234, users 14=14, folders 121=121, ca_certs 2=2, schema 202.

Pulled off-box to valbox:/home/efran/obscura-preconvert-backup/: the database dump, secrets,
env and checksums (11 MB), all re-verified with sha256sum -c after transfer. The 1.4 GB object
tarball deliberately stays on dms1 — conversion copies objects and never deletes them, so the
originals remain at their bare-hash keys until an opt-in -prune days later.

Rehearsed end to end on a scratch restore of the real production database:

application tables to move : 162
schema version             : 202 (this build expects 202)
vector extension in public : true
control schema present     : false
No blockers.

-apply then produced: 162 tables in t_dms, public empty, 234 documents, 14 users, 2 CA certs,
goose 202, vector in public. Scratch database dropped; prod untouched and serving 200
throughout
(verified after: 162 tables still in public, 234 documents, readyz 200).

Pre-flight

  1. Schema version must equal what the build expects. obscura-convert refuses on drift —
    deliberately, "so the conversion window contains only the conversion". Both are 202 today. If you
    deploy anything between now and the conversion, re-check.
  2. Escrow the keys first. scripts/escrow-keys.sh. Also note that the secure-folder KEKs on
    dms1/dms2 are still not escrowed — unrelated to this migration but a bad thing to discover
    during one.
  3. Take a backup and rehearse on the restored copy. Not optional. The rehearsal is the only way
    to learn your own object count and timing, and this deployment has real customer data.
  4. Decide the auth answer (above) before touching anything.
  5. Pick the tenant id. dms preserves the URL. It is also permanent in every object key after
    the blob copy.

Runbook

Rehearse all of this on a restored copy first. The tool is idempotent and plans read-only by default.

# 0. Plan only — read-only, refuses loudly if anything is wrong
docker compose run --rm --entrypoint /obscura-convert obscura \
  -tenant-id dms -name "PT Virtue Digital Indonesia"

# 1. Stop the deployment
# 2. The database step — ONE transaction, converted or not at all
#    (this also moves the vector extension back to public by itself)
docker compose run --rm --entrypoint /obscura-convert obscura \
  -tenant-id dms -name "Virtue Digital Indonesia" -apply

# 3. Objects — copies, never moves. Resumable, abandonable.
docker compose run --rm --entrypoint /obscura-convert obscura \
  -tenant-id dms -blobs -apply

# 5. Start with Cloud settings. Control-plane migrations run on THIS boot.
#    TENANCY_MODE=schema  TENANT_BASE_DOMAIN=val.id  CONTROL_PLANE_TOKEN=<new secret>
# 6. Register the tenant
docker compose run --rm --entrypoint /obscura-convert obscura -tenant-id dms -register

# 7. Grant the modules the licence already carries
#    (Cloud effective set = licence ∩ control.tenant_modules — a licence alone does nothing)
INSERT INTO control.tenant_modules (tenant_id, module)
SELECT 'dms', m FROM unnest(ARRAY['correspondence','watermarking','ai','esign',
                                  'office','klasifikasi','securefolder','contracts']) m
ON CONFLICT DO NOTHING;
# 8. Verify sign-in at dms.val.id  unchanged address, so this is a real regression test
# 9. Days later, once satisfied:
docker compose run --rm --entrypoint /obscura-convert obscura -tenant-id dms -blobs -prune

🔴 Do not skip step 4. Convert the database without the objects and every document is a row
pointing at nothing.

⚠️ TENANCY_MODE must be set on that boot. Missing, the server silently boots Enterprise and
writes into the wrong shape — this has poisoned a database before.


Rollback

  • The database step is one transaction: it converted or it did not.
  • Objects are copied, never moved. An abandoned conversion leaves them untouched.
  • -prune is the only irreversible act, is opt-in, and runs only after every object is verified at
    its new key.

To roll back before pruning: stop, ALTER SCHEMA t_dms RENAME TO public, drop control, start with
TENANCY_MODE=single. The bare-hash objects are still there.

⚠️ Rollback restores schema and images — it does not undo control-plane migrations that have
already run, and it does not un-run tenant migrations. Keep the pre-conversion dump.


After conversion: standing up PT Sakti

  1. Create the tenant (operator console, or control API with CONTROL_PLANE_TOKEN).
  2. Grant its modules — it does not inherit Virtue's.
  3. Set its signing identity: Admin → Security → Signing identity →
    PT Sakti Anugerah Sentosa. This generates that tenant's own CA. Virtue's is untouched.
  4. If Sakti gets its own Peruri contract later, add a control.tenant_esign_identity row —
    operator-set only, and deliberately not tenant-facing, because a tenant admin pasting a staging
    corporate id would mint signatures that look valid and are legally worthless.

The thing the software will not check for you

The in-house CA is self-signed and self-asserted. It will say "PT Sakti Anugerah Sentosa"
because someone typed it. It proves a document has not changed since signing; it does not prove who
signed. Under Indonesian TTE rules it is not a certified signature — that needs a PSrE, and there
the certificate carries the individual's identity (KTP, NPWP, eKYC), not the company's.

Since Virtue is the subcontractor and Sakti the prime, issuing documents under Sakti's name should
rest on Sakti's written authorisation. Get it before the first signature, not after. There is no
technical control here and there is not meant to be one.


Open decisions

  1. Auth: AUTH_MODE=both (recommended) or two Casdoor organisations?
  2. Tenant id for Virtue: dms preserves the URL. Confirm — it is permanent in every object key.
  3. Operator console host: it needs one (VM2 uses admin.demo.getobscura.id). admin.val.id?
  4. Where to rehearse: a restored copy on VM2 is tempting but VM2 is the public demo and already
    struggles under build load. A scratch box is safer.