Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/runbooks/first-user-walk.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ COMMONLY_WALK_PASSWORD="$(openssl rand -base64 24)" \
cleanup, `2` the walk passed but a step is not yet covered (today, the
Bring-your-own connect step).

### Staging: the shared sign-in account

Staging signup needs a verified email, which a walk address cannot receive, so
staging runs sign in to one shared walk account that SRE pre-verifies. Set
`COMMONLY_WALK_EMAIL` and `COMMONLY_WALK_PASSWORD` from the environment and pass
the staging API as the target:

```bash
COMMONLY_WALK_EMAIL=<shared walk account> COMMONLY_WALK_PASSWORD=<its password> \
node scripts/first-user-walk.mjs --base https://api-staging.commonly.me
```

- The run signs in and never signs up. It removes only the pods, seats and
agents it created, and it never deletes the shared account.
- `COMMONLY_WALK_EMAIL` aimed at any host other than staging is refused before
the walk starts, so the shared account cannot be used on prod.
- The manifest records `"mode": "signin"` for these runs.

## Cleanup is part of the run

The walk deletes its own account in a `finally` block, sending the password and
Expand Down
77 changes: 58 additions & 19 deletions scripts/first-user-walk-cleanup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,47 @@ export const ownerOf = (pod) => String(pod.createdBy?._id ?? pod.createdBy ?? ''
// Only the bots this run created go in the manifest: the hosted agent (its
// username carries this run's agent name) and the per-user Scout (`scout-u…`).
// The shared default bot is a member of every new pod and is never recorded,
// because SRE's purge would delete an identity every pod uses.
export const isRunBot = (member, agentName) => {
// because SRE's purge would delete an identity every pod uses. `scout: false`
// is for a kept account: its Scout is that account's own bot, not this run's.
export const isRunBot = (member, agentName, { scout = true } = {}) => {
const name = String(member?.username || '');
return member?.isBot === true && (name === agentName || name.startsWith(`${agentName}-`) || name.startsWith('scout-u'));
const isScout = scout && name.startsWith('scout-u');
return member?.isBot === true && (name === agentName || name.startsWith(`${agentName}-`) || isScout);
};

// After an ambiguous pod create (an error, or a reply without an id), the pod
// may exist. Find this run's pods by their unique name and record them, so
// cleanup removes them. Returns true only when a matching pod is found. It
// returns false when the list cannot be read or holds no match, and the caller
// must report the name as an unresolved survivor. An empty match would let
// cleanup mark the run clean while the pod is still live.
export const recoverRunPods = async ({ api, token, userId, podName, podIds }) => {
try {
const list = await api('GET', '/api/pods', { token });
// A 200 whose body is not a pod list is unreadable, not an empty list.
if (list.status !== 200 || !(Array.isArray(list.data) || Array.isArray(list.data?.pods))) return false;
let found = false;
for (const p of ids(list.data)) {
if (p.name === podName && ownerOf(p) === String(userId)) {
found = true;
if (!podIds.includes(String(p._id))) podIds.push(String(p._id));
}
}
return found;
} catch {
return false;
}
};

// `api` is the walk's request helper: (method, path, { token, body }) => { status, data }.
// `podIds` are the pods the walk recorded as created in its manifest. They are
// removed and verified even when the owner match misses them, so a filter
// miss cannot turn into a silent survivor. `onBotIds(podId, botIds)` is called
// before each pod is deleted, so a throw later in the loop cannot lose the ids.
export const cleanupRun = async ({ api, token, password, email, userId, agentName, podIds = [], onBotIds = () => {} }) => {
// `deleteAccount: false` is the shared staging sign-in account: the run removes
// only the pods it recorded, never pods that account owns from elsewhere, and
// never the account itself.
export const cleanupRun = async ({ api, token, password, email, userId, agentName, podIds = [], onBotIds = () => {}, deleteAccount = true }) => {
const survivors = [];
const botIds = new Set();

Expand All @@ -44,10 +73,16 @@ export const cleanupRun = async ({ api, token, password, email, userId, agentNam
const stop = await api('POST', '/api/hosted/deprovision', { token, body: { agentName } });
if (stop.status >= 300 && stop.status !== 404) survivors.push(`hosted:${agentName} (deprovision ${stop.status})`);

const owned = await listOwnPods();
if (owned === null) survivors.push('pods (list failed)');
let owned = [];
if (deleteAccount) {
owned = await listOwnPods();
if (owned === null) {
survivors.push('pods (list failed)');
owned = [];
}
}
const known = new Set(podIds.map(String));
for (const pod of owned || []) known.add(String(pod._id));
for (const pod of owned) known.add(String(pod._id));

// Pods only: the walk never joins a pod it did not create, so every pod
// whose createdBy is this account is the run's own footprint. Bot members
Expand All @@ -61,7 +96,7 @@ export const cleanupRun = async ({ api, token, password, email, userId, agentNam
survivors.push(`pod:${id} (lookup ${detail.status})`);
continue;
}
const bots = (detail.data?.members || []).filter((m) => isRunBot(m, agentName)).map((m) => String(m._id));
const bots = (detail.data?.members || []).filter((m) => isRunBot(m, agentName, { scout: deleteAccount })).map((m) => String(m._id));
bots.forEach((bot) => botIds.add(bot));
onBotIds(id, bots);

Expand All @@ -75,23 +110,27 @@ export const cleanupRun = async ({ api, token, password, email, userId, agentNam
// listed under this account, or still readable by id, is a survivor
// whatever the delete said. A list that fails here cannot prove the pods
// are gone, so it is a survivor too.
const after = await listOwnPods();
if (after === null) survivors.push('pods (list failed on verify)');
for (const pod of after || []) survivors.push(`pod:${String(pod._id)} (still listed)`);
if (deleteAccount) {
const after = await listOwnPods();
if (after === null) survivors.push('pods (list failed on verify)');
for (const pod of after || []) survivors.push(`pod:${String(pod._id)} (still listed)`);
}
for (const id of known) {
const check = await api('GET', `/api/pods/${id}`, { token });
if (check.status === 200) survivors.push(`pod:${id} (still readable)`);
else if (check.status !== 404) survivors.push(`pod:${id} (verify ${check.status})`);
}

const del = await api('DELETE', '/api/users/me', { token, body: { password, confirmation: 'DELETE' } });
if (del.status >= 300) {
survivors.push(`account (delete ${del.status})`);
} else {
// The delete reply is not proof: the account is gone only if its own
// password no longer logs in.
const login = await api('POST', '/api/auth/login', { body: { email, password } });
if (login.status === 200) survivors.push('account (still logs in)');
if (deleteAccount) {
const del = await api('DELETE', '/api/users/me', { token, body: { password, confirmation: 'DELETE' } });
if (del.status >= 300) {
survivors.push(`account (delete ${del.status})`);
} else {
// The delete reply is not proof: the account is gone only if its own
// password no longer logs in.
const login = await api('POST', '/api/auth/login', { body: { email, password } });
if (login.status === 200) survivors.push('account (still logs in)');
}
}

return { ok: survivors.length === 0, survivors, botIds: [...botIds] };
Expand Down
98 changes: 97 additions & 1 deletion scripts/first-user-walk-cleanup.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Run: node --test scripts/first-user-walk-cleanup.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { cleanupRun } from './first-user-walk-cleanup.mjs';
import { cleanupRun, recoverRunPods } from './first-user-walk-cleanup.mjs';
import { chooseWalkMode } from './first-user-walk-mode.mjs';

const USER = 'user-walk';
const OTHER = 'user-other';
Expand Down Expand Up @@ -194,3 +195,98 @@ test('the shared default bot is never recorded, even though it is a pod member',
assert.deepEqual(result.botIds, ['scout', 'agent']);
assert.ok(!result.botIds.includes('shared-bot'));
});

test('shared sign-in account: the run never deletes the account', async () => {
const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }] });
const result = await run(backend, { podIds: ['p1'], deleteAccount: false });
assert.equal(result.ok, true);
assert.equal(backend.state.accountGone, false);
assert.ok(!backend.state.calls.includes('DELETE /api/users/me'), `calls: ${backend.state.calls}`);
assert.ok(!backend.state.calls.includes('POST /api/auth/login'), 'no login check when the account is kept');
});

test('shared sign-in account: only the recorded pods go, not other pods the account owns', async () => {
const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }, { _id: 'old', createdBy: USER }] });
const result = await run(backend, { podIds: ['p1'], deleteAccount: false });
assert.equal(result.ok, true);
assert.deepEqual(backend.state.pods.map((p) => p._id), ['old']);
assert.ok(!backend.state.calls.includes('DELETE /api/pods/old'));
});

test('shared sign-in account: a recorded pod that survives is still a survivor', async () => {
const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }], stuck: new Set(['p1']) });
const result = await run(backend, { podIds: ['p1'], deleteAccount: false });
assert.equal(result.ok, false);
assert.ok(result.survivors.includes('pod:p1 (still readable)'), `survivors: ${result.survivors}`);
});

