Metadata-Version: 2.4
Name: saltext.bitwarden
Version: 0.2.0
Summary: Salt Extension Modules for Bitwarden
Author-email: "Gary T. Giesen" <ggiesen@giesen.me>
License: Mozilla Public License 2.0 (MPL 2.0)
Project-URL: Homepage, https://gitlab.com/ggiesen/saltext-bitwarden
Project-URL: Documentation, https://ggiesen.gitlab.io/saltext-bitwarden
Project-URL: Source, https://gitlab.com/ggiesen/saltext-bitwarden
Project-URL: Tracker, https://gitlab.com/ggiesen/saltext-bitwarden/-/issues
Keywords: salt-extension
Platform: any
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: salt>=3006
Requires-Dist: pyhumps
Requires-Dist: requests
Provides-Extra: changelog
Requires-Dist: towncrier==24.8.0; extra == "changelog"
Provides-Extra: dev
Requires-Dist: nox[uv]>=2024.3; extra == "dev"
Requires-Dist: pre-commit>=2.21.0; extra == "dev"
Provides-Extra: dev-extra
Requires-Dist: black==26.5.1; extra == "dev-extra"
Requires-Dist: isort==8.0.1; extra == "dev-extra"
Requires-Dist: coverage>=7.6; extra == "dev-extra"
Provides-Extra: docs
Requires-Dist: sphinx; extra == "docs"
Requires-Dist: sphinx-prompt; extra == "docs"
Requires-Dist: sphinxcontrib-spelling; extra == "docs"
Requires-Dist: sphinx-copybutton; extra == "docs"
Requires-Dist: towncrier==24.8.0; extra == "docs"
Requires-Dist: sphinxcontrib-towncrier; extra == "docs"
Requires-Dist: myst_parser; extra == "docs"
Requires-Dist: furo; extra == "docs"
Requires-Dist: sphinx-inline-tabs; extra == "docs"
Requires-Dist: sphinx-togglebutton; extra == "docs"
Provides-Extra: docsauto
Requires-Dist: sphinx-autobuild; extra == "docsauto"
Provides-Extra: lint
Requires-Dist: pylint==3.2.7; extra == "lint"
Requires-Dist: SaltPyLint; extra == "lint"
Provides-Extra: tests
Requires-Dist: pytest>=7.2.0; extra == "tests"
Requires-Dist: pytest-salt-factories>=1.0.0; sys_platform == "win32" and extra == "tests"
Requires-Dist: pytest-salt-factories[docker]>=1.0.0; sys_platform != "win32" and extra == "tests"
Requires-Dist: pytest-instafail; extra == "tests"
Requires-Dist: pytest-skip-markers>=1.5.0; extra == "tests"
Requires-Dist: pytest-subprocess; extra == "tests"
Requires-Dist: pytest-timeout; extra == "tests"
Requires-Dist: requests-mock; extra == "tests"
Dynamic: license-file

# bitwarden

Salt Extension Modules for Bitwarden

