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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve
- `shell` seals each encrypted session's password to your vault when it
registers the session, using the vault key the browser handed it at
`shell login`, and will not seal to a key that has changed since.
- A privacy policy at `/privacy`, linked from sign-in, sign-up, the Terms and
the Account page.
- Delete your account from the Account page. It removes your sign-in, your
machines' tokens, your sessions, vault, comments and notifications; hands a
team you own to its longest-standing admin, or member if there is none; and
deletes the team when nobody else is in it.

### Changed

Expand Down
68 changes: 68 additions & 0 deletions app/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1908,3 +1908,71 @@ describe("session vault", () => {
expect(member.accountKey).toBe(body.public_key);
});
});

describe("DELETE /api/account", () => {
/* A token from a sign-in that just happened, which deletion asks for. */
const freshSignIn = (claims: Record<string, unknown> = {}) =>
idToken({ auth_time: Math.floor(Date.now() / 1000), ...claims });

async function remove(claims: Record<string, unknown> = {}, confirm = "ana@example.com") {
return call("DELETE", "/api/account", { auth: await freshSignIn(claims), body: { confirm } });
}

it("asks for the account's email address, typed back", async () => {
await call("GET", "/api/org", { auth: await idToken() });
const result = await remove({}, "someone@else.com");
expect(result.status).toBe(400);
expect((await call("GET", "/api/org", { auth: await idToken() })).status).toBe(200);
});

it("asks for a recent sign-in", async () => {
const stale = await call("DELETE", "/api/account", {
auth: await idToken({ auth_time: Math.floor(Date.now() / 1000) - 3600 }),
body: { confirm: "ana@example.com" },
});
expect(stale).toMatchObject({ status: 403, body: { reauthenticate: true } });
});

it("removes a lone account with its team and machines", async () => {
const tokens = await login();
expect(await devices()).toHaveLength(1);

const result = await remove();
expect(result).toMatchObject({ status: 200, body: { deleted: true, owner: null } });

expect(await devices()).toEqual([]);
expect((await call("GET", "/api/cli/me", { auth: tokens.access_token })).status).toBe(401);
});

it("does not build a new team for a token that outlived its account", async () => {
await call("GET", "/api/org", { auth: await idToken() });
await remove();

expect((await call("GET", "/api/org", { auth: await idToken() })).status).toBe(401);
expect((await call("GET", "/api/sessions", { auth: await idToken() })).status).toBe(401);
});

it("hands the team to its longest-standing admin", async () => {
const owner = await idToken();
const forMember = await call("POST", "/api/org/invites", { auth: owner, body: { role: "member" } });
const forAdmin = await call("POST", "/api/org/invites", { auth: owner, body: { role: "admin" } });
const member = await idToken({ sub: "uid-2", email: "bo@example.com" });
const admin = await idToken({ sub: "uid-3", email: "cy@example.com" });
await call("GET", `/api/org?invite=${forMember.body.invite.id}`, { auth: member });
await call("GET", `/api/org?invite=${forAdmin.body.invite.id}`, { auth: admin });

const result = await remove();
expect(result.body.owner).toMatchObject({ uid: "uid-3", email: "cy@example.com" });

const view = await call("GET", "/api/org", { auth: admin });
expect(view.body.you.role).toBe("owner");
const uids = view.body.members.map((entry: { uid: string }) => entry.uid).sort();
expect(uids).toEqual(["uid-2", "uid-3"]);
});

it("succeeds again when the browser retries", async () => {
await call("GET", "/api/org", { auth: await idToken() });
expect((await remove()).status).toBe(200);
expect((await remove()).status).toBe(200);
});
});
18 changes: 17 additions & 1 deletion app/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from "./routes/organizations";
import { recordAudit, assignSession, auditCsv } from "./routes/audit";
import { addComment, inbox, notifyAssigned, notifySessionStarted } from "./routes/social";
import { deleteAccount } from "./routes/account";
import { callerAddress, rateLimiter } from "./lib/rate-limit";
import { logMailer, type Mailer } from "./lib/mail";

