Repository: googleapis/python-bigquery

Issue:
Implement: Authorized UDF support for dataset Access
## Overview

Add support for authorized UDFs.

The Access property of a dataset now allows references to **Routine** entities, similar to the existing View support.  Only UDF routines are currently supported.  This feature is enabled and can be implemented immediately.

## References

Example impl: https://github.com/googleapis/google-cloud-go/pull/3084

What we document in the discovery layer for dataset's `access` field:
```
"access": {
          "type": "array",
          "description": "[Optional] An array of objects that define dataset access for one or more entities. You can set this property when inserting or updating a dataset in order to control who is allowed to access the data. If unspecified at dataset creation time, BigQuery adds default dataset access for the following entities: access.specialGroup: projectReaders; access.role: READER; access.specialGroup: projectWriters; access.role: WRITER; access.specialGroup: projectOwners; access.role: OWNER; access.userByEmail: [dataset creator email]; access.role: OWNER;",
          "items": {
            "properties": {
              "view": {
                "$ref": "TableReference",
                "description": "[Pick one] A view from a different dataset to grant access to. Queries executed against that view will have read access to tables in this dataset. The role field is not required when this field is set. If that view is updated by any user, access to the view needs to be granted again via an update operation."
              },
              "groupByEmail": {
                "description": "[Pick one] An email address of a Google Group to grant access to. Maps to IAM policy member \"group:GROUP\".",
                "type": "string"
              },
              "userByEmail": {
                "description": "[Pick one] An email address of a user to grant access to. For example: fred@example.com. Maps to IAM policy member \"user:EMAIL\" or \"serviceAccount:EMAIL\".",
                "type": "string"
              },
              "domain": {
                "description": "[Pick one] A domain to grant access to. Any users signed in with the domain specified will be granted the specified access. Example: \"example.com\". Maps to IAM policy member \"domain:DOMAIN\".",
                "type": "string"
              },
              "iamMember": {
                "type": "string",
                "description": "[Pick one] Some other type of member that appears in the IAM Policy but isn't a user, group, domain, or special group."
              },
              "role": {
                "description": "[Required] An IAM role ID that should be granted to the user, group, or domain specified in this access entry. The following legacy mappings will be applied: OWNER  roles/bigquery.dataOwner WRITER  roles/bigquery.dataEditor READER  roles/bigquery.dataViewer This field will accept any of the above formats, but will return only the legacy format. For example, if you set this field to \"roles/bigquery.dataOwner\", it will be returned back as \"OWNER\".",
                "type": "string"
              },
              "specialGroup": {
                "type": "string",
                "description": "[Pick one] A special group to grant access to. Possible values include: projectOwners: Owners of the enclosing project. projectReaders: Readers of the enclosing project. projectWriters: Writers of the enclosing project. allAuthenticatedUsers: All authenticated BigQuery users. Maps to similarly-named IAM members."
              },
              "routine": {
                "$ref": "RoutineReference",
                "description": "[Pick one] A routine from a different dataset to grant access to. Queries executed against that routine will have read access to views/tables/routines in this dataset. Only UDF is supported for now. The role field is not required when this field is set. If that routine is updated by any user, access to the routine needs to be granted again via an update operation."
              }
            },
            "type": "object"
          }
        },
```

Internal request issue: 170647386



Discussion:
@cguardia There's not a rush on the `proto-plus` changes right now (it's still important, as it'll be relevant the next time we want to refresh the BQML protos). If you're still pretty stuck on that, maybe you can take this task up?
@tswast yes, I'll take this. Thanks!

