think
16px
820px

Operator console — roadmap

Branch feat/saas-tenancy · what obscura-operator can do, what it needs next, and what is
deliberately deferred. Companion to CLOUD_STATUS.md (the honest
built/not-built inventory) and CLOUD_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_modules stays 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_suspend defaults 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 as system, 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 PUBLIC POST /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 answers 403 tenant.suspended and 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 href is 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 td with ZERO vertical padding by design, so every console
    table had text touching the row border. Fixed once in .op-page rather 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.sh does not build this container — it covers obscura + web. Rebuild the console by
    hand, and export OBSCURA_VERSION/OBSCURA_COMMIT first or it stamps dev. 🔴 Run update.sh
    FIRST and the console SECOND: the console's service used to declare depends_on: [postgres, obscura], and --build applies to the whole dependency closure, so rebuilding the console
    rebuilt obscura (unstamped, since a hand-run compose has no OBSCURA_VERSION) and recreated the
    data services. The depends_on is 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/version is 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=api only 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 to localhost: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--primary and precedes the footer in the DOM, so .cds--modal .cds--btn--primary selects
    CLOSE, not the primary action. Scope to .cds--modal-footer.
  • The signup form is off by default and lives elsewhere. SIGNUP_ENABLED=true exposes
    POST /api/v1/signup on 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.