1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
This commit is contained in:
catborise 2026-11-07 21:08:24 +00:00 • committed by GitHub
commit a16e4ea02f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1309 changed files with 96979 additions and 281490 deletions

View file

@ -16,8 +16,15 @@
**/env*
**/.env*
# Local databases and runtime data
# Local databases and runtime data (secret key, admin password, update backups)
backups
**/*.sqlite3*
data
**/*.bak
**/*~
**/*.swp
**/*.swo
**/staticfiles
**/*.log
**/console/cert.pem*
**/dhcpd.*

View file

@ -1,5 +0,0 @@
ignored:
- DL3008
- DL3013
- DL3042
- SC1091

View file

@ -1,61 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
name: "CodeQL"
on:
push:
branches: [master]
pull_request:
branches-ignore: [master]
schedule:
- cron: '0 21 * * 3'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Override automatic language detection by changing the below list
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
language: ['javascript', 'python']
# Learn more...
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

View file

@ -1,92 +1,32 @@
###########################
###########################
## Linter GitHub Actions ##
###########################
###########################
name: Lint
name: Lint Code Base
#
# Documentation:
# https://help.github.com/en/articles/workflow-syntax-for-github-actions
#
#############################
# Start the job on all push #
#############################
# a branch with a pull request (also one stacked on another) is tested by its
# pull_request run; push runs for every branch tested each commit twice
on:
push:
branches: [master]
pull_request:
branches-ignore: [master]
###############
# Set the Job #
###############
# the jobs only read the code
permissions:
contents: read
jobs:
build:
name: Lint Code Base
# Set the agent to run on
ruff:
name: Ruff
runs-on: ubuntu-latest
##################
# Load all steps #
###################
steps:
##########################
# Checkout the code base #
##########################
- name: Checkout Code
uses: actions/checkout@v4
with:
# Full git history is needed to get a proper list of changed files within `super-linter`
fetch-depth: 0
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Required packages
run: |
sudo apt-get update -qq
sudo apt-get install -y python3-venv python3-virtualenv libvirt-dev libldap2-dev libsasl2-dev python3-lxml zlib1g-dev libxslt1-dev pkg-config gcc libssl-dev
- name: Create & Activate VENV
run: |
python3 -m venv venv
echo "$GITHUB_WORKSPACE/venv/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: |
pip3 install --upgrade pip setuptools wheel
if [ -f dev/requirements.txt ]; then pip3 install -r dev/requirements.txt; else pip3 install -r conf/requirements.txt; fi
################################
# Run Linter against code base #
################################
- name: Lint Code Base
uses: docker://github/super-linter:latest
env:
FILTER_REGEX_EXCLUDE: .*(static|scss|venv|locale|templates)/.*
DEFAULT_BRANCH: master
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VALIDATE_ALL_CODEBASE: false
VALIDATE_MARKDOWN: false
VALIDATE_HTML: false
VALIDATE_JSCPD: false
VALIDATE_ANSIBLE: false
VALIDATE_CLOJURE: false
VALIDATE_COFFEE: false
VALIDATE_DART: false
VALIDATE_GO: false
VALIDATE_JSX: false
VALIDATE_KOTLIN: false
VALIDATE_POWERSHELL: false
VALIDATE_PERL: false
VALIDATE_PHP: false
VALIDATE_RAKU: false
VALIDATE_RUBY: false
VALIDATE_TSX: false
VALIDATE_TERRAFORM: false
# Same ruff version as dev/requirements.txt
- name: Install Ruff
run: pip install "$(grep '^ruff==' dev/requirements.txt)"
- name: Run Ruff
run: ruff check --output-format=github .

View file

