Skip to content

Commit d93c2d9

Browse files
authored
feat(bigquery): support IAM conditions in datasets (#11123)
This PR adds support for IAM conditions via the existing dataset access mechanism. To do so, the following changes are necessary: * add the `Expr` type for expressing conditions, and wire it into the existing DatasetAccessEntry. * add an option pattern to the Dataset-related RPC methods * Add a new WithAccessPolicyVersion option for setting access policies To expose the new functionality, this PR adds CreateWithOptions, UpdateWithOptions, MetadataWithOptions methods on Dataset that accept the new option.
1 parent ab75177 commit d93c2d9

File tree

3 files changed

+260
-4
lines changed

3 files changed

+260
-4
lines changed

bigquery/dataset.go

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,12 +190,28 @@ func (d *Dataset) Identifier(f IdentifierFormat) (string, error) {
190190
}
191191
}
192192

193-
// Create creates a dataset in the BigQuery service. An error will be returned if the
194-
// dataset already exists. Pass in a DatasetMetadata value to configure the dataset.
193+
// Create creates a dataset in the BigQuery service.
194+
//
195+
// An error will be returned if the dataset already exists.
196+
// Pass in a DatasetMetadata value to configure the dataset.
195197
func (d *Dataset) Create(ctx context.Context, md *DatasetMetadata) (err error) {
198+
return d.CreateWithOptions(ctx, md)
199+
}
200+
201+
// CreateWithOptions creates a dataset in the BigQuery service, and
202+
// provides additional options to control the behavior of the call.
203+
//
204+
// An error will be returned if the dataset already exists.
205+
// Pass in a DatasetMetadata value to configure the dataset.
206+
func (d *Dataset) CreateWithOptions(ctx context.Context, md *DatasetMetadata, opts ...DatasetOption) (err error) {
196207
ctx = trace.StartSpan(ctx, "cloud.google.com/go/bigquery.Dataset.Create")
197208
defer func() { trace.EndSpan(ctx, err) }()
198209

210+
cOpt := &dsCallOption{}
211+
for _, o := range opts {
212+
o(cOpt)
213+
}
214+
199215
ds, err := md.toBQ()
200216
if err != nil {
201217
return err
@@ -207,6 +223,9 @@ func (d *Dataset) Create(ctx context.Context, md *DatasetMetadata) (err error) {
207223
}
208224
call := d.c.bqs.Datasets.Insert(d.ProjectID, ds).Context(ctx)
209225
setClientHeader(call.Header())
226+
if cOpt.accessPolicyVersion != nil {
227+
call.AccessPolicyVersion(int64(optional.ToInt(cOpt.accessPolicyVersion)))
228+
}
210229
_, err = call.Do()
211230
return err
212231
}
@@ -289,11 +308,25 @@ func (d *Dataset) deleteInternal(ctx context.Context, deleteContents bool) (err
289308

290309
// Metadata fetches the metadata for the dataset.
291310
func (d *Dataset) Metadata(ctx context.Context) (md *DatasetMetadata, err error) {
311+
return d.MetadataWithOptions(ctx)
312+
}
313+
314+
// MetadataWithOptions fetches metadata for the dataset, and provides additional options for
315+
// controlling the request.
316+
func (d *Dataset) MetadataWithOptions(ctx context.Context, opts ...DatasetOption) (md *DatasetMetadata, err error) {
292317
ctx = trace.StartSpan(ctx, "cloud.google.com/go/bigquery.Dataset.Metadata")
293318
defer func() { trace.EndSpan(ctx, err) }()
294319

320+
cOpt := &dsCallOption{}
321+
for _, o := range opts {
322+
o(cOpt)
323+
}
324+
295325
call := d.c.bqs.Datasets.Get(d.ProjectID, d.DatasetID).Context(ctx)
296326
setClientHeader(call.Header())
327+
if cOpt.accessPolicyVersion != nil {
328+
call.AccessPolicyVersion(int64(optional.ToInt(cOpt.accessPolicyVersion)))
329+
}
297330
var ds *bq.Dataset
298331
if err := runWithRetry(ctx, func() (err error) {
299332
sCtx := trace.StartSpan(ctx, "bigquery.datasets.get")
@@ -306,6 +339,36 @@ func (d *Dataset) Metadata(ctx context.Context) (md *DatasetMetadata, err error)
306339
return bqToDatasetMetadata(ds, d.c)
307340
}
308341

342+
// dsCallOption provides a general option holder for dataset RPCs
343+
type dsCallOption struct {
344+
accessPolicyVersion optional.Int
345+
}
346+
347+
// DatasetOption provides an option type for customizing requests against the Dataset
348+
// service.
349+
type DatasetOption func(*dsCallOption)
350+
351+
// WithAccessPolicyVersion is an option that enabled setting of the Access Policy Version for a request
352+
// where appropriate. Valid values are 0, 1, and 3.
353+
//
354+
// Requests specifying an invalid value will be rejected.
355+
// Requests for conditional access policy binding in datasets must specify version 3.
356+
//
357+
// Dataset with no conditional role bindings in access policy may specify any valid value
358+
// or leave the field unset.
359+
//
360+
// This field will be mapped to [IAM Policy version] (https://cloud.google.com/iam/docs/policies#versions)
361+
// and will be used to fetch policy from IAM. If unset or if 0 or 1 value is used for
362+
// dataset with conditional bindings, access entry with condition will have role string
363+
// appended by 'withcond' string followed by a hash value.
364+
//
365+
// Please refer https://cloud.google.com/iam/docs/troubleshooting-withcond for more details.
366+
func WithAccessPolicyVersion(apv int) DatasetOption {
367+
return func(o *dsCallOption) {
368+
o.accessPolicyVersion = apv
369+
}
370+
}
371+
309372
func bqToDatasetMetadata(d *bq.Dataset, c *Client) (*DatasetMetadata, error) {
310373
dm := &DatasetMetadata{
311374
CreationTime: unixMillisToTime(d.CreationTime),
@@ -345,18 +408,36 @@ func bqToDatasetMetadata(d *bq.Dataset, c *Client) (*DatasetMetadata, error) {
345408
// set the etag argument to the DatasetMetadata.ETag field from the read.
346409
// Pass the empty string for etag for a "blind write" that will always succeed.
347410
func (d *Dataset) Update(ctx context.Context, dm DatasetMetadataToUpdate, etag string) (md *DatasetMetadata, err error) {
411+
return d.UpdateWithOptions(ctx, dm, etag)
412+
}
413+
414+
// UpdateWithOptions modifies specific Dataset metadata fields and
415+
// provides an interface for specifying additional options to the request.
416+
//
417+
// To perform a read-modify-write that protects against intervening reads,
418+
// set the etag argument to the DatasetMetadata.ETag field from the read.
419+
// Pass the empty string for etag for a "blind write" that will always succeed.
420+
func (d *Dataset) UpdateWithOptions(ctx context.Context, dm DatasetMetadataToUpdate, etag string, opts ...DatasetOption) (md *DatasetMetadata, err error) {
348421
ctx = trace.StartSpan(ctx, "cloud.google.com/go/bigquery.Dataset.Update")
349422
defer func() { trace.EndSpan(ctx, err) }()
350423

424+
cOpt := &dsCallOption{}
425+
for _, o := range opts {
426+
o(cOpt)
427+
}
351428
ds, err := dm.toBQ()
352429
if err != nil {
353430
return nil, err
354431
}
432+
355433
call := d.c.bqs.Datasets.Patch(d.ProjectID, d.DatasetID, ds).Context(ctx)
356434
setClientHeader(call.Header())
357435
if etag != "" {
358436
call.Header().Set("If-Match", etag)
359437
}
438+
if cOpt.accessPolicyVersion != nil {
439+
call.AccessPolicyVersion(int64(optional.ToInt(cOpt.accessPolicyVersion)))
440+
}
360441
var ds2 *bq.Dataset
361442
if err := runWithRetry(ctx, func() (err error) {
362443
sCtx := trace.StartSpan(ctx, "bigquery.datasets.patch")
@@ -811,6 +892,50 @@ type AccessEntry struct {
811892
View *Table // The view granted access (EntityType must be ViewEntity)
812893
Routine *Routine // The routine granted access (only UDF currently supported)
813894
Dataset *DatasetAccessEntry // The resources within a dataset granted access.
895+
Condition *Expr // Condition for the access binding.
896+
}
897+
898+
// Expr represents the conditional information related to dataset access policies.
899+
type Expr struct {
900+
// Textual representation of an expression in Common Expression Language syntax.
901+
Expression string
902+
903+
// Optional. Title for the expression, i.e. a short string describing
904+
// its purpose. This can be used e.g. in UIs which allow to enter the
905+
// expression.
906+
Title string
907+
908+
// Optional. Description of the expression. This is a longer text which
909+
// describes the expression, e.g. when hovered over it in a UI.
910+
Description string
911+
912+
// Optional. String indicating the location of the expression for error
913+
// reporting, e.g. a file name and a position in the file.
914+
Location string
915+
}
916+
917+
func (ex *Expr) toBQ() *bq.Expr {
918+
if ex == nil {
919+
return nil
920+
}
921+
return &bq.Expr{
922+
Expression: ex.Expression,
923+
Title: ex.Title,
924+
Description: ex.Description,
925+
Location: ex.Location,
926+
}
927+
}
928+
929+
func bqToExpr(bq *bq.Expr) *Expr {
930+
if bq == nil {
931+
return nil
932+
}
933+
return &Expr{
934+
Expression: bq.Expression,
935+
Title: bq.Title,
936+
Description: bq.Description,
937+
Location: bq.Location,
938+
}
814939
}
815940

816941
// AccessRole is the level of access to grant to a dataset.
@@ -857,7 +982,10 @@ const (
857982
)
858983

859984
func (e *AccessEntry) toBQ() (*bq.DatasetAccess, error) {
860-
q := &bq.DatasetAccess{Role: string(e.Role)}
985+
q := &bq.DatasetAccess{
986+
Role: string(e.Role),
987+
Condition: e.Condition.toBQ(),
988+
}
861989
switch e.EntityType {
862990
case DomainEntity:
863991
q.Domain = e.Entity
@@ -911,6 +1039,9 @@ func bqToAccessEntry(q *bq.DatasetAccess, c *Client) (*AccessEntry, error) {
9111039
default:
9121040
return nil, errors.New("bigquery: invalid access value")
9131041
}
1042+
if q.Condition != nil {
1043+
e.Condition = bqToExpr(q.Condition)
1044+
}
9141045
return e, nil
9151046
}
9161047

bigquery/dataset_integration_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"time"
2323

2424
"cloud.google.com/go/internal/testutil"
25+
"github.com/google/go-cmp/cmp"
2526
"github.com/google/go-cmp/cmp/cmpopts"
2627
)
2728

@@ -435,6 +436,102 @@ func TestIntegration_DatasetUpdateAccess(t *testing.T) {
435436
}
436437
}
437438

439+
// This test validates behaviors related to IAM conditions in
440+
// dataset access control.
441+
func TestIntegration_DatasetConditions(t *testing.T) {
442+
if client == nil {
443+
t.Skip("Integration tests skipped")
444+
}
445+
ctx := context.Background()
446+
// Use our test dataset for a base access policy.
447+
md, err := dataset.Metadata(ctx)
448+
if err != nil {
449+
t.Fatal(err)
450+
}
451+
452+
wantEntry := &AccessEntry{
453+
Role: ReaderRole,
454+
Entity: "[email protected]",
455+
EntityType: UserEmailEntity,
456+
Condition: &Expr{
457+
Expression: "request.time < timestamp('2030-01-01T00:00:00Z')",
458+
Description: "requests before the year 2030",
459+
Title: "test condition",
460+
},
461+
}
462+
origAccess := append(md.Access, wantEntry)
463+
464+
ds := client.Dataset(datasetIDs.New())
465+
wantMeta := &DatasetMetadata{
466+
Access: origAccess,
467+
Description: "test dataset",
468+
}
469+
470+
// First, attempt to create the dataset without specifying a policy access version.
471+
err = ds.Create(ctx, wantMeta)
472+
if err == nil {
473+
t.Fatalf("expected Create failure, but succeeded")
474+
}
475+
476+
err = ds.CreateWithOptions(ctx, wantMeta, WithAccessPolicyVersion(3))
477+
if err != nil {
478+
t.Fatalf("expected Create to succeed, but failed: %v", err)
479+
}
480+
defer func() {
481+
if err := ds.Delete(ctx); err != nil {
482+
t.Logf("defer deletion failed: %v", err)
483+
}
484+
}()
485+
486+
// Now, get the dataset without specifying policy version
487+
md, err = ds.Metadata(ctx)
488+
if err != nil {
489+
t.Fatalf("Metadata: %v", err)
490+
}
491+
for _, entry := range md.Access {
492+
if entry.Entity == wantEntry.Entity &&
493+
entry.Condition != nil {
494+
t.Fatalf("got policy with condition without specifying access policy version")
495+
}
496+
}
497+
498+
// Re-fetch metadata with access policy specified.
499+
md, err = ds.MetadataWithOptions(ctx, WithAccessPolicyVersion(3))
500+
if err != nil {
501+
t.Fatalf("Metadata (WithAccessPolicy): %v", err)
502+
}
503+
var foundEntry bool
504+
for _, entry := range md.Access {
505+
if entry.Entity == wantEntry.Entity {
506+
if cmp.Equal(entry.Condition, wantEntry.Condition) {
507+
foundEntry = true
508+
break
509+
}
510+
}
511+
}
512+
if !foundEntry {
513+
t.Fatalf("failed to find wanted entry in access list")
514+
}
515+
516+
newAccess := append(origAccess, &AccessEntry{
517+
Role: ReaderRole,
518+
Entity: "allUsers",
519+
EntityType: IAMMemberEntity,
520+
})
521+
522+
// append another entry. Should fail without sending access policy version since we have conditions present.
523+
md, err = ds.Update(ctx, DatasetMetadataToUpdate{Access: newAccess}, "")
524+
if err == nil {
525+
t.Fatalf("Update succeeded where failure expected: %v", err)
526+
}
527+
528+
md, err = ds.UpdateWithOptions(ctx, DatasetMetadataToUpdate{Access: newAccess}, "", WithAccessPolicyVersion(3))
529+
if err != nil {
530+
t.Fatalf("Update failed: %v", err)
531+
}
532+
533+
}
534+
438535
// Comparison function for AccessEntries to enable order insensitive equality checking.
439536
func lessAccessEntries(x, y *AccessEntry) bool {
440537
if x.Entity < y.Entity {

bigquery/dataset_test.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,15 @@ func TestBQToDatasetMetadata(t *testing.T) {
420420
Labels: map[string]string{"x": "y"},
421421
Access: []*bq.DatasetAccess{
422422
{Role: "READER", UserByEmail: "[email protected]"},
423+
{Role: "READER",
424+
UserByEmail: "[email protected]",
425+
Condition: &bq.Expr{
426+
Description: "desc",
427+
Expression: "expr",
428+
Location: "loc",
429+
Title: "title",
430+
},
431+
},
423432
{Role: "WRITER", GroupByEmail: "[email protected]"},
424433
{
425434
Dataset: &bq.DatasetAccessEntry{
@@ -456,7 +465,20 @@ func TestBQToDatasetMetadata(t *testing.T) {
456465
Location: "EU",
457466
Labels: map[string]string{"x": "y"},
458467
Access: []*AccessEntry{
459-
{Role: ReaderRole, Entity: "[email protected]", EntityType: UserEmailEntity},
468+
{Role: ReaderRole,
469+
Entity: "[email protected]",
470+
EntityType: UserEmailEntity,
471+
},
472+
{Role: ReaderRole,
473+
Entity: "[email protected]",
474+
EntityType: UserEmailEntity,
475+
Condition: &Expr{
476+
Title: "title",
477+
Expression: "expr",
478+
Location: "loc",
479+
Description: "desc",
480+
},
481+
},
460482
{Role: WriterRole, Entity: "[email protected]", EntityType: GroupEmailEntity},
461483
{
462484
EntityType: DatasetEntity,
@@ -536,6 +558,12 @@ func TestConvertAccessEntry(t *testing.T) {
536558
{Role: OwnerRole, Entity: "e", EntityType: UserEmailEntity},
537559
{Role: ReaderRole, Entity: "e", EntityType: SpecialGroupEntity},
538560
{Role: ReaderRole, Entity: "e", EntityType: IAMMemberEntity},
561+
{Role: WriterRole, Entity: "e", EntityType: IAMMemberEntity,
562+
Condition: &Expr{Expression: "expr",
563+
Title: "title",
564+
Location: "loc",
565+
Description: "desc",
566+
}},
539567
{Role: ReaderRole, EntityType: ViewEntity,
540568
View: &Table{ProjectID: "p", DatasetID: "d", TableID: "t", c: c}},
541569
{Role: ReaderRole, EntityType: RoutineEntity,

0 commit comments

Comments
 (0)