diff --git a/packages/atlas-admin-api/src/atlas-admin-api-service.spec.ts b/packages/atlas-admin-api/src/atlas-admin-api-service.spec.ts index 69d3e9fc8ad..b650d7dd026 100644 --- a/packages/atlas-admin-api/src/atlas-admin-api-service.spec.ts +++ b/packages/atlas-admin-api/src/atlas-admin-api-service.spec.ts @@ -475,6 +475,74 @@ describe('AtlasAdminApiService', function () { }); }); + describe('getSystemStatus', function () { + it('should hit the system status endpoint and return the ip address and user', async function () { + atlasServiceStub.authenticatedFetch.resolves({ + json: () => + Promise.resolve({ + appName: 'MongoDB Atlas', + ipAddress: '1.2.3.4', + user: { username: 'user@example.com' }, + }), + }); + + const res = await service.getSystemStatus(); + + expect(res).to.deep.equal({ + ipAddress: '1.2.3.4', + user: { username: 'user@example.com' }, + }); + expect(fetchUrl(0)).to.equal('http://example.com/api/atlas/v2'); + expect( + atlasServiceStub.authenticatedFetch.firstCall.args[1].headers.Accept + ).to.equal( + `application/vnd.atlas.${ATLAS_ADMIN_API_DEFAULT_VERSION}+json` + ); + }); + + it('should omit the user when it is not returned', async function () { + atlasServiceStub.authenticatedFetch.resolves({ + json: () => Promise.resolve({ ipAddress: '1.2.3.4' }), + }); + + expect(await service.getSystemStatus()).to.deep.equal({ + ipAddress: '1.2.3.4', + }); + }); + + it('should throw when the ip address is missing', async function () { + atlasServiceStub.authenticatedFetch.resolves({ + json: () => Promise.resolve({ user: { username: 'user@example.com' } }), + }); + + try { + await service.getSystemStatus(); + expect.fail('Expected getSystemStatus to throw'); + } catch (err) { + expect(err).to.have.property( + 'message', + 'Got unexpected backend response for Atlas Admin API system status request, missing or malformed ipAddress' + ); + } + }); + + it('should throw when the returned user is malformed', async function () { + atlasServiceStub.authenticatedFetch.resolves({ + json: () => Promise.resolve({ ipAddress: '1.2.3.4', user: {} }), + }); + + try { + await service.getSystemStatus(); + expect.fail('Expected getSystemStatus to throw'); + } catch (err) { + expect(err).to.have.property( + 'message', + 'Got unexpected backend response for Atlas Admin API system status request, missing or malformed username' + ); + } + }); + }); + describe('getProjectIPAccessList', function () { it('should hit the access list endpoint and return the entries', async function () { stubSequentialJsonResponses([page([{ cidrBlock: '0.0.0.0/0' }])]); diff --git a/packages/atlas-admin-api/src/atlas-admin-api-service.ts b/packages/atlas-admin-api/src/atlas-admin-api-service.ts index c355df2b2a0..ebcd9f3bf13 100644 --- a/packages/atlas-admin-api/src/atlas-admin-api-service.ts +++ b/packages/atlas-admin-api/src/atlas-admin-api-service.ts @@ -13,6 +13,10 @@ import { type AtlasGroupCluster, type AtlasGroupClusterResponse, } from './cluster-types'; +import { + assertSystemStatus, + type AtlasSystemStatus, +} from './system-status-types'; import { connectionStringMatches, extractConnectionStrings } from './util'; import { getAtlasAdminApiAcceptHeader } from './version'; @@ -100,6 +104,23 @@ export class AtlasAdminApiService { return results; } + /** + * Fetches the Atlas Admin API system status, which reports the public IP + * address the request came from and, when authenticated as a user, that user + * (the username is the email the user is logged in with). + */ + async getSystemStatus( + options?: AtlasAdminApiRequestOptions + ): Promise { + const requestUrl = this.atlasService.adminApiEndpoint('/v2'); + const json = await this.fetchJson(requestUrl, options); + assertSystemStatus(json); + return { + ipAddress: json.ipAddress, + ...(json.user && { user: { username: json.user.username } }), + }; + } + async listGroupIds(): Promise { const clusters = await this.fetchAllPages<{ groupId: string }>( (pagination) => diff --git a/packages/atlas-admin-api/src/index.ts b/packages/atlas-admin-api/src/index.ts index fe6ab0f80bb..e77a3e90741 100644 --- a/packages/atlas-admin-api/src/index.ts +++ b/packages/atlas-admin-api/src/index.ts @@ -11,4 +11,5 @@ export { type AtlasClusterState, type AtlasGroupCluster, } from './cluster-types'; +export { type AtlasSystemStatus } from './system-status-types'; export { ATLAS_ADMIN_API_DEFAULT_VERSION } from './version'; diff --git a/packages/atlas-admin-api/src/system-status-types.ts b/packages/atlas-admin-api/src/system-status-types.ts new file mode 100644 index 00000000000..ef1d748442d --- /dev/null +++ b/packages/atlas-admin-api/src/system-status-types.ts @@ -0,0 +1,34 @@ +/** + * Subset of the Atlas Admin API system status object (`GET /api/atlas/v2`) that + * we consume: the public IP address the request originated from (always + * returned) and, when the request is authenticated as a user rather than an API + * key, the user making it. + */ +export type AtlasSystemStatus = { + ipAddress: string; + user?: { username: string }; +}; + +export function assertSystemStatus( + json: unknown +): asserts json is AtlasSystemStatus { + const status = json as { ipAddress?: unknown; user?: { username?: unknown } }; + if ( + !json || + typeof json !== 'object' || + typeof status.ipAddress !== 'string' + ) { + throw new Error( + 'Got unexpected backend response for Atlas Admin API system status request, missing or malformed ipAddress' + ); + } + if ( + status.user !== undefined && + (typeof status.user !== 'object' || + typeof status.user.username !== 'string') + ) { + throw new Error( + 'Got unexpected backend response for Atlas Admin API system status request, missing or malformed username' + ); + } +} diff --git a/packages/atlas-service/src/atlas-admin-api-auth-endpoints.ts b/packages/atlas-service/src/atlas-admin-api-auth-endpoints.ts index 32d15aa6744..337139ab856 100644 --- a/packages/atlas-service/src/atlas-admin-api-auth-endpoints.ts +++ b/packages/atlas-service/src/atlas-admin-api-auth-endpoints.ts @@ -6,6 +6,7 @@ const groupId = /([a-f0-9]{24})/; const clusterName = /[a-zA-Z0-9][a-zA-Z0-9-]*/; export const ATLAS_ADMIN_API_AUTH_ENDPOINTS = [ + '/api/atlas/v2', '/api/atlas/v2/clusters', new RegExp(`^/api/atlas/v2/groups/${groupId.source}/clusters$`), new RegExp( diff --git a/packages/atlas-service/src/main.spec.ts b/packages/atlas-service/src/main.spec.ts index f5fd79a101a..154dbe30f65 100644 --- a/packages/atlas-service/src/main.spec.ts +++ b/packages/atlas-service/src/main.spec.ts @@ -620,6 +620,17 @@ describe('CompassAuthServiceMain', function () { expect(authHeaders).to.not.have.property('X-Compass-Auth'); }); + it('should add auth headers for the system status request', async function () { + const authHeaders = await CompassAuthService.handleAuthHeaders({ + requestHeaders: { 'X-Compass-Auth': 'true' }, + url: `${defaultConfig.atlasAdminApiBaseUrl}/v2`, + }); + expect(authHeaders).to.have.property( + 'Authorization', + `Bearer ${accessToken}` + ); + }); + it('should not add auth headers if they werent asked for', async function () { const url = 'http://example.com/api/private/some-endpoint'; const oldHeaders = { diff --git a/packages/compass-generative-ai/src/tools/debug-connection.spec.ts b/packages/compass-generative-ai/src/tools/debug-connection.spec.ts index b8539b024a7..e5110ffaedc 100644 --- a/packages/compass-generative-ai/src/tools/debug-connection.spec.ts +++ b/packages/compass-generative-ai/src/tools/debug-connection.spec.ts @@ -71,6 +71,7 @@ describe('debugConnection', function () { getProjectIdAndClusterName: Sinon.SinonStub; getClusterState: Sinon.SinonStub; getProjectIPAccessList: Sinon.SinonStub; + getSystemStatus: Sinon.SinonStub; }; function mockAtlasAdminApi( @@ -78,10 +79,16 @@ describe('debugConnection', function () { state?: AtlasClusterState; paused?: boolean; ipAccessList?: AtlasAccessListEntry[]; + userIp?: string; projectIdAndClusterName?: { projectId: string; clusterName: string }; } = {} ) { - const { state = 'IDLE', paused = false, ipAccessList = [] } = opts; + const { + state = 'IDLE', + paused = false, + ipAccessList = [], + userIp = USER_IP, + } = opts; // The cluster lookup resolves to undefined when the cluster is not among // the ones the user can see, so an explicit undefined has to be // distinguishable from an omitted option here. @@ -95,6 +102,7 @@ describe('debugConnection', function () { .resolves(projectIdAndClusterName), getClusterState: sandbox.stub().resolves({ state, paused }), getProjectIPAccessList: sandbox.stub().resolves(ipAccessList), + getSystemStatus: sandbox.stub().resolves({ ipAddress: userIp }), }; return atlasAdminApi as unknown as AtlasAdminApiService; } @@ -124,6 +132,7 @@ describe('debugConnection', function () { }); expect(atlasAdminApi.getClusterState).to.not.have.been.called; expect(atlasAdminApi.getProjectIPAccessList).to.not.have.been.called; + expect(atlasAdminApi.getSystemStatus).to.not.have.been.called; }); it('looks up the cluster and the access list with the resolved project id and cluster name', async function () { @@ -209,6 +218,35 @@ describe('debugConnection', function () { expect(result.ipAccessStatus).to.equal('Could not confirm'); }); + it('matches the access list against the ip reported by the system status endpoint', async function () { + const api = mockAtlasAdminApi({ + userIp: '9.9.9.9', + ipAccessList: [{ ipAddress: '9.9.9.9' }], + }); + + const result = await debugConnection( + CONNECTION_STRING, + api, + CLOUD_UI_BASE_URL + ); + + expect(result.ipAccessStatus).to.equal('Client IP Allowed'); + }); + + it('fails when the user ip cannot be resolved', async function () { + const api = mockAtlasAdminApi({ + ipAccessList: [{ ipAddress: USER_IP }], + }); + atlasAdminApi.getSystemStatus.rejects(new Error('nope')); + + try { + await debugConnection(CONNECTION_STRING, api, CLOUD_UI_BASE_URL); + expect.fail('expected debugConnection to reject'); + } catch (err) { + expect((err as Error).message).to.equal('nope'); + } + }); + it('cannot confirm when the access list is empty', async function () { const api = mockAtlasAdminApi({ ipAccessList: [] }); diff --git a/packages/compass-generative-ai/src/tools/debug-connection.ts b/packages/compass-generative-ai/src/tools/debug-connection.ts index 4b0af596e56..befc67d800c 100644 --- a/packages/compass-generative-ai/src/tools/debug-connection.ts +++ b/packages/compass-generative-ai/src/tools/debug-connection.ts @@ -135,9 +135,10 @@ async function getNetworkAccessInfo({ ipAccessStatus: IpAccessStatus; networkAccessDetails: NetworkAccessDetails; }> { - const ipAccessList = await atlasAdminApi.getProjectIPAccessList(projectId); - // TODO(COMPASS-10981): replace with Atlas Admin API once it's ready - const userIp = '1.2.3.4'; + const [ipAccessList, { ipAddress: userIp }] = await Promise.all([ + atlasAdminApi.getProjectIPAccessList(projectId), + atlasAdminApi.getSystemStatus(), + ]); return { ipAccessStatus: ipAccessList && userIp && isUserIpIncluded(ipAccessList, userIp) @@ -145,7 +146,7 @@ async function getNetworkAccessInfo({ : 'Could not confirm', networkAccessDetails: { networkAccessList: ipAccessList, - userIp, + ...(userIp && { userIp }), }, }; }