The "Lost OTP?" page writes a row when it mails a user their QR code link,
or fails to, under the anonymous requester and their address. A password
change refused for a wrong old password writes a failed row.
- libvirt errors all answered 400. A VM, pool or volume the host does not
have now answers 404, a volume in use 409 (vrtManager.util.InUse) and a
host that cannot be reached 503, which a client may retry.
- A template's power on or cycle and a stopped pool answered
{"status": ...} with 409; they answer {"detail": ...}, as every other
refusal. The schema documents these 409s (ErrorSerializer).
- The volume list and a volume of a stopped pool answered 200 with an
empty list or a made-up volume; they answer 409.
- An API URL that names nothing answered the HTML error page; it answers
a JSON 404 after the API's authentication.
- A session that must change its password was redirected to the HTML
form; an API request of it gets the JSON 403 a token gets.
Every upstream release and this fork's up to v2.0.0 created the first
user as admin/admin. The first-login password change only covered the
password the panel generates itself (data/admin_password), so an upgraded
installation whose admin never changed the default kept a working
admin/admin login with no prompt, on the pages and through API tokens.
Migration accounts.0013 marks a user named admin whose password is
"admin" with must_change_password: the next login goes to the password
change form, and the API refuses the user's tokens until then. Only that
one name is checked, so the migration hashes one password at most.
Found by upgrading an installation of the upstream release of
September 2026 (installer and Docker) to this version: admin/admin still
signed in.
- console settings were split over four files; who sees the form, the
listen address, and the settings themselves are test_console_settings,
and who may open a console is test_console_access;
- the three template files are test_templates;
- the quota lock test joins test_quota;
- accounts' test_security_fixes held two topics: the SSH key tests are
test_ssh_keys, the owner-row delete redirect is in test_views;
- instances' test_security_mutations, who may change a VM, is
test_permissions.
The suite runs the same 2097 tests.
- create_admin had a branch for `"test" in sys.argv` that gave the admin
the password "admin"; 17 places signed in with it, and failed silently
with ADMIN_PASSWORD set in the environment. The tests now make their
own superuser and force_login it, and the shipped code has no test
branch: the test runner gives migrate a password (also when
ADMIN_PASSWORD is set but empty), so no data/admin_password is written
into the checkout.
- AccountsTestCase builds its rows in setUpTestData instead of
setUpClass and deleting them by hand.
- accounts tests set OTP_ENABLED with override_settings instead of
assigning settings (one assignment was never undone, so later tests in
the process ran with OTP on), and undo their app_settings change. The
OTP login class reloads the URLs again only after the setting is off.
- computes/tests/test_views.py reached the machine's own libvirt
(localhost over the socket) when TEST_LIBVIRT_HOST was unset; its live
tests now run only with TEST_LIBVIRT_HOST, like the other live tests,
as the README says.
AUTH_PASSWORD_VALIDATORS was unset, so the user forms and the password
change accepted any password, a one-letter one included. The template
now sets Django's four standard validators: at least 8 characters, not
a common password, not only digits, not like the user's name or e-mail.
The tests that set weak passwords use ones the validators accept.
get_user_totp_device looked for the user's TOTP device and made one if
there was none; two requests at once could both make one, and both
devices' codes then logged in. It now does so under a lock on the user's
row.
A double click made two tokens: the second key was shown, the first
never, yet it stayed valid until it expired and took a place of the ten.
A user's token names are now unique, checked under a lock on the user's
row, which also keeps two forms sent at once from passing the limit
together. tr and zh too.
The Lost OTP? page, open without a login, and the admin's Email OTP QR
code mailed the QR code itself: the TOTP secret then stayed in the
mailbox and on every server the mail passed. The mail now carries a
signed link, good for an hour, to a page that shows the QR code (not
cached); the page shows nothing for an old or altered link or a user
without a device. tr and zh too.
python-ldap waits for a server without end, to connect and for each
answer: a directory that stopped answering held every login's thread.
The settings template's LDAP block sets 5 seconds to connect and 10 for
each answer.
EMAIL_TIMEOUT was not set, and Django's SMTP connection then waits for
a server without end: one that accepts the connection and stops
answering held the request's thread for good, also on the login page's
Email OTP form, which anyone may post. The settings template sets 10
seconds. An administrator whose mail fails is told why on the account
page instead of getting an error page.
django-axes wrote an AccessLog row at every successful login and nothing
removed them; the Logs page already records each login. Its access log
is now off; failed logins are still counted for the lock.
The page where a user changes their own password and the one where an
administrator sets a user's password shared an old card: a horizontal
form, a blue Cancel and a green Change. The administrator's had Django's
"Password-based authentication: Enabled/Disabled" with a paragraph about
other backends, and its password fields stayed while Disabled was chosen.
Both pages now have the user and group forms' layout, each its own
template. The administrator chooses how the user signs in, With a
password or Without a password (such as LDAP), and the password fields
and "must change it" show only with a password; Cancel goes back to the
user. A user's own page says that their other sessions end, or, when
they must change it, that they choose one before going on, without a
Cancel. A refused password is said at its field only, without the
general "Wrong Data Provided". The section styles of these forms move
into webvirtcloud.css.
The create and edit pages of a user had one form for both, ordered for
editing: permissions first, the name and password folded below them and
the quota folded and closed at the end; a new user's name came last.
Both pages now go Account (name, password, may sign in), Role, Quota,
then Groups and permissions, each section with what it is for beside its
fields. Template editor and Superuser become one Role choice with User:
choosing Superuser hides the quota and permissions, which a superuser
does not need (no quota check applies, every permission is theirs); it
keeps the is_staff it had. The quota is always open: each limit takes a
number from 0, or No limit, which is kept as -1 as before; memory is
entered in GiB and kept in MiB, shown exactly so saving a user again
changes nothing. The button says Create user or Save changes.
The pages many views share were a horizontal form whose Cancel went
back in a primary colour beside a green Save, and a delete question in
a yellow alert squeezed into half the width, which wrapped word by word
on a phone.
Both now sit in a centred column with their heading: the form's fields
with their labels above in a card, Cancel outlined and Save the primary
action; the delete page asks "Delete <name>?" in a card with a red
edge, says what deleting does, and offers Cancel and Delete.
The form for a user's rights on a VM names them as the VM's Users tab
does: Change settings, Delete the VM, and Change console settings,
which only counts together with Change settings.
A superuser's page of a user was a heading, two unlabelled icon buttons
and three tabs: the VMs a table of check columns, the SSH keys a table
that pushed the page sideways on a phone.
Now a card at the top shows the user's name, role, Blocked when they
are, username, email, groups, last login and joining date, with Edit
user and Email OTP QR code as labelled buttons and links to the sections
below with their counts. The VMs card shows each VM with its host and
what the user may do beyond power and console as badges in the VM Users
tab's words (Change settings, Delete the VM, Change console settings),
with Change access and Remove from user in a menu, and Add VM in its
header. The SSH keys and the API tokens are cards; the key rows are now
one include with the user's own Profile, and both lists say when they
are empty. The full key shows over its fingerprint.
The tab script had no page left and goes.
The Profile page was three tabs, each a box inside a box with horizontal
labels: the SSH keys a centred bare row with a grey trash icon, a tall
empty textarea under them, and the user known only by the heading.
Now the user's name, username, role, email and last login head a card
that holds the profile form and the password link; beside it the SSH
keys and the API tokens are cards with their count, a line on what
they are for, one row each with Delete in a menu, and the form to add
one behind a button. Adding or deleting a key returns to #sshkeys.
The token rows are a list-group include that the administrator's view
of a user shares.
The Users, Groups and Removed VMs pages were bare tables: rows of
coloured icon buttons without words, five columns of small checks for
a user's role and permissions, dates broken over three lines and a
lone "+" for a new entry. The owner chose a directory layout from three
rendered options; it follows the host pages' cards:
- Each list is a card with its count and search in the header and one
list-group item per entry, which stacks on a phone without scrolling.
- A user shows their role as a badge (Superuser, Template editor) or
"User", Blocked as a red badge, their groups, a granted clone
permission and live API tokens, and the last login on one line;
role and status are searchable. Edit is the visible action, the menu
holds View profile, Block or Unblock and Delete; Block and Delete
are disabled on one's own account, which the server refuses anyway.
- A group shows its member and permission counts; its name opens it.
The page's title said "Users".
- A removed VM shows its UUID, the host it left, its owners and when
the record was made and expires.
- Logs keep their table in a card, the server search in its header,
the count of matching rows, Done and Failed as badges, and a message
column wide enough to read on a phone.
The user form was one long column of horizontal fields. The owner
rejected three boxed layouts and chose one led by what the user may
do: the Active switch, Template editor and Superuser as two cards,
groups as chips, the user's own permissions as switches each saying
what it allows, then the name and password and the quota folded into
details whose summaries tell their values (-1 as "any"); they open on
a new user and on an error inside them. A refused group or permission
is said beside its field. The group form follows it: the name, what
its members may do, its members as chips. Cancel goes back to the
list instead of the browser history.
Settings > Users was a bold title, an unlabelled "+" and a table of
names with a trash icon. The owner chose this layout from three
rendered options: one list of owners, each with what it may do
(change settings, delete the VM, change console settings, or power and
console only), Edit rights and Remove, an empty state, and a labelled
"Add owner".
- Remove and Edit rights come back to the VM's Users tab: the return
address carried #users unencoded, so it was lost, and Edit rights
went to the user's account page. Both accounts views now go to a
?next page of this site.
- The add-owner list offers only users who are not owners yet and not
administrators, who can use every VM anyway; a superuser owner also
took the only place when a VM may have one owner.
- The unused del_owner view and URL are removed: the list uses the
accounts page, which asks before removing an owner.
XML:
- An XML edit not saved yet is kept: the page no longer reloads itself
when the VM's state changes, and the browser asks before leaving it.
A state change lost the edit.
- The button that opens the review dialog reads "Review changes"; it
did not save, as "Change" suggested. Closing the dialog gives focus
back to the editor.
- An SSH public key whose comment holds spaces (ssh-keygen -C "John Doe
laptop") is accepted; only the type and the key are checked.
- The created API token is shown by a GET after a redirect, so reloading
the page no longer creates a second token the user never sees.
- The OTP login page shows field errors instead of an empty error box.
- A user without a local password (an LDAP user) is not offered a password
change that could never succeed.
On a superuser's page of a user the VM permissions read True/False; they
are now check marks as on the Users list. A user without VMs gets "No VMs."
instead of an empty table. A superuser can delete one of the user's SSH
keys, as with API tokens: the same view takes the user, the row names
whose key it was.
Closes#218
"Lost OTP?" mails the QR code only when exactly one active user has the
address. Any user could enter another user's address in their profile and
silently switch the recovery off for both. The profile and the admin user
forms now refuse an address another user has, ignoring case; an empty
address stays allowed for everyone.
- The administrator's password form took "password-based
authentication: Disabled" with "must change it at the next login":
the change form asks for the old password, which the user then never
had, so they could not get past it. That combination is refused, and
an administrator cannot disable their own password.
- A user who must change the password could still use the API with
their tokens. A token is now refused until they change it, as the
pages are.
Which users can reach the API with a token was seen only by opening each
user. The user list now has an API tokens column: the number of the
user's tokens that still work (expired ones are left out; they are
deleted only at the user's next token), linked to the user's token tab.
One query counts them for all users.
The user page a superuser opens from the user list has an API Tokens
tab: the user's tokens with their name, first characters, creation,
expiry and last use, and a delete button for one that may have leaked.
The token itself is never shown; only its digest is kept.
The delete reuses the owner's delete view with the user's id; only a
superuser may name another user. The confirmation and the log row say
whose token it was ("API token backup of alice"); the owner's own
confirmation now reads "Delete "API token backup"?" too. The profile
and the user page share one token table, and one script that opens a
tab named in the address (#apitokens).
The API Tokens tab gets a "Last used" column, so an unused token can be
told from one a script still needs. The token's last use is written when
it authenticates, at most once a minute (accounts.auth.TokenAuthentication,
knox's class with that one addition; the API schema describes it as
knox's).
A user has at most ten tokens; a new one is refused until one is
deleted. Expired tokens do not count, and are deleted when a new token is
made.
knox's first migration creates its AuthToken table even when
KNOX_TOKEN_MODEL replaces it with accounts.ApiToken. The unused
knox_authtoken table references auth_user, and Django, which does not see
it, does not empty it with the others: on PostgreSQL every test that
empties the database failed ("cannot truncate a table referenced in a
foreign key constraint"). SQLite does not check this, so only CI's
PostgreSQL leg showed it. knox stays installed for its token code, with
its migrations turned off (MIGRATION_MODULES).
The REST API took only the login session, so a script could not use it
without a browser. A user now makes API tokens on the profile page (API
Tokens tab): a name, and an expiry of 30, 90 or 365 days or none. The
token is shown once, on a page that is not cached, and then kept only as
a SHA-512 digest; the list shows its name, first characters, creation
and expiry, and a token is deleted there. Tools send it as
"Authorization: Token wvc_...", and act as its user, with that user's
permissions. Making and deleting a token is logged, without the token.
A password alone (HTTP Basic) still does not reach the API, as it would
skip the OTP second factor: a token is made in a session that passed the
login and its OTP. The token class comes first, so a refused request gets
401 with its challenge instead of 403.
django-rest-knox 5.1.0 provides the tokens (accounts.ApiToken adds the
name); settings.py takes "knox", KNOX_TOKEN_MODEL and REST_KNOX from the
template.
Operations on a VM now write their rows the way the admin audit does: one
row per operation, done or failed with a safe reason, with the user, IP and
host. Each row names the VM ("VM web-1: delete snapshot snap1") and carries
its UUID in the new Logs.instance_uuid column.
- audit_vm and failed_vm wrap audit() and failed() for a VM. A failure the
view turns into a message is caught outside the block, so its failed row
is written; busy claims, permission and quota refusals and invalid input
write nothing. No password, key or XML goes into a row.
- All VM page operations move from addlogmsg to audit_vm, and addlogmsg
goes. QoS changes and defining a VM from XML are logged now; a gstfsd
error reply (root password, SSH key) is a failed row, not a done one.
- Partial results are rows of their own: the VM delete, then each disk
delete; the disk detach, then the volume delete; the migration, then a
failed autostart restore, NVRAM removal or record on the target.
- Migration rows use the compute name as the host column, as every other
row does.
- VM job rows (create, clone, snapshots) and their resolutions carry the
VM's UUID; snapshot job intents now record it. The owner rows of the VM
page and the accounts pages, and the ownership system rows, carry it too.
- The Logs tab loads vm_logs/<pk>/ and shows the rows with the VM's UUID,
so a rename keeps the history, two VMs with one name are told apart and
a VM on two computes is one VM. It gains a Result column. Rows from
before this change have no UUID and stay on the Logs page only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Django's login form gives the username its max_length after the field
is built, so a longer username was not refused and reached
authentication. django-axes then stored it in its 255-character column,
which PostgreSQL refuses: a login with a 300-character username was a
500 (SQLite does not enforce the length). Both login forms, with and
without OTP, now check the length, so such a username is a form error
and never reaches authentication, the lockout or the Logs page.
This also turns the PostgreSQL leg of CI green again: the S-16 test
from #167 hit exactly this.
With "Multiple Owner for VM" off, the ownership form counted the
ownership being edited as another owner, so changing the only owner's
rights (change, delete, console) always failed with "Instance owned by
another user". The check now leaves out the ownership being edited; a
second owner is still refused.
The Logs page now records the panel's own records too: users (add,
change with the changed fields, delete, block, unblock, an admin's
password set, a user's own password change including the first-login
one, own profile edits), groups with their members and permissions,
VM owners from the accounts pages and from the VM page, OTP QR code
mails, flavors from web and API, app settings and the theme,
removed-VM records and SSH keys. No row carries a password or a key.
- A user is created in one transaction: when a VM named in
NEW_USER_DEFAULT_INSTANCES is missing, no half-made user stays.
- The OTP mail checks the email address before it creates a device;
without one it writes a failed row and makes no device.
- The VM page's owner rows read "VM web-1 owner bob: add" and
"...: remove" instead of "Add owner: bob" and "Delete owner: 17"; a
second owner refused by the single-owner setting is a failed row.
Owner rows keep the VM's host and name, so the VM's log tab shows
them.
- A setting or theme posted with its current value is no change and
writes no row; the old free-text setting rows are gone.
Host-side objects (networks, storage, interfaces, secrets, filters)
follow in the next layer.
The Logs page shows who added, changed or deleted a compute, from web
or API, and every login, failed login, lockout and logout, with the
client address and a Result column (Done, Failed, Unknown for older
rows). A failed change is recorded with its reason and the error still
reaches the user; a refused one is recorded too.
- logs.audit.write saves each row in its own savepoint and never
raises, so a log write cannot break or undo the action it records.
The audit() context manager writes one row when the change ends.
- Logs gains success and ip; user, host and instance are wider and
date is indexed. The page orders by date then id.
- A compute edit names the changed fields; an edit that changes
nothing writes no row, and an empty password field is no change.
- A compute's VMs leaving the panel get a system row each: owners
kept on the Removed VMs page, moved to another compute, restored,
left without owners, or the removed record expired.
- Job rows carry the result. A job's log entry that cannot be
computed is logged and skipped instead of rolling back the finish
(test_service.py changes this on purpose).
- Logins are recorded by signal receivers; CustomLoginView is removed
and the plain LoginView is used. A wrong OTP code is a failed login,
counted by django-axes toward the lockout.
Panel records and host-side objects follow in the next layers.
The login page showed its sign-in card to a user who was already signed
in, with the navbar squeezed beside it. Both login views, with and
without OTP, now send that user to the page in next, or to the default
page when next is missing or points elsewhere. Every permission check
answers 403 instead of redirecting to the login, so this cannot loop.
The page test renders the login page signed out.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
131 test modules sat next to the code they test; instances alone had
51 of them beside views.py and utils.py. They move into a
tests/ package per app, and the JavaScript checks into
webvirtcloud/tests/js. Each non-empty tests.py becomes tests/test_views.py;
the empty generated stubs are removed.
Imports of the app's own modules become absolute, paths built from
__file__ go one level up, the live helper is instances.tests.livetest,
and coverage omits */tests/*. The suite discovers the same 960 tests as
before.