Files
UmbraTrans/web
umbra audio ed72f61975 Separate organizations, and give members their organization's projects
An organization's work is separate from every other organization's, and the
separation is now one module rather than a property each route remembers.

`tenancy.py` resolves the caller's scope in a single place. A member is pinned
to the organization on their own assignment, expressed as a concrete identifier
rather than a flag, so "no filter" is unreachable except for a platform
administrator. Every read and write resolves through it, so there is no per-route
variation to drift.

Within an organization, every account sees every project. The brief is explicit
that two people working on different books both see both, and assignment says
who is *working* on a project rather than who may *know* it exists. This
diverges from `app.can_access_project`, which restricts visibility to assigned
members; the divergence, and what it means if row-level security is ever
switched on, are recorded in migrations/README.md because that would otherwise
be a silent tenant-internal regression no test here would catch.

Two rules, both learned by getting them wrong first:

- A read naming another organization is ignored, because refusing it would
  confirm the identifier exists. A write is refused rather than retargeted,
  because a project landing in an organization the caller did not ask for is
  somewhere they will not look.
- A missing project and a foreign one get the same error, so an identifier cannot
  be probed across a boundary.

Projects: the `projects` table, its statuses, and the project-scoped arm of
`user_roles` were already in the schema and unused. No migration.

The dashboard is one server-scoped call, `/api/workspace`, rather than several
assembled in the browser — pieces from different tenants that nobody notices are
not a failure mode worth shipping. Filters narrow the list already in the page,
so switching cannot mix two fetches, and an empty list is distinguishable from a
failed one.

**A sign-in lockout, found by the integration suite.** An assignment is a second
active `user_roles` row, and `_resolve_assignment` refused an account with more
than one. So assigning anybody to anything made them unable to authenticate —
locked out of the page the assignment was meant to show them. `_sync_assignment`
had the same defect. Six queries were scope-blind; three were harmless only
because the assignment role is named `viewer` and never matched a tenant role
name, which is luck rather than a property, so a structural test now pins the
predicate on every one. The guard against two *tenant* rows survives, narrowed to
the ambiguity that actually matters.

Also fixed: `get_project_by_slug` cast `slug::text`, making citext's
case-insensitivity a case-*sensitive* comparison that appeared to work everywhere
else. An assignment grants the `viewer` project role, since no `member` role
exists at project scope; it is a named constant, not a query literal, because the
project workflow is not defined yet.

410 tests pass with the integration suite against a real database; 386 without.
2026-09-28 11:34:37 -07:00
..

UmbraTrans web application

The browser client and the account/organization API it talks to.

This is not a mock. Every page calls the real backend under /api. The three routes below are served from the same origin as the API by umbratrans_web, because the session cookie is HttpOnly and the CSRF token travels as a header, and the application deliberately installs no CORS middleware. A separately hosted frontend could not call it at all.

What is real

Surface Backend
Sign in, forced first-login password change, sign out Real, Argon2id and server-side sessions
Accounts: list, filter, provision, suspend, reactivate, deactivate, revoke sessions Real
Organizations: list, create, rename, re-slug, suspend, reactivate, archive Real
Audit log Real
Dashboard counts Real, scoped to the caller
Project workspace contents, maintenance, statistics Not built yet

The unbuilt areas render as explicit empty states that name the missing backend work. No page displays a fabricated name, count, or metric.

Running it

In development the API serves this directory. From the repository root:

cd /home/umbra/src/UmbraTrans
UMBRA_ENV=development \
UMBRATRANS_REPOSITORY=json \
UMBRATRANS_REPOSITORY_PATH=.local/data/development/umbratrans-web/dev-accounts.json \
UMBRATRANS_STATIC_ROOT=/home/umbra/src/UmbraTrans/web \
UMBRATRANS_ALLOWED_HOSTS=127.0.0.1,localhost \
  /home/umbra/.local/bin/uv run python -m umbratrans_web serve \
    --host 127.0.0.1 --port 8400 --repository json

There is no first administrator until one is provisioned through the operator command, which reads the one-time password from a hidden prompt:

UMBRA_ENV=development \
  /home/umbra/.local/bin/uv run umbratrans-bootstrap-admin \
  --username Umbraadmin \
  --repository-path .local/data/development/umbratrans-web/dev-accounts.json

--repository json keeps accounts across restarts. --repository memory starts empty every time.

Routes

  • / — sign in, and the mandatory password change for a temporary-password account
  • /uadmin/ — control panel: dashboard, accounts, organizations, audit
  • /workspace.html — project workspace

Security properties the client relies on

  • No secret is ever sent by a client. POST /api/uadmin/accounts has no password field; the service generates the one-time password. The request schema refuses unknown fields, so a submitted password is rejected before a handler sees it.
  • A one-time password is shown exactly once, in the otp-modal panel. It is held in one variable, cleared when the panel closes and on pagehide, and is never written to localStorage, sessionStorage, a cookie, the URL, the page title, or the console.
  • Every unsafe request carries X-CSRF-Token, read fresh from the readable umbratrans_csrf cookie. GET /api/health on page load bootstraps that cookie, because the middleware does not issue one on an unsafe method and POST /api/auth/login is exempt.
  • No inline script, no inline style, no innerHTML. The server sends script-src 'self'; style-src 'self', so an inline <script> or a style= attribute would be refused by the browser. All server-supplied text is rendered with textContent through the el() helper in api.js.
  • Nothing is trusted from the browser. The UI hides controls a role cannot use, but the service re-checks every one of them; a forged identifier is refused with the same generic error as any other forbidden request.

Not a production deployment

The application fails closed in production: the PostgreSQL adapter does not exist, so create_app raises rather than starting against an unreviewed store. Serving this directory is therefore a development arrangement, and the reviewed migration in umbratrans_web/migrations/ has not been applied.

Systemd

umbratrans-web-prototype.service currently serves this directory with python3 -m http.server, which is static only and cannot reach the API. It exists so the port stays occupied and the public hostname keeps answering. See systemd/umbratrans-web-prototype.service.