Commit Graph

10 Commits

Author SHA1 Message Date
umbra audio
31fd8720ec Add the PostgreSQL adapter for the authoritative store
Implements AccountRepository against the control-plane schema plus the
phase-1 identity migration, and wires it in so production can select it.

The adapter is not a thin SQL wrapper, because the control plane does not
store the two things the application needs on the users row.

Role and organization come from user_roles. There is no role column and no
organization column on users; a role is assigned through user_roles with a
scope, and app.validate_user_role_scope() enforces that the role's scope
matches the assignment. Resolving one role per user is therefore a join, and
a user holding more than one active assignment is refused rather than resolved
to an arbitrary row. The organization on that assignment becomes the scope of
every administrative decision, so guessing would hand out the wrong tenant's
authority. Reconciling this with project-scoped membership stays an open
review question, recorded as such.

Email became a required field on account creation. users.email is NOT NULL
UNIQUE, so accepting a missing address would let account creation succeed
against the development repositories and then fail on the authoritative store.
It is now required in the service, the API schema, and the bootstrap command,
so it fails identically everywhere.

Audit identifiers now belong to the store. audit_events.id is a bigint
identity, so the adapter reports that value; the application correlation UUID
is preserved in the detail and never displaces the row key. AuditEvent.id is a
string for that reason, which removes a type the two stores disagreed about.

Three further fail-closed behaviours: a users.status of invited or locked is
refused rather than coerced onto active; a row with mfa_required is refused,
because this phase cannot satisfy a second factor and authenticating it anyway
would grant access to someone who was supposed to present one; and a missing
organization blocks access rather than granting it. save_user deliberately does
not write username or email, since both are identity keys with no reviewed
change path and no audit event behind them.

Every operation runs in one transaction on one pooled connection and publishes
the caller with SET LOCAL app.current_user_id, so a returned connection cannot
carry one request's identity into the next. That is inert until the reviewed RLS
policies exist; the explicit WHERE clauses enforce scope today. A statement
timeout bounds how long a pooled connection can be held.

Production fails closed at two independent gates: the settings layer refuses
without a DSN, and the repository boundary refuses to build a PostgreSQL store
without one. Verified by running both, including that an unreachable host
refuses to start rather than degrading.

The migration gains an index on audit_events (target_type, target_id), which
the administrator audit filter needs and nothing covered, and the review
questions now include the three things that must be checked before
provisioning: the roles rows and their scopes, any row with mfa_required set,
and any user holding more than one active assignment.

Verification, and its limit. 236 tests pass with 3 skipped. The adapter's row
mapping, role resolution, conflict mapping, and configuration are unit tested
and run everywhere. Its SQL, transactions, and pooled reuse are NOT executed:
this environment has no database credentials, because sudo to the postgres user
needs a password, there is no umbra database role, and there is no .pgpass. The
integration suite is written and selected by UMBRATRANS_TEST_DSN, and its
fixture refuses any DSN whose database name contains umbracontrol. A green run
here says nothing about whether the SQL is correct.
2026-09-26 20:28:07 -07:00
umbra audio
08a44dbacd Wire the browser client to the real account and organization API
The pages under web/ were a static mockup with hardcoded names and counts. They
now call the real backend, and the areas with no backend are explicit empty
states rather than invented numbers.

Real: sign in, the mandatory first-login password change, sign out, the account
table with filters and lifecycle actions, organization create/rename/re-slug/
status, the audit log, and dashboard counts scoped to the caller. The project
workspace keeps its Studio identity and its real session wiring, but its
project, maintenance, and statistics areas now say which backend work is
pending instead of showing sample figures.

Removed the mock release-readiness pipeline and the fake production metrics
rather than restyling them.

The client depends on properties the server already enforces, and states them
where a future editor will look:

- No secret is sent by a client. Account provisioning has no password field;
  the service generates the one-time password and returns it once.
- That one-time password lives in exactly one variable, is cleared when the
  reveal panel closes and on pagehide, and is never written to storage, a
  cookie, the URL, the page title, or the console.
- Every unsafe request carries X-CSRF-Token read fresh from the readable
  cookie, and GET /api/health on page load bootstraps it because the
  middleware issues none on an unsafe method and login is exempt.
- Nothing is trusted from the browser. Controls a role cannot use are hidden,
  and the service re-checks each one.

Rendered markup is built by an el() helper that routes all server-supplied text
through textContent, with no innerHTML anywhere. The response CSP is
script-src 'self' and style-src 'self', so an inline script or style attribute
would be refused by the browser; there is none.

Verification, and its limits. The banned-pattern grep for inline script, inline
handlers, style attributes, innerHTML, storage, and console output comes back
empty. Replaying the exact request sequence the client performs against a real
uvicorn server confirmed all 86 contract assertions: every field the
JavaScript reads is present, the CSRF cookie is issued by the health probe, the
one-time password is absent from every later response, and the error codes the
UI branches on are the ones the API returns.

The client was NOT executed in a browser. No desktop browser is attached to
this session and no JavaScript runtime is installed on this host, so the
one-time-password reveal, the modal focus trap, and the role gating are
verified by reading and by the contract replay, not by driving a real browser.
That gap needs closing before this is trusted.

web/README.md replaces the mock description, including the run command and the
fact that this is not a production deployment: create_app still refuses to
start in production because the PostgreSQL adapter does not exist.
2026-09-26 20:13:46 -07:00
umbra audio
0b9c4d4220 Add organization tenancy and same-origin frontend mounting
The administrator interface needs organizations to manage, and the slice had
none: users.organization_id was a bare UUID pointing at nothing, so there was
no tenant to create, list, or validate an account against.

