@davidvornholt/standards 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/package.json +19 -1
- package/src/cli.ts +2 -2
- package/src/creds-add-cloudflare-commit.ts +2 -5
- package/src/creds-add-github.ts +82 -30
- package/src/creds-add-policy.ts +106 -26
- package/src/creds-add-preflight.ts +68 -0
- package/src/creds-add.ts +29 -20
- package/src/creds-args.ts +14 -0
- package/src/creds-cloudflare-account.ts +2 -2
- package/src/creds-cloudflare-id.ts +10 -0
- package/src/creds-cloudflare-zone.ts +40 -0
- package/src/creds-commands.ts +34 -11
- package/src/creds-github-app-api.ts +132 -0
- package/src/creds-github-apps.ts +107 -0
- package/src/creds-login-github-manifest.ts +38 -15
- package/src/creds-login-github.ts +78 -24
- package/src/creds-naming.ts +13 -0
- package/src/creds-plan-renew.ts +2 -5
- package/src/creds-plan-report.ts +87 -0
- package/src/creds-plan-run.ts +2 -11
- package/src/creds-plan-types.ts +19 -0
- package/src/creds-plan-unmanaged.ts +102 -0
- package/src/creds-plan.ts +25 -21
- package/src/creds-revoke.ts +162 -0
- package/src/creds-sops-value.ts +89 -0
- package/src/creds-sops.ts +0 -53
- package/src/creds-store-github.ts +121 -0
- package/src/creds-store-lock-holder.ts +105 -0
- package/src/creds-store-lock-inspection.ts +125 -0
- package/src/creds-store-lock-lease.ts +32 -0
- package/src/creds-store-lock.ts +28 -51
- package/src/creds-store.ts +14 -43
- package/src/dev-env-compose.ts +120 -0
- package/src/dev-env-destination-duplicates.ts +10 -10
- package/src/dev-env-destination-gitignore.ts +22 -0
- package/src/dev-env-destination.ts +52 -30
- package/src/dev-env-document.ts +19 -6
- package/src/dev-env-dotenv.ts +12 -3
- package/src/dev-env-reconciliation.ts +170 -0
- package/src/dev-env-secrets.ts +41 -0
- package/src/dev-env-transaction-recovery.ts +74 -11
- package/src/dev-env-transaction.ts +61 -15
- package/src/dev-env.ts +115 -63
- package/src/github-command-shared.ts +8 -1
- package/src/github-live-drift.ts +3 -3
- package/src/poller-closing-reference.ts +179 -0
- package/src/poller-fix-outcome.ts +9 -0
- package/src/poller-fix-publication.ts +5 -12
- package/src/poller-fix-run.ts +2 -3
- package/src/poller-fix-validation.ts +7 -8
- package/src/poller-outcome.ts +4 -1
- package/src/poller-protected-paths.ts +1 -25
- package/src/poller-protocol.ts +0 -42
- package/src/poller-review-publication.ts +2 -8
- package/src/poller-review-validation.ts +5 -12
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,10 +17,13 @@ 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;
|
|
23
|
+
force: boolean;
|
|
22
24
|
org: string | undefined;
|
|
23
25
|
name: string | undefined;
|
|
26
|
+
tokenId: string | undefined;
|
|
24
27
|
readonly words: Array<string>;
|
|
25
28
|
};
|
|
26
29
|
|
|
@@ -51,10 +54,13 @@ export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
|
|
|
51
54
|
account: undefined,
|
|
52
55
|
ttlDays: undefined,
|
|
53
56
|
bucket: undefined,
|
|
57
|
+
zone: undefined,
|
|
54
58
|
jurisdiction: DEFAULT_R2_JURISDICTION,
|
|
55
59
|
s3: false,
|
|
60
|
+
force: false,
|
|
56
61
|
org: undefined,
|
|
57
62
|
name: undefined,
|
|
63
|
+
tokenId: undefined,
|
|
58
64
|
words: [],
|
|
59
65
|
};
|
|
60
66
|
const setters: Readonly<Record<string, (value: string) => void>> = {
|
|
@@ -76,6 +82,9 @@ export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
|
|
|
76
82
|
'--bucket': (value) => {
|
|
77
83
|
flags.bucket = value;
|
|
78
84
|
},
|
|
85
|
+
'--zone': (value) => {
|
|
86
|
+
flags.zone = value;
|
|
87
|
+
},
|
|
79
88
|
'--jurisdiction': (value) => {
|
|
80
89
|
if (!isR2Jurisdiction(value)) {
|
|
81
90
|
throw new Error('--jurisdiction must be default or eu');
|
|
@@ -88,12 +97,17 @@ export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
|
|
|
88
97
|
'--name': (value) => {
|
|
89
98
|
flags.name = value;
|
|
90
99
|
},
|
|
100
|
+
'--token-id': (value) => {
|
|
101
|
+
flags.tokenId = value;
|
|
102
|
+
},
|
|
91
103
|
};
|
|
92
104
|
for (let index = 0; index < argv.length; index += 1) {
|
|
93
105
|
const arg = argv[index] ?? '';
|
|
94
106
|
const setter = setters[arg];
|
|
95
107
|
if (arg === '--s3') {
|
|
96
108
|
flags.s3 = true;
|
|
109
|
+
} else if (arg === '--force') {
|
|
110
|
+
flags.force = true;
|
|
97
111
|
} else if (setter !== undefined) {
|
|
98
112
|
setter(flagValue(argv, index));
|
|
99
113
|
index += 1;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { isCloudflareId } from './creds-cloudflare-id';
|
|
2
2
|
|
|
3
3
|
export const isCloudflareAccountId = (value: unknown): value is string =>
|
|
4
|
-
typeof value === 'string' &&
|
|
4
|
+
typeof value === 'string' && isCloudflareId(value);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Cloudflare identifies accounts, zones, and API tokens by the same
|
|
2
|
+
// 32-character lowercase hexadecimal shape, and this module owns that shape for
|
|
3
|
+
// all three. Checking it before a request turns a typo into a precise message
|
|
4
|
+
// instead of a provider 404, which reads the same as a resource that exists but
|
|
5
|
+
// is out of reach.
|
|
6
|
+
|
|
7
|
+
const CLOUDFLARE_ID = /^[0-9a-f]{32}$/u;
|
|
8
|
+
|
|
9
|
+
export const isCloudflareId = (value: string): boolean =>
|
|
10
|
+
CLOUDFLARE_ID.test(value);
|
|
@@ -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
|
+
import { isCloudflareId } from './creds-cloudflare-id';
|
|
8
|
+
|
|
9
|
+
export const ZONE_SCOPE = 'com.cloudflare.api.account.zone';
|
|
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) => !isCloudflareId(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,10 +2,12 @@ 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';
|
|
8
9
|
import { runCredsPlan } from './creds-plan-run';
|
|
10
|
+
import { runCredsRevoke } from './creds-revoke';
|
|
9
11
|
import {
|
|
10
12
|
inspectBrokerFileMode,
|
|
11
13
|
readBrokerStore,
|
|
@@ -15,12 +17,13 @@ import {
|
|
|
15
17
|
const CREDS_USAGE = `Usage: standards creds <command> [options]
|
|
16
18
|
|
|
17
19
|
Commands:
|
|
18
|
-
login github Create
|
|
20
|
+
login github Create or replace one account's broker GitHub App via the manifest flow
|
|
19
21
|
login cloudflare Store a Cloudflare account's bootstrap token (one guided paste per account)
|
|
20
22
|
add cloudflare Mint a scoped, expiring account token and write it into a SOPS target
|
|
21
|
-
add github
|
|
22
|
-
plan
|
|
23
|
-
apply Execute the plan: revoke
|
|
23
|
+
add github Select the repository owner's App, verify its installation, and write it into SOPS
|
|
24
|
+
plan Reconcile SOPS keys against brokered tokens: show revocations, rotations, and the live account tokens nothing reconciles here
|
|
25
|
+
apply Execute the plan: revoke brokered tokens whose SOPS key is gone, roll expiring ones into SOPS
|
|
26
|
+
revoke Delete one Cloudflare token named by --token-id; bootstrap credentials and brokered tokens are refused
|
|
24
27
|
permissions List Cloudflare permission group names for --permissions
|
|
25
28
|
status Show the broker store location and configured providers
|
|
26
29
|
|
|
@@ -31,15 +34,23 @@ Options:
|
|
|
31
34
|
--account <id> Cloudflare account when more than one is configured
|
|
32
35
|
--ttl-days <n> Token lifetime in days (default: 90)
|
|
33
36
|
--bucket <name> Scope a Cloudflare token to one R2 bucket
|
|
37
|
+
--zone <zone-id> Add a zone resource for zone-scoped groups (comma-separated IDs)
|
|
34
38
|
--jurisdiction <name> R2 jurisdiction: default or eu (default: default)
|
|
35
39
|
--s3 Store the derived R2 S3 credential pair (<key>.access_key_id, <key>.secret_access_key) instead of the raw token
|
|
40
|
+
--token-id <id> Cloudflare token to revoke (32-character hexadecimal ID)
|
|
41
|
+
--force Let revoke delete a token brokered to a repository that is not this checkout's. It verifies nothing about that repository, so use it only when nothing reconciles the token any more: that repository was renamed, transferred, or deleted, so no checkout resolves to its name and no \`standards creds apply\` will ever revoke the token. It never permits revoking a broker bootstrap credential, a token brokered to this repository, a token whose brokered repository differs from this checkout's only in capitalisation, or any brokered token when this checkout's origin remote resolves to no GitHub repository, because ownership is then the very thing that could not be checked
|
|
36
42
|
--org <org> Create the GitHub App under an organization
|
|
37
|
-
--name <name> GitHub App name (default: ${BROKER_IDENTITY_NAME})
|
|
43
|
+
--name <name> GitHub App name (default: ${BROKER_IDENTITY_NAME}, suffixed by --org)
|
|
38
44
|
|
|
39
45
|
Secret values are written directly into SOPS-encrypted targets and never printed.`;
|
|
40
46
|
const runCredsStatus = async (): Promise<boolean> => {
|
|
41
47
|
const path = resolveBrokerPath();
|
|
42
|
-
const
|
|
48
|
+
const loaded = await loadOwnedGithubStore(path);
|
|
49
|
+
if (!loaded.ok) {
|
|
50
|
+
console.error(`standards creds: ${loaded.problem}`);
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const { value: store } = loaded;
|
|
43
54
|
console.log(`broker store: ${path}`);
|
|
44
55
|
const mode = inspectBrokerFileMode(path);
|
|
45
56
|
if (!mode.exists) {
|
|
@@ -47,11 +58,16 @@ const runCredsStatus = async (): Promise<boolean> => {
|
|
|
47
58
|
} else if (mode.problem !== null) {
|
|
48
59
|
console.log(` WARNING: ${mode.problem}`);
|
|
49
60
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
if (store.github.length === 0) {
|
|
62
|
+
console.log('github: not configured (`standards creds login github`)');
|
|
63
|
+
} else {
|
|
64
|
+
console.log('github:');
|
|
65
|
+
for (const app of store.github) {
|
|
66
|
+
console.log(
|
|
67
|
+
` ${app.owner}: App ${app.slug} (id ${app.appId}) — ${app.htmlUrl}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
55
71
|
console.log(
|
|
56
72
|
store.cloudflare.length === 0
|
|
57
73
|
? 'cloudflare: not configured (`standards creds login cloudflare`)'
|
|
@@ -105,6 +121,13 @@ export const runCredsCommand = (
|
|
|
105
121
|
'add github': () => runCredsAddGithub(flags.dir, flags),
|
|
106
122
|
plan: () => runCredsPlan(flags.dir, false),
|
|
107
123
|
apply: () => runCredsPlan(flags.dir, true),
|
|
124
|
+
revoke: () =>
|
|
125
|
+
runCredsRevoke({
|
|
126
|
+
account: flags.account,
|
|
127
|
+
tokenId: flags.tokenId,
|
|
128
|
+
dir: flags.dir,
|
|
129
|
+
force: flags.force,
|
|
130
|
+
}),
|
|
108
131
|
permissions: () => runCredsPermissions(flags.account),
|
|
109
132
|
status: () => runCredsStatus(),
|
|
110
133
|
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createSign } from 'node:crypto';
|
|
2
|
+
import type { GithubBrokerApp } from './creds-store';
|
|
3
|
+
import { type ApiResponse, apiError, HTTP_OK, request } from './github-api';
|
|
4
|
+
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
5
|
+
|
|
6
|
+
const JWT_CLOCK_SKEW_SECONDS = 60;
|
|
7
|
+
const SECONDS_PER_MINUTE = 60;
|
|
8
|
+
const JWT_LIFETIME_MINUTES = 9;
|
|
9
|
+
const JWT_LIFETIME_SECONDS = JWT_LIFETIME_MINUTES * SECONDS_PER_MINUTE;
|
|
10
|
+
const MILLISECONDS_PER_SECOND = 1000;
|
|
11
|
+
const HTTP_NOT_FOUND = 404;
|
|
12
|
+
|
|
13
|
+
type GithubAppResult<T> =
|
|
14
|
+
| { readonly ok: true; readonly value: T }
|
|
15
|
+
| { readonly ok: false; readonly problem: string };
|
|
16
|
+
|
|
17
|
+
export const createGithubAppJwt = (
|
|
18
|
+
app: GithubBrokerApp,
|
|
19
|
+
now = Date.now(),
|
|
20
|
+
): string => {
|
|
21
|
+
const seconds = Math.floor(now / MILLISECONDS_PER_SECOND);
|
|
22
|
+
const header = Buffer.from(
|
|
23
|
+
JSON.stringify({ alg: 'RS256', typ: 'JWT' }),
|
|
24
|
+
).toString('base64url');
|
|
25
|
+
const payload = Buffer.from(
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
iat: seconds - JWT_CLOCK_SKEW_SECONDS,
|
|
28
|
+
exp: seconds + JWT_LIFETIME_SECONDS,
|
|
29
|
+
iss: app.clientId,
|
|
30
|
+
}),
|
|
31
|
+
).toString('base64url');
|
|
32
|
+
const unsigned = `${header}.${payload}`;
|
|
33
|
+
const signature = createSign('RSA-SHA256')
|
|
34
|
+
.update(unsigned)
|
|
35
|
+
.sign(app.privateKey, 'base64url');
|
|
36
|
+
return `${unsigned}.${signature}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const appRequest = async (
|
|
40
|
+
app: GithubBrokerApp,
|
|
41
|
+
path: string,
|
|
42
|
+
): Promise<GithubAppResult<ApiResponse>> => {
|
|
43
|
+
try {
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
value: await request(createGithubAppJwt(app), 'GET', path),
|
|
47
|
+
};
|
|
48
|
+
} catch (error) {
|
|
49
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
problem: `cannot authenticate as App ${app.slug}: ${message}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const resolveGithubAppOwner = async (
|
|
58
|
+
app: GithubBrokerApp,
|
|
59
|
+
): Promise<GithubAppResult<string>> => {
|
|
60
|
+
const result = await appRequest(app, '/app');
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
if (result.value.status !== HTTP_OK) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
problem: apiError(`resolve owner for App ${app.slug}`, result.value),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const { body } = result.value;
|
|
71
|
+
if (
|
|
72
|
+
!isRecord(body) ||
|
|
73
|
+
body.id !== app.appId ||
|
|
74
|
+
!isRecord(body.owner) ||
|
|
75
|
+
!isNonEmptyString(body.owner.login)
|
|
76
|
+
) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
problem: `resolve owner for App ${app.slug}: unexpected response shape or App identity`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, value: body.owner.login };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const verifyGithubAppInstallation = async (
|
|
86
|
+
app: GithubBrokerApp,
|
|
87
|
+
repo: string,
|
|
88
|
+
): Promise<GithubAppResult<true>> => {
|
|
89
|
+
const [owner, name, ...rest] = repo.split('/');
|
|
90
|
+
if (
|
|
91
|
+
owner === undefined ||
|
|
92
|
+
name === undefined ||
|
|
93
|
+
owner.length === 0 ||
|
|
94
|
+
name.length === 0 ||
|
|
95
|
+
rest.length > 0
|
|
96
|
+
) {
|
|
97
|
+
return { ok: false, problem: `invalid GitHub repository: ${repo}` };
|
|
98
|
+
}
|
|
99
|
+
const result = await appRequest(
|
|
100
|
+
app,
|
|
101
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/installation`,
|
|
102
|
+
);
|
|
103
|
+
if (!result.ok) {
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
if (result.value.status === HTTP_NOT_FOUND) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
problem: `App ${app.slug} for ${owner} is not installed on ${repo}; install it for that selected repository at ${app.htmlUrl}/installations/new`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (result.value.status !== HTTP_OK) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
problem: apiError(`verify App installation on ${repo}`, result.value),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const { body } = result.value;
|
|
119
|
+
if (
|
|
120
|
+
!isRecord(body) ||
|
|
121
|
+
body.app_id !== app.appId ||
|
|
122
|
+
!isRecord(body.account) ||
|
|
123
|
+
!isNonEmptyString(body.account.login) ||
|
|
124
|
+
body.account.login.toLowerCase() !== owner.toLowerCase()
|
|
125
|
+
) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
problem: `verify App installation on ${repo}: unexpected App or account identity`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return { ok: true, value: true };
|
|
132
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { resolveGithubAppOwner } from './creds-github-app-api';
|
|
2
|
+
import {
|
|
3
|
+
type BrokerStore,
|
|
4
|
+
type GithubBrokerApp,
|
|
5
|
+
readBrokerStore,
|
|
6
|
+
updateBrokerStore,
|
|
7
|
+
} from './creds-store';
|
|
8
|
+
|
|
9
|
+
type GithubAppsResult<T> =
|
|
10
|
+
| { readonly ok: true; readonly value: T }
|
|
11
|
+
| { readonly ok: false; readonly problem: string };
|
|
12
|
+
|
|
13
|
+
export const sameGithubApp = (
|
|
14
|
+
left: GithubBrokerApp,
|
|
15
|
+
right: GithubBrokerApp,
|
|
16
|
+
): boolean =>
|
|
17
|
+
left.owner?.toLowerCase() === right.owner?.toLowerCase() &&
|
|
18
|
+
left.appId === right.appId &&
|
|
19
|
+
left.slug === right.slug &&
|
|
20
|
+
left.htmlUrl === right.htmlUrl &&
|
|
21
|
+
left.clientId === right.clientId &&
|
|
22
|
+
left.privateKey === right.privateKey;
|
|
23
|
+
|
|
24
|
+
export const loadOwnedGithubStore = async (
|
|
25
|
+
path: string,
|
|
26
|
+
lookupOwner: (
|
|
27
|
+
app: GithubBrokerApp,
|
|
28
|
+
) => Promise<GithubAppsResult<string>> = resolveGithubAppOwner,
|
|
29
|
+
): Promise<GithubAppsResult<BrokerStore>> => {
|
|
30
|
+
const initial = await readBrokerStore(path);
|
|
31
|
+
const legacy = initial.github.find((app) => app.owner === null);
|
|
32
|
+
if (legacy === undefined) {
|
|
33
|
+
return { ok: true, value: initial };
|
|
34
|
+
}
|
|
35
|
+
const owner = await lookupOwner(legacy);
|
|
36
|
+
if (!owner.ok) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
problem: `cannot migrate the legacy singleton GitHub App in ${path}: ${owner.problem}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
await updateBrokerStore(path, (current) => ({
|
|
43
|
+
...current,
|
|
44
|
+
github: current.github.map((app) =>
|
|
45
|
+
app.owner === null && sameGithubApp(app, legacy)
|
|
46
|
+
? { ...app, owner: owner.value }
|
|
47
|
+
: app,
|
|
48
|
+
),
|
|
49
|
+
}));
|
|
50
|
+
const migrated = await readBrokerStore(path);
|
|
51
|
+
if (migrated.github.some((app) => app.owner === null)) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
problem: `the GitHub App in ${path} changed while its legacy owner was being migrated; retry`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, value: migrated };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const selectGithubAppForRepo = (
|
|
61
|
+
apps: ReadonlyArray<GithubBrokerApp>,
|
|
62
|
+
repo: string,
|
|
63
|
+
): GithubAppsResult<GithubBrokerApp> => {
|
|
64
|
+
const owner = repo.split('/')[0] ?? '';
|
|
65
|
+
const matches = apps.filter(
|
|
66
|
+
(app) => app.owner?.toLowerCase() === owner.toLowerCase(),
|
|
67
|
+
);
|
|
68
|
+
if (matches.length === 1 && matches[0] !== undefined) {
|
|
69
|
+
return { ok: true, value: matches[0] };
|
|
70
|
+
}
|
|
71
|
+
if (matches.length > 1) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
problem: `multiple broker GitHub Apps are configured for repository owner ${owner}; keep exactly one entry for that owner`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const loginHint =
|
|
78
|
+
apps.length === 0
|
|
79
|
+
? 'no broker GitHub Apps are configured'
|
|
80
|
+
: `configured owners: ${apps.map((app) => app.owner).join(', ')}`;
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
problem: `no broker GitHub App is configured for repository owner ${owner} (${loginHint}); run \`standards creds login github --org ${owner}\` for an organization, or run it without --org while signed in as ${owner}`,
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const upsertGithubApp = (
|
|
88
|
+
apps: ReadonlyArray<GithubBrokerApp>,
|
|
89
|
+
app: GithubBrokerApp,
|
|
90
|
+
): {
|
|
91
|
+
readonly apps: ReadonlyArray<GithubBrokerApp>;
|
|
92
|
+
readonly replaced: GithubBrokerApp | null;
|
|
93
|
+
} => {
|
|
94
|
+
if (app.owner === null || apps.some((entry) => entry.owner === null)) {
|
|
95
|
+
throw new Error('legacy GitHub Apps must be migrated before login');
|
|
96
|
+
}
|
|
97
|
+
const index = apps.findIndex(
|
|
98
|
+
(entry) => entry.owner?.toLowerCase() === app.owner?.toLowerCase(),
|
|
99
|
+
);
|
|
100
|
+
if (index === -1) {
|
|
101
|
+
return { apps: [...apps, app], replaced: null };
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
apps: apps.map((entry, entryIndex) => (entryIndex === index ? app : entry)),
|
|
105
|
+
replaced: apps[index] ?? null,
|
|
106
|
+
};
|
|
107
|
+
};
|
|
@@ -4,11 +4,35 @@
|
|
|
4
4
|
// creds-login-github.ts.
|
|
5
5
|
|
|
6
6
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import { BROKER_IDENTITY_NAME } from './creds-naming';
|
|
7
8
|
import type { GithubBrokerApp } from './creds-store';
|
|
9
|
+
import { isGithubBrokerApp } from './creds-store-github';
|
|
8
10
|
import { apiError, HTTP_CREATED, request } from './github-api';
|
|
9
11
|
import { isRecord } from './github-settings-parse';
|
|
10
12
|
|
|
11
13
|
export const MANIFEST_STATE_BYTES = 32;
|
|
14
|
+
const GITHUB_APP_NAME_MAX_LENGTH = 34;
|
|
15
|
+
|
|
16
|
+
type GithubAppNameResult =
|
|
17
|
+
| { readonly ok: true; readonly value: string }
|
|
18
|
+
| { readonly ok: false; readonly problem: string };
|
|
19
|
+
|
|
20
|
+
export const resolveGithubAppName = (
|
|
21
|
+
requested: string | undefined,
|
|
22
|
+
org: string | undefined,
|
|
23
|
+
): GithubAppNameResult => {
|
|
24
|
+
const name =
|
|
25
|
+
requested ??
|
|
26
|
+
(org === undefined
|
|
27
|
+
? BROKER_IDENTITY_NAME
|
|
28
|
+
: `${BROKER_IDENTITY_NAME}-${org}`);
|
|
29
|
+
return name.length <= GITHUB_APP_NAME_MAX_LENGTH
|
|
30
|
+
? { ok: true, value: name }
|
|
31
|
+
: {
|
|
32
|
+
ok: false,
|
|
33
|
+
problem: `GitHub App names may contain at most ${GITHUB_APP_NAME_MAX_LENGTH} characters; pass --name with a shorter globally unique name`,
|
|
34
|
+
};
|
|
35
|
+
};
|
|
12
36
|
|
|
13
37
|
// Root-credential ceiling: everything the broker may ever delegate. The
|
|
14
38
|
// `permission-*` inputs a workflow passes when minting are a request, not a
|
|
@@ -70,21 +94,20 @@ export const manifestFormHtml = (
|
|
|
70
94
|
): string =>
|
|
71
95
|
`<!doctype html><html><body><form id="m" action="${escapeAttribute(`${action}?state=${encodeURIComponent(state)}`)}" method="post"><input type="hidden" name="manifest" value="${escapeAttribute(manifest)}"><noscript><button type="submit">Create GitHub App</button></noscript></form><script>document.getElementById("m").submit()</script></body></html>`;
|
|
72
96
|
|
|
73
|
-
export const parseConversion = (body: unknown): GithubBrokerApp | null =>
|
|
74
|
-
isRecord(body) &&
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
: null;
|
|
97
|
+
export const parseConversion = (body: unknown): GithubBrokerApp | null => {
|
|
98
|
+
if (!(isRecord(body) && isRecord(body.owner))) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const app = {
|
|
102
|
+
owner: body.owner.login,
|
|
103
|
+
appId: body.id,
|
|
104
|
+
slug: body.slug,
|
|
105
|
+
htmlUrl: body.html_url,
|
|
106
|
+
clientId: body.client_id,
|
|
107
|
+
privateKey: body.pem,
|
|
108
|
+
};
|
|
109
|
+
return isGithubBrokerApp(app) && app.owner !== null ? app : null;
|
|
110
|
+
};
|
|
88
111
|
|
|
89
112
|
export type ManifestConversion =
|
|
90
113
|
| { readonly ok: true; readonly app: GithubBrokerApp }
|