Operator console — roadmap
Branch
feat/saas-tenancy· whatobscura-operatorcan do, what it needs next, and what is
deliberately deferred. Companion toCLOUD_STATUS.md(the honest
built/not-built inventory) andCLOUD_DEPLOYMENT.md§4b (how to run it).
Built today
Tenants: list (searchable; status, plan and term filters), detail, provision, suspend/reactivate,
close, purge, module editor (licence ∩ tenant, replace-not-delta), seed an administrator (emailed by
default), edit the customer record, set the commercial term, apply a plan, and operate the tenant's
own user accounts (list / reset password / clear lockout / reset 2FA / disable).
Plans: a catalogue of named module bundles — create, edit, archive, delete-when-unused — applied
to one tenant or in bulk across a selection, with drift from the plan reported both ways.
Terms: trial and contract end dates with per-tenant opt-in automatic suspension, an hourly
per-tenant sweep that enforces it, and an attention list on Overview for what is lapsing.
Operators: list, create (owner/viewer), disable, reset password, reset TOTP, revoke sessions.
Reporting: daily per-tenant usage snapshots, deployment totals, per-tenant series.
Signups: a public request form's inbox (off by default), approve → provision (optionally on a
plan, with the invitation emailed) or reject with a reason that can be emailed back.
KYB: business verification per tenant, and the gate it puts in front of the regulated modules —
esign cannot be granted without a current approval, through any of the four paths that write
entitlements.
Audit: append-only trail of every operator action, filterable by tenant / action prefix /
operator / date range, paged with an honest total, exportable as CSV, and shown per tenant on its own
History tab.
1. Features / operability — the panel as a tool for running tenants
Tier 1 — DONE
- ✅ Customer record on
control.tenants: contact name/email, billing email (separate on
purpose — a suspension notice should not land in accounts payable), operator notes, contract
reference.PATCH /control/tenants/{id}patches only the fields it names, so saving one panel
cannot blank another's. - ✅ The display name is editable (blank is refused — it is what every operator screen shows).
- ✅ A tenant's USERS are visible and operable: who exists, who holds admin, who is disabled /
locked out / must change their password, plus reset-password, clear-lockout, reset-2FA and
disable. Documents are deliberately NOT readable from the console. Disabling a tenant's last
enabled administrator is refused — that would lock the customer out of their own system. - ✅ Search + status filter on the tenant list, matching id, name, contact and contract ref.
Tier 2 — DONE
- ✅ Plans/tiers.
control.plans(code, name, description, modules[], archived) +
control.tenants.plan, seeded with Starter / Professional / Enterprise. Applying a plan copies
its modules onto the tenant;tenant_modulesstays the entitlement of record and the request-path
gate is untouched. Resolving entitlements THROUGH the plan was rejected: it would mean a form save
silently changing what fifty live customers can do, with nobody shown a list of who was affected.
So a plan edit changes the template, and pushing it out is a separate audited action that names
them. Drift is therefore possible and is reported both directions (extra / missing) rather than
reconciled behind the operator's back — a negotiated extra module is legitimate, so it is teal, not
red. Deleting a plan any tenant carries is refused (archive withdraws it from sale instead);
assigning an archived plan to a NEW tenant is refused but re-applying to a tenant already on it is
not, or withdrawing a plan would strand its customers. Clearing a plan leaves entitlements alone. - ✅ Bulk apply across a selection, with per-tenant results collected rather than stopping at the
first failure, and the tenants named before anything moves. Each affected tenant also gets its own
audit entry, so a change made in bulk appears in that customer's history. - ✅ Trial and contract end dates, with opt-in auto-suspend (
auto_suspenddefaults to
false). One date plus a "is this a trial" flag rather than two date columns, so there is no
which-one-wins rule to get wrong. The automation is per-tenant opt-in for the same reason the boot
migration sweep refuses to auto-suspend a broken tenant: taking a paying customer offline is an
operator's decision, not a background job's. Enforced hourly per tenant by
control.enforce_tenant_expiry, recorded in the operator trail assystem, and an operator's
action outranks the automation — reactivating a tenant whose date is still past disarms
auto-suspend and says so, rather than letting the next sweep undo them. - ✅ Emailed admin invitations, on by default. On success the password is never shown to the
operator; on a delivery failure it comes back WITH the reason, because the account already exists
and withholding it would leave an administrator nobody can sign in as. - ✅ Audit filtering + CSV export, plus a per-tenant History tab. One shared predicate builds the
page, its count and the export.until=<date>covers the whole day (treating it as midnight would
silently drop a day from an audit). The CSV is unlimited and streams — an export that stopped at a
page boundary would look complete — and a mid-stream failure is written INTO the file. The export
is itself audited: handing over a customer's change history is a disclosure.
Tier 3 — mostly DONE
- ✅ Signup requests queue.
control.signup_requests+ a PUBLICPOST /api/v1/signup
(SIGNUP_ENABLED, off by default, routes unregistered when off, per-IP rate limit, 1 MiB body cap,
field caps, one pending request per email via a partial unique index). The queue is public; the ACT
is not — creating a tenant runs the whole migration set, issues a CA and registers scheduled tasks,
which does not go on an unauthenticated form while neither billing nor KYB bounds it. - 🔴 It never reports whether a subdomain is free. A form replying "acme is taken" hands the
customer list to anyone with a browser; collisions surface to the operator at approval, where a
fix is an edit. (The resolver's 404 is not the argument — a live tenant is already
distinguishable by its 401. The argument is that a signup form must not be a lookup service.) - Nothing in the row is trusted input: the tenant id, name and plan come from the OPERATOR.
Provision happens BEFORE the request is marked, so a failure leaves it pending and retryable. - Approving with a plan sends the plan CODE and lets the server apply it — reimplementing plan
application in the approve dialog dropped modules the licence does not yet cover (which a plan
may include on purpose) and never recorded the plan label. - ✅ KYB review queue, and the gate that gives it teeth: granting a REGULATED module (
esign,
which carries e-Meterai and e-Stamp) is refused unless the tenant's verification is approved and
current. Covers all four entitlement writers — SetModules, ApplyPlan, ApplyPlanBulk, Provision.
Fails closed on a read error. Does NOT revoke: grandfathered and lapsed tenants are a compliance
backlog the console lists, because a gate that silently exempted everything before it would read as
compliance without being it. Approving grants nothing — the grant stays a separate audited act.
The signatory's national ID number is deliberately not stored (see migration 00010). - Broadcast / maintenance notice to tenants — still open. Needs a per-tenant notice mechanism in
the product first. - ✅ Confirmation dialogs on every consequential action (
Confirm.tsx): suspend, reactivate,
close, and the four per-user actions (reset password, clear lockout, clear 2FA, disable/enable).
The rule they encode: a confirmation states WHAT WILL HAPPEN, not "are you sure?" — these are taken
on somebody else's system. Deliberately NOT on actions whose whole content is a form the operator
just filled in (seed an admin, edit the record): a dialog after a deliberate submission is the
friction that teaches people to click through dialogs, which is how the one on the dangerous action
stops working. The 2FA/password copy names the social-engineered support call out loud, because
that is the attack those two buttons are the target of. - ✅ The tenant is told it is suspended. Previously every resolution failure answered an identical
404, so a suspended customer's staff met a login form that rejected every correct password. Now
suspension answers403 tenant.suspendedand the tenant app renders a page over the whole SPA —
live, no reload, verified by suspending a tenant while signed in. It gives no reason. Unknown,
closed and provisioning keep the uniform 404, so the distinction is exactly one state wide. - ✅ Console navigation is client-side and marks the current page (
NavItem). Carbon's
SideNavLink hrefis a plain anchor, so every click had been reloading the entire SPA — session,
tenant list and bundle re-fetched to show a page the client already had — and nothing was ever
marked current, which also left the active-marker CSS matching nothing at all. - ✅ Table row padding. Carbon ships
tdwith ZERO vertical padding by design, so every console
table had text touching the row border. Fixed once in.op-pagerather than per table. - ❌ Support impersonation — CLOSED, do not re-propose. Decided against 2026-07-30: it makes
tenants less secure, and that outweighs the support convenience.
The reasoning, so it does not get re-litigated. Impersonation means an operator holding a real
session INSIDE a customer's Obscura — which is a deliberate, authenticated bypass of the tenant
boundary the whole architecture exists to enforce. The console can already see WHO a tenant's users
are; it cannot read their DOCUMENTS, and that line is the promise a buyer of Obscura Cloud is being
sold. Time-boxing and audit entries reduce the blast radius of a misuse; they do not change the
fact that the capability exists, that a console compromise becomes a document-archive compromise,
or that "your provider can read your files" is now true.
What to use instead, all of which already exists: the tenant's own user list, reset password /
clear lockout / clear 2FA, the per-tenant History tab, the schema and migration panels, and asking
the customer for a screen share — which keeps the customer present for their own data.
2. Diagnostics / operational visibility — deferred, but one item here is a live bug
✅ FIXED — existing tenants now get new migrations at boot. The bug: MigrateSchema had
exactly one caller, Provision, so a release that added a migration upgraded NEW tenants and
silently left existing ones behind — it worked for the customer who signed up yesterday and broke
the one who signed up last month.
Now every ACTIVE tenant is migrated on boot, before the bootstrap sweep (whose seeds can touch
tables a new migration just added). One tenant's failure does not fail the boot: returning on
the first error would let one customer's broken schema take every other customer offline, turning
a one-tenant problem into an outage. Failures are collected, logged at ERROR, and recorded in
control.tenants.migration_error so they survive the log; the tenant keeps serving on its old
schema, because it was serving a moment ago on that same schema and automatically suspending a
customer is an operator's decision, not a boot sequence's.
✅ And the console shows it. The tenant list has a Schema column (current / behind by N /
migration failed / ahead by N (rolled back?)) and the detail page has a Schema panel carrying
the tenant's version against the build's, the timestamp of the last attempt, and the verbatim
SQLSTATE when it failed. behind is amber rather than red because the tenant is still serving —
overstating it would train an operator to ignore the colour that means act now.
The rest is genuine roadmap:
- Tenant health panel — migration state DONE (above); the rest still unsurfaced: CA presence
and expiry, scheduled tasks registered / last run / overdue, outbox backlog, audit-chain
verification state, stuck-in-provisioning. Every input already exists per tenant. - Repair actions. Re-run bootstrap (the service method exists and is idempotent — it just has
no route), re-run migrations for one tenant, force a stats collection instead of waiting a day. - Alerting. Nothing tells an operator anything: a failed provision, a broken audit chain, a
tenant near a storage ceiling. Even a daily digest to owners would change the posture from
"notice eventually" to "be told". - Quotas (storage, users) per tenant. Filed under billing in
CLOUD_STATUS.md, but really
operational: without a ceiling one tenant can fill the disk for every other.
3. Known operational notes
- Sole-owner recovery. If the only owner loses their authenticator, recovery is CLI:
run --rm operator -bootstrap-owner=<email>creates a second owner. Works, and the console's
last-owner guard prevents the other lockout (disabling the final enabled owner is refused).
Keep at least two owners on any deployment that matters. - The published demo viewer belongs on the demo deployment only. On a deployment with real
customers it would expose the customer list to anyone with the URL. update.shdoes not build this container — it coversobscura+web. Rebuild the console by
hand, and exportOBSCURA_VERSION/OBSCURA_COMMITfirst or it stampsdev. 🔴 Runupdate.sh
FIRST and the console SECOND: the console's service used to declaredepends_on: [postgres, obscura], and--buildapplies to the whole dependency closure, so rebuilding the console
rebuilt obscura (unstamped, since a hand-run compose has noOBSCURA_VERSION) and recreated the
data services. Thedepends_onis gone and a test keeps it gone;restart: unless-stopped
replaces it. The console shows its
build at the foot of the side nav (GET /op/api/version, session-required) and WARNs at boot when
unstamped./op/api/versionis deliberately NOT public: the server's equivalent is public because a
credential-less deploy gate polls it, nothing gates this container that way, and the boot log is the
deploy-time answer — so an unauthenticated route on the surface that administers every tenant would
be surface for nothing.- The expiry sweep needs the worker role running. It is a scheduled task like every other, so a
deployment running-role=apionly will record terms and show them but never enforce them. - Emailed invitations need SMTP to actually work. The channel is always constructed (the host
defaults tolocalhost:1025), so "no mail configured" is not a state the code can detect — a send
against a dead relay fails loudly and returns the password, which is the intended fallback, but a
send against a reachable misconfigured relay reports success. Check that a real invitation
arrives once per deployment. - Carbon modal gotcha, for whoever drives this UI in a test: the ✕ close button carries
cds--btn--primaryand precedes the footer in the DOM, so.cds--modal .cds--btn--primaryselects
CLOSE, not the primary action. Scope to.cds--modal-footer. - The signup form is off by default and lives elsewhere.
SIGNUP_ENABLED=trueexposes
POST /api/v1/signupon the APEX (it is exempt from tenant binding — the caller has no tenant, that
is what they are asking for). The FORM itself belongs on the marketing site; this repo ships the
endpoint and the queue, not the page. - The KYB gate does not apply in Enterprise. No control plane, no tenant registry; the licence is
the gate and the buyer signed a contract.