@bevel-software/platform-core-backend 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/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +22 -0
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +10 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/audit/agent-audit.service.d.ts +54 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -0
- package/dist/modules/audit/agent-audit.service.js +290 -0
- package/dist/modules/audit/agent-audit.service.js.map +1 -0
- package/dist/modules/audit/agent-rest-audit.middleware.d.ts +40 -0
- package/dist/modules/audit/agent-rest-audit.middleware.d.ts.map +1 -0
- package/dist/modules/audit/agent-rest-audit.middleware.js +75 -0
- package/dist/modules/audit/agent-rest-audit.middleware.js.map +1 -0
- package/dist/modules/audit/audit.contract.d.ts +145 -0
- package/dist/modules/audit/audit.contract.d.ts.map +1 -0
- package/dist/modules/audit/audit.contract.js +35 -0
- package/dist/modules/audit/audit.contract.js.map +1 -0
- package/dist/modules/audit/audit.routes.d.ts +21 -0
- package/dist/modules/audit/audit.routes.d.ts.map +1 -0
- package/dist/modules/audit/audit.routes.js +157 -0
- package/dist/modules/audit/audit.routes.js.map +1 -0
- package/dist/modules/audit/event-classifier.d.ts +52 -0
- package/dist/modules/audit/event-classifier.d.ts.map +1 -0
- package/dist/modules/audit/event-classifier.js +123 -0
- package/dist/modules/audit/event-classifier.js.map +1 -0
- package/dist/modules/audit/index.d.ts +7 -0
- package/dist/modules/audit/index.d.ts.map +1 -0
- package/dist/modules/audit/index.js +7 -0
- package/dist/modules/audit/index.js.map +1 -0
- package/dist/modules/audit/request-audit.d.ts +57 -0
- package/dist/modules/audit/request-audit.d.ts.map +1 -0
- package/dist/modules/audit/request-audit.js +117 -0
- package/dist/modules/audit/request-audit.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +414 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +99 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +6 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.d.ts +9 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.js +16 -3
- package/dist/modules/kb-fs/git-guarded-filesystem.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +32 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +8 -1
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +9 -1
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +48 -5
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +59 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +180 -8
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +10 -2
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +32 -6
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.interface.d.ts +21 -5
- package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts +1 -0
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +22 -9
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +10 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.js +1 -0
- package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +8 -0
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +9 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +8 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +9 -0
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +49 -13
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/git-internals.d.ts +7 -1
- package/dist/shared/git-internals.d.ts.map +1 -1
- package/dist/shared/git-internals.js +67 -9
- package/dist/shared/git-internals.js.map +1 -1
- package/migrations/0012_agent_audit.sql +41 -0
- package/migrations/0013_agent_identity_and_key_soft_delete.sql +29 -0
- package/migrations/meta/0012_snapshot.json +2247 -0
- package/migrations/meta/0013_snapshot.json +2259 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +4 -4
- package/src/core/create-core-server.ts +28 -0
- package/src/core/create-core-services.ts +13 -0
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +370 -0
- package/src/modules/audit/__tests__/agent-rest-audit.middleware.test.ts +113 -0
- package/src/modules/audit/__tests__/audit.routes.test.ts +201 -0
- package/src/modules/audit/__tests__/event-classifier.test.ts +121 -0
- package/src/modules/audit/agent-audit.service.ts +316 -0
- package/src/modules/audit/agent-rest-audit.middleware.ts +83 -0
- package/src/modules/audit/audit.contract.ts +164 -0
- package/src/modules/audit/audit.routes.ts +168 -0
- package/src/modules/audit/event-classifier.ts +155 -0
- package/src/modules/audit/index.ts +14 -0
- package/src/modules/audit/request-audit.ts +141 -0
- package/src/modules/database/core-schema.ts +104 -0
- package/src/modules/diff/diff.service.ts +6 -1
- package/src/modules/kb-fs/git-guarded-filesystem.ts +16 -3
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +183 -13
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -2
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +24 -4
- package/src/modules/mcp/__tests__/mcp.routes.stateless.test.ts +1 -1
- package/src/modules/mcp/__tests__/mcp.service.test.ts +120 -3
- package/src/modules/mcp/mcp-auth.middleware.ts +31 -0
- package/src/modules/mcp/mcp.routes.ts +11 -1
- package/src/modules/mcp/mcp.service.ts +62 -4
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +184 -6
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +35 -0
- package/src/modules/settings/deployment-settings.service.ts +47 -8
- package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +29 -8
- package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +6 -0
- package/src/modules/tool-auth/external-api-key.interface.ts +22 -5
- package/src/modules/tool-auth/external-api-key.service.ts +25 -8
- package/src/modules/tool-auth/internal-token.service.ts +11 -0
- package/src/modules/tool-auth/tool-auth.middleware.ts +17 -1
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +135 -44
- package/src/modules/workspace/workspace.service.ts +9 -0
- package/src/modules/workspace/workspace.tools.ts +47 -11
- package/src/shared/__tests__/git-internals.test.ts +41 -0
- package/src/shared/git-internals.ts +61 -6
- package/dist/modules/access/access-errors.d.ts +0 -34
- package/dist/modules/access/access-errors.d.ts.map +0 -1
- package/dist/modules/access/access-errors.js +0 -40
- package/dist/modules/access/access-errors.js.map +0 -1
- package/dist/modules/access/access-splice.d.ts +0 -140
- package/dist/modules/access/access-splice.d.ts.map +0 -1
- package/dist/modules/access/access-splice.js +0 -389
- package/dist/modules/access/access-splice.js.map +0 -1
- package/dist/modules/access/group-files.d.ts +0 -83
- package/dist/modules/access/group-files.d.ts.map +0 -1
- package/dist/modules/access/group-files.js +0 -167
- package/dist/modules/access/group-files.js.map +0 -1
- package/dist/modules/access/kb-read-filter.d.ts +0 -41
- package/dist/modules/access/kb-read-filter.d.ts.map +0 -1
- package/dist/modules/access/kb-read-filter.js +0 -60
- package/dist/modules/access/kb-read-filter.js.map +0 -1
- package/dist/modules/access/render-roles-yaml.d.ts +0 -22
- package/dist/modules/access/render-roles-yaml.d.ts.map +0 -1
- package/dist/modules/access/render-roles-yaml.js +0 -56
- package/dist/modules/access/render-roles-yaml.js.map +0 -1
- package/dist/modules/access/roles-yaml-guard.d.ts +0 -56
- package/dist/modules/access/roles-yaml-guard.d.ts.map +0 -1
- package/dist/modules/access/roles-yaml-guard.js +0 -79
- package/dist/modules/access/roles-yaml-guard.js.map +0 -1
- package/dist/modules/diff/diff-paths.d.ts +0 -2
- package/dist/modules/diff/diff-paths.d.ts.map +0 -1
- package/dist/modules/diff/diff-paths.js +0 -9
- package/dist/modules/diff/diff-paths.js.map +0 -1
- package/dist/modules/groups/group-provision.service.d.ts +0 -130
- package/dist/modules/groups/group-provision.service.d.ts.map +0 -1
- package/dist/modules/groups/group-provision.service.js +0 -288
- package/dist/modules/groups/group-provision.service.js.map +0 -1
- package/dist/modules/groups/groups.contract.d.ts +0 -106
- package/dist/modules/groups/groups.contract.d.ts.map +0 -1
- package/dist/modules/groups/groups.contract.js +0 -36
- package/dist/modules/groups/groups.contract.js.map +0 -1
- package/dist/modules/groups/groups.routes.d.ts +0 -42
- package/dist/modules/groups/groups.routes.d.ts.map +0 -1
- package/dist/modules/groups/groups.routes.js +0 -379
- package/dist/modules/groups/groups.routes.js.map +0 -1
- package/dist/modules/groups/groups.service.d.ts +0 -60
- package/dist/modules/groups/groups.service.d.ts.map +0 -1
- package/dist/modules/groups/groups.service.js +0 -172
- package/dist/modules/groups/groups.service.js.map +0 -1
- package/dist/modules/groups/index.d.ts +0 -7
- package/dist/modules/groups/index.d.ts.map +0 -1
- package/dist/modules/groups/index.js +0 -6
- package/dist/modules/groups/index.js.map +0 -1
- package/dist/modules/groups/join-proposals.d.ts +0 -53
- package/dist/modules/groups/join-proposals.d.ts.map +0 -1
- package/dist/modules/groups/join-proposals.js +0 -67
- package/dist/modules/groups/join-proposals.js.map +0 -1
- package/dist/modules/groups/join-requests.service.d.ts +0 -81
- package/dist/modules/groups/join-requests.service.d.ts.map +0 -1
- package/dist/modules/groups/join-requests.service.js +0 -135
- package/dist/modules/groups/join-requests.service.js.map +0 -1
- package/dist/modules/mcp/mcp-session-store.d.ts +0 -74
- package/dist/modules/mcp/mcp-session-store.d.ts.map +0 -1
- package/dist/modules/mcp/mcp-session-store.js +0 -131
- package/dist/modules/mcp/mcp-session-store.js.map +0 -1
- package/dist/modules/workflow/file-change-notifier.d.ts +0 -38
- package/dist/modules/workflow/file-change-notifier.d.ts.map +0 -1
- package/dist/modules/workflow/file-change-notifier.js +0 -22
- package/dist/modules/workflow/file-change-notifier.js.map +0 -1
- package/dist/modules/workflow/git/branch-name.d.ts +0 -10
- package/dist/modules/workflow/git/branch-name.d.ts.map +0 -1
- package/dist/modules/workflow/git/branch-name.js +0 -76
- package/dist/modules/workflow/git/branch-name.js.map +0 -1
- package/dist/modules/workflow/git/clone-config.d.ts +0 -61
- package/dist/modules/workflow/git/clone-config.d.ts.map +0 -1
- package/dist/modules/workflow/git/clone-config.js +0 -69
- package/dist/modules/workflow/git/clone-config.js.map +0 -1
- package/dist/modules/workflow/git/mutex.d.ts +0 -11
- package/dist/modules/workflow/git/mutex.d.ts.map +0 -1
- package/dist/modules/workflow/git/mutex.js +0 -23
- package/dist/modules/workflow/git/mutex.js.map +0 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +0 -137
- package/dist/modules/workflow/locking-filesystem.d.ts.map +0 -1
- package/dist/modules/workflow/locking-filesystem.js +0 -553
- package/dist/modules/workflow/locking-filesystem.js.map +0 -1
- package/dist/modules/workflow/read-only-filesystem.d.ts +0 -24
- package/dist/modules/workflow/read-only-filesystem.d.ts.map +0 -1
- package/dist/modules/workflow/read-only-filesystem.js +0 -39
- package/dist/modules/workflow/read-only-filesystem.js.map +0 -1
- package/dist/modules/workflow/workflow.errors.d.ts +0 -197
- package/dist/modules/workflow/workflow.errors.d.ts.map +0 -1
- package/dist/modules/workflow/workflow.errors.js +0 -298
- package/dist/modules/workflow/workflow.errors.js.map +0 -1
- package/dist/modules/workspace/bevel-ignore.d.ts +0 -30
- package/dist/modules/workspace/bevel-ignore.d.ts.map +0 -1
- package/dist/modules/workspace/bevel-ignore.js +0 -61
- package/dist/modules/workspace/bevel-ignore.js.map +0 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +0 -36
- package/dist/modules/workspace/kb-seed.interface.d.ts.map +0 -1
- package/dist/modules/workspace/kb-seed.interface.js +0 -2
- package/dist/modules/workspace/kb-seed.interface.js.map +0 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +0 -125
- package/dist/modules/workspace/kb-seed.service.d.ts.map +0 -1
- package/dist/modules/workspace/kb-seed.service.js +0 -535
- package/dist/modules/workspace/kb-seed.service.js.map +0 -1
- package/dist/modules/workspace/plugins-migration.d.ts +0 -50
- package/dist/modules/workspace/plugins-migration.d.ts.map +0 -1
- package/dist/modules/workspace/plugins-migration.js +0 -379
- package/dist/modules/workspace/plugins-migration.js.map +0 -1
- package/dist/shared/fs-errors.d.ts +0 -8
- package/dist/shared/fs-errors.d.ts.map +0 -1
- package/dist/shared/fs-errors.js +0 -11
- package/dist/shared/fs-errors.js.map +0 -1
- package/dist/shared/fs-walk.d.ts +0 -23
- package/dist/shared/fs-walk.d.ts.map +0 -1
- package/dist/shared/fs-walk.js +0 -42
- package/dist/shared/fs-walk.js.map +0 -1
- package/dist/shared/hash-email.d.ts +0 -11
- package/dist/shared/hash-email.d.ts.map +0 -1
- package/dist/shared/hash-email.js +0 -14
- package/dist/shared/hash-email.js.map +0 -1
- package/dist/shared/kb-walk.d.ts +0 -52
- package/dist/shared/kb-walk.d.ts.map +0 -1
- package/dist/shared/kb-walk.js +0 -76
- package/dist/shared/kb-walk.js.map +0 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import type { Server } from 'node:http';
|
|
4
|
+
import { createAuditRoutes } from '../audit.routes.js';
|
|
5
|
+
import {
|
|
6
|
+
AuditPrincipalNotFoundError,
|
|
7
|
+
InvalidCursorError,
|
|
8
|
+
type AuditPrincipal,
|
|
9
|
+
type IAgentAuditService,
|
|
10
|
+
} from '../audit.contract.js';
|
|
11
|
+
import { TokenNotFoundError } from '../../tool-auth/external-api-key.errors.js';
|
|
12
|
+
import type { IAdminAccessService } from '../../admin/admin.interface.js';
|
|
13
|
+
|
|
14
|
+
const ALICE = { id: 'u-alice', email: 'alice@example.com', name: 'Alice' };
|
|
15
|
+
const BOB = { id: 'u-bob', email: 'bob@example.com', name: 'Bob' };
|
|
16
|
+
const KEY_ID = '0f1e2d3c-4b5a-4697-8877-665544332211';
|
|
17
|
+
const AGENT_ID = '11111111-2222-4333-8444-555555555555';
|
|
18
|
+
const ALICE_AGENT_ID = '22222222-3333-4444-8555-666666666666';
|
|
19
|
+
|
|
20
|
+
const ALICE_KEY: AuditPrincipal = {
|
|
21
|
+
kind: 'key',
|
|
22
|
+
id: KEY_ID,
|
|
23
|
+
label: 'CI pipeline',
|
|
24
|
+
keyKind: 'key',
|
|
25
|
+
createdAt: Date.UTC(2026, 0, 1),
|
|
26
|
+
lastUsedAt: null,
|
|
27
|
+
revokedAt: null,
|
|
28
|
+
revokedBy: null,
|
|
29
|
+
eventCount: 2,
|
|
30
|
+
user: ALICE,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Who owns what, as the fake service answers `ownerOf`. */
|
|
34
|
+
const OWNERS: Record<string, string> = { [KEY_ID]: ALICE.id, [AGENT_ID]: BOB.id, [ALICE_AGENT_ID]: ALICE.id };
|
|
35
|
+
|
|
36
|
+
const audit = {
|
|
37
|
+
listPrincipals: vi.fn(async () => [ALICE_KEY]),
|
|
38
|
+
ownerOf: vi.fn(async (p: { id: string }) => OWNERS[p.id] ?? null),
|
|
39
|
+
listEvents: vi.fn(async () => ({ events: [], total: 0, nextCursor: null })),
|
|
40
|
+
revokeConnection: vi.fn(async () => {}),
|
|
41
|
+
} satisfies IAgentAuditService;
|
|
42
|
+
|
|
43
|
+
const keys = {
|
|
44
|
+
revoke: vi.fn(async () => {}),
|
|
45
|
+
revokeAny: vi.fn(async () => {}),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function makeApp(opts: { admin: boolean; as?: { id: string; email: string } }) {
|
|
49
|
+
const adminAccess: IAdminAccessService = { isAdmin: vi.fn(async () => opts.admin) };
|
|
50
|
+
const app = express();
|
|
51
|
+
app.use(express.json());
|
|
52
|
+
// Stand-in auth middleware: stamps the caller identity the way the real JWT middleware does.
|
|
53
|
+
app.use((req, _res, next) => {
|
|
54
|
+
req.userId = opts.as?.id ?? ALICE.id;
|
|
55
|
+
req.userEmail = opts.as?.email ?? ALICE.email;
|
|
56
|
+
next();
|
|
57
|
+
});
|
|
58
|
+
app.use('/api', createAuditRoutes(audit, keys, adminAccess));
|
|
59
|
+
return app;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let server: Server;
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
server?.close();
|
|
65
|
+
vi.clearAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
async function listen(app: express.Express): Promise<string> {
|
|
69
|
+
await new Promise<void>((resolve) => {
|
|
70
|
+
server = app.listen(0, resolve);
|
|
71
|
+
});
|
|
72
|
+
const address = server.address();
|
|
73
|
+
if (typeof address === 'string' || !address) throw new Error('no port');
|
|
74
|
+
return `http://127.0.0.1:${address.port}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('audit routes — principals', () => {
|
|
78
|
+
it("lists the caller's own rows by default, and refuses scope=all to a member", async () => {
|
|
79
|
+
const base = await listen(makeApp({ admin: false }));
|
|
80
|
+
const mine = await fetch(`${base}/api/audit/principals`);
|
|
81
|
+
expect(mine.status).toBe(200);
|
|
82
|
+
expect(await mine.json()).toEqual({ principals: [ALICE_KEY] });
|
|
83
|
+
expect(audit.listPrincipals).toHaveBeenCalledWith({ userId: ALICE.id });
|
|
84
|
+
|
|
85
|
+
expect((await fetch(`${base}/api/audit/principals?scope=all`)).status).toBe(403);
|
|
86
|
+
expect(audit.listPrincipals).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('lists every account for an admin asking for scope=all', async () => {
|
|
90
|
+
const base = await listen(makeApp({ admin: true }));
|
|
91
|
+
const res = await fetch(`${base}/api/audit/principals?scope=all`);
|
|
92
|
+
expect(res.status).toBe(200);
|
|
93
|
+
expect(audit.listPrincipals).toHaveBeenCalledWith('all');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('audit routes — events', () => {
|
|
98
|
+
it('serves the owner their events, with the page parameters passed through and clamped', async () => {
|
|
99
|
+
const base = await listen(makeApp({ admin: false }));
|
|
100
|
+
const res = await fetch(`${base}/api/audit/principals/key/${KEY_ID}/events?before=123.e-9&limit=5000`);
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
expect(await res.json()).toEqual({ events: [], total: 0, nextCursor: null });
|
|
103
|
+
expect(audit.listEvents).toHaveBeenCalledWith({ kind: 'key', id: KEY_ID }, { before: '123.e-9', limit: 200 });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('answers a cursor the service does not recognise with 400, not 500', async () => {
|
|
107
|
+
const base = await listen(makeApp({ admin: false }));
|
|
108
|
+
audit.listEvents.mockRejectedValueOnce(new InvalidCursorError());
|
|
109
|
+
const res = await fetch(`${base}/api/audit/principals/key/${KEY_ID}/events?before=123.bad`);
|
|
110
|
+
expect(res.status).toBe(400);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("refuses another person's principal to a member, and serves it to an admin", async () => {
|
|
114
|
+
const asAlice = await listen(makeApp({ admin: false }));
|
|
115
|
+
expect((await fetch(`${asAlice}/api/audit/principals/agent/${AGENT_ID}/events`)).status).toBe(403);
|
|
116
|
+
expect(audit.listEvents).not.toHaveBeenCalled();
|
|
117
|
+
server.close();
|
|
118
|
+
|
|
119
|
+
const asAdmin = await listen(makeApp({ admin: true }));
|
|
120
|
+
expect((await fetch(`${asAdmin}/api/audit/principals/agent/${AGENT_ID}/events`)).status).toBe(200);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('404s an unknown kind, a malformed id, and a principal nobody owns', async () => {
|
|
124
|
+
const base = await listen(makeApp({ admin: true }));
|
|
125
|
+
expect((await fetch(`${base}/api/audit/principals/robot/${KEY_ID}/events`)).status).toBe(404);
|
|
126
|
+
expect((await fetch(`${base}/api/audit/principals/key/not-a-uuid/events`)).status).toBe(404);
|
|
127
|
+
expect(
|
|
128
|
+
(await fetch(`${base}/api/audit/principals/key/00000000-0000-4000-8000-000000000000/events`)).status,
|
|
129
|
+
).toBe(404);
|
|
130
|
+
expect(audit.listEvents).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('audit routes — revoke an agent', () => {
|
|
135
|
+
it("scopes a member's revoke to their own connections and records it as the owner's", async () => {
|
|
136
|
+
const base = await listen(makeApp({ admin: false, as: BOB }));
|
|
137
|
+
const res = await fetch(`${base}/api/audit/agents/${AGENT_ID}`, { method: 'DELETE' });
|
|
138
|
+
expect(res.status).toBe(200);
|
|
139
|
+
expect(audit.revokeConnection).toHaveBeenCalledWith(AGENT_ID, 'owner', BOB.id);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("revokes anyone else's connection for an admin, recorded as an admin's doing", async () => {
|
|
143
|
+
const base = await listen(makeApp({ admin: true }));
|
|
144
|
+
const res = await fetch(`${base}/api/audit/agents/${AGENT_ID}`, { method: 'DELETE' });
|
|
145
|
+
expect(res.status).toBe(200);
|
|
146
|
+
expect(audit.revokeConnection).toHaveBeenCalledWith(AGENT_ID, 'admin');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("records an admin revoking their OWN agent as the owner's doing, like anyone else's", async () => {
|
|
150
|
+
const base = await listen(makeApp({ admin: true }));
|
|
151
|
+
const res = await fetch(`${base}/api/audit/agents/${ALICE_AGENT_ID}`, { method: 'DELETE' });
|
|
152
|
+
expect(res.status).toBe(200);
|
|
153
|
+
expect(audit.revokeConnection).toHaveBeenCalledWith(ALICE_AGENT_ID, 'owner', ALICE.id);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("refuses another person's agent to a member, and 404s a malformed or unknown id, all without revoking", async () => {
|
|
157
|
+
const base = await listen(makeApp({ admin: false }));
|
|
158
|
+
expect((await fetch(`${base}/api/audit/agents/nope`, { method: 'DELETE' })).status).toBe(404);
|
|
159
|
+
expect((await fetch(`${base}/api/audit/agents/${AGENT_ID}`, { method: 'DELETE' })).status).toBe(403);
|
|
160
|
+
expect(
|
|
161
|
+
(await fetch(`${base}/api/audit/agents/00000000-0000-4000-8000-000000000000`, { method: 'DELETE' })).status,
|
|
162
|
+
).toBe(404);
|
|
163
|
+
expect(audit.revokeConnection).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('404s when the connection vanished between the ownership check and the revoke', async () => {
|
|
167
|
+
const base = await listen(makeApp({ admin: false, as: BOB }));
|
|
168
|
+
audit.revokeConnection.mockRejectedValueOnce(new AuditPrincipalNotFoundError());
|
|
169
|
+
expect((await fetch(`${base}/api/audit/agents/${AGENT_ID}`, { method: 'DELETE' })).status).toBe(404);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('audit routes — revoke a key', () => {
|
|
174
|
+
it("revokes the caller's own key through the owner path", async () => {
|
|
175
|
+
const base = await listen(makeApp({ admin: false }));
|
|
176
|
+
const res = await fetch(`${base}/api/audit/keys/${KEY_ID}`, { method: 'DELETE' });
|
|
177
|
+
expect(res.status).toBe(200);
|
|
178
|
+
expect(keys.revoke).toHaveBeenCalledWith(KEY_ID, ALICE.id);
|
|
179
|
+
expect(keys.revokeAny).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("revokes someone else's key only for an admin, through the admin path", async () => {
|
|
183
|
+
const asBob = await listen(makeApp({ admin: false, as: BOB }));
|
|
184
|
+
expect((await fetch(`${asBob}/api/audit/keys/${KEY_ID}`, { method: 'DELETE' })).status).toBe(403);
|
|
185
|
+
expect(keys.revoke).not.toHaveBeenCalled();
|
|
186
|
+
expect(keys.revokeAny).not.toHaveBeenCalled();
|
|
187
|
+
server.close();
|
|
188
|
+
|
|
189
|
+
const asAdmin = await listen(makeApp({ admin: true, as: BOB }));
|
|
190
|
+
expect((await fetch(`${asAdmin}/api/audit/keys/${KEY_ID}`, { method: 'DELETE' })).status).toBe(200);
|
|
191
|
+
expect(keys.revokeAny).toHaveBeenCalledWith(KEY_ID);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('404s an unknown key, for members and admins alike', async () => {
|
|
195
|
+
const base = await listen(makeApp({ admin: true }));
|
|
196
|
+
const ghost = '00000000-0000-4000-8000-000000000000';
|
|
197
|
+
keys.revokeAny.mockRejectedValueOnce(new TokenNotFoundError());
|
|
198
|
+
expect((await fetch(`${base}/api/audit/keys/${ghost}`, { method: 'DELETE' })).status).toBe(404);
|
|
199
|
+
expect((await fetch(`${base}/api/audit/keys/garbage`, { method: 'DELETE' })).status).toBe(404);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { classifyToolCall, skillContaining, skillReadPath, splitUtcpName } from '../event-classifier.js';
|
|
3
|
+
|
|
4
|
+
const KB = 'KNOWLEDGE_BASE';
|
|
5
|
+
const ctx = { kbManualName: KB, catalogNames: new Map([['my_server', 'my-server']]) };
|
|
6
|
+
const skills = [
|
|
7
|
+
{ name: 'rfi', path: 'Plugins/Sales/rfi' },
|
|
8
|
+
{ name: 'deploy-checklist', path: 'Skills/Engineering/deploy-checklist' },
|
|
9
|
+
// Nested under another skill's folder: the longer match must win.
|
|
10
|
+
{ name: 'rfi-followup', path: 'Plugins/Sales/rfi/followup' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
describe('splitUtcpName', () => {
|
|
14
|
+
it('splits on the first dot and leaves a bare name without a manual', () => {
|
|
15
|
+
expect(splitUtcpName('KNOWLEDGE_BASE.read_file')).toEqual({ manual: 'KNOWLEDGE_BASE', tool: 'read_file' });
|
|
16
|
+
expect(splitUtcpName('notion.notion-search')).toEqual({ manual: 'notion', tool: 'notion-search' });
|
|
17
|
+
expect(splitUtcpName('call_tool_chain')).toEqual({ manual: '', tool: 'call_tool_chain' });
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('skillReadPath', () => {
|
|
22
|
+
it('asks for the skill catalog only for a platform read that names a path, or a get_skill with a name', () => {
|
|
23
|
+
expect(skillReadPath(`${KB}.read_file`, { body: { path: 'Plugins/Sales/rfi/SKILL.md' } }, KB)).toBe(
|
|
24
|
+
'Plugins/Sales/rfi/SKILL.md',
|
|
25
|
+
);
|
|
26
|
+
expect(skillReadPath(`${KB}.grep`, { path: 'Skills' }, KB)).toBe('Skills');
|
|
27
|
+
expect(skillReadPath(`${KB}.get_skill`, { body: { name: 'rfi' } }, KB)).toBe('');
|
|
28
|
+
// Not worth a catalog fetch: a write, a read with no path, another manual's tool, a meta-tool.
|
|
29
|
+
expect(skillReadPath(`${KB}.write_file`, { body: { path: 'Plugins/x' } }, KB)).toBeNull();
|
|
30
|
+
expect(skillReadPath(`${KB}.read_file`, { body: {} }, KB)).toBeNull();
|
|
31
|
+
expect(skillReadPath('notion.search', { body: { path: 'Plugins/x' } }, KB)).toBeNull();
|
|
32
|
+
expect(skillReadPath('call_tool_chain', { code: 'x' }, KB)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('skillContaining', () => {
|
|
37
|
+
it('matches a path inside a skill folder on a segment boundary, from either root, longest folder first', () => {
|
|
38
|
+
expect(skillContaining('Plugins/Sales/rfi/SKILL.md', skills)?.name).toBe('rfi');
|
|
39
|
+
expect(skillContaining('knowledge-base/Plugins/Sales/rfi/scripts/build.py', skills)?.name).toBe('rfi');
|
|
40
|
+
expect(skillContaining('./Skills\\Engineering\\deploy-checklist\\SKILL.md', skills)?.name).toBe('deploy-checklist');
|
|
41
|
+
expect(skillContaining('Plugins/Sales/rfi/followup/SKILL.md', skills)?.name).toBe('rfi-followup');
|
|
42
|
+
// The file tools accept a doubled separator as the same path; so does this.
|
|
43
|
+
expect(skillContaining('Plugins//Sales/rfi/SKILL.md', skills)?.name).toBe('rfi');
|
|
44
|
+
// `.` and `..` are resolved before matching: a path that climbs out of a
|
|
45
|
+
// skill folder is not a read of that skill, and one that climbs above its
|
|
46
|
+
// own start is no place at all.
|
|
47
|
+
expect(skillContaining('Plugins/Sales/rfi/./SKILL.md', skills)?.name).toBe('rfi');
|
|
48
|
+
expect(skillContaining('Plugins/Sales/other/../rfi/SKILL.md', skills)?.name).toBe('rfi');
|
|
49
|
+
expect(skillContaining('Plugins/Sales/rfi/../../../KnowledgeBase/Secrets.md', skills)).toBeNull();
|
|
50
|
+
expect(skillContaining('../Plugins/Sales/rfi/SKILL.md', skills)).toBeNull();
|
|
51
|
+
// A folder that merely shares a prefix is not the skill's folder.
|
|
52
|
+
expect(skillContaining('Plugins/Sales/rfi-old/notes.md', skills)).toBeNull();
|
|
53
|
+
expect(skillContaining('KnowledgeBase/Product/roadmap.md', skills)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('classifyToolCall', () => {
|
|
58
|
+
it('logs the platform manual and the meta-tools as hexis capabilities, by bare name', () => {
|
|
59
|
+
expect(classifyToolCall(`${KB}.write_file`, { body: { path: 'x' } }, ctx, null)).toEqual({
|
|
60
|
+
kind: 'capability',
|
|
61
|
+
manual: null,
|
|
62
|
+
name: 'write_file',
|
|
63
|
+
});
|
|
64
|
+
expect(classifyToolCall('call_tool_chain', { code: 'x' }, ctx, null)).toEqual({
|
|
65
|
+
kind: 'capability',
|
|
66
|
+
manual: null,
|
|
67
|
+
name: 'call_tool_chain',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("logs another manual's tool as a tool under its catalog name", () => {
|
|
72
|
+
expect(classifyToolCall('my_server.search', { q: 'x' }, ctx, null)).toEqual({
|
|
73
|
+
kind: 'tool',
|
|
74
|
+
manual: 'my-server',
|
|
75
|
+
name: 'search',
|
|
76
|
+
});
|
|
77
|
+
// An http `.tool` manual has no rewrite, so its registered name is the catalog name.
|
|
78
|
+
expect(classifyToolCall('hubspot.search_contacts', {}, ctx, null)).toEqual({
|
|
79
|
+
kind: 'tool',
|
|
80
|
+
manual: 'hubspot',
|
|
81
|
+
name: 'search_contacts',
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('logs get_skill as a skill read, with the folder when the catalog knows it', () => {
|
|
86
|
+
expect(classifyToolCall(`${KB}.get_skill`, { body: { name: 'rfi' } }, ctx, skills)).toEqual({
|
|
87
|
+
kind: 'skill',
|
|
88
|
+
manual: 'Plugins/Sales/rfi',
|
|
89
|
+
name: 'rfi',
|
|
90
|
+
});
|
|
91
|
+
expect(classifyToolCall(`${KB}.get_skill`, { name: 'unknown' }, ctx, skills)).toEqual({
|
|
92
|
+
kind: 'skill',
|
|
93
|
+
manual: null,
|
|
94
|
+
name: 'unknown',
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('logs a platform read inside a skill folder as that skill, and elsewhere as the read', () => {
|
|
99
|
+
expect(classifyToolCall(`${KB}.read_file`, { body: { path: 'Plugins/Sales/rfi/SKILL.md' } }, ctx, skills)).toEqual({
|
|
100
|
+
kind: 'skill',
|
|
101
|
+
manual: 'Plugins/Sales/rfi',
|
|
102
|
+
name: 'rfi',
|
|
103
|
+
});
|
|
104
|
+
expect(classifyToolCall(`${KB}.list_files`, { body: { path: 'Skills/Engineering/deploy-checklist' } }, ctx, skills)).toEqual({
|
|
105
|
+
kind: 'skill',
|
|
106
|
+
manual: 'Skills/Engineering/deploy-checklist',
|
|
107
|
+
name: 'deploy-checklist',
|
|
108
|
+
});
|
|
109
|
+
expect(classifyToolCall(`${KB}.read_file`, { body: { path: 'KnowledgeBase/Product/roadmap.md' } }, ctx, skills)).toEqual({
|
|
110
|
+
kind: 'capability',
|
|
111
|
+
manual: null,
|
|
112
|
+
name: 'read_file',
|
|
113
|
+
});
|
|
114
|
+
// No catalog (the fetch failed): the honest fallback is the read itself.
|
|
115
|
+
expect(classifyToolCall(`${KB}.read_file`, { body: { path: 'Plugins/Sales/rfi/SKILL.md' } }, ctx, null)).toEqual({
|
|
116
|
+
kind: 'capability',
|
|
117
|
+
manual: null,
|
|
118
|
+
name: 'read_file',
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { and, asc, count, desc, eq, isNull, lt, or, type SQL } from 'drizzle-orm';
|
|
2
|
+
import { logger } from '../../shared/logging.js';
|
|
3
|
+
import type { Database } from '../database/connection.js';
|
|
4
|
+
import { agentConnections, agentEvents, oauthTokens, users } from '../database/schema.js';
|
|
5
|
+
import type { IExternalApiKeyService } from '../tool-auth/external-api-key.interface.js';
|
|
6
|
+
import {
|
|
7
|
+
AuditPrincipalNotFoundError,
|
|
8
|
+
InvalidCursorError,
|
|
9
|
+
isUuid,
|
|
10
|
+
parseRetentionWindow,
|
|
11
|
+
type AgentEventInput,
|
|
12
|
+
type AgentEventPage,
|
|
13
|
+
type AgentEventView,
|
|
14
|
+
type AuditPrincipal,
|
|
15
|
+
type AuditPrincipalRef,
|
|
16
|
+
type AuditScope,
|
|
17
|
+
type IAgentAuditService,
|
|
18
|
+
type IAgentEventRecorder,
|
|
19
|
+
} from './audit.contract.js';
|
|
20
|
+
|
|
21
|
+
const log = logger('audit');
|
|
22
|
+
|
|
23
|
+
/** The most events one page may carry; the routes clamp to it. */
|
|
24
|
+
export const MAX_EVENT_PAGE = 200;
|
|
25
|
+
|
|
26
|
+
/** How often, at most, one process sweeps events past the retention window. */
|
|
27
|
+
const PRUNE_INTERVAL_MS = 60 * 60_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The Audit log: the write half the MCP proxy records through, and the read
|
|
31
|
+
* and revoke half its page is built on.
|
|
32
|
+
*
|
|
33
|
+
* Writes are fire-and-forget by contract — a tool result must never wait on,
|
|
34
|
+
* or fail over, its own bookkeeping. Retention is enforced opportunistically
|
|
35
|
+
* on the write path (one sweep per process per hour, piggybacked on an
|
|
36
|
+
* insert), the same way the OAuth tables prune themselves on mint: no
|
|
37
|
+
* scheduler, and a deployment that records nothing has nothing to sweep.
|
|
38
|
+
*
|
|
39
|
+
* Connection keys are read through the key service rather than joined here,
|
|
40
|
+
* so their summary shape (kind, revoked-by, …) has exactly one definition.
|
|
41
|
+
*/
|
|
42
|
+
export class AgentAuditService implements IAgentAuditService, IAgentEventRecorder {
|
|
43
|
+
private lastPruneAt = 0;
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
private readonly db: Database,
|
|
47
|
+
private readonly keys: Pick<IExternalApiKeyService, 'listForUser' | 'listForDeployment' | 'ownerOf'>,
|
|
48
|
+
/** Read per sweep, so a changed setting applies without a restart; null = keep forever. */
|
|
49
|
+
private readonly retentionDays: () => number | null,
|
|
50
|
+
private readonly now: () => number = Date.now,
|
|
51
|
+
) {}
|
|
52
|
+
|
|
53
|
+
record(event: AgentEventInput): void {
|
|
54
|
+
this.db
|
|
55
|
+
.insert(agentEvents)
|
|
56
|
+
.values({
|
|
57
|
+
userId: event.userId,
|
|
58
|
+
keyId: event.principal.kind === 'key' ? event.principal.id : null,
|
|
59
|
+
connectionId: event.principal.kind === 'agent' ? event.principal.id : null,
|
|
60
|
+
kind: event.kind,
|
|
61
|
+
manual: event.manual,
|
|
62
|
+
name: event.name,
|
|
63
|
+
outcome: event.outcome,
|
|
64
|
+
durationMs: event.durationMs,
|
|
65
|
+
at: new Date(this.now()),
|
|
66
|
+
})
|
|
67
|
+
.then(
|
|
68
|
+
() => this.maybePrune(),
|
|
69
|
+
(err) => log.warn('recording an agent event failed:', { err }),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Sweep events older than the retention window — at most once per hour per
|
|
75
|
+
* process, best-effort, never awaited by a caller. A deployment that keeps
|
|
76
|
+
* events forever sweeps nothing.
|
|
77
|
+
*/
|
|
78
|
+
private async maybePrune(): Promise<void> {
|
|
79
|
+
const now = this.now();
|
|
80
|
+
if (now - this.lastPruneAt < PRUNE_INTERVAL_MS) return;
|
|
81
|
+
this.lastPruneAt = now;
|
|
82
|
+
const days = this.retentionDays();
|
|
83
|
+
if (days === null) return;
|
|
84
|
+
const cutoff = new Date(now - days * 24 * 60 * 60_000);
|
|
85
|
+
try {
|
|
86
|
+
await this.db.delete(agentEvents).where(lt(agentEvents.at, cutoff));
|
|
87
|
+
} catch (err) {
|
|
88
|
+
log.warn('agent-event prune failed (non-fatal):', { err });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async listPrincipals(scope: AuditScope): Promise<AuditPrincipal[]> {
|
|
93
|
+
const mine = scope === 'all' ? null : scope.userId;
|
|
94
|
+
const [keyRows, connectionRows, countRows] = await Promise.all([
|
|
95
|
+
mine === null ? this.keys.listForDeployment() : this.keysForUser(mine),
|
|
96
|
+
this.db
|
|
97
|
+
.select({
|
|
98
|
+
connection: agentConnections,
|
|
99
|
+
user: { id: users.id, email: users.email, name: users.name },
|
|
100
|
+
})
|
|
101
|
+
.from(agentConnections)
|
|
102
|
+
.innerJoin(users, eq(agentConnections.userId, users.id))
|
|
103
|
+
.where(mine === null ? undefined : eq(agentConnections.userId, mine))
|
|
104
|
+
.orderBy(asc(users.email), desc(agentConnections.connectedAt)),
|
|
105
|
+
this.db
|
|
106
|
+
.select({ keyId: agentEvents.keyId, connectionId: agentEvents.connectionId, n: count() })
|
|
107
|
+
.from(agentEvents)
|
|
108
|
+
.where(mine === null ? undefined : eq(agentEvents.userId, mine))
|
|
109
|
+
.groupBy(agentEvents.keyId, agentEvents.connectionId),
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const counts = new Map<string, number>();
|
|
113
|
+
for (const row of countRows) {
|
|
114
|
+
const id = row.keyId ?? row.connectionId;
|
|
115
|
+
if (id) counts.set(id, Number(row.n));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const principals: AuditPrincipal[] = [
|
|
119
|
+
...keyRows.map((k) => ({
|
|
120
|
+
kind: 'key' as const,
|
|
121
|
+
id: k.id,
|
|
122
|
+
label: k.label,
|
|
123
|
+
keyKind: k.kind,
|
|
124
|
+
createdAt: k.createdAt,
|
|
125
|
+
lastUsedAt: k.lastUsedAt,
|
|
126
|
+
revokedAt: k.revokedAt,
|
|
127
|
+
revokedBy: k.revokedBy,
|
|
128
|
+
deletedAt: k.deletedAt,
|
|
129
|
+
eventCount: counts.get(k.id) ?? 0,
|
|
130
|
+
user: k.user,
|
|
131
|
+
})),
|
|
132
|
+
...connectionRows.map(({ connection: c, user }) => ({
|
|
133
|
+
kind: 'agent' as const,
|
|
134
|
+
id: c.id,
|
|
135
|
+
label: c.clientName ?? 'Unnamed agent',
|
|
136
|
+
createdAt: c.connectedAt.getTime(),
|
|
137
|
+
lastUsedAt: c.lastUsedAt ? c.lastUsedAt.getTime() : null,
|
|
138
|
+
revokedAt: c.revokedAt ? c.revokedAt.getTime() : null,
|
|
139
|
+
revokedBy: (c.revokedBy as 'owner' | 'admin' | null) ?? null,
|
|
140
|
+
eventCount: counts.get(c.id) ?? 0,
|
|
141
|
+
user,
|
|
142
|
+
})),
|
|
143
|
+
];
|
|
144
|
+
// Per account (by email), live before revoked, most recently used first,
|
|
145
|
+
// newest first among the never-used — the order the page shows without
|
|
146
|
+
// re-sorting, and the one the Connection keys overview had.
|
|
147
|
+
principals.sort((a, b) => {
|
|
148
|
+
const byEmail = a.user.email.localeCompare(b.user.email);
|
|
149
|
+
if (byEmail !== 0) return byEmail;
|
|
150
|
+
const aRevoked = a.revokedAt !== null;
|
|
151
|
+
const bRevoked = b.revokedAt !== null;
|
|
152
|
+
if (aRevoked !== bRevoked) return aRevoked ? 1 : -1;
|
|
153
|
+
const byUse = (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0);
|
|
154
|
+
return byUse !== 0 ? byUse : b.createdAt - a.createdAt;
|
|
155
|
+
});
|
|
156
|
+
return principals;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One person's keys, with the owner attached the way the deployment listing attaches it. */
|
|
160
|
+
private async keysForUser(userId: string) {
|
|
161
|
+
const [keys, [user]] = await Promise.all([
|
|
162
|
+
this.keys.listForUser(userId),
|
|
163
|
+
this.db
|
|
164
|
+
.select({ id: users.id, email: users.email, name: users.name })
|
|
165
|
+
.from(users)
|
|
166
|
+
.where(eq(users.id, userId))
|
|
167
|
+
.limit(1),
|
|
168
|
+
]);
|
|
169
|
+
if (!user) return [];
|
|
170
|
+
return keys.map((k) => ({ ...k, user }));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async ownerOf(principal: AuditPrincipalRef): Promise<string | null> {
|
|
174
|
+
if (principal.kind === 'agent') {
|
|
175
|
+
const [row] = await this.db
|
|
176
|
+
.select({ userId: agentConnections.userId })
|
|
177
|
+
.from(agentConnections)
|
|
178
|
+
.where(eq(agentConnections.id, principal.id))
|
|
179
|
+
.limit(1);
|
|
180
|
+
return row?.userId ?? null;
|
|
181
|
+
}
|
|
182
|
+
// A key is the key service's to answer for, like every other read of one.
|
|
183
|
+
return this.keys.ownerOf(principal.id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async listEvents(
|
|
187
|
+
principal: AuditPrincipalRef,
|
|
188
|
+
page: { before?: string; limit: number },
|
|
189
|
+
): Promise<AgentEventPage> {
|
|
190
|
+
const limit = Math.max(1, Math.min(MAX_EVENT_PAGE, Math.floor(page.limit)));
|
|
191
|
+
const byPrincipal =
|
|
192
|
+
principal.kind === 'key'
|
|
193
|
+
? eq(agentEvents.keyId, principal.id)
|
|
194
|
+
: eq(agentEvents.connectionId, principal.id);
|
|
195
|
+
// Keyset on (at, id): a page boundary between two events at the same
|
|
196
|
+
// instant is still a boundary, and a row inserted while the reader pages
|
|
197
|
+
// never shifts what the next page returns.
|
|
198
|
+
const cursor = page.before ? parseCursor(page.before) : null;
|
|
199
|
+
if (page.before && !cursor) throw new InvalidCursorError();
|
|
200
|
+
const after: SQL | undefined = cursor
|
|
201
|
+
? or(
|
|
202
|
+
lt(agentEvents.at, cursor.at),
|
|
203
|
+
and(eq(agentEvents.at, cursor.at), lt(agentEvents.id, cursor.id)),
|
|
204
|
+
)
|
|
205
|
+
: undefined;
|
|
206
|
+
// The total is counted on the FIRST page only: a reader paging back
|
|
207
|
+
// through a busy principal's history would otherwise pay a full count of
|
|
208
|
+
// the same number on every step, for a figure the page already holds.
|
|
209
|
+
const [rows, totals] = await Promise.all([
|
|
210
|
+
this.db
|
|
211
|
+
.select({
|
|
212
|
+
id: agentEvents.id,
|
|
213
|
+
kind: agentEvents.kind,
|
|
214
|
+
manual: agentEvents.manual,
|
|
215
|
+
name: agentEvents.name,
|
|
216
|
+
outcome: agentEvents.outcome,
|
|
217
|
+
at: agentEvents.at,
|
|
218
|
+
})
|
|
219
|
+
.from(agentEvents)
|
|
220
|
+
.where(and(byPrincipal, after))
|
|
221
|
+
.orderBy(desc(agentEvents.at), desc(agentEvents.id))
|
|
222
|
+
.limit(limit + 1),
|
|
223
|
+
cursor ? null : this.db.select({ total: count() }).from(agentEvents).where(byPrincipal),
|
|
224
|
+
]);
|
|
225
|
+
const hasMore = rows.length > limit;
|
|
226
|
+
const pageRows = hasMore ? rows.slice(0, limit) : rows;
|
|
227
|
+
const events: AgentEventView[] = pageRows.map((r) => ({
|
|
228
|
+
id: r.id,
|
|
229
|
+
kind: r.kind as AgentEventView['kind'],
|
|
230
|
+
manual: r.manual,
|
|
231
|
+
name: r.name,
|
|
232
|
+
outcome: r.outcome as AgentEventView['outcome'],
|
|
233
|
+
at: r.at.getTime(),
|
|
234
|
+
}));
|
|
235
|
+
const last = pageRows[pageRows.length - 1];
|
|
236
|
+
return {
|
|
237
|
+
events,
|
|
238
|
+
total: totals ? Number(totals[0]?.total ?? 0) : null,
|
|
239
|
+
nextCursor: hasMore && last ? formatCursor(last.at, last.id) : null,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async revokeConnection(id: string, by: 'owner' | 'admin', ownerUserId?: string): Promise<void> {
|
|
244
|
+
const scope = ownerUserId
|
|
245
|
+
? and(eq(agentConnections.id, id), eq(agentConnections.userId, ownerUserId))!
|
|
246
|
+
: eq(agentConnections.id, id);
|
|
247
|
+
await this.db.transaction(async (tx) => {
|
|
248
|
+
const revoked = await tx
|
|
249
|
+
.update(agentConnections)
|
|
250
|
+
.set({ revokedAt: new Date(this.now()), revokedBy: by })
|
|
251
|
+
.where(and(scope, isNull(agentConnections.revokedAt)))
|
|
252
|
+
.returning({ id: agentConnections.id });
|
|
253
|
+
if (revoked.length === 0) {
|
|
254
|
+
// Already revoked stays idempotent; out of scope or unknown throws —
|
|
255
|
+
// the same split `ExternalApiKeyService.markRevoked` makes.
|
|
256
|
+
const [existing] = await tx
|
|
257
|
+
.select({ id: agentConnections.id })
|
|
258
|
+
.from(agentConnections)
|
|
259
|
+
.where(scope)
|
|
260
|
+
.limit(1);
|
|
261
|
+
if (!existing) throw new AuditPrincipalNotFoundError();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Every live token of the connection, access and refresh alike: the
|
|
265
|
+
// agent's next request 401s, its refresh fails, and re-authorizing —
|
|
266
|
+
// through the browser, as a person — is the only way back in.
|
|
267
|
+
await tx
|
|
268
|
+
.update(oauthTokens)
|
|
269
|
+
.set({ revokedAt: new Date(this.now()) })
|
|
270
|
+
.where(and(eq(oauthTokens.connectionId, id), isNull(oauthTokens.revokedAt)));
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** `<epoch ms>.<uuid>` — the page's opaque-enough cursor. */
|
|
276
|
+
function formatCursor(at: Date, id: string): string {
|
|
277
|
+
return `${at.getTime()}.${id}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The cursor back into its parts, or null for anything this service did not
|
|
282
|
+
* issue: the id must be a uuid (the column is one, and Postgres refuses to
|
|
283
|
+
* compare it with anything else) and the instant a whole number of epoch
|
|
284
|
+
* milliseconds a `timestamp` can hold.
|
|
285
|
+
*/
|
|
286
|
+
function parseCursor(raw: string): { at: Date; id: string } | null {
|
|
287
|
+
const dot = raw.indexOf('.');
|
|
288
|
+
if (dot < 0) return null;
|
|
289
|
+
const msText = raw.slice(0, dot);
|
|
290
|
+
const id = raw.slice(dot + 1);
|
|
291
|
+
if (!/^\d{1,15}$/.test(msText) || !isUuid(id)) return null;
|
|
292
|
+
return { at: new Date(Number(msText)), id };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The last invalid setting value warned about, so a standing misconfiguration is said once, not once per prune. */
|
|
296
|
+
let warnedInvalidRetention: string | null = null;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The retention window the service runs on, in days — or null to keep
|
|
300
|
+
* events forever, which is what a blank, zero or negative setting means. A
|
|
301
|
+
* value that is not a window at all is kept-forever too, said out loud once:
|
|
302
|
+
* the Deployment page shows an environment value as the one in effect and
|
|
303
|
+
* would otherwise be the only place claiming a window nobody is keeping.
|
|
304
|
+
*/
|
|
305
|
+
export function retentionDaysFrom(raw: string): number | null {
|
|
306
|
+
const window = parseRetentionWindow(raw);
|
|
307
|
+
if (window === 'forever') return null;
|
|
308
|
+
if (window !== null) return window.days;
|
|
309
|
+
if (raw !== warnedInvalidRetention) {
|
|
310
|
+
warnedInvalidRetention = raw;
|
|
311
|
+
log.warn(
|
|
312
|
+
`auditRetentionDays is "${raw}", which is not a whole number of days — keeping events forever until it is fixed.`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|