Skip to content

Commit dd2cd14

Browse files
Record a removal before retiring what the person owned (#574)
Co-authored-by: David McKay <david@copilotkit.ai>
1 parent 39900b9 commit dd2cd14

5 files changed

Lines changed: 155 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### Removing somebody is recorded even when retiring what they owned fails
12+
13+
Removing somebody denies their access and ends their sessions, then retires the credentials and
14+
brokered connections they had granted this deployment. When that second half failed — a vault or
15+
Composio not answering — the removal was already committed but nothing was written to the audit trail,
16+
and removing them a second time reported success without retrying it, leaving those connections
17+
standing. The removal is now recorded as soon as it takes effect, and removing somebody already
18+
removed finishes the retirement that failed.
19+
1120
### Removing a connector takes its grants with it
1221

1322
A grant naming a connector's tool outlived the connector. Removing an app revoked every credential

server/src/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,10 @@ export function createApp(
782782
);
783783
}
784784

785+
if (revoked) {
786+
await peopleStore.retireOwned(userId, context.var.actor.id);
787+
}
788+
785789
return context.json({ person: await peopleStore.find(userId) });
786790
});
787791

server/src/people/store.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export type PeopleStore = {
7272
list: (query?: PeopleQuery) => Promise<PeoplePage>;
7373
setRole: (userId: string, role: OpenBotRole) => Promise<void>;
7474
revoke: (userId: string, revokedBy: string) => Promise<void>;
75+
retireOwned: (userId: string, revokedBy: string) => Promise<void>;
7576
restore: (userId: string) => Promise<void>;
7677
find: (userId: string) => Promise<Person | undefined>;
7778
isRevoked: (email: string) => Promise<boolean>;
@@ -309,20 +310,22 @@ export function createPeopleStore(
309310
.onConflictDoNothing();
310311
await tx.delete(sessions).where(eq(sessions.userId, userId));
311312
});
313+
},
312314

