diff --git a/bigquery/cloud-client/grantAccessToDataset.js b/bigquery/cloud-client/grantAccessToDataset.js new file mode 100644 index 0000000000..5b493ecf23 --- /dev/null +++ b/bigquery/cloud-client/grantAccessToDataset.js @@ -0,0 +1,111 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +/** + * Grants access to a BigQuery dataset for a specified entity. + * + * @param {string} datasetId ID of the dataset to grant access to. + * @param {string} entityId ID of the entity to grant access to. + * @param {string} role Role to grant. + * @returns {Promise} Array of access entries. + */ +async function grantAccessToDataset(datasetId, entityId, role) { + // [START bigquery_grant_access_to_dataset] + const {BigQuery} = require('@google-cloud/bigquery'); + + // Define enum for HTTP codes. + const HTTP_STATUS = { + PRECONDITION_FAILED: 412, + }; + + // TODO(developer): Update and un-comment below lines. + + // ID of the dataset to revoke access to. + // datasetId = "my_project_id.my_dataset_name"; + + // ID of the user or group from whom you are adding access. + // Alternatively, the JSON REST API representation of the entity, + // such as a view's table reference. + // entityId = "user-or-group-to-add@example.com"; + + // One of the "Basic roles for datasets" described here: + // https://cloud.google.com/bigquery/docs/access-control-basic-roles#dataset-basic-roles + // role = "READER"; + + // Type of entity you are granting access to. + // Find allowed allowed entity type names here: + // https://cloud.google.com/bigquery/docs/reference/rest/v2/datasets#resource:-dataset + // In this case, we're using groupByEmail + const entityType = 'groupByEmail'; + + // Instantiate a client. + const client = new BigQuery(); + + try { + // Get a reference to the dataset. + const [dataset] = await client.dataset(datasetId).get(); + + // The 'access entries' array is immutable. Create a copy for modifications. + const entries = Array.isArray(dataset.metadata.access) + ? [...dataset.metadata.access] + : []; + + // Append an AccessEntry to grant the role to a dataset. + // Find more details about the AccessEntry object in the BigQuery documentation: + // https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.dataset.AccessEntry + entries.push({ + role: role, + [entityType]: entityId, + }); + + // Assign the array of AccessEntries back to the dataset. + const metadata = { + access: entries, + }; + + // Update will only succeed if the dataset + // has not been modified externally since retrieval. + // + // See the BigQuery client library documentation for more details on metadata updates: + // https://cloud.google.com/nodejs/docs/reference/bigquery/latest + + // Update just the 'access entries' property of the dataset. + const [updatedDataset] = await client + .dataset(datasetId) + .setMetadata(metadata); + + // Show a success message. + console.log( + `Role '${role}' granted for entity '${entityId}' in dataset '${datasetId}'.` + ); + + return updatedDataset.access; + } catch (error) { + if (error.code === HTTP_STATUS.PRECONDITION_FAILED) { + console.error( + `Dataset '${datasetId}' was modified remotely before this update. ` + + 'Fetch the latest version and retry.' + ); + } else { + throw error; + } + } + // [END bigquery_grant_access_to_dataset] +} + +module.exports = { + grantAccessToDataset, +}; diff --git a/bigquery/cloud-client/grantAccessToTableOrView.js b/bigquery/cloud-client/grantAccessToTableOrView.js new file mode 100644 index 0000000000..d1fad7bfe1 --- /dev/null +++ b/bigquery/cloud-client/grantAccessToTableOrView.js @@ -0,0 +1,95 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +/** + * Grants access to a BigQuery table or view for a specified principal. + * + * @param {string} projectId Google Cloud Platform project ID. + * @param {string} datasetId Dataset where the table or view is. + * @param {string} resourceName Table or view name to get the access policy. + * @param {string} principalId The principal requesting access to the table or view. + * @param {string} role Role to assign to the member. + * @returns {Promise} The updated policy bindings. + */ +async function grantAccessToTableOrView( + projectId, + datasetId, + resourceName, + principalId, + role +) { + // [START bigquery_grant_access_to_table_or_view] + const {BigQuery} = require('@google-cloud/bigquery'); + + // TODO(developer): Update and un-comment below lines. + + // Google Cloud Platform project. + // projectId = "my_project_id" + + // Dataset where the table or view is. + // datasetId = "my_dataset_id" + + // Table or view name to get the access policy. + // resourceName = "my_table_id" + + // The principal requesting access to the table or view. + // Find more details about principal identifiers here: + // https://cloud.google.com/iam/docs/principal-identifiers + // principalId = "user:bob@example.com" + + // Role to assign to the member. + // role = "roles/bigquery.dataViewer" + + // Instantiate a client. + const client = new BigQuery(); + + // Get a reference to the dataset by datasetId. + const dataset = client.dataset(datasetId); + // Get a reference to the table by tableName. + const table = dataset.table(resourceName); + + // Get the IAM access policy for the table or view. + const [policy] = await table.getIamPolicy(); + + // Initialize bindings array. + if (!policy.bindings) { + policy.bindings = []; + } + + // To grant access to a table or view + // add bindings to the Table or View policy. + // + // Find more details about Policy and Binding objects here: + // https://cloud.google.com/security-command-center/docs/reference/rest/Shared.Types/Policy + // https://cloud.google.com/security-command-center/docs/reference/rest/Shared.Types/Binding + const binding = { + role: role, + members: [principalId], + }; + policy.bindings.push(binding); + + // Set the IAM access policy with updated bindings. + const [updatedPolicy] = await table.setIamPolicy(policy); + + // Show a success message. + console.log( + `Role '${role}' granted for principal '${principalId}' on resource '${datasetId}.${resourceName}'.` + ); + // [END bigquery_grant_access_to_table_or_view] + return updatedPolicy.bindings; +} + +module.exports = {grantAccessToTableOrView}; diff --git a/bigquery/cloud-client/package.json b/bigquery/cloud-client/package.json new file mode 100644 index 0000000000..4316e269c0 --- /dev/null +++ b/bigquery/cloud-client/package.json @@ -0,0 +1,26 @@ +{ + "name": "bigquery-cloud-client", + "description": "Big Query Cloud Client Node.js samples", + "version": "0.0.1", + "private": true, + "license": "Apache Version 2.0", + "author": "Google LLC", + "engines": { + "node": "20.x" + }, + "scripts": { + "deploy": "gcloud app deploy", + "start": "node app.js", + "unit-test": "c8 mocha -p -j 2 test/ --timeout=10000 --exit", + "test": "npm run unit-test" + }, + "dependencies": { + "@google-cloud/bigquery": "7.9.2" + }, + "devDependencies": { + "c8": "^10.0.0", + "chai": "^4.5.0", + "mocha": "^10.0.0", + "sinon": "^18.0.0" + } +} \ No newline at end of file diff --git a/bigquery/cloud-client/revokeDatasetAccess.js b/bigquery/cloud-client/revokeDatasetAccess.js new file mode 100644 index 0000000000..8b11a1cc95 --- /dev/null +++ b/bigquery/cloud-client/revokeDatasetAccess.js @@ -0,0 +1,91 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +/** + * Revokes access to a dataset for a specified entity. + * + * @param {string} datasetId ID of the dataset to revoke access to. + * @param {string} entityId ID of the user or group from whom you are revoking access. + * Alternatively, the JSON REST API representation of the entity, + * such as a view's table reference. + * @returns {Promise} A promise that resolves to the updated access entries. + */ +async function revokeDatasetAccess(datasetId, entityId) { + // [START bigquery_revoke_dataset_access] + const {BigQuery} = require('@google-cloud/bigquery'); + + // Define enum for HTTP codes. + const HTTP_STATUS = { + PRECONDITION_FAILED: 412, + }; + + // TODO (developer): Update and un-comment below lines. + + // ID of the dataset to revoke access to. + // datasetId = "my_project.my_dataset" + + // ID of the user or group from whom you are revoking access. + // Alternatively, the JSON REST API representation of the entity, + // such as a view's table reference. + // entityId = "user-or-group-to-remove@example.com" + + // Instantiate a client. + const bigquery = new BigQuery(); + + // Get a reference to the dataset. + const [dataset] = await bigquery.dataset(datasetId).get(); + + // To revoke access to a dataset, remove elements from the access array. + // + // See the BigQuery client library documentation for more details on access entries: + // https://cloud.google.com/nodejs/docs/reference/secret-manager/4.1.4 + + // Filter access entries to exclude entries matching the specified entity_id + // and assign a new array back to the access array. + dataset.metadata.access = dataset.metadata.access.filter(entry => { + // Return false (remove entry) if any of these fields match entityId. + return !( + entry.entity_id === entityId || + entry.userByEmail === entityId || + entry.groupByEmail === entityId + ); + }); + + // Update will only succeed if the dataset + // has not been modified externally since retrieval. + + try { + // Update just the access entries property of the dataset. + const [updatedDataset] = await dataset.setMetadata(dataset.metadata); + + return updatedDataset.access; + } catch (error) { + // Check if it's a precondition failed error (a read-modify-write error). + if (error.code === HTTP_STATUS.PRECONDITION_FAILED) { + console.log( + `Dataset '${dataset.id}' was modified remotely before this update. ` + + 'Fetch the latest version and retry.' + ); + } else { + throw error; + } + } + // [END bigquery_revoke_dataset_access] +} + +module.exports = { + revokeDatasetAccess, +}; diff --git a/bigquery/cloud-client/revokeTableOrViewAccess.js b/bigquery/cloud-client/revokeTableOrViewAccess.js new file mode 100644 index 0000000000..d3b3ae5647 --- /dev/null +++ b/bigquery/cloud-client/revokeTableOrViewAccess.js @@ -0,0 +1,119 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +/** + * Revokes access to a BigQuery table or view. + * @param {string} projectId The ID of the Google Cloud project. + * @param {string} datasetId The ID of the dataset containing the table/view. + * @param {string} resourceName The ID of the table or view. + * @param {string} [roleToRemove=null] Optional. Specific role to revoke. + * @param {string} [principalToRemove=null] Optional. Specific principal to revoke access from. + * @returns {Promise} The updated IAM policy. + */ +async function revokeAccessToTableOrView( + projectId, + datasetId, + resourceName, + roleToRemove = null, + principalToRemove = null +) { + // [START bigquery_revoke_access_to_table_or_view] + const {BigQuery} = require('@google-cloud/bigquery'); + + // TODO (developer): Update and un-comment below lines. + + // Google Cloud Platform project. + // projectId = "my_project_id" + + // Dataset where the table or view is. + // datasetId = "my_dataset_id" + + // Table or view name to get the access policy. + // resourceName = "my_table_id" + + // (Optional) Role to remove from the table or view. + // roleToRemove = "roles/bigquery.dataViewer" + + // (Optional) Principal to remove from the table or view. + // principalToRemove = "user:alice@example.com" + + // Find more information about roles and principals (refered as members) here: + // https://cloud.google.com/security-command-center/docs/reference/rest/Shared.Types/Binding + + // Instantiate a client. + const client = new BigQuery(); + + // Get a reference to the dataset by datasetId. + const dataset = client.dataset(datasetId); + // Get a reference to the table by tableName. + const table = dataset.table(resourceName); + + // Get the IAM access policy for the table or view. + const [policy] = await table.getIamPolicy(); + + // Initialize bindings array. + if (!policy.bindings) { + policy.bindings = []; + } + + // To revoke access to a table or view, + // remove bindings from the Table or View policy. + // + // Find more details about Policy objects here: + // https://cloud.google.com/security-command-center/docs/reference/rest/Shared.Types/Policy + + if (roleToRemove) { + // Filter out all bindings with the `roleToRemove` + // and assign a new array back to the policy bindings. + policy.bindings = policy.bindings.filter(b => b.role !== roleToRemove); + } + + if (principalToRemove) { + // The `bindings` array is immutable. Create a copy for modifications. + const bindings = [...policy.bindings]; + + // Filter out the principal from each binding. + for (const binding of bindings) { + if (binding.members) { + binding.members = binding.members.filter(m => m !== principalToRemove); + } + } + + // Filter out bindings with empty members. + policy.bindings = bindings.filter( + binding => binding.members && binding.members.length > 0 + ); + } + + try { + // Set the IAM access policy with updated bindings. + await table.setIamPolicy(policy); + + // Get the policy again to confirm it's set correctly. + const [verifiedPolicy] = await table.getIamPolicy(); + + // Return the updated policy bindings. + return verifiedPolicy && verifiedPolicy.bindings + ? verifiedPolicy.bindings + : []; + } catch (error) { + console.error('Error settings IAM policy:', error); + throw error; + } + // [END bigquery_revoke_access_to_table_or_view] +} + +module.exports = {revokeAccessToTableOrView}; diff --git a/bigquery/cloud-client/test/config.js b/bigquery/cloud-client/test/config.js new file mode 100644 index 0000000000..a4f56ef623 --- /dev/null +++ b/bigquery/cloud-client/test/config.js @@ -0,0 +1,255 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const {BigQuery} = require('@google-cloud/bigquery'); +const crypto = require('crypto'); + +// Define enum for HTTP codes +const HTTP_STATUS = { + NOT_FOUND: 404, +}; + +// Generate a unique prefix using a random UUID to ensure uniqueness across test runs +function createRandomPrefix() { + return `nodejs_test_${crypto.randomUUID().replace(/-/g, '').substring(0, 8)}`; +} + +const PREFIX = createRandomPrefix(); +console.log(`Generated test prefix: ${PREFIX}`); + +const ENTITY_ID = 'cloud-developer-relations@google.com'; // Group account +const DATASET_ID = `${PREFIX}_cloud_client`; +const TABLE_NAME = `${PREFIX}_table`; +const VIEW_NAME = `${PREFIX}_view`; + +// Shared client for all tests +let sharedClient = null; +let sharedProjectId = null; +let sharedDataset = null; +let sharedTable = null; +let sharedView = null; +let resourcesCreated = false; + +// Helper functions to get shared resources +const getClient = async () => { + if (!sharedClient) { + sharedClient = new BigQuery(); + } + return sharedClient; +}; + +const getProjectId = async () => { + if (!sharedProjectId) { + const client = await getClient(); + sharedProjectId = client.projectId; + } + return sharedProjectId; +}; + +const getEntityId = () => ENTITY_ID; + +const getDataset = async () => { + try { + if (!sharedDataset) { + const client = await getClient(); + + try { + // First try to get the dataset if it exists + console.log(`Checking for dataset ${DATASET_ID}`); + [sharedDataset] = await client.dataset(DATASET_ID).get(); + console.log(`Using existing dataset: ${DATASET_ID}`); + } catch (err) { + if (err.code === HTTP_STATUS.NOT_FOUND) { + // If dataset doesn't exist, create it + console.log(`Creating dataset: ${DATASET_ID}...`); + [sharedDataset] = await client.createDataset(DATASET_ID); + resourcesCreated = true; + console.log(`Created dataset: ${DATASET_ID}`); + } else { + console.error(`Error getting dataset: ${err.message}`); + throw err; + } + } + } + return sharedDataset; + } catch (err) { + console.error(`Error in getDataset: ${err.message}`); + throw err; + } +}; + +const getTable = async () => { + try { + if (!sharedTable) { + const client = await getClient(); + + const sample_schema = [{name: 'id', type: 'INTEGER', mode: 'REQUIRED'}]; + const tableOptions = { + schema: sample_schema, + }; + + try { + // Try to get table if it exists + console.log(`Checking for table ${TABLE_NAME}`); + [sharedTable] = await client + .dataset(DATASET_ID) + .table(TABLE_NAME) + .get(); + console.log(`Using existing table: ${TABLE_NAME}`); + } catch (err) { + if (err.code === HTTP_STATUS.NOT_FOUND) { + // If table doesn't exist, create it + console.log(`Creating table: ${TABLE_NAME}...`); + [sharedTable] = await client + .dataset(DATASET_ID) + .createTable(TABLE_NAME, tableOptions); + resourcesCreated = true; + console.log(`Created table: ${TABLE_NAME}`); + } else { + console.error(`Error getting table: ${err.message}`); + throw err; + } + } + } + return sharedTable; + } catch (err) { + console.error(`Error in getTable: ${err.message}`); + throw err; + } +}; +const getView = async () => { + try { + if (!sharedView) { + const client = await getClient(); + const projectId = await getProjectId(); + + const viewOptions = { + view: { + query: `SELECT * FROM \`${projectId}.${DATASET_ID}.${TABLE_NAME}\``, + useLegacySql: false, + }, + }; + + try { + // Try to get view if it exists + console.log(`Checking for view ${VIEW_NAME}`); + [sharedView] = await client.dataset(DATASET_ID).table(VIEW_NAME).get(); + console.log(`Using existing view: ${VIEW_NAME}`); + } catch (err) { + if (err.code === HTTP_STATUS.NOT_FOUND) { + // If view doesn't exist, create it + console.log(`Creating view: ${VIEW_NAME}...`); + [sharedView] = await client + .dataset(DATASET_ID) + .createTable(VIEW_NAME, viewOptions); + resourcesCreated = true; + console.log(`Created view: ${VIEW_NAME}`); + } else { + console.error(`Error getting view: ${err.message}`); + throw err; + } + } + } + return sharedView; + } catch (err) { + console.error(`Error in getView: ${err.message}`); + throw err; + } +}; + +// Setup and teardown functions for test suites +const setupBeforeAll = async () => { + console.log('=== Setting up test resources ==='); + try { + await getClient(); + await getProjectId(); + // Initialize dataset, table, and view + await getDataset(); + await getTable(); + await getView(); + console.log('=== Test setup complete ==='); + } catch (err) { + console.error(`Setup failed: ${err.message}`); + throw err; + } +}; + +const cleanupResources = async () => { + console.log('=== Cleaning up test resources ==='); + + if (sharedClient && sharedDataset) { + try { + console.log( + `Deleting dataset: ${DATASET_ID} and all contained tables/views` + ); + await sharedClient.dataset(DATASET_ID).delete({force: true}); + console.log(`Successfully deleted dataset: ${DATASET_ID}`); + } catch (err) { + if (err.code !== HTTP_STATUS.NOT_FOUND) { + console.error(`Error deleting dataset: ${err.message}`); + } else { + console.log(`Dataset ${DATASET_ID} already deleted or not found`); + } + } + } + + // Reset all shared resources + sharedClient = null; + sharedProjectId = null; + sharedDataset = null; + sharedTable = null; + sharedView = null; + resourcesCreated = false; + + console.log('=== Cleanup complete ==='); +}; + +const teardownAfterAll = async () => { + // Always clean up resources after tests + await cleanupResources(); +}; + +// Cleanup on process exit or termination +process.on('exit', () => { + if (resourcesCreated) { + console.log('Process exiting, cleaning up BigQuery resources...'); + } +}); + +process.on('SIGINT', async () => { + console.log('Received SIGINT, cleaning up before exit...'); + await cleanupResources(); +}); + +process.on('uncaughtException', async err => { + console.error('Uncaught exception:', err); + await cleanupResources(); +}); + +module.exports = { + PREFIX, + ENTITY_ID, + DATASET_ID, + TABLE_NAME, + VIEW_NAME, + getClient, + getProjectId, + getEntityId, + getDataset, + getTable, + getView, + setupBeforeAll, + teardownAfterAll, + cleanupResources, +}; diff --git a/bigquery/cloud-client/test/grantAccessToDataset.test.js b/bigquery/cloud-client/test/grantAccessToDataset.test.js new file mode 100644 index 0000000000..3bccabdae4 --- /dev/null +++ b/bigquery/cloud-client/test/grantAccessToDataset.test.js @@ -0,0 +1,70 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +const {expect} = require('chai'); +const { + getDataset, + getEntityId, + setupBeforeAll, + teardownAfterAll, +} = require('./config'); +const {grantAccessToDataset} = require('../grantAccessToDataset'); + +describe('grantAccessToDataset', () => { + // Set up fixtures before all tests (similar to pytest's module scope). + before(async () => { + await setupBeforeAll(); + }); + + // Clean up after all tests. + after(async () => { + await teardownAfterAll(); + }); + + it('should add entity to access entries', async () => { + const dataset = await getDataset(); + const entityId = getEntityId(); + + console.log({dataset}); + console.log({entityId}); + + // Act: Grant access to the dataset. + const accessEntries = await grantAccessToDataset( + dataset.id, + entityId, + 'READER' + ); + + // Assert: Check if entity was added to access entries. + const updatedEntityIds = accessEntries + .filter(entry => entry !== null) + .map(entry => { + // Handle different entity types. + if (entry.groupByEmail) { + return entry.groupByEmail; + } else if (entry.userByEmail) { + return entry.userByEmail; + } else if (entry.specialGroup) { + return entry.specialGroup; + } + return null; + }) + .filter(id => id !== null); + + // Check if our entity ID is in the updated access entries. + expect(updatedEntityIds).to.include(entityId); + }); +}); diff --git a/bigquery/cloud-client/test/grantAccessToTableOrView.test.js b/bigquery/cloud-client/test/grantAccessToTableOrView.test.js new file mode 100644 index 0000000000..12f421a4e6 --- /dev/null +++ b/bigquery/cloud-client/test/grantAccessToTableOrView.test.js @@ -0,0 +1,93 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const {describe, it, before, after} = require('mocha'); +const assert = require('assert'); +const {grantAccessToTableOrView} = require('../grantAccessToTableOrView'); +const { + getProjectId, + getEntityId, + getDataset, + getTable, + setupBeforeAll, + teardownAfterAll, +} = require('./config'); + +describe('grantAccessToTableOrView', () => { + // Setup shared resources before all tests. + before(async () => { + await setupBeforeAll(); + }); + + // Clean up resources after all tests. + after(async () => { + await teardownAfterAll(); + }); + + it('should grant access to a table', async () => { + // Get required test resources. + const projectId = await getProjectId(); + const dataset = await getDataset(); + const table = await getTable(); + const entityId = getEntityId(); + + const ROLE = 'roles/bigquery.dataViewer'; + const PRINCIPAL_ID = `group:${entityId}`; + + // Get the initial empty policy. + const [emptyPolicy] = await table.getIamPolicy(); + + // Initialize bindings array. + if (!emptyPolicy.bindings) { + emptyPolicy.bindings = []; + } + + // In an empty policy the role and principal should not be present. + assert.strictEqual( + emptyPolicy.bindings.some(p => p.role === ROLE), + false, + 'Role should not exist in empty policy' + ); + assert.strictEqual( + emptyPolicy.bindings.some( + p => p.members && p.members.includes(PRINCIPAL_ID) + ), + false, + 'Principal should not exist in empty policy' + ); + + // Grant access to the table. + const updatedPolicy = await grantAccessToTableOrView( + projectId, + dataset.id, + table.id, + PRINCIPAL_ID, + ROLE + ); + + // A binding with that role should exist. + assert.strictEqual( + updatedPolicy.some(p => p.role === ROLE), + true, + 'Role should exist after granting access' + ); + + // A binding for that principal should exist. + assert.strictEqual( + updatedPolicy.some(p => p.members && p.members.includes(PRINCIPAL_ID)), + true, + 'Principal should exist after granting access' + ); + }); +}); diff --git a/bigquery/cloud-client/test/revokeDatasetAccess.test.js b/bigquery/cloud-client/test/revokeDatasetAccess.test.js new file mode 100644 index 0000000000..f7a3552e0d --- /dev/null +++ b/bigquery/cloud-client/test/revokeDatasetAccess.test.js @@ -0,0 +1,103 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const {describe, it, before, after} = require('mocha'); +const assert = require('assert'); +const {grantAccessToDataset} = require('../grantAccessToDataset'); +const {revokeDatasetAccess} = require('../revokeDatasetAccess'); +const { + getDataset, + getEntityId, + setupBeforeAll, + teardownAfterAll, +} = require('./config'); + +describe('revokeDatasetAccess', () => { + // Setup resources before all tests. + before(async () => { + await setupBeforeAll(); + }); + + // Clean up resources after all tests. + after(async () => { + await teardownAfterAll(); + }); + + it('should revoke access to a dataset', async () => { + // Get test resources. + const dataset = await getDataset(); + const entityId = getEntityId(); + + // Directly use the dataset ID. + const datasetId = dataset.id; + console.log(`Testing with dataset: ${datasetId} and entity: ${entityId}`); + + // First grant access to the dataset. + const datasetAccessEntries = await grantAccessToDataset( + datasetId, + entityId, + 'READER' + ); + + // Create a set of all entity IDs and email addresses to check. + const datasetEntityIds = new Set(); + datasetAccessEntries.forEach(entry => { + if (entry.entity_id) { + datasetEntityIds.add(entry.entity_id); + } + if (entry.userByEmail) { + datasetEntityIds.add(entry.userByEmail); + } + if (entry.groupByEmail) { + datasetEntityIds.add(entry.groupByEmail); + } + }); + + // Check if our entity ID is in the set. + const hasAccess = datasetEntityIds.has(entityId); + console.log(`Entity ${entityId} has access after granting: ${hasAccess}`); + assert.strictEqual( + hasAccess, + true, + 'Entity should have access after granting' + ); + + // Now revoke access. + const newAccessEntries = await revokeDatasetAccess(datasetId, entityId); + + // Check that the entity no longer has access. + const updatedEntityIds = new Set(); + newAccessEntries.forEach(entry => { + if (entry.entity_id) { + updatedEntityIds.add(entry.entity_id); + } + if (entry.userByEmail) { + updatedEntityIds.add(entry.userByEmail); + } + if (entry.groupByEmail) { + updatedEntityIds.add(entry.groupByEmail); + } + }); + + const stillHasAccess = updatedEntityIds.has(entityId); + console.log( + `Entity ${entityId} has access after revoking: ${stillHasAccess}` + ); + assert.strictEqual( + stillHasAccess, + false, + 'Entity should not have access after revoking' + ); + }); +}); diff --git a/bigquery/cloud-client/test/revokeTableOrViewAccess.test.js b/bigquery/cloud-client/test/revokeTableOrViewAccess.test.js new file mode 100644 index 0000000000..47801627f2 --- /dev/null +++ b/bigquery/cloud-client/test/revokeTableOrViewAccess.test.js @@ -0,0 +1,129 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const {assert} = require('chai'); +const {describe, before, after, it} = require('mocha'); + +const {revokeAccessToTableOrView} = require('../revokeTableOrViewAccess'); +const {grantAccessToTableOrView} = require('../grantAccessToTableOrView'); +const { + getProjectId, + getEntityId, + getDataset, + getTable, + setupBeforeAll, + teardownAfterAll, +} = require('./config'); + +describe('revokeTableOrViewAccess', () => { + before(async () => { + await setupBeforeAll(); + }); + + after(async () => { + await teardownAfterAll(); + }); + + it('should revoke access to a table for a specific role', async () => { + const dataset = await getDataset(); + const projectId = await getProjectId(); + const table = await getTable(); + const entityId = getEntityId(); + + const ROLE = 'roles/bigquery.dataViewer'; + const PRINCIPAL_ID = `group:${entityId}`; + + // Get the initial empty policy. + const [emptyPolicy] = await table.getIamPolicy(); + + // Initialize bindings array. + if (!emptyPolicy.bindings) { + emptyPolicy.bindings = []; + } + + // Grant access. + const policyWithRole = await grantAccessToTableOrView( + projectId, + dataset.id, + table.id, + PRINCIPAL_ID, + ROLE + ); + + // Check that there is a binding with that role. + const hasRole = policyWithRole.some(b => b.role === ROLE); + assert.isTrue(hasRole); + + // Revoke access for the role. + const policyWithRevokedRole = await revokeAccessToTableOrView( + projectId, + dataset.id, + table.id, + ROLE, + null + ); + + // Check that this role is not present in the policy anymore. + const roleExists = policyWithRevokedRole.some(b => b.role === ROLE); + assert.isFalse(roleExists); + }); + + it('should revoke access to a table for a specific principal', async () => { + const dataset = await getDataset(); + const projectId = await getProjectId(); + const table = await getTable(); + const entityId = getEntityId(); + + const ROLE = 'roles/bigquery.dataViewer'; + const PRINCIPAL_ID = `group:${entityId}`; + + // Get the initial empty policy. + const [emptyPolicy] = await table.getIamPolicy(); + + // Initialize bindings array. + if (!emptyPolicy.bindings) { + emptyPolicy.bindings = []; + } + + // Grant access. + const updatedPolicy = await grantAccessToTableOrView( + projectId, + dataset.id, + table.id, + PRINCIPAL_ID, + ROLE + ); + + // There is a binding for that principal. + const hasPrincipal = updatedPolicy.some( + b => b.members && b.members.includes(PRINCIPAL_ID) + ); + assert.isTrue(hasPrincipal); + + // Revoke access for the principal. + const policyWithRemovedPrincipal = await revokeAccessToTableOrView( + projectId, + dataset.id, + table.id, + null, + PRINCIPAL_ID + ); + + // This principal is not present in the policy anymore. + const hasPrincipalAfterRevoke = policyWithRemovedPrincipal.some( + b => b.members && b.members.includes(PRINCIPAL_ID) + ); + assert.isFalse(hasPrincipalAfterRevoke); + }); +}); diff --git a/bigquery/cloud-client/test/viewDatasetAccessPolicy.test.js b/bigquery/cloud-client/test/viewDatasetAccessPolicy.test.js new file mode 100644 index 0000000000..725e0763fd --- /dev/null +++ b/bigquery/cloud-client/test/viewDatasetAccessPolicy.test.js @@ -0,0 +1,35 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const assert = require('assert'); +const {getDataset, setupBeforeAll, teardownAfterAll} = require('./config'); +const {viewDatasetAccessPolicy} = require('../viewDatasetAccessPolicy'); + +describe('viewDatasetAccessPolicy', () => { + before(async () => { + await setupBeforeAll(); + }); + + after(async () => { + await teardownAfterAll(); + }); + + it('should view dataset access policies', async () => { + const dataset = await getDataset(); + const accessPolicy = await viewDatasetAccessPolicy(dataset.id); + + assert.ok(accessPolicy, 'Access policy should be defined'); + assert.ok(Array.isArray(accessPolicy), 'Access policy should be an array'); + }); +}); diff --git a/bigquery/cloud-client/test/viewTableOrViewAccessPolicy.test.js b/bigquery/cloud-client/test/viewTableOrViewAccessPolicy.test.js new file mode 100644 index 0000000000..df50ad6c39 --- /dev/null +++ b/bigquery/cloud-client/test/viewTableOrViewAccessPolicy.test.js @@ -0,0 +1,90 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const assert = require('assert'); +const { + getProjectId, + getDataset, + getTable, + getView, + setupBeforeAll, + teardownAfterAll, +} = require('./config.js'); +const viewTableOrViewAccessPolicy = require('../viewTableOrViewAccessPolicy.js'); + +describe('viewTableOrViewAccessPolicy', () => { + before(async () => { + await setupBeforeAll(); + }); + + after(async () => { + await teardownAfterAll(); + }); + + it('should view table access policies', async () => { + const projectId = await getProjectId(); + const dataset = await getDataset(); + const table = await getTable(); + + const policy = await viewTableOrViewAccessPolicy( + projectId, + dataset.id, + table.id + ); + + // Verify that the policy exists. + assert.ok(policy, 'Policy should be defined'); + + // Verify that bindings exists and is an array. + assert.ok(Array.isArray(policy.bindings), 'Bindings should be an array'); + + // In a new policy, bindings should be empty. + assert.strictEqual( + policy.bindings.length, + 0, + 'Bindings list should be empty' + ); + + // Verify that etag exists, but do not validate its exact value. + assert.ok(policy.etag, 'Etag should be defined'); + }); + + it('should view view access policies', async () => { + const projectId = await getProjectId(); + const dataset = await getDataset(); + const view = await getView(); + + const policy = await viewTableOrViewAccessPolicy( + projectId, + dataset.id, + view.id + ); + + // Verify that the policy exists. + assert.ok(policy, 'Policy should be defined'); + + // Verify that bindings exists and is an array. + assert.ok(Array.isArray(policy.bindings), 'Bindings should be an array'); + + // In a new policy, bindings should be empty. + assert.strictEqual( + policy.bindings.length, + 0, + 'Bindings list should be empty' + ); + + // Verify that etag exists, but do not validate its exact value. + assert.ok(policy.etag, 'Etag should be defined'); + }); +}); diff --git a/bigquery/cloud-client/viewDatasetAccessPolicy.js b/bigquery/cloud-client/viewDatasetAccessPolicy.js new file mode 100644 index 0000000000..6400e78f55 --- /dev/null +++ b/bigquery/cloud-client/viewDatasetAccessPolicy.js @@ -0,0 +1,59 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +/** + * View access policies for a BigQuery dataset. + * @param {string} datasetId Dataset ID to view access policies for. + * @returns {Array} Array of access entries. + */ +function viewDatasetAccessPolicy(datasetId) { + // [START bigquery_view_dataset_access_policy] + const {BigQuery} = require('@google-cloud/bigquery'); + + // Instantiate a client. + const bigquery = new BigQuery(); + + // TODO (developer): Update and un-comment below lines. + + // Dataset from which to get the access policy. + // datasetId = "my_dataset_id"; + + // Get a reference to the dataset. + const dataset = bigquery.dataset(datasetId); + + return dataset.getMetadata().then(([metadata]) => { + const accessEntries = metadata.access || []; + + // Show the array of AccessEntry objects. + // More details about the AccessEntry object in the BigQuery documentation: + // https://cloud.google.com/nodejs/docs/reference/bigquery/latest + console.log( + `${accessEntries.length} Access entries in dataset '${datasetId}':` + ); + for (const accessEntry of accessEntries) { + console.log(`Role: ${accessEntry.role || 'null'}`); + console.log(`Special group: ${accessEntry.specialGroup || 'null'}`); + console.log(`User by Email: ${accessEntry.userByEmail || 'null'}`); + } + + return accessEntries; + }); + // [END bigquery_view_dataset_access_policy] +} + +module.exports = { + viewDatasetAccessPolicy, +}; diff --git a/bigquery/cloud-client/viewTableOrViewAccessPolicy.js b/bigquery/cloud-client/viewTableOrViewAccessPolicy.js new file mode 100644 index 0000000000..e6cc0a75bc --- /dev/null +++ b/bigquery/cloud-client/viewTableOrViewAccessPolicy.js @@ -0,0 +1,68 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +/** + * View access policy for a BigQuery table or view. + * + * @param {string} projectId Google Cloud Platform project. + * @param {string} datasetId Dataset where the table or view is. + * @param {string} resourceName Table or view name to get the access policy. + * @returns {Promise} The IAM policy object. + */ +async function viewTableOrViewAccessPolicy(projectId, datasetId, resourceName) { + // [START bigquery_view_table_or_view_access_policy] + const {BigQuery} = require('@google-cloud/bigquery'); + + // TODO(developer): Update and un-comment below lines. + + // Google Cloud Platform project. + // projectId = "my_project_id"; + + // Dataset where the table or view is. + // datasetId = "my_dataset_id"; + + // Table or view name to get the access policy. + // resourceName = "my_table_name_id"; + + // Instantiate a client. + const client = new BigQuery(); + + // Get a reference to the dataset by datasetId. + const dataset = client.dataset(datasetId); + // Get a reference to the table by tableName. + const table = dataset.table(resourceName); + + // Get the IAM access policy for the table or view. + const [policy] = await table.getIamPolicy(); + + // Initialize bindings array. + if (!policy.bindings) { + policy.bindings = []; + } + + // Show policy details + // Find more details for the Policy object here: + // https://cloud.google.com/bigquery/docs/reference/rest/v2/Policy + console.log(`Access Policy details for table or view '${resourceName}'.`); + console.log(`Bindings: ${JSON.stringify(policy.bindings, null, 2)}`); + console.log(`etag: ${policy.etag}`); + console.log(`Version: ${policy.version}`); + + // [END bigquery_view_table_or_view_access_policy] + return policy; +} + +module.exports = viewTableOrViewAccessPolicy;