@lanes-sh/link 0.6.2 → 0.6.4
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/.dockerignore +45 -0
- package/README.md +15 -0
- package/bunfig.toml +17 -0
- package/package.json +4 -2
- package/src/cli/commands/connect/bind-credential.ts +48 -0
- package/src/cli/commands/connect/index.ts +5 -0
- package/src/cli/commands/operate/inspect.ts +32 -0
- package/src/deployments/bind.ts +214 -0
- package/src/deployments/gcp/Dockerfile +24 -2
- package/src/deployments/gcp/provision.ts +118 -49
package/.dockerignore
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# The build context for src/deployments/gcp/Dockerfile is this directory.
|
|
2
|
+
#
|
|
3
|
+
# The first block is a security control, not an image-size optimisation. `data/`
|
|
4
|
+
# holds the encrypted credential store *and* the key that opens it, and `*.key`
|
|
5
|
+
# catches the same file wherever else it lands. A credential baked into an image
|
|
6
|
+
# is pushed to a registry, cached on every builder that touched it, and readable
|
|
7
|
+
# by anyone who can pull the tag — and the deployed target does not want them
|
|
8
|
+
# anyway, because it reads credentials from Secret Manager.
|
|
9
|
+
data/
|
|
10
|
+
*.key
|
|
11
|
+
*.pem
|
|
12
|
+
*.enc
|
|
13
|
+
*.p12
|
|
14
|
+
.env
|
|
15
|
+
.env.*
|
|
16
|
+
|
|
17
|
+
# Not needed at runtime, and `.git` in particular carries every branch.
|
|
18
|
+
.git/
|
|
19
|
+
.gitignore
|
|
20
|
+
.worktrees/
|
|
21
|
+
node_modules/
|
|
22
|
+
**/node_modules/
|
|
23
|
+
coverage/
|
|
24
|
+
dist/
|
|
25
|
+
build/
|
|
26
|
+
*.tsbuildinfo
|
|
27
|
+
|
|
28
|
+
# Tests, docs, and tooling: the image runs the endpoint and nothing else.
|
|
29
|
+
#
|
|
30
|
+
# `instructions/` is what the CLI installs into a client — a skill and an agent
|
|
31
|
+
# definition. The endpoint describes itself from code (`server/mcp/instructions.ts`)
|
|
32
|
+
# precisely so a deployed revision does not need these files.
|
|
33
|
+
**/*.test.ts
|
|
34
|
+
**/*.test.json
|
|
35
|
+
docs/
|
|
36
|
+
instructions/
|
|
37
|
+
**/README.md
|
|
38
|
+
.lanes/
|
|
39
|
+
.claude/
|
|
40
|
+
.vscode/
|
|
41
|
+
.idea/
|
|
42
|
+
.DS_Store
|
|
43
|
+
|
|
44
|
+
# The compiled binary from `bun build --compile`, if one was made locally.
|
|
45
|
+
/lanes
|
package/README.md
CHANGED
|
@@ -69,6 +69,20 @@ Your agents can now use it. Memory, tasks, files, skills, and the vault hold you
|
|
|
69
69
|
rather than an account, so they are already there — nothing to connect, no credentials, no browser.
|
|
70
70
|
Mail and calendar are the next step. **[Full quickstart →](docs/quickstart.md)**
|
|
71
71
|
|
|
72
|
+
## In the Lanes desktop app
|
|
73
|
+
|
|
74
|
+
Prefer not to use a terminal? The [Lanes desktop app](https://lanes.sh/desktop) drives this CLI from
|
|
75
|
+
a settings page. **Settings → Integrations → Lanes Link** installs it, holds the profile and target
|
|
76
|
+
every command runs against, connects your accounts, starts and stops the endpoint, and registers it
|
|
77
|
+
with Claude Code or Codex.
|
|
78
|
+
|
|
79
|
+

|
|
80
|
+
|
|
81
|
+
It runs the commands above rather than reimplementing them, so consent and the token stay here where
|
|
82
|
+
they belong, and an endpoint set up in the app is the same one you get from a shell. Available from
|
|
83
|
+
Lanes v0.47.0, as a research preview.
|
|
84
|
+
**[How to use it →](https://lanes.sh/docs/desktop/lanes-link)**
|
|
85
|
+
|
|
72
86
|
## What your agent gets
|
|
73
87
|
|
|
74
88
|
| | | Manage it with |
|
|
@@ -166,6 +180,7 @@ than being rebuilt.
|
|
|
166
180
|
## Docs
|
|
167
181
|
|
|
168
182
|
- **[Quickstart](docs/quickstart.md)** — from nothing to a working endpoint
|
|
183
|
+
- **[In the Lanes desktop app](https://lanes.sh/docs/desktop/lanes-link)** — the page that drives it
|
|
169
184
|
- **[Connect your accounts](docs/connect.md)** — every provider, and what each one needs
|
|
170
185
|
- **[Add it to your agent](docs/clients.md)** — Claude Code, Codex, Claude Desktop, claude.ai, ChatGPT
|
|
171
186
|
- **[Deploy to your own cloud](docs/deploy.md)** — five commands to a URL
|
package/bunfig.toml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[install]
|
|
2
|
+
# Supply chain: this repository holds live OAuth refresh tokens, so a
|
|
3
|
+
# dependency compromise is a live threat. The common attack is to publish a
|
|
4
|
+
# compromised version and yank it within hours; a release-age floor keeps a
|
|
5
|
+
# version that young out of the lockfile entirely.
|
|
6
|
+
#
|
|
7
|
+
# 604800 seconds = 7 days.
|
|
8
|
+
#
|
|
9
|
+
# An urgent security fix can be pulled in ahead of the window by installing an
|
|
10
|
+
# exact version explicitly, or by adding the package to minimumReleaseAgeExcludes.
|
|
11
|
+
minimumReleaseAge = 604800
|
|
12
|
+
minimumReleaseAgeExcludes = []
|
|
13
|
+
|
|
14
|
+
# Pin exact versions rather than ranges, so the lockfile is the only thing
|
|
15
|
+
# that decides what gets installed. Bun does not run dependency lifecycle
|
|
16
|
+
# scripts unless a package is listed in trustedDependencies; keep that empty.
|
|
17
|
+
exact = true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lanes-sh/link",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://lanes.sh/link",
|
|
@@ -42,7 +42,9 @@
|
|
|
42
42
|
"!src/**/*.test.ts",
|
|
43
43
|
"instructions",
|
|
44
44
|
"README.md",
|
|
45
|
-
"LICENSE"
|
|
45
|
+
"LICENSE",
|
|
46
|
+
"bunfig.toml",
|
|
47
|
+
".dockerignore"
|
|
46
48
|
],
|
|
47
49
|
"scripts": {
|
|
48
50
|
"test": "bun test",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { bindConnectionCredentials, type BindOutcome } from '#deployments/bind.ts';
|
|
2
|
+
import type { Runtime } from '../../runtime.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Step 7 of `connect`: bind the credential to the revision that will serve it.
|
|
6
|
+
*
|
|
7
|
+
* Split out for the reason every other step in this directory was — `index.ts`
|
|
8
|
+
* holds the order, not the substance, and the file has a size budget
|
|
9
|
+
* (`architecture.test.ts`) that exists to keep it that way.
|
|
10
|
+
*
|
|
11
|
+
* What it is for is in `deployments/bind.ts`. Two decisions belong here.
|
|
12
|
+
*
|
|
13
|
+
* **It is called ahead of `connect`'s early return, not after it.** Re-running
|
|
14
|
+
* `connect` against an existing connection is what an operator does to repair
|
|
15
|
+
* one, and that run reaches the end with no config changes to make. Binding
|
|
16
|
+
* after the return would skip the repair path, leaving the command that looks
|
|
17
|
+
* like the fix doing nothing about the actual fault.
|
|
18
|
+
*
|
|
19
|
+
* **A failure is a note, never a throw.** The credential is in the store and the
|
|
20
|
+
* config is about to be saved by the time this runs, so failing the command
|
|
21
|
+
* would report "connect failed" about a connect that happened — and connecting
|
|
22
|
+
* an account against a deployed target from a machine that has never had
|
|
23
|
+
* `gcloud` installed has to keep working.
|
|
24
|
+
*
|
|
25
|
+
* The third decision is which connection to bind. The declared row is
|
|
26
|
+
* preferred over one assembled from the arguments, because an operator may have
|
|
27
|
+
* written a `credential_ref` into the profile by hand and that field decides
|
|
28
|
+
* where a `local` provider's credential lives — binding the derived ref instead
|
|
29
|
+
* would grant a secret nobody writes and leave the written one unbound, which is
|
|
30
|
+
* the failure `rotatableCredentialRefsFor` documents from the other direction.
|
|
31
|
+
*/
|
|
32
|
+
export function bindNewCredential(
|
|
33
|
+
runtime: Runtime,
|
|
34
|
+
providerId: string,
|
|
35
|
+
connectionId: string,
|
|
36
|
+
account: string,
|
|
37
|
+
): Promise<BindOutcome> {
|
|
38
|
+
const declared = runtime.config.connections.find(
|
|
39
|
+
(candidate) => candidate.provider === providerId && candidate.id === connectionId,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return bindConnectionCredentials({
|
|
43
|
+
deploy: runtime.declared.deploy,
|
|
44
|
+
target: runtime.target,
|
|
45
|
+
connection: declared ?? { provider: providerId, id: connectionId, account },
|
|
46
|
+
manifest: runtime.manifestFor(providerId),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -16,6 +16,7 @@ import { chooseAuthMethod } from './method.ts';
|
|
|
16
16
|
import { preflight } from './requirements.ts';
|
|
17
17
|
import { ALREADY, NOTHING, renderOutcome, where, type ConnectOutcome } from './outcome.ts';
|
|
18
18
|
import { nextAfterEdit, publishRuntimeEdit } from '#cli/publish.ts';
|
|
19
|
+
import { bindNewCredential } from './bind-credential.ts';
|
|
19
20
|
import { ensureStaticCredential } from './setup.ts';
|
|
20
21
|
import { settleIdentity } from './settle.ts';
|
|
21
22
|
import { runStrategySetup } from './strategy.ts';
|
|
@@ -356,6 +357,9 @@ export async function runConnect(
|
|
|
356
357
|
? ['that is your own memory, tasks, assets, skills and vault — no account, nothing stored until you use them']
|
|
357
358
|
: [];
|
|
358
359
|
|
|
360
|
+
// 7. Bind the credential to the revision that serves it — `bind-credential.ts`.
|
|
361
|
+
const bound = await bindNewCredential(runtime, providerId, connectionId, account);
|
|
362
|
+
if (bound.failed) notes.push(bound.failed);
|
|
359
363
|
if (changes.length === 0 && granted.length === 0) {
|
|
360
364
|
return {
|
|
361
365
|
...NOTHING,
|
|
@@ -364,6 +368,7 @@ export async function runConnect(
|
|
|
364
368
|
account,
|
|
365
369
|
label,
|
|
366
370
|
...where(runtime),
|
|
371
|
+
...(notes.length > 0 ? { notes } : {}),
|
|
367
372
|
discovered: discovered.length,
|
|
368
373
|
next: ALREADY,
|
|
369
374
|
};
|
|
@@ -4,6 +4,7 @@ import { announce, announceProfile, emit, fail, ok, print, warn } from '../../ou
|
|
|
4
4
|
import { staleNudge } from '../../release.ts';
|
|
5
5
|
import { openRuntime, resolveProfileOnly, type GlobalFlags, type Runtime } from '../../runtime.ts';
|
|
6
6
|
import type { FetchLike } from '#deployments/knowledge.ts';
|
|
7
|
+
import { unboundRotatableRefs } from '#deployments/bind.ts';
|
|
7
8
|
import { credentialAge, reportCapabilityDrift } from './findings.ts';
|
|
8
9
|
import { migratedContract, migratedRenamedProviders } from './migrate.ts';
|
|
9
10
|
|
|
@@ -276,6 +277,37 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
276
277
|
});
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
// Whether the revision can still rewrite what it serves.
|
|
281
|
+
//
|
|
282
|
+
// A *problem*, not a warning: an unbound credential is a connection that
|
|
283
|
+
// works until its access token expires and then stops, and every other
|
|
284
|
+
// report on this machine calls it healthy in the meantime — `status` and
|
|
285
|
+
// `setup_overview` read the state store, and the state store knows nothing
|
|
286
|
+
// about IAM. This is the only place that asks the thing that decides.
|
|
287
|
+
//
|
|
288
|
+
// Skipped entirely for a local target, where credentials are a file this
|
|
289
|
+
// process owns and there is no revision to grant anything to.
|
|
290
|
+
const rotation = await unboundRotatableRefs({
|
|
291
|
+
deploy: runtime.declared.deploy,
|
|
292
|
+
target: runtime.target,
|
|
293
|
+
connections: runtime.config.connections,
|
|
294
|
+
manifestFor: runtime.manifestFor,
|
|
295
|
+
});
|
|
296
|
+
if (rotation.unbound.length > 0) {
|
|
297
|
+
problems.push({
|
|
298
|
+
kind: 'unbound_credentials',
|
|
299
|
+
message:
|
|
300
|
+
`the deployed endpoint can read ${rotation.unbound.join(', ')} but not rotate ` +
|
|
301
|
+
`${rotation.unbound.length === 1 ? 'it' : 'them'} — so ${rotation.unbound.length === 1 ? 'that connection' : 'those connections'} ` +
|
|
302
|
+
'will fail about an hour after each use, when the token refresh tries to persist. ' +
|
|
303
|
+
'A connection made since the last deploy is the usual cause',
|
|
304
|
+
fix: forSelection('lanes link deploy'),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (rotation.unavailable) {
|
|
308
|
+
warnings.push({ kind: 'rotation_uncheckable', message: rotation.unavailable });
|
|
309
|
+
}
|
|
310
|
+
|
|
279
311
|
if (problems.length > 0) process.exitCode = 1;
|
|
280
312
|
|
|
281
313
|
return emit(flags.json, { ok: problems.length === 0, checks, warnings, problems }, () => {
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { ConnectionConfig, DeployConfig } from '#profile';
|
|
2
|
+
import { credentialRefFor, rotatableCredentialRefsFor } from '#registry';
|
|
3
|
+
import type { SecretRef } from '#secrets';
|
|
4
|
+
import type { DeployDriver } from './driver.ts';
|
|
5
|
+
import { driverFor } from './drivers.ts';
|
|
6
|
+
import { secretGrantSteps } from './gcp/provision.ts';
|
|
7
|
+
import { requireProject } from './gcp/gcloud.ts';
|
|
8
|
+
import { encodeRef } from './adapters/gcp-secret-manager.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Binding one connection's credentials to the revision that will serve them.
|
|
12
|
+
*
|
|
13
|
+
* **The invariant this restores: a revision can rotate every credential it
|
|
14
|
+
* serves.** `provisionSteps` establishes it over the connections the config held
|
|
15
|
+
* *at deploy time*, one secret at a time, which is the right shape — a
|
|
16
|
+
* resource-level grant needs no condition to be scoped. What nothing did was
|
|
17
|
+
* keep it true afterwards. `connect` writes a credential into the same store and
|
|
18
|
+
* binds nothing, so from the next connect until the next deploy the invariant is
|
|
19
|
+
* false and no command says so.
|
|
20
|
+
*
|
|
21
|
+
* The failure it produces is the worst shape available. Read is unaffected, so
|
|
22
|
+
* the connection authorises, answers, and reports `active`; only the *write* on
|
|
23
|
+
* the far side of the first token refresh is denied, roughly an hour later, by
|
|
24
|
+
* which time the connect that caused it is not the recent event. `status` and
|
|
25
|
+
* `setup_overview` both keep saying "connected and reachable" throughout,
|
|
26
|
+
* because both read the state store and the state store knows nothing about IAM.
|
|
27
|
+
*
|
|
28
|
+
* **Every path that writes a credential reaches this one.** `connect custom`
|
|
29
|
+
* delegates to `runConnect` after writing its manifest, and `connectFamily`
|
|
30
|
+
* calls `runConnect` per member, so binding at that one call site covers all
|
|
31
|
+
* three. The reverse direction needs nothing: `disconnect` deletes the whole
|
|
32
|
+
* secret rather than a version, and a secret's IAM policy goes with it, so there
|
|
33
|
+
* is no orphaned binding to revoke.
|
|
34
|
+
*
|
|
35
|
+
* Same steps as the deploy, from the same functions, over one connection's refs.
|
|
36
|
+
* Not a second implementation of the grant: `reconcile.ts` argues that a preview
|
|
37
|
+
* computed differently from the mutation eventually becomes a lie, and the
|
|
38
|
+
* argument is stronger here, because two spellings of a grant do not disagree on
|
|
39
|
+
* screen — they disagree about which permission actually exists.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export interface BindOutcome {
|
|
43
|
+
/** Refs the revision can now read and rotate. Empty is a normal result. */
|
|
44
|
+
readonly bound: readonly SecretRef[];
|
|
45
|
+
/**
|
|
46
|
+
* Why nothing was bound, when nothing was and that is fine: a local target, a
|
|
47
|
+
* deployment with no runtime service account, or a provider holding no
|
|
48
|
+
* credential at all.
|
|
49
|
+
*/
|
|
50
|
+
readonly skipped?: string | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* A binding that could not be applied, as a sentence for the operator.
|
|
53
|
+
*
|
|
54
|
+
* Carried rather than thrown, and that is deliberate. By the time this runs
|
|
55
|
+
* the credential is already in the store and the config is already saved, so
|
|
56
|
+
* failing the command would report "connect failed" about a connect that
|
|
57
|
+
* happened. It also must not require `gcloud` to be installed: someone can
|
|
58
|
+
* legitimately connect an account against a deployed target from a machine
|
|
59
|
+
* that has never deployed one.
|
|
60
|
+
*/
|
|
61
|
+
readonly failed?: string | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const NOTHING_TO_BIND = 'this provider stores no credential';
|
|
65
|
+
|
|
66
|
+
export async function bindConnectionCredentials(input: {
|
|
67
|
+
readonly deploy: DeployConfig | undefined;
|
|
68
|
+
readonly target: string;
|
|
69
|
+
readonly connection: ConnectionConfig;
|
|
70
|
+
/**
|
|
71
|
+
* The provider's manifest, so an omitted `credential_ref` can be derived.
|
|
72
|
+
*
|
|
73
|
+
* Typed off `#registry`'s own signature rather than by importing
|
|
74
|
+
* `ProviderManifest`: `#deployments` may not import `#connectivity`
|
|
75
|
+
* (`architecture.test.ts`), which is the same rule that put `credentialRefFor`
|
|
76
|
+
* in `#registry` in the first place. `prepare.ts` avoids it by never naming
|
|
77
|
+
* the type; this one has to name it, so it derives it.
|
|
78
|
+
*/
|
|
79
|
+
readonly manifest: Parameters<typeof rotatableCredentialRefsFor>[1];
|
|
80
|
+
/** Injectable so a test asserts the argv without a cloud project near it. */
|
|
81
|
+
readonly driver?: DeployDriver | undefined;
|
|
82
|
+
}): Promise<BindOutcome> {
|
|
83
|
+
const { deploy, connection, manifest } = input;
|
|
84
|
+
if (!deploy) return { bound: [], skipped: 'this target runs here, not on a platform' };
|
|
85
|
+
|
|
86
|
+
const cloudrun = requireProject(deploy, input.target);
|
|
87
|
+
const serviceAccount = cloudrun.service_account;
|
|
88
|
+
if (!serviceAccount) {
|
|
89
|
+
return { bound: [], skipped: 'this deployment declares no runtime service account' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Both halves, because a connection made since the last deploy has neither.
|
|
93
|
+
// The read grant is the one an older deployment's project-wide
|
|
94
|
+
// `secretAccessor` happens to cover, which is exactly what made this failure
|
|
95
|
+
// partial and therefore slow to find; a deployment provisioned since that
|
|
96
|
+
// changed has no read on it either.
|
|
97
|
+
const readable = credentialRefFor(connection, manifest);
|
|
98
|
+
const rotatable = rotatableCredentialRefsFor(connection, manifest);
|
|
99
|
+
if (!readable && rotatable.length === 0) return { bound: [], skipped: NOTHING_TO_BIND };
|
|
100
|
+
|
|
101
|
+
const steps = secretGrantSteps({
|
|
102
|
+
project: cloudrun.project,
|
|
103
|
+
serviceAccount,
|
|
104
|
+
readable: readable ? [readable] : [],
|
|
105
|
+
rotatable,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const driver = input.driver ?? (await driverFor(deploy.platform));
|
|
109
|
+
|
|
110
|
+
for (const step of steps) {
|
|
111
|
+
const result = await driver.run(step.argv, { quiet: true });
|
|
112
|
+
// `tolerateFailure` on these means "the secret may already exist", which is
|
|
113
|
+
// the create step's success case. A binding that genuinely could not be
|
|
114
|
+
// applied is still worth saying out loud — quietly tolerating it here is how
|
|
115
|
+
// the deploy path let this class of gap through in the first place.
|
|
116
|
+
if (!result.ok && !step.argv.includes('add-iam-policy-binding')) continue;
|
|
117
|
+
if (!result.ok) {
|
|
118
|
+
return {
|
|
119
|
+
bound: [],
|
|
120
|
+
failed:
|
|
121
|
+
`could not bind ${connection.provider}.${connection.id}'s credential to ` +
|
|
122
|
+
`${serviceAccount}, so the deployed endpoint will be able to read it and not ` +
|
|
123
|
+
'rotate it — which fails about an hour after the first use. ' +
|
|
124
|
+
`Run \`lanes link deploy --target ${input.target}\` to bind it. ` +
|
|
125
|
+
`(${driver.tool}: ${result.stderr.trim().split('\n').slice(-1)[0] ?? 'failed'})`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { bound: readable ? [readable, ...rotatable] : rotatable };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The credentials a deployed revision serves but cannot rewrite.
|
|
135
|
+
*
|
|
136
|
+
* The detection half of the same invariant `bindConnectionCredentials` keeps.
|
|
137
|
+
* Binding at connect time closes the gap going forward; this one answers for a
|
|
138
|
+
* workspace that already has it, and for every way a binding can go missing that
|
|
139
|
+
* no command is watching — a secret rebuilt by hand, a service account replaced,
|
|
140
|
+
* a profile edited in an editor, a connection made by an older CLI.
|
|
141
|
+
*
|
|
142
|
+
* Asked of the platform rather than derived from a record this repository keeps,
|
|
143
|
+
* because a record would only ever agree with itself. IAM is the thing that
|
|
144
|
+
* actually decides, so IAM is what gets read.
|
|
145
|
+
*
|
|
146
|
+
* One call per ref, concurrently. `doctor` is the command where a few seconds
|
|
147
|
+
* buys an answer nothing else on the machine can give — and the alternative,
|
|
148
|
+
* finding out from a 403 an hour after a connect, is the failure this exists to
|
|
149
|
+
* pre-empt.
|
|
150
|
+
*/
|
|
151
|
+
export async function unboundRotatableRefs(input: {
|
|
152
|
+
readonly deploy: DeployConfig | undefined;
|
|
153
|
+
readonly target: string;
|
|
154
|
+
readonly connections: readonly ConnectionConfig[];
|
|
155
|
+
readonly manifestFor: (providerId: string) => Parameters<typeof rotatableCredentialRefsFor>[1];
|
|
156
|
+
readonly driver?: DeployDriver | undefined;
|
|
157
|
+
}): Promise<{ unbound: readonly SecretRef[]; unavailable?: string | undefined }> {
|
|
158
|
+
const { deploy } = input;
|
|
159
|
+
if (!deploy) return { unbound: [] };
|
|
160
|
+
|
|
161
|
+
const cloudrun = requireProject(deploy, input.target);
|
|
162
|
+
const serviceAccount = cloudrun.service_account;
|
|
163
|
+
if (!serviceAccount) return { unbound: [] };
|
|
164
|
+
|
|
165
|
+
const refs = new Set<SecretRef>();
|
|
166
|
+
for (const connection of input.connections) {
|
|
167
|
+
for (const ref of rotatableCredentialRefsFor(connection, input.manifestFor(connection.provider))) {
|
|
168
|
+
refs.add(ref);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (refs.size === 0) return { unbound: [] };
|
|
172
|
+
|
|
173
|
+
const driver = input.driver ?? (await driverFor(deploy.platform));
|
|
174
|
+
const member = `serviceAccount:${serviceAccount}`;
|
|
175
|
+
|
|
176
|
+
const verdicts = await Promise.all(
|
|
177
|
+
[...refs].map(async (ref) => {
|
|
178
|
+
const result = await driver.run(
|
|
179
|
+
['secrets', 'get-iam-policy', encodeRef(ref), '--project', cloudrun.project, '--format', 'json'],
|
|
180
|
+
{ quiet: true },
|
|
181
|
+
);
|
|
182
|
+
// A ref whose policy cannot be read is not reported as unbound: "could not
|
|
183
|
+
// look" and "is not granted" send an operator to different places, and
|
|
184
|
+
// this command exists to be trusted about the second one.
|
|
185
|
+
if (!result.ok) return { ref, bound: true, unreadable: true };
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const policy = JSON.parse(result.stdout) as {
|
|
189
|
+
bindings?: { role?: string; members?: string[] }[];
|
|
190
|
+
};
|
|
191
|
+
const bound = (policy.bindings ?? []).some(
|
|
192
|
+
(binding) =>
|
|
193
|
+
binding.role === 'roles/secretmanager.secretVersionAdder' &&
|
|
194
|
+
(binding.members ?? []).includes(member),
|
|
195
|
+
);
|
|
196
|
+
return { ref, bound, unreadable: false };
|
|
197
|
+
} catch {
|
|
198
|
+
return { ref, bound: true, unreadable: true };
|
|
199
|
+
}
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const unreadable = verdicts.filter((verdict) => verdict.unreadable).length;
|
|
204
|
+
return {
|
|
205
|
+
unbound: verdicts.filter((verdict) => !verdict.bound).map((verdict) => verdict.ref),
|
|
206
|
+
...(unreadable > 0
|
|
207
|
+
? {
|
|
208
|
+
unavailable:
|
|
209
|
+
`${unreadable} of ${refs.size} credential policies could not be read, so this ` +
|
|
210
|
+
`check covered the rest. ${driver.tool} has to be installed and authorised for it.`,
|
|
211
|
+
}
|
|
212
|
+
: {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -18,9 +18,31 @@ WORKDIR /app
|
|
|
18
18
|
# The manifest first, so a source-only change reuses the install layer. One
|
|
19
19
|
# package now, rather than fifteen manifests copied ahead of the install to
|
|
20
20
|
# satisfy workspace resolution.
|
|
21
|
-
|
|
21
|
+
#
|
|
22
|
+
# `bun.lock*`, not `bun.lock`, and the star is the whole point: **npm strips a
|
|
23
|
+
# root lockfile out of a published tarball whatever `files` says.** Verified
|
|
24
|
+
# against npm 12, which is what `release.yml` installs before publishing; npm
|
|
25
|
+
# 11.6 still packs it, so a local `npm pack` says the opposite and the gap only
|
|
26
|
+
# shows up in what people actually install.
|
|
27
|
+
#
|
|
28
|
+
# This image is built from whichever tree `lanes link deploy` is run against, and
|
|
29
|
+
# there are two of them. From a checkout the lockfile is present and pins the
|
|
30
|
+
# whole transitive set. From a bun-global install — the only install method this
|
|
31
|
+
# CLI documents — it cannot be there at all, and naming it outright made deploy
|
|
32
|
+
# impossible for every one of those users: the build pulled the base image,
|
|
33
|
+
# pushed a context, and then died on `stat bun.lock: file does not exist`.
|
|
34
|
+
COPY package.json bunfig.toml bun.lock* ./
|
|
22
35
|
|
|
23
|
-
|
|
36
|
+
# Not `--frozen-lockfile || bun install`: that swallows a genuine
|
|
37
|
+
# lockfile-versus-manifest disagreement in a checkout build, which is the one
|
|
38
|
+
# thing the frozen flag is for. The branch says which tree it is building and
|
|
39
|
+
# holds the guarantee that tree can offer.
|
|
40
|
+
#
|
|
41
|
+
# What the fallback gives up is narrower than it looks. Every runtime dependency
|
|
42
|
+
# in `package.json` is an exact version, so the direct set is identical either
|
|
43
|
+
# way; the lockfile pins what those depend on in turn, and a published package
|
|
44
|
+
# has never been able to carry one.
|
|
45
|
+
RUN if [ -f bun.lock ]; then bun install --frozen-lockfile; else bun install; fi
|
|
24
46
|
|
|
25
47
|
COPY src/ src/
|
|
26
48
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { VAULT_DOCUMENT_REF } from '#secrets';
|
|
1
|
+
import { VAULT_DOCUMENT_REF, type SecretRef } from '#secrets';
|
|
2
2
|
import type { DeployStep, ProvisionInput } from '../driver.ts';
|
|
3
3
|
import { encodeRef } from '../adapters/gcp-secret-manager.ts';
|
|
4
4
|
import { requireProject } from './gcloud.ts';
|
|
@@ -43,6 +43,109 @@ const REQUIRED_SERVICES = [
|
|
|
43
43
|
'cloudresourcemanager.googleapis.com',
|
|
44
44
|
];
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Who the grant is for, and on what.
|
|
48
|
+
*
|
|
49
|
+
* A named type because these two functions are now called from two places that
|
|
50
|
+
* must not disagree — see the note on `secretGrantSteps`.
|
|
51
|
+
*/
|
|
52
|
+
export interface SecretGrant {
|
|
53
|
+
readonly project: string;
|
|
54
|
+
readonly serviceAccount: string;
|
|
55
|
+
readonly refs: readonly SecretRef[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read, named one secret at a time, so the grant needs no condition to be scoped. */
|
|
59
|
+
export function readSteps({ project, serviceAccount, refs }: SecretGrant): DeployStep[] {
|
|
60
|
+
return refs.map((ref) => ({
|
|
61
|
+
title: `let the revision read ${ref}`,
|
|
62
|
+
argv: [
|
|
63
|
+
'secrets',
|
|
64
|
+
'add-iam-policy-binding',
|
|
65
|
+
encodeRef(ref),
|
|
66
|
+
'--project',
|
|
67
|
+
project,
|
|
68
|
+
'--member',
|
|
69
|
+
`serviceAccount:${serviceAccount}`,
|
|
70
|
+
'--role',
|
|
71
|
+
'roles/secretmanager.secretAccessor',
|
|
72
|
+
// Bindings are printed as the whole policy otherwise, which is pages
|
|
73
|
+
// of YAML per deploy and buries everything after it.
|
|
74
|
+
'--condition',
|
|
75
|
+
'None',
|
|
76
|
+
],
|
|
77
|
+
tolerateFailure: true,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Write, two steps each, and the first is what keeps the second narrow: the
|
|
83
|
+
* secret is created here so the revision only ever needs to *add a version*,
|
|
84
|
+
* never `secrets.create`, which is a project-level permission that would let it
|
|
85
|
+
* mint credential references of its own.
|
|
86
|
+
*/
|
|
87
|
+
export function rotateSteps({ project, serviceAccount, refs }: SecretGrant): DeployStep[] {
|
|
88
|
+
return refs.flatMap((ref) => {
|
|
89
|
+
const id = encodeRef(ref);
|
|
90
|
+
return [
|
|
91
|
+
{
|
|
92
|
+
title: `create the secret ${id}, so the revision never needs secrets.create`,
|
|
93
|
+
argv: ['secrets', 'create', id, '--project', project, '--replication-policy', 'automatic'],
|
|
94
|
+
tolerateFailure: true,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
title: `let the revision rewrite ${ref}, and nothing else in the store`,
|
|
98
|
+
argv: [
|
|
99
|
+
'secrets',
|
|
100
|
+
'add-iam-policy-binding',
|
|
101
|
+
id,
|
|
102
|
+
'--project',
|
|
103
|
+
project,
|
|
104
|
+
'--member',
|
|
105
|
+
`serviceAccount:${serviceAccount}`,
|
|
106
|
+
'--role',
|
|
107
|
+
'roles/secretmanager.secretVersionAdder',
|
|
108
|
+
'--condition',
|
|
109
|
+
'None',
|
|
110
|
+
],
|
|
111
|
+
tolerateFailure: true,
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Both halves for one connection's credentials, for a caller that is not a deploy.
|
|
119
|
+
*
|
|
120
|
+
* **This exists because the grant was a deploy-time snapshot of a set that
|
|
121
|
+
* changes between deploys.** `provisionSteps` walks the config's connections and
|
|
122
|
+
* binds each secret it finds; `connect` then adds a connection, writes its
|
|
123
|
+
* credential, and binds nothing. The revision can read the new secret — an older
|
|
124
|
+
* deployment's project-wide `secretAccessor` covers it, and a current one does
|
|
125
|
+
* not — but it cannot add a version, so the first OAuth refresh 403s. The
|
|
126
|
+
* connection works for exactly as long as its initial access token lasts, which
|
|
127
|
+
* is about an hour, and then stops for a reason nothing on the connection says.
|
|
128
|
+
*
|
|
129
|
+
* So the same steps are reachable from `connect`, over one connection's refs
|
|
130
|
+
* rather than the whole config's. Deliberately the *same functions* rather than
|
|
131
|
+
* a second implementation: `reconcile.ts` makes the same argument for planning
|
|
132
|
+
* and applying, and it holds harder here, because a second spelling of a grant
|
|
133
|
+
* is not a wrong answer on screen, it is a permission that is missing in one
|
|
134
|
+
* path and present in the other.
|
|
135
|
+
*/
|
|
136
|
+
export function secretGrantSteps(
|
|
137
|
+
grant: Omit<SecretGrant, 'refs'> & {
|
|
138
|
+
readonly readable: readonly SecretRef[];
|
|
139
|
+
readonly rotatable: readonly SecretRef[];
|
|
140
|
+
},
|
|
141
|
+
): DeployStep[] {
|
|
142
|
+
const { project, serviceAccount } = grant;
|
|
143
|
+
return [
|
|
144
|
+
...readSteps({ project, serviceAccount, refs: grant.readable }),
|
|
145
|
+
...rotateSteps({ project, serviceAccount, refs: grant.rotatable }),
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
46
149
|
export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
|
|
47
150
|
const cloudrun = requireProject(input.deploy, input.target);
|
|
48
151
|
const { project, region, service_account: serviceAccount } = cloudrun;
|
|
@@ -116,27 +219,7 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
|
|
|
116
219
|
// Affordable because the serving path reads by explicit ref: `list()` is a
|
|
117
220
|
// CLI call, and `secretAccessor` never carried `secrets.list` anyway.
|
|
118
221
|
// `readableRefs` derives the set from config and manifests at deploy time.
|
|
119
|
-
|
|
120
|
-
steps.push({
|
|
121
|
-
title: `let the revision read ${ref}`,
|
|
122
|
-
argv: [
|
|
123
|
-
'secrets',
|
|
124
|
-
'add-iam-policy-binding',
|
|
125
|
-
encodeRef(ref),
|
|
126
|
-
'--project',
|
|
127
|
-
project,
|
|
128
|
-
'--member',
|
|
129
|
-
`serviceAccount:${serviceAccount}`,
|
|
130
|
-
'--role',
|
|
131
|
-
'roles/secretmanager.secretAccessor',
|
|
132
|
-
// Bindings are printed as the whole policy otherwise, which is pages
|
|
133
|
-
// of YAML per deploy and buries everything after it.
|
|
134
|
-
'--condition',
|
|
135
|
-
'None',
|
|
136
|
-
],
|
|
137
|
-
tolerateFailure: true,
|
|
138
|
-
});
|
|
139
|
-
}
|
|
222
|
+
steps.push(...readSteps({ project, serviceAccount, refs: input.readable ?? [] }));
|
|
140
223
|
}
|
|
141
224
|
|
|
142
225
|
// What a revision rewrites in its own credential store, named one secret at a
|
|
@@ -165,32 +248,8 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
|
|
|
165
248
|
]
|
|
166
249
|
: [];
|
|
167
250
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
steps.push({
|
|
172
|
-
title: `create the secret ${id}, so the revision never needs secrets.create`,
|
|
173
|
-
argv: ['secrets', 'create', id, '--project', project, '--replication-policy', 'automatic'],
|
|
174
|
-
tolerateFailure: true,
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
steps.push({
|
|
178
|
-
title: `let the revision rewrite ${ref}, and nothing else in the store`,
|
|
179
|
-
argv: [
|
|
180
|
-
'secrets',
|
|
181
|
-
'add-iam-policy-binding',
|
|
182
|
-
id,
|
|
183
|
-
'--project',
|
|
184
|
-
project,
|
|
185
|
-
'--member',
|
|
186
|
-
`serviceAccount:${serviceAccount}`,
|
|
187
|
-
'--role',
|
|
188
|
-
'roles/secretmanager.secretVersionAdder',
|
|
189
|
-
'--condition',
|
|
190
|
-
'None',
|
|
191
|
-
],
|
|
192
|
-
tolerateFailure: true,
|
|
193
|
-
});
|
|
251
|
+
if (serviceAccount) {
|
|
252
|
+
steps.push(...rotateSteps({ project, serviceAccount, refs: writable }));
|
|
194
253
|
}
|
|
195
254
|
|
|
196
255
|
// Any target that addresses a bucket, which deployed means all of them:
|
|
@@ -241,8 +300,18 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
|
|
|
241
300
|
// what the revision owns from what declares what it is. Anchored to the
|
|
242
301
|
// profile segment rather than matched loosely: `contains("/providers.d/")`
|
|
243
302
|
// would also catch a blob whose own key happened to spell it.
|
|
303
|
+
//
|
|
304
|
+
// The dot is a character class, not `\.`, and that is the fix rather than
|
|
305
|
+
// a style: this string is a *CEL string literal* holding a regex, so it is
|
|
306
|
+
// unescaped once by CEL before the regex engine ever sees it. `\.` is not
|
|
307
|
+
// a CEL escape sequence, so the whole expression failed to compile —
|
|
308
|
+
// `token recognition error at: '"^projects/_/buckets/...providers\.'` —
|
|
309
|
+
// and both bindings below carry `tolerateFailure`, so a deploy printed two
|
|
310
|
+
// warnings and carried on with the scoping silently not applied. `[.]` is
|
|
311
|
+
// the same regex and survives a layer of string unescaping unchanged,
|
|
312
|
+
// which is what keeps the next person from reintroducing it.
|
|
244
313
|
const providerManifests =
|
|
245
|
-
`resource.name.matches("^projects/_/buckets/${bucket}/objects/data/[^/]+/providers
|
|
314
|
+
`resource.name.matches("^projects/_/buckets/${bucket}/objects/data/[^/]+/providers[.]d/")`;
|
|
246
315
|
|
|
247
316
|
steps.push({
|
|
248
317
|
title: 'let the revision write its own data, but not the manifests in it',
|