diff --git a/extensions/mssql/package.json b/extensions/mssql/package.json index 51e3862b37..cbd6dfea0f 100644 --- a/extensions/mssql/package.json +++ b/extensions/mssql/package.json @@ -44,6 +44,10 @@ "command": "mssqlCluster.copyPath", "title": "%mssqlCluster.copyPath%" }, + { + "command": "mssqlCluster.manageAccess", + "title": "%mssqlCluster.manageAccess%" + }, { "command": "mssqlCluster.task.newNotebook", "title": "%notebook.command.new%", @@ -221,6 +225,10 @@ "command": "mssqlCluster.copyPath", "when": "false" }, + { + "command": "mssqlCluster.manageAccess", + "when": "false" + }, { "command": "mssqlCluster.task.newNotebook", "when": "false" diff --git a/extensions/mssql/package.nls.json b/extensions/mssql/package.nls.json index b16aaad4e2..e99c1d940c 100644 --- a/extensions/mssql/package.nls.json +++ b/extensions/mssql/package.nls.json @@ -12,6 +12,7 @@ "mssqlCluster.previewFile": "Preview", "mssqlCluster.saveFile": "Save", "mssqlCluster.copyPath": "Copy Path", + "mssqlCluster.manageAccess": "Manage Access", "notebook.command.new": "New Notebook", "notebook.command.open": "Open Notebook", diff --git a/extensions/mssql/src/apiWrapper.ts b/extensions/mssql/src/apiWrapper.ts index b984f5712c..87f6dc25eb 100644 --- a/extensions/mssql/src/apiWrapper.ts +++ b/extensions/mssql/src/apiWrapper.ts @@ -30,8 +30,8 @@ export class ApiWrapper { return azdata.dataprotocol.registerFileBrowserProvider(provider); } - public createDialog(title: string): azdata.window.Dialog { - return azdata.window.createModelViewDialog(title); + public createDialog(title: string, dialogName?: string, isWide?: boolean): azdata.window.Dialog { + return azdata.window.createModelViewDialog(title, dialogName, isWide); } public openDialog(dialog: azdata.window.Dialog): void { diff --git a/extensions/mssql/src/hdfs/aclEntry.ts b/extensions/mssql/src/hdfs/aclEntry.ts index a8d0b43a7d..e2b9562d34 100644 --- a/extensions/mssql/src/hdfs/aclEntry.ts +++ b/extensions/mssql/src/hdfs/aclEntry.ts @@ -101,7 +101,7 @@ export class AclEntryPermission { * e.g. The following are all valid strings * rwx * --- - * -w- + * -w- * @param permissionString The string representation of the permission */ function parseAclPermission(permissionString: string): AclEntryPermission { @@ -113,10 +113,11 @@ function parseAclPermission(permissionString: string): AclEntryPermission { } /** - * A single ACL Entry. This consists of up to 4 values - * scope - The scope of the entry @see AclEntryScope - * type - The type of the entry @see AclEntryType - * name - The name of the user/group. Optional. + * A single ACL Permission entry + * scope - The scope of the entry @see AclEntryScope + * type - The type of the entry @see AclEntryType + * name - The name of the user/group used to set ACLs Optional. + * displayName - The name to display in the UI * permission - The permission set for this ACL. @see AclPermission */ export class AclEntry { @@ -124,6 +125,7 @@ export class AclEntry { public readonly scope: AclEntryScope, public readonly type: AclEntryType | AclPermissionType, public readonly name: string, + public readonly displayName: string, public readonly permission: AclEntryPermission, ) { } @@ -135,12 +137,48 @@ export class AclEntry { * Example strings : * user:bob:rwx * default:user:bob:rwx - * user::r-x - * default:group::r-- + * user::r-x + * default:group::r-- */ - toString(): string { - return `${this.scope === AclEntryScope.default ? 'default:' : ''}${this.type}:${this.name}:${this.permission.toString()}`; + toAclString(): string { + return `${this.scope === AclEntryScope.default ? 'default:' : ''}${getAclEntryType(this.type)}:${this.name}:${this.permission.toString()}`; } + + /** + * Checks whether this and the specified AclEntry are equal. Two entries are considered equal + * if their scope, type and name are equal. + * @param other The other entry to compare against + */ + public isEqual(other: AclEntry): boolean { + if (!other) { + return false; + } + return this.scope === other.scope && + this.type === other.type && + this.name === other.name; + } +} + +/** + * Maps the possible entry types into their corresponding values for using in an ACL string + * @param type The type to convert + */ +function getAclEntryType(type: AclEntryType | AclPermissionType): AclEntryType { + // We only need to map AclPermissionType - AclEntryType is already the + // correct values we're mapping to. + if (type in AclPermissionType) { + switch (type) { + case AclPermissionType.owner: + return AclEntryType.user; + case AclPermissionType.group: + return AclEntryType.group; + case AclPermissionType.other: + return AclEntryType.other; + default: + throw new Error(`Unknown AclPermissionType : ${type}`); + } + } + return type; } /** @@ -156,7 +194,7 @@ export class AclEntry { * default:other:r-- * * So a valid ACL string might look like this - * user:bob:rwx,user::rwx,default::bob:rwx,group::r-x,default:other:r-- + * user:bob:rwx,user::rwx,default::bob:rwx,group::r-x,default:other:r-- * @param aclString The string representation of the ACL */ export function parseAcl(aclString: string): AclEntry[] { @@ -194,7 +232,7 @@ export function parseAclEntry(aclString: string): AclEntry { } const name = parts[i++]; const permission = parseAclPermission(parts[i++]); - return new AclEntry(scope, type, name, permission); + return new AclEntry(scope, type, name, name, permission); } /** @@ -208,7 +246,7 @@ export function parseAclEntry(aclString: string): AclEntry { * So an octal of 730 would map to : * - The owner with rwx permissions * - The group with -wx permissions - * - All others with --- permissions + * - All others with --- permissions * @param octal The octal string to parse */ export function parseAclPermissionFromOctal(octal: string): { owner: AclEntryPermission, group: AclEntryPermission, other: AclEntryPermission } { diff --git a/extensions/mssql/src/hdfs/hdfsModel.ts b/extensions/mssql/src/hdfs/hdfsModel.ts new file mode 100644 index 0000000000..ebc00a1467 --- /dev/null +++ b/extensions/mssql/src/hdfs/hdfsModel.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IFileSource } from '../objectExplorerNodeProvider/fileSources'; +import { IAclStatus, AclEntry, AclEntryScope, AclEntryType, AclEntryPermission } from './aclEntry'; + +/** + * Model for storing the state of a specified file/folder in HDFS + */ +export class HdfsModel { + + private readonly _onAclStatusUpdated = new vscode.EventEmitter(); + /** + * Event that's fired anytime changes are made by the model to the ACLStatus + */ + public onAclStatusUpdated = this._onAclStatusUpdated.event; + + /** + * The ACL status of the file/folder + */ + public aclStatus: IAclStatus; + + constructor(private fileSource: IFileSource, private path: string) { + this.refresh(); + } + + /** + * Refresh the ACL status with the current values on HDFS + */ + public async refresh(): Promise { + this.aclStatus = await this.fileSource.getAclStatus(this.path); + this._onAclStatusUpdated.fire(this.aclStatus); + } + + /** + * Creates a new ACL Entry and adds it to the list of current entries. Will do nothing + * if a duplicate entry (@see AclEntry.isEqual) exists + * @param name The name of the ACL Entry + * @param type The type of ACL to create + */ + public createAndAddAclEntry(name: string, type: AclEntryType): void { + if (!this.aclStatus) { + return; + } + const newEntry = new AclEntry(AclEntryScope.access, type, name, name, new AclEntryPermission(true, true, true)); + // Don't add duplicates. This also checks the owner, group and other items + if ([this.aclStatus.owner, this.aclStatus.group, this.aclStatus.other].concat(this.aclStatus.entries).find(entry => entry.isEqual(newEntry))) { + return; + } + + this.aclStatus.entries.push(newEntry); + this._onAclStatusUpdated.fire(this.aclStatus); + } + + /** + * Deletes the specified entry from the list of registered + * @param entryToDelete The entry to delete + */ + public deleteAclEntry(entryToDelete: AclEntry): void { + this.aclStatus.entries = this.aclStatus.entries.filter(entry => !entry.isEqual(entryToDelete)); + this._onAclStatusUpdated.fire(this.aclStatus); + } + + + /** + * Applies the changes made to this model to HDFS. Note that this will overwrite ALL permissions so any + * permissions that shouldn't change need to still exist and have the same values. + * @param recursive Whether to apply the changes recursively (to all sub-folders and files) + */ + public apply(recursive: boolean = false): Promise { + // TODO Apply recursive + return this.fileSource.setAcl(this.path, this.aclStatus.owner, this.aclStatus.group, this.aclStatus.other, this.aclStatus.entries); + } +} diff --git a/extensions/mssql/src/hdfs/ui/hdfsManageAccessDialog.ts b/extensions/mssql/src/hdfs/ui/hdfsManageAccessDialog.ts new file mode 100644 index 0000000000..e5cd367a09 --- /dev/null +++ b/extensions/mssql/src/hdfs/ui/hdfsManageAccessDialog.ts @@ -0,0 +1,337 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import { HdfsModel } from '../hdfsModel'; +import { IFileSource } from '../../objectExplorerNodeProvider/fileSources'; +import { IAclStatus, AclEntry, AclEntryType } from '../../hdfs/aclEntry'; +import { cssStyles } from './uiConstants'; +import { ownersHeader, permissionsHeader, stickyHeader, readHeader, writeHeader, executeHeader, manageAccessTitle, allOthersHeader, addUserOrGroupHeader, enterNamePlaceholder, addLabel, accessHeader, defaultHeader, applyText, applyRecursivelyText, userLabel, groupLabel, errorApplyingAclChanges } from '../../localizedConstants'; +import { HdfsError } from '../webhdfs'; +import { ApiWrapper } from '../../apiWrapper'; + +const permissionsNameColumnWidth = 250; +const permissionsStickyColumnWidth = 50; +const permissionsReadColumnWidth = 50; +const permissionsWriteColumnWidth = 50; +const permissionsExecuteColumnWidth = 50; +const permissionsDeleteColumnWidth = 50; + +const permissionsRowHeight = 75; + +export class ManageAccessDialog { + + private hdfsModel: HdfsModel; + private viewInitialized: boolean = false; + + private modelBuilder: azdata.ModelBuilder; + private rootLoadingComponent: azdata.LoadingComponent; + private ownersPermissionsContainer: azdata.FlexContainer; + private othersPermissionsContainer: azdata.FlexContainer; + private namedUsersAndGroupsPermissionsContainer: azdata.FlexContainer; + private addUserOrGroupInput: azdata.InputBoxComponent; + private dialog: azdata.window.Dialog; + + private addUserOrGroupSelectedType: AclEntryType; + + constructor(private hdfsPath: string, private fileSource: IFileSource, private readonly apiWrapper: ApiWrapper) { + this.hdfsModel = new HdfsModel(this.fileSource, this.hdfsPath); + this.hdfsModel.onAclStatusUpdated(aclStatus => this.handleAclStatusUpdated(aclStatus)); + } + + public openDialog(): void { + if (!this.dialog) { + this.dialog = this.apiWrapper.createDialog(manageAccessTitle, 'HdfsManageAccess', true); + this.dialog.okButton.label = applyText; + + const applyRecursivelyButton = azdata.window.createButton(applyRecursivelyText); + applyRecursivelyButton.onClick(async () => { + try { + await this.hdfsModel.apply(true); + } catch (err) { + this.apiWrapper.showErrorMessage(errorApplyingAclChanges(err instanceof HdfsError ? err.message : err)); + } + }); + this.dialog.customButtons = [applyRecursivelyButton]; + this.dialog.registerCloseValidator(async (): Promise => { + try { + await this.hdfsModel.apply(); + return true; + } catch (err) { + this.apiWrapper.showErrorMessage(errorApplyingAclChanges(err instanceof HdfsError ? err.message : err)); + } + return false; + }); + const tab = azdata.window.createTab(manageAccessTitle); + tab.registerContent(async (modelView: azdata.ModelView) => { + this.modelBuilder = modelView.modelBuilder; + + const rootContainer = modelView.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column', width: '100%', height: '100%' }) + .component(); + + this.rootLoadingComponent = modelView.modelBuilder.loadingComponent().withItem(rootContainer).component(); + + const pathLabel = modelView.modelBuilder.text().withProperties({ value: this.hdfsPath }).component(); + rootContainer.addItem(pathLabel, { flex: '0 0 auto' }); + + // ===================== + // = Permissions Title = + // ===================== + const permissionsTitle = modelView.modelBuilder.text() + .withProperties({ value: permissionsHeader, CSSStyles: { 'margin-block-start': '0px', 'margin-block-end': '10px' } }) + .component(); + rootContainer.addItem(permissionsTitle, { CSSStyles: { 'margin-top': '15px', 'padding-left': '10px', ...cssStyles.titleCss } }); + + // ============================== + // = Owners permissions section = + // ============================== + + const ownersPermissionsHeaderRow = createPermissionsHeaderRow(modelView.modelBuilder, ownersHeader, true); + rootContainer.addItem(ownersPermissionsHeaderRow, { CSSStyles: { 'padding-left': '10px', 'box-sizing': 'border-box', 'user-select': 'text' } }); + + // Empty initially - this is going to eventually be populated with the owner/owning group permissions + this.ownersPermissionsContainer = modelView.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component(); + rootContainer.addItem(this.ownersPermissionsContainer); + + // ============================== + // = Others permissions section = + // ============================== + + const othersPermissionsHeaderRow = modelView.modelBuilder.flexContainer().withLayout({ flexFlow: 'row' }).component(); + const ownersHeaderCell = modelView.modelBuilder.text().withProperties({ value: allOthersHeader }).component(); + othersPermissionsHeaderRow.addItem(ownersHeaderCell, { flex: '1 1 auto', CSSStyles: { 'width': `${permissionsNameColumnWidth}px`, 'min-width': `${permissionsNameColumnWidth}px`, ...cssStyles.tableHeaderCss } }); + + rootContainer.addItem(othersPermissionsHeaderRow, { CSSStyles: { 'padding-left': '10px', 'box-sizing': 'border-box', 'user-select': 'text' } }); + + // Empty initially - this is eventually going to be populated with the "Everyone" permissions + this.othersPermissionsContainer = modelView.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component(); + rootContainer.addItem(this.othersPermissionsContainer); + + // =========================== + // = Add User Or Group Input = + // =========================== + + const addUserOrGroupTitle = modelView.modelBuilder.text() + .withProperties({ value: addUserOrGroupHeader, CSSStyles: { 'margin-block-start': '0px', 'margin-block-end': '10px' } }) + .component(); + rootContainer.addItem(addUserOrGroupTitle, { CSSStyles: { 'margin-top': '15px', 'padding-left': '10px', ...cssStyles.titleCss } }); + + const typeContainer = modelView.modelBuilder.flexContainer().withProperties({ flexFlow: 'row' }).component(); + const aclEntryTypeGroup = 'aclEntryType'; + const userTypeButton = this.createRadioButton(modelView.modelBuilder, userLabel, aclEntryTypeGroup, AclEntryType.user); + const groupTypeButton = this.createRadioButton(modelView.modelBuilder, groupLabel, aclEntryTypeGroup, AclEntryType.group); + userTypeButton.checked = true; + this.addUserOrGroupSelectedType = AclEntryType.user; + + typeContainer.addItems([userTypeButton, groupTypeButton], { flex: '0 0 auto' }); + rootContainer.addItem(typeContainer, { flex: '0 0 auto' }); + const addUserOrGroupInputRow = modelView.modelBuilder.flexContainer().withLayout({ flexFlow: 'row' }).component(); + + this.addUserOrGroupInput = modelView.modelBuilder.inputBox().withProperties({ inputType: 'text', placeHolder: enterNamePlaceholder, width: 250 }).component(); + const addUserOrGroupButton = modelView.modelBuilder.button().withProperties({ label: addLabel, width: 75 }).component(); + addUserOrGroupButton.onDidClick(() => { + this.hdfsModel.createAndAddAclEntry(this.addUserOrGroupInput.value, this.addUserOrGroupSelectedType); + this.addUserOrGroupInput.value = ''; + }); + addUserOrGroupButton.enabled = false; // Init to disabled since we don't have any name entered in yet + this.addUserOrGroupInput.onTextChanged(() => { + if (this.addUserOrGroupInput.value === '') { + addUserOrGroupButton.enabled = false; + } else { + addUserOrGroupButton.enabled = true; + } + }); + addUserOrGroupInputRow.addItem(this.addUserOrGroupInput, { flex: '0 0 auto' }); + addUserOrGroupInputRow.addItem(addUserOrGroupButton, { flex: '0 0 auto', CSSStyles: { 'margin-left': '20px' } }); + + rootContainer.addItem(addUserOrGroupInputRow); + + // ================================================= + // = Named Users and Groups permissions header row = + // ================================================= + + const namedUsersAndGroupsPermissionsHeaderRow = createPermissionsHeaderRow(modelView.modelBuilder, ownersHeader, false); + rootContainer.addItem(namedUsersAndGroupsPermissionsHeaderRow, { CSSStyles: { 'padding-left': '10px', 'box-sizing': 'border-box', 'user-select': 'text' } }); + + // Empty initially - this is eventually going to be populated with the ACL entries set for this path + this.namedUsersAndGroupsPermissionsContainer = modelView.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component(); + rootContainer.addItem(this.namedUsersAndGroupsPermissionsContainer); + + this.viewInitialized = true; + this.handleAclStatusUpdated(this.hdfsModel.aclStatus); + await modelView.initializeModel(this.rootLoadingComponent); + }); + this.dialog.content = [tab]; + } + + azdata.window.openDialog(this.dialog); + } + + private handleAclStatusUpdated(aclStatus: IAclStatus): void { + if (!aclStatus || !this.viewInitialized) { + return; + } + + // Owners + const ownerPermissionsRow = this.createOwnerPermissionsRow(this.modelBuilder, aclStatus.stickyBit, aclStatus.owner); + const owningGroupPermissionsRow = this.createPermissionsRow(this.modelBuilder, aclStatus.group, false); + this.ownersPermissionsContainer.clearItems(); + this.ownersPermissionsContainer.addItems([ownerPermissionsRow, owningGroupPermissionsRow], { CSSStyles: { 'border-bottom': cssStyles.tableBorder, 'border-top': cssStyles.tableBorder } }); + + // Others + const otherPermissionsRow = this.createPermissionsRow(this.modelBuilder, aclStatus.other, false); + this.othersPermissionsContainer.clearItems(); + this.othersPermissionsContainer.addItem(otherPermissionsRow, { CSSStyles: { 'border-bottom': cssStyles.tableBorder, 'border-top': cssStyles.tableBorder } }); + + this.namedUsersAndGroupsPermissionsContainer.clearItems(); + // Named users and groups + aclStatus.entries.forEach(entry => { + const namedEntryRow = this.createPermissionsRow(this.modelBuilder, entry, true); + this.namedUsersAndGroupsPermissionsContainer.addItem(namedEntryRow, { CSSStyles: { 'border-bottom': cssStyles.tableBorder, 'border-top': cssStyles.tableBorder } }); + }); + + this.rootLoadingComponent.loading = false; + } + + private createRadioButton(modelBuilder: azdata.ModelBuilder, label: string, name: string, aclEntryType: AclEntryType): azdata.RadioButtonComponent { + const button = modelBuilder.radioButton().withProperties({ label: label, name: name }).component(); + button.onDidClick(() => { + this.addUserOrGroupSelectedType = aclEntryType; + }); + return button; + } + + private createOwnerPermissionsRow(builder: azdata.ModelBuilder, sticky: boolean, entry: AclEntry): azdata.FlexContainer { + const row = this.createPermissionsRow(builder, entry, false); + const stickyCheckbox = builder.checkBox().withProperties({ checked: sticky, height: 25, width: 25 }).component(); + const stickyContainer = builder.flexContainer().withLayout({ width: permissionsReadColumnWidth }).withItems([stickyCheckbox]).component(); + // Insert after name item but before other checkboxes + row.insertItem(stickyContainer, 1, { flex: '0 0 auto' }); + return row; + } + + private createPermissionsRow(builder: azdata.ModelBuilder, entry: AclEntry, includeDelete: boolean): azdata.FlexContainer { + const rowContainer = builder.flexContainer().withLayout({ flexFlow: 'row', height: permissionsRowHeight }).component(); + const nameCell = builder.text().withProperties({ value: entry.displayName }).component(); + rowContainer.addItem(nameCell); + + // Access - Read + const accessReadComponents = createCheckbox(builder, entry.permission.read, permissionsReadColumnWidth, permissionsRowHeight); + rowContainer.addItem(accessReadComponents.container, { flex: '0 0 auto' }); + accessReadComponents.checkbox.onChanged(() => { + entry.permission.read = accessReadComponents.checkbox.checked; + }); + + // Access - Write + const accessWriteComponents = createCheckbox(builder, entry.permission.write, permissionsWriteColumnWidth, permissionsRowHeight); + rowContainer.addItem(accessWriteComponents.container, { flex: '0 0 auto' }); + accessWriteComponents.checkbox.onChanged(() => { + entry.permission.write = accessWriteComponents.checkbox.checked; + }); + + // Access - Execute + const accessExecuteComponents = createCheckbox(builder, entry.permission.execute, permissionsExecuteColumnWidth, permissionsRowHeight); + rowContainer.addItem(accessExecuteComponents.container, { flex: '0 0 auto', CSSStyles: { 'border-right': cssStyles.tableBorder } }); + accessExecuteComponents.checkbox.onChanged(() => { + entry.permission.execute = accessExecuteComponents.checkbox.checked; + }); + + // Default - Read + const defaultReadComponents = createCheckbox(builder, false, permissionsReadColumnWidth, permissionsRowHeight); + rowContainer.addItem(defaultReadComponents.container, { flex: '0 0 auto' }); + defaultReadComponents.checkbox.onChanged(() => { + // entry.permission.read = defaultReadComponents.checkbox.checked; TODO hook up default logic + }); + + // Default - Write + const defaultWriteComponents = createCheckbox(builder, false, permissionsWriteColumnWidth, permissionsRowHeight); + rowContainer.addItem(defaultWriteComponents.container, { flex: '0 0 auto' }); + accessReadComponents.checkbox.onChanged(() => { + // entry.permission.write = accessReadComponents.checkbox.checked; TODO hook up default logic + }); + + // Default - Execute + const defaultExecuteComponents = createCheckbox(builder, false, permissionsExecuteColumnWidth, permissionsRowHeight); + rowContainer.addItem(defaultExecuteComponents.container, { flex: '0 0 auto' }); + accessReadComponents.checkbox.onChanged(() => { + // entry.permission.execute = accessReadComponents.checkbox.checked; TODO hook up default logic + }); + + const deleteContainer = builder.flexContainer().withLayout({ width: permissionsDeleteColumnWidth }).component(); + + if (includeDelete) { + const deleteButton = builder.button().withProperties({ label: 'Delete' }).component(); + deleteButton.onDidClick(() => { this.hdfsModel.deleteAclEntry(entry); }); + deleteContainer.addItem(deleteButton, { flex: '0 0 auto' }); + } + rowContainer.addItem(deleteContainer, { flex: '0 0 auto' }); + + return rowContainer; + } +} + +/** + * Creates the header row for the permissions tables. This contains headers for the name, optional sticky and then read/write/execute for both + * access and default sections. + * @param modelBuilder The builder used to create the model components + * @param nameColumnText The text to display for the name column + * @param includeSticky Whether to include the sticky header + */ +function createPermissionsHeaderRow(modelBuilder: azdata.ModelBuilder, nameColumnText: string, includeSticky: boolean): azdata.FlexContainer { + const rowsContainer = modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component(); + + // Section Headers + const sectionHeaderContainer = modelBuilder.flexContainer().withLayout({ flexFlow: 'row', justifyContent: 'flex-end' }).component(); + const accessSectionHeader = modelBuilder.text().withProperties({ value: accessHeader }).component(); + sectionHeaderContainer.addItem(accessSectionHeader, { CSSStyles: { 'width': `${permissionsReadColumnWidth + permissionsWriteColumnWidth + permissionsExecuteColumnWidth}px`, 'min-width': `${permissionsReadColumnWidth + permissionsWriteColumnWidth + permissionsExecuteColumnWidth}px`, ...cssStyles.tableHeaderCss } }); + const defaultSectionHeader = modelBuilder.text().withProperties({ value: defaultHeader }).component(); + sectionHeaderContainer.addItem(defaultSectionHeader, { CSSStyles: { 'width': `${permissionsReadColumnWidth + permissionsWriteColumnWidth + permissionsExecuteColumnWidth}px`, 'min-width': `${permissionsReadColumnWidth + permissionsWriteColumnWidth + permissionsExecuteColumnWidth}px`, ...cssStyles.tableHeaderCss } }); + + rowsContainer.addItem(sectionHeaderContainer); + + // Table headers + const headerRowContainer = modelBuilder.flexContainer().withLayout({ flexFlow: 'row' }).component(); + const ownersCell = modelBuilder.text().withProperties({ value: nameColumnText }).component(); + headerRowContainer.addItem(ownersCell, { flex: '1 1 auto', CSSStyles: { 'width': `${permissionsNameColumnWidth}px`, 'min-width': `${permissionsNameColumnWidth}px`, ...cssStyles.tableHeaderCss } }); + if (includeSticky) { + const stickyCell = modelBuilder.text().withProperties({ value: stickyHeader }).component(); + headerRowContainer.addItem(stickyCell, { CSSStyles: { 'width': `${permissionsStickyColumnWidth}px`, 'min-width': `${permissionsStickyColumnWidth}px`, ...cssStyles.tableHeaderCss } }); + } + + // Access Permissions Group + const accessReadCell = modelBuilder.text().withProperties({ value: readHeader }).component(); + headerRowContainer.addItem(accessReadCell, { CSSStyles: { 'width': `${permissionsReadColumnWidth}px`, 'min-width': `${permissionsReadColumnWidth}px`, ...cssStyles.tableHeaderCss } }); + const accessWriteCell = modelBuilder.text().withProperties({ value: writeHeader }).component(); + headerRowContainer.addItem(accessWriteCell, { CSSStyles: { 'width': `${permissionsWriteColumnWidth}px`, 'min-width': `${permissionsWriteColumnWidth}px`, ...cssStyles.tableHeaderCss } }); + const accessExecuteCell = modelBuilder.text().withProperties({ value: executeHeader }).component(); + headerRowContainer.addItem(accessExecuteCell, { CSSStyles: { 'width': `${permissionsExecuteColumnWidth}px`, 'min-width': `${permissionsExecuteColumnWidth}px`, ...cssStyles.tableHeaderCss } }); + // Default Permissions Group + const defaultReadCell = modelBuilder.text().withProperties({ value: readHeader }).component(); + headerRowContainer.addItem(defaultReadCell, { CSSStyles: { 'width': `${permissionsReadColumnWidth}px`, 'min-width': `${permissionsReadColumnWidth}px`, ...cssStyles.tableHeaderCss } }); + const defaultWriteCell = modelBuilder.text().withProperties({ value: writeHeader }).component(); + headerRowContainer.addItem(defaultWriteCell, { CSSStyles: { 'width': `${permissionsWriteColumnWidth}px`, 'min-width': `${permissionsWriteColumnWidth}px`, ...cssStyles.tableHeaderCss } }); + const defaultExecuteCell = modelBuilder.text().withProperties({ value: executeHeader }).component(); + headerRowContainer.addItem(defaultExecuteCell, { CSSStyles: { 'width': `${permissionsExecuteColumnWidth}px`, 'min-width': `${permissionsExecuteColumnWidth}px`, ...cssStyles.tableHeaderCss } }); + // Delete + const deleteCell = modelBuilder.text().component(); + headerRowContainer.addItem(deleteCell, { CSSStyles: { 'width': `${permissionsDeleteColumnWidth}px`, 'min-width': `${permissionsDeleteColumnWidth}px` } }); + + rowsContainer.addItem(headerRowContainer); + + return rowsContainer; +} + +function createCheckbox(builder: azdata.ModelBuilder, checked: boolean, containerWidth: number, containerHeight: number): { container: azdata.FlexContainer, checkbox: azdata.CheckBoxComponent } { + const checkbox = builder.checkBox().withProperties({ checked: checked, height: 25, width: 25 }).component(); + return { + container: builder.flexContainer() + .withLayout({ width: containerWidth, height: containerHeight }) + .withItems([checkbox]) + .component(), + checkbox: checkbox + }; + +} diff --git a/extensions/mssql/src/hdfs/ui/uiConstants.ts b/extensions/mssql/src/hdfs/ui/uiConstants.ts new file mode 100644 index 0000000000..8f12dd1cbb --- /dev/null +++ b/extensions/mssql/src/hdfs/ui/uiConstants.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export namespace cssStyles { + export const tableBorder = '1px solid #ccc'; + export const titleCss = { 'font-size': '20px', 'font-weight': '600' }; + export const tableHeaderCss = { 'font-weight': 'bold', 'text-transform': 'uppercase', 'font-size': '10px', 'user-select': 'text' }; +} diff --git a/extensions/mssql/src/hdfs/webhdfs.ts b/extensions/mssql/src/hdfs/webhdfs.ts index 0ccef66019..cd7b7fd708 100644 --- a/extensions/mssql/src/hdfs/webhdfs.ts +++ b/extensions/mssql/src/hdfs/webhdfs.ts @@ -12,6 +12,7 @@ import * as nls from 'vscode-nls'; import * as auth from '../util/auth'; import { IHdfsOptions, IRequestParams } from '../objectExplorerNodeProvider/fileSources'; import { IAclStatus, AclEntry, parseAcl, AclPermissionType, parseAclPermissionFromOctal, AclEntryScope } from './aclEntry'; +import { everyoneName } from '../localizedConstants'; const localize = nls.loadMessageBundle(); const ErrorMessageInvalidDataStructure = localize('webhdfs.invalidDataStructure', "Invalid Data Structure"); @@ -435,11 +436,11 @@ export class WebHDFS { } else if (response.body.hasOwnProperty('AclStatus')) { const permissions = parseAclPermissionFromOctal(response.body.AclStatus.permission); const aclStatus: IAclStatus = { - owner: new AclEntry(AclEntryScope.access, AclPermissionType.owner, response.body.AclStatus.owner || '', permissions.owner), - group: new AclEntry(AclEntryScope.access, AclPermissionType.group, response.body.AclStatus.group || '', permissions.group), - other: new AclEntry(AclEntryScope.access, AclPermissionType.other, response.body.AclStatus.other || '', permissions.other), + owner: new AclEntry(AclEntryScope.access, AclPermissionType.owner, '', response.body.AclStatus.owner || '', permissions.owner), + group: new AclEntry(AclEntryScope.access, AclPermissionType.group, '', response.body.AclStatus.group || '', permissions.group), + other: new AclEntry(AclEntryScope.access, AclPermissionType.other, '', everyoneName, permissions.other), stickyBit: !!response.body.AclStatus.stickyBit, - entries: (response.body.AclStatus.entries).map(entry => parseAcl(entry)).reduce((acc, parsedEntries) => acc.concat(parsedEntries, [])) + entries: (response.body.AclStatus.entries).map(entry => parseAcl(entry)).reduce((acc, parsedEntries) => acc.concat(parsedEntries), []) }; callback(undefined, aclStatus); } else { @@ -449,16 +450,22 @@ export class WebHDFS { } /** - * Set ACL for the given path + * Set ACL for the given path. The owner, group and other fields are required - other entries are optional. * @param path The path to the file/folder to set the ACL on - * @param aclEntries The ACL entries to set + * @param ownerEntry The entry corresponding to the path owner + * @param groupEntry The entry corresponding to the path owning group + * @param otherEntry The entry corresponding to default permissions for all other users + * @param aclEntries The optional additional ACL entries to set * @param callback Callback to handle the response * @returns void */ - public setAcl(path: string, aclEntries: AclEntry[], callback: (error: HdfsError) => void): void { + public setAcl(path: string, ownerEntry: AclEntry, groupEntry: AclEntry, otherEntry: AclEntry, aclEntries: AclEntry[], callback: (error: HdfsError) => void): void { this.checkArgDefined('path', path); + this.checkArgDefined('ownerEntry', ownerEntry); + this.checkArgDefined('groupEntry', groupEntry); + this.checkArgDefined('otherEntry', otherEntry); this.checkArgDefined('aclEntries', aclEntries); - const aclSpec = aclEntries.join(','); + const aclSpec = [ownerEntry, groupEntry, otherEntry].concat(aclEntries).map(entry => entry.toAclString()).join(','); let endpoint = this.getOperationEndpoint('setacl', path, { aclspec: aclSpec }); this.sendRequest('PUT', endpoint, undefined, (error) => { return callback && callback(error); diff --git a/extensions/mssql/src/localizedConstants.ts b/extensions/mssql/src/localizedConstants.ts index f683815496..e0bcb057cc 100644 --- a/extensions/mssql/src/localizedConstants.ts +++ b/extensions/mssql/src/localizedConstants.ts @@ -3,14 +3,36 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -'use strict'; - import * as nls from 'vscode-nls'; const localize = nls.loadMessageBundle(); // HDFS Constants ////////////////////////////////////////////////////////// export const msgMissingNodeContext = localize('msgMissingNodeContext', 'Node Command called without any node passed'); +// HDFS Manage Access Dialog Constants //////////////////////////////////// + +export const manageAccessTitle = localize('mssql.manageAccessTitle', "Manage Access"); +export const permissionsHeader = localize('mssql.permissionsTitle', "Permissions"); +export const ownersHeader = localize('mssql.ownersHeader', "Owners"); +export const allOthersHeader = localize('mssql.allOthersHeader', "All Others"); +export const everyoneName = localize('mssql.everyone', "Everyone"); +export const userLabel = localize('mssql.userLabel', "User"); +export const groupLabel = localize('mssql.groupLabel', "Group"); +export const accessHeader = localize('mssql.accessHeader', "Access"); +export const defaultHeader = localize('mssql.defaultHeader', "Default"); +export const stickyHeader = localize('mssql.stickyHeader', "Sticky"); +export const readHeader = localize('mssql.readHeader', "Read"); +export const writeHeader = localize('mssql.writeHeader', "Write"); +export const executeHeader = localize('mssql.executeHeader', "Execute"); +export const addUserOrGroupHeader = localize('mssql.addUserOrGroup', "Add User or Group"); +export const enterNamePlaceholder = localize('mssql.enterNamePlaceholder', "Enter name"); +export const addLabel = localize('mssql.addLabel', "Add"); +export const namedUsersAndGroupsHeader = localize('mssql.namedUsersAndGroups', "Named Users and Groups"); +export const applyText = localize('mssql.apply', "Apply"); +export const applyRecursivelyText = localize('mssql.applyRecursively', "Apply Recursively"); + +export function errorApplyingAclChanges(errMsg: string): string { return localize('mssql.errorApplyingAclChanges', "Unexpected error occurred while applying changes : {0}", errMsg); } + // Spark Job Submission Constants ////////////////////////////////////////// export const sparkLocalFileDestinationHint = localize('sparkJobSubmission_LocalFileDestinationHint', 'Local file will be uploaded to HDFS. '); export const sparkJobSubmissionEndMessage = localize('sparkJobSubmission_SubmissionEndMessage', '.......................... Submit Spark Job End ............................'); diff --git a/extensions/mssql/src/main.ts b/extensions/mssql/src/main.ts index a0212e0601..a7c63c624a 100644 --- a/extensions/mssql/src/main.ts +++ b/extensions/mssql/src/main.ts @@ -13,7 +13,7 @@ import ContextProvider from './contextProvider'; import * as Utils from './utils'; import { AppContext } from './appContext'; import { ApiWrapper } from './apiWrapper'; -import { UploadFilesCommand, MkDirCommand, SaveFileCommand, PreviewFileCommand, CopyPathCommand, DeleteFilesCommand } from './objectExplorerNodeProvider/hdfsCommands'; +import { UploadFilesCommand, MkDirCommand, SaveFileCommand, PreviewFileCommand, CopyPathCommand, DeleteFilesCommand, ManageAccessCommand } from './objectExplorerNodeProvider/hdfsCommands'; import { IPrompter } from './prompts/question'; import CodeAdapter from './prompts/adapter'; import { IExtension } from './mssql'; @@ -99,6 +99,7 @@ function registerHdfsCommands(context: vscode.ExtensionContext, prompter: IPromp context.subscriptions.push(new PreviewFileCommand(prompter, appContext)); context.subscriptions.push(new CopyPathCommand(appContext)); context.subscriptions.push(new DeleteFilesCommand(prompter, appContext)); + context.subscriptions.push(new ManageAccessCommand(appContext)); } function activateSparkFeatures(appContext: AppContext): void { diff --git a/extensions/mssql/src/objectExplorerNodeProvider/fileSources.ts b/extensions/mssql/src/objectExplorerNodeProvider/fileSources.ts index 79014c8879..4072373da1 100644 --- a/extensions/mssql/src/objectExplorerNodeProvider/fileSources.ts +++ b/extensions/mssql/src/objectExplorerNodeProvider/fileSources.ts @@ -65,8 +65,20 @@ export interface IFileSource { readFileLines(path: string, maxLines: number): Promise; writeFile(localFile: IFile, remoteDir: string): Promise; delete(path: string, recursive?: boolean): Promise; + /** + * Get ACL status for given path + * @param path The path to the file/folder to get the status of + */ getAclStatus(path: string): Promise; - setAcl(path: string, aclEntries: AclEntry[]): Promise; + /** + * Sets the ACL status for given path + * @param path The path to the file/folder to set the ACL on + * @param ownerEntry The entry corresponding to the path owner + * @param groupEntry The entry corresponding to the path owning group + * @param otherEntry The entry corresponding to default permissions for all other users + * @param aclEntries The ACL entries to set + */ + setAcl(path: string, ownerEntry: AclEntry, groupEntry: AclEntry, otherEntry: AclEntry, aclEntries: AclEntry[]): Promise; exists(path: string): Promise; } @@ -323,11 +335,14 @@ export class HdfsFileSource implements IFileSource { /** * Sets the ACL status for given path * @param path The path to the file/folder to set the ACL on + * @param ownerEntry The entry corresponding to the path owner + * @param groupEntry The entry corresponding to the path owning group + * @param otherEntry The entry corresponding to default permissions for all other users * @param aclEntries The ACL entries to set */ - public setAcl(path: string, aclEntries: AclEntry[]): Promise { + public setAcl(path: string, ownerEntry: AclEntry, groupEntry: AclEntry, otherEntry: AclEntry, aclEntries: AclEntry[]): Promise { return new Promise((resolve, reject) => { - this.client.setAcl(path, aclEntries, (error: HdfsError) => { + this.client.setAcl(path, ownerEntry, groupEntry, otherEntry, aclEntries, (error: HdfsError) => { if (error) { reject(error); } else { diff --git a/extensions/mssql/src/objectExplorerNodeProvider/hdfsCommands.ts b/extensions/mssql/src/objectExplorerNodeProvider/hdfsCommands.ts index cbbcca9bbb..ca5cd8c84f 100644 --- a/extensions/mssql/src/objectExplorerNodeProvider/hdfsCommands.ts +++ b/extensions/mssql/src/objectExplorerNodeProvider/hdfsCommands.ts @@ -21,6 +21,7 @@ import * as utils from '../utils'; import { AppContext } from '../appContext'; import { TreeNode } from './treeNodes'; import { MssqlObjectExplorerNodeProvider } from './objectExplorerNodeProvider'; +import { ManageAccessDialog } from '../hdfs/ui/hdfsManageAccessDialog'; async function getSaveableUri(apiWrapper: ApiWrapper, fileName: string, isPreview?: boolean): Promise { let root = utils.getUserHome(); @@ -384,3 +385,24 @@ export class CopyPathCommand extends Command { } } } + +export class ManageAccessCommand extends Command { + + constructor(appContext: AppContext) { + super('mssqlCluster.manageAccess', appContext); + } + + async execute(context: ICommandViewContext | ICommandObjectExplorerContext, ...args: any[]): Promise { + try { + let node = await getNode(context, this.appContext); + if (node) { + new ManageAccessDialog(node.hdfsPath, node.fileSource, this.apiWrapper).openDialog(); + } else { + this.apiWrapper.showErrorMessage(LocalizedConstants.msgMissingNodeContext); + } + } catch (err) { + this.apiWrapper.showErrorMessage( + localize('manageAccessError', "An unexpected error occurred while opening the Manage Access dialog: {0}", utils.getErrorMessage(err, true))); + } + } +} diff --git a/extensions/mssql/src/objectExplorerNodeProvider/hdfsProvider.ts b/extensions/mssql/src/objectExplorerNodeProvider/hdfsProvider.ts index a6757176c4..b7c260ba5d 100644 --- a/extensions/mssql/src/objectExplorerNodeProvider/hdfsProvider.ts +++ b/extensions/mssql/src/objectExplorerNodeProvider/hdfsProvider.ts @@ -75,7 +75,7 @@ export class HdfsProvider implements vscode.TreeDataProvider, ITreeCha } export abstract class HdfsFileSourceNode extends TreeNode { - constructor(protected context: TreeDataContext, protected _path: string, protected fileSource: IFileSource) { + constructor(protected context: TreeDataContext, protected _path: string, public readonly fileSource: IFileSource) { super(); }