@davidvornholt/standards 0.18.0 → 0.20.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 +5 -4
- package/package.json +12 -1
- package/src/cli.ts +283 -73
- 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 +5 -0
- package/src/creds-cloudflare-zone.ts +40 -0
- package/src/creds-commands.ts +21 -9
- 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-plan-renew.ts +2 -5
- 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 +98 -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 +27 -51
- package/src/creds-store.ts +14 -43
- package/src/github-command-shared.ts +8 -1
- package/src/github-live-drift.ts +3 -3
- package/src/managed-files.ts +116 -14
- package/src/managed-path-ownership.ts +85 -0
- 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
package/src/creds-add-github.ts
CHANGED
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
// identity itself; workflows mint short-lived installation tokens from it at
|
|
5
5
|
// runtime, scoped per repository and permission.
|
|
6
6
|
|
|
7
|
+
import { inspectDestinations } from './creds-add-preflight';
|
|
7
8
|
import { resolveContext } from './creds-dest';
|
|
9
|
+
import { verifyGithubAppInstallation } from './creds-github-app-api';
|
|
8
10
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from './creds-
|
|
11
|
+
loadOwnedGithubStore,
|
|
12
|
+
sameGithubApp,
|
|
13
|
+
selectGithubAppForRepo,
|
|
14
|
+
} from './creds-github-apps';
|
|
15
|
+
import { setSopsValues } from './creds-sops';
|
|
16
|
+
import { verifySopsStoredValue } from './creds-sops-value';
|
|
17
|
+
import { readBrokerStore, resolveBrokerPath } from './creds-store';
|
|
18
|
+
import { withBrokerLock } from './creds-store-lock';
|
|
13
19
|
|
|
14
20
|
export const runCredsAddGithub = async (
|
|
15
21
|
consumer: string,
|
|
@@ -19,41 +25,87 @@ export const runCredsAddGithub = async (
|
|
|
19
25
|
if (context === null) {
|
|
20
26
|
return false;
|
|
21
27
|
}
|
|
22
|
-
if (context.store.github === null) {
|
|
23
|
-
console.error(
|
|
24
|
-
'standards creds: no broker GitHub App configured; run `standards creds login github`',
|
|
25
|
-
);
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
const { appId, privateKey, slug } = context.store.github;
|
|
29
28
|
const appIdPath = `${context.dest.key}.app_id`;
|
|
30
29
|
const privateKeyPath = `${context.dest.key}.private_key`;
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
const blocked = await inspectDestinations(consumer, context.rel, [
|
|
31
|
+
appIdPath,
|
|
32
|
+
privateKeyPath,
|
|
33
|
+
]);
|
|
34
|
+
if (blocked !== null) {
|
|
35
|
+
console.error(`standards creds: ${blocked}`);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const storePath = resolveBrokerPath();
|
|
39
|
+
const loaded = await loadOwnedGithubStore(storePath);
|
|
40
|
+
if (!loaded.ok) {
|
|
41
|
+
console.error(`standards creds: ${loaded.problem}`);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const selected = selectGithubAppForRepo(loaded.value.github, context.repo);
|
|
45
|
+
if (!selected.ok) {
|
|
46
|
+
console.error(`standards creds: ${selected.problem}`);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const installation = await verifyGithubAppInstallation(
|
|
50
|
+
selected.value,
|
|
51
|
+
context.repo,
|
|
35
52
|
);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
53
|
+
if (!installation.ok) {
|
|
54
|
+
console.error(`standards creds: ${installation.problem}`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const { appId, privateKey, slug } = selected.value;
|
|
58
|
+
let writeResult:
|
|
59
|
+
| { readonly ok: true }
|
|
60
|
+
| { readonly ok: false; readonly problem: string };
|
|
61
|
+
try {
|
|
62
|
+
writeResult = await withBrokerLock(storePath, async () => {
|
|
63
|
+
const current = selectGithubAppForRepo(
|
|
64
|
+
(await readBrokerStore(storePath)).github,
|
|
65
|
+
context.repo,
|
|
66
|
+
);
|
|
67
|
+
if (!current.ok) {
|
|
68
|
+
return current;
|
|
69
|
+
}
|
|
70
|
+
if (!sameGithubApp(current.value, selected.value)) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
problem: `the broker GitHub App for ${selected.value.owner} changed while its installation was being verified; retry so the replacement can be verified`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return setSopsValues(consumer, context.rel, [
|
|
77
|
+
{ path: appIdPath, value: String(appId) },
|
|
78
|
+
{ path: privateKeyPath, value: privateKey },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
console.error(`standards creds: ${message}`);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
if (!writeResult.ok) {
|
|
87
|
+
console.error(`standards creds: ${writeResult.problem}`);
|
|
39
88
|
return false;
|
|
40
89
|
}
|
|
41
|
-
const
|
|
90
|
+
const verified = [
|
|
42
91
|
{ path: appIdPath, value: String(appId) },
|
|
43
92
|
{ path: privateKeyPath, value: privateKey },
|
|
44
|
-
])
|
|
45
|
-
|
|
46
|
-
|
|
93
|
+
].map(({ path, value }) => ({
|
|
94
|
+
path,
|
|
95
|
+
result: verifySopsStoredValue(consumer, context.rel, path, value),
|
|
96
|
+
}));
|
|
97
|
+
const unverifiable = verified.find(({ result }) => !result.ok);
|
|
98
|
+
if (unverifiable !== undefined && !unverifiable.result.ok) {
|
|
99
|
+
console.error(`standards creds: ${unverifiable.result.problem}`);
|
|
47
100
|
return false;
|
|
48
101
|
}
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
console.error(`standards creds: ${failedVerification.problem}`);
|
|
102
|
+
const mismatched = verified
|
|
103
|
+
.filter(({ result }) => result.ok && !result.matches)
|
|
104
|
+
.map(({ path }) => path);
|
|
105
|
+
if (mismatched.length > 0) {
|
|
106
|
+
console.error(
|
|
107
|
+
`standards creds: the stored SOPS value at ${mismatched.join(', ')} does not match the selected GitHub App`,
|
|
108
|
+
);
|
|
57
109
|
return false;
|
|
58
110
|
}
|
|
59
111
|
console.log(
|
package/src/creds-add-policy.ts
CHANGED
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import { listPermissionGroups } from './creds-cloudflare';
|
|
8
8
|
import type { PermissionGroup, TokenPolicy } from './creds-cloudflare-api';
|
|
9
|
+
import { ZONE_SCOPE, zoneResource } from './creds-cloudflare-zone';
|
|
9
10
|
import {
|
|
10
|
-
DEFAULT_R2_JURISDICTION,
|
|
11
|
-
isR2BucketName,
|
|
12
11
|
R2_BUCKET_SCOPE,
|
|
13
12
|
type R2Jurisdiction,
|
|
14
13
|
r2BucketResource,
|
|
@@ -17,6 +16,16 @@ import type { CloudflareBrokerAccount } from './creds-store';
|
|
|
17
16
|
|
|
18
17
|
const ACCOUNT_SCOPE = 'com.cloudflare.api.account';
|
|
19
18
|
|
|
19
|
+
const tokenPolicy = (
|
|
20
|
+
resources: TokenPolicy['resources'],
|
|
21
|
+
groups: ReadonlyArray<PermissionGroup>,
|
|
22
|
+
): TokenPolicy => ({
|
|
23
|
+
effect: 'allow',
|
|
24
|
+
resources,
|
|
25
|
+
// biome-ignore lint/style/useNamingConvention: Cloudflare's policy wire field is snake_case.
|
|
26
|
+
permission_groups: groups.map(({ id }) => ({ id })),
|
|
27
|
+
});
|
|
28
|
+
|
|
20
29
|
export const unsupportedResourceScopes = (
|
|
21
30
|
groups: ReadonlyArray<PermissionGroup>,
|
|
22
31
|
scope: string,
|
|
@@ -26,23 +35,99 @@ export const unsupportedResourceScopes = (
|
|
|
26
35
|
.map((group) => group.name);
|
|
27
36
|
|
|
28
37
|
const scopeProblem = (
|
|
29
|
-
|
|
38
|
+
resource: { readonly kind: 'account' | 'bucket' },
|
|
30
39
|
selected: ReadonlyArray<PermissionGroup>,
|
|
31
40
|
): string | null => {
|
|
32
|
-
const scope =
|
|
41
|
+
const scope = resource.kind === 'account' ? ACCOUNT_SCOPE : R2_BUCKET_SCOPE;
|
|
33
42
|
const unsupported = unsupportedResourceScopes(selected, scope);
|
|
34
43
|
if (unsupported.length === 0) {
|
|
35
44
|
return null;
|
|
36
45
|
}
|
|
37
|
-
return
|
|
38
|
-
? `permission group(s) ${unsupported.join(', ')} cannot target an account resource; choose account-scoped groups
|
|
46
|
+
return resource.kind === 'account'
|
|
47
|
+
? `permission group(s) ${unsupported.join(', ')} cannot target an account resource; choose account-scoped groups, name the zones with --zone for zone-scoped groups, or pass --bucket for R2 bucket-item groups`
|
|
39
48
|
: `permission group(s) ${unsupported.join(', ')} cannot target an R2 bucket resource; --bucket accepts only bucket-scoped groups such as Workers R2 Storage Bucket Item Read/Write`;
|
|
40
49
|
};
|
|
41
50
|
|
|
51
|
+
// With zones named, a token spans two resources, so the selection is split by
|
|
52
|
+
// what each group can actually target rather than checked against one scope.
|
|
53
|
+
// A group matching neither resource is the operator's mistake and is named.
|
|
54
|
+
// A group Cloudflare reports as both zone- and account-scoped goes to the zone
|
|
55
|
+
// policy: `--zone` asks for zone reach, and the narrower reading is the safe
|
|
56
|
+
// one to guess wrong.
|
|
57
|
+
const zonePolicies = (
|
|
58
|
+
accountId: string,
|
|
59
|
+
zoneIds: ReadonlyArray<string>,
|
|
60
|
+
selected: ReadonlyArray<PermissionGroup>,
|
|
61
|
+
):
|
|
62
|
+
| { readonly policies: ReadonlyArray<TokenPolicy> }
|
|
63
|
+
| { readonly problem: string } => {
|
|
64
|
+
const zoneGroups = selected.filter((group) =>
|
|
65
|
+
group.scopes.includes(ZONE_SCOPE),
|
|
66
|
+
);
|
|
67
|
+
const accountGroups = selected.filter(
|
|
68
|
+
(group) =>
|
|
69
|
+
!group.scopes.includes(ZONE_SCOPE) &&
|
|
70
|
+
group.scopes.includes(ACCOUNT_SCOPE),
|
|
71
|
+
);
|
|
72
|
+
const unsupported = selected
|
|
73
|
+
.filter(
|
|
74
|
+
(group) =>
|
|
75
|
+
!(
|
|
76
|
+
group.scopes.includes(ZONE_SCOPE) ||
|
|
77
|
+
group.scopes.includes(ACCOUNT_SCOPE)
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
.map((group) => group.name);
|
|
81
|
+
if (unsupported.length > 0) {
|
|
82
|
+
return {
|
|
83
|
+
problem: `permission group(s) ${unsupported.join(', ')} target neither a zone nor an account resource; R2 bucket-item groups require --bucket, which cannot be combined with --zone`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (zoneGroups.length === 0) {
|
|
87
|
+
return {
|
|
88
|
+
problem:
|
|
89
|
+
'--zone was given but no selected permission group is zone-scoped; drop --zone, or add a zone-scoped group such as DNS Write',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const zoneResources = Object.fromEntries(
|
|
93
|
+
zoneIds.map((zoneId) => [zoneResource(zoneId), '*']),
|
|
94
|
+
);
|
|
95
|
+
return {
|
|
96
|
+
policies: [
|
|
97
|
+
...(accountGroups.length === 0
|
|
98
|
+
? []
|
|
99
|
+
: [
|
|
100
|
+
tokenPolicy(
|
|
101
|
+
{ [`${ACCOUNT_SCOPE}.${accountId}`]: '*' },
|
|
102
|
+
accountGroups,
|
|
103
|
+
),
|
|
104
|
+
]),
|
|
105
|
+
tokenPolicy(zoneResources, zoneGroups),
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// One token reaches exactly one kind of resource, so the caller states which
|
|
111
|
+
// rather than passing flags whose illegal combinations would have to be
|
|
112
|
+
// re-rejected here.
|
|
113
|
+
export type PolicyResource =
|
|
114
|
+
| { readonly kind: 'account' }
|
|
115
|
+
| {
|
|
116
|
+
readonly kind: 'bucket';
|
|
117
|
+
readonly bucket: string;
|
|
118
|
+
readonly jurisdiction: R2Jurisdiction;
|
|
119
|
+
}
|
|
120
|
+
| {
|
|
121
|
+
readonly kind: 'zones';
|
|
122
|
+
// Non-empty by type: a zone policy with no resource would be accepted by
|
|
123
|
+
// the wire shape and reach nothing.
|
|
124
|
+
readonly zoneIds: readonly [string, ...ReadonlyArray<string>];
|
|
125
|
+
};
|
|
126
|
+
|
|
42
127
|
export type ResolvedTokenPolicy =
|
|
43
128
|
| {
|
|
44
129
|
readonly ok: true;
|
|
45
|
-
readonly
|
|
130
|
+
readonly policies: ReadonlyArray<TokenPolicy>;
|
|
46
131
|
readonly wanted: ReadonlyArray<string>;
|
|
47
132
|
}
|
|
48
133
|
| { readonly ok: false; readonly problem: string };
|
|
@@ -51,10 +136,10 @@ export const resolveTokenPolicy = async (
|
|
|
51
136
|
account: CloudflareBrokerAccount,
|
|
52
137
|
options: {
|
|
53
138
|
readonly permissions: string | undefined;
|
|
54
|
-
readonly
|
|
55
|
-
readonly jurisdiction?: R2Jurisdiction;
|
|
139
|
+
readonly resource: PolicyResource;
|
|
56
140
|
},
|
|
57
141
|
): Promise<ResolvedTokenPolicy> => {
|
|
142
|
+
const { resource } = options;
|
|
58
143
|
if (options.permissions === undefined || options.permissions.length === 0) {
|
|
59
144
|
return {
|
|
60
145
|
ok: false,
|
|
@@ -62,12 +147,6 @@ export const resolveTokenPolicy = async (
|
|
|
62
147
|
'--permissions "<Group Name>[,<Group Name>...]" is required; list names with `standards creds permissions`',
|
|
63
148
|
};
|
|
64
149
|
}
|
|
65
|
-
if (options.bucket !== undefined && !isR2BucketName(options.bucket)) {
|
|
66
|
-
return {
|
|
67
|
-
ok: false,
|
|
68
|
-
problem: `invalid R2 bucket name: ${options.bucket} (3-63 lowercase letters, digits, and hyphens)`,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
150
|
const groups = await listPermissionGroups(account.accountId, account.token);
|
|
72
151
|
if (!groups.ok) {
|
|
73
152
|
return { ok: false, problem: groups.problem };
|
|
@@ -93,26 +172,27 @@ export const resolveTokenPolicy = async (
|
|
|
93
172
|
const selected = resolved.flatMap(({ group }) =>
|
|
94
173
|
group === undefined ? [] : [group],
|
|
95
174
|
);
|
|
96
|
-
|
|
175
|
+
if (resource.kind === 'zones') {
|
|
176
|
+
const zoned = zonePolicies(account.accountId, resource.zoneIds, selected);
|
|
177
|
+
return 'problem' in zoned
|
|
178
|
+
? { ok: false, problem: zoned.problem }
|
|
179
|
+
: { ok: true, wanted, policies: zoned.policies };
|
|
180
|
+
}
|
|
181
|
+
const problem = scopeProblem(resource, selected);
|
|
97
182
|
if (problem !== null) {
|
|
98
183
|
return { ok: false, problem };
|
|
99
184
|
}
|
|
100
|
-
const
|
|
101
|
-
|
|
185
|
+
const target =
|
|
186
|
+
resource.kind === 'account'
|
|
102
187
|
? `${ACCOUNT_SCOPE}.${account.accountId}`
|
|
103
188
|
: r2BucketResource(
|
|
104
189
|
account.accountId,
|
|
105
|
-
|
|
106
|
-
|
|
190
|
+
resource.bucket,
|
|
191
|
+
resource.jurisdiction,
|
|
107
192
|
);
|
|
108
193
|
return {
|
|
109
194
|
ok: true,
|
|
110
195
|
wanted,
|
|
111
|
-
|
|
112
|
-
effect: 'allow',
|
|
113
|
-
resources: { [resource]: '*' },
|
|
114
|
-
// biome-ignore lint/style/useNamingConvention: Cloudflare's policy wire field is snake_case.
|
|
115
|
-
permission_groups: selected.map(({ id }) => ({ id })),
|
|
116
|
-
},
|
|
196
|
+
policies: [tokenPolicy({ [target]: '*' }, selected)],
|
|
117
197
|
};
|
|
118
198
|
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Everything `creds add cloudflare` can settle before it mints anything: which
|
|
2
|
+
// resource the token will reach, and whether each destination is a writable
|
|
3
|
+
// scalar leaf. Minting first and discovering these afterwards would leave a
|
|
4
|
+
// live credential nobody asked for.
|
|
5
|
+
|
|
6
|
+
import type { PolicyResource } from './creds-add-policy';
|
|
7
|
+
import { parseZoneArgument } from './creds-cloudflare-zone';
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_R2_JURISDICTION,
|
|
10
|
+
isR2BucketName,
|
|
11
|
+
type R2Jurisdiction,
|
|
12
|
+
} from './creds-r2';
|
|
13
|
+
import { inspectSopsScalarDestination } from './creds-sops';
|
|
14
|
+
|
|
15
|
+
export const inspectDestinations = async (
|
|
16
|
+
consumer: string,
|
|
17
|
+
rel: string,
|
|
18
|
+
paths: ReadonlyArray<string>,
|
|
19
|
+
): Promise<string | null> => {
|
|
20
|
+
const inspected = await Promise.all(
|
|
21
|
+
paths.map((path) => inspectSopsScalarDestination(consumer, rel, path)),
|
|
22
|
+
);
|
|
23
|
+
const blocked = inspected.find((result) => !result.ok);
|
|
24
|
+
return blocked !== undefined && !blocked.ok ? blocked.problem : null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ResolvedResourceFlags =
|
|
28
|
+
| { readonly ok: true; readonly resource: PolicyResource }
|
|
29
|
+
| { readonly ok: false; readonly problem: string };
|
|
30
|
+
|
|
31
|
+
// A token reaches one kind of resource, so the flags naming it collapse to that
|
|
32
|
+
// one choice here — before any provider or SOPS work, because no rejection
|
|
33
|
+
// below can be salvaged afterwards.
|
|
34
|
+
export const resolveResourceFlags = (options: {
|
|
35
|
+
readonly bucket: string | undefined;
|
|
36
|
+
readonly zone: string | undefined;
|
|
37
|
+
readonly jurisdiction?: R2Jurisdiction;
|
|
38
|
+
}): ResolvedResourceFlags => {
|
|
39
|
+
if (options.bucket !== undefined && options.zone !== undefined) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
problem:
|
|
43
|
+
'--bucket and --zone cannot be combined; an R2 bucket credential and a zone credential are separate tokens',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (options.bucket !== undefined) {
|
|
47
|
+
return isR2BucketName(options.bucket)
|
|
48
|
+
? {
|
|
49
|
+
ok: true,
|
|
50
|
+
resource: {
|
|
51
|
+
kind: 'bucket',
|
|
52
|
+
bucket: options.bucket,
|
|
53
|
+
jurisdiction: options.jurisdiction ?? DEFAULT_R2_JURISDICTION,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
: {
|
|
57
|
+
ok: false,
|
|
58
|
+
problem: `invalid R2 bucket name: ${options.bucket} (3-63 lowercase letters, digits, and hyphens)`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (options.zone === undefined) {
|
|
62
|
+
return { ok: true, resource: { kind: 'account' } };
|
|
63
|
+
}
|
|
64
|
+
const zones = parseZoneArgument(options.zone);
|
|
65
|
+
return zones.ok
|
|
66
|
+
? { ok: true, resource: { kind: 'zones', zoneIds: zones.zoneIds } }
|
|
67
|
+
: { ok: false, problem: zones.problem };
|
|
68
|
+
};
|
package/src/creds-add.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { commitCreatedCloudflareToken } from './creds-add-cloudflare-commit';
|
|
2
2
|
import { findManagedDestinationCollision } from './creds-add-collision';
|
|
3
3
|
import { resolveTokenPolicy } from './creds-add-policy';
|
|
4
|
+
import {
|
|
5
|
+
inspectDestinations,
|
|
6
|
+
resolveResourceFlags,
|
|
7
|
+
} from './creds-add-preflight';
|
|
4
8
|
import { createAccountToken } from './creds-cloudflare';
|
|
9
|
+
import type { TokenPolicy } from './creds-cloudflare-api';
|
|
5
10
|
import { cloudflareExpiresOn } from './creds-cloudflare-expiry';
|
|
6
11
|
import { resolveContext, selectAccount } from './creds-dest';
|
|
7
12
|
import { tokenNameOf } from './creds-naming';
|
|
@@ -13,11 +18,7 @@ import {
|
|
|
13
18
|
s3Endpoint,
|
|
14
19
|
s3PairPaths,
|
|
15
20
|
} from './creds-r2';
|
|
16
|
-
import {
|
|
17
|
-
inspectSopsScalarDestination,
|
|
18
|
-
readEncryptedKeys,
|
|
19
|
-
setSopsValues,
|
|
20
|
-
} from './creds-sops';
|
|
21
|
+
import { readEncryptedKeys, setSopsValues } from './creds-sops';
|
|
21
22
|
|
|
22
23
|
const DEFAULT_TTL_DAYS = 90;
|
|
23
24
|
const DAY_MS = 86_400_000;
|
|
@@ -30,9 +31,16 @@ const printSuccess = (input: {
|
|
|
30
31
|
readonly format: DestinationFormat;
|
|
31
32
|
readonly accountId: string;
|
|
32
33
|
readonly jurisdiction: R2Jurisdiction;
|
|
34
|
+
readonly policies: ReadonlyArray<TokenPolicy>;
|
|
33
35
|
}): void => {
|
|
34
36
|
console.log(`standards creds: minted Cloudflare token ${input.name}`);
|
|
35
37
|
console.log(` permissions: ${input.permissions.join(', ')}`);
|
|
38
|
+
// Read off the minted policies rather than the flags that asked for them:
|
|
39
|
+
// a selection can add an account policy the flags do not mention, and the
|
|
40
|
+
// reach of a token is the one thing worth stating from ground truth.
|
|
41
|
+
console.log(
|
|
42
|
+
` resources: ${input.policies.flatMap((policy) => Object.keys(policy.resources)).join(', ')}`,
|
|
43
|
+
);
|
|
36
44
|
console.log(
|
|
37
45
|
` expires: ${input.expiresOn} (rotate via \`standards creds apply\`)`,
|
|
38
46
|
);
|
|
@@ -46,18 +54,6 @@ const printSuccess = (input: {
|
|
|
46
54
|
}
|
|
47
55
|
};
|
|
48
56
|
|
|
49
|
-
const inspectDestinations = async (
|
|
50
|
-
consumer: string,
|
|
51
|
-
rel: string,
|
|
52
|
-
paths: ReadonlyArray<string>,
|
|
53
|
-
): Promise<string | null> => {
|
|
54
|
-
const inspected = await Promise.all(
|
|
55
|
-
paths.map((path) => inspectSopsScalarDestination(consumer, rel, path)),
|
|
56
|
-
);
|
|
57
|
-
const blocked = inspected.find((result) => !result.ok);
|
|
58
|
-
return blocked !== undefined && !blocked.ok ? blocked.problem : null;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
57
|
export const runCredsAddCloudflare = async (
|
|
62
58
|
consumer: string,
|
|
63
59
|
options: {
|
|
@@ -66,6 +62,7 @@ export const runCredsAddCloudflare = async (
|
|
|
66
62
|
readonly account: string | undefined;
|
|
67
63
|
readonly ttlDays: number | undefined;
|
|
68
64
|
readonly bucket: string | undefined;
|
|
65
|
+
readonly zone: string | undefined;
|
|
69
66
|
readonly jurisdiction?: R2Jurisdiction;
|
|
70
67
|
readonly s3: boolean;
|
|
71
68
|
},
|
|
@@ -76,6 +73,11 @@ export const runCredsAddCloudflare = async (
|
|
|
76
73
|
);
|
|
77
74
|
return false;
|
|
78
75
|
}
|
|
76
|
+
const flags = resolveResourceFlags(options);
|
|
77
|
+
if (!flags.ok) {
|
|
78
|
+
console.error(`standards creds: ${flags.problem}`);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
79
81
|
const context = await resolveContext(consumer, options.dest);
|
|
80
82
|
if (context === null) {
|
|
81
83
|
return false;
|
|
@@ -102,7 +104,10 @@ export const runCredsAddCloudflare = async (
|
|
|
102
104
|
return false;
|
|
103
105
|
}
|
|
104
106
|
const keys = new Set(encryptedKeys.keys);
|
|
105
|
-
const resolved = await resolveTokenPolicy(account,
|
|
107
|
+
const resolved = await resolveTokenPolicy(account, {
|
|
108
|
+
permissions: options.permissions,
|
|
109
|
+
resource: flags.resource,
|
|
110
|
+
});
|
|
106
111
|
if (!resolved.ok) {
|
|
107
112
|
console.error(`standards creds: ${resolved.problem}`);
|
|
108
113
|
return false;
|
|
@@ -123,7 +128,7 @@ export const runCredsAddCloudflare = async (
|
|
|
123
128
|
name,
|
|
124
129
|
expiresOn,
|
|
125
130
|
condition: null,
|
|
126
|
-
policies:
|
|
131
|
+
policies: resolved.policies,
|
|
127
132
|
});
|
|
128
133
|
if (!created.ok) {
|
|
129
134
|
console.error(`standards creds: ${created.problem}`);
|
|
@@ -157,7 +162,11 @@ export const runCredsAddCloudflare = async (
|
|
|
157
162
|
destination: `${context.rel} at ${context.dest.key}`,
|
|
158
163
|
format,
|
|
159
164
|
accountId: account.accountId,
|
|
160
|
-
jurisdiction:
|
|
165
|
+
jurisdiction:
|
|
166
|
+
flags.resource.kind === 'bucket'
|
|
167
|
+
? flags.resource.jurisdiction
|
|
168
|
+
: DEFAULT_R2_JURISDICTION,
|
|
169
|
+
policies: resolved.policies,
|
|
161
170
|
});
|
|
162
171
|
return true;
|
|
163
172
|
};
|
package/src/creds-args.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type CredsFlags = {
|
|
|
17
17
|
account: string | undefined;
|
|
18
18
|
ttlDays: number | undefined;
|
|
19
19
|
bucket: string | undefined;
|
|
20
|
+
zone: string | undefined;
|
|
20
21
|
jurisdiction: R2Jurisdiction;
|
|
21
22
|
s3: boolean;
|
|
22
23
|
org: string | undefined;
|
|
@@ -51,6 +52,7 @@ export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
|
|
|
51
52
|
account: undefined,
|
|
52
53
|
ttlDays: undefined,
|
|
53
54
|
bucket: undefined,
|
|
55
|
+
zone: undefined,
|
|
54
56
|
jurisdiction: DEFAULT_R2_JURISDICTION,
|
|
55
57
|
s3: false,
|
|
56
58
|
org: undefined,
|
|
@@ -76,6 +78,9 @@ export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
|
|
|
76
78
|
'--bucket': (value) => {
|
|
77
79
|
flags.bucket = value;
|
|
78
80
|
},
|
|
81
|
+
'--zone': (value) => {
|
|
82
|
+
flags.zone = value;
|
|
83
|
+
},
|
|
79
84
|
'--jurisdiction': (value) => {
|
|
80
85
|
if (!isR2Jurisdiction(value)) {
|
|
81
86
|
throw new Error('--jurisdiction must be default or eu');
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Zone resources for `standards creds add cloudflare`. Zones are named by ID,
|
|
2
|
+
// never by domain name: resolving a name would need Zone Read on the bootstrap
|
|
3
|
+
// token, which holds Account API Tokens / Edit and nothing else, so the lookup
|
|
4
|
+
// would fail — or, worse, come back empty and read as a missing zone. A zone ID
|
|
5
|
+
// is not a secret and is shown on the zone's dashboard overview.
|
|
6
|
+
|
|
7
|
+
export const ZONE_SCOPE = 'com.cloudflare.api.account.zone';
|
|
8
|
+
|
|
9
|
+
const ZONE_ID_PATTERN = /^[0-9a-f]{32}$/u;
|
|
10
|
+
|
|
11
|
+
export const zoneResource = (zoneId: string): string =>
|
|
12
|
+
`${ZONE_SCOPE}.${zoneId}`;
|
|
13
|
+
|
|
14
|
+
export type ParsedZoneArgument =
|
|
15
|
+
| {
|
|
16
|
+
readonly ok: true;
|
|
17
|
+
readonly zoneIds: readonly [string, ...ReadonlyArray<string>];
|
|
18
|
+
}
|
|
19
|
+
| { readonly ok: false; readonly problem: string };
|
|
20
|
+
|
|
21
|
+
export const parseZoneArgument = (value: string): ParsedZoneArgument => {
|
|
22
|
+
const [first, ...rest] = value
|
|
23
|
+
.split(',')
|
|
24
|
+
.map((zone) => zone.trim())
|
|
25
|
+
.filter((zone) => zone.length > 0);
|
|
26
|
+
if (first === undefined) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
problem: '--zone requires at least one zone ID',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const entries = [first, ...rest] as const;
|
|
33
|
+
const invalid = entries.filter((zone) => !ZONE_ID_PATTERN.test(zone));
|
|
34
|
+
return invalid.length === 0
|
|
35
|
+
? { ok: true, zoneIds: entries }
|
|
36
|
+
: {
|
|
37
|
+
ok: false,
|
|
38
|
+
problem: `not a zone ID: ${invalid.join(', ')}; --zone takes the 32-character hexadecimal zone ID shown on the zone's dashboard overview, not its domain name`,
|
|
39
|
+
};
|
|
40
|
+
};
|
package/src/creds-commands.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { runCredsAddCloudflare } from './creds-add';
|
|
|
2
2
|
import { runCredsAddGithub } from './creds-add-github';
|
|
3
3
|
import { parseCredsArgs } from './creds-args';
|
|
4
4
|
import { listPermissionGroups } from './creds-cloudflare';
|
|
5
|
+
import { loadOwnedGithubStore } from './creds-github-apps';
|
|
5
6
|
import { runCredsLoginCloudflare } from './creds-login-cloudflare';
|
|
6
7
|
import { runCredsLoginGithub } from './creds-login-github';
|
|
7
8
|
import { BROKER_IDENTITY_NAME } from './creds-naming';
|
|
@@ -15,10 +16,10 @@ import {
|
|
|
15
16
|
const CREDS_USAGE = `Usage: standards creds <command> [options]
|
|
16
17
|
|
|
17
18
|
Commands:
|
|
18
|
-
login github Create
|
|
19
|
+
login github Create or replace one account's broker GitHub App via the manifest flow
|
|
19
20
|
login cloudflare Store a Cloudflare account's bootstrap token (one guided paste per account)
|
|
20
21
|
add cloudflare Mint a scoped, expiring account token and write it into a SOPS target
|
|
21
|
-
add github
|
|
22
|
+
add github Select the repository owner's App, verify its installation, and write it into SOPS
|
|
22
23
|
plan Show revocations and rotations reconciling SOPS keys against brokered tokens
|
|
23
24
|
apply Execute the plan: revoke orphaned tokens, roll expiring ones into SOPS
|
|
24
25
|
permissions List Cloudflare permission group names for --permissions
|
|
@@ -31,15 +32,21 @@ Options:
|
|
|
31
32
|
--account <id> Cloudflare account when more than one is configured
|
|
32
33
|
--ttl-days <n> Token lifetime in days (default: 90)
|
|
33
34
|
--bucket <name> Scope a Cloudflare token to one R2 bucket
|
|
35
|
+
--zone <zone-id> Add a zone resource for zone-scoped groups (comma-separated IDs)
|
|
34
36
|
--jurisdiction <name> R2 jurisdiction: default or eu (default: default)
|
|
35
37
|
--s3 Store the derived R2 S3 credential pair (<key>.access_key_id, <key>.secret_access_key) instead of the raw token
|
|
36
38
|
--org <org> Create the GitHub App under an organization
|
|
37
|
-
--name <name> GitHub App name (default: ${BROKER_IDENTITY_NAME})
|
|
39
|
+
--name <name> GitHub App name (default: ${BROKER_IDENTITY_NAME}, suffixed by --org)
|
|
38
40
|
|
|
39
41
|
Secret values are written directly into SOPS-encrypted targets and never printed.`;
|
|
40
42
|
const runCredsStatus = async (): Promise<boolean> => {
|
|
41
43
|
const path = resolveBrokerPath();
|
|
42
|
-
const
|
|
44
|
+
const loaded = await loadOwnedGithubStore(path);
|
|
45
|
+
if (!loaded.ok) {
|
|
46
|
+
console.error(`standards creds: ${loaded.problem}`);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const { value: store } = loaded;
|
|
43
50
|
console.log(`broker store: ${path}`);
|
|
44
51
|
const mode = inspectBrokerFileMode(path);
|
|
45
52
|
if (!mode.exists) {
|
|
@@ -47,11 +54,16 @@ const runCredsStatus = async (): Promise<boolean> => {
|
|
|
47
54
|
} else if (mode.problem !== null) {
|
|
48
55
|
console.log(` WARNING: ${mode.problem}`);
|
|
49
56
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
if (store.github.length === 0) {
|
|
58
|
+
console.log('github: not configured (`standards creds login github`)');
|
|
59
|
+
} else {
|
|
60
|
+
console.log('github:');
|
|
61
|
+
for (const app of store.github) {
|
|
62
|
+
console.log(
|
|
63
|
+
` ${app.owner}: App ${app.slug} (id ${app.appId}) — ${app.htmlUrl}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
55
67
|
console.log(
|
|
56
68
|
store.cloudflare.length === 0
|
|
57
69
|
? 'cloudflare: not configured (`standards creds login cloudflare`)'
|