test('shared sign-in account: its own Scout bot is never recorded for purge', async () => {
const members = [
{ _id: 'account-scout', isBot: true, username: 'scout-uowner1' },
{ _id: 'agent', isBot: true, username: 'walk-x-agent' },
];
const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER, members }] });
const result = await run(backend, { podIds: ['p1'], deleteAccount: false });
assert.deepEqual(result.botIds, ['agent']);
});

test('a signup run still records the Scout it created', async () => {
const members = [{ _id: 'run-scout', isBot: true, username: 'scout-unew1' }];
const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER, members }] });
const result = await run(backend);
assert.deepEqual(result.botIds, ['run-scout']);
});

test('handoff: the staging sign-in mode reaches cleanup as deleteAccount false, and the account is never deleted', async () => {
const { deleteAccount } = chooseWalkMode({ base: 'https://api-staging.commonly.me', env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } });
const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }] });
const result = await run(backend, { podIds: ['p1'], deleteAccount });
assert.equal(result.ok, true);
assert.ok(!backend.state.calls.includes('DELETE /api/users/me'), 'the shared account must not be deleted');
});

test('handoff: a signup run reaches cleanup as deleteAccount true, and the account is deleted', async () => {
const { deleteAccount } = chooseWalkMode({ base: 'https://api-staging.commonly.me', env: {} });
const backend = fakeBackend({ pods: [] });
await run(backend, { deleteAccount });
assert.ok(backend.state.calls.includes('DELETE /api/users/me'));
});

