@davidvornholt/standards 0.19.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 +11 -1
- package/src/cli.ts +1 -1
- 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/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,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`)'
|
|
@@ -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 }
|