@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
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
|
};
|
|
@@ -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
|
@@ -1,7 +1,11 @@
|
|
|
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';
|
|
8
|
+
import { reportCredsPlan } from './creds-plan-report';
|
|
5
9
|
import { revokePlannedToken } from './creds-plan-revoke';
|
|
6
10
|
import type { AccountToken, PlannedAction } from './creds-plan-types';
|
|
7
11
|
import { readEncryptedKeys } from './creds-sops';
|
|
@@ -10,12 +14,21 @@ import {
|
|
|
10
14
|
readBrokerStore,
|
|
11
15
|
resolveBrokerPath,
|
|
12
16
|
} from './creds-store';
|
|
17
|
+
import { refreshDevEnvForSopsWrites } from './dev-env-brokered-refresh';
|
|
13
18
|
import { resolveGithubRepo } from './github-api';
|
|
14
19
|
|
|
15
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
|
+
}
|
|
16
29
|
const keysByTarget = new Map<string, ReadonlySet<string>>();
|
|
17
30
|
const targetKeys = await Promise.all(
|
|
18
|
-
|
|
31
|
+
inventory.targets.map(async ({ target, rel }) => ({
|
|
19
32
|
target,
|
|
20
33
|
rel,
|
|
21
34
|
keys: await readEncryptedKeys(consumer, rel),
|
|
@@ -60,18 +73,23 @@ const applyAction = (
|
|
|
60
73
|
consumer: string,
|
|
61
74
|
store: BrokerStore,
|
|
62
75
|
action: PlannedAction,
|
|
63
|
-
): Promise<
|
|
76
|
+
): Promise<RenewPlannedTokenResult> => {
|
|
64
77
|
const account = store.cloudflare.find(
|
|
65
78
|
(entry) => entry.accountId === action.accountId,
|
|
66
79
|
);
|
|
67
80
|
if (account === undefined) {
|
|
68
|
-
return Promise.resolve(
|
|
69
|
-
`${action.name}: account ${action.accountId} is not in the broker store`,
|
|
70
|
-
|
|
81
|
+
return Promise.resolve({
|
|
82
|
+
failure: `${action.name}: account ${action.accountId} is not in the broker store`,
|
|
83
|
+
refreshEvidence: null,
|
|
84
|
+
});
|
|
71
85
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
if (action.kind === 'renew') {
|
|
87
|
+
return renewPlannedToken(consumer, account, action);
|
|
88
|
+
}
|
|
89
|
+
return revokePlannedToken(account, action).then((failure) => ({
|
|
90
|
+
failure,
|
|
91
|
+
refreshEvidence: null,
|
|
92
|
+
}));
|
|
75
93
|
};
|
|
76
94
|
|
|
77
95
|
const fail = (message: string): false => {
|
|
@@ -109,30 +127,25 @@ export const runCredsPlan = async (
|
|
|
109
127
|
tokens: state.tokens,
|
|
110
128
|
now: new Date(),
|
|
111
129
|
});
|
|
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
|
-
);
|
|
130
|
+
reportCredsPlan(plan, apply);
|
|
123
131
|
if (plan.findings.length > 0) {
|
|
124
132
|
return fail('reconciliation aborted until every finding is resolved');
|
|
125
133
|
}
|
|
126
134
|
if (!apply || plan.actions.length === 0) {
|
|
127
135
|
return true;
|
|
128
136
|
}
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
);
|
|
134
143
|
for (const failure of failures) {
|
|
135
144
|
console.error(`standards creds: ${failure}`);
|
|
136
145
|
}
|
|
137
|
-
|
|
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;
|
|
138
151
|
};
|
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
|
};
|