Skip to content

Commit

Permalink
Add RBAC permission request flow for NoSQL accounts
Browse files Browse the repository at this point in the history
If connecting to a database account using AAD fails because of missing
RBAC permissions, we now notify the user with instructions and
an option to assign a contributor role for them.

If this fails, show an error notification with a link to RBAC instructions.
  • Loading branch information
sevoku committed Jul 18, 2024
1 parent 12aade7 commit f05d010
Show file tree
Hide file tree
Showing 4 changed files with 130 additions and 7 deletions.
25 changes: 20 additions & 5 deletions src/docdb/tree/DocDBAccountTreeItemBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@

import { DatabaseAccountGetResults } from '@azure/arm-cosmosdb/src/models';
import { CosmosClient, DatabaseDefinition, DatabaseResponse, FeedOptions, QueryIterator, Resource } from '@azure/cosmos';
import { AzExtParentTreeItem, AzExtTreeItem, ICreateChildImplContext } from '@microsoft/vscode-azext-utils';
import { AzExtParentTreeItem, AzExtTreeItem, IActionContext, ICreateChildImplContext } from '@microsoft/vscode-azext-utils';
import * as vscode from 'vscode';
import { IDeleteWizardContext } from '../../commands/deleteDatabaseAccount/IDeleteWizardContext';
import { deleteCosmosDBAccount } from '../../commands/deleteDatabaseAccount/deleteCosmosDBAccount';
import { SERVERLESS_CAPABILITY_NAME, getThemeAgnosticIconPath } from '../../constants';
import { getThemeAgnosticIconPath, SERVERLESS_CAPABILITY_NAME } from '../../constants';
import { nonNullProp } from '../../utils/nonNull';
import { rejectOnTimeout } from '../../utils/timeout';
import { CosmosDBCredential, getCosmosClient, getCosmosKeyCredential } from '../getCosmosClient';
import { getSignedInPrincipalIdForAccountEndpoint } from '../utils/azureSessionHelper';
import { ensureRbacPermission, isRbacException, showRBACPermissionError } from '../utils/rbacUtils';
import { DocDBTreeItemBase } from './DocDBTreeItemBase';