Expand Down Expand Up @@ -104,6 +105,7 @@ const CREDENTIAL_ROUTES = new Set([
"POST /api/cli/token",
"POST /api/cli/refresh",
"POST /api/cli/revoke",
"DELETE /api/account",
]);
const CREDENTIAL_BUCKET = { burst: 12, perSecond: 0.2 };
const GENERAL_BUCKET = { burst: 240, perSecond: 40 };
Expand Down Expand Up @@ -290,7 +292,7 @@ export function createApp(options: AppOptions) {
async function requireMember(request: IncomingMessage, inviteId?: string) {
const identity = await requireUser(request);
if (!identity) return null;
return (await ensureMembership(store, identity, inviteId)).membership;
return (await ensureMembership(store, identity, inviteId))?.membership ?? null;
}

/* The CLI authenticates with an opaque access token issued by this service. */
Expand Down Expand Up @@ -443,6 +445,7 @@ export function createApp(options: AppOptions) {
const identity = await requireUser(request);
if (!identity) return send(response, 401, { error: "sign in first" });
const resolved = await ensureMembership(store, identity, invite);
if (!resolved) return send(response, 401, { error: "sign in first" });
/* Publishing the browser key here keeps it current without a
separate call on every sign-in. */
const publicKey = url.searchParams.get("key");
Expand Down Expand Up @@ -675,6 +678,19 @@ export function createApp(options: AppOptions) {
return send(response, 200, { public_key: key.publicKey, version: key.version });
}

/*
* Deleting an account. An ID token reaches it, not a membership, so a
* second request -- the browser retrying after Firebase refused to
* delete the sign-in -- finds nothing left to remove and still succeeds.
*/
if (route === "DELETE /api/account") {
const identity = await requireUser(request);
if (!identity) return send(response, 401, { error: "sign in first" });
const body = (await readBody(request)) as Record<string, unknown>;
const result = await deleteAccount(store, identity, body.confirm);
return send(response, result.status, result.body);
}

/* ---- Session registry ---- */
if (route === "POST /api/sessions") {
const token = await requireCli(request);
Expand Down
11 changes: 11 additions & 0 deletions app/server/lib/migrations/008_deleted_accounts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Accounts deleted in the last two hours.
--
-- A browser still signed in to an account that has just been deleted holds a
-- Firebase ID token that verifies for up to an hour. Any request it makes in
-- that time finds no membership, and without this the service would create a
-- new team for a person who asked to be removed. Only the uid is kept, and
-- only as long as such a token can live, with slack; purging drops it after.
CREATE TABLE IF NOT EXISTS deleted_accounts (
uid TEXT PRIMARY KEY,
deleted_at BIGINT NOT NULL
);
27 changes: 27 additions & 0 deletions app/server/lib/orgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
checkInvite,
newId,
outranks,
successorFor,
suggestOrgName,
type Invite,
type Membership,
} from "./orgs";

function invite(overrides: Partial<Invite> = {}): Invite {
Expand All @@ -22,6 +24,31 @@ function invite(overrides: Partial<Invite> = {}): Invite {
};
}

describe("successorFor", () => {
function member(uid: string, role: Membership["role"], joinedAt: number): Membership {
return { orgId: "org_1", uid, email: `${uid}@example.com`, name: uid, role, joinedAt };
}

it("prefers the longest-standing admin over an earlier member", () => {
const members = [
member("owner", "owner", 1),
member("early-member", "member", 2),
member("late-admin", "admin", 4),
member("early-admin", "admin", 3),
];
expect(successorFor(members, "owner")?.uid).toBe("early-admin");
});

it("falls back to the longest-standing member", () => {
const members = [member("owner", "owner", 1), member("b", "member", 3), member("a", "member", 2)];
expect(successorFor(members, "owner")?.uid).toBe("a");
});

it("names nobody for an owner who is alone", () => {
expect(successorFor([member("owner", "owner", 1)], "owner")).toBeUndefined();
});
});

describe("suggestOrgName", () => {
it("uses the company domain for a work address", async () => {
expect(suggestOrgName("alex@vulturelabs.io")).toBe("Vulturelabs");
Expand Down
18 changes: 18 additions & 0 deletions app/server/lib/orgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,24 @@ export function outranks(actor: Role, target: Role): boolean {
return RANK[actor] > RANK[target];
}

/**
* Who takes over when an owner deletes their account.
*
* The longest-standing admin, who already helps run the team, and failing that
* the longest-standing member. Undefined when nobody else is in it, in which
* case the organization goes with its owner. A tie on joinedAt falls to the
* uid, so the answer never depends on the order rows come back in.
*/
export function successorFor(members: Membership[], leavingUid: string): Membership | undefined {
const others = members.filter((entry) => entry.uid !== leavingUid);
const byTenure = (a: Membership, b: Membership) =>
a.joinedAt - b.joinedAt || (a.uid < b.uid ? -1 : a.uid > b.uid ? 1 : 0);
return (
others.filter((entry) => entry.role === "admin").sort(byTenure)[0] ??
others.sort(byTenure)[0]
);
}

export type InviteCheck =
| { ok: true; invite: Invite }
| { ok: false; reason: string };
Expand Down
111 changes: 110 additions & 1 deletion app/server/lib/store-conformance.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { MemoryStore } from "./store-memory";
import { PostgresStore } from "./store-postgres";
import type { Store } from "./store";
import { DELETED_ACCOUNT_MEMORY_MS, DELETED_ACTOR_EMAIL, type Store } from "./store";
import type { AgentCommand, AuditEvent, CliToken, Notification, SessionRecord } from "./types";
import type { Invite, Membership, Organization } from "./orgs";

Expand Down Expand Up @@ -128,6 +128,7 @@ function notification(overrides: Partial<Notification> = {}): Notification {
type Implementation = { name: string; open: () => Promise<Store>; reset: (store: Store) => Promise<void> };

const TABLES = [
"deleted_accounts",
"account_keys",
"session_key_shares",
"sessions",
Expand Down Expand Up @@ -820,5 +821,113 @@ for (const implementation of implementations) {
expect(["org_1", "org_2"]).toContain(after?.orgId);
});
});

describe("account deletion", () => {
async function team() {
await store.putOrganization(organization());
await store.putMembership(membership());
await store.putMembership(
membership({ uid: "uid-2", email: "bo@example.com", name: "Bo", role: "member", joinedAt: 2000 }),
);
await store.putMembership(
membership({ uid: "uid-3", email: "cy@example.com", name: "Cy", role: "admin", joinedAt: 3000 }),
);
}

it("removes what the account held and keeps the team's trail without its email", async () => {
await team();
await store.putToken(token());
await store.putCommand(command());
await store.upsertSession(session());
await store.upsertSession(
session({ id: "s2", uid: "uid-2", ownerUid: "uid-2", assigneeUid: "uid-1", assigneeUids: ["uid-1", "uid-3"] }),
);
await store.putKeyShares("org_1", "s2", [
{ uid: "uid-1", senderPublicKey: "spk", sealed: "for-ana" },
{ uid: "uid-3", senderPublicKey: "spk", sealed: "for-cy" },
]);
await store.putAccountKey({
uid: "uid-1",
publicKey: "pk",
encryptedPrivateKey: "enc",
recoveryWrap: "wrap",
version: 1,
createdAt: 1000,
updatedAt: 1000,
});
await store.putAudit(auditEvent({ sessionId: "s2" }));
await store.putComment({
id: "cmt_1",
orgId: "org_1",
sessionId: "s2",
authorUid: "uid-1",
body: "looks done",
at: 1000,
mentions: [],
});
await store.putNotification(notification());
await store.putNotification(notification({ id: "ntf_2", uid: "uid-1", actorUid: "uid-2" }));

await store.deleteAccount("uid-1", { orgId: "org_1", dissolve: false, successorUid: "uid-3" }, 5000);

expect(await store.membershipOf("uid-1")).toBeNull();
expect((await store.membershipOf("uid-3"))?.role).toBe("owner");
expect((await store.membershipOf("uid-2"))?.role).toBe("member");
expect(await store.findByAccessHash("access-hash")).toBeNull();
expect(await store.listCommands("uid-1")).toEqual([]);
expect(await store.listSessions("uid-1")).toEqual([]);
expect(await store.accountKey("uid-1")).toBeNull();
expect(await store.comments("org_1", "s2")).toEqual([]);
expect(await store.notificationsFor("uid-1")).toEqual([]);
expect(await store.notificationsFor("uid-2")).toEqual([]);

const kept = (await store.listOrgSessions("org_1")).find((entry) => entry.id === "s2");
expect(kept?.assigneeUids).toEqual(["uid-3"]);
expect(kept?.assigneeUid).toBe("uid-3");
expect(kept?.keyShares?.map((share) => share.uid)).toEqual(["uid-3"]);

const trail = await store.auditFor("org_1", "s2");
expect(trail).toHaveLength(1);
expect(trail[0].actorEmail).toBe(DELETED_ACTOR_EMAIL);
expect(trail[0].text).toBe("ls -la");
});

it("dissolves a team nobody else is in", async () => {
await store.putOrganization(organization());
await store.putMembership(membership());
await store.putInvite(invite());
await store.upsertSession(session());
await store.putAudit(auditEvent());

await store.deleteAccount("uid-1", { orgId: "org_1", dissolve: true }, 5000);

expect(await store.organization("org_1")).toBeNull();
expect(await store.members("org_1")).toEqual([]);
expect(await store.invites("org_1")).toEqual([]);
expect(await store.listOrgSessions("org_1")).toEqual([]);
expect(await store.auditFor("org_1", "s1")).toEqual([]);
});

it("forgets the address an invite was sent to once its recipient is gone", async () => {
await team();
await store.putInvite(invite({ email: "bo@example.com", acceptedBy: "uid-2", acceptedAt: 2000 }));

await store.deleteAccount("uid-2", { orgId: "org_1", dissolve: false }, 5000);

expect((await store.invite("inv_1"))?.email).toBeFalsy();
expect((await store.membershipOf("uid-1"))?.role).toBe("owner");
});

it("remembers a deleted uid for DELETED_ACCOUNT_MEMORY_MS and no longer", async () => {
await store.deleteAccount("uid-9", { dissolve: false }, 5000);

expect(await store.recentlyDeleted("uid-9", 4000)).toBe(true);
expect(await store.recentlyDeleted("uid-9", 6000)).toBe(false);
expect(await store.recentlyDeleted("uid-8", 0)).toBe(false);

await store.purgeExpired(5000 + DELETED_ACCOUNT_MEMORY_MS);
expect(await store.recentlyDeleted("uid-9", 0)).toBe(false);
});
});
});
}
Loading
Loading