Resolving patch:
diff --git a/google/cloud/bigquery/dataset.py b/google/cloud/bigquery/dataset.py
--- a/google/cloud/bigquery/dataset.py
+++ b/google/cloud/bigquery/dataset.py
@@ -79,8 +79,9 @@ class AccessEntry(object):
     """Represents grant of an access role to an entity.
 
     An entry must have exactly one of the allowed :attr:`ENTITY_TYPES`. If
-    anything but ``view`` is set, a ``role`` is also required. ``role`` is
-    omitted for a ``view``, because ``view`` s are always read-only.
+    anything but ``view`` or ``routine`` are set, a ``role`` is also required.
+    ``role`` is omitted for ``view`` and ``routine``, because they are always
+    read-only.
 
     See https://cloud.google.com/bigquery/docs/reference/rest/v2/datasets.
 
@@ -88,17 +89,17 @@ class AccessEntry(object):
         role (str):
             Role granted to the entity. The following string values are
             supported: `'READER'`, `'WRITER'`, `'OWNER'`. It may also be
-            :data:`None` if the ``entity_type`` is ``view``.
+            :data:`None` if the ``entity_type`` is ``view`` or ``routine``.
 
         entity_type (str):
             Type of entity being granted the role. One of :attr:`ENTITY_TYPES`.
 
         entity_id (Union[str, Dict[str, str]]):
-            If the ``entity_type`` is not 'view', the ``entity_id`` is the
-            ``str`` ID of the entity being granted the role. If the
-            ``entity_type`` is 'view', the ``entity_id`` is a ``dict``
-            representing the view from a different dataset to grant access to
-            in the following format::
+            If the ``entity_type`` is not 'view' or 'routine', the ``entity_id``
+            is the ``str`` ID of the entity being granted the role. If the
+            ``entity_type`` is 'view' or 'routine', the ``entity_id`` is a ``dict``
+            representing the view  or routine from a different dataset to grant
+            access to in the following format for views::
 
                 {
                     'projectId': string,
@@ -106,11 +107,19 @@ class AccessEntry(object):
                     'tableId': string
                 }
 
+            For routines::
+
+                {
+                    'projectId': string,
+                    'datasetId': string,
+                    'routineId': string
+                }
+
     Raises:
         ValueError:
             If the ``entity_type`` is not among :attr:`ENTITY_TYPES`, or if a
-            ``view`` has ``role`` set, or a non ``view`` **does not** have a
-            ``role`` set.
+            ``view`` or a ``routine`` has ``role`` set, or a non ``view`` and
+            non ``routine`` **does not** have a ``role`` set.
 
     Examples:
         >>> entry = AccessEntry('OWNER', 'userByEmail', 'user@example.com')
@@ -124,7 +133,15 @@ class AccessEntry(object):
     """
 
     ENTITY_TYPES = frozenset(
-        ["userByEmail", "groupByEmail", "domain", "specialGroup", "view", "iamMember"]
+        [
+            "userByEmail",
+            "groupByEmail",
+            "domain",
+            "specialGroup",
+            "view",
+            "iamMember",
+            "routine",
+        ]
     )
     """Allowed entity types."""
 
@@ -135,10 +152,11 @@ def __init__(self, role, entity_type, entity_id):
                 ", ".join(self.ENTITY_TYPES),
             )
             raise ValueError(message)
-        if entity_type == "view":
+        if entity_type in ("view", "routine"):
             if role is not None:
                 raise ValueError(
-                    "Role must be None for a view. Received " "role: %r" % (role,)
+                    "Role must be None for a %r. Received "
+                    "role: %r" % (entity_type, role)
                 )
         else:
             if role is None:
@@ -409,7 +427,7 @@ def access_entries(self):
         entries.
 
         ``role`` augments the entity type and must be present **unless** the
-        entity type is ``view``.
+        entity type is ``view`` or ``routine``.
 
         Raises:
             TypeError: If 'value' is not a sequence


Test patch:
diff --git a/tests/unit/test_dataset.py b/tests/unit/test_dataset.py
--- a/tests/unit/test_dataset.py
+++ b/tests/unit/test_dataset.py
@@ -53,6 +53,21 @@ def test_ctor_view_success(self):
         self.assertEqual(entry.entity_type, entity_type)
         self.assertEqual(entry.entity_id, entity_id)
 
+    def test_ctor_routine_with_role(self):
+        role = "READER"
+        entity_type = "routine"
+        with self.assertRaises(ValueError):
+            self._make_one(role, entity_type, None)
+
+    def test_ctor_routine_success(self):
+        role = None
+        entity_type = "routine"
+        entity_id = object()
+        entry = self._make_one(role, entity_type, entity_id)
+        self.assertEqual(entry.role, role)
+        self.assertEqual(entry.entity_type, entity_type)
+        self.assertEqual(entry.entity_id, entity_id)
+
     def test_ctor_nonview_without_role(self):
         role = None
         entity_type = "userByEmail"
@@ -115,6 +130,17 @@ def test_to_api_repr_view(self):
         exp_resource = {"view": view}
         self.assertEqual(resource, exp_resource)
 
+    def test_to_api_repr_routine(self):
+        routine = {
+            "projectId": "my-project",
+            "datasetId": "my_dataset",
+            "routineId": "my_routine",
+        }
+        entry = self._make_one(None, "routine", routine)
+        resource = entry.to_api_repr()
+        exp_resource = {"routine": routine}
+        self.assertEqual(resource, exp_resource)
+
     def test_from_api_repr(self):
         resource = {"role": "OWNER", "userByEmail": "salmon@example.com"}
         entry = self._get_target_class().from_api_repr(resource)