@davidvornholt/standards 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/package.json +17 -1
- package/src/cli.ts +1 -1
- package/src/creds-add.ts +19 -1
- package/src/creds-args.ts +9 -0
- package/src/creds-cloudflare-account.ts +2 -2
- package/src/creds-cloudflare-api.ts +6 -10
- package/src/creds-cloudflare-id.ts +10 -0
- package/src/creds-cloudflare-request.ts +31 -0
- package/src/creds-cloudflare-zone.ts +3 -3
- package/src/creds-commands.ts +13 -2
- package/src/creds-dest.ts +40 -25
- package/src/creds-naming.ts +13 -0
- package/src/creds-plan-renew.ts +45 -13
- package/src/creds-plan-report.ts +87 -0
- package/src/creds-plan-run.ts +39 -26
- package/src/creds-plan-types.ts +19 -0
- package/src/creds-plan-unmanaged.ts +102 -0
- package/src/creds-plan.ts +25 -21
- package/src/creds-revoke.ts +162 -0
- package/src/creds-store-lock-holder.ts +8 -1
- package/src/creds-store-lock.ts +2 -1
- package/src/creds-target.ts +57 -0
- package/src/dev-env-brokered-allowlist.ts +44 -0
- package/src/dev-env-brokered-preserve.ts +65 -0
- package/src/dev-env-brokered-refresh.ts +129 -0
- package/src/dev-env-brokered-resolve.ts +158 -0
- package/src/dev-env-brokered.ts +83 -0
- package/src/dev-env-compose.ts +129 -0
- package/src/dev-env-destination-duplicates.ts +10 -10
- package/src/dev-env-destination-gitignore.ts +22 -0
- package/src/dev-env-destination.ts +52 -30
- package/src/dev-env-document.ts +104 -22
- package/src/dev-env-dotenv.ts +50 -5
- package/src/dev-env-plain-layer.ts +53 -0
- package/src/dev-env-reconciliation.ts +170 -0
- package/src/dev-env-secrets.ts +41 -0
- package/src/dev-env-transaction-recovery.ts +74 -11
- package/src/dev-env-transaction.ts +61 -15
- package/src/dev-env.ts +132 -79
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// One-shot retirement of a Cloudflare token the broker does not manage, named
|
|
2
|
+
// explicitly by ID. Replacing a hand-made credential means minting a brokered
|
|
3
|
+
// one and deleting the original; without this the second half could only be
|
|
4
|
+
// done in the dashboard. The power granted is deliberately momentary: nothing
|
|
5
|
+
// is recorded, nothing becomes automatic, and a human names the target every
|
|
6
|
+
// time. Tokens reconciliation owns are refused — those are retired by deleting
|
|
7
|
+
// their SOPS key and running `standards creds apply`, which keeps the secret
|
|
8
|
+
// and the provider in step.
|
|
9
|
+
|
|
10
|
+
import { deleteAccountToken } from './creds-cloudflare';
|
|
11
|
+
import { isCloudflareId } from './creds-cloudflare-id';
|
|
12
|
+
import { selectAccount } from './creds-dest';
|
|
13
|
+
import { identifyCloudflareBootstrapAuthority } from './creds-login-cloudflare';
|
|
14
|
+
import {
|
|
15
|
+
BROKER_IDENTITY_NAME,
|
|
16
|
+
type BrokeredTokenRef,
|
|
17
|
+
parseAnyTokenName,
|
|
18
|
+
} from './creds-naming';
|
|
19
|
+
import { readBrokerStore, resolveBrokerPath } from './creds-store';
|
|
20
|
+
import { resolveGithubRepo } from './github-api';
|
|
21
|
+
|
|
22
|
+
const fail = (message: string): false => {
|
|
23
|
+
console.error(`standards creds: ${message}`);
|
|
24
|
+
return false;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// GitHub owner and repository names are case-insensitive, so a checkout cloned
|
|
28
|
+
// as `DavidVornholt/Example` owns the token minted as `davidvornholt/example`.
|
|
29
|
+
// An exact comparison would call that token foreign and let --force delete a
|
|
30
|
+
// live credential this very checkout reconciles. Owner and name are compared
|
|
31
|
+
// together: `otherowner/example` shares this repository's name and nothing
|
|
32
|
+
// else.
|
|
33
|
+
const sameRepo = (left: string, right: string): boolean =>
|
|
34
|
+
left.toLowerCase() === right.toLowerCase();
|
|
35
|
+
|
|
36
|
+
// A brokered name carries the repository that owns it, and the reconciled
|
|
37
|
+
// remedy — delete the SOPS key, run `apply` — only works in that repository.
|
|
38
|
+
// Naming a bare `<target>:<key>` to an operator standing somewhere else would
|
|
39
|
+
// have them destroy their own live credential of the same name, so the owning
|
|
40
|
+
// repository is always named and the remedy is placed where it runs.
|
|
41
|
+
const brokeredRefusal = ({
|
|
42
|
+
tokenId,
|
|
43
|
+
name,
|
|
44
|
+
brokered,
|
|
45
|
+
currentRepo,
|
|
46
|
+
force,
|
|
47
|
+
}: {
|
|
48
|
+
readonly tokenId: string;
|
|
49
|
+
readonly name: string;
|
|
50
|
+
readonly brokered: BrokeredTokenRef;
|
|
51
|
+
readonly currentRepo: string | null;
|
|
52
|
+
readonly force: boolean;
|
|
53
|
+
}): string | null => {
|
|
54
|
+
// Without a repository here, the one thing --force claims to override — that
|
|
55
|
+
// this checkout is not the owner — is the thing that could not be checked,
|
|
56
|
+
// so the override is refused rather than granted on an unevaluated guard.
|
|
57
|
+
if (currentRepo === null) {
|
|
58
|
+
return `token ${tokenId} is brokered as ${name}, which belongs to ${brokered.repo}, and this checkout cannot resolve the GitHub repository from its origin remote, so whether this checkout owns the token could not be determined; --force widens only that ownership check, so here it has nothing to widen. Re-run from a checkout whose origin remote names a GitHub repository, or point --dir at one. If ${brokered.repo} still exists, the remedy runs there: delete ${brokered.target}:${brokered.key} from its SOPS target and run \`standards creds apply\` in that checkout`;
|
|
59
|
+
}
|
|
60
|
+
if (brokered.repo === currentRepo) {
|
|
61
|
+
return `token ${tokenId} is brokered as ${name} for this repository (${brokered.repo}); delete ${brokered.target}:${brokered.key} from this repository's SOPS target and run \`standards creds apply\`, which revokes it and keeps the secret in step`;
|
|
62
|
+
}
|
|
63
|
+
// Revoke compares repository names case-insensitively, but reconciliation
|
|
64
|
+
// matches the token name exactly, so a checkout whose origin differs only in
|
|
65
|
+
// capitalisation renews nothing and revokes nothing. Sending the operator to
|
|
66
|
+
// the reconciled remedy would prescribe a no-op.
|
|
67
|
+
if (sameRepo(brokered.repo, currentRepo)) {
|
|
68
|
+
return `token ${tokenId} is brokered as ${name}, and this checkout's origin names ${currentRepo}, which differs from ${brokered.repo} only in capitalisation; reconciliation matches the token name exactly, so from here it will neither renew nor revoke this token. Re-point the origin remote at ${brokered.repo} to bring the token back under reconciliation, or retire it in the Cloudflare dashboard`;
|
|
69
|
+
}
|
|
70
|
+
if (force) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return `token ${tokenId} is brokered as ${name}, which belongs to ${brokered.repo}, and this checkout is ${currentRepo}; the remedy runs there: delete ${brokered.target}:${brokered.key} from ${brokered.repo}'s SOPS target and run \`standards creds apply\` in that checkout. Pass --force only if ${brokered.repo} was renamed, transferred, or deleted, so no checkout resolves to that name and no apply will ever revoke this token`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const runCredsRevoke = async (options: {
|
|
77
|
+
readonly account: string | undefined;
|
|
78
|
+
readonly tokenId: string | undefined;
|
|
79
|
+
readonly dir: string;
|
|
80
|
+
readonly force: boolean;
|
|
81
|
+
}): Promise<boolean> => {
|
|
82
|
+
const { tokenId } = options;
|
|
83
|
+
if (tokenId === undefined) {
|
|
84
|
+
return fail(
|
|
85
|
+
'revoke requires --token-id <id>; `standards creds plan` prints the ID inside every revoke command it offers, and the Cloudflare dashboard has the ID of anything that listing leaves out',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (!isCloudflareId(tokenId)) {
|
|
89
|
+
return fail(
|
|
90
|
+
`not a token ID: ${tokenId}; --token-id takes the 32-character hexadecimal token ID`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const store = await readBrokerStore(resolveBrokerPath());
|
|
94
|
+
const account = selectAccount(store, options.account);
|
|
95
|
+
if (account === null) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const identified = await identifyCloudflareBootstrapAuthority(
|
|
99
|
+
account.accountId,
|
|
100
|
+
account.token,
|
|
101
|
+
);
|
|
102
|
+
if (!identified.ok) {
|
|
103
|
+
return fail(`account ${account.accountId}: ${identified.problem}`);
|
|
104
|
+
}
|
|
105
|
+
// The bootstrap token is matched by its verified ID rather than its name,
|
|
106
|
+
// because deleting it would strip the broker of every power it has and no
|
|
107
|
+
// rename should be able to disguise it.
|
|
108
|
+
if (identified.value.id === tokenId) {
|
|
109
|
+
return fail(
|
|
110
|
+
`token ${tokenId} is this account's broker bootstrap credential; revoking it would leave the broker unable to mint or reconcile anything`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const target = identified.value.tokens.find((entry) => entry.id === tokenId);
|
|
114
|
+
if (target === undefined) {
|
|
115
|
+
return fail(
|
|
116
|
+
`no token ${tokenId} in account ${account.accountId}; revoke names a token in the account the bootstrap credential belongs to`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
// The broker store is machine-global and `creds login` is a per-machine
|
|
120
|
+
// bootstrap, so one account can hold several tokens under the reserved name
|
|
121
|
+
// — one per machine. Only this machine's is identified by ID above; the rest
|
|
122
|
+
// are refused by name, because deleting one locks another machine out of the
|
|
123
|
+
// account and the broker cannot re-mint its own root credential.
|
|
124
|
+
if (target.name === BROKER_IDENTITY_NAME) {
|
|
125
|
+
return fail(
|
|
126
|
+
`token ${tokenId} is named ${BROKER_IDENTITY_NAME}, the reserved name for a machine's broker bootstrap credential; this machine's own bootstrap is a different token, so this one belongs to another machine or is a superseded one — retire it in the Cloudflare dashboard, where you can confirm which machine it belongs to`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const brokered = parseAnyTokenName(target.name);
|
|
130
|
+
if (brokered !== null) {
|
|
131
|
+
const refusal = brokeredRefusal({
|
|
132
|
+
tokenId,
|
|
133
|
+
name: target.name,
|
|
134
|
+
brokered,
|
|
135
|
+
currentRepo: resolveGithubRepo(options.dir),
|
|
136
|
+
force: options.force,
|
|
137
|
+
});
|
|
138
|
+
if (refusal !== null) {
|
|
139
|
+
return fail(refusal);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const deleted = await deleteAccountToken(
|
|
143
|
+
account.accountId,
|
|
144
|
+
account.token,
|
|
145
|
+
tokenId,
|
|
146
|
+
);
|
|
147
|
+
if (!deleted.ok) {
|
|
148
|
+
return fail(`${target.name}: ${deleted.problem}`);
|
|
149
|
+
}
|
|
150
|
+
console.log(
|
|
151
|
+
`standards creds: revoked ${target.name} (${account.accountId}/${tokenId})`,
|
|
152
|
+
);
|
|
153
|
+
if (brokered !== null) {
|
|
154
|
+
console.log(
|
|
155
|
+
` it was brokered to ${brokered.repo}; --force took it because this checkout is not that repository`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
console.log(
|
|
159
|
+
' any stored copy of its value is now dead; replace it with `standards creds add cloudflare` if something still needs it',
|
|
160
|
+
);
|
|
161
|
+
return true;
|
|
162
|
+
};
|
|
@@ -10,6 +10,13 @@ const OWNER_ONLY_FILE_MODE = 0o600;
|
|
|
10
10
|
const HOLDER_PREFIX = 'holder-';
|
|
11
11
|
const HOLDER_SUFFIX = '.json';
|
|
12
12
|
const LEASE_DIVISOR = 3;
|
|
13
|
+
// Both codes mean the lock is no longer this owner's to clean up. ENOENT: it is
|
|
14
|
+
// already gone. ENOTEMPTY: a contender published its own generation into the
|
|
15
|
+
// directory during the moment the unlink left it empty — renaming onto an empty
|
|
16
|
+
// directory succeeds, which is exactly how a waiter takes the lock. Releasing
|
|
17
|
+
// runs from a finally, so throwing here would replace the result of an
|
|
18
|
+
// operation that already succeeded.
|
|
19
|
+
const RELEASABLE_RMDIR_CODES = ['ENOENT', 'ENOTEMPTY'] as const;
|
|
13
20
|
|
|
14
21
|
export type HeldBrokerLock = {
|
|
15
22
|
readonly holderPath: string;
|
|
@@ -91,7 +98,7 @@ export const releaseBrokerLock = async ({
|
|
|
91
98
|
try {
|
|
92
99
|
await rmdir(lockPath);
|
|
93
100
|
} catch (error) {
|
|
94
|
-
if (!isErrorCode(error,
|
|
101
|
+
if (!RELEASABLE_RMDIR_CODES.some((code) => isErrorCode(error, code))) {
|
|
95
102
|
throw error;
|
|
96
103
|
}
|
|
97
104
|
}
|
package/src/creds-store-lock.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// place before its operation starts. A worker-thread lease remains live while
|
|
3
3
|
// synchronous SOPS/Nix blocks the main event loop. Cleanup unlinks the exact
|
|
4
4
|
// generation token before removing the directory, so an old owner cannot
|
|
5
|
-
// remove a replacement
|
|
5
|
+
// remove a replacement — and finding one there instead of an empty directory
|
|
6
|
+
// is a no-op, never a failure of the operation being unwound.
|
|
6
7
|
|
|
7
8
|
import { mkdir } from 'node:fs/promises';
|
|
8
9
|
import { dirname } from 'node:path';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { isContainedSopsPath } from './creds-sops-structure';
|
|
2
|
+
|
|
3
|
+
const SAFE_TARGET = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?$/u;
|
|
4
|
+
|
|
5
|
+
export const isSafeSecretsTargetName = (target: string): boolean =>
|
|
6
|
+
SAFE_TARGET.test(target);
|
|
7
|
+
|
|
8
|
+
export type TargetRelResolution =
|
|
9
|
+
| { readonly ok: true; readonly rel: string }
|
|
10
|
+
| {
|
|
11
|
+
readonly ok: false;
|
|
12
|
+
readonly kind: 'invalid' | 'missing' | 'ambiguous';
|
|
13
|
+
readonly problem: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const resolveTargetRelResult = (
|
|
17
|
+
consumer: string,
|
|
18
|
+
target: string,
|
|
19
|
+
): TargetRelResolution => {
|
|
20
|
+
if (!isSafeSecretsTargetName(target)) {
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
kind: 'invalid',
|
|
24
|
+
problem: `secrets target "${target}" is not a safe target name`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const host = `infra/hosts/${target}/secrets.yaml`;
|
|
28
|
+
const flat = `secrets/${target}.yaml`;
|
|
29
|
+
const hostExists = isContainedSopsPath(consumer, host, 'file');
|
|
30
|
+
const flatExists = isContainedSopsPath(consumer, flat, 'file');
|
|
31
|
+
if (hostExists && flatExists) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
kind: 'ambiguous',
|
|
35
|
+
problem: `secrets target "${target}" is ambiguous because both ${flat} and ${host} exist; rename one target so the name binds exactly one encrypted file`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (hostExists) {
|
|
39
|
+
return { ok: true, rel: host };
|
|
40
|
+
}
|
|
41
|
+
if (flatExists) {
|
|
42
|
+
return { ok: true, rel: flat };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
kind: 'missing',
|
|
47
|
+
problem: `secrets target "${target}" does not exist`,
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const resolveTargetRel = (
|
|
52
|
+
consumer: string,
|
|
53
|
+
target: string,
|
|
54
|
+
): string | null => {
|
|
55
|
+
const resolved = resolveTargetRelResult(consumer, target);
|
|
56
|
+
return resolved.ok ? resolved.rel : null;
|
|
57
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { parseDestination } from './creds-dest';
|
|
2
|
+
import type { DevEnvLayerKind } from './dev-env-document';
|
|
3
|
+
|
|
4
|
+
export const BROKERED_REFERENCES_KEY = 'brokeredReferences';
|
|
5
|
+
|
|
6
|
+
export type ParsedBrokeredAllowlist = {
|
|
7
|
+
readonly entries: ReadonlySet<string>;
|
|
8
|
+
readonly problems: ReadonlyArray<string>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const parseBrokeredAllowlist = (
|
|
12
|
+
source: string,
|
|
13
|
+
raw: unknown,
|
|
14
|
+
layerKind: DevEnvLayerKind,
|
|
15
|
+
): ParsedBrokeredAllowlist => {
|
|
16
|
+
if (layerKind === 'configuration') {
|
|
17
|
+
return {
|
|
18
|
+
entries: new Set(),
|
|
19
|
+
problems: [
|
|
20
|
+
`${source} reserved top-level key "${BROKERED_REFERENCES_KEY}" belongs only in the SOPS-encrypted secrets/dev.yaml layer`,
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (!Array.isArray(raw)) {
|
|
25
|
+
return {
|
|
26
|
+
entries: new Set(),
|
|
27
|
+
problems: [
|
|
28
|
+
`${source} "${BROKERED_REFERENCES_KEY}" must list exact "<target>:<dotted.key>" entries`,
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const entries = new Set<string>();
|
|
33
|
+
const problems: Array<string> = [];
|
|
34
|
+
for (const [index, entry] of raw.entries()) {
|
|
35
|
+
if (typeof entry !== 'string' || parseDestination(entry) === null) {
|
|
36
|
+
problems.push(
|
|
37
|
+
`${source} "${BROKERED_REFERENCES_KEY}" entry ${index + 1} must be an exact "<target>:<dotted.key>" string`,
|
|
38
|
+
);
|
|
39
|
+
} else {
|
|
40
|
+
entries.add(entry);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { entries, problems };
|
|
44
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { DevEnvValue } from './dev-env-document';
|
|
4
|
+
import { preserveRenderedDotenvValues, renderDotenv } from './dev-env-dotenv';
|
|
5
|
+
|
|
6
|
+
export type DevEnvRunOptions = {
|
|
7
|
+
readonly preservedBrokeredReferences: ReadonlySet<string>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type RenderInput = {
|
|
11
|
+
readonly consumer: string;
|
|
12
|
+
readonly rel: string;
|
|
13
|
+
readonly sourcePath: string;
|
|
14
|
+
readonly sources: ReadonlyArray<string>;
|
|
15
|
+
readonly resolvedEnv: Readonly<Record<string, string>>;
|
|
16
|
+
readonly composedEnv: Readonly<Record<string, DevEnvValue>>;
|
|
17
|
+
readonly preservedReferences: ReadonlySet<string>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type RenderResult =
|
|
21
|
+
| { readonly ok: true; readonly content: string }
|
|
22
|
+
| { readonly ok: false; readonly problem: string };
|
|
23
|
+
|
|
24
|
+
const preservedKeys = (
|
|
25
|
+
env: Readonly<Record<string, DevEnvValue>>,
|
|
26
|
+
references: ReadonlySet<string>,
|
|
27
|
+
): ReadonlyArray<string> =>
|
|
28
|
+
Object.entries(env).flatMap(([key, value]) =>
|
|
29
|
+
typeof value !== 'string' &&
|
|
30
|
+
references.has(`${value.brokeredS3}:${value.key}`)
|
|
31
|
+
? [key]
|
|
32
|
+
: [],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const renderDevEnvPreservingUnsafeReferences = (
|
|
36
|
+
input: RenderInput,
|
|
37
|
+
): RenderResult => {
|
|
38
|
+
const rendered = renderDotenv(
|
|
39
|
+
input.sourcePath,
|
|
40
|
+
input.sources,
|
|
41
|
+
input.resolvedEnv,
|
|
42
|
+
);
|
|
43
|
+
const keys = preservedKeys(input.composedEnv, input.preservedReferences);
|
|
44
|
+
if (keys.length === 0) {
|
|
45
|
+
return { ok: true, content: rendered };
|
|
46
|
+
}
|
|
47
|
+
let previous: string;
|
|
48
|
+
try {
|
|
49
|
+
const path = join(input.consumer, input.rel);
|
|
50
|
+
const stat = lstatSync(path);
|
|
51
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
52
|
+
throw new Error('not a regular file');
|
|
53
|
+
}
|
|
54
|
+
previous = readFileSync(path, 'utf8');
|
|
55
|
+
} catch {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
problem: `${input.rel} has no readable prior generated values for unsafe brokered references`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const preserved = preserveRenderedDotenvValues(previous, rendered, keys);
|
|
62
|
+
return preserved.ok
|
|
63
|
+
? preserved
|
|
64
|
+
: { ok: false, problem: `${input.rel}: ${preserved.problem}` };
|
|
65
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// After the creds broker writes into a SOPS target, the generated dev env
|
|
2
|
+
// files are stale if any workspace env key references a brokered pair in
|
|
3
|
+
// that target. Regenerating here closes the rotation loop: a renewed pair
|
|
4
|
+
// reaches every generated .env.local without a manual copy step.
|
|
5
|
+
|
|
6
|
+
import { resolveTargetRelResult } from './creds-target';
|
|
7
|
+
import { runDevEnv } from './dev-env';
|
|
8
|
+
import { parseDevEnvDocument } from './dev-env-document';
|
|
9
|
+
import { readPlainLayer } from './dev-env-plain-layer';
|
|
10
|
+
|
|
11
|
+
const DEV_PLAIN_LAYERS = ['config/dev.yaml', 'config/dev.local.yaml'] as const;
|
|
12
|
+
|
|
13
|
+
type LayerReferenceDiscovery = {
|
|
14
|
+
readonly references: ReadonlyArray<{
|
|
15
|
+
readonly target: string;
|
|
16
|
+
readonly key: string;
|
|
17
|
+
}>;
|
|
18
|
+
readonly problems: ReadonlyArray<string>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const layerReferenceTargets = (
|
|
22
|
+
consumer: string,
|
|
23
|
+
layerRel: string,
|
|
24
|
+
): LayerReferenceDiscovery => {
|
|
25
|
+
const layer = readPlainLayer(consumer, layerRel);
|
|
26
|
+
if (layer.input === null) {
|
|
27
|
+
return { references: [], problems: layer.problems };
|
|
28
|
+
}
|
|
29
|
+
const document = parseDevEnvDocument(
|
|
30
|
+
layer.input.raw,
|
|
31
|
+
layerRel,
|
|
32
|
+
'configuration',
|
|
33
|
+
);
|
|
34
|
+
return {
|
|
35
|
+
references: document.targets.flatMap((target) =>
|
|
36
|
+
Object.values(target.env).flatMap((value) =>
|
|
37
|
+
typeof value === 'string'
|
|
38
|
+
? []
|
|
39
|
+
: [{ target: value.brokeredS3, key: value.key }],
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
problems: [...layer.problems, ...document.problems],
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type BrokeredReferenceDiscovery = {
|
|
47
|
+
readonly references: ReadonlySet<string>;
|
|
48
|
+
readonly problems: ReadonlyArray<string>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type BrokeredRefreshEvidence = {
|
|
52
|
+
readonly target: string;
|
|
53
|
+
readonly key: string;
|
|
54
|
+
readonly safety: 'verified' | 'unsafe';
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const referenceIdentity = (target: string, key: string): string =>
|
|
58
|
+
`${target}:${key}`;
|
|
59
|
+
|
|
60
|
+
const discoverBrokeredReferenceRels = (
|
|
61
|
+
consumer: string,
|
|
62
|
+
): BrokeredReferenceDiscovery => {
|
|
63
|
+
const references = new Set<string>();
|
|
64
|
+
const problems: Array<string> = [];
|
|
65
|
+
for (const layerRel of DEV_PLAIN_LAYERS) {
|
|
66
|
+
const layer = layerReferenceTargets(consumer, layerRel);
|
|
67
|
+
problems.push(...layer.problems);
|
|
68
|
+
for (const reference of layer.references) {
|
|
69
|
+
const resolved = resolveTargetRelResult(consumer, reference.target);
|
|
70
|
+
if (resolved.ok) {
|
|
71
|
+
references.add(referenceIdentity(reference.target, reference.key));
|
|
72
|
+
} else {
|
|
73
|
+
problems.push(
|
|
74
|
+
resolved.kind === 'ambiguous'
|
|
75
|
+
? `dev config references ${resolved.problem}`
|
|
76
|
+
: `dev config references secrets target "${reference.target}", but it is missing or is not a contained regular SOPS file`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { references, problems };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const refreshDevEnvForSopsWrites = async (
|
|
85
|
+
consumer: string,
|
|
86
|
+
evidence: ReadonlyArray<BrokeredRefreshEvidence>,
|
|
87
|
+
): Promise<boolean> => {
|
|
88
|
+
if (evidence.length === 0) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
const discovery = discoverBrokeredReferenceRels(consumer);
|
|
92
|
+
if (discovery.problems.length > 0) {
|
|
93
|
+
console.error(
|
|
94
|
+
`standards creds: cannot determine whether changed credentials require dev env regeneration because the configuration has ${discovery.problems.length} problem(s):`,
|
|
95
|
+
);
|
|
96
|
+
console.error(
|
|
97
|
+
discovery.problems.map((problem) => ` - ${problem}`).join('\n'),
|
|
98
|
+
);
|
|
99
|
+
console.error(
|
|
100
|
+
'standards creds: credential changes were written, but generated .env.local files were not updated; fix the configuration and run `bun standards dev-env`',
|
|
101
|
+
);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const unsafe = new Set(
|
|
105
|
+
evidence
|
|
106
|
+
.filter((entry) => entry.safety === 'unsafe')
|
|
107
|
+
.map((entry) => referenceIdentity(entry.target, entry.key)),
|
|
108
|
+
);
|
|
109
|
+
const verified = new Set(
|
|
110
|
+
evidence
|
|
111
|
+
.filter((entry) => entry.safety === 'verified')
|
|
112
|
+
.map((entry) => referenceIdentity(entry.target, entry.key)),
|
|
113
|
+
);
|
|
114
|
+
const changedReferences = [...verified].filter(
|
|
115
|
+
(reference) =>
|
|
116
|
+
discovery.references.has(reference) && !unsafe.has(reference),
|
|
117
|
+
);
|
|
118
|
+
if (changedReferences.length === 0) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
console.log(
|
|
122
|
+
'standards creds: regenerating dev env files (a brokered pair referenced by the dev config changed)',
|
|
123
|
+
);
|
|
124
|
+
return await runDevEnv(consumer, {
|
|
125
|
+
preservedBrokeredReferences: new Set(
|
|
126
|
+
[...unsafe].filter((reference) => discovery.references.has(reference)),
|
|
127
|
+
),
|
|
128
|
+
});
|
|
129
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Resolves brokered S3 pair references in the composed dev env into literal
|
|
2
|
+
// values by decrypting each referenced SOPS target once. Resolution runs
|
|
3
|
+
// after layer composition so a later layer's literal can override an earlier
|
|
4
|
+
// layer's reference, and a reference that lost the merge is never decrypted.
|
|
5
|
+
|
|
6
|
+
import { resolveTargetRelResult } from './creds-target';
|
|
7
|
+
import type { BrokeredS3Reference } from './dev-env-brokered';
|
|
8
|
+
import type { ComposedDevEnvTarget } from './dev-env-compose';
|
|
9
|
+
import { encodeBunDotenvValue } from './dev-env-dotenv-value';
|
|
10
|
+
import { isRecord } from './github-settings-parse';
|
|
11
|
+
import { runSops } from './sops-exec';
|
|
12
|
+
|
|
13
|
+
export type ResolvedDevEnvTarget = {
|
|
14
|
+
readonly group: string;
|
|
15
|
+
readonly workspace: string;
|
|
16
|
+
readonly env: Readonly<Record<string, string>>;
|
|
17
|
+
readonly sources: ReadonlyArray<string>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ResolvedDevEnv = {
|
|
21
|
+
readonly targets: ReadonlyArray<ResolvedDevEnvTarget>;
|
|
22
|
+
readonly problems: ReadonlyArray<string>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type SopsDocumentResult =
|
|
26
|
+
| { readonly ok: true; readonly value: unknown }
|
|
27
|
+
| { readonly ok: false; readonly problem: string };
|
|
28
|
+
|
|
29
|
+
const decryptSopsJson = (consumer: string, rel: string): SopsDocumentResult => {
|
|
30
|
+
const result = runSops(['--decrypt', '--output-type', 'json', rel], consumer);
|
|
31
|
+
if (result.status !== 0) {
|
|
32
|
+
const detail = result.errorMessage ?? result.stderr.trim();
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
problem: detail
|
|
36
|
+
? `could not decrypt ${rel}: ${detail}`
|
|
37
|
+
: `could not decrypt ${rel}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return { ok: true, value: JSON.parse(result.stdout) as unknown };
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
problem: `could not parse decrypted ${rel} as JSON: ${
|
|
46
|
+
error instanceof Error ? error.message : String(error)
|
|
47
|
+
}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const lookupReference = (
|
|
53
|
+
document: unknown,
|
|
54
|
+
reference: BrokeredS3Reference,
|
|
55
|
+
): { readonly value: string | null; readonly problem: string | null } => {
|
|
56
|
+
let node: unknown = document;
|
|
57
|
+
for (const segment of reference.key.split('.')) {
|
|
58
|
+
if (!isRecord(node) || node[segment] === undefined) {
|
|
59
|
+
return {
|
|
60
|
+
value: null,
|
|
61
|
+
problem: `has no key "${reference.key}"; mint the pair with \`bun standards creds add cloudflare --s3 --dest ${reference.brokeredS3}:${reference.key}\``,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
node = node[segment];
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
!isRecord(node) ||
|
|
68
|
+
typeof node.access_key_id !== 'string' ||
|
|
69
|
+
typeof node.secret_access_key !== 'string'
|
|
70
|
+
) {
|
|
71
|
+
return {
|
|
72
|
+
value: null,
|
|
73
|
+
problem: `key "${reference.key}" does not hold a complete brokered S3 pair; both "access_key_id" and "secret_access_key" must be strings`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { value: node[reference.part] as string, problem: null };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const resolveBrokeredReferences = (
|
|
80
|
+
consumer: string,
|
|
81
|
+
targets: ReadonlyArray<ComposedDevEnvTarget>,
|
|
82
|
+
allowedReferences: ReadonlySet<string>,
|
|
83
|
+
preservedReferences: ReadonlySet<string> = new Set(),
|
|
84
|
+
): ResolvedDevEnv => {
|
|
85
|
+
const problems: Array<string> = [];
|
|
86
|
+
const documents = new Map<string, SopsDocumentResult>();
|
|
87
|
+
const readDocument = (targetName: string): SopsDocumentResult => {
|
|
88
|
+
const cached = documents.get(targetName);
|
|
89
|
+
if (cached !== undefined) {
|
|
90
|
+
return cached;
|
|
91
|
+
}
|
|
92
|
+
const resolved = resolveTargetRelResult(consumer, targetName);
|
|
93
|
+
const result = resolved.ok
|
|
94
|
+
? decryptSopsJson(consumer, resolved.rel)
|
|
95
|
+
: {
|
|
96
|
+
ok: false as const,
|
|
97
|
+
problem:
|
|
98
|
+
resolved.kind === 'ambiguous'
|
|
99
|
+
? resolved.problem
|
|
100
|
+
: `secrets target "${targetName}" does not exist; create it and mint the pair with \`bun standards creds add cloudflare --s3\``,
|
|
101
|
+
};
|
|
102
|
+
documents.set(targetName, result);
|
|
103
|
+
return result;
|
|
104
|
+
};
|
|
105
|
+
const resolveValue = (
|
|
106
|
+
label: string,
|
|
107
|
+
reference: BrokeredS3Reference,
|
|
108
|
+
): { readonly value: string | null; readonly problem: string | null } => {
|
|
109
|
+
const allowlistEntry = `${reference.brokeredS3}:${reference.key}`;
|
|
110
|
+
if (!allowedReferences.has(allowlistEntry)) {
|
|
111
|
+
return {
|
|
112
|
+
value: null,
|
|
113
|
+
problem: `${label}: unauthorized brokered S3 pair; add "${allowlistEntry}" to the encrypted secrets/dev.yaml brokeredReferences allowlist`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (preservedReferences.has(allowlistEntry)) {
|
|
117
|
+
return { value: '', problem: null };
|
|
118
|
+
}
|
|
119
|
+
const document = readDocument(reference.brokeredS3);
|
|
120
|
+
if (!document.ok) {
|
|
121
|
+
return { value: null, problem: `${label}: ${document.problem}` };
|
|
122
|
+
}
|
|
123
|
+
const lookup = lookupReference(document.value, reference);
|
|
124
|
+
if (lookup.value === null) {
|
|
125
|
+
return { value: null, problem: `${label}: ${lookup.problem}` };
|
|
126
|
+
}
|
|
127
|
+
if (encodeBunDotenvValue(lookup.value) === null) {
|
|
128
|
+
return {
|
|
129
|
+
value: null,
|
|
130
|
+
problem: `${label}: resolved value cannot be represented losslessly in Bun dotenv syntax`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { value: lookup.value, problem: null };
|
|
134
|
+
};
|
|
135
|
+
const resolved = targets.map((target) => {
|
|
136
|
+
const env = Object.create(null) as Record<string, string>;
|
|
137
|
+
for (const [key, value] of Object.entries(target.env)) {
|
|
138
|
+
if (typeof value === 'string') {
|
|
139
|
+
env[key] = value;
|
|
140
|
+
} else {
|
|
141
|
+
const label = `${target.group}.${target.workspace}.${key} reference to secrets target "${value.brokeredS3}"`;
|
|
142
|
+
const outcome = resolveValue(label, value);
|
|
143
|
+
if (outcome.value === null) {
|
|
144
|
+
problems.push(outcome.problem ?? `${label}: unresolved reference`);
|
|
145
|
+
} else {
|
|
146
|
+
env[key] = outcome.value;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
group: target.group,
|
|
152
|
+
workspace: target.workspace,
|
|
153
|
+
env,
|
|
154
|
+
sources: target.sources,
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
return { targets: resolved, problems };
|
|
158
|
+
};
|