Skip to content

Commit 17191ce

Browse files
bitwarden plugin! (#148)
- initial version of Bitwarden Secrets Manager plugin, along with docs - Along the way made some small adjustments to the 1password plugin to match. notably that the access token will now be injected (using the type system) by default - updated 1pass docs - adjusted how plugin URLs are extracted from package json to respect a directory path --------- Co-authored-by: Phil Miller <[email protected]>
1 parent f3b9297 commit 17191ce

File tree

24 files changed

+632
-121
lines changed

24 files changed

+632
-121
lines changed

.changeset/grumpy-poems-change.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@dmno/1password-plugin": patch
3+
"@dmno/bitwarden-plugin": patch
4+
"@dmno/configraph": patch
5+
"dmno": patch
6+
---
7+
8+
bitwarden plugin, 1password plugin tweaks, core cleanup

example-repo/.dmno/config.mts

+11-4
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
import { DmnoBaseTypes, defineDmnoService, configPath, NodeEnvType, switchBy, inject } from 'dmno';
22
import { OnePasswordDmnoPlugin, OnePasswordTypes } from '@dmno/1password-plugin';
3+
import { BitwardenSecretsManagerDmnoPlugin, BitwardenSecretsManagerTypes } from '@dmno/bitwarden-plugin';
34
import { EncryptedVaultDmnoPlugin, EncryptedVaultTypes } from '@dmno/encrypted-vault-plugin';
45

56

67

78
const OnePassSecretsProd = new OnePasswordDmnoPlugin('1pass/prod', {
8-
token: inject(),
9+
// token: configPath('..', 'OP_TOKEN_PROD'),
910
envItemLink: 'https://start.1password.com/open/i?a=I3GUA2KU6BD3FBHA47QNBIVEV4&v=ut2dftalm3ugmxc6klavms6tfq&i=n4wmgfq77mydg5lebtroa3ykvm&h=dmnoinc.1password.com',
1011
fallbackToCliBasedAuth: true,
1112
});
1213
const OnePassSecretsDev = new OnePasswordDmnoPlugin('1pass', {
13-
token: inject(),
1414
envItemLink: 'https://start.1password.com/open/i?a=I3GUA2KU6BD3FBHA47QNBIVEV4&v=ut2dftalm3ugmxc6klavms6tfq&i=4u4klfhpldobgdxrcjwb2bqsta&h=dmnoinc.1password.com',
1515
fallbackToCliBasedAuth: true,
16-
// token: InjectPluginInputByType,
17-
// token: 'asdf',
1816
});
1917

18+
const BitwardenPlugin = new BitwardenSecretsManagerDmnoPlugin('bitwarden');
2019

2120
const EncryptedVaultSecrets = new EncryptedVaultDmnoPlugin('vault/prod', { name: 'prod', key: inject() });
2221
// const NonProdVault = new EncryptedVaultDmnoPlugin('vault/dev', {
@@ -67,6 +66,14 @@ export default defineDmnoService({
6766
value: OnePassSecretsDev.itemByReference("op://dev test/example/username"),
6867
},
6968

69+
BWS_TOKEN: {
70+
extends: BitwardenSecretsManagerTypes.machineAccountAccessToken,
71+
},
72+
BWS_ITEM: {
73+
value: BitwardenPlugin.secretById('df2246f1-7889-4d1b-a18e-b219001ee3b3'),
74+
},
75+
76+
7077
SOME_API_KEY: {
7178
value: switchBy('DMNO_ENV', {
7279
_default: OnePassSecretsDev.item(),

example-repo/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"devDependencies": {
1212
"@dmno/1password-plugin": "link:../packages/plugins/1password",
13+
"@dmno/bitwarden-plugin": "link:../packages/plugins/bitwarden",
1314
"dmno": "link:../packages/core",
1415
"@dmno/encrypted-vault-plugin": "link:../packages/plugins/encrypted-vault"
1516
}

example-repo/pnpm-lock.yaml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/configraph/src/plugin.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ export type PluginPackageMetadata = {
2323

2424
const debug = Debug('configraph:plugins');
2525

26-
27-
function cleanGitUrl(repoUrl: string) {
26+
function cleanGitUrl(repoUrl: string, directoryPath?: string) {
2827
if (!repoUrl) return;
29-
return repoUrl.replace(/^git\+/, '').replace(/\.git$/, '');
28+
let url = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '');
29+
// TODO: check if this works for non public github repos
30+
if (directoryPath) url += `/tree/main/${directoryPath}`;
31+
return url;
3032
}
3133

3234
export abstract class ConfigraphPlugin<
@@ -58,7 +60,7 @@ EntityClass extends ConfigraphEntity = ConfigraphEntity,
5860
PluginClass.packageMetadata = {
5961
name: opts.packageJson.name,
6062
version: opts.packageJson.version,
61-
repositoryUrl: cleanGitUrl(opts.packageJson.repository?.url),
63+
repositoryUrl: cleanGitUrl(opts.packageJson.repository?.url, opts.packageJson.repository?.directory),
6264
websiteUrl: opts.packageJson.homepage,
6365
};
6466
}

packages/core/src/cli/commands/init.command.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88

99
import { tryCatch } from '@dmno/ts-lib';
1010

11+
import Debug from 'debug';
1112
import { findDmnoServices } from '../../config-loader/find-services';
1213
import { DmnoCommand } from '../lib/dmno-command';
1314

@@ -22,6 +23,8 @@ import { CliExitError } from '../lib/cli-error';
2223
import { detectJsPackageManager } from '../../lib/detect-package-manager';
2324
import { pathExists } from '../../lib/fs-utils';
2425

26+
const debug = Debug('dmno:init');
27+
2528
const program = new DmnoCommand('init')
2629
.summary('Sets up dmno')
2730
.description('Sets up dmno in your repo, and can help add to new packages within your monorepo - safe to run multiple times')
@@ -60,7 +63,7 @@ program.action(async (opts: {
6063
// console.log('');
6164

6265
const rootPackage = workspaceInfo.workspacePackages[0];
63-
console.log(workspaceInfo.workspacePackages);
66+
debug(workspaceInfo.workspacePackages);
6467

6568
if (!workspaceInfo.autoSelectedPackage) {
6669
throw new Error('unable to detect which package you are in... whats happening?');

packages/docs-site/astro.config.ts

+18-12
Original file line numberDiff line numberDiff line change
@@ -156,18 +156,24 @@ export default defineConfig({
156156
}, {
157157
label: 'Plugins',
158158
badge: 'New',
159-
items: [{
160-
label: 'Overview',
161-
link: '/docs/plugins/overview/',
162-
},
163-
{
164-
label: 'Encrypted Vaults',
165-
link: '/docs/plugins/encrypted-vault/',
166-
},
167-
{
168-
label: '1Password',
169-
link: '/docs/plugins/1password/',
170-
}],
159+
items: [
160+
{
161+
label: 'Overview',
162+
link: '/docs/plugins/overview/',
163+
},
164+
{
165+
label: 'Encrypted Vaults',
166+
link: '/docs/plugins/encrypted-vault/',
167+
},
168+
{
169+
label: '1Password',
170+
link: '/docs/plugins/1password/',
171+
},
172+
{
173+
label: 'Bitwarden',
174+
link: '/docs/plugins/bitwarden/',
175+
},
176+
],
171177
}, {
172178
label: 'Integrations',
173179
badge: 'New',

packages/docs-site/src/content/docs/docs/plugins/1password.mdx

+16-12
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,16 @@ Install the package in the service(s) that will use config from 1Password.
1717

1818
-----
1919

20-
After installation, you'll need to initialize the plugin in your dmno config and wire it up to the config path that will hold your 1Password service account token. It's ok if you have not created this service account yet - we'll do that in the next section.
20+
After installation, you'll need to initialize the plugin in your dmno config and add a service 1password service account token into your config schema. You can explicitly wire the plugin up to the service account token if using multiple tokens at once, or it will be injected by default. It's ok if you have not created this service account yet - we'll do that in the next section.
2121

2222
```diff lang="ts" title='.dmno/config.mts'
2323
+import { OnePasswordDmnoPlugin, OnePasswordTypes } from '@dmno/1password-plugin';
2424

25-
+const OnePassBackend = new OnePasswordDmnoPlugin('1pass', {
25+
// token will be injected using types by default
26+
+const onePassSecrets = new OnePasswordDmnoPlugin('1pass');
27+
28+
// or you can wire up the path explicitly
29+
+const onePassSecrets2 = new OnePasswordDmnoPlugin('1passWithExplicitPath', {
2630
+ token: configPath('..', 'OP_TOKEN'),
2731
+});
2832

@@ -49,7 +53,7 @@ In a monorepo, you are likely managing secrets for multiple services. If you wil
4953
import { OnePasswordDmnoPlugin } from '@dmno/1password-plugin';
5054

5155
// 💉 inject the already initialized plugin instead of re-initializing it
52-
const OnePassBackend = OnePasswordDmnoPlugin.injectInstance('1pass');
56+
const onePassSecrets = OnePasswordDmnoPlugin.injectInstance('1pass');
5357
```
5458

5559

@@ -94,7 +98,7 @@ During local development, you may find it convenient to skip the service account
9498
<Steps>
9599
1. **Opt-in while initializing the plugin**
96100
```diff lang="ts" title='.dmno/config.mts'
97-
const OnePassBackend = new OnePasswordDmnoPlugin('1pass/dev', {
101+
const onePassSecrets = new OnePasswordDmnoPlugin('1pass/dev', {
98102
token: configPath('..', 'OP_TOKEN'),
99103
+ fallbackToCliBasedAuth: true,
100104
});
@@ -149,7 +153,7 @@ Managing lots of individual 1Password items and connecting them to your config c
149153
Your dmno config should end up looking like this:
150154

151155
```diff lang="ts" title=".dmno/config.mts"
152-
const OnePassBackend = new OnePasswordDmnoPlugin('1pass/prod', {
156+
const onePassSecrets = new OnePasswordDmnoPlugin('1pass/prod', {
153157
token: configPath('..', 'OP_TOKEN'),
154158
+ envItemLink: 'https://start.1password.com/open/i?a=I3GUA2KU6BD3FBHA47QNBIVEV4&v=ut2dftalm3ugmxc6klavms6tfq&i=n4wmgfq77mydg5lebtroa3ykvm&h=dmnoinc.1password.com',
155159
});
@@ -161,7 +165,7 @@ export default defineDmnoService({
161165
},
162166
+ SOME_API_KEY: {
163167
+ sensitive: true,
164-
+ value: OnePassBackend.item(),
168+
+ value: onePassSecrets.item(),
165169
+ }
166170
},
167171
});
@@ -189,11 +193,11 @@ export default defineDmnoService({
189193
sensitive: true,
190194
value: switchBy('APP_ENV', {
191195
// uses the default key of "SOME_API_SECRET"
192-
_default: OnePassBackendDev.item(),
196+
_default: onePassSecretsDev.item(),
193197
// uses overridden key
194-
staging: OnePassBackendDev.item('SOME_API_SECRET_STAGING'),
198+
staging: onePassSecretsDev.item('SOME_API_SECRET_STAGING'),
195199
// uses the default key but looking in a different 1pass item
196-
staging: OnePassBackendProduction.item(),
200+
staging: onePassSecretsProduction.item(),
197201
}),
198202
},
199203
},
@@ -209,18 +213,18 @@ export default defineDmnoService({
209213
schema: {
210214
// using item private link
211215
ITEM_WITH_LINK: {
212-
value: OnePassBackend.itemByLink(
216+
value: onePassSecrets.itemByLink(
213217
'https://start.1password.com/open/i?a=I3GUA2KU6BD3FBHA47QNBIVEV4&v=ut2dftalm3ugmxc6klavms6tfq&i=n4wmgfq77mydg5lebtroa3ykvm&h=dmnoinc.1password.com',
214218
'somefieldid',
215219
),
216220
},
217221
// using UUIDs
218222
ITEM_WITH_IDS: {
219-
value: OnePassBackend.itemById('vaultUuid', 'itemUuid', 'somefieldid'),
223+
value: onePassSecrets.itemById('vaultUuid', 'itemUuid', 'somefieldid'),
220224
},
221225
// using item reference url
222226
ITEM_WITH_REFERENCE: {
223-
value: OnePassBackend.itemByReference('op://vaultname/itemname/path'),
227+
value: onePassSecrets.itemByReference('op://vaultname/itemname/path'),
224228
},
225229
},
226230
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
---
2+
title: Bitwarden plugin
3+
description: DMNO's Bitwarden plugin allows you to securely access your secrets stored in Bitwarden Secrets Manager.
4+
npmPackage: "@dmno/bitwarden-plugin"
5+
---
6+
7+
import { Steps, Icon } from '@astrojs/starlight/components';
8+
import TabbedCode from '@/components/TabbedCode.astro';
9+
10+
This DMNO plugin allows you to securely access your secrets stored in [Bitwarden Secrets Manager](https://bitwarden.com/products/secrets-manager/). Please note that this plugin is **not compatible with Bitwarden's Password Manager product**. Authentication with Bitwarden uses [Machine Account Access Tokens](https://bitwarden.com/help/access-tokens/).
11+
12+
## Installation & setup
13+
14+
Install the package in the service(s) that will use secrets from Bitwarden.
15+
16+
<TabbedCode packageName="@dmno/bitwarden-plugin" />
17+
18+
-----
19+
20+
After installation, you'll need to initialize the plugin in your `config.mts` and add a config item to hold your machine account access token. You can explicitly wire the plugin up to the service account token if using multiple tokens at once, or it will be injected by default based on the `BitwardenSecretsManagerTypes.machineAccountAccessToken` type. It's ok if you have not created the machine account or access token - we'll do that in the next section.
21+
22+
```diff lang="ts" title='.dmno/config.mts'
23+
+import { BitwardenSecretsManagerDmnoPlugin, BitwardenSecretsManagerTypes } from '@dmno/bitwarden-plugin';
24+
25+
// by default, access token will be injected using types
26+
+const bitwardenPlugin = new BitwardenSecretsManagerDmnoPlugin('bitwarden');
27+
28+
// or you can explicitly wire it up by path
29+
+const bitwardenPlugin2 = new BitwardenSecretsManagerDmnoPlugin('bitwarden', {
30+
+ accessToken: configPath('..', 'BWS_TOKEN')
31+
+});
32+
33+
export default defineDmnoService({
34+
schema: {
35+
+ BWS_TOKEN: {
36+
+ extends: BitwardenSecretsManagerTypes.machineAccountAccessToken,
37+
+ // NOTE - the type itself is already marked as sensitive 🔐
38+
+ },
39+
},
40+
});
41+
```
42+
43+
:::tip[Plugin instance IDs]
44+
You must give each plugin instance a unique id so we can refer to it in other services and the [`dmno` CLI](/docs/reference/cli/plugin/).
45+
46+
In this case we used `bitwarden`, but you can imagine splitting vaults and access, and having multiple plugin instances - for example `bitwarden/prod` for highly sensitive production secrets and `bitwarden/dev` for everything else.
47+
:::
48+
49+
### Injecting the plugin in monorepo services
50+
In a monorepo, you are likely managing secrets for multiple services. If you will be using the same service account(s) to access those secrets, you can initialize a plugin instance once in your root service as seen above, and then inject it in child services. Note we must use that same id we set during initialization.
51+
52+
```typescript title='apps/some-service/.dmno/config.mts'
53+
import { BitwardenSecretsManagerDmnoPlugin } from '@dmno/bitwarden-plugin';
54+
55+
// 💉 inject the already initialized plugin instead of re-initializing it
56+
const bitwardenPlugin = BitwardenSecretsManagerDmnoPlugin.injectInstance('bitwarden');
57+
```
58+
59+
60+
------------
61+
62+
## Setup Project & Secrets
63+
64+
If you are already using Bitwarden Secrets Manager, you likely already have existing [projects](https://bitwarden.com/help/projects/) that contain [secrets](https://bitwarden.com/help/secrets/). If so, now would be a good time to review how they are all organized. If not, you should create at least one project, as each secret can have a parent project it belongs to, and access can be granted to projects rather than managing each secret individually.
65+
66+
:::tip[Use projects to segment access]
67+
You should use multiple projects to segment your secrets following the [Principle of Least Privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege). This means at minimum, you should have one project for ultra-sensitive production secrets, and another for everything else. How much more you want to break things up is up to you and will depend on your security requirements.
68+
69+
It is technically possible to create secrets without using projects and/or to assign permissions at the individual secret level, but we do not recommend it.
70+
:::
71+
72+
## Setup Machine Account & Access Tokens
73+
74+
Machine accounts can be granted access to projects, and each machine account can have multiple access tokens with optional expiration. How you want to manage this is up to you, but a sensible approach could be:
75+
76+
- Production machine account has access to all projects
77+
- single access token is used at a time
78+
- Staging/CI machine account has access to all projects except ultra-sensitive prod secrets
79+
- each environment/CI/external tool could have a unique access token
80+
- Dev machine account has access to secrets needed for local dev
81+
- each developer could have a unique access token
82+
83+
Expiring tokens are more secure, but require the overhead of rolling those tokens, which must be done manually. Not wanting to cause an outage, you may want to roll them manually without the pressure of a potentially forgotten expiry date. To roll without downtime, create a new token, redeploy, and then decommission the previous token.
84+
85+
###
86+
87+
88+
Machine account access tokens now serve as your _secret-zero_ - which grants access to the rest of your sensitive config stored in Bitwarden. It must be set locally and in deployed environments, but it is sensitive so we must pass in the value as an _override_ rather than storing it within the config. Locally, this usually means storing it in your [`.env.local` file](/docs/guides/env-files/) and on a deployed environment you'll usually set it within some kind of UI, wherever you would normally pass in environment variables.
89+
90+
```diff title=".dmno/.env.local"
91+
+BWS_TOKEN=0.abc123...
92+
```
93+
94+
Note that the config path of `BWS_TOKEN` is arbitrary and you can see how it was wired up from your config schema to the plugin input in the example above.
95+
96+
------
97+
98+
## Add items to your schema
99+
100+
With the plugin initialized and access wired up, now we must update our config schema to connect specific config values to data stored in Bitwarden secrets.
101+
102+
Items are wired up using the secret UUIDs found in the Bitwarden UI. For example:
103+
104+
```ts
105+
export default defineDmnoService({
106+
schema: {
107+
ITEM_WITH_ID: {
108+
value: bitwardenPlugin.secretById('abc123-secretuuid-xyz789'),
109+
},
110+
// example showing a switchBy and multiple plugin instances
111+
SWITCHED_ITEM: {
112+
value: switchBy('MY_ENV_FLAG', {
113+
_default: 'not-sensitive',
114+
staging: bitwardenDevSecrets.secretById('0123...'),
115+
production: bitwardenProdSecrets.secretById('789...'),
116+
}),
117+
},
118+
},
119+
});
120+
```
121+
122+
## Caching
123+
In order to avoid rate limits and keep dev server restarts extremely fast, we heavily cache data fetched from external sources. After updating secrets in Bitwarden, if the item has been cached, you'll need to clear the cache to see it take effect.
124+
125+
- Use the [`dmno clear-cache` command](/docs/reference/cli/clear-cache/) to clear the cache once
126+
- The [`dmno resolve`](/docs/reference/cli/resolve/) and [`dmno run`](/docs/reference/cli/run/) commands have cache related flags:
127+
- `--skip-cache` - skips caching logic altogether
128+
- `--clear-cache` - clears the cache once before continuing as normal
129+
130+
:::tip[Active config iteration]
131+
While you are actively working on the config itself, `dmno resolve -w --skip-cache` will combine watch mode with skipping cache logic.
132+
133+
Once you are satisfied, clear the cache once more and you are good to go.
134+
:::
135+
136+
137+
## Self-hosted
138+
In case you are self-hosting Bitwarden Secrets Manager, the `BitwardenSecretsManagerDmnoPlugin` also takes additional inputs for `apiServerUrl` and `identityServerUrl`. The values for this can be found in the Bitwarden UI under `Machine Accounts` > `Config`. See the [Bitwarden docs](https://bitwarden.com/help/machine-accounts/#configuration-information) for more details.
139+
140+
```typescript
141+
const bitwardenPlugin = new BitwardenSecretsManagerDmnoPlugin('bitwarden', {
142+
apiServerUrl: 'https://vault.bitwarden.com/api', // default value
143+
identityServerUrl: 'https://vault.bitwarden.com/identity', // default value
144+
});

0 commit comments

Comments
 (0)