@davidvornholt/standards 0.21.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 +1 -1
- package/package.json +9 -1
- package/src/creds-add.ts +19 -1
- package/src/creds-cloudflare-api.ts +6 -10
- package/src/creds-cloudflare-request.ts +31 -0
- package/src/creds-dest.ts +40 -25
- package/src/creds-plan-renew.ts +45 -13
- package/src/creds-plan-run.ts +37 -15
- 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 +14 -5
- package/src/dev-env-document.ts +87 -18
- package/src/dev-env-dotenv.ts +38 -2
- package/src/dev-env-plain-layer.ts +53 -0
- package/src/dev-env.ts +60 -59
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ standards poller --print-units --config <path>
|
|
|
29
29
|
|
|
30
30
|
`dependabot --write` regenerates the composed `.github/dependabot.yml` from the canonical `.github/dependabot.base.yml` and the optional repo-owned `.github/dependabot.local.yml`; `init` and `sync` do the same automatically. `dependabot --check` verifies the generated file still matches its sources, and `doctor`/`check` include the same verification. The overlay is deliberately lean and additive only: new update blocks for repo-specific ecosystems, top-level private registry definitions, and `ignore` or `registries` entries appended by repeating a canonical block's normalized target. Matching blocks cannot add labels, groups, cooldowns, pull-request limits, or other policy. Update blocks for the same ecosystem and target branch must not select intersecting directories after Dependabot's path normalization. A plural `directories` target is expanded as a glob when it contains `*`, `?`, or both `[` and `]`: `*` and bare `**` stay within one path segment, `**/` matches zero or more directory segments, `?` matches one non-separator character, and bracket expressions match character sets or ranges. Singular `directory` values are always literal.
|
|
31
31
|
|
|
32
|
-
`dev-env` composes each workspace's dev environment from three layers sharing one shape — keyed by workspace group and a one-segment kebab-case workspace name (`apps.<name>`, `packages.<name>`)
|
|
32
|
+
`dev-env` composes each workspace's dev environment from three layers sharing one workspace shape — keyed by workspace group and a one-segment kebab-case workspace name (`apps.<name>`, `packages.<name>`): the tracked plain `config/dev.yaml`, the SOPS-encrypted `secrets/dev.yaml` (required, decrypted via sops), and the gitignored plain `config/dev.local.yaml`, applied in that order with later layers overriding per key. Secret-layer env values are strings; a plain-layer env value may be a string or the brokered reference described below. A key declared in both tracked shared layers is a validation problem — a value is either configuration or a secret — while a local override of either is the file's purpose. Plain layers are optional and may be comment-only; `config/dev.local.yaml` must itself be gitignored because it may override secret values. The command transactionally writes every declared workspace's `.env.local` with owner-only permissions and a do-not-edit header naming the contributing sources. It also discovers direct `apps/*` and `packages/*` directories containing `package.json` and removes a stale `.env.local` only when its first line is that exact header; hand-owned files, edited headers, nested directories, and symlinked paths are left untouched. Writes and removals commit or roll back as one transaction, preserving prior contents, modes, and absent files. The final files and the transaction's actual random `.env.local.standards-*.tmp` and `.env.local.standards-*.bak` paths must all be gitignored before staging starts. Existing consumers must add those two artifact patterns themselves because `.gitignore` is seeded only during `init`; `sync` does not change it. Its Bun-specific dotenv codec exactly round-trips every value it accepts without adding helper environment variables; a value the codec cannot encode exactly is gathered as a validation problem instead of being changed silently. Validation gathers all document, containment, destination-type, workspace, and gitignore problems before writing anything; symlink destinations, duplicate resolved destinations, changed parent-directory identities, and symlinked workspace paths are rejected without following the link. Cleanup trouble after a completed commit is reported loudly as a warning rather than falsely reporting generation failure. Parent identities are checked immediately before each path operation; an unavoidable swap in the syscall-sized interval after a check remains outside the non-hostile maintainer threat model. The canonical `justfile` exposes the command as `just dev-env-generate`, and `just dev-refresh` chains a `just secrets edit dev` session into regeneration. A plain configuration layer may declare a **brokered S3 pair reference** instead of a literal: an object value `{ brokeredS3: <secrets-target>, key: <dotted.key>, part: access_key_id | secret_access_key }` pointing at a credential pair `creds add cloudflare --s3` minted into another SOPS target. An existing target name must bind exactly one contained file, either `infra/hosts/<name>/secrets.yaml` or `secrets/<name>.yaml`; both files existing is an ambiguity error, never host preference. The SOPS-encrypted `secrets/dev.yaml` must authorize every referenced pair with its exact `<secrets-target>:<dotted.key>` string under the reserved top-level `brokeredReferences` list. Plain layers reject that reserved key, and the secrets layer rejects reference objects: configuration selects a pair while encrypted policy authorizes its release into plaintext `.env.local`. Resolution rejects an unauthorized pair with the exact allowlist entry to add before invoking SOPS on that target. It then requires both `access_key_id` and `secret_access_key` to be strings, even when the reference selects only one part. Resolution runs after layer composition, so a later layer's literal overrides an earlier layer's reference and a reference that lost the merge is never decrypted; each authorized referenced target is decrypted once per run, and a missing target, ambiguous target, missing or partial pair, or unencodable resolved value is gathered as a named validation problem without exposing secret values. The secret value therefore keeps exactly one home — the broker's ledger. The loop closes on the broker side: after `creds add` verifies an exact referenced pair write, and after `creds apply` verifies a renewal in one, the command regenerates the dev env files itself, so a rotated pair reaches every generated `.env.local` without a manual step. Refresh evidence is pair-granular: a bearer sibling in the same SOPS file cannot trigger rendering of an unsafe pair, and a verified S3 sibling updates while an unsafe pair keeps its prior generated lines. If those prior owned lines cannot be proven and preserved, the refresh fails without changing any env file; every refresh failure is reported after the durable SOPS write remains committed.
|
|
33
33
|
|
|
34
34
|
`check` also rejects the raw token formed by `biome-` + `ignore` anywhere in lock-listed canonical files. The intentionally blunt policy keeps synced sources compatible with consumer Biome configurations without invoking Biome or maintaining language-aware comment parsing.
|
|
35
35
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidvornholt/standards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"src/creds-cloudflare-expiry.ts",
|
|
35
35
|
"src/creds-cloudflare-id.ts",
|
|
36
36
|
"src/creds-cloudflare-pagination.ts",
|
|
37
|
+
"src/creds-cloudflare-request.ts",
|
|
37
38
|
"src/creds-cloudflare-zone.ts",
|
|
38
39
|
"src/creds-commands.ts",
|
|
39
40
|
"src/creds-args.ts",
|
|
@@ -65,6 +66,7 @@
|
|
|
65
66
|
"src/creds-store-lock-holder.ts",
|
|
66
67
|
"src/creds-store-lock-inspection.ts",
|
|
67
68
|
"src/creds-store-lock-lease.ts",
|
|
69
|
+
"src/creds-target.ts",
|
|
68
70
|
"src/dependabot-compose-input.ts",
|
|
69
71
|
"src/dependabot-compose.ts",
|
|
70
72
|
"src/dependabot-directory-character-class.ts",
|
|
@@ -72,6 +74,12 @@
|
|
|
72
74
|
"src/dependabot-directory-overlap.ts",
|
|
73
75
|
"src/dependabot-inspect.ts",
|
|
74
76
|
"src/dependabot-update.ts",
|
|
77
|
+
"src/dev-env-brokered.ts",
|
|
78
|
+
"src/dev-env-brokered-allowlist.ts",
|
|
79
|
+
"src/dev-env-brokered-preserve.ts",
|
|
80
|
+
"src/dev-env-brokered-resolve.ts",
|
|
81
|
+
"src/dev-env-brokered-refresh.ts",
|
|
82
|
+
"src/dev-env-plain-layer.ts",
|
|
75
83
|
"src/dev-env-compose.ts",
|
|
76
84
|
"src/dev-env-document.ts",
|
|
77
85
|
"src/dev-env-destination-duplicates.ts",
|
package/src/creds-add.ts
CHANGED
|
@@ -19,10 +19,25 @@ import {
|
|
|
19
19
|
s3PairPaths,
|
|
20
20
|
} from './creds-r2';
|
|
21
21
|
import { readEncryptedKeys, setSopsValues } from './creds-sops';
|
|
22
|
+
import { refreshDevEnvForSopsWrites } from './dev-env-brokered-refresh';
|
|
22
23
|
|
|
23
24
|
const DEFAULT_TTL_DAYS = 90;
|
|
24
25
|
const DAY_MS = 86_400_000;
|
|
25
26
|
|
|
27
|
+
const addRefreshEvidence = (
|
|
28
|
+
format: DestinationFormat,
|
|
29
|
+
destination: { readonly target: string; readonly key: string },
|
|
30
|
+
) =>
|
|
31
|
+
format === 's3'
|
|
32
|
+
? [
|
|
33
|
+
{
|
|
34
|
+
target: destination.target,
|
|
35
|
+
key: destination.key,
|
|
36
|
+
safety: 'verified' as const,
|
|
37
|
+
},
|
|
38
|
+
]
|
|
39
|
+
: [];
|
|
40
|
+
|
|
26
41
|
const printSuccess = (input: {
|
|
27
42
|
readonly name: string;
|
|
28
43
|
readonly permissions: ReadonlyArray<string>;
|
|
@@ -168,5 +183,8 @@ export const runCredsAddCloudflare = async (
|
|
|
168
183
|
: DEFAULT_R2_JURISDICTION,
|
|
169
184
|
policies: resolved.policies,
|
|
170
185
|
});
|
|
171
|
-
return
|
|
186
|
+
return await refreshDevEnvForSopsWrites(
|
|
187
|
+
consumer,
|
|
188
|
+
addRefreshEvidence(format, context.dest),
|
|
189
|
+
);
|
|
172
190
|
};
|
|
@@ -5,10 +5,9 @@ import {
|
|
|
5
5
|
type DecodedTokenCondition,
|
|
6
6
|
decodeTokenCondition,
|
|
7
7
|
} from './creds-cloudflare-condition';
|
|
8
|
+
import { requestCloudflare } from './creds-cloudflare-request';
|
|
8
9
|
import { isRecord } from './github-settings-parse';
|
|
9
10
|
|
|
10
|
-
const API_ROOT = 'https://api.cloudflare.com/client/v4';
|
|
11
|
-
|
|
12
11
|
export type CfResult<T> =
|
|
13
12
|
| { readonly ok: true; readonly value: T }
|
|
14
13
|
| { readonly ok: false; readonly problem: string };
|
|
@@ -57,14 +56,11 @@ export const cfRequest = async (
|
|
|
57
56
|
path: string,
|
|
58
57
|
body?: unknown,
|
|
59
58
|
): Promise<CfResult<Envelope>> => {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
},
|
|
66
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
67
|
-
});
|
|
59
|
+
const requested = await requestCloudflare(token, method, path, body);
|
|
60
|
+
if (!requested.ok) {
|
|
61
|
+
return requested;
|
|
62
|
+
}
|
|
63
|
+
const response = requested.value;
|
|
68
64
|
let parsed: unknown = null;
|
|
69
65
|
try {
|
|
70
66
|
parsed = (await response.json()) as unknown;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const API_ROOT = 'https://api.cloudflare.com/client/v4';
|
|
2
|
+
|
|
3
|
+
type CloudflareRequestResult =
|
|
4
|
+
| { readonly ok: true; readonly value: Response }
|
|
5
|
+
| { readonly ok: false; readonly problem: string };
|
|
6
|
+
|
|
7
|
+
export const requestCloudflare = async (
|
|
8
|
+
token: string,
|
|
9
|
+
method: string,
|
|
10
|
+
path: string,
|
|
11
|
+
body: unknown,
|
|
12
|
+
): Promise<CloudflareRequestResult> => {
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch(`${API_ROOT}${path}`, {
|
|
15
|
+
method,
|
|
16
|
+
headers: {
|
|
17
|
+
authorization: `Bearer ${token}`,
|
|
18
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
19
|
+
},
|
|
20
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
21
|
+
});
|
|
22
|
+
return { ok: true, value: response };
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
problem: `${method} ${path}: request failed: ${
|
|
27
|
+
error instanceof Error ? error.message : String(error)
|
|
28
|
+
}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
};
|
package/src/creds-dest.ts
CHANGED
|
@@ -11,16 +11,22 @@ import {
|
|
|
11
11
|
readBrokerStore,
|
|
12
12
|
resolveBrokerPath,
|
|
13
13
|
} from './creds-store';
|
|
14
|
+
import {
|
|
15
|
+
isSafeSecretsTargetName,
|
|
16
|
+
resolveTargetRelResult,
|
|
17
|
+
} from './creds-target';
|
|
14
18
|
import { resolveGithubRepo } from './github-api';
|
|
15
19
|
import { isRecord } from './github-settings-parse';
|
|
16
20
|
|
|
17
|
-
const SAFE_TARGET = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?$/u;
|
|
18
|
-
|
|
19
21
|
export type CredsDestination = {
|
|
20
22
|
readonly target: string;
|
|
21
23
|
readonly key: string;
|
|
22
24
|
};
|
|
23
25
|
export type SecretsTarget = { readonly target: string; readonly rel: string };
|
|
26
|
+
export type SecretsTargetInventory = {
|
|
27
|
+
readonly targets: ReadonlyArray<SecretsTarget>;
|
|
28
|
+
readonly problems: ReadonlyArray<string>;
|
|
29
|
+
};
|
|
24
30
|
export type ResolvedContext = {
|
|
25
31
|
readonly repo: string;
|
|
26
32
|
readonly rel: string;
|
|
@@ -34,7 +40,7 @@ export const parseDestination = (raw: string): CredsDestination | null => {
|
|
|
34
40
|
const key = raw.slice(separator + 1);
|
|
35
41
|
return separator > 0 &&
|
|
36
42
|
separator < raw.length - 1 &&
|
|
37
|
-
|
|
43
|
+
isSafeSecretsTargetName(target) &&
|
|
38
44
|
parseSopsKeyPath(key) !== null
|
|
39
45
|
? { target, key }
|
|
40
46
|
: null;
|
|
@@ -88,38 +94,38 @@ const isHostTargetCandidate = (consumer: string, name: string): boolean => {
|
|
|
88
94
|
|
|
89
95
|
export const listSecretsTargets = (
|
|
90
96
|
consumer: string,
|
|
91
|
-
):
|
|
97
|
+
): SecretsTargetInventory => {
|
|
92
98
|
const flat = listDir(consumer, 'secrets')
|
|
93
99
|
.filter(isYamlSecrets)
|
|
94
100
|
.map((name) => ({
|
|
95
101
|
target: name.slice(0, -'.yaml'.length),
|
|
96
102
|
rel: `secrets/${name}`,
|
|
97
103
|
}))
|
|
98
|
-
.filter(({ target }) =>
|
|
104
|
+
.filter(({ target }) => isSafeSecretsTargetName(target));
|
|
99
105
|
const hosts = listDir(consumer, 'infra/hosts')
|
|
100
106
|
.filter(
|
|
101
|
-
(name) =>
|
|
107
|
+
(name) =>
|
|
108
|
+
isSafeSecretsTargetName(name) && isHostTargetCandidate(consumer, name),
|
|
102
109
|
)
|
|
103
110
|
.map((name) => ({
|
|
104
111
|
target: name,
|
|
105
112
|
rel: `infra/hosts/${name}/secrets.yaml`,
|
|
106
113
|
}));
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
): string | null => {
|
|
114
|
-
if (!SAFE_TARGET.test(target)) {
|
|
115
|
-
return null;
|
|
114
|
+
const targets = [...flat, ...hosts];
|
|
115
|
+
const relsByTarget = new Map<string, Array<string>>();
|
|
116
|
+
for (const { target, rel } of targets) {
|
|
117
|
+
const rels = relsByTarget.get(target) ?? [];
|
|
118
|
+
rels.push(rel);
|
|
119
|
+
relsByTarget.set(target, rels);
|
|
116
120
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
const problems = [...relsByTarget.entries()].flatMap(([target, rels]) =>
|
|
122
|
+
rels.length > 1
|
|
123
|
+
? [
|
|
124
|
+
`secrets target "${target}" is ambiguous because ${rels.join(' and ')} both claim that identity; rename one target so the name binds exactly one encrypted file`,
|
|
125
|
+
]
|
|
126
|
+
: [],
|
|
127
|
+
);
|
|
128
|
+
return { targets, problems };
|
|
123
129
|
};
|
|
124
130
|
|
|
125
131
|
export const resolveContext = async (
|
|
@@ -144,14 +150,23 @@ export const resolveContext = async (
|
|
|
144
150
|
);
|
|
145
151
|
return null;
|
|
146
152
|
}
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
153
|
+
const resolved = resolveTargetRelResult(consumer, dest.target);
|
|
154
|
+
if (!resolved.ok) {
|
|
149
155
|
console.error(
|
|
150
|
-
`standards creds:
|
|
156
|
+
`standards creds: ${
|
|
157
|
+
resolved.kind === 'missing'
|
|
158
|
+
? `${resolved.problem}; create it with \`just secrets edit ${dest.target}\` first`
|
|
159
|
+
: resolved.problem
|
|
160
|
+
}`,
|
|
151
161
|
);
|
|
152
162
|
return null;
|
|
153
163
|
}
|
|
154
|
-
return {
|
|
164
|
+
return {
|
|
165
|
+
repo,
|
|
166
|
+
rel: resolved.rel,
|
|
167
|
+
dest,
|
|
168
|
+
store: await readBrokerStore(resolveBrokerPath()),
|
|
169
|
+
};
|
|
155
170
|
};
|
|
156
171
|
|
|
157
172
|
export const selectAccount = (
|
package/src/creds-plan-renew.ts
CHANGED
|
@@ -4,15 +4,34 @@
|
|
|
4
4
|
// mismatch deletes the replacement and preserves the old token.
|
|
5
5
|
|
|
6
6
|
import { createAccountToken, deleteAccountToken } from './creds-cloudflare';
|
|
7
|
-
import { resolveTargetRel } from './creds-dest';
|
|
8
7
|
import type { PlannedAction } from './creds-plan-types';
|
|
9
8
|
import { destinationWrites, s3AccessKeyPath, s3PairPaths } from './creds-r2';
|
|
10
9
|
import { inspectSopsScalarDestination, setSopsValues } from './creds-sops';
|
|
11
10
|
import { verifySopsStoredValue } from './creds-sops-value';
|
|
12
11
|
import type { CloudflareBrokerAccount } from './creds-store';
|
|
12
|
+
import { resolveTargetRelResult } from './creds-target';
|
|
13
|
+
import type { BrokeredRefreshEvidence } from './dev-env-brokered-refresh';
|
|
13
14
|
|
|
14
15
|
type RenewAction = Extract<PlannedAction, { readonly kind: 'renew' }>;
|
|
15
16
|
|
|
17
|
+
export type RenewPlannedTokenResult = {
|
|
18
|
+
readonly failure: string | null;
|
|
19
|
+
readonly refreshEvidence: BrokeredRefreshEvidence | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const renewalResult = (
|
|
23
|
+
failure: string | null,
|
|
24
|
+
evidence: BrokeredRefreshEvidence | null = null,
|
|
25
|
+
): RenewPlannedTokenResult => ({ failure, refreshEvidence: evidence });
|
|
26
|
+
|
|
27
|
+
const refreshEvidence = (
|
|
28
|
+
action: RenewAction,
|
|
29
|
+
safety: BrokeredRefreshEvidence['safety'],
|
|
30
|
+
): BrokeredRefreshEvidence | null =>
|
|
31
|
+
action.format === 's3'
|
|
32
|
+
? { target: action.target, key: action.key, safety }
|
|
33
|
+
: null;
|
|
34
|
+
|
|
16
35
|
const cleanupReplacement = async (
|
|
17
36
|
account: CloudflareBrokerAccount,
|
|
18
37
|
replacementId: string,
|
|
@@ -48,19 +67,20 @@ export const renewPlannedToken = async (
|
|
|
48
67
|
consumer: string,
|
|
49
68
|
account: CloudflareBrokerAccount,
|
|
50
69
|
action: RenewAction,
|
|
51
|
-
): Promise<
|
|
70
|
+
): Promise<RenewPlannedTokenResult> => {
|
|
52
71
|
const { accountId, token: bootstrapToken } = account;
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
55
|
-
return `${action.name}:
|
|
72
|
+
const resolved = resolveTargetRelResult(consumer, action.target);
|
|
73
|
+
if (!resolved.ok) {
|
|
74
|
+
return renewalResult(`${action.name}: ${resolved.problem}`);
|
|
56
75
|
}
|
|
76
|
+
const { rel } = resolved;
|
|
57
77
|
const destinationProblem = await inspectRenewDestinations(
|
|
58
78
|
consumer,
|
|
59
79
|
rel,
|
|
60
80
|
action,
|
|
61
81
|
);
|
|
62
82
|
if (destinationProblem !== null) {
|
|
63
|
-
return destinationProblem;
|
|
83
|
+
return renewalResult(destinationProblem, refreshEvidence(action, 'unsafe'));
|
|
64
84
|
}
|
|
65
85
|
if (action.format === 's3') {
|
|
66
86
|
const accessKey = verifySopsStoredValue(
|
|
@@ -70,7 +90,10 @@ export const renewPlannedToken = async (
|
|
|
70
90
|
action.tokenId,
|
|
71
91
|
);
|
|
72
92
|
if (!(accessKey.ok && accessKey.matches)) {
|
|
73
|
-
return
|
|
93
|
+
return renewalResult(
|
|
94
|
+
`${action.name}: could not prove the stored S3 access key belongs to the managed token; renewal stopped before mutation`,
|
|
95
|
+
refreshEvidence(action, 'unsafe'),
|
|
96
|
+
);
|
|
74
97
|
}
|
|
75
98
|
}
|
|
76
99
|
const replacement = await createAccountToken(accountId, bootstrapToken, {
|
|
@@ -80,7 +103,7 @@ export const renewPlannedToken = async (
|
|
|
80
103
|
condition: action.condition,
|
|
81
104
|
});
|
|
82
105
|
if (!replacement.ok) {
|
|
83
|
-
return `${action.name}: ${replacement.problem}
|
|
106
|
+
return renewalResult(`${action.name}: ${replacement.problem}`);
|
|
84
107
|
}
|
|
85
108
|
const { id: replacementId, value } = replacement.value;
|
|
86
109
|
const writes = destinationWrites(
|
|
@@ -99,7 +122,10 @@ export const renewPlannedToken = async (
|
|
|
99
122
|
);
|
|
100
123
|
if (unverifiable.length > 0) {
|
|
101
124
|
const detail = unverifiable.join('; ');
|
|
102
|
-
return
|
|
125
|
+
return renewalResult(
|
|
126
|
+
`${action.name}: ${written.ok ? detail : `${written.problem}; ${detail}`}; account ${accountId} replacement ${replacementId} and old token ${action.tokenId} remain active because the stored value is unverifiable`,
|
|
127
|
+
refreshEvidence(action, 'unsafe'),
|
|
128
|
+
);
|
|
103
129
|
}
|
|
104
130
|
const mismatched = verified.flatMap(({ path, result }) =>
|
|
105
131
|
result.ok && !result.matches ? [path] : [],
|
|
@@ -108,14 +134,20 @@ export const renewPlannedToken = async (
|
|
|
108
134
|
const problem = written.ok
|
|
109
135
|
? `${action.name}: ${rel} at ${mismatched.join(', ')} now holds a value matching neither the old nor the replacement token; repair the stored value manually`
|
|
110
136
|
: `${action.name}: ${written.problem}`;
|
|
111
|
-
return
|
|
137
|
+
return renewalResult(
|
|
138
|
+
await cleanupReplacement(account, replacementId, action.tokenId, problem),
|
|
139
|
+
refreshEvidence(action, 'unsafe'),
|
|
140
|
+
);
|
|
112
141
|
}
|
|
113
142
|
const deleted = await deleteAccountToken(
|
|
114
143
|
accountId,
|
|
115
144
|
bootstrapToken,
|
|
116
145
|
action.tokenId,
|
|
117
146
|
);
|
|
118
|
-
return
|
|
119
|
-
|
|
120
|
-
|
|
147
|
+
return renewalResult(
|
|
148
|
+
deleted.ok
|
|
149
|
+
? null
|
|
150
|
+
: `${action.name}: replacement ${replacementId} is stored, but old token ${action.tokenId} could not be revoked: ${deleted.problem}`,
|
|
151
|
+
refreshEvidence(action, 'verified'),
|
|
152
|
+
);
|
|
121
153
|
};
|
package/src/creds-plan-run.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { listSecretsTargets } from './creds-dest';
|
|
2
2
|
import { identifyCloudflareBootstrapAuthority } from './creds-login-cloudflare';
|
|
3
3
|
import { computeCredsPlan } from './creds-plan';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type RenewPlannedTokenResult,
|
|
6
|
+
renewPlannedToken,
|
|
7
|
+
} from './creds-plan-renew';
|
|
5
8
|
import { reportCredsPlan } from './creds-plan-report';
|
|
6
9
|
import { revokePlannedToken } from './creds-plan-revoke';
|
|
7
10
|
import type { AccountToken, PlannedAction } from './creds-plan-types';
|
|
@@ -11,12 +14,21 @@ import {
|
|
|
11
14
|
readBrokerStore,
|
|
12
15
|
resolveBrokerPath,
|
|
13
16
|
} from './creds-store';
|
|
17
|
+
import { refreshDevEnvForSopsWrites } from './dev-env-brokered-refresh';
|
|
14
18
|
import { resolveGithubRepo } from './github-api';
|
|
15
19
|
|
|
16
20
|
const gatherRepoState = async (consumer: string, store: BrokerStore) => {
|
|
21
|
+
const inventory = listSecretsTargets(consumer);
|
|
22
|
+
if (inventory.problems.length > 0) {
|
|
23
|
+
return {
|
|
24
|
+
keysByTarget: new Map<string, ReadonlySet<string>>(),
|
|
25
|
+
tokens: [] as Array<AccountToken>,
|
|
26
|
+
problems: inventory.problems,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
17
29
|
const keysByTarget = new Map<string, ReadonlySet<string>>();
|
|
18
30
|
const targetKeys = await Promise.all(
|
|
19
|
-
|
|
31
|
+
inventory.targets.map(async ({ target, rel }) => ({
|
|
20
32
|
target,
|
|
21
33
|
rel,
|
|
22
34
|
keys: await readEncryptedKeys(consumer, rel),
|
|
@@ -61,18 +73,23 @@ const applyAction = (
|
|
|
61
73
|
consumer: string,
|
|
62
74
|
store: BrokerStore,
|
|
63
75
|
action: PlannedAction,
|
|
64
|
-
): Promise<
|
|
76
|
+
): Promise<RenewPlannedTokenResult> => {
|
|
65
77
|
const account = store.cloudflare.find(
|
|
66
78
|
(entry) => entry.accountId === action.accountId,
|
|
67
79
|
);
|
|
68
80
|
if (account === undefined) {
|
|
69
|
-
return Promise.resolve(
|
|
70
|
-
`${action.name}: account ${action.accountId} is not in the broker store`,
|
|
71
|
-
|
|
81
|
+
return Promise.resolve({
|
|
82
|
+
failure: `${action.name}: account ${action.accountId} is not in the broker store`,
|
|
83
|
+
refreshEvidence: null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (action.kind === 'renew') {
|
|
87
|
+
return renewPlannedToken(consumer, account, action);
|
|
72
88
|
}
|
|
73
|
-
return action.
|
|
74
|
-
|
|
75
|
-
:
|
|
89
|
+
return revokePlannedToken(account, action).then((failure) => ({
|
|
90
|
+
failure,
|
|
91
|
+
refreshEvidence: null,
|
|
92
|
+
}));
|
|
76
93
|
};
|
|
77
94
|
|
|
78
95
|
const fail = (message: string): false => {
|
|
@@ -117,13 +134,18 @@ export const runCredsPlan = async (
|
|
|
117
134
|
if (!apply || plan.actions.length === 0) {
|
|
118
135
|
return true;
|
|
119
136
|
}
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
137
|
+
const results = await Promise.all(
|
|
138
|
+
plan.actions.map((action) => applyAction(consumer, store, action)),
|
|
139
|
+
);
|
|
140
|
+
const failures = results.flatMap(({ failure }) =>
|
|
141
|
+
failure === null ? [] : [failure],
|
|
142
|
+
);
|
|
125
143
|
for (const failure of failures) {
|
|
126
144
|
console.error(`standards creds: ${failure}`);
|
|
127
145
|
}
|
|
128
|
-
|
|
146
|
+
const refreshEvidence = results.flatMap(({ refreshEvidence: evidence }) =>
|
|
147
|
+
evidence === null ? [] : [evidence],
|
|
148
|
+
);
|
|
149
|
+
const refreshed = await refreshDevEnvForSopsWrites(consumer, refreshEvidence);
|
|
150
|
+
return failures.length === 0 && refreshed;
|
|
129
151
|
};
|
|
@@ -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
|
+
};
|