├── .babelrc ├── .coveragerc ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .python-version ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── build.sh ├── bun.lockb ├── package.json ├── pyproject.toml ├── teamvault ├── __init__.py ├── __version__.py ├── apps │ ├── __init__.py │ ├── accounts │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── context_processors.py │ │ ├── forms.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ ├── 0002_add_user_settings.py │ │ │ ├── 0003_usersettings_avatar.py │ │ │ ├── 0004_alter_usersettings_hide_deleted_secrets.py │ │ │ ├── 0005_rename_usersettings_userprofile.py │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── templates │ │ │ └── accounts │ │ │ │ ├── _avatar.html │ │ │ │ ├── login.html │ │ │ │ ├── logout.html │ │ │ │ ├── user_detail.html │ │ │ │ ├── user_list.html │ │ │ │ └── user_settings.html │ │ ├── urls.py │ │ ├── utils.py │ │ └── views.py │ ├── audit │ │ ├── __init__.py │ │ ├── auditlog.py │ │ ├── filters.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ ├── 0002_auto_20170313_1544.py │ │ │ ├── 0003_logentry_categories.py │ │ │ ├── 0004_alter_logentry_category.py │ │ │ ├── 0005_alter_logentry_category.py │ │ │ ├── 0006_logentry_reason.py │ │ │ ├── 0007_alter_logentry_category.py │ │ │ ├── 0008_logentry_logentry_category_idx_and_more.py │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── templates │ │ │ └── audit │ │ │ │ └── log.html │ │ ├── urls.py │ │ └── views.py │ ├── secrets │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── serializers.py │ │ │ ├── urls.py │ │ │ └── views.py │ │ ├── context_processors.py │ │ ├── exceptions.py │ │ ├── filters.py │ │ ├── forms.py │ │ ├── management │ │ │ ├── __init__.py │ │ │ └── commands │ │ │ │ ├── __init__.py │ │ │ │ ├── create_fake_data.py │ │ │ │ └── update_search_index.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ ├── 0002_secret_filename.py │ │ │ ├── 0003_auto_20150113_1915.py │ │ │ ├── 0004_unaccent_extension.py │ │ │ ├── 0005_secret_search_index.py │ │ │ ├── 0006_auto_20150124_1103.py │ │ │ ├── 0007_auto_20150205_1918.py │ │ │ ├── 0008_auto_20150322_0944.py │ │ │ ├── 0009_auto_20150322_0949.py │ │ │ ├── 0011_remove_secret_search_index.py │ │ │ ├── 0012_secret_search_index.py │ │ │ ├── 0013_auto_20161021_1411.py │ │ │ ├── 0014_auto_20170313_1544.py │ │ │ ├── 0015_secretrevision_plaintext_data_sha256.py │ │ │ ├── 0016_auto_20180220_1053.py │ │ │ ├── 0017_auto_20180220_1115.py │ │ │ ├── 0018_auto_20180220_1244.py │ │ │ ├── 0019_secret_notify_on_access_request.py │ │ │ ├── 0020_auto_20180220_1356.py │ │ │ ├── 0021_auto_20180220_1428.py │ │ │ ├── 0022_secret_last_changed.py │ │ │ ├── 0023_auto_20190822_1234.py │ │ │ ├── 0024_auto_20210824_1203.py │ │ │ ├── 0025_alter_secret_description_alter_secret_name.py │ │ │ ├── 0026_allowed_groups_users_intermediate.py │ │ │ ├── 0027_sharedsecretdata_only_one_set.py │ │ │ ├── 0028_sharedsecretdata_grant_description_and_more.py │ │ │ ├── 0029_sharedsecretdata_granted_by.py │ │ │ ├── 0030_sharedsecretdata_granted_on.py │ │ │ ├── 0031_rename_share_data_fields.py │ │ │ ├── 0032_alter_sharedsecretdata_granted_by.py │ │ │ ├── 0033_secretrevision_encrypted_otp_key.py │ │ │ ├── 0034_remove_secretrevision_encrypted_otp_key_and_more.py │ │ │ ├── 0035_remove_secretrevision_encrypted_otp_key_data_and_more.py │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── tasks.py │ │ ├── templates │ │ │ ├── opensearch.xml │ │ │ └── secrets │ │ │ │ ├── addedit_content │ │ │ │ ├── cc.html │ │ │ │ ├── file.html │ │ │ │ └── password.html │ │ │ │ ├── dashboard.html │ │ │ │ ├── detail_content │ │ │ │ ├── _js.html │ │ │ │ ├── _su_confirm_modal.html │ │ │ │ ├── cc.html │ │ │ │ ├── file.html │ │ │ │ ├── meta.html │ │ │ │ └── password.html │ │ │ │ ├── search │ │ │ │ └── _search_item.html │ │ │ │ ├── secret_addedit.html │ │ │ │ ├── secret_delete.html │ │ │ │ ├── secret_detail.html │ │ │ │ ├── secret_list.html │ │ │ │ ├── secret_restore.html │ │ │ │ ├── secret_row.html │ │ │ │ ├── secret_search.html │ │ │ │ └── share_content │ │ │ │ ├── _share_list_entry.html │ │ │ │ └── share_list_modal.html │ │ ├── templatetags │ │ │ ├── __init__.py │ │ │ └── smart_pagination.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ └── test_model_consistency.py │ │ ├── urls.py │ │ ├── utils.py │ │ ├── validators.py │ │ └── views.py │ └── settings │ │ ├── __init__.py │ │ ├── config.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ │ ├── models.py │ │ └── webpack.py ├── cli.py ├── manage.py ├── middleware.py ├── settings.py ├── static │ ├── js │ │ ├── index.js │ │ ├── otp.js │ │ └── utils.js │ └── scss │ │ ├── avatars.scss │ │ ├── base.scss │ │ ├── card.scss │ │ ├── circularProgressbar.scss │ │ ├── fontawesome.scss │ │ ├── scrollbar.scss │ │ ├── search.scss │ │ ├── secrets.scss │ │ ├── select2.scss │ │ └── theme.scss ├── templates │ ├── 404_anon.html │ ├── 404_loggedin.html │ ├── base.html │ ├── base_nav.html │ ├── helpers │ │ ├── filter.html │ │ ├── filter_item.html │ │ └── filter_row.html │ ├── pagination.html │ └── rest_framework │ │ └── api.html ├── urls.py ├── views.py └── wsgi.py ├── uv.lock ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/syntax-dynamic-import"], 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "modules": false 8 | } 9 | ] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = teamvault 4 | 5 | [report] 6 | omit = */migrations/* 7 | 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | build/ 3 | dist/ 4 | huey.db 5 | teamvault.cfg 6 | teamvault/static/bundled/ 7 | teamvault/static_collected/ 8 | teamvault/webpack-stats.json 9 | package-lock.json 10 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if command -v bun &> /dev/null; then 4 | BUILDER="bun" 5 | elif command -v yarn &> /dev/null; then 6 | BUILDER="yarn" 7 | else 8 | BUILDER="npm" 9 | fi 10 | 11 | PROJECT_DIR="$(dirname "$0")" 12 | PKG_DIR="$PROJECT_DIR/dist" 13 | 14 | cd "$PROJECT_DIR" 15 | 16 | printf ">> Cleaning up old build packages...\n\n" 17 | [ -e "$PKG_DIR" ] && printf "Deleting existing dist in $PKG_DIR...\n" && rm -r "$PKG_DIR" 18 | 19 | printf ">> Creating webpack bundle via $BUILDER...\n\n" 20 | $BUILDER run build 21 | [ $? -eq 0 ] && printf "\n>> Bundle created.\n" 22 | 23 | printf ">> Generating python package...\n\n" 24 | uv build --no-sources 25 | [ $? -eq 0 ] && printf "\n>> Generated python package in dist/." 26 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seibert-media/teamvault/9e857487675b27ed8a018a665b72a55aa253ec13/bun.lockb -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "teamvault", 3 | "dependencies": { 4 | "@eonasdan/tempus-dominus": "^6.9.10", 5 | "@fontsource/poppins": "^5.0.14", 6 | "@fortawesome/fontawesome-free": "^6.6.0", 7 | "@popperjs/core": "^2.11.8", 8 | "@tarekraafat/autocomplete.js": "^10.2.7", 9 | "@zxcvbn-ts/core": "^3.0.4", 10 | "@zxcvbn-ts/language-common": "^3.0.4", 11 | "@zxcvbn-ts/language-en": "^3.0.2", 12 | "bigtext": "https://github.com/zachleat/BigText", 13 | "bootstrap": "^5.3.3", 14 | "card": "^2.5.4", 15 | "clipboard": "^2.0.11", 16 | "core-js": "^3.38.0", 17 | "dompurify": "^3.1.6", 18 | "esbuild": "^0.20.0", 19 | "htmx.org": "^1.9.12", 20 | "jquery": "^3.7.1", 21 | "jsqr": "^1.4.0", 22 | "lodash": "^4.17.21", 23 | "notyf": "^3.10.0", 24 | "select2": "4.0.13", 25 | "select2-bootstrap-5-theme": "^1.3.0" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.25.2", 29 | "@babel/preset-env": "^7.25.3", 30 | "@webpack-cli/generators": "^3.0.7", 31 | "babel-loader": "^9.1.3", 32 | "css-loader": "^6.11.0", 33 | "mini-css-extract-plugin": "^2.9.0", 34 | "sass-embedded": "^1.86.0", 35 | "sass-loader": "^16.0.5", 36 | "style-loader": "^3.3.4", 37 | "webpack": "^5.93.0", 38 | "webpack-bundle-tracker": "^3.1.0", 39 | "webpack-cli": "^5.1.4", 40 | "webpack-dev-server": "^5.0.4", 41 | "webpack-merge": "^6.0.1" 42 | }, 43 | "packageChangelogComments": { 44 | "@eonasdan/tempus-dominus": "https://github.com/Eonasdan/tempus-dominus/releases", 45 | "@fontsource/poppins": "https://github.com/fontsource/fontsource/blob/main/CHANGELOG.md", 46 | "@fortawesome/fontawesome-free": "https://fontawesome.com/changelog", 47 | "@popperjs/core": "https://github.com/floating-ui/floating-ui/releases", 48 | "@tarekraafat/autocomplete.js": "https://github.com/TarekRaafat/autoComplete.js/releases", 49 | "@zxcvbn-ts/core": "https://github.com/zxcvbn-ts/zxcvbn/blob/master/packages/libraries/main/CHANGELOG.md", 50 | "@zxcvbn-ts/language-common": "https://github.com/zxcvbn-ts/zxcvbn/blob/master/packages/libraries/main/CHANGELOG.md", 51 | "@zxcvbn-ts/language-en": "https://github.com/zxcvbn-ts/zxcvbn/blob/master/packages/libraries/main/CHANGELOG.md", 52 | "bigtext": "No updates since 7 years", 53 | "bootstrap": "https://github.com/twbs/bootstrap/releases", 54 | "card": "https://github.com/jessepollak/card/releases", 55 | "clipboard": "https://github.com/zenorocha/clipboard.js/releases", 56 | "core-js": "https://github.com/zloirock/core-js/blob/master/CHANGELOG.md", 57 | "dompurify": "https://github.com/cure53/DOMPurify/releases", 58 | "esbuild": "https://github.com/evanw/esbuild/blob/main/CHANGELOG.md", 59 | "htmx.org": "https://github.com/bigskysoftware/htmx/blob/master/CHANGELOG.md", 60 | "jquery": "https://github.com/jquery/jquery/blob/main/changelog.md", 61 | "lodash": "https://github.com/lodash/lodash/wiki/Changelog", 62 | "notyf": "https://github.com/caroso1222/notyf/blob/master/CHANGELOG.md", 63 | "select2": "https://github.com/select2/select2/blob/develop/CHANGELOG.md", 64 | "select2-bootstrap-5-theme": "https://github.com/apalfrey/select2-bootstrap-5-theme/releases", 65 | "@babel/core": "https://github.com/babel/babel/blob/main/CHANGELOG.md", 66 | "@babel/preset-env": "https://github.com/babel/babel/blob/main/CHANGELOG.md", 67 | "@webpack-cli/generators": "https://github.com/webpack/webpack-cli/blob/master/CHANGELOG.md", 68 | "babel-loader": "https://github.com/babel/babel-loader/blob/main/CHANGELOG.md", 69 | "css-loader": "https://github.com/webpack-contrib/css-loader/blob/master/CHANGELOG.md", 70 | "mini-css-extract-plugin": "https://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/CHANGELOG.md", 71 | "sass(-embedded)": "https://github.com/sass/dart-sass/blob/main/CHANGELOG.md", 72 | "sass-loader": "https://github.com/webpack-contrib/sass-loader/blob/master/CHANGELOG.md", 73 | "style-loader": "https://github.com/webpack-contrib/style-loader/blob/master/CHANGELOG.md", 74 | "webpack": "https://github.com/webpack/webpack/releases", 75 | "webpack-bundle-tracker": "https://github.com/django-webpack/webpack-bundle-tracker/releases", 76 | "webpack-cli": "https://github.com/webpack/webpack-cli/blob/master/CHANGELOG.md", 77 | "webpack-dev-server": "https://github.com/webpack/webpack-dev-server/blob/master/CHANGELOG.md", 78 | "webpack-merge": "https://github.com/survivejs/webpack-merge/blob/develop/CHANGELOG.md" 79 | }, 80 | "version": "1.0.0", 81 | "description": "Teamvault", 82 | "scripts": { 83 | "build": "webpack --config webpack.prod.js", 84 | "serve": "webpack serve --config webpack.dev.js" 85 | }, 86 | "trustedDependencies": [ 87 | "core-js", 88 | "esbuild" 89 | ] 90 | } 91 | -------------------------------------------------------------------------------- /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.10" 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.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Topic :: Office/Business", 24 | "Topic :: Security", 25 | ] 26 | keywords = ["password", "safe", "manager", "sharing"] 27 | dependencies = [ 28 | "cryptography~=42.0", 29 | "django-auth-ldap~=4.6", 30 | "django-bootstrap5==23.4", 31 | "django-filter==23.5", 32 | "django-htmx~=1.17", 33 | "django-webpack-loader~=3.0", 34 | "django~=5.1.8", 35 | "djangorestframework~=3.14", 36 | "gunicorn~=21.2", 37 | "hashids~=1.3", 38 | "pyotp~=2.9", 39 | "huey~=2.5", 40 | "psycopg~=3.2", 41 | "pytz~=2024.2", 42 | "requests~=2.32", 43 | "social-auth-app-django~=5.4", 44 | "whitenoise[brotli]~=6.6", 45 | ] 46 | 47 | # dynamic = ["version"] - Currently unsupported by uv_build 48 | version = '1.0.0rc6' # Also change in teamvault/__version__.py 49 | 50 | [dependency-groups] 51 | dev = [ 52 | "django-stubs~=5.1", 53 | "djangorestframework-stubs~=3.15", 54 | "faker", 55 | ] 56 | 57 | [project.scripts] 58 | teamvault = "teamvault.cli:main" 59 | 60 | [project.urls] 61 | Source = "https://github.com/seibert-media/teamvault" 62 | 63 | [tool.uv] 64 | package = true 65 | 66 | [tool.uv.build-backend] 67 | module-root = "" 68 | source-include = [ 69 | "CHANGELOG.md", 70 | "MANIFEST.in", 71 | ] 72 | 73 | [tool.uv.sources] 74 | teamvault = { workspace = true } 75 | -------------------------------------------------------------------------------- /teamvault/__init__.py: -------------------------------------------------------------------------------- 1 | from teamvault.__version__ import __version__ 2 | -------------------------------------------------------------------------------- /teamvault/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.0rc6" # Also change in pyproject.toml 2 | -------------------------------------------------------------------------------- /teamvault/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seibert-media/teamvault/9e857487675b27ed8a018a665b72a55aa253ec13/teamvault/apps/__init__.py -------------------------------------------------------------------------------- /teamvault/apps/accounts/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | name = 'teamvault.apps.accounts' 6 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seibert-media/teamvault/9e857487675b27ed8a018a665b72a55aa253ec13/teamvault/apps/accounts/migrations/__init__.py -------------------------------------------------------------------------------- /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/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/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/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/accounts/templates/accounts/user_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load smart_pagination %} 4 | {% block title %}{% trans "Users" %}{% endblock %} 5 | {% block content %} 6 |
7 |
8 |