/**
Expand Down Expand Up @@ -83,12 +85,25 @@ export abstract class DocDBAccountTreeItemBase extends DocDBTreeItemBase<Databas
return this.initChild(nonNullProp(database, 'resource'));
}

public async loadMoreChildrenImpl(clearCache: boolean): Promise<AzExtTreeItem[]> {
public async loadMoreChildrenImpl(clearCache: boolean, context: IActionContext): Promise<AzExtTreeItem[]> {
if (this.root.isEmulator) {
const unableToReachEmulatorMessage: string = "Unable to reach emulator. Please ensure it is started and connected to the port specified by the 'cosmosDB.emulator.port' setting, then try again.";
return await rejectOnTimeout(2000, () => super.loadMoreChildrenImpl(clearCache), unableToReachEmulatorMessage);
return await rejectOnTimeout(2000, () => super.loadMoreChildrenImpl(clearCache, context), unableToReachEmulatorMessage);
} else {
return await super.loadMoreChildrenImpl(clearCache);
try {
return await super.loadMoreChildrenImpl(clearCache, context);
} catch (e) {
if (e instanceof Error && isRbacException(e)) {
const principalId = await getSignedInPrincipalIdForAccountEndpoint(this.root.endpoint) ?? '';
// chedck if the principal ID matches the one that is signed in, otherwise this might be a security problem, hence show the error message
if (e.message.includes(`[${principalId}]`) && await ensureRbacPermission(this, principalId, context)) {
return await super.loadMoreChildrenImpl(clearCache, context);
} else {
void showRBACPermissionError(this.fullId, principalId);
}
}
throw e; // rethrowing tells the resources extension to show the exception message in the tree
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/docdb/tree/DocDBTreeItemBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { CosmosClient, FeedOptions, QueryIterator } from '@azure/cosmos';
import { AzExtParentTreeItem, AzExtTreeItem } from '@microsoft/vscode-azext-utils';
import { AzExtParentTreeItem, AzExtTreeItem, IActionContext } from '@microsoft/vscode-azext-utils';
import { getBatchSizeSetting } from '../../utils/workspacUtils';
import { IDocDBTreeRoot } from './IDocDBTreeRoot';

Expand Down Expand Up @@ -34,7 +34,7 @@ export abstract class DocDBTreeItemBase<T> extends AzExtParentTreeItem {
this._batchSize = getBatchSizeSetting();
}

public async loadMoreChildrenImpl(clearCache: boolean): Promise<AzExtTreeItem[]> {
public async loadMoreChildrenImpl(clearCache: boolean, _context: IActionContext): Promise<AzExtTreeItem[]> {
if (clearCache || this._iterator === undefined) {
this._hasMoreChildren = true;
const client = this.root.getCosmosClient();
Expand Down
20 changes: 20 additions & 0 deletions src/docdb/utils/azureSessionHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

// eslint-disable-next-line import/no-internal-modules
import { getSessionFromVSCode } from '@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode';
import * as vscode from "vscode";

export async function getSignedInPrincipalIdForAccountEndpoint(accountEndpoint: string): Promise<string | undefined> {
const session = await getSessionForDatabaseAccount(accountEndpoint);
const principalId = session?.account.id.split('/')[1] ?? session?.account.id;
return principalId;
}

async function getSessionForDatabaseAccount(endpoint: string): Promise<vscode.AuthenticationSession | undefined> {
const endpointUrl = new URL(endpoint);
const scrope = `${endpointUrl.origin}${endpointUrl.pathname}.default`;
return await getSessionFromVSCode(scrope, undefined, { createIfNone: false });
}
88 changes: 88 additions & 0 deletions src/docdb/utils/rbacUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { SqlRoleAssignmentCreateUpdateParameters } from '@azure/arm-cosmosdb';
import { getResourceGroupFromId } from '@microsoft/vscode-azext-azureutils';
import { IActionContext, ISubscriptionContext } from '@microsoft/vscode-azext-utils';
import { randomUUID } from 'crypto';
import * as vscode from 'vscode';
import { Uri } from 'vscode';
import { createCosmosDBClient } from '../../utils/azureClients';
import { getDatabaseAccountNameFromId } from '../../utils/azureUtils';
import { DocDBAccountTreeItemBase } from '../tree/DocDBAccountTreeItemBase';

export async function ensureRbacPermission(docDbItem: DocDBAccountTreeItemBase, principalId: string, context: IActionContext): Promise<boolean> {
const accountName: string = getDatabaseAccountNameFromId(docDbItem.fullId);
if (await askForRbacPermissions(accountName, docDbItem.subscription.subscriptionDisplayName)) {
const resourceGroup: string = getResourceGroupFromId(docDbItem.fullId);
try {
await addRBACContributorPermission(accountName, principalId, resourceGroup, context, docDbItem.subscription);
return true;
} catch (error) {
// swallow the error, we want the user to reach out to the account owner if this failed
}
}
return false;
}

export function isRbacException(error: Error): boolean {
return (error instanceof Error && error.message.includes("does not have required RBAC permissions to perform action"));
}

export async function showRBACPermissionError(accountName: string, principalId: string): Promise<void> {
const message = `You do not have the required permissions to access '${accountName}' with your principal Id '${principalId}'.\nPlease contact the account owner to get the required permissions.`;
const readMoreItem = "Read More";
await vscode.window.showErrorMessage(message, { modal: false }, ...[readMoreItem]).then((item) => {
if (item === readMoreItem) {
void vscode.env.openExternal(Uri.parse("https://aka.ms/cosmos-native-rbac"));
}
});
}

async function askForRbacPermissions(databaseAccount: string, subscription: string): Promise<boolean> {
const message =
["You need the 'Data Contributor' RBAC role to enable all Azure Databases Extension features for the selected account.\n\n",
"Account Name: ", databaseAccount, "\n",
"Subscription: ", subscription, "\n"
].join("");
const options: vscode.MessageOptions = { modal: true, detail: message };
const readMoreItem = "Read More";
const setPermissionItem = "Extend RBAC permissions";

const result = await vscode.window.showWarningMessage('No required RBAC permissions', options, ...[setPermissionItem, readMoreItem]);
if (result === setPermissionItem) {
return true;
} else if (result === readMoreItem) {
void vscode.env.openExternal(Uri.parse("https://aka.ms/cosmos-native-rbac"));
}
return false;
}

async function addRBACContributorPermission(databaseAccount: string, principalId: string, resourceGroup: string, context: IActionContext, subscription: ISubscriptionContext): Promise<string | undefined> {
const defaultRoleId = "00000000-0000-0000-0000-000000000002"; // this is a predefined role with read and write access to data plane resources
const fullAccountId = `/subscriptions/${subscription.subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${databaseAccount}`;

const createUpdateSqlRoleAssignmentParameters: SqlRoleAssignmentCreateUpdateParameters =
{
principalId: principalId,
roleDefinitionId: fullAccountId + "/sqlRoleDefinitions/" + defaultRoleId,
scope: fullAccountId,
};

/*
// TODO: find a better way to check if a role assignment for the current user already exists,
// iterating over all role assignments and definitions is not efficient.
const rbac = client.sqlResources.listSqlRoleAssignments(resourceGroup, databaseAccount)
for await (const role of rbac) {
console.log(role);
}*/

const roleAssignmentId = randomUUID();
const client = await createCosmosDBClient([context, subscription]);
const create = await client.sqlResources.beginCreateUpdateSqlRoleAssignmentAndWait(roleAssignmentId, resourceGroup, databaseAccount, createUpdateSqlRoleAssignmentParameters);

return create.id;
}

0 comments on commit f05d010

Please sign in to comment.