@davidvornholt/standards 0.19.0 → 0.21.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 +7 -5
- package/package.json +19 -1
- package/src/cli.ts +2 -2
- package/src/creds-add-cloudflare-commit.ts +2 -5
- package/src/creds-add-github.ts +82 -30
- package/src/creds-add-policy.ts +106 -26
- package/src/creds-add-preflight.ts +68 -0
- package/src/creds-add.ts +29 -20
- package/src/creds-args.ts +14 -0
- package/src/creds-cloudflare-account.ts +2 -2
- package/src/creds-cloudflare-id.ts +10 -0
- package/src/creds-cloudflare-zone.ts +40 -0
- package/src/creds-commands.ts +34 -11
- package/src/creds-github-app-api.ts +132 -0
- package/src/creds-github-apps.ts +107 -0
- package/src/creds-login-github-manifest.ts +38 -15
- package/src/creds-login-github.ts +78 -24
- package/src/creds-naming.ts +13 -0
- package/src/creds-plan-renew.ts +2 -5
- package/src/creds-plan-report.ts +87 -0
- package/src/creds-plan-run.ts +2 -11
- 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-sops-value.ts +89 -0
- package/src/creds-sops.ts +0 -53
- package/src/creds-store-github.ts +121 -0
- package/src/creds-store-lock-holder.ts +105 -0
- package/src/creds-store-lock-inspection.ts +125 -0
- package/src/creds-store-lock-lease.ts +32 -0
- package/src/creds-store-lock.ts +28 -51
- package/src/creds-store.ts +14 -43
- package/src/dev-env-compose.ts +120 -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 +19 -6
- package/src/dev-env-dotenv.ts +12 -3
- 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 +115 -63
- package/src/github-command-shared.ts +8 -1
- package/src/github-live-drift.ts +3 -3
- package/src/poller-closing-reference.ts +179 -0
- package/src/poller-fix-outcome.ts +9 -0
- package/src/poller-fix-publication.ts +5 -12
- package/src/poller-fix-run.ts +2 -3
- package/src/poller-fix-validation.ts +7 -8
- package/src/poller-outcome.ts +4 -1
- package/src/poller-protected-paths.ts +1 -25
- package/src/poller-protocol.ts +0 -42
- package/src/poller-review-publication.ts +2 -8
- package/src/poller-review-validation.ts +5 -12
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { openInBrowser } from './creds-browser';
|
|
3
|
+
import {
|
|
4
|
+
loadOwnedGithubStore,
|
|
5
|
+
sameGithubApp,
|
|
6
|
+
upsertGithubApp,
|
|
7
|
+
} from './creds-github-apps';
|
|
3
8
|
import {
|
|
4
9
|
buildAppManifest,
|
|
5
10
|
convertManifestCode,
|
|
6
11
|
createManifestState,
|
|
7
12
|
MANIFEST_STATE_BYTES,
|
|
8
13
|
manifestFormHtml,
|
|
14
|
+
resolveGithubAppName,
|
|
9
15
|
} from './creds-login-github-manifest';
|
|
10
|
-
import { BROKER_IDENTITY_NAME } from './creds-naming';
|
|
11
16
|
import {
|
|
12
|
-
|
|
17
|
+
type GithubBrokerApp,
|
|
13
18
|
resolveBrokerPath,
|
|
14
19
|
updateBrokerStore,
|
|
15
20
|
} from './creds-store';
|
|
@@ -84,50 +89,99 @@ export const waitForCode = (
|
|
|
84
89
|
};
|
|
85
90
|
export const githubInstallMessage = (installUrl: string): string =>
|
|
86
91
|
`Install it only on the selected repositories that need broker credentials: ${installUrl}`;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
readonly
|
|
90
|
-
|
|
92
|
+
|
|
93
|
+
type GithubLoginDependencies = {
|
|
94
|
+
readonly waitForCode: typeof waitForCode;
|
|
95
|
+
readonly convertManifestCode: typeof convertManifestCode;
|
|
96
|
+
readonly openInBrowser: typeof openInBrowser;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const DEFAULT_LOGIN_DEPENDENCIES: GithubLoginDependencies = {
|
|
100
|
+
waitForCode,
|
|
101
|
+
convertManifestCode,
|
|
102
|
+
openInBrowser,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const runCredsLoginGithub = async (
|
|
106
|
+
options: {
|
|
107
|
+
readonly name: string | undefined;
|
|
108
|
+
readonly org: string | undefined;
|
|
109
|
+
},
|
|
110
|
+
dependencies: GithubLoginDependencies = DEFAULT_LOGIN_DEPENDENCIES,
|
|
111
|
+
): Promise<boolean> => {
|
|
112
|
+
const resolvedName = resolveGithubAppName(options.name, options.org);
|
|
113
|
+
if (!resolvedName.ok) {
|
|
114
|
+
console.error(`standards creds: ${resolvedName.problem}`);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
91
117
|
const storePath = resolveBrokerPath();
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
94
|
-
console.error(
|
|
95
|
-
`standards creds: a broker GitHub App is already configured (${store.github.htmlUrl}); remove the "github" section from ${storePath} to re-run login`,
|
|
96
|
-
);
|
|
118
|
+
const loaded = await loadOwnedGithubStore(storePath);
|
|
119
|
+
if (!loaded.ok) {
|
|
120
|
+
console.error(`standards creds: ${loaded.problem}`);
|
|
97
121
|
return false;
|
|
98
122
|
}
|
|
99
123
|
const action =
|
|
100
124
|
options.org === undefined
|
|
101
125
|
? 'https://github.com/settings/apps/new'
|
|
102
126
|
: `https://github.com/organizations/${options.org}/settings/apps/new`;
|
|
103
|
-
const
|
|
104
|
-
const code = await waitForCode((port, state) =>
|
|
127
|
+
const code = await dependencies.waitForCode((port, state) =>
|
|
105
128
|
manifestFormHtml(
|
|
106
129
|
action,
|
|
107
130
|
JSON.stringify(
|
|
108
|
-
buildAppManifest(
|
|
131
|
+
buildAppManifest(
|
|
132
|
+
resolvedName.value,
|
|
133
|
+
`http://127.0.0.1:${port}/callback`,
|
|
134
|
+
),
|
|
109
135
|
),
|
|
110
136
|
state,
|
|
111
137
|
),
|
|
112
138
|
);
|
|
113
|
-
const conversion = await convertManifestCode(code);
|
|
139
|
+
const conversion = await dependencies.convertManifestCode(code);
|
|
114
140
|
if (!conversion.ok) {
|
|
115
141
|
console.error(`standards creds: ${conversion.problem}`);
|
|
116
142
|
return false;
|
|
117
143
|
}
|
|
118
144
|
const { app } = conversion;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
145
|
+
const observed = loaded.value.github.find(
|
|
146
|
+
(entry) => entry.owner?.toLowerCase() === app.owner?.toLowerCase(),
|
|
147
|
+
);
|
|
148
|
+
let replaced: GithubBrokerApp | undefined;
|
|
149
|
+
try {
|
|
150
|
+
await updateBrokerStore(storePath, (current) => {
|
|
151
|
+
const currentEntry = current.github.find(
|
|
152
|
+
(entry) => entry.owner?.toLowerCase() === app.owner?.toLowerCase(),
|
|
123
153
|
);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
154
|
+
const unchanged =
|
|
155
|
+
observed === undefined
|
|
156
|
+
? currentEntry === undefined
|
|
157
|
+
: currentEntry !== undefined && sameGithubApp(currentEntry, observed);
|
|
158
|
+
if (!unchanged) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`the broker App for ${app.owner} changed while this App was being created`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const upserted = upsertGithubApp(current.github, app);
|
|
164
|
+
replaced = upserted.replaced ?? undefined;
|
|
165
|
+
return { ...current, github: upserted.apps };
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
169
|
+
console.error(
|
|
170
|
+
`standards creds: the newly created App ${app.htmlUrl} was not stored (${message}); retry login, and delete the new App if it is still absent from \`creds status\``,
|
|
171
|
+
);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
console.log(
|
|
175
|
+
`standards creds: created App ${app.slug} for ${app.owner} (${app.htmlUrl})`,
|
|
176
|
+
);
|
|
128
177
|
console.log(` credentials stored in ${storePath}`);
|
|
178
|
+
if (replaced !== undefined) {
|
|
179
|
+
console.log(
|
|
180
|
+
` replaced App ${replaced.slug} for ${app.owner}; update its SOPS destinations, verify token minting, then delete the old App`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
129
183
|
const installUrl = `${app.htmlUrl}/installations/new`;
|
|
130
184
|
console.log(githubInstallMessage(installUrl));
|
|
131
|
-
openInBrowser(installUrl);
|
|
185
|
+
dependencies.openInBrowser(installUrl);
|
|
132
186
|
return true;
|
|
133
187
|
};
|
package/src/creds-naming.ts
CHANGED
|
@@ -80,3 +80,16 @@ export const parseTokenName = (
|
|
|
80
80
|
}
|
|
81
81
|
return ref;
|
|
82
82
|
};
|
|
83
|
+
|
|
84
|
+
// The repository a minted name belongs to is carried by the name itself, so a
|
|
85
|
+
// command holding no repository — `creds revoke` is account-scoped — can still
|
|
86
|
+
// tell a token reconciliation owns from one nothing manages.
|
|
87
|
+
export const parseAnyTokenName = (name: string): BrokeredTokenRef | null => {
|
|
88
|
+
if (!isInMintedNamespace(name)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const [owner, repo] = name.slice(`${NAME_PREFIX}/`.length).split('/');
|
|
92
|
+
return owner === undefined || repo === undefined
|
|
93
|
+
? null
|
|
94
|
+
: parseTokenName(name, `${owner}/${repo}`);
|
|
95
|
+
};
|
package/src/creds-plan-renew.ts
CHANGED
|
@@ -7,11 +7,8 @@ import { createAccountToken, deleteAccountToken } from './creds-cloudflare';
|
|
|
7
7
|
import { resolveTargetRel } from './creds-dest';
|
|
8
8
|
import type { PlannedAction } from './creds-plan-types';
|
|
9
9
|
import { destinationWrites, s3AccessKeyPath, s3PairPaths } from './creds-r2';
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
setSopsValues,
|
|
13
|
-
verifySopsStoredValue,
|
|
14
|
-
} from './creds-sops';
|
|
10
|
+
import { inspectSopsScalarDestination, setSopsValues } from './creds-sops';
|
|
11
|
+
import { verifySopsStoredValue } from './creds-sops-value';
|
|
15
12
|
import type { CloudflareBrokerAccount } from './creds-store';
|
|
16
13
|
|
|
17
14
|
type RenewAction = Extract<PlannedAction, { readonly kind: 'renew' }>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The operator-facing rendering of a computed plan. Every block is labelled and
|
|
2
|
+
// printed only when it has entries, because the three lists say different
|
|
3
|
+
// things: what reconciliation will do, what it will never touch, and what no
|
|
4
|
+
// repository reconciles at all. The summary counts them apart for the same
|
|
5
|
+
// reason.
|
|
6
|
+
|
|
7
|
+
import { BROKER_IDENTITY_NAME } from './creds-naming';
|
|
8
|
+
import type {
|
|
9
|
+
BrokeredElsewhereToken,
|
|
10
|
+
CredsPlan,
|
|
11
|
+
PlannedAction,
|
|
12
|
+
UnmanagedToken,
|
|
13
|
+
} from './creds-plan-types';
|
|
14
|
+
|
|
15
|
+
// Each listed token is offered with the exact command that retires it rather
|
|
16
|
+
// than with identifiers to reassemble: `plan` already holds the account, and
|
|
17
|
+
// `revoke` demands it whenever the broker holds more than one.
|
|
18
|
+
const revokeCommand = (token: UnmanagedToken, extra: string): string =>
|
|
19
|
+
`\`standards creds revoke --account ${token.accountId} --token-id ${token.tokenId}${extra}\``;
|
|
20
|
+
|
|
21
|
+
// One unmanaged shape has no revoke command to offer: the reserved bootstrap
|
|
22
|
+
// name. This machine's own bootstrap is excluded from the listing by verified
|
|
23
|
+
// ID, so a row under that name is another machine's or a superseded one, and
|
|
24
|
+
// `revoke` refuses it by name — printing a command that always fails would
|
|
25
|
+
// send the operator down a dead end. The remedy is the dashboard, which is
|
|
26
|
+
// also the only place the machine it belongs to can be confirmed.
|
|
27
|
+
const unmanagedRemedy = (token: UnmanagedToken): string =>
|
|
28
|
+
token.name === BROKER_IDENTITY_NAME
|
|
29
|
+
? "the reserved name for a machine's broker bootstrap credential, which `revoke` refuses; retire it in the Cloudflare dashboard, where you can confirm which machine it belongs to"
|
|
30
|
+
: `retire it with ${revokeCommand(token, '')}`;
|
|
31
|
+
|
|
32
|
+
const reportActions = (
|
|
33
|
+
actions: ReadonlyArray<PlannedAction>,
|
|
34
|
+
apply: boolean,
|
|
35
|
+
): void => {
|
|
36
|
+
if (actions.length === 0) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.log(`standards creds: ${apply ? '' : 'planned '}actions`);
|
|
40
|
+
for (const action of actions) {
|
|
41
|
+
console.log(
|
|
42
|
+
` ${apply ? '' : 'would '}${action.kind} ${action.name} (${action.reason})`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const reportUnmanaged = (tokens: ReadonlyArray<UnmanagedToken>): void => {
|
|
48
|
+
if (tokens.length === 0) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
console.log(
|
|
52
|
+
'standards creds: unmanaged tokens — reconciliation mints nothing of this shape, and plan and apply never mutate them',
|
|
53
|
+
);
|
|
54
|
+
for (const token of tokens) {
|
|
55
|
+
console.log(
|
|
56
|
+
` ${token.name} (${token.status}) — ${unmanagedRemedy(token)}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const reportBrokeredElsewhere = (
|
|
62
|
+
tokens: ReadonlyArray<BrokeredElsewhereToken>,
|
|
63
|
+
): void => {
|
|
64
|
+
if (tokens.length === 0) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
console.log(
|
|
68
|
+
'standards creds: tokens brokered to another repository — never mutated here, and reconciled there only if a checkout still mints that exact name',
|
|
69
|
+
);
|
|
70
|
+
for (const token of tokens) {
|
|
71
|
+
console.log(
|
|
72
|
+
` ${token.name} (${token.status}) — brokered to ${token.repo}; if no checkout mints this name any more — ${token.repo} renamed, transferred, or deleted — then nothing reconciles the token, and only then: ${revokeCommand(token, ' --force')}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const reportCredsPlan = (plan: CredsPlan, apply: boolean): void => {
|
|
78
|
+
reportActions(plan.actions, apply);
|
|
79
|
+
reportUnmanaged(plan.unmanaged);
|
|
80
|
+
reportBrokeredElsewhere(plan.brokeredElsewhere);
|
|
81
|
+
for (const finding of plan.findings) {
|
|
82
|
+
console.error(`standards creds: ${finding}`);
|
|
83
|
+
}
|
|
84
|
+
console.log(
|
|
85
|
+
`standards creds: ${plan.actions.length} action(s), ${plan.findings.length} finding(s), ${plan.healthy} brokered token(s) healthy, ${plan.unmanaged.length} unmanaged token(s), ${plan.brokeredElsewhere.length} token(s) brokered to another repository`,
|
|
86
|
+
);
|
|
87
|
+
};
|
package/src/creds-plan-run.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { listSecretsTargets } from './creds-dest';
|
|
|
2
2
|
import { identifyCloudflareBootstrapAuthority } from './creds-login-cloudflare';
|
|
3
3
|
import { computeCredsPlan } from './creds-plan';
|
|
4
4
|
import { renewPlannedToken } from './creds-plan-renew';
|
|
5
|
+
import { reportCredsPlan } from './creds-plan-report';
|
|
5
6
|
import { revokePlannedToken } from './creds-plan-revoke';
|
|
6
7
|
import type { AccountToken, PlannedAction } from './creds-plan-types';
|
|
7
8
|
import { readEncryptedKeys } from './creds-sops';
|
|
@@ -109,17 +110,7 @@ export const runCredsPlan = async (
|
|
|
109
110
|
tokens: state.tokens,
|
|
110
111
|
now: new Date(),
|
|
111
112
|
});
|
|
112
|
-
|
|
113
|
-
console.log(
|
|
114
|
-
` ${apply ? '' : 'would '}${action.kind} ${action.name} (${action.reason})`,
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
for (const finding of plan.findings) {
|
|
118
|
-
console.error(`standards creds: ${finding}`);
|
|
119
|
-
}
|
|
120
|
-
console.log(
|
|
121
|
-
`standards creds: ${plan.actions.length} action(s), ${plan.findings.length} finding(s), ${plan.healthy} brokered token(s) healthy`,
|
|
122
|
-
);
|
|
113
|
+
reportCredsPlan(plan, apply);
|
|
123
114
|
if (plan.findings.length > 0) {
|
|
124
115
|
return fail('reconciliation aborted until every finding is resolved');
|
|
125
116
|
}
|
package/src/creds-plan-types.ts
CHANGED
|
@@ -7,6 +7,21 @@ export type AccountToken = {
|
|
|
7
7
|
readonly token: CloudflareToken;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
export type UnmanagedToken = {
|
|
11
|
+
readonly accountId: string;
|
|
12
|
+
readonly tokenId: string;
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly status: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// A token the broker minted under another repository's name. That repository
|
|
18
|
+
// normally reconciles it, so it is not unmanaged — the distinction matters
|
|
19
|
+
// because it stops being true when that repository is renamed, transferred, or
|
|
20
|
+
// deleted, and this account is then the only place the token is still visible.
|
|
21
|
+
export type BrokeredElsewhereToken = UnmanagedToken & {
|
|
22
|
+
readonly repo: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
10
25
|
export type PlannedAction =
|
|
11
26
|
| {
|
|
12
27
|
readonly kind: 'revoke';
|
|
@@ -33,4 +48,8 @@ export type CredsPlan = {
|
|
|
33
48
|
readonly actions: ReadonlyArray<PlannedAction>;
|
|
34
49
|
readonly findings: ReadonlyArray<string>;
|
|
35
50
|
readonly healthy: number;
|
|
51
|
+
// Tokens in the account the broker did not mint. Reported, never mutated.
|
|
52
|
+
readonly unmanaged: ReadonlyArray<UnmanagedToken>;
|
|
53
|
+
// Tokens the broker minted for another repository. Reported, never mutated.
|
|
54
|
+
readonly brokeredElsewhere: ReadonlyArray<BrokeredElsewhereToken>;
|
|
36
55
|
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Reconciliation matches tokens to a repository by name and used to discard
|
|
2
|
+
// every other token in the account silently. That silence is where a forgotten
|
|
3
|
+
// credential hides: replacing a hand-made token means minting a brokered one
|
|
4
|
+
// and deleting the original, and nothing noticed when the second half was
|
|
5
|
+
// skipped. Classification makes the leftovers visible without giving the broker
|
|
6
|
+
// any power over them — a token named here is never mutated automatically.
|
|
7
|
+
|
|
8
|
+
import type { CloudflareToken } from './creds-cloudflare-api';
|
|
9
|
+
import {
|
|
10
|
+
type BrokeredTokenRef,
|
|
11
|
+
isInMintedNamespace,
|
|
12
|
+
parseAnyTokenName,
|
|
13
|
+
parseTokenName,
|
|
14
|
+
repoTokenPrefix,
|
|
15
|
+
} from './creds-naming';
|
|
16
|
+
import type {
|
|
17
|
+
AccountToken,
|
|
18
|
+
BrokeredElsewhereToken,
|
|
19
|
+
UnmanagedToken,
|
|
20
|
+
} from './creds-plan-types';
|
|
21
|
+
|
|
22
|
+
type Classification =
|
|
23
|
+
| { readonly kind: 'managed'; readonly ref: BrokeredTokenRef }
|
|
24
|
+
| { readonly kind: 'malformed' }
|
|
25
|
+
| { readonly kind: 'unmanaged' }
|
|
26
|
+
| { readonly kind: 'brokered-elsewhere'; readonly repo: string }
|
|
27
|
+
| { readonly kind: 'ignored' };
|
|
28
|
+
|
|
29
|
+
const classifyAccountToken = (
|
|
30
|
+
token: CloudflareToken,
|
|
31
|
+
repo: string,
|
|
32
|
+
): Classification => {
|
|
33
|
+
const ref = parseTokenName(token.name, repo);
|
|
34
|
+
if (ref !== null) {
|
|
35
|
+
return { kind: 'managed', ref };
|
|
36
|
+
}
|
|
37
|
+
// An expired token grants nothing, and listing years of them would bury the
|
|
38
|
+
// live ones. Brokered tokens keep their own expiry handling in creds-plan.
|
|
39
|
+
if (token.status === 'expired') {
|
|
40
|
+
return { kind: 'ignored' };
|
|
41
|
+
}
|
|
42
|
+
if (token.name.startsWith(repoTokenPrefix(repo))) {
|
|
43
|
+
return { kind: 'malformed' };
|
|
44
|
+
}
|
|
45
|
+
// A brokered name carries the repository that owns it, which normally
|
|
46
|
+
// reconciles it. That premise fails exactly once: rename, transfer, or delete
|
|
47
|
+
// that repository and no checkout resolves to the old name, so nothing renews
|
|
48
|
+
// the token, nothing revokes it, and no repository would ever mention it.
|
|
49
|
+
// Every account sharer reporting it is the price of the orphan being seen at
|
|
50
|
+
// all. It stays out of the unmanaged bucket, which asserts something else —
|
|
51
|
+
// that the broker mints nothing of this shape anywhere.
|
|
52
|
+
const elsewhere = parseAnyTokenName(token.name);
|
|
53
|
+
if (elsewhere !== null) {
|
|
54
|
+
return { kind: 'brokered-elsewhere', repo: elsewhere.repo };
|
|
55
|
+
}
|
|
56
|
+
return isInMintedNamespace(token.name)
|
|
57
|
+
? { kind: 'ignored' }
|
|
58
|
+
: { kind: 'unmanaged' };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type ManagedTokenRef = AccountToken & {
|
|
62
|
+
readonly ref: BrokeredTokenRef;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type TokenPartition = {
|
|
66
|
+
readonly managed: ReadonlyArray<ManagedTokenRef>;
|
|
67
|
+
readonly unmanaged: ReadonlyArray<UnmanagedToken>;
|
|
68
|
+
readonly brokeredElsewhere: ReadonlyArray<BrokeredElsewhereToken>;
|
|
69
|
+
readonly findings: ReadonlyArray<string>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const reported = (entry: AccountToken): UnmanagedToken => ({
|
|
73
|
+
accountId: entry.accountId,
|
|
74
|
+
tokenId: entry.token.id,
|
|
75
|
+
name: entry.token.name,
|
|
76
|
+
status: entry.token.status,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const partitionAccountTokens = (
|
|
80
|
+
tokens: ReadonlyArray<AccountToken>,
|
|
81
|
+
repo: string,
|
|
82
|
+
): TokenPartition => {
|
|
83
|
+
const managed: Array<ManagedTokenRef> = [];
|
|
84
|
+
const unmanaged: Array<UnmanagedToken> = [];
|
|
85
|
+
const brokeredElsewhere: Array<BrokeredElsewhereToken> = [];
|
|
86
|
+
const findings: Array<string> = [];
|
|
87
|
+
for (const entry of tokens) {
|
|
88
|
+
const classification = classifyAccountToken(entry.token, repo);
|
|
89
|
+
if (classification.kind === 'managed') {
|
|
90
|
+
managed.push({ ...entry, ref: classification.ref });
|
|
91
|
+
} else if (classification.kind === 'malformed') {
|
|
92
|
+
findings.push(
|
|
93
|
+
`${entry.token.name} claims this repository's brokered namespace but is not a name this broker mints; rename it in the dashboard or retire it with \`standards creds revoke --account ${entry.accountId} --token-id ${entry.token.id}\``,
|
|
94
|
+
);
|
|
95
|
+
} else if (classification.kind === 'unmanaged') {
|
|
96
|
+
unmanaged.push(reported(entry));
|
|
97
|
+
} else if (classification.kind === 'brokered-elsewhere') {
|
|
98
|
+
brokeredElsewhere.push({ ...reported(entry), repo: classification.repo });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { managed, unmanaged, brokeredElsewhere, findings };
|
|
102
|
+
};
|
package/src/creds-plan.ts
CHANGED
|
@@ -3,23 +3,28 @@
|
|
|
3
3
|
// naming scheme). A brokered token whose secret key vanished from SOPS is
|
|
4
4
|
// revoked; one nearing expiry is replaced while copying its live policy and
|
|
5
5
|
// lifetime. Secret keys without a brokered token are simply unmanaged — most
|
|
6
|
-
// secrets are — and are never touched.
|
|
6
|
+
// secrets are — and are never touched. Tokens the broker did not mint, and
|
|
7
|
+
// tokens it minted for another repository, are reported
|
|
8
|
+
// (creds-plan-unmanaged.ts) but never mutated. Execution lives in
|
|
9
|
+
// creds-plan-run.ts.
|
|
7
10
|
|
|
8
11
|
import { cloudflareExpiresOn } from './creds-cloudflare-expiry';
|
|
9
|
-
import { parseTokenName } from './creds-naming';
|
|
10
12
|
import { groupByIntersectingFootprint } from './creds-plan-groups';
|
|
11
13
|
import type {
|
|
12
14
|
AccountToken,
|
|
13
15
|
CredsPlan,
|
|
14
16
|
PlannedAction,
|
|
15
17
|
} from './creds-plan-types';
|
|
18
|
+
import {
|
|
19
|
+
type ManagedTokenRef,
|
|
20
|
+
partitionAccountTokens,
|
|
21
|
+
} from './creds-plan-unmanaged';
|
|
16
22
|
import { destinationFormatOf, inferredDestinationFootprint } from './creds-r2';
|
|
17
23
|
|
|
18
24
|
const DEFAULT_RENEW_WITHIN_DAYS = 30;
|
|
19
25
|
const DAY_MS = 86_400_000;
|
|
20
26
|
|
|
21
|
-
type ManagedToken =
|
|
22
|
-
readonly ref: { readonly target: string; readonly key: string };
|
|
27
|
+
type ManagedToken = ManagedTokenRef & {
|
|
23
28
|
readonly footprint: ReadonlyArray<string>;
|
|
24
29
|
};
|
|
25
30
|
|
|
@@ -145,23 +150,16 @@ export const computeCredsPlan = (input: {
|
|
|
145
150
|
const renewWithin =
|
|
146
151
|
(input.renewWithinDays ?? DEFAULT_RENEW_WITHIN_DAYS) * DAY_MS;
|
|
147
152
|
const actions: Array<PlannedAction> = [];
|
|
148
|
-
const
|
|
153
|
+
const partition = partitionAccountTokens(input.tokens, input.repo);
|
|
154
|
+
const findings: Array<string> = [...partition.findings];
|
|
149
155
|
let healthy = 0;
|
|
150
|
-
const managed =
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
ref,
|
|
158
|
-
footprint: inferredDestinationFootprint(
|
|
159
|
-
input.keysByTarget.get(ref.target),
|
|
160
|
-
ref.key,
|
|
161
|
-
),
|
|
162
|
-
},
|
|
163
|
-
];
|
|
164
|
-
});
|
|
156
|
+
const managed = partition.managed.map((entry) => ({
|
|
157
|
+
...entry,
|
|
158
|
+
footprint: inferredDestinationFootprint(
|
|
159
|
+
input.keysByTarget.get(entry.ref.target),
|
|
160
|
+
entry.ref.key,
|
|
161
|
+
),
|
|
162
|
+
}));
|
|
165
163
|
const dispositions = groupByIntersectingFootprint(managed).map((group) =>
|
|
166
164
|
dispositionForGroup(group, input, renewWithin),
|
|
167
165
|
);
|
|
@@ -174,5 +172,11 @@ export const computeCredsPlan = (input: {
|
|
|
174
172
|
healthy += 1;
|
|
175
173
|
}
|
|
176
174
|
}
|
|
177
|
-
return {
|
|
175
|
+
return {
|
|
176
|
+
actions,
|
|
177
|
+
findings,
|
|
178
|
+
healthy,
|
|
179
|
+
unmanaged: partition.unmanaged,
|
|
180
|
+
brokeredElsewhere: partition.brokeredElsewhere,
|
|
181
|
+
};
|
|
178
182
|
};
|
|
@@ -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
|
+
};
|