9 | {% trans "Users" %} 10 |

11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for user in users %} 22 | 23 | 26 | 29 | 36 | 43 | 44 | 45 | {% endfor %} 46 |
{% trans "Username" %}{% trans "Email" %}{% trans "Active" %}{% trans "Admin" %}{% trans "Last login" %}
24 | {{ user.username }} 25 | 27 | {{ user.email }} 28 | 30 | {% if user.is_active %} 31 | 32 | {% else %} 33 | 34 | {% endif %} 35 | 37 | {% if user.is_superuser %} 38 | 39 | {% else %} 40 | 41 | {% endif %} 42 | {{ user.last_login|date:"Y-m-d H:i:s e" }}
47 |
48 |
49 | {% include "pagination.html" %} 50 |
51 |
52 | {% endblock %} 53 | -------------------------------------------------------------------------------- /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/apps/accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.views import LoginView, LogoutView 2 | from django.urls import path 3 | 4 | from . import views 5 | 6 | urlpatterns = ( 7 | path( 8 | 'login/', 9 | LoginView.as_view(template_name="accounts/login.html"), 10 | name='accounts.login', 11 | ), 12 | path( 13 | 'logout/', 14 | LogoutView.as_view(template_name="accounts/logout.html"), 15 | name='accounts.logout', 16 | ), 17 | path( 18 | 'users/', 19 | views.users, 20 | name='accounts.user-list', 21 | ), 22 | path( 23 | 'users//', 24 | views.user_detail, 25 | name='accounts.user-detail', 26 | ), 27 | path( 28 | 'users//reactivate', 29 | views.user_activate, 30 | name='accounts.user-reactivate', 31 | ), 32 | path( 33 | 'users//deactivate', 34 | views.user_activate, 35 | {'deactivate': True}, 36 | name='accounts.user-deactivate', 37 | ), 38 | path( 39 | 'settings/', 40 | views.user_settings, 41 | name='accounts.user-settings', 42 | ), 43 | ) 44 | -------------------------------------------------------------------------------- /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/apps/accounts/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.contrib.auth.decorators import user_passes_test, login_required 3 | from django.contrib.auth.models import User 4 | from django.db import transaction 5 | from django.http import HttpResponseRedirect 6 | from django.shortcuts import get_object_or_404 7 | from django.urls import reverse, reverse_lazy 8 | from django.utils.translation import gettext_lazy as _ 9 | from django.views.decorators.http import require_http_methods 10 | from django.views.generic import DetailView, ListView, UpdateView 11 | 12 | from .forms import UserProfileForm 13 | from .models import UserProfile as UserProfileModel 14 | from ..audit.auditlog import log 15 | from ..audit.models import AuditLogCategoryChoices 16 | from ..secrets.models import Secret, SecretRevision 17 | 18 | 19 | class UserProfile(UpdateView): 20 | form_class = UserProfileForm 21 | model = UserProfileModel 22 | template_name = "accounts/user_settings.html" 23 | success_url = reverse_lazy('accounts.user-settings') 24 | 25 | def get_object(self, *args, **kwargs): 26 | return UserProfileModel.objects.get_or_create(user=self.request.user)[0] 27 | 28 | def form_valid(self, form): 29 | response = super().form_valid(form) 30 | messages.success(self.request, _('Successfully updated settings.')) 31 | return response 32 | 33 | 34 | user_settings = login_required(UserProfile.as_view()) 35 | 36 | 37 | class UserList(ListView): 38 | context_object_name = 'users' 39 | paginate_by = 25 40 | template_name = "accounts/user_list.html" 41 | 42 | def get_queryset(self): 43 | return User.objects.order_by('username') 44 | 45 | 46 | users = user_passes_test(lambda u: u.is_superuser)(UserList.as_view()) 47 | 48 | 49 | class UserDetail(DetailView): 50 | context_object_name = 'user' 51 | model = User 52 | slug_field = 'username' 53 | slug_url_kwarg = 'username' 54 | template_name = 'accounts/user_detail.html' 55 | 56 | 57 | user_detail = user_passes_test(lambda u: u.is_superuser)(UserDetail.as_view()) 58 | 59 | 60 | @user_passes_test(lambda u: u.is_superuser) 61 | @require_http_methods(["POST"]) 62 | def user_activate(request, username, deactivate=False): 63 | user = get_object_or_404( 64 | User, 65 | username=username, 66 | is_active=deactivate, 67 | ) 68 | user.is_active = not deactivate 69 | user.save() 70 | if deactivate: 71 | user.groups.clear() 72 | accessed_revs = SecretRevision.objects.filter( 73 | accessed_by=user, 74 | ).exclude( 75 | secret__needs_changing_on_leave=False, 76 | ).exclude( 77 | secret__status=Secret.STATUS_NEEDS_CHANGING, 78 | ).select_related( 79 | 'secret', 80 | ) 81 | secrets = set() 82 | for rev in accessed_revs: 83 | if rev.is_current_revision: 84 | secrets.add(rev.secret) 85 | with transaction.atomic(): 86 | for secret in secrets: 87 | secret.status = Secret.STATUS_NEEDS_CHANGING 88 | secret.save() 89 | msg = _( 90 | "secret '{secret}' needs changing because user '{user}' was deactivated" 91 | ).format( 92 | secret=secret.name, 93 | user=user.username, 94 | ) 95 | log( 96 | msg, 97 | actor=request.user, 98 | category=AuditLogCategoryChoices.USER_DEACTIVATED, 99 | secret=secret, 100 | user=user, 101 | ) 102 | log( 103 | _("{actor} deactivated {user}, {secrets} secrets marked for changing").format( 104 | actor=request.user.username, 105 | user=user.username, 106 | secrets=len(secrets), 107 | ), 108 | actor=request.user, 109 | category=AuditLogCategoryChoices.USER_DEACTIVATED, 110 | user=user, 111 | ) 112 | else: 113 | log( 114 | _("{actor} reactivated {user}").format( 115 | actor=request.user.username, 116 | user=user.username, 117 | ), 118 | actor=request.user, 119 | category=AuditLogCategoryChoices.USER_ACTIVATED, 120 | user=user, 121 | ) 122 | return HttpResponseRedirect(reverse('accounts.user-detail', kwargs={'username': user.username})) 123 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /teamvault/apps/audit/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seibert-media/teamvault/9e857487675b27ed8a018a665b72a55aa253ec13/teamvault/apps/audit/migrations/__init__.py -------------------------------------------------------------------------------- /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_NEEDS_CHANGING_REMINDER = 'secret_needs_changing_reminder', _('secret_needs_changing_reminder') 13 | SECRET_SHARED = 'secret_shared', _('secret_shared') 14 | SECRET_SUPERUSER_SHARED = 'secret_superuser_shared', _('secret_superuser_shared') 15 | SECRET_SHARE_REMOVED = 'secret_share_removed', _('secret_share_removed') 16 | SECRET_SUPERUSER_SHARE_REMOVED = 'secret_superuser_share_removed', _('secret_superuser_share_removed') 17 | SECRET_ACCESS_REQUEST = 'secret_legacy_access_requests', _('secret_legacy_access_requests') 18 | 19 | USER_ACTIVATED = 'user_activated', _('user_activated') 20 | USER_DEACTIVATED = 'user_deactivated', _('user_deactivated') 21 | USER_SETTINGS_CHANGED = 'user_settings_changed', _('user_settings_changed') 22 | 23 | SHARE_AUTOMATICALLY_REVOKED = 'share_automatically_revoked', _('share_automatically_revoked') 24 | 25 | MISCELLANEOUS = 'miscellaneous', _('miscellaneous') 26 | 27 | 28 | class LogEntry(models.Model): 29 | actor = models.ForeignKey( 30 | settings.AUTH_USER_MODEL, 31 | models.PROTECT, 32 | blank=True, 33 | null=True, 34 | related_name='logged_actions', 35 | ) 36 | category = models.CharField( 37 | choices=AuditLogCategoryChoices.choices, 38 | default=AuditLogCategoryChoices.MISCELLANEOUS, 39 | max_length=64, 40 | ) 41 | group = models.ForeignKey( 42 | 'auth.Group', 43 | models.PROTECT, 44 | blank=True, 45 | null=True, 46 | related_name='logged_actions', 47 | ) 48 | message = models.TextField() 49 | reason = models.TextField( 50 | blank=True, 51 | null=True, 52 | ) 53 | secret = models.ForeignKey( 54 | 'secrets.Secret', 55 | models.PROTECT, 56 | blank=True, 57 | null=True, 58 | related_name='logged_actions', 59 | ) 60 | secret_revision = models.ForeignKey( 61 | 'secrets.SecretRevision', 62 | models.PROTECT, 63 | blank=True, 64 | null=True, 65 | related_name='logged_actions', 66 | ) 67 | time = models.DateTimeField( 68 | auto_now_add=True, 69 | ) 70 | user = models.ForeignKey( 71 | settings.AUTH_USER_MODEL, 72 | models.PROTECT, 73 | blank=True, 74 | null=True, 75 | related_name='affected_by_actions', 76 | ) 77 | 78 | class Meta: 79 | indexes = [ 80 | models.Index(fields=['category'], name='logentry_category_idx'), 81 | models.Index(fields=['time'], name='logentry_time_idx'), 82 | models.Index(fields=['category', 'time'], name='logentry_category_time_idx'), 83 | 84 | models.Index(fields=['actor'], name='logentry_actor_idx'), 85 | models.Index(fields=['group'], name='logentry_group_idx'), 86 | models.Index(fields=['secret'], name='logentry_secret_idx'), 87 | models.Index(fields=['user'], name='logentry_user_idx'), 88 | ] 89 | ordering = ('-time',) 90 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SecretsConfig(AppConfig): 5 | name = 'teamvault.apps.secrets' 6 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seibert-media/teamvault/9e857487675b27ed8a018a665b72a55aa253ec13/teamvault/apps/secrets/api/__init__.py -------------------------------------------------------------------------------- /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/secrets/context_processors.py: -------------------------------------------------------------------------------- 1 | from teamvault.__version__ import __version__ 2 | 3 | 4 | def version(request): 5 | return {'version': __version__} 6 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/exceptions.py: -------------------------------------------------------------------------------- 1 | class PermissionError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /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 | 13 | User = get_user_model() 14 | 15 | 16 | def add_tooltip(label, tooltip_message): 17 | return format_html( 18 | '{} ' 19 | '' 21 | '', 22 | label, 23 | tooltip_message 24 | ) 25 | 26 | 27 | class Icons(enum.Enum): 28 | CREDIT_CARD = "fa-credit-card text-secondary" 29 | DELETED_DANGER = "fa-trash text-danger" 30 | FILE = "fa-file text-secondary" 31 | KEY = "fa-key text-secondary" 32 | REFRESH_DANGER = "fa-refresh text-danger" 33 | 34 | @property 35 | def html(self): 36 | return f' ' 37 | 38 | 39 | class ContentTypeChoice(IntegerChoices): 40 | # TODO: Merge CONTENT_* vars with these ones. 41 | # Preferably migrate occurances of Secret.CONTENT_CHOICES to this class 42 | PASSWORD = Secret.CONTENT_PASSWORD, mark_safe(Icons.KEY.html + _('Password')) 43 | CREDIT_CARD = Secret.CONTENT_CC, mark_safe(Icons.CREDIT_CARD.html + _('Credit Card')) 44 | FILE = Secret.CONTENT_FILE, mark_safe(Icons.FILE.html + _('File')) 45 | 46 | 47 | class StatusChoices(IntegerChoices): 48 | # TODO: Merge STATUS_* vars with these ones. 49 | # Preferably migrate occurances of Secret.STATUS_CHOICES to this class 50 | OK = Secret.STATUS_OK, mark_safe(Icons.KEY.html + _('Regular')) 51 | NEEDS_CHANGING = Secret.STATUS_NEEDS_CHANGING, mark_safe(Icons.REFRESH_DANGER.html + _('Needs Changing')) 52 | DELETED = Secret.STATUS_DELETED, mark_safe(Icons.DELETED_DANGER.html) + f"{add_tooltip( _('Deleted'),_('Hide deleted secrets per default by changing your settings.'))}" 53 | 54 | 55 | class SecretFilter(django_filters.FilterSet): 56 | content_type = django_filters.MultipleChoiceFilter( 57 | choices=ContentTypeChoice, 58 | widget=forms.CheckboxSelectMultiple, 59 | label=_('Type') 60 | ) 61 | status = django_filters.MultipleChoiceFilter( 62 | choices=StatusChoices, 63 | widget=forms.CheckboxSelectMultiple, 64 | label=_('Status') 65 | ) 66 | created_by = django_filters.ModelChoiceFilter( 67 | queryset=User.objects.all().order_by('username'), 68 | ) 69 | 70 | class Meta: 71 | model = Secret 72 | fields = ['content_type', 'status', 'created_by'] 73 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seibert-media/teamvault/9e857487675b27ed8a018a665b72a55aa253ec13/teamvault/apps/secrets/management/__init__.py -------------------------------------------------------------------------------- /teamvault/apps/secrets/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seibert-media/teamvault/9e857487675b27ed8a018a665b72a55aa253ec13/teamvault/apps/secrets/management/commands/__init__.py -------------------------------------------------------------------------------- /teamvault/apps/secrets/management/commands/create_fake_data.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | from datetime import timedelta 4 | from hashlib import sha256 5 | 6 | from cryptography.fernet import Fernet 7 | from django.conf import settings 8 | from django.core.management.base import BaseCommand 9 | from django.utils import timezone 10 | 11 | from django.contrib.auth.models import User, Group 12 | from teamvault.apps.secrets.models import Secret, SecretRevision, SharedSecretData 13 | 14 | 15 | class Command(BaseCommand): 16 | help = 'Create fake users and secrets' 17 | 18 | def handle(self, *args, **kwargs): 19 | from faker import Faker 20 | 21 | fake = Faker() 22 | if not hasattr(settings, 'TEAMVAULT_SECRET_KEY'): 23 | self.stderr.write(self.style.ERROR('TEAMVAULT_SECRET_KEY is not set in settings.')) 24 | return 25 | 26 | try: 27 | fernet = Fernet(settings.TEAMVAULT_SECRET_KEY) 28 | except Exception as e: 29 | self.stderr.write(self.style.ERROR(f'Invalid TEAMVAULT_SECRET_KEY: {e}')) 30 | return 31 | 32 | groups = [] 33 | for _ in range(5): 34 | group_name = fake.unique.word().capitalize() 35 | group, created = Group.objects.get_or_create(name=group_name) 36 | groups.append(group) 37 | self.stdout.write(self.style.SUCCESS(f'Created {len(groups)} groups.')) 38 | 39 | # Create 50 fake users 40 | users = [] 41 | for _ in range(50): 42 | try: 43 | username = fake.unique.user_name() 44 | email = fake.unique.email() 45 | password = fake.password(length=12) 46 | user = User.objects.create_user(username=username, email=email, password=password) 47 | # Optionally, assign user to random groups 48 | if groups: 49 | user_groups = random.sample(groups, k=random.randint(0, len(groups))) 50 | user.groups.set(user_groups) 51 | user.save() 52 | users.append(user) 53 | except Exception as e: 54 | self.stderr.write(self.style.ERROR(f'Error creating user: {e}')) 55 | self.stdout.write(self.style.SUCCESS('Created 50 fake users.')) 56 | 57 | # Create secrets for each user 58 | for user in users: 59 | for _ in range(10): 60 | try: 61 | secret_name = fake.unique.word().capitalize() 62 | description = fake.sentence(nb_words=10) 63 | url = fake.url() 64 | username_field = fake.user_name() 65 | # Create the Secret object 66 | secret = Secret.objects.create( 67 | name=secret_name, 68 | description=description, 69 | url=url, 70 | username=username_field, 71 | content_type=Secret.CONTENT_PASSWORD, 72 | created_by=user, 73 | status=Secret.STATUS_OK, 74 | ) 75 | # Optionally, share the secret with random groups/users 76 | if groups and random.choice([True, False]): 77 | group = random.choice(groups) 78 | SharedSecretData.objects.create( 79 | group=group, 80 | secret=secret, 81 | grant_description='Shared via fake data script', 82 | granted_by=user, 83 | granted_until=timezone.now() + timedelta(days=30) 84 | ) 85 | # Create a SecretRevision 86 | plaintext_password = fake.password(length=12) 87 | plaintext_data = { 88 | 'password': plaintext_password 89 | } 90 | plaintext_data_json = json.dumps(plaintext_data) 91 | plaintext_data_sha256 = sha256(plaintext_data_json.encode('utf-8')).hexdigest() 92 | encrypted_data = fernet.encrypt(plaintext_data_json.encode('utf-8')) 93 | secret_revision = SecretRevision.objects.create( 94 | secret=secret, 95 | set_by=user, 96 | encrypted_data=encrypted_data, 97 | length=len(plaintext_password), 98 | plaintext_data_sha256=plaintext_data_sha256, 99 | ) 100 | # Assign the current_revision 101 | secret.current_revision = secret_revision 102 | secret.last_changed = timezone.now() 103 | secret.last_read = timezone.now() 104 | secret.save() 105 | except Exception as e: 106 | self.stderr.write(self.style.ERROR(f'Error creating secret for user {user.username}: {e}')) 107 | self.stdout.write(self.style.SUCCESS('Created secrets and secret revisions for all users.')) 108 | -------------------------------------------------------------------------------- /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/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 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ('auth', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='AccessRequest', 19 | fields=[ 20 | ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), 21 | ('closed', models.DateTimeField(null=True, blank=True)), 22 | ('created', models.DateTimeField(auto_now_add=True)), 23 | ('reason_request', models.TextField(null=True, blank=True)), 24 | ('reason_rejected', models.TextField(null=True, blank=True)), 25 | ('status', models.PositiveSmallIntegerField(default=1, choices=[(1, 'pending'), (2, 'rejected'), (3, 'approved')])), 26 | ('closed_by', models.ForeignKey(null=True, blank=True, on_delete=models.CASCADE, related_name='access_requests_closed', to=settings.AUTH_USER_MODEL)), 27 | ('requester', models.ForeignKey(on_delete=models.CASCADE, to=settings.AUTH_USER_MODEL, related_name='access_requests_created')), 28 | ('reviewers', models.ManyToManyField(to=settings.AUTH_USER_MODEL, related_name='access_requests_reviewed')), 29 | ], 30 | options={ 31 | 'ordering': ('-created',), 32 | }, 33 | bases=(models.Model,), 34 | ), 35 | migrations.CreateModel( 36 | name='Secret', 37 | fields=[ 38 | ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), 39 | ('access_policy', models.PositiveSmallIntegerField(default=1, choices=[(1, 'request'), (2, 'everyone'), (3, 'hidden')])), 40 | ('content_type', models.PositiveSmallIntegerField(default=1, choices=[(1, 'Password'), (2, 'Credit Card'), (3, 'File')])), 41 | ('created', models.DateTimeField(auto_now_add=True)), 42 | ('description', models.TextField(null=True, blank=True)), 43 | ('last_read', models.DateTimeField(default=django.utils.timezone.now)), 44 | ('name', models.CharField(max_length=92)), 45 | ('needs_changing_on_leave', models.BooleanField(default=True)), 46 | ('status', models.PositiveSmallIntegerField(default=1, choices=[(1, 'OK'), (2, 'needs changing'), (3, 'deleted')])), 47 | ('url', models.URLField(null=True, blank=True)), 48 | ('username', models.CharField(null=True, max_length=255, blank=True)), 49 | ('allowed_groups', models.ManyToManyField(to='auth.Group', blank=True, related_name='allowed_passwords')), 50 | ('allowed_users', models.ManyToManyField(to=settings.AUTH_USER_MODEL, blank=True, related_name='allowed_passwords')), 51 | ('created_by', models.ForeignKey(on_delete=models.CASCADE, to=settings.AUTH_USER_MODEL, related_name='passwords_created')), 52 | ], 53 | options={ 54 | 'ordering': ('name',), 55 | }, 56 | bases=(models.Model,), 57 | ), 58 | migrations.CreateModel( 59 | name='SecretRevision', 60 | fields=[ 61 | ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), 62 | ('created', models.DateTimeField(auto_now_add=True)), 63 | ('encrypted_data', models.BinaryField()), 64 | ('length', models.PositiveIntegerField(default=0)), 65 | ('accessed_by', models.ManyToManyField(to=settings.AUTH_USER_MODEL)), 66 | ('secret', models.ForeignKey(on_delete=models.CASCADE, to='secrets.Secret')), 67 | ('set_by', models.ForeignKey(on_delete=models.CASCADE, to=settings.AUTH_USER_MODEL, related_name='password_revisions_set')), 68 | ], 69 | options={ 70 | 'ordering': ('-created',), 71 | }, 72 | bases=(models.Model,), 73 | ), 74 | migrations.AlterUniqueTogether( 75 | name='secretrevision', 76 | unique_together=set([('encrypted_data', 'secret')]), 77 | ), 78 | migrations.AddField( 79 | model_name='secret', 80 | name='current_revision', 81 | field=models.ForeignKey(null=True, blank=True, on_delete=models.CASCADE, related_name='_password_current_revision', to='secrets.SecretRevision'), 82 | preserve_default=True, 83 | ), 84 | migrations.AddField( 85 | model_name='accessrequest', 86 | name='secret', 87 | field=models.ForeignKey(on_delete=models.CASCADE, to='secrets.Secret', related_name='access_requests'), 88 | preserve_default=True, 89 | ), 90 | ] 91 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seibert-media/teamvault/9e857487675b27ed8a018a665b72a55aa253ec13/teamvault/apps/secrets/migrations/__init__.py -------------------------------------------------------------------------------- /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/secrets/templates/opensearch.xml: -------------------------------------------------------------------------------- 1 | 2 | TeamVault 3 | UTF-8 4 | 5 | 6 | -------------------------------------------------------------------------------- /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/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/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/templates/secrets/detail_content/_js.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 132 | 150 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/detail_content/_su_confirm_modal.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 36 | 37 | 42 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/detail_content/file.html: -------------------------------------------------------------------------------- 1 | {% extends 'secrets/secret_detail.html' %} 2 | {% load i18n %} 3 | 4 | {% block secret_content %} 5 | 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/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/templates/secrets/search/_search_item.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | ${data.value.name} 5 | 6 | 7 | ${data.value.meta} 8 | 9 |
10 |
11 | 12 |
13 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templates/secrets/secret_row.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
4 |
5 | {% if secret.content_type == secret.CONTENT_PASSWORD %} 6 | 7 | {% elif secret.content_type == secret.CONTENT_FILE %} 8 | 9 | {% elif secret.content_type == secret.CONTENT_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 == secret.STATUS_DELETED %} 30 | 31 | {% elif secret.status == secret.STATUS_NEEDS_CHANGING %} 32 | 33 | {% endif %} 34 | {% if secret not in readable_secrets %} 35 | 36 | {% endif %} 37 |
38 |
39 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seibert-media/teamvault/9e857487675b27ed8a018a665b72a55aa253ec13/teamvault/apps/secrets/templatetags/__init__.py -------------------------------------------------------------------------------- /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/apps/secrets/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seibert-media/teamvault/9e857487675b27ed8a018a665b72a55aa253ec13/teamvault/apps/secrets/tests/__init__.py -------------------------------------------------------------------------------- /teamvault/apps/secrets/tests/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/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//metadata', 43 | views.secret_metadata, 44 | name='secrets.secret-metadata', 45 | ), 46 | path( 47 | 'secrets//restore', 48 | views.secret_restore, 49 | name='secrets.secret-restore', 50 | ), 51 | path( 52 | 'secrets//share', 53 | views.secret_share_list, 54 | name='secrets.secret-share', 55 | ), 56 | path( 57 | 'secrets//share//delete', 58 | views.secret_share_delete, 59 | name='secrets.secret-share-delete', 60 | ), 61 | path( 62 | 'secrets/add/', 63 | views.secret_add, 64 | name='secrets.secret-add', 65 | ), 66 | path( 67 | 'secrets/live-search', 68 | views.secret_search, 69 | name='secrets.secret-search', 70 | ), 71 | ) 72 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/utils.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import string 3 | from urllib.parse import urlparse, parse_qs 4 | 5 | from django.core.files.uploadhandler import MemoryFileUploadHandler, SkipFile 6 | 7 | from .models import Secret 8 | 9 | 10 | def extract_url_and_params(data): 11 | data_as_url = urlparse(data) 12 | data_params = parse_qs(data_as_url.query) 13 | for key, value in data_params.items(): 14 | data_params[key] = value[0] 15 | return data_as_url, data_params 16 | 17 | 18 | def serialize_add_edit_data(cleaned_data, secret): 19 | plaintext_data = {} 20 | if secret.content_type == Secret.CONTENT_PASSWORD: 21 | cleaned_data_as_url, data_params = extract_url_and_params(cleaned_data["otp_key_data"]) 22 | if cleaned_data.get("password"): 23 | plaintext_data['password'] = cleaned_data['password'] 24 | for attr in ['secret', 'digits', 'algorithm']: 25 | if data_params.get(attr): 26 | plaintext_data[attr] = data_params[attr] 27 | elif secret.content_type == Secret.CONTENT_FILE: 28 | plaintext_data["file_content"] = cleaned_data['file'].read().decode("utf-8") 29 | secret.filename = cleaned_data['file'].name 30 | secret.save() 31 | elif secret.content_type == Secret.CONTENT_CC: 32 | plaintext_data = { 33 | 'holder': cleaned_data['holder'], 34 | 'number': cleaned_data['number'], 35 | 'expiration_month': str(cleaned_data['expiration_month']), 36 | 'expiration_year': str(cleaned_data['expiration_year']), 37 | 'security_code': str(cleaned_data['security_code']), 38 | 'password': cleaned_data['password'], 39 | } 40 | return plaintext_data 41 | 42 | 43 | def generate_password(length, digits, upper, lower, special): 44 | characters = string.ascii_letters + string.digits + string.punctuation 45 | password = [] 46 | password.extend(secrets.choice(string.digits) for _ in range(digits)) 47 | password.extend(secrets.choice(string.ascii_lowercase) for _ in range(lower)) 48 | password.extend(secrets.choice(string.ascii_uppercase) for _ in range(upper)) 49 | password.extend(secrets.choice(string.punctuation) for _ in range(special)) 50 | 51 | # Fill the rest of the lenght with random characters from all types 52 | password.extend(secrets.choice(characters) for _ in range(length - len(password))) 53 | 54 | # Randomly shuffle the characters, so they're not grouped by type 55 | secrets.SystemRandom().shuffle(password) 56 | 57 | return ''.join(password) 58 | 59 | 60 | class CappedMemoryFileUploadHandler(MemoryFileUploadHandler): 61 | def receive_data_chunk(self, raw_data, start): 62 | if not self.activated: # if the file size is too big, this handler will not be activated 63 | # if we use StopUpload here, forms will not get fully validated, 64 | # which leads to more form errors than we prefer 65 | # raise StopUpload(connection_reset=True) 66 | raise SkipFile() 67 | super(CappedMemoryFileUploadHandler, self).receive_data_chunk(raw_data, start) 68 | -------------------------------------------------------------------------------- /teamvault/apps/secrets/validators.py: -------------------------------------------------------------------------------- 1 | from base64 import b32decode 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | def is_valid_b32_string(value, casefold=False): 8 | try: 9 | b32decode(value, casefold=casefold) 10 | except Exception: 11 | raise ValidationError(_('OTP key has wrong format. Please enter a valid OTP key.')) 12 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /teamvault/apps/settings/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seibert-media/teamvault/9e857487675b27ed8a018a665b72a55aa253ec13/teamvault/apps/settings/migrations/__init__.py -------------------------------------------------------------------------------- /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/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/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/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/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/settings.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname, join, realpath 2 | 3 | from huey import SqliteHuey 4 | 5 | from teamvault.apps.settings.config import ( 6 | configure_database, 7 | configure_django_secret_key, 8 | configure_hashid, 9 | configure_huey, 10 | configure_logging, 11 | configure_password_generator, 12 | configure_session, 13 | configure_time_zone, 14 | get_config, 15 | get_from_config, 16 | ) 17 | 18 | CONFIG = get_config() 19 | PROJECT_ROOT = realpath(dirname(__file__)) 20 | 21 | # Django 22 | 23 | AUTHENTICATION_BACKENDS = [ 24 | 'django.contrib.auth.backends.ModelBackend', 25 | ] 26 | 27 | DATABASES = configure_database(CONFIG) 28 | 29 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 30 | 31 | FILE_UPLOAD_HANDLERS = ( 32 | "teamvault.apps.secrets.utils.CappedMemoryFileUploadHandler", 33 | ) 34 | 35 | FIXTURE_DIRS = ( 36 | join(PROJECT_ROOT, "fixtures"), 37 | ) 38 | 39 | INSTALLED_APPS = [ 40 | 'django.contrib.auth', 41 | 'django.contrib.contenttypes', 42 | 'django.contrib.humanize', 43 | 'django.contrib.sessions', 44 | 'django.contrib.messages', 45 | 'django.contrib.staticfiles', 46 | 'django_bootstrap5', 47 | 'huey.contrib.djhuey', 48 | 'rest_framework', 49 | 'social_django', 50 | 'teamvault.apps.accounts.AccountsConfig', 51 | 'teamvault.apps.audit.AuditConfig', 52 | 'teamvault.apps.secrets.SecretsConfig', 53 | 'teamvault.apps.settings.SettingsConfig', 54 | 'webpack_loader', 55 | ] 56 | 57 | LANGUAGE_CODE = "en-us" 58 | 59 | LOCALE_PATHS = (PROJECT_ROOT + "/locale",) 60 | 61 | LOGIN_REDIRECT_URL = "/" 62 | LOGIN_URL = 'accounts.login' 63 | LOGOUT_URL = 'accounts.logout' 64 | 65 | LOGGING = configure_logging(CONFIG) 66 | 67 | MIDDLEWARE = [ 68 | 'django.middleware.security.SecurityMiddleware', 69 | 'whitenoise.middleware.WhiteNoiseMiddleware', 70 | 'django.contrib.sessions.middleware.SessionMiddleware', 71 | 'django.middleware.locale.LocaleMiddleware', 72 | 'django.middleware.common.CommonMiddleware', 73 | 'django.middleware.csrf.CsrfViewMiddleware', 74 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 75 | 'django.contrib.messages.middleware.MessageMiddleware', 76 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 77 | 'teamvault.middleware.htmx_message_middleware', 78 | ] 79 | 80 | ROOT_URLCONF = "teamvault.urls" 81 | 82 | SECRET_KEY = configure_django_secret_key(CONFIG) 83 | 84 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 85 | 86 | SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies' 87 | SESSION_COOKIE_AGE, SESSION_EXPIRE_AT_BROWSER_CLOSE, SESSION_COOKIE_SECURE = \ 88 | configure_session(CONFIG) 89 | 90 | STATIC_ROOT = join(PROJECT_ROOT, "static_collected") 91 | 92 | # remember this is hardcoded in the error page templates (e.g. 500.html) 93 | STATIC_URL = "/static/" 94 | 95 | STATICFILES_DIRS = ( 96 | join(PROJECT_ROOT, "static"), 97 | ) 98 | 99 | TEMPLATES = [ 100 | { 101 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 102 | 'DIRS': [join(PROJECT_ROOT, "templates"), ], 103 | 'APP_DIRS': True, 104 | 'OPTIONS': { 105 | 'context_processors': [ 106 | 'django.contrib.auth.context_processors.auth', 107 | 'django.contrib.messages.context_processors.messages', 108 | 'django.template.context_processors.csrf', 109 | 'django.template.context_processors.debug', 110 | 'django.template.context_processors.i18n', 111 | 'django.template.context_processors.media', 112 | 'django.template.context_processors.request', 113 | 'django.template.context_processors.static', 114 | 'teamvault.apps.accounts.context_processors.google_auth_enabled', 115 | 'teamvault.apps.secrets.context_processors.version', 116 | ], 117 | }, 118 | }, 119 | ] 120 | 121 | TEST_RUNNER = 'django.test.runner.DiscoverRunner' 122 | 123 | TIME_ZONE = configure_time_zone(CONFIG) 124 | 125 | USE_I18N = False 126 | USE_THOUSAND_SEPARATOR = False 127 | USE_TZ = True 128 | 129 | # HashID 130 | HASHID_MIN_LENGTH, HASHID_SALT = configure_hashid(CONFIG) 131 | 132 | # Django REST Framework 133 | REST_FRAMEWORK = { 134 | 'DEFAULT_MODEL_SERIALIZER_CLASS': 135 | 'rest_framework.serializers.HyperlinkedModelSerializer', 136 | 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 137 | 'DEFAULT_PERMISSION_CLASSES': ( 138 | 'rest_framework.permissions.IsAuthenticated', 139 | ), 140 | 'PAGE_SIZE': 25, 141 | } 142 | 143 | # Social Auth 144 | SOCIAL_AUTH_JSONFIELD_ENABLED = True 145 | 146 | # Django-Bootstrap5 147 | BOOTSTRAP5 = { 148 | 'horizontal_field_class': 'col-xl-8', 149 | 'horizontal_label_class': 'col-xl-2', 150 | 'required_css_class': 'required', 151 | 'success_css_class': 'is-valid', 152 | 'error_css_class': 'is-invalid', 153 | } 154 | 155 | HUEY = SqliteHuey('teamvault') 156 | HUEY_TASKS = configure_huey(CONFIG) 157 | -------------------------------------------------------------------------------- /teamvault/static/js/index.js: -------------------------------------------------------------------------------- 1 | // Import our custom CSS 2 | import '../scss/base.scss' 3 | 4 | import * as bootstrap from 'bootstrap' // TODO: Specify which plugins we really need 5 | import $ from 'jquery' 6 | import {Notyf} from 'notyf'; 7 | import 'notyf/notyf.min.css' 8 | import autoCompleteJS from '@tarekraafat/autocomplete.js'; 9 | import ClipboardJS from "clipboard"; 10 | import DOMPurify from 'dompurify'; 11 | import {TempusDominus} from '@eonasdan/tempus-dominus' 12 | 13 | import {zxcvbn, zxcvbnOptions} from '@zxcvbn-ts/core' 14 | import * as zxcvbnCommonPackage from '@zxcvbn-ts/language-common' 15 | import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en' 16 | 17 | import * as teamvault from './utils' 18 | import * as otp from './otp' 19 | 20 | window.otp = otp 21 | window.teamvault = teamvault 22 | 23 | // Bootstrap 24 | window.bootstrap = bootstrap 25 | 26 | // HTMX 27 | window.htmx = require('htmx.org') 28 | 29 | // jQuery 30 | window.$ = $ 31 | window.jQuery = $ 32 | 33 | //js qr scanner 34 | window.qrScanner = require("jsqr") 35 | 36 | // Bigtext 37 | require('bigtext'); 38 | 39 | // Notyf 40 | document.addEventListener('DOMContentLoaded', () => { 41 | // Notyf tries to hook to a body, which we don't have in this context yet. 42 | window.notyf = new Notyf({position: {x: 'right', y: 'top'}}) 43 | }) 44 | 45 | // Card 46 | window.Card = require('card') 47 | 48 | // ClipboardJS 49 | window.ClipboardJS = ClipboardJS 50 | 51 | // autocomplete.js 52 | window.autoCompleteJS = autoCompleteJS 53 | 54 | // DOMPurify (needed for autocompleteJS ajax queries) 55 | window.DOMPurify = DOMPurify 56 | 57 | // Tempus Dominus 58 | window.TempusDominus = TempusDominus 59 | 60 | 61 | // zxcvbn 62 | zxcvbnOptions.setOptions({ 63 | translations: zxcvbnEnPackage.translations, 64 | graphs: zxcvbnCommonPackage.adjacencyGraphs, 65 | dictionary: { 66 | ...zxcvbnCommonPackage.dictionary, 67 | ...zxcvbnEnPackage.dictionary, 68 | }, 69 | }) 70 | window.zxcvbn = zxcvbn 71 | 72 | // Select2 73 | require('select2'); 74 | $.fn.select2.defaults.set("theme", "bootstrap-5") 75 | $.fn.select2.defaults.set("width", "100%") // https://github.com/select2/select2/issues/3278 76 | $.fn.select2.amd.require(['select2/selection/search'], function (Search) { 77 | // Patch backspace on select2 4.X. See https://github.com/select2/select2/issues/3354 78 | Search.prototype.searchRemoveChoice = function (decorated, item) { 79 | this.trigger('unselect', { 80 | data: item 81 | }); 82 | 83 | this.$search.val(''); 84 | this.handleSearch(); 85 | }; 86 | }, null, true); 87 | -------------------------------------------------------------------------------- /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/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/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 | 29 | a { 30 | &:hover, &:focus { 31 | > .avatar { 32 | border-style: solid; 33 | border-width: 1px; 34 | } 35 | } 36 | 37 | &:hover > .avatar { 38 | --tv-avatar-opacity: 80% !important; 39 | } 40 | 41 | &:focus > .avatar { 42 | --tv-avatar-opacity: 90%; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /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 | 19 | @import "./fontawesome"; 20 | @import "./select2"; 21 | 22 | @import '@eonasdan/tempus-dominus/src/scss/tempus-dominus.scss'; 23 | 24 | body { 25 | font-family: 'Poppins', serif; 26 | } 27 | 28 | a { 29 | text-decoration: none; 30 | color: var(--bs-info); 31 | } 32 | 33 | .background { 34 | position: fixed; 35 | top: 0; 36 | right: 0; 37 | bottom: 0; 38 | left: 0; 39 | background-position: 50% 50%; 40 | background: var(--bs-body-bg); 41 | z-index: -999; 42 | } 43 | 44 | .dropdown-menu i { 45 | color: #92A6B2; 46 | } 47 | 48 | h1 { 49 | color: var(--text-body); 50 | 51 | > .badge { 52 | vertical-align: top; 53 | } 54 | } 55 | 56 | .card { 57 | table { 58 | margin-bottom: 0; 59 | } 60 | 61 | &.shadow { 62 | box-shadow: 0 0.5rem 0.5rem rgba(0, 0, 0, 0.05); 63 | } 64 | } 65 | 66 | .card .card-body .table tr:last-child { 67 | border-bottom-style: hidden; 68 | } 69 | 70 | kbd { 71 | font-family: monospace; 72 | text-align: center; 73 | border-color: #303030; 74 | font-size: inherit; 75 | 76 | &:not(kbd:first-of-type) { 77 | margin-left: 0.1rem; 78 | } 79 | } 80 | 81 | .list-group-hover .list-group-item { 82 | &:hover { 83 | background-color: var(--bs-list-group-action-active-bg); 84 | } 85 | } 86 | 87 | .list-group-item { 88 | --bs-list-group-item-padding-y: 0.75rem; 89 | } 90 | 91 | @include media-breakpoint-down(lg) { 92 | .modal-lg { 93 | --bs-modal-width: 88% 94 | } 95 | } 96 | 97 | @include media-breakpoint-down(xl) { 98 | .modal-xl { 99 | --bs-modal-width: 88% 100 | } 101 | } 102 | 103 | [data-bs-theme="light"] { 104 | .lt-otp { 105 | color: white; 106 | } 107 | } 108 | 109 | [data-bs-theme="dark"] { 110 | .btn-light { 111 | color: var(--bs-light); 112 | background-color: var(--bs-secondary-bg); 113 | border-color: var(--bs-secondary-bg); 114 | border-width: 1px; 115 | transition: filter .15s ease-in-out; 116 | 117 | &:hover { 118 | filter: brightness(1.2); 119 | } 120 | &:active { 121 | filter: brightness(1.4); 122 | background-color: var(--bs-secondary-bg); 123 | border-color: var(--bs-secondary-bg); 124 | color: var(--bs-light); 125 | } 126 | } 127 | 128 | // bg-color for filter chips 129 | .bg-secondary-subtle { 130 | background-color: #37383b!important; 131 | } 132 | } 133 | 134 | .separator { 135 | height: 0.3em; 136 | width: 0.3em; 137 | margin-bottom: 0.2em; 138 | background-color: var(--bs-secondary); 139 | border-radius: 50%; 140 | display: inline-block; 141 | } 142 | 143 | .bg-otp { 144 | background-color: var(--bs-tertiary-bg); 145 | } 146 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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 | 14 | {% endblock %} 15 | {% block footer %}{% endblock %} 16 | -------------------------------------------------------------------------------- /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/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 | {% render_bundle 'main' %} 17 | 18 | {% block head %}{% endblock %} 19 | 20 | 21 | 22 | 34 | 59 | 60 |
61 | 62 | {% block navbar %}{% include 'base_nav.html' %}{% endblock %} 63 | 64 | {% block super_content %} 65 |
66 | {% block content %}{% endblock %} 67 |
68 | {% endblock %} 69 | 70 | {% block footer %} 71 |
72 |
73 | TeamVault {{ version }}   ·   © 2014-2024 Seibert Group 74 |
75 |
76 | {% endblock %} 77 | 78 | {% block additionalJS %} 79 | {% endblock %} 80 | 81 | 82 | -------------------------------------------------------------------------------- /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/templates/helpers/filter_item.html: -------------------------------------------------------------------------------- 1 |
3 |
4 | {{ filter_item_name }} 5 |
6 |
7 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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)$/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------