## Introduction
This extension for [Salt](https://saltproject.io) enables Salt to access and administer a
[Bitwarden](https://bitwarden.com/) vault/instance.

This project aims to eventually have 100% coverage of the
[Bitwarden Vault Management API](https://bitwarden.com/help/vault-management-api/) and the
[Bitwarden Public (Organization Management) API](https://bitwarden.com/help/api/). The Vault
Management API is now covered for both reads and writes; the Public API is not yet implemented.

This extension is changing quickly, and while attempts will be made to not make breaking changes,
the current focus is on functionality rather than syntax/API stability.

## Requirements
This extension requires the `bw` Bitwarden CLI utility to be installed for functions that depend
on the `Bitwarden Vault Management API`. Installation instructions are available
[here](https://bitwarden.com/help/cli/#download-and-install). It also has dependencies on the
[pyhumps](https://github.com/nficano/humps) and [requests](https://github.com/psf/requests) python
packages, which are automatically installed.

Python 3.10 or later and Salt 3006 or later are required.

Talking to `bw serve` over a unix socket, which is the recommended deployment (see
[Connecting to the API](#connecting-to-the-api)), requires Bitwarden CLI 2025.9.0 or later. Earlier
releases can only listen on a TCP port.

## Installation
An example state file for installing and configuring this extension on EL8 (RHEL, AlmaLinux, Rocky
Linux, Oracle Linux, Scientific Linux, etc.):

```yaml
# Install the NodeSource EL8 repo to get Node.js
cmd_nodesource-el8.repo:
  cmd.run:
    - name: curl -fsSL https://rpm.nodesource.com/setup_18.x | bash -
    - creates:
        - /etc/yum.repos.d/nodesource-el8.repo

# Bitwarden CLI depends on Node.js
pkg_nodejs:
  pkg.installed:
    - name: nodejs
    - require:
        - cmd_nodesource-el8.repo

# Install the Bitwarden CLI client (which includes the Vault Management REST API
# server)
npm_bitwarden_cli:
  npm.installed:
    - name: '@bitwarden/cli'
    - require:
        - pkg_nodejs

# Install the Bitwarden Salt Extension
pip_saltext.bitwarden:
  pip.installed:
    - name: saltext.bitwarden
    - require:
        - npm_bitwarden_cli

# Configure server options for Bitwarden REST API Server
file_/etc/sysconfig/bw-api:
  file.managed:
    - name: /etc/sysconfig/bw-api
    - user: root
    - group: root
    - mode: "0644"
    - contents: |
        # Command-line options for bw serve
        BITWARDENCLI_APPDATA_DIR=/etc/salt/.bitwarden
        OPTIONS="--hostname unix:///run/bw-api/vault.sock"
    - require:
        - pip_saltext.bitwarden
    - watch_in:
        - service_bw-api

# Create a systemd service unit file since one is not included in the package.
# Reloads systemctl if the file changes.
file_/etc/systemd/system/bw-api.service:
  file.managed:
    - name: /etc/systemd/system/bw-api.service
    - user: root
    - group: root
    - mode: "0644"
    - contents: |
        [Unit]
        Description=Bitwarden Vault Management API
        Documentation=https://bitwarden.com/help/cli/
        After=network.target

        [Service]
        EnvironmentFile=-/etc/sysconfig/bw-api
        User=salt
        Group=salt
        Type=simple
        WorkingDirectory=~
        # Creates /run/bw-api owned by salt:salt, mode 0750, and removes it on
        # stop. Traversing that directory is what gates access to the socket,
        # so only root and the salt group can reach the API.
        RuntimeDirectory=bw-api
        RuntimeDirectoryMode=0750
        ExecStart=/bin/bw serve $OPTIONS
        Restart=always

        [Install]
        WantedBy=multi-user.target
    - require:
        - file_/etc/sysconfig/bw-api
    - watch_in:
        - service_bw-api

# You must manually create files in /etc/salt/master.d/bitwarden.conf or
# /etc/salt/minion.d/bitwarden.conf (or both) depending on the context, with the following contents
# (substituting values as appropriate):
#
#  bitwarden:
#    driver: bitwarden
#    cli_path: /bin/bw
#    cli_conf_dir: /etc/salt/.bitwarden
#    cli_runas: salt
#    vault_url: https://bitwarden.com
#    email: user@example.com
#    password: CorrectHorseBatteryStaple
#    vault_api_url: unix:///run/bw-api/vault.sock
#    public_api_url: https://api.bitwarden.com
#    client_id: 25fa6fc6-deeb-4b42-a279-5e680b51aa58
#    client_secret: AofieD0oexiex1mie3eigi9oojooF3
#    org_client_id: organization.d0e19db4-38aa-4284-be3d-e80cff306e6c
#    org_client_secret: aWMk2MBf4NWXfaevrKyxa3uqNXYVQy

# Used for runner and SDB modules
file_/etc/salt/master.d/bitwarden.conf:
  file.exists:
    - name: /etc/salt/master.d/bitwarden.conf
    - require:
        - pip_saltext.bitwarden

# Used for execution, SDB and state modules
file_/etc/salt/minion.d/bitwarden.conf:
  file.exists:
    - name: /etc/salt/minion.d/bitwarden.conf
    - require:
        - pip_saltext.bitwarden

# Make sure the Bitwarden vault is logged in before we start the REST API server
# service otherwise the service will refuse to start
bitwarden_logged_in:
  bitwarden.logged_in:
    - name: logged_in
    - use_cli: True
    - profile: bitwarden

# Run the Bitwarden Vault Management REST API server `bw serve`
service_bw-api:
  service.running:
    - name: bw-api
    - enable: True
    - init_delay: 10
    - require:
        - bitwarden_logged_in

# State to reload systemctl
service_systemctl_reload:
  module.run:
    - name: service.systemctl_reload
    - onchanges:
        - file_/etc/systemd/system/bw-api.service

```

Nothing in that state restricts access to the API, because nothing needs to: the socket lives in a
directory only root and the `salt` group can traverse. On a TCP port the equivalent takes a set of
firewalld direct rules matching on `--uid-owner`; see
[Connecting to the API](#connecting-to-the-api).

The configuration files required vary depending on which module you wish to use:

| module type | file type      |
|-------------|----------------|
| execution   | minion         |
| runner      | master         |
| sdb         | master, minion |
| state       | minion         |

## Connecting to the API

`bw serve` can listen on either a unix socket or a TCP port, and `vault_api_url` accepts both:

```yaml
# Unix socket (recommended)
vault_api_url: unix:///run/bw-api/vault.sock

# TCP
vault_api_url: http://localhost:8087
```

Prefer the socket. The Vault Management API has
[no authentication of its own](https://github.com/bitwarden/clients/issues/3932): once the vault is
unlocked, whatever can reach the API can read every secret in it. On a TCP port that is every
process on the host, and the traffic crosses the loopback interface in clear text. A socket is
guarded by ordinary filesystem permissions, so access can be restricted to one user or group, and
there is no port for anything else to find.

Access is gated by permission to traverse the socket's directory, so put the socket somewhere
restricted rather than relying on the mode of the socket file itself, which Node creates from the
process umask. Under systemd, `RuntimeDirectory=` and `RuntimeDirectoryMode=` do this for you, as in
the state above.

Socket paths are limited to about 108 bytes by the operating system, not by this extension or by
Bitwarden. A longer path fails at startup with `Error: listen EINVAL: invalid argument`, which does
not name the length as the cause. `/run` keeps paths comfortably short.

Do not pass `--port` alongside `--hostname unix://...`. CLI 2026.6.0 added DNS-rebinding
protection, which rejects any request whose `Host` header is not in an allowlist built from the
hostname and port `bw serve` was given. That check runs for unix sockets too, even though a browser
cannot reach one, and the only allowlist entries a socket client can match are the port-based ones.
The extension therefore sends `Host: localhost:8087`, matching the default. Passing `--port` moves
the allowlist and every request comes back `403`.

If you are on a Bitwarden CLI older than 2025.9.0, or otherwise need TCP, bind it to `localhost` and
restrict it by owning UID. The Salt `firewalld` state module does not support direct rules, so the
configuration file has to be managed directly:

```yaml
service_firewalld:
  service.running:
    - name: firewalld
    - enable: True
    - reload: True

file_/etc/firewalld/direct.xml:
  file.managed:
    - name: /etc/firewalld/direct.xml
    - user: root
    - group: root
    - mode: "0644"
    - contents: |
        <?xml version="1.0" encoding="utf-8"?>
        <!--
        ########################################################################
        #                                                                      #
        #              THIS FILE IS MANAGED BY SALT - DO NOT EDIT              #
        #                                                                      #
        # The contents of this file are managed by Salt. Any changes to this   #
        # file may be overwritten automatically and without warning.           #
        ########################################################################
        -->
        <direct>
          <rule ipv="ipv4" table="filter" chain="OUTPUT" priority="0">-o lo -p tcp --dport 8087 -m owner --uid-owner root -j ACCEPT</rule>
          <rule ipv="ipv4" table="filter" chain="OUTPUT" priority="0">-o lo -p tcp --dport 8087 -m owner --uid-owner salt -j ACCEPT</rule>
          <rule ipv="ipv4" table="filter" chain="OUTPUT" priority="1">-o lo -p tcp --dport 8087 -j REJECT</rule>
          <rule ipv="ipv6" table="filter" chain="OUTPUT" priority="0">-o lo -p tcp --dport 8087 -m owner --uid-owner root -j ACCEPT</rule>
          <rule ipv="ipv6" table="filter" chain="OUTPUT" priority="0">-o lo -p tcp --dport 8087 -m owner --uid-owner salt -j ACCEPT</rule>
          <rule ipv="ipv6" table="filter" chain="OUTPUT" priority="1">-o lo -p tcp --dport 8087 -j REJECT</rule>
        </direct>
    - watch_in:
        - service_firewalld
```

Both transports are exercised by the integration tests against a real `bw serve`.

## Usage

The extension provides read and write access to a Bitwarden vault through execution, runner, state
and `sdb` modules. The execution and runner modules expose the same set of functions; the runner
runs them on the master, the execution module on a minion.

### Reading and writing items

```bash
# Read
salt '*' bitwarden.get_item item_id=2fcd790a-70f7-43a2-b265-08a763873980
salt '*' bitwarden.get_password item_id=2fcd790a-70f7-43a2-b265-08a763873980
salt '*' bitwarden.list_items search="Example Item"

# Write
salt '*' bitwarden.create_folder name="Servers"
salt '*' bitwarden.create_item item='{"type": 1, "name": "Example", "login": {"username": "jdoe", "password": "hunter2"}}'
salt '*' bitwarden.delete_item item_id=2fcd790a-70f7-43a2-b265-08a763873980 permanent=True
```

Attachments, Sends and organization collections are managed the same way; see the
[module documentation](https://ggiesen.gitlab.io/saltext-bitwarden) for the full list.

### States

```yaml
Servers folder exists:
  bitwarden.folder_present:
    - name: Servers

Example login:
  bitwarden.item_present:
    - name: Example Login
    - item:
        type: 1
        login:
          username: jdoe
          password: {{ salt['sdb.get']('sdb://bitwarden/by-uuid/.../password') }}
```

States support `test=True`. Secret values are redacted from the `changes` output by default,
because state returns are written to the job cache and the master's logs; set
`show_secret_changes: True` on an individual state if you need to see them.

> **Declaring secrets in SLS files puts them in your state tree**, which is normally version
> controlled. Prefer rendering them from pillar or an `sdb://` URI, or manage only non-secret
> attributes and generate passwords with `bitwarden.generate_password`.

### Error handling

Failures raise exceptions rather than returning a value. `salt.exceptions.SaltInvocationError`
means the arguments were wrong; subclasses of `salt.exceptions.CommandExecutionError`
(`BitwardenNotFoundError`, `BitwardenAuthenticationError`, `BitwardenUnavailableError`,
`BitwardenServerError`) mean the operation failed. Salt reports these itself, so there is no need
to test return values for falsiness.

### A note on consistency

`bw serve` answers from a local cache that it populates asynchronously. An object that was just
created is not reliably visible to the very next request, and a read issued immediately after a
write can return the pre-write value.

This extension retries once after a sync when an object is unexpectedly missing, and the state
modules sync before comparing so they never diff against stale data. If you are scripting your own
sequence of calls against the API, call `bitwarden.sync` between a write and a dependent read.

### SDB

```bash
# SDB via runner module
salt-run sdb.get 'sdb://bitwarden/by-uuid/2fcd790a-70f7-43a2-b265-08a763873980/password'
CorrectHorseBatteryStaple
```

```bash
# SDB via execution module
salt-call sdb.get 'sdb://bitwarden/by-uuid/2fcd790a-70f7-43a2-b265-08a763873980/password'
local:
    CorrectHorseBatteryStaple
```

As always, you can also reference SDB modules in your pillar files:

```yaml
example_pillar:
  some_password: {{ salt['sdb.get']('sdb://bitwarden/by-uuid/2fcd790a-70f7-43a2-b265-08a763873980/password') }}
```

The format of the SDB URI is as follows:

`sdb://<profile>/by-uuid/<uuid>/<object>`

Where `<profile>` is the profile defined in the master or minion configuration
file, `<uuid>` is the UUID of the item, and `<object>` is one of:

- name
- username
- password
- totp
- notes
- creation_date
- revision_date
- deleted_date
- password_revision_date

Additionally, password history can be retrieved with the following SDB URI
format:

`sdb://<profile>/by-uuid/<uuid>/password_history/by-index/<index>`

Where ``<profile>`` is the profile defined in the master or minion configuration
file, ``<uuid>`` is the UUID of the item, and ``index`` is a non-negative
integer specifying which password to retrieve from the history (0 being the
current password, 1 being the previous password, and so forth).

Lastly, custom fields can be retrieved with the following SDB URI format:

`sdb://<profile>/by-uuid/<uuid>/fields/by-name/<field_name>/<object>`

Where `<profile>` is the profile defined in the master or minion configuration
file, `<uuid>` is the UUID of the item, `<field_name>` is the name of
custom field, and object is one of:

- value
- type
- linked_id

Note that the custom field name must be unique within an item.
Bitwarden does not enforce unique custom field names, so that is left up to the
user.

`sdb.set` writes to an item's `name`, `username`, `password`, `notes` or a custom field's `value`:

```bash
salt-run sdb.set 'sdb://bitwarden/by-uuid/2fcd790a-70f7-43a2-b265-08a763873980/password' 'NewCorrectHorseBatteryStaple'
```

It only sets values within an existing item; use the execution, runner or state modules to create
items.

The UUID of an item can be found using the Bitwarden CLI:

```bash
bw list items --search "Google Account" --pretty
[
  {
    "object": "item",
    "id": "2fa63ad5-e4e4-43d4-a089-3fadcf455be2",
    "organizationId": null,
    "folderId": null,
    "type": 1,
    "reprompt": 0,
    "name": "Google Account",
    "notes": null,
    "favorite": false,
    "login": {
      "uris": [
        {
          "match": null,
          "uri": "https://accounts.google.com"
        }
      ],
      "username": "user@example.com",
      "password": "aTjSsJvhQY5E24",
      "totp": "AEM1HEESIEV8YAED8THUBEHOOW",
      "passwordRevisionDate": null
    },
    "collectionIds": [],
    "revisionDate": "1970-01-01T00:00:00.000Z"
  }
]
```

## Development

```bash
python -m venv .venv && . .venv/bin/activate
pip install -e '.[tests,dev,lint]'
pre-commit install

# Unit tests only -- no vault, no container runtime needed
pytest tests/unit/

# Everything, including integration tests
pytest tests/
```

The integration tests bring up a [Vaultwarden](https://github.com/dani-garcia/vaultwarden)
container, register an account against it and run a real `bw serve` against that account, so they
exercise the actual API rather than a mock. They need a container runtime and the `bw` CLI, and
skip themselves cleanly when either is missing.

Vaultwarden is served over TLS with a throwaway self-signed certificate, because current Bitwarden
CLI releases refuse to talk to a plain-HTTP server.

Under rootless Podman, point the Docker SDK at Podman's socket:

```bash
systemctl --user start podman.socket
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock"
pytest tests/
```

Vaultwarden implements the personal vault faithfully but only part of the organization surface, so
organization collections, members and confirmation are covered by unit tests only. Exercising those
for real needs a paid Bitwarden organization.

## Releasing

Releases are published to PyPI by GitLab CI using
[PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/), so no API
token is stored in the project. GitLab mints a short-lived OIDC token, twine
exchanges it for a PyPI token valid for about 15 minutes, and uploads with that.

One-time setup on PyPI, under the project's *Publishing* settings, add a GitLab
publisher:

| Field             | Value                |
|-------------------|----------------------|
| Namespace         | `ggiesen`            |
| Repository name   | `saltext-bitwarden` |
| Pipeline filepath | `.gitlab-ci.yml`     |
| Environment       | `release`            |

The environment is optional to PyPI but recommended: pair it with a
[protected environment](https://docs.gitlab.com/ci/environments/protected_environments/)
in GitLab to require manual approval before an upload runs.

To cut a release:

```bash
# 1. Add changelog fragments as you go, under changelog/
#    named <issue>.<type>.md, or +<slug>.<type>.md when there is no issue.

# 2. Fold them into CHANGELOG.md
towncrier build --version X.Y.Z --yes

# 3. Commit, tag, push. Tags are v-prefixed; setuptools_scm strips the v, so
#    the released version is still X.Y.Z.
git commit -am "Release X.Y.Z"
git tag -a vX.Y.Z -m "Release X.Y.Z"
git push && git push origin vX.Y.Z
```

Pushing the tag runs the pipeline. The `publish` job only appears for tags
matching `vX.Y`, and it is **manual**: nothing reaches PyPI until someone runs
it, so tagging a release does not auto-fire an upload. It refuses to upload if
the built version does not match the tag, which catches a shallow clone or a
dirty tree producing a `.dev` version.

## Docs
Module documentation is available at [https://ggiesen.gitlab.io/saltext-bitwarden](https://ggiesen.gitlab.io/saltext-bitwarden).

## Bugs
Bugs can be reported using the [Issue Tracker](https://gitlab.com/ggiesen/saltext-bitwarden/-/issues).

## Contributing
All contributions are welcome and very much appreciated. Contributing guide coming soon.
Contributing can take many forms, including:

 - Reporting bugs
 - Feature requests
 - Code submissions (bug fixes/new features/improve code quality)
 - Writing tests
 - Writing documentation
 - Detailing use cases
 - Writing blog posts

## License
This project is licensed under the Mozilla Public License 2.0. See `LICENSE` for the licence text.

Releases up to and including `0.1.0` were published under the Apache Software
License. That grant is not retracted: anyone who obtained those releases keeps
their rights under Apache 2.0. MPL 2.0 applies from the next release onwards.

Some of the packaging and tooling in this repository (`noxfile.py`,
`docs/conf.py`, the `.pre-commit-hooks/` scripts and parts of the test
scaffolding) originates from the Apache-2.0 licensed
[salt-extension-copier](https://github.com/salt-extensions/salt-extension-copier)
template. MPL 2.0 is a file-level licence and explicitly allows combining with
other terms, so those files may still carry their original licence.