test('recoverRunPods records this run\'s pod by its unique name, never another owner\'s', async () => {
const backend = fakeBackend({ pods: [
{ _id: 'mine', name: 'walk-x workspace', createdBy: USER },
{ _id: 'theirs', name: 'walk-x workspace', createdBy: OTHER },
{ _id: 'other-name', name: 'Team', createdBy: USER },
] });
const podIds = [];
const ok = await recoverRunPods({ api: backend.api, token: 'tok', userId: USER, podName: 'walk-x workspace', podIds });
assert.equal(ok, true);
assert.deepEqual(podIds, ['mine']);
});

test('recoverRunPods reports failure when the list cannot be read, so the name is unresolved', async () => {
const backend = fakeBackend({ pods: [], listFails: true });
const podIds = [];
const ok = await recoverRunPods({ api: backend.api, token: 'tok', userId: USER, podName: 'walk-x workspace', podIds });
assert.equal(ok, false);
assert.deepEqual(podIds, []);
});

test('recoverRunPods reports unresolved when a valid list has no match for this run', async () => {
const backend = fakeBackend({ pods: [
{ _id: 'theirs', name: 'walk-x workspace', createdBy: OTHER },
{ _id: 'other-name', name: 'Team', createdBy: USER },
] });
const podIds = [];
const ok = await recoverRunPods({ api: backend.api, token: 'tok', userId: USER, podName: 'walk-x workspace', podIds });
assert.equal(ok, false);
assert.deepEqual(podIds, []);
Comment thread
lilyshen0722 marked this conversation as resolved.
Resolved
});

test('recoverRunPods treats a 200 that is not a pod list as unresolved', async () => {
const backend = fakeBackend({ pods: [] });
const garbage = async (method, path) => (method === 'GET' && path === '/api/pods' ? { status: 200, data: { error: 'nope' } } : backend.api(method, path));
const podIds = [];
const ok = await recoverRunPods({ api: garbage, token: 'tok', userId: USER, podName: 'walk-x workspace', podIds });
assert.equal(ok, false);
});
19 changes: 19 additions & 0 deletions scripts/first-user-walk-mode.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// How the walk gets its account (TASK-706). Prod signs up a fresh account every
// run. Staging needs a verified email, which a walk address cannot receive, so
// SRE pre-verifies one shared walk account there and the walk signs in to it
// instead. Its credentials come from env only, and that account is never deleted.
// Exact host and https only: a substring match would send the shared password to
// any host whose name contains "staging".
const STAGING_HOSTS = new Set(['api-staging.commonly.me']);

// `deleteAccount` is the one value main() hands to cleanup, so the mode decides
// the account's fate here and nowhere else.
export const chooseWalkMode = ({ base, env }) => {
const email = env.COMMONLY_WALK_EMAIL || '';
if (!email) return { mode: 'signup', deleteAccount: true };
const url = new URL(base);
if (url.protocol !== 'https:' || !STAGING_HOSTS.has(url.hostname)) {
throw new Error('COMMONLY_WALK_EMAIL is set but the target is not the staging API over https; the shared sign-in account exists on staging only');
}
return { mode: 'signin', email, deleteAccount: false };
};
30 changes: 30 additions & 0 deletions scripts/first-user-walk-mode.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Run: node --test scripts/first-user-walk-mode.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { chooseWalkMode } from './first-user-walk-mode.mjs';

const STAGING = 'https://api-staging.commonly.me';
const PROD = 'https://api.commonly.me';

test('staging with COMMONLY_WALK_EMAIL takes the sign-in path', () => {
assert.deepEqual(chooseWalkMode({ base: STAGING, env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), { mode: 'signin', email: 'walk@commonly.me', deleteAccount: false });
});

test('no COMMONLY_WALK_EMAIL signs up a fresh account, on any target, and that account is deleted', () => {
assert.deepEqual(chooseWalkMode({ base: PROD, env: {} }), { mode: 'signup', deleteAccount: true });
assert.deepEqual(chooseWalkMode({ base: STAGING, env: {} }), { mode: 'signup', deleteAccount: true });
});

test('the shared sign-in account is refused on prod', () => {
assert.throws(() => chooseWalkMode({ base: PROD, env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), /staging only/);
});

test('a host that merely contains "staging" never receives the shared account', () => {
for (const base of ['https://staging.attacker.example', 'https://api-staging.commonly.me.attacker.example', 'https://evil-staging.example']) {
assert.throws(() => chooseWalkMode({ base, env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), /staging API over https/, base);
}
});

test('the shared sign-in account is refused over plain http, even on the staging host', () => {
assert.throws(() => chooseWalkMode({ base: 'http://api-staging.commonly.me', env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), /over https/);
});
Loading
Loading