├── .python-version ├── teamvault ├── apps │ ├── __init__.py │ ├── secrets │ │ ├── api │ │ │ ├── __init__.py │ │ │ └── urls.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── models │ │ │ │ ├── __init__.py │ │ │ │ ├── test_model_consistency.py │ │ │ │ ├── test_constraints.py │ │ │ │ ├── test_secret_revisions.py │ │ │ │ └── test_file_payload_migration.py │ │ │ ├── views │ │ │ │ ├── __init__.py │ │ │ │ └── test_encryption_view_constraints.py │ │ │ └── utils.py │ │ ├── management │ │ │ ├── __init__.py │ │ │ └── commands │ │ │ │ ├── __init__.py │ │ │ │ └── update_search_index.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0004_unaccent_extension.py │ │ │ ├── 0006_auto_20150124_1103.py │ │ │ ├── 0011_remove_secret_search_index.py │ │ │ ├── 0030_sharedsecretdata_granted_on.py │ │ │ ├── 0033_secretrevision_encrypted_otp_key.py │ │ │ ├── 0002_secret_filename.py │ │ │ ├── 0021_auto_20180220_1428.py │ │ │ ├── 0005_secret_search_index.py │ │ │ ├── 0015_secretrevision_plaintext_data_sha256.py │ │ │ ├── 0022_secret_last_changed.py │ │ │ ├── 0018_auto_20180220_1244.py │ │ │ ├── 0007_auto_20150205_1918.py │ │ │ ├── 0012_secret_search_index.py │ │ │ ├── 0019_secret_notify_on_access_request.py │ │ │ ├── 0034_remove_secretrevision_encrypted_otp_key_and_more.py │ │ │ ├── 0035_remove_secretrevision_encrypted_otp_key_data_and_more.py │ │ │ ├── 0028_sharedsecretdata_grant_description_and_more.py │ │ │ ├── 0024_auto_20210824_1203.py │ │ │ ├── 0003_auto_20150113_1915.py │ │ │ ├── 0027_sharedsecretdata_only_one_set.py │ │ │ ├── 0023_auto_20190822_1234.py │ │ │ ├── 0032_alter_sharedsecretdata_granted_by.py │ │ │ ├── 0029_sharedsecretdata_granted_by.py │ │ │ ├── 0025_alter_secret_description_alter_secret_name.py │ │ │ ├── 0020_auto_20180220_1356.py │ │ │ ├── 0008_auto_20150322_0944.py │ │ │ ├── 0040_secretchange_scrubbed_fields.py │ │ │ ├── 0013_auto_20161021_1411.py │ │ │ ├── 0016_auto_20180220_1053.py │ │ │ ├── 0036_alter_secret_shared_groups_alter_secret_shared_users.py │ │ │ ├── 0037_change_secretrevision_plaintextdata_key_of_password_type.py │ │ │ ├── 0017_auto_20180220_1115.py │ │ │ ├── 0009_auto_20150322_0949.py │ │ │ ├── 0014_auto_20170313_1544.py │ │ │ ├── 0031_rename_share_data_fields.py │ │ │ ├── 0039_migrate_old_file_saves_into_new_format.py │ │ │ └── 0026_allowed_groups_users_intermediate.py │ │ ├── templatetags │ │ │ ├── __init__.py │ │ │ └── smart_pagination.py │ │ ├── exceptions.py │ │ ├── __init__.py │ │ ├── context_processors.py │ │ ├── templates │ │ │ ├── opensearch.xml │ │ │ └── secrets │ │ │ │ ├── search │ │ │ │ └── _search_item.html │ │ │ │ ├── detail_content │ │ │ │ ├── file.html │ │ │ │ ├── _su_confirm_modal.html │ │ │ │ └── meta.html │ │ │ │ ├── secret_delete.html │ │ │ │ ├── secret_restore.html │ │ │ │ ├── secret_row.html │ │ │ │ ├── dashboard.html │ │ │ │ ├── secret_list.html │ │ │ │ └── addedit_content │ │ │ │ ├── file.html │ │ │ │ └── cc.html │ │ ├── validators.py │ │ ├── enums.py │ │ ├── urls.py │ │ ├── filters.py │ │ └── tasks.py │ ├── audit │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0009_alter_logentry_category.py │ │ │ ├── 0008_logentry_logentry_category_idx_and_more.py │ │ │ ├── 0006_logentry_reason.py │ │ │ ├── 0004_alter_logentry_category.py │ │ │ ├── 0001_initial.py │ │ │ ├── 0007_alter_logentry_category.py │ │ │ ├── 0002_auto_20170313_1544.py │ │ │ ├── 0005_alter_logentry_category.py │ │ │ └── 0003_logentry_categories.py │ │ ├── __init__.py │ │ ├── urls.py │ │ ├── auditlog.py │ │ ├── filters.py │ │ ├── views.py │ │ ├── models.py │ │ └── templates │ │ │ └── audit │ │ │ └── log.html │ ├── accounts │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0003_usersettings_avatar.py │ │ │ ├── 0001_initial.py │ │ │ ├── 0005_rename_usersettings_userprofile.py │ │ │ ├── 0004_alter_usersettings_hide_deleted_secrets.py │ │ │ └── 0002_add_user_settings.py │ │ ├── __init__.py │ │ ├── context_processors.py │ │ ├── forms.py │ │ ├── templates │ │ │ └── accounts │ │ │ │ ├── logout.html │ │ │ │ ├── _avatar.html │ │ │ │ ├── user_settings.html │ │ │ │ └── login.html │ │ ├── models.py │ │ ├── auth.py │ │ ├── urls.py │ │ └── utils.py │ └── settings │ │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ │ ├── webpack.py │ │ ├── __init__.py │ │ └── models.py ├── __init__.py ├── __version__.py ├── static │ ├── scss │ │ ├── card.scss │ │ ├── fontawesome.scss │ │ ├── scrollbar.scss │ │ ├── circularProgressbar.scss │ │ ├── tom-select.scss │ │ ├── select2.scss │ │ ├── theme.scss │ │ ├── avatars.scss │ │ ├── base.scss │ │ ├── search.scss │ │ └── secrets.scss │ └── js │ │ ├── utils.js │ │ ├── zxcvbn.ts │ │ ├── otp.js │ │ └── index.js ├── templates │ ├── helpers │ │ ├── filter_item.html │ │ ├── filter_row.html │ │ └── filter.html │ ├── rest_framework │ │ └── api.html │ ├── 404_anon.html │ ├── 404_loggedin.html │ ├── pagination.html │ └── base.html ├── wsgi.py ├── manage.py ├── urls.py ├── middleware.py ├── views.py └── cli.py ├── .coveragerc ├── .gitattributes ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── .babelrc ├── webpack.prod.js ├── MANIFEST.in ├── webpack.dev.js ├── webpack.common.js ├── justfile ├── README.md ├── .github └── workflows │ ├── run-tests-on-pr.yml │ └── create-build-on-release.yml ├── pyproject.toml └── CHANGELOG.md /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /teamvault/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teamvault/apps/audit/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teamvault/apps/settings/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teamvault/__init__.py: -------------------------------------------------------------------------------- 1 | from teamvault.__version__ import __version__ 2 | -------------------------------------------------------------------------------- /teamvault/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.11.6" # Also change in pyproject.toml 2 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/exceptions.py: -------------------------------------------------------------------------------- 1 | class PermissionError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = teamvault 4 | 5 | [report] 6 | omit = */migrations/* 7 | 8 | -------------------------------------------------------------------------------- /teamvault/apps/audit/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AuditConfig(AppConfig): 5 | name = 'teamvault.apps.audit' 6 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SecretsConfig(AppConfig): 5 | name = 'teamvault.apps.secrets' 6 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | name = 'teamvault.apps.accounts' 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/context_processors.py: -------------------------------------------------------------------------------- 1 | from teamvault.__version__ import __version__ 2 | 3 | 4 | def version(request): 5 | return {'version': __version__} 6 | -------------------------------------------------------------------------------- /teamvault/static/scss/card.scss: -------------------------------------------------------------------------------- 1 | // custom dark credit card background 2 | [data-bs-theme="dark"] .jp-card-front { 3 | background-color: $secondary !important; 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def google_auth_enabled(request): 5 | return {'google_auth_enabled': settings.GOOGLE_AUTH_ENABLED} 6 | -------------------------------------------------------------------------------- /teamvault/apps/audit/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = ( 6 | path( 7 | 'log/', 8 | views.auditlog, 9 | name='audit.log', 10 | ), 11 | ) 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.egg-info/ 3 | build/ 4 | dist/ 5 | huey.db 6 | node_modules/ 7 | teamvault.cfg 8 | teamvault/static/bundled/ 9 | teamvault/static_collected/ 10 | teamvault/webpack-stats.json 11 | package-lock.json 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": true, 5 | "module": "es6", 6 | "target": "es5", 7 | "jsx": "react", 8 | "allowJs": true, 9 | "moduleResolution": "node" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/syntax-dynamic-import"], 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "modules": false 8 | } 9 | ], 10 | [ 11 | "@babel/preset-typescript" 12 | ] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /teamvault/templates/helpers/filter_item.html: -------------------------------------------------------------------------------- 1 |
3 |
4 | {{ filter_item_name }} 5 |
6 |
7 | -------------------------------------------------------------------------------- /teamvault/wsgi.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | 6 | environ.setdefault("DJANGO_SETTINGS_MODULE", "teamvault.settings") 7 | environ.setdefault("TEAMVAULT_CONFIG_FILE", "/etc/teamvault.cfg") 8 | 9 | application = get_wsgi_application() 10 | -------------------------------------------------------------------------------- /teamvault/templates/rest_framework/api.html: -------------------------------------------------------------------------------- 1 | {% extends "rest_framework/base.html" %} 2 | {% load i18n %} 3 | {% block title %}TeamVault API{% endblock %} 4 | {% block branding %} 5 | 6 | TeamVault {{ version }} 7 | 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | mode: 'production', 6 | output: { 7 | publicPath: '/static/bundled/' 8 | }, 9 | optimization: { 10 | minimize: true, 11 | usedExports: true, 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /teamvault/templates/helpers/filter_row.html: -------------------------------------------------------------------------------- 1 |
2 |
{{ filter_name|capfirst }}:
3 | 4 | {% for field_item in filter_items %} 5 | {% include "helpers/filter_item.html" with filter_item_name=field_item %} 6 | {% endfor %} 7 |
8 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/opensearch.xml: -------------------------------------------------------------------------------- 1 | 2 | TeamVault 3 | UTF-8 4 | 5 | 6 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.utils.translation import gettext_lazy as _ 3 | from pyotp import TOTP 4 | 5 | 6 | def is_valid_otp_secret(value): 7 | try: 8 | TOTP(value).byte_secret() 9 | except Exception: 10 | raise ValidationError(_('OTP key has wrong format. Please enter a valid OTP key.')) 11 | -------------------------------------------------------------------------------- /teamvault/static/scss/fontawesome.scss: -------------------------------------------------------------------------------- 1 | $fa-font-path: '@fortawesome/fontawesome-free/webfonts'; 2 | @import '@fortawesome/fontawesome-free/scss/fontawesome'; 3 | @import "@fortawesome/fontawesome-free/scss/brands"; 4 | @import '@fortawesome/fontawesome-free/scss/solid'; 5 | @import '@fortawesome/fontawesome-free/scss/regular'; 6 | 7 | .list-group-item .fa { 8 | vertical-align: middle 9 | } 10 | -------------------------------------------------------------------------------- /teamvault/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | from os.path import dirname, join, realpath 5 | 6 | if __name__ == "__main__": 7 | sys.path.append(join(realpath(dirname(dirname(__file__))))) 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "teamvault.settings") 9 | 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0004_unaccent_extension.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.contrib.postgres.operations import UnaccentExtension 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ('secrets', '0003_auto_20150113_1915'), 11 | ] 12 | operations = [ 13 | UnaccentExtension(), 14 | ] 15 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | include LICENSE 3 | include MANIFEST.in 4 | include README.md 5 | include pyproject.toml 6 | 7 | recursive-include teamvault *.html 8 | recursive-include teamvault *.json 9 | recursive-include teamvault *.txt 10 | recursive-include teamvault *.xml 11 | recursive-include teamvault/static * 12 | recursive-exclude * *.py[co] 13 | recursive-exclude * .DS_Store 14 | recursive-exclude * __pycache__ 15 | recursive-exclude node_modules * 16 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0006_auto_20150124_1103.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('secrets', '0005_secret_search_index'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name='secret', 16 | options={'ordering': ('name', 'username')}, 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from teamvault.apps.accounts.models import UserProfile 4 | 5 | 6 | class UserProfileForm(forms.ModelForm): 7 | def __init__(self, *args, **kwargs): 8 | super().__init__(*args, **kwargs) 9 | self.fields['default_sharing_groups'].queryset = self.fields['default_sharing_groups'].queryset.order_by('name') 10 | 11 | class Meta: 12 | fields = ['default_sharing_groups', 'hide_deleted_secrets'] 13 | model = UserProfile 14 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/migrations/0003_usersettings_avatar.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-07-11 18:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0002_add_user_settings"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="usersettings", 14 | name="avatar", 15 | field=models.BinaryField(blank=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /teamvault/templates/404_anon.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load static %} 4 | {% block navbar %}{% endblock %} 5 | {% block title %}{% trans "Logout" %}{% endblock %} 6 | {% block content %} 7 |
8 |
9 | 10 | {% trans "Page not found." %}  11 | {% trans "Log in" %} 12 |
13 |
14 | {% endblock %} 15 | {% block footer %}{% endblock %} 16 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0011_remove_secret_search_index.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-09-30 10:41 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('secrets', '0009_auto_20150322_0949'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='secret', 17 | name='search_index', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0030_sharedsecretdata_granted_on.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-06-12 00:09 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("secrets", "0029_sharedsecretdata_granted_by"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="sharedsecretdata", 14 | name="granted_on", 15 | field=models.DateTimeField(auto_now_add=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/search/_search_item.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 |
7 | 8 |
9 | -------------------------------------------------------------------------------- /teamvault/static/scss/scrollbar.scss: -------------------------------------------------------------------------------- 1 | body, .modal-dialog-scrollable .modal-body { 2 | &::-webkit-scrollbar { 3 | width: 20px; 4 | } 5 | 6 | &::-webkit-scrollbar-track { 7 | background-color: transparent; 8 | } 9 | 10 | &::-webkit-scrollbar-thumb { 11 | background-color: var(--bs-secondary); 12 | border-radius: 20px; 13 | border: 8px solid transparent; 14 | background-clip: content-box; 15 | min-height: 50px; 16 | } 17 | 18 | &::-webkit-scrollbar-thumb:hover { 19 | background-color: var(--bs-gray-500); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0033_secretrevision_encrypted_otp_key.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.13 on 2024-07-15 11:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('secrets', '0032_alter_sharedsecretdata_granted_by'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='secretrevision', 15 | name='encrypted_otp_key', 16 | field=models.BinaryField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0002_secret_filename.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('secrets', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='secret', 16 | name='filename', 17 | field=models.CharField(blank=True, max_length=255, null=True), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0021_auto_20180220_1428.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-20 14:28 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('secrets', '0020_auto_20180220_1356'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='secret', 15 | name='owner_groups', 16 | ), 17 | migrations.RemoveField( 18 | model_name='secret', 19 | name='owner_users', 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /teamvault/apps/settings/webpack.py: -------------------------------------------------------------------------------- 1 | from os.path import join 2 | 3 | 4 | def configure_webpack(settings): 5 | settings.WEBPACK_LOADER = { 6 | 'DEFAULT': { 7 | 'CACHE': not settings.DEBUG, 8 | 'BUNDLE_DIR_NAME': 'bundled/', # must end with slash 9 | 'STATS_FILE': join(settings.PROJECT_ROOT, 'webpack-stats.json'), 10 | 'POLL_INTERVAL': 0.1, 11 | 'TIMEOUT': None, 12 | 'IGNORE': [r'.+\.hot-update.js', r'.+\.map'], 13 | 'LOADER_CLASS': 'webpack_loader.loader.WebpackLoader', 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0005_secret_search_index.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('secrets', '0004_unaccent_extension'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='secret', 16 | name='search_index', 17 | field=models.CharField(default="X", max_length=1), 18 | preserve_default=False, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-06-15 17:48 2 | 3 | from django.db import migrations 4 | 5 | 6 | def remove_inactivate_users_from_groups(apps, schema_editor): 7 | user_model = apps.get_model('auth', 'User') 8 | for user in user_model.objects.all().exclude(groups__isnull=False, is_active=True): 9 | user.groups.clear() 10 | 11 | 12 | class Migration(migrations.Migration): 13 | dependencies = [('auth', '__latest__')] 14 | 15 | operations = [ 16 | migrations.RunPython(remove_inactivate_users_from_groups), 17 | ] 18 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0015_secretrevision_plaintext_data_sha256.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-20 10:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('secrets', '0014_auto_20170313_1544'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='secretrevision', 15 | name='plaintext_data_sha256', 16 | field=models.CharField(default='', max_length=64), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/enums.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | class AccessPolicy(models.IntegerChoices): 5 | DISCOVERABLE = 1, _("discoverable") 6 | ANY = 2, _("everyone") 7 | HIDDEN = 3, _("hidden") 8 | 9 | 10 | class SecretStatus(models.IntegerChoices): 11 | OK = 1, _("OK") 12 | NEEDS_CHANGING = 2, _("needs changing") 13 | DELETED = 3, _("deleted") 14 | 15 | class ContentType(models.IntegerChoices): 16 | PASSWORD = 1, _("Password") 17 | CC = 2, _("Credit Card") 18 | FILE = 3, _("File") 19 | -------------------------------------------------------------------------------- /teamvault/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include 2 | from django.urls import path 3 | 4 | handler404 = 'teamvault.views.handler404' 5 | 6 | urlpatterns = ( 7 | path('api/', include('teamvault.apps.secrets.api.urls'), name='api'), 8 | path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), 9 | path('audit', include('teamvault.apps.audit.urls'), name='audit'), 10 | path('', include('teamvault.apps.secrets.urls'), name='secrets'), 11 | path('', include('teamvault.apps.accounts.urls'), name='accounts'), 12 | path('', include('social_django.urls', namespace='social')), 13 | ) 14 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0022_secret_last_changed.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2019-08-22 12:34 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('secrets', '0021_auto_20180220_1428'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='secret', 16 | name='last_changed', 17 | field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), 18 | preserve_default=False, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /teamvault/templates/404_loggedin.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static %} 3 | {% load i18n %} 4 | {% block title %}{% trans "Page not found" %}{% endblock %} 5 | {% block nav_search %}active{% endblock %} 6 | {% block content %} 7 |
8 |
9 |
10 |

{% trans "404 - Page Not Found" %}

11 |
12 |

{% trans "Sorry, the page you requested couldn't be found." %}

13 |

{% trans "If you expected a secret here, you may need to ask someone to grant you access." %}

14 |
15 |
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0018_auto_20180220_1244.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-20 12:44 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('secrets', '0017_auto_20180220_1115'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterUniqueTogether( 14 | name='secretrevision', 15 | unique_together={('plaintext_data_sha256', 'secret')}, 16 | ), 17 | migrations.RemoveField( 18 | model_name='secretrevision', 19 | name='encrypted_data_sha256', 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0007_auto_20150205_1918.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import teamvault.apps.secrets.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('secrets', '0006_auto_20150124_1103'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='secret', 17 | name='url', 18 | field=models.CharField(blank=True, null=True, validators=[teamvault.apps.secrets.models.validate_url], max_length=255), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/migrations/0005_rename_usersettings_userprofile.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-09-05 11:57 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 10 | ("auth", "0012_alter_user_first_name_max_length"), 11 | ("accounts", "0004_alter_usersettings_hide_deleted_secrets"), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameModel( 16 | old_name="UserSettings", 17 | new_name="UserProfile", 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0012_secret_search_index.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-09-30 10:51 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.postgres.search 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('secrets', '0011_remove_secret_search_index'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='secret', 18 | name='search_index', 19 | field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/migrations/0004_alter_usersettings_hide_deleted_secrets.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-08-29 12:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0003_usersettings_avatar"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="usersettings", 14 | name="hide_deleted_secrets", 15 | field=models.BooleanField( 16 | default=True, 17 | help_text="Hides deleted secrets per default. Enable them in filters to see them again.", 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /teamvault/apps/audit/auditlog.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | 3 | from .models import LogEntry 4 | 5 | AUDIT_LOG = getLogger(__name__) 6 | 7 | 8 | def log( 9 | msg, 10 | level='info', 11 | category=None, 12 | actor=None, 13 | reason=None, 14 | secret=None, 15 | secret_revision=None, 16 | group=None, 17 | user=None, 18 | ): 19 | getattr(AUDIT_LOG, level)(msg) 20 | entry = LogEntry() 21 | entry.message = msg 22 | entry.category = category 23 | entry.actor = actor 24 | entry.reason = reason 25 | entry.secret = secret 26 | entry.secret_revision = secret_revision 27 | entry.group = group 28 | entry.user = user 29 | entry.save() 30 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0019_secret_notify_on_access_request.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-20 13:56 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ('secrets', '0018_auto_20180220_1244'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='secret', 17 | name='notify_on_access_request', 18 | field=models.ManyToManyField(blank=True, related_name='notify_on_access_requests_for', to=settings.AUTH_USER_MODEL), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0034_remove_secretrevision_encrypted_otp_key_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.13 on 2024-07-22 14:06 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('secrets', '0033_secretrevision_encrypted_otp_key'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='secretrevision', 15 | name='encrypted_otp_key', 16 | ), 17 | migrations.AddField( 18 | model_name='secretrevision', 19 | name='encrypted_otp_key_data', 20 | field=models.BinaryField(blank=True, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0035_remove_secretrevision_encrypted_otp_key_data_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.13 on 2024-08-08 14:07 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('secrets', '0034_remove_secretrevision_encrypted_otp_key_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='secretrevision', 15 | name='encrypted_otp_key_data', 16 | ), 17 | migrations.AddField( 18 | model_name='secretrevision', 19 | name='otp_key_set', 20 | field=models.BooleanField(default=False), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0028_sharedsecretdata_grant_description_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-06-11 22:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("secrets", "0027_sharedsecretdata_only_one_set"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="sharedsecretdata", 14 | name="grant_description", 15 | field=models.TextField(null=True), 16 | ), 17 | migrations.AddField( 18 | model_name="sharedsecretdata", 19 | name="granted_until", 20 | field=models.DateTimeField(blank=True, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /teamvault/apps/settings/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Setting', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), 17 | ('key', models.CharField(unique=True, max_length=64)), 18 | ('value', models.CharField(max_length=255)), 19 | ], 20 | options={ 21 | 'ordering': ('key',), 22 | }, 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const {merge} = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const path = require("path"); 4 | 5 | module.exports = merge(common, { 6 | mode: 'development', 7 | output: { 8 | publicPath: 'http://localhost:3000/dist/', 9 | }, 10 | optimization: { 11 | minimize: false, 12 | usedExports: false, 13 | }, 14 | devServer: { 15 | static: path.resolve('./teamvault/static/bundled/'), 16 | hot: true, 17 | port: 3000, 18 | headers: { 19 | "Access-Control-Allow-Origin": "*", 20 | } 21 | }, 22 | 23 | // Temporary fix until https://github.com/twbs/bootstrap/pull/39030 is merged 24 | ignoreWarnings: [{ 25 | 'message': /Deprecation Passing percentage units to the global abs/, 26 | }] 27 | }); 28 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0024_auto_20210824_1203.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-24 12:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('secrets', '0023_auto_20190822_1234'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='secret', 15 | name='notify_on_access_request', 16 | ), 17 | migrations.AlterField( 18 | model_name='secret', 19 | name='access_policy', 20 | field=models.PositiveSmallIntegerField(choices=[(1, 'discoverable'), (2, 'everyone'), (3, 'hidden')], default=1), 21 | ), 22 | migrations.DeleteModel( 23 | name='AccessRequest', 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0003_auto_20150113_1915.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('secrets', '0002_secret_filename'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='secretrevision', 16 | name='encrypted_data_sha256', 17 | field=models.CharField(default="0000000000000000000000000000000000000000000000000000000000000000", max_length=64), 18 | preserve_default=False, 19 | ), 20 | migrations.AlterUniqueTogether( 21 | name='secretrevision', 22 | unique_together=set([('encrypted_data_sha256', 'secret')]), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0027_sharedsecretdata_only_one_set.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-06-11 22:41 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("secrets", "0026_allowed_groups_users_intermediate"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddConstraint( 13 | model_name="sharedsecretdata", 14 | constraint=models.CheckConstraint( 15 | check=models.Q( 16 | models.Q(("group__isnull", False), ("user__isnull", True)), 17 | models.Q(("group__isnull", True), ("user__isnull", False)), 18 | _connector="OR", 19 | ), 20 | name="only_one_set", 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /teamvault/static/scss/circularProgressbar.scss: -------------------------------------------------------------------------------- 1 | $circle_size: 88; 2 | $max_progress: 30; 3 | 4 | 5 | 6 | #countdown { 7 | --progress: $max_progress; 8 | position: relative; 9 | height: 40px; 10 | width: 40px; 11 | text-align: center; 12 | } 13 | 14 | #countdown-number { 15 | font-family: monospace; 16 | line-height: 40px; 17 | display: inline-block; 18 | font-size: small; 19 | margin-left: -1px; 20 | } 21 | 22 | #countdown svg { 23 | position: absolute; 24 | top: 0; 25 | right: 0; 26 | width: 40px; 27 | height: 40px; 28 | transform: rotateY(-180deg) rotateZ(-90deg); 29 | } 30 | 31 | svg circle { 32 | stroke-linecap: round; 33 | stroke-width: 2px; 34 | stroke: rgb(min(255, 255 - ((var(--progress) * (10/3)) * 2.55)), 35 | max(0, ((var(--progress) * (10/3))*2.55)), 36 | 0); 37 | fill: none; 38 | } 39 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0023_auto_20190822_1234.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2019-08-22 12:34 2 | 3 | from django.db import migrations 4 | 5 | 6 | def backfill_last_changed(apps, schema_editor): 7 | # We can't import the Person model directly as it may be a newer 8 | # version than this migration expects. We use the historical version. 9 | Secret = apps.get_model('secrets', 'Secret') 10 | for secret in Secret.objects.all(): 11 | if secret.current_revision: # leftovers from a bug 12 | secret.last_changed = secret.current_revision.created 13 | secret.save() 14 | 15 | 16 | class Migration(migrations.Migration): 17 | 18 | dependencies = [ 19 | ('secrets', '0022_secret_last_changed'), 20 | ] 21 | 22 | operations = [ 23 | migrations.RunPython(backfill_last_changed), 24 | ] 25 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/templates/accounts/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load static %} 4 | {% block navbar %}{% endblock %} 5 | {% block title %}{% trans "Logout" %}{% endblock %} 6 | 7 | {% block super_content %} 8 |
9 |
10 |
11 | TeamVault 12 |
13 |
14 | {% trans "Logged out." %} 15 |
16 | {% trans "Log in again?" %} 17 |
18 |
19 | {% endblock %} 20 | {% block footer %}{% endblock %} 21 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0032_alter_sharedsecretdata_granted_by.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-09-05 10:20 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("secrets", "0031_rename_share_data_fields"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="sharedsecretdata", 17 | name="granted_by", 18 | field=models.ForeignKey( 19 | null=True, 20 | on_delete=django.db.models.deletion.PROTECT, 21 | related_name="+", 22 | to=settings.AUTH_USER_MODEL, 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/management/commands/update_search_index.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.search import SearchVector 2 | from django.core.management.base import BaseCommand 3 | 4 | from ...models import Secret 5 | 6 | 7 | class Command(BaseCommand): 8 | help = 'Update search index' 9 | 10 | def handle(self, *args, **options): 11 | secrets_total = Secret.objects.count() 12 | Secret.objects.all().update( 13 | search_index=( 14 | SearchVector('name', weight='A') + 15 | SearchVector('description', weight='B') + 16 | SearchVector('username', weight='C') + 17 | SearchVector('filename', weight='D') 18 | ) 19 | ) 20 | self.stdout.write(self.style.SUCCESS( 21 | "Finished updating search index for {} objects.".format(secrets_total) 22 | )) 23 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0029_sharedsecretdata_granted_by.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-06-11 23:18 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("secrets", "0028_sharedsecretdata_grant_description_and_more"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="sharedsecretdata", 17 | name="granted_by", 18 | field=models.ForeignKey( 19 | null=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | related_name="+", 22 | to=settings.AUTH_USER_MODEL, 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0025_alter_secret_description_alter_secret_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-06-11 16:50 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("secrets", "0024_auto_20210824_1203"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="secret", 14 | name="description", 15 | field=models.TextField( 16 | blank=True, help_text="Further information on the secret.", null=True 17 | ), 18 | ), 19 | migrations.AlterField( 20 | model_name="secret", 21 | name="name", 22 | field=models.CharField( 23 | help_text="Enter a unique name for the secret", max_length=92 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0020_auto_20180220_1356.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-20 13:56 2 | 3 | from django.db import migrations 4 | 5 | 6 | def copy_owner_data(apps, schema_editor): 7 | Secret = apps.get_model('secrets', 'Secret') 8 | for secret in Secret.objects.all(): 9 | secret.allowed_groups.add(*list(secret.owner_groups.all())) 10 | secret.allowed_users.add(*list(secret.owner_users.all())) 11 | secret.notify_on_access_request.add(*list(secret.owner_users.all())) 12 | for group in secret.owner_groups.all(): 13 | secret.notify_on_access_request.add(*list(group.user_set.all())) 14 | 15 | 16 | class Migration(migrations.Migration): 17 | 18 | dependencies = [ 19 | ('secrets', '0019_secret_notify_on_access_request'), 20 | ] 21 | 22 | operations = [ 23 | migrations.RunPython(copy_owner_data), 24 | ] 25 | -------------------------------------------------------------------------------- /teamvault/static/scss/tom-select.scss: -------------------------------------------------------------------------------- 1 | .usersearch { 2 | position: relative; 3 | cursor: text !important; 4 | 5 | .search-icon, 6 | .ts-control input { 7 | cursor: text !important; 8 | } 9 | } 10 | 11 | .ts-dropdown { 12 | .active { 13 | background-color: var(--bs-secondary-bg); 14 | } 15 | 16 | .no-results { 17 | color: var(--bs-body-color, #e5e7eb); 18 | float: left; 19 | } 20 | 21 | .spinner { 22 | float: left; 23 | margin-left: 1em; 24 | } 25 | } 26 | 27 | .ts-wrapper { 28 | 29 | &.form-control { 30 | border: 0 !important; 31 | cursor: text !important; 32 | 33 | .ts-control { 34 | cursor: text !important; 35 | box-shadow: none !important; 36 | 37 | input, 38 | .dropdown-input { 39 | color: var(--bs-body-color) !important; 40 | } 41 | } 42 | } 43 | } 44 | 45 | .ts-option-name-field { 46 | color: var(--bs-body-color); 47 | } 48 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0008_auto_20150322_0944.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('secrets', '0007_auto_20150205_1918'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='accessrequest', 16 | name='hashid', 17 | field=models.CharField(unique=True, max_length=24, null=True), 18 | ), 19 | migrations.AddField( 20 | model_name='secret', 21 | name='hashid', 22 | field=models.CharField(unique=True, max_length=24, null=True), 23 | ), 24 | migrations.AddField( 25 | model_name='secretrevision', 26 | name='hashid', 27 | field=models.CharField(unique=True, max_length=24, null=True), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /teamvault/static/scss/select2.scss: -------------------------------------------------------------------------------- 1 | @import "select2/src/scss/core.scss"; 2 | 3 | /* See https://github.com/apalfrey/select2-bootstrap-5-theme/issues/75 */ 4 | $s2bs5-border-color: $border-color; 5 | @import "select2-bootstrap-5-theme/src/include-all"; 6 | 7 | /* Hide selected options in choices */ 8 | .select2-results__option[aria-selected="true"] { 9 | display: none; 10 | } 11 | 12 | .select2-container--bootstrap-5 { 13 | .select2-selection { 14 | // Fix misaligned clear button 15 | &--single .select2-selection__clear { 16 | position: absolute; 17 | } 18 | 19 | &__choice__remove { 20 | cursor: pointer; 21 | } 22 | 23 | &.select2-selection--multiple { 24 | padding: 1rem; 25 | 26 | .select2-selection__rendered { 27 | display: flex; 28 | } 29 | } 30 | } 31 | &.select2-container--open .select2-selection { 32 | box-shadow: none; 33 | } 34 | 35 | .select2-results__option--highlighted { 36 | filter: brightness(0.8); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0040_secretchange_scrubbed_fields.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('secrets', '0039_migrate_old_file_saves_into_new_format'), 9 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='secretchange', 15 | name='scrubbed_at', 16 | field=models.DateTimeField(blank=True, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='secretchange', 20 | name='scrubbed_by', 21 | field=models.ForeignKey( 22 | blank=True, 23 | null=True, 24 | on_delete=models.SET_NULL, 25 | related_name='scrubbed_secret_changes', 26 | to=settings.AUTH_USER_MODEL, 27 | ), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /teamvault/apps/settings/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SettingsConfig(AppConfig): 5 | name = 'teamvault.apps.settings' 6 | 7 | def ready(self): 8 | from django.conf import settings 9 | from . import config, webpack 10 | parsed_config = config.get_config() 11 | config.configure_base_url(parsed_config, settings) 12 | config.configure_debugging(parsed_config, settings) 13 | config.configure_ldap_auth(parsed_config, settings) 14 | config.configure_google_auth(parsed_config, settings) 15 | config.configure_max_file_size(parsed_config, settings) 16 | config.configure_password_generator(parsed_config, settings) 17 | config.configure_superuser_reads(parsed_config, settings) 18 | config.configure_teamvault_secret_key(parsed_config, settings) 19 | config.configure_password_update_alert(parsed_config, settings) 20 | config.configure_whitenoise(settings) 21 | webpack.configure_webpack(settings) 22 | -------------------------------------------------------------------------------- /teamvault/apps/settings/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class Setting(models.Model): 6 | key = models.CharField( 7 | max_length=64, 8 | unique=True, 9 | ) 10 | value = models.CharField( 11 | max_length=255, 12 | ) 13 | 14 | class Meta: 15 | ordering = ('key',) 16 | 17 | @classmethod 18 | def get(cls, key, **kwargs): 19 | try: 20 | return cls.objects.get(key=key).value 21 | except cls.DoesNotExist: 22 | try: 23 | return kwargs['default'] 24 | except KeyError: 25 | raise KeyError(_("value for '{}' not set").format(key)) 26 | 27 | @classmethod 28 | def set(cls, key, value): 29 | try: 30 | setting = cls.objects.get(key=key) 31 | except cls.DoesNotExist: 32 | setting = cls() 33 | setting.key = key 34 | setting.value = value 35 | setting.save() 36 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0013_auto_20161021_1411.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.2 on 2016-10-21 14:11 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ('auth', '0008_alter_user_username_max_length'), 14 | ('secrets', '0012_secret_search_index'), 15 | ] 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name='secret', 20 | name='owner_groups', 21 | field=models.ManyToManyField(blank=True, related_name='owned_passwords', to='auth.Group'), 22 | ), 23 | migrations.AddField( 24 | model_name='secret', 25 | name='owner_users', 26 | field=models.ManyToManyField(blank=True, related_name='owned_passwords', to=settings.AUTH_USER_MODEL), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /teamvault/static/scss/theme.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | 3 | @import "bootstrap/scss/functions"; 4 | @import "bootstrap/scss/variables"; 5 | 6 | // disable RFS for more consistent styling 7 | $enable-rfs: false; 8 | 9 | $accent: #f8592c; 10 | $danger: #ff3333; 11 | $custom-dark: #2f2d35; 12 | $info: #289cdd; 13 | $main: #1a1c22; 14 | $success: #13ca5c; 15 | $tertiary: #424242; 16 | $warning: #ffbf3d; 17 | 18 | $custom-colors: ( 19 | "accent": $accent, 20 | "danger": $danger, 21 | "custom-dark": $custom-dark, 22 | "info": $info, 23 | "success": $success, 24 | "tertiary": $tertiary, 25 | "warning": $warning, 26 | ); 27 | 28 | // Merge the maps 29 | $theme-colors: map.merge($theme-colors, $custom-colors); 30 | 31 | $alert-border-width: 0; 32 | $badge-font-weight: 400; 33 | @import "bootstrap/scss/bootstrap"; 34 | 35 | :root { 36 | --tv-color-secondary-txt: rgb(130, 129, 136); 37 | } 38 | 39 | .bg-gray-200 { 40 | background-color: $gray-200; 41 | } 42 | 43 | .fs-xs { 44 | font-size: 0.75rem; 45 | } 46 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0016_auto_20180220_1053.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-20 10:53 2 | from hashlib import sha256 3 | 4 | from cryptography.fernet import Fernet 5 | from django.conf import settings 6 | from django.db import migrations 7 | 8 | 9 | def backfill_plaintext_hashes(apps, schema_editor): 10 | SecretRevision = apps.get_model('secrets', 'SecretRevision') 11 | f = Fernet(settings.TEAMVAULT_SECRET_KEY) 12 | for srev in SecretRevision.objects.all(): 13 | encrypted_data = srev.encrypted_data 14 | if isinstance(encrypted_data, memoryview): # backwards compatibility with psycopg2 15 | encrypted_data = encrypted_data.tobytes() 16 | plaintext_data = f.decrypt(encrypted_data) 17 | srev.plaintext_data_sha256 = sha256(plaintext_data).hexdigest() 18 | srev.save() 19 | 20 | 21 | class Migration(migrations.Migration): 22 | 23 | dependencies = [ 24 | ('secrets', '0015_secretrevision_plaintext_data_sha256'), 25 | ] 26 | 27 | operations = [ 28 | migrations.RunPython(backfill_plaintext_hashes), 29 | ] 30 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/detail_content/file.html: -------------------------------------------------------------------------------- 1 | {% extends 'secrets/secret_detail.html' %} 2 | {% load i18n %} 3 | 4 | {% block secret_content %} 5 |
6 | 8 |
9 | 10 |  {% trans "Download" %}   11 | 12 |
13 | {% endblock %} 14 | 15 | {% block secret_attributes %} 16 | {% if secret.description %} 17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 |
{% trans "Description" %}{{ secret.description|linebreaksbr|urlize }}
25 |
26 | {% endif %} 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0036_alter_secret_shared_groups_alter_secret_shared_users.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.1 on 2025-06-02 10:22 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('auth', '0012_alter_user_first_name_max_length'), 11 | ('secrets', '0035_remove_secretrevision_encrypted_otp_key_data_and_more'), 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='secret', 18 | name='shared_groups', 19 | field=models.ManyToManyField(blank=True, through='secrets.SharedSecretData', through_fields=('secret', 'group'), to='auth.group'), 20 | ), 21 | migrations.AlterField( 22 | model_name='secret', 23 | name='shared_users', 24 | field=models.ManyToManyField(blank=True, through='secrets.SharedSecretData', through_fields=('secret', 'user'), to=settings.AUTH_USER_MODEL), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /teamvault/apps/audit/filters.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from django import forms 3 | from django.contrib.auth.models import User 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from .models import LogEntry, AuditLogCategoryChoices 7 | from ..secrets.models import Secret 8 | 9 | 10 | class AuditLogFilter(django_filters.FilterSet): 11 | actor = django_filters.ModelChoiceFilter( 12 | to_field_name='username', 13 | queryset=User.objects.all().order_by('username'), 14 | ) 15 | category = django_filters.MultipleChoiceFilter( 16 | choices=AuditLogCategoryChoices.choices, 17 | label=_('Categories'), 18 | widget=forms.CheckboxSelectMultiple(), 19 | ) 20 | secret = django_filters.ModelChoiceFilter( 21 | field_name='secret', 22 | to_field_name='hashid', 23 | queryset=Secret.objects.all(), 24 | ) 25 | user = django_filters.ModelChoiceFilter( 26 | to_field_name='username', 27 | queryset=User.objects.all().order_by('username'), 28 | ) 29 | 30 | class Meta: 31 | model = LogEntry 32 | fields = ['secret', 'actor', 'user'] 33 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/templates/accounts/_avatar.html: -------------------------------------------------------------------------------- 1 | {% if user.profile.avatar %} 2 | {{ user.username }} avatar 10 | {% elif user.first_name and user.last_name %} 11 | 18 | 19 | {{ user.first_name.0.capitalize }}{{ user.last_name.0.capitalize }} 20 | 21 | 22 | {% else %} 23 |
24 | 25 |
26 | {% endif %} 27 | -------------------------------------------------------------------------------- /teamvault/static/scss/avatars.scss: -------------------------------------------------------------------------------- 1 | .avatar { 2 | --tv-avatar-border-radius: 100%; 3 | --tv-avatar-opacity: 100%; 4 | --tv-avatar-size-default: 2rem; 5 | --tv-avatar-size-sm: 1.25rem; 6 | --tv-avatar-size-lg: 2.5rem; 7 | --tv-avatar-size: var(--tv-avatar-size-default); 8 | 9 | border-color: var(--bs-border-color-translucent); 10 | border-radius: var(--tv-avatar-border-radius); 11 | opacity: var(--tv-avatar-opacity); 12 | height: var(--tv-avatar-size); 13 | width: var(--tv-avatar-size); 14 | 15 | &.avatar-sm { 16 | --tv-avatar-size: var(--tv-avatar-size-sm); 17 | } 18 | 19 | &.avatar-lg { 20 | --tv-avatar-size: var(--tv-avatar-size-lg); 21 | } 22 | } 23 | 24 | svg.avatar { 25 | background-color: var(--bs-gray-500); 26 | font-size: calc(var(--tv-avatar-size) * 0.4); 27 | 28 | /* revert bootstrap default positioning & transform */ 29 | position: revert; 30 | transform: none; 31 | } 32 | 33 | a { 34 | &:hover, &:focus { 35 | > .avatar { 36 | border-style: solid; 37 | border-width: 1px; 38 | } 39 | } 40 | 41 | &:hover > .avatar { 42 | --tv-avatar-opacity: 80% !important; 43 | } 44 | 45 | &:focus > .avatar { 46 | --tv-avatar-opacity: 90%; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Group, User 2 | from django.db import models 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class UserProfile(models.Model): 7 | # Since our static files are not served by some webserver but by TeamVault (/Whitenoise) directly 8 | # to keep the installation overhead low, we'd have to do the same thing with media files. 9 | # Static files will get replaced with each teamvault deployment, media files should not. 10 | # Because of that, we'd have to make admins configure a persistent directory for them. 11 | # For now, that trade-off is not worth it, so let's store avatars as binary data, instead. 12 | avatar = models.BinaryField(blank=True, null=True) 13 | default_sharing_groups = models.ManyToManyField( 14 | Group, 15 | blank=True, 16 | help_text=_('New secrets created by you will be shared with these groups.'), 17 | related_name='+', 18 | ) 19 | hide_deleted_secrets = models.BooleanField( 20 | default=True, 21 | help_text=_('Hides deleted secrets per default. Enable them in filters to see them again.') 22 | ) 23 | user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') 24 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templatetags/smart_pagination.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | from django import template 4 | 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.simple_tag() 10 | def querystring(request, **kwargs): 11 | """ 12 | Append or update params in a querystring. 13 | """ 14 | querydict = request.GET.copy() 15 | for k, v in kwargs.items(): 16 | if v is not None: 17 | querydict[k] = str(v) 18 | elif k in querydict: 19 | querydict.pop(k) 20 | qs = querydict.urlencode(safe='/') 21 | if qs: 22 | return '?' + qs 23 | else: 24 | return '' 25 | 26 | 27 | @register.filter 28 | def smart_pages(all_pages, current_page): 29 | all_pages = list(all_pages) 30 | smart_pages = set([ 31 | 1, 32 | all_pages[-1], 33 | current_page, 34 | max(min(current_page // 2, all_pages[-1]), 1), 35 | max(min(current_page + ((all_pages[-1] - current_page) // 2), all_pages[-1]), 1), 36 | max(min(current_page + 1, all_pages[-1]), 1), 37 | max(min(current_page + 2, all_pages[-1]), 1), 38 | max(min(current_page - 1, all_pages[-1]), 1), 39 | max(min(current_page - 2, all_pages[-1]), 1), 40 | ]) 41 | return sorted(smart_pages) 42 | -------------------------------------------------------------------------------- /teamvault/static/js/utils.js: -------------------------------------------------------------------------------- 1 | export function scrollIfNeeded(el, containerEl) { 2 | const rect = el.getBoundingClientRect() 3 | const containerRect = containerEl.getBoundingClientRect() 4 | if (rect.top < containerRect.top) { 5 | if (!(el.previousElementSibling)) { 6 | el.scrollIntoView({block: 'end'}) 7 | } else { 8 | el.scrollIntoView({block: 'start'}) 9 | } 10 | } else if (rect.bottom > containerRect.bottom) { 11 | if (!(el.nextElementSibling)) { 12 | el.scrollIntoView({block: 'start'}) 13 | } else { 14 | el.scrollIntoView({block: 'end'}) 15 | } 16 | } 17 | } 18 | 19 | /** 20 | * @param {string} password 21 | * @returns {HTMLSpanElement[]} div containing the colored password 22 | */ 23 | export const getColorfulPasswordHTML = (password) => 24 | password.split("").map(character => { 25 | const newSpan = document.createElement("span"); 26 | newSpan.innerText = character; 27 | 28 | if ("a" <= character && character <= "z") newSpan.classList.add("pw-lowercase"); 29 | else if ("A" <= character && character <= "Z") newSpan.classList.add("pw-uppercase"); 30 | else if ("0" <= character && character <= "9") newSpan.classList.add("pw-number"); 31 | else newSpan.classList.add("pw-symbol"); 32 | 33 | return newSpan; 34 | }); 35 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/templates/accounts/user_settings.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load django_bootstrap5 %} 3 | {% load i18n %} 4 | {% block title %}{% translate "Settings" %}{% endblock %} 5 | {% block content %} 6 |
7 |

{% translate "Settings" %}

8 |
9 |
10 |
11 | {% csrf_token %} 12 |
13 |
14 | {% bootstrap_field form.default_sharing_groups label_class="h5" %} 15 |
16 |
17 | {% bootstrap_field form.hide_deleted_secrets checkbox_style="switch" %} 18 | 21 |
22 |
23 |
24 |
25 | {% endblock %} 26 | {% block additionalJS %} 27 | 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /teamvault/middleware.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.contrib.messages.storage.base import BaseStorage, Message 3 | from django_htmx.http import trigger_client_event 4 | 5 | 6 | def htmx_message_middleware(get_response): 7 | # One-time configuration and initialization. 8 | 9 | def middleware(request): 10 | # Code to be executed for each request before 11 | # the view (and later middleware) are called. 12 | response = get_response(request) 13 | 14 | # Ignore non-HTMX requests 15 | if "HX-Request" not in request.headers: 16 | return response 17 | 18 | # HTMX will not read HX headers in redirects but the subsequent GET response. 19 | if 300 <= response.status_code < 400: 20 | return response 21 | 22 | storage: BaseStorage = messages.get_messages(request) 23 | msg_list = [] 24 | for msg in storage: 25 | msg: Message 26 | msg_list.append({ 27 | 'message': msg.message, 28 | # debug|info|success|warning|error 29 | 'level': msg.level_tag, 30 | }) 31 | 32 | trigger_client_event(response, 'django.contrib.messages', {'message_list': msg_list}) 33 | return response 34 | 35 | return middleware 36 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0037_change_secretrevision_plaintextdata_key_of_password_type.py: -------------------------------------------------------------------------------- 1 | from json import dumps, loads 2 | 3 | from cryptography.fernet import Fernet 4 | from django.db import migrations 5 | from django.conf import settings 6 | 7 | 8 | def change_plaintextdata_key(apps, schema_editor): 9 | Secret = apps.get_model('secrets', 'Secret') 10 | password_secrets = Secret.objects.filter(secretrevision__otp_key_set=True) 11 | f = Fernet(settings.TEAMVAULT_SECRET_KEY) 12 | for secret in password_secrets: 13 | plaintext_data = secret.current_revision.encrypted_data 14 | plaintext_data = f.decrypt(plaintext_data).decode("utf-8") 15 | plaintext_data = loads(plaintext_data) 16 | if 'secret' in plaintext_data: 17 | plaintext_data['otp_key'] = plaintext_data.pop('secret') 18 | plaintext_data = dumps(plaintext_data).encode("utf-8") 19 | secret.current_revision.encrypted_data = f.encrypt(plaintext_data) 20 | secret.current_revision.save() 21 | 22 | 23 | class Migration(migrations.Migration): 24 | dependencies = [ 25 | ("secrets", "0036_alter_secret_shared_groups_alter_secret_shared_users"), 26 | ] 27 | 28 | operations = [ 29 | migrations.RunPython(change_plaintextdata_key, reverse_code=migrations.RunPython.noop), 30 | ] 31 | 32 | 33 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import SecretDetail, SecretList, SecretRevisionDetail, SecretShare, SecretShareDetail, data_get, \ 4 | generate_password_view, otp_get 5 | 6 | urlpatterns = ( 7 | path( 8 | 'secrets/', 9 | SecretList.as_view(), 10 | name='api.secret_list', 11 | ), 12 | path( 13 | 'secrets//', 14 | SecretDetail.as_view(), 15 | name='api.secret_detail', 16 | ), 17 | path( 18 | 'secrets//shares/', 19 | SecretShare.as_view(), 20 | name='api.secret_share', 21 | ), 22 | path( 23 | 'secrets//shares/', 24 | SecretShareDetail.as_view(), 25 | name='api.secret_share_detail', 26 | ), 27 | path( 28 | 'secret-revisions//', 29 | SecretRevisionDetail.as_view(), 30 | name='api.secret-revision_detail', 31 | ), 32 | path( 33 | 'secret-revisions//data', 34 | data_get, 35 | name='api.secret-revision_data', 36 | ), 37 | path( 38 | 'secret-revisions//data/otp', 39 | otp_get, 40 | name='api.secret-revision_otp', 41 | ), 42 | path( 43 | 'generate_password/', 44 | generate_password_view, 45 | name='api.generate-password', 46 | ) 47 | ) 48 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/auth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from django_auth_ldap.backend import LDAPBackend, _LDAPUser 5 | from django_auth_ldap.config import LDAPSearch 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def find_ldap_username_for_social_auth(details, *_args, **kwargs): 12 | if not kwargs.get('is_new'): 13 | return {} 14 | 15 | connection = _LDAPUser(LDAPBackend(), username='').connection 16 | ldap_mail_attribute = settings.AUTH_LDAP_USER_ATTR_MAP['email'] 17 | social_auth_mail_value = details['email'] 18 | logger.info(f'Trying to find LDAP username for social auth user {social_auth_mail_value}...') 19 | search = LDAPSearch( 20 | settings.AUTH_LDAP_USER_SEARCH.base_dn, 21 | settings.AUTH_LDAP_USER_SEARCH.scope, 22 | f'({ldap_mail_attribute}={social_auth_mail_value})', 23 | ['uid'] 24 | ) 25 | results = search.execute(connection) 26 | if results is not None and len(results) > 0: 27 | uid = results[0][1]['uid'][0] 28 | logger.info(f'Found LDAP username for social auth user {social_auth_mail_value}: {uid}') 29 | return {'username': uid} 30 | logger.info(f'No LDAP username found for social auth user {social_auth_mail_value}') 31 | return {} 32 | 33 | 34 | def populate_from_ldap(*_args, **kwargs): 35 | LDAPBackend().populate_user(kwargs['user'].username) 36 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/secret_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load static %} 4 | {% block title %}{% trans "Confirm deletion" %}{% endblock %} 5 | {% block content %} 6 |
7 |
8 |

9 | {% blocktrans with name=secret.name content_type=secret.get_content_type_display %} 10 | Delete {{ content_type }} '{{ name }}'? 11 | {% endblocktrans %} 12 |

13 |
14 |
15 |
16 |
17 |
18 | 23 |
24 | {% csrf_token %} 25 |   {% trans "No, go back" %} 26 | 27 |
28 |
29 |
30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/tests/models/test_model_consistency.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User, Group 2 | from django.db import IntegrityError 3 | from django.test import TestCase 4 | 5 | from teamvault.apps.secrets.models import Secret, SharedSecretData 6 | 7 | 8 | class SecretConsistencyTestCase(TestCase): 9 | def setUp(self): 10 | User.objects.create(username='testuser') 11 | Group.objects.create(name='testgroup') 12 | 13 | def test_secret_unique_together(self): 14 | user = User.objects.get(username='testuser') 15 | group = Group.objects.get(name='testgroup') 16 | 17 | secret = Secret.objects.create(name="testsecret", created_by=user) 18 | secret.shared_users.add(user, through_defaults={}) 19 | secret.shared_groups.add(group, through_defaults={}) 20 | 21 | with self.assertRaises(IntegrityError): 22 | SharedSecretData.objects.create(secret=secret, user=user) 23 | SharedSecretData.objects.create(secret=secret, group=group) 24 | 25 | def test_shared_secret_data_only_one_constraint(self): 26 | user = User.objects.get(username='testuser') 27 | group = Group.objects.get(name='testgroup') 28 | secret = Secret.objects.create(name="testsecret", created_by=user) 29 | 30 | with self.assertRaises(IntegrityError): 31 | SharedSecretData.objects.create(secret=secret, group=group, user=user) 32 | -------------------------------------------------------------------------------- /teamvault/apps/audit/migrations/0009_alter_logentry_category.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.1 on 2025-09-15 11:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('audit', '0008_logentry_logentry_category_idx_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='logentry', 15 | name='category', 16 | field=models.CharField(choices=[('secret_read', 'secret_read'), ('secret_elevated_superuser_read', 'secret_elevated_superuser_read'), ('secret_permission_violation', 'secret_permission_violation'), ('secret_changed', 'secret_changed'), ('secret_metadata_changed', 'secret_metadata_changed'), ('secret_restored', 'secret_restored'), ('secret_needs_changing_reminder', 'secret_needs_changing_reminder'), ('secret_shared', 'secret_shared'), ('secret_superuser_shared', 'secret_superuser_shared'), ('secret_share_removed', 'secret_share_removed'), ('secret_superuser_share_removed', 'secret_superuser_share_removed'), ('secret_legacy_access_requests', 'secret_legacy_access_requests'), ('user_activated', 'user_activated'), ('user_deactivated', 'user_deactivated'), ('user_settings_changed', 'user_settings_changed'), ('share_automatically_revoked', 'share_automatically_revoked'), ('miscellaneous', 'miscellaneous')], default='miscellaneous', max_length=64), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/tests/models/test_constraints.py: -------------------------------------------------------------------------------- 1 | from django.db import IntegrityError 2 | from django.test import TestCase 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.models import Group 5 | 6 | from teamvault.apps.secrets.models import SharedSecretData 7 | from ..utils import make_user, new_secret 8 | User = get_user_model() 9 | 10 | class ShareConstraintsTests(TestCase): 11 | def setUp(self): 12 | self.owner = make_user("owner") 13 | self.u = make_user("other") 14 | self.g = Group.objects.create(name="g") 15 | self.s = new_secret(self.owner, share_with_owner=False) 16 | 17 | def test_only_one_of_user_or_group(self): 18 | with self.assertRaises(IntegrityError): 19 | SharedSecretData.objects.create( 20 | secret=self.s, user=self.u, group=self.g, granted_by=self.owner 21 | ) 22 | 23 | def test_unique_user_secret_pair(self): 24 | SharedSecretData.objects.create(secret=self.s, user=self.u, granted_by=self.owner) 25 | with self.assertRaises(IntegrityError): 26 | SharedSecretData.objects.create(secret=self.s, user=self.u, granted_by=self.owner) 27 | 28 | def test_unique_group_secret_pair(self): 29 | SharedSecretData.objects.create(secret=self.s, group=self.g, granted_by=self.owner) 30 | with self.assertRaises(IntegrityError): 31 | SharedSecretData.objects.create(secret=self.s, group=self.g, granted_by=self.owner) 32 | -------------------------------------------------------------------------------- /teamvault/apps/audit/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import user_passes_test 2 | from django.db.models import Q 3 | from django.views.generic import ListView 4 | 5 | from .filters import AuditLogFilter 6 | from .models import LogEntry 7 | from ..secrets.models import Secret 8 | from ...views import FilterMixin 9 | 10 | 11 | class LogEntryList(ListView, FilterMixin): 12 | filter = None 13 | filter_class = AuditLogFilter 14 | context_object_name = 'log_entries' 15 | paginate_by = 25 16 | template_name = "audit/log.html" 17 | 18 | def get_queryset(self): 19 | queryset = LogEntry.objects.all() 20 | if "search" in self.request.GET: 21 | query = self.request.GET['search'] 22 | queryset = queryset.filter( 23 | Q(actor__icontains=query) | 24 | Q(message__icontains=query) 25 | ) 26 | 27 | return self.get_filtered_queryset(queryset) 28 | 29 | @staticmethod 30 | def manipulate_filter_form(bound_data, filter_form): 31 | # Set queryset since we'll retrieve choices via ajax and need to show the initial one 32 | if bound_data.get('secret'): 33 | secret_choices = Secret.objects.filter(pk=bound_data['secret'].pk) 34 | else: 35 | secret_choices = Secret.objects.none() 36 | filter_form.fields['secret'].queryset = secret_choices 37 | return filter_form 38 | 39 | 40 | auditlog = user_passes_test(lambda u: u.is_superuser)(LogEntryList.as_view()) 41 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0017_auto_20180220_1115.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-20 11:15 2 | 3 | from django.db import migrations 4 | 5 | 6 | def remove_duplicate_srevs(apps, schema_editor): 7 | LogEntry = apps.get_model('audit', 'LogEntry') 8 | Secret = apps.get_model('secrets', 'Secret') 9 | SecretRevision = apps.get_model('secrets', 'SecretRevision') 10 | for secret in Secret.objects.all(): 11 | revisions = SecretRevision.objects.filter(secret=secret).order_by('-id') 12 | hashes_to_revisions = {} 13 | for revision in revisions: 14 | if revision.plaintext_data_sha256 in hashes_to_revisions: 15 | correct_revision = hashes_to_revisions[revision.plaintext_data_sha256] 16 | assert revision != revision.secret.current_revision 17 | for log_entry in LogEntry.objects.filter(secret_revision=revision): 18 | log_entry.secret_revision = correct_revision 19 | log_entry.save() 20 | correct_revision.accessed_by.add(*list(revision.accessed_by.all())) 21 | revision.delete() 22 | else: 23 | hashes_to_revisions[revision.plaintext_data_sha256] = revision 24 | 25 | 26 | class Migration(migrations.Migration): 27 | 28 | dependencies = [ 29 | ('audit', '0002_auto_20170313_1544'), 30 | ('secrets', '0016_auto_20180220_1053'), 31 | ] 32 | 33 | operations = [ 34 | migrations.RunPython(remove_duplicate_srevs), 35 | ] 36 | -------------------------------------------------------------------------------- /teamvault/apps/audit/migrations/0008_logentry_logentry_category_idx_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-11-14 13:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("audit", "0007_alter_logentry_category"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddIndex( 14 | model_name="logentry", 15 | index=models.Index(fields=["category"], name="logentry_category_idx"), 16 | ), 17 | migrations.AddIndex( 18 | model_name="logentry", 19 | index=models.Index(fields=["time"], name="logentry_time_idx"), 20 | ), 21 | migrations.AddIndex( 22 | model_name="logentry", 23 | index=models.Index(fields=["category", "time"], name="logentry_category_time_idx"), 24 | ), 25 | migrations.AddIndex( 26 | model_name="logentry", 27 | index=models.Index(fields=["actor"], name="logentry_actor_idx"), 28 | ), 29 | migrations.AddIndex( 30 | model_name="logentry", 31 | index=models.Index(fields=["group"], name="logentry_group_idx"), 32 | ), 33 | migrations.AddIndex( 34 | model_name="logentry", 35 | index=models.Index(fields=["secret"], name="logentry_secret_idx"), 36 | ), 37 | migrations.AddIndex( 38 | model_name="logentry", 39 | index=models.Index(fields=["user"], name="logentry_user_idx"), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const BundleTracker = require('webpack-bundle-tracker'); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | 5 | module.exports = { 6 | context: __dirname, 7 | entry: './teamvault/static/js/index.js', 8 | output: { 9 | path: path.resolve('./teamvault/static/bundled/'), 10 | filename: "[name]-[fullhash].js", 11 | chunkFilename: "[name]-[fullhash].js" 12 | }, 13 | plugins: [ 14 | new BundleTracker({path: __dirname + '/teamvault', filename: 'webpack-stats.json'}), 15 | ], 16 | resolve: { 17 | extensions: ['*', '.js'] 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.(js|jsx|ts|tsx)$/i, 23 | exclude: /node_modules/, 24 | loader: 'babel-loader', 25 | }, 26 | { 27 | test: /\.css$/i, 28 | use: ['style-loader', 'css-loader'], 29 | }, 30 | { 31 | test: /\.s[ac]ss$/i, 32 | use: [ 33 | "style-loader", 34 | "css-loader", 35 | { 36 | loader: "sass-loader", 37 | options: { 38 | sassOptions: { 39 | api: "modern-compiler", // Future default - only use with sass-embedded 40 | quietDeps: true, 41 | silenceDeprecations: [ 42 | "import", 43 | ], 44 | }, 45 | }, 46 | }, 47 | ], 48 | }, 49 | { 50 | test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i, 51 | type: 'asset', 52 | }, 53 | ], 54 | }, 55 | } 56 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/secret_restore.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load static %} 4 | {% block title %}{% trans "Confirm restore" %}{% endblock %} 5 | {% block content %} 6 |
7 |
8 |

9 | {% blocktrans with name=secret.name content_type=secret.get_content_type_display %} 10 | Restore {{ content_type }} '{{ name }}'? 11 | {% endblocktrans %} 12 |

13 |
14 |
15 |
16 |
17 |
18 | 24 |
25 | {% csrf_token %} 26 |   {% trans "No, go back" %} 28 | 30 |
31 |
32 |
33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | export TEAMVAULT_CONFIG_FILE := "teamvault.cfg" 2 | 3 | # just run 4 | default: run 5 | 6 | # Check lint, formatting, types and tests 7 | qa: check-lint check-format types test 8 | 9 | # Run ruff linter 10 | check-lint: 11 | uvx ruff check 12 | 13 | # Fix ruff linting issues 14 | fix-lint: 15 | uvx ruff check --fix 16 | 17 | # Check ruff formatting 18 | check-format: 19 | uvx ruff format --check 20 | 21 | # Reformat using ruff 22 | format: 23 | uvx ruff format 24 | 25 | # Check types with ty 26 | types: 27 | uvx ty check 28 | 29 | # Run tests (once we have them) 30 | test: 31 | uv run teamvault/manage.py test 32 | 33 | # Bring up postgres 34 | db: 35 | # prefixed with '-' so the recipe doesn't fail if the container's already up 36 | -docker run --rm --detach --publish=5432:5432 --name teamvault-postgres -e POSTGRES_USER=teamvault -e POSTGRES_PASSWORD=teamvault postgres:latest 37 | 38 | # wait until Postgres answers before starting servers 39 | db_ready: 40 | until pg_isready -h localhost -p 5432 -U teamvault; do sleep 0.2; done 41 | 42 | # Run webpack (bun) 43 | webpack: 44 | bun run serve 45 | 46 | # Start DB and then teamvault 47 | teamvault: db db_ready 48 | uv run teamvault run 49 | 50 | # Run teamvault and webpack together 51 | [parallel] 52 | run: webpack teamvault 53 | 54 | # Run our install steps 55 | install: db db_ready 56 | uv sync 57 | -uv run teamvault setup 58 | vim teamvault.cfg # base_url = http://localhost:8000; session_cookie_secure = False; database config as needed 59 | uv run teamvault upgrade 60 | uv run teamvault plumbing createsuperuser 61 | 62 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/secret_row.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
4 |
5 | {% if secret.content_type == ContentType.PASSWORD %} 6 | 7 | {% elif secret.content_type == ContentType.FILE %} 8 | 9 | {% elif secret.content_type == ContentType.CC %} 10 | 11 | {% endif %} 12 |
13 | 18 | {% if secret.username or secret.filename %} 19 |
20 | {% if secret.username %} 21 | {{ secret.username }} 22 | {% endif %} 23 | {% if secret.filename %} 24 | {{ secret.filename }} 25 | {% endif %} 26 |
27 | {% endif %} 28 |
29 | {% if secret.status == SecretStatus.DELETED %} 30 | 31 | {% elif secret.status == SecretStatus.NEEDS_CHANGING %} 32 | 33 | {% endif %} 34 | {% if secret not in readable_secrets %} 35 | 36 | {% endif %} 37 |
38 |
39 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0009_auto_20150322_0949.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.conf import settings 5 | from django.db import migrations 6 | from hashids import Hashids 7 | 8 | 9 | def generate_hashids(apps, schema_editor): 10 | AccessRequest = apps.get_model("secrets", "AccessRequest") 11 | Secret = apps.get_model("secrets", "Secret") 12 | SecretRevision = apps.get_model("secrets", "SecretRevision") 13 | 14 | for model_class, hashid_namespace in ( 15 | (AccessRequest, "AccessRequest"), 16 | (Secret, "Secret"), 17 | (SecretRevision, "SecretRevision"), 18 | ): 19 | for obj in model_class.objects.all(): 20 | if not obj.hashid: 21 | # We cannot use the same salt for every model because 22 | # 1. sequentially create lots of secrets 23 | # 2. note the hashid of each secrets 24 | # 3. you can now enumerate access requests by using the same 25 | # hashids 26 | # it's not a huge deal, but let's avoid it anyway 27 | hasher = Hashids( 28 | min_length=settings.HASHID_MIN_LENGTH, 29 | salt=hashid_namespace + settings.HASHID_SALT, 30 | ) 31 | obj.hashid = hasher.encode(obj.pk) 32 | obj.save() 33 | 34 | 35 | class Migration(migrations.Migration): 36 | 37 | dependencies = [ 38 | ('secrets', '0008_auto_20150322_0944'), 39 | ] 40 | 41 | operations = [ 42 | migrations.RunPython(generate_hashids), 43 | ] 44 | -------------------------------------------------------------------------------- /teamvault/apps/audit/migrations/0006_logentry_reason.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2024-02-13 15:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def add_current_share_reasons_to_audit_log(apps, schema_editor): 7 | log_entry_model = apps.get_model('audit', 'LogEntry') 8 | shared_secret_data_model = apps.get_model('secrets', 'SharedSecretData') 9 | shares = shared_secret_data_model.objects.all().exclude(granted_by__isnull=True) 10 | shared_log_entries = log_entry_model.objects.filter( 11 | category__in=['secret_shared', 'secret_superuser_shared'] 12 | ).only('secret__id', 'actor__id', 'reason') 13 | 14 | for share in shares: 15 | matched_entry = shared_log_entries.filter( 16 | actor__id=share.granted_by.id, 17 | secret__id=share.secret.id, 18 | ).order_by('time').last() 19 | 20 | # Matching can fail for immediate shares while creating new secrets 21 | if matched_entry: 22 | matched_entry.reason = share.grant_description 23 | matched_entry.save(update_fields=['reason']) 24 | 25 | 26 | class Migration(migrations.Migration): 27 | dependencies = [ 28 | ("audit", "0005_alter_logentry_category"), 29 | ("secrets", "0032_alter_sharedsecretdata_granted_by") 30 | ] 31 | 32 | operations = [ 33 | migrations.AddField( 34 | model_name="logentry", 35 | name="reason", 36 | field=models.TextField(blank=True, null=True), 37 | ), 38 | migrations.RunPython( 39 | code=add_current_share_reasons_to_audit_log, 40 | reverse_code=migrations.RunPython.noop, 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /teamvault/apps/audit/migrations/0004_alter_logentry_category.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-07-25 17:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("audit", "0003_logentry_categories"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="logentry", 14 | name="category", 15 | field=models.CharField( 16 | choices=[ 17 | ("secret_read", "secret_read"), 18 | ( 19 | "secret_elevated_superuser_read", 20 | "secret_elevated_superuser_read", 21 | ), 22 | ("secret_permission_violation", "secret_permission_violation"), 23 | ("secret_changed", "secret_changed"), 24 | ( 25 | "secret_needs_changing_reminder", 26 | "secret_needs_changing_reminder", 27 | ), 28 | ("secret_shared", "secret_shared"), 29 | ("secret_superuser_shared", "secret_superuser_shared"), 30 | ("secret_legacy_access_requests", "secret_legacy_access_requests"), 31 | ("user_activated", "user_activated"), 32 | ("user_deactivated", "user_deactivated"), 33 | ("user_settings_changed", "user_settings_changed"), 34 | ("miscellaneous", "miscellaneous"), 35 | ], 36 | default="miscellaneous", 37 | max_length=64, 38 | ), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/tests/utils.py: -------------------------------------------------------------------------------- 1 | from cryptography.fernet import Fernet 2 | from django.contrib.auth.models import User 3 | 4 | from teamvault.apps.secrets.enums import AccessPolicy, ContentType, SecretStatus 5 | from teamvault.apps.secrets.models import ( 6 | Secret, 7 | SharedSecretData, 8 | ) 9 | from teamvault.apps.secrets.services.revision import RevisionService 10 | 11 | TEST_KEY = Fernet.generate_key() # random key for the test run 12 | COMMON_OVERRIDES = { 13 | 'TEAMVAULT_SECRET_KEY': TEST_KEY, 14 | 'HASHID_MIN_LENGTH': 8, 15 | 'HASHID_SALT': 'test‑salt', 16 | 'BASE_URL': 'https://test.example', 17 | 'ALLOW_SUPERUSER_READS': True, 18 | } 19 | 20 | 21 | def make_user(username: str, superuser=False): 22 | return User.objects.create_user( 23 | username=username, 24 | email=f'{username}@example.com', 25 | password='pw', 26 | is_superuser=superuser, 27 | is_staff=superuser, 28 | ) 29 | 30 | 31 | def new_secret(owner: User, **kwargs) -> Secret: 32 | """Creates a password secret with minimal required data.""" 33 | secret = Secret.objects.create( 34 | name=kwargs.get('name', 'Test Secret'), 35 | created_by=owner, 36 | content_type=ContentType.PASSWORD, 37 | access_policy=kwargs.get('access_policy', AccessPolicy.DISCOVERABLE), 38 | status=SecretStatus.OK, 39 | ) 40 | RevisionService.save_payload( 41 | secret=secret, 42 | actor=owner, 43 | payload={'password': 'initial‑pw'}, 44 | skip_acl=True 45 | ) 46 | # Give the owner permanent share so they can delegate 47 | SharedSecretData.objects.create(secret=secret, user=owner) 48 | return secret 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /teamvault/apps/audit/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('auth', '0005_alter_user_last_login_null'), 13 | ('secrets', '0006_auto_20150124_1103'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='LogEntry', 19 | fields=[ 20 | ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), 21 | ('message', models.TextField()), 22 | ('time', models.DateTimeField(auto_now_add=True)), 23 | ('actor', models.ForeignKey(null=True, on_delete=models.CASCADE, related_name='logged_actions', to=settings.AUTH_USER_MODEL, blank=True)), 24 | ('group', models.ForeignKey(null=True, on_delete=models.CASCADE, related_name='logged_actions', to='auth.Group', blank=True)), 25 | ('secret', models.ForeignKey(null=True, on_delete=models.CASCADE, related_name='logged_actions', to='secrets.Secret', blank=True)), 26 | ('secret_revision', models.ForeignKey(null=True, on_delete=models.CASCADE, related_name='logged_actions', to='secrets.SecretRevision', blank=True)), 27 | ('user', models.ForeignKey(null=True, on_delete=models.CASCADE, related_name='affected_by_actions', to=settings.AUTH_USER_MODEL, blank=True)), 28 | ], 29 | options={ 30 | 'ordering': ('-time',), 31 | }, 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.views import LoginView, LogoutView 2 | from django.urls import path 3 | 4 | from .views import ( 5 | get_user_avatar_partial, 6 | search_user, 7 | user_activate, 8 | user_detail, 9 | user_detail_from_request, 10 | user_settings, 11 | users, 12 | ) 13 | 14 | urlpatterns = ( 15 | path( 16 | 'login/', 17 | LoginView.as_view(template_name="accounts/login.html"), 18 | name='accounts.login', 19 | ), 20 | path( 21 | 'logout/', 22 | LogoutView.as_view(template_name="accounts/logout.html"), 23 | name='accounts.logout', 24 | ), 25 | path( 26 | 'users/', 27 | users, 28 | name='accounts.user-list', 29 | ), 30 | path( 31 | 'users/avatar/', 32 | get_user_avatar_partial, 33 | name='accounts.user.avatar', 34 | ), 35 | path( 36 | 'users/detail/', 37 | user_detail_from_request, 38 | name='accounts.user-detail-from-request', 39 | ), 40 | 41 | path( 42 | 'users/search/', 43 | search_user, 44 | name='accounts.search-user', 45 | ), 46 | path( 47 | 'users//', 48 | user_detail, 49 | name='accounts.user-detail', 50 | ), 51 | path( 52 | 'users//reactivate', 53 | user_activate, 54 | name='accounts.user-reactivate', 55 | ), 56 | path( 57 | 'users//deactivate', 58 | user_activate, 59 | {'deactivate': True}, 60 | name='accounts.user-deactivate', 61 | ), 62 | path( 63 | 'settings/', 64 | user_settings, 65 | name='accounts.user-settings', 66 | ), 67 | ) 68 | -------------------------------------------------------------------------------- /teamvault/apps/audit/migrations/0007_alter_logentry_category.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2024-03-05 14:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("audit", "0006_logentry_reason"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="logentry", 14 | name="category", 15 | field=models.CharField( 16 | choices=[ 17 | ("secret_read", "secret_read"), 18 | ("secret_elevated_superuser_read", "secret_elevated_superuser_read"), 19 | ("secret_permission_violation", "secret_permission_violation"), 20 | ("secret_changed", "secret_changed"), 21 | ("secret_needs_changing_reminder", "secret_needs_changing_reminder"), 22 | ("secret_shared", "secret_shared"), 23 | ("secret_superuser_shared", "secret_superuser_shared"), 24 | ("secret_share_removed", "secret_share_removed"), 25 | ("secret_superuser_share_removed", "secret_superuser_share_removed"), 26 | ("secret_legacy_access_requests", "secret_legacy_access_requests"), 27 | ("user_activated", "user_activated"), 28 | ("user_deactivated", "user_deactivated"), 29 | ("user_settings_changed", "user_settings_changed"), 30 | ("share_automatically_revoked", "share_automatically_revoked"), 31 | ("miscellaneous", "miscellaneous"), 32 | ], 33 | default="miscellaneous", 34 | max_length=64, 35 | ), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/migrations/0002_add_user_settings.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-07-06 15:13 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [ 12 | ("auth", "0012_alter_user_first_name_max_length"), 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ("accounts", "0001_initial"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="UserSettings", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ("hide_deleted_secrets", models.BooleanField(default=True)), 31 | ( 32 | "default_sharing_groups", 33 | models.ManyToManyField( 34 | blank=True, 35 | help_text="New secrets created by you will be shared with these groups.", 36 | related_name="+", 37 | to="auth.group", 38 | ), 39 | ), 40 | ( 41 | "user", 42 | models.OneToOneField( 43 | on_delete=django.db.models.deletion.CASCADE, 44 | related_name="profile", 45 | to=settings.AUTH_USER_MODEL, 46 | ), 47 | ), 48 | ], 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /teamvault/static/js/zxcvbn.ts: -------------------------------------------------------------------------------- 1 | import {zxcvbn, zxcvbnOptions} from '@zxcvbn-ts/core' 2 | import * as zxcvbnCommonPackage from '@zxcvbn-ts/language-common' 3 | import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en' 4 | import { 5 | MatchEstimated, 6 | MatchExtended, 7 | Matcher, 8 | Match, 9 | } from '@zxcvbn-ts/core/dist/types' 10 | 11 | 12 | const minLengthMatcher: Matcher = { 13 | Matching: class MatchMinLength { 14 | minLength = 12 // TODO: Make this configurable via settings 15 | 16 | match({password}: { password: string }) { 17 | const matches: Match[] = [] 18 | if (password.length != 0 && password.length < this.minLength) { 19 | matches.push({ 20 | pattern: 'minLength', 21 | token: password, 22 | i: 0, 23 | j: password.length - 1, 24 | }) 25 | } 26 | return matches 27 | } 28 | }, 29 | feedback(match: MatchEstimated, isSoleMatch: boolean) { 30 | return { 31 | warning: 'Your password is not long enough', 32 | suggestions: [], 33 | } 34 | }, 35 | scoring(match: MatchExtended) { 36 | // The length of the password is multiplied by 10 to create a higher score the more characters are added. 37 | return match.token.length * 10 38 | }, 39 | } 40 | 41 | 42 | export function initZxcvbn() { 43 | zxcvbnOptions.addMatcher('minLength', minLengthMatcher) 44 | zxcvbnOptions.setOptions({ 45 | translations: zxcvbnEnPackage.translations, 46 | graphs: zxcvbnCommonPackage.adjacencyGraphs, 47 | dictionary: { 48 | ...zxcvbnCommonPackage.dictionary, 49 | ...zxcvbnEnPackage.dictionary, 50 | }, 51 | }) 52 | return zxcvbn 53 | } 54 | -------------------------------------------------------------------------------- /teamvault/templates/helpers/filter.html: -------------------------------------------------------------------------------- 1 | {% load django_bootstrap5 %} 2 | {% load i18n %} 3 | 4 |
5 |
6 | 10 |
11 |
12 | {% for field, field_data in active_filters.items %} 13 | {% include 'helpers/filter_row.html' with filter_name=field filter_items=field_data %} 14 | {% endfor %} 15 |
16 |
17 | 40 | -------------------------------------------------------------------------------- /teamvault/apps/audit/migrations/0002_auto_20170313_1544.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.6 on 2017-03-13 15:44 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('audit', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name='logentry', 19 | name='actor', 20 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='logged_actions', to=settings.AUTH_USER_MODEL), 21 | ), 22 | migrations.AlterField( 23 | model_name='logentry', 24 | name='group', 25 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='logged_actions', to='auth.Group'), 26 | ), 27 | migrations.AlterField( 28 | model_name='logentry', 29 | name='secret', 30 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='logged_actions', to='secrets.Secret'), 31 | ), 32 | migrations.AlterField( 33 | model_name='logentry', 34 | name='secret_revision', 35 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='logged_actions', to='secrets.SecretRevision'), 36 | ), 37 | migrations.AlterField( 38 | model_name='logentry', 39 | name='user', 40 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='affected_by_actions', to=settings.AUTH_USER_MODEL), 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load static %} 4 | {% block title %}{% trans "Dashboard" %}{% endblock %} 5 | {% block content %} 6 |
7 |
8 |

{% trans "Recently used secrets" %}

9 |
10 |
11 | {% if recently_used_secrets %} 12 |
13 | {% for secret in recently_used_secrets %} 14 | {% include "secrets/secret_row.html" %} 15 | {% endfor %} 16 |
17 | {% else %} 18 | 21 | {% endif %} 22 |
23 |
24 |
25 |
26 |

{% trans "Most used secrets" %}

27 |
28 |
29 | {% if most_used_secrets %} 30 |
31 | {% for secret in most_used_secrets %} 32 | {% include "secrets/secret_row.html" %} 33 | {% endfor %} 34 |
35 | {% else %} 36 | 39 | {% endif %} 40 |
41 |
42 |
43 |
44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/tests/models/test_secret_revisions.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | 3 | from django.test import TestCase, override_settings 4 | 5 | from teamvault.apps.secrets.models import SecretRevision, SecretChange 6 | from teamvault.apps.secrets.services.revision import RevisionService 7 | from ..utils import COMMON_OVERRIDES, make_user, new_secret 8 | 9 | 10 | @override_settings(**COMMON_OVERRIDES) 11 | class RevisionHistoryTests(TestCase): 12 | def setUp(self): 13 | self.owner = make_user('owner') 14 | self.secret = new_secret(self.owner) 15 | 16 | def test_payload_and_metadata_timeline(self): 17 | """Create two payload revisions + one pure metadata change → verify 18 | object counts and diff rendering assumptions.""" 19 | s = self.secret 20 | 21 | # 2nd REVISION: payload update 22 | RevisionService.save_payload( 23 | secret=s, 24 | actor=self.owner, 25 | payload={'password': 'second‑pw'} 26 | ) 27 | self.assertEqual(SecretRevision.objects.filter(secret=s).count(), 2) 28 | # Two distinct payload revisions 29 | self.assertEqual(SecretRevision.objects.filter(secret=s).count(), 2) 30 | 31 | # 3rd change: ONLY metadata update 32 | s.description = 'New description' 33 | s.save() 34 | 35 | # feed identical payload back in → no new revision but new meta 36 | current_payload = s.current_revision.get_data(self.owner) 37 | 38 | RevisionService.save_payload( 39 | secret=s, 40 | actor=self.owner, 41 | payload=copy(current_payload) 42 | ) 43 | 44 | self.assertEqual(SecretRevision.objects.filter(secret=s).count(), 2) 45 | # Three SecretChange rows (2 payload + 1 metadata-only snapshot) 46 | self.assertEqual(SecretChange.objects.filter(secret=s).count(), 3) 47 | 48 | # History view contract: 3 rows (2 payload, 1 meta diff) 49 | rows = RevisionService.get_revision_history(s, self.owner) 50 | self.assertEqual(len(rows), 3) 51 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/detail_content/_su_confirm_modal.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 36 | 37 | 42 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/secret_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load humanize %} 3 | {% load i18n %} 4 | {% load static %} 5 | {% load smart_pagination %} 6 | {% block title %}{% translate "Browse" %}{% endblock %} 7 | {% block content %} 8 |
9 |
10 |
11 |
12 |
13 |

14 | {% if request.GET.search %} 15 | {% blocktrans with search=request.GET.search %}Search results for '{{ search }}'... 16 | {% endblocktrans %} 17 | {% else %} 18 | {% translate "Browse all items" %} 19 | {% endif %} 20 |

21 |
22 |
23 |
24 |
25 | 26 | {% blocktranslate with count=page_obj.paginator.count %} 27 | {{ count }} item(s) found 28 | {% endblocktranslate %} 29 | 30 |
31 | {% include 'helpers/filter.html' %} 32 |
33 |
34 |
35 | {% for secret in secrets %} 36 | {% include "secrets/secret_row.html" %} 37 | {% endfor %} 38 |
39 |
40 |
41 | {% include "pagination.html" %} 42 |
43 |
44 | {% endblock %} 45 | {% block additionalJS %} 46 | 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/templates/accounts/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load static %} 4 | {% block navbar %}{% endblock %} 5 | {% block title %}{% trans "Login" %}{% endblock %} 6 | {% block super_content %} 7 |
8 |
9 | {% csrf_token %} 10 |
11 | TeamVault 12 |
13 | {% if form.errors %} 14 |

{% trans "Your username and password didn't match. Please try again." %}

15 | {% endif %} 16 |
17 | 19 |
20 |
21 |
22 | 24 | 26 |
27 |
28 | {% if google_auth_enabled %} 29 |

{% trans "or" %}

30 | 37 | {% endif %} 38 |
39 |
40 | {% endblock %} 41 | {% block footer %}{% endblock %} 42 | -------------------------------------------------------------------------------- /teamvault/static/js/otp.js: -------------------------------------------------------------------------------- 1 | export async function refreshOtpEvery30Sec(inputElement, secret_url, bigElement) { 2 | const response = await fetch(secret_url+"/otp"); 3 | const otp = await response.json(); 4 | let newFieldData = otp.slice(0, 3) + "" + otp.slice(3); 5 | inputElement.innerHTML = newFieldData; 6 | if ( !bigElement.classList.contains("invisible")) { 7 | bigElement.children[1].innerHTML = newFieldData.replace('mx-1', 'mx-3'); 8 | } 9 | } 10 | 11 | export async function otpCountdown(countdownContainerEl, countdownNumberEl, inputField, secret_url, bigElement) { 12 | const countdownTime = getcountdownTime(); 13 | setCircleParams(document.getElementById("progress-circle"), countdownTime); 14 | countdownContainerEl.style.setProperty('--progress', countdownTime); 15 | countdownNumberEl.textContent = countdownTime; 16 | if ( !bigElement.children[0].classList.contains("invisible")) { 17 | const bigCountdownElement = bigElement.children[0].children[0]; 18 | const bigCountdownNumberElement = bigCountdownElement.children[0]; 19 | const bigCountdownCircleElement = bigCountdownElement.children[1].children[0]; 20 | bigCountdownNumberElement.textContent = countdownTime; 21 | bigCountdownElement.style.setProperty('--progress', countdownTime); 22 | bigCountdownCircleElement.setAttribute("r", 50); 23 | bigCountdownCircleElement.setAttribute("cx", 100); 24 | bigCountdownCircleElement.setAttribute("cy", 100); 25 | setCircleParams(bigCountdownCircleElement, countdownTime); 26 | } 27 | if (countdownTime+1 === 30) { 28 | await refreshOtpEvery30Sec(inputField, secret_url, bigElement).then(); 29 | } 30 | } 31 | 32 | function getcountdownTime(interval = 30) { 33 | const curUnixTime = Math.floor(Date.now() / 1000); 34 | const intervalStartTime = Math.floor(curUnixTime / 30) * 30; 35 | return intervalStartTime + 30 - curUnixTime - 1; 36 | } 37 | 38 | 39 | function setCircleParams(circleElement, progress){ 40 | const radius = circleElement.getAttribute("r"); 41 | const circleSize = (2 * Math.PI) * radius; 42 | const progressOffset = circleSize - ((progress/30)*circleSize); 43 | circleElement.style.setProperty("stroke-dasharray", circleSize+'px'); 44 | circleElement.style.setProperty("stroke-dashoffset", progressOffset+'px'); 45 | } 46 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/addedit_content/file.html: -------------------------------------------------------------------------------- 1 | {% extends "secrets/secret_addedit.html" %} 2 | {% load django_bootstrap5 %} 3 | {% load i18n %} 4 | {% block form_attributes %}enctype="multipart/form-data"{% endblock %} 5 | {% block content_type_fields %} 6 |
7 | 10 |
11 |
12 | 15 |
16 | 18 | 21 |
22 |
23 | {% for error in form.file.errors %} 24 |
{{ error }}
25 | {% endfor %} 26 |
27 |
28 | {% endblock %} 29 | 30 | {% block additionalJS %} 31 | {{ block.super }} 32 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /teamvault/templates/pagination.html: -------------------------------------------------------------------------------- 1 | {% load smart_pagination %} 2 | {% if is_paginated %} 3 |
4 |
5 |
    6 | {% if page_obj.has_previous %} 7 |
  • 8 | 10 | 11 | 12 |
  • 13 | {% else %} 14 |
  • 15 | 16 | 17 | 18 |
  • 19 | {% endif %} 20 | {% for page in paginator.page_range|smart_pages:page_obj.number %} 21 | {% if page_obj.number == page %} 22 |
  • 23 | {{ page }} 24 |
  • 25 | {% else %} 26 |
  • 27 | 28 | {{ page }} 29 | 30 |
  • 31 | {% endif %} 32 | {% endfor %} 33 | {% if page_obj.has_next %} 34 |
  • 35 | 37 | 38 | 39 |
  • 40 | {% else %} 41 |
  • 42 | 43 | 44 | 45 |
  • 46 | {% endif %} 47 |
48 |
49 |
50 | {% endif %} 51 | -------------------------------------------------------------------------------- /teamvault/static/js/index.js: -------------------------------------------------------------------------------- 1 | // Import vendor tom select css 2 | import 'tom-select/dist/css/tom-select.bootstrap5.min.css'; 3 | // Import our custom CSS 4 | import '../scss/base.scss' 5 | 6 | import * as bootstrap from 'bootstrap' // TODO: Specify which plugins we really need 7 | import $ from 'jquery' 8 | import {Notyf} from 'notyf'; 9 | import 'notyf/notyf.min.css' 10 | import autoCompleteJS from '@tarekraafat/autocomplete.js'; 11 | import ClipboardJS from "clipboard"; 12 | import DOMPurify from 'dompurify'; 13 | import {TempusDominus} from '@eonasdan/tempus-dominus' 14 | import TomSelect from 'tom-select'; 15 | 16 | import {initZxcvbn} from './zxcvbn.ts' 17 | 18 | import * as teamvault from './utils' 19 | import * as otp from './otp' 20 | 21 | window.otp = otp 22 | window.teamvault = teamvault 23 | 24 | // Bootstrap 25 | window.bootstrap = bootstrap 26 | 27 | // HTMX 28 | window.htmx = require('htmx.org') 29 | 30 | // jQuery 31 | window.$ = $ 32 | window.jQuery = $ 33 | 34 | //js qr scanner 35 | window.qrScanner = require("jsqr") 36 | 37 | // Bigtext 38 | require('bigtext'); 39 | 40 | // Notyf 41 | document.addEventListener('DOMContentLoaded', () => { 42 | // Notyf tries to hook to a body, which we don't have in this context yet. 43 | window.notyf = new Notyf({position: {x: 'right', y: 'top'}}) 44 | }) 45 | 46 | // Card 47 | window.Card = require('card') 48 | 49 | // ClipboardJS 50 | window.ClipboardJS = ClipboardJS 51 | 52 | // autocomplete.js 53 | window.autoCompleteJS = autoCompleteJS 54 | 55 | // DOMPurify (needed for autocompleteJS ajax queries) 56 | window.DOMPurify = DOMPurify 57 | 58 | // Tempus Dominus 59 | window.TempusDominus = TempusDominus 60 | 61 | // zxcvbn 62 | window.zxcvbn = initZxcvbn() 63 | 64 | // Select2 65 | require('select2'); 66 | $.fn.select2.defaults.set("theme", "bootstrap-5") 67 | $.fn.select2.defaults.set("width", "100%") // https://github.com/select2/select2/issues/3278 68 | $.fn.select2.amd.require(['select2/selection/search'], function (Search) { 69 | // Patch backspace on select2 4.X. See https://github.com/select2/select2/issues/3354 70 | Search.prototype.searchRemoveChoice = function (decorated, item) { 71 | this.trigger('unselect', { 72 | data: item 73 | }); 74 | 75 | this.$search.val(''); 76 | this.handleSearch(); 77 | }; 78 | }, null, true); 79 | 80 | // tom-select 81 | window.TomSelect = TomSelect 82 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0014_auto_20170313_1544.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.6 on 2017-03-13 15:44 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('secrets', '0013_auto_20161021_1411'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name='accessrequest', 19 | name='closed_by', 20 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='access_requests_closed', to=settings.AUTH_USER_MODEL), 21 | ), 22 | migrations.AlterField( 23 | model_name='accessrequest', 24 | name='requester', 25 | field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='access_requests_created', to=settings.AUTH_USER_MODEL), 26 | ), 27 | migrations.AlterField( 28 | model_name='accessrequest', 29 | name='secret', 30 | field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='access_requests', to='secrets.Secret'), 31 | ), 32 | migrations.AlterField( 33 | model_name='secret', 34 | name='created_by', 35 | field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='passwords_created', to=settings.AUTH_USER_MODEL), 36 | ), 37 | migrations.AlterField( 38 | model_name='secret', 39 | name='current_revision', 40 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='_password_current_revision', to='secrets.SecretRevision'), 41 | ), 42 | migrations.AlterField( 43 | model_name='secretrevision', 44 | name='secret', 45 | field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='secrets.Secret'), 46 | ), 47 | migrations.AlterField( 48 | model_name='secretrevision', 49 | name='set_by', 50 | field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='password_revisions_set', to=settings.AUTH_USER_MODEL), 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/tests/models/test_file_payload_migration.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode, b64decode 2 | from json import loads 3 | 4 | from cryptography.fernet import Fernet 5 | from django.conf import settings 6 | from django.contrib.auth.models import User 7 | from django.core.management import call_command 8 | from django.test import override_settings 9 | from django_test_migrations.contrib.unittest_case import MigratorTestCase 10 | 11 | from teamvault.apps.secrets.enums import ContentType 12 | 13 | STATIC_TEST_KEY = b"WKGGUS52yN68AtcgOKKKqDzccS3hOy32ShZWKwDWe3Q=" 14 | 15 | 16 | @override_settings(TEAMVAULT_SECRET_KEY=STATIC_TEST_KEY) 17 | class Secret0039MigrationTest(MigratorTestCase): 18 | """ 19 | Ensure file migration works as expected. 20 | After migration we want all files to be safed as encrypted dict, 21 | where the content is b64 encoded 22 | """ 23 | apps = ['accounts', 'audit', 'settings'] 24 | migrate_from = ('secrets', '0038_secretrevision_last_read_secretchange') 25 | migrate_to = ('secrets', '0039_migrate_old_file_saves_into_new_format') 26 | 27 | def prepare(self): 28 | self.fernet_key = Fernet(settings.TEAMVAULT_SECRET_KEY) 29 | user = User.objects.create_user(username='file migration test user') 30 | user.save() 31 | call_command('migrate', 'settings', verbosity=0) 32 | call_command('migrate', 'social_django', verbosity=0) 33 | call_command('migrate', 'secrets', '0038', verbosity=0) 34 | call_command('loaddata', 'test_file_v3_migration_fixtures.json', verbosity=0) 35 | call_command('migrate', 'secrets', '0039', verbosity=0) 36 | 37 | def test_migration_secrets0038(self): 38 | HistoricalSecretRevisionModel = self.new_state.apps.get_model('secrets', 'SecretRevision') 39 | revisions = HistoricalSecretRevisionModel.objects.filter(secret__content_type=ContentType.FILE) 40 | for revision in revisions: 41 | self.assertTrue(self.check_if_file_secret_is_v3(revision)) 42 | 43 | def check_if_file_secret_is_v3(self, revision): 44 | try: 45 | decrypted_data = loads(self.fernet_key.decrypt(revision.encrypted_data)) 46 | decrypted_data = decrypted_data['file_content'].encode() 47 | return b64encode(b64decode(decrypted_data, validate=True)) == decrypted_data 48 | except Exception as e: 49 | print(f'Error checking file secret v3 format: {e}') 50 | return False 51 | -------------------------------------------------------------------------------- /teamvault/apps/audit/migrations/0005_alter_logentry_category.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2024-02-13 15:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def categorize_log_entries_of_shares(apps, schema_editor): 7 | log_entry_model = apps.get_model('audit', 'LogEntry') 8 | log_entry_model.objects.filter( 9 | category='secret_shared', 10 | message__regex=r"^.* removed access of \w+ '.*'.*$", 11 | ).update( 12 | category='secret_share_removed' 13 | ) 14 | 15 | 16 | def categorize_log_entries_of_shares_reverse(apps, schema_editor): 17 | log_entry_model = apps.get_model('audit', 'LogEntry') 18 | log_entry_model.objects.filter( 19 | category='secret_share_removed' 20 | ).update( 21 | category='secret_shared' 22 | ) 23 | 24 | 25 | class Migration(migrations.Migration): 26 | dependencies = [ 27 | ("audit", "0004_alter_logentry_category"), 28 | ] 29 | 30 | operations = [ 31 | migrations.AlterField( 32 | model_name="logentry", 33 | name="category", 34 | field=models.CharField( 35 | choices=[ 36 | ("secret_read", "secret_read"), 37 | ("secret_elevated_superuser_read", "secret_elevated_superuser_read"), 38 | ("secret_permission_violation", "secret_permission_violation"), 39 | ("secret_changed", "secret_changed"), 40 | ("secret_needs_changing_reminder", "secret_needs_changing_reminder"), 41 | ("secret_shared", "secret_shared"), 42 | ("secret_superuser_shared", "secret_superuser_shared"), 43 | ("secret_share_removed", "secret_share_removed"), 44 | ("secret_superuser_share_removed", "secret_superuser_share_removed"), 45 | ("secret_legacy_access_requests", "secret_legacy_access_requests"), 46 | ("user_activated", "user_activated"), 47 | ("user_deactivated", "user_deactivated"), 48 | ("user_settings_changed", "user_settings_changed"), 49 | ("miscellaneous", "miscellaneous"), 50 | ], 51 | default="miscellaneous", 52 | max_length=64, 53 | ), 54 | ), 55 | migrations.RunPython( 56 | code=categorize_log_entries_of_shares, 57 | reverse_code=categorize_log_entries_of_shares_reverse, 58 | ), 59 | ] 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TeamVault 2 | 3 | TeamVault is an open-source web-based shared password manager for behind-the-firewall installation. It requires Python 3.10+ and PostgreSQL (with the unaccent extension). 4 | 5 | ## Installation 6 | 7 | apt-get install libffi-dev libldap2-dev libpq-dev libsasl2-dev python3.X-dev postgresql-contrib 8 | pip install teamvault 9 | teamvault setup 10 | vim /etc/teamvault.conf 11 | # note that the teamvault database user will need SUPERUSER privileges 12 | # during this step in order to activate the unaccent extension 13 | teamvault upgrade 14 | teamvault plumbing createsuperuser 15 | teamvault run 16 | 17 | ## Update 18 | 19 | pip install --upgrade teamvault 20 | teamvault upgrade 21 | 22 | ## Development 23 | ### Start a PostgreSQL database 24 | Create a database and superuser for TeamVault to use, for example by starting a Docker container: 25 | 26 | docker run --rm --detach --publish=5432:5432 --name teamvault-postgres -e POSTGRES_USER=teamvault -e POSTGRES_PASSWORD=teamvault postgres:latest 27 | 28 | 29 | ### Run Webpack to serve static files 30 | To compile all JS & SCSS files, you'll need to install all required packages via bun (or yarn/npm) with node >= v18. 31 | 32 | Use ```bun/yarn/npm run serve``` to start a dev server. 33 | 34 | **Note**: 35 | Some MacOS users have reported errors when running the dev server via bun. In this case feel free to switch to NPM. 36 | 37 | 38 | ### Configure your Virtualenv via uv 39 | uv sync 40 | 41 | ### Setup TeamVault 42 | export TEAMVAULT_CONFIG_FILE=teamvault.cfg 43 | teamvault setup 44 | vim teamvault.cfg # base_url = http://localhost:8000 45 | # session_cookie_secure = False 46 | # database config as needed 47 | teamvault upgrade 48 | teamvault plumbing createsuperuser 49 | 50 | ### Start the development server 51 | teamvault run 52 | 53 | Now open http://localhost:8000 54 | 55 | ## Scheduled background jobs 56 | 57 | We use [huey](https://huey.readthedocs.io/en/latest/) to run background jobs. This requires you to run a second process, in parallel to TeamVault itself. You can launch it via `manage.py`: 58 | 59 | teamvault run_huey 60 | 61 | ## Release process 62 | 1. Bump the version in ```teamvault/__version__.py``` and ```pyproject.toml``` 63 | 2. Update CHANGELOG.md with the new version and current date 64 | 3. Make a release commit with the changes made above 65 | 4. Push the commit 66 | 5. Run ```./build.sh``` to create a new package 67 | 6. Sign and push the artifacts to PyPI via ```uv publish``` 68 | 7. Test that the package can be installed: ```uv run --isolated --no-cache --prerelease allow --with teamvault --no-project -- teamvault --version``` 69 | 8. Add a new GitHub release 70 | -------------------------------------------------------------------------------- /teamvault/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from django.views.generic.base import ContextMixin 3 | 4 | 5 | def handler404(request, exception, **kwargs): 6 | if request.user.is_authenticated: 7 | return render(request, "404_loggedin.html", status=404) 8 | else: 9 | return render(request, "404_anon.html", status=404) 10 | 11 | 12 | class FilterMixin(ContextMixin): 13 | _bound_filter = None 14 | filter_class = None 15 | request = None 16 | 17 | def get_filter(self, queryset): 18 | if self.filter_class is None: 19 | raise AttributeError('No filter class specified when using FilterMixin!') 20 | 21 | self._bound_filter = self.filter_class(self.request.GET, queryset) 22 | return self._bound_filter 23 | 24 | def get_filtered_queryset(self, queryset): 25 | return self.get_filter(queryset=queryset).qs 26 | 27 | @staticmethod 28 | def manipulate_filter_form(bound_data, filter_form): 29 | """ 30 | Can be overwritten in subclasses to add custom behaviour for a single view 31 | Has to return a filter_form 32 | """ 33 | return filter_form 34 | 35 | def get_context_data(self, **kwargs): 36 | context = super().get_context_data(**kwargs) 37 | bound_filter_data = self._bound_filter.form.cleaned_data 38 | new_filter_form = self._bound_filter.get_form_class()() 39 | 40 | active_filters = {} 41 | initial = {} 42 | 43 | for field, field_data in bound_filter_data.items(): 44 | if field_data: 45 | field_label = new_filter_form.fields[field].label 46 | initial[field] = field_data 47 | 48 | values = field_data if isinstance(field_data, (list, tuple)) else [field_data] 49 | 50 | if hasattr(new_filter_form.fields[field], 'choices'): 51 | mapping = dict(new_filter_form.fields[field].choices) 52 | converted_values = [] 53 | for val in values: 54 | try: 55 | key = int(val) 56 | except (ValueError, TypeError): 57 | key = val 58 | converted_values.append(mapping.get(key, val)) 59 | active_filters[field_label] = converted_values 60 | else: 61 | active_filters[field_label] = values 62 | 63 | new_filter_form.initial = initial 64 | new_filter_form = self.manipulate_filter_form(bound_filter_data, new_filter_form) 65 | context.update({ 66 | 'active_filters': active_filters, 67 | 'filter_form': new_filter_form, 68 | }) 69 | return context 70 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = ( 6 | path( 7 | '', 8 | views.dashboard, 9 | name='dashboard', 10 | ), 11 | path( 12 | 'opensearch.xml', 13 | views.opensearch, 14 | name='opensearch', 15 | ), 16 | path( 17 | 'secrets/', 18 | views.secret_list, 19 | name='secrets.secret-list', 20 | ), 21 | path( 22 | 'secrets//', 23 | views.secret_detail, 24 | name='secrets.secret-detail', 25 | ), 26 | path( 27 | 'secrets//delete', 28 | views.secret_delete, 29 | name='secrets.secret-delete', 30 | ), 31 | path( 32 | 'secrets//download', 33 | views.secret_download, 34 | name='secrets.secret-download', 35 | ), 36 | path( 37 | 'secrets//edit', 38 | views.secret_edit, 39 | name='secrets.secret-edit', 40 | ), 41 | path( 42 | 'secrets//revisions', 43 | views.secret_revisions, 44 | name='secrets.secret-revisions', 45 | ), 46 | path( 47 | 'secrets//changes//delete', 48 | views.secret_change_delete, 49 | name='secrets.secret-change-delete', 50 | ), 51 | path( 52 | 'revisions//', 53 | views.secret_revision_detail, 54 | name='secrets.revision-detail', 55 | ), 56 | path( 57 | "revisions//download/", 58 | views.secret_revision_download, 59 | name="secrets.revision-download", 60 | ), 61 | path( 62 | '/revisions//restore/', 63 | views.restore_secret_revision, 64 | name='restore_secret_revision' 65 | ), 66 | path( 67 | 'secrets//metadata', 68 | views.secret_metadata, 69 | name='secrets.secret-metadata', 70 | ), 71 | path( 72 | 'secrets//restore', 73 | views.secret_restore, 74 | name='secrets.secret-restore', 75 | ), 76 | path( 77 | 'secrets//share', 78 | views.secret_share_list, 79 | name='secrets.secret-share', 80 | ), 81 | path( 82 | 'secrets//share//delete', 83 | views.secret_share_delete, 84 | name='secrets.secret-share-delete', 85 | ), 86 | path( 87 | 'secrets/add/', 88 | views.secret_add, 89 | name='secrets.secret-add', 90 | ), 91 | path( 92 | 'secrets/live-search', 93 | views.secret_search, 94 | name='secrets.secret-search', 95 | ), 96 | ) 97 | -------------------------------------------------------------------------------- /teamvault/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load render_bundle from webpack_loader %} 3 | {% load webpack_static from webpack_loader %} 4 | 5 | {% load static %} 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% trans "TeamVault" %} · {% block title %}{% endblock %} 13 | 14 | 15 | 16 | 17 | {% render_bundle 'main' %} 18 | 19 | {% block head %}{% endblock %} 20 | 21 | 22 | 23 | 35 | 60 | 61 |
62 | 63 | {% block navbar %}{% include 'base_nav.html' %}{% endblock %} 64 | 65 | {% block super_content %} 66 |
67 | {% block content %}{% endblock %} 68 |
69 | {% endblock %} 70 | 71 | {% block footer %} 72 |
73 |
74 | TeamVault {{ version }}   ·   © 2014-2025 Seibert Group 75 |
76 |
77 | {% endblock %} 78 | 79 | {% block additionalJS %} 80 | {% endblock %} 81 | 82 | 83 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/filters.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | import django_filters 4 | from django.contrib.auth import get_user_model 5 | from django import forms 6 | from django.db.models import IntegerChoices 7 | from django.utils.html import format_html 8 | from django.utils.safestring import mark_safe 9 | from django.utils.translation import gettext_lazy as _ 10 | 11 | from teamvault.apps.secrets.models import Secret 12 | from teamvault.apps.secrets.enums import ContentType, SecretStatus 13 | 14 | User = get_user_model() 15 | 16 | 17 | def add_tooltip(label, tooltip_message): 18 | return format_html( 19 | '{} ' 20 | '' 22 | '', 23 | label, 24 | tooltip_message 25 | ) 26 | 27 | 28 | class Icons(enum.Enum): 29 | CREDIT_CARD = "fa-credit-card text-secondary" 30 | DELETED_DANGER = "fa-trash text-danger" 31 | FILE = "fa-file text-secondary" 32 | KEY = "fa-key text-secondary" 33 | REFRESH_DANGER = "fa-refresh text-danger" 34 | 35 | @property 36 | def html(self): 37 | return f' ' 38 | 39 | 40 | class ContentTypeChoice(IntegerChoices): 41 | # TODO: Merge CONTENT_* vars with these ones. 42 | # Preferably migrate occurances of Secret.CONTENT_CHOICES to this class 43 | PASSWORD = ContentType.PASSWORD, mark_safe(Icons.KEY.html + _('Password')) 44 | CREDIT_CARD = ContentType.CC, mark_safe(Icons.CREDIT_CARD.html + _('Credit Card')) 45 | FILE = ContentType.FILE, mark_safe(Icons.FILE.html + _('File')) 46 | 47 | 48 | class StatusChoices(IntegerChoices): 49 | # TODO: Merge STATUS_* vars with these ones. 50 | # Preferably migrate occurances of Secret.STATUS_CHOICES to this class 51 | OK = SecretStatus.OK, mark_safe(Icons.KEY.html + _('Regular')) 52 | NEEDS_CHANGING = SecretStatus.NEEDS_CHANGING, mark_safe(Icons.REFRESH_DANGER.html + _('Needs Changing')) 53 | DELETED = SecretStatus.DELETED, mark_safe(Icons.DELETED_DANGER.html) + f"{add_tooltip( _('Deleted'),_('Hide deleted secrets per default by changing your settings.'))}" 54 | 55 | 56 | class SecretFilter(django_filters.FilterSet): 57 | content_type = django_filters.MultipleChoiceFilter( 58 | choices=ContentTypeChoice, 59 | widget=forms.CheckboxSelectMultiple, 60 | label=_('Type') 61 | ) 62 | status = django_filters.MultipleChoiceFilter( 63 | choices=StatusChoices, 64 | widget=forms.CheckboxSelectMultiple, 65 | label=_('Status') 66 | ) 67 | created_by = django_filters.ModelChoiceFilter( 68 | queryset=User.objects.all().order_by('username'), 69 | ) 70 | 71 | class Meta: 72 | model = Secret 73 | fields = ['content_type', 'status', 'created_by'] 74 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0031_rename_share_data_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-07-07 13:22 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("auth", "0012_alter_user_first_name_max_length"), 12 | ("secrets", "0030_sharedsecretdata_granted_on"), 13 | ] 14 | 15 | operations = [ 16 | migrations.RemoveField( 17 | model_name="secret", 18 | name="allowed_groups", 19 | ), 20 | migrations.RemoveField( 21 | model_name="secret", 22 | name="allowed_users", 23 | ), 24 | migrations.AddField( 25 | model_name="secret", 26 | name="shared_groups", 27 | field=models.ManyToManyField( 28 | blank=True, through="secrets.SharedSecretData", to="auth.group" 29 | ), 30 | ), 31 | migrations.AddField( 32 | model_name="secret", 33 | name="shared_users", 34 | field=models.ManyToManyField( 35 | blank=True, 36 | through="secrets.SharedSecretData", 37 | to=settings.AUTH_USER_MODEL, 38 | ), 39 | ), 40 | migrations.AlterField( 41 | model_name="sharedsecretdata", 42 | name="granted_by", 43 | field=models.ForeignKey( 44 | null=True, 45 | on_delete=django.db.models.deletion.SET_NULL, 46 | related_name="+", 47 | to=settings.AUTH_USER_MODEL, 48 | ), 49 | ), 50 | migrations.AlterField( 51 | model_name="sharedsecretdata", 52 | name="group", 53 | field=models.ForeignKey( 54 | null=True, 55 | on_delete=django.db.models.deletion.CASCADE, 56 | related_name="secret_share_data", 57 | to="auth.group", 58 | ), 59 | ), 60 | migrations.AlterField( 61 | model_name="sharedsecretdata", 62 | name="secret", 63 | field=models.ForeignKey( 64 | on_delete=django.db.models.deletion.CASCADE, 65 | related_name="share_data", 66 | to="secrets.secret", 67 | ), 68 | ), 69 | migrations.AlterField( 70 | model_name="sharedsecretdata", 71 | name="user", 72 | field=models.ForeignKey( 73 | null=True, 74 | on_delete=django.db.models.deletion.CASCADE, 75 | related_name="secret_share_data", 76 | to=settings.AUTH_USER_MODEL, 77 | ), 78 | ), 79 | ] 80 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0039_migrate_old_file_saves_into_new_format.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | from base64 import b64decode, b64encode 3 | from json import JSONDecodeError, dumps, loads 4 | 5 | from cryptography.fernet import Fernet 6 | from django.conf import settings 7 | from django.db import migrations 8 | 9 | from teamvault.apps.secrets.models import Secret 10 | from teamvault.apps.secrets.enums import ContentType 11 | 12 | 13 | class LegacyJsonButNotBase64(Exception): 14 | pass 15 | 16 | 17 | def migrate_file_secrets_to_new_save_method(apps, schema_editor): 18 | # Currently 3 ways secret files are stored: 19 | # v1. encrypted_data is the file content f.encrypt(file_content) (0.9.2) 20 | # v2. json object with .decode() 'file_content' (1.0.0 rc7) 21 | # v3. json object with b64 encoded 'file_content' (1.0.0 rc8) 22 | HistoricalSecretRevisionModel = apps.get_model('secrets', 'SecretRevision') 23 | revisions = HistoricalSecretRevisionModel.objects.filter(secret__content_type=ContentType.FILE) 24 | f = Fernet(settings.TEAMVAULT_SECRET_KEY) 25 | 26 | for revision in revisions: 27 | decrypted_data = f.decrypt(revision.encrypted_data) 28 | try: 29 | payload = loads(decrypted_data) # doesn't need decode, works on bytes 30 | if isinstance(payload, list): 31 | # JSON can also be a list, where .get will not work 32 | raise LegacyJsonButNotBase64 33 | 34 | content = payload.get("file_content") 35 | if isinstance(content, str): 36 | # content is textual; decide whether it’s already B64 37 | try: 38 | b64decode(content, validate=True) 39 | # Already v3 – nothing to do 40 | continue 41 | except binascii.Error: 42 | # v2 – .decode() was applied; convert to bytes 43 | raw_bytes = content.encode() 44 | else: 45 | # it's json but doesn't have B64 file_content 46 | raise LegacyJsonButNotBase64 47 | except (JSONDecodeError, LegacyJsonButNotBase64, UnicodeDecodeError): 48 | # v1 – `decrypted` is raw bytes 49 | raw_bytes = decrypted_data 50 | 51 | # At this point raw_bytes should hold the original file bytes 52 | new_payload = dumps({"file_content": b64encode(raw_bytes).decode()}).encode() 53 | 54 | encrypted_data = f.encrypt(new_payload) 55 | revision.encrypted_data = encrypted_data 56 | revision.save() 57 | 58 | 59 | class Migration(migrations.Migration): 60 | dependencies = [ 61 | ("secrets", "0038_secretrevision_last_read_secretchange"), 62 | ] 63 | 64 | operations = [ 65 | migrations.RunPython(migrate_file_secrets_to_new_save_method, reverse_code=migrations.RunPython.noop), 66 | ] 67 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/tasks.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import logging 4 | 5 | from ..audit.auditlog import log 6 | from ..audit.models import LogEntry, AuditLogCategoryChoices 7 | from ..secrets.models import SharedSecretData 8 | 9 | from django.conf import settings 10 | from django.contrib.auth.models import Group 11 | from django.utils.timezone import now 12 | from django.utils.translation import gettext_lazy as _ 13 | from huey import crontab 14 | from huey.contrib.djhuey import periodic_task 15 | 16 | 17 | huey_log = logging.getLogger('huey') 18 | 19 | 20 | @periodic_task(crontab(**settings.HUEY_TASKS['scheduler_frequency'])) 21 | def prune_expired_shares(): 22 | for share in SharedSecretData.objects.with_expiry_state().filter(is_expired=True): 23 | huey_log.info( 24 | _("Removing expired share of '{secret}' ({secret_id}) for {share_type} '{who}', was valid until {until}").format( 25 | secret=share.secret, 26 | secret_id=share.secret.hashid, 27 | share_type=_("user") if share.user else _("group"), 28 | until=share.granted_until, 29 | who=share.user or share.group, 30 | ), 31 | ) 32 | share.delete() 33 | 34 | 35 | @periodic_task(crontab(**settings.HUEY_TASKS['scheduler_frequency'])) 36 | def revoke_unused_shares(): 37 | if settings.HUEY_TASKS['revoke_unused_shares_after_days'] is None: 38 | return 39 | 40 | grace_period = now() - timedelta(days=settings.HUEY_TASKS['revoke_unused_shares_after_days']) 41 | 42 | for share in SharedSecretData.objects.filter( 43 | granted_on__lt=grace_period, 44 | ): 45 | users_to_check = [] 46 | 47 | if share.user: 48 | users_to_check.append(share.user) 49 | else: 50 | users_to_check.extend(share.group.user_set.all()) 51 | 52 | do_revoke = True 53 | for user in users_to_check: 54 | accessed = LogEntry.objects.filter( 55 | actor=user, 56 | secret=share.secret, 57 | time__gte=grace_period, 58 | ) 59 | if accessed: 60 | do_revoke = False 61 | # Skip further unnecessary checks. 62 | break 63 | 64 | if do_revoke: 65 | log( 66 | _( 67 | "Share for {share_type} '{who}' automatically revoked, " 68 | "not used since {grace_period}" 69 | ).format( 70 | grace_period=grace_period, 71 | share_type=_("user") if share.user else _("group"), 72 | who=share.user or share.group, 73 | ), 74 | category=AuditLogCategoryChoices.SHARE_AUTOMATICALLY_REVOKED, 75 | level='info', 76 | secret=share.secret, 77 | ) 78 | share.delete() 79 | -------------------------------------------------------------------------------- /teamvault/apps/audit/migrations/0003_logentry_categories.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-07-20 12:41 2 | 3 | from django.db import migrations, models 4 | 5 | category_choices = [ 6 | ("secret_read", "secret_read"), 7 | ("secret_elevated_superuser_read", "secret_elevated_superuser_read"), 8 | ("secret_permission_violation", "secret_permission_violation"), 9 | ("secret_changed", "secret_changed"), 10 | ("secret_needs_changing_reminder", "secret_needs_changing_reminder"), 11 | ("secret_legacy_access_requests", "secret_legacy_access_requests"), 12 | ("secret_shared", "secret_shared"), 13 | ("secret_superuser_shared", "secret_superuser_shared"), 14 | ("user_activated", "user_activated"), 15 | ("user_deactivated", "user_deactivated"), 16 | ("user_settings_changed", "user_settings_changed"), 17 | ("miscellaneous", "miscellaneous"), 18 | ] 19 | 20 | 21 | def categorize_log_entries(apps, schema_editor): 22 | log_entry_model = apps.get_model('audit', 'LogEntry') 23 | 24 | mapping = { 25 | r"^.* used superuser privileges to read '.*'$": "secret_superuser_read", 26 | r"^.* read '.*'$": "secret_read", 27 | r"^.* tried to access '.*' without permission$": "secret_permission_violation", 28 | r"^.* shared '.* with .*$": "secret_shared", 29 | r"^.* granted access to \w+ '.*'.*$": "secret_shared", 30 | r"^.* set a new secret for '.*' \([\w\d]+->[\w\d]+\)$": "secret_changed", 31 | r"^.* deleted '.*' \(\d+:\d+\)$": "secret_changed", 32 | r"^.* restore '.*' \(\d+:\d+\)$": "secret_changed", 33 | r"^.* has \w+ access request #\d+ for .*, ?\w* allowing access to '.*'$": "secret_legacy_access_requests", 34 | r"^secret '.*' needs changing because user '.*' was deactivated$": "secret_needs_changing_reminder", 35 | r"^.* reactivated .*": "user_activated$", 36 | r"^.* deactivated .*, \d+ secrets marked for changing": "user_deactivated$", 37 | } 38 | 39 | for regex, category in mapping.items(): 40 | log_entry_model.objects.filter(message__regex=regex).update(category=category) 41 | 42 | 43 | class Migration(migrations.Migration): 44 | dependencies = [ 45 | ("audit", "0002_auto_20170313_1544"), 46 | ] 47 | 48 | operations = [ 49 | migrations.AddField( 50 | model_name="logentry", 51 | name="category", 52 | field=models.CharField( 53 | choices=category_choices, 54 | default="miscellaneous", 55 | max_length=64, 56 | ), 57 | ), 58 | migrations.RunPython( 59 | code=categorize_log_entries, 60 | ), 61 | migrations.AlterField( 62 | model_name="logentry", 63 | name="category", 64 | field=models.CharField( 65 | choices=category_choices, 66 | max_length=64, 67 | ), 68 | ), 69 | ] 70 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/detail_content/meta.html: -------------------------------------------------------------------------------- 1 | {% load humanize %} 2 | {% load i18n %} 3 | 4 |
5 |
6 | 7 | 8 | 9 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 58 | 59 |
{% trans "Changed" %} 10 | 12 | {{ secret.last_changed|naturalday:"Y-m-d" }} 13 | 14 |
{% trans "Changed by" %}{{ secret.current_revision.set_by.username }}
{% trans "Created" %} 27 | 29 | {{ secret.created|naturalday:"Y-m-d" }} 30 | 31 |
{% trans "Created by" %}{{ secret.created_by.username }}
{% trans "Shared with" %} 44 | 46 | {% blocktrans with groupcount=allowed_groups|length %} 47 | {{ groupcount }} group(s) 48 | {% endblocktrans %} 49 | 50 |
51 | 53 | {% blocktrans with usercount=allowed_users|length %} 54 | {{ usercount }} user(s) 55 | {% endblocktrans %} 56 | 57 |
60 |
61 | {% if request.user.is_superuser %} 62 | 63 | 64 | {% trans "Audit log" %} 65 | 66 | {% endif %} 67 |
68 |
69 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/migrations/0026_allowed_groups_users_intermediate.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-06-11 21:27 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | def copy_shared_secrets_data(apps, schema_editor): 9 | secret_model = apps.get_model('secrets', 'Secret') 10 | shared_secret_data_model = apps.get_model('secrets', 'SharedSecretData') 11 | 12 | secret_data = secret_model.objects.all().only( 13 | 'id', 14 | 'allowed_users', 15 | 'allowed_groups' 16 | ) 17 | 18 | user_shares = [] 19 | group_shares = [] 20 | for secret in secret_data: 21 | group_shares += [(secret, group) for group in secret.allowed_groups.all()] 22 | user_shares += [(secret, user) for user in secret.allowed_users.all()] 23 | 24 | shared_secret_data_model.objects.bulk_create( 25 | [shared_secret_data_model(secret=secret, user=user) for secret, user in user_shares] + 26 | [shared_secret_data_model(secret=secret, group=group) for secret, group in group_shares] 27 | ) 28 | 29 | 30 | class Migration(migrations.Migration): 31 | dependencies = [ 32 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 33 | ('auth', '0012_alter_user_first_name_max_length'), 34 | ('secrets', '0025_alter_secret_description_alter_secret_name'), 35 | ] 36 | 37 | operations = [ 38 | migrations.CreateModel( 39 | name='SharedSecretData', 40 | fields=[ 41 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 42 | ('group', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.group')), 43 | ('secret', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='secrets.secret')), 44 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), 45 | ], 46 | options={ 47 | 'unique_together': {('user', 'secret'), ('group', 'secret')}, 48 | }, 49 | ), 50 | migrations.RunPython( 51 | code=copy_shared_secrets_data, 52 | ), 53 | migrations.RemoveField( 54 | model_name='secret', 55 | name='allowed_groups', 56 | ), 57 | migrations.RemoveField( 58 | model_name='secret', 59 | name='allowed_users', 60 | ), 61 | migrations.AddField( 62 | model_name='secret', 63 | name='allowed_groups', 64 | field=models.ManyToManyField(blank=True, related_name='allowed_passwords', through='secrets.SharedSecretData', to='auth.group'), 65 | ), 66 | migrations.AddField( 67 | model_name='secret', 68 | name='allowed_users', 69 | field=models.ManyToManyField(blank=True, related_name='allowed_passwords', through='secrets.SharedSecretData', to=settings.AUTH_USER_MODEL), 70 | ), 71 | ] 72 | -------------------------------------------------------------------------------- /teamvault/static/scss/base.scss: -------------------------------------------------------------------------------- 1 | @import "@fontsource/poppins/100.css"; 2 | @import "@fontsource/poppins/200.css"; 3 | @import "@fontsource/poppins/300.css"; 4 | @import "@fontsource/poppins/400.css"; 5 | @import "@fontsource/poppins/500.css"; 6 | @import "@fontsource/poppins/600.css"; 7 | @import "@fontsource/poppins/700.css"; 8 | @import "@fontsource/poppins/800.css"; 9 | @import "@fontsource/poppins/900.css"; 10 | 11 | @import "./theme"; 12 | @import "./avatars"; 13 | @import "./scrollbar"; 14 | @import "./search"; 15 | @import "./secrets"; 16 | @import "./card.scss"; 17 | @import "./circularProgressbar"; 18 | @import "./tom-select"; 19 | 20 | @import "./fontawesome"; 21 | @import "./select2"; 22 | 23 | @import '@eonasdan/tempus-dominus/src/scss/tempus-dominus.scss'; 24 | 25 | body { 26 | font-family: 'Poppins', serif; 27 | } 28 | 29 | a { 30 | text-decoration: none; 31 | color: var(--bs-info); 32 | } 33 | 34 | .background { 35 | position: fixed; 36 | top: 0; 37 | right: 0; 38 | bottom: 0; 39 | left: 0; 40 | background-position: 50% 50%; 41 | background: var(--bs-body-bg); 42 | z-index: -999; 43 | } 44 | 45 | .dropdown-menu i { 46 | color: #92A6B2; 47 | } 48 | 49 | h1 { 50 | color: var(--text-body); 51 | 52 | > .badge { 53 | vertical-align: top; 54 | } 55 | } 56 | 57 | .card { 58 | table { 59 | margin-bottom: 0; 60 | } 61 | 62 | &.shadow { 63 | box-shadow: 0 0.5rem 0.5rem rgba(0, 0, 0, 0.05); 64 | } 65 | } 66 | 67 | .card .card-body .table tr:last-child { 68 | border-bottom-style: hidden; 69 | } 70 | 71 | kbd { 72 | font-family: monospace; 73 | text-align: center; 74 | border-color: #303030; 75 | font-size: inherit; 76 | 77 | &:not(kbd:first-of-type) { 78 | margin-left: 0.1rem; 79 | } 80 | } 81 | 82 | .list-group-hover .list-group-item { 83 | &:hover { 84 | background-color: var(--bs-list-group-action-active-bg); 85 | } 86 | } 87 | 88 | .list-group-item { 89 | --bs-list-group-item-padding-y: 0.75rem; 90 | } 91 | 92 | @include media-breakpoint-down(lg) { 93 | .modal-lg { 94 | --bs-modal-width: 88% 95 | } 96 | } 97 | 98 | @include media-breakpoint-down(xl) { 99 | .modal-xl { 100 | --bs-modal-width: 88% 101 | } 102 | } 103 | 104 | [data-bs-theme="light"] { 105 | .lt-otp { 106 | color: white; 107 | } 108 | } 109 | 110 | [data-bs-theme="dark"] { 111 | .btn-light { 112 | color: var(--bs-light); 113 | background-color: var(--bs-secondary-bg); 114 | border-color: var(--bs-secondary-bg); 115 | border-width: 1px; 116 | transition: filter .15s ease-in-out; 117 | 118 | &:hover { 119 | filter: brightness(1.2); 120 | } 121 | &:active { 122 | filter: brightness(1.4); 123 | background-color: var(--bs-secondary-bg); 124 | border-color: var(--bs-secondary-bg); 125 | color: var(--bs-light); 126 | } 127 | } 128 | 129 | // bg-color for filter chips 130 | .bg-secondary-subtle { 131 | background-color: #37383b!important; 132 | } 133 | } 134 | 135 | .separator { 136 | height: 0.3em; 137 | width: 0.3em; 138 | margin-bottom: 0.2em; 139 | background-color: var(--bs-secondary); 140 | border-radius: 50%; 141 | display: inline-block; 142 | } 143 | 144 | .bg-otp { 145 | background-color: var(--bs-tertiary-bg); 146 | } 147 | -------------------------------------------------------------------------------- /.github/workflows/run-tests-on-pr.yml: -------------------------------------------------------------------------------- 1 | name: Run Django Tests on PR 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | tests: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | # based on django version in dependencies - minus 3.10 & 3.11 because of typing support 13 | python-version: [ "3.12", "3.13", "3.14"] 14 | 15 | env: 16 | TEAMVAULT_CONFIG_FILE: ${{ github.workspace }}/teamvault.cfg 17 | DATABASE_URL: postgres://postgres:postgres@localhost:5432/teamvault 18 | PGPORT: 5432 19 | 20 | services: 21 | postgres: 22 | image: postgres:17 23 | env: 24 | POSTGRES_DB: teamvault 25 | POSTGRES_USER: teamvault 26 | POSTGRES_PASSWORD: teamvault 27 | ports: 28 | - 5432:5432 29 | options: >- 30 | --health-cmd pg_isready 31 | --health-interval 10s 32 | --health-timeout 5s 33 | --health-retries 5 34 | 35 | 36 | steps: 37 | - name: Install system build deps 38 | run: | 39 | sudo apt-get update 40 | sudo apt-get install -y --no-install-recommends \ 41 | build-essential \ 42 | libffi-dev \ 43 | libldap2-dev \ 44 | libpq-dev \ 45 | libsasl2-dev \ 46 | postgresql-contrib 47 | 48 | - uses: actions/checkout@v4 49 | 50 | - name: Set up bun 51 | id: bun 52 | uses: oven-sh/setup-bun@v2 53 | with: 54 | bun-version: latest 55 | 56 | - name: Restore bun cache 57 | uses: actions/cache@v4 58 | with: 59 | path: /home/runner/.bun/install/cache 60 | key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-version }}-${{ hashFiles('bun.lockb') }} 61 | restore-keys: | 62 | ${{ runner.os }}-bun- 63 | 64 | - name: Set up Python 65 | uses: actions/setup-python@v5 66 | with: 67 | python-version: ${{ matrix.python-version }} 68 | 69 | - name: Set Up uv 70 | uses: astral-sh/setup-uv@v5 71 | with: 72 | enable-cache: true 73 | cache-dependency-glob: | 74 | **/uv.lock 75 | **/pyproject.toml 76 | 77 | - name: Cache venv 78 | uses: actions/cache@v4 79 | with: 80 | path: | 81 | .venv 82 | key: uv-venv-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('**/uv.lock', '**/pyproject.toml') }} 83 | restore-keys: | 84 | uv-venv-${{ runner.os }}-py${{ matrix.python-version }}- 85 | 86 | - name: Install dependencies 87 | working-directory: ${{ github.workspace }} 88 | run: | 89 | uv sync && uv pip install -e . 90 | 91 | - name: Activate venv for later steps 92 | shell: bash 93 | run: | 94 | echo "${{ github.workspace }}/.venv/bin" >> "$GITHUB_PATH" 95 | 96 | - name: Build frontend assets 97 | working-directory: ${{ github.workspace }} 98 | run: | 99 | bun install 100 | bun run build 101 | 102 | - name: Set Up TeamVault Config File 103 | working-directory: ${{ github.workspace }} 104 | run: | 105 | teamvault setup 106 | 107 | - name: Run Tests 108 | working-directory: ${{ github.workspace }}/teamvault 109 | run: | 110 | python manage.py test 111 | -------------------------------------------------------------------------------- /teamvault/static/scss/search.scss: -------------------------------------------------------------------------------- 1 | @import "bootstrap/scss/variables"; 2 | 3 | .searchbar { 4 | &.modal-header { 5 | border-bottom: 0; 6 | } 7 | 8 | div, form { 9 | cursor: auto; 10 | box-sizing: border-box; 11 | height: 56px; 12 | margin: 0; 13 | padding: 0 12px; 14 | position: relative; 15 | width: 100%; 16 | border-radius: var(--bs-border-radius); 17 | } 18 | 19 | label { 20 | margin: 0; 21 | padding: 0; 22 | align-items: center; 23 | display: flex; 24 | justify-content: center; 25 | color: var(--bs-custom-dark); 26 | 27 | svg { 28 | height: 24px; 29 | width: 24px; 30 | stroke-width: 1.6; 31 | } 32 | } 33 | 34 | .loading-indicator { 35 | display: none; 36 | margin: 0; 37 | padding: 0; 38 | } 39 | 40 | input { 41 | border: 0; 42 | box-shadow: none; 43 | background-color: var(--bs-body-bg); 44 | 45 | &:focus { 46 | border: 0; 47 | box-shadow: none; 48 | } 49 | } 50 | 51 | button[type="reset"] { 52 | animation: fade-in .1s ease-in forwards; 53 | visibility: hidden; 54 | background: none; 55 | border: 0; 56 | border-radius: 50%; 57 | padding: 2px; 58 | right: 0; 59 | stroke-width: 1.4; 60 | } 61 | } 62 | 63 | #searchbar-toggle { 64 | color: var(--tv-color-secondary-txt); 65 | min-width: 16rem; 66 | 67 | kbd { 68 | padding-inline: .5rem; 69 | font-size: $small-font-size; 70 | color: var(--tv-color-secondary-txt); 71 | 72 | &:first-of-type { 73 | border-top-left-radius: 1rem; 74 | border-bottom-left-radius: 1rem; 75 | } 76 | 77 | &:last-of-type { 78 | border-top-right-radius: 1rem; 79 | border-bottom-right-radius: 1rem; 80 | } 81 | } 82 | } 83 | 84 | #search-modal-results .list-group-item { 85 | height: 73px; 86 | 87 | &[aria-selected="true"], &:hover { 88 | background-color: var(--bs-list-group-action-active-bg); 89 | 90 | .search-modal-result-action > .search-modal-result-action-link { 91 | display: flex; 92 | } 93 | .search-modal-result-action > i:not(.search-modal-result-action-link) { 94 | display: none; 95 | } 96 | } 97 | } 98 | 99 | .search-modal-result-action-link { 100 | box-sizing: border-box; 101 | 102 | &:hover { 103 | background: var(--bs-secondary-bg-subtle); 104 | opacity: 80%; 105 | } 106 | } 107 | 108 | 109 | #search-modal-results { 110 | height: 90vh; 111 | } 112 | 113 | .search-modal-result-content { 114 | display: flex; 115 | flex: 1 1 auto; 116 | flex-direction: column; 117 | font-weight: 500; 118 | justify-content: center; 119 | line-height: 1.2em; 120 | margin: 0 8px; 121 | overflow-x: hidden; 122 | position: relative; 123 | white-space: nowrap; 124 | width: 80%; 125 | } 126 | 127 | .search-modal-result-content-title { 128 | font-size: .9em; 129 | } 130 | 131 | .search-modal-result-content-extras { 132 | font-size: 0.75em; 133 | } 134 | 135 | .search-modal-result-action { 136 | display: flex; 137 | justify-content: end; 138 | height: 2rem; 139 | 140 | > .search-modal-result-action-link { 141 | display: none; 142 | } 143 | } 144 | 145 | .search-modal-actions { 146 | list-style: none; 147 | margin: 0; 148 | padding: 0; 149 | 150 | li { 151 | align-items: center; 152 | display: flex; 153 | 154 | &:not(:last-of-type) { 155 | margin-right: 0.8rem 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /.github/workflows/create-build-on-release.yml: -------------------------------------------------------------------------------- 1 | name: Create Build and Python Package on Release 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | workflow_dispatch: 7 | inputs: 8 | dry_run: 9 | description: "Run without creating a GitHub release or publishing to PyPI" 10 | required: false 11 | type: boolean 12 | default: true 13 | 14 | permissions: 15 | contents: write 16 | id-token: write 17 | 18 | jobs: 19 | build-and-publish: 20 | runs-on: ubuntu-latest 21 | environment: pypi 22 | 23 | steps: 24 | - name: Checkout Repo 25 | uses: actions/checkout@v4 26 | 27 | - name: Set up bun 28 | id: bun 29 | uses: oven-sh/setup-bun@v2 30 | with: 31 | bun-version: latest 32 | 33 | - name: Restore bun cache 34 | uses: actions/cache@v4 35 | with: 36 | path: ~/.bun/install/cache 37 | key: ${{ runner.os }}-bun-${{ steps.bun.outputs.bun-version }}-${{ hashFiles('bun.lockb') }} 38 | restore-keys: | 39 | ${{ runner.os }}-bun- 40 | 41 | - name: Frontend Install Dependencies and Build 42 | run: | 43 | bun install 44 | bun run build 45 | working-directory: ${{ github.workspace }} 46 | 47 | - name: Zip Bundle 48 | run: | 49 | zip -r "$BUNDLE_ZIP" bundled 50 | env: 51 | BUNDLE_ZIP: ${{ github.workspace }}/teamvault/static/bundled.zip 52 | working-directory: ${{ github.workspace }}/teamvault/static 53 | 54 | - name: Upload Webpack Bundle to Release 55 | if: | 56 | github.event_name == 'release' || 57 | (github.event_name == 'workflow_dispatch' && !inputs.dry_run) 58 | uses: softprops/action-gh-release@v2 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | with: 62 | files: ${{ github.workspace }}/teamvault/static/bundled.zip 63 | 64 | - name: Set up Python 65 | uses: actions/setup-python@v5 66 | with: 67 | python-version: "3.12" 68 | 69 | - name: Install uv 70 | uses: astral-sh/setup-uv@v5 71 | 72 | - name: Build wheel 73 | id: wheel 74 | run: | 75 | uv build --no-sources 76 | WHEEL_PATH=$(realpath dist/*.whl) 77 | echo "wheel=$WHEEL_PATH" >> "$GITHUB_OUTPUT" 78 | echo "wheel_name=$(basename "$WHEEL_PATH")" >> "$GITHUB_OUTPUT" 79 | 80 | - name: Upload wheel to the GitHub Release 81 | if: | 82 | github.event_name == 'release' || 83 | (github.event_name == 'workflow_dispatch' && !inputs.dry_run) 84 | uses: softprops/action-gh-release@v2 85 | with: 86 | files: ${{ steps.wheel.outputs.wheel }} 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | 90 | - name: Inspect wheel contents (smoke test) 91 | run: | 92 | unzip -l dist/*.whl | grep -E "teamvault/static/bundled|webpack-stats.json" 93 | 94 | # For manual runs: test publish logic, but don't upload anything 95 | - name: Publish to PyPI (dry run) 96 | if: github.event_name == 'workflow_dispatch' && inputs.dry_run 97 | run: | 98 | uv publish --dry-run 99 | 100 | # For real releases: actually publish 101 | - name: Publish package distribution to PyPI with uv 102 | if: 103 | github.event_name == 'release' || 104 | (github.event_name == 'workflow_dispatch' && !inputs.dry_run) 105 | run: | 106 | uv publish --trusted-publishing always 107 | -------------------------------------------------------------------------------- /teamvault/apps/accounts/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from base64 import b64encode 3 | from hashlib import md5 4 | 5 | import requests 6 | 7 | from teamvault.apps.accounts.models import UserProfile as UserProfileModel, UserProfile 8 | from teamvault.apps.audit.models import LogEntry 9 | from teamvault.apps.secrets.models import SharedSecretData, Secret, SecretRevision 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def save_gravatar(user, *_args, **_kwargs): 15 | email_hash = md5(user.email.strip().lower().encode("utf-8")).hexdigest() 16 | resp = requests.get(f'https://gravatar.com/avatar/{email_hash}?s=200&r=g&d=mp') 17 | if resp.ok: 18 | user_settings = UserProfileModel.objects.get_or_create(user=user)[0] 19 | user_settings.avatar = b64encode(resp.content) 20 | user_settings.save() 21 | 22 | 23 | def save_google_avatar(response, user, *_args, **_kwargs): 24 | resp = requests.get(response['picture']) 25 | if resp.ok: 26 | user_settings = UserProfileModel.objects.get_or_create(user=user)[0] 27 | user_settings.avatar = b64encode(resp.content) 28 | user_settings.save() 29 | 30 | 31 | def merge_users(user1, user2, dry_run=True): 32 | logger.info( 33 | f'Merging user {user1.username} into {user2.username}\n' 34 | f'Secrets & Audit Logs will be merged. User Profiles, Social Auth data and User itself will be deleted.\n' 35 | f'Dry run: {dry_run}' 36 | ) 37 | 38 | # Secrets / SharedSecretData / SecretRevisions 39 | user1_secrets = SharedSecretData.objects.filter(user=user1) 40 | user2_secrets = SharedSecretData.objects.filter(user=user2) 41 | secrets_to_merge = user1_secrets.exclude(pk__in=user2_secrets.values_list('pk', flat=True)) 42 | logger.info(f'{secrets_to_merge.count()} Secrets found: {secrets_to_merge.values_list("pk", flat=True)}') 43 | 44 | user1_created = Secret.objects.filter(created_by=user1) 45 | logger.info(f'{user1_created.count()} Secrets w/ created_by found: {user1_created.values_list("pk", flat=True)}') 46 | 47 | user1_revisions = SecretRevision.objects.filter(set_by=user1) 48 | logger.info(f'{user1_revisions.count()} SecretRevisions found: {user1_revisions.values_list("pk", flat=True)}') 49 | 50 | # Audit Logs 51 | user1_actor_logs = LogEntry.objects.filter(actor=user1) 52 | user1_user_logs = LogEntry.objects.filter(user=user1) 53 | logger.info(f'{user1_actor_logs.count()} Actor Logs found: {user1_actor_logs.values_list("pk", flat=True)}') 54 | logger.info(f'{user1_user_logs.count()} User Logs found: {user1_user_logs.values_list("pk", flat=True)}') 55 | 56 | # User Profiles 57 | user1_profiles = UserProfile.objects.filter(user=user1) 58 | logger.info(f'{user1_profiles.count()} User Profiles found: {user1_profiles.values_list("pk", flat=True)}') 59 | 60 | # User Social Auth data 61 | user1_social_data = user1.social_auth.all().exclude(pk__in=user2.social_auth.all().values_list('pk', flat=True)) 62 | logger.info(f'{user1_social_data.count()} Social Auth data found: {user1_social_data.values_list("pk", flat=True)}') 63 | 64 | if not dry_run: 65 | secrets_to_merge.update(user=user2) 66 | user1_created.update(created_by=user2) 67 | user1_revisions.update(set_by=user2) 68 | logger.info('Updated secrets.') 69 | 70 | user1_actor_logs.update(actor=user2) 71 | user1_user_logs.update(user=user2) 72 | logger.info('Updated logs.') 73 | 74 | user1_profiles.delete() 75 | logger.info('Deleted User Profiles.') 76 | 77 | user1_social_data.delete() 78 | logger.info('Deleted Social Auth data.') 79 | 80 | user1.delete() 81 | logger.info('Deleted User') 82 | -------------------------------------------------------------------------------- /teamvault/static/scss/secrets.scss: -------------------------------------------------------------------------------- 1 | @import '@fortawesome/fontawesome-free/scss/functions'; 2 | @import '@fortawesome/fontawesome-free/scss/mixins'; 3 | @import '@fortawesome/fontawesome-free/scss/variables'; 4 | 5 | $circle_size: 314; 6 | $max_progress: 30s; 7 | 8 | .pw-uppercase { 9 | color: #999999 10 | } 11 | .pw-lowercase { 12 | color: var(--bs-body-color); 13 | } 14 | .pw-number { 15 | color: #ff3333; 16 | } 17 | .pw-symbol { 18 | color: #5982ff; 19 | } 20 | 21 | #password-field { 22 | font-family: "Ubuntu Mono", monospace; 23 | text-align: center; 24 | display: block; 25 | } 26 | 27 | .large-type { 28 | --bs-body-color: var(--bs-white); 29 | background-color: black; 30 | display: table; 31 | font-family: "Ubuntu Mono", monospace; 32 | height: 100%; 33 | left: 0; 34 | opacity: 0.9; 35 | padding: 1%; 36 | position: fixed; 37 | text-align: center; 38 | top: 0; 39 | width: 100%; 40 | z-index: 9001; 41 | 42 | > div { 43 | display: table-cell; 44 | vertical-align: middle; 45 | } 46 | 47 | > .lt-otp-countdown { 48 | > #countdown { 49 | background-color: black; 50 | display: flex; 51 | align-items: center; 52 | height: 200px; 53 | width: 200px; 54 | margin: auto; 55 | > svg { 56 | height: 200px; 57 | width: 200px; 58 | 59 | > circle { 60 | stroke-width: 3; 61 | } 62 | } 63 | 64 | > #countdown-number { 65 | color: white; 66 | font-size: 50px; 67 | margin: auto; 68 | } 69 | } 70 | } 71 | 72 | > .lt-otp { 73 | background-color: transparent; 74 | 75 | > .separator { 76 | background-color: var(--bs-accent); 77 | height: 0.25em; 78 | width: 0.25em; 79 | } 80 | } 81 | } 82 | 83 | .secret-attributes { 84 | td { 85 | /* font-size: 16px; */ 86 | padding: 0.25rem; 87 | 88 | a { 89 | color: var(--bs-info); 90 | } 91 | 92 | &:first-child { 93 | color: #708999; 94 | padding-right: 3%; 95 | text-align: right; 96 | width: 35%; 97 | } 98 | } 99 | 100 | tr { 101 | &:last-child td { 102 | vertical-align: baseline; 103 | } 104 | 105 | td:last-child button { 106 | margin-left: 0.5rem; 107 | } 108 | } 109 | } 110 | 111 | .secret-meta { 112 | td { 113 | color: var(--bs-body); 114 | 115 | &:first-child { 116 | color: #708999; 117 | padding-left: 3%; 118 | } 119 | } 120 | 121 | > .table > :not(caption) > * > * { 122 | /* Overwrite bootstrap defaults */ 123 | padding: 0.3rem 0.3rem; 124 | } 125 | } 126 | 127 | .text-success-bright { 128 | color: #44aa44; 129 | } 130 | 131 | .text-warning-bright { 132 | color: #ffa800; 133 | } 134 | 135 | .text-danger-bright { 136 | color: #bb0000; 137 | } 138 | 139 | .text-muted-bright { 140 | color: #cccccc; 141 | } 142 | 143 | .secret-detail-toggle { 144 | float: right; 145 | color: var(--bs-black); 146 | 147 | &[aria-expanded="true"] { 148 | i { 149 | @include fa-icon-solid($fa-var-minus-square) 150 | } 151 | } 152 | 153 | &[aria-expanded="false"] { 154 | i { 155 | @include fa-icon-solid($fa-var-plus-square) 156 | } 157 | } 158 | } 159 | 160 | .secret-extra-icon { 161 | background-color: var(--bs-border-color); 162 | border-radius: 50%; 163 | border: 5px solid var(--bs-border-color); 164 | margin-left: 0.35rem; 165 | } 166 | 167 | .col-form-label, .form-label { 168 | font-weight: 500; 169 | } 170 | 171 | // custom hover for "generate random secret button" 172 | [data-bs-theme="light"] #id_pwgen:hover { 173 | background: #ddd; 174 | } 175 | -------------------------------------------------------------------------------- /teamvault/apps/audit/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.db.models import TextChoices 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | class AuditLogCategoryChoices(TextChoices): 8 | SECRET_READ = 'secret_read', _('secret_read') 9 | SECRET_ELEVATED_SUPERUSER_READ = 'secret_elevated_superuser_read', _('secret_elevated_superuser_read') 10 | SECRET_PERMISSION_VIOLATION = 'secret_permission_violation', _('secret_permission_violation') 11 | SECRET_CHANGED = 'secret_changed', _('secret_changed') 12 | SECRET_METADATA_CHANGED = 'secret_metadata_changed', _('secret_metadata_changed') 13 | SECRET_RESTORED = 'secret_restored', _('secret_restored') 14 | SECRET_NEEDS_CHANGING_REMINDER = 'secret_needs_changing_reminder', _('secret_needs_changing_reminder') 15 | SECRET_SHARED = 'secret_shared', _('secret_shared') 16 | SECRET_SUPERUSER_SHARED = 'secret_superuser_shared', _('secret_superuser_shared') 17 | SECRET_SHARE_REMOVED = 'secret_share_removed', _('secret_share_removed') 18 | SECRET_SUPERUSER_SHARE_REMOVED = 'secret_superuser_share_removed', _('secret_superuser_share_removed') 19 | SECRET_ACCESS_REQUEST = 'secret_legacy_access_requests', _('secret_legacy_access_requests') 20 | 21 | USER_ACTIVATED = 'user_activated', _('user_activated') 22 | USER_DEACTIVATED = 'user_deactivated', _('user_deactivated') 23 | USER_SETTINGS_CHANGED = 'user_settings_changed', _('user_settings_changed') 24 | 25 | SHARE_AUTOMATICALLY_REVOKED = 'share_automatically_revoked', _('share_automatically_revoked') 26 | 27 | MISCELLANEOUS = 'miscellaneous', _('miscellaneous') 28 | 29 | 30 | class LogEntry(models.Model): 31 | actor = models.ForeignKey( 32 | settings.AUTH_USER_MODEL, 33 | models.PROTECT, 34 | blank=True, 35 | null=True, 36 | related_name='logged_actions', 37 | ) 38 | category = models.CharField( 39 | choices=AuditLogCategoryChoices.choices, 40 | default=AuditLogCategoryChoices.MISCELLANEOUS, 41 | max_length=64, 42 | ) 43 | group = models.ForeignKey( 44 | 'auth.Group', 45 | models.PROTECT, 46 | blank=True, 47 | null=True, 48 | related_name='logged_actions', 49 | ) 50 | message = models.TextField() 51 | reason = models.TextField( 52 | blank=True, 53 | null=True, 54 | ) 55 | secret = models.ForeignKey( 56 | 'secrets.Secret', 57 | models.PROTECT, 58 | blank=True, 59 | null=True, 60 | related_name='logged_actions', 61 | ) 62 | secret_revision = models.ForeignKey( 63 | 'secrets.SecretRevision', 64 | models.PROTECT, 65 | blank=True, 66 | null=True, 67 | related_name='logged_actions', 68 | ) 69 | time = models.DateTimeField( 70 | auto_now_add=True, 71 | ) 72 | user = models.ForeignKey( 73 | settings.AUTH_USER_MODEL, 74 | models.PROTECT, 75 | blank=True, 76 | null=True, 77 | related_name='affected_by_actions', 78 | ) 79 | 80 | class Meta: 81 | indexes = [ 82 | models.Index(fields=['category'], name='logentry_category_idx'), 83 | models.Index(fields=['time'], name='logentry_time_idx'), 84 | models.Index(fields=['category', 'time'], name='logentry_category_time_idx'), 85 | 86 | models.Index(fields=['actor'], name='logentry_actor_idx'), 87 | models.Index(fields=['group'], name='logentry_group_idx'), 88 | models.Index(fields=['secret'], name='logentry_secret_idx'), 89 | models.Index(fields=['user'], name='logentry_user_idx'), 90 | ] 91 | ordering = ('-time',) 92 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/tests/views/test_encryption_view_constraints.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | 3 | from django.test import TestCase, override_settings 4 | from django.urls import reverse 5 | 6 | from teamvault.apps.secrets.enums import ContentType 7 | from teamvault.apps.secrets.models import Secret, SecretRevision 8 | from teamvault.apps.secrets.services.revision import RevisionService 9 | from ..utils import COMMON_OVERRIDES, make_user, new_secret 10 | 11 | 12 | @override_settings(**COMMON_OVERRIDES) 13 | class EncryptionViewConstraintsTests(TestCase): 14 | @classmethod 15 | def setUpTestData(cls): 16 | cls.user = make_user("alice") 17 | cls.pass_secret: Secret = new_secret(cls.user, name="pw-secret") 18 | cls.file_bytes = b"hello-from-bytes-\xf0\x9f\x9a\x80" 19 | cls.file_secret: Secret = new_secret(cls.user, name="file-secret") 20 | cls.file_secret.content_type = ContentType.FILE 21 | cls.file_secret.filename = "hello.bin" 22 | cls.file_secret.save(update_fields=["content_type", "filename"]) 23 | RevisionService.save_payload( 24 | secret=cls.file_secret, 25 | actor=cls.user, 26 | payload={"file_content": b64encode(cls.file_bytes).decode("ascii")}, 27 | ) 28 | 29 | def test_secret_detail_page_never_leaks_plaintext(self): 30 | """The HTML detail view must not contain the decrypted payload text.""" 31 | self.client.force_login(self.user) 32 | url = reverse("secrets.secret-detail", args=[self.pass_secret.hashid]) 33 | resp = self.client.get(url) 34 | self.assertEqual(resp.status_code, 200) 35 | plaintext = self.pass_secret.current_revision.get_data(self.user)["password"].encode("utf-8") 36 | self.assertNotIn(plaintext, resp.content) 37 | 38 | def test_api_revision_data_decrypts_and_updates_last_read_and_accessed_by(self): 39 | """Calling the data API decrypts payload and updates bookkeeping.""" 40 | self.client.force_login(self.user) 41 | rev: SecretRevision = self.pass_secret.current_revision 42 | 43 | pre_last_read = rev.last_read 44 | pre_access = rev.accessed_by.count() 45 | 46 | api_url = reverse("api.secret-revision_data", kwargs={"hashid": rev.hashid}) 47 | resp = self.client.get(api_url) 48 | self.assertEqual(resp.status_code, 200) 49 | 50 | data = resp.json() 51 | self.assertEqual(data.get("password"), "initial‑pw") 52 | 53 | rev.refresh_from_db() 54 | self.assertIsNotNone(rev.last_read) 55 | self.assertTrue(pre_last_read is None or rev.last_read >= pre_last_read) 56 | self.assertGreaterEqual(rev.accessed_by.count(), pre_access) 57 | self.assertIn(self.user, rev.accessed_by.all()) 58 | 59 | def test_secret_download_returns_file_bytes_with_attachment_headers(self): 60 | """ 61 | Should return the exact file bytes as an attachment. 62 | """ 63 | self.client.force_login(self.user) 64 | url = reverse("secrets.secret-download", kwargs={"hashid": self.file_secret.hashid}) 65 | resp = self.client.get(url) 66 | self.assertEqual(resp.status_code, 200) 67 | 68 | cd = resp.headers.get("Content-Disposition", "") 69 | self.assertIn("attachment;", cd) 70 | self.assertIn("filename*=", cd) 71 | self.assertEqual(resp.content, self.file_bytes) 72 | 73 | def test_secret_list_page_does_not_render_plaintext_for_file_secret(self): 74 | """List page must not inline decrypted bytes.""" 75 | self.client.force_login(self.user) 76 | url = reverse("secrets.secret-list") 77 | resp = self.client.get(url) 78 | self.assertEqual(resp.status_code, 200) 79 | self.assertNotIn(self.file_bytes, resp.content) 80 | 81 | def test_secret_revisions_page_loads_without_plaintext(self): 82 | """History page renders without leaking plaintext.""" 83 | self.client.force_login(self.user) 84 | url = reverse("secrets.secret-revisions", args=[self.pass_secret.hashid]) 85 | resp = self.client.get(url) 86 | self.assertEqual(resp.status_code, 200) 87 | self.assertNotIn("initial-pw".encode("utf-8"), resp.content) 88 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["uv_build>=0.6,<0.7"] 3 | build-backend = "uv_build" 4 | 5 | [project] 6 | name = "teamvault" 7 | description = "Keep your passwords behind the firewall" 8 | readme = "README.md" 9 | license-files = ["LICENSE"] 10 | authors = [{ name = "Seibert Group GmbH" }] 11 | requires-python = ">=3.12" 12 | classifiers = [ 13 | "Development Status :: 3 - Alpha", 14 | "Environment :: Web Environment", 15 | "Framework :: Django", 16 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 17 | "Natural Language :: English", 18 | "Operating System :: POSIX :: Linux", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Topic :: Office/Business", 23 | "Topic :: Security", 24 | ] 25 | keywords = ["password", "safe", "manager", "sharing"] 26 | dependencies = [ 27 | "cryptography~=46.0.3", 28 | "django-auth-ldap~=5.2.0", 29 | "django-bootstrap5==25.3", 30 | "django-filter==25.2", 31 | "django-htmx~=1.26.0", 32 | "django-test-migrations>=1.5.0", 33 | "django-webpack-loader~=3.2.2", 34 | "django~=5.2.8", 35 | "djangorestframework~=3.16.1", 36 | "gunicorn~=23.0.0", 37 | "hashids~=1.3.1", 38 | "pyotp~=2.9", 39 | "huey~=2.5.4", 40 | "psycopg~=3.2.13", 41 | "pytz~=2025.2", 42 | "requests~=2.32", 43 | "social-auth-app-django~=5.6.0", 44 | "whitenoise[brotli]~=6.11.0", 45 | ] 46 | 47 | # dynamic = ["version"] - Currently unsupported by uv_build 48 | version = '0.11.6' # Also change in teamvault/__version__.py 49 | 50 | [dependency-groups] 51 | dev = [ 52 | "django-stubs~=5.2.0", 53 | "djangorestframework-stubs~=3.16.0", 54 | "faker", 55 | "ruff>=0.14.6", 56 | ] 57 | 58 | [project.scripts] 59 | teamvault = "teamvault.cli:main" 60 | 61 | [project.urls] 62 | Source = "https://github.com/seibert-media/teamvault" 63 | 64 | [tool.uv] 65 | package = true 66 | 67 | [tool.uv.build-backend] 68 | module-root = "" 69 | source-include = [ 70 | "CHANGELOG.md", 71 | "MANIFEST.in", 72 | ] 73 | 74 | [tool.uv.sources] 75 | teamvault = { workspace = true } 76 | 77 | [tool.ruff] 78 | src = ["teamvault"] 79 | target-version = "py312" 80 | line-length = 120 81 | indent-width = 4 82 | preview = true 83 | extend-exclude = [ 84 | "teamvault/apps/*/migrations" 85 | ] 86 | 87 | [tool.ruff.format] 88 | # Like Black, use single quotes for strings. 89 | quote-style = "single" 90 | 91 | [tool.ruff.lint] 92 | select = [ 93 | # Note: Preferably, we'd use the deactivated rules below, too. Once there's time, let's try to fix them one by one. 94 | # 95 | "ARG", # flake8-unused-arguments - when enabling, make sure you keep the same signature of parent methods 96 | "B", # flake8-bugbear 97 | "C4", # flake8-comprehensions 98 | "DJ", # flake-django 99 | "E", # pycodestyle errors 100 | "F", # pyflakes 101 | "G", # flake8-logging-format 102 | "FURB", # refurb - depends on enabled preview option 103 | "I", # isort 104 | "INT", # flake8-gettext 105 | "ISC", # flake8-implicit-str-concat 106 | "PERF", # perflint 107 | "PLE", # pylint errors 108 | "PLR", # pylint refactor errors 109 | "PTH", # flake8-use-pathlib 110 | "RUF100", # ruff / unused-noqa 111 | "SIM", # flake8-simplify 112 | "TCH", # flake8-type-checking 113 | "UP", # pyupgrade 114 | "W", # pycodestyle warnings 115 | ] 116 | ignore = [ 117 | "DJ008", # django-model-without-dunder-str - Often, we don't care about these 118 | "PERF401", # manual-list-comprehension - Forcing us to use list comprehensions with dicts inside them is weird. 119 | "UP015", # redundant-open-modes - We like to have the mode explicitly set in open() calls 120 | ] 121 | 122 | [tool.ruff.lint.isort] 123 | combine-as-imports = true 124 | no-lines-before = ["local-folder"] 125 | relative-imports-order = "closest-to-furthest" 126 | 127 | [tool.ruff.lint.mccabe] 128 | max-complexity = 13 129 | 130 | [tool.ruff.lint.pylint] 131 | max-args = 15 # default is 5 132 | max-branches = 28 # default is 12 133 | max-returns = 13 # default is 6 134 | max-statements = 134 # default is 50 135 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/addedit_content/cc.html: -------------------------------------------------------------------------------- 1 | {% extends "secrets/secret_addedit.html" %} 2 | {% load django_bootstrap5 %} 3 | {% load i18n %} 4 | {% block content_type_fields %} 5 |
6 |
7 | {% bootstrap_field form.number placeholder='Credit card number' layout='horizontal' horizontal_label_class="col-lg-3" horizontal_field_class="col-lg-7" %} 8 | {% bootstrap_field form.holder placeholder='Card holder' layout='horizontal' horizontal_label_class="col-lg-3" horizontal_field_class="col-lg-7" %} 9 | 10 |
11 | 12 |
13 |
14 | 19 |
20 | 25 |
26 |
27 |
28 | {% bootstrap_field form.security_code placeholder='Security Code' layout='horizontal' horizontal_label_class="col-lg-3" horizontal_field_class="col-lg-7" %} 29 | {% bootstrap_field form.password placeholder="(optional, for 3D-Secure/SecureCode)" layout="horizontal" horizontal_label_class="col-lg-3" horizontal_field_class="col-lg-7" %} 30 |
31 |
32 |
33 |
34 |
35 |
36 | 37 | {% translate "All fields in this section will be stored securely." %} 38 | 39 |
40 |
41 |
42 | {% endblock %} 43 | 44 | {% block additionalJS %} 45 | {{ block.super }} 46 | 78 | {% endblock %} 79 | -------------------------------------------------------------------------------- /teamvault/cli.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser, REMAINDER 2 | from gettext import gettext as _ 3 | from hashlib import sha1 4 | from os import environ, mkdir 5 | from shutil import rmtree 6 | from subprocess import Popen 7 | from sys import argv 8 | 9 | import django 10 | from django.core.management import execute_from_command_line, get_commands 11 | 12 | from teamvault.__version__ import __version__ 13 | from teamvault.apps.settings.config import create_default_config, UnconfiguredSettingsError 14 | 15 | 16 | def build_parser(): 17 | parser = ArgumentParser(prog="teamvault") 18 | parser.add_argument( 19 | "--version", 20 | action='version', 21 | version=__version__, 22 | ) 23 | subparsers = parser.add_subparsers( 24 | title=_("subcommands"), 25 | help=_("use 'teamvault --help' for more info"), 26 | ) 27 | 28 | environ['DJANGO_SETTINGS_MODULE'] = 'teamvault.settings' 29 | environ.setdefault("TEAMVAULT_CONFIG_FILE", "/etc/teamvault.cfg") 30 | 31 | # teamvault plumbing 32 | unconfigured_settings = False 33 | try: 34 | django.setup() 35 | except UnconfiguredSettingsError: 36 | unconfigured_settings = True 37 | 38 | commands = [k for k in get_commands()] 39 | plumbing_help = f'One of: {",".join(commands)}' 40 | if unconfigured_settings: 41 | plumbing_help += " - To see all available commands, configure teamvault settings with \"teamvault setup\"" 42 | 43 | parser_plumbing = subparsers.add_parser("plumbing") 44 | parser_plumbing.add_argument('plumbing_command', nargs=REMAINDER, help=plumbing_help) 45 | parser_plumbing.set_defaults(func=plumbing) 46 | 47 | # teamvault run 48 | parser_run = subparsers.add_parser("run") 49 | parser_run.add_argument('--bind', nargs='?', help='define bind, default is 127.0.0.1:8000') 50 | parser_run.set_defaults(func=run) 51 | 52 | # teamvault run_huey 53 | parser_run = subparsers.add_parser("run_huey") 54 | parser_run.set_defaults(func=run_huey) 55 | 56 | # teamvault setup 57 | parser_setup = subparsers.add_parser("setup") 58 | parser_setup.set_defaults(func=setup) 59 | 60 | # teamvault upgrade 61 | parser_upgrade = subparsers.add_parser("upgrade") 62 | parser_upgrade.set_defaults(func=upgrade) 63 | return parser 64 | 65 | 66 | def main(*args): 67 | """ 68 | Entry point for the 'teamvault' command line utility. 69 | 70 | args: used for integration tests 71 | """ 72 | if not args: 73 | args = argv[1:] 74 | 75 | parser = build_parser() 76 | pargs = parser.parse_args(args) 77 | if not hasattr(pargs, 'func'): 78 | parser.print_help() 79 | exit(2) 80 | pargs.func(pargs) 81 | 82 | 83 | def plumbing(pargs): 84 | execute_from_command_line([""] + pargs.plumbing_command) 85 | 86 | 87 | def run(pargs): 88 | cmd = "gunicorn --preload teamvault.wsgi:application" 89 | if pargs.bind: 90 | cmd += ' -b ' + pargs.bind 91 | 92 | print("Now open http://localhost:8000") 93 | gunicorn = Popen( 94 | cmd, 95 | shell=True, 96 | ) 97 | gunicorn.communicate() 98 | 99 | 100 | def run_huey(pargs): 101 | execute_from_command_line(["", "run_huey"]) 102 | 103 | 104 | def setup(pargs): 105 | environ.setdefault("TEAMVAULT_CONFIG_FILE", "/etc/teamvault.cfg") 106 | create_default_config(environ['TEAMVAULT_CONFIG_FILE']) 107 | 108 | 109 | def upgrade(pargs): 110 | environ['DJANGO_SETTINGS_MODULE'] = 'teamvault.settings' 111 | environ.setdefault("TEAMVAULT_CONFIG_FILE", "/etc/teamvault.cfg") 112 | 113 | print("\n### Running migrations...\n") 114 | execute_from_command_line(["", "migrate", "--noinput", "-v", "3", "--traceback"]) 115 | 116 | from django.conf import settings 117 | from .apps.settings.models import Setting 118 | 119 | if Setting.get("fernet_key_hash", default=None) is None: 120 | print("\n### Storing fernet_key hash in database...\n") 121 | key_hash = sha1(settings.TEAMVAULT_SECRET_KEY.encode('utf-8')).hexdigest() 122 | Setting.set("fernet_key_hash", key_hash) 123 | 124 | print("\n### Gathering static files...\n") 125 | try: 126 | rmtree(settings.STATIC_ROOT) 127 | except FileNotFoundError: 128 | pass 129 | mkdir(settings.STATIC_ROOT) 130 | execute_from_command_line(["", "collectstatic", "--noinput"]) 131 | 132 | print("\n### Updating search index...\n") 133 | execute_from_command_line(["", "update_search_index"]) 134 | -------------------------------------------------------------------------------- /teamvault/apps/audit/templates/audit/log.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load humanize %} 3 | {% load i18n %} 4 | {% load static %} 5 | {% load smart_pagination %} 6 | {% block title %}{% trans "Audit log" %}{% endblock %} 7 | {% block content %} 8 |
9 |
10 |
11 |

12 | {% translate "Audit log" %} 13 |

14 |
15 |
16 | {% include 'helpers/filter.html' %} 17 |
18 |
19 |
20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {% for entry in log_entries %} 35 | 36 | 37 | 38 | 39 | 42 | 48 | 49 | 50 | {% endfor %} 51 | 52 |
{% translate "Time" %}{% translate "Actor" %}{% translate "User" %}{% translate "Secret" %}{% translate "Message" %}{% translate "Category" %}
{{ entry.time|date:"Y-m-d H:i:s e" }}{% if entry.actor %}{{ entry.actor.username }}{% endif %}{{ entry.user|default_if_none:'' }}{% if entry.secret %} 40 | {{ entry.secret.name }}{% endif %} 41 | 43 | {{ entry.message }} 44 | {% if entry.reason %} 45 |
{% translate "Reason" %}: {{ entry.reason }} 46 | {% endif %} 47 |
{{ entry.category }}
53 |
54 |
55 |
56 |
57 | {% include "pagination.html" %} 58 |
59 |
60 | {% endblock %} 61 | 62 | {% block additionalJS %} 63 | 103 | {% endblock %} 104 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.9.2 2 | 3 | 2021-02-08 4 | 5 | * follow deeplinks after Google login 6 | 7 | 8 | # 0.9.1 9 | 10 | 2021-02-01 11 | 12 | * fixed missing social auth depedency 13 | 14 | 15 | # 0.9.0 16 | 17 | 2021-02-01 18 | 19 | * added Google OAuth2 20 | * added LDAP client certificate auth and StartTLS 21 | * descriptions now have line breaks and clickable links 22 | * improved LDAP debug logging 23 | * removed Share button 24 | * updated Python dependencies 25 | 26 | 27 | # 0.8.5 28 | 29 | 2020-11-13 30 | 31 | * fixed bootstrapping problem when running `teamvault upgrade` on empty DB 32 | 33 | 34 | # 0.8.4 35 | 36 | 2019-11-06 37 | 38 | * fixed sending email notifications 39 | 40 | 41 | # 0.8.3 42 | 43 | 2019-10-24 44 | 45 | * fixed creating access requests 46 | 47 | 48 | # 0.8.2 49 | 50 | 2019-10-23 51 | 52 | * fixed creating secrets by API 53 | 54 | 55 | # 0.8.1 56 | 57 | 2019-09-23 58 | 59 | * fixed packaging issue 60 | 61 | 62 | # 0.8.0 63 | 64 | 2019-09-23 65 | 66 | * added hidden URL parameters for filtering search results 67 | * replaced owners with notification settings 68 | * fixed storage of credit card CVV values as integers 69 | * fixed deleting secrets by API 70 | * fixed storing past iterations of passwords 71 | 72 | 73 | # 0.7.3 74 | 75 | 2017-03-26 76 | 77 | * fixed pagination with GET parameters 78 | 79 | 80 | # 0.7.2 81 | 82 | 2017-03-13 83 | 84 | * fixed missing opensearch.xml 85 | * improved database integrity protection 86 | 87 | 88 | # 0.7.1 89 | 90 | 2017-03-06 91 | 92 | * fixed "needs changing on leave" option 93 | * include actions on user in user audit log 94 | 95 | 96 | # 0.7.0 97 | 98 | 2017-03-05 99 | 100 | * added `teamvault run --bind` 101 | * added audit log 102 | * added OpenSearch 103 | * added user management 104 | * added user-friendly URLs to API output 105 | * removed syslog logging in favor of stdout 106 | * improved secret status diplay 107 | * fixed access request API 108 | * fixed API pagination 109 | 110 | 111 | # 0.6.1 112 | 113 | 2016-11-07 114 | 115 | * fixed an issue that prevented adding oneself to owners and allowed users 116 | 117 | 118 | # 0.6.0 119 | 120 | 2016-11-06 121 | 122 | * added search bar to every page 123 | * added secret details in access request view 124 | * added most used and recently used secrets to dashboard 125 | * added secret owners 126 | * new fonts 127 | * removed broken hotkey copy feature 128 | * fixed assignment of deactivated users as reviewers 129 | 130 | 131 | # 0.5.1 132 | 133 | 2015-10-27 134 | 135 | * added more copy confirmation messages 136 | * used brighter colors for password strength indication 137 | * fix exception when searching via API 138 | 139 | 140 | # 0.5.0 141 | 142 | 2015-10-24 143 | 144 | * added rudimentary password generator and strength meter 145 | * added 404 error pages 146 | * added secret restoration for admins 147 | * fixed revealing credit card secrets 148 | * fixed display of deleted secrets 149 | 150 | 151 | # 0.4.3 152 | 153 | 2015-05-25 154 | 155 | * show username field by default when adding passwords 156 | * fixed `teamvault upgrade` missing update_search_field 157 | * fixed typing in secret sharing modal 158 | 159 | 160 | # 0.4.2 161 | 162 | 2015-05-19 163 | 164 | * added a password copy confirmation message 165 | * improved pagination 166 | * made session settings configurable 167 | * fixed duplicate search results 168 | 169 | 170 | # 0.4.1 171 | 172 | 2015-04-15 173 | 174 | * fixed missing email templates in distribution 175 | * fixed Python 3 tag on wheel distribution 176 | * fixed exceptions not being logged 177 | * fixed exception when closing access request as non-reviewer 178 | 179 | 180 | # 0.4.0 181 | 182 | 2015-04-06 183 | 184 | * changed URLs to use hashids 185 | * added substring search for filename, URL, and username 186 | * added notification emails for access requests 187 | * fixed display of allowed users/group in secret detail view 188 | 189 | 190 | # 0.3.0 191 | 192 | 2015-02-05 193 | 194 | * added full text search 195 | * added search API 196 | * improved secret list display 197 | * added pagination for secret lists 198 | * relaxed URL validation even further 199 | 200 | 201 | # 0.2.2 202 | 203 | 2015-01-27 204 | 205 | * fixed overzealous URL validation 206 | * fixed access policy selection 207 | 208 | 209 | # 0.2.1 210 | 211 | 2015-01-20 212 | 213 | * fixed uploading of non-tiny files (#30) 214 | * fixed editing secrets without changing encrypted data (#30) 215 | 216 | 217 | # 0.2.0 218 | 219 | 2015-01-11 220 | 221 | * added file secrets 222 | * added credit card secrets 223 | * added logging to syslog 224 | * added `teamvault plumbing` command 225 | * fixed login with some WebKit-based browsers 226 | 227 | 228 | # 0.1.0 229 | 230 | 2014-12-20 231 | 232 | * first public release 233 | --------------------------------------------------------------------------------