313-
/*
314-
* After the transaction, and deliberately not inside it.
315-
*
316-
* Retiring a credential is a write to the vault plus an audit row, and the vault is reached
317-
* through its own interface rather than this transaction's handle. Holding the person's removal
318-
* open until that finishes would make an unrelated failure able to undo the deny-list row and
319-
* the session deletion, which are the two things that must not fail to stick.
320-
*
321-
* So the order is: stop them getting in, then stop us holding their secret. If the second half
322-
* throws, the first is already done and the audit trail shows a removal with no retirement
323-
* beside it — which is the honest record of what happened, and is recoverable by removing them
324-
* again.
325-
*/
315+
/*
316+
* After `revoke`, and deliberately not inside it.
317+
*
318+
* Retiring a credential is a write to the vault plus an audit row, and the vault is reached
319+
* through its own interface rather than that transaction's handle. Holding the person's removal
320+
* open until that finishes would make an unrelated failure able to undo the deny-list row and
321+
* the session deletion, which are the two things that must not fail to stick.
322+
*
323+
* So the order is: stop them getting in, then stop us holding their secret. If the second half
324+
* throws, the first is already done and the audit trail shows a removal with no retirement
325+
* beside it — which is the honest record of what happened, and is recoverable by removing them
326+
* again.
327+
*/
328+
async retireOwned(userId, revokedBy) {
326329
await retireOwnedCredentials?.(userId, revokedBy);
327330
},
328331

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { afterAll, expect, test } from "bun:test";
2+
import { randomUUID } from "node:crypto";
3+
import { eq, inArray } from "drizzle-orm";
4+
import { createApp } from "../src/app";
5+
import { loadConfig } from "../src/config";
6+
import { createDatabase } from "../src/db/client";
7+
import { revokedAccess, sessions, users } from "../src/db/schema";
8+
import { createPeopleStore } from "../src/people/store";
9+
import { TEST_POOL, testDatabaseUrl } from "./support/database";
10+
import { testEnvironment } from "./support/environment";
11+
12+
const database = createDatabase(testDatabaseUrl(), TEST_POOL);
13+
14+
const suite = randomUUID().slice(0, 8);
15+
const adminId = `offboarding-admin-${suite}`;
16+
const memberId = `offboarding-member-${suite}`;
17+
const memberEmail = `${memberId}@openbot.test`;
18+
19+
const ADMIN = {
20+
id: adminId,
21+
email: `${adminId}@openbot.test`,
22+
name: "An Administrator",
23+
image: null,
24+
};
25+
26+
afterAll(async () => {
27+
await database
28+
.delete(revokedAccess)
29+
.where(eq(revokedAccess.email, memberEmail));
30+
await database.delete(sessions).where(eq(sessions.userId, memberId));
31+
await database.delete(users).where(inArray(users.id, [adminId, memberId]));
32+
});
33+
34+
function appFor(retire: () => Promise<{ retired: number }>) {
35+
const events: string[] = [];
36+
const store = createPeopleStore(database, [], retire);
37+
const auditStore = {
38+
insert: async (event: { eventType: string }) => {
39+
events.push(event.eventType);
40+
},
41+
};
42+
43+
const app = createApp(
44+
loadConfig(testEnvironment()),
45+
{
46+
handler: () => new Response(null, { status: 204 }),
47+
api: { getSession: async () => ({ user: ADMIN }) },
48+
} as never,
49+
{ rolesForUser: async () => ["admin"] },
50+
...(Array.from({ length: 9 }) as never[]),
51+
auditStore as never,
52+
...(Array.from({ length: 4 }) as never[]),
53+
store as never,
54+
);
55+
56+
return {
57+
events,
58+
remove: () =>
59+
app.request(`http://openbot.test/api/admin/people/${memberId}/access`, {
60+
method: "POST",
61+
headers: { "content-type": "application/json" },
62+
body: JSON.stringify({ revoked: true }),
63+
}),
64+
};
65+
}
66+
67+
test("a broker that will not answer still leaves the removal on the trail, and asking again finishes it", async () => {
68+
await database
69+
.insert(users)
70+
.values([
71+
{
72+
id: adminId,
73+
email: ADMIN.email,
74+
name: "An Administrator",
75+
emailVerified: true,
76+
},
77+
{
78+
id: memberId,
79+
email: memberEmail,
80+
name: "A Member",
81+
emailVerified: true,
82+
},
83+
])
84+
.onConflictDoNothing();
85+
await database.insert(sessions).values({
86+
id: `${memberId}-session`,
87+
userId: memberId,
88+
token: `${memberId}-token`,
89+
expiresAt: new Date(Date.now() + 86_400_000),
90+
});
91+
92+
let attempts = 0;
93+
const { events, remove } = appFor(async () => {
94+
attempts += 1;
95+
if (attempts === 1) {
96+
throw new Error("composio: 503 Service Unavailable");
97+
}
98+
return { retired: 0 };
99+
});
100+
101+
expect((await remove()).status).toBe(500);
102+
103+
const denied = await database
104+
.select({ email: revokedAccess.email })
105+
.from(revokedAccess)
106+
.where(eq(revokedAccess.email, memberEmail));
107+
expect(denied).toHaveLength(1);
108+
expect(
109+
await database
110+
.select({ id: sessions.id })
111+
.from(sessions)
112+
.where(eq(sessions.userId, memberId)),
113+
).toEqual([]);
114+
expect(events).toEqual(["person.access_revoked"]);
115+
116+
expect((await remove()).status).toBe(200);
117+
expect(attempts).toBe(2);
118+
expect(events).toEqual(["person.access_revoked"]);
119+
});

server/tests/people-routes.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ function appWith(
5151
revoke: async (userId, by) => {
5252
calls.push(`revoke:${userId}:${by}`);
5353
},
54+
retireOwned: async (userId, by) => {
55+
calls.push(`retireOwned:${userId}:${by}`);
56+
},
5457
restore: async (userId) => {
5558
calls.push(`restore:${userId}`);
5659
},
@@ -188,7 +191,10 @@ describe("people routes", () => {
188191

189192
await request("/api/admin/people/u1/access", json({ revoked: true }));
190193

191-
expect(calls).toEqual([`revoke:u1:${ADMIN.id}`]);
194+
expect(calls).toEqual([
195+
`revoke:u1:${ADMIN.id}`,
196+
`retireOwned:u1:${ADMIN.id}`,
197+
]);
192198
});
193199

194200
test("restores access for somebody already removed", async () => {

0 commit comments

Comments
 (0)