@davidvornholt/standards 0.14.1 → 0.15.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 +3 -1
- package/package.json +8 -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 +90 -111
- package/src/creds-args.ts +108 -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 +34 -42
- 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
|
+
};
|
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,21 @@
|
|
|
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 type { CloudflareToken, TokenPolicy } from './creds-cloudflare-api';
|
|
9
|
-
import type { TokenCondition } from './creds-cloudflare-condition';
|
|
10
8
|
import { parseTokenName } from './creds-naming';
|
|
9
|
+
import { groupByIntersectingFootprint } from './creds-plan-groups';
|
|
10
|
+
import type {
|
|
11
|
+
AccountToken,
|
|
12
|
+
CredsPlan,
|
|
13
|
+
PlannedAction,
|
|
14
|
+
} from './creds-plan-types';
|
|
15
|
+
import { destinationFormatOf, inferredDestinationFootprint } from './creds-r2';
|
|
11
16
|
|
|
12
17
|
const DEFAULT_RENEW_WITHIN_DAYS = 30;
|
|
13
18
|
const DAY_MS = 86_400_000;
|
|
14
19
|
|
|
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
20
|
type ManagedToken = AccountToken & {
|
|
48
21
|
readonly ref: { readonly target: string; readonly key: string };
|
|
22
|
+
readonly footprint: ReadonlyArray<string>;
|
|
49
23
|
};
|
|
50
24
|
|
|
51
25
|
type Disposition =
|
|
@@ -69,7 +43,11 @@ const dispositionOf = (
|
|
|
69
43
|
finding: `${token.name} (${accountId}/${token.id}) has status ${token.status}; it is not healthy and will not be mutated automatically`,
|
|
70
44
|
};
|
|
71
45
|
}
|
|
72
|
-
|
|
46
|
+
const format = destinationFormatOf(
|
|
47
|
+
input.keysByTarget.get(ref.target),
|
|
48
|
+
ref.key,
|
|
49
|
+
);
|
|
50
|
+
if (format === 'absent') {
|
|
73
51
|
return {
|
|
74
52
|
kind: 'action',
|
|
75
53
|
action: {
|
|
@@ -79,6 +57,12 @@ const dispositionOf = (
|
|
|
79
57
|
},
|
|
80
58
|
};
|
|
81
59
|
}
|
|
60
|
+
if (format === 'partial') {
|
|
61
|
+
return {
|
|
62
|
+
kind: 'finding',
|
|
63
|
+
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`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
82
66
|
if (!token.condition.supported) {
|
|
83
67
|
return {
|
|
84
68
|
kind: 'finding',
|
|
@@ -118,6 +102,7 @@ const dispositionOf = (
|
|
|
118
102
|
kind: 'renew',
|
|
119
103
|
target: ref.target,
|
|
120
104
|
key: ref.key,
|
|
105
|
+
format,
|
|
121
106
|
policies: token.policies,
|
|
122
107
|
condition: token.condition.value,
|
|
123
108
|
replacementExpiresOn: new Date(
|
|
@@ -140,7 +125,7 @@ const dispositionForGroup = (
|
|
|
140
125
|
const [first] = group;
|
|
141
126
|
return {
|
|
142
127
|
kind: 'finding',
|
|
143
|
-
finding: `ambiguous Cloudflare tokens
|
|
128
|
+
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
129
|
};
|
|
145
130
|
}
|
|
146
131
|
const [entry] = group;
|
|
@@ -163,13 +148,20 @@ export const computeCredsPlan = (input: {
|
|
|
163
148
|
let healthy = 0;
|
|
164
149
|
const managed = input.tokens.flatMap((entry) => {
|
|
165
150
|
const ref = parseTokenName(entry.token.name, input.repo);
|
|
166
|
-
return ref === null
|
|
151
|
+
return ref === null
|
|
152
|
+
? []
|
|
153
|
+
: [
|
|
154
|
+
{
|
|
155
|
+
...entry,
|
|
156
|
+
ref,
|
|
157
|
+
footprint: inferredDestinationFootprint(
|
|
158
|
+
input.keysByTarget.get(ref.target),
|
|
159
|
+
ref.key,
|
|
160
|
+
),
|
|
161
|
+
},
|
|
162
|
+
];
|
|
167
163
|
});
|
|
168
|
-
const
|
|
169
|
-
managed,
|
|
170
|
-
({ ref }) => `${ref.target}\0${ref.key}`,
|
|
171
|
-
);
|
|
172
|
-
const dispositions = [...byDestination.values()].map((group) =>
|
|
164
|
+
const dispositions = groupByIntersectingFootprint(managed).map((group) =>
|
|
173
165
|
dispositionForGroup(group, input, renewWithin),
|
|
174
166
|
);
|
|
175
167
|
for (const disposition of dispositions) {
|