Tenancy:
- Organization record with name, slug, and status active|suspended|archived,
  matching the control-plane vocabulary rather than reusing the account one.
- Reads are open to any administrator; every write is platform-admin only, so
  the tenant boundary stays under a single authority. An organization admin is
  pinned to the organization on their own principal.
- A derived slug is normalized from the display name, but a slug the operator
  typed must already be canonical, so nothing is silently rewritten behind
  their back. archived is terminal.
- GET /api/uadmin/summary for the dashboard, recomputed per request from the
  same scoped queries the list endpoints use, so a summary cannot disclose a
  number the caller could not reach by listing.

Two integrity gaps closed:

1. Account creation accepted any UUID as an organization. It now requires the
   organization to exist and to be active.
2. Suspending an organization revoked member sessions but did not stop new
   logins, because each member's own account status stays active. Both
   authenticate and resolve_session now refuse a member of a non-active
   organization, and a missing organization record blocks access rather than
   granting it. A test asserts the member is still active yet locked out.

Migration, still unapplied: section 4 creates organizations with CREATE TABLE
IF NOT EXISTS so a control-plane table is skipped, never redefined, and the
rollback drops it only when this migration actually created it. The membership
divergence is recorded as the top open review question rather than decided:
user_roles is the control plane's membership source, this phase uses a column,
and the migration adds no membership column so the choice stays open.

Serving: UMBRATRANS_STATIC_ROOT mounts the browser client at / after the API
routes, so the session cookie and CSRF header work without CORS. HTML responses
get their own CSP allowing only same-origin script and style, which is why the
frontend carries no inline script or style.

Verification: 204 tests pass (21 new organization tests plus the existing
suite), ruff check and format clean. Also exercised against a real uvicorn
server rather than only the test client: bootstrap, forced password change,
organization creation, one-time password issue, member lockout on organization
suspension, and rejection of an account assigned to a suspended tenant.
2026-09-26 19:58:17 -07:00
umbra audio
6482921d0b Run the static prototype under systemd
The prototype was served by a bare `python3 -m http.server` in a foreground
shell. It has no restart policy, so it died silently and the public URL
returned a 502 from Caddy with valid DNS and a valid certificate already in
place. This unit makes the failure mode visible and recoverable.

umbratrans-web-prototype.service:
- Restart=on-failure with a 5s delay and a 120s/5-attempt start limit, so a
  crash loop stops retrying but a clean stop always starts again.
- Binds 0.0.0.0:8442 serving web/; Caddy keeps terminating TLS upstream.
- Sandboxed: runs unprivileged with an empty capability bounding set, no new
  privileges, read-only home and system, private tmp and devices, system call
  filtering, and no access to kernel or control-group interfaces.
- Journal output to the user journal for inspection.

PrivateUsers=yes is documented as deliberately omitted. It hides /etc/group
from the manager, so systemd cannot resolve supplementary groups and the unit
fails with status=216/GROUP. A probe unit confirmed the remaining sandbox
directives are all valid in user scope.

Verified: all three routes and the icon return 200 locally and the public
HTTPS URL returns 200. SIGKILL of the main PID recovered automatically within
the restart delay. `systemd-analyze verify` is clean.

Two follow-ups requiring root are recorded in web/README.md: the rootless
install must drop User= because a --user manager cannot setuid, and
`loginctl enable-linger umbra` is needed for the service to survive logout and
start at boot.
2026-09-26 19:16:16 -07:00
umbra audio
b474995414 Add phase-1 account management backend slice
Implements the account and user-management system behind the new web UI
against development repositories only. Nothing here connects to, reads, or
modifies the production umbracontrol database, and no account is created in
any approved environment.

Account service and API:
- Local temporary-password and OIDC enrollment, separated by type.
- Argon2id credentials, must-change enforcement, server-side sessions with
  hashed tokens, idle and absolute deadlines, rotation and revocation.
- Administrator account management: list, get, create, suspend, reactivate,
  deactivate, revoke sessions, org-scoped audit view.
- Hidden-prompt operator command for the first administrator, refusing
  production mode and refusing to run without a TTY.

Temporary passwords are never accepted from a client. The request schema has
no password field and refuses unknown fields; the administrator path generates
the one-time password in process, hashes it immediately, and returns it once in
the creation response. The account can reach no route, including
/api/auth/me, until it replaces that password, after which the one-time value
no longer authenticates.

Security posture: fail-closed configuration, Secure cookies in production,
double-submit CSRF, constant-time token comparison, generic authentication
errors, audit detail allow-listing, and logger-tree secret redaction.

Reviewed but unapplied: migrations/0001_phase1_identity.sql adds
users.username, a separate user_credentials table, and additive session
columns, with a rollback and open review questions. The PostgreSQL adapter is
deliberately absent, so the application refuses to start in production rather
than run against an unreviewed store.

Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff
format, compileall, and make web-check are clean.
2026-09-26 08:29:23 -07:00
umbra audio
37b2056808 Add UmbraTrans UI prototype and integration design 2026-09-26 07:57:11 -07:00
umbra audio
6a3baacb34 Harden agent communication validation 2026-09-24 16:24:18 -07:00
umbra audio
127a048cfe Restrict package discovery to agent_comm 2026-09-24 16:15:26 -07:00
umbra audio
8a61d9298b Add verified NAS backup automation 2026-09-24 16:13:53 -07:00
umbra audio
fc6c36331b Bootstrap UmbraTrans agent communication workspace 2026-09-24 16:09:23 -07:00