@davidvornholt/standards 0.14.1 → 0.15.1
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 +3 -1
- package/package.json +9 -1
- package/src/creds-add-cloudflare-commit.ts +26 -13
- package/src/creds-add-collision.ts +46 -0
- package/src/creds-add-policy.ts +118 -0
- package/src/creds-add.ts +92 -112
- package/src/creds-args.ts +108 -0
- package/src/creds-cloudflare-expiry.ts +7 -0
- package/src/creds-commands.ts +4 -86
- package/src/creds-plan-groups.ts +29 -0
- package/src/creds-plan-renew.ts +124 -0
- package/src/creds-plan-revoke.ts +1 -1
- package/src/creds-plan-run.ts +12 -72
- package/src/creds-plan-types.ts +36 -0
- package/src/creds-plan.ts +37 -44
- package/src/creds-r2.ts +117 -0
- package/src/creds-sops.ts +0 -7
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_R2_JURISDICTION,
|
|
5
|
+
isR2Jurisdiction,
|
|
6
|
+
type R2Jurisdiction,
|
|
7
|
+
} from './creds-r2';
|
|
8
|
+
|
|
9
|
+
const DAY_MS = 86_400_000;
|
|
10
|
+
const MAX_PROVIDER_EXPIRATION_MS = Date.parse('9999-12-31T23:59:59.999Z');
|
|
11
|
+
const POSITIVE_INTEGER = /^[1-9][0-9]*$/u;
|
|
12
|
+
|
|
13
|
+
export type CredsFlags = {
|
|
14
|
+
dir: string;
|
|
15
|
+
dest: string | undefined;
|
|
16
|
+
permissions: string | undefined;
|
|
17
|
+
account: string | undefined;
|
|
18
|
+
ttlDays: number | undefined;
|
|
19
|
+
bucket: string | undefined;
|
|
20
|
+
jurisdiction: R2Jurisdiction;
|
|
21
|
+
s3: boolean;
|
|
22
|
+
org: string | undefined;
|
|
23
|
+
name: string | undefined;
|
|
24
|
+
readonly words: Array<string>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const flagValue = (argv: ReadonlyArray<string>, index: number): string => {
|
|
28
|
+
const value = argv[index + 1];
|
|
29
|
+
if (value === undefined || value.startsWith('--')) {
|
|
30
|
+
throw new Error(`${argv[index]} requires a value`);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const parseTtlDays = (raw: string): number => {
|
|
36
|
+
const ttlDays = Number(raw);
|
|
37
|
+
if (
|
|
38
|
+
!(POSITIVE_INTEGER.test(raw) && Number.isSafeInteger(ttlDays)) ||
|
|
39
|
+
Date.now() + ttlDays * DAY_MS > MAX_PROVIDER_EXPIRATION_MS
|
|
40
|
+
) {
|
|
41
|
+
throw new Error('--ttl-days must be a provider-safe positive integer');
|
|
42
|
+
}
|
|
43
|
+
return ttlDays;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
|
|
47
|
+
const flags: CredsFlags = {
|
|
48
|
+
dir: process.cwd(),
|
|
49
|
+
dest: undefined,
|
|
50
|
+
permissions: undefined,
|
|
51
|
+
account: undefined,
|
|
52
|
+
ttlDays: undefined,
|
|
53
|
+
bucket: undefined,
|
|
54
|
+
jurisdiction: DEFAULT_R2_JURISDICTION,
|
|
55
|
+
s3: false,
|
|
56
|
+
org: undefined,
|
|
57
|
+
name: undefined,
|
|
58
|
+
words: [],
|
|
59
|
+
};
|
|
60
|
+
const setters: Readonly<Record<string, (value: string) => void>> = {
|
|
61
|
+
'--dir': (value) => {
|
|
62
|
+
flags.dir = value;
|
|
63
|
+
},
|
|
64
|
+
'--dest': (value) => {
|
|
65
|
+
flags.dest = value;
|
|
66
|
+
},
|
|
67
|
+
'--permissions': (value) => {
|
|
68
|
+
flags.permissions = value;
|
|
69
|
+
},
|
|
70
|
+
'--account': (value) => {
|
|
71
|
+
flags.account = value;
|
|
72
|
+
},
|
|
73
|
+
'--ttl-days': (value) => {
|
|
74
|
+
flags.ttlDays = parseTtlDays(value);
|
|
75
|
+
},
|
|
76
|
+
'--bucket': (value) => {
|
|
77
|
+
flags.bucket = value;
|
|
78
|
+
},
|
|
79
|
+
'--jurisdiction': (value) => {
|
|
80
|
+
if (!isR2Jurisdiction(value)) {
|
|
81
|
+
throw new Error('--jurisdiction must be default or eu');
|
|
82
|
+
}
|
|
83
|
+
flags.jurisdiction = value;
|
|
84
|
+
},
|
|
85
|
+
'--org': (value) => {
|
|
86
|
+
flags.org = value;
|
|
87
|
+
},
|
|
88
|
+
'--name': (value) => {
|
|
89
|
+
flags.name = value;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
93
|
+
const arg = argv[index] ?? '';
|
|
94
|
+
const setter = setters[arg];
|
|
95
|
+
if (arg === '--s3') {
|
|
96
|
+
flags.s3 = true;
|
|
97
|
+
} else if (setter !== undefined) {
|
|
98
|
+
setter(flagValue(argv, index));
|
|
99
|
+
index += 1;
|
|
100
|
+
} else if (arg.startsWith('--')) {
|
|
101
|
+
throw new Error(`Unknown creds option: ${arg}`);
|
|
102
|
+
} else {
|
|
103
|
+
flags.words.push(arg);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
flags.dir = resolve(flags.dir);
|
|
107
|
+
return flags;
|
|
108
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Cloudflare rejects fractional seconds in expires_on before creating the
|
|
2
|
+
// token; it requires whole-second RFC3339 like "2005-12-30T01:02:03Z".
|
|
3
|
+
|
|
4
|
+
const FRACTIONAL_SECONDS = /\.\d{3}Z$/u;
|
|
5
|
+
|
|
6
|
+
export const cloudflareExpiresOn = (epochMs: number): string =>
|
|
7
|
+
new Date(epochMs).toISOString().replace(FRACTIONAL_SECONDS, 'Z');
|
package/src/creds-commands.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { resolve } from 'node:path';
|
|
2
|
-
import process from 'node:process';
|
|
3
1
|
import { runCredsAddCloudflare } from './creds-add';
|
|
4
2
|
import { runCredsAddGithub } from './creds-add-github';
|
|
3
|
+
import { parseCredsArgs } from './creds-args';
|
|
5
4
|
import { listPermissionGroups } from './creds-cloudflare';
|
|
6
5
|
import { runCredsLoginCloudflare } from './creds-login-cloudflare';
|
|
7
6
|
import { runCredsLoginGithub } from './creds-login-github';
|
|
@@ -31,94 +30,13 @@ Options:
|
|
|
31
30
|
--permissions <list> Comma-separated Cloudflare permission group names
|
|
32
31
|
--account <id> Cloudflare account when more than one is configured
|
|
33
32
|
--ttl-days <n> Token lifetime in days (default: 90)
|
|
33
|
+
--bucket <name> Scope a Cloudflare token to one R2 bucket
|
|
34
|
+
--jurisdiction <name> R2 jurisdiction: default or eu (default: default)
|
|
35
|
+
--s3 Store the derived R2 S3 credential pair (<key>.access_key_id, <key>.secret_access_key) instead of the raw token
|
|
34
36
|
--org <org> Create the GitHub App under an organization
|
|
35
37
|
--name <name> GitHub App name (default: ${BROKER_IDENTITY_NAME})
|
|
36
38
|
|
|
37
39
|
Secret values are written directly into SOPS-encrypted targets and never printed.`;
|
|
38
|
-
const DAY_MS = 86_400_000;
|
|
39
|
-
const MAX_PROVIDER_EXPIRATION_MS = Date.parse('9999-12-31T23:59:59.999Z');
|
|
40
|
-
const POSITIVE_INTEGER = /^[1-9][0-9]*$/u;
|
|
41
|
-
|
|
42
|
-
type CredsFlags = {
|
|
43
|
-
dir: string;
|
|
44
|
-
dest: string | undefined;
|
|
45
|
-
permissions: string | undefined;
|
|
46
|
-
account: string | undefined;
|
|
47
|
-
ttlDays: number | undefined;
|
|
48
|
-
org: string | undefined;
|
|
49
|
-
name: string | undefined;
|
|
50
|
-
readonly words: Array<string>;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const flagValue = (argv: ReadonlyArray<string>, index: number): string => {
|
|
54
|
-
const value = argv[index + 1];
|
|
55
|
-
if (value === undefined || value.startsWith('--')) {
|
|
56
|
-
throw new Error(`${argv[index]} requires a value`);
|
|
57
|
-
}
|
|
58
|
-
return value;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export const parseTtlDays = (raw: string): number => {
|
|
62
|
-
const ttlDays = Number(raw);
|
|
63
|
-
if (
|
|
64
|
-
!(POSITIVE_INTEGER.test(raw) && Number.isSafeInteger(ttlDays)) ||
|
|
65
|
-
Date.now() + ttlDays * DAY_MS > MAX_PROVIDER_EXPIRATION_MS
|
|
66
|
-
) {
|
|
67
|
-
throw new Error('--ttl-days must be a provider-safe positive integer');
|
|
68
|
-
}
|
|
69
|
-
return ttlDays;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
|
|
73
|
-
const flags: CredsFlags = {
|
|
74
|
-
dir: process.cwd(),
|
|
75
|
-
dest: undefined,
|
|
76
|
-
permissions: undefined,
|
|
77
|
-
account: undefined,
|
|
78
|
-
ttlDays: undefined,
|
|
79
|
-
org: undefined,
|
|
80
|
-
name: undefined,
|
|
81
|
-
words: [],
|
|
82
|
-
};
|
|
83
|
-
const setters: Readonly<Record<string, (value: string) => void>> = {
|
|
84
|
-
'--dir': (value) => {
|
|
85
|
-
flags.dir = value;
|
|
86
|
-
},
|
|
87
|
-
'--dest': (value) => {
|
|
88
|
-
flags.dest = value;
|
|
89
|
-
},
|
|
90
|
-
'--permissions': (value) => {
|
|
91
|
-
flags.permissions = value;
|
|
92
|
-
},
|
|
93
|
-
'--account': (value) => {
|
|
94
|
-
flags.account = value;
|
|
95
|
-
},
|
|
96
|
-
'--ttl-days': (value) => {
|
|
97
|
-
flags.ttlDays = parseTtlDays(value);
|
|
98
|
-
},
|
|
99
|
-
'--org': (value) => {
|
|
100
|
-
flags.org = value;
|
|
101
|
-
},
|
|
102
|
-
'--name': (value) => {
|
|
103
|
-
flags.name = value;
|
|
104
|
-
},
|
|
105
|
-
};
|
|
106
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
107
|
-
const arg = argv[index] ?? '';
|
|
108
|
-
const setter = setters[arg];
|
|
109
|
-
if (setter !== undefined) {
|
|
110
|
-
setter(flagValue(argv, index));
|
|
111
|
-
index += 1;
|
|
112
|
-
} else if (arg.startsWith('--')) {
|
|
113
|
-
throw new Error(`Unknown creds option: ${arg}`);
|
|
114
|
-
} else {
|
|
115
|
-
flags.words.push(arg);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
flags.dir = resolve(flags.dir);
|
|
119
|
-
return flags;
|
|
120
|
-
};
|
|
121
|
-
|
|
122
40
|
const runCredsStatus = async (): Promise<boolean> => {
|
|
123
41
|
const path = resolveBrokerPath();
|
|
124
42
|
const store = await readBrokerStore(path);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { destinationFootprintsIntersect } from './creds-r2';
|
|
2
|
+
|
|
3
|
+
type FootprintedDestination = {
|
|
4
|
+
readonly ref: { readonly target: string };
|
|
5
|
+
readonly footprint: ReadonlyArray<string>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const intersects = (
|
|
9
|
+
left: FootprintedDestination,
|
|
10
|
+
right: FootprintedDestination,
|
|
11
|
+
): boolean =>
|
|
12
|
+
left.ref.target === right.ref.target &&
|
|
13
|
+
destinationFootprintsIntersect(left.footprint, right.footprint);
|
|
14
|
+
|
|
15
|
+
export const groupByIntersectingFootprint = <
|
|
16
|
+
Destination extends FootprintedDestination,
|
|
17
|
+
>(
|
|
18
|
+
destinations: ReadonlyArray<Destination>,
|
|
19
|
+
): ReadonlyArray<ReadonlyArray<Destination>> =>
|
|
20
|
+
destinations.reduce<Array<ReadonlyArray<Destination>>>(
|
|
21
|
+
(groups, destination) => {
|
|
22
|
+
const intersecting = groups.filter((group) =>
|
|
23
|
+
group.some((candidate) => intersects(candidate, destination)),
|
|
24
|
+
);
|
|
25
|
+
const separate = groups.filter((group) => !intersecting.includes(group));
|
|
26
|
+
return [...separate, [...intersecting.flat(), destination]];
|
|
27
|
+
},
|
|
28
|
+
[],
|
|
29
|
+
);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Renewal execution for `standards creds apply`: create the replacement
|
|
2
|
+
// first, durably write and verify the SOPS destination, and only then revoke
|
|
3
|
+
// the old token. An unverifiable destination keeps both tokens; a verified
|
|
4
|
+
// mismatch deletes the replacement and preserves the old token.
|
|
5
|
+
|
|
6
|
+
import { createAccountToken, deleteAccountToken } from './creds-cloudflare';
|
|
7
|
+
import { resolveTargetRel } from './creds-dest';
|
|
8
|
+
import type { PlannedAction } from './creds-plan-types';
|
|
9
|
+
import { destinationWrites, s3AccessKeyPath, s3PairPaths } from './creds-r2';
|
|
10
|
+
import {
|
|
11
|
+
inspectSopsScalarDestination,
|
|
12
|
+
setSopsValues,
|
|
13
|
+
verifySopsStoredValue,
|
|
14
|
+
} from './creds-sops';
|
|
15
|
+
import type { CloudflareBrokerAccount } from './creds-store';
|
|
16
|
+
|
|
17
|
+
type RenewAction = Extract<PlannedAction, { readonly kind: 'renew' }>;
|
|
18
|
+
|
|
19
|
+
const cleanupReplacement = async (
|
|
20
|
+
account: CloudflareBrokerAccount,
|
|
21
|
+
replacementId: string,
|
|
22
|
+
oldId: string,
|
|
23
|
+
context: string,
|
|
24
|
+
): Promise<string> => {
|
|
25
|
+
const { accountId, token } = account;
|
|
26
|
+
const cleanup = await deleteAccountToken(accountId, token, replacementId);
|
|
27
|
+
return cleanup.ok
|
|
28
|
+
? `${context}; deleted replacement ${replacementId} and preserved old token ${oldId}`
|
|
29
|
+
: `${context}; cleanup of replacement ${replacementId} also failed: ${cleanup.problem}; old token ${oldId} remains active`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const inspectRenewDestinations = async (
|
|
33
|
+
consumer: string,
|
|
34
|
+
rel: string,
|
|
35
|
+
action: RenewAction,
|
|
36
|
+
): Promise<string | null> => {
|
|
37
|
+
const paths = action.format === 's3' ? s3PairPaths(action.key) : [action.key];
|
|
38
|
+
const inspected = await Promise.all(
|
|
39
|
+
paths.map((path) => inspectSopsScalarDestination(consumer, rel, path)),
|
|
40
|
+
);
|
|
41
|
+
const blocked = inspected.find((result) => !result.ok);
|
|
42
|
+
if (blocked !== undefined && !blocked.ok) {
|
|
43
|
+
return `${action.name}: ${blocked.problem}`;
|
|
44
|
+
}
|
|
45
|
+
return inspected.some((result) => result.ok && result.state !== 'scalar')
|
|
46
|
+
? `${action.name}: secret ${action.target}:${action.key} disappeared before renewal`
|
|
47
|
+
: null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const renewPlannedToken = async (
|
|
51
|
+
consumer: string,
|
|
52
|
+
account: CloudflareBrokerAccount,
|
|
53
|
+
action: RenewAction,
|
|
54
|
+
): Promise<string | null> => {
|
|
55
|
+
const { accountId, token: bootstrapToken } = account;
|
|
56
|
+
const rel = resolveTargetRel(consumer, action.target);
|
|
57
|
+
if (rel === null) {
|
|
58
|
+
return `${action.name}: secrets target ${action.target} not found`;
|
|
59
|
+
}
|
|
60
|
+
const destinationProblem = await inspectRenewDestinations(
|
|
61
|
+
consumer,
|
|
62
|
+
rel,
|
|
63
|
+
action,
|
|
64
|
+
);
|
|
65
|
+
if (destinationProblem !== null) {
|
|
66
|
+
return destinationProblem;
|
|
67
|
+
}
|
|
68
|
+
if (action.format === 's3') {
|
|
69
|
+
const accessKey = verifySopsStoredValue(
|
|
70
|
+
consumer,
|
|
71
|
+
rel,
|
|
72
|
+
s3AccessKeyPath(action.key),
|
|
73
|
+
action.tokenId,
|
|
74
|
+
);
|
|
75
|
+
if (!(accessKey.ok && accessKey.matches)) {
|
|
76
|
+
return `${action.name}: could not prove the stored S3 access key belongs to the managed token; renewal stopped before mutation`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const replacement = await createAccountToken(accountId, bootstrapToken, {
|
|
80
|
+
name: action.name,
|
|
81
|
+
policies: action.policies,
|
|
82
|
+
expiresOn: action.replacementExpiresOn,
|
|
83
|
+
condition: action.condition,
|
|
84
|
+
});
|
|
85
|
+
if (!replacement.ok) {
|
|
86
|
+
return `${action.name}: ${replacement.problem}`;
|
|
87
|
+
}
|
|
88
|
+
const { id: replacementId, value } = replacement.value;
|
|
89
|
+
const writes = destinationWrites(
|
|
90
|
+
action.format,
|
|
91
|
+
action.key,
|
|
92
|
+
replacementId,
|
|
93
|
+
value,
|
|
94
|
+
);
|
|
95
|
+
const written = setSopsValues(consumer, rel, writes);
|
|
96
|
+
const verified = writes.map((write) => ({
|
|
97
|
+
path: write.path,
|
|
98
|
+
result: verifySopsStoredValue(consumer, rel, write.path, write.value),
|
|
99
|
+
}));
|
|
100
|
+
const unverifiable = verified.flatMap(({ result }) =>
|
|
101
|
+
result.ok ? [] : [result.problem],
|
|
102
|
+
);
|
|
103
|
+
if (unverifiable.length > 0) {
|
|
104
|
+
const detail = unverifiable.join('; ');
|
|
105
|
+
return `${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`;
|
|
106
|
+
}
|
|
107
|
+
const mismatched = verified.flatMap(({ path, result }) =>
|
|
108
|
+
result.ok && !result.matches ? [path] : [],
|
|
109
|
+
);
|
|
110
|
+
if (mismatched.length > 0) {
|
|
111
|
+
const problem = written.ok
|
|
112
|
+
? `${action.name}: ${rel} at ${mismatched.join(', ')} now holds a value matching neither the old nor the replacement token; repair the stored value manually`
|
|
113
|
+
: `${action.name}: ${written.problem}`;
|
|
114
|
+
return cleanupReplacement(account, replacementId, action.tokenId, problem);
|
|
115
|
+
}
|
|
116
|
+
const deleted = await deleteAccountToken(
|
|
117
|
+
accountId,
|
|
118
|
+
bootstrapToken,
|
|
119
|
+
action.tokenId,
|
|
120
|
+
);
|
|
121
|
+
return deleted.ok
|
|
122
|
+
? null
|
|
123
|
+
: `${action.name}: replacement ${replacementId} is stored, but old token ${action.tokenId} could not be revoked: ${deleted.problem}`;
|
|
124
|
+
};
|
package/src/creds-plan-revoke.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { deleteAccountToken } from './creds-cloudflare';
|
|
2
|
-
import type { PlannedAction } from './creds-plan';
|
|
2
|
+
import type { PlannedAction } from './creds-plan-types';
|
|
3
3
|
import type { CloudflareBrokerAccount } from './creds-store';
|
|
4
4
|
|
|
5
5
|
export const revokePlannedToken = async (
|
package/src/creds-plan-run.ts
CHANGED
|
@@ -1,24 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createAccountToken as createToken,
|
|
3
|
-
deleteAccountToken as deleteToken,
|
|
4
|
-
} from './creds-cloudflare';
|
|
5
|
-
import { listSecretsTargets, resolveTargetRel } from './creds-dest';
|
|
1
|
+
import { listSecretsTargets } from './creds-dest';
|
|
6
2
|
import { identifyCloudflareBootstrapAuthority } from './creds-login-cloudflare';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
computeCredsPlan,
|
|
10
|
-
type PlannedAction,
|
|
11
|
-
} from './creds-plan';
|
|
3
|
+
import { computeCredsPlan } from './creds-plan';
|
|
4
|
+
import { renewPlannedToken } from './creds-plan-renew';
|
|
12
5
|
import { revokePlannedToken } from './creds-plan-revoke';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
readEncryptedKeys,
|
|
16
|
-
verifySopsStoredValue as verifyStoredValue,
|
|
17
|
-
setSopsValue as writeValue,
|
|
18
|
-
} from './creds-sops';
|
|
6
|
+
import type { AccountToken, PlannedAction } from './creds-plan-types';
|
|
7
|
+
import { readEncryptedKeys } from './creds-sops';
|
|
19
8
|
import {
|
|
20
9
|
type BrokerStore,
|
|
21
|
-
type CloudflareBrokerAccount,
|
|
22
10
|
readBrokerStore,
|
|
23
11
|
resolveBrokerPath,
|
|
24
12
|
} from './creds-store';
|
|
@@ -68,20 +56,7 @@ const gatherRepoState = async (consumer: string, store: BrokerStore) => {
|
|
|
68
56
|
return { keysByTarget, tokens, problems };
|
|
69
57
|
};
|
|
70
58
|
|
|
71
|
-
const
|
|
72
|
-
account: CloudflareBrokerAccount,
|
|
73
|
-
replacementId: string,
|
|
74
|
-
oldId: string,
|
|
75
|
-
context: string,
|
|
76
|
-
): Promise<string> => {
|
|
77
|
-
const { accountId, token } = account;
|
|
78
|
-
const cleanup = await deleteToken(accountId, token, replacementId);
|
|
79
|
-
return cleanup.ok
|
|
80
|
-
? `${context}; deleted replacement ${replacementId} and preserved old token ${oldId}`
|
|
81
|
-
: `${context}; cleanup of replacement ${replacementId} also failed: ${cleanup.problem}; old token ${oldId} remains active`;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const applyAction = async (
|
|
59
|
+
const applyAction = (
|
|
85
60
|
consumer: string,
|
|
86
61
|
store: BrokerStore,
|
|
87
62
|
action: PlannedAction,
|
|
@@ -90,48 +65,13 @@ const applyAction = async (
|
|
|
90
65
|
(entry) => entry.accountId === action.accountId,
|
|
91
66
|
);
|
|
92
67
|
if (account === undefined) {
|
|
93
|
-
return
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (action.kind === 'revoke') {
|
|
97
|
-
return revokePlannedToken(account, action);
|
|
98
|
-
}
|
|
99
|
-
const rel = resolveTargetRel(consumer, action.target);
|
|
100
|
-
if (rel === null) {
|
|
101
|
-
return `${action.name}: secrets target ${action.target} not found`;
|
|
102
|
-
}
|
|
103
|
-
const destination = await inspectDestination(consumer, rel, action.key);
|
|
104
|
-
if (!destination.ok) {
|
|
105
|
-
return `${action.name}: ${destination.problem}`;
|
|
106
|
-
}
|
|
107
|
-
if (destination.state !== 'scalar') {
|
|
108
|
-
return `${action.name}: secret ${action.target}:${action.key} disappeared before renewal`;
|
|
109
|
-
}
|
|
110
|
-
const replacement = await createToken(accountId, bootstrapToken, {
|
|
111
|
-
name: action.name,
|
|
112
|
-
policies: action.policies,
|
|
113
|
-
expiresOn: action.replacementExpiresOn,
|
|
114
|
-
condition: action.condition,
|
|
115
|
-
});
|
|
116
|
-
if (!replacement.ok) {
|
|
117
|
-
return `${action.name}: ${replacement.problem}`;
|
|
118
|
-
}
|
|
119
|
-
const { id: replacementId, value } = replacement.value;
|
|
120
|
-
const written = writeValue(consumer, rel, action.key, value);
|
|
121
|
-
const verified = verifyStoredValue(consumer, rel, action.key, value);
|
|
122
|
-
if (verified.ok && !verified.matches) {
|
|
123
|
-
const problem = written.ok
|
|
124
|
-
? `${action.name}: ${rel} at ${action.key} now holds a value matching neither the old nor the replacement token; repair the stored value manually`
|
|
125
|
-
: `${action.name}: ${written.problem}`;
|
|
126
|
-
return cleanupReplacement(account, replacementId, action.tokenId, problem);
|
|
127
|
-
}
|
|
128
|
-
if (!verified.ok) {
|
|
129
|
-
return `${action.name}: ${written.ok ? verified.problem : `${written.problem}; ${verified.problem}`}; account ${accountId} replacement ${replacementId} and old token ${action.tokenId} remain active because the stored value is unverifiable`;
|
|
68
|
+
return Promise.resolve(
|
|
69
|
+
`${action.name}: account ${action.accountId} is not in the broker store`,
|
|
70
|
+
);
|
|
130
71
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
: `${action.name}: replacement ${replacementId} is stored, but old token ${action.tokenId} could not be revoked: ${deleted.problem}`;
|
|
72
|
+
return action.kind === 'revoke'
|
|
73
|
+
? revokePlannedToken(account, action)
|
|
74
|
+
: renewPlannedToken(consumer, account, action);
|
|
135
75
|
};
|
|
136
76
|
|
|
137
77
|
const fail = (message: string): false => {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { CloudflareToken, TokenPolicy } from './creds-cloudflare-api';
|
|
2
|
+
import type { TokenCondition } from './creds-cloudflare-condition';
|
|
3
|
+
import type { DestinationFormat } from './creds-r2';
|
|
4
|
+
|
|
5
|
+
export type AccountToken = {
|
|
6
|
+
readonly accountId: string;
|
|
7
|
+
readonly token: CloudflareToken;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type PlannedAction =
|
|
11
|
+
| {
|
|
12
|
+
readonly kind: 'revoke';
|
|
13
|
+
readonly accountId: string;
|
|
14
|
+
readonly tokenId: string;
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly reason: string;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
readonly kind: 'renew';
|
|
20
|
+
readonly accountId: string;
|
|
21
|
+
readonly tokenId: string;
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly target: string;
|
|
24
|
+
readonly key: string;
|
|
25
|
+
readonly format: DestinationFormat;
|
|
26
|
+
readonly policies: ReadonlyArray<TokenPolicy>;
|
|
27
|
+
readonly condition: TokenCondition | null;
|
|
28
|
+
readonly replacementExpiresOn: string;
|
|
29
|
+
readonly reason: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type CredsPlan = {
|
|
33
|
+
readonly actions: ReadonlyArray<PlannedAction>;
|
|
34
|
+
readonly findings: ReadonlyArray<string>;
|
|
35
|
+
readonly healthy: number;
|
|
36
|
+
};
|
package/src/creds-plan.ts
CHANGED
|
@@ -5,47 +5,22 @@
|
|
|
5
5
|
// lifetime. Secret keys without a brokered token are simply unmanaged — most
|
|
6
6
|
// secrets are — and are never touched. Execution lives in creds-plan-run.ts.
|
|
7
7
|
|
|
8
|
-
import
|
|
9
|
-
import type { TokenCondition } from './creds-cloudflare-condition';
|
|
8
|
+
import { cloudflareExpiresOn } from './creds-cloudflare-expiry';
|
|
10
9
|
import { parseTokenName } from './creds-naming';
|
|
10
|
+
import { groupByIntersectingFootprint } from './creds-plan-groups';
|
|
11
|
+
import type {
|
|
12
|
+
AccountToken,
|
|
13
|
+
CredsPlan,
|
|
14
|
+
PlannedAction,
|
|
15
|
+
} from './creds-plan-types';
|
|
16
|
+
import { destinationFormatOf, inferredDestinationFootprint } from './creds-r2';
|
|
11
17
|
|
|
12
18
|
const DEFAULT_RENEW_WITHIN_DAYS = 30;
|
|
13
19
|
const DAY_MS = 86_400_000;
|
|
14
20
|
|
|
15
|
-
export type AccountToken = {
|
|
16
|
-
readonly accountId: string;
|
|
17
|
-
readonly token: CloudflareToken;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type PlannedAction =
|
|
21
|
-
| {
|
|
22
|
-
readonly kind: 'revoke';
|
|
23
|
-
readonly accountId: string;
|
|
24
|
-
readonly tokenId: string;
|
|
25
|
-
readonly name: string;
|
|
26
|
-
readonly reason: string;
|
|
27
|
-
}
|
|
28
|
-
| {
|
|
29
|
-
readonly kind: 'renew';
|
|
30
|
-
readonly accountId: string;
|
|
31
|
-
readonly tokenId: string;
|
|
32
|
-
readonly name: string;
|
|
33
|
-
readonly target: string;
|
|
34
|
-
readonly key: string;
|
|
35
|
-
readonly policies: ReadonlyArray<TokenPolicy>;
|
|
36
|
-
readonly condition: TokenCondition | null;
|
|
37
|
-
readonly replacementExpiresOn: string;
|
|
38
|
-
readonly reason: string;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export type CredsPlan = {
|
|
42
|
-
readonly actions: ReadonlyArray<PlannedAction>;
|
|
43
|
-
readonly findings: ReadonlyArray<string>;
|
|
44
|
-
readonly healthy: number;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
21
|
type ManagedToken = AccountToken & {
|
|
48
22
|
readonly ref: { readonly target: string; readonly key: string };
|
|
23
|
+
readonly footprint: ReadonlyArray<string>;
|
|
49
24
|
};
|
|
50
25
|
|
|
51
26
|
type Disposition =
|
|
@@ -69,7 +44,11 @@ const dispositionOf = (
|
|
|
69
44
|
finding: `${token.name} (${accountId}/${token.id}) has status ${token.status}; it is not healthy and will not be mutated automatically`,
|
|
70
45
|
};
|
|
71
46
|
}
|
|
72
|
-
|
|
47
|
+
const format = destinationFormatOf(
|
|
48
|
+
input.keysByTarget.get(ref.target),
|
|
49
|
+
ref.key,
|
|
50
|
+
);
|
|
51
|
+
if (format === 'absent') {
|
|
73
52
|
return {
|
|
74
53
|
kind: 'action',
|
|
75
54
|
action: {
|
|
@@ -79,6 +58,12 @@ const dispositionOf = (
|
|
|
79
58
|
},
|
|
80
59
|
};
|
|
81
60
|
}
|
|
61
|
+
if (format === 'partial') {
|
|
62
|
+
return {
|
|
63
|
+
kind: 'finding',
|
|
64
|
+
finding: `${token.name} maps to an incomplete S3 credential pair at ${ref.target}:${ref.key} (expected both access_key_id and secret_access_key); it will not be mutated automatically`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
82
67
|
if (!token.condition.supported) {
|
|
83
68
|
return {
|
|
84
69
|
kind: 'finding',
|
|
@@ -118,11 +103,12 @@ const dispositionOf = (
|
|
|
118
103
|
kind: 'renew',
|
|
119
104
|
target: ref.target,
|
|
120
105
|
key: ref.key,
|
|
106
|
+
format,
|
|
121
107
|
policies: token.policies,
|
|
122
108
|
condition: token.condition.value,
|
|
123
|
-
replacementExpiresOn:
|
|
109
|
+
replacementExpiresOn: cloudflareExpiresOn(
|
|
124
110
|
input.now.getTime() + expiry - issued,
|
|
125
|
-
)
|
|
111
|
+
),
|
|
126
112
|
reason: `expires ${token.expiresOn}`,
|
|
127
113
|
},
|
|
128
114
|
};
|
|
@@ -140,7 +126,7 @@ const dispositionForGroup = (
|
|
|
140
126
|
const [first] = group;
|
|
141
127
|
return {
|
|
142
128
|
kind: 'finding',
|
|
143
|
-
finding: `ambiguous Cloudflare tokens
|
|
129
|
+
finding: `ambiguous Cloudflare tokens have destination footprints intersecting in ${first?.ref.target}: ${group.map((candidate) => `${candidate.ref.key} (${candidate.accountId}/${candidate.token.id})`).join(', ')}; revoke duplicates manually before plan/apply`,
|
|
144
130
|
};
|
|
145
131
|
}
|
|
146
132
|
const [entry] = group;
|
|
@@ -163,13 +149,20 @@ export const computeCredsPlan = (input: {
|
|
|
163
149
|
let healthy = 0;
|
|
164
150
|
const managed = input.tokens.flatMap((entry) => {
|
|
165
151
|
const ref = parseTokenName(entry.token.name, input.repo);
|
|
166
|
-
return ref === null
|
|
152
|
+
return ref === null
|
|
153
|
+
? []
|
|
154
|
+
: [
|
|
155
|
+
{
|
|
156
|
+
...entry,
|
|
157
|
+
ref,
|
|
158
|
+
footprint: inferredDestinationFootprint(
|
|
159
|
+
input.keysByTarget.get(ref.target),
|
|
160
|
+
ref.key,
|
|
161
|
+
),
|
|
162
|
+
},
|
|
163
|
+
];
|
|
167
164
|
});
|
|
168
|
-
const
|
|
169
|
-
managed,
|
|
170
|
-
({ ref }) => `${ref.target}\0${ref.key}`,
|
|
171
|
-
);
|
|
172
|
-
const dispositions = [...byDestination.values()].map((group) =>
|
|
165
|
+
const dispositions = groupByIntersectingFootprint(managed).map((group) =>
|
|
173
166
|
dispositionForGroup(group, input, renewWithin),
|
|
174
167
|
);
|
|
175
168
|
for (const disposition of dispositions) {
|