diff --git a/cmd/internal/openapi/client.gen.go b/cmd/internal/openapi/client.gen.go index 68e1e8d0..5343cc9e 100644 --- a/cmd/internal/openapi/client.gen.go +++ b/cmd/internal/openapi/client.gen.go @@ -1068,34 +1068,34 @@ func (e AdminDashboardDownloadersItemsStatus) Valid() bool { // Defines values for AuditEventActorType. const ( - AuditEventActorTypeAgent AuditEventActorType = "agent" - AuditEventActorTypeAnonymous AuditEventActorType = "anonymous" - AuditEventActorTypeApiKey AuditEventActorType = "api_key" - AuditEventActorTypeDownloader AuditEventActorType = "downloader" - AuditEventActorTypeOauth AuditEventActorType = "oauth" - AuditEventActorTypeSystem AuditEventActorType = "system" - AuditEventActorTypeTaskUpload AuditEventActorType = "task-upload" - AuditEventActorTypeUser AuditEventActorType = "user" + Agent AuditEventActorType = "agent" + Anonymous AuditEventActorType = "anonymous" + ApiKey AuditEventActorType = "api_key" + Device AuditEventActorType = "device" + Oauth AuditEventActorType = "oauth" + System AuditEventActorType = "system" + TaskUpload AuditEventActorType = "task-upload" + User AuditEventActorType = "user" ) // Valid indicates whether the value is a known member of the AuditEventActorType enum. func (e AuditEventActorType) Valid() bool { switch e { - case AuditEventActorTypeAgent: + case Agent: return true - case AuditEventActorTypeAnonymous: + case Anonymous: return true - case AuditEventActorTypeApiKey: + case ApiKey: return true - case AuditEventActorTypeDownloader: + case Device: return true - case AuditEventActorTypeOauth: + case Oauth: return true - case AuditEventActorTypeSystem: + case System: return true - case AuditEventActorTypeTaskUpload: + case TaskUpload: return true - case AuditEventActorTypeUser: + case User: return true default: return false @@ -4895,7 +4895,12 @@ type AnnouncementList struct { // AuditEvent defines model for AuditEvent. type AuditEvent struct { - Action string `json:"action"` + Action string `json:"action"` + Actor struct { + Image *string `json:"image"` + Name string `json:"name"` + Resolved bool `json:"resolved"` + } `json:"actor"` ActorIssuer *string `json:"actorIssuer"` ActorRef *string `json:"actorRef"` ActorType AuditEventActorType `json:"actorType"` diff --git a/docs/verification/pr-557-audit-agent-identities.png b/docs/verification/pr-557-audit-agent-identities.png new file mode 100644 index 00000000..a3c54ffb Binary files /dev/null and b/docs/verification/pr-557-audit-agent-identities.png differ diff --git a/docs/verification/pr-557-audit-api-key-identity.png b/docs/verification/pr-557-audit-api-key-identity.png new file mode 100644 index 00000000..60810020 Binary files /dev/null and b/docs/verification/pr-557-audit-api-key-identity.png differ diff --git a/package.json b/package.json index a35a0af4..291352be 100644 --- a/package.json +++ b/package.json @@ -87,11 +87,11 @@ "i18next-browser-languagedetector": "^8.2.1", "jose": "6.2.3", "lucide-react": "^0.577.0", - "nanoid": "^5.1.11", + "nanoid": "^5.1.16", "next-themes": "^0.4.6", "nodemailer": "^9.0.1", "paseto-ts": "^2.0.6", - "pdfjs-dist": "^5.6.205", + "pdfjs-dist": "^6.2.108", "posthog-node": "^5.36.4", "radix-ui": "^1.4.3", "react": "^19.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a190fae..85d3fd24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,8 +134,8 @@ importers: specifier: ^0.577.0 version: 0.577.0(react@19.2.5) nanoid: - specifier: ^5.1.11 - version: 5.1.11 + specifier: ^5.1.16 + version: 5.1.16 next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -146,8 +146,8 @@ importers: specifier: ^2.0.6 version: 2.0.6 pdfjs-dist: - specifier: ^5.6.205 - version: 5.6.205 + specifier: ^6.2.108 + version: 6.2.108 posthog-node: specifier: ^5.36.4 version: 5.36.4 @@ -1506,24 +1506,48 @@ packages: cpu: [arm64] os: [android] + '@napi-rs/canvas-android-arm64@1.0.3': + resolution: {integrity: sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + '@napi-rs/canvas-darwin-arm64@0.1.100': resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@napi-rs/canvas-darwin-arm64@1.0.3': + resolution: {integrity: sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@napi-rs/canvas-darwin-x64@0.1.100': resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@napi-rs/canvas-darwin-x64@1.0.3': + resolution: {integrity: sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} engines: {node: '>= 10'} cpu: [arm] os: [linux] + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} engines: {node: '>= 10'} @@ -1531,6 +1555,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-arm64-musl@0.1.100': resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} engines: {node: '>= 10'} @@ -1538,6 +1569,13 @@ packages: os: [linux] libc: [musl] + '@napi-rs/canvas-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} engines: {node: '>= 10'} @@ -1545,6 +1583,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-riscv64-gnu@1.0.3': + resolution: {integrity: sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-x64-gnu@0.1.100': resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} engines: {node: '>= 10'} @@ -1552,6 +1597,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-x64-musl@0.1.100': resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} engines: {node: '>= 10'} @@ -1559,22 +1611,45 @@ packages: os: [linux] libc: [musl] + '@napi-rs/canvas-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@napi-rs/canvas-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@napi-rs/canvas-win32-x64-msvc@0.1.100': resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@napi-rs/canvas-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@napi-rs/canvas@0.1.100': resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} engines: {node: '>= 10'} + '@napi-rs/canvas@1.0.3': + resolution: {integrity: sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==} + engines: {node: '>= 10'} + '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} @@ -4601,13 +4676,13 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.11: - resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} engines: {node: ^18 || >=20} hasBin: true @@ -4649,9 +4724,6 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-readable-to-web-readable-stream@0.4.2: - resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} - node-releases@2.0.46: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} @@ -4725,9 +4797,9 @@ packages: resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} engines: {node: '>=20.16.0 || >=22.3.0'} - pdfjs-dist@5.6.205: - resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==} - engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} + pdfjs-dist@6.2.108: + resolution: {integrity: sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==} + engines: {node: '>=22.13.0 || >=24'} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -6924,36 +6996,69 @@ snapshots: '@napi-rs/canvas-android-arm64@0.1.100': optional: true + '@napi-rs/canvas-android-arm64@1.0.3': + optional: true + '@napi-rs/canvas-darwin-arm64@0.1.100': optional: true + '@napi-rs/canvas-darwin-arm64@1.0.3': + optional: true + '@napi-rs/canvas-darwin-x64@0.1.100': optional: true + '@napi-rs/canvas-darwin-x64@1.0.3': + optional: true + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': optional: true + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.3': + optional: true + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': optional: true + '@napi-rs/canvas-linux-arm64-gnu@1.0.3': + optional: true + '@napi-rs/canvas-linux-arm64-musl@0.1.100': optional: true + '@napi-rs/canvas-linux-arm64-musl@1.0.3': + optional: true + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': optional: true + '@napi-rs/canvas-linux-riscv64-gnu@1.0.3': + optional: true + '@napi-rs/canvas-linux-x64-gnu@0.1.100': optional: true + '@napi-rs/canvas-linux-x64-gnu@1.0.3': + optional: true + '@napi-rs/canvas-linux-x64-musl@0.1.100': optional: true + '@napi-rs/canvas-linux-x64-musl@1.0.3': + optional: true + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': optional: true + '@napi-rs/canvas-win32-arm64-msvc@1.0.3': + optional: true + '@napi-rs/canvas-win32-x64-msvc@0.1.100': optional: true + '@napi-rs/canvas-win32-x64-msvc@1.0.3': + optional: true + '@napi-rs/canvas@0.1.100': optionalDependencies: '@napi-rs/canvas-android-arm64': 0.1.100 @@ -6969,6 +7074,21 @@ snapshots: '@napi-rs/canvas-win32-x64-msvc': 0.1.100 optional: true + '@napi-rs/canvas@1.0.3': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.3 + '@napi-rs/canvas-darwin-arm64': 1.0.3 + '@napi-rs/canvas-darwin-x64': 1.0.3 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.3 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.3 + '@napi-rs/canvas-linux-arm64-musl': 1.0.3 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.3 + '@napi-rs/canvas-linux-x64-gnu': 1.0.3 + '@napi-rs/canvas-linux-x64-musl': 1.0.3 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.3 + '@napi-rs/canvas-win32-x64-msvc': 1.0.3 + optional: true + '@neon-rs/load@0.0.4': {} '@noble/ciphers@2.2.0': {} @@ -7936,7 +8056,7 @@ snapshots: '@scalar/types@0.13.3': dependencies: '@scalar/helpers': 0.8.2 - nanoid: 5.1.11 + nanoid: 5.1.16 type-fest: 5.7.0 zod: 4.4.3 @@ -10094,9 +10214,9 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.16: {} + nanoid@3.3.18: {} - nanoid@5.1.11: {} + nanoid@5.1.16: {} nanostores@1.3.0: {} @@ -10125,9 +10245,6 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-readable-to-web-readable-stream@0.4.2: - optional: true - node-releases@2.0.46: {} nodemailer@9.0.1: {} @@ -10202,10 +10319,9 @@ snapshots: optionalDependencies: '@napi-rs/canvas': 0.1.100 - pdfjs-dist@5.6.205: + pdfjs-dist@6.2.108: optionalDependencies: - '@napi-rs/canvas': 0.1.100 - node-readable-to-web-readable-stream: 0.4.2 + '@napi-rs/canvas': 1.0.3 picocolors@1.1.1: {} @@ -10240,7 +10356,7 @@ snapshots: postcss@8.5.25: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/server/adapters/gateways/agent-info.integration.test.ts b/server/adapters/gateways/agent-info.integration.test.ts new file mode 100644 index 00000000..88b8a2d4 --- /dev/null +++ b/server/adapters/gateways/agent-info.integration.test.ts @@ -0,0 +1,149 @@ +import { createServer, type RequestListener } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { auditActorIdentityKey } from '../../usecases/ports' +import { createAgentInfoGateway } from './agent-info' + +const servers: Array> = [] + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))) +}) + +describe('Agent Info gateway', () => { + it('discovers and caches an Agent profile for a trusted issuer', async () => { + let discoveryRequests = 0 + let agentInfoRequests = 0 + const redirects: RequestRedirect[] = [] + const { origin } = await listen((request, response) => { + if (request.url === '/api/auth/.well-known/openid-configuration') { + discoveryRequests += 1 + response.setHeader('content-type', 'application/json') + response.end( + JSON.stringify({ issuer: `${origin}/api/auth`, agentinfo_endpoint: `${origin}/api/auth/agentinfo` }), + ) + return + } + if (request.url?.startsWith('/api/auth/agentinfo?')) { + agentInfoRequests += 1 + const subject = new URL(request.url, origin).searchParams.get('sub') + response.setHeader('content-type', 'application/json') + response.setHeader('cache-control', 'public, max-age=300') + response.end( + JSON.stringify({ + iss: `${origin}/api/auth`, + sub: subject, + name: subject === 'agt_1' ? 'Mac Agent' : 'Second Agent', + picture: `${origin}/agent.svg`, + updated_at: 1, + }), + ) + return + } + response.statusCode = 404 + response.end() + }) + const gateway = createAgentInfoGateway((input, init) => { + if (init?.redirect) redirects.push(init.redirect) + return fetch(input, init) + }) + const identity = { type: 'oauth', ref: 'agt_1', issuer: `${origin}/api/auth` } as const + const secondIdentity = { type: 'oauth', ref: 'agt_2', issuer: `${origin}/api/auth` } as const + const trustedOrigins = new Set([origin]) + + const first = await gateway.resolve([identity, secondIdentity], trustedOrigins) + const second = await gateway.resolve([identity, secondIdentity], trustedOrigins) + + expect(first.get(auditActorIdentityKey(identity))).toEqual({ + name: 'Mac Agent', + image: `${origin}/agent.svg`, + resolved: true, + }) + expect(second).toEqual(first) + expect(discoveryRequests).toBe(1) + expect(agentInfoRequests).toBe(2) + expect(redirects).toEqual(['manual', 'manual', 'manual']) + }) + + it('does not contact an untrusted issuer', async () => { + let requests = 0 + const { origin } = await listen((_request, response) => { + requests += 1 + response.end('{}') + }) + const gateway = createAgentInfoGateway() + const identity = { type: 'oauth', ref: 'agt_1', issuer: `${origin}/api/auth` } as const + + const profiles = await gateway.resolve([identity], new Set()) + + expect(profiles.size).toBe(0) + expect(requests).toBe(0) + }) + + it('rejects an Agent Info response for a different subject', async () => { + const { origin } = await listen((request, response) => { + response.setHeader('content-type', 'application/json') + if (request.url === '/api/auth/.well-known/openid-configuration') { + response.end( + JSON.stringify({ issuer: `${origin}/api/auth`, agentinfo_endpoint: `${origin}/api/auth/agentinfo` }), + ) + return + } + response.end(JSON.stringify({ iss: `${origin}/api/auth`, sub: 'agt_other', name: 'Wrong Agent' })) + }) + const gateway = createAgentInfoGateway() + const identity = { type: 'oauth', ref: 'agt_1', issuer: `${origin}/api/auth` } as const + + const profiles = await gateway.resolve([identity], new Set([origin])) + + expect(profiles.size).toBe(0) + }) + + it('rejects invalid discovery documents and ignores incomplete actor identities', async () => { + let requests = 0 + const { origin } = await listen((_request, response) => { + requests += 1 + response.statusCode = 503 + response.end() + }) + const gateway = createAgentInfoGateway() + + const profiles = await gateway.resolve( + [ + { type: 'user', ref: 'user-1', issuer: origin }, + { type: 'agent', ref: null, issuer: origin }, + { type: 'agent', ref: 'agt-1', issuer: null }, + { type: 'agent', ref: 'agt-1', issuer: `${origin}/api/auth` }, + ], + new Set([origin]), + ) + + expect(profiles.size).toBe(0) + expect(requests).toBe(1) + }) + + it('rejects Agent Info endpoints on a different origin', async () => { + const { origin } = await listen((_request, response) => { + response.setHeader('content-type', 'application/json') + response.end( + JSON.stringify({ issuer: `${origin}/api/auth`, agentinfo_endpoint: 'https://untrusted.example/agentinfo' }), + ) + }) + const gateway = createAgentInfoGateway() + + const profiles = await gateway.resolve( + [{ type: 'agent', ref: 'agt-1', issuer: `${origin}/api/auth` }], + new Set([origin]), + ) + + expect(profiles.size).toBe(0) + }) +}) + +async function listen(handler: RequestListener): Promise<{ origin: string }> { + const server = createServer(handler) + servers.push(server) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('test_server_address_missing') + return { origin: `http://127.0.0.1:${address.port}` } +} diff --git a/server/adapters/gateways/agent-info.ts b/server/adapters/gateways/agent-info.ts new file mode 100644 index 00000000..6d4b002d --- /dev/null +++ b/server/adapters/gateways/agent-info.ts @@ -0,0 +1,178 @@ +import { z } from 'zod' +import { + type AgentInfoGateway, + type AuditActorIdentity, + type AuditActorProfile, + auditActorIdentityKey, +} from '../../usecases/ports' + +const DISCOVERY_TTL_MS = 5 * 60 * 1000 +const PROFILE_TTL_MS = 5 * 60 * 1000 +const REQUEST_TIMEOUT_MS = 3_000 +const MAX_CACHE_ENTRIES = 500 +const MAX_CONCURRENT_REQUESTS = 8 + +const discoverySchema = z.object({ + issuer: z.string().url(), + agentinfo_endpoint: z.string().url(), +}) + +const agentInfoSchema = z.object({ + iss: z.string().url(), + sub: z.string().min(1), + name: z.string().min(1), + picture: z.string().url().nullable().optional(), + updated_at: z.number().optional(), +}) + +type CacheEntry = { value: T; expiresAt: number } + +export function createAgentInfoGateway(request: typeof fetch = fetch): AgentInfoGateway { + const discoveryCache = new Map>() + const discoveryInflight = new Map>() + const profileCache = new Map>() + + return { + async resolve(actors, trustedIssuerOrigins) { + const profiles = new Map() + const uniqueActors = uniqueAgentActors(actors) + await inBatches(uniqueActors, MAX_CONCURRENT_REQUESTS, async (actor) => { + const issuer = trustedIssuer(actor.issuer, trustedIssuerOrigins) + if (!issuer || !actor.ref) return + const key = auditActorIdentityKey(actor) + const cached = readCache(profileCache, key) + if (cached) { + profiles.set(key, cached) + return + } + + const profile = await loadAgentProfile(request, issuer, actor.ref, discoveryCache, discoveryInflight) + if (!profile) return + profiles.set(key, profile) + writeCache(profileCache, key, profile, PROFILE_TTL_MS) + }) + return profiles + }, + } +} + +function uniqueAgentActors(actors: readonly AuditActorIdentity[]): AuditActorIdentity[] { + const unique = new Map() + for (const actor of actors) { + if ((actor.type !== 'oauth' && actor.type !== 'agent') || !actor.ref || !actor.issuer) continue + unique.set(auditActorIdentityKey(actor), actor) + } + return [...unique.values()] +} + +function trustedIssuer(value: string | null, trustedOrigins: ReadonlySet): URL | null { + if (!value) return null + const issuer = parseSecureUrl(value) + return issuer && trustedOrigins.has(issuer.origin) ? issuer : null +} + +async function loadAgentProfile( + request: typeof fetch, + issuer: URL, + subject: string, + discoveryCache: Map>, + discoveryInflight: Map>, +): Promise { + try { + const endpoint = await agentInfoEndpoint(request, issuer, discoveryCache, discoveryInflight) + if (!endpoint) return null + const url = new URL(endpoint) + url.searchParams.set('sub', subject) + const response = await request(url, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) return null + const parsed = agentInfoSchema.safeParse(await response.json()) + if (!parsed.success || parsed.data.iss !== issuer.href.replace(/\/$/, '') || parsed.data.sub !== subject) + return null + return { name: parsed.data.name, image: parsed.data.picture ?? null, resolved: true } + } catch { + return null + } +} + +async function agentInfoEndpoint( + request: typeof fetch, + issuer: URL, + cache: Map>, + inflight: Map>, +): Promise { + const issuerValue = issuer.href.replace(/\/$/, '') + const cached = readCache(cache, issuerValue) + if (cached) return cached + const existing = inflight.get(issuerValue) + if (existing) return existing + const requestPromise = loadAgentInfoEndpoint(request, issuer, issuerValue, cache) + inflight.set(issuerValue, requestPromise) + try { + return await requestPromise + } finally { + if (inflight.get(issuerValue) === requestPromise) inflight.delete(issuerValue) + } +} + +async function loadAgentInfoEndpoint( + request: typeof fetch, + issuer: URL, + issuerValue: string, + cache: Map>, +): Promise { + const discoveryUrl = new URL(`${issuerValue}/.well-known/openid-configuration`) + const response = await request(discoveryUrl, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) return null + const parsed = discoverySchema.safeParse(await response.json()) + if (!parsed.success || parsed.data.issuer !== issuerValue) return null + const endpoint = parseSecureUrl(parsed.data.agentinfo_endpoint) + if (!endpoint || endpoint.origin !== issuer.origin) return null + writeCache(cache, issuerValue, endpoint.href, DISCOVERY_TTL_MS) + return endpoint.href +} + +function parseSecureUrl(value: string): URL | null { + try { + const url = new URL(value) + if (url.protocol === 'https:') return url + if ( + url.protocol === 'http:' && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]') + ) { + return url + } + return null + } catch { + return null + } +} + +function readCache(cache: Map>, key: string): T | null { + const entry = cache.get(key) + if (!entry) return null + if (entry.expiresAt > Date.now()) return entry.value + cache.delete(key) + return null +} + +function writeCache(cache: Map>, key: string, value: T, ttlMs: number): void { + if (cache.size >= MAX_CACHE_ENTRIES) { + const oldest = cache.keys().next() + if (!oldest.done) cache.delete(oldest.value) + } + cache.set(key, { value, expiresAt: Date.now() + ttlMs }) +} + +async function inBatches(items: readonly T[], size: number, operation: (item: T) => Promise): Promise { + for (let offset = 0; offset < items.length; offset += size) { + await Promise.all(items.slice(offset, offset + size).map(operation)) + } +} diff --git a/server/adapters/repos/audit-actor-directory.integration.test.ts b/server/adapters/repos/audit-actor-directory.integration.test.ts new file mode 100644 index 00000000..b5299895 --- /dev/null +++ b/server/adapters/repos/audit-actor-directory.integration.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { apikey, oauthClient } from '../../db/auth-schema' +import { downloaders } from '../../db/schema' +import { createTestApp } from '../../test/setup' +import { createAuditActorDirectoryRepo } from './audit-actor-directory' + +describe('audit actor directory repository', () => { + it('resolves API key names in one local lookup', async () => { + const { db } = await createTestApp() + await db.insert(apikey).values({ + id: 'key-1', + configId: 'remote-download', + name: 'CME downloader', + referenceId: 'user-1', + key: 'hashed-secret', + createdAt: new Date(0), + updatedAt: new Date(0), + }) + const directory = createAuditActorDirectoryRepo(db) + + await expect(directory.findApiKeyNames(['key-1', 'missing'])).resolves.toEqual( + new Map([['key-1', 'CME downloader']]), + ) + }) + + it('resolves device names in one local lookup', async () => { + const { db } = await createTestApp() + await db.insert(downloaders).values({ + id: 'device-1', + name: 'Office Mac', + tokenHash: 'hashed-token', + tokenJti: 'device-token-jti', + createdBy: 'user-1', + createdAt: new Date(0), + updatedAt: new Date(0), + }) + const directory = createAuditActorDirectoryRepo(db) + + await expect(directory.findDeviceNames(['device-1', 'missing'])).resolves.toEqual( + new Map([['device-1', 'Office Mac']]), + ) + }) + + it('trusts only secure issuer origins backed by an enabled registered client', async () => { + const { db } = await createTestApp() + await db.insert(oauthClient).values([ + { + id: 'oauth-client-enabled', + clientId: 'realmroot', + redirectUris: '[]', + jwksUri: 'https://id.realmroot.dev/api/auth/jwks', + }, + { + id: 'oauth-client-disabled', + clientId: 'disabled', + redirectUris: '[]', + jwksUri: 'https://disabled.example/jwks', + disabled: true, + }, + { + id: 'oauth-client-insecure', + clientId: 'insecure', + redirectUris: '[]', + jwksUri: 'http://issuer.example/jwks', + }, + { + id: 'oauth-client-local', + clientId: 'local', + redirectUris: '[]', + jwksUri: 'http://127.0.0.1:8787/jwks', + }, + { + id: 'oauth-client-invalid', + clientId: 'invalid', + redirectUris: '[]', + jwksUri: 'not a URL', + }, + ]) + const directory = createAuditActorDirectoryRepo(db) + + await expect(directory.listTrustedAgentIssuerOrigins()).resolves.toEqual( + new Set(['https://id.realmroot.dev', 'http://127.0.0.1:8787']), + ) + }) + + it('skips database queries for empty identity lists', async () => { + const { db } = await createTestApp() + const directory = createAuditActorDirectoryRepo(db) + + await expect(directory.findApiKeyNames([])).resolves.toEqual(new Map()) + await expect(directory.findDeviceNames([])).resolves.toEqual(new Map()) + }) +}) diff --git a/server/adapters/repos/audit-actor-directory.ts b/server/adapters/repos/audit-actor-directory.ts new file mode 100644 index 00000000..75ccf707 --- /dev/null +++ b/server/adapters/repos/audit-actor-directory.ts @@ -0,0 +1,58 @@ +import { inArray } from 'drizzle-orm' +import { apikey, oauthClient } from '../../db/auth-schema' +import { downloaders } from '../../db/schema' +import type { Database } from '../../platform/interface' +import type { AuditActorDirectory } from '../../usecases/ports' + +export function createAuditActorDirectoryRepo(db: Database): AuditActorDirectory { + return { + async findApiKeyNames(keyIds) { + const uniqueIds = [...new Set(keyIds)] + if (uniqueIds.length === 0) return new Map() + const rows = await db + .select({ id: apikey.id, name: apikey.name }) + .from(apikey) + .where(inArray(apikey.id, uniqueIds)) + return new Map(rows.flatMap((row) => (row.name ? [[row.id, row.name] as const] : []))) + }, + + async findDeviceNames(deviceIds) { + const uniqueIds = [...new Set(deviceIds)] + if (uniqueIds.length === 0) return new Map() + const rows = await db + .select({ id: downloaders.id, name: downloaders.name }) + .from(downloaders) + .where(inArray(downloaders.id, uniqueIds)) + return new Map(rows.map((row) => [row.id, row.name] as const)) + }, + + async listTrustedAgentIssuerOrigins() { + const clients = await db + .select({ disabled: oauthClient.disabled, jwksUri: oauthClient.jwksUri }) + .from(oauthClient) + const origins = new Set() + for (const client of clients) { + if (client.disabled === true || !client.jwksUri) continue + const url = parseSecureUrl(client.jwksUri) + if (url) origins.add(url.origin) + } + return origins + }, + } +} + +function parseSecureUrl(value: string): URL | null { + try { + const url = new URL(value) + if (url.protocol === 'https:') return url + if ( + url.protocol === 'http:' && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]') + ) { + return url + } + return null + } catch { + return null + } +} diff --git a/server/adapters/repos/audit.ts b/server/adapters/repos/audit.ts index d278d795..86dd3443 100644 --- a/server/adapters/repos/audit.ts +++ b/server/adapters/repos/audit.ts @@ -4,7 +4,7 @@ import { organization, user } from '../../db/auth-schema' import { auditEvents } from '../../db/schema' import { assertAuditEvent } from '../../domain/audit-events' import type { Database } from '../../platform/interface' -import type { AuditActorType, AuditRepo, RecordAuditEventInput } from '../../usecases/ports' +import type { AuditActorProfile, AuditActorType, AuditRepo, RecordAuditEventInput } from '../../usecases/ports' export function auditEventValues(event: RecordAuditEventInput): typeof auditEvents.$inferInsert { assertAuditEvent(event) @@ -60,13 +60,16 @@ export function idempotentSystemEventValues(input: { function normalizeActorType(value: string | null, userId?: string | null): AuditActorType { // Audit rows written before OAuth was generalized used the old compound actor type. if (value === ['agent', 'oauth'].join('_')) return 'oauth' + // Downloader records represent device identities. Keep the storage vocabulary + // compatible while exposing the product-level actor consistently. + if (value === 'downloader') return 'device' if ( value === 'api_key' || value === 'oauth' || value === 'agent' || value === 'anonymous' || value === 'system' || - value === 'downloader' || + value === 'device' || value === 'task-upload' ) return value @@ -80,11 +83,33 @@ function actorDisplayName(actorType: AuditActorType, actorRef: string | null): s if (actorType === 'oauth') return actorRef ? `OAuth:${actorRef}` : 'OAuth' if (actorType === 'agent') return actorRef ? `Agent:${actorRef}` : 'Agent' if (actorType === 'system') return actorRef ? `System:${actorRef}` : 'System' - if (actorType === 'downloader') return actorRef ? `Downloader:${actorRef}` : 'Downloader' + if (actorType === 'device') return actorRef ? `Device · ${actorRef}` : 'Device' if (actorType === 'task-upload') return actorRef ? `Task upload:${actorRef}` : 'Task upload' return '' } +function actorProfile( + actorType: AuditActorType, + actorRef: string | null, + userId: string | null, + userName: string | null, + userImage: string | null, +): AuditActorProfile { + if (actorType === 'user') { + return { name: userName ?? userId ?? 'User', image: userImage, resolved: userName !== null } + } + if (actorType === 'api_key') { + return { name: actorRef ? `API key · ${actorRef}` : 'API key', image: null, resolved: false } + } + if (actorType === 'oauth' || actorType === 'agent') { + return { name: actorRef ? `Agent · ${actorRef}` : 'Agent', image: null, resolved: false } + } + if (actorType === 'device') { + return { name: actorRef ? `Device · ${actorRef}` : 'Device', image: null, resolved: false } + } + return { name: actorDisplayName(actorType, actorRef), image: null, resolved: true } +} + export function createAuditRepo(db: Database): AuditRepo { return { async record(event) { @@ -156,6 +181,7 @@ export function createAuditRepo(db: Database): AuditRepo { name: row.userName ?? actorDisplayName(actorType, row.actorRef), image: row.userImage ?? null, }, + actor: actorProfile(actorType, row.actorRef, row.userId, row.userName, row.userImage), } }) @@ -229,6 +255,7 @@ export function createAuditRepo(db: Database): AuditRepo { name: row.userName ?? actorDisplayName(actorType, row.actorRef), image: row.userImage ?? null, }, + actor: actorProfile(actorType, row.actorRef, row.userId, row.userName, row.userImage), orgName: row.orgName ?? null, } }) diff --git a/server/composition.ts b/server/composition.ts index 78bda6ff..d404a267 100644 --- a/server/composition.ts +++ b/server/composition.ts @@ -6,6 +6,7 @@ import { type CloudflareKvNamespaceLike, createCloudflareKvBackend } from './adapters/cache/cloudflare-kv' import { createRuntimeCache, resolveCacheMode } from './adapters/cache/runtime-cache' +import { createAgentInfoGateway } from './adapters/gateways/agent-info' import { createArchiveJobsGateway } from './adapters/gateways/archive-jobs' import { createEmailGateway } from './adapters/gateways/email' import { createImageUploadGateway } from './adapters/gateways/image-upload' @@ -19,6 +20,7 @@ import { createAnnouncementRepo } from './adapters/repos/announcement' import { createApiKeyGateway } from './adapters/repos/api-keys' import { createArchiveTargetFolderRepo } from './adapters/repos/archive-target-folder' import { createAuditRepo } from './adapters/repos/audit' +import { createAuditActorDirectoryRepo } from './adapters/repos/audit-actor-directory' import { createBackgroundJobRepo } from './adapters/repos/background-job' import { createCloudStoreRepo } from './adapters/repos/cloud-store' import { createCloudTrafficReportRepo } from './adapters/repos/cloud-traffic-report' @@ -81,6 +83,8 @@ export function createDeps(platform: Platform, options: CreateDepsOptions = {}): const downloadTokens = createDownloadTokenGateway() return { audit: createAuditRepo(db), + auditActorDirectory: createAuditActorDirectoryRepo(db), + agentInfo: createAgentInfoGateway(), adminStats: createAdminStatsRepo(db), oauth: createOAuthGateway(), announcements: createAnnouncementRepo(db), diff --git a/server/http/downloads/download-tasks.integration.test.ts b/server/http/downloads/download-tasks.integration.test.ts index d483fa7e..5739ea9b 100644 --- a/server/http/downloads/download-tasks.integration.test.ts +++ b/server/http/downloads/download-tasks.integration.test.ts @@ -1065,6 +1065,18 @@ describe('Download tasks API integration', () => { expect(confirmRes.status).toBe(200) const confirmed = (await confirmRes.json()) as { id: string; status: string } expect(confirmed.status).toBe('active') + const uploadAudit = await db.all<{ actorType: string; actorRef: string | null; userId: string | null }>(sql` + SELECT actor_type AS actorType, actor_ref AS actorRef, user_id AS userId + FROM audit_events + WHERE action = 'upload_confirm' AND target_id = ${object.id} + `) + expect(uploadAudit).toEqual([ + { + actorType: 'device', + actorRef: createdDownloader.downloader.id, + userId: createdTask.createdBy, + }, + ]) const uploadingRes = await app.request(`/api/downloads/tasks/${createdTask.id}`, { method: 'PATCH', diff --git a/server/http/site/audit.integration.test.ts b/server/http/site/audit.integration.test.ts index f13af9e3..a8346006 100644 --- a/server/http/site/audit.integration.test.ts +++ b/server/http/site/audit.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { adminHeaders, authedHeaders, createTestApp, seedProLicense } from '../../test/setup.js' +import { auditActorIdentityKey } from '../../usecases/ports.js' describe('GET /api/site/audit-events — auth guards', () => { it('returns 401 without auth [spec: audit/auth-required]', async () => { @@ -50,8 +51,153 @@ describe('GET /api/site/audit-events — licensed admin', () => { const res = await app.request('/api/site/audit-events?action=objects_list', { headers }) expect(res.status).toBe(200) - const body = (await res.json()) as { items: Array<{ actorType: string; user: { name: string } }> } - expect(body.items[0]).toMatchObject({ actorType: 'oauth', user: { name: 'OAuth:controller-1' } }) + const body = (await res.json()) as { + items: Array<{ actorType: string; user: { name: string }; actor: { name: string; resolved: boolean } }> + } + expect(body.items[0]).toMatchObject({ + actorType: 'oauth', + user: { name: 'OAuth:controller-1' }, + actor: { name: 'Agent · controller-1', resolved: false }, + }) + }) + + it('returns the API key name as the actor instead of the delegated user', async () => { + const { app, db } = await createTestApp() + await seedProLicense(db) + const headers = await adminHeaders(app) + const { apikey } = await import('../../db/auth-schema.js') + const { auditEvents } = await import('../../db/schema.js') + await db.insert(apikey).values({ + id: 'key-cme', + configId: 'remote-download', + name: 'CME downloader', + referenceId: 'admin-user', + key: 'hashed-secret', + createdAt: new Date(0), + updatedAt: new Date(0), + }) + await db.insert(auditEvents).values({ + id: 'evt-api-key', + orgId: 'org-cme', + userId: 'admin-user', + actorType: 'api_key', + actorRef: 'key-cme', + action: 'download_task_created', + targetType: 'download_task', + targetId: 'task-1', + targetName: 'task-1', + createdAt: new Date(), + }) + + const res = await app.request('/api/site/audit-events?action=download_task_created', { headers }) + + expect(res.status).toBe(200) + const body = (await res.json()) as { + items: Array<{ user: { name: string }; actor: { name: string; image: string | null; resolved: boolean } }> + } + expect(body.items[0].actor).toEqual({ name: 'API key · CME downloader', image: null, resolved: true }) + expect(body.items[0].user.name).not.toBe(body.items[0].actor.name) + }) + + it('returns the registered device name and normalizes the legacy downloader actor type', async () => { + const { app, db } = await createTestApp() + await seedProLicense(db) + const headers = await adminHeaders(app) + const { auditEvents, downloaders } = await import('../../db/schema.js') + await db.insert(downloaders).values({ + id: 'device-office-mac', + name: 'Office Mac', + tokenHash: 'hashed-device-token', + tokenJti: 'device-token-jti', + createdBy: 'admin-user', + createdAt: new Date(0), + updatedAt: new Date(0), + }) + await db.insert(auditEvents).values({ + id: 'evt-device-upload', + orgId: 'org-device', + userId: 'admin-user', + actorType: 'downloader', + actorRef: 'device-office-mac', + action: 'upload_confirm', + targetType: 'file', + targetId: 'file-device-upload', + targetName: 'downloaded.txt', + createdAt: new Date(), + }) + + const res = await app.request('/api/site/audit-events?action=upload_confirm', { headers }) + + expect(res.status).toBe(200) + const body = (await res.json()) as { + items: Array<{ + actorType: string + actorRef: string + actor: { name: string; image: string | null; resolved: boolean } + }> + } + expect(body.items[0]).toMatchObject({ + actorType: 'device', + actorRef: 'device-office-mac', + actor: { name: 'Device · Office Mac', image: null, resolved: true }, + }) + + await db.delete(downloaders) + const missingDeviceRes = await app.request('/api/site/audit-events?action=upload_confirm', { headers }) + const missingDeviceBody = (await missingDeviceRes.json()) as { + items: Array<{ actor: { name: string; resolved: boolean } }> + } + expect(missingDeviceBody.items[0].actor).toEqual({ + name: 'Device · device-office-mac', + image: null, + resolved: false, + }) + }) + + it('returns the resolved Agent name and image while retaining the delegated user', async () => { + const { app, db, deps } = await createTestApp() + await seedProLicense(db) + const headers = await adminHeaders(app) + const { auditEvents } = await import('../../db/schema.js') + const identity = { type: 'oauth', ref: 'agt_1', issuer: 'https://id.realmroot.dev/api/auth' } as const + deps.auditActorDirectory.listTrustedAgentIssuerOrigins = async () => new Set(['https://id.realmroot.dev']) + deps.agentInfo.resolve = async () => + new Map([ + [ + auditActorIdentityKey(identity), + { + name: 'Mac Agent', + image: 'https://id.realmroot.dev/agent-picture-v1.svg', + resolved: true, + }, + ], + ]) + await db.insert(auditEvents).values({ + id: 'evt-agent', + orgId: 'org-agent', + userId: 'admin-user', + actorType: 'oauth', + actorRef: identity.ref, + actorIssuer: identity.issuer, + action: 'upload', + targetType: 'file', + targetId: 'file-1', + targetName: 'agent.txt', + createdAt: new Date(), + }) + + const res = await app.request('/api/site/audit-events?action=upload', { headers }) + + expect(res.status).toBe(200) + const body = (await res.json()) as { + items: Array<{ user: { name: string }; actor: { name: string; image: string | null; resolved: boolean } }> + } + expect(body.items[0].actor).toEqual({ + name: 'Mac Agent', + image: 'https://id.realmroot.dev/agent-picture-v1.svg', + resolved: true, + }) + expect(body.items[0].user.name).not.toBe(body.items[0].actor.name) }) it('returns an empty list when no events match the filter [spec: audit/empty]', async () => { @@ -406,6 +552,8 @@ describe('GET /api/site/audit-events — licensed admin', () => { const item = body.items[0] expect(item).toHaveProperty('user') expect((item.user as Record).name).toBeTruthy() + expect(item).toHaveProperty('actor') + expect(item.actor).toEqual({ name: 'Test User', image: null, resolved: true }) expect(item).toHaveProperty('orgName') }) }) diff --git a/server/http/site/audit.ts b/server/http/site/audit.ts index 3eacbdc7..4e890264 100644 --- a/server/http/site/audit.ts +++ b/server/http/site/audit.ts @@ -13,7 +13,7 @@ const auditEventSchema = z id: opaqueIdSchema, orgId: opaqueIdSchema.or(z.literal('')), userId: opaqueIdSchema.nullable(), - actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']), + actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'device', 'task-upload']), actorRef: z.string().nullable(), actorIssuer: z.string().nullable(), action: z.string(), @@ -23,6 +23,7 @@ const auditEventSchema = z metadata: z.string().nullable(), createdAt: z.string(), user: z.object({ id: opaqueIdSchema.nullable(), name: z.string(), image: z.string().nullable() }), + actor: z.object({ name: z.string(), image: z.string().nullable(), resolved: z.boolean() }), orgName: z.string().nullable(), }) .openapi('AuditEvent') diff --git a/server/http/teams.integration.test.ts b/server/http/teams.integration.test.ts index 5cd2c810..5a1b3e7d 100644 --- a/server/http/teams.integration.test.ts +++ b/server/http/teams.integration.test.ts @@ -391,7 +391,12 @@ describe('GET /api/teams/:teamId/activity — happy path', () => { const res = await app.request(`/api/teams/${orgId}/activity`, { headers }) expect(res.status).toBe(200) const body = (await res.json()) as { - items: Array<{ id: string; targetName: string; user: { id: string; name: string; image: string | null } }> + items: Array<{ + id: string + targetName: string + user: { id: string; name: string; image: string | null } + actor: { name: string; image: string | null; resolved: boolean } + }> total: number } expect(body.total).toBe(1) @@ -399,6 +404,7 @@ describe('GET /api/teams/:teamId/activity — happy path', () => { expect(body.items[0].id).toBe('evt-1') expect(body.items[0].targetName).toBe('document.pdf') expect(body.items[0].user).toMatchObject({ id: userId, name: 'Test User' }) + expect(body.items[0].actor).toEqual({ name: 'Test User', image: null, resolved: true }) }) it('includes all expected activity event fields in each item', async () => { diff --git a/server/http/teams.ts b/server/http/teams.ts index 38324931..9527b9ad 100644 --- a/server/http/teams.ts +++ b/server/http/teams.ts @@ -77,7 +77,7 @@ const activityEventSchema = z id: opaqueIdSchema, orgId: opaqueIdSchema, userId: opaqueIdSchema.nullable(), - actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']), + actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'device', 'task-upload']), actorRef: z.string().nullable(), actorIssuer: z.string().nullable(), action: z.string(), @@ -87,6 +87,7 @@ const activityEventSchema = z metadata: z.string().nullable(), createdAt: z.string(), user: z.object({ id: opaqueIdSchema.nullable(), name: z.string(), image: z.string().nullable() }), + actor: z.object({ name: z.string(), image: z.string().nullable(), resolved: z.boolean() }), }) .openapi('AuditEvent') diff --git a/server/middleware/audit-actor.test.ts b/server/middleware/audit-actor.test.ts index 735c8748..19e404a4 100644 --- a/server/middleware/audit-actor.test.ts +++ b/server/middleware/audit-actor.test.ts @@ -3,6 +3,35 @@ import { auditActor } from './audit-actor' import type { AuthPrincipal } from './platform' describe('auditActor', () => { + it('records unauthenticated, user, API key, and device principals directly', () => { + expect(auditActor(null)).toEqual({ userId: null, actorType: 'anonymous', actorRef: null, actorIssuer: null }) + expect(auditActor({ kind: 'user', userId: 'user-1', orgId: null, authMethod: 'cookie' })).toEqual({ + userId: 'user-1', + actorType: 'user', + actorRef: null, + actorIssuer: null, + }) + expect( + auditActor({ + kind: 'api-key', + userId: 'user-1', + keyId: 'key-1', + configId: 'remote-download', + orgId: null, + scope: { mode: 'user-workspaces' }, + permissions: null, + authMethod: 'api-key', + }), + ).toEqual({ userId: 'user-1', actorType: 'api_key', actorRef: 'key-1', actorIssuer: null }) + expect( + auditActor({ + kind: 'downloader', + downloaderId: 'device-1', + authMethod: 'bearer', + }), + ).toEqual({ userId: null, actorType: 'device', actorRef: 'device-1', actorIssuer: null }) + }) + it('records OAuth principals as delegated Agent actors', () => { const principal: AuthPrincipal = { kind: 'oauth', @@ -39,4 +68,24 @@ describe('auditActor', () => { actorIssuer: null, }) }) + + it('records a device as the actor behind a task upload credential', () => { + const principal: AuthPrincipal = { + kind: 'download-task-upload', + downloaderId: 'device-1', + taskId: 'task-1', + orgId: 'org-1', + targetFolder: 'Downloads', + createdByUserId: 'user-1', + scopes: ['objects:create'], + authMethod: 'bearer', + } + + expect(auditActor(principal)).toEqual({ + userId: 'user-1', + actorType: 'device', + actorRef: 'device-1', + actorIssuer: null, + }) + }) }) diff --git a/server/middleware/audit-actor.ts b/server/middleware/audit-actor.ts index 1c0041e9..769ffe36 100644 --- a/server/middleware/audit-actor.ts +++ b/server/middleware/audit-actor.ts @@ -20,15 +20,15 @@ export function auditActor(principal: AuthPrincipal | null): AuditActor { } } if (principal.kind === 'downloader') { - return { userId: null, actorType: 'downloader', actorRef: principal.downloaderId, actorIssuer: null } + return { userId: null, actorType: 'device', actorRef: principal.downloaderId, actorIssuer: null } } if (principal.kind === 'downloader-bootstrap') { return { userId: principal.userId, actorType: 'user', actorRef: null, actorIssuer: null } } return { userId: principal.createdByUserId, - actorType: 'task-upload', - actorRef: principal.taskId, + actorType: 'device', + actorRef: principal.downloaderId, actorIssuer: null, } } diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 3ee8ce13..90133020 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -98,7 +98,7 @@ export const authMiddleware = createMiddleware(async (c, next) => { userId: taskUpload.createdByUserId, workspace: { mode: 'bound', orgId: taskUpload.orgId }, grantedScopes: new Set(taskUpload.scopes.filter(isAuthorizationScope)), - actor: { type: 'task-upload', ref: taskUpload.taskId }, + actor: { type: 'device', ref: taskUpload.downloaderId }, state: { downloaderId: taskUpload.downloaderId, taskId: taskUpload.taskId }, }) c.set('userId', null) @@ -119,7 +119,7 @@ export const authMiddleware = createMiddleware(async (c, next) => { AuthorizationScope.DOWNLOAD_TASKS_CANCEL, AuthorizationScope.DOWNLOADERS_UPDATE, ]), - actor: { type: 'downloader', ref: downloader.downloaderId }, + actor: { type: 'device', ref: downloader.downloaderId }, state: {}, }) c.set('userId', null) diff --git a/server/middleware/authz.integration.test.ts b/server/middleware/authz.integration.test.ts index a16eccae..d2b9e426 100644 --- a/server/middleware/authz.integration.test.ts +++ b/server/middleware/authz.integration.test.ts @@ -460,7 +460,7 @@ describe('evaluateAuthorization', () => { userId: null, workspace: { mode: 'none' as const, orgId: null }, grantedScopes: new Set([AuthorizationScope.DOWNLOADERS_UPDATE]), - actor: { type: 'downloader' as const, ref: 'downloader-1' }, + actor: { type: 'device' as const, ref: 'downloader-1' }, state: {}, } const bootstrapContext = { @@ -527,7 +527,7 @@ describe('evaluateAuthorization', () => { userId: 'user-1', workspace: { mode: 'bound' as const, orgId: 'org-1' }, grantedScopes: new Set([AuthorizationScope.OBJECTS_CREATE]), - actor: { type: 'task-upload' as const, ref: 'task-1' }, + actor: { type: 'device' as const, ref: 'downloader-1' }, state: { downloaderId: 'downloader-1', taskId: 'task-1' }, } diff --git a/server/middleware/platform.ts b/server/middleware/platform.ts index e21b7b67..65af90ca 100644 --- a/server/middleware/platform.ts +++ b/server/middleware/platform.ts @@ -129,7 +129,7 @@ export type AuthzContext = userId: null workspace: { mode: 'none'; orgId: null } grantedScopes: ReadonlySet - actor: { type: 'downloader'; ref: string } + actor: { type: 'device'; ref: string } state: Record } | { @@ -145,7 +145,7 @@ export type AuthzContext = userId: string workspace: { mode: 'bound'; orgId: string } grantedScopes: ReadonlySet - actor: { type: 'task-upload'; ref: string } + actor: { type: 'device'; ref: string } state: { downloaderId: string; taskId: string } } diff --git a/server/usecases/audit-actors.ts b/server/usecases/audit-actors.ts new file mode 100644 index 00000000..5464fb07 --- /dev/null +++ b/server/usecases/audit-actors.ts @@ -0,0 +1,89 @@ +import { + type AgentInfoGateway, + type AuditActorDirectory, + type AuditActorIdentity, + type AuditActorProfile, + type AuditEventWithUser, + auditActorIdentityKey, +} from './ports' + +export async function resolveAuditActorProfiles( + deps: { auditActorDirectory: AuditActorDirectory; agentInfo: AgentInfoGateway }, + events: T[], +): Promise { + const identities = uniqueResolvableIdentities(events) + if (identities.length === 0) return events + + const profiles = await resolveProfiles(deps, identities) + return events.map((event) => { + const profile = profiles.get( + auditActorIdentityKey({ type: event.actorType, ref: event.actorRef, issuer: event.actorIssuer }), + ) + return profile ? { ...event, actor: profile } : event + }) +} + +async function resolveProfiles( + deps: { auditActorDirectory: AuditActorDirectory; agentInfo: AgentInfoGateway }, + identities: readonly AuditActorIdentity[], +): Promise> { + const profiles = new Map() + const apiKeyActors = identities.flatMap((identity) => + identity.type === 'api_key' && identity.ref ? [{ identity, ref: identity.ref }] : [], + ) + if (apiKeyActors.length > 0) { + const names = await deps.auditActorDirectory.findApiKeyNames(apiKeyActors.map((actor) => actor.ref)) + for (const actor of apiKeyActors) { + const name = names.get(actor.ref) + if (name) + profiles.set(auditActorIdentityKey(actor.identity), { + name: `API key · ${name}`, + image: null, + resolved: true, + }) + } + } + + const deviceActors = identities.flatMap((identity) => + identity.type === 'device' && identity.ref ? [{ identity, ref: identity.ref }] : [], + ) + if (deviceActors.length > 0) { + const names = await deps.auditActorDirectory.findDeviceNames(deviceActors.map((actor) => actor.ref)) + for (const actor of deviceActors) { + const name = names.get(actor.ref) + if (name) + profiles.set(auditActorIdentityKey(actor.identity), { + name: `Device · ${name}`, + image: null, + resolved: true, + }) + } + } + + const agentActors = identities.filter( + (identity) => (identity.type === 'oauth' || identity.type === 'agent') && identity.ref && identity.issuer, + ) + if (agentActors.length > 0) { + const trustedOrigins = await deps.auditActorDirectory.listTrustedAgentIssuerOrigins() + const agentProfiles = await deps.agentInfo.resolve(agentActors, trustedOrigins) + for (const [key, profile] of agentProfiles) profiles.set(key, profile) + } + return profiles +} + +function uniqueResolvableIdentities(events: readonly AuditEventWithUser[]): AuditActorIdentity[] { + const identities = new Map() + for (const event of events) { + if ( + event.actorType !== 'api_key' && + event.actorType !== 'device' && + event.actorType !== 'oauth' && + event.actorType !== 'agent' + ) + continue + if (!event.actorRef) continue + const identity = { type: event.actorType, ref: event.actorRef, issuer: event.actorIssuer } + identities.set(auditActorIdentityKey(identity), identity) + } + return [...identities.values()] +} diff --git a/server/usecases/deps.ts b/server/usecases/deps.ts index 0c8d251e..626c27c7 100644 --- a/server/usecases/deps.ts +++ b/server/usecases/deps.ts @@ -4,10 +4,12 @@ import type { AdminStatsRepo, + AgentInfoGateway, AnnouncementRepo, ApiKeyGateway, ArchiveJobsGateway, ArchiveTargetFolderRepo, + AuditActorDirectory, AuditRepo, BackgroundJobRepo, CacheService, @@ -57,6 +59,8 @@ import type { export interface Deps { audit: AuditRepo + auditActorDirectory: AuditActorDirectory + agentInfo: AgentInfoGateway adminStats: AdminStatsRepo oauth: OAuthGateway announcements: AnnouncementRepo diff --git a/server/usecases/ports/audit.ts b/server/usecases/ports/audit.ts index 36d6ce8b..f579130d 100644 --- a/server/usecases/ports/audit.ts +++ b/server/usecases/ports/audit.ts @@ -1,14 +1,6 @@ // Plain, framework-free DTOs and the repository port for audit events. -export type AuditActorType = - | 'user' - | 'api_key' - | 'oauth' - | 'agent' - | 'anonymous' - | 'system' - | 'downloader' - | 'task-upload' +export type AuditActorType = 'user' | 'api_key' | 'oauth' | 'agent' | 'anonymous' | 'system' | 'device' | 'task-upload' export interface RecordAuditEventInput { orgId: string @@ -40,6 +32,7 @@ export interface AuditEvent { export interface AuditEventWithUser extends AuditEvent { user: { id: string | null; name: string; image: string | null } + actor: AuditActorProfile } export interface AdminAuditEventWithOrg extends AuditEventWithUser { @@ -79,3 +72,34 @@ export interface AuditRepo { opts: ListAuditByTargetOpts, ): Promise<{ items: AuditEvent[]; total: number; page: number; pageSize: number }> } + +export interface AuditActorIdentity { + type: AuditActorType + ref: string | null + issuer: string | null +} + +export interface AuditActorProfile { + name: string + image: string | null + resolved: boolean +} + +export interface AuditActorDirectory { + findApiKeyNames(keyIds: readonly string[]): Promise> + findDeviceNames(deviceIds: readonly string[]): Promise> + listTrustedAgentIssuerOrigins(): Promise> +} + +export interface AgentInfoGateway { + // Profiles are display-only and never authoritative. An omitted identity + // tells the caller to retain the stable issuer/subject fallback. + resolve( + actors: readonly AuditActorIdentity[], + trustedIssuerOrigins: ReadonlySet, + ): Promise> +} + +export function auditActorIdentityKey(actor: AuditActorIdentity): string { + return JSON.stringify([actor.type, actor.issuer, actor.ref]) +} diff --git a/server/usecases/site/audit.test.ts b/server/usecases/site/audit.test.ts index e49d5fb8..9d076735 100644 --- a/server/usecases/site/audit.test.ts +++ b/server/usecases/site/audit.test.ts @@ -1,13 +1,21 @@ import { describe, expect, it, vi } from 'vitest' -import type { AuditRepo } from '../ports' +import { type AdminAuditEventWithOrg, type AgentInfoGateway, type AuditRepo, auditActorIdentityKey } from '../ports' import { listAuditEvents } from './audit' describe('audit usecase', () => { it('forwards the query options to listAdminAudit', async () => { const result = { items: [], total: 0, page: 1, pageSize: 20 } const listAdminAudit = vi.fn(async () => result) + const resolve = vi.fn(async () => new Map()) + const findApiKeyNames = vi.fn(async () => new Map()) + const findDeviceNames = vi.fn(async () => new Map()) + const listTrustedAgentIssuerOrigins = vi.fn(async () => new Set()) const out = await listAuditEvents( - { audit: { listAdminAudit } as Pick }, + { + audit: { listAdminAudit } as Pick, + auditActorDirectory: { findApiKeyNames, findDeviceNames, listTrustedAgentIssuerOrigins }, + agentInfo: { resolve } as AgentInfoGateway, + }, { page: 1, pageSize: 20, @@ -16,5 +24,126 @@ describe('audit usecase', () => { ) expect(out).toBe(result) expect(listAdminAudit).toHaveBeenCalledWith({ page: 1, pageSize: 20, orgId: 'o1' }) + expect(resolve).not.toHaveBeenCalled() + expect(findApiKeyNames).not.toHaveBeenCalled() + expect(findDeviceNames).not.toHaveBeenCalled() + expect(listTrustedAgentIssuerOrigins).not.toHaveBeenCalled() + }) + + it('uses the resolved Agent profile without replacing the delegated user', async () => { + const event = { + id: 'e1', + orgId: 'o1', + orgName: 'Personal', + userId: 'u1', + actorType: 'oauth', + actorRef: 'agt_1', + actorIssuer: 'https://id.realmroot.dev/api/auth', + action: 'upload', + targetType: 'file', + targetId: 'f1', + targetName: 'agent.txt', + metadata: null, + createdAt: new Date(0), + user: { id: 'u1', name: 'Ambor', image: null }, + actor: { name: 'Agent · agt_1', image: null, resolved: false }, + } satisfies AdminAuditEventWithOrg + const identity = { type: 'oauth', ref: 'agt_1', issuer: 'https://id.realmroot.dev/api/auth' } as const + const resolved = { name: 'Mac Agent', image: 'https://id.realmroot.dev/agent.svg', resolved: true } + const resolve = vi.fn(async () => new Map([[auditActorIdentityKey(identity), resolved]])) + + const out = await listAuditEvents( + { + audit: { listAdminAudit: async () => ({ items: [event], total: 1, page: 1, pageSize: 20 }) }, + auditActorDirectory: { + findApiKeyNames: async () => new Map(), + findDeviceNames: async () => new Map(), + listTrustedAgentIssuerOrigins: async () => new Set(['https://id.realmroot.dev']), + }, + agentInfo: { resolve }, + }, + { page: 1, pageSize: 20 }, + ) + + expect(out.items[0]).toMatchObject({ user: { name: 'Ambor' }, actor: resolved }) + expect(resolve).toHaveBeenCalledWith([identity], new Set(['https://id.realmroot.dev'])) + }) + + it('formats the API key name as the actor without calling Agent Info', async () => { + const event = { + id: 'e2', + orgId: 'o1', + orgName: 'Personal', + userId: 'u1', + actorType: 'api_key', + actorRef: 'key-1', + actorIssuer: null, + action: 'download_task_created', + targetType: 'download_task', + targetId: 'task-1', + targetName: 'task-1', + metadata: null, + createdAt: new Date(0), + user: { id: 'u1', name: 'Ambor', image: null }, + actor: { name: 'API key · key-1', image: null, resolved: false }, + } satisfies AdminAuditEventWithOrg + const resolve = vi.fn(async () => new Map()) + + const out = await listAuditEvents( + { + audit: { listAdminAudit: async () => ({ items: [event], total: 1, page: 1, pageSize: 20 }) }, + auditActorDirectory: { + findApiKeyNames: async () => new Map([['key-1', 'CME downloader']]), + findDeviceNames: async () => new Map(), + listTrustedAgentIssuerOrigins: async () => new Set(), + }, + agentInfo: { resolve }, + }, + { page: 1, pageSize: 20 }, + ) + + expect(out.items[0]).toMatchObject({ + user: { name: 'Ambor' }, + actor: { name: 'API key · CME downloader', image: null, resolved: true }, + }) + expect(resolve).not.toHaveBeenCalled() + }) + + it('formats the registered device name as the actor behind an upload token', async () => { + const event = { + id: 'e3', + orgId: 'o1', + orgName: 'Personal', + userId: 'u1', + actorType: 'device', + actorRef: 'device-1', + actorIssuer: null, + action: 'upload_confirm', + targetType: 'file', + targetId: 'f1', + targetName: 'downloaded.txt', + metadata: null, + createdAt: new Date(0), + user: { id: 'u1', name: 'Ambor', image: null }, + actor: { name: 'Device · device-1', image: null, resolved: false }, + } satisfies AdminAuditEventWithOrg + + const out = await listAuditEvents( + { + audit: { listAdminAudit: async () => ({ items: [event], total: 1, page: 1, pageSize: 20 }) }, + auditActorDirectory: { + findApiKeyNames: async () => new Map(), + findDeviceNames: async () => new Map([['device-1', 'Office Mac']]), + listTrustedAgentIssuerOrigins: async () => new Set(), + }, + agentInfo: { resolve: async () => new Map() }, + }, + { page: 1, pageSize: 20 }, + ) + + expect(out.items[0]).toMatchObject({ + user: { name: 'Ambor' }, + actor: { name: 'Device · Office Mac', image: null, resolved: true }, + }) }) }) diff --git a/server/usecases/site/audit.ts b/server/usecases/site/audit.ts index d582f52b..87ffb791 100644 --- a/server/usecases/site/audit.ts +++ b/server/usecases/site/audit.ts @@ -1,12 +1,26 @@ // The admin audit resource usecase (/api/admin/audit). Reads org-joined -// activity events. A single-port operation today; it lives here so the resource -// has one home and the http handler stays free of deps access. +// activity events and resolves their display-only actor projection. It lives +// here so the resource has one home and the http handler stays free of deps access. -import type { AdminAuditEventWithOrg, AuditRepo, ListAdminAuditOpts } from '../ports' +import { resolveAuditActorProfiles } from '../audit-actors' +import type { + AdminAuditEventWithOrg, + AgentInfoGateway, + AuditActorDirectory, + AuditRepo, + ListAdminAuditOpts, +} from '../ports' -export function listAuditEvents( - deps: { audit: Pick }, +export async function listAuditEvents( + deps: { + audit: Pick + auditActorDirectory: AuditActorDirectory + agentInfo: AgentInfoGateway + }, opts: ListAdminAuditOpts, ): Promise<{ items: AdminAuditEventWithOrg[]; total: number; page: number; pageSize: number }> { - return deps.audit.listAdminAudit(opts) + const result = await deps.audit.listAdminAudit(opts) + const items = await resolveAuditActorProfiles(deps, result.items) + if (items === result.items) return result + return { ...result, items } } diff --git a/server/usecases/team.test.ts b/server/usecases/team.test.ts index 26f78ff1..06a71581 100644 --- a/server/usecases/team.test.ts +++ b/server/usecases/team.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { + AgentInfoGateway, + AuditActorDirectory, AuditRepo, EntitlementResult, ImageUpload, @@ -84,6 +86,12 @@ function makeDeps( ) { const deps: TeamDeps = { audit: { record: async () => {}, list: async () => ({ items: [], total: 0 }) } as unknown as AuditRepo, + auditActorDirectory: { + findApiKeyNames: async () => new Map(), + findDeviceNames: async () => new Map(), + listTrustedAgentIssuerOrigins: async () => new Set(), + } as AuditActorDirectory, + agentInfo: { resolve: async () => new Map() } as AgentInfoGateway, org: { listUserOrgs: async () => [], listUserWorkspaceCatalog: async () => [], diff --git a/server/usecases/team.ts b/server/usecases/team.ts index a5ef77bf..ea5ae359 100644 --- a/server/usecases/team.ts +++ b/server/usecases/team.ts @@ -16,7 +16,10 @@ // failure outward unchanged so the http layer maps {status} directly. import type { Platform } from '../platform/interface' +import { resolveAuditActorProfiles } from './audit-actors' import { + type AgentInfoGateway, + type AuditActorDirectory, type AuditEventWithUser, type AuditRepo, type EntitlementResult, @@ -39,6 +42,8 @@ export type TeamDeps = { teamInvites: TeamInviteRepo org: OrgRepo audit: AuditRepo + auditActorDirectory: AuditActorDirectory + agentInfo: AgentInfoGateway imageUpload: ImageUpload userAdmin: UserAdminRepo } @@ -105,7 +110,7 @@ export type ListActivityOutcome = | { ok: false; reason: 'forbidden' } export async function listActivity( - deps: Pick, + deps: Pick, params: { teamId: string; userId: string; page: number; pageSize: number }, ): Promise { const { teamId, userId, page, pageSize } = params @@ -114,7 +119,10 @@ export async function listActivity( return { ok: false, reason: 'forbidden' } } const result = await deps.audit.list(teamId, { page, pageSize }) - return { ok: true, result } + return { + ok: true, + result: { ...result, items: await resolveAuditActorProfiles(deps, result.items) }, + } } // ─── User-facing: org logo ─────────────────────────────────────────────────── diff --git a/shared/types/index.ts b/shared/types/index.ts index da96107f..e6e89a56 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -622,15 +622,7 @@ export interface Announcement { updatedAt: string } -export type AuditActorType = - | 'user' - | 'api_key' - | 'oauth' - | 'agent' - | 'anonymous' - | 'system' - | 'downloader' - | 'task-upload' +export type AuditActorType = 'user' | 'api_key' | 'oauth' | 'agent' | 'anonymous' | 'system' | 'device' | 'task-upload' export interface AuditEvent { id: string @@ -650,6 +642,11 @@ export interface AuditEvent { name: string image: string | null } + actor: { + name: string + image: string | null + resolved: boolean + } } export interface AdminAuditEvent extends AuditEvent { diff --git a/src/components/admin/audit-activity-feed.tsx b/src/components/admin/audit-activity-feed.tsx index b5bb7dd5..c58e5745 100644 --- a/src/components/admin/audit-activity-feed.tsx +++ b/src/components/admin/audit-activity-feed.tsx @@ -64,8 +64,8 @@ function AdminAuditActivityItem({ event }: { event: AdminAuditEvent }) { ? Object.entries(metadata).filter(([key, value]) => !['status', 'result', 'from', 'to'].includes(key) && value) : [] const targetName = event.targetName || event.targetId || event.targetType - const actorLabel = formatActor(event) - const actorImage = event.actorType === 'user' ? event.user.image : null + const actorLabel = event.actor.name + const actorImage = event.actor.image return (
@@ -83,6 +83,7 @@ function AdminAuditActivityItem({ event }: { event: AdminAuditEvent }) {
{formatTimestamp(event.createdAt)} + {actorLabel} {event.orgName && {event.orgName}} {event.targetId && {event.targetId}}
@@ -110,22 +111,6 @@ function AdminAuditActivityItem({ event }: { event: AdminAuditEvent }) { ) } -function formatActor(event: AdminAuditEvent): string { - if (event.actorType === 'oauth') { - const identity = event.actorRef ?? 'unknown' - return event.actorIssuer ? `Agent:${identity} · ${event.actorIssuer}` : `Agent:${identity}` - } - if (event.user.name) return event.user.name - if (event.userId) return event.userId - if (event.actorType === 'api_key') return event.actorRef ? `API key:${event.actorRef}` : 'API key' - if (event.actorType === 'agent') return event.actorRef ? `Agent:${event.actorRef}` : 'Agent' - if (event.actorType === 'anonymous') return 'Anonymous' - if (event.actorType === 'system') return event.actorRef ? `System:${event.actorRef}` : 'System' - if (event.actorType === 'downloader') return event.actorRef ? `Downloader:${event.actorRef}` : 'Downloader' - if (event.actorType === 'task-upload') return event.actorRef ? `Task upload:${event.actorRef}` : 'Task upload' - return 'Unknown' -} - function parseActivityMetadata(metadata: string | null): Record | null { if (!metadata) return null diff --git a/src/routes/_authenticated/admin/audit.test.tsx b/src/routes/_authenticated/admin/audit.test.tsx index fe79fa99..6374a728 100644 --- a/src/routes/_authenticated/admin/audit.test.tsx +++ b/src/routes/_authenticated/admin/audit.test.tsx @@ -91,6 +91,7 @@ function auditEvent(overrides: Partial = {}): AdminAuditEvent { name: 'Ava Stone', image: null, }, + actor: { name: 'Ava Stone', image: null, resolved: true }, ...overrides, } } @@ -134,13 +135,35 @@ describe('AuditLogsPage filters and pagination', () => { actorType: 'oauth', actorRef: 'agt_agent-1', actorIssuer: 'https://id.realmroot.dev/api/auth', + actor: { + name: 'Mac Agent', + image: 'https://id.realmroot.dev/agent-picture-v1.svg', + resolved: true, + }, }), ]), ) renderAuditPage() - expect(await screen.findByText('Agent:agt_agent-1 · https://id.realmroot.dev/api/auth')).toBeTruthy() + expect(await screen.findByText('Mac Agent')).toBeTruthy() + expect(screen.queryByText('Ava Stone')).toBeNull() + }) + + it('shows the API key name instead of the key owner', async () => { + vi.mocked(listAdminAuditLogs).mockResolvedValue( + auditPage(1, [ + auditEvent({ + actorType: 'api_key', + actorRef: 'key-1', + actor: { name: 'API key · CME downloader', image: null, resolved: true }, + }), + ]), + ) + + renderAuditPage() + + expect(await screen.findByText('API key · CME downloader')).toBeTruthy() expect(screen.queryByText('Ava Stone')).toBeNull() }) diff --git a/src/routes/_authenticated/admin/audit.tsx b/src/routes/_authenticated/admin/audit.tsx index 5115003d..7de5587a 100644 --- a/src/routes/_authenticated/admin/audit.tsx +++ b/src/routes/_authenticated/admin/audit.tsx @@ -70,8 +70,8 @@ function metaDetail(event: AdminAuditEvent, t: (key: string, opts?: Record @@ -92,22 +92,6 @@ function AuditRow({ event }: { event: AdminAuditEvent }) { ) } -function formatActor(event: AdminAuditEvent): string { - if (event.actorType === 'oauth') { - const identity = event.actorRef ?? 'unknown' - return event.actorIssuer ? `Agent:${identity} · ${event.actorIssuer}` : `Agent:${identity}` - } - if (event.user.name) return event.user.name - if (event.userId) return event.userId - if (event.actorType === 'api_key') return event.actorRef ? `API key:${event.actorRef}` : 'API key' - if (event.actorType === 'agent') return event.actorRef ? `Agent:${event.actorRef}` : 'Agent' - if (event.actorType === 'anonymous') return 'Anonymous' - if (event.actorType === 'system') return event.actorRef ? `System:${event.actorRef}` : 'System' - if (event.actorType === 'downloader') return event.actorRef ? `Downloader:${event.actorRef}` : 'Downloader' - if (event.actorType === 'task-upload') return event.actorRef ? `Task upload:${event.actorRef}` : 'Task upload' - return 'Unknown' -} - function AuditLogsPage() { const { t } = useTranslation() const { hasFeature, isLoading: entitlementLoading } = useEntitlement() diff --git a/src/routes/_authenticated/admin/teams/$orgId.test.tsx b/src/routes/_authenticated/admin/teams/$orgId.test.tsx index 700a9e73..f3756597 100644 --- a/src/routes/_authenticated/admin/teams/$orgId.test.tsx +++ b/src/routes/_authenticated/admin/teams/$orgId.test.tsx @@ -177,6 +177,7 @@ function auditEvent(overrides: Partial = {}): AdminAuditEvent { name: 'Ava Stone', image: null, }, + actor: { name: 'Ava Stone', image: null, resolved: true }, ...overrides, } } diff --git a/src/routes/_authenticated/admin/users/$userId.test.tsx b/src/routes/_authenticated/admin/users/$userId.test.tsx index f07ace4f..bb663a69 100644 --- a/src/routes/_authenticated/admin/users/$userId.test.tsx +++ b/src/routes/_authenticated/admin/users/$userId.test.tsx @@ -165,6 +165,7 @@ function auditEvent(overrides: Partial = {}): AdminAuditEvent { name: 'Ava Stone', image: null, }, + actor: { name: 'Ava Stone', image: null, resolved: true }, ...overrides, } } diff --git a/src/routes/_authenticated/teams/$teamId/activity.test.tsx b/src/routes/_authenticated/teams/$teamId/activity.test.tsx new file mode 100644 index 00000000..5b816688 --- /dev/null +++ b/src/routes/_authenticated/teams/$teamId/activity.test.tsx @@ -0,0 +1,73 @@ +import type { AuditEvent } from '@shared/types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, render, screen } from '@testing-library/react' +import type * as React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { listTeamActivities } from '@/lib/api' +import { Route } from './activity' + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: () => (options: object) => ({ ...options, useParams: () => ({ teamId: 'team-1' }) }), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: { defaultValue?: string }) => values?.defaultValue ?? key, + }), +})) + +vi.mock('@/lib/api', () => ({ listTeamActivities: vi.fn() })) + +type ActivityRoute = typeof Route & { component: React.ComponentType } + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('TeamActivityPage actors', () => { + it('shows the resolved Agent identity instead of the delegated user', async () => { + const event = auditEvent({ + actorType: 'oauth', + actorRef: 'agt_1', + actorIssuer: 'https://id.realmroot.dev/api/auth', + actor: { + name: 'Mac Agent', + image: 'https://id.realmroot.dev/agent-picture-v1.svg', + resolved: true, + }, + }) + vi.mocked(listTeamActivities).mockResolvedValue({ items: [event], total: 1, page: 1, pageSize: 20 }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const Component = (Route as ActivityRoute).component + + render( + + + , + ) + + expect(await screen.findByText('Mac Agent')).toBeTruthy() + expect(screen.queryByText('Ambor')).toBeNull() + }) +}) + +function auditEvent(overrides: Partial = {}): AuditEvent { + return { + id: 'audit-1', + orgId: 'team-1', + userId: 'user-1', + actorType: 'user', + actorRef: null, + actorIssuer: null, + action: 'upload', + targetType: 'file', + targetId: 'file-1', + targetName: 'agent.txt', + metadata: null, + createdAt: '2026-08-08T02:00:00.000Z', + user: { id: 'user-1', name: 'Ambor', image: null }, + actor: { name: 'Ambor', image: null, resolved: true }, + ...overrides, + } +} diff --git a/src/routes/_authenticated/teams/$teamId/activity.tsx b/src/routes/_authenticated/teams/$teamId/activity.tsx index 3e9f658c..d68eca0b 100644 --- a/src/routes/_authenticated/teams/$teamId/activity.tsx +++ b/src/routes/_authenticated/teams/$teamId/activity.tsx @@ -59,8 +59,8 @@ function ActivityItem({ event }: { event: AuditEvent }) { } const createdAt = new Date(event.createdAt as unknown as string | number) - const actorLabel = formatActor(event) - const actorImage = event.actorType === 'user' ? event.user.image : null + const actorLabel = event.actor.name + const actorImage = event.actor.image return (
@@ -78,22 +78,6 @@ function ActivityItem({ event }: { event: AuditEvent }) { ) } -function formatActor(event: AuditEvent): string { - if (event.actorType === 'oauth') { - const identity = event.actorRef ?? 'unknown' - return event.actorIssuer ? `Agent:${identity} · ${event.actorIssuer}` : `Agent:${identity}` - } - if (event.user.name) return event.user.name - if (event.userId) return event.userId - if (event.actorType === 'api_key') return event.actorRef ? `API key:${event.actorRef}` : 'API key' - if (event.actorType === 'agent') return event.actorRef ? `Agent:${event.actorRef}` : 'Agent' - if (event.actorType === 'anonymous') return 'Anonymous' - if (event.actorType === 'system') return event.actorRef ? `System:${event.actorRef}` : 'System' - if (event.actorType === 'downloader') return event.actorRef ? `Downloader:${event.actorRef}` : 'Downloader' - if (event.actorType === 'task-upload') return event.actorRef ? `Task upload:${event.actorRef}` : 'Task upload' - return 'Unknown' -} - function TeamActivityPage() { const { t } = useTranslation() const { teamId } = Route.useParams()