@ -1,19 +1,49 @@
name: Test Suite
# a branch with a pull request (also one stacked on another) is tested by its
# pull_request run; push runs for every branch tested each commit twice
on:
push:
branches: [master]
pull_request:
branches: [master]
# the jobs only read the code
permissions:
contents: read
jobs:
test:
name: Python ${{ matrix.python-version }}
name: Python ${{ matrix.python-version }} / ${{ matrix.database }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12']
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
database: [sqlite]
include:
- python-version: '3.12'
database: postgresql
# Used by the postgresql leg only; settings fall back to sqlite when
# WEBVIRTCLOUD_DB_HOST is empty.
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: webvirtcloud
POSTGRES_PASSWORD: webvirtcloud
POSTGRES_DB: webvirtcloud
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
WEBVIRTCLOUD_DB_HOST: ${{ matrix.database == 'postgresql' && 'localhost' || '' }}
WEBVIRTCLOUD_DB_PASSWORD: webvirtcloud
steps:
- name: Checkout Code
@ -24,28 +54,30 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: 'conf/requirements.txt'
cache-dependency-path: |
conf/requirements.txt
dev/requirements.txt
# Build headers for the sdist-only bindings (libvirt-python, python-ldap)
# A runner's package mirror sometimes stops answering: time out and
# retry instead of hanging until the job's 6-hour limit
- name: Install System Dependencies
timeout-minutes: 10
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
APT="-o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30"
sudo apt-get $APT update -qq
sudo apt-get $APT install -y --no-install-recommends \
libvirt-dev \
libldap2-dev \
libsasl2-dev \
python3-lxml \
zlib1g-dev \
libxslt1-dev \
pkg-config \
gcc \
libssl-dev \
libxml2-dev
gettext
- name: Install Python Dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
pip install -r conf/requirements.txt
if [ -f dev/requirements.txt ]; then pip install -r dev/requirements.txt; fi
python -m pip install --upgrade pip setuptools
pip install -r dev/requirements.txt
- name: Configure WebVirtCloud Settings
run: |
@ -53,14 +85,134 @@ jobs:
SECRET_KEY=$(python -c 'import secrets; print(secrets.token_urlsafe(50))')
sed -i "s|^SECRET_KEY = .*|SECRET_KEY = \"${SECRET_KEY}\"|" webvirtcloud/settings.py
- name: Check For Missing Migrations
run: python manage.py makemigrations --check --dry-run
- name: Run Django Migrations
run: |
python manage.py migrate --noinput
run: python manage.py migrate --noinput
- name: Run Django Test Suite
# Each catalog of LANGUAGES translates every message, and its compiled
# .mo is the .po's: a changed text needs its translations
- name: Check Translation Catalogs
if: matrix.python-version == '3.12' && matrix.database == 'sqlite'
run: |
python manage.py test
python manage.py makemessages -l tr -l zh --no-obsolete --ignore=staticfiles -v0
for dir in locale/tr/LC_MESSAGES locale/zh/LC_MESSAGES; do
msgfmt --check -o /tmp/django.mo "$dir/django.po"
missing=$(msgattrib --untranslated "$dir/django.po"; msgattrib --only-fuzzy "$dir/django.po")
if [ -n "$missing" ]; then echo "$missing"; echo "::error file=$dir/django.po::untranslated or fuzzy messages"; exit 1; fi
cmp <(msgunfmt "$dir/django.mo") <(msgunfmt /tmp/django.mo) || { echo "::error file=$dir/django.mo::not compiled from django.po (compilemessages)"; exit 1; }
done
- name: Run vrtManager Unit Tests
# Includes the vrtManager tests; fails below the coverage floor in pyproject.toml
- name: Run Test Suite
run: |
python -m unittest discover -s vrtManager -p "test_*.py"
coverage run manage.py test
coverage report --skip-covered
# The project test runner (webvirtcloud/test_runner.py) on the Docker
# image's Python, whose default start method its workers need changed
- name: Run Test Suite In Parallel
if: matrix.python-version == '3.14' && matrix.database == 'sqlite'
run: python manage.py test --parallel
# The per-file report of one leg, to see what a change left untested
- name: Store Coverage Report
if: always() && matrix.python-version == '3.12' && matrix.database == 'sqlite'
run: coverage html -d coverage-html --fail-under=0
- name: Upload Coverage Report
if: always() && matrix.python-version == '3.12' && matrix.database == 'sqlite'
uses: actions/upload-artifact@v4
with:
name: coverage-html
path: coverage-html
- name: Validate OpenAPI Schema
run: python manage.py spectacular --validate --fail-on-warn --file /dev/null
# Security warnings (HSTS, secure cookies) depend on the deployment;
# gate on errors only.
- name: Deployment Checks
run: python manage.py check --deploy --fail-level ERROR
# What installations get, resolved for this Python; not the CI tools
# (their fixes can need a newer Python than the leg's, as filelock's did)
- name: Audit Shipped Packages
run: pip-audit -r conf/requirements.txt -r conf/requirements-ldap.txt
docker:
name: Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
# Files that exist in a used checkout and must never enter the image
- name: Plant Secret Sentinels
run: |
mkdir -p data
for f in data/secret_key data/admin_password webvirtcloud/settings.py.bak \
'webvirtcloud/settings.py~' webvirtcloud/.settings.py.swp; do
echo sentinel > "$f"
done
- name: Build Image
run: docker build -t webvirtcloud:ci .
- name: No Runtime Data In Image
run: |
docker run --rm --entrypoint sh webvirtcloud:ci -c '
cd /srv/webvirtcloud
for f in data/secret_key data/admin_password webvirtcloud/settings.py.bak \
"webvirtcloud/settings.py~" webvirtcloud/.settings.py.swp; do
if [ -e "$f" ]; then echo "$f is in the image"; exit 1; fi
done'
# The default profile, and the HTTPS profile behind a TLS proxy
- name: Start Containers
run: |
docker run -d --name wvc -p 127.0.0.1:8080:80 webvirtcloud:ci
docker run -d --name wvc-https -e WEBVIRTCLOUD_HTTPS=1 webvirtcloud:ci
- name: Wait Until Healthy
run: |
for c in wvc wvc-https; do
for i in $(seq 40); do
[ "$(docker inspect -f '{{.State.Health.Status}}' $c)" = healthy ] && continue 2
sleep 5
done
docker inspect -f '{{json .State.Health}}' $c || true
docker exec $c sv status /etc/service/nginx /etc/service/webvirtcloud /etc/service/novnc || true
docker logs $c || true
exit 1
done
- name: Import Compiled Dependencies
run: docker exec wvc /srv/webvirtcloud/venv/bin/python3 -c "import libvirt, ldap, lxml.etree, psycopg"
# Static URLs carry no version: browsers must revalidate them
- name: Static Files Revalidated
run: |
url=http://127.0.0.1:8080/static/css/themes/zephyr.min.css
header() { tr -d '\r' | awk -v h="$1" 'tolower($0) ~ "^" h ":" {sub(/^[^:]*: */, ""); print}'; }
h=$(curl -s -m 5 -D - -o /dev/null "$url")
[ "$(echo "$h" | header cache-control)" = no-cache ] || { echo "$h"; exit 1; }
etag=$(echo "$h" | header etag)
[ -n "$etag" ]
h=$(curl -s -m 5 -D - -o /dev/null -H "If-None-Match: $etag" "$url")
echo "$h" | head -1 | grep -q " 304" || { echo "$h"; exit 1; }
[ "$(echo "$h" | header cache-control)" = no-cache ]
docker exec wvc sh -c 'echo "/* changed */" >> /srv/webvirtcloud/staticfiles/css/themes/zephyr.min.css'
h=$(curl -s -m 5 -D - -o /dev/null -H "If-None-Match: $etag" "$url")
echo "$h" | head -1 | grep -q " 200" || { echo "$h"; exit 1; }
new=$(echo "$h" | header etag)
[ -n "$new" ] && [ "$new" != "$etag" ]
# The browser reaches the console through nginx on the panel's port
- name: Console Through Nginx Only
run: |
[ "$(docker inspect -f '{{json .Config.ExposedPorts}}' webvirtcloud:ci)" = '{"80/tcp":{}}' ]
code=$(curl -s -m 5 -o /dev/null -w '%{http_code}' -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGVzdGtleXRlc3RrZXk9PQ==' http://127.0.0.1:8080/novncd/ || true)
[ "$code" = 101 ] || { echo "websocket upgrade through nginx: $code"; exit 1; }

18
.gitignore vendored
View file

@ -8,13 +8,29 @@ venv2
.webvirtcloud
*.pyc
db.sqlite3*
test_db*.sqlite3*
console/cert.pem*
tags
dhcpd.*
webvirtcloud/settings.py
*migrations/*
.coverage
htmlcov
*.log
templates/webvirtcloud.code-workspace
DEPENDENCY_UPGRADE_PLAN.md
AGENTS.md
ROADMAP.md
.agents
# Runtime data (secret key, etc.)
data/
# collectstatic output
/staticfiles/
# Local planning and agent workspace (superpowers)
/docs/superpowers/
/.superpowers/
# Panel backups (manage.py backup)
/backups/

View file

@ -1,5 +0,0 @@
image: gitpod/workspace-full
tasks:
- init: 'echo "TODO: Replace with init/build command"'
command: 'echo "TODO: Replace with command to start project"'

View file

@ -1,53 +1,57 @@
FROM phusion/baseimage:noble-1.0.2
EXPOSE 80
EXPOSE 6080
# Use baseimage-docker's init system.
CMD ["/sbin/my_init"]
RUN echo 'APT::Get::Clean=always;' >> /etc/apt/apt.conf.d/99AutomaticClean
FROM phusion/baseimage:resolute-1.0.23 AS build
# Compilers and headers for libvirt-python and python-ldap; they stay in this stage.
# hadolint ignore=DL3008
RUN apt-get update -qqy \
&& DEBIAN_FRONTEND=noninteractive apt-get -qyy install \
--no-install-recommends \
git \
python3-venv \
python3-pip \
python3-dev \
python3-lxml \
python3-libvirt \
libvirt-dev \
zlib1g-dev \
nginx \
pkg-config \
gcc \
libldap2-dev \
libssl-dev \
libsasl2-dev \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
WORKDIR /srv/webvirtcloud
COPY conf/requirements.txt conf/requirements-ldap.txt conf/
# hadolint ignore=DL3013,DL3042
RUN python3 -m venv venv \
&& venv/bin/pip install --no-cache-dir -U pip wheel \
&& venv/bin/pip install --no-cache-dir -r conf/requirements.txt -r conf/requirements-ldap.txt
FROM phusion/baseimage:resolute-1.0.23
EXPOSE 80
# Use baseimage-docker's init system. my_init force-stops services after
# KILL_PROCESS_TIMEOUT seconds (default 30); the worker drains jobs for 60 s.
ENV KILL_PROCESS_TIMEOUT=80
CMD ["/sbin/my_init"]
RUN echo 'APT::Get::Clean=always;' >> /etc/apt/apt.conf.d/99AutomaticClean
# Shared libraries of the compiled wheels, and nginx; no compilers or headers.
# hadolint ignore=DL3008
RUN apt-get update -qqy \
&& DEBIAN_FRONTEND=noninteractive apt-get -qyy install \
--no-install-recommends \
python3 \
nginx \
libvirt0 \
libsasl2-modules \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Setup webvirtcloud
WORKDIR /srv/webvirtcloud
# Install Python dependencies first with system-site-packages to leverage prebuilt bindings
COPY conf/requirements.txt conf/requirements.txt
# hadolint ignore=DL3013,DL3042,SC1091
RUN python3 -m venv --system-site-packages venv && \
. venv/bin/activate && \
pip3 install --no-cache-dir -U pip wheel && \
pip3 install --no-cache-dir -r conf/requirements.txt
# Copy application source
COPY --from=build /srv/webvirtcloud/venv venv
COPY . /srv/webvirtcloud
# Run collectstatic with temporary dummy key, then remove temporary settings file
# hadolint ignore=SC1091
RUN . venv/bin/activate && \
cp webvirtcloud/settings.py.template webvirtcloud/settings.py && \
SECRET_KEY="build-dummy-key-only-for-collectstatic" python3 manage.py collectstatic --noinput && \
RUN cp webvirtcloud/settings.py.template webvirtcloud/settings.py && \
SECRET_KEY="build-dummy-key-only-for-collectstatic" venv/bin/python3 manage.py collectstatic --noinput && \
rm -f webvirtcloud/settings.py && \
chown -R www-data:www-data /srv/webvirtcloud
@ -63,19 +67,33 @@ RUN mkdir -p /etc/my_init.d \
/etc/service/nginx \
/etc/service/nginx-log-forwarder \
/etc/service/webvirtcloud \
/etc/service/novnc
/etc/service/webvirtcloud-worker \
/etc/service/novnc \
/etc/service/socketiod
COPY conf/runit/10_webvirtcloud_init.sh /etc/my_init.d/10_webvirtcloud_init.sh
COPY conf/runit/nginx /etc/service/nginx/run
COPY conf/runit/nginx-log-forwarder /etc/service/nginx-log-forwarder/run
COPY conf/runit/novncd.sh /etc/service/novnc/run
COPY conf/runit/socketiod.sh /etc/service/socketiod/run
COPY conf/runit/webvirtcloud.sh /etc/service/webvirtcloud/run
COPY conf/runit/webvirtcloud-worker.sh /etc/service/webvirtcloud-worker/run
COPY --chmod=644 conf/runit/backup.cron /etc/cron.d/webvirtcloud-backup
COPY --chmod=755 conf/runit/healthcheck /usr/local/bin/webvirtcloud-healthcheck
RUN chmod +x /etc/my_init.d/10_webvirtcloud_init.sh \
/etc/service/nginx/run \
/etc/service/nginx-log-forwarder/run \
/etc/service/novnc/run \
/etc/service/webvirtcloud/run
/etc/service/socketiod/run \
/etc/service/webvirtcloud/run \
/etc/service/webvirtcloud-worker/run
# Declare mountable data directory for persistent SQLite and SSH keys
VOLUME ["/srv/webvirtcloud/data", "/var/www/.ssh"]
# The panel's page and every service (conf/runit/healthcheck). X-Forwarded-Proto:
# https keeps the probe from being redirected when the HTTPS profile is on
# (nginx passes the header on; see conf/nginx).
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD /usr/local/bin/webvirtcloud-healthcheck
# Declare mountable data directory for persistent SQLite, SSH keys and TLS certificates, and their backups
VOLUME ["/srv/webvirtcloud/data", "/var/www/.ssh", "/var/www/.pki", "/srv/webvirtcloud/backups"]
WORKDIR /srv/webvirtcloud

1122
README.md

File diff suppressed because it is too large Load diff

127
Vagrantfile vendored
View file

@ -1,56 +1,79 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
# Two disposable compute nodes for the live tests (README: Running tests), so
# that migration runs between them: AlmaLinux 10 with nested KVM, set up by
# dev/libvirt-bootstrap.sh, with a SASL user for TCP and the host user's SSH
# key for root (SASL user wvc, password wvc-test). The empty second disk
# /dev/vdb is for the LVM tests (TEST_LIBVIRT_LVM_DEVICE); each also serves a
# 1 GiB iSCSI LUN on 127.0.0.1 for the iSCSI tests (TEST_LIBVIRT_ISCSI_TARGET),
# exports /srv/wvc-nfs to 127.0.0.1 for the NFS pool test (TEST_LIBVIRT_NFS_EXPORT),
# and accepts TLS logins (SASL) with a certificate from a throwaway CA.
Vagrant.configure(2) do |config|
# Default machine, if name not specified...
config.vm.define "dev", primary: true do |dev|
dev.vm.box = "ubuntu/bionic64"
dev.vm.hostname = "webvirtcloud"
dev.vm.network "private_network", ip: "192.168.33.10"
dev.vm.provision "shell", inline: <<-SHELL
sudo sh /vagrant/dev/libvirt-bootstrap.sh
sudo sed -i 's/auth_tcp = \"sasl\"/auth_tcp = \"none\"/g' /etc/libvirt/libvirtd.conf
sudo service libvirt-bin restart
sudo adduser vagrant libvirtd
sudo apt-get -y install python3-virtualenv virtualenv python3-pip python3-dev python3-lxml libvirt-dev zlib1g-dev python3-guestfs
virtualenv -p python3 /vagrant/venv
source /vagrant/venv/bin/activate
pip3 install -r /vagrant/dev/requirements.txt
SHELL
end
# To start this machine run "vagrant up prod"
# To enter this machine run "vagrant ssh prod"
config.vm.define "prod", autostart: false do |prod|
prod.vm.box = "ubuntu/bionic64"
prod.vm.hostname = "webvirtcloud"
prod.vm.network "private_network", ip: "192.168.33.11"
prod.vm.network "forwarded_port", guest: 80, host: 8081
#prod.vm.synced_folder ".", "/srv/webvirtcloud"
prod.vm.provision "shell", inline: <<-SHELL
sudo mkdir /srv/webvirtcloud
sudo cp -R /vagrant/* /srv/webvirtcloud
sudo sh /srv/webvirtcloud/dev/libvirt-bootstrap.sh
sudo sed -i 's/auth_tcp = \"sasl\"/auth_tcp = \"none\"/g' /etc/libvirt/libvirtd.conf
sudo service libvirt-bin restart
sudo adduser vagrant libvirtd
sudo chown -R vagrant:vagrant /srv/webvirtcloud
sudo apt-get -y install python3-virtualenv python3-dev python3-lxml python3-pip virtualenv libvirt-dev zlib1g-dev libxslt1-dev nginx supervisor libsasl2-modules gcc pkg-config python3-guestfs
virtualenv -p python3 /srv/webvirtcloud/venv
source /srv/webvirtcloud/venv/bin/activate
pip3 install -r /srv/webvirtcloud/requirements.txt
sudo cp /srv/webvirtcloud/conf/supervisor/webvirtcloud.conf /etc/supervisor/conf.d
sudo cp /srv/webvirtcloud/conf/nginx/webvirtcloud.conf /etc/nginx/conf.d
sudo cp /srv/webvirtcloud/webvirtcloud/settings.py.template /srv/webvirtcloud/webvirtcloud/settings.py
sudo sed "s/SECRET_KEY = ''/SECRET_KEY = '"`python3 /srv/webvirtcloud/conf/runit/secret_generator.py`"'/" -i /srv/webvirtcloud/webvirtcloud/settings.py
python3 /srv/webvirtcloud/manage.py makemigrations
python3 /srv/webvirtcloud/manage.py migrate
python3 /srv/webvirtcloud/manage.py collectstatic --noinput
sudo rm /etc/nginx/sites-enabled/default
sudo chown -R www-data:www-data /srv/webvirtcloud
sudo service nginx restart
sudo service supervisor restart
SHELL
# The CA is made here once; each compute signs its own certificate with it.
# The client certificate sits where libvirt looks with HOME=.vagrant/tls.
pki = File.join(__dir__, ".vagrant/tls/.pki/libvirt")
unless File.exist?(File.join(pki, "clientcert.pem"))
# the host's openssl, not the one Vagrant puts first on PATH
Vagrant::Util::Env.with_original_env do
system("sh", "-ec", <<-SH, pki, exception: true)
mkdir -p "$0" && cd "$0"
openssl req -x509 -newkey rsa:3072 -nodes -keyout cakey.pem -out cacert.pem -days 365 -subj /CN=wvc-test-ca
openssl req -new -newkey rsa:3072 -nodes -keyout clientkey.pem -subj /CN=wvc-test-client |
openssl x509 -req -CA cacert.pem -CAkey cakey.pem -days 365 -out clientcert.pem
SH
end
end
Vagrant.configure(2) do |config|
config.vm.box = "almalinux/10"
%w[compute1 compute2].each do |name|
config.vm.define(name) { |node| node.vm.hostname = "wvc-#{name}" }
end
config.vm.synced_folder ".", "/vagrant", disabled: true
config.vm.provider :libvirt do |v|
v.memory = 4096
v.cpus = 2
v.nested = true
v.cpu_mode = "host-passthrough"
v.video_type = "virtio" # EL10 hosts have no cirrus
v.storage :file, size: "4G"
end
# the bootstrap installs this checkout's gstfsd
config.vm.provision "file", source: "dev", destination: "/tmp/wvc/dev"
config.vm.provision "file", source: "conf", destination: "/tmp/wvc/conf"
config.vm.provision "file", source: "~/.ssh/id_ed25519.pub", destination: "/tmp/wvc/key.pub"
config.vm.provision "file", source: "#{pki}/cacert.pem", destination: "/tmp/wvc/cacert.pem"
config.vm.provision "file", source: "#{pki}/cakey.pem", destination: "/tmp/wvc/cakey.pem"
config.vm.provision "shell", inline: <<-SHELL
set -e
sh /tmp/wvc/dev/libvirt-bootstrap.sh
virsh pool-info default > /dev/null 2>&1 || { virsh pool-define-as default dir --target /var/lib/libvirt/images && virsh pool-autostart default && virsh pool-start default; }
echo wvc-test | saslpasswd2 -a libvirt -p wvc
install -d -m 700 /root/.ssh
cat /tmp/wvc/key.pub >> /root/.ssh/authorized_keys
dnf -y install targetcli
systemctl enable --now target
T=iqn.2026-10.test.wvc:lun
targetcli ls /iscsi/$T > /dev/null 2>&1 || {
targetcli /backstores/fileio create wvc /var/lib/wvc-iscsi.img 1G
targetcli /iscsi create $T
targetcli /iscsi/$T/tpg1/portals delete 0.0.0.0 3260
targetcli /iscsi/$T/tpg1/portals create 127.0.0.1 3260
targetcli /iscsi/$T/tpg1/luns create /backstores/fileio/wvc
targetcli /iscsi/$T/tpg1 set attribute generate_node_acls=1 demo_mode_write_protect=0
targetcli saveconfig
}
dnf -y install nfs-utils
install -d /srv/wvc-nfs
echo "/srv/wvc-nfs 127.0.0.1(rw,no_root_squash)" > /etc/exports.d/wvc.exports
systemctl enable --now nfs-server
exportfs -r
install -D -m 644 /tmp/wvc/cacert.pem /etc/pki/CA/cacert.pem
install -d /etc/pki/libvirt/private
echo "subjectAltName=IP:$(hostname -I | cut -d' ' -f1)" > /tmp/wvc/ext
openssl req -new -newkey rsa:3072 -nodes -keyout /etc/pki/libvirt/private/serverkey.pem -subj /CN=$(hostname) |
openssl x509 -req -CA /tmp/wvc/cacert.pem -CAkey /tmp/wvc/cakey.pem -days 365 -extfile /tmp/wvc/ext -out /etc/pki/libvirt/servercert.pem
sed -i 's/^#auth_tls = "none"/auth_tls = "sasl"/' /etc/libvirt/virtproxyd.conf
# a running service would keep the old settings and refuse a new socket
systemctl stop virtproxyd.service
systemctl enable --now virtproxyd-tls.socket
SHELL
end

View file

@ -1 +0,0 @@
default_app_config = 'accounts.apps.AccountsConfig'

View file

@ -1,57 +1,105 @@
import os
from django.apps import AppConfig
from django.db.models.signals import post_migrate
from django.db import transaction
def apply_change_password(sender, **kwargs):
"""
Apply new change_password permission for all users
Depending on settings SHOW_PROFILE_EDIT_PASSWORD
"""
def admin_password_path():
from django.conf import settings
from django.contrib.auth.models import Permission, User
if hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"):
print("\033[1m! \033[92mSHOW_PROFILE_EDIT_PASSWORD is found inside settings.py\033[0m")
print("\033[1m* \033[92mApplying permission can_change_password for all users\033[0m")
users = User.objects.all()
permission = Permission.objects.get(codename="change_password")
if settings.SHOW_PROFILE_EDIT_PASSWORD:
print("\033[1m! \033[91mWarning!!! Setting to True for all users\033[0m")
for user in users:
user.user_permissions.add(permission)
else:
print("\033[1m* \033[91mWarning!!! Setting to False for all users\033[0m")
for user in users:
user.user_permissions.remove(permission)
print("\033[1m! Don`t forget to remove the option from settings.py\033[0m")
return os.path.join(str(settings.BASE_DIR), "data", "admin_password")
def _migrated(state_apps, model, field=None):
"""post_migrate follows every migrate, also targeted ones and rollbacks:
act only once the post-migration state has the model (and field)."""
if state_apps is None:
return False
try:
fields = state_apps.get_model(model)._meta.get_fields()
except LookupError:
return False
return field is None or any(f.name == field for f in fields)
def grant_change_password(sender, instance, created, raw=False, **kwargs):
"""New users may change their own password unless an admin takes the
permission away; the user form saves its own choice after this."""
if not created or raw:
return
from django.contrib.auth.models import Permission
permission = Permission.objects.filter(content_type__app_label="accounts", codename="change_password").first()
if permission is not None: # created by post_migrate on a fresh database
instance.user_permissions.add(permission)
def _store_generated_password(password):
"""
Write the generated admin password to data/admin_password (mode 0600)
instead of printing it, so it does not end up in install logs or
`docker logs`. Abort provisioning if private storage fails.
"""
path = admin_password_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
# Never write through a pre-existing file or symlink (this runs as
# root in Docker, while data/ belongs to www-data).
if os.path.lexists(path):
os.unlink(path)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "w") as f:
os.fchmod(f.fileno(), 0o600)
f.write(password + "\n")
print(f"\033[1m* \033[93mGenerated admin password written to {path}\033[0m")
except OSError as err:
raise RuntimeError(
f"Cannot securely store the generated admin password at {path}. "
"Fix the data directory permissions and run migrate again."
) from err
def create_admin(sender, **kwargs):
"""
Create initial admin user
"""
import os
import secrets
from django.contrib.auth.models import User
from accounts.models import UserAttributes
plan = kwargs.get("plan", [])
for migration, rolled_back in plan:
if (
migration.app_label == "accounts"
and migration.name == "0001_initial"
and not rolled_back
):
if User.objects.count() == 0:
print("\033[1m* \033[92mCreating default admin user\033[0m")
admin = User.objects.create_superuser("admin", None, "admin")
UserAttributes(
user=admin,
max_instances=-1,
max_cpus=-1,
max_memory=-1,
max_disk_size=-1,
).save()
break
# Any real migrate run provisions while no user exists, so a run that
# failed to store the password can simply be repeated. flush (used by
# tests) sends plan=None.
if kwargs.get("plan") is None:
return
state_apps = kwargs.get("apps")
if not (_migrated(state_apps, "auth.User") and _migrated(state_apps, "accounts.UserAttributes", "must_change_password")):
return
if User.objects.exists():
return
admin_user = os.environ.get("ADMIN_USERNAME", "admin")
admin_pass = os.environ.get("ADMIN_PASSWORD")
generated_password = not admin_pass
if generated_password:
admin_pass = secrets.token_urlsafe(16)
_store_generated_password(admin_pass)
print(f"\033[1m* \033[92mCreating default admin user '{admin_user}'\033[0m")
with transaction.atomic():
admin = User.objects.create_superuser(admin_user, None, admin_pass)
UserAttributes.objects.create(
user=admin,
must_change_password=generated_password,
max_instances=-1,
max_cpus=-1,
max_memory=-1,
max_disk_size=-1,
)
class AccountsConfig(AppConfig):
@ -60,4 +108,8 @@ class AccountsConfig(AppConfig):
def ready(self):
post_migrate.connect(create_admin, sender=self)
post_migrate.connect(apply_change_password, sender=self)
from django.conf import settings
from django.db.models.signals import post_save
post_save.connect(grant_change_password, sender=settings.AUTH_USER_MODEL)
from . import checks # noqa: F401

31
accounts/auth.py Normal file
View file

@ -0,0 +1,31 @@
"""API token authentication: knox's, noting when each token was last used."""
from datetime import timedelta
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from drf_spectacular.contrib.knox_auth_token import KnoxTokenScheme
from knox.auth import TokenAuthentication as KnoxTokenAuthentication
from rest_framework import exceptions
from accounts.models import UserAttributes
# a token's last use is written at most once a minute, not on every request
LAST_USED_EVERY = timedelta(minutes=1)
class TokenAuthentication(KnoxTokenAuthentication):
def validate_user(self, auth_token):
user, token = super().validate_user(auth_token)
# as the pages do (ForcePasswordChangeMiddleware), until the user changes the password
if UserAttributes.objects.filter(user=user, must_change_password=True).exists():
raise exceptions.AuthenticationFailed(_("Change your password before using the API."))
now = timezone.now()
if token.last_used is None or now - token.last_used >= LAST_USED_EVERY:
type(token).objects.filter(pk=token.pk).update(last_used=now)
return user, token
class TokenScheme(KnoxTokenScheme):
"""The API schema's description of these tokens, as of knox's."""
target_class = "accounts.auth.TokenAuthentication"

53
accounts/checks.py Normal file
View file

@ -0,0 +1,53 @@
from django.conf import settings
from django.core.checks import Error, Tags, Warning, register
@register(Tags.security)
def password_change_middleware_check(app_configs, **kwargs):
middleware = list(settings.MIDDLEWARE)
required = "accounts.middleware.ForcePasswordChangeMiddleware"
authentication = "django.contrib.auth.middleware.AuthenticationMiddleware"
if required not in middleware or (
authentication in middleware
and middleware.index(required) < middleware.index(authentication)
):
return [
Error(
"ForcePasswordChangeMiddleware must follow AuthenticationMiddleware.",
hint=f"Add '{required}' to MIDDLEWARE in webvirtcloud/settings.py, as settings.py.template does.",
id="accounts.E001",
)
]
return []
@register(Tags.security)
def login_required_middleware_check(app_configs, **kwargs):
middleware = list(settings.MIDDLEWARE)
required = "django.contrib.auth.middleware.LoginRequiredMiddleware"
old = "login_required.middleware.LoginRequiredMiddleware"
if old in middleware or required not in middleware:
return [
Error(
"MIDDLEWARE must use Django's LoginRequiredMiddleware.",
hint=f"In webvirtcloud/settings.py replace '{old}' with '{required}' "
"and delete LOGIN_REQUIRED_IGNORE_VIEW_NAMES, as settings.py.template does.",
id="accounts.E002",
)
]
return []
@register()
def obsolete_settings_check(app_configs, **kwargs):
if hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"):
return [
Warning(
"SHOW_PROFILE_EDIT_PASSWORD is no longer used.",
hint="Remove it from webvirtcloud/settings.py. Whether a user may change their own "
"password is the 'Can change password' permission, per user or group.",
id="accounts.W001",
)
]
return []

View file

@ -1,6 +1,8 @@
from appsettings.settings import app_settings
from django.contrib.auth import get_user_model
from django.forms import EmailField, Form, ModelForm, ValidationError
from django.contrib.auth.forms import AuthenticationForm
from django.core.validators import MaxLengthValidator
from django.forms import CharField, EmailField, Form, ModelForm, TypedChoiceField, ValidationError
from django.utils.translation import gettext_lazy as _
from .models import UserInstance, UserSSHKey
@ -20,7 +22,8 @@ class UserInstanceForm(ModelForm):
def clean_instance(self):
instance = self.cleaned_data["instance"]
if app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == "False":
exists = UserInstance.objects.filter(instance=instance)
# the ownership being edited is not another owner
exists = UserInstance.objects.filter(instance=instance).exclude(pk=self.instance.pk)
if exists:
raise ValidationError(_("Instance owned by another user"))
@ -29,9 +32,24 @@ class UserInstanceForm(ModelForm):
class Meta:
model = UserInstance
fields = "__all__"
# the rights beyond power and console, as the VM's Users tab names them
labels = {"is_change": _("Change settings"), "is_delete": _("Delete the VM"),
"is_vnc": _("Change console settings")}
help_texts = {"is_vnc": _("Only together with Change settings")}
class ProfileForm(ModelForm):
class UniqueEmailMixin:
""""Lost OTP?" mails only an address that is one user's: a second user
with the same address would switch it off for both."""
def clean_email(self):
email = self.cleaned_data["email"]
if email and get_user_model().objects.filter(email__iexact=email).exclude(pk=self.instance.pk).exists():
raise ValidationError(_("Another user has this email address"))
return email
class ProfileForm(UniqueEmailMixin, ModelForm):
class Meta:
model = get_user_model()
fields = ("first_name", "last_name", "email")
@ -71,5 +89,28 @@ class UserSSHKeyForm(ModelForm):
fields = ("keyname", "keypublic")
class ApiTokenForm(Form):
name = CharField(label=_("Name"), max_length=64, help_text=_("What uses it, such as a script or a host"))
days = TypedChoiceField(
label=_("Expires"), coerce=int, empty_value=None, required=False, initial=90,
choices=[(30, _("in 30 days")), (90, _("in 90 days")), (365, _("in a year")), ("", _("never"))],
)
class EmailOTPForm(Form):
email = EmailField(label=_("Email"))
class BoundedUsernameMixin:
"""Django's login form gives the username its max_length after the field
is built, so a longer one is not refused and reaches authentication;
django-axes then cannot store it (PostgreSQL refuses it: 500)."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
username = self.fields["username"]
username.validators.append(MaxLengthValidator(username.max_length))
class LoginForm(BoundedUsernameMixin, AuthenticationForm):
pass

54
accounts/lockout.py Normal file
View file

@ -0,0 +1,54 @@
"""
Settings for django-axes, read from the Settings page (AppSettings) on each
use, so an administrator can change them without a restart.
"""
from datetime import timedelta
from ipaddress import ip_address, ip_network
from appsettings.models import AppSettings
from django.conf import settings
DEFAULT_FAILURE_LIMIT = 5
DEFAULT_LOCKOUT_MINUTES = 15
def _positive_int(key, default):
try:
value = int(AppSettings.objects.get(key=key).value)
except (AppSettings.DoesNotExist, ValueError):
return default
return value if value > 0 else default
def failure_limit(request, credentials):
"""AXES_FAILURE_LIMIT: failed logins allowed before the lock."""
return _positive_int("LOGIN_FAILURE_LIMIT", DEFAULT_FAILURE_LIMIT)
def cooloff_time(request):
"""AXES_COOLOFF_TIME: how long a lock lasts."""
return timedelta(minutes=_positive_int("LOGIN_LOCKOUT_MINUTES", DEFAULT_LOCKOUT_MINUTES))
def _from_trusted_proxy(remote):
try:
address = ip_address(remote)
except ValueError:
return False
if address.is_loopback: # the bundled nginx
return True
return any(address in ip_network(net, strict=False) for net in getattr(settings, "LOGIN_TRUSTED_PROXIES", []))
def client_ip(request):
"""
AXES_CLIENT_IP_CALLABLE. Behind the bundled nginx every request comes from
127.0.0.1 and nginx sets X-Real-IP to the client's address (overwriting
any sent by the client). The header is used only for requests from that
proxy, or from the addresses in LOGIN_TRUSTED_PROXIES; from anyone else
it could be forged to escape a lock.
"""
remote = request.META.get("REMOTE_ADDR", "")
real_ip = request.META.get("HTTP_X_REAL_IP", "").strip()
return real_ip if real_ip and _from_trusted_proxy(remote) else remote

30
accounts/middleware.py Normal file
View file

@ -0,0 +1,30 @@
from django.http import JsonResponse
from django.shortcuts import redirect
from django.utils.translation import gettext as _
from django.urls import Resolver404, resolve
from accounts.models import UserAttributes
ALLOWED_VIEWS = {"accounts:change_password", "accounts:logout", "accounts:login"}
class ForcePasswordChangeMiddleware:
"""Send users who must change their password (the generated admin one, or one
an administrator set) to the change form."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated and UserAttributes.objects.filter(
user=request.user, must_change_password=True
).exists():
try:
view_name = resolve(request.path_info).view_name
except Resolver404:
view_name = None
if view_name not in ALLOWED_VIEWS:
if request.path_info.startswith("/api/"): # as for a token (accounts.auth)
return JsonResponse({"detail": _("Change your password before using the API.")}, status=403)
return redirect("accounts:change_password")
return self.get_response(request)

View file

@ -0,0 +1,41 @@
# Generated by Django 4.2.30 on 2026-09-27 10:18
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('accounts', '0005_auto_20200616_1039'),
]
operations = [
migrations.AlterField(
model_name='userattributes',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='userinstance',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='usersshkey',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='usersshkey',
name='keypublic',
field=models.TextField(max_length=16384, verbose_name='public key'),
),
migrations.AlterField(
model_name='usersshkey',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View file

@ -0,0 +1,44 @@
from pathlib import Path
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.db import migrations, models
def flag_legacy_generated_password(apps, schema_editor):
"""Preserve first-login enforcement for installations with a password file."""
path = Path(settings.BASE_DIR) / "data" / "admin_password"
try:
generated = path.read_text().strip()
except FileNotFoundError:
return
except OSError as err:
raise RuntimeError(
f"Cannot read {path}; run migrate as its owner to preserve first-login enforcement."
) from err
if not generated:
return
alias = schema_editor.connection.alias
User = apps.get_model(*settings.AUTH_USER_MODEL.split("."))
Attributes = apps.get_model("accounts", "UserAttributes")
for user in User.objects.using(alias).filter(is_superuser=True):
if check_password(generated, user.password):
attributes, _ = Attributes.objects.using(alias).get_or_create(
user_id=user.pk
)
attributes.must_change_password = True
attributes.save(using=alias, update_fields=["must_change_password"])
class Migration(migrations.Migration):
dependencies = [
("accounts", "0006_alter_userattributes_id_alter_userinstance_id_and_more")
]
operations = [
migrations.AddField(
model_name="userattributes",
name="must_change_password",
field=models.BooleanField(default=False),
),
migrations.RunPython(flag_legacy_generated_password, migrations.RunPython.noop),
]

View file

@ -0,0 +1,31 @@
from django.conf import settings
from django.db import migrations
def apply_setting_once(apps, schema_editor):
"""SHOW_PROFILE_EDIT_PASSWORD was replaced by the change_password
permission, but its value was applied to every user on each migrate,
undoing per-user and per-group choices. Apply it one last time."""
if not hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"):
return
alias = schema_editor.connection.alias
Permission = apps.get_model("auth", "Permission")
User = apps.get_model(*settings.AUTH_USER_MODEL.split("."))
permission = Permission.objects.using(alias).filter(
content_type__app_label="accounts", codename="change_password"
).first()
if permission is None: # fresh database: no users and no permissions yet
return
for user in User.objects.using(alias).all():
if settings.SHOW_PROFILE_EDIT_PASSWORD:
user.user_permissions.add(permission)
else:
user.user_permissions.remove(permission)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0007_userattributes_must_change_password"),
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [migrations.RunPython(apply_setting_once, migrations.RunPython.noop)]

View file

@ -0,0 +1,30 @@
# Generated by Django 5.2.18 on 2026-10-24 19:58
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_apply_show_profile_edit_password_once'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ApiToken',
fields=[
('digest', models.CharField(max_length=128, primary_key=True, serialize=False)),
('token_key', models.CharField(db_index=True, max_length=25)),
('created', models.DateTimeField(auto_now_add=True)),
('expiry', models.DateTimeField(blank=True, null=True)),
('name', models.CharField(max_length=64, verbose_name='name')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_token_set', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created'],
},
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.18 on 2026-10-24 20:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0009_api_token'),
]
operations = [
migrations.AddField(
model_name='apitoken',
name='last_used',
field=models.DateTimeField(blank=True, null=True, verbose_name='last used'),
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 5.2.18 on 2026-10-25 19:29
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0010_api_token_last_used'),
]
operations = [
migrations.AlterField(
model_name='userattributes',
name='max_cpus',
field=models.IntegerField(default=2, help_text='-1: no limit; 0: none allowed', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max CPUs'),
),
migrations.AlterField(
model_name='userattributes',
name='max_disk_size',
field=models.IntegerField(default=20, help_text='-1: no limit; 0: none allowed', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max disk size (GiB)'),
),
migrations.AlterField(
model_name='userattributes',
name='max_instances',
field=models.IntegerField(default=2, help_text='-1: no limit; 0: none allowed', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max instances'),
),
migrations.AlterField(
model_name='userattributes',
name='max_memory',
field=models.IntegerField(default=2048, help_text='-1: no limit; 0: none allowed', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max memory (MiB)'),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 5.2.18 on 2026-11-06 07:57
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0011_quota_help'),
('instances', '0014_instancetombstone_host'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterUniqueTogether(
name='userinstance',
unique_together=set(),
),
migrations.AddConstraint(
model_name='userinstance',
constraint=models.UniqueConstraint(fields=('user', 'instance'), name='unique_user_instance'),
),
]

View file

@ -0,0 +1,19 @@
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.db import migrations
def flag_default_admin_password(apps, schema_editor):
"""Versions up to v2.0.0 created the first user as admin/admin. An
installation that never changed it must replace it at the next login."""
alias = schema_editor.connection.alias
User = apps.get_model(*settings.AUTH_USER_MODEL.split("."))
Attributes = apps.get_model("accounts", "UserAttributes")
for user in User.objects.using(alias).filter(username="admin"):
if check_password("admin", user.password):
Attributes.objects.using(alias).update_or_create(user_id=user.pk, defaults={"must_change_password": True})
class Migration(migrations.Migration):
dependencies = [("accounts", "0012_user_instance_constraint")]
operations = [migrations.RunPython(flag_default_admin_password, migrations.RunPython.noop)]

View file

@ -3,6 +3,7 @@ from django.core.validators import MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from instances.models import Instance
from knox.models import AbstractAuthToken
class UserInstanceManager(models.Manager):
@ -26,13 +27,13 @@ class UserInstance(models.Model):
}
class Meta:
unique_together = ["user", "instance"]
constraints = [models.UniqueConstraint(fields=["user", "instance"], name="unique_user_instance")]
class UserSSHKey(models.Model):
user = models.ForeignKey(User, on_delete=models.DO_NOTHING)
user = models.ForeignKey(User, on_delete=models.CASCADE)
keyname = models.CharField(_("key name"), max_length=25)
keypublic = models.CharField(_("public key"), max_length=500)
keypublic = models.TextField(_("public key"), max_length=16384)
def __str__(self):
return self.keyname
@ -41,28 +42,29 @@ class UserSSHKey(models.Model):
class UserAttributes(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
can_clone_instances = models.BooleanField(default=True)
must_change_password = models.BooleanField(default=False)
max_instances = models.IntegerField(
_("max instances"),
default=2,
help_text=_("-1 for unlimited. Any integer value"),
help_text=_("-1: no limit; 0: none allowed"),
validators=[MinValueValidator(-1)],
)
max_cpus = models.IntegerField(
_("max CPUs"),
default=2,
help_text=_("-1 for unlimited. Any integer value"),
help_text=_("-1: no limit; 0: none allowed"),
validators=[MinValueValidator(-1)],
)
max_memory = models.IntegerField(
_("max memory"),
_("max memory (MiB)"),
default=2048,
help_text=_("-1 for unlimited. Any integer value"),
help_text=_("-1: no limit; 0: none allowed"),
validators=[MinValueValidator(-1)],
)
max_disk_size = models.IntegerField(
_("max disk size"),
_("max disk size (GiB)"),
default=20,
help_text=_("-1 for unlimited. Any integer value"),
help_text=_("-1: no limit; 0: none allowed"),
validators=[MinValueValidator(-1)],
)
@ -80,3 +82,15 @@ class PermissionSet(models.Model):
permissions = (("change_password", _("Can change password")),)
managed = False
class ApiToken(AbstractAuthToken):
"""A token for the REST API, kept as a digest (django-rest-knox); the
name says what uses it."""
LIMIT = 10 # per user, expired ones not counted
name = models.CharField(_("name"), max_length=64)
last_used = models.DateTimeField(_("last used"), null=True, blank=True)
class Meta:
ordering = ["-created"]

45
accounts/otp.py Normal file
View file

@ -0,0 +1,45 @@
"""The login form used when OTP_ENABLED.
django-otp's ``OTPAuthenticationForm`` requires every user to present a token
and, since 1.6, to name the device explicitly, so the single-field login page
cannot satisfy it and a user who never enrolled is locked out. This form makes
OTP per user: a user with a confirmed TOTP device must enter a valid code, one
without a device logs in with the password alone. The device is chosen by the
server, so the page needs only the token field and a posted device id (which
django-otp does not check for confirmed status or ownership-by-type) is
ignored.
"""
from django.contrib.auth.signals import user_login_failed
from django.core.exceptions import ValidationError
from django_otp.forms import OTPAuthenticationForm
from django_otp.plugins.otp_totp.models import TOTPDevice
from accounts.forms import BoundedUsernameMixin
class SingleDeviceOTPForm(BoundedUsernameMixin, OTPAuthenticationForm):
def _confirmed_totp(self, user, for_verify=False):
"""The user's oldest confirmed TOTP device, or None. With for_verify
the row is locked (select_for_update) so the token's replay and
throttling counters cannot race; call it only inside clean_otp's
transaction."""
devices = TOTPDevice.objects.filter(user=user, confirmed=True)
if for_verify:
devices = devices.select_for_update()
return devices.order_by("id").first()
def _chosen_device(self, user):
# Ignore a posted otp_device: it is verified within clean_otp's
# transaction, where the lock belongs.
return self._confirmed_totp(user, for_verify=True)
def clean_otp(self, user):
if user is not None and self._confirmed_totp(user) is None:
return # no confirmed device: the password is the only factor
try:
return super().clean_otp(user)
except ValidationError:
# a wrong code is a failed login: counted by django-axes and logged
user_login_failed.send(sender=__name__, credentials={"username": user.get_username()}, request=self.request)
raise

View file

@ -2,92 +2,96 @@
{% load i18n %}
{% load bootstrap_icons %}
{% load qr_code %}
{% block title %}{% trans "User Profile" %} - {{ user }}{% endblock %}
{% block page_heading %}{% trans "User Profile" %}: {{ user }}{% endblock page_heading %}
{% block page_heading_extra %}
{% if otp_enabled %}
<a href="{% url 'accounts:admin_email_otp' user.id %}" class="btn btn-secondary" title="{% trans "Email OTP QR code" %}">
{% bs_icon 'qr-code' %}
</a>
{% endif %}
<a href="{% url 'admin:user_update' user.id %}?next={% url 'accounts:account' user.id %}" class="btn btn-primary" title="{% trans "Edit user" %}">
{% bs_icon 'pencil' %}
</a>
<a href="{% url 'accounts:user_instance_create' user.id %}" class="btn btn-success" title="{% trans "Create user instance" %}">
{% bs_icon 'plus-circle-fill' %}
</a>
{% endblock page_heading_extra %}
{% block page_heading %}{% trans "User Profile" %}{% endblock page_heading %}
{% block content %}
<ul class="nav nav-tabs">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#instances" type="button" role="tab" aria-controls="instances" aria-selected="true">
{% trans "Instances" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#public-keys" type="button" role="tab" aria-controls="public-keys" aria-selected="false">
{% trans "Public Keys" %}
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="instances">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">{% trans "Instance" %}</th>
<th scope="col">{% trans "VNC" %}</th>
<th scope="col">{% trans "Resize" %}</th>
<th scope="col">{% trans "Delete" %}</th>
<th scope="colgroup" colspan="2">{% trans "Action" %}</th>
</tr>
</thead>
<tbody>
{% for inst in user_insts %}
<tr>
<td>{{ forloop.counter }}</td>
<td><a href="{% url 'instances:instance' inst.instance.id %}">{{ inst.instance.name }}</a></td>
<td>{{ inst.is_vnc }}</td>
<td>{{ inst.is_change }}</td>
<td>{{ inst.is_delete }}</td>
<td style="width:5px;">
<a href="{% url 'accounts:user_instance_update' inst.id %}" class="btn btn-sm btn-secondary" title="{% trans "edit" %}">
{% bs_icon 'pencil' %}
</a>
</td>
<td style="width:5px;">
<a class="btn btn-sm btn-secondary" href="{% url 'accounts:user_instance_delete' inst.id %}" title="{% trans "Delete" %}">
{% bs_icon 'trash' %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tab-pane fade" id="public-keys">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">{% trans "Key name" %}</th>
<th scope="col">{% trans "Public key" %}</th>
</tr>
</thead>
<tbody>
{% for publickey in publickeys %}
<tr>
<td>{{ publickey.keyname }}</td>
<td title="{{ publickey.keypublic }}">{{ publickey.keypublic|truncatechars:64 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<section class="card mb-4" aria-labelledby="identity-title">
<div class="card-body">
<div class="row g-3 align-items-start">
<div class="col-lg-8">
<div class="d-flex flex-wrap align-items-center gap-2">
<h2 class="h4 mb-0 text-break" id="identity-title">{{ user.get_full_name|default:user.username }}</h2>
{% if user.is_superuser %}
<span class="badge text-bg-secondary">{% trans "Superuser" %}</span>
{% elif user.is_staff %}
<span class="badge text-bg-secondary">{% trans "Template editor" %}</span>
{% endif %}
{% if not user.is_active %}<span class="badge text-bg-danger">{% trans "Blocked" %}</span>{% endif %}
</div>
<div class="d-flex flex-wrap align-items-center gap-2 small mt-2">
<span class="text-body-secondary text-break">{{ user.username }}</span>
{% if user.email %}<span class="text-break">{{ user.email }}</span>{% endif %}
{% for group in user.groups.all %}<span class="badge text-bg-light border mw-100 text-wrap text-break">{{ group.name }}</span>{% endfor %}
</div>
<div class="d-flex flex-wrap gap-2 gap-md-4 small text-body-secondary mt-3">
<span>{% trans "Last login" %} {{ user.last_login|date:"Y-m-d H:i"|default:_("never") }}</span>
<span>{% trans "Joined" %} {{ user.date_joined|date:"Y-m-d" }}</span>
</div>
</div>
<div class="col-lg-4 d-flex flex-wrap justify-content-lg-end gap-2">
<a href="{% url 'admin:user_update' user.id %}?next={% url 'accounts:account' user.id %}" class="btn btn-primary">{% bs_icon 'pencil' %} {% trans "Edit user" %}</a>
{% if otp_enabled %}
<form method="post" action="{% url 'accounts:admin_email_otp' user.id %}">{% csrf_token %}
<button type="submit" class="btn btn-outline-secondary">{% bs_icon 'qr-code' %} {% trans "Email OTP QR code" %}</button>
</form>
{% endif %}
</div>
</div>
</div>
<nav class="card-footer d-flex flex-wrap gap-3 small" aria-label="{% trans 'User sections' %}">
<a href="#instances">{% trans "VMs" %} <span class="badge text-bg-light border">{{ user_insts|length }}</span></a>
<a href="#public-keys">{% trans "SSH Keys" %} <span class="badge text-bg-light border">{{ publickeys|length }}</span></a>
<a href="#apitokens">{% trans "API Tokens" %} <span class="badge text-bg-light border">{{ api_tokens|length }}</span></a>
</nav>
</section>
<section class="card mb-4" id="instances" aria-labelledby="instances-title">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<h2 class="h5 mb-0" id="instances-title">{% trans "VMs" %} <span class="badge text-bg-light border">{{ user_insts|length }}</span></h2>
<a href="{% url 'accounts:user_instance_create' user.id %}" class="btn btn-sm btn-primary">{% bs_icon 'plus-lg' %} {% trans "Add VM" %}</a>
</div>
<div class="list-group list-group-flush">
{% for inst in user_insts %}
<div class="list-group-item py-3">
<div class="d-flex align-items-start align-items-md-center gap-3">
<div class="row g-2 align-items-center flex-grow-1" style="min-width: 0;">
<div class="col-12 col-md-6">
<a class="fw-semibold text-break" href="{% url 'instances:instance' inst.instance.id %}">{{ inst.instance.name }}</a>
<div class="small text-body-secondary text-break">{% trans "Host" %}: {{ inst.instance.compute.name }}</div>
</div>
<div class="col-12 col-md-6 d-flex flex-wrap gap-1">
{# the rights as the VM's Users tab names them: every owner powers the VM and opens its console #}
{% if inst.is_change %}<span class="badge text-bg-light border">{% trans "Change settings" %}</span>{% endif %}
{% if inst.is_delete %}<span class="badge text-bg-light border">{% trans "Delete the VM" %}</span>{% endif %}
{% if inst.is_change and inst.is_vnc %}<span class="badge text-bg-light border">{% trans "Change console settings" %}</span>{% endif %}
{% if not inst.is_change and not inst.is_delete %}<span class="small text-body-secondary">{% trans "Power and console only" %}</span>{% endif %}
</div>
</div>
<div class="dropdown flex-shrink-0">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
aria-label="{% blocktrans with name=inst.instance.name %}Actions for {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'accounts:user_instance_update' inst.id %}">{% trans "Change access" %}</a></li>
<li><a class="dropdown-item text-danger" href="{% url 'accounts:user_instance_delete' inst.id %}">{% trans "Remove from user" %}</a></li>
</ul>
</div>
</div>
</div>
{% empty %}
<div class="list-group-item text-body-secondary">{% trans "No VMs." %}</div>
{% endfor %}
</div>
</section>
<section class="card mb-4" id="public-keys" aria-labelledby="public-keys-title">
<div class="card-header"><h2 class="h5 mb-0" id="public-keys-title">{% trans "SSH Keys" %} <span class="badge text-bg-light border">{{ publickeys|length }}</span></h2></div>
<div class="list-group list-group-flush">{% include "ssh_key_list.html" with owner=user %}</div>
</section>
<section class="card" id="apitokens" aria-labelledby="apitokens-title">
<div class="card-header"><h2 class="h5 mb-0" id="apitokens-title">{% trans "API Tokens" %} <span class="badge text-bg-light border">{{ api_tokens|length }}</span></h2></div>
<div class="list-group list-group-flush">{% include "api_token_list.html" with owner=user %}</div>
</section>
{% endblock content %}

View file

@ -1,34 +1,39 @@
{% extends "base.html" %}
{% load django_bootstrap5 %}
{% load i18n %}
{% load bootstrap_icons %}
{% block title %}{%trans "Change Password" %}{% endblock title %}
{% block title %}{% trans "Change Password" %}{% endblock title %}
{% block page_heading %}{% trans "Change Password" %}{% endblock page_heading %}
{% block content %}
<div class="row">
<div class="offset-2 col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="card-title">{%trans "Change Password" %}: {{ user }}</h4>
{# a user changes their own password; one who must (a new or reset account) cannot go elsewhere first #}
<form id="password-change" method="post">
{% csrf_token %}
{% bootstrap_form_errors form type="non_fields" %}
<section class="form-section">
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1 text-break">{{ request.user.username }}</h2>
<p class="small text-body-secondary mb-0">
{% if must_change %}{% trans "Choose a new password before you go on." %}{% else %}{% trans "You stay signed in here; your other sessions end." %}{% endif %}
</p>
</div>
<div class="card-body">
<form method="post" id="password-change">
{% csrf_token %}
{% bootstrap_form form layout='horizontal' %}
</form>
</div>
<div class="card-footer">
<div class="float-end">
<a class="btn btn-primary" href="javascript:history.back()">{% bs_icon 'x' %}
{% trans "Cancel" %}</a>
<button type="submit" form="password-change" class="btn btn-success">
{% bs_icon 'check2' %} {% trans "Change" %}
</button>
<div class="col-lg-9 form-section-fields">
<div class="row g-3">
<div class="col-sm-6">{% bootstrap_field form.old_password %}</div>
<div class="w-100 m-0"></div>
<div class="col-sm-6">{% bootstrap_field form.new_password1 %}</div>
<div class="col-sm-6">{% bootstrap_field form.new_password2 show_help=False %}</div>
</div>
</div>
</div>
</section>
<div class="form-section d-flex justify-content-end gap-2">
{% if not must_change %}<a class="btn btn-outline-secondary" href="{% url 'accounts:profile' %}">{% trans "Cancel" %}</a>{% endif %}
<button type="submit" class="btn btn-primary">{% trans "Change password" %}</button>
</div>
</div>
{% endblock content %}
</form>
{% endblock content %}

View file

@ -1,9 +0,0 @@
{% load i18n %}
{% load qr_code %}
{% blocktrans %}
Scan this QR code to get OTP for account '{{ user }}'
{% endblocktrans %}
<br>
{% qr_from_text totp_url %}
<p class="small">{% trans 'Some e-mail clients does not render SVG, also generating PNG.' %}</p>
{% qr_from_text totp_url size="s" image_format="png" error_correction="M" %}

View file

@ -22,7 +22,7 @@
</div>
<div class="card-footer">
<div class="mb-0 float-end">
<a class="btn btn-primary" href="javascript:history.back()">{% bs_icon 'arrow-left' %} {% trans "Cancel" %}</a>
<a class="btn btn-primary" href="#" data-history-back>{% bs_icon 'arrow-left' %} {% trans "Cancel" %}</a>
<button type="submit" form="create-update" class="btn btn-success">
{% bs_icon 'envelope-open' %} {% trans "Send" %}
</button>

View file

@ -1,57 +1,77 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% load i18n %}
{% load django_bootstrap5 %}
{% block title %}{% trans "WebVirtCloud" %} - {% trans "Sign In with OTP" %}{% endblock title %}
{% block style %}
<link href="{% static "css/signin.css" %}" rel="stylesheet">
{% endblock style %}
{% block content %}
<div class="login-box">
<div class="page-header">
<a href="/"><h1>WebVirtCloud</h1></a>
</div>
<hr>
<div class="col-12" role="main">
{% if form.errors %}
<div class="alert alert-danger" role="alert">
{% bootstrap_form_errors form %}
</div>
{% endif %}
<div class="row justify-content-center pt-5">
<div class="col-sm-9 col-md-7 col-lg-6">
<div class="card shadow-sm">
<div class="card-body p-4 p-sm-5">
<div class="text-center mb-4">
<div class="d-inline-block rounded-3 bg-primary text-white fs-2 lh-1 p-3 mb-3"><i class="bi bi-shield-check"></i></div>
<h1 class="h3 fw-bold mb-1">{% trans "Two-Factor Auth" %}</h1>
<p class="text-body-secondary small mb-0">{% trans "Enter your authentication code to continue" %}</p>
</div>
<form id="form-signin" class="form-signin" method="post" role="form" aria-label="Login form">{% csrf_token %}
<h2 class="form-signin-heading">{% trans "Sign In" %}</h2>
{% bootstrap_field form.username layout='inline' %}
{% bootstrap_field form.password layout='inline' %}
{% bootstrap_field form.otp_token layout='inline'%}
<a href="{% url 'accounts:email_otp' %}" class="float-end">{% trans "I do not have/lost my OTP!" %}</a>
<br>
<div class="d-grid">
<button id="btn-signin" class="btn btn-lg btn-success" type="submit">{% trans "Sign In" %}</button>
{% if form.errors %}
<div class="alert alert-danger d-flex align-items-center mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill fs-5 me-2 flex-shrink-0"></i>
<div>{% bootstrap_form_errors form %}</div>
</div>
{% endif %}
<form id="form-signin" method="post" aria-label="OTP Login form">
{% csrf_token %}
<div class="mb-3">
<label class="form-label small fw-medium" for="id_username">{% trans "Username" %}</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input type="text" id="id_username" class="form-control" name="username" value="{{ form.username.value|default:'' }}" placeholder="{% trans 'Enter username' %}" autocapitalize="none" autocorrect="off" required>
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-medium" for="id_password">{% trans "Password" %}</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-shield-lock"></i></span>
<input type="password" id="id_password" class="form-control" name="password" placeholder="{% trans 'Enter password' %}" required>
<button class="btn btn-outline-secondary" type="button" id="btn-toggle-pwd" tabindex="-1" aria-label="{% trans 'Toggle password visibility' %}">
<i class="bi bi-eye" id="toggle-icon"></i>
</button>
</div>
</div>
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label small fw-medium mb-0" for="id_otp_token">{% trans "Security Code" %}</label>
<a href="{% url 'accounts:email_otp' %}" class="small">{% trans "Lost OTP?" %}</a>
</div>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="text" id="id_otp_token" class="form-control font-monospace" name="otp_token" placeholder="000000" inputmode="numeric" pattern="[0-9]*" maxlength="6" autofocus>
</div>
<div class="form-text">{% trans "Leave empty if you have not set up two-factor authentication." %}</div>
</div>
<button id="btn-signin" class="btn btn-primary w-100 py-2" type="submit" data-busy-text="{% trans "Verifying..." %}">
<i class="bi bi-shield-lock-fill me-1"></i>
<span id="btn-text">{% trans "Verify & Sign In" %}</span>
</button>
</form>
<div class="d-flex align-items-center small text-body-secondary border-top mt-4 pt-3">
<span class="d-inline-block rounded-circle p-1 bg-success me-2" aria-hidden="true"></span>
{% trans "2FA Protection Active" %}
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock content %}
{% endblock %}
{% block script %}
<script>
$(document).ready(function() {
$("#btn-signin").click(function() {
// disable button
$(this).prop("disabled", true);
// add spinner to button
$(this).html(
`
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{% trans "Loading" %}...
`
);
$("#form-signin").submit();
});
});
</script>
{% endblock script%}
<script src="{% static 'js/login_form.js' %}"></script>
{% endblock script %}

View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "OTP QR Code" %}{% endblock %}
{% block page_heading %}{% trans "OTP QR Code" %}{% endblock page_heading %}
{% block content %}
<div class="card">
<div class="card-body">
<p>{% blocktrans %}Scan this QR code to get OTP for account '{{ user }}'{% endblocktrans %}</p>
{{ qr_svg }}
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "API token created" %}{% endblock %}
{% block page_heading %}{% trans "API token created" %}: {{ name }}{% endblock page_heading %}
{% block content %}
<div class="alert alert-warning" role="alert">
{% trans "Copy the token now: it is not shown again. Anyone who has it can use the REST API as you." %}
</div>
<label class="form-label" for="api-token">{% trans "Token" %}</label>
<input type="text" class="form-control font-monospace mb-3" id="api-token" value="{{ key }}" readonly>
<p>{% trans "Use it like this:" %}</p>
<pre class="bg-body-tertiary p-2 text-break" style="white-space: pre-wrap"><code>curl -H "Authorization: Token {{ key }}" {{ request.scheme }}://{{ request.get_host }}{% url 'instance-list' %}</code></pre>
<a class="btn btn-primary" href="{% url 'accounts:profile' %}#apitokens">{% trans "Back to the profile" %}</a>
{% endblock content %}

View file

@ -0,0 +1,25 @@
{% load i18n %}
{% load bootstrap_icons %}
{# the tokens of the signed-in user, or of owner on a superuser's view of a user: the items of a list-group #}
{% for token in api_tokens %}
<div class="list-group-item py-3">
<div class="d-flex justify-content-between align-items-center gap-2">
<span class="fw-semibold text-break">{{ token.name }}</span>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
aria-label="{% blocktrans with name=token.name %}Actions for API token {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item text-danger" href="{% if owner %}{% url 'accounts:account_api_token_delete' owner.id token.token_key %}{% else %}{% url 'accounts:api_token_delete' token.token_key %}{% endif %}">{% trans "Delete" %}</a></li>
</ul>
</div>
</div>
<div class="small mt-1">{% trans "Starts with" %} <code>{{ token.token_key }}</code></div>
<div class="row g-2 small mt-1">
<div class="col-12 col-md-4"><span class="text-body-secondary">{% trans "Created" %}</span> <span class="text-nowrap">{{ token.created|date:"Y-m-d H:i" }}</span></div>
<div class="col-12 col-md-4"><span class="text-body-secondary">{% trans "Expires" %}</span> <span class="text-nowrap">{% if token.expiry %}{{ token.expiry|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %}</span></div>
<div class="col-12 col-md-4"><span class="text-body-secondary">{% trans "Last used" %}</span> <span class="text-nowrap">{% if token.last_used %}{{ token.last_used|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %}</span></div>
</div>
</div>
{% empty %}
<div class="list-group-item text-body-secondary">{% trans "No API tokens." %}</div>
{% endfor %}

View file

@ -1,55 +1,66 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "WebVirtCloud" %} - {% trans "Sign In" %}{% endblock title %}
{% block style %}
<link href="{% static "css/signin.css" %}" rel="stylesheet">
{% endblock style %}
{% block content %}
<div class="login-box">
<div class="page-header">
<a href="/"><h1>WebVirtCloud</h1></a>
</div>
<hr>
<div class="col-12" role="main">
{% if form.errors %}
<div class="alert alert-danger" role="alert">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
{% trans "Incorrect username or password." %}
<div class="row justify-content-center pt-5">
<div class="col-sm-9 col-md-7 col-lg-6">
<div class="card shadow-sm">
<div class="card-body p-4 p-sm-5">
<div class="text-center mb-4">
<div class="d-inline-block rounded-3 bg-primary text-white fs-2 lh-1 p-3 mb-3"><i class="bi bi-hdd-network"></i></div>
<h1 class="h3 fw-bold mb-1">WebVirtCloud</h1>
<p class="text-body-secondary small mb-0">{% trans "Virtualization Management Console" %}</p>
</div>
{% if form.errors %}
<div class="alert alert-danger d-flex align-items-center mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill fs-5 me-2 flex-shrink-0"></i>
<div>{% trans "Incorrect username or password." %}</div>
</div>
{% endif %}
<form id="form-signin" method="post" aria-label="Login form">
{% csrf_token %}
<div class="mb-3">
<label class="form-label small fw-medium" for="id_username">{% trans "Username" %}</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input type="text" id="id_username" class="form-control" name="username" placeholder="{% trans 'Enter username' %}" autocapitalize="none" autocorrect="off" autofocus required>
</div>
</div>
<div class="mb-4">
<label class="form-label small fw-medium" for="id_password">{% trans "Password" %}</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-shield-lock"></i></span>
<input type="password" id="id_password" class="form-control" name="password" placeholder="{% trans 'Enter password' %}" required>
<button class="btn btn-outline-secondary" type="button" id="btn-toggle-pwd" tabindex="-1" aria-label="{% trans 'Toggle password visibility' %}">
<i class="bi bi-eye" id="toggle-icon"></i>
</button>
</div>
</div>
<input type="hidden" name="next" value="{{ next }}">
<button id="btn-signin" class="btn btn-primary w-100 py-2" type="submit" data-busy-text="{% trans "Signing in..." %}">
<i class="bi bi-box-arrow-in-right me-1"></i>
<span id="btn-text">{% trans "Sign In" %}</span>
</button>
</form>
<div class="d-flex align-items-center small text-body-secondary border-top mt-4 pt-3">
<span class="d-inline-block rounded-circle p-1 bg-success me-2" aria-hidden="true"></span>
KVM / Libvirt
</div>
</div>
{% endif %}
<form id="form-signin" class="form-signin" method="post" role="form" aria-label="Login form">{% csrf_token %}
<h2 class="form-signin-heading">{% trans "Sign In" %}</h2>
<input type="text" class="form-control" name="username" placeholder="{% trans 'User' %}" autocapitalize="none" autocorrect="off" autofocus required>
<input type="password" class="form-control" name="password" placeholder="{% trans 'Password' %}" required>
<input type="hidden" name="next" value="{{ next }}">
<div class="d-grid">
<button id="btn-signin" class="btn btn-lg btn-success" type="submit">{% trans "Sign In" %}</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
$(document).ready(function() {
$("#btn-signin").click(function() {
// disable button
$(this).prop("disabled", true);
// add spinner to button
$(this).html(
`
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{% trans "Loading" %}...
`
);
$("#form-signin").submit();
});
});
</script>
{% endblock script%}
<script src="{% static 'js/login_form.js' %}"></script>
{% endblock script %}

View file

@ -1,17 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}
{% trans "WebVirtCloud" %} - {% trans "Sign Out"%}
{% endblock %}
{% block content %}
<div>
<div class="page-header">
<a href="/"><h1>WebVirtCloud</h1></a>
</div>
<div class="col-12" role="main">
<div class="logout">
<h2>{% trans "Successful log out" %}</h2>
</div>
</div>
</div>
{% endblock %}

View file

@ -2,83 +2,78 @@
{% load i18n %}
{% load django_bootstrap5 %}
{% load bootstrap_icons %}
{% load tags_fingerprint %}
{% block title %}{% trans "Profile" %}: {{ request.user.first_name }} {{ request.user.last_name}}{% endblock %}
{% block title %}{% trans "Profile" %}{% endblock %}
{% block page_heading %}{% trans "Profile" %}: {{ request.user.first_name }} {{ request.user.last_name}}{% endblock page_heading %}
{% block page_heading %}{% trans "Profile" %}{% endblock page_heading %}
{% block content %}
<ul class="nav nav-tabs">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#editprofile" type="button" role="tab" aria-controls="editprofile" aria-selected="true">
{% trans "Edit Profile" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#sshkeys" type="button" role="tab" aria-controls="sshkeys" aria-selected="false">
{% trans "SSH Keys" %}
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane tab-pane-bordered active" id="editprofile">
<div class="card">
<div class="card-body">
<form method="post" action="" role="form" aria-label="Edit user info form">
{% csrf_token %}
{% bootstrap_form profile_form layout='horizontal' %}
{% if perms.accounts.change_password %}
<a href="{% url 'accounts:change_password' %}" class="btn btn-primary">
{% bs_icon 'lock' %} {% trans "Change Password" %}
</a>
<div class="row g-4">
<div class="col-lg-4">
<section class="card" aria-labelledby="identity-title">
<div class="card-body border-bottom">
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<h2 class="h4 mb-0 text-break" id="identity-title">{{ user.get_full_name|default:user.username }}</h2>
{% if user.is_superuser %}
<span class="badge text-bg-secondary">{% trans "Superuser" %}</span>
{% elif user.is_staff %}
<span class="badge text-bg-secondary">{% trans "Template editor" %}</span>
{% endif %}
<div class="mb-0 float-end">
<button type="submit" class="btn btn-primary">
{% bs_icon 'pencil' %} {% trans "Update" %}
</button>
</div>
</form>
</div>
<div class="text-body-secondary text-break">{{ user.username }}</div>
{% if user.email %}<div class="small text-break mt-2">{{ user.email }}</div>{% endif %}
<div class="small text-body-secondary mt-2">{% trans "Last login" %} {{ user.last_login|date:"Y-m-d H:i"|default:_("never") }}</div>
</div>
</div>
<form method="post" class="card-body" aria-label="{% trans 'Edit user info form' %}">
{% csrf_token %}
{% bootstrap_form profile_form %}
<button type="submit" class="btn btn-primary w-100">{% trans "Save profile" %}</button>
</form>
{# an LDAP user's password is changed in LDAP #}
{% if perms.accounts.change_password and user.has_usable_password %}
<div class="card-footer">
<a href="{% url 'accounts:change_password' %}" class="btn btn-sm btn-outline-secondary">{% bs_icon 'lock' %} {% trans "Change Password" %}</a>
</div>
{% endif %}
</section>
</div>
<div class="tab-pane tab-pane-bordered fade" id="sshkeys">
{% if publickeys %}
<div class="col-lg-12">
<div class="table-responsive">
<table class="table table-hover">
<tbody class="text-center">
{% for key in publickeys %}
<tr>
<td>{{ key.keyname }} ({% ssh_to_fingerprint key.keypublic %})</td>
<td>
<a href="{% url 'accounts:ssh_key_delete' key.id %}" title="{% trans "Delete" %}" class="btn btn-sm btn-secondary">
{% bs_icon 'trash' %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="col-lg-8">
<section class="card mb-4" id="sshkeys" aria-labelledby="sshkeys-title">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<h2 class="h5 mb-0" id="sshkeys-title">{% trans "SSH Keys" %} <span class="badge text-bg-light border">{{ publickeys|length }}</span></h2>
<button class="btn btn-sm btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#add-ssh-key" aria-expanded="false" aria-controls="add-ssh-key">
{% bs_icon 'plus-lg' %} {% trans "Add SSH Key" %}
</button>
</div>
</div>
{% endif %}
<div class="card">
<div class="card-header">
{%trans "Add SSH Key" %}
</div>
<div class="card-body">
<form method="post" action="{% url 'accounts:ssh_key_create' %}" role="form" aria-label="Add key to user form">
<div class="card-body small text-body-secondary">{% trans "Offered when you create a VM and on a VM's Access tab." %}</div>
<div class="list-group list-group-flush">{% include "ssh_key_list.html" %}</div>
<div class="collapse" id="add-ssh-key">
<form method="post" action="{% url 'accounts:ssh_key_create' %}" class="card-body border-top" aria-label="{% trans 'Add key to user form' %}">
{% csrf_token %}
{% bootstrap_form ssh_key_form layout='horizontal' %}
<div class="mb-0 float-end">
<button type="submit" class="btn btn-primary">
{% bs_icon 'plus-circle-fill' %} {% trans "Add" %}
</button>
</div>
{% bootstrap_form ssh_key_form %}
<button type="submit" class="btn btn-primary">{% trans "Add" %}</button>
</form>
</div>
</div>
</section>
<section class="card" id="apitokens" aria-labelledby="apitokens-title">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<h2 class="h5 mb-0" id="apitokens-title">{% trans "API Tokens" %} <span class="badge text-bg-light border">{{ api_tokens|length }}</span></h2>
<button class="btn btn-sm btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#create-api-token" aria-expanded="false" aria-controls="create-api-token">
{% bs_icon 'plus-lg' %} {% trans "Create an API token" %}
</button>
</div>
<div class="card-body small text-body-secondary">{% blocktrans %}A token lets a script or a tool use the REST API as you, without your password or OTP: send it as <code>Authorization: Token &lt;token&gt;</code>. A password change does not delete tokens: delete a token you no longer use, or one that may have leaked.{% endblocktrans %}</div>
<div class="list-group list-group-flush">{% include "api_token_list.html" %}</div>
<div class="collapse" id="create-api-token">
<form method="post" action="{% url 'accounts:api_token_create' %}" class="card-body border-top" aria-label="{% trans 'Create an API token' %}">
{% csrf_token %}
{% bootstrap_form api_token_form %}
<button type="submit" class="btn btn-primary">{% trans "Create" %}</button>
</form>
</div>
</section>
</div>
</div>
{% endblock %}
{% endblock %}

View file

@ -0,0 +1,21 @@
{% load i18n %}
{% load bootstrap_icons %}
{% load tags_fingerprint %}
{# the keys of the signed-in user, or of owner on a superuser's view of a user: the items of a list-group #}
{% for key in publickeys %}
<div class="list-group-item py-3">
<div class="d-flex justify-content-between align-items-center gap-2">
<span class="fw-semibold text-break">{{ key.keyname }}</span>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
aria-label="{% blocktrans with name=key.keyname %}Actions for SSH key {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item text-danger" href="{% if owner %}{% url 'accounts:account_ssh_key_delete' owner.id key.id %}{% else %}{% url 'accounts:ssh_key_delete' key.id %}{% endif %}">{% trans "Delete" %}</a></li>
</ul>
</div>
</div>
<div class="font-monospace small text-break mt-1" title="{{ key.keypublic }}">{% ssh_to_fingerprint key.keypublic %}</div>
</div>
{% empty %}
<div class="list-group-item text-body-secondary">{% trans "No SSH keys." %}</div>
{% endfor %}

View file

@ -1,69 +0,0 @@
import base64
import struct
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import TestCase
from django_otp.plugins.otp_totp.models import TOTPDevice
from accounts.utils import get_user_totp_device, send_email_with_otp, validate_ssh_key
class AccountsUtilsTestCase(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="test_otp_user", email="user@example.com", password="password123"
)
def test_validate_ssh_key_valid(self):
blob = (
struct.pack(">I", 7)
+ b"ssh-rsa"
+ struct.pack(">I", 3)
+ b"\x01\x00\x01"
+ struct.pack(">I", 8)
+ b"\x00" * 8
)
b64 = base64.b64encode(blob).decode()
valid_key = f"ssh-rsa {b64} user@example.com"
self.assertTrue(validate_ssh_key(valid_key))
def test_validate_ssh_key_invalid_parts(self):
self.assertFalse(validate_ssh_key("only_two parts"))
self.assertFalse(validate_ssh_key("one_part"))
self.assertFalse(validate_ssh_key("four parts key here now"))
def test_validate_ssh_key_invalid_base64(self):
self.assertFalse(validate_ssh_key("ssh-rsa not-valid-b64! user@example.com"))
def test_validate_ssh_key_struct_error(self):
# Base64 with less than 4 bytes cannot unpack length
short_b64 = base64.b64encode(b"ab").decode()
self.assertFalse(validate_ssh_key(f"ssh-rsa {short_b64} user@example.com"))
def test_validate_ssh_key_type_mismatch(self):
blob = struct.pack(">I", 7) + b"ssh-rsa" + b"\x00" * 8
b64 = base64.b64encode(blob).decode()
# header says ssh-ed25519 but payload has ssh-rsa
self.assertFalse(validate_ssh_key(f"ssh-ed25519 {b64} user@example.com"))
def test_get_user_totp_device_creates_new(self):
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 0)
device = get_user_totp_device(self.user)
self.assertIsInstance(device, TOTPDevice)
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 1)
def test_get_user_totp_device_retrieves_existing(self):
device1 = get_user_totp_device(self.user)
device2 = get_user_totp_device(self.user)
self.assertEqual(device1.id, device2.id)
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 1)
def test_send_email_with_otp(self):
device = get_user_totp_device(self.user)
send_email_with_otp(self.user, device)
self.assertEqual(len(mail.outbox), 1)
self.assertIn("OTP QR Code", mail.outbox[0].subject)
self.assertEqual(mail.outbox[0].to, ["user@example.com"])

View file

@ -0,0 +1,82 @@
"""A superuser's page of a user: the VMs and their permissions, the SSH keys."""
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from accounts.models import UserInstance, UserSSHKey
from computes.models import Compute
from instances.models import Instance
from logs.models import Logs
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGbXMJlKTOlUOvwzdjDs3ac3ivHQoOxAUZEwi3WsJHfs alice@example"
class AccountPageTests(TestCase):
def setUp(self):
self.admin = User.objects.create_superuser("ap-admin", "ap@example.com", "pw")
self.alice = User.objects.create_user("alice", password="pw")
self.key = UserSSHKey.objects.create(user=self.alice, keyname="laptop", keypublic=KEY)
self.client.force_login(self.admin)
def page(self):
return self.client.get(reverse("accounts:account", args=[self.alice.pk]))
def test_the_permissions_are_marks_not_true_or_false(self):
compute = Compute.objects.create(name="ap-host", hostname="localhost", login="", password="", details="", type=4)
vm = Instance.objects.create(compute=compute, name="ap-vm", uuid="00000000-0000-0000-0000-000000000001")
UserInstance.objects.create(user=self.alice, instance=vm, is_vnc=True, is_change=False, is_delete=False)
response = self.page()
self.assertContains(response, "ap-vm")
self.assertNotContains(response, "<td>True</td>", html=True)
self.assertNotContains(response, "<td>False</td>", html=True)
# what the user may do beyond power and console, in the VM Users tab's words, and the VM's host
self.assertContains(response, "Power and console only")
self.assertNotContains(response, "Change settings")
self.assertContains(response, "Host: ap-host")
# the console settings come with the right to change the VM
UserInstance.objects.filter(user=self.alice).update(is_change=True)
response = self.page()
self.assertContains(response, "Change settings")
self.assertContains(response, "Change console settings")
self.assertNotContains(response, "Power and console only")
def test_a_blocked_user_is_marked(self):
self.assertNotContains(self.page(), "Blocked")
self.alice.is_active = False
self.alice.save()
self.assertContains(self.page(), '<span class="badge text-bg-danger">Blocked</span>', html=True)
def test_a_user_without_vms_or_tokens_is_told_so(self):
response = self.page()
self.assertContains(response, "No VMs.")
self.assertContains(response, "No API tokens.")
# the whole key shows over its fingerprint
self.assertContains(response, f'title="{KEY}"')
def test_a_superuser_deletes_a_users_key(self):
url = reverse("accounts:account_ssh_key_delete", args=[self.alice.pk, self.key.pk])
self.assertContains(self.page(), f'href="{url}"')
self.assertContains(self.client.get(url), "laptop")
response = self.client.post(url)
self.assertRedirects(response, reverse("accounts:account", args=[self.alice.pk]) + "#public-keys",
fetch_redirect_response=False)
self.assertFalse(UserSSHKey.objects.exists())
self.assertTrue(Logs.objects.filter(message="SSH key laptop of alice: delete").exists())
def test_only_a_superuser_and_only_that_users_key(self):
bob = User.objects.create_user("bob", password="pw")
self.assertEqual(self.client.post(reverse("accounts:account_ssh_key_delete", args=[bob.pk, self.key.pk])).status_code, 404)
self.client.force_login(bob)
url = reverse("accounts:account_ssh_key_delete", args=[self.alice.pk, self.key.pk])
self.assertEqual(self.client.post(url).status_code, 403)
self.assertTrue(UserSSHKey.objects.exists())
def test_the_owner_form_names_the_rights_as_the_vm_page_does(self):
compute = Compute.objects.create(name="ap-host2", hostname="localhost", login="", password="", details="", type=4)
vm = Instance.objects.create(compute=compute, name="ap-vm2", uuid="00000000-0000-0000-0000-000000000002")
owner = UserInstance.objects.create(user=self.alice, instance=vm)
response = self.client.get(reverse("accounts:user_instance_update", args=[owner.pk]))
for label in ("Change settings", "Delete the VM", "Change console settings"):
self.assertContains(response, label)
self.assertNotContains(response, "Is vnc")

View file

@ -0,0 +1,38 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
class AdminEmailOtpTestCase(TestCase):
"""Sending the OTP QR code by email must not happen on GET."""
def setUp(self):
User = get_user_model()
self.superuser = User.objects.create_superuser(
username="otp_super", email="otp_super@example.com", password="x"
)
self.user = User.objects.create_user(
username="otp_user", email="otp_user@example.com", password="x"
)
self.client.force_login(self.superuser)
self.url = reverse("accounts:admin_email_otp", args=[self.user.id])
@patch("accounts.views.send_email_with_otp")
def test_get_does_not_send_email(self, mock_send):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
mock_send.assert_not_called()
@patch("accounts.views.send_email_with_otp")
def test_post_sends_email(self, mock_send):
response = self.client.post(self.url)
self.assertEqual(response.status_code, 302)
mock_send.assert_called_once()
def test_the_smtp_connection_waits_a_bounded_time(self):
from django.core.mail.backends.smtp import EmailBackend
# None would wait for a server that stopped answering forever
self.assertIsNotNone(EmailBackend().timeout)

View file

@ -0,0 +1,92 @@
"""The generated admin password must not be printed to the log."""
import io
import os
import stat
import tempfile
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import MagicMock, patch
from django.apps import apps
from django.contrib.auth import authenticate, get_user_model
from django.test import RequestFactory, TestCase, override_settings
from accounts.apps import create_admin
from accounts.models import UserAttributes
class GeneratedAdminPasswordTestCase(TestCase):
def _run_create_admin(self, base_dir, plan=None):
get_user_model().objects.all().delete()
if plan is None:
migration = MagicMock(app_label="accounts")
migration.name = "0001_initial"
plan = [(migration, False)]
out = io.StringIO()
env = {k: v for k, v in os.environ.items() if k not in ("ADMIN_PASSWORD", "ADMIN_USERNAME")}
with override_settings(BASE_DIR=Path(base_dir)), patch.dict(
os.environ, env, clear=True
), redirect_stdout(out):
create_admin(sender=None, plan=plan, apps=apps)
return out.getvalue()
def test_password_is_written_to_a_private_file_not_printed(self):
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / "data").mkdir()
output = self._run_create_admin(tmp)
password_file = Path(tmp) / "data" / "admin_password"
self.assertTrue(password_file.exists())
password = password_file.read_text().strip()
self.assertEqual(stat.S_IMODE(password_file.stat().st_mode), 0o600)
self.assertNotIn(password, output)
self.assertIn(str(password_file), output)
self.assertIsNotNone(authenticate(RequestFactory().get("/"), username="admin", password=password))
self.assertTrue(UserAttributes.objects.get(user__username="admin").must_change_password)
def test_does_not_follow_a_planted_symlink(self):
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / "data").mkdir()
victim = Path(tmp) / "victim"
victim.write_text("original\n")
(Path(tmp) / "data" / "admin_password").symlink_to(victim)
self._run_create_admin(tmp)
password_file = Path(tmp) / "data" / "admin_password"
self.assertEqual(victim.read_text(), "original\n")
self.assertFalse(password_file.is_symlink())
self.assertEqual(stat.S_IMODE(password_file.stat().st_mode), 0o600)
def test_storage_failure_does_not_print_password_or_create_an_admin(self):
password = "review-dummy-generated-password"
output = io.StringIO()
with tempfile.TemporaryDirectory() as tmp, patch(
"secrets.token_urlsafe", return_value=password
), patch("accounts.apps.os.open", side_effect=PermissionError("read-only")), redirect_stdout(output):
with self.assertRaisesRegex(RuntimeError, "Cannot securely store"):
self._run_create_admin(tmp)
self.assertNotIn(password, output.getvalue())
self.assertFalse(get_user_model().objects.exists())
def test_rerunning_migrate_after_a_storage_failure_creates_the_admin(self):
with tempfile.TemporaryDirectory() as tmp:
with patch("accounts.apps.os.open", side_effect=PermissionError("read-only")), redirect_stdout(io.StringIO()):
with self.assertRaises(RuntimeError):
self._run_create_admin(tmp)
# The second migrate has nothing left to apply: an empty plan.
(Path(tmp) / "data").mkdir(exist_ok=True)
self._run_create_admin(tmp, plan=[])
self.assertTrue(get_user_model().objects.filter(username="admin").exists())
def test_flush_does_not_create_an_admin(self):
get_user_model().objects.all().delete()
create_admin(sender=None, plan=None, apps=apps)
self.assertFalse(get_user_model().objects.exists())
def test_migrating_another_app_before_auth_creates_nothing(self):
get_user_model().objects.all().delete()
state_without_auth = MagicMock()
state_without_auth.get_model.side_effect = LookupError
create_admin(sender=None, plan=[], apps=state_without_auth)
self.assertFalse(get_user_model().objects.exists())

View file

@ -0,0 +1,216 @@
"""API tokens: a user makes them on the profile page, in a session that
passed the login (and its OTP), and scripts use them on the REST API."""
import re
from datetime import timedelta
from unittest.mock import patch
from accounts.models import ApiToken, UserAttributes
from django.contrib.auth import get_user_model
from django.db import connection
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from logs.models import Logs
from lxml import html
class ApiTokenTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user("tok-user", password="pw")
self.other = get_user_model().objects.create_user("tok-other", password="pw")
def make(self, name="ansible", days="90"):
"""The token the creation page shows once."""
self.client.force_login(self.user)
response = self.client.post(reverse("accounts:api_token_create"), {"name": name, "days": days}, follow=True)
self.assertEqual(response.status_code, 200)
return re.search(r"wvc_[0-9a-f]{64}", response.content.decode()).group(0)
def api(self, token):
self.client.logout()
return self.client.get(reverse("instance-list"), HTTP_AUTHORIZATION=f"Token {token}")
def test_a_token_opens_the_api_without_a_session(self):
token = self.make()
response = self.api(token)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), []) # the user's own VMs: none
def test_a_token_is_kept_only_as_a_hash_and_shown_once(self):
token = self.make(name="ci")
(stored,) = ApiToken.objects.filter(user=self.user)
self.assertEqual((stored.name, stored.token_key), ("ci", token[:15]))
self.assertNotIn(token, stored.digest)
page = self.client.get(reverse("accounts:profile")).content.decode()
self.assertIn("ci", page)
self.assertNotIn(token, page)
def test_the_page_showing_the_token_is_not_cached(self):
self.client.force_login(self.user)
response = self.client.post(reverse("accounts:api_token_create"), {"name": "ci", "days": "30"}, follow=True)
self.assertIn("no-store", response["Cache-Control"])
def test_reloading_the_page_creates_no_second_token(self):
token = self.make(name="ci")
response = self.client.get(reverse("accounts:api_token_created"))
self.assertRedirects(response, reverse("accounts:profile") + "#apitokens", fetch_redirect_response=False)
self.assertNotIn(token, response.content.decode())
self.assertEqual(ApiToken.objects.filter(user=self.user).count(), 1)
def test_a_token_lasts_the_days_chosen_or_until_deleted(self):
self.make(name="month", days="30")
self.make(name="always", days="")
expiry = dict(ApiToken.objects.values_list("name", "expiry"))
self.assertAlmostEqual(expiry["month"], timezone.now() + timedelta(days=30), delta=timedelta(minutes=1))
self.assertIsNone(expiry["always"])
def test_an_expired_token_or_a_wrong_one_is_refused(self):
token = self.make()
ApiToken.objects.update(expiry=timezone.now() - timedelta(seconds=1))
self.assertEqual(self.api(token).status_code, 401)
self.assertEqual(self.api("wvc_" + "0" * 64).status_code, 401)
def test_an_inactive_users_token_is_refused(self):
token = self.make()
self.user.is_active = False
self.user.save()
self.assertEqual(self.api(token).status_code, 401)
def test_a_deleted_token_stops_working_and_only_its_owner_deletes_it(self):
token = self.make()
stored = ApiToken.objects.get(user=self.user)
url = reverse("accounts:api_token_delete", args=[stored.token_key])
self.client.force_login(self.other)
self.assertEqual(self.client.post(url).status_code, 404)
self.client.force_login(self.user)
self.assertRedirects(self.client.post(url), reverse("accounts:profile") + "#apitokens",
fetch_redirect_response=False)
self.assertEqual(self.api(token).status_code, 401)
def test_making_and_deleting_a_token_is_logged_without_the_token(self):
token = self.make(name="backup")
stored = ApiToken.objects.get(user=self.user)
self.client.post(reverse("accounts:api_token_delete", args=[stored.token_key]))
rows = list(Logs.objects.order_by("id").values_list("message", "success"))
self.assertEqual(rows[-2:], [("API token backup: add", True), ("API token backup: delete", True)])
self.assertFalse(Logs.objects.filter(message__contains=token[4:20]).exists())
def test_a_token_needs_a_name(self):
self.client.force_login(self.user)
response = self.client.post(reverse("accounts:api_token_create"), {"name": " ", "days": "30"})
self.assertFalse(ApiToken.objects.exists())
self.assertNotRegex(response.content.decode(), r"wvc_[0-9a-f]{64}")
def test_knox_makes_no_table_of_its_own(self):
# accounts.ApiToken replaces knox's AuthToken; a knox_authtoken table
# would reference auth_user unseen, and PostgreSQL could not empty
# auth_user between tests
self.assertNotIn("knox_authtoken", connection.introspection.table_names())
def test_a_token_shows_when_it_was_last_used(self):
token = self.make()
self.assertIsNone(ApiToken.objects.get().last_used)
self.api(token)
first = ApiToken.objects.get().last_used
self.assertAlmostEqual(first, timezone.now(), delta=timedelta(seconds=5))
# within a minute a request writes nothing
self.api(token)
self.assertEqual(ApiToken.objects.get().last_used, first)
later = timezone.now() + timedelta(minutes=2)
with patch("accounts.auth.timezone.now", return_value=later):
self.api(token)
self.assertEqual(ApiToken.objects.get().last_used, later)
self.client.force_login(self.user)
self.assertIn("Last used", self.client.get(reverse("accounts:profile")).content.decode())
def test_a_token_stops_while_its_user_must_change_the_password(self):
token = self.make()
UserAttributes.objects.update_or_create(user=self.user, defaults={"must_change_password": True})
self.assertEqual(self.api(token).status_code, 401)
UserAttributes.objects.filter(user=self.user).update(must_change_password=False)
self.assertEqual(self.api(token).status_code, 200)
def test_a_user_has_at_most_ten_tokens(self):
for number in range(10):
self.make(name=f"t{number}")
response = self.client.post(reverse("accounts:api_token_create"), {"name": "one more", "days": "30"})
self.assertEqual(ApiToken.objects.filter(user=self.user).count(), 10)
self.assertNotRegex(response.content.decode(), r"wvc_[0-9a-f]{64}")
self.assertIn("at most 10", response.content.decode())
def test_a_form_sent_twice_makes_one_token(self):
self.make("ci")
response = self.client.post(reverse("accounts:api_token_create"), {"name": "ci", "days": "90"})
self.assertContains(response, "You have a token named ci: choose another name.")
self.assertEqual(ApiToken.objects.filter(user=self.user).count(), 1)
self.make("ci-2") # another name is fine
self.assertEqual(ApiToken.objects.filter(user=self.user).count(), 2)
def test_expired_tokens_do_not_count_toward_the_limit(self):
for number in range(10):
self.make(name=f"t{number}")
ApiToken.objects.filter(name="t0").update(expiry=timezone.now() - timedelta(days=1))
self.make(name="new")
self.assertEqual(sorted(ApiToken.objects.values_list("name", flat=True))[:2], ["new", "t1"])
class AdminApiTokenTests(TestCase):
"""A superuser sees a user's tokens on the user's page and deletes one
that may have leaked."""
def setUp(self):
self.user = get_user_model().objects.create_user("tok-user", password="pw")
self.admin = get_user_model().objects.create_superuser("tok-admin", "admin@example.com", "pw")
self.client.force_login(self.user)
response = self.client.post(reverse("accounts:api_token_create"), {"name": "ansible", "days": "90"}, follow=True)
self.token = re.search(r"wvc_[0-9a-f]{64}", response.content.decode()).group(0)
self.stored = ApiToken.objects.get(user=self.user)
def delete_url(self, user):
return reverse("accounts:account_api_token_delete", args=[user.id, self.stored.token_key])
def test_the_users_page_lists_the_tokens_without_them(self):
self.client.force_login(self.admin)
page = self.client.get(reverse("accounts:account", args=[self.user.id])).content.decode()
self.assertIn("ansible", page)
self.assertIn(self.stored.token_key, page)
self.assertIn(self.delete_url(self.user), page)
self.assertNotIn(self.token, page)
def test_a_superuser_deletes_a_users_token(self):
self.client.force_login(self.admin)
# the confirmation names whose token it is
self.assertIn("API token ansible of tok-user", self.client.get(self.delete_url(self.user)).content.decode())
self.assertRedirects(self.client.post(self.delete_url(self.user)),
reverse("accounts:account", args=[self.user.id]) + "#apitokens",
fetch_redirect_response=False)
self.assertFalse(ApiToken.objects.exists())
self.assertEqual(Logs.objects.order_by("id").values_list("message", "user").last(),
("API token ansible of tok-user: delete", "tok-admin"))
self.client.logout()
self.assertEqual(self.client.get(reverse("instance-list"), HTTP_AUTHORIZATION=f"Token {self.token}").status_code, 401)
def test_only_a_superuser_deletes_another_users_token(self):
other = get_user_model().objects.create_user("tok-other", password="pw")
self.client.force_login(other)
self.assertEqual(self.client.post(self.delete_url(self.user)).status_code, 403)
# nor through the owner's own address: it names the caller's tokens only
self.assertEqual(self.client.post(reverse("accounts:api_token_delete", args=[self.stored.token_key])).status_code, 404)
self.client.force_login(self.admin)
self.assertEqual(self.client.post(self.delete_url(other)).status_code, 404)
self.assertTrue(ApiToken.objects.exists())
def test_the_user_list_counts_each_users_live_tokens(self):
self.client.force_login(self.user)
self.client.post(reverse("accounts:api_token_create"), {"name": "old", "days": "30"})
ApiToken.objects.filter(name="old").update(expiry=timezone.now() - timedelta(days=1))
self.client.force_login(self.admin)
page = html.fromstring(self.client.get(reverse("admin:user_list")).content)
def tokens(name):
return page.xpath(f"//div[contains(@class, 'list-group-item')][.//a[normalize-space()='{name}']]"
"//a[contains(@href, '#apitokens')]")
# the expired one is not counted; the count opens the user's tokens
(link,) = tokens("tok-user")
self.assertEqual(link.text_content().strip(), "1 API token")
self.assertEqual(link.get("href"), reverse("accounts:account", args=[self.user.id]) + "#apitokens")
self.assertEqual(tokens("tok-admin"), [])

View file

@ -0,0 +1,114 @@
"""Changes to a user's own account, VM ownership, OTP mails and SSH keys write rows on the Logs page."""
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from accounts.models import UserAttributes
from logs.models import Logs
def rows():
return list(Logs.objects.order_by("id").values_list("message", "success"))
class OwnAccountRowTests(TestCase):
def setUp(self):
self.bob = get_user_model().objects.create_user("bob", password="Bob-pass-2026")
self.client.force_login(self.bob)
Logs.objects.all().delete() # the login's row
def test_own_profile_and_password(self):
self.client.post(reverse("accounts:profile"), {"username": "bob", "email": "bob@example.com"})
self.client.post(reverse("accounts:change_password"), {
"old_password": "Bob-pass-2026", "new_password1": "Bob-new-pass-1", "new_password2": "Bob-new-pass-1"})
self.assertEqual(rows(), [("User bob: change email", True), ("User bob: change password", True)])
self.assertFalse(Logs.objects.filter(message__contains="pass-").exists())
def test_a_wrong_old_password_is_a_failed_row(self):
# someone at an open session guessing the password
self.client.post(reverse("accounts:change_password"), {
"old_password": "guess-2026", "new_password1": "Bob-new-pass-1", "new_password2": "Bob-new-pass-1"})
self.client.get(reverse("accounts:change_password"))
self.client.post(reverse("accounts:change_password"), { # none given: not a guess
"new_password1": "Bob-new-pass-1", "new_password2": "Bob-new-pass-1"})
self.assertEqual(rows(), [("User bob: change password: wrong old password", False)])
self.assertFalse(Logs.objects.filter(message__contains="pass-").exists())
def test_the_first_login_password_change(self):
self.bob.user_permissions.clear()
UserAttributes.objects.update_or_create(user=self.bob, defaults={"must_change_password": True})
self.client.post(reverse("accounts:change_password"), {
"old_password": "Bob-pass-2026", "new_password1": "Bob-new-pass-1", "new_password2": "Bob-new-pass-1"})
self.assertEqual(rows(), [("User bob: change password", True)])
SSH_KEY = ("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfx"
"YHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw=="
" test@test")
class AdminAccountRowTests(TestCase):
def setUp(self):
from computes.models import Compute
from instances.models import Instance
self.admin = get_user_model().objects.create_superuser("audit-admin", "audit@example.com", "pw")
self.bob = get_user_model().objects.create_user("bob", password="Bob-pass-2026")
compute = Compute.objects.create(name="c1", hostname="127.0.0.1", login="", password="", type=4)
self.vm = Instance.objects.create(compute=compute, name="vm", uuid="66666666-6666-6666-6666-666666666666")
self.client.force_login(self.admin)
Logs.objects.all().delete() # the login's row
def test_ownership_from_the_accounts_pages(self):
from accounts.models import UserInstance
self.client.post(reverse("accounts:user_instance_create", args=[self.bob.pk]),
{"user": self.bob.pk, "instance": self.vm.pk})
ui = UserInstance.objects.get(user=self.bob, instance=self.vm)
url = reverse("accounts:user_instance_update", args=[ui.pk])
self.client.post(url, {"is_vnc": "on"})
self.client.post(url, {"is_vnc": "on"}) # no change
self.client.post(reverse("accounts:user_instance_delete", args=[ui.pk]))
self.assertEqual(rows(), [
("VM vm: add owner bob", True),
("VM vm: change owner bob: is_vnc", True),
("VM vm: remove owner bob", True),
])
self.assertEqual(set(Logs.objects.values_list("user", "host", "instance", "instance_uuid")),
{("audit-admin", "c1", "vm", self.vm.uuid)})
def test_otp_mail_without_an_email(self):
from smtplib import SMTPException
from unittest.mock import patch
from django_otp.plugins.otp_totp.models import TOTPDevice
url = reverse("accounts:admin_email_otp", args=[self.bob.pk])
with patch("accounts.views.send_email_with_otp") as send:
self.client.post(url)
send.assert_not_called()
self.assertFalse(TOTPDevice.objects.filter(user=self.bob).exists())
self.bob.email = "bob@example.com"
self.bob.save()
with patch("accounts.views.send_email_with_otp") as send:
self.client.post(url)
send.assert_called_once()
with patch("accounts.views.send_email_with_otp", side_effect=SMTPException("refused")):
response = self.client.post(url, follow=True)
self.assertContains(response, "The OTP QR code could not be mailed to bob: refused")
self.assertEqual(rows(), [
("User bob: mail OTP QR code: no email address", False),
("User bob: mail OTP QR code", True),
("User bob: mail OTP QR code: SMTPException", False),
])
def test_ssh_keys(self):
from accounts.models import UserSSHKey
self.client.post(reverse("accounts:ssh_key_create"), {"keyname": "laptop", "keypublic": SSH_KEY})
key = UserSSHKey.objects.get(keyname="laptop")
self.client.post(reverse("accounts:ssh_key_delete", args=[key.pk]))
self.assertEqual(rows(), [("SSH key laptop: add", True), ("SSH key laptop: delete", True)])
self.assertFalse(Logs.objects.filter(message__contains="AAAA").exists())

View file

@ -0,0 +1,99 @@
"""Who may change their own password is the change_password permission: new
users get it, admins may take it away, and later migrate runs keep their choice."""
import importlib
from unittest.mock import MagicMock
from django.apps import apps
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.core.management import call_command
from django.test import TestCase, override_settings
from django.urls import reverse
from admin.forms import UserCreateForm
migration = importlib.import_module("accounts.migrations.0008_apply_show_profile_edit_password_once")
def permission():
return Permission.objects.get(content_type__app_label="accounts", codename="change_password")
def can_change(user):
return get_user_model().objects.get(pk=user.pk).has_perm("accounts.change_password")
class ChangePasswordPermissionTestCase(TestCase):
def test_new_users_may_change_their_password(self):
self.assertTrue(can_change(get_user_model().objects.create_user("new", password="pw")))
def test_user_without_the_permission_cannot_open_the_page(self):
user = get_user_model().objects.create_user("limited", password="pw")
user.user_permissions.remove(permission())
self.client.force_login(user)
self.assertEqual(self.client.get(reverse("accounts:change_password")).status_code, 403)
def test_a_user_who_must_change_it_has_no_cancel(self):
from accounts.models import UserAttributes
user = get_user_model().objects.create_user("forced", password="pw")
self.client.force_login(user)
page = self.client.get(reverse("accounts:change_password"))
self.assertContains(page, "your other sessions end")
self.assertContains(page, f'href="{reverse("accounts:profile")}">Cancel')
UserAttributes.objects.create(user=user, must_change_password=True)
page = self.client.get(reverse("accounts:change_password"))
self.assertContains(page, "Choose a new password before you go on.")
self.assertNotContains(page, f'href="{reverse("accounts:profile")}">Cancel')
def test_a_user_without_a_local_password_cannot_change_it(self):
user = get_user_model().objects.create_user("ldap-user") # as django-auth-ldap makes them
self.client.force_login(user)
self.assertEqual(self.client.get(reverse("accounts:change_password")).status_code, 403)
self.assertNotContains(self.client.get(reverse("accounts:profile")), reverse("accounts:change_password"))
def test_create_form_ticks_it_and_the_admin_choice_wins(self):
self.assertIn(permission(), UserCreateForm().fields["user_permissions"].initial)
form = UserCreateForm(data={"username": "unticked", "password": "Kq7-panel-pass", "is_active": True, "role": "user"})
self.assertTrue(form.is_valid(), form.errors)
self.assertFalse(can_change(form.save()))
@override_settings(SHOW_PROFILE_EDIT_PASSWORD=True)
def test_migrate_keeps_a_removed_permission(self):
user = get_user_model().objects.create_user("kept", password="pw")
user.user_permissions.remove(permission())
call_command("migrate", verbosity=0)
self.assertFalse(can_change(user))
class ApplySettingOnceTestCase(TestCase):
def run_migration(self):
migration.apply_setting_once(apps, MagicMock(connection=MagicMock(alias="default")))
def setUp(self):
self.with_perm = get_user_model().objects.create_user("with", password="pw")
self.without = get_user_model().objects.create_user("without", password="pw")
self.without.user_permissions.remove(permission())
@override_settings(SHOW_PROFILE_EDIT_PASSWORD=True)
def test_true_grants_to_everyone(self):
self.run_migration()
self.assertTrue(can_change(self.with_perm) and can_change(self.without))
@override_settings(SHOW_PROFILE_EDIT_PASSWORD=False)
def test_false_revokes_from_everyone(self):
self.run_migration()
self.assertFalse(can_change(self.with_perm) or can_change(self.without))
def test_without_the_setting_nothing_changes(self):
with self.settings():
from django.conf import settings
if hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"):
del settings.SHOW_PROFILE_EDIT_PASSWORD
self.run_migration()
self.assertTrue(can_change(self.with_perm))
self.assertFalse(can_change(self.without))

View file

@ -0,0 +1,23 @@
from django.conf import settings
from django.test import SimpleTestCase, override_settings
from accounts.checks import login_required_middleware_check
OLD = "login_required.middleware.LoginRequiredMiddleware"
NEW = "django.contrib.auth.middleware.LoginRequiredMiddleware"
class LoginRequiredMiddlewareCheckTestCase(SimpleTestCase):
def ids(self, middleware):
with override_settings(MIDDLEWARE=middleware):
return [e.id for e in login_required_middleware_check(None)]
def test_current_settings_pass(self):
self.assertEqual(self.ids(settings.MIDDLEWARE), [])
def test_settings_from_before_django_5_2_are_rejected(self):
old = [OLD if m == NEW else m for m in settings.MIDDLEWARE]
self.assertEqual(self.ids(old), ["accounts.E002"])
def test_missing_login_middleware_is_rejected(self):
self.assertEqual(self.ids([m for m in settings.MIDDLEWARE if m != NEW]), ["accounts.E002"])

View file

@ -0,0 +1,80 @@
"""The "Lost OTP?" page mails a user their OTP QR code without a login. It
exists only while OTP is enabled; it resends the QR only to a user who
has already enrolled (it never creates a device), mails one exact match at
most once per interval, and never fails with an error page."""
from django.contrib.auth import get_user_model
from django_otp.plugins.otp_totp.models import TOTPDevice
from django.core import mail
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.urls import reverse
from logs.models import Logs
URL = reverse("accounts:email_otp")
@override_settings(
OTP_ENABLED=True,
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "email-otp-tests"}},
)
class EmailOTPTests(TestCase):
def setUp(self):
cache.clear()
self.user = get_user_model().objects.create_user("otp-user", email="otp.user@example.com", password="x")
def enroll(self, user=None):
return TOTPDevice.objects.create(user=user or self.user, confirmed=True)
def post(self, email):
response = self.client.post(URL, {"email": email})
self.assertRedirects(response, reverse("accounts:login"), fetch_redirect_response=False)
return response
def test_one_mail_to_an_enrolled_user(self):
self.enroll()
self.post("OTP.User@Example.com") # addresses match case-insensitively
self.assertEqual([m.to for m in mail.outbox], [["otp.user@example.com"]])
# the requester is anonymous: the row does not make it the user
self.assertEqual(list(Logs.objects.values_list("user", "message", "success", "ip")),
[("", "User otp-user: mail OTP QR code (lost OTP)", True, "127.0.0.1")])
def test_an_unenrolled_user_gets_no_mail_and_no_device(self):
self.post("otp.user@example.com")
self.assertEqual(mail.outbox, [])
self.assertFalse(TOTPDevice.objects.filter(user=self.user).exists())
def test_repeated_requests_send_one_mail_per_interval(self):
self.enroll()
for _ in range(20):
self.post("otp.user@example.com")
self.assertEqual(len(mail.outbox), 1)
def test_an_address_of_two_users_sends_nothing(self):
get_user_model().objects.create_user("otp-twin", email="otp.user@example.com", password="x")
self.post("otp.user@example.com")
self.assertEqual(mail.outbox, [])
def test_unknown_and_inactive_addresses_send_nothing(self):
get_user_model().objects.create_user("otp-gone", email="gone@example.com", password="x", is_active=False)
self.post("nobody@example.com")
self.post("gone@example.com")
self.assertEqual(mail.outbox, [])
@override_settings(EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend", EMAIL_HOST="127.0.0.1", EMAIL_PORT=1)
def test_a_mail_server_that_is_down_is_no_error_page(self):
self.enroll()
with self.assertLogs("accounts.views", level="ERROR"):
self.post("otp.user@example.com")
self.assertIs(Logs.objects.get(message__startswith="User otp-user: mail OTP QR code (lost OTP):").success, False)
@override_settings(OTP_ENABLED=False)
class EmailOTPDisabledTests(TestCase):
def test_the_page_does_not_exist_without_otp(self):
get_user_model().objects.create_user("otp-off", email="off@example.com", password="x")
self.assertEqual(self.client.get(URL).status_code, 404)
self.assertEqual(self.client.post(URL, {"email": "off@example.com"}).status_code, 404)
self.assertEqual(mail.outbox, [])

View file

@ -0,0 +1,155 @@
"""The generated admin password must be replaced at first login."""
import tempfile
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse
from unittest.mock import patch
from accounts.models import UserAttributes
GENERATED = "Generated-Pass-123"
class FirstLoginPasswordChangeTestCase(TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.password_file = Path(self.tmp.name) / "data" / "admin_password"
self.password_file.parent.mkdir()
self.password_file.write_text(GENERATED + "\n")
settings_override = override_settings(BASE_DIR=Path(self.tmp.name))
settings_override.enable()
self.addCleanup(settings_override.disable)
self.admin = get_user_model().objects.create_superuser(
username="firstadmin", email="firstadmin@example.com", password=GENERATED
)
UserAttributes.objects.create(user=self.admin, must_change_password=True)
def test_login_with_generated_password_redirects_every_page_to_change_form(self):
self.client.login(request=RequestFactory().get("/"), username="firstadmin", password=GENERATED)
for url in (reverse("index"), reverse("instances:index"), reverse("accounts:profile")):
with self.subTest(url=url):
response = self.client.get(url)
self.assertRedirects(
response, reverse("accounts:change_password"), fetch_redirect_response=False
)
self.assertEqual(self.client.get(reverse("accounts:change_password")).status_code, 200)
def test_an_api_request_of_the_session_gets_json_not_the_form(self):
# as a token of that user does (accounts.auth)
self.client.login(request=RequestFactory().get("/"), username="firstadmin", password=GENERATED)
response = self.client.get("/api/v1/instances/")
self.assertEqual((response.status_code, response.json()), (403, {"detail": "Change your password before using the API."}))
def test_changing_the_password_lifts_the_redirect_and_deletes_the_file(self):
self.client.login(request=RequestFactory().get("/"), username="firstadmin", password=GENERATED)
self.client.post(
reverse("accounts:change_password"),
{
"old_password": GENERATED,
"new_password1": "A-new-strong-pass-456",
"new_password2": "A-new-strong-pass-456",
},
)
self.assertFalse(self.password_file.exists())
self.assertFalse(UserAttributes.objects.get(user=self.admin).must_change_password)
response = self.client.get(reverse("instances:index"))
self.assertEqual(response.status_code, 200)
def test_user_with_another_password_is_not_forced(self):
get_user_model().objects.create_superuser(
username="otheradmin", email="otheradmin@example.com", password="Other-pass-789"
)
self.client.login(request=RequestFactory().get("/"), username="otheradmin", password="Other-pass-789")
self.assertEqual(self.client.get(reverse("instances:index")).status_code, 200)
self.assertTrue(self.password_file.exists())
def test_missing_file_and_a_new_session_do_not_bypass_enforcement(self):
self.password_file.unlink()
self.client.force_login(self.admin)
self.assertRedirects(
self.client.get(reverse("accounts:profile")),
reverse("accounts:change_password"), fetch_redirect_response=False,
)
def test_unreadable_password_file_is_not_used_during_login(self):
with patch("accounts.apps.admin_password_path", side_effect=AssertionError("must not read file")):
self.client.login(request=RequestFactory().get("/"), username="firstadmin", password=GENERATED)
self.assertRedirects(
self.client.get(reverse("accounts:profile")),
reverse("accounts:change_password"), fetch_redirect_response=False,
)
def test_cleanup_failure_does_not_undo_a_successful_password_change(self):
self.client.force_login(self.admin)
with patch("accounts.views.os.remove", side_effect=PermissionError("read-only")):
response = self.client.post(reverse("accounts:change_password"), {
"old_password": GENERATED, "new_password1": "A-new-strong-pass-456",
"new_password2": "A-new-strong-pass-456",
})
self.assertEqual(response.status_code, 302)
self.assertFalse(UserAttributes.objects.get(user=self.admin).must_change_password)
self.admin.refresh_from_db()
self.assertTrue(self.admin.check_password("A-new-strong-pass-456"))
self.assertEqual(self.client.get(reverse("accounts:profile")).status_code, 200)
def test_missing_middleware_is_reported_by_the_upgrade_check(self):
from accounts.checks import password_change_middleware_check
with override_settings(MIDDLEWARE=["django.contrib.auth.middleware.AuthenticationMiddleware"]):
errors = password_change_middleware_check(None)
self.assertEqual([error.id for error in errors], ["accounts.E001"])
def test_legacy_migration_flags_only_the_matching_account(self):
import importlib
from types import SimpleNamespace
from django.apps import apps
from django.db import connection
migration = importlib.import_module("accounts.migrations.0007_userattributes_must_change_password")
UserAttributes.objects.filter(user=self.admin).update(must_change_password=False)
other = get_user_model().objects.create_superuser("legacy_other", "", "other-password")
migration.flag_legacy_generated_password(apps, SimpleNamespace(connection=connection))
self.assertTrue(UserAttributes.objects.get(user=self.admin).must_change_password)
self.assertFalse(UserAttributes.objects.filter(user=other, must_change_password=True).exists())
def test_legacy_migration_fails_if_password_file_cannot_be_read(self):
import importlib
migration = importlib.import_module("accounts.migrations.0007_userattributes_must_change_password")
with patch("pathlib.Path.read_text", side_effect=PermissionError("unreadable")):
with self.assertRaisesRegex(RuntimeError, "run migrate as its owner"):
migration.flag_legacy_generated_password(None, None)
class DefaultAdminPasswordMigrationTestCase(TestCase):
"""An installation of a version up to v2.0.0 still signed in as admin/admin."""
def admin_with(self, password):
admin, _ = get_user_model().objects.get_or_create(username="admin", defaults={"is_superuser": True})
admin.set_password(password)
admin.save()
UserAttributes.objects.filter(user=admin).delete() # the migration also makes them
return admin
def migrate(self):
import importlib
from types import SimpleNamespace
from django.apps import apps
from django.db import connection
migration = importlib.import_module("accounts.migrations.0013_default_admin_password")
migration.flag_default_admin_password(apps, SimpleNamespace(connection=connection))
def test_the_default_admin_password_must_be_changed(self):
admin = self.admin_with("admin")
self.migrate()
self.assertTrue(UserAttributes.objects.get(user=admin).must_change_password)
self.client.force_login(admin)
self.assertRedirects(self.client.get(reverse("instances:index")), reverse("accounts:change_password"),
fetch_redirect_response=False)
def test_a_changed_admin_password_is_left_alone(self):
admin = self.admin_with("Changed-pw-2026")
UserAttributes.objects.create(user=admin)
self.migrate()
self.assertFalse(UserAttributes.objects.get(user=admin).must_change_password)

View file

@ -0,0 +1,20 @@
"""A login through LDAP waits a bounded time for the directory."""
import unittest
from django.test import SimpleTestCase
try:
import ldap
from django_auth_ldap.backend import LDAPBackend
except ImportError: # LDAP is optional (conf/requirements-ldap.txt)
ldap = None
@unittest.skipIf(ldap is None, "python-ldap is not installed")
class LdapTimeoutTests(SimpleTestCase):
def test_the_connection_and_each_answer_have_a_timeout(self):
# python-ldap's default for both is to wait without end
options = LDAPBackend().settings.CONNECTION_OPTIONS
self.assertGreater(options.get(ldap.OPT_NETWORK_TIMEOUT, -1), 0)
self.assertGreater(options.get(ldap.OPT_TIMEOUT, -1), 0)

View file

@ -0,0 +1,99 @@
"""Failed logins lock a username for the address it is tried from
(django-axes). The number of attempts and the lock time come from the
Settings page."""
from datetime import timedelta
from appsettings.models import AppSettings
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse
from accounts.lockout import client_ip, cooloff_time, failure_limit
@override_settings(OTP_ENABLED=False)
class LoginLockoutTests(TestCase):
def setUp(self):
get_user_model().objects.create_user("lo-user", password="right-pass-1")
AppSettings.objects.filter(key="LOGIN_FAILURE_LIMIT").update(value="3")
def login(self, password, ip="203.0.113.10", via_proxy=True):
meta = {"REMOTE_ADDR": "127.0.0.1", "HTTP_X_REAL_IP": ip} if via_proxy else {"REMOTE_ADDR": ip}
return self.client.post(reverse("accounts:login"), {"username": "lo-user", "password": password}, **meta)
def test_the_limit_from_the_settings_page_locks_the_user_at_that_address(self):
for _ in range(3):
self.login("wrong")
self.assertEqual(self.login("right-pass-1").status_code, 429)
# another address is not locked: an attacker cannot lock someone out
self.assertEqual(self.login("right-pass-1", ip="198.51.100.7").status_code, 302)
def test_a_successful_login_starts_the_count_again(self):
self.login("wrong")
self.login("wrong")
self.assertEqual(self.login("right-pass-1").status_code, 302)
self.client.logout()
self.login("wrong")
self.login("wrong")
self.assertEqual(self.login("right-pass-1").status_code, 302)
def test_a_successful_login_is_logged_on_the_logs_page_only(self):
from axes.models import AccessLog
from logs.models import Logs
self.assertEqual(self.login("right-pass-1").status_code, 302)
self.assertTrue(Logs.objects.filter(user="lo-user").exists())
self.assertFalse(AccessLog.objects.exists())
def test_the_proxy_header_is_trusted_only_from_the_local_proxy(self):
# direct requests: X-Real-IP is ignored, so changing it does not escape the lock
for n in range(3):
self.client.post(reverse("accounts:login"), {"username": "lo-user", "password": "wrong"},
REMOTE_ADDR="203.0.113.20", HTTP_X_REAL_IP=f"10.0.0.{n}")
response = self.client.post(reverse("accounts:login"), {"username": "lo-user", "password": "right-pass-1"},
REMOTE_ADDR="203.0.113.20", HTTP_X_REAL_IP="10.0.0.99")
self.assertEqual(response.status_code, 429)
class LockoutSettingsTests(TestCase):
def request(self, remote, real_ip=None):
meta = {"REMOTE_ADDR": remote}
if real_ip:
meta["HTTP_X_REAL_IP"] = real_ip
return RequestFactory().post("/", **meta)
def test_values_from_the_settings_page(self):
AppSettings.objects.filter(key="LOGIN_FAILURE_LIMIT").update(value="7")
AppSettings.objects.filter(key="LOGIN_LOCKOUT_MINUTES").update(value="30")
self.assertEqual(failure_limit(None, {}), 7)
self.assertEqual(cooloff_time(None), timedelta(minutes=30))
def test_defaults_and_invalid_values(self):
for value in ("", "abc", "0", "-3"):
with self.subTest(value=value):
AppSettings.objects.filter(key__in=["LOGIN_FAILURE_LIMIT", "LOGIN_LOCKOUT_MINUTES"]).update(value=value)
self.assertEqual(failure_limit(None, {}), 5)
self.assertEqual(cooloff_time(None), timedelta(minutes=15))
def test_client_ip(self):
self.assertEqual(client_ip(self.request("127.0.0.1", "203.0.113.5")), "203.0.113.5")
self.assertEqual(client_ip(self.request("::1", "203.0.113.5")), "203.0.113.5")
self.assertEqual(client_ip(self.request("127.0.0.1")), "127.0.0.1")
self.assertEqual(client_ip(self.request("198.51.100.2", "203.0.113.5")), "198.51.100.2")
def test_client_ip_behind_a_configured_proxy(self):
with self.settings(LOGIN_TRUSTED_PROXIES=["172.18.0.0/16", "10.0.0.5"]):
self.assertEqual(client_ip(self.request("172.18.3.4", "203.0.113.5")), "203.0.113.5")
self.assertEqual(client_ip(self.request("10.0.0.5", "203.0.113.6")), "203.0.113.6")
self.assertEqual(client_ip(self.request("10.0.0.6", "203.0.113.6")), "10.0.0.6")
class SettingsPageTests(TestCase):
def test_both_values_are_on_the_settings_page(self):
self.client.force_login(get_user_model().objects.create_superuser("lo-admin", "lo@example.com", "pw"))
page = self.client.get(reverse("appsettings")).content.decode()
self.assertIn('name="LOGIN_FAILURE_LIMIT"', page)
self.assertIn('name="LOGIN_LOCKOUT_MINUTES"', page)
self.client.post(reverse("appsettings"), {"LOGIN_LOCKOUT_MINUTES": "45"})
self.assertEqual(cooloff_time(None), timedelta(minutes=45))

View file

@ -0,0 +1,181 @@
"""OTP login (SingleDeviceOTPForm): a user with a confirmed TOTP device must
enter a valid code; a user without one logs in with the password alone. The
device is chosen by the server (a posted device id is ignored), so the single
token field of the login page works and no one else's device can be used."""
import importlib
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase, override_settings
from django.urls import clear_url_caches
from django_otp.oath import totp
from django_otp.plugins.otp_totp.models import TOTPDevice
import accounts.urls
import webvirtcloud.urls
from accounts.otp import SingleDeviceOTPForm
def token_for(device, offset=0):
return format(
totp(device.bin_key, step=device.step, t0=device.t0, digits=device.digits, drift=offset),
"0{}d".format(device.digits),
)
def wrong_token(device):
"""A code that is invalid now: it differs from the tokens of the current
step and its neighbours (which TOTP accepts within its tolerance), so the
test never flakes on a code that happens to match."""
valid = {token_for(device, d) for d in (-1, 0, 1)}
return next("%06d" % i for i in range(1000000) if "%06d" % i not in valid)
class SingleDeviceOTPFormTestCase(TestCase):
def setUp(self):
self.User = get_user_model()
self.user = self.User.objects.create_user("alice", password="pw-correct-horse")
def _form(self, **extra):
data = {"username": "alice", "password": "pw-correct-horse", **extra}
return SingleDeviceOTPForm(RequestFactory().post("/accounts/login/"), data=data)
def _device(self, user=None, confirmed=True):
return TOTPDevice.objects.create(user=user or self.user, confirmed=confirmed)
def test_user_without_device_logs_in_with_password_only(self):
self.assertTrue(self._form(otp_token="").is_valid())
def test_user_with_device_needs_a_token(self):
self._device()
form = self._form(otp_token="")
self.assertFalse(form.is_valid())
def test_user_with_device_valid_token(self):
device = self._device()
self.assertTrue(self._form(otp_token=token_for(device)).is_valid())
def test_user_with_device_invalid_token(self):
device = self._device()
self.assertFalse(self._form(otp_token=wrong_token(device)).is_valid())
def test_single_token_field_without_device_id(self):
# the login page posts only otp_token; the form must still find the device
device = self._device()
form = self._form(otp_token=token_for(device))
self.assertTrue(form.is_valid(), form.errors)
def test_an_unconfirmed_device_does_not_require_otp(self):
self._device(confirmed=False)
self.assertTrue(self._form(otp_token="").is_valid())
def test_another_users_device_is_not_used(self):
other = self.User.objects.create_user("mallory", password="x")
other_device = self._device(user=other)
# alice has no device of her own: a token from mallory's device is ignored
self.assertTrue(self._form(otp_token=token_for(other_device)).is_valid())
def test_a_posted_device_id_is_ignored(self):
other = self.User.objects.create_user("mallory", password="x")
alice_device = self._device()
other_device = self._device(user=other)
form = self._form(otp_token=token_for(alice_device), otp_device=other_device.persistent_id)
self.assertTrue(form.is_valid(), form.errors)
def test_token_cannot_be_replayed(self):
device = self._device()
code = token_for(device)
self.assertTrue(self._form(otp_token=code).is_valid())
self.assertFalse(self._form(otp_token=code).is_valid())
def reload_urls():
importlib.reload(accounts.urls)
importlib.reload(webvirtcloud.urls)
clear_url_caches()
@override_settings(OTP_ENABLED=True)
class OTPLoginHTTPTestCase(TestCase):
"""The actual login page (OTP_ENABLED): the single token field logs an
enrolled user in, and a user without a device needs only the password.
Reloads the URLconf because accounts/urls.py picks the login form from
OTP_ENABLED at import time."""
@classmethod
def setUpClass(cls):
# class cleanups run last first: the URLs reload after the setting is off again
cls.addClassCleanup(reload_urls)
super().setUpClass() # turns OTP_ENABLED on
reload_urls()
def setUp(self):
self.user = get_user_model().objects.create_user("bob", password="pw-correct-horse")
def _post(self, **extra):
from django.urls import reverse
return self.client.post(reverse("accounts:login"), {
"username": "bob", "password": "pw-correct-horse", **extra,
})
def test_user_without_device_logs_in(self):
response = self._post(otp_token="")
self.assertEqual(response.status_code, 302)
def test_enrolled_user_logs_in_with_only_the_token(self):
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
response = self._post(otp_token=token_for(device))
self.assertEqual(response.status_code, 302)
def test_enrolled_user_wrong_token_stays(self):
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
response = self._post(otp_token=wrong_token(device))
self.assertEqual(response.status_code, 200)
def messages(self):
from logs.models import Logs
return list(Logs.objects.order_by("id").values_list("message", flat=True))
def test_a_wrong_otp_code_is_a_failed_login(self):
from axes.models import AccessAttempt
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
self._post(otp_token=wrong_token(device))
self.assertEqual(self.messages(), ["Login failed"])
self.assertEqual(AccessAttempt.objects.get(username="bob").failures_since_start, 1)
def test_wrong_otp_codes_reach_the_lockout(self):
from appsettings.models import AppSettings
AppSettings.objects.filter(key="LOGIN_FAILURE_LIMIT").update(value="3")
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
self._post(otp_token=wrong_token(device))
self._post(otp_token=wrong_token(device))
self.assertEqual(self._post(otp_token=wrong_token(device)).status_code, 429)
self.assertEqual(self.messages(), ["Login failed", "Login failed", "Locked out", "Login failed"])
def test_a_right_code_logs_in(self):
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
self._post(otp_token=token_for(device))
self.assertEqual(self.messages(), ["Login"])
def test_an_oversized_username_is_refused_by_the_form(self):
response = self._post(username="x" * 300, otp_token="")
self.assertEqual(response.status_code, 200)
self.assertIn("username", response.context["form"].errors)
self.assertEqual(self.messages(), [])
def test_a_signed_in_user_is_sent_on_from_the_login_page(self):
from django.urls import reverse
self.client.force_login(self.user)
response = self.client.get(f"{reverse('accounts:login')}?next={reverse('computes')}")
self.assertRedirects(response, reverse("computes"), fetch_redirect_response=False)
class OtpLoginPageTestCase(TestCase):
def test_a_field_error_is_shown_in_the_error_box(self):
from django.template.loader import render_to_string
request = RequestFactory().post("/accounts/login/")
form = SingleDeviceOTPForm(request, data={"username": "u" * 200, "password": "pw"})
self.assertFalse(form.is_valid())
page = render_to_string("accounts/otp_login.html", {"form": form}, request=request)
self.assertIn(str(form.errors["username"][0]), page)

View file

@ -0,0 +1,35 @@
"""A user's SSH keys: they go with the user, and a key is valid with or without a comment."""
from django.contrib.auth import get_user_model
from django.test import TestCase
from accounts.models import UserSSHKey
from accounts.utils import validate_ssh_key
class SshKeyTestCase(TestCase):
def test_ssh_key_cascade_deletion(self):
user = get_user_model().objects.create_user(username="sec_acc_user", password="password")
key = UserSSHKey.objects.create(
user=user,
keyname="my-key",
keypublic="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGabcdef123456 test@example.com",
)
self.assertTrue(UserSSHKey.objects.filter(id=key.id).exists())
# When user is deleted, key should be deleted via CASCADE (not left orphan)
user.delete()
self.assertFalse(UserSSHKey.objects.filter(id=key.id).exists())
def test_validate_ssh_key_supports_keys_with_and_without_comment(self):
rsa_base = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfxYHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw=="
# Key with comment
key_with_comment = f"{rsa_base} test@test"
self.assertTrue(validate_ssh_key(key_with_comment))
# Key without comment
key_without_comment = rsa_base
self.assertTrue(validate_ssh_key(key_without_comment))
# Invalid key
self.assertFalse(validate_ssh_key("not a valid key string"))

View file

@ -0,0 +1,31 @@
"""An email address belongs to one user: "Lost OTP?" mails only an exact,
single match, so a second user with the same address would switch it off for both."""
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from accounts.forms import ProfileForm
from admin.forms import UserForm
class UniqueEmailTests(TestCase):
def setUp(self):
self.alice = User.objects.create_user("alice", "alice@example.com", "pw")
self.bob = User.objects.create_user("bob", "", "pw")
def test_the_profile_refuses_another_users_address(self):
self.client.force_login(self.bob)
response = self.client.post(reverse("accounts:profile"), {"email": "Alice@Example.com"})
self.assertContains(response, "Another user has this email address")
self.bob.refresh_from_db()
self.assertEqual(self.bob.email, "")
def test_the_admin_form_refuses_another_users_address(self):
form = UserForm({"username": "bob", "email": "alice@example.com", "is_active": "on"}, instance=self.bob)
self.assertEqual(form.errors["email"], ["Another user has this email address"])
def test_own_address_and_no_address_are_kept(self):
User.objects.create_user("carol", "", "pw")
self.assertTrue(ProfileForm({"email": ""}, instance=self.bob).is_valid())
self.assertTrue(ProfileForm({"email": "alice@example.com"}, instance=self.alice).is_valid())

View file

@ -0,0 +1,119 @@
import base64
import hashlib
import re
import struct
import time
from types import SimpleNamespace
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse
from django_otp.plugins.otp_totp.models import TOTPDevice
from lxml import html
from accounts.utils import (OTP_LINK, OTP_LINK_MAX_AGE, get_existing_totp_device, get_user_totp_device, otp_qr_svg,
send_email_with_otp, validate_ssh_key)
class AccountsUtilsTestCase(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="test_otp_user", email="user@example.com", password="password123"
)
def test_validate_ssh_key_valid(self):
blob = (
struct.pack(">I", 7)
+ b"ssh-rsa"
+ struct.pack(">I", 3)
+ b"\x01\x00\x01"
+ struct.pack(">I", 8)
+ b"\x00" * 8
)
b64 = base64.b64encode(blob).decode()
valid_key = f"ssh-rsa {b64} user@example.com"
self.assertTrue(validate_ssh_key(valid_key))
# ssh-keygen -C "John Doe laptop": the comment holds spaces
self.assertTrue(validate_ssh_key(f"ssh-rsa {b64} John Doe laptop"))
self.assertTrue(validate_ssh_key(f"ssh-rsa {b64}"))
def test_validate_ssh_key_invalid_parts(self):
self.assertFalse(validate_ssh_key("only_two parts"))
self.assertFalse(validate_ssh_key("one_part"))
def test_validate_ssh_key_invalid_base64(self):
self.assertFalse(validate_ssh_key("ssh-rsa not-valid-b64! user@example.com"))
def test_validate_ssh_key_struct_error(self):
# Base64 with less than 4 bytes cannot unpack length
short_b64 = base64.b64encode(b"ab").decode()
self.assertFalse(validate_ssh_key(f"ssh-rsa {short_b64} user@example.com"))
def test_validate_ssh_key_type_mismatch(self):
blob = struct.pack(">I", 7) + b"ssh-rsa" + b"\x00" * 8
b64 = base64.b64encode(blob).decode()
# header says ssh-ed25519 but payload has ssh-rsa
self.assertFalse(validate_ssh_key(f"ssh-ed25519 {b64} user@example.com"))
def test_get_user_totp_device_creates_new(self):
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 0)
device = get_user_totp_device(self.user)
self.assertIsInstance(device, TOTPDevice)
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 1)
def test_get_user_totp_device_retrieves_existing(self):
device1 = get_user_totp_device(self.user)
device2 = get_user_totp_device(self.user)
self.assertEqual(device1.id, device2.id)
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 1)
def test_get_existing_totp_device_returns_none_without_a_device(self):
self.assertIsNone(get_existing_totp_device(self.user))
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 0)
def test_get_existing_totp_device_ignores_unconfirmed(self):
TOTPDevice.objects.create(user=self.user, confirmed=False)
self.assertIsNone(get_existing_totp_device(self.user))
def test_get_existing_totp_device_returns_confirmed(self):
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
self.assertEqual(get_existing_totp_device(self.user).id, device.id)
def test_the_device_is_made_under_a_lock_on_the_user(self):
from django.db.models.query import QuerySet
with patch.object(QuerySet, "select_for_update", autospec=True, side_effect=QuerySet.select_for_update) as lock:
device = get_user_totp_device(self.user)
lock.assert_called_once()
self.assertEqual(get_user_totp_device(self.user), device)
@override_settings(OTP_ENABLED=True)
def test_the_mail_links_to_the_qr_code_and_holds_no_secret(self):
device = get_user_totp_device(self.user)
send_email_with_otp(RequestFactory().get("/"), self.user)
self.assertEqual(len(mail.outbox), 1)
message = mail.outbox[0]
self.assertEqual((message.subject, message.to, message.alternatives), ("OTP QR Code", ["user@example.com"], []))
self.assertNotIn(device.bin_key.hex(), message.body)
self.assertNotIn("otpauth", message.body)
link = re.search(r"http://testserver(/\S+)", message.body).group(1)
page = self.client.get(link)
self.assertEqual(page["Cache-Control"], "no-store")
self.assertEqual(len(html.fromstring(page.content).xpath("//svg")), 1)
@override_settings(OTP_ENABLED=True)
def test_a_link_that_is_old_or_not_signed_shows_nothing(self):
get_user_totp_device(self.user)
token = OTP_LINK.sign(str(self.user.pk))
with patch("django.core.signing.time.time", return_value=time.time() + OTP_LINK_MAX_AGE + 1):
self.assertEqual(self.client.get(reverse("accounts:otp_qr", args=[token])).status_code, 404)
self.assertEqual(self.client.get(reverse("accounts:otp_qr", args=[f"{self.user.pk}:x:y"])).status_code, 404)
def test_the_qr_code_is_what_django_qr_code_drew(self):
# sha256 of the SVG django-qr-code 4.2.0 made for this URL
url = "otpauth://totp/WebVirtCloud:alice?secret=JBSWY3DPEHPK3PXP&issuer=WebVirtCloud"
svg = str(otp_qr_svg(SimpleNamespace(config_url=url)))
self.assertEqual(hashlib.sha256(svg.encode()).hexdigest(), "b57323be725891af13d862a3a3351cac3ac64a493793c0258cf3e4a5886467c2")

View file

@ -1,10 +1,11 @@
from appsettings.models import AppSettings
from appsettings.settings import app_settings
from computes.models import Compute
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.shortcuts import reverse
from django.test import Client, TestCase
from django.test import Client, RequestFactory, TestCase, override_settings
from instances.models import Instance
@ -15,12 +16,9 @@ from accounts.utils import validate_ssh_key
class AccountsTestCase(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Add users for testing purposes
def setUpTestData(cls):
User = get_user_model()
cls.admin_user = User.objects.get(pk=1)
cls.admin_user = User.objects.create_superuser("accounts-admin", "admin@example.com", "pw")
cls.test_user = User.objects.create_user(username="test", password="test")
# Add compute
@ -37,16 +35,8 @@ class AccountsTestCase(TestCase):
name="test-vm",
)
@classmethod
def tearDownClass(cls):
if getattr(cls, "instance", None):
cls.instance.delete()
if getattr(cls, "compute", None):
cls.compute.delete()
super().tearDownClass()
def setUp(self):
self.client.login(username="admin", password="admin")
self.client.force_login(self.admin_user)
permission = Permission.objects.get(codename="change_password")
self.test_user.user_permissions.add(permission)
self.rsa_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfxYHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw== test@test"
@ -61,8 +51,8 @@ class AccountsTestCase(TestCase):
)
self.assertEqual(response.status_code, 200)
@override_settings(OTP_ENABLED=True)
def test_account_with_otp(self):
settings.OTP_ENABLED = True
response = self.client.get(
reverse("accounts:account", args=[self.test_user.id])
)
@ -76,9 +66,17 @@ class AccountsTestCase(TestCase):
)
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL)
response = client.get(reverse("accounts:logout"))
response = client.post(reverse("accounts:logout"))
self.assertRedirects(response, reverse("accounts:login"))
def test_a_signed_in_user_is_sent_on_from_the_login_page(self):
self.client.force_login(self.test_user)
login = reverse("accounts:login")
response = self.client.get(f"{login}?next={reverse('computes')}")
self.assertRedirects(response, reverse("computes"), fetch_redirect_response=False)
response = self.client.get(f"{login}?next=https://example.com/")
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False)
def test_change_password(self):
self.client.force_login(self.test_user)
@ -89,8 +87,8 @@ class AccountsTestCase(TestCase):
reverse("accounts:change_password"),
{
"old_password": "wrongpass",
"new_password1": "newpw",
"new_password2": "newpw",
"new_password1": "Wvc-changed-9",
"new_password2": "Wvc-changed-9",
},
)
self.assertEqual(response.status_code, 200)
@ -99,15 +97,15 @@ class AccountsTestCase(TestCase):
reverse("accounts:change_password"),
{
"old_password": "test",
"new_password1": "newpw",
"new_password2": "newpw",
"new_password1": "Wvc-changed-9",
"new_password2": "Wvc-changed-9",
},
)
self.assertRedirects(response, reverse("accounts:profile"))
self.client.logout()
logged_in = self.client.login(username="test", password="newpw")
logged_in = self.client.login(request=RequestFactory().get("/"), username="test", password="Wvc-changed-9")
self.assertTrue(logged_in)
def test_user_instance_create_update_delete(self):
@ -227,7 +225,7 @@ class AccountsTestCase(TestCase):
"keypublic": self.rsa_key,
},
)
self.assertRedirects(response, reverse("accounts:profile"))
self.assertRedirects(response, reverse("accounts:profile") + "#sshkeys")
key = UserSSHKey.objects.get(keyname="keyname")
self.assertEqual(key.keyname, "keyname")
@ -237,7 +235,22 @@ class AccountsTestCase(TestCase):
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("accounts:ssh_key_delete", args=[key.id]))
self.assertRedirects(response, reverse("accounts:profile"))
self.assertRedirects(response, reverse("accounts:profile") + "#sshkeys")
def test_the_profile_lists_keys_and_tokens_where_links_land(self):
admin = self.admin_user
key = UserSSHKey.objects.create(user=admin, keyname="laptop", keypublic=self.rsa_key)
response = self.client.get(reverse("accounts:profile"))
# other pages link to #sshkeys and #apitokens
self.assertContains(response, 'id="sshkeys"')
self.assertContains(response, 'id="apitokens"')
self.assertContains(response, f'href="{reverse("accounts:ssh_key_delete", args=[key.id])}"')
self.assertContains(response, f'href="{reverse("accounts:change_password")}"')
# an LDAP user's password is changed in LDAP
admin.set_unusable_password()
admin.save()
self.client.force_login(admin)
self.assertNotContains(self.client.get(reverse("accounts:profile")), f'href="{reverse("accounts:change_password")}"')
def test_validate_ssh_key(self):
self.assertFalse(validate_ssh_key(""))
@ -264,6 +277,7 @@ class AccountsTestCase(TestCase):
)
self.assertFalse(form.is_valid())
self.addCleanup(setattr, app_settings, "ALLOW_INSTANCE_MULTIPLE_OWNER", app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER)
app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER = "False"
form = UserInstanceForm(
{
@ -285,3 +299,58 @@ class AccountsTestCase(TestCase):
}
)
self.assertFalse(form.is_valid())
class SingleOwnerEditTests(TestCase):
"""With one owner per VM, the owner's own rights can still be changed."""
def test_the_only_owner_is_edited(self):
AppSettings.objects.filter(key="ALLOW_INSTANCE_MULTIPLE_OWNER").update(value="False")
admin = get_user_model().objects.create_superuser("audit-admin", "audit@example.com", "pw")
bob = get_user_model().objects.create_user("bob")
compute = Compute.objects.create(name="c1", hostname="127.0.0.1", login="", password="", type=4)
vm = Instance.objects.create(compute=compute, name="vm", uuid="99999999-9999-9999-9999-999999999999")
ui = UserInstance.objects.create(user=bob, instance=vm)
self.client.force_login(admin)
response = self.client.post(reverse("accounts:user_instance_update", args=[ui.pk]), {"is_vnc": "on"})
self.assertRedirects(response, reverse("accounts:account", args=[bob.pk]), fetch_redirect_response=False)
ui.refresh_from_db()
self.assertTrue(ui.is_vnc)
class OwnerRightsReturnTestCase(TestCase):
"""Edit rights on a VM's Users tab comes back to that tab; a page of
another site does not count."""
def test_an_owner_s_rights_come_back_to_the_vm_page(self):
admin = get_user_model().objects.create_superuser("rights-admin", "rights@example.com", "x")
owner = get_user_model().objects.create_user("rights-owner")
compute = Compute.objects.create(name="rights", hostname="192.0.2.30", login="u", password="p", type=1)
vm = Instance.objects.create(compute=compute, name="rights-vm", uuid="12121212-0000-0000-0000-000000000000")
user_instance = UserInstance.objects.create(user=owner, instance=vm)
self.client.force_login(admin)
update = reverse("accounts:user_instance_update", args=[user_instance.id])
for next_url, back in ((f"/instances/{vm.id}/%23users", f"/instances/{vm.id}/#users"),
("https://example.com/", reverse("accounts:account", args=[owner.id]))):
with self.subTest(next_url):
response = self.client.post(f"{update}?next={next_url}", {"is_change": True})
self.assertRedirects(response, back, fetch_redirect_response=False)
class UserInstanceDeleteTestCase(TestCase):
def test_open_redirect_prevented_in_user_instance_delete(self):
User = get_user_model()
admin = User.objects.create_superuser(username="sec_acc_admin", email="admin@example.com", password="password")
user = User.objects.create_user(username="sec_acc_user", password="password")
compute = Compute.objects.create(name="acc-comp", hostname="127.0.0.1", login="root", type=1)
instance = Instance.objects.create(compute=compute, name="acc-vm", uuid="99999999-9999-9999-9999-999999999999")
user_inst = UserInstance.objects.create(instance=instance, user=user)
self.client.force_login(admin)
url = reverse("accounts:user_instance_delete", args=[user_inst.id])
# Attempt external malicious redirect
res = self.client.post(f"{url}?next=https://malicious-attacker.com")
self.assertEqual(res.status_code, 302)
# Should redirect to safe internal URL (user account page), NOT external site
self.assertNotIn("malicious-attacker.com", res.url)
self.assertEqual(res.url, reverse("accounts:account", args=[user.id]))

View file

@ -1,17 +1,21 @@
from django.conf import settings
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path
from django_otp.forms import OTPAuthenticationForm
from . import views
from .views import CustomLoginView
from .forms import LoginForm
from .otp import SingleDeviceOTPForm
app_name = "accounts"
urlpatterns = [
path("logout/", LogoutView.as_view(template_name="logout.html"), name="logout"),
# LOGOUT_REDIRECT_URL sends a signed-out user to the sign-in page
path("logout/", LogoutView.as_view(), name="logout"),
path("profile/", views.profile, name="profile"),
path("profile/<int:user_id>/", views.account, name="account"),
path("profile/<int:user_id>/api_token/<str:token_key>/delete/", views.api_token_delete,
name="account_api_token_delete"),
path("profile/<int:user_id>/ssh_key/<int:pk>/delete/", views.ssh_key_delete, name="account_ssh_key_delete"),
path("change_password/", views.change_password, name="change_password"),
path(
"user_instance/create/<int:user_id>/",
@ -30,7 +34,11 @@ urlpatterns = [
),
path("ssh_key/create/", views.ssh_key_create, name="ssh_key_create"),
path("ssh_key/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"),
path("api_token/create/", views.api_token_create, name="api_token_create"),
path("api_token/created/", views.api_token_created, name="api_token_created"),
path("api_token/<str:token_key>/delete/", views.api_token_delete, name="api_token_delete"),
path("email_otp/", views.email_otp, name="email_otp"),
path("otp_qr/<str:token>/", views.otp_qr, name="otp_qr"),
path(
"admin_email_otp/<int:user_id>/",
views.admin_email_otp,
@ -44,12 +52,14 @@ if settings.OTP_ENABLED:
"login/",
LoginView.as_view(
template_name="accounts/otp_login.html",
authentication_form=OTPAuthenticationForm,
authentication_form=SingleDeviceOTPForm,
redirect_authenticated_user=True,
),
name="login",
),
]
else:
urlpatterns += (
path("login/", CustomLoginView.as_view(template_name="login.html"), name="login"),
path("login/", LoginView.as_view(template_name="login.html", authentication_form=LoginForm,
redirect_authenticated_user=True), name="login"),
)

View file

@ -1,29 +1,46 @@
import base64
import binascii
import struct
from decimal import Decimal
import segno
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.core.signing import TimestampSigner
from django.db import transaction
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django_otp import devices_for_user
from django_otp.plugins.otp_totp.models import TOTPDevice
def get_user_totp_device(user):
devices = devices_for_user(user)
for device in devices:
def get_existing_totp_device(user):
"""The user's confirmed TOTP device, or None. Unlike get_user_totp_device
it never creates one, so an unauthenticated caller cannot turn a
password-only account into an OTP-required one."""
for device in devices_for_user(user, confirmed=True):
if isinstance(device, TOTPDevice):
return device
return None
device = user.totpdevice_set.create()
return device
def get_user_totp_device(user):
"""The user's TOTP device, made if they have none. The user's row is
locked meanwhile: two requests at once (a double click) make one device,
not two that both log in."""
with transaction.atomic():
get_user_model().objects.select_for_update().get(pk=user.pk)
for device in devices_for_user(user):
if isinstance(device, TOTPDevice):
return device
return user.totpdevice_set.create()
def validate_ssh_key(key):
array = key.encode().split()
# Each rsa-ssh key has 3 different strings in it, first one being
# typeofkey second one being keystring third one being username .
if len(array) != 3:
# An OpenSSH public key is a type, a key and an optional comment, which may hold spaces
array = key.strip().encode().split(maxsplit=2)
if len(array) < 2:
return False
typeofkey = array[0]
string = array[1]
@ -44,18 +61,26 @@ def validate_ssh_key(key):
return True
def send_email_with_otp(user, device):
# the link a mail carries to its user's OTP QR code: the mail holds no secret
OTP_LINK = TimestampSigner(salt="accounts.otp_qr")
OTP_LINK_MAX_AGE = 3600
def send_email_with_otp(request, user):
"""Mail the user a link to the page of their OTP QR code, good for
OTP_LINK_MAX_AGE: the QR code's secret is never in a mailbox."""
url = request.build_absolute_uri(reverse("accounts:otp_qr", args=[OTP_LINK.sign(str(user.pk))]))
send_mail(
_("OTP QR Code"),
_("Please view HTML version of this message."),
_("Open this link within an hour to see the OTP QR code of account '%(user)s':\n\n%(url)s")
% {"user": user, "url": url},
None,
[user.email],
html_message=render_to_string(
"accounts/email/otp.html",
{
"totp_url": device.config_url,
"user": user,
},
),
fail_silently=False,
)
def otp_qr_svg(device):
"""The device's QR code as inline SVG: 1.8 mm modules, error correction M, a 4-module border."""
qr = segno.make(device.config_url, error="m", mode="byte", encoding="utf-8", micro=False)
return mark_safe(qr.svg_inline(scale=Decimal("1.8"), unit="mm", border=4, dark="black", light="white"))

View file

@ -1,42 +1,50 @@
import logging
import os
from datetime import timedelta
from admin.decorators import superuser_only
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model, update_session_auth_hash, login as auth_login
from django.contrib.auth.decorators import permission_required
from django.contrib.auth import get_user_model, update_session_auth_hash
from django.contrib.auth.decorators import login_not_required, login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.core.exceptions import PermissionDenied
from django.core.signing import BadSignature
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.http import HttpResponseRedirect
from django.core.cache import cache
from django.http import Http404
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from instances.models import Instance
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_POST
from django.views.decorators.debug import sensitive_post_parameters
from accounts.forms import EmailOTPForm, ProfileForm, UserSSHKeyForm
from accounts.models import *
from accounts.apps import admin_password_path
from computes.utils import lock_ownership
from logs.audit import audit, audit_vm, changed_fields, failed, snapshot
from accounts.forms import ApiTokenForm, EmailOTPForm, ProfileForm, UserSSHKeyForm
from accounts.models import ApiToken, User, UserAttributes, UserInstance, UserSSHKey
from . import forms
from .utils import get_user_totp_device, send_email_with_otp
from django.contrib.auth.views import LoginView
from logs.views import addlogmsg
from .utils import OTP_LINK, OTP_LINK_MAX_AGE, get_existing_totp_device, get_user_totp_device, otp_qr_svg, send_email_with_otp
class CustomLoginView(LoginView):
def form_valid(self, form):
username = form.cleaned_data['username']
addlogmsg(username, "-", "-", "Logged In")
auth_login(self.request, form.get_user())
return HttpResponseRedirect(self.get_success_url())
def form_invalid(self, form):
username = form.cleaned_data['username']
addlogmsg(username, "-", "-", "Failed Login Attempt")
return self.render_to_response(self.get_context_data(form=form))
def profile(request):
publickeys = UserSSHKey.objects.filter(user_id=request.user.id)
before = snapshot(request.user)
profile_form = ProfileForm(request.POST or None, instance=request.user)
ssh_key_form = UserSSHKeyForm()
if profile_form.is_valid():
profile_form.save()
with audit(request, None, f"User {request.user.username}", "change") as entry:
profile_form.save()
fields = changed_fields(before, snapshot(request.user))
entry.action = "change " + ", ".join(fields)
entry.skip = not fields
messages.success(request, _("Profile updated"))
return redirect("accounts:profile")
@ -47,6 +55,75 @@ def profile(request):
"publickeys": publickeys,
"profile_form": profile_form,
"ssh_key_form": ssh_key_form,
"api_tokens": ApiToken.objects.filter(user=request.user),
"api_token_form": ApiTokenForm(),
},
)
@require_POST
@never_cache
def api_token_create(request):
"""A REST API token, shown once: only its digest is kept. It is made in
a session that passed the login, and its OTP when OTP is on."""
form = ApiTokenForm(request.POST)
if not form.is_valid():
return render(request, "common/form.html", {"form": form, "title": _("Create an API token")})
name, days = form.cleaned_data["name"], form.cleaned_data["days"]
with transaction.atomic():
# the user's row: two forms sent at once (a double click) take their turns
get_user_model().objects.select_for_update().get(pk=request.user.pk)
ApiToken.objects.filter(user=request.user, expiry__lt=timezone.now()).delete()
if ApiToken.objects.filter(user=request.user).count() >= ApiToken.LIMIT:
form.add_error(None, _("A user has at most %(limit)s API tokens: delete one first.") % {"limit": ApiToken.LIMIT})
elif ApiToken.objects.filter(user=request.user, name=name).exists():
form.add_error("name", _("You have a token named %(name)s: choose another name.") % {"name": name})
else:
with audit(request, None, f"API token {name}", "add"):
_token, key = ApiToken.objects.create(request.user, expiry=timedelta(days=days) if days else None,
name=name)
if form.errors:
return render(request, "common/form.html", {"form": form, "title": _("Create an API token")})
# shown by a GET, so reloading the page does not create another token
request.session["api_token_created"] = {"name": name, "key": key}
return redirect("accounts:api_token_created")
@never_cache
def api_token_created(request):
"""The token just created, shown once."""
created = request.session.pop("api_token_created", None)
if created is None:
return redirect(reverse("accounts:profile") + "#apitokens")
return render(request, "api_token_created.html", created)
def api_token_delete(request, token_key, user_id=None):
"""Delete one of the user's own tokens or, given user_id, a superuser
deletes one of that user's."""
if user_id is None:
token = get_object_or_404(ApiToken, token_key=token_key, user=request.user)
subject = f"API token {token.name}"
back = reverse("accounts:profile")
elif request.user.is_superuser:
token = get_object_or_404(ApiToken, token_key=token_key, user_id=user_id)
subject = f"API token {token.name} of {token.user.username}"
back = reverse("accounts:account", args=[user_id])
else:
raise PermissionDenied
if request.method == "POST":
with audit(request, None, subject, "delete"):
token.delete()
messages.success(request, _("API token deleted"))
return redirect(back + "#apitokens")
return render(
request,
"common/confirm_delete.html",
{
"object": subject,
"title": _("Delete API token"),
"consequence": _("Scripts and tools that use it can no longer reach the API."),
},
)
@ -54,9 +131,10 @@ def profile(request):
def ssh_key_create(request):
key_form = UserSSHKeyForm(request.POST or None, user=request.user)
if key_form.is_valid():
key_form.save()
with audit(request, None, f"SSH key {key_form.cleaned_data['keyname']}", "add"):
key_form.save()
messages.success(request, _("SSH key added"))
return redirect("accounts:profile")
return redirect(reverse("accounts:profile") + "#sshkeys")
return render(
request,
@ -68,28 +146,40 @@ def ssh_key_create(request):
)
def ssh_key_delete(request, pk):
ssh_key = get_object_or_404(UserSSHKey, pk=pk, user=request.user)
def ssh_key_delete(request, pk, user_id=None):
"""Delete one of the user's own keys or, given user_id, a superuser
deletes one of that user's."""
if user_id is None:
ssh_key = get_object_or_404(UserSSHKey, pk=pk, user=request.user)
subject = f"SSH key {ssh_key.keyname}"
back = reverse("accounts:profile") + "#sshkeys"
elif request.user.is_superuser:
ssh_key = get_object_or_404(UserSSHKey, pk=pk, user_id=user_id)
subject = f"SSH key {ssh_key.keyname} of {ssh_key.user.username}"
back = reverse("accounts:account", args=[user_id]) + "#public-keys"
else:
raise PermissionDenied
if request.method == "POST":
ssh_key.delete()
with audit(request, None, subject, "delete"):
ssh_key.delete()
messages.success(request, _("SSH key deleted"))
return redirect("accounts:profile")
return redirect(back)
return render(
request,
"common/confirm_delete.html",
{
"object": ssh_key,
"object": subject,
"title": _("Delete SSH key"),
"consequence": _("VMs that already have it keep it; VMs created from now on do not get it."),
},
)
@superuser_only
def account(request, user_id):
user = User.objects.get(id=user_id)
user_insts = UserInstance.objects.filter(user_id=user_id)
instances = Instance.objects.all().order_by("name")
user = get_object_or_404(User, id=user_id)
user_insts = UserInstance.objects.filter(user_id=user_id).select_related("instance__compute")
publickeys = UserSSHKey.objects.filter(user_id=user_id)
return render(
@ -98,24 +188,44 @@ def account(request, user_id):
{
"user": user,
"user_insts": user_insts,
"instances": instances,
"publickeys": publickeys,
"api_tokens": ApiToken.objects.filter(user_id=user_id),
"otp_enabled": settings.OTP_ENABLED,
},
)
@permission_required("accounts.change_password", raise_exception=True)
@sensitive_post_parameters()
@login_required
def change_password(request):
must_change = UserAttributes.objects.filter(
user=request.user, must_change_password=True
).exists()
if not must_change and not request.user.has_perm("accounts.change_password"):
raise PermissionDenied
if not request.user.has_usable_password(): # an LDAP user: the password is changed in LDAP
raise PermissionDenied
form = PasswordChangeForm(request.user, request.POST or None)
if form.is_valid():
user = form.save()
update_session_auth_hash(request, user) # Important!
with audit(request, None, f"User {request.user.username}", "change password"), transaction.atomic():
user = form.save()
UserAttributes.objects.filter(user=user).update(must_change_password=False)
update_session_auth_hash(request, user)
request.session.pop("must_change_password", None)
if must_change and not UserAttributes.objects.filter(must_change_password=True).exists():
try:
os.remove(admin_password_path())
except FileNotFoundError:
pass
except OSError:
logging.getLogger(__name__).warning("Could not remove the generated admin password file")
messages.success(request, _("Password Changed"))
return redirect("accounts:profile")
if form.has_error("old_password", "password_incorrect"): # someone at an open session guessing it
failed(request, None, f"User {request.user.username}", "change password", "wrong old password")
return render(request, "accounts/change_password_form.html", {"form": form})
return render(request, "accounts/change_password_form.html", {"form": form, "must_change": must_change})
@superuser_only
@ -124,8 +234,17 @@ def user_instance_create(request, user_id):
form = forms.UserInstanceForm(request.POST or None, initial={"user": user})
if form.is_valid():
form.save()
return redirect(reverse("accounts:account", args=[user.id]))
instance = form.cleaned_data["instance"]
# the form's one-owner check again, with the insert in one step, as on the VM page
with audit_vm(request, instance, f"add owner {form.cleaned_data['user'].username}") as entry, \
transaction.atomic():
lock_ownership()
form.full_clean()
entry.skip = not form.is_valid()
if form.is_valid():
form.save()
if form.is_valid():
return redirect(reverse("accounts:account", args=[user.id]))
return render(
request,
@ -137,13 +256,29 @@ def user_instance_create(request, user_id):
)
def _next_or(request, default):
"""To the ?next page, the VM page an owner was changed from, when it is
on this site; else to default."""
next_url = request.GET.get("next")
if next_url and url_has_allowed_host_and_scheme(
url=next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()
):
return redirect(next_url)
return redirect(default)
@superuser_only
def user_instance_update(request, pk):
user_instance = get_object_or_404(UserInstance, pk=pk)
instance, before = user_instance.instance, snapshot(user_instance)
form = forms.UserInstanceForm(request.POST or None, instance=user_instance)
if form.is_valid():
form.save()
return redirect(reverse("accounts:account", args=[user_instance.user.id]))
with audit_vm(request, instance, "change owner") as entry:
form.save()
fields = changed_fields(before, snapshot(user_instance))
entry.action = f"change owner {user_instance.user.username}: " + ", ".join(fields)
entry.skip = not fields
return _next_or(request, reverse("accounts:account", args=[user_instance.user.id]))
return render(
request,
@ -159,32 +294,48 @@ def user_instance_update(request, pk):
def user_instance_delete(request, pk):
user_instance = get_object_or_404(UserInstance, pk=pk)
if request.method == "POST":
user = user_instance.user
user_instance.delete()
next = request.GET.get("next", None)
if next:
return redirect(next)
else:
return redirect(reverse("accounts:account", args=[user.id]))
user, instance = user_instance.user, user_instance.instance
with audit_vm(request, instance, f"remove owner {user.username}"):
user_instance.delete()
return _next_or(request, reverse("accounts:account", args=[user.id]))
return render(
request,
"common/confirm_delete.html",
{"object": user_instance},
{"object": user_instance,
"consequence": _("The user loses access to this VM; the VM itself is not changed.")},
)
# a user gets at most one OTP mail per this many seconds
EMAIL_OTP_INTERVAL = 300
@login_not_required
def email_otp(request):
"""
"Lost OTP?": mail a link to the OTP QR code to the user with this address. Open to
anyone, so it mails only one exact, active match, at most once per
EMAIL_OTP_INTERVAL, and answers the same in every case.
"""
if not settings.OTP_ENABLED:
raise Http404
form = EmailOTPForm(request.POST or None)
if form.is_valid():
UserModel = get_user_model()
try:
user = UserModel.objects.get(email=form.cleaned_data["email"])
except UserModel.DoesNotExist:
pass
else:
device = get_user_totp_device(user)
send_email_with_otp(user, device)
users = list(get_user_model().objects.filter(email__iexact=form.cleaned_data["email"], is_active=True)[:2])
# the cache is per process: with several workers the bound is per worker
if len(users) == 1 and cache.add(f"email_otp:{users[0].pk}", True, EMAIL_OTP_INTERVAL):
user = users[0]
# resend only to a user who already enrolled: creating a device here
# would let anyone who knows an email force OTP on that account
device = get_existing_totp_device(user)
if device is not None:
try:
# the row's user is the requester: anonymous
with audit(request, None, f"User {user.username}", "mail OTP QR code (lost OTP)"):
send_email_with_otp(request, user)
except Exception:
logging.getLogger(__name__).exception("Could not mail the OTP QR code to user %s", user.pk)
messages.success(
request, _("OTP Sent to %(email)s") % {"email": form.cleaned_data["email"]}
@ -201,15 +352,42 @@ def email_otp(request):
)
@login_not_required
def otp_qr(request, token):
"""The OTP QR code of the user a mailed link (send_email_with_otp) names."""
if not settings.OTP_ENABLED:
raise Http404
try:
user_id = OTP_LINK.unsign(token, max_age=OTP_LINK_MAX_AGE)
except BadSignature:
raise Http404
user = get_object_or_404(get_user_model(), pk=user_id, is_active=True)
device = get_existing_totp_device(user)
if device is None:
raise Http404
response = render(request, "accounts/otp_qr.html", {"user": user, "qr_svg": otp_qr_svg(device)})
response["Cache-Control"] = "no-store"
return response
@require_POST
@superuser_only
def admin_email_otp(request, user_id):
user = get_object_or_404(get_user_model(), pk=user_id)
device = get_user_totp_device(user)
subject = f"User {user.username}"
if user.email != "":
send_email_with_otp(user, device)
messages.success(
request, _("OTP QR code was emailed to user %(user)s") % {"user": user}
)
try:
with audit(request, None, subject, "mail OTP QR code"):
get_user_totp_device(user)
send_email_with_otp(request, user)
except OSError as err: # the SMTP server refused it, or did not answer within EMAIL_TIMEOUT
messages.error(request, _("The OTP QR code could not be mailed to %(user)s: %(error)s") % {
"user": user, "error": err})
else:
messages.success(
request, _("OTP QR code was emailed to user %(user)s") % {"user": user}
)
else:
failed(request, None, subject, "mail OTP QR code", "no email address")
messages.error(request, _("User email not set, failed to send QR code"))
return redirect("accounts:account", user.id)

View file

@ -1 +0,0 @@
default_app_config = 'admin.apps.AdminConfig'

View file

@ -1,7 +1,9 @@
from functools import wraps
from django.core.exceptions import PermissionDenied
def superuser_only(function):
@wraps(function)
def _inner(request, *args, **kwargs):
if not request.user.is_superuser:
raise PermissionDenied

View file

@ -1,15 +1,36 @@
from decimal import ROUND_HALF_UP, Decimal
from django import forms
from django.contrib.auth.forms import ReadOnlyPasswordHashField
from django.conf import settings
from django.contrib.auth import password_validation
from django.contrib.auth.forms import AdminPasswordChangeForm
from django.contrib.auth.models import Group, User
from django.urls import reverse_lazy
from django.utils.text import format_lazy
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from accounts.forms import UniqueEmailMixin
from accounts.models import UserAttributes
from instances.models import Instance
from .models import Permission
# what each permission lets a user do, shown beside its switch
PERMISSION_HELP = {
"change_password": _("Change their own password on their profile page"),
"clone_instances": _("Clone the VMs they may change and the templates they may view, within their quota"),
"passwordless_console": _("Open a console without the VM's console password"),
"snapshot_instances": _("Take, revert and delete snapshots of the VMs they may change"),
"view_instances": _("See every VM and host in the lists, read-only"),
}
def permission_rows(field):
"""Each permission's checkbox of a bound field with what it lets a user do."""
return [(box, PERMISSION_HELP.get(box.data["value"].instance.codename, "")) for box in field]
class GroupForm(forms.ModelForm):
permissions = forms.ModelMultipleChoiceField(
widget=forms.CheckboxSelectMultiple,
@ -19,7 +40,7 @@ class GroupForm(forms.ModelForm):
users = forms.ModelMultipleChoiceField(
widget=forms.CheckboxSelectMultiple,
queryset=User.objects.all(),
queryset=User.objects.order_by("username"),
required=False,
)
@ -29,6 +50,9 @@ class GroupForm(forms.ModelForm):
if instance and instance.id:
self.fields["users"].initial = self.instance.user_set.all()
def permission_rows(self):
return permission_rows(self["permissions"])
def save_m2m(self):
self.instance.user_set.set(self.cleaned_data["users"])
@ -42,7 +66,15 @@ class GroupForm(forms.ModelForm):
fields = "__all__"
class UserForm(forms.ModelForm):
class UserForm(UniqueEmailMixin, forms.ModelForm):
# one choice for is_staff and is_superuser: a superuser keeps the
# is_staff it had (it adds nothing to every permission)
role = forms.ChoiceField(
widget=forms.RadioSelect,
choices=[("user", _("User")), ("template_editor", _("Template editor")), ("superuser", _("Superuser"))],
initial="user",
)
user_permissions = forms.ModelMultipleChoiceField(
widget=forms.CheckboxSelectMultiple,
queryset=Permission.objects.filter(content_type__model="permissionset"),
@ -66,37 +98,73 @@ class UserForm(forms.ModelForm):
"last_name",
"email",
"user_permissions",
"is_staff",
"is_active",
"is_superuser",
]
def __init__(self, *args, **kwargs):
super(UserForm, self).__init__(*args, **kwargs)
if self.instance.id:
password = ReadOnlyPasswordHashField(
label=_("Password"),
help_text=format_lazy(
_(
"""Raw passwords are not stored, so there is no way to see this user's password,
but you can change the password using <a href='{}'>this form</a>."""
),
reverse_lazy(
"admin:user_update_password",
args=[
self.instance.id,
],
),
),
)
self.fields["Password"] = password
super().__init__(*args, **kwargs)
if self.instance.is_superuser:
self.initial["role"] = "superuser"
elif self.instance.is_staff:
self.initial["role"] = "template_editor"
def clean(self):
cleaned = super().clean()
role = cleaned.get("role")
if role:
cleaned["is_superuser"] = self.instance.is_superuser = role == "superuser"
if role != "superuser":
self.instance.is_staff = role == "template_editor"
return cleaned
def permission_rows(self):
return permission_rows(self["user_permissions"])
class UserCreateForm(UserForm):
password = forms.CharField(widget=forms.PasswordInput)
class Meta:
model = User
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# New users may change their own password and open their VMs' consoles
# (whose VNC password the console page sends) unless unticked here
self.fields["user_permissions"].initial = Permission.objects.filter(
Q(content_type__app_label="accounts", codename="change_password")
| Q(content_type__app_label="instances", codename="passwordless_console")
)
def clean(self):
# the VMs a new user owns, by name: a name may repeat across hosts
cleaned = super().clean()
self.default_instances = []
for name in settings.NEW_USER_DEFAULT_INSTANCES:
found = list(Instance.objects.filter(name=name)[:2])
if len(found) != 1:
raise ValidationError(_("NEW_USER_DEFAULT_INSTANCES: %(name)s is not exactly one VM") % {"name": name})
self.default_instances += found
return cleaned
def _post_clean(self):
# The validators in AUTH_PASSWORD_VALIDATORS, as on the password change
# forms; after the instance has the form's values (username, email).
super()._post_clean()
password = self.cleaned_data.get("password")
if password:
try:
password_validation.validate_password(password, self.instance)
except ValidationError as error:
self.add_error("password", error)
def save(self, commit=True):
# hashed before the user is first written: the raw password never
# reaches the database
user = super().save(commit=False)
user.set_password(self.cleaned_data["password"])
if commit:
user.save()
self.save_m2m()
return user
class Meta(UserForm.Meta):
fields = [
"username",
"password",
@ -105,13 +173,70 @@ class UserCreateForm(UserForm):
"last_name",
"email",
"user_permissions",
"is_staff",
"is_active",
"is_superuser",
]
# the quota's limits, with the unit each is entered in (memory in GiB, kept in MiB)
QUOTA_UNITS = {"max_instances": "", "max_cpus": "", "max_memory": "GiB", "max_disk_size": "GiB"}
class UserAttributesForm(forms.ModelForm):
"""The quota: each limit a number from 0 (none allowed) or No limit,
which is kept as -1; memory is entered in GiB and kept in MiB."""
max_instances = forms.IntegerField(label=_("VMs"), min_value=0, required=False)
max_cpus = forms.IntegerField(label=_("vCPUs"), min_value=0, required=False)
max_memory = forms.DecimalField(label=_("Memory"), min_value=0, required=False)
max_disk_size = forms.IntegerField(label=_("Disk"), min_value=0, required=False)
class Meta:
model = UserAttributes
exclude = ["user", "can_clone_instances"]
fields = list(QUOTA_UNITS)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for name in QUOTA_UNITS:
limit = getattr(self.instance, name) # a new user's: the model's default
self.fields[f"{name}_unlimited"] = forms.BooleanField(label=_("No limit"), required=False, initial=limit == -1)
if limit == -1:
self.initial[name] = None
elif name == "max_memory":
# exact: a MiB count over 1024 always ends, so saving it again changes nothing
self.initial[name] = format(Decimal(limit) / 1024, "f")
else:
self.initial[name] = limit
def clean(self):
cleaned = super().clean()
for name in QUOTA_UNITS:
if cleaned.get(f"{name}_unlimited"):
cleaned[name] = -1
elif cleaned.get(name) is None:
if name not in self.errors:
self.add_error(name, _("Enter a limit or choose No limit."))
elif name == "max_memory":
cleaned[name] = int((cleaned[name] * 1024).to_integral_value(ROUND_HALF_UP))
return cleaned
def quota_rows(self):
"""Each limit's field, its No limit box and its unit, for the form page."""
return [(self[name], self[f"{name}_unlimited"], unit) for name, unit in QUOTA_UNITS.items()]
class AdminPasswordForm(AdminPasswordChangeForm):
"""A password an administrator sets; the user may have to change it at their next login."""
must_change_password = forms.BooleanField(label=_("The user must change it at their next login"), required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["must_change_password"] = self.fields.pop("must_change_password") # last, after Django's own
# Django's "Password-based authentication: Enabled/Disabled", said as how the user signs in
self.fields["usable_password"].choices = [("true", _("With a password")), ("false", _("Without a password"))]
def clean(self):
cleaned = super().clean()
# the change form asks for the old password: a user without one could never pass it
if cleaned.get("must_change_password") and not cleaned.get("set_usable_password", True):
raise ValidationError(_("A user without a password cannot be made to change it"))
return cleaned

View file

@ -1,15 +1,17 @@
from django.db import models, migrations
from django.db import migrations
def apply_migration(apps, schema_editor):
Group = apps.get_model('auth', 'Group')
Group.objects.create(name='Technicians')
Group = apps.get_model("auth", "Group")
Group.objects.get_or_create(name="Technicians")
class Migration(migrations.Migration):
dependencies = [
('admin', '0002_auto_20200609_0830'),
]
("admin", "0002_auto_20200609_0830"),
]
operations = [
migrations.RunPython(apply_migration)
]
migrations.RunPython(apply_migration, migrations.RunPython.noop),
]

15
admin/permissions.py Normal file
View file

@ -0,0 +1,15 @@
from rest_framework.permissions import BasePermission
class IsSuperUser(BasePermission):
"""
Allows access only to active superusers.
"""
def has_permission(self, request, view):
return bool(
request.user
and request.user.is_authenticated
and request.user.is_active
and request.user.is_superuser
)

View file

@ -0,0 +1,80 @@
{% extends "base.html" %}
{% load django_bootstrap5 %}
{% load i18n %}
{% load static %}
{% block title %}{{ title }}{% endblock %}
{% block page_heading %}{{ title }}{% endblock page_heading %}
{% block content %}
<form id="create-update" method="post">
{% csrf_token %}
{% bootstrap_form_errors form type="non_fields" %}
<section class="form-section">
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1">{% trans "Group" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "Users who share a set of permissions." %}</p>
</div>
<div class="col-lg-9 form-section-fields">
<div class="row"><div class="col-sm-6">{% bootstrap_field form.name %}</div></div>
</div>
</div>
</section>
<section class="form-section">
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1">{% trans "Permissions" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "Each member has these permissions besides their own." %}</p>
</div>
<div class="col-lg-9 form-section-fields">
<div class="border-top">
{% for box, help in form.permission_rows %}
<div class="form-check form-switch border-bottom py-2 ps-5">
<input id="{{ box.id_for_label }}" class="form-check-input" type="checkbox" role="switch" name="permissions"
value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
<label class="form-check-label" for="{{ box.id_for_label }}">
<span class="d-block">{{ box.data.value.instance.name }}</span>
<span class="small text-body-secondary">{{ help }}</span>
</label>
</div>
{% endfor %}
{% for error in form.permissions.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
</div>
</div>
</section>
<section class="form-section">
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1">{% trans "Members" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "Who gets these permissions." %}</p>
</div>
<fieldset class="col-lg-9 form-section-fields">
<legend class="visually-hidden">{% trans "Members" %}</legend>
<input id="filter" class="form-control form-control-sm mb-2" style="max-width: 20rem" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search users" %}" data-forget>
{# the members first, then everyone else; each by username #}
<div class="d-flex flex-wrap gap-2 searchable">
{% for box in form.users %}{% if box.data.selected %}{% include "admin/group_member.html" %}{% endif %}{% endfor %}
{% for box in form.users %}{% if not box.data.selected %}{% include "admin/group_member.html" %}{% endif %}{% endfor %}
</div>
<p id="no-match" class="small text-body-secondary mt-2 mb-0" hidden>{% trans "No user matches the search." %}</p>
{% for error in form.users.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</fieldset>
</div>
</section>
<div class="form-section d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary" href="{% url 'admin:group_list' %}">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% if group %}{% trans "Save changes" %}{% else %}{% trans "Create group" %}{% endif %}</button>
</div>
</form>
{% endblock content %}
{% block script %}
<script src="{% static 'js/table_filter.js' %}"></script>
{% endblock script %}

View file

@ -2,58 +2,60 @@
{% load i18n %}
{% load static %}
{% load bootstrap_icons %}
{% block title %}{% trans "Users" %}{% endblock %}
{% block title %}{% trans "Groups" %}{% endblock %}
{% block page_heading %}{% trans "Groups" %}{% endblock page_heading %}
{% block page_heading_extra %}
<a href="{% url 'admin:group_create' %}" class="btn btn-primary">
<span aria-hidden="true">{% bs_icon 'plus-lg' %}</span> {% trans "New group" %}
</a>
{% endblock page_heading_extra %}
{% block content %}
<div class="row">
<div class="col-lg-12">
<a href="{% url 'admin:group_create' %}" class="btn btn-success btn-header float-end">
{% bs_icon 'plus-circle-fill' %}
</a>
{% include 'search_block.html' %}
<h1 class="page-header">{% trans "Groups" %}</h1>
{% if not groups %}
<div class="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle' %} {% trans "There are no groups yet." %}
</div>
</div>
<div class="row">
{% if not groups %}
<div class="col-lg-12">
<div class="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle '%} <strong>{% trans "Warning" %}:</strong> {% trans "You don't have any groups" %}
{% else %}
<div class="card mb-3">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<span class="me-auto">{% trans "Groups" %} <span class="badge text-bg-light border">{{ groups|length }}</span></span>
<input id="filter" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search groups" %}">
</div>
<div class="list-group list-group-flush searchable">
{% for group in groups %}
<div class="list-group-item py-3" data-search="{{ group.name }}">
<div class="row g-2 align-items-center">
<div class="col-12 col-md-4">
<a href="{% url 'admin:group_update' group.id %}" class="fw-semibold text-break">{{ group.name }}</a>
</div>
<div class="col-12 col-md-5 small text-body-secondary">
{% blocktrans count count=group.members %}{{ count }} member{% plural %}{{ count }} members{% endblocktrans %}
· {% blocktrans count count=group.perms %}{{ count }} permission{% plural %}{{ count }} permissions{% endblocktrans %}
</div>
<div class="col-12 col-md-3 text-md-end">
<div class="btn-group">
<a href="{% url 'admin:group_update' group.id %}" class="btn btn-sm btn-outline-secondary">{% trans "Edit" %}</a>
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false"
data-bs-popper-config='{"strategy": "fixed"}' aria-label="{% blocktrans with name=group.name %}More actions for {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
<ul class="dropdown-menu dropdown-menu-end shadow">
<li><a class="dropdown-item text-danger" href="{% url 'admin:group_delete' group.id %}">{% bs_icon 'trash' %} {% trans "Delete" %}</a></li>
</ul>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="col-lg-12">
<table class="table table-striped table-hover">
<thead>
<tr>
<th span="col">{% trans "Group Name" %}</th>
<th span="col">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody class="searchable">
{% for group in groups %}
<tr>
<td>
<a href=""><strong>{{ group.name }}</strong></a>
</td>
<td>
<div class="float-end btn-group">
<a class="btn btn-primary" href="{% url 'admin:group_update' group.id %}" title="{%trans "Edit" %}">
{% bs_icon 'pencil' %}
</a>
<a class="btn btn-danger" href="{% url 'admin:group_delete' group.id %}" title="{%trans "Delete" %}">
{% bs_icon 'x' %}
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div role="status">
<div id="no-match" class="text-center text-body-secondary py-3" hidden>{% trans "No group matches the search." %}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% endblock content %}
{% block script %}
<script src="{% static 'js/filter-table.js' %}"></script>
{% if groups %}<script src="{% static 'js/table_filter.js' %}"></script>{% endif %}
{% endblock script %}

View file

@ -0,0 +1,7 @@
{# one user of the group form's Members: box is a checkbox of GroupForm.users #}
{% with user=box.data.value.instance %}
<label class="border rounded px-2 py-1 text-break" data-search="{{ user.username }} {{ user.get_full_name }} {{ user.email }}">
<input id="{{ box.id_for_label }}" class="form-check-input me-1" type="checkbox" name="users" value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
{{ user.username }}{% if user.get_full_name %} <span class="small text-body-secondary">{{ user.get_full_name }}</span>{% endif %}
</label>
{% endwith %}

View file

@ -8,52 +8,59 @@
{% block page_heading %}{% trans "Logs" %}{% endblock page_heading %}
{% block page_heading_extra %}
{% include 'search_block.html' %}
<a class="btn btn-outline-primary" href="{% url 'admin:logs_export' %}{% if query %}?{{ query }}{% endif %}"
title="{% trans 'Download these rows as an Excel file' %}"><span aria-hidden="true">{% bs_icon 'download' %}</span> {% trans "Excel" %}</a>
{% endblock page_heading_extra %}
{% block content %}
<div class="row">
<div class="col-lg-12">
{% if not logs %}
<div class="col-lg-12">
<div class="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle'%} <strong>{% trans "Warning" %}:</strong> {% trans "You don't have any Logs" %}
</div>
</div>
{% else %}
<div class="table-responsive">
<table class="table table-hover table-striped">
<thead>
<div class="card mb-3">
{# the search runs on the server over every row; the download takes the same rows #}
<form method="get" class="card-header d-flex flex-wrap align-items-center gap-2" role="search">
<span class="me-auto">{% trans "Logs" %} <span class="badge text-bg-light border">{{ logs.paginator.count }}</span></span>
<input name="q" value="{{ q }}" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans 'Search' %}"
aria-label="{% trans 'Search the logs' %}">
<div class="form-check mb-0">
<input id="logs-failed" class="form-check-input" type="checkbox" name="failed" value="1"{% if failed %} checked{% endif %}>
<label class="form-check-label small" for="logs-failed">{% trans "Failed only" %}</label>
</div>
<button type="submit" class="btn btn-sm btn-primary">{% trans "Search" %}</button>
</form>
{% if not logs %}
<div class="card-body text-center text-body-secondary">
{% if q or failed %}{% trans "No log rows match." %}{% else %}{% trans "There are no log rows yet." %}{% endif %}
</div>
{% else %}
<div class="table-responsive position-relative">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">{% blocktrans %}Date ({{ time_zone }}){% endblocktrans %}</th>
<th scope="col">{% trans "User" %}</th>
<th scope="col">{% trans "Host" %}</th>
<th scope="col">{% trans "Instance" %}</th>
<th scope="col">{% trans "Message" %}</th>
<th scope="col">{% trans "Result" %}</th>
<th scope="col">{% trans "IP" %}</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<th scope="col">#</th>
<th scope="col">{% trans "Date" %}</th>
<th scope="col">{% trans "User" %}</th>
<th scope="col">{% trans "Host" %}</th>
<th scope="col">{% trans "Instance" %}</th>
<th scope="col">{% trans "Message" %}</th>
</tr>
</thead>
<tbody class="searchable">
{% for log in logs %}
<tr>
<td>{{ log.id }}</td>
<td style="width:130px;">{{ log.date|date:"M d H:i:s" }}</td>
<td>{{ log.user }}</td>
<td>{{ log.host }}</td>
<td>{{ log.instance }}</td>
<td>{{ log.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% bootstrap_pagination logs %}
{% endif %}
</div>
<td class="small text-body-secondary">{{ log.id }}</td>
<td class="small text-nowrap">{{ log.date|date:"Y-m-d H:i:s" }}</td>
<td>{{ log.user }}</td>
<td>{{ log.host }}</td>
<td>{% if log.vm_pk %}<a href="{% url 'instances:instance' log.vm_pk %}">{{ log.instance }}</a>{% else %}{{ log.instance }}{% endif %}</td>
<td class="log-message">{{ log.message }}</td>
<td>{% if log.success is None %}<span class="small text-body-secondary">{% trans "Unknown" %}</span>{% elif log.success %}<span class="badge text-bg-light border">{% trans "Done" %}</span>{% else %}<span class="badge text-bg-danger">{% trans "Failed" %}</span>{% endif %}</td>
<td class="small font-monospace">{{ log.ip|default:"" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% if logs %}{% bootstrap_pagination logs extra=query %}{% endif %}
{% endblock %}
{% block script %}
<script src="/static/js/filter-table.js"></script>
{% endblock script %}

View file

@ -0,0 +1,57 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% load bootstrap_icons %}
{% block title %}{% trans "Removed VMs" %}{% endblock %}
{% block page_heading %}{% trans "Removed VMs" %}{% endblock page_heading %}
{% block content %}
<p class="text-body-secondary">{% trans "VMs that vanished from their compute. Their owners come back if the same UUID shows up again before the record expires." %}</p>
{% if not tombstones %}
<div class="alert alert-info shadow-sm">
{% bs_icon 'info-circle' %} {% trans "No removed VMs" %}
</div>
{% else %}
<div class="card mb-3">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<span class="me-auto">{% trans "Removed VMs" %} <span class="badge text-bg-light border">{{ tombstones|length }}</span></span>
<input id="filter" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search removed VMs" %}">
</div>
<div class="list-group list-group-flush searchable">
{% for tombstone in tombstones %}
<div class="list-group-item py-3" data-search="{{ tombstone.name }} {{ tombstone.uuid }} {{ tombstone.host }} {{ tombstone.owner_names|join:' ' }}">
<div class="row g-2 align-items-center">
<div class="col-12 col-md-4">
<span class="fw-semibold text-break">{{ tombstone.name }}</span>
{% if tombstone.is_template %}<span class="badge text-bg-light border">{% trans "Template" %}</span>{% endif %}
<div class="font-monospace small text-body-secondary text-break mt-1">{{ tombstone.uuid }}</div>
</div>
<div class="col-12 col-md-5 small">
<div>
{% trans "Left" %} <span class="fw-semibold">{{ tombstone.host }}</span>
<span class="text-body-secondary">· {% trans "Owners" %}:</span> {{ tombstone.owner_names|join:", "|default:"–" }}
</div>
<div class="text-body-secondary mt-1">
{% trans "Removed" %} <span class="text-nowrap">{{ tombstone.removed|date:"Y-m-d H:i" }}</span>
· {% trans "expires" %} <span class="text-nowrap">{{ tombstone.expires|date:"Y-m-d H:i" }}</span>
</div>
</div>
<div class="col-12 col-md-3 text-md-end">
<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:tombstone_delete' tombstone.id %}">{% trans "Delete" %}</a>
</div>
</div>
</div>
{% endfor %}
</div>
<div role="status">
<div id="no-match" class="text-center text-body-secondary py-3" hidden>{% trans "No removed VM matches the search." %}</div>
</div>
</div>
{% endif %}
{% endblock content %}
{% block script %}
{% if tombstones %}<script src="{% static 'js/table_filter.js' %}"></script>{% endif %}
{% endblock script %}

View file

@ -1,27 +1,167 @@
{% extends "base.html" %}
{% load django_bootstrap5 %}
{% load bootstrap_icons %}
{% load i18n %}
{% load static %}
{% block title %}{{ title }}{% endblock %}
{% block page_heading %}{{ title }}{% endblock page_heading %}
{% block content %}
<div class="card col-sm-10 offset-1">
<div class="card-body">
<form id="create-update" action="" method="post">
{% csrf_token %}
{% bootstrap_form user_form layout='horizontal' %}
{% bootstrap_form attributes_form layout='horizontal' %}
</form>
<div class="float-end">
<a class="btn btn-primary" href="javascript:history.back()">
{% bs_icon 'x-square-fill' %} {% trans "Cancel" %}</a>
<button type="submit" form="create-update" class="btn btn-success">
{% bs_icon 'check2' %} {% trans "Save" %}
</button>
{# who the user is, what they manage, what they may use, then what more they may do #}
<form id="create-update" method="post">
{% csrf_token %}
{% bootstrap_form_errors user_form type="non_fields" %}
<section class="form-section">
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1">{% trans "Account" %}</h2>
{% if edited_user %}
<p class="small text-body-secondary mb-0">{% trans "Last login" %}: {{ edited_user.last_login|date:"Y-m-d H:i"|default:_("never") }}</p>
{% else %}
<p class="small text-body-secondary mb-0">{% trans "What the user signs in with." %}</p>
{% endif %}
</div>
<div class="col-lg-9 form-section-fields">
<div class="row g-3">
<div class="col-sm-6">{% bootstrap_field user_form.username %}</div>
<div class="col-sm-6">
{% if edited_user %}
<span class="form-label d-block">{% trans "Password" %}</span>
<div class="d-flex flex-wrap align-items-center gap-2">
<span>{% if edited_user.has_usable_password %}{% trans "Set" %}{% else %}{% trans "Not set" %}{% endif %}</span>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:user_update_password' edited_user.id %}">{% trans "Change password" %}</a>
</div>
{% else %}
{% bootstrap_field user_form.password %}
{% endif %}
</div>
<div class="col-sm-6">{% bootstrap_field user_form.first_name %}</div>
<div class="col-sm-6">{% bootstrap_field user_form.last_name %}</div>
<div class="col-12">{% bootstrap_field user_form.email %}</div>
</div>
<div class="form-check form-switch">
<input id="{{ user_form.is_active.id_for_label }}" class="form-check-input" type="checkbox" role="switch" name="is_active"
aria-describedby="active-help"{% if user_form.is_active.value %} checked{% endif %}>
<label class="form-check-label" for="{{ user_form.is_active.id_for_label }}">{% trans "May sign in" %}</label>
<div id="active-help" class="small text-body-secondary">{% trans "Turn off to block the user while keeping the account." %}</div>
</div>
</div>
</div>
</section>
<section class="form-section">
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1" id="role-heading">{% trans "Role" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "What the user manages." %}</p>
</div>
<div class="col-lg-9 form-section-fields">
{% trans "Uses the VMs given to them, within their quota." as user_help %}
{% trans "May change and delete the template VMs given to them." as editor_help %}
{% trans "Manages the panel: every permission, no quota." as superuser_help %}
<div class="row row-cols-1 row-cols-md-3 g-2" role="radiogroup" aria-labelledby="role-heading">
{% for choice in user_form.role %}
<div class="col">
<label class="form-choice border rounded p-3 d-flex gap-2 h-100">
<input class="form-check-input flex-shrink-0 mt-1" type="radio" name="role" value="{{ choice.data.value }}"{% if choice.data.selected %} checked{% endif %}>
<span><span class="d-block fw-semibold">{{ choice.choice_label }}</span>
<span class="small text-body-secondary">{% if choice.data.value == "superuser" %}{{ superuser_help }}{% elif choice.data.value == "template_editor" %}{{ editor_help }}{% else %}{{ user_help }}{% endif %}</span></span>
</label>
</div>
{% endfor %}
</div>
{% for error in user_form.role.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
</div>
</section>
{# user_form.js hides the sections a superuser does not need while Superuser is chosen #}
{% with superuser=user_form.role.value %}
<p id="superuser-note" class="form-section small text-body-secondary mb-0"{% if superuser != "superuser" %} hidden{% endif %}>
{% trans "A superuser has every permission and no quota: there is nothing more to set." %}
</p>
<section class="form-section" data-not-superuser{% if superuser == "superuser" %} hidden{% endif %}>
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1">{% trans "Quota" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "The most the user's VMs may have in total." %}</p>
</div>
<div class="col-lg-9 form-section-fields">
<div class="row row-cols-2 row-cols-md-4 g-3">
{% for field, unlimited, unit in attributes_form.quota_rows %}
<div class="col">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
<div class="input-group{% if field.errors %} has-validation{% endif %}">
<input id="{{ field.id_for_label }}" class="form-control{% if field.errors %} is-invalid{% endif %}" type="number" name="{{ field.html_name }}"
min="0" step="{% if field.name == 'max_memory' %}any{% else %}1{% endif %}" value="{{ field.value|default_if_none:'' }}"{% if unlimited.value %} disabled{% endif %}>
{% if unit %}<span class="input-group-text">{{ unit }}</span>{% endif %}
{% for error in field.errors %}<div class="invalid-feedback">{{ error }}</div>{% endfor %}
</div>
<div class="form-check mt-1">
<input id="{{ unlimited.id_for_label }}" class="form-check-input" type="checkbox" name="{{ unlimited.html_name }}"
data-no-limit="{{ field.id_for_label }}"{% if unlimited.value %} checked{% endif %}>
<label class="form-check-label small" for="{{ unlimited.id_for_label }}">{{ unlimited.label }}</label>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</section>
<section class="form-section" data-not-superuser{% if superuser == "superuser" %} hidden{% endif %}>
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1">{% trans "Groups and permissions" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "The permissions of the user's groups apply too." %}</p>
</div>
<div class="col-lg-9 form-section-fields">
<fieldset class="mb-3">
<legend class="visually-hidden">{% trans "Groups" %}</legend>
<div class="d-flex flex-wrap gap-2">
{% for box in user_form.groups %}
<label class="border rounded px-2 py-1 text-break">
<input id="{{ box.id_for_label }}" class="form-check-input me-1" type="checkbox" name="groups" value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
{{ box.choice_label }}
</label>
{% empty %}
<span class="small text-body-secondary">{% trans "There are no groups yet." %}</span>
{% endfor %}
</div>
{% for error in user_form.groups.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</fieldset>
<fieldset>
<legend class="visually-hidden">{% trans "Own permissions" %}</legend>
<p class="small text-body-secondary mb-2">{% trans "Off grants nothing here; it takes away nothing a group grants." %}</p>
<div class="border-top">
{% for box, help in user_form.permission_rows %}
<div class="form-check form-switch border-bottom py-2 ps-5">
<input id="{{ box.id_for_label }}" class="form-check-input" type="checkbox" role="switch" name="user_permissions"
value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
<label class="form-check-label" for="{{ box.id_for_label }}">
<span class="d-block">{{ box.data.value.instance.name }}</span>
<span class="small text-body-secondary">{{ help }}</span>
</label>
</div>
{% endfor %}
</div>
{% for error in user_form.user_permissions.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</fieldset>
</div>
</div>
</section>
{% endwith %}
<div class="form-section d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary" href="{% url 'admin:user_list' %}">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% if edited_user %}{% trans "Save changes" %}{% else %}{% trans "Create user" %}{% endif %}</button>
</div>
</div>
{% endblock content %}
</form>
{% endblock content %}
{% block script %}
<script src="{% static 'js/user_form.js' %}"></script>
{% endblock script %}

View file

@ -1,7 +1,6 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% load common_tags %}
{% load bootstrap_icons %}
{% block title %}{{ title }}{% endblock %}
@ -9,73 +8,86 @@
{% block page_heading %}{{ title }}{% endblock page_heading %}
{% block page_heading_extra %}
<a href="{% url 'admin:user_create' %}" class="btn btn-success btn-header float-end">
{% bs_icon 'plus-circle-fill' %}
<a href="{% url 'admin:user_create' %}" class="btn btn-primary">
<span aria-hidden="true">{% bs_icon 'plus-lg' %}</span> {% trans "New user" %}
</a>
{% include 'search_block.html' %}
{% endblock page_heading_extra %}
{% block content %}
<div class="row">
{% if not users %}
<div class="col-lg-12">
<div class="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle '%} <strong>{% trans "Warning" %}:</strong> {% trans "You don't have any user" %}
<div class="card mb-3">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<span class="me-auto">{% trans "Users" %} <span class="badge text-bg-light border">{{ users|length }}</span></span>
<input id="filter" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search users" %}">
</div>
<div class="list-group list-group-flush searchable">
{% for user in users %}
<div class="list-group-item py-3" data-search="{{ user.username }} {{ user.email }}{% for group in user.groups.all %} {{ group.name }}{% endfor %} {% if user.is_superuser %}{% trans "Superuser" %}{% elif user.is_staff %}{% trans "Template editor" %}{% else %}{% trans "User" %}{% endif %} {% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Blocked" %}{% endif %}">
<div class="row g-2 align-items-center">
<div class="col-12 col-md-4">
<a href="{% url 'accounts:account' user.id %}" class="fw-semibold text-break">{{ user.username }}</a>
<div class="d-flex flex-wrap align-items-center gap-2 mt-1 small text-body-secondary">
{% if user.is_superuser %}
<span class="badge text-bg-secondary">{% trans "Superuser" %}</span>
{% elif user.is_staff %}
<span class="badge text-bg-light border">{% trans "Template editor" %}</span>
{% else %}
<span>{% trans "User" %}</span>
{% endif %}
{% if user.is_active %}
<span>{% trans "Active" %}</span>
{% else %}
<span class="badge text-bg-danger"><span aria-hidden="true">{% bs_icon 'slash-circle' %}</span> {% trans "Blocked" %}</span>
{% endif %}
</div>
</div>
<div class="col-12 col-md-5 small">
<div class="d-flex flex-wrap align-items-center gap-2">
{% for group in user.groups.all %}<span class="badge text-bg-light border mw-100 text-wrap text-break">{{ group.name }}</span>{% endfor %}
{# a superuser has every permission: only a granted clone permission is news #}
{% if user.is_active and user.can_clone and not user.is_superuser %}<span>{% trans "Can clone" %}</span>{% endif %}
{% if user.api_tokens %}
<a href="{% url 'accounts:account' user.id %}#apitokens">{% blocktrans count count=user.api_tokens %}{{ count }} API token{% plural %}{{ count }} API tokens{% endblocktrans %}</a>
{% endif %}
{% if not user.groups.all and not user.api_tokens %}{% if user.is_superuser or not user.is_active or not user.can_clone %}
<span class="text-body-secondary">{% trans "No groups or API tokens" %}</span>
{% endif %}{% endif %}
</div>
<div class="text-body-secondary mt-1">
{% trans "Last login" %}: <span class="text-nowrap">{{ user.last_login|date:"Y-m-d H:i"|default:_("never") }}</span>
</div>
</div>
<div class="col-12 col-md-3 text-md-end">
<div class="btn-group">
<a href="{% url 'admin:user_update' user.id %}" class="btn btn-sm btn-outline-secondary">{% trans "Edit" %}</a>
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false"
data-bs-popper-config='{"strategy": "fixed"}' aria-label="{% blocktrans with name=user.username %}More actions for {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
<ul class="dropdown-menu dropdown-menu-end shadow">
<li><a class="dropdown-item" href="{% url 'accounts:account' user.id %}">{% bs_icon 'person' %} {% trans "View profile" %}</a></li>
{# an administrator does not block or delete their own account #}
<li><form method="post" action="{% if user.is_active %}{% url 'admin:user_block' user.id %}{% else %}{% url 'admin:user_unblock' user.id %}{% endif %}">{% csrf_token %}
<button type="submit" class="dropdown-item"{% if user == request.user %} disabled{% endif %}>
{% if user.is_active %}{% bs_icon 'slash-circle' %} {% trans "Block" %}{% else %}{% bs_icon 'play' %} {% trans "Unblock" %}{% endif %}
</button>
</form></li>
<li><hr class="dropdown-divider"></li>
<li>{% if user == request.user %}
<button type="button" class="dropdown-item text-danger" disabled>{% bs_icon 'trash' %} {% trans "Delete" %}</button>
{% else %}
<a class="dropdown-item text-danger" href="{% url 'admin:user_delete' user.id %}">{% bs_icon 'trash' %} {% trans "Delete" %}</a>
{% endif %}</li>
</ul>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-lg-12">
<table class="table table-striped table-hover">
<thead>
<tr>
<th span="col">{% trans "Username" %}</th>
<th span="col">{% trans "Status" %}</th>
<th span="col">{% trans "Last Login" %}</th>
<th span="col">{% trans "Staff" %}</th>
<th span="col">{% trans "Superuser" %}</th>
<th span="col">{% trans "Can Clone" %}</th>
<th span="col">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody class="searchable">
{% for user in users %}
{% has_perm user 'instances.clone_instances' as can_clone %}
<tr class="{% if not user.is_active %}danger{% endif %}">
<td>
{{ user.username }}
</td>
<td>
{% if user.is_active %}
{% trans "Active" %}
{% else %}
{% trans "Blocked" %}
{% endif %}
</td>
<td>{{ user.last_login }}</td>
<td>{% if user.is_staff %}{% bs_icon 'check' %}{% endif %}</td>
<td>{% if user.is_superuser %}{% bs_icon 'check' %}</span>{% endif %}</td>
<td>{% if can_clone %}{% bs_icon 'check' %}{% endif %}</td>
<td>
<div class="float-end btn-group">
<a class="btn btn-success" title="{%trans "View Profile" %}" href="{% url 'accounts:account' user.id %}">{% bs_icon 'eye-fill' %}</a>
<a class="btn btn-primary" title="{%trans "Edit" %}" href="{% url 'admin:user_update' user.id %}">{% bs_icon 'pencil-fill' %}</a>
{% if user.is_active %}
<a class="btn btn-warning" title="{%trans "Block" %}" href="{% url 'admin:user_block' user.id %}">{% bs_icon 'stop-fill' %}</a>
{% else %}
<a class="btn btn-success" title="{%trans "Unblock" %}" href="{% url 'admin:user_unblock' user.id %}">{% bs_icon 'play-fill' %}</a>
{% endif %}
<a class="btn btn-danger" title="{%trans "Delete" %}" href="{% url 'admin:user_delete' user.id %}">{% bs_icon 'x-circle-fill' %}</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endfor %}
</div>
<div role="status">
<div id="no-match" class="text-center text-body-secondary py-3" hidden>{% trans "No user matches the search." %}</div>
</div>
</div>
{% endblock content %}
{% block script %}
<script src="{% static "js/filter-table.js" %}"></script>
<script src="{% static 'js/table_filter.js' %}"></script>
{% endblock script %}

View file

@ -0,0 +1,52 @@
{% extends "base.html" %}
{% load django_bootstrap5 %}
{% load i18n %}
{% block title %}{% trans "Change Password" %}{% endblock %}
{% block page_heading %}{% trans "Change Password" %}{% endblock page_heading %}
{% block content %}
{# an administrator sets another user's password, or takes it away (sign-in by LDAP, for one) #}
<form id="password-change" method="post">
{% csrf_token %}
{% bootstrap_form_errors form type="non_fields" %}
<section class="form-section">
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1 text-break">{{ edited_user.username }}</h2>
<p class="small text-body-secondary mb-0">{% trans "How the user signs in." %} {% trans "Changing it deletes the user's API tokens." %}</p>
</div>
<div class="col-lg-9 form-section-fields">
{% trans "Signs in with the password set here." as with_help %}
{% trans "Signs in another way, such as LDAP. The current password is removed." as without_help %}
<div class="row row-cols-1 row-cols-md-2 g-2 mb-3" role="radiogroup" aria-label="{% trans "How the user signs in." %}">
{% for choice in form.usable_password %}
<div class="col">
<label class="form-choice border rounded p-3 d-flex gap-2 h-100">
<input class="form-check-input flex-shrink-0 mt-1" type="radio" name="usable_password" value="{{ choice.data.value }}"{% if choice.data.selected %} checked{% endif %}>
<span><span class="d-block fw-semibold">{{ choice.choice_label }}</span>
<span class="small text-body-secondary">{% if choice.data.value == "true" %}{{ with_help }}{% else %}{{ without_help }}{% endif %}</span></span>
</label>
</div>
{% endfor %}
</div>
{# hidden while Without a password is chosen (webvirtcloud.css) #}
<div class="with-password">
<div class="row g-3">
<div class="col-sm-6">{% bootstrap_field form.password1 %}</div>
<div class="col-sm-6">{% bootstrap_field form.password2 show_help=False %}</div>
</div>
{% bootstrap_field form.must_change_password %}
</div>
</div>
</div>
</section>
<div class="form-section d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary" href="{% url 'admin:user_update' edited_user.id %}">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% trans "Change password" %}</button>
</div>
</form>
{% endblock content %}

View file

@ -1,124 +0,0 @@
from django.contrib.auth.models import Group, User
from django.core.exceptions import ObjectDoesNotExist
from django.shortcuts import reverse
from django.test import TestCase
from accounts.models import UserAttributes
class AdminTestCase(TestCase):
def setUp(self):
self.client.login(username="admin", password="admin")
def test_group_list(self):
response = self.client.get(reverse("admin:group_list"))
self.assertEqual(response.status_code, 200)
def test_groups(self):
response = self.client.get(reverse("admin:group_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(
reverse("admin:group_create"), {"name": "Test Group"}
)
self.assertRedirects(response, reverse("admin:group_list"))
group = Group.objects.get(name="Test Group")
group_id = group.id
response = self.client.get(reverse("admin:group_update", args=[group_id]))
self.assertEqual(response.status_code, 200)
response = self.client.post(
reverse("admin:group_update", args=[group_id]), {"name": "Updated Group Test"}
)
self.assertRedirects(response, reverse("admin:group_list"))
group = Group.objects.get(id=group_id)
self.assertEqual(group.name, "Updated Group Test")
response = self.client.get(reverse("admin:group_delete", args=[group_id]))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("admin:group_delete", args=[group_id]))
self.assertRedirects(response, reverse("admin:group_list"))
with self.assertRaises(ObjectDoesNotExist):
Group.objects.get(id=group_id)
def test_user_list(self):
response = self.client.get(reverse("admin:user_list"))
self.assertEqual(response.status_code, 200)
def test_users(self):
response = self.client.get(reverse("admin:user_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(
reverse("admin:user_create"),
{
"username": "test",
"password": "test",
"max_instances": 1,
"max_cpus": 1,
"max_memory": 1024,
"max_disk_size": 4,
},
)
self.assertRedirects(response, reverse("admin:user_list"))
user = User.objects.get(username="test")
user_id = user.id
ua: UserAttributes = UserAttributes.objects.get(user_id=user_id)
self.assertEqual(ua.user_id, user_id)
self.assertEqual(ua.max_instances, 1)
self.assertEqual(ua.max_cpus, 1)
self.assertEqual(ua.max_memory, 1024)
self.assertEqual(ua.max_disk_size, 4)
response = self.client.get(reverse("admin:user_update", args=[user_id]))
self.assertEqual(response.status_code, 200)
response = self.client.post(
reverse("admin:user_update", args=[user_id]),
{
"username": "utest",
"max_instances": 2,
"max_cpus": 2,
"max_memory": 2048,
"max_disk_size": 8,
},
)
self.assertRedirects(response, reverse("admin:user_list"))
user = User.objects.get(id=user_id)
self.assertEqual(user.username, "utest")
ua = UserAttributes.objects.get(user_id=user_id)
self.assertEqual(ua.user_id, user_id)
self.assertEqual(ua.max_instances, 2)
self.assertEqual(ua.max_cpus, 2)
self.assertEqual(ua.max_memory, 2048)
self.assertEqual(ua.max_disk_size, 8)
response = self.client.get(reverse("admin:user_block", args=[user_id]))
user = User.objects.get(id=user_id)
self.assertFalse(user.is_active)
response = self.client.get(reverse("admin:user_unblock", args=[user_id]))
user = User.objects.get(id=user_id)
self.assertTrue(user.is_active)
response = self.client.get(reverse("admin:user_delete", args=[user_id]))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("admin:user_delete", args=[user_id]))
self.assertRedirects(response, reverse("admin:user_list"))
with self.assertRaises(ObjectDoesNotExist):
User.objects.get(id=user_id)
def test_logs(self):
response = self.client.get(reverse("admin:logs"))
self.assertEqual(response.status_code, 200)

0
admin/tests/__init__.py Normal file
View file

View file

@ -0,0 +1,115 @@
"""Changes to users and groups write rows on the Logs page."""
from django.contrib.auth.models import Group, Permission, User
from django.test import TestCase
from django.urls import reverse
from logs.models import Logs
ATTRIBUTES = {"role": "user", "max_instances": 2, "max_cpus": 2, "max_memory": 2, "max_disk_size": 20}
def rows():
return list(Logs.objects.order_by("id").values_list("message", "success"))
class UserRowTests(TestCase):
def setUp(self):
self.admin = User.objects.create_superuser("audit-admin", "audit@example.com", "pw")
self.client.force_login(self.admin)
Logs.objects.all().delete() # the login's row
def create(self, username="carol", **extra):
return self.client.post(reverse("admin:user_create"),
{"username": username, "password": "Carol-pass-2026", "is_active": "on", **ATTRIBUTES, **extra})
def edit(self, user, **changes):
data = {"username": user.username, "email": user.email, "is_active": "on", **ATTRIBUTES,
"user_permissions": [p.pk for p in user.user_permissions.all()],
"groups": [g.pk for g in user.groups.all()], **changes}
return self.client.post(reverse("admin:user_update", args=[user.pk]), data)
def test_user_add_change_delete(self):
self.create()
carol = User.objects.get(username="carol")
self.edit(carol, email="carol@example.com")
self.edit(User.objects.get(pk=carol.pk)) # nothing changed
self.client.post(reverse("admin:user_delete", args=[carol.pk]))
self.assertEqual(rows(), [
("User carol: add", True),
("User carol: change email", True),
("User carol: delete", True),
])
def test_attributes_and_groups_are_named(self):
self.create()
carol = User.objects.get(username="carol")
permission = Permission.objects.get(content_type__app_label="accounts", codename="change_password")
carol.user_permissions.remove(permission)
Logs.objects.all().delete()
self.edit(carol, max_instances=5)
group = Group.objects.create(name="ops")
self.edit(carol, max_instances=5, groups=[group.pk], user_permissions=[permission.pk])
self.edit(carol, max_instances=5, groups=[group.pk], user_permissions=[permission.pk], username="carla")
self.edit(User.objects.get(pk=carol.pk), username="") # invalid: no row
self.assertEqual([m for m, _ in rows()], [
"User carol: change max_instances",
"User carol: change groups, user_permissions",
"User carol: change username",
])
self.assertTrue(User.objects.get(pk=carol.pk).user_permissions.filter(pk=permission.pk).exists())
def test_block_unblock_and_password(self):
self.create()
carol = User.objects.get(username="carol")
Logs.objects.all().delete()
self.client.post(reverse("admin:user_block", args=[carol.pk]))
self.client.post(reverse("admin:user_block", args=[carol.pk])) # already blocked: nothing changes
self.client.post(reverse("admin:user_unblock", args=[carol.pk]))
self.client.post(reverse("admin:user_unblock", args=[carol.pk]))
self.client.post(reverse("admin:user_update_password", args=[carol.pk]),
{"password1": "New-carol-pass-1", "password2": "New-carol-pass-1"})
self.assertEqual(rows(), [
("User carol: block", True),
("User carol: unblock", True),
("User carol: set password", True),
])
self.assertFalse(Logs.objects.filter(message__contains="New-carol-pass-1").exists())
def test_the_row_has_actor_and_ip(self):
self.client.post(
reverse("admin:user_create"), {"username": "carol", "password": "Carol-pass-2026", **ATTRIBUTES},
REMOTE_ADDR="192.0.2.9")
row = Logs.objects.get()
self.assertEqual((row.user, row.host, row.instance, row.ip), ("audit-admin", "-", "", "192.0.2.9"))
class GroupAndRemovedVMRowTests(TestCase):
def setUp(self):
self.client.force_login(User.objects.create_superuser("audit-admin", "audit@example.com", "pw"))
Logs.objects.all().delete() # the login's row
def test_group_add_change_delete(self):
member = User.objects.create_user("dave")
permission = Permission.objects.get(content_type__app_label="accounts", codename="change_password")
self.client.post(reverse("admin:group_create"), {"name": "ops"})
group = Group.objects.get(name="ops")
url = reverse("admin:group_update", args=[group.pk])
self.client.post(url, {"name": "ops", "users": [member.pk]})
self.client.post(url, {"name": "ops", "users": [member.pk], "permissions": [permission.pk]})
self.client.post(url, {"name": "ops", "users": [member.pk], "permissions": [permission.pk]}) # no change
self.client.post(reverse("admin:group_delete", args=[group.pk]))
self.assertEqual(rows(), [
("Group ops: add", True),
("Group ops: change users", True),
("Group ops: change permissions", True),
("Group ops: delete", True),
])
def test_a_removed_vm_record_delete(self):
from instances.models import InstanceTombstone
uuid = "55555555-5555-5555-5555-555555555555"
tombstone = InstanceTombstone.objects.create(uuid=uuid, name="vm", owners=[])
self.client.post(reverse("admin:tombstone_delete", args=[tombstone.pk]))
self.assertEqual(rows(), [(f"Removed VM vm ({uuid}): delete", True)])

View file

@ -0,0 +1,113 @@
"""The Logs page searches every row on the server, can show the failed ones
only, says the year and time zone, links a VM's rows to the VM, and downloads
what it shows as an Excel workbook."""
from io import BytesIO
from unittest.mock import patch
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse
from instances.models import Instance
from logs.models import Logs
from lxml import html
from openpyxl import load_workbook
UUID = "11111111-2222-3333-4444-555555555555"
@override_settings(TIME_ZONE="UTC")
class LogsPageTests(TestCase):
def setUp(self):
self.admin = get_user_model().objects.create_superuser("lg-admin", "lg@example.com", "pw")
self.client.force_login(self.admin)
Logs.objects.all().delete()
# the row to find is the oldest: past the first page
Logs.objects.create(user="alice", host="host1", instance="web", instance_uuid=UUID,
message="VM web: delete", success=False, ip="192.0.2.7")
Logs.objects.bulk_create(Logs(user="bob", instance="db", message=f"VM db: power on {n}", success=True)
for n in range(150))
compute = Compute.objects.create(name="host1", hostname="192.0.2.1", login="u", password="p", type=1)
self.vm = Instance.objects.create(compute=compute, name="web", uuid=UUID)
def page(self, **query):
return html.fromstring(self.client.get(reverse("admin:logs"), query).content)
def rows(self, page):
return [[" ".join(td.text_content().split()) for td in tr.xpath("td")] for tr in page.xpath("//tbody/tr")]
def test_a_search_finds_a_row_on_any_page(self):
for query in ("alice", "WEB: DELETE", "192.0.2.7", "host1"):
with self.subTest(query=query):
rows = self.rows(self.page(q=query))
self.assertEqual([row[2] for row in rows], ["alice"])
def test_a_search_is_text_not_a_pattern(self):
Logs.objects.create(user="carol", instance="", message="disk (vda) [full]", success=True)
self.assertEqual([row[2] for row in self.rows(self.page(q="(vda) ["))], ["carol"])
def test_the_failed_rows_only_and_marked(self):
page = self.page(failed="1")
self.assertEqual([row[2] for row in self.rows(page)], ["alice"])
self.assertTrue(page.xpath("//tbody/tr[1]//span[contains(@class, 'text-bg-danger')]"))
def test_the_pages_keep_the_search(self):
page = self.page(q="bob")
self.assertTrue(page.xpath("//a[contains(@href, 'page=2') and contains(@href, 'q=bob')]"))
def test_dates_have_the_year_and_the_time_zone(self):
page = self.page(q="alice")
self.assertIn("Date (UTC)", [" ".join(th.text_content().split()) for th in page.xpath("//thead//th")])
log = Logs.objects.get(user="alice")
self.assertEqual(self.rows(page)[0][1], log.date.strftime("%Y-%m-%d %H:%M:%S"))
def test_a_vm_row_links_the_vm_while_it_is_there(self):
page = self.page(q="alice")
self.assertEqual(page.xpath("//tbody/tr/td/a/@href"), [reverse("instances:instance", args=[self.vm.pk])])
self.vm.delete()
self.assertEqual(self.page(q="alice").xpath("//tbody/tr/td/a"), [])
def test_the_rows_shown_download_as_a_workbook(self):
Logs.objects.create(user="mallory", instance="", message="=HYPERLINK(1)", success=False)
response = self.client.get(reverse("admin:logs_export"), {"failed": "1"})
self.assertEqual(response["Content-Type"], "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
self.assertIn("attachment", response["Content-Disposition"])
sheet = load_workbook(BytesIO(response.content)).active
values = list(sheet.values)
self.assertEqual(values[0], ("Date (UTC)", "User", "Host", "VM", "VM UUID", "Message", "Result", "IP"))
self.assertEqual([row[1] for row in values[1:]], ["mallory", "alice"])
# a message starting with = stays text
self.assertEqual(sheet.cell(row=2, column=6).data_type, "s")
# a bold header that stays in view, with filters over every row
self.assertEqual((sheet.freeze_panes, sheet.auto_filter.ref, sheet["A1"].font.b), ("A2", "A1:H3", True))
def test_the_workbook_is_written_row_by_row(self):
# openpyxl's write-only mode: a large download does not keep every cell in memory
from openpyxl import Workbook
with patch("instances.report.Workbook", wraps=Workbook) as book:
self.client.get(reverse("admin:logs_export"))
book.assert_called_once_with(write_only=True)
page = self.page(q="alice", failed="1")
self.assertEqual(page.xpath("//a[contains(@href, 'export')]/@href"),
[reverse("admin:logs_export") + "?q=alice&failed=1"])
def test_only_an_administrator_downloads(self):
self.client.force_login(get_user_model().objects.create_user("lg-user", password="pw"))
self.assertEqual(self.client.get(reverse("admin:logs_export")).status_code, 403)
def test_a_control_character_in_a_row_is_shown_not_a_failure(self):
# a failed login writes the name it was tried with, as typed
Logs.objects.create(user="eve\x01", instance="", message="Login", success=False)
response = self.client.get(reverse("admin:logs_export"), {"q": "eve"})
self.assertEqual(response.status_code, 200)
self.assertEqual(load_workbook(BytesIO(response.content)).active.cell(row=2, column=2).value, "eve\\x01")
def test_the_download_stops_at_its_row_limit_and_says_so(self):
with patch("admin.views.EXPORT_ROWS", 3):
book = load_workbook(BytesIO(self.client.get(reverse("admin:logs_export")).content))
self.assertEqual(book.active.max_row, 4) # the header and three rows
notes = [row[0] for row in book["About"].values]
self.assertIn("Only the newest 3 rows: narrow the search for the others", notes)

View file

@ -0,0 +1,63 @@
"""Superusers list the ownership kept for VMs that vanished and can remove it."""
from datetime import timedelta
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from instances.models import InstanceTombstone
from logs.models import Logs
UUID = "00000000-0000-4000-8000-000000000001"
class TombstoneTests(TestCase):
def setUp(self):
self.client.force_login(User.objects.create_superuser("ts-admin", "ts@example.com", "pw"))
self.owner = User.objects.create_user("ts-owner", password="pw")
self.tombstone = InstanceTombstone.objects.create(
uuid=UUID,
name="ts-vm",
host="ts-host",
owners=[
{"user": self.owner.id, "is_change": True, "is_delete": False, "is_vnc": True},
{"user": 999999, "is_change": False, "is_delete": False, "is_vnc": False},
],
)
@override_settings(INSTANCE_OWNERSHIP_RETENTION_DAYS=7)
def test_the_list_shows_the_vm_its_owners_and_when_it_expires(self):
removed = timezone.now() - timedelta(days=2)
InstanceTombstone.objects.filter(pk=self.tombstone.pk).update(removed=removed)
response = self.client.get(reverse("admin:tombstone_list"))
self.assertEqual(response.status_code, 200)
row = response.context["tombstones"][0]
self.assertEqual(row.owner_names, ["ts-owner", "deleted user"])
self.assertEqual(row.expires, removed + timedelta(days=7))
self.assertContains(response, "ts-vm")
self.assertContains(response, UUID)
self.assertContains(response, "ts-owner")
self.assertContains(response, '<span class="fw-semibold">ts-host</span>', html=True)
def test_only_superusers_see_or_remove_them(self):
self.client.force_login(self.owner)
self.assertEqual(self.client.get(reverse("admin:tombstone_list")).status_code, 403)
url = reverse("admin:tombstone_delete", args=[self.tombstone.pk])
self.assertEqual(self.client.post(url).status_code, 403)
self.assertTrue(InstanceTombstone.objects.exists())
def test_a_get_only_asks_for_confirmation(self):
response = self.client.get(reverse("admin:tombstone_delete", args=[self.tombstone.pk]))
self.assertContains(response, "ts-vm")
self.assertTrue(InstanceTombstone.objects.exists())
def test_a_post_removes_it(self):
response = self.client.post(reverse("admin:tombstone_delete", args=[self.tombstone.pk]), follow=True)
self.assertRedirects(response, reverse("admin:tombstone_list"))
self.assertContains(response, "Removed VM ts-vm deleted")
self.assertFalse(InstanceTombstone.objects.exists())
# the VM's history (found by UUID) has it
self.assertEqual(list(Logs.objects.filter(message__startswith="Removed VM").values_list("host", "instance", "instance_uuid")),
[("ts-host", "ts-vm", UUID)])

View file

@ -0,0 +1,104 @@
"""Creating a user in the admin pages: the password reaches the database only
hashed, and the configured password validators apply as on the change forms."""
import re
from django.contrib.auth.models import Group, Permission, User
from django.db import connection
from django.test import TestCase, override_settings
from django.urls import reverse
from accounts.models import UserInstance
from computes.models import Compute
from instances.models import Instance
RAW = "Raw-Passw0rd-in-the-form"
LIMITS = {"role": "user", "max_instances": 1, "max_cpus": 1, "max_memory": 1, "max_disk_size": 4}
class UserCreateTests(TestCase):
def setUp(self):
self.client.force_login(User.objects.create_superuser("uc-admin", "uc@example.com", "pw"))
self.change_password = Permission.objects.get(content_type__app_label="accounts", codename="change_password")
def create(self, **data):
return self.client.post(reverse("admin:user_create"), {"username": "newbie", "password": RAW, **LIMITS, **data})
def test_the_raw_password_is_never_written(self):
written = []
def record(execute, sql, params, many, context):
if RAW in repr(params):
written.append(sql)
return execute(sql, params, many, context)
with connection.execute_wrapper(record):
response = self.create()
self.assertRedirects(response, reverse("admin:user_list"))
self.assertEqual(written, [])
self.assertTrue(User.objects.get(username="newbie").check_password(RAW))
def test_a_new_user_may_change_the_password_and_open_consoles_unless_unticked(self):
# new VMs have a VNC password, which the console page sends only with passwordless_console
page = self.client.get(reverse("admin:user_create")).content.decode()
ticked = set(re.findall(r'<input[^>]*name="user_permissions"[^>]*value="(\d+)"[^>]*checked', page))
self.assertEqual(ticked, {str(self.change_password.pk), str(Permission.objects.get(codename="passwordless_console").pk)})
def test_groups_and_permissions_are_saved(self):
group = Group.objects.create(name="uc-group")
self.create(groups=[group.pk], user_permissions=[self.change_password.pk])
user = User.objects.get(username="newbie")
self.assertEqual(list(user.groups.all()), [group])
self.assertEqual(list(user.user_permissions.all()), [self.change_password])
def test_an_unticked_permission_stays_off(self):
self.create()
self.assertFalse(User.objects.get(username="newbie").user_permissions.exists())
@override_settings(
AUTH_PASSWORD_VALIDATORS=[
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 30}}
]
)
def test_configured_password_validators_apply(self):
response = self.create()
self.assertEqual(response.status_code, 200)
self.assertContains(response, "at least 30 characters")
self.assertFalse(User.objects.filter(username="newbie").exists())
@override_settings(
AUTH_PASSWORD_VALIDATORS=[{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}]
)
def test_the_password_is_checked_against_the_new_username(self):
response = self.client.post(
reverse("admin:user_create"), {"username": "rumpelstiltskin", "password": "rumpelstiltskin1", **LIMITS}
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "too similar to the username")
class DefaultInstancesTests(TestCase):
"""NEW_USER_DEFAULT_INSTANCES names VMs, and a name may repeat across hosts."""
def setUp(self):
self.client.force_login(User.objects.create_superuser("uc-admin", "uc@example.com", "pw"))
one, two = (Compute.objects.create(name=name, hostname="localhost", login="", password="", details="", type=4)
for name in ("uc-one", "uc-two"))
self.web = Instance.objects.create(compute=one, name="web", uuid="00000000-0000-0000-0000-000000000001")
for compute, uuid in ((one, "00000000-0000-0000-0000-000000000002"), (two, "00000000-0000-0000-0000-000000000003")):
Instance.objects.create(compute=compute, name="db", uuid=uuid)
def create(self):
return self.client.post(reverse("admin:user_create"), {"username": "newbie", "password": RAW, **LIMITS})
@override_settings(NEW_USER_DEFAULT_INSTANCES=["web"])
def test_the_new_user_owns_the_named_vm(self):
self.assertRedirects(self.create(), reverse("admin:user_list"))
self.assertEqual([o.instance for o in UserInstance.objects.filter(user__username="newbie")], [self.web])
def test_a_name_that_is_not_one_vm_is_refused_on_the_form(self):
for name in ("no-such-vm", "db"):
with self.subTest(name), override_settings(NEW_USER_DEFAULT_INSTANCES=["web", name]):
response = self.create()
self.assertContains(response, f"NEW_USER_DEFAULT_INSTANCES: {name} is not exactly one VM")
self.assertFalse(User.objects.filter(username="newbie").exists())

View file

@ -0,0 +1,166 @@
"""The Users pages: an administrator cannot lock their own account out, the
user form says what its fields mean, and a password set by an administrator
can be one the user must change."""
from accounts.models import ApiToken, UserAttributes
from django.contrib.auth import get_user_model
from django.contrib.messages import get_messages
from django.test import TestCase
from django.urls import reverse
from lxml import html
class UserPagesTests(TestCase):
def setUp(self):
User = get_user_model()
self.admin = User.objects.create_superuser("up-admin", "up@example.com", "pw")
self.alice = User.objects.create_user("up-alice", password="Alice-pw-123")
UserAttributes.objects.get_or_create(user=self.alice)
self.client.force_login(self.admin)
def messages(self, response):
return [str(m) for m in get_messages(response.wsgi_request)]
def form_data(self, user, **changes):
data = {"username": user.username, "is_active": "on", "role": "superuser" if user.is_superuser else "user",
"max_instances": "2", "max_cpus": "2", "max_memory": "2", "max_disk_size": "20", **changes}
return {key: value for key, value in data.items() if value != ""}
def test_an_administrator_cannot_block_delete_or_demote_themselves(self):
own = self.admin.pk
for response in (self.client.post(reverse("admin:user_block", args=[own])),
self.client.post(reverse("admin:user_delete", args=[own]))):
self.assertIn("You cannot block, delete or demote your own account", self.messages(response))
for change in ({"role": "user"}, {"is_active": ""}):
response = self.client.post(reverse("admin:user_update", args=[own]), self.form_data(self.admin, **change))
self.assertContains(response, "You cannot block, delete or demote your own account")
self.admin.refresh_from_db()
self.assertTrue(self.admin.is_active and self.admin.is_superuser)
# another user's account is theirs to change
self.client.post(reverse("admin:user_block", args=[self.alice.pk]))
self.alice.refresh_from_db()
self.assertFalse(self.alice.is_active)
def test_the_form_tells_the_password_state_and_links_its_page(self):
page = html.fromstring(self.client.get(reverse("admin:user_update", args=[self.alice.pk])).content)
text = " ".join(page.text_content().split())
self.assertIn("Password Set Change password", text)
self.assertNotIn("No password set", text)
self.assertEqual(page.xpath("//a[normalize-space()='Change password']/@href"),
[reverse("admin:user_update_password", args=[self.alice.pk])])
self.assertEqual(page.xpath("//a[@href='../password/']"), [])
def test_roles_say_what_they_mean(self):
page = self.client.get(reverse("admin:user_update", args=[self.alice.pk])).content.decode()
self.assertIn("Template editor", page)
self.assertIn("May change and delete the template VMs given to them", page)
self.assertIn("Manages the panel: every permission, no quota.", page)
self.assertNotIn("log into this admin site", page)
def test_the_password_page_says_how_the_user_signs_in(self):
url = reverse("admin:user_update_password", args=[self.alice.pk])
page = self.client.get(url).content.decode()
self.assertIn("With a password", page)
self.assertIn("Signs in another way, such as LDAP. The current password is removed.", page)
self.assertIn(f'href="{reverse("admin:user_update", args=[self.alice.pk])}">Cancel', page)
# a mismatch is said at its field only
response = self.client.post(url, {"usable_password": "true", "password1": "Ab-123456x", "password2": "other"})
self.assertContains(response, "The two password fields didn’t match.")
self.assertNotIn("Wrong Data Provided", self.messages(response))
def test_a_refused_choice_is_said_beside_its_field(self):
url = reverse("admin:user_update", args=[self.alice.pk])
response = self.client.post(url, {"username": "up-alice", "is_active": "on", "groups": "99999", "user_permissions": "99999",
"role": "user", "max_instances": 1, "max_cpus": 1, "max_memory": 1, "max_disk_size": 1})
page = html.fromstring(response.content)
self.assertEqual(len(page.xpath("//fieldset//div[contains(@class, 'invalid-feedback')]")), 2)
def test_the_quota_shows_no_limit_and_memory_in_gib(self):
UserAttributes.objects.filter(user=self.alice).update(max_instances=-1, max_cpus=1, max_memory=1536, max_disk_size=-1)
page = html.fromstring(self.client.get(reverse("admin:user_update", args=[self.alice.pk])).content)
shown = {box.get("name"): (box.get("value"), box.get("disabled") is not None) for box in page.xpath("//input[@type='number']")}
self.assertEqual(shown, {"max_instances": ("", True), "max_cpus": ("1", False), "max_memory": ("1.5", False),
"max_disk_size": ("", True)})
self.assertEqual(page.xpath("//input[@checked][@data-no-limit]/@name"), ["max_instances_unlimited", "max_disk_size_unlimited"])
def test_a_new_user_starts_with_the_default_quota(self):
page = html.fromstring(self.client.get(reverse("admin:user_create")).content)
shown = {box.get("name"): box.get("value") for box in page.xpath("//input[@type='number']")}
self.assertEqual(shown, {"max_instances": "2", "max_cpus": "2", "max_memory": "2", "max_disk_size": "20"})
self.assertEqual(page.xpath("//input[@name='role'][@checked]/@value"), ["user"])
def test_no_limit_is_kept_as_minus_one_and_gib_as_mib(self):
response = self.client.post(reverse("admin:user_update", args=[self.alice.pk]),
self.form_data(self.alice, max_cpus_unlimited="on", max_memory="0.75"))
self.assertRedirects(response, reverse("admin:user_list"))
quota = UserAttributes.objects.get(user=self.alice)
self.assertEqual((quota.max_instances, quota.max_cpus, quota.max_memory, quota.max_disk_size), (2, -1, 768, 20))
def test_a_limit_needs_a_number_or_no_limit(self):
for value in ("", "-1"):
with self.subTest(value=value):
response = self.client.post(reverse("admin:user_update", args=[self.alice.pk]),
self.form_data(self.alice, max_cpus=value) | {"max_cpus": value})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(html.fromstring(response.content).xpath("//input[@name='max_cpus'][contains(@class, 'is-invalid')]")), 1)
def test_a_role_sets_staff_and_superuser(self):
url = reverse("admin:user_update", args=[self.alice.pk])
for role, flags in (("template_editor", (True, False)), ("superuser", (True, True)), ("user", (False, False))):
with self.subTest(role=role):
self.assertRedirects(self.client.post(url, self.form_data(self.alice, role=role)), reverse("admin:user_list"))
self.alice.refresh_from_db()
self.assertEqual((self.alice.is_staff, self.alice.is_superuser), flags)
def test_a_missing_users_profile_is_not_found(self):
self.assertEqual(self.client.get(reverse("accounts:account", args=[99999])).status_code, 404)
def test_a_user_who_never_logged_in_says_never(self):
page = html.fromstring(self.client.get(reverse("admin:user_list")).content)
(row,) = page.xpath("//div[contains(@class, 'list-group-item')][.//a[normalize-space()='up-alice']]")
self.assertIn("Last login: never", " ".join(row.text_content().split()))
def test_the_search_finds_a_user_by_role_and_status(self):
self.alice.is_active = False
self.alice.save()
page = html.fromstring(self.client.get(reverse("admin:user_list")).content)
(row,) = page.xpath("//div[contains(@class, 'list-group-item')][.//a[normalize-space()='up-alice']]")
self.assertIn("User Blocked", row.get("data-search"))
def test_a_password_set_by_an_administrator_can_be_one_to_change(self):
url = reverse("admin:user_update_password", args=[self.alice.pk])
self.client.post(url, {"password1": "New-pw-4567x", "password2": "New-pw-4567x", "must_change_password": "on"})
self.assertTrue(UserAttributes.objects.get(user=self.alice).must_change_password)
# the user is sent to the change form until they choose their own
self.alice.refresh_from_db() # the new password's hash, or the session is not hers
self.client.force_login(self.alice)
self.assertRedirects(self.client.get(reverse("instances:index")), reverse("accounts:change_password"),
fetch_redirect_response=False)
self.client.force_login(self.admin)
self.client.post(url, {"password1": "New-pw-4567y", "password2": "New-pw-4567y"})
self.assertFalse(UserAttributes.objects.get(user=self.alice).must_change_password)
def test_a_password_set_by_an_administrator_ends_the_users_api_tokens(self):
# a reset closes a taken-over account: a stolen token must not outlive it
ApiToken.objects.create(self.alice, expiry=None, name="script")
ApiToken.objects.create(self.admin, expiry=None, name="mine")
url = reverse("admin:user_update_password", args=[self.alice.pk])
self.client.post(url, {"password1": "New-pw-4567x", "password2": "New-pw-4567x"})
self.assertFalse(ApiToken.objects.filter(user=self.alice).exists())
self.assertTrue(ApiToken.objects.filter(user=self.admin).exists())
def test_a_user_without_a_password_cannot_be_made_to_change_it(self):
# the change form asks for the old password: one they never had
url = reverse("admin:user_update_password", args=[self.alice.pk])
response = self.client.post(url, {"usable_password": "false", "must_change_password": "on"})
self.assertContains(response, "A user without a password cannot be made to change it")
self.alice.refresh_from_db()
self.assertTrue(self.alice.has_usable_password())
def test_an_administrator_keeps_their_own_password(self):
url = reverse("admin:user_update_password", args=[self.admin.pk])
response = self.client.post(url, {"usable_password": "false"})
self.assertContains(response, "You cannot block, delete or demote your own account")
self.admin.refresh_from_db()
self.assertTrue(self.admin.has_usable_password())

View file

@ -0,0 +1,26 @@
"""After saving a user, the edit page goes back to ?next= only on this site."""
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
LIMITS = {"role": "user", "max_instances": 1, "max_cpus": 1, "max_memory": 1, "max_disk_size": 4}
class UserUpdateNextTests(TestCase):
def setUp(self):
self.client.force_login(User.objects.create_superuser("un-admin", "un@example.com", "pw"))
self.user = User.objects.create_user("un-user", password="pw")
def save(self, next_url):
url = reverse("admin:user_update", args=[self.user.id]) + "?next=" + next_url
return self.client.post(url, {"username": "un-user", "is_active": "on", **LIMITS})
def test_a_page_of_this_site(self):
account = reverse("accounts:account", args=[self.user.id])
self.assertRedirects(self.save(account), account, fetch_redirect_response=False)
def test_another_site_is_not_followed(self):
for next_url in ("https://evil.example/", "//evil.example/", "/\\evil.example/"):
with self.subTest(next_url=next_url):
self.assertRedirects(self.save(next_url), reverse("admin:user_list"), fetch_redirect_response=False)

211
admin/tests/test_views.py Normal file
View file

@ -0,0 +1,211 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission, User
from django.core.exceptions import ObjectDoesNotExist
from django.db import connection
from django.shortcuts import reverse
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from lxml import html
from accounts.models import UserAttributes
from logs.models import Logs
class AdminTestCase(TestCase):
def setUp(self):
self.admin = get_user_model().objects.create_superuser("tests-admin", "admin@example.com", "pw")
self.client.force_login(self.admin)
Logs.objects.all().delete() # the login's row
def test_group_list(self):
response = self.client.get(reverse("admin:group_list"))
self.assertEqual(response.status_code, 200)
def test_groups(self):
response = self.client.get(reverse("admin:group_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(
reverse("admin:group_create"), {"name": "Test Group"}
)
self.assertRedirects(response, reverse("admin:group_list"))
group = Group.objects.get(name="Test Group")
group_id = group.id
response = self.client.get(reverse("admin:group_update", args=[group_id]))
self.assertEqual(response.status_code, 200)
response = self.client.post(
reverse("admin:group_update", args=[group_id]), {"name": "Updated Group Test"}
)
self.assertRedirects(response, reverse("admin:group_list"))
group = Group.objects.get(id=group_id)
self.assertEqual(group.name, "Updated Group Test")
response = self.client.get(reverse("admin:group_delete", args=[group_id]))
self.assertEqual(response.status_code, 200)
# the shared confirmation page names what it deletes
self.assertContains(response, "<title>Delete Updated Group Test</title>", html=True)
response = self.client.post(reverse("admin:group_delete", args=[group_id]))
self.assertRedirects(response, reverse("admin:group_list"))
with self.assertRaises(ObjectDoesNotExist):
Group.objects.get(id=group_id)
def test_group_form_lists_its_members_first(self):
for name in ("zed", "bob", "amy"):
User.objects.create_user(name)
User.objects.filter(username="amy").update(first_name="Amy", last_name="Adams")
group = Group.objects.create(name="Ops")
group.user_set.add(User.objects.get(username="zed"))
page = html.fromstring(self.client.get(reverse("admin:group_update", args=[group.id])).content)
boxes = page.xpath("//div[contains(@class, 'searchable')]/label")
names = [box.text_content().split()[0] for box in boxes]
self.assertEqual(names, ["zed", "admin", "amy", "bob", "tests-admin"])
self.assertIn("Amy Adams", boxes[2].get("data-search"))
self.assertEqual(page.xpath("//input[@id='filter']/@data-forget"), [""])
self.assertEqual(page.xpath("//form[@id='create-update']//button[@type='submit']/text()"), ["Save changes"])
page = html.fromstring(self.client.get(reverse("admin:group_create")).content)
self.assertEqual(page.xpath("//form[@id='create-update']//button[@type='submit']/text()"), ["Create group"])
def test_user_list(self):
response = self.client.get(reverse("admin:user_list"))
self.assertEqual(response.status_code, 200)
def test_user_list_shows_who_can_clone_in_queries_independent_of_the_users(self):
clone = Permission.objects.get(content_type__app_label="instances", codename="clone_instances")
User.objects.create_user("direct").user_permissions.add(clone)
group = Group.objects.create(name="Cloners")
group.permissions.add(clone)
User.objects.create_user("by-group").groups.add(group)
blocked = User.objects.create_user("blocked", is_active=False)
blocked.user_permissions.add(clone)
User.objects.create_user("plain")
def rows():
with CaptureQueriesContext(connection) as queries:
page = html.fromstring(self.client.get(reverse("admin:user_list")).content)
return {item.xpath(".//a[contains(@class, 'fw-semibold')]")[0].text: "Can clone" in item.text_content()
for item in page.xpath("//div[contains(@class, 'list-group-item')]")}, len(queries)
shown, count = rows()
self.assertEqual({name: shown[name] for name in ("direct", "by-group", "blocked", "plain", "admin")},
{"direct": True, "by-group": True, "blocked": False, "plain": False, "admin": False})
for i in range(5):
User.objects.create_user(f"more-{i}").user_permissions.add(clone)
self.assertEqual(rows()[1], count)
def test_users(self):
response = self.client.get(reverse("admin:user_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(
reverse("admin:user_create"),
{
"username": "test",
"password": "Wvc-created-9",
"role": "user",
"max_instances": 1,
"max_cpus": 1,
"max_memory": 1, # GiB
"max_disk_size": 4,
},
)
self.assertRedirects(response, reverse("admin:user_list"))
user = User.objects.get(username="test")
user_id = user.id
ua: UserAttributes = UserAttributes.objects.get(user_id=user_id)
self.assertEqual(ua.user_id, user_id)
self.assertEqual(ua.max_instances, 1)
self.assertEqual(ua.max_cpus, 1)
self.assertEqual(ua.max_memory, 1024)
self.assertEqual(ua.max_disk_size, 4)
response = self.client.get(reverse("admin:user_update", args=[user_id]))
self.assertEqual(response.status_code, 200)
response = self.client.post(
reverse("admin:user_update", args=[user_id]),
{
"username": "utest",
"role": "user",
"max_instances": 2,
"max_cpus": 2,
"max_memory": 2, # GiB
"max_disk_size": 8,
},
)
self.assertRedirects(response, reverse("admin:user_list"))
user = User.objects.get(id=user_id)
self.assertEqual(user.username, "utest")
ua = UserAttributes.objects.get(user_id=user_id)
self.assertEqual(ua.user_id, user_id)
self.assertEqual(ua.max_instances, 2)
self.assertEqual(ua.max_cpus, 2)
self.assertEqual(ua.max_memory, 2048)
self.assertEqual(ua.max_disk_size, 8)
# Blocking changes state, so a GET (e.g. an <img> tag) must not do it.
User.objects.filter(id=user_id).update(is_active=True)
response = self.client.get(reverse("admin:user_block", args=[user_id]))
self.assertEqual(response.status_code, 405)
self.assertTrue(User.objects.get(id=user_id).is_active)
response = self.client.post(reverse("admin:user_block", args=[user_id]))
user = User.objects.get(id=user_id)
self.assertFalse(user.is_active)
response = self.client.get(reverse("admin:user_unblock", args=[user_id]))
self.assertEqual(response.status_code, 405)
self.assertFalse(User.objects.get(id=user_id).is_active)
response = self.client.post(reverse("admin:user_unblock", args=[user_id]))
user = User.objects.get(id=user_id)
self.assertTrue(user.is_active)
response = self.client.get(reverse("admin:user_delete", args=[user_id]))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("admin:user_delete", args=[user_id]))
self.assertRedirects(response, reverse("admin:user_list"))
with self.assertRaises(ObjectDoesNotExist):
User.objects.get(id=user_id)
def test_logs(self):
response = self.client.get(reverse("admin:logs"))
self.assertEqual(response.status_code, 200)
def test_logs_show_result_and_ip(self):
Logs.objects.create(message="first", success=True, ip="192.0.2.7")
Logs.objects.create(message="second", success=False)
Logs.objects.create(message="third")
response = self.client.get(reverse("admin:logs"))
self.assertContains(response, "Done")
self.assertContains(response, "Failed")
self.assertContains(response, "Unknown")
self.assertContains(response, "192.0.2.7")
def test_logs_order_ties_by_id(self):
first = Logs.objects.create(message="first")
second = Logs.objects.create(message="second")
Logs.objects.filter(id__in=[first.id, second.id]).update(date=first.date)
response = self.client.get(reverse("admin:logs"))
self.assertEqual([log.id for log in response.context["logs"]], [second.id, first.id])
def test_logs_bad_page_numbers(self):
Logs.objects.create(message="entry")
response = self.client.get(reverse("admin:logs"), {"page": "abc"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["logs"].number, 1)
response = self.client.get(reverse("admin:logs"), {"page": "999"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["logs"].number, response.context["logs"].paginator.num_pages)

View file

@ -15,4 +15,7 @@ urlpatterns = [
path("users/<int:pk>/block/", views.user_block, name="user_block"),
path("users/<int:pk>/unblock/", views.user_unblock, name="user_unblock"),
path("logs/", views.logs, name="logs"),
path("logs/export.xlsx", views.logs_export, name="logs_export"),
path("removed-vms/", views.tombstone_list, name="tombstone_list"),
path("removed-vms/<int:pk>/delete/", views.tombstone_delete, name="tombstone_delete"),
]

View file

@ -1,22 +1,38 @@
from accounts.models import Instance, UserAttributes, UserInstance
from datetime import timedelta
from accounts.models import ApiToken, Instance, UserAttributes, UserInstance
from appsettings.settings import app_settings
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.forms import AdminPasswordChangeForm
from django.contrib.auth.models import Group, User
from django.contrib.auth.models import Group, Permission, User
from django.core.paginator import Paginator
from django.http import HttpResponse
from django.db import transaction
from django.db.models import Count, Exists, OuterRef, Q
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST
from django.views.decorators.debug import sensitive_post_parameters
from instances.models import InstanceTombstone
from instances.report import workbook
from logs.audit import audit, changed_fields, snapshot
from logs.models import Logs
from . import forms
from .decorators import superuser_only
def group_snapshot(group):
"""snapshot() of a group with its members, which are a reverse relation snapshot() does not see."""
return snapshot(group) | {"users": frozenset(group.user_set.values_list("pk", flat=True))}
@superuser_only
def group_list(request):
groups = Group.objects.all()
groups = Group.objects.annotate(members=Count("user", distinct=True), perms=Count("permissions", distinct=True)).order_by("name")
return render(
request,
"admin/group_list.html",
@ -30,12 +46,13 @@ def group_list(request):
def group_create(request):
form = forms.GroupForm(request.POST or None)
if form.is_valid():
form.save()
with audit(request, None, f"Group {form.cleaned_data['name']}", "add"):
form.save()
return redirect("admin:group_list")
return render(
request,
"common/form.html",
"admin/group_form.html",
{
"form": form,
"title": _("Create Group"),
@ -46,16 +63,22 @@ def group_create(request):
@superuser_only
def group_update(request, pk):
group = get_object_or_404(Group, pk=pk)
subject, before = f"Group {group.name}", group_snapshot(group)
form = forms.GroupForm(request.POST or None, instance=group)
if form.is_valid():
form.save()
with audit(request, None, subject, "change") as entry:
form.save()
fields = changed_fields(before, group_snapshot(group))
entry.action = "change " + ", ".join(fields)
entry.skip = not fields
return redirect("admin:group_list")
return render(
request,
"common/form.html",
"admin/group_form.html",
{
"form": form,
"group": group,
"title": _("Update Group"),
},
)
@ -65,19 +88,33 @@ def group_update(request, pk):
def group_delete(request, pk):
group = get_object_or_404(Group, pk=pk)
if request.method == "POST":
group.delete()
with audit(request, None, f"Group {group.name}", "delete"):
group.delete()
return redirect("admin:group_list")
return render(
request,
"common/confirm_delete.html",
{"object": group},
{"object": group, "consequence": _("Its members stay but lose the permissions it gave them.")},
)
# an administrator who blocks, deletes or demotes their own account is out of
# these pages at once, and may have been the last one
OWN_ACCOUNT = _("You cannot block, delete or demote your own account")
@superuser_only
def user_list(request):
users = User.objects.all()
# API tokens that still work: an expired one is deleted only at the user's next create
live = Q(auth_token_set__expiry__isnull=True) | Q(auth_token_set__expiry__gt=timezone.now())
# the clone permission, given to the user or to a group of the user: one
# query for the list, not a has_perm (two queries) a user
clone = Permission.objects.filter(content_type__app_label="instances", codename="clone_instances")
users = User.objects.annotate(
api_tokens=Count("auth_token_set", filter=live),
can_clone=Exists(clone.filter(Q(user=OuterRef("pk")) | Q(group__user=OuterRef("pk")))),
).prefetch_related("groups")
return render(
request,
"admin/user_list.html",
@ -88,19 +125,19 @@ def user_list(request):
)
@sensitive_post_parameters()
@superuser_only
def user_create(request):
user_form = forms.UserCreateForm(request.POST or None)
attributes_form = forms.UserAttributesForm(request.POST or None)
if user_form.is_valid() and attributes_form.is_valid():
user = user_form.save()
password = user_form.cleaned_data["password"]
user.set_password(password)
user.save()
attributes = attributes_form.save(commit=False)
attributes.user = user
attributes.save()
add_default_instances(user)
with audit(request, None, f"User {user_form.cleaned_data['username']}", "add"), transaction.atomic():
user = user_form.save()
attributes = attributes_form.save(commit=False)
attributes.user = user
attributes.save()
UserInstance.objects.bulk_create(UserInstance(user=user, instance=instance)
for instance in user_form.default_instances)
return redirect("admin:user_list")
return render(
@ -114,19 +151,35 @@ def user_create(request):
)
@sensitive_post_parameters()
@superuser_only
def user_update(request, pk):
user = get_object_or_404(User, pk=pk)
attributes, attributes_created = UserAttributes.objects.get_or_create(user=user)
subject = f"User {user.username}"
before, before_attributes = snapshot(user), snapshot(attributes)
user_form = forms.UserForm(request.POST or None, instance=user)
attributes_form = forms.UserAttributesForm(
request.POST or None, instance=attributes
)
if user_form.is_valid() and attributes_form.is_valid():
user_form.save()
attributes_form.save()
next = request.GET.get("next")
return redirect(next or "admin:user_list")
valid = user_form.is_valid() and attributes_form.is_valid()
if valid and user == request.user and not (user_form.cleaned_data["is_active"] and user_form.cleaned_data["is_superuser"]):
user_form.add_error(None, OWN_ACCOUNT)
valid = False
if valid:
with audit(request, None, subject, "change") as entry:
user_form.save()
attributes_form.save()
fields = set(changed_fields(before, snapshot(user))) | set(changed_fields(before_attributes, snapshot(attributes)))
fields = sorted(fields - {"password", "last_login"})
entry.action = "change " + ", ".join(fields)
entry.skip = not fields
next_url = request.GET.get("next")
if next_url and url_has_allowed_host_and_scheme(
url=next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()
):
return redirect(next_url)
return redirect("admin:user_list")
return render(
request,
@ -134,84 +187,172 @@ def user_update(request, pk):
{
"user_form": user_form,
"attributes_form": attributes_form,
"edited_user": user,
"title": _("Update User"),
},
)
@sensitive_post_parameters()
@superuser_only
def user_update_password(request, pk):
user = get_object_or_404(User, pk=pk)
if request.method == "POST":
form = AdminPasswordChangeForm(user, request.POST)
if form.is_valid():
user = form.save()
form = forms.AdminPasswordForm(user, request.POST)
valid = form.is_valid()
if valid and user == request.user and not form.cleaned_data["set_usable_password"]:
form.add_error(None, OWN_ACCOUNT)
valid = False
if valid:
with audit(request, None, f"User {user.username}", "set password"), transaction.atomic():
user = form.save()
UserAttributes.objects.update_or_create(
user=user, defaults={"must_change_password": form.cleaned_data["must_change_password"]})
# a reset closes a taken-over account: its tokens would outlive the old password
ApiToken.objects.filter(user=user).delete()
update_session_auth_hash(request, user) # Important!
messages.success(
request, _("Password changed for %(user)s") % {"user": user.username}
)
return redirect("admin:user_list")
else:
messages.error(request, _("Wrong Data Provided"))
else:
form = AdminPasswordChangeForm(user)
form = forms.AdminPasswordForm(user)
return render(
request,
"accounts/change_password_form.html",
{
"form": form,
"user": user.username,
},
)
return render(request, "admin/user_password_form.html", {"form": form, "edited_user": user})
@superuser_only
def user_delete(request, pk):
user = get_object_or_404(User, pk=pk)
if user == request.user:
messages.error(request, OWN_ACCOUNT)
return redirect("admin:user_list")
if request.method == "POST":
user.delete()
with audit(request, None, f"User {user.username}", "delete"):
user.delete()
return redirect("admin:user_list")
return render(
request,
"common/confirm_delete.html",
{"object": user},
{"object": user,
"consequence": _("Their access to VMs, SSH keys and settings go; the VMs themselves stay.")},
)
@require_POST
@superuser_only
def user_block(request, pk):
user: User = get_object_or_404(User, pk=pk)
user.is_active = False
user.save()
if user == request.user:
messages.error(request, OWN_ACCOUNT)
return redirect("admin:user_list")
with audit(request, None, f"User {user.username}", "block") as entry:
entry.skip = not user.is_active # already blocked
user.is_active = False
user.save()
return redirect("admin:user_list")
@require_POST
@superuser_only
def user_unblock(request, pk):
user: User = get_object_or_404(User, pk=pk)
user.is_active = True
user.save()
with audit(request, None, f"User {user.username}", "unblock") as entry:
entry.skip = user.is_active # not blocked
user.is_active = True
user.save()
return redirect("admin:user_list")
# rows in one Logs download: the workbook is built in memory
EXPORT_ROWS = 100000
def _logs(request):
"""The Logs rows the search and the failed-only box choose, newest first,
and the query that chooses them again."""
rows = Logs.objects.order_by("-date", "-id")
text = request.GET.get("q", "").strip()
failed = bool(request.GET.get("failed"))
if text:
rows = rows.filter(Q(user__icontains=text) | Q(host__icontains=text) | Q(instance__icontains=text)
| Q(instance_uuid__icontains=text) | Q(message__icontains=text) | Q(ip__icontains=text))
if failed:
rows = rows.filter(success=False)
query = urlencode([(key, value) for key, value in (("q", text), ("failed", "1" if failed else "")) if value])
return rows, text, failed, query
def _result(success):
return _("Unknown") if success is None else _("Done") if success else _("Failed")
@superuser_only
def logs(request):
l = Logs.objects.order_by("-date")
paginator = Paginator(l, int(app_settings.LOGS_PER_PAGE))
page = request.GET.get("page", 1)
logs = paginator.page(page)
return render(request, "admin/logs.html", {"logs": logs})
rows, text, failed, query = _logs(request)
logs = Paginator(rows, int(app_settings.LOGS_PER_PAGE)).get_page(request.GET.get("page"))
# a VM's rows link to it while it is in the panel
vms = dict(Instance.objects.filter(uuid__in={log.instance_uuid for log in logs if log.instance_uuid})
.values_list("uuid", "pk"))
for log in logs:
log.vm_pk = vms.get(log.instance_uuid)
return render(request, "admin/logs.html", {"logs": logs, "q": text, "failed": failed, "query": query,
"time_zone": timezone.get_current_timezone_name()})
def add_default_instances(user):
"""
Adds instances listed in NEW_USER_DEFAULT_INSTANCES to user
"""
existing_instances = UserInstance.objects.filter(user=user)
if not existing_instances:
for instance_name in settings.NEW_USER_DEFAULT_INSTANCES:
instance = Instance.objects.get(name=instance_name)
user_instance = UserInstance(user=user, instance=instance)
user_instance.save()
@superuser_only
def logs_export(request):
"""The rows the Logs page shows for the same search, every page of them,
as an Excel workbook."""
rows, text, failed, _query = _logs(request)
zone = timezone.get_current_timezone_name()
header = [f"Date ({zone})", "User", "Host", "VM", "VM UUID", "Message", "Result", "IP"]
# tuples, not model objects: up to EXPORT_ROWS of them are held at once
logs = list(rows.values_list("date", "user", "host", "instance", "instance_uuid", "message", "success", "ip")
[:EXPORT_ROWS + 1])
data = [[timezone.localtime(date).strftime("%Y-%m-%d %H:%M:%S"), user, host, instance, uuid, message,
str(_result(success)), ip or ""]
for date, user, host, instance, uuid, message, success, ip in logs[:EXPORT_ROWS]]
notes = [f"Search: {text}" if text else "Search: none", "Failed only" if failed else "All results",
f"Rows: {len(data)}", f"Times in {zone}"]
if len(logs) > EXPORT_ROWS:
notes.append(f"Only the newest {EXPORT_ROWS} rows: narrow the search for the others")
response = HttpResponse(workbook("Logs", header, data, "About", notes),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
name = f"webvirtcloud-logs-{timezone.localtime():%Y%m%d-%H%M%S}.xlsx"
response["Content-Disposition"] = f'attachment; filename="{name}"'
return response
@superuser_only
def tombstone_list(request):
"""The owners kept for VMs that vanished from their compute, in case the
same UUID comes back."""
tombstones = list(InstanceTombstone.objects.order_by("-removed"))
ids = {owner["user"] for tombstone in tombstones for owner in tombstone.owners}
names = dict(User.objects.filter(id__in=ids).values_list("id", "username"))
retention = timedelta(days=getattr(settings, "INSTANCE_OWNERSHIP_RETENTION_DAYS", 30))
for tombstone in tombstones:
tombstone.owner_names = [names.get(owner["user"], _("deleted user")) for owner in tombstone.owners]
tombstone.expires = tombstone.removed + retention
return render(request, "admin/tombstone_list.html", {"tombstones": tombstones})
@superuser_only
def tombstone_delete(request, pk):
tombstone = get_object_or_404(InstanceTombstone, pk=pk)
if request.method == "POST":
with audit(request, tombstone.host or None, f"Removed VM {tombstone.name} ({tombstone.uuid})", "delete",
instance=tombstone.name, uuid=tombstone.uuid):
tombstone.delete()
messages.success(request, _("Removed VM %(name)s deleted") % {"name": tombstone.name})
return redirect("admin:tombstone_list")
return render(
request,
"common/confirm_delete.html",
{"object": tombstone,
"consequence": _("Its owners no longer get the VM back if its UUID shows up again.")},
)

View file

@ -3,3 +3,4 @@ from django.apps import AppConfig
class AppsettingsConfig(AppConfig):
name = "appsettings"
default_auto_field = "django.db.models.AutoField"

View file

View file

@ -0,0 +1,83 @@
import fcntl
import os
import shutil
import sqlite3
import subprocess
from contextlib import closing
from datetime import datetime
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import connection
PREFIX = "webvirtcloud-backup-"
class Command(BaseCommand):
help = ("Copy the database, secret key, worker id, settings and the SSH keys and TLS certificates of the "
"panel's user into a new directory in backups/, then delete all but the newest --keep backups.")
def add_arguments(self, parser):
parser.add_argument("--keep", type=int, default=7, help="how many backups to keep (default 7)")
def handle(self, *args, keep, **options):
if keep < 1:
raise CommandError("--keep must be at least 1")
os.umask(0o077) # the database holds compute passwords and keys
backups = settings.BASE_DIR / "backups"
backups.mkdir(mode=0o700, exist_ok=True)
with open(backups / ".lock", "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise CommandError("Another backup is running") from None
self.stdout.write(str(self.backup(backups, keep)))
def backup(self, backups, keep):
name = PREFIX + datetime.now().strftime("%Y%m%d-%H%M%S")
partial = backups / f".{name}"
partial.mkdir()
try:
self.copy(partial)
partial.rename(backups / name)
except BaseException:
shutil.rmtree(partial, ignore_errors=True)
raise
# only after a good backup: a failing one never costs an older one;
# the partial ones are what a killed run left behind
for old in [*sorted(backups.glob(PREFIX + "*"))[:-keep], *backups.glob(f".{PREFIX}*")]:
shutil.rmtree(old)
return backups / name
def copy(self, to):
database = connection.settings_dict
if database["ENGINE"] == "django.db.backends.sqlite3":
# the backup API copies a consistent state while the panel writes
read_only = Path(database["NAME"]).resolve().as_uri() + "?mode=ro"
with closing(sqlite3.connect(read_only, uri=True, timeout=30)) as source, \
closing(sqlite3.connect(to / "db.sqlite3")) as copy:
source.backup(copy)
result = copy.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
raise CommandError(f"The copy of the database is damaged: {result}")
else:
self.stderr.write("The database is not sqlite and is not in the backup: back it up with its own tools.")
data = settings.BASE_DIR / "data"
for source in (data / "secret_key", data / "node_id", settings.BASE_DIR / "webvirtcloud" / "settings.py"):
if source.is_file():
shutil.copyfile(source, to / source.name)
ssh = Path.home() / ".ssh"
if ssh.is_dir():
(to / "ssh").mkdir()
for source in ssh.iterdir():
if source.is_file() and not source.is_symlink():
shutil.copyfile(source, to / "ssh" / source.name)
# the TLS client certificates libvirt reads (~/.pki/libvirt); a link stays a link
pki = Path.home() / ".pki"
if pki.is_dir():
shutil.copytree(pki, to / "pki", symlinks=True)
if (settings.BASE_DIR / ".git").exists():
commit = subprocess.run(["git", "rev-parse", "HEAD"], cwd=settings.BASE_DIR, capture_output=True, text=True)
if commit.returncode == 0:
(to / "commit").write_text(commit.stdout)

View file

@ -0,0 +1,27 @@
from pathlib import Path
import sass
from django.conf import settings
from django.core.management.base import BaseCommand
from appsettings.views import THEMES_DIR
SCSS = Path(settings.BASE_DIR, "dev", "scss")
def compile_theme(theme):
return sass.compile(
string=f"@import '{SCSS}/wvc-themes/{theme}/variables';\n"
f"@import '{SCSS}/bootstrap-overrides.scss';\n"
f"@import '{SCSS}/wvc-themes/{theme}/bootswatch';",
output_style="compressed",
)
class Command(BaseCommand):
help = "Compile the themes in dev/scss/wvc-themes into static/css/themes (needs libsass, dev/requirements.txt)."
def handle(self, *args, **options):
THEMES_DIR.mkdir(exist_ok=True)
for theme in sorted(p.name for p in (SCSS / "wvc-themes").iterdir() if p.is_dir()):
(THEMES_DIR / f"{theme}.min.css").write_text(compile_theme(theme))

View file

@ -1,4 +1,4 @@
from .settings import app_settings, get_settings
from .settings import get_settings
class AppSettingsMiddleware(object):

View file

@ -0,0 +1,141 @@
"""The settings table and its rows as migrations 0001 to 0024 left them,
which this migration replaces."""
from django.db import migrations, models
# (name, key, value, choices, description) of each setting, in their old order
SETTINGS = [
('Theme', 'BOOTSTRAP_THEME', 'zephyr', '',
'Bootstrap CSS & Bootswatch Theme'),
('All Instances View Style', 'VIEW_INSTANCES_LIST_STYLE', 'grouped', 'grouped,nongrouped',
'Instances page for administrators: VMs under their host, or one list'),
('Logs per Page', 'LOGS_PER_PAGE', '100', '',
'Rows per page on the Logs page'),
('Multiple Owner for VM', 'ALLOW_INSTANCE_MULTIPLE_OWNER', 'True', 'True,False',
'A VM can be given to more than one user'),
('Quota Debug', 'QUOTA_DEBUG', 'True', 'True,False',
'A quota refusal shows the numbers: what would be used and what is allowed'),
('Disk Format', 'INSTANCE_VOLUME_DEFAULT_FORMAT', 'qcow2', 'raw,qcow,qcow2',
'Format of new disk images in directory pools'),
('Disk Bus', 'INSTANCE_VOLUME_DEFAULT_BUS', 'virtio', 'virtio,scsi,ide,usb,sata',
'Bus of new disks'),
('Disk SCSI Controller', 'INSTANCE_VOLUME_DEFAULT_SCSI_CONTROLLER', 'virtio-scsi', 'virtio-scsi,lsilogic',
'Controller model for disks on the SCSI bus'),
('Disk Cache', 'INSTANCE_VOLUME_DEFAULT_CACHE', 'directsync', 'default,directsync,none,unsafe,writeback,writethrough',
'Cache mode of new disks'),
('Disk IO Type', 'INSTANCE_VOLUME_DEFAULT_IO', 'default', 'default,native,threads',
'I/O mode of new disks (native needs cache none or directsync)'),
('Disk Detect Zeroes', 'INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES', 'default', 'default,on,off,unmap',
'Detect zeroes of new disks: how blocks of zeros the guest writes are stored'),
('Disk Discard', 'INSTANCE_VOLUME_DEFAULT_DISCARD', 'default', 'default,unmap,ignore',
"Discard of new disks (unmap: the guest's TRIM frees space in the image)"),
('Disk Owner UID', 'INSTANCE_VOLUME_DEFAULT_OWNER_UID', '0', '',
'Owner of new disk image files (0: root; qemu is often 107)'),
('Disk Owner GID', 'INSTANCE_VOLUME_DEFAULT_OWNER_GID', '0', '',
'Group of new disk image files (0: root; qemu is often 107)'),
('VM CPU Mode', 'INSTANCE_CPU_DEFAULT_MODE', 'host-model', 'host-model,host-passthrough,maximum',
'CPU mode chosen first for a new VM'),
('VM Machine Type', 'INSTANCE_MACHINE_DEFAULT_TYPE', 'q35', 'q35,pc,virt',
'Machine type chosen first on the new VM pages'),
('VM Firmware Type', 'INSTANCE_FIRMWARE_DEFAULT_TYPE', 'BIOS', 'BIOS,UEFI',
'Firmware chosen first for a new x86_64 VM'),
('VM Architecture Type', 'INSTANCE_ARCH_DEFAULT_TYPE', 'x86_64', 'x86_64,i686,aarch64',
'Architecture chosen first on the new VM pages'),
('VM Clone Name Prefix', 'CLONE_INSTANCE_DEFAULT_PREFIX', 'instance', '',
'Prefix of the names made for clones: prefix1, prefix2, ...'),
('VM Clone Auto Name', 'CLONE_INSTANCE_AUTO_NAME', 'False', 'True,False',
'Users who are not administrators get clone names made from the prefix'),
('Show Access Root Pass', 'SHOW_ACCESS_ROOT_PASSWORD', 'False', 'True,False',
"Show Root Password on a VM's Access tab"),
('Show Access SSH Keys', 'SHOW_ACCESS_SSH_KEYS', 'False', 'True,False',
"Show SSH Keys on a VM's Access tab"),
('Console Scale', 'CONSOLE_SCALE', 'False', 'True,False',
'The console starts scaled to its window'),
('Console View-Only', 'CONSOLE_VIEW_ONLY', 'False', 'True,False',
'The console starts view-only: no keyboard or mouse input'),
('Console Resize Session', 'CONSOLE_RESIZE_SESSION', 'False', 'True,False',
'The console starts asking the guest to fit its screen to the window'),
('Console Clip Viewport', 'CONSOLE_CLIP_VIEWPORT', 'False', 'True,False',
'The console starts clipped to its window, without scaling'),
('VM DRBD Status', 'VM_DRBD_STATUS', 'False', 'True,False',
"The Instances page shows each VM's DRBD state"),
('VM CD-ROM Device', 'INSTANCE_CDROM_ADD', 'sata', 'None,ide,sata,scsi',
'Bus of the CD-ROM a new VM gets (None: no CD-ROM)'),
('VM Video Type', 'INSTANCE_VIDEO_DEFAULT_TYPE', 'vga', 'None,virtio,vga,cirrus,vmvga,bochs,ramfb',
'Video device chosen first for a new VM'),
('VM Input Device', 'INSTANCE_INPUT_DEFAULT_DEVICE', 'default', 'None,default,virtio,usb',
'Input device chosen first for a new VM (None: no input device)'),
('VM NIC Type', 'INSTANCE_NIC_DEFAULT_TYPE', 'default', 'default,e1000,e1000e,rtl8139,virtio',
"Network card model chosen first for a new VM (default: libvirt's choice)"),
('Login Failure Limit', 'LOGIN_FAILURE_LIMIT', '5', '',
'Failed logins of a user from one address before it is locked'),
('Login Lockout Minutes', 'LOGIN_LOCKOUT_MINUTES', '15', '',
'How long a locked login stays locked, in minutes'),
('Snapshot Free Percent', 'SNAPSHOT_FREE_SPACE_PERCENT', '10', '',
"Free space a storage pool keeps for a snapshot, in percent of the VM's disks in it (0: none)"),
('Snapshot Free Min GiB', 'SNAPSHOT_FREE_SPACE_MIN_GIB', '1', '',
'Free space a storage pool keeps for a snapshot at least, in GiB (0: none)'),
('Worker Concurrency', 'WORKER_CONCURRENCY', '4', '',
'Jobs the worker runs at the same time (1-16)'),
('Job Retention Days', 'JOB_RETENTION_DAYS', '30', '',
'Days finished jobs stay in the job history (1-3650)'),
('Log Retention Days', 'LOG_RETENTION_DAYS', '365', '',
'Days the Logs page keeps its rows (1-3650)'),
]
def add_settings(apps, schema_editor):
AppSettings = apps.get_model("appsettings", "AppSettings")
AppSettings.objects.using(schema_editor.connection.alias).bulk_create(
AppSettings(name=name, key=key, value=value, choices=choices, description=description)
for name, key, value, choices, description in SETTINGS
)
class Migration(migrations.Migration):
replaces = [
("appsettings", "0001_initial"),
("appsettings", "0002_auto_20200527_1603"),
("appsettings", "0003_auto_20200615_0637"),
("appsettings", "0004_auto_20200716_0637"),
("appsettings", "0005_auto_20200911_1233"),
("appsettings", "0006_auto_20220630_0717"),
("appsettings", "0007_auto_20220905_0918"),
("appsettings", "0008_auto_20220905_1459"),
("appsettings", "0009_alter_appsettings_id"),
("appsettings", "0010_auto_20231030_1305"),
("appsettings", "0011_alter_appsettings_id"),
("appsettings", "0012_remove_spice_qemu_console_type"),
("appsettings", "0013_remove_qemu_console_default_type"),
("appsettings", "0014_login_lockout_settings"),
("appsettings", "0015_remove_clone_instance_auto_migrate"),
("appsettings", "0016_nic_type_rtl8139"),
("appsettings", "0017_snapshot_free_space_settings"),
("appsettings", "0018_worker_settings"),
("appsettings", "0019_prebuilt_themes"),
("appsettings", "0020_fix_setting_choices"),
("appsettings", "0021_setting_descriptions"),
("appsettings", "0022_cpu_mode_choices"),
("appsettings", "0023_remove_bottom_bar"),
("appsettings", "0024_log_retention_setting"),
]
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="AppSettings",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=25, verbose_name="name")),
("key", models.CharField(db_index=True, max_length=50, unique=True, verbose_name="key")),
("value", models.CharField(max_length=25, verbose_name="value")),
("choices", models.CharField(max_length=70, verbose_name="choices")),
("description", models.CharField(max_length=100, null=True, verbose_name="description")),
],
),
migrations.RunPython(add_settings, migrations.RunPython.noop),
]

View file

@ -1,7 +1,6 @@
# Generated by Django 3.2.13 on 2022-06-30 07:17
from django.db import migrations
from django.utils.translation import gettext_lazy as _
def update_default_settings(apps, schema_editor):
setting = apps.get_model("appsettings", "AppSettings")

View file

@ -0,0 +1,38 @@
from django.db import migrations
def update_console_type(apps, schema_editor):
AppSettings = apps.get_model("appsettings", "AppSettings")
db_alias = schema_editor.connection.alias
setting = (
AppSettings.objects.using(db_alias)
.filter(key="QEMU_CONSOLE_DEFAULT_TYPE")
.first()
)
if setting:
setting.value = "vnc"
setting.choices = "vnc"
setting.save(using=db_alias)
def reverse_console_type(apps, schema_editor):
AppSettings = apps.get_model("appsettings", "AppSettings")
db_alias = schema_editor.connection.alias
setting = (
AppSettings.objects.using(db_alias)
.filter(key="QEMU_CONSOLE_DEFAULT_TYPE")
.first()
)
if setting:
setting.choices = "vnc,spice"
setting.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
("appsettings", "0011_alter_appsettings_id"),
]
operations = [
migrations.RunPython(update_console_type, reverse_console_type),
]

View file

@ -0,0 +1,25 @@
from django.db import migrations
def remove_console_type(apps, schema_editor):
"""VNC is the only console type; the setting has nothing left to choose."""
AppSettings = apps.get_model("appsettings", "AppSettings")
AppSettings.objects.using(schema_editor.connection.alias).filter(key="QEMU_CONSOLE_DEFAULT_TYPE").delete()
def restore_console_type(apps, schema_editor):
AppSettings = apps.get_model("appsettings", "AppSettings")
AppSettings.objects.using(schema_editor.connection.alias).get_or_create(
key="QEMU_CONSOLE_DEFAULT_TYPE",
defaults={"name": "VM Console Type", "value": "vnc", "choices": "vnc", "description": "Default console type"},
)
class Migration(migrations.Migration):
dependencies = [
("appsettings", "0012_remove_spice_qemu_console_type"),
]
operations = [
migrations.RunPython(remove_console_type, restore_console_type),
]

View file

@ -0,0 +1,38 @@
from django.core.management.color import no_style
from django.db import migrations
from django.utils.translation import gettext_noop as _
SETTINGS = [
(_("Login Failure Limit"), "LOGIN_FAILURE_LIMIT", "5",
_("Failed logins of a user from one address before it is locked")),
(_("Login Lockout Minutes"), "LOGIN_LOCKOUT_MINUTES", "15",
_("How long a locked login stays locked, in minutes")),
]
def add_settings(apps, schema_editor):
setting = apps.get_model("appsettings", "AppSettings")
db_alias = schema_editor.connection.alias
# Earlier migrations inserted the default rows with fixed ids, which a
# PostgreSQL id sequence does not see: move it past them before inserting
# without an id (a no-op on sqlite).
with schema_editor.connection.cursor() as cursor:
for sql in schema_editor.connection.ops.sequence_reset_sql(no_style(), [setting]):
cursor.execute(sql)
for name, key, value, description in SETTINGS:
setting.objects.using(db_alias).get_or_create(
key=key, defaults={"name": name, "value": value, "choices": "", "description": description}
)
def del_settings(apps, schema_editor):
setting = apps.get_model("appsettings", "AppSettings")
db_alias = schema_editor.connection.alias
setting.objects.using(db_alias).filter(key__in=[key for _, key, _, _ in SETTINGS]).delete()
class Migration(migrations.Migration):
dependencies = [("appsettings", "0013_remove_qemu_console_default_type")]
operations = [migrations.RunPython(add_settings, del_settings)]

View file

@ -0,0 +1,31 @@
from django.db import migrations
def remove_auto_migrate(apps, schema_editor):
"""A clone stays on the host of the VM it was made from: the setting sent
it to a random host, whatever storage, CPU or network that host had."""
AppSettings = apps.get_model("appsettings", "AppSettings")
AppSettings.objects.using(schema_editor.connection.alias).filter(key="CLONE_INSTANCE_AUTO_MIGRATE").delete()
def restore_auto_migrate(apps, schema_editor):
AppSettings = apps.get_model("appsettings", "AppSettings")
AppSettings.objects.using(schema_editor.connection.alias).get_or_create(
key="CLONE_INSTANCE_AUTO_MIGRATE",
defaults={
"name": "VM Clone Auto Migrate",
"value": "False",
"choices": "True,False",
"description": "Auto migrate instance after clone",
},
)
class Migration(migrations.Migration):
dependencies = [
("appsettings", "0014_login_lockout_settings"),
]
operations = [
migrations.RunPython(remove_auto_migrate, restore_auto_migrate),
]

View file

@ -0,0 +1,22 @@
from django.db import migrations
def fix_rtl8139(apps, schema_editor):
"""The NIC type is rtl8139: a VM with model rt18139 does not start. Not
undone on the way back, as nothing needs the misspelling."""
AppSettings = apps.get_model("appsettings", "AppSettings")
for setting in AppSettings.objects.using(schema_editor.connection.alias).filter(key="INSTANCE_NIC_DEFAULT_TYPE"):
setting.choices = setting.choices.replace("rt18139", "rtl8139")
if setting.value == "rt18139":
setting.value = "rtl8139"
setting.save()
class Migration(migrations.Migration):
dependencies = [
("appsettings", "0015_remove_clone_instance_auto_migrate"),
]
operations = [
migrations.RunPython(fix_rtl8139, migrations.RunPython.noop),
]

View file

@ -0,0 +1,36 @@
from django.core.management.color import no_style
from django.db import migrations
from django.utils.translation import gettext_noop as _
SETTINGS = [
(_("Snapshot Free Percent"), "SNAPSHOT_FREE_SPACE_PERCENT", "10",
_("Free space a storage pool keeps for a snapshot, in percent of the VM's disks in it (0: none)")),
(_("Snapshot Free Min GiB"), "SNAPSHOT_FREE_SPACE_MIN_GIB", "1",
_("Free space a storage pool keeps for a snapshot at least, in GiB (0: none)")),
]
def add_settings(apps, schema_editor):
setting = apps.get_model("appsettings", "AppSettings")
db_alias = schema_editor.connection.alias
# see 0014: move a PostgreSQL id sequence past the fixed ids first
with schema_editor.connection.cursor() as cursor:
for sql in schema_editor.connection.ops.sequence_reset_sql(no_style(), [setting]):
cursor.execute(sql)
for name, key, value, description in SETTINGS:
setting.objects.using(db_alias).get_or_create(
key=key, defaults={"name": name, "value": value, "choices": "", "description": description}
)
def del_settings(apps, schema_editor):
setting = apps.get_model("appsettings", "AppSettings")
db_alias = schema_editor.connection.alias
setting.objects.using(db_alias).filter(key__in=[key for _, key, _, _ in SETTINGS]).delete()
class Migration(migrations.Migration):
dependencies = [("appsettings", "0016_nic_type_rtl8139")]
operations = [migrations.RunPython(add_settings, del_settings)]

View file

@ -0,0 +1,34 @@
from django.core.management.color import no_style
from django.db import migrations
from django.utils.translation import gettext_noop as _
SETTINGS = [
(_("Worker Concurrency"), "WORKER_CONCURRENCY", "4", _("Jobs the worker runs at the same time (1-16)")),
(_("Job Retention Days"), "JOB_RETENTION_DAYS", "30", _("Days finished jobs stay in the job history (1-3650)")),
]
def add_settings(apps, schema_editor):
setting = apps.get_model("appsettings", "AppSettings")
db_alias = schema_editor.connection.alias
# see 0014: move a PostgreSQL id sequence past the fixed ids first
with schema_editor.connection.cursor() as cursor:
for sql in schema_editor.connection.ops.sequence_reset_sql(no_style(), [setting]):
cursor.execute(sql)
for name, key, value, description in SETTINGS:
setting.objects.using(db_alias).get_or_create(
key=key, defaults={"name": name, "value": value, "choices": "", "description": description}
)
def del_settings(apps, schema_editor):
setting = apps.get_model("appsettings", "AppSettings")
db_alias = schema_editor.connection.alias
setting.objects.using(db_alias).filter(key__in=[key for _, key, _, _ in SETTINGS]).delete()
class Migration(migrations.Migration):
dependencies = [("appsettings", "0017_snapshot_free_space_settings")]
operations = [migrations.RunPython(add_settings, del_settings)]

View file

@ -0,0 +1,15 @@
from django.db import migrations
def forward(apps, schema_editor):
setting = apps.get_model("appsettings", "AppSettings")
db_alias = schema_editor.connection.alias
setting.objects.using(db_alias).filter(key="SASS_DIR").delete()
# the default was a typo that never applied: these installs showed the shipped zephyr
setting.objects.using(db_alias).filter(key="BOOTSTRAP_THEME", value="flaty").update(value="zephyr")
class Migration(migrations.Migration):
dependencies = [("appsettings", "0018_worker_settings")]
operations = [migrations.RunPython(forward, migrations.RunPython.noop)]

Some files were not shown because too many files have changed in this diff Show more