├── .backstage └── iam-go.yaml ├── .gitignore ├── .goreleaser.yml ├── .sage ├── go.mod ├── go.sum ├── main.go └── proto.go ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── cmd ├── example-server │ ├── cmd.go │ ├── server.go │ └── spanner.go └── protoc-gen-go-iam │ ├── internal │ └── geniam │ │ ├── authorization.go │ │ ├── descriptor.go │ │ ├── gen.go │ │ └── helpers.go │ └── main.go ├── go.mod ├── go.sum ├── iamannotations ├── longrunningoperationsauthorization.go ├── methodauthorization.go ├── resolveresource.go ├── validate.go └── validate_test.go ├── iamauthz ├── after.go ├── before.go ├── beforelongrunning.go ├── doc.go ├── errors.go ├── middleware.go └── middleware_test.go ├── iamcaller ├── caller.go ├── chain.go ├── chain_test.go ├── middleware.go ├── resolver.go └── validate.go ├── iamcel ├── after.go ├── ancestor.go ├── ancestor_test.go ├── before.go ├── collectdependencies.go ├── doc.go ├── join.go ├── join_test.go ├── member.go ├── member_test.go ├── test.go ├── testall.go ├── testany.go └── tester.go ├── iamexample ├── authorization.go ├── caller.go ├── convert_shipments.go ├── convert_shippers.go ├── convert_sites.go ├── doc.go ├── iamexampledata │ ├── doc.go │ ├── init.go │ ├── policy.go │ ├── shippers.go │ └── sites.go ├── iamexampledb │ ├── database_gen.go │ └── descriptor_gen.go ├── schema.go ├── schema.sql ├── server.go ├── server_iam.go ├── server_iam_test.go ├── server_longrunning.go ├── server_longrunning_test.go ├── server_shipments_batchget.go ├── server_shipments_batchget_test.go ├── server_shipments_create.go ├── server_shipments_create_test.go ├── server_shipments_delete.go ├── server_shipments_get.go ├── server_shipments_list.go ├── server_shipments_update.go ├── server_shippers_create.go ├── server_shippers_create_test.go ├── server_shippers_delete.go ├── server_shippers_delete_test.go ├── server_shippers_get.go ├── server_shippers_get_test.go ├── server_shippers_list.go ├── server_shippers_list_test.go ├── server_shippers_update.go ├── server_shippers_update_test.go ├── server_sites_batchget.go ├── server_sites_batchget_test.go ├── server_sites_create.go ├── server_sites_create_test.go ├── server_sites_delete.go ├── server_sites_delete_test.go ├── server_sites_get.go ├── server_sites_get_test.go ├── server_sites_list.go ├── server_sites_list_test.go ├── server_sites_search.go ├── server_sites_update.go ├── server_sites_update_test.go └── server_test.go ├── iamfirebase ├── doc.go ├── idtoken.go └── payload.go ├── iamgoogle ├── doc.go ├── idtoken.go ├── idtoken_test.go ├── payload.go ├── payload_test.go └── signature.go ├── iammember ├── doc.go ├── new.go ├── new_test.go ├── parse.go ├── parse_test.go └── validate.go ├── iammixin ├── doc.go └── register.go ├── iampermission ├── doc.go ├── haswildcard.go ├── haswildcard_test.go ├── longrunningoperationpermission.go ├── match.go ├── match_test.go ├── resourcepermission.go ├── resourcepermission_test.go ├── scanner.go ├── validate.go └── validate_test.go ├── iampolicy ├── add.go ├── doc.go ├── remove.go ├── remove_test.go └── validate.go ├── iamregistry ├── doc.go ├── roles.go └── roles_test.go ├── iamresource ├── doc.go └── root.go ├── iamrole ├── doc.go ├── haspermission.go ├── haspermission_test.go ├── validate.go └── validate_test.go ├── iamspanner ├── doc.go ├── iamspannerdb │ ├── database_gen.go │ └── descriptor_gen.go ├── schema.go ├── schema.sql ├── server.go ├── server_bindings_read.go ├── server_policies.go ├── server_policies_get.go ├── server_policies_set.go ├── server_roles_get.go ├── server_roles_list.go ├── server_test.go ├── server_testiampermissions.go └── server_testpermissions.go ├── iamtest ├── caller.go ├── doc.go ├── fixture.go ├── permissiontester.go └── permissiontester_test.go ├── iamtoken ├── identitytoken.go ├── identitytoken_test.go ├── token.go ├── validate.go └── validate_test.go ├── proto ├── .gitignore ├── Makefile ├── api-linter.yaml ├── buf.gen.example.yaml ├── buf.gen.yaml ├── buf.lock ├── buf.yaml ├── einride │ └── iam │ │ ├── example │ │ └── v1 │ │ │ ├── freight_service.proto │ │ │ ├── shipment.proto │ │ │ ├── shipper.proto │ │ │ └── site.proto │ │ └── v1 │ │ ├── annotations.proto │ │ ├── caller.proto │ │ └── identity_token.proto └── gen │ └── einride │ └── iam │ ├── example │ └── v1 │ │ ├── freight_service.pb.go │ │ ├── freight_service_aip.go │ │ ├── freight_service_grpc.pb.go │ │ ├── freight_service_iam.pb.go │ │ ├── shipment.pb.go │ │ ├── shipment_aip.go │ │ ├── shipper.pb.go │ │ ├── shipper_aip.go │ │ ├── site.pb.go │ │ └── site_aip.go │ └── v1 │ ├── annotations.pb.go │ ├── caller.pb.go │ └── identity_token.pb.go └── spanner.yaml /.backstage/iam-go.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: backstage.io/v1alpha1 2 | kind: Component 3 | metadata: 4 | name: iam-go 5 | title: IAM Go 6 | description: | 7 | An opinionated Open Source implementation of the google.iam.v1.IAMPolicy service API, using Cloud Spanner for storage. 8 | spec: 9 | type: go-library 10 | system: backend-sdk 11 | lifecycle: production 12 | owner: platform-engineering 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .DS_Store 4 | .semrel/ 5 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | 5 | builds: 6 | - id: protoc-gen-go-iam 7 | binary: protoc-gen-go-iam 8 | dir: ./cmd/protoc-gen-go-iam 9 | main: main.go 10 | env: 11 | - CGO_ENABLED=0 12 | goos: 13 | - linux 14 | - windows 15 | - darwin 16 | 17 | checksum: 18 | name_template: "checksums.txt" 19 | 20 | snapshot: 21 | name_template: "{{ .Tag }}-next" 22 | 23 | release: 24 | github: 25 | prerelease: auto 26 | -------------------------------------------------------------------------------- /.sage/go.mod: -------------------------------------------------------------------------------- 1 | module sage 2 | 3 | go 1.23.4 4 | 5 | require go.einride.tech/sage v0.354.0 6 | -------------------------------------------------------------------------------- /.sage/go.sum: -------------------------------------------------------------------------------- 1 | go.einride.tech/sage v0.354.0 h1:K+1uiIaiubbwJOWOzyEKjj9mA8HsTuhqNlo0xvsft7c= 2 | go.einride.tech/sage v0.354.0/go.mod h1:sy9YuK//XVwEZ2wD3f19xVSKEtN8CYtgtBZGpzC3p80= 3 | -------------------------------------------------------------------------------- /.sage/proto.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/sage/sg" 7 | "go.einride.tech/sage/sgtool" 8 | "go.einride.tech/sage/tools/sgapilinter" 9 | "go.einride.tech/sage/tools/sgbuf" 10 | ) 11 | 12 | type Proto sg.Namespace 13 | 14 | func (Proto) All(ctx context.Context) error { 15 | sg.Deps(ctx, Proto.BufFormat) 16 | sg.Deps(ctx, Proto.BufLint, Proto.BufBreaking) 17 | sg.Deps(ctx, Proto.APILinterLint) 18 | sg.Deps(ctx, Proto.BufGenerate) 19 | sg.Deps(ctx, Proto.BufGenerateExample) 20 | return nil 21 | } 22 | 23 | func (Proto) BufBreaking(ctx context.Context) error { 24 | sg.Logger(ctx).Println("checking proto files for breaking changes...") 25 | // TODO: Re-enable after migrating to new longrunning package. 26 | // cmd := sgbuf.Command(ctx, "breaking", "--against", "buf.build/einride/iam", "--path", "einride") 27 | // cmd.Dir = sg.FromGitRoot("proto") 28 | // return cmd.Run() 29 | return nil 30 | } 31 | 32 | func (Proto) BufLint(ctx context.Context) error { 33 | sg.Logger(ctx).Println("linting proto files...") 34 | cmd := sgbuf.Command(ctx, "lint") 35 | cmd.Dir = sg.FromGitRoot("proto") 36 | return cmd.Run() 37 | } 38 | 39 | func (Proto) APILinterLint(ctx context.Context) error { 40 | sg.Logger(ctx).Println("linting gRPC APIs...") 41 | return sgapilinter.Run(ctx) 42 | } 43 | 44 | func (Proto) BufFormat(ctx context.Context) error { 45 | sg.Logger(ctx).Println("formatting proto files...") 46 | cmd := sgbuf.Command(ctx, "format", "--write") 47 | cmd.Dir = sg.FromGitRoot("proto") 48 | return cmd.Run() 49 | } 50 | 51 | func (Proto) ProtocGenGo(ctx context.Context) error { 52 | sg.Logger(ctx).Println("installing...") 53 | _, err := sgtool.GoInstallWithModfile(ctx, "google.golang.org/protobuf/cmd/protoc-gen-go", "go.mod") 54 | return err 55 | } 56 | 57 | func (Proto) ProtocGenGoAIP(ctx context.Context) error { 58 | sg.Logger(ctx).Println("installing...") 59 | _, err := sgtool.GoInstallWithModfile(ctx, "go.einride.tech/aip/cmd/protoc-gen-go-aip", "go.mod") 60 | return err 61 | } 62 | 63 | func (Proto) ProtocGenGoGRPC(ctx context.Context) error { 64 | sg.Logger(ctx).Println("installing...") 65 | _, err := sgtool.GoInstall(ctx, "google.golang.org/grpc/cmd/protoc-gen-go-grpc", "v1.2.0") 66 | return err 67 | } 68 | 69 | func (Proto) ProtocGenGoIAM(ctx context.Context) error { 70 | sg.Logger(ctx).Println("building binary...") 71 | return sg.Command(ctx, "go", "build", "-o", sg.FromBinDir("protoc-gen-go-iam"), "./cmd/protoc-gen-go-iam").Run() 72 | } 73 | 74 | func (Proto) BufGenerate(ctx context.Context) error { 75 | sg.Deps(ctx, Proto.ProtocGenGo) 76 | sg.Logger(ctx).Println("generating proto stubs...") 77 | cmd := sgbuf.Command(ctx, "generate", "--template", "buf.gen.yaml", "--path", "einride") 78 | cmd.Dir = sg.FromGitRoot("proto") 79 | return cmd.Run() 80 | } 81 | 82 | func (Proto) BufGenerateExample(ctx context.Context) error { 83 | sg.Deps(ctx, Proto.ProtocGenGo, Proto.ProtocGenGoGRPC, Proto.ProtocGenGoAIP, Proto.ProtocGenGoIAM) 84 | sg.Logger(ctx).Println("generating example proto stubs...") 85 | cmd := sgbuf.Command(ctx, "generate", "--template", "buf.gen.example.yaml", "--path", "einride") 86 | cmd.Dir = sg.FromGitRoot("proto") 87 | return cmd.Run() 88 | } 89 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Einride AB 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Code generated by go.einride.tech/sage. DO NOT EDIT. 2 | # To learn more, see .sage/main.go and https://github.com/einride/sage. 3 | 4 | .DEFAULT_GOAL := all 5 | 6 | cwd := $(dir $(realpath $(firstword $(MAKEFILE_LIST)))) 7 | sagefile := $(abspath $(cwd)/.sage/bin/sagefile) 8 | 9 | # Setup Go. 10 | go := $(shell command -v go 2>/dev/null) 11 | export GOWORK ?= off 12 | ifndef go 13 | SAGE_GO_VERSION ?= 1.23.4 14 | export GOROOT := $(abspath $(cwd)/.sage/tools/go/$(SAGE_GO_VERSION)/go) 15 | export PATH := $(PATH):$(GOROOT)/bin 16 | go := $(GOROOT)/bin/go 17 | os := $(shell uname | tr '[:upper:]' '[:lower:]') 18 | arch := $(shell uname -m) 19 | ifeq ($(arch),x86_64) 20 | arch := amd64 21 | endif 22 | $(go): 23 | $(info installing Go $(SAGE_GO_VERSION)...) 24 | @mkdir -p $(dir $(GOROOT)) 25 | @curl -sSL https://go.dev/dl/go$(SAGE_GO_VERSION).$(os)-$(arch).tar.gz | tar xz -C $(dir $(GOROOT)) 26 | @touch $(GOROOT)/go.mod 27 | @chmod +x $(go) 28 | endif 29 | 30 | .PHONY: $(sagefile) 31 | $(sagefile): $(go) 32 | @cd .sage && $(go) mod tidy && $(go) run . 33 | 34 | .PHONY: sage 35 | sage: 36 | @$(MAKE) $(sagefile) 37 | 38 | .PHONY: update-sage 39 | update-sage: $(go) 40 | @cd .sage && $(go) get -d go.einride.tech/sage@latest && $(go) mod tidy && $(go) run . 41 | 42 | .PHONY: clean-sage 43 | clean-sage: 44 | @git clean -fdx .sage/tools .sage/bin .sage/build 45 | 46 | .PHONY: all 47 | all: $(sagefile) 48 | @$(sagefile) All 49 | 50 | .PHONY: convco-check 51 | convco-check: $(sagefile) 52 | @$(sagefile) ConvcoCheck 53 | 54 | .PHONY: format-markdown 55 | format-markdown: $(sagefile) 56 | @$(sagefile) FormatMarkdown 57 | 58 | .PHONY: format-yaml 59 | format-yaml: $(sagefile) 60 | @$(sagefile) FormatYAML 61 | 62 | .PHONY: git-verify-no-diff 63 | git-verify-no-diff: $(sagefile) 64 | @$(sagefile) GitVerifyNoDiff 65 | 66 | .PHONY: go-lint 67 | go-lint: $(sagefile) 68 | @$(sagefile) GoLint 69 | 70 | .PHONY: go-mod-tidy 71 | go-mod-tidy: $(sagefile) 72 | @$(sagefile) GoModTidy 73 | 74 | .PHONY: go-releaser 75 | go-releaser: $(sagefile) 76 | ifndef snapshot 77 | $(error missing argument snapshot="...") 78 | endif 79 | @$(sagefile) GoReleaser "$(snapshot)" 80 | 81 | .PHONY: go-test 82 | go-test: $(sagefile) 83 | @$(sagefile) GoTest 84 | 85 | .PHONY: semantic-release 86 | semantic-release: $(sagefile) 87 | ifndef repo 88 | $(error missing argument repo="...") 89 | endif 90 | ifndef dry 91 | $(error missing argument dry="...") 92 | endif 93 | @$(sagefile) SemanticRelease "$(repo)" "$(dry)" 94 | 95 | .PHONY: proto 96 | proto: 97 | $(MAKE) -C proto -f Makefile 98 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Einride welcomes feedback from security researchers and the general public to 4 | help improve our security. If you believe you have discovered a vulnerability, 5 | privacy issue, exposed data, or other security issues in relation to this 6 | project, we want to hear from you. This policy outlines steps for reporting 7 | security issues to us, what we expect, and what you can expect from us. 8 | 9 | ## Supported versions 10 | 11 | We release patches for security issues according to semantic versioning. This 12 | project is currently unstable (v0.x) and only the latest version will receive 13 | security patches. 14 | 15 | ## Reporting a vulnerability 16 | 17 | Please do not report security vulnerabilities through public issues, 18 | discussions, or change requests. 19 | 20 | Please report security issues via [oss-security@einride.tech][email]. Provide 21 | all relevant information, including steps to reproduce the issue, any affected 22 | versions, and known mitigations. The more details you provide, the easier it 23 | will be for us to triage and fix the issue. You will receive a response from us 24 | within 2 business days. If the issue is confirmed, a patch will be released as 25 | soon as possible. 26 | 27 | For more information, or security issues not relating to open source code, 28 | please consult our [Vulnerability Disclosure Policy][vdp]. 29 | 30 | ## Preferred languages 31 | 32 | English is our preferred language of communication. 33 | 34 | ## Contributions and recognition 35 | 36 | We appreciate every contribution and will do our best to publicly 37 | [acknowledge][acknowledgments] your contributions. 38 | 39 | [acknowledgments]: https://einride.tech/security-acknowledgments.txt 40 | [email]: mailto:oss-security@einride.tech 41 | [vdp]: https://www.einride.tech/vulnerability-disclosure-policy 42 | -------------------------------------------------------------------------------- /cmd/example-server/cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | 8 | "go.einride.tech/iam/iamcaller" 9 | "go.einride.tech/iam/iamexample/iamexampledata" 10 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 11 | "google.golang.org/grpc" 12 | "google.golang.org/grpc/credentials/insecure" 13 | ) 14 | 15 | func main() { 16 | if err := runMain(context.Background()); err != nil { 17 | log.Fatal(err) 18 | } 19 | } 20 | 21 | func runMain(ctx context.Context) error { 22 | address := flag.String("address", "localhost:8080", "listen to address") 23 | spannerEmulatorHost := flag.String("spanner-emulator-host", "localhost:9010", "connect to emulator host") 24 | log.Printf("connecting to Spanner emulator on address %s...", *spannerEmulatorHost) 25 | conn, err := grpc.NewClient( 26 | *spannerEmulatorHost, 27 | grpc.WithTransportCredentials(insecure.NewCredentials()), 28 | ) 29 | if err != nil { 30 | return err 31 | } 32 | log.Printf("initializing Spanner database...") 33 | spannerClient, err := newSpannerDatabase(ctx, conn) 34 | if err != nil { 35 | return err 36 | } 37 | if err := iamexampledata.BootstrapRootAdmin(ctx, spannerClient); err != nil { 38 | return err 39 | } 40 | server, err := newServer(spannerClient) 41 | if err != nil { 42 | return err 43 | } 44 | log.Println("initializing example resources...") 45 | if err := iamexampledata.InitializeResources( 46 | iamcaller.WithResolvedContext( 47 | ctx, 48 | &iamv1.Caller{ 49 | Members: []string{iamexampledata.RootAdminMember}, 50 | Metadata: map[string]*iamv1.Caller_Metadata{ 51 | "x-example-data-init": { 52 | Members: []string{iamexampledata.RootAdminMember}, 53 | }, 54 | }, 55 | }, 56 | ), 57 | server, 58 | ); err != nil { 59 | return err 60 | } 61 | return runServer(ctx, server, *address) 62 | } 63 | -------------------------------------------------------------------------------- /cmd/example-server/spanner.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "cloud.google.com/go/spanner" 10 | database "cloud.google.com/go/spanner/admin/database/apiv1" 11 | "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" 12 | instance "cloud.google.com/go/spanner/admin/instance/apiv1" 13 | "cloud.google.com/go/spanner/admin/instance/apiv1/instancepb" 14 | "cloud.google.com/go/spanner/spansql" 15 | "go.einride.tech/iam/iamexample" 16 | "go.einride.tech/iam/iamspanner" 17 | "google.golang.org/api/option" 18 | "google.golang.org/grpc" 19 | ) 20 | 21 | func newSpannerDatabase(ctx context.Context, conn *grpc.ClientConn) (*spanner.Client, error) { 22 | instanceAdminClient, err := instance.NewInstanceAdminClient(ctx, option.WithGRPCConn(conn)) 23 | if err != nil { 24 | return nil, err 25 | } 26 | const projectID = "example" 27 | instanceID := fmt.Sprintf("example-instance-%d", time.Now().UnixNano()) 28 | createInstanceOp, err := instanceAdminClient.CreateInstance(ctx, &instancepb.CreateInstanceRequest{ 29 | Parent: fmt.Sprintf("projects/%s", projectID), 30 | InstanceId: instanceID, 31 | Instance: &instancepb.Instance{ 32 | DisplayName: "Example", 33 | NodeCount: 1, 34 | }, 35 | }) 36 | if err != nil { 37 | return nil, err 38 | } 39 | createdInstance, err := createInstanceOp.Wait(ctx) 40 | if err != nil { 41 | return nil, err 42 | } 43 | log.Println("created", createdInstance) 44 | databaseAdminClient, err := database.NewDatabaseAdminClient(ctx, option.WithGRPCConn(conn)) 45 | if err != nil { 46 | return nil, err 47 | } 48 | databaseID := fmt.Sprintf("db%d", time.Now().UnixNano()) 49 | var statements []string 50 | for _, schema := range []string{iamspanner.SQLSchema(), iamexample.SQLSchema()} { 51 | ddl, err := spansql.ParseDDL("example", schema) 52 | if err != nil { 53 | return nil, err 54 | } 55 | for _, ddlStmt := range ddl.List { 56 | statements = append(statements, ddlStmt.SQL()) 57 | } 58 | } 59 | createDatabaseOp, err := databaseAdminClient.CreateDatabase(ctx, &databasepb.CreateDatabaseRequest{ 60 | Parent: fmt.Sprintf("projects/%s/instances/%s", projectID, instanceID), 61 | CreateStatement: fmt.Sprintf("CREATE DATABASE %s", databaseID), 62 | ExtraStatements: statements, 63 | }) 64 | if err != nil { 65 | return nil, err 66 | } 67 | createdDatabase, err := createDatabaseOp.Wait(ctx) 68 | if err != nil { 69 | return nil, err 70 | } 71 | log.Println("created", createdDatabase) 72 | return spanner.NewClient(ctx, createdDatabase.GetName(), option.WithGRPCConn(conn)) 73 | } 74 | -------------------------------------------------------------------------------- /cmd/protoc-gen-go-iam/internal/geniam/helpers.go: -------------------------------------------------------------------------------- 1 | package geniam 2 | 3 | import ( 4 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 5 | "google.golang.org/protobuf/compiler/protogen" 6 | "google.golang.org/protobuf/proto" 7 | ) 8 | 9 | func getPredefinedRoles( 10 | service *protogen.Service, 11 | ) *iamv1.PredefinedRoles { 12 | return proto.GetExtension(service.Desc.Options(), iamv1.E_PredefinedRoles).(*iamv1.PredefinedRoles) 13 | } 14 | 15 | func getMethodAuthorizationOptions( 16 | method *protogen.Method, 17 | ) *iamv1.MethodAuthorizationOptions { 18 | return proto.GetExtension(method.Desc.Options(), iamv1.E_MethodAuthorization).(*iamv1.MethodAuthorizationOptions) 19 | } 20 | 21 | func getLongRunningOperationsAuthorizationOptions( 22 | service *protogen.Service, 23 | ) *iamv1.LongRunningOperationsAuthorizationOptions { 24 | return proto.GetExtension( 25 | service.Desc.Options(), iamv1.E_LongRunningOperationsAuthorization, 26 | ).(*iamv1.LongRunningOperationsAuthorizationOptions) 27 | } 28 | -------------------------------------------------------------------------------- /cmd/protoc-gen-go-iam/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "go.einride.tech/iam/cmd/protoc-gen-go-iam/internal/geniam" 8 | "google.golang.org/protobuf/compiler/protogen" 9 | "google.golang.org/protobuf/reflect/protoregistry" 10 | ) 11 | 12 | const docURL = "https://pkg.go.dev/go.einride.tech/iam" 13 | 14 | func main() { 15 | if len(os.Args) == 2 && os.Args[1] == "--help" { 16 | _, _ = fmt.Fprintf(os.Stdout, "See %s for usage information.\n", docURL) 17 | os.Exit(0) 18 | } 19 | protogen.Options{}.Run(func(gen *protogen.Plugin) error { 20 | var files protoregistry.Files 21 | for _, file := range gen.Files { 22 | if err := files.RegisterFile(file.Desc); err != nil { 23 | return err 24 | } 25 | } 26 | for _, f := range gen.Files { 27 | if f.Generate { 28 | if err := geniam.GenerateFile(gen, &files, f); err != nil { 29 | return err 30 | } 31 | } 32 | } 33 | return nil 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.einride.tech/iam 2 | 3 | go 1.22 4 | 5 | require ( 6 | cloud.google.com/go/iam v1.2.2 7 | cloud.google.com/go/longrunning v0.6.2 8 | cloud.google.com/go/spanner v1.70.0 9 | github.com/google/cel-go v0.21.0 10 | go.einride.tech/aip v0.68.0 11 | go.einride.tech/spanner-aip v0.63.0 12 | google.golang.org/api v0.203.0 13 | google.golang.org/genproto v0.0.0-20241015192408-796eee8c2d53 14 | google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 15 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 16 | google.golang.org/grpc v1.67.1 17 | google.golang.org/protobuf v1.35.1 18 | gotest.tools/v3 v3.5.1 19 | ) 20 | 21 | require ( 22 | cel.dev/expr v0.16.0 // indirect 23 | cloud.google.com/go v0.116.0 // indirect 24 | cloud.google.com/go/auth v0.9.9 // indirect 25 | cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect 26 | cloud.google.com/go/compute/metadata v0.5.2 // indirect 27 | cloud.google.com/go/monitoring v1.21.1 // indirect 28 | github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.0 // indirect 29 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.24.1 // indirect 30 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 31 | github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect 32 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 33 | github.com/cncf/xds/go v0.0.0-20240822171458-6449f94b4d59 // indirect 34 | github.com/envoyproxy/go-control-plane v0.13.0 // indirect 35 | github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect 36 | github.com/felixge/httpsnoop v1.0.4 // indirect 37 | github.com/go-logr/logr v1.4.2 // indirect 38 | github.com/go-logr/stdr v1.2.2 // indirect 39 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 40 | github.com/google/go-cmp v0.6.0 // indirect 41 | github.com/google/s2a-go v0.1.8 // indirect 42 | github.com/google/uuid v1.6.0 // indirect 43 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 44 | github.com/googleapis/gax-go/v2 v2.13.0 // indirect 45 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect 46 | github.com/stoewer/go-strcase v1.3.0 // indirect 47 | go.opencensus.io v0.24.0 // indirect 48 | go.opentelemetry.io/contrib/detectors/gcp v1.29.0 // indirect 49 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect 50 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect 51 | go.opentelemetry.io/otel v1.29.0 // indirect 52 | go.opentelemetry.io/otel/metric v1.29.0 // indirect 53 | go.opentelemetry.io/otel/sdk v1.29.0 // indirect 54 | go.opentelemetry.io/otel/sdk/metric v1.29.0 // indirect 55 | go.opentelemetry.io/otel/trace v1.29.0 // indirect 56 | golang.org/x/crypto v0.31.0 // indirect 57 | golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect 58 | golang.org/x/net v0.33.0 // indirect 59 | golang.org/x/oauth2 v0.23.0 // indirect 60 | golang.org/x/sync v0.10.0 // indirect 61 | golang.org/x/sys v0.28.0 // indirect 62 | golang.org/x/text v0.21.0 // indirect 63 | golang.org/x/time v0.7.0 // indirect 64 | ) 65 | -------------------------------------------------------------------------------- /iamannotations/longrunningoperationsauthorization.go: -------------------------------------------------------------------------------- 1 | package iamannotations 2 | 3 | import ( 4 | "fmt" 5 | 6 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 7 | "google.golang.org/protobuf/proto" 8 | "google.golang.org/protobuf/reflect/protoreflect" 9 | "google.golang.org/protobuf/reflect/protoregistry" 10 | ) 11 | 12 | // ResolveLongRunningOperationsAuthorizationOptions resolves long-running operation authorization options for a service. 13 | // The provided files registry is used for resolving resource patterns. 14 | func ResolveLongRunningOperationsAuthorizationOptions( 15 | options *iamv1.LongRunningOperationsAuthorizationOptions, 16 | files *protoregistry.Files, 17 | startPackage protoreflect.FullName, 18 | ) (*iamv1.LongRunningOperationsAuthorizationOptions, error) { 19 | result := proto.Clone(options).(*iamv1.LongRunningOperationsAuthorizationOptions) 20 | for _, operationPermissions := range result.GetOperationPermissions() { 21 | operation, ok := resolveResource(files, startPackage, operationPermissions.GetOperation().GetType()) 22 | if !ok { 23 | return nil, fmt.Errorf( 24 | "resolve long-running operations authorization options in %s: unknown resource %s", 25 | startPackage, 26 | operationPermissions.GetOperation().GetType(), 27 | ) 28 | } 29 | operationPermissions.Operation.Pattern = append(operationPermissions.Operation.Pattern, operation.GetPattern()...) 30 | } 31 | return result, nil 32 | } 33 | -------------------------------------------------------------------------------- /iamannotations/methodauthorization.go: -------------------------------------------------------------------------------- 1 | package iamannotations 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.einride.tech/iam/iamresource" 7 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 8 | "google.golang.org/protobuf/proto" 9 | "google.golang.org/protobuf/reflect/protoreflect" 10 | "google.golang.org/protobuf/reflect/protoregistry" 11 | ) 12 | 13 | // ResolveMethodAuthorizationOptions loads method authorization options for a service. 14 | // The provided files registry is used for resolving resource patterns. 15 | func ResolveMethodAuthorizationOptions( 16 | options *iamv1.MethodAuthorizationOptions, 17 | files *protoregistry.Files, 18 | startPackage protoreflect.FullName, 19 | ) (*iamv1.MethodAuthorizationOptions, error) { 20 | result := proto.Clone(options).(*iamv1.MethodAuthorizationOptions) 21 | if permissions, ok := result.GetPermissions().(*iamv1.MethodAuthorizationOptions_ResourcePermissions); ok { 22 | for _, resourcePermission := range permissions.ResourcePermissions.GetResourcePermission() { 23 | switch { 24 | case resourcePermission.GetResource().GetType() == iamresource.Root: 25 | // Root resource requires no pattern resolution. 26 | case len(resourcePermission.GetResource().GetPattern()) > 0: 27 | // Resource is annotated with patterns manually. No need to resolve. 28 | default: 29 | resource, ok := resolveResource(files, startPackage, resourcePermission.GetResource().GetType()) 30 | if !ok { 31 | return nil, fmt.Errorf( 32 | "resolve method authorization options in '%s': unable to resolve resource '%s'", 33 | startPackage, 34 | resourcePermission.GetResource().GetType(), 35 | ) 36 | } 37 | if len(resource.GetPattern()) == 0 { 38 | return nil, fmt.Errorf( 39 | "resolve method authorization options in '%s': resource '%s' has no patterns", 40 | resourcePermission.GetResource().GetType(), 41 | startPackage, 42 | ) 43 | } 44 | resourcePermission.Resource.Pattern = append(resourcePermission.Resource.Pattern, resource.GetPattern()...) 45 | } 46 | } 47 | } 48 | return result, nil 49 | } 50 | -------------------------------------------------------------------------------- /iamannotations/resolveresource.go: -------------------------------------------------------------------------------- 1 | package iamannotations 2 | 3 | import ( 4 | "google.golang.org/genproto/googleapis/api/annotations" 5 | "google.golang.org/protobuf/proto" 6 | "google.golang.org/protobuf/reflect/protoreflect" 7 | "google.golang.org/protobuf/reflect/protoregistry" 8 | ) 9 | 10 | func resolveResource( 11 | files *protoregistry.Files, 12 | startPackage protoreflect.FullName, 13 | resourceType string, 14 | ) (*annotations.ResourceDescriptor, bool) { 15 | var result *annotations.ResourceDescriptor 16 | var searchMessagesFn func(protoreflect.MessageDescriptors) bool 17 | searchMessagesFn = func(messages protoreflect.MessageDescriptors) bool { 18 | for i := 0; i < messages.Len(); i++ { 19 | message := messages.Get(i) 20 | if resource := proto.GetExtension( 21 | message.Options(), annotations.E_Resource, 22 | ).(*annotations.ResourceDescriptor); resource != nil { 23 | if resource.GetType() == resourceType { 24 | result = resource 25 | return false 26 | } 27 | } 28 | if !searchMessagesFn(message.Messages()) { 29 | return false 30 | } 31 | } 32 | return true 33 | } 34 | searchFileFn := func(file protoreflect.FileDescriptor) bool { 35 | // Search file annotations. 36 | for _, resource := range proto.GetExtension( 37 | file.Options(), annotations.E_ResourceDefinition, 38 | ).([]*annotations.ResourceDescriptor) { 39 | if resource.GetType() == resourceType { 40 | result = resource 41 | return false 42 | } 43 | } 44 | return searchMessagesFn(file.Messages()) 45 | } 46 | // Start with a narrow search in the same package. 47 | files.RangeFilesByPackage(startPackage, searchFileFn) 48 | if result != nil { 49 | return result, true 50 | } 51 | // Fall back to a broad search of all files. 52 | files.RangeFiles(searchFileFn) 53 | return result, result != nil 54 | } 55 | -------------------------------------------------------------------------------- /iamannotations/validate_test.go: -------------------------------------------------------------------------------- 1 | package iamannotations 2 | 3 | import ( 4 | "testing" 5 | 6 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 7 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 8 | "google.golang.org/genproto/googleapis/rpc/errdetails" 9 | "google.golang.org/grpc/status" 10 | "google.golang.org/protobuf/testing/protocmp" 11 | "gotest.tools/v3/assert" 12 | ) 13 | 14 | func TestValidateRoles(t *testing.T) { 15 | for _, tt := range []struct { 16 | name string 17 | roles *iamv1.PredefinedRoles 18 | expected *errdetails.BadRequest 19 | }{ 20 | { 21 | name: "valid", 22 | roles: &iamv1.PredefinedRoles{ 23 | Role: []*adminpb.Role{ 24 | { 25 | Name: "roles/foo.barBaz", 26 | Title: "Foo Bar Baz", 27 | Description: "Longer description", 28 | IncludedPermissions: []string{"pubsub.*", "storage.buckets.*", "kms.keys.create"}, 29 | }, 30 | { 31 | Name: "roles/baz.fooBar", 32 | Title: "Baz Foo Bar", 33 | Description: "Longer description", 34 | IncludedPermissions: []string{"pubsub.*", "storage.buckets.*", "kms.keys.create"}, 35 | }, 36 | }, 37 | }, 38 | }, 39 | 40 | { 41 | name: "invalid name format", 42 | roles: &iamv1.PredefinedRoles{ 43 | Role: []*adminpb.Role{ 44 | { 45 | Name: "foobarbaz", 46 | Title: "Foo Bar Baz", 47 | Description: "Longer description", 48 | IncludedPermissions: []string{"pubsub.*", "storage.buckets.*", "kms.keys.create"}, 49 | }, 50 | { 51 | Name: "roles/baz.fooBar", 52 | Title: "Baz Foo Bar", 53 | Description: "Longer description", 54 | IncludedPermissions: []string{"pubsub.*", "storage.buckets.*", "kms.keys.create"}, 55 | }, 56 | }, 57 | }, 58 | expected: &errdetails.BadRequest{ 59 | FieldViolations: []*errdetails.BadRequest_FieldViolation{ 60 | { 61 | Field: "role[0].name", 62 | Description: "role name 'foobarbaz' is not on the format `roles/{service}.{role}`", 63 | }, 64 | }, 65 | }, 66 | }, 67 | } { 68 | t.Run(tt.name, func(t *testing.T) { 69 | err := ValidatePredefinedRoles(tt.roles) 70 | if tt.expected == nil { 71 | assert.NilError(t, err) 72 | } else { 73 | actual, ok := status.Convert(err).Details()[0].(*errdetails.BadRequest) 74 | assert.Assert(t, ok) 75 | assert.DeepEqual(t, tt.expected, actual, protocmp.Transform()) 76 | } 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /iamauthz/after.go: -------------------------------------------------------------------------------- 1 | package iamauthz 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/google/cel-go/cel" 8 | "go.einride.tech/iam/iamcaller" 9 | "go.einride.tech/iam/iamcel" 10 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/status" 13 | "google.golang.org/protobuf/proto" 14 | "google.golang.org/protobuf/reflect/protoreflect" 15 | ) 16 | 17 | type AfterMethodAuthorization struct { 18 | options *iamv1.MethodAuthorizationOptions 19 | callerResolver iamcaller.Resolver 20 | program cel.Program 21 | } 22 | 23 | func NewAfterMethodAuthorization( 24 | method protoreflect.MethodDescriptor, 25 | options *iamv1.MethodAuthorizationOptions, 26 | permissionTester iamcel.PermissionTester, 27 | callerResolver iamcaller.Resolver, 28 | ) (*AfterMethodAuthorization, error) { 29 | afterStrategy, ok := options.GetStrategy().(*iamv1.MethodAuthorizationOptions_After) 30 | if !ok { 31 | return nil, fmt.Errorf("strategy must be 'after'") 32 | } 33 | env, err := iamcel.NewAfterEnv(method) 34 | if err != nil { 35 | return nil, err 36 | } 37 | ast, issues := env.Compile(afterStrategy.After.GetExpression()) 38 | if issues.Err() != nil { 39 | return nil, issues.Err() 40 | } 41 | program, err := env.Program( 42 | ast, 43 | //nolint: staticcheck // TODO: migrate to new top-level CEL API 44 | cel.Functions( 45 | iamcel.NewTestFunctionImplementation(options, permissionTester), 46 | iamcel.NewTestAllFunctionImplementation(options, permissionTester), 47 | iamcel.NewTestAnyFunctionImplementation(options, permissionTester), 48 | iamcel.NewAncestorFunctionImplementation(), 49 | iamcel.NewMemberFunctionImplementation(), 50 | iamcel.NewJoinFunctionImplementation(), 51 | ), 52 | ) 53 | if err != nil { 54 | return nil, err 55 | } 56 | return &AfterMethodAuthorization{ 57 | options: options, 58 | callerResolver: callerResolver, 59 | program: program, 60 | }, nil 61 | } 62 | 63 | func (a *AfterMethodAuthorization) AuthorizeRequestAndResponse( 64 | ctx context.Context, 65 | request proto.Message, 66 | response proto.Message, 67 | ) (context.Context, error) { 68 | Authorize(ctx) 69 | caller, err := a.callerResolver.ResolveCaller(ctx) 70 | if err != nil { 71 | return nil, err 72 | } 73 | val, _, err := a.program.ContextEval(ctx, map[string]interface{}{ 74 | "caller": caller, 75 | "request": request, 76 | "response": response, 77 | }) 78 | if err != nil { 79 | return nil, forwardErrorCodes(err) 80 | } 81 | boolVal, ok := val.Value().(bool) 82 | if !ok { 83 | return nil, status.Error(codes.Internal, "authorization policy returned non-bool result") 84 | } 85 | if !boolVal { 86 | return nil, status.Error(codes.PermissionDenied, a.options.GetAfter().GetDescription()) 87 | } 88 | return ctx, nil 89 | } 90 | -------------------------------------------------------------------------------- /iamauthz/before.go: -------------------------------------------------------------------------------- 1 | package iamauthz 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/google/cel-go/cel" 8 | "go.einride.tech/iam/iamcaller" 9 | "go.einride.tech/iam/iamcel" 10 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/status" 13 | "google.golang.org/protobuf/proto" 14 | "google.golang.org/protobuf/reflect/protoreflect" 15 | ) 16 | 17 | type BeforeMethodAuthorization struct { 18 | options *iamv1.MethodAuthorizationOptions 19 | callerResolver iamcaller.Resolver 20 | program cel.Program 21 | } 22 | 23 | func NewBeforeMethodAuthorization( 24 | method protoreflect.MethodDescriptor, 25 | options *iamv1.MethodAuthorizationOptions, 26 | permissionTester iamcel.PermissionTester, 27 | callerResolver iamcaller.Resolver, 28 | ) (*BeforeMethodAuthorization, error) { 29 | beforeStrategy, ok := options.GetStrategy().(*iamv1.MethodAuthorizationOptions_Before) 30 | if !ok { 31 | return nil, fmt.Errorf("strategy must be 'before'") 32 | } 33 | env, err := iamcel.NewBeforeEnv(method) 34 | if err != nil { 35 | return nil, err 36 | } 37 | ast, issues := env.Compile(beforeStrategy.Before.GetExpression()) 38 | if issues.Err() != nil { 39 | return nil, issues.Err() 40 | } 41 | program, err := env.Program( 42 | ast, 43 | //nolint: staticcheck // TODO: migrate to new top-level CEL API 44 | cel.Functions( 45 | iamcel.NewTestFunctionImplementation(options, permissionTester), 46 | iamcel.NewTestAllFunctionImplementation(options, permissionTester), 47 | iamcel.NewTestAnyFunctionImplementation(options, permissionTester), 48 | iamcel.NewAncestorFunctionImplementation(), 49 | iamcel.NewMemberFunctionImplementation(), 50 | iamcel.NewJoinFunctionImplementation(), 51 | ), 52 | ) 53 | if err != nil { 54 | return nil, err 55 | } 56 | return &BeforeMethodAuthorization{ 57 | options: options, 58 | callerResolver: callerResolver, 59 | program: program, 60 | }, nil 61 | } 62 | 63 | func (a *BeforeMethodAuthorization) AuthorizeRequest( 64 | ctx context.Context, 65 | request proto.Message, 66 | ) (context.Context, error) { 67 | Authorize(ctx) 68 | caller, err := a.callerResolver.ResolveCaller(ctx) 69 | if err != nil { 70 | return nil, err 71 | } 72 | val, _, err := a.program.ContextEval(ctx, map[string]interface{}{ 73 | "caller": caller, 74 | "request": request, 75 | }) 76 | if err != nil { 77 | return nil, forwardErrorCodes(err) 78 | } 79 | boolVal, ok := val.Value().(bool) 80 | if !ok { 81 | return nil, status.Error(codes.Internal, "authorization policy returned non-bool result") 82 | } 83 | if !boolVal { 84 | return nil, status.Error(codes.PermissionDenied, a.options.GetBefore().GetDescription()) 85 | } 86 | return ctx, nil 87 | } 88 | -------------------------------------------------------------------------------- /iamauthz/beforelongrunning.go: -------------------------------------------------------------------------------- 1 | package iamauthz 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/iam/iamcaller" 7 | "go.einride.tech/iam/iamcel" 8 | "go.einride.tech/iam/iampermission" 9 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | ) 13 | 14 | type BeforeLongRunningOperationMethodAuthorization struct { 15 | options *iamv1.LongRunningOperationsAuthorizationOptions 16 | permissionTester iamcel.PermissionTester 17 | callerResolver iamcaller.Resolver 18 | } 19 | 20 | func NewBeforeLongRunningOperationMethodAuthorization( 21 | options *iamv1.LongRunningOperationsAuthorizationOptions, 22 | permissionTester iamcel.PermissionTester, 23 | callerResolver iamcaller.Resolver, 24 | ) (*BeforeLongRunningOperationMethodAuthorization, error) { 25 | return &BeforeLongRunningOperationMethodAuthorization{ 26 | options: options, 27 | permissionTester: permissionTester, 28 | callerResolver: callerResolver, 29 | }, nil 30 | } 31 | 32 | func (a *BeforeLongRunningOperationMethodAuthorization) AuthorizeRequest( 33 | ctx context.Context, 34 | request iampermission.LongRunningOperationRequest, 35 | ) (context.Context, error) { 36 | Authorize(ctx) 37 | permission, ok := iampermission.ResolveLongRunningOperationPermission(a.options.GetOperationPermissions(), request) 38 | if !ok { 39 | return nil, status.Error(codes.PermissionDenied, "no permission configured for long-running operation request") 40 | } 41 | caller, err := a.callerResolver.ResolveCaller(ctx) 42 | if err != nil { 43 | return nil, err 44 | } 45 | result, err := a.permissionTester.TestPermissions( 46 | ctx, caller, map[string]string{request.GetName(): permission}, 47 | ) 48 | if err != nil { 49 | return nil, err 50 | } 51 | if !result[request.GetName()] { 52 | return nil, status.Errorf(codes.PermissionDenied, "operation requires permission %s", permission) 53 | } 54 | return ctx, nil 55 | } 56 | -------------------------------------------------------------------------------- /iamauthz/doc.go: -------------------------------------------------------------------------------- 1 | // Package iamauthz provides primitives for performing IAM request authorization. 2 | package iamauthz 3 | -------------------------------------------------------------------------------- /iamauthz/errors.go: -------------------------------------------------------------------------------- 1 | package iamauthz 2 | 3 | import ( 4 | "strings" 5 | 6 | "google.golang.org/grpc/codes" 7 | "google.golang.org/grpc/status" 8 | ) 9 | 10 | // forwardErrorCodes is a workaround for CEL custom functions not being able to return Go errors with gRPC codes. 11 | // Instead, the CEL functions are expected to return the status code as a prefix of the error. 12 | func forwardErrorCodes(err error) error { 13 | errStr := err.Error() 14 | for _, code := range []codes.Code{ 15 | codes.InvalidArgument, 16 | codes.PermissionDenied, 17 | codes.Unauthenticated, 18 | codes.DeadlineExceeded, 19 | codes.Unavailable, 20 | } { 21 | codeStr := code.String() 22 | if strings.HasPrefix(errStr, codeStr) { 23 | return status.Error(code, strings.TrimPrefix(strings.TrimPrefix(errStr, codeStr), ": ")) 24 | } 25 | } 26 | return err 27 | } 28 | -------------------------------------------------------------------------------- /iamauthz/middleware.go: -------------------------------------------------------------------------------- 1 | package iamauthz 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "google.golang.org/grpc" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | ) 11 | 12 | // Authorize marks the current request as processed by an authorization check. 13 | // WithAuthorization must have been called on the context for the call to be effective. 14 | // 15 | // Authorize should be called at the start of an authorization check, to ensure that any errors resulting from the 16 | // authorization check itself are forwarded to the caller. 17 | func Authorize(ctx context.Context) { 18 | if value, ok := ctx.Value(contextKey{}).(*contextValue); ok { 19 | value.mu.Lock() 20 | value.authorized = true 21 | value.mu.Unlock() 22 | } 23 | } 24 | 25 | // RequireAuthorizationUnaryInterceptor is a grpc.UnaryServerInterceptor that requires authorization 26 | // to be performed on all incoming requests. 27 | // 28 | // To mark the request as processed by authorization checks, the method implementing authorization should call 29 | // Authorize on the request context as soon as authorization starts. 30 | func RequireAuthorizationUnaryInterceptor( 31 | ctx context.Context, 32 | req interface{}, 33 | _ *grpc.UnaryServerInfo, 34 | handler grpc.UnaryHandler, 35 | ) (interface{}, error) { 36 | ctx = WithAuthorization(ctx) 37 | resp, err := handler(ctx, req) 38 | if code := status.Code(err); code == codes.Unauthenticated || 39 | code == codes.PermissionDenied || 40 | code == codes.ResourceExhausted { 41 | return nil, err 42 | } 43 | value := ctx.Value(contextKey{}).(*contextValue) 44 | value.mu.Lock() 45 | defer value.mu.Unlock() 46 | if !value.authorized { 47 | return nil, status.Error(codes.Internal, "server did not perform authorization") 48 | } 49 | return resp, err 50 | } 51 | 52 | var _ grpc.UnaryServerInterceptor = RequireAuthorizationUnaryInterceptor 53 | 54 | // RequireAuthorizationStreamInterceptor is a grpc.StreamServerInterceptor that 55 | // aborts all incoming streams, pending implementation of stream support in this package. 56 | func RequireAuthorizationStreamInterceptor( 57 | _ interface{}, 58 | _ grpc.ServerStream, 59 | _ *grpc.StreamServerInfo, 60 | _ grpc.StreamHandler, 61 | ) error { 62 | return status.Error(codes.Internal, "server has not implemented stream authorization") 63 | } 64 | 65 | var _ grpc.StreamServerInterceptor = RequireAuthorizationStreamInterceptor 66 | 67 | // WithAuthorization adds authorization to the current request context. 68 | func WithAuthorization(ctx context.Context) context.Context { 69 | if _, ok := ctx.Value(contextKey{}).(*contextValue); ok { 70 | return ctx 71 | } 72 | return context.WithValue(ctx, contextKey{}, &contextValue{}) 73 | } 74 | 75 | type contextKey struct{} 76 | 77 | type contextValue struct { 78 | mu sync.Mutex 79 | authorized bool 80 | } 81 | -------------------------------------------------------------------------------- /iamcaller/caller.go: -------------------------------------------------------------------------------- 1 | package iamcaller 2 | 3 | import ( 4 | "sort" 5 | 6 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 7 | ) 8 | 9 | // Add adds the metadata resolved from the provided key to the provider caller info. 10 | func Add(caller *iamv1.Caller, key string, metadata *iamv1.Caller_Metadata) { 11 | MemberLoop: 12 | for _, member := range metadata.GetMembers() { 13 | for _, existingMember := range caller.GetMembers() { 14 | if member == existingMember { 15 | continue MemberLoop 16 | } 17 | } 18 | caller.Members = append(caller.Members, member) 19 | } 20 | sort.Slice(caller.GetMembers(), func(i, j int) bool { 21 | return caller.GetMembers()[i] < caller.GetMembers()[j] 22 | }) 23 | if caller.Metadata == nil { 24 | caller.Metadata = map[string]*iamv1.Caller_Metadata{} 25 | } 26 | caller.Metadata[key] = metadata 27 | } 28 | -------------------------------------------------------------------------------- /iamcaller/chain.go: -------------------------------------------------------------------------------- 1 | package iamcaller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 8 | ) 9 | 10 | // ChainResolvers creates a single resolver out of a chain of many resolvers. 11 | // 12 | // The resulting resolved caller data will be the union of the caller data resolved by each resolver. 13 | // 14 | // If any resolver returns an error, that error is immediately returned and no further resolvers are called. 15 | // 16 | // If multiple resolvers resolve the same metadata key, the only last encountered metadata value will be kept. 17 | func ChainResolvers(resolvers ...Resolver) Resolver { 18 | return chainResolver{resolvers: resolvers} 19 | } 20 | 21 | type chainResolver struct { 22 | resolvers []Resolver 23 | } 24 | 25 | // ResolveCaller implements Resolver. 26 | func (c chainResolver) ResolveCaller(ctx context.Context) (*iamv1.Caller, error) { 27 | var result iamv1.Caller 28 | for i, resolver := range c.resolvers { 29 | nextCaller, err := resolver.ResolveCaller(ctx) 30 | if err != nil { 31 | return nil, err 32 | } 33 | if err := Validate(nextCaller); err != nil { 34 | return nil, fmt.Errorf("chain callers: resolver %d: %w", i, err) 35 | } 36 | for key, value := range nextCaller.GetMetadata() { 37 | Add(&result, key, value) 38 | } 39 | // TODO: Remove this when CEL-Go supports async functions with context arguments. 40 | if result.GetContext() == nil && nextCaller.GetContext() != nil { 41 | result.Context = nextCaller.GetContext() 42 | } 43 | } 44 | return &result, nil 45 | } 46 | -------------------------------------------------------------------------------- /iamcaller/middleware.go: -------------------------------------------------------------------------------- 1 | package iamcaller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 8 | "google.golang.org/grpc" 9 | ) 10 | 11 | // FromResolvedContext returns the resolved IAM members and metadata from the provided context. 12 | func FromResolvedContext(ctx context.Context) (*iamv1.Caller, bool) { 13 | value, ok := ctx.Value(resolvedContextKey{}).(*iamv1.Caller) 14 | return value, ok 15 | } 16 | 17 | // WithResolvedContext returns a new context with cached IAM member resolve result. 18 | func WithResolvedContext(ctx context.Context, resolveResult *iamv1.Caller) context.Context { 19 | return context.WithValue(ctx, resolvedContextKey{}, resolveResult) 20 | } 21 | 22 | type resolvedContextKey struct{} 23 | 24 | // FromContextResolver returns a Resolver that resolves cached IAM members and metadata from the current context. 25 | func FromContextResolver() Resolver { 26 | return contextResolver{} 27 | } 28 | 29 | type contextResolver struct{} 30 | 31 | // ResolveCaller implements Resolver. 32 | func (contextResolver) ResolveCaller(ctx context.Context) (*iamv1.Caller, error) { 33 | result, ok := FromResolvedContext(ctx) 34 | if !ok { 35 | return nil, fmt.Errorf("unresolved IAM member context") 36 | } 37 | return result, nil 38 | } 39 | 40 | // ResolveContextUnaryInterceptor returns a gRPC server middleware that resolves IAM members with the provided resolver. 41 | func ResolveContextUnaryInterceptor(resolver Resolver) grpc.UnaryServerInterceptor { 42 | return func( 43 | ctx context.Context, 44 | request interface{}, 45 | _ *grpc.UnaryServerInfo, 46 | handler grpc.UnaryHandler, 47 | ) (interface{}, error) { 48 | result, err := resolver.ResolveCaller(ctx) 49 | if err != nil { 50 | return nil, err 51 | } 52 | return handler(WithResolvedContext(ctx, result), request) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /iamcaller/resolver.go: -------------------------------------------------------------------------------- 1 | package iamcaller 2 | 3 | import ( 4 | "context" 5 | 6 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 7 | ) 8 | 9 | // Resolver resolves the IAM caller identity for the current context. 10 | type Resolver interface { 11 | ResolveCaller(context.Context) (*iamv1.Caller, error) 12 | } 13 | -------------------------------------------------------------------------------- /iamcaller/validate.go: -------------------------------------------------------------------------------- 1 | package iamcaller 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.einride.tech/iam/iammember" 7 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 8 | ) 9 | 10 | // Validate checks that resolved caller info is valid. 11 | func Validate(caller *iamv1.Caller) error { 12 | if err := validateMembers(caller); err != nil { 13 | return fmt.Errorf("validate caller: %w", err) 14 | } 15 | if err := validateMetadata(caller); err != nil { 16 | return fmt.Errorf("validate caller: %w", err) 17 | } 18 | return nil 19 | } 20 | 21 | func validateMembers(caller *iamv1.Caller) error { 22 | ValidateMembersLoop: 23 | for _, member := range caller.GetMembers() { 24 | // Validate the member. 25 | if err := iammember.Validate(member); err != nil { 26 | return err 27 | } 28 | // Validate that the member is present in the metadata. 29 | for _, metadata := range caller.GetMetadata() { 30 | for _, metadataMember := range metadata.GetMembers() { 31 | if member == metadataMember { 32 | continue ValidateMembersLoop 33 | } 34 | } 35 | } 36 | return fmt.Errorf("member '%s' not in metadata", member) 37 | } 38 | return nil 39 | } 40 | 41 | func validateMetadata(caller *iamv1.Caller) error { 42 | for key, metadata := range caller.GetMetadata() { 43 | ValidateMetadataLoop: 44 | for _, metadataMember := range metadata.GetMembers() { 45 | // Validate the metadata member. 46 | if err := iammember.Validate(metadataMember); err != nil { 47 | return err 48 | } 49 | // Validate that the metadata member is present in the top-level members. 50 | for _, callerMember := range caller.GetMembers() { 51 | if metadataMember == callerMember { 52 | continue ValidateMetadataLoop 53 | } 54 | } 55 | return fmt.Errorf("member '%s' from metadata '%s' not in caller members", metadataMember, key) 56 | } 57 | } 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /iamcel/after.go: -------------------------------------------------------------------------------- 1 | package iamcel 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/google/cel-go/cel" 7 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 8 | "google.golang.org/protobuf/reflect/protoreflect" 9 | ) 10 | 11 | // NewAfterEnv creates a new CEL environment for authorization checks that run after the request has been handled. 12 | func NewAfterEnv(method protoreflect.MethodDescriptor) (*cel.Env, error) { 13 | caller := (&iamv1.Caller{}).ProtoReflect().Descriptor() 14 | dependencies, err := collectDependencies(caller, method.Input(), method.Output()) 15 | if err != nil { 16 | return nil, fmt.Errorf("new IAM CEL `after` env: %w", err) 17 | } 18 | env, err := cel.NewEnv( 19 | cel.TypeDescs(dependencies), 20 | cel.Variable("caller", cel.ObjectType(string(caller.FullName()))), 21 | cel.Variable("request", cel.ObjectType(string(method.Input().FullName()))), 22 | cel.Variable("response", cel.ObjectType(string(method.Output().FullName()))), 23 | cel.Declarations( 24 | // TODO: Migrate declarations to new top-level API. 25 | NewTestFunctionDeclaration(), 26 | NewTestAllFunctionDeclaration(), 27 | NewTestAnyFunctionDeclaration(), 28 | NewAncestorFunctionDeclaration(), 29 | NewMemberFunctionDeclaration(), 30 | NewJoinFunctionDeclaration(), 31 | ), 32 | ) 33 | if err != nil { 34 | return nil, fmt.Errorf("new IAM CEL `after` env: %w", err) 35 | } 36 | return env, nil 37 | } 38 | -------------------------------------------------------------------------------- /iamcel/ancestor.go: -------------------------------------------------------------------------------- 1 | package iamcel 2 | 3 | import ( 4 | "github.com/google/cel-go/checker/decls" 5 | "github.com/google/cel-go/common/types" 6 | "github.com/google/cel-go/common/types/ref" 7 | "github.com/google/cel-go/interpreter/functions" 8 | "go.einride.tech/aip/resourcename" 9 | expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1" 10 | ) 11 | 12 | // AncestorFunction is the name of the CEL ancestor function. 13 | const AncestorFunction = "ancestor" 14 | 15 | const ancestorFunctionOverload = "ancestor_string_string" 16 | 17 | // NewAncestorFunctionDeclaration creates a new declaration for the ancestor function. 18 | func NewAncestorFunctionDeclaration() *expr.Decl { 19 | return decls.NewFunction( 20 | AncestorFunction, 21 | decls.NewOverload( 22 | ancestorFunctionOverload, 23 | []*expr.Type{decls.String, decls.String}, 24 | decls.String, 25 | ), 26 | ) 27 | } 28 | 29 | // NewAncestorFunctionImplementation creates a new implementation for the ancestor function. 30 | func NewAncestorFunctionImplementation() *functions.Overload { 31 | return &functions.Overload{ 32 | Operator: ancestorFunctionOverload, 33 | Binary: func(nameVal, patternVal ref.Val) ref.Val { 34 | name, ok := nameVal.Value().(string) 35 | if !ok { 36 | return types.NewErr("parent: unexpected type of arg 1, expected string but got %T", nameVal.Value()) 37 | } 38 | pattern, ok := patternVal.Value().(string) 39 | if !ok { 40 | return types.NewErr("parent: unexpected type of arg 2, expected string but got %T", patternVal.Value()) 41 | } 42 | result, ok := resourcename.Ancestor(name, pattern) 43 | if !ok { 44 | return types.String("") 45 | } 46 | return types.String(result) 47 | }, 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /iamcel/ancestor_test.go: -------------------------------------------------------------------------------- 1 | package iamcel 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/cel-go/cel" 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func TestResourceNameFunctions(t *testing.T) { 11 | env, err := cel.NewEnv(cel.Declarations(NewAncestorFunctionDeclaration())) 12 | assert.NilError(t, err) 13 | t.Run("ok", func(t *testing.T) { 14 | ast, issues := env.Compile(`ancestor('foo/1/bar/2', 'foo/{foo}')`) 15 | assert.NilError(t, issues.Err()) 16 | //nolint: staticcheck // TODO: migrate to new top-level API 17 | program, err := env.Program(ast, cel.Functions(NewAncestorFunctionImplementation())) 18 | assert.NilError(t, err) 19 | result, _, err := program.Eval(map[string]interface{}(nil)) 20 | assert.NilError(t, err) 21 | assert.Equal(t, "foo/1", result.Value().(string)) 22 | }) 23 | t.Run("no match", func(t *testing.T) { 24 | ast, issues := env.Compile(`ancestor('baz/1/bar/2', 'foo/{foo}')`) 25 | assert.NilError(t, issues.Err()) 26 | //nolint: staticcheck // TODO: migrate to new top-level API 27 | program, err := env.Program(ast, cel.Functions(NewAncestorFunctionImplementation())) 28 | assert.NilError(t, err) 29 | result, _, err := program.Eval(map[string]interface{}(nil)) 30 | assert.NilError(t, err) 31 | assert.Equal(t, "", result.Value().(string)) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /iamcel/before.go: -------------------------------------------------------------------------------- 1 | package iamcel 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/google/cel-go/cel" 7 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 8 | "google.golang.org/protobuf/reflect/protoreflect" 9 | ) 10 | 11 | // NewBeforeEnv creates a new CEL environment for authorization checks that run before the request has been handled. 12 | func NewBeforeEnv(method protoreflect.MethodDescriptor) (*cel.Env, error) { 13 | caller := (&iamv1.Caller{}).ProtoReflect().Descriptor() 14 | descriptors, err := collectDependencies(caller, method.Input()) 15 | if err != nil { 16 | return nil, fmt.Errorf("new IAM CEL `before` env: %w", err) 17 | } 18 | env, err := cel.NewEnv( 19 | cel.TypeDescs(descriptors), 20 | cel.Variable("caller", cel.ObjectType(string(caller.FullName()))), 21 | cel.Variable("request", cel.ObjectType(string(method.Input().FullName()))), 22 | cel.Declarations( 23 | // TODO: Migrate declarations to new top-level API. 24 | NewTestFunctionDeclaration(), 25 | NewTestAllFunctionDeclaration(), 26 | NewTestAnyFunctionDeclaration(), 27 | NewAncestorFunctionDeclaration(), 28 | NewMemberFunctionDeclaration(), 29 | NewJoinFunctionDeclaration(), 30 | ), 31 | ) 32 | if err != nil { 33 | return nil, fmt.Errorf("new IAM CEL `before` env: %w", err) 34 | } 35 | return env, nil 36 | } 37 | -------------------------------------------------------------------------------- /iamcel/collectdependencies.go: -------------------------------------------------------------------------------- 1 | package iamcel 2 | 3 | import ( 4 | "google.golang.org/protobuf/reflect/protoreflect" 5 | "google.golang.org/protobuf/reflect/protoregistry" 6 | ) 7 | 8 | func collectDependencies(messages ...protoreflect.MessageDescriptor) (*protoregistry.Files, error) { 9 | fileDescriptors := make(map[string]protoreflect.FileDescriptor, len(messages)) 10 | for _, message := range messages { 11 | messageParentFile := message.ParentFile() 12 | fileDescriptors[messageParentFile.Path()] = messageParentFile 13 | // Initialize list of transitive imports. 14 | transitiveImports := make([]protoreflect.FileImport, 0, messageParentFile.Imports().Len()) 15 | for i := 0; i < messageParentFile.Imports().Len(); i++ { 16 | transitiveImports = append(transitiveImports, messageParentFile.Imports().Get(i)) 17 | } 18 | // Expand list of transitive imports. 19 | for i := 0; i < len(transitiveImports); i++ { 20 | currImport := transitiveImports[i] 21 | if _, ok := fileDescriptors[currImport.Path()]; ok { 22 | continue 23 | } 24 | fileDescriptors[currImport.Path()] = currImport.FileDescriptor 25 | for j := 0; j < currImport.FileDescriptor.Imports().Len(); j++ { 26 | transitiveImports = append(transitiveImports, currImport.FileDescriptor.Imports().Get(j)) 27 | } 28 | } 29 | } 30 | var registry protoregistry.Files 31 | for _, fileDescriptor := range fileDescriptors { 32 | if err := registry.RegisterFile(fileDescriptor); err != nil { 33 | return nil, err 34 | } 35 | } 36 | return ®istry, nil 37 | } 38 | -------------------------------------------------------------------------------- /iamcel/doc.go: -------------------------------------------------------------------------------- 1 | // Package iamcel provides primitives for using CEL expressions for access management. 2 | package iamcel 3 | -------------------------------------------------------------------------------- /iamcel/join.go: -------------------------------------------------------------------------------- 1 | package iamcel 2 | 3 | import ( 4 | "github.com/google/cel-go/checker/decls" 5 | "github.com/google/cel-go/common/types" 6 | "github.com/google/cel-go/common/types/ref" 7 | "github.com/google/cel-go/interpreter/functions" 8 | "go.einride.tech/aip/resourcename" 9 | expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1" 10 | ) 11 | 12 | // JoinFunction is the name of the CEL descendant function. 13 | const JoinFunction = "join" 14 | 15 | const joinFunctionOverload = "join_string_string" 16 | 17 | // NewJoinFunctionDeclaration creates a new declaration for the descendant function. 18 | func NewJoinFunctionDeclaration() *expr.Decl { 19 | return decls.NewFunction( 20 | JoinFunction, 21 | // TODO: if ever possible in CEL-go, declare this as a variadic function. 22 | decls.NewOverload( 23 | joinFunctionOverload, 24 | []*expr.Type{decls.String, decls.String}, 25 | decls.String, 26 | ), 27 | ) 28 | } 29 | 30 | // NewJoinFunctionImplementation creates a new implementation for the descendant function. 31 | func NewJoinFunctionImplementation() *functions.Overload { 32 | return &functions.Overload{ 33 | Operator: joinFunctionOverload, 34 | Binary: func(parentVal, childVal ref.Val) ref.Val { 35 | parent, ok := parentVal.Value().(string) 36 | if !ok { 37 | return types.NewErr("parent: unexpected type of arg 1, expected string but got %T", parentVal.Value()) 38 | } 39 | child, ok := childVal.Value().(string) 40 | if !ok { 41 | return types.NewErr("child: unexpected type of arg 2, expected string but got %T", childVal.Value()) 42 | } 43 | return types.String(resourcename.Join(parent, child)) 44 | }, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /iamcel/member.go: -------------------------------------------------------------------------------- 1 | package iamcel 2 | 3 | import ( 4 | "github.com/google/cel-go/checker/decls" 5 | "github.com/google/cel-go/common/types" 6 | "github.com/google/cel-go/common/types/ref" 7 | "github.com/google/cel-go/interpreter/functions" 8 | "go.einride.tech/iam/iammember" 9 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 10 | expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1" 11 | ) 12 | 13 | // MemberFunction is the name of the CEL member function. 14 | const MemberFunction = "member" 15 | 16 | const memberFunctionOverload = "member_caller_string_string" 17 | 18 | // NewMemberFunctionDeclaration creates a new declaration for the member function. 19 | func NewMemberFunctionDeclaration() *expr.Decl { 20 | return decls.NewFunction( 21 | MemberFunction, 22 | decls.NewInstanceOverload( 23 | memberFunctionOverload, 24 | []*expr.Type{ 25 | decls.NewObjectType(string((&iamv1.Caller{}).ProtoReflect().Descriptor().FullName())), 26 | decls.String, 27 | }, 28 | decls.String, 29 | ), 30 | ) 31 | } 32 | 33 | // NewMemberFunctionImplementation creates a new implementation for the member function. 34 | func NewMemberFunctionImplementation() *functions.Overload { 35 | return &functions.Overload{ 36 | Operator: memberFunctionOverload, 37 | Binary: func(callerVal, kindVal ref.Val) ref.Val { 38 | caller, ok := callerVal.Value().(*iamv1.Caller) 39 | if !ok { 40 | return types.NewErr("test: unexpected type of arg 1, expected %T but got %T", &iamv1.Caller{}, callerVal.Value()) 41 | } 42 | 43 | kind, ok := kindVal.Value().(string) 44 | if !ok { 45 | return types.NewErr("test: unexpected type of arg 2, expected string but got %T", kindVal.Value()) 46 | } 47 | 48 | for _, member := range caller.GetMembers() { 49 | memberKind, memberValue, ok := iammember.Parse(member) 50 | if !ok { 51 | return types.NewErr("member: error parsing caller member '%s'", member) 52 | } 53 | if memberKind == kind { 54 | return types.String(memberValue) 55 | } 56 | } 57 | 58 | return types.NewErr("member: no kind '%s' found in caller", kind) 59 | }, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /iamcel/member_test.go: -------------------------------------------------------------------------------- 1 | package iamcel 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/cel-go/cel" 7 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestMemberFunction(t *testing.T) { 12 | caller := (&iamv1.Caller{}).ProtoReflect().Descriptor() 13 | dependencies, err := collectDependencies(caller) 14 | assert.NilError(t, err) 15 | env, err := cel.NewEnv( 16 | cel.TypeDescs(dependencies), 17 | cel.Variable("caller", cel.ObjectType(string(caller.FullName()))), 18 | cel.Declarations(NewMemberFunctionDeclaration()), 19 | ) 20 | assert.NilError(t, err) 21 | 22 | t.Run("single kind", func(t *testing.T) { 23 | ast, issues := env.Compile(`caller.member('kind1')`) 24 | assert.NilError(t, issues.Err()) 25 | //nolint: staticcheck // TODO: migrate to new top-level API 26 | program, err := env.Program(ast, cel.Functions(NewMemberFunctionImplementation())) 27 | assert.NilError(t, err) 28 | result, _, err := program.Eval( 29 | map[string]interface{}{ 30 | "caller": &iamv1.Caller{ 31 | Members: []string{ 32 | "kind1:value1", 33 | "kind2:value2", 34 | "kind2:value3", 35 | }, 36 | }, 37 | }, 38 | ) 39 | assert.NilError(t, err) 40 | assert.Equal(t, "value1", result.Value().(string)) 41 | }) 42 | t.Run("pick first", func(t *testing.T) { 43 | ast, issues := env.Compile(`caller.member('kind2')`) 44 | assert.NilError(t, issues.Err()) 45 | //nolint: staticcheck // TODO: migrate to new top-level API 46 | program, err := env.Program(ast, cel.Functions(NewMemberFunctionImplementation())) 47 | assert.NilError(t, err) 48 | result, _, err := program.Eval( 49 | map[string]interface{}{ 50 | "caller": &iamv1.Caller{ 51 | Members: []string{ 52 | "kind1:value1", 53 | "kind2:value2", 54 | "kind2:value3", 55 | }, 56 | }, 57 | }, 58 | ) 59 | assert.NilError(t, err) 60 | assert.Equal(t, "value2", result.Value().(string)) 61 | }) 62 | t.Run("no such kind", func(t *testing.T) { 63 | ast, issues := env.Compile(`caller.member('kind3')`) 64 | assert.NilError(t, issues.Err()) 65 | //nolint: staticcheck // TODO: migrate to new top-level API 66 | program, err := env.Program(ast, cel.Functions(NewMemberFunctionImplementation())) 67 | assert.NilError(t, err) 68 | _, _, err = program.Eval( 69 | map[string]interface{}{ 70 | "caller": &iamv1.Caller{ 71 | Members: []string{ 72 | "kind1:value1", 73 | "kind2:value2", 74 | "kind2:value3", 75 | }, 76 | }, 77 | }, 78 | ) 79 | assert.Error(t, err, "member: no kind 'kind3' found in caller") 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /iamcel/test.go: -------------------------------------------------------------------------------- 1 | package iamcel 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/cel-go/checker/decls" 7 | "github.com/google/cel-go/common/types" 8 | "github.com/google/cel-go/common/types/ref" 9 | "github.com/google/cel-go/interpreter/functions" 10 | "go.einride.tech/iam/iampermission" 11 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 12 | expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1" 13 | "google.golang.org/grpc/codes" 14 | "google.golang.org/grpc/status" 15 | ) 16 | 17 | // TestFunction is the name of the test permission function. 18 | const TestFunction = "test" 19 | 20 | // permission test function overloads. 21 | const testFunctionOverload = "test_caller_string_bool" 22 | 23 | // NewTestFunctionDeclaration creates a new declaration for the test permission function. 24 | func NewTestFunctionDeclaration() *expr.Decl { 25 | return decls.NewFunction( 26 | TestFunction, 27 | decls.NewOverload( 28 | testFunctionOverload, 29 | []*expr.Type{ 30 | decls.NewObjectType(string((&iamv1.Caller{}).ProtoReflect().Descriptor().FullName())), 31 | decls.String, 32 | }, 33 | decls.Bool, 34 | ), 35 | ) 36 | } 37 | 38 | // NewTestFunctionImplementation creates a new implementation for the test permission function. 39 | func NewTestFunctionImplementation( 40 | options *iamv1.MethodAuthorizationOptions, 41 | tester PermissionTester, 42 | ) *functions.Overload { 43 | return &functions.Overload{ 44 | Operator: testFunctionOverload, 45 | Binary: func(callerVal, resourceVal ref.Val) ref.Val { 46 | caller, ok := callerVal.Value().(*iamv1.Caller) 47 | if !ok { 48 | return types.NewErr("test: unexpected type of arg 1, expected %T but got %T", &iamv1.Caller{}, callerVal.Value()) 49 | } 50 | resource, ok := resourceVal.Value().(string) 51 | if !ok { 52 | return types.NewErr("test: unexpected type of arg 2, expected string but got %T", resourceVal.Value()) 53 | } 54 | permission, ok := iampermission.ResolveMethodPermission(options, resource) 55 | if !ok { 56 | return types.NewErr("%s: no permission configured for resource '%s'", codes.PermissionDenied, resource) 57 | } 58 | // TODO: When cel-go supports async functions, use the caller context here. 59 | ctx := context.Background() 60 | if caller.GetContext().GetDeadline() != nil { 61 | var cancel context.CancelFunc 62 | ctx, cancel = context.WithDeadline(ctx, caller.GetContext().GetDeadline().AsTime()) 63 | defer cancel() 64 | } 65 | result, err := tester.TestPermissions(ctx, caller, map[string]string{resource: permission}) 66 | switch { 67 | case err != nil: 68 | if s, ok := status.FromError(err); ok { 69 | return types.NewErr("%s: %s", s.Code(), s.Message()) 70 | } 71 | return types.NewErr("test: error testing permission '%s': %v", permission, err) 72 | case !result[resource]: 73 | return types.False 74 | default: 75 | return types.True 76 | } 77 | }, 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /iamcel/tester.go: -------------------------------------------------------------------------------- 1 | package iamcel 2 | 3 | import ( 4 | "context" 5 | 6 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 7 | ) 8 | 9 | // PermissionTester is an interface for testing IAM permissions. 10 | type PermissionTester interface { 11 | TestPermissions( 12 | ctx context.Context, 13 | caller *iamv1.Caller, 14 | resourcePermissions map[string]string, 15 | ) (map[string]bool, error) 16 | } 17 | -------------------------------------------------------------------------------- /iamexample/caller.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/iam/iamcaller" 7 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 8 | "google.golang.org/grpc/metadata" 9 | "google.golang.org/protobuf/types/known/timestamppb" 10 | ) 11 | 12 | // MemberHeader is the gRPC header used by the example server to determine IAM members of the caller. 13 | const MemberHeader = "x-iam-example-members" 14 | 15 | // NewMemberHeaderResolver returns an iammember.Resolver that resolves members from MemberHeader. 16 | func NewMemberHeaderResolver() iamcaller.Resolver { 17 | return &memberHeaderResolver{} 18 | } 19 | 20 | var _ iamcaller.Resolver = &memberHeaderResolver{} 21 | 22 | type memberHeaderResolver struct{} 23 | 24 | // ResolveCaller implements iamcaller.Resolver. 25 | func (m *memberHeaderResolver) ResolveCaller(ctx context.Context) (*iamv1.Caller, error) { 26 | var result iamv1.Caller 27 | if md, ok := metadata.FromIncomingContext(ctx); ok { 28 | iamcaller.Add(&result, MemberHeader, &iamv1.Caller_Metadata{ 29 | Members: md.Get(MemberHeader), 30 | }) 31 | } 32 | if deadline, ok := ctx.Deadline(); ok { 33 | result.Context = &iamv1.Caller_Context{ 34 | Deadline: timestamppb.New(deadline), 35 | } 36 | } 37 | return &result, nil 38 | } 39 | 40 | // WithOutgoingMembers appends the provided members to the outgoing gRPC context. 41 | func WithOutgoingMembers(ctx context.Context, members ...string) context.Context { 42 | pairs := make([]string, 0, len(members)*2) 43 | for _, member := range members { 44 | pairs = append(pairs, MemberHeader, member) 45 | } 46 | return metadata.AppendToOutgoingContext(ctx, pairs...) 47 | } 48 | -------------------------------------------------------------------------------- /iamexample/convert_shippers.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "fmt" 5 | 6 | "cloud.google.com/go/spanner" 7 | "go.einride.tech/aip/resourcename" 8 | "go.einride.tech/iam/iamexample/iamexampledb" 9 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 10 | "google.golang.org/protobuf/types/known/timestamppb" 11 | ) 12 | 13 | func convertShipperProtoToRow(msg *iamexamplev1.Shipper) (*iamexampledb.ShippersRow, error) { 14 | var row iamexampledb.ShippersRow 15 | // name = 1 16 | if err := resourcename.Sscan(msg.GetName(), "shippers/{shipper}", &row.ShipperId); err != nil { 17 | return nil, fmt.Errorf("name: %w", err) 18 | } 19 | // create_time = 2 20 | row.CreateTime = msg.GetCreateTime().AsTime() 21 | // update_time = 3 22 | row.UpdateTime = msg.GetUpdateTime().AsTime() 23 | // delete_time = 4 24 | if msg.GetDeleteTime() != nil { 25 | row.DeleteTime = spanner.NullTime{ 26 | Time: msg.GetDeleteTime().AsTime(), 27 | Valid: true, 28 | } 29 | } 30 | // display_name = 5 31 | row.DisplayName = msg.GetDisplayName() 32 | if err := row.Validate(); err != nil { 33 | return nil, fmt.Errorf("validate row: %w", err) 34 | } 35 | return &row, nil 36 | } 37 | 38 | func convertShipperRowToProto(row *iamexampledb.ShippersRow) (*iamexamplev1.Shipper, error) { 39 | var msg iamexamplev1.Shipper 40 | // name = 1 41 | msg.Name = resourcename.Sprint("shippers/{shipper}", row.ShipperId) 42 | // create_time = 2 43 | msg.CreateTime = timestamppb.New(row.CreateTime) 44 | // update_time = 3 45 | msg.UpdateTime = timestamppb.New(row.UpdateTime) 46 | // delete_time = 4 47 | if row.DeleteTime.Valid { 48 | msg.DeleteTime = timestamppb.New(row.DeleteTime.Time) 49 | } 50 | // display_name = 5 51 | msg.DisplayName = row.DisplayName 52 | return &msg, nil 53 | } 54 | -------------------------------------------------------------------------------- /iamexample/convert_sites.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "fmt" 5 | 6 | "cloud.google.com/go/spanner" 7 | "go.einride.tech/aip/resourcename" 8 | "go.einride.tech/iam/iamexample/iamexampledb" 9 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 10 | "google.golang.org/genproto/googleapis/type/latlng" 11 | "google.golang.org/protobuf/types/known/timestamppb" 12 | ) 13 | 14 | func convertSiteProtoToRow(msg *iamexamplev1.Site) (*iamexampledb.SitesRow, error) { 15 | var row iamexampledb.SitesRow 16 | // name = 1 17 | if err := resourcename.Sscan( 18 | msg.GetName(), 19 | "shippers/{shipper}/sites/{site}", 20 | &row.ShipperId, 21 | &row.SiteId, 22 | ); err != nil { 23 | return nil, fmt.Errorf("name: %w", err) 24 | } 25 | // create_time = 2 26 | row.CreateTime = msg.GetCreateTime().AsTime() 27 | // update_time = 3 28 | row.UpdateTime = msg.GetUpdateTime().AsTime() 29 | // delete_time = 4 30 | if msg.GetDeleteTime() != nil { 31 | row.DeleteTime = spanner.NullTime{ 32 | Time: msg.GetDeleteTime().AsTime(), 33 | Valid: true, 34 | } 35 | } 36 | // display_name = 5 37 | row.DisplayName = msg.GetDisplayName() 38 | if err := row.Validate(); err != nil { 39 | return nil, fmt.Errorf("validate row: %w", err) 40 | } 41 | // lat_lng = 6 42 | if msg.GetLatLng() != nil { 43 | row.Latitude = spanner.NullFloat64{ 44 | Float64: msg.GetLatLng().GetLatitude(), 45 | Valid: true, 46 | } 47 | row.Longitude = spanner.NullFloat64{ 48 | Float64: msg.GetLatLng().GetLongitude(), 49 | Valid: true, 50 | } 51 | } 52 | return &row, nil 53 | } 54 | 55 | func convertSiteRowToProto(row *iamexampledb.SitesRow) (*iamexamplev1.Site, error) { 56 | var msg iamexamplev1.Site 57 | // name = 1 58 | msg.Name = resourcename.Sprint("shippers/{shipper}/sites/{site}", row.ShipperId, row.SiteId) 59 | // create_time = 2 60 | msg.CreateTime = timestamppb.New(row.CreateTime) 61 | // update_time = 3 62 | msg.UpdateTime = timestamppb.New(row.UpdateTime) 63 | // delete_time = 4 64 | if row.DeleteTime.Valid { 65 | msg.DeleteTime = timestamppb.New(row.DeleteTime.Time) 66 | } 67 | // display_name = 5 68 | msg.DisplayName = row.DisplayName 69 | // lat_lng = 6 70 | if row.Latitude.Valid && row.Longitude.Valid { 71 | msg.LatLng = &latlng.LatLng{ 72 | Latitude: row.Latitude.Float64, 73 | Longitude: row.Longitude.Float64, 74 | } 75 | } 76 | return &msg, nil 77 | } 78 | -------------------------------------------------------------------------------- /iamexample/doc.go: -------------------------------------------------------------------------------- 1 | // Package iamexample provides a reference implementation of a gRPC service that uses IAM for access management. 2 | package iamexample 3 | -------------------------------------------------------------------------------- /iamexample/iamexampledata/doc.go: -------------------------------------------------------------------------------- 1 | // Package iamexampledata provides predefined example data for the IAM example service. 2 | package iamexampledata 3 | -------------------------------------------------------------------------------- /iamexample/iamexampledata/init.go: -------------------------------------------------------------------------------- 1 | package iamexampledata 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/iam/apiv1/iampb" 7 | "cloud.google.com/go/spanner" 8 | "go.einride.tech/aip/resourcename" 9 | "go.einride.tech/iam/iamexample/iamexampledb" 10 | "go.einride.tech/iam/iamresource" 11 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 12 | ) 13 | 14 | // BootstrapRootAdmin bootstraps an IAM database with RootAdminMember as iamexamplev1.FreightServiceServer root admin. 15 | func BootstrapRootAdmin(ctx context.Context, spannerClient *spanner.Client) error { 16 | if _, err := spannerClient.Apply(ctx, []*spanner.Mutation{ 17 | spanner.Insert((&iamexampledb.IamPolicyBindingsRow{ 18 | Resource: iamresource.Root, 19 | BindingIndex: 0, 20 | Role: "roles/freight.admin", 21 | MemberIndex: 0, 22 | Member: RootAdminMember, 23 | }).Mutate()), 24 | }); err != nil { 25 | return err 26 | } 27 | return nil 28 | } 29 | 30 | // InitializeResources uses an iamexamplev1.FreightServiceClient to initialize the set of example resources. 31 | func InitializeResources(ctx context.Context, server iamexamplev1.FreightServiceServer) error { 32 | einride := Einride() 33 | var shipperID string 34 | if err := resourcename.Sscan(einride.GetName(), "shippers/{shipper}", &shipperID); err != nil { 35 | return err 36 | } 37 | if _, err := server.CreateShipper(ctx, &iamexamplev1.CreateShipperRequest{ 38 | Shipper: einride, 39 | ShipperId: shipperID, 40 | }); err != nil { 41 | return err 42 | } 43 | for _, site := range []*iamexamplev1.Site{ 44 | EinrideGothenburgOffice(), 45 | EinrideStockholmOffice(), 46 | EinrideBatcave(), 47 | } { 48 | var siteID string 49 | if err := resourcename.Sscan( 50 | site.GetName(), 51 | "shippers/{shipper}/sites/{site}", 52 | &shipperID, 53 | &siteID, 54 | ); err != nil { 55 | return err 56 | } 57 | if _, err := server.CreateSite(ctx, &iamexamplev1.CreateSiteRequest{ 58 | Parent: einride.GetName(), 59 | Site: site, 60 | SiteId: siteID, 61 | }); err != nil { 62 | return err 63 | } 64 | } 65 | for _, request := range []*iampb.SetIamPolicyRequest{ 66 | EinrideSetIamPolicyRequest(), 67 | EinrideGothenburgOfficeSetIamPolicyRequest(), 68 | EinrideBatcaveSetIamPolicyRequest(), 69 | } { 70 | if _, err := server.SetIamPolicy(ctx, request); err != nil { 71 | return err 72 | } 73 | } 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /iamexample/iamexampledata/policy.go: -------------------------------------------------------------------------------- 1 | package iamexampledata 2 | 3 | import ( 4 | "cloud.google.com/go/iam/apiv1/iampb" 5 | ) 6 | 7 | // Example IAM policy members. 8 | const ( 9 | RootAdminMember = "user:root" 10 | EinrideAdminMember = "user:einride-admin" 11 | EinrideGothenburgFreightPlannerMember = "user:einride-gothenburg-freight-planner" 12 | EinrideBatcaveWorkerMember = "user:einride-batcave-worker" 13 | ) 14 | 15 | // EinrideSetIamPolicyRequest returns an iampb.SetIamPolicyRequest for the 16 | // Einride shipper resource. 17 | func EinrideSetIamPolicyRequest() *iampb.SetIamPolicyRequest { 18 | return &iampb.SetIamPolicyRequest{ 19 | Resource: Einride().GetName(), 20 | Policy: &iampb.Policy{ 21 | Bindings: []*iampb.Binding{ 22 | { 23 | Role: "roles/freight.admin", 24 | Members: []string{EinrideAdminMember}, 25 | }, 26 | }, 27 | }, 28 | } 29 | } 30 | 31 | // EinrideGothenburgOfficeSetIamPolicyRequest returns an iampb.SetIamPolicyRequest for the 32 | // Einride Gothenburg Office site resource. 33 | func EinrideGothenburgOfficeSetIamPolicyRequest() *iampb.SetIamPolicyRequest { 34 | return &iampb.SetIamPolicyRequest{ 35 | Resource: EinrideGothenburgOffice().GetName(), 36 | Policy: &iampb.Policy{ 37 | Bindings: []*iampb.Binding{ 38 | { 39 | Role: "roles/freight.editor", 40 | Members: []string{EinrideGothenburgFreightPlannerMember}, 41 | }, 42 | }, 43 | }, 44 | } 45 | } 46 | 47 | // EinrideBatcaveSetIamPolicyRequest returns an iampb.SetIamPolicyRequest for the 48 | // Einride Batcave site resource. 49 | func EinrideBatcaveSetIamPolicyRequest() *iampb.SetIamPolicyRequest { 50 | return &iampb.SetIamPolicyRequest{ 51 | Resource: EinrideBatcave().GetName(), 52 | Policy: &iampb.Policy{ 53 | Bindings: []*iampb.Binding{ 54 | { 55 | Role: "roles/freight.viewer", 56 | Members: []string{EinrideBatcaveWorkerMember}, 57 | }, 58 | }, 59 | }, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /iamexample/iamexampledata/shippers.go: -------------------------------------------------------------------------------- 1 | package iamexampledata 2 | 3 | import ( 4 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 5 | ) 6 | 7 | // Einride returns the Einride example shipper. 8 | func Einride() *iamexamplev1.Shipper { 9 | return &iamexamplev1.Shipper{ 10 | Name: "shippers/einride", 11 | DisplayName: "Einride", 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /iamexample/iamexampledata/sites.go: -------------------------------------------------------------------------------- 1 | package iamexampledata 2 | 3 | import ( 4 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 5 | "google.golang.org/genproto/googleapis/type/latlng" 6 | ) 7 | 8 | // EinrideGothenburgOffice returns an iamexamplev1.Site representing Einride's Gothenburg office. 9 | func EinrideGothenburgOffice() *iamexamplev1.Site { 10 | return &iamexamplev1.Site{ 11 | Name: Einride().GetName() + "/sites/gothenburg", 12 | DisplayName: "Einride Gothenburg Office", 13 | LatLng: &latlng.LatLng{ 14 | Latitude: 57.70775726491335, 15 | Longitude: 11.94977470756508, 16 | }, 17 | } 18 | } 19 | 20 | // EinrideStockholmOffice returns an iamexamplev1.Site representing Einride's Stockholm office. 21 | func EinrideStockholmOffice() *iamexamplev1.Site { 22 | return &iamexamplev1.Site{ 23 | Name: Einride().GetName() + "/sites/sthlm", 24 | DisplayName: "Einride Gothenburg Office", 25 | LatLng: &latlng.LatLng{ 26 | Latitude: 59.33749110496606, 27 | Longitude: 18.063672598779984, 28 | }, 29 | } 30 | } 31 | 32 | // EinrideBatcave returns an iamexamplev1.Site representing Einride's Batcave. 33 | func EinrideBatcave() *iamexamplev1.Site { 34 | return &iamexamplev1.Site{ 35 | Name: Einride().GetName() + "/sites/batcave", 36 | DisplayName: "Einride Batcave", 37 | LatLng: &latlng.LatLng{ 38 | Latitude: 59.33749110496606, 39 | Longitude: 18.063672598779984, 40 | }, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /iamexample/schema.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import _ "embed" 4 | 5 | //go:embed schema.sql 6 | var spannerSQLSchema string 7 | 8 | // SQLSchema returns the example Spanner SQL schema. 9 | func SQLSchema() string { 10 | return spannerSQLSchema 11 | } 12 | -------------------------------------------------------------------------------- /iamexample/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE shippers ( 2 | shipper_id STRING(63) NOT NULL, 3 | create_time TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true), 4 | update_time TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true), 5 | delete_time TIMESTAMP OPTIONS (allow_commit_timestamp=true), 6 | display_name STRING(63) NOT NULL, 7 | ) PRIMARY KEY(shipper_id); 8 | 9 | CREATE TABLE sites ( 10 | shipper_id STRING(63) NOT NULL, 11 | site_id STRING(63) NOT NULL, 12 | create_time TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true), 13 | update_time TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true), 14 | delete_time TIMESTAMP OPTIONS (allow_commit_timestamp=true), 15 | display_name STRING(63) NOT NULL, 16 | latitude FLOAT64, 17 | longitude FLOAT64, 18 | CONSTRAINT fk_sites_parent 19 | FOREIGN KEY (shipper_id) 20 | REFERENCES shippers (shipper_id), 21 | ) PRIMARY KEY(shipper_id, site_id); 22 | 23 | CREATE TABLE shipments ( 24 | shipper_id STRING(63) NOT NULL, 25 | shipment_id STRING(63) NOT NULL, 26 | create_time TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true), 27 | update_time TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true), 28 | delete_time TIMESTAMP OPTIONS (allow_commit_timestamp=true), 29 | origin_site_id STRING(63) NOT NULL, 30 | destination_site_id STRING(63) NOT NULL, 31 | pickup_earliest_time TIMESTAMP NOT NULL, 32 | pickup_latest_time TIMESTAMP NOT NULL, 33 | delivery_earliest_time TIMESTAMP NOT NULL, 34 | delivery_latest_time TIMESTAMP NOT NULL, 35 | annotations ARRAY NOT NULL, 36 | CONSTRAINT fk_shipments_parent 37 | FOREIGN KEY (shipper_id) 38 | REFERENCES shippers (shipper_id), 39 | CONSTRAINT fk_shipments_origin_site 40 | FOREIGN KEY (shipper_id, origin_site_id) 41 | REFERENCES sites (shipper_id, site_id), 42 | CONSTRAINT fk_shipments_destination_site 43 | FOREIGN KEY (shipper_id, destination_site_id) 44 | REFERENCES sites (shipper_id, site_id), 45 | ) PRIMARY KEY(shipper_id, shipment_id); 46 | 47 | CREATE TABLE line_items ( 48 | shipper_id STRING(63) NOT NULL, 49 | shipment_id STRING(63) NOT NULL, 50 | line_number INT64 NOT NULL, 51 | title STRING(63) NOT NULL, 52 | quantity FLOAT64 NOT NULL, 53 | weight_kg FLOAT64 NOT NULL, 54 | volume_m3 FLOAT64 NOT NULL, 55 | ) PRIMARY KEY(shipper_id, shipment_id, line_number ASC), 56 | INTERLEAVE IN PARENT shipments ON DELETE CASCADE; 57 | -------------------------------------------------------------------------------- /iamexample/server.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/spanner" 7 | "go.einride.tech/iam/iamspanner" 8 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 9 | "google.golang.org/grpc/codes" 10 | "google.golang.org/grpc/status" 11 | ) 12 | 13 | // Server implements iamexamplev1.FreightServiceServer. 14 | type Server struct { 15 | IAM *iamspanner.IAMServer 16 | Spanner *spanner.Client 17 | Config Config 18 | } 19 | 20 | // Config for a Server. 21 | type Config struct { 22 | ErrorHook func(context.Context, error) 23 | } 24 | 25 | var _ iamexamplev1.FreightServiceServer = &Server{} 26 | 27 | func (s *Server) errorHook(ctx context.Context, err error) { 28 | if s.Config.ErrorHook != nil { 29 | s.Config.ErrorHook(ctx, err) 30 | } 31 | } 32 | 33 | func (s *Server) handleStorageError(ctx context.Context, err error) error { 34 | s.errorHook(ctx, err) 35 | switch code := status.Code(err); code { 36 | case codes.Canceled, codes.DeadlineExceeded, codes.Aborted, codes.Unavailable: 37 | return status.Error(code, "transient storage error") 38 | default: 39 | return status.Error(codes.Internal, "internal storage error") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /iamexample/server_iam.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 7 | "cloud.google.com/go/iam/apiv1/iampb" 8 | ) 9 | 10 | // SetIamPolicy implements iampb.IAMPolicyServer. 11 | func (s *Server) SetIamPolicy( 12 | ctx context.Context, 13 | request *iampb.SetIamPolicyRequest, 14 | ) (*iampb.Policy, error) { 15 | return s.IAM.SetIamPolicy(ctx, request) 16 | } 17 | 18 | // GetIamPolicy implements iampb.IAMPolicyServer. 19 | func (s *Server) GetIamPolicy( 20 | ctx context.Context, 21 | request *iampb.GetIamPolicyRequest, 22 | ) (*iampb.Policy, error) { 23 | return s.IAM.GetIamPolicy(ctx, request) 24 | } 25 | 26 | // TestIamPermissions implements iampb.IAMPolicyServer. 27 | func (s *Server) TestIamPermissions( 28 | ctx context.Context, 29 | request *iampb.TestIamPermissionsRequest, 30 | ) (*iampb.TestIamPermissionsResponse, error) { 31 | return s.IAM.TestIamPermissions(ctx, request) 32 | } 33 | 34 | // ListRoles implements adminpb.IAMServer. 35 | func (s *Server) ListRoles( 36 | ctx context.Context, 37 | request *adminpb.ListRolesRequest, 38 | ) (*adminpb.ListRolesResponse, error) { 39 | return s.IAM.ListRoles(ctx, request) 40 | } 41 | 42 | // GetRole implements adminpb.IAMServer. 43 | func (s *Server) GetRole( 44 | ctx context.Context, 45 | request *adminpb.GetRoleRequest, 46 | ) (*adminpb.Role, error) { 47 | return s.IAM.GetRole(ctx, request) 48 | } 49 | -------------------------------------------------------------------------------- /iamexample/server_iam_test.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "cloud.google.com/go/iam/apiv1/iampb" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func (ts *serverTestSuite) testIAM(t *testing.T) { 14 | t.Parallel() 15 | ctx := withTestDeadline(context.Background(), t) 16 | 17 | t.Run("authorized", func(t *testing.T) { 18 | t.Run("unconfigured resource", func(t *testing.T) { 19 | const ( 20 | member = "user:test@example.com" 21 | shipper = "shippers/1234" 22 | ) 23 | fx := ts.newTestFixture(t) 24 | fx.iam.AddPolicyBinding(t, "/", "roles/freight.admin", member) 25 | got, err := fx.client.GetIamPolicy( 26 | WithOutgoingMembers(ctx, member), 27 | &iampb.GetIamPolicyRequest{ 28 | Resource: "resources/foo", 29 | }, 30 | ) 31 | assert.Equal(t, codes.PermissionDenied, status.Code(err), "unexpected status: %v", err) 32 | assert.Assert(t, got == nil) 33 | }) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /iamexample/server_longrunning.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/longrunning/autogen/longrunningpb" 7 | "google.golang.org/grpc/codes" 8 | "google.golang.org/grpc/status" 9 | "google.golang.org/protobuf/types/known/emptypb" 10 | ) 11 | 12 | // ListOperations implements longrunningpb.OperationsServer. 13 | func (s *Server) ListOperations( 14 | context.Context, 15 | *longrunningpb.ListOperationsRequest, 16 | ) (*longrunningpb.ListOperationsResponse, error) { 17 | return &longrunningpb.ListOperationsResponse{}, nil 18 | } 19 | 20 | // GetOperation implements longrunningpb.OperationsServer. 21 | func (s *Server) GetOperation( 22 | context.Context, 23 | *longrunningpb.GetOperationRequest, 24 | ) (*longrunningpb.Operation, error) { 25 | return nil, status.Error(codes.NotFound, "operation not found") 26 | } 27 | 28 | // DeleteOperation implements longrunningpb.OperationsServer. 29 | func (s *Server) DeleteOperation( 30 | context.Context, 31 | *longrunningpb.DeleteOperationRequest, 32 | ) (*emptypb.Empty, error) { 33 | return nil, status.Error(codes.NotFound, "operation not found") 34 | } 35 | 36 | // CancelOperation implements longrunningpb.OperationsServer. 37 | func (s *Server) CancelOperation( 38 | context.Context, 39 | *longrunningpb.CancelOperationRequest, 40 | ) (*emptypb.Empty, error) { 41 | return nil, status.Error(codes.NotFound, "operation not found") 42 | } 43 | 44 | // WaitOperation implements longrunningpb.OperationsServer. 45 | func (s *Server) WaitOperation( 46 | context.Context, 47 | *longrunningpb.WaitOperationRequest, 48 | ) (*longrunningpb.Operation, error) { 49 | return nil, status.Error(codes.NotFound, "operation not found") 50 | } 51 | -------------------------------------------------------------------------------- /iamexample/server_shipments_create_test.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | "google.golang.org/protobuf/testing/protocmp" 11 | "google.golang.org/protobuf/types/known/timestamppb" 12 | "gotest.tools/v3/assert" 13 | ) 14 | 15 | func (ts *serverTestSuite) testCreateShipment(t *testing.T) { 16 | t.Parallel() 17 | ctx := withTestDeadline(context.Background(), t) 18 | 19 | t.Run("authorized", func(t *testing.T) { 20 | t.Run("ok", func(t *testing.T) { 21 | const ( 22 | member = "user:test@example.com" 23 | parent = "shippers/aaaa" 24 | originSite = "shippers/aaaa/sites/origin" 25 | destinationSite = "shippers/aaaa/sites/destination" 26 | shipmentID = "bbbb" 27 | ) 28 | fx := ts.newTestFixture(t) 29 | fx.createShipper(t, parent) 30 | fx.createSite(t, originSite) 31 | fx.createSite(t, destinationSite) 32 | fx.iam.AddPolicyBinding(t, "/", "roles/freight.admin", member) 33 | input := &iamexamplev1.Shipment{ 34 | OriginSite: originSite, 35 | DestinationSite: destinationSite, 36 | PickupEarliestTime: timestamppb.Now(), 37 | PickupLatestTime: timestamppb.Now(), 38 | DeliveryEarliestTime: timestamppb.Now(), 39 | DeliveryLatestTime: timestamppb.Now(), 40 | LineItems: []*iamexamplev1.LineItem{ 41 | {Title: "test 1", Quantity: 1}, 42 | {Title: "test 2", Quantity: 2}, 43 | }, 44 | } 45 | got, err := fx.client.CreateShipment( 46 | WithOutgoingMembers(ctx, member), 47 | &iamexamplev1.CreateShipmentRequest{ 48 | Parent: parent, 49 | Shipment: input, 50 | ShipmentId: shipmentID, 51 | }, 52 | ) 53 | assert.NilError(t, err) 54 | assert.DeepEqual(t, input.GetLineItems(), got.GetLineItems(), protocmp.Transform()) 55 | }) 56 | }) 57 | 58 | t.Run("unauthorized", func(t *testing.T) { 59 | const ( 60 | member = "user:test@example.com" 61 | parent = "shippers/aaaa" 62 | originSite = "shippers/aaaa/sites/origin" 63 | destinationSite = "shippers/aaaa/sites/destination" 64 | shipmentID = "5678" 65 | ) 66 | fx := ts.newTestFixture(t) 67 | input := &iamexamplev1.Shipment{ 68 | OriginSite: originSite, 69 | DestinationSite: destinationSite, 70 | PickupEarliestTime: timestamppb.Now(), 71 | PickupLatestTime: timestamppb.Now(), 72 | DeliveryEarliestTime: timestamppb.Now(), 73 | DeliveryLatestTime: timestamppb.Now(), 74 | LineItems: []*iamexamplev1.LineItem{ 75 | {Title: "test 1", Quantity: 1}, 76 | {Title: "test 2", Quantity: 2}, 77 | }, 78 | } 79 | got, err := fx.client.CreateShipment( 80 | WithOutgoingMembers(ctx, member), 81 | &iamexamplev1.CreateShipmentRequest{ 82 | Parent: parent, 83 | Shipment: input, 84 | ShipmentId: shipmentID, 85 | }, 86 | ) 87 | assert.Equal(t, codes.PermissionDenied, status.Code(err), "unexpected status: %v", err) 88 | assert.Assert(t, got == nil) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /iamexample/server_shipments_delete.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/spanner" 7 | "go.einride.tech/aip/resourcename" 8 | "go.einride.tech/aip/validation" 9 | "go.einride.tech/iam/iamexample/iamexampledb" 10 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/status" 13 | "google.golang.org/protobuf/types/known/timestamppb" 14 | ) 15 | 16 | // DeleteShipment implements iamexamplev1.FreightServiceServer. 17 | func (s *Server) DeleteShipment( 18 | ctx context.Context, 19 | request *iamexamplev1.DeleteShipmentRequest, 20 | ) (*iamexamplev1.Shipment, error) { 21 | var parsedRequest deleteShipmentRequest 22 | if err := parsedRequest.parse(request); err != nil { 23 | return nil, err 24 | } 25 | return s.deleteShipment(ctx, &parsedRequest) 26 | } 27 | 28 | func (s *Server) deleteShipment( 29 | ctx context.Context, 30 | request *deleteShipmentRequest, 31 | ) (*iamexamplev1.Shipment, error) { 32 | var result *iamexamplev1.Shipment 33 | commitTime, err := s.Spanner.ReadWriteTransaction( 34 | ctx, 35 | func(ctx context.Context, tx *spanner.ReadWriteTransaction) error { 36 | row, err := iamexampledb.Query(tx).GetShipmentsRow(ctx, iamexampledb.GetShipmentsRowQuery{ 37 | Key: iamexampledb.ShipmentsKey{ 38 | ShipperId: request.shipperID, 39 | ShipmentId: request.shipmentID, 40 | }, 41 | }) 42 | if err != nil { 43 | return err 44 | } 45 | if row.DeleteTime.Valid { 46 | return status.Errorf(codes.FailedPrecondition, "site already deleted: %s", request.name) 47 | } 48 | row.UpdateTime = spanner.CommitTimestamp 49 | row.DeleteTime = spanner.NullTime{ 50 | Time: spanner.CommitTimestamp, 51 | Valid: true, 52 | } 53 | result, err = convertShipmentRowToProto(row) 54 | if err != nil { 55 | return err 56 | } 57 | return tx.BufferWrite([]*spanner.Mutation{spanner.Update(row.Mutate())}) 58 | }, 59 | ) 60 | if err != nil { 61 | switch code := status.Code(err); code { 62 | case codes.FailedPrecondition: 63 | return nil, err 64 | case codes.NotFound: 65 | return nil, status.Errorf(code, "no such site: %s", request.name) 66 | default: 67 | return nil, s.handleStorageError(ctx, err) 68 | } 69 | } 70 | result.UpdateTime = timestamppb.New(commitTime) 71 | result.DeleteTime = result.GetUpdateTime() 72 | return result, nil 73 | } 74 | 75 | type deleteShipmentRequest struct { 76 | name string 77 | shipperID string 78 | shipmentID string 79 | } 80 | 81 | func (r *deleteShipmentRequest) parse(request *iamexamplev1.DeleteShipmentRequest) error { 82 | var v validation.MessageValidator 83 | // name = 1 84 | if request.GetName() == "" { 85 | v.AddFieldViolation("name", "required field") 86 | } else if err := resourcename.Sscan( 87 | request.GetName(), 88 | "shippers/{shipper}/shipments/{shipment}", 89 | &r.shipperID, 90 | &r.shipmentID, 91 | ); err != nil { 92 | v.AddFieldError("name", err) 93 | } 94 | r.name = request.GetName() 95 | return v.Err() 96 | } 97 | -------------------------------------------------------------------------------- /iamexample/server_shipments_get.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/aip/resourcename" 7 | "go.einride.tech/aip/validation" 8 | "go.einride.tech/iam/iamexample/iamexampledb" 9 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | ) 13 | 14 | // GetShipment implements iamexamplev1.FreightServiceServer. 15 | func (s *Server) GetShipment( 16 | ctx context.Context, 17 | request *iamexamplev1.GetShipmentRequest, 18 | ) (*iamexamplev1.Shipment, error) { 19 | var parsedRequest getShipmentRequest 20 | if err := parsedRequest.parse(request); err != nil { 21 | return nil, err 22 | } 23 | return s.getShipment(ctx, &parsedRequest) 24 | } 25 | 26 | func (s *Server) getShipment( 27 | ctx context.Context, 28 | request *getShipmentRequest, 29 | ) (*iamexamplev1.Shipment, error) { 30 | tx := s.Spanner.Single() 31 | defer tx.Close() 32 | row, err := iamexampledb.Query(tx).GetShipmentsRow(ctx, iamexampledb.GetShipmentsRowQuery{ 33 | Key: iamexampledb.ShipmentsKey{ 34 | ShipperId: request.shipperID, 35 | ShipmentId: request.shipmentID, 36 | }, 37 | }) 38 | if err != nil { 39 | if code := status.Code(err); code == codes.NotFound { 40 | return nil, status.Errorf(code, "not found: %s", request.name) 41 | } 42 | return nil, s.handleStorageError(ctx, err) 43 | } 44 | msg, err := convertShipmentRowToProto(row) 45 | if err != nil { 46 | s.errorHook(ctx, err) 47 | return nil, status.Error(codes.Internal, "internal data conversion error") 48 | } 49 | return msg, nil 50 | } 51 | 52 | type getShipmentRequest struct { 53 | name string 54 | shipperID string 55 | shipmentID string 56 | } 57 | 58 | func (r *getShipmentRequest) parse(request *iamexamplev1.GetShipmentRequest) error { 59 | var v validation.MessageValidator 60 | // name = 1 61 | if request.GetName() == "" { 62 | v.AddFieldViolation("name", "required field") 63 | } else if err := resourcename.Sscan( 64 | request.GetName(), 65 | "shippers/{shipper}/shipments/{shipment}", 66 | &r.shipperID, 67 | &r.shipmentID, 68 | ); err != nil { 69 | v.AddFieldError("name", err) 70 | } 71 | r.name = request.GetName() 72 | return v.Err() 73 | } 74 | -------------------------------------------------------------------------------- /iamexample/server_shippers_create.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/spanner" 7 | "go.einride.tech/aip/resourceid" 8 | "go.einride.tech/aip/resourcename" 9 | "go.einride.tech/aip/validation" 10 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/status" 13 | "google.golang.org/protobuf/types/known/timestamppb" 14 | ) 15 | 16 | // CreateShipper implements iamexamplev1.FreightServiceServer. 17 | func (s *Server) CreateShipper( 18 | ctx context.Context, 19 | request *iamexamplev1.CreateShipperRequest, 20 | ) (*iamexamplev1.Shipper, error) { 21 | var parsedRequest createShipperRequest 22 | if err := parsedRequest.parse(request); err != nil { 23 | return nil, err 24 | } 25 | return s.createShipper(ctx, &parsedRequest) 26 | } 27 | 28 | func (s *Server) createShipper( 29 | ctx context.Context, 30 | request *createShipperRequest, 31 | ) (*iamexamplev1.Shipper, error) { 32 | row, err := convertShipperProtoToRow(request.shipper) 33 | if err != nil { 34 | s.errorHook(ctx, err) 35 | return nil, status.Error(codes.Internal, "internal data conversion error") 36 | } 37 | row.CreateTime = spanner.CommitTimestamp 38 | row.UpdateTime = spanner.CommitTimestamp 39 | commitTime, err := s.Spanner.Apply(ctx, []*spanner.Mutation{spanner.Insert(row.Mutate())}) 40 | if err != nil { 41 | switch code := status.Code(err); code { 42 | case codes.AlreadyExists: 43 | return nil, status.Errorf(code, "shipper %s already exists", request.shipper.GetName()) 44 | default: 45 | return nil, s.handleStorageError(ctx, err) 46 | } 47 | } 48 | request.shipper.CreateTime = timestamppb.New(commitTime) 49 | request.shipper.UpdateTime = request.shipper.GetCreateTime() 50 | return request.shipper, nil 51 | } 52 | 53 | type createShipperRequest struct { 54 | shipperID string 55 | shipper *iamexamplev1.Shipper 56 | } 57 | 58 | func (r *createShipperRequest) parse(request *iamexamplev1.CreateShipperRequest) error { 59 | var v validation.MessageValidator 60 | // shipper_id = 3 61 | if request.GetShipperId() != "" { 62 | if err := resourceid.ValidateUserSettable(request.GetShipperId()); err != nil { 63 | v.AddFieldError("shipper_id", err) 64 | } 65 | r.shipperID = request.GetShipperId() 66 | } else { 67 | r.shipperID = resourceid.NewSystemGeneratedBase32() 68 | } 69 | if request.GetShipper() == nil { 70 | v.AddFieldViolation("shipment", "required field") 71 | } else { 72 | // name = 1 73 | request.Shipper.Name = resourcename.Sprint("shippers/{shipper}", r.shipperID) 74 | // create_time = 2 75 | request.Shipper.CreateTime = nil 76 | // create_time = 3 77 | request.Shipper.CreateTime = nil 78 | // delete_time = 4 79 | request.Shipper.DeleteTime = nil 80 | // display_name = 5 81 | if len(request.GetShipper().GetDisplayName()) == 0 { 82 | v.AddFieldViolation("shipper.display_name", "required field") 83 | } else if len(request.GetShipper().GetDisplayName()) >= 64 { 84 | v.AddFieldViolation("shipper.display_name", "should be <= 63 characters") 85 | } 86 | r.shipper = request.GetShipper() 87 | } 88 | return v.Err() 89 | } 90 | -------------------------------------------------------------------------------- /iamexample/server_shippers_create_test.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func (ts *serverTestSuite) testCreateShipper(t *testing.T) { 14 | t.Parallel() 15 | ctx := withTestDeadline(context.Background(), t) 16 | 17 | t.Run("authorized", func(t *testing.T) { 18 | t.Run("ok", func(t *testing.T) { 19 | const ( 20 | member = "user:test@example.com" 21 | shipperID = "aaaa" 22 | ) 23 | fx := ts.newTestFixture(t) 24 | fx.iam.AddPolicyBinding(t, "/", "roles/freight.admin", member) 25 | input := &iamexamplev1.Shipper{ 26 | DisplayName: "Test Shipper", 27 | } 28 | got, err := fx.client.CreateShipper( 29 | WithOutgoingMembers(ctx, member), 30 | &iamexamplev1.CreateShipperRequest{ 31 | Shipper: input, 32 | ShipperId: shipperID, 33 | }, 34 | ) 35 | assert.NilError(t, err) 36 | assert.Equal(t, input.GetDisplayName(), got.GetDisplayName()) 37 | }) 38 | }) 39 | 40 | t.Run("unauthorized", func(t *testing.T) { 41 | const ( 42 | member = "user:test@example.com" 43 | shipperID = "aaaa" 44 | ) 45 | fx := ts.newTestFixture(t) 46 | got, err := fx.client.CreateShipper( 47 | WithOutgoingMembers(ctx, member), 48 | &iamexamplev1.CreateShipperRequest{ 49 | Shipper: &iamexamplev1.Shipper{ 50 | DisplayName: "Test Shipper", 51 | }, 52 | ShipperId: shipperID, 53 | }, 54 | ) 55 | assert.Equal(t, codes.PermissionDenied, status.Code(err), "unexpected status: %v", err) 56 | assert.Assert(t, got == nil) 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /iamexample/server_shippers_delete_test.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | "google.golang.org/protobuf/proto" 11 | "google.golang.org/protobuf/types/known/anypb" 12 | "gotest.tools/v3/assert" 13 | ) 14 | 15 | func (ts *serverTestSuite) testDeleteShipper(t *testing.T) { 16 | t.Parallel() 17 | ctx := withTestDeadline(context.Background(), t) 18 | 19 | t.Run("authorized", func(t *testing.T) { 20 | t.Run("ok", func(t *testing.T) { 21 | const ( 22 | member = "user:test@example.com" 23 | shipperID = "aaaa" 24 | ) 25 | fx := ts.newTestFixture(t) 26 | fx.iam.AddPolicyBinding(t, "/", "roles/freight.admin", member) 27 | input := &iamexamplev1.Shipper{ 28 | DisplayName: "Test Shipper", 29 | } 30 | created, err := fx.client.CreateShipper( 31 | WithOutgoingMembers(ctx, member), 32 | &iamexamplev1.CreateShipperRequest{ 33 | Shipper: input, 34 | ShipperId: shipperID, 35 | }, 36 | ) 37 | assert.NilError(t, err) 38 | assert.Equal(t, input.GetDisplayName(), created.GetDisplayName()) 39 | operation, err := fx.client.DeleteShipper( 40 | WithOutgoingMembers(ctx, member), 41 | &iamexamplev1.DeleteShipperRequest{ 42 | Name: created.GetName(), 43 | }, 44 | ) 45 | assert.NilError(t, err) 46 | assert.Assert(t, operation.GetDone()) 47 | response, err := anypb.UnmarshalNew(operation.GetResponse(), proto.UnmarshalOptions{}) 48 | assert.NilError(t, err) 49 | deleted, ok := response.(*iamexamplev1.Shipper) 50 | assert.Assert(t, ok) 51 | assert.Equal(t, created.GetName(), deleted.GetName()) 52 | }) 53 | }) 54 | 55 | t.Run("unauthorized", func(t *testing.T) { 56 | const ( 57 | member = "user:test@example.com" 58 | shipperID = "aaaa" 59 | shipper = "shippers/" + shipperID 60 | ) 61 | fx := ts.newTestFixture(t) 62 | deleted, err := fx.client.DeleteShipper( 63 | WithOutgoingMembers(ctx, member), 64 | &iamexamplev1.DeleteShipperRequest{ 65 | Name: shipper, 66 | }, 67 | ) 68 | assert.Equal(t, codes.PermissionDenied, status.Code(err), "unexpected status: %v", err) 69 | assert.Assert(t, deleted == nil) 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /iamexample/server_shippers_get.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/aip/resourcename" 7 | "go.einride.tech/aip/validation" 8 | "go.einride.tech/iam/iamexample/iamexampledb" 9 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | ) 13 | 14 | // GetShipper implements iamexamplev1.FreightServiceServer. 15 | func (s *Server) GetShipper( 16 | ctx context.Context, 17 | request *iamexamplev1.GetShipperRequest, 18 | ) (*iamexamplev1.Shipper, error) { 19 | var parsedRequest getShipperRequest 20 | if err := parsedRequest.parse(request); err != nil { 21 | return nil, err 22 | } 23 | return s.getShipper(ctx, &parsedRequest) 24 | } 25 | 26 | func (s *Server) getShipper( 27 | ctx context.Context, 28 | request *getShipperRequest, 29 | ) (*iamexamplev1.Shipper, error) { 30 | tx := s.Spanner.Single() 31 | defer tx.Close() 32 | row, err := iamexampledb.Query(tx).GetShippersRow(ctx, iamexampledb.GetShippersRowQuery{ 33 | Key: iamexampledb.ShippersKey{ 34 | ShipperId: request.shipperID, 35 | }, 36 | }) 37 | if err != nil { 38 | if code := status.Code(err); code == codes.NotFound { 39 | return nil, status.Errorf(code, "not found: %s", request.name) 40 | } 41 | return nil, s.handleStorageError(ctx, err) 42 | } 43 | msg, err := convertShipperRowToProto(row) 44 | if err != nil { 45 | s.errorHook(ctx, err) 46 | return nil, status.Error(codes.Internal, "internal data conversion error") 47 | } 48 | return msg, nil 49 | } 50 | 51 | type getShipperRequest struct { 52 | name string 53 | shipperID string 54 | } 55 | 56 | func (r *getShipperRequest) parse(request *iamexamplev1.GetShipperRequest) error { 57 | var v validation.MessageValidator 58 | // name = 1 59 | if request.GetName() == "" { 60 | v.AddFieldViolation("name", "required field") 61 | } else if err := resourcename.Sscan( 62 | request.GetName(), 63 | "shippers/{shipper}", 64 | &r.shipperID, 65 | ); err != nil { 66 | v.AddFieldError("name", err) 67 | } 68 | r.name = request.GetName() 69 | return v.Err() 70 | } 71 | -------------------------------------------------------------------------------- /iamexample/server_shippers_get_test.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func (ts *serverTestSuite) testGetShipper(t *testing.T) { 14 | t.Parallel() 15 | ctx := withTestDeadline(context.Background(), t) 16 | 17 | t.Run("authorized", func(t *testing.T) { 18 | t.Run("not found", func(t *testing.T) { 19 | const ( 20 | member = "user:test@example.com" 21 | shipper = "shippers/1234" 22 | ) 23 | fx := ts.newTestFixture(t) 24 | fx.iam.AddPolicyBinding(t, "/", "roles/freight.admin", member) 25 | got, err := fx.client.GetShipper( 26 | WithOutgoingMembers(ctx, member), 27 | &iamexamplev1.GetShipperRequest{ 28 | Name: shipper, 29 | }, 30 | ) 31 | assert.Equal(t, codes.NotFound, status.Code(err), "unexpected status: %v", err) 32 | assert.Assert(t, got == nil) 33 | }) 34 | }) 35 | 36 | t.Run("unauthorized", func(t *testing.T) { 37 | const ( 38 | member = "user:test@example.com" 39 | shipper = "shippers/1234" 40 | ) 41 | fx := ts.newTestFixture(t) 42 | got, err := fx.client.GetShipper( 43 | WithOutgoingMembers(ctx, member), 44 | &iamexamplev1.GetShipperRequest{ 45 | Name: shipper, 46 | }, 47 | ) 48 | assert.Equal(t, codes.PermissionDenied, status.Code(err), "unexpected status: %v", err) 49 | assert.Assert(t, got == nil) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /iamexample/server_shippers_list.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/aip/pagination" 7 | "go.einride.tech/aip/validation" 8 | "go.einride.tech/iam/iamexample/iamexampledb" 9 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 10 | ) 11 | 12 | // ListShippers implements iamexamplev1.FreightServiceServer. 13 | func (s *Server) ListShippers( 14 | ctx context.Context, 15 | request *iamexamplev1.ListShippersRequest, 16 | ) (*iamexamplev1.ListShippersResponse, error) { 17 | var parsedRequest listShippersRequest 18 | if err := parsedRequest.parse(request); err != nil { 19 | return nil, err 20 | } 21 | return s.listShippers(ctx, &parsedRequest) 22 | } 23 | 24 | func (s *Server) listShippers( 25 | ctx context.Context, 26 | request *listShippersRequest, 27 | ) (*iamexamplev1.ListShippersResponse, error) { 28 | response := iamexamplev1.ListShippersResponse{ 29 | Shippers: make([]*iamexamplev1.Shipper, 0, request.pageSize+1), 30 | } 31 | tx := s.Spanner.Single() 32 | defer tx.Close() 33 | if err := iamexampledb.Query(tx).ListShippersRows(ctx, iamexampledb.ListShippersRowsQuery{ 34 | Limit: request.pageSize + 1, 35 | Offset: request.pageToken.Offset, 36 | }).Do(func(row *iamexampledb.ShippersRow) error { 37 | msg, err := convertShipperRowToProto(row) 38 | if err != nil { 39 | return err 40 | } 41 | response.Shippers = append(response.Shippers, msg) 42 | return nil 43 | }); err != nil { 44 | return nil, s.handleStorageError(ctx, err) 45 | } 46 | if len(response.GetShippers()) > int(request.pageSize) { 47 | response.Shippers = response.GetShippers()[:request.pageSize] 48 | response.NextPageToken = request.nextPageToken() 49 | } 50 | return &response, nil 51 | } 52 | 53 | type listShippersRequest struct { 54 | protoRequest *iamexamplev1.ListShippersRequest 55 | pageSize int32 56 | pageToken pagination.PageToken 57 | } 58 | 59 | func (r *listShippersRequest) parse(request *iamexamplev1.ListShippersRequest) error { 60 | var v validation.MessageValidator 61 | const ( 62 | defaultPageSize = 100 63 | maxPageSize = 1_000 64 | ) 65 | switch { 66 | case request.GetPageSize() < 0: 67 | v.AddFieldViolation("page_size", "must be >= 0") 68 | case request.GetPageSize() == 0: 69 | r.pageSize = defaultPageSize 70 | case request.GetPageSize() > maxPageSize: 71 | r.pageSize = maxPageSize 72 | default: 73 | r.pageSize = request.GetPageSize() 74 | } 75 | if pageToken, err := pagination.ParsePageToken(request); err != nil { 76 | v.AddFieldError("page_token", err) 77 | } else { 78 | r.pageToken = pageToken 79 | } 80 | r.protoRequest = request 81 | return v.Err() 82 | } 83 | 84 | func (r *listShippersRequest) nextPageToken() string { 85 | return r.pageToken.Next(r.protoRequest).String() 86 | } 87 | -------------------------------------------------------------------------------- /iamexample/server_shippers_list_test.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 9 | "google.golang.org/grpc/codes" 10 | "google.golang.org/grpc/status" 11 | "google.golang.org/protobuf/testing/protocmp" 12 | "gotest.tools/v3/assert" 13 | ) 14 | 15 | func (ts *serverTestSuite) testListShippers(t *testing.T) { 16 | t.Parallel() 17 | ctx := withTestDeadline(context.Background(), t) 18 | 19 | t.Run("authorized", func(t *testing.T) { 20 | t.Run("ok", func(t *testing.T) { 21 | const member = "user:test@example.com" 22 | fx := ts.newTestFixture(t) 23 | fx.iam.AddPolicyBinding(t, "/", "roles/freight.admin", member) 24 | const count = 20 25 | expected := make([]*iamexamplev1.Shipper, 0, count) 26 | for i := 0; i < count; i++ { 27 | created, err := fx.client.CreateShipper( 28 | WithOutgoingMembers(ctx, member), 29 | &iamexamplev1.CreateShipperRequest{ 30 | Shipper: &iamexamplev1.Shipper{ 31 | DisplayName: fmt.Sprintf("Test Shipper %d", i), 32 | }, 33 | ShipperId: fmt.Sprintf("shipper%04d", i), 34 | }, 35 | ) 36 | assert.NilError(t, err) 37 | expected = append(expected, created) 38 | } 39 | actual := make([]*iamexamplev1.Shipper, 0, count) 40 | var pageToken string 41 | for { 42 | response, err := fx.client.ListShippers( 43 | WithOutgoingMembers(ctx, member), 44 | &iamexamplev1.ListShippersRequest{ 45 | PageSize: count / 6, 46 | PageToken: pageToken, 47 | }, 48 | ) 49 | assert.NilError(t, err) 50 | actual = append(actual, response.GetShippers()...) 51 | pageToken = response.GetNextPageToken() 52 | if pageToken == "" { 53 | break 54 | } 55 | } 56 | assert.DeepEqual(t, expected, actual, protocmp.Transform()) 57 | }) 58 | }) 59 | 60 | t.Run("unauthorized", func(t *testing.T) { 61 | const member = "user:test@example.com" 62 | fx := ts.newTestFixture(t) 63 | response, err := fx.client.ListShippers( 64 | WithOutgoingMembers(ctx, member), 65 | &iamexamplev1.ListShippersRequest{ 66 | PageSize: 10, 67 | }, 68 | ) 69 | assert.Equal(t, codes.PermissionDenied, status.Code(err), "unexpected status: %v", err) 70 | assert.Assert(t, response == nil) 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /iamexample/server_shippers_update_test.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func (ts *serverTestSuite) testUpdateShipper(t *testing.T) { 14 | t.Parallel() 15 | ctx := withTestDeadline(context.Background(), t) 16 | 17 | t.Run("authorized", func(t *testing.T) { 18 | t.Run("ok", func(t *testing.T) { 19 | const ( 20 | member = "user:test@example.com" 21 | shipperID = "aaaa" 22 | shipper = "shippers/" + shipperID 23 | ) 24 | fx := ts.newTestFixture(t) 25 | fx.iam.AddPolicyBinding(t, "/", "roles/freight.admin", member) 26 | input := &iamexamplev1.Shipper{ 27 | DisplayName: "Test Shipper", 28 | } 29 | created, err := fx.client.CreateShipper( 30 | WithOutgoingMembers(ctx, member), 31 | &iamexamplev1.CreateShipperRequest{ 32 | Shipper: input, 33 | ShipperId: shipperID, 34 | }, 35 | ) 36 | assert.NilError(t, err) 37 | assert.Equal(t, input.GetDisplayName(), created.GetDisplayName()) 38 | update := &iamexamplev1.Shipper{ 39 | Name: shipper, 40 | DisplayName: "Updated Test Shipper", 41 | } 42 | updated, err := fx.client.UpdateShipper( 43 | WithOutgoingMembers(ctx, member), 44 | &iamexamplev1.UpdateShipperRequest{ 45 | Shipper: update, 46 | }, 47 | ) 48 | assert.NilError(t, err) 49 | assert.Equal(t, update.GetDisplayName(), updated.GetDisplayName()) 50 | }) 51 | }) 52 | 53 | t.Run("unauthorized", func(t *testing.T) { 54 | const ( 55 | member = "user:test@example.com" 56 | shipperID = "aaaa" 57 | shipper = "shippers/" + shipperID 58 | ) 59 | fx := ts.newTestFixture(t) 60 | update := &iamexamplev1.Shipper{ 61 | Name: shipper, 62 | DisplayName: "Updated Test Shipper", 63 | } 64 | updated, err := fx.client.UpdateShipper( 65 | WithOutgoingMembers(ctx, member), 66 | &iamexamplev1.UpdateShipperRequest{ 67 | Shipper: update, 68 | }, 69 | ) 70 | assert.Equal(t, codes.PermissionDenied, status.Code(err), "unexpected status: %v", err) 71 | assert.Assert(t, updated == nil) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /iamexample/server_sites_batchget.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go.einride.tech/aip/resourcename" 8 | "go.einride.tech/aip/validation" 9 | "go.einride.tech/iam/iamexample/iamexampledb" 10 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/status" 13 | ) 14 | 15 | // BatchGetSites implements iamexamplev1.FreightServiceServer. 16 | func (s *Server) BatchGetSites( 17 | ctx context.Context, 18 | request *iamexamplev1.BatchGetSitesRequest, 19 | ) (*iamexamplev1.BatchGetSitesResponse, error) { 20 | var parsedRequest batchGetSitesRequest 21 | if err := parsedRequest.parse(request); err != nil { 22 | return nil, err 23 | } 24 | return s.batchGetSites(ctx, &parsedRequest) 25 | } 26 | 27 | func (s *Server) batchGetSites( 28 | ctx context.Context, 29 | request *batchGetSitesRequest, 30 | ) (*iamexamplev1.BatchGetSitesResponse, error) { 31 | tx := s.Spanner.Single() 32 | defer tx.Close() 33 | rows, err := iamexampledb.Query(tx).BatchGetSitesRows(ctx, iamexampledb.BatchGetSitesRowsQuery{ 34 | Keys: request.keys, 35 | }) 36 | if err != nil { 37 | return nil, s.handleStorageError(ctx, err) 38 | } 39 | response := iamexamplev1.BatchGetSitesResponse{ 40 | Sites: make([]*iamexamplev1.Site, 0, len(request.keys)), 41 | } 42 | for i, key := range request.keys { 43 | row, ok := rows[key] 44 | if !ok { 45 | return nil, status.Errorf(codes.NotFound, "not found: %s", request.names[i]) 46 | } 47 | msg, err := convertSiteRowToProto(row) 48 | if err != nil { 49 | s.errorHook(ctx, err) 50 | return nil, status.Errorf(codes.Internal, "internal data conversion error") 51 | } 52 | response.Sites = append(response.Sites, msg) 53 | } 54 | return &response, nil 55 | } 56 | 57 | type batchGetSitesRequest struct { 58 | parent iamexampledb.ShippersKey 59 | names []string 60 | keys []iamexampledb.SitesKey 61 | } 62 | 63 | func (r *batchGetSitesRequest) parse(request *iamexamplev1.BatchGetSitesRequest) error { 64 | var v validation.MessageValidator 65 | // parent = 1 66 | if request.GetParent() != "" { 67 | if err := resourcename.Sscan(request.GetParent(), "shippers/{shipper}", &r.parent.ShipperId); err != nil { 68 | v.AddFieldError("parent", err) 69 | } 70 | } 71 | // names = 2 72 | if len(request.GetNames()) == 0 { 73 | v.AddFieldViolation("names", "required field") 74 | } 75 | r.keys = make([]iamexampledb.SitesKey, 0, len(request.GetNames())) 76 | r.names = request.GetNames() 77 | for i, name := range request.GetNames() { 78 | if resourcename.ContainsWildcard(name) { 79 | v.AddFieldViolation(fmt.Sprintf("names[%d]", i), "wildcard not supported") 80 | } 81 | if request.GetParent() != "" && !resourcename.HasParent(name, request.GetParent()) { 82 | v.AddFieldViolation(fmt.Sprintf("names[%d]", i), "must be a child of parent %s", request.GetParent()) 83 | } 84 | var key iamexampledb.SitesKey 85 | if err := resourcename.Sscan( 86 | name, 87 | "shippers/{shipper}/sites/{site}", 88 | &key.ShipperId, 89 | &key.SiteId, 90 | ); err != nil { 91 | v.AddFieldError(fmt.Sprintf("names[%d]", i), err) 92 | continue 93 | } 94 | r.keys = append(r.keys, key) 95 | } 96 | return v.Err() 97 | } 98 | -------------------------------------------------------------------------------- /iamexample/server_sites_create_test.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func (ts *serverTestSuite) testCreateSite(t *testing.T) { 14 | t.Parallel() 15 | ctx := withTestDeadline(context.Background(), t) 16 | 17 | t.Run("authorized", func(t *testing.T) { 18 | t.Run("ok", func(t *testing.T) { 19 | const ( 20 | member = "user:test@example.com" 21 | parent = "shippers/aaaa" 22 | siteID = "bbbb" 23 | ) 24 | fx := ts.newTestFixture(t) 25 | fx.createShipper(t, parent) 26 | fx.iam.AddPolicyBinding(t, "/", "roles/freight.admin", member) 27 | input := &iamexamplev1.Site{ 28 | DisplayName: "Test Site", 29 | } 30 | got, err := fx.client.CreateSite( 31 | WithOutgoingMembers(ctx, member), 32 | &iamexamplev1.CreateSiteRequest{ 33 | Parent: parent, 34 | Site: input, 35 | SiteId: siteID, 36 | }, 37 | ) 38 | assert.NilError(t, err) 39 | assert.Equal(t, input.GetDisplayName(), got.GetDisplayName()) 40 | }) 41 | }) 42 | 43 | t.Run("unauthorized", func(t *testing.T) { 44 | const ( 45 | member = "user:test@example.com" 46 | parent = "shippers/aaaa" 47 | siteID = "bbbb" 48 | ) 49 | fx := ts.newTestFixture(t) 50 | got, err := fx.client.CreateSite( 51 | WithOutgoingMembers(ctx, member), 52 | &iamexamplev1.CreateSiteRequest{ 53 | Parent: parent, 54 | Site: &iamexamplev1.Site{ 55 | DisplayName: "Test Site", 56 | }, 57 | SiteId: siteID, 58 | }, 59 | ) 60 | assert.Equal(t, codes.PermissionDenied, status.Code(err), "unexpected status: %v", err) 61 | assert.Assert(t, got == nil) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /iamexample/server_sites_delete.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/spanner" 7 | "go.einride.tech/aip/resourcename" 8 | "go.einride.tech/aip/validation" 9 | "go.einride.tech/iam/iamexample/iamexampledb" 10 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/status" 13 | "google.golang.org/protobuf/types/known/timestamppb" 14 | ) 15 | 16 | // DeleteSite implements iamexamplev1.FreightServiceServer. 17 | func (s *Server) DeleteSite( 18 | ctx context.Context, 19 | request *iamexamplev1.DeleteSiteRequest, 20 | ) (*iamexamplev1.Site, error) { 21 | var parsedRequest deleteSiteRequest 22 | if err := parsedRequest.parse(request); err != nil { 23 | return nil, err 24 | } 25 | return s.deleteSite(ctx, &parsedRequest) 26 | } 27 | 28 | func (s *Server) deleteSite( 29 | ctx context.Context, 30 | request *deleteSiteRequest, 31 | ) (*iamexamplev1.Site, error) { 32 | var result *iamexamplev1.Site 33 | commitTime, err := s.Spanner.ReadWriteTransaction( 34 | ctx, 35 | func(ctx context.Context, tx *spanner.ReadWriteTransaction) error { 36 | row, err := iamexampledb.Query(tx).GetSitesRow(ctx, iamexampledb.GetSitesRowQuery{ 37 | Key: iamexampledb.SitesKey{ 38 | ShipperId: request.shipperID, 39 | SiteId: request.siteID, 40 | }, 41 | }) 42 | if err != nil { 43 | return err 44 | } 45 | if row.DeleteTime.Valid { 46 | return status.Errorf(codes.FailedPrecondition, "site already deleted: %s", request.name) 47 | } 48 | row.UpdateTime = spanner.CommitTimestamp 49 | row.DeleteTime = spanner.NullTime{ 50 | Time: spanner.CommitTimestamp, 51 | Valid: true, 52 | } 53 | result, err = convertSiteRowToProto(row) 54 | if err != nil { 55 | return err 56 | } 57 | return tx.BufferWrite([]*spanner.Mutation{spanner.Update(row.Mutate())}) 58 | }, 59 | ) 60 | if err != nil { 61 | switch code := status.Code(err); code { 62 | case codes.FailedPrecondition: 63 | return nil, err 64 | case codes.NotFound: 65 | return nil, status.Errorf(code, "no such site: %s", request.name) 66 | default: 67 | return nil, s.handleStorageError(ctx, err) 68 | } 69 | } 70 | result.UpdateTime = timestamppb.New(commitTime) 71 | result.DeleteTime = result.GetUpdateTime() 72 | return result, nil 73 | } 74 | 75 | type deleteSiteRequest struct { 76 | name string 77 | shipperID string 78 | siteID string 79 | } 80 | 81 | func (r *deleteSiteRequest) parse(request *iamexamplev1.DeleteSiteRequest) error { 82 | var v validation.MessageValidator 83 | // name = 1 84 | if request.GetName() == "" { 85 | v.AddFieldViolation("name", "required field") 86 | } else if err := resourcename.Sscan( 87 | request.GetName(), 88 | "shippers/{shipper}/sites/{site}", 89 | &r.shipperID, 90 | &r.siteID, 91 | ); err != nil { 92 | v.AddFieldError("name", err) 93 | } 94 | r.name = request.GetName() 95 | return v.Err() 96 | } 97 | -------------------------------------------------------------------------------- /iamexample/server_sites_delete_test.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 9 | "google.golang.org/grpc/codes" 10 | "google.golang.org/grpc/status" 11 | "gotest.tools/v3/assert" 12 | ) 13 | 14 | func (ts *serverTestSuite) testDeleteSite(t *testing.T) { 15 | t.Parallel() 16 | ctx := withTestDeadline(context.Background(), t) 17 | 18 | t.Run("authorized", func(t *testing.T) { 19 | t.Run("ok", func(t *testing.T) { 20 | const ( 21 | member = "user:test@example.com" 22 | parent = "shippers/aaaa" 23 | siteID = "bbbb" 24 | ) 25 | fx := ts.newTestFixture(t) 26 | fx.createShipper(t, parent) 27 | fx.iam.AddPolicyBinding(t, "/", "roles/freight.admin", member) 28 | input := &iamexamplev1.Site{ 29 | DisplayName: "Test Site", 30 | } 31 | created, err := fx.client.CreateSite( 32 | WithOutgoingMembers(ctx, member), 33 | &iamexamplev1.CreateSiteRequest{ 34 | Parent: parent, 35 | Site: input, 36 | SiteId: siteID, 37 | }, 38 | ) 39 | assert.NilError(t, err) 40 | assert.Equal(t, input.GetDisplayName(), created.GetDisplayName()) 41 | deleted, err := fx.client.DeleteSite( 42 | WithOutgoingMembers(ctx, member), 43 | &iamexamplev1.DeleteSiteRequest{ 44 | Name: created.GetName(), 45 | }, 46 | ) 47 | assert.NilError(t, err) 48 | assert.Equal(t, created.GetName(), deleted.GetName()) 49 | assert.Assert(t, time.Since(deleted.GetDeleteTime().AsTime()) < time.Second) 50 | }) 51 | }) 52 | 53 | t.Run("unauthorized", func(t *testing.T) { 54 | const ( 55 | member = "user:test@example.com" 56 | site = "shippers/aaaa/sites/bbbb" 57 | ) 58 | fx := ts.newTestFixture(t) 59 | deleted, err := fx.client.DeleteSite( 60 | WithOutgoingMembers(ctx, member), 61 | &iamexamplev1.DeleteSiteRequest{ 62 | Name: site, 63 | }, 64 | ) 65 | assert.Equal(t, codes.PermissionDenied, status.Code(err), "unexpected status: %v", err) 66 | assert.Assert(t, deleted == nil) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /iamexample/server_sites_get.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/aip/resourcename" 7 | "go.einride.tech/aip/validation" 8 | "go.einride.tech/iam/iamexample/iamexampledb" 9 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | ) 13 | 14 | // GetSite implements iamexamplev1.FreightServiceServer. 15 | func (s *Server) GetSite( 16 | ctx context.Context, 17 | request *iamexamplev1.GetSiteRequest, 18 | ) (*iamexamplev1.Site, error) { 19 | var parsedRequest getSiteRequest 20 | if err := parsedRequest.parse(request); err != nil { 21 | return nil, err 22 | } 23 | return s.getSite(ctx, &parsedRequest) 24 | } 25 | 26 | func (s *Server) getSite( 27 | ctx context.Context, 28 | request *getSiteRequest, 29 | ) (*iamexamplev1.Site, error) { 30 | tx := s.Spanner.Single() 31 | defer tx.Close() 32 | row, err := iamexampledb.Query(tx).GetSitesRow(ctx, iamexampledb.GetSitesRowQuery{ 33 | Key: iamexampledb.SitesKey{ 34 | ShipperId: request.shipperID, 35 | SiteId: request.siteID, 36 | }, 37 | }) 38 | if err != nil { 39 | if code := status.Code(err); code == codes.NotFound { 40 | return nil, status.Errorf(code, "not found: %s", request.name) 41 | } 42 | return nil, s.handleStorageError(ctx, err) 43 | } 44 | msg, err := convertSiteRowToProto(row) 45 | if err != nil { 46 | s.errorHook(ctx, err) 47 | return nil, status.Error(codes.Internal, "internal data conversion error") 48 | } 49 | return msg, nil 50 | } 51 | 52 | type getSiteRequest struct { 53 | name string 54 | shipperID string 55 | siteID string 56 | } 57 | 58 | func (r *getSiteRequest) parse(request *iamexamplev1.GetSiteRequest) error { 59 | var v validation.MessageValidator 60 | // name = 1 61 | if request.GetName() == "" { 62 | v.AddFieldViolation("name", "required field") 63 | } else if err := resourcename.Sscan( 64 | request.GetName(), 65 | "shippers/{shipper}/sites/{site}", 66 | &r.shipperID, 67 | &r.siteID, 68 | ); err != nil { 69 | v.AddFieldError("name", err) 70 | } 71 | r.name = request.GetName() 72 | return v.Err() 73 | } 74 | -------------------------------------------------------------------------------- /iamexample/server_sites_get_test.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func (ts *serverTestSuite) testGetSite(t *testing.T) { 14 | t.Parallel() 15 | ctx := withTestDeadline(context.Background(), t) 16 | 17 | t.Run("authorized", func(t *testing.T) { 18 | t.Run("not found", func(t *testing.T) { 19 | const ( 20 | member = "user:test@example.com" 21 | site = "shippers/1234/sites/5678" 22 | ) 23 | fx := ts.newTestFixture(t) 24 | fx.iam.AddPolicyBinding(t, "/", "roles/freight.admin", member) 25 | got, err := fx.client.GetSite( 26 | WithOutgoingMembers(ctx, member), 27 | &iamexamplev1.GetSiteRequest{ 28 | Name: site, 29 | }, 30 | ) 31 | assert.Equal(t, codes.NotFound, status.Code(err), "unexpected status: %v", err) 32 | assert.Assert(t, got == nil) 33 | }) 34 | }) 35 | 36 | t.Run("unauthorized", func(t *testing.T) { 37 | const ( 38 | member = "user:test@example.com" 39 | site = "shippers/1234/sites/5678" 40 | ) 41 | fx := ts.newTestFixture(t) 42 | got, err := fx.client.GetSite( 43 | WithOutgoingMembers(ctx, member), 44 | &iamexamplev1.GetSiteRequest{ 45 | Name: site, 46 | }, 47 | ) 48 | assert.Equal(t, codes.PermissionDenied, status.Code(err), "unexpected status: %v", err) 49 | assert.Assert(t, got == nil) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /iamexample/server_sites_list.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/aip/pagination" 7 | "go.einride.tech/aip/resourcename" 8 | "go.einride.tech/aip/validation" 9 | "go.einride.tech/iam/iamexample/iamexampledb" 10 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 11 | ) 12 | 13 | // ListSites implements iamexamplev1.FreightServiceServer. 14 | func (s *Server) ListSites( 15 | ctx context.Context, 16 | request *iamexamplev1.ListSitesRequest, 17 | ) (*iamexamplev1.ListSitesResponse, error) { 18 | var parsedRequest listSitesRequest 19 | if err := parsedRequest.parse(request); err != nil { 20 | return nil, err 21 | } 22 | return s.listSites(ctx, &parsedRequest) 23 | } 24 | 25 | func (s *Server) listSites( 26 | ctx context.Context, 27 | request *listSitesRequest, 28 | ) (*iamexamplev1.ListSitesResponse, error) { 29 | response := iamexamplev1.ListSitesResponse{ 30 | Sites: make([]*iamexamplev1.Site, 0, request.pageSize+1), 31 | } 32 | tx := s.Spanner.Single() 33 | defer tx.Close() 34 | if err := iamexampledb.Query(tx).ListSitesRows(ctx, iamexampledb.ListSitesRowsQuery{ 35 | Where: iamexampledb.ShippersKey{ShipperId: request.shipperID}.BoolExpr(), 36 | Limit: request.pageSize + 1, 37 | Offset: request.pageToken.Offset, 38 | }).Do(func(row *iamexampledb.SitesRow) error { 39 | msg, err := convertSiteRowToProto(row) 40 | if err != nil { 41 | return err 42 | } 43 | response.Sites = append(response.Sites, msg) 44 | return nil 45 | }); err != nil { 46 | return nil, s.handleStorageError(ctx, err) 47 | } 48 | if len(response.GetSites()) > int(request.pageSize) { 49 | response.Sites = response.GetSites()[:request.pageSize] 50 | response.NextPageToken = request.nextPageToken() 51 | } 52 | return &response, nil 53 | } 54 | 55 | type listSitesRequest struct { 56 | protoRequest *iamexamplev1.ListSitesRequest 57 | shipperID string 58 | pageSize int32 59 | pageToken pagination.PageToken 60 | } 61 | 62 | func (r *listSitesRequest) parse(request *iamexamplev1.ListSitesRequest) error { 63 | var v validation.MessageValidator 64 | const ( 65 | defaultPageSize = 100 66 | maxPageSize = 1_000 67 | ) 68 | switch { 69 | case request.GetPageSize() < 0: 70 | v.AddFieldViolation("page_size", "must be >= 0") 71 | case request.GetPageSize() == 0: 72 | r.pageSize = defaultPageSize 73 | case request.GetPageSize() > maxPageSize: 74 | r.pageSize = maxPageSize 75 | default: 76 | r.pageSize = request.GetPageSize() 77 | } 78 | if request.GetParent() == "" { 79 | v.AddFieldViolation("parent", "missing required field") 80 | } else if resourcename.ContainsWildcard(request.GetParent()) { 81 | v.AddFieldViolation("parent", "wildcard not allowed") 82 | } else if err := resourcename.Sscan(request.GetParent(), "shippers/{shipper}", &r.shipperID); err != nil { 83 | v.AddFieldError("parent", err) 84 | } 85 | if pageToken, err := pagination.ParsePageToken(request); err != nil { 86 | v.AddFieldError("page_token", err) 87 | } else { 88 | r.pageToken = pageToken 89 | } 90 | r.protoRequest = request 91 | return v.Err() 92 | } 93 | 94 | func (r *listSitesRequest) nextPageToken() string { 95 | return r.pageToken.Next(r.protoRequest).String() 96 | } 97 | -------------------------------------------------------------------------------- /iamexample/server_sites_list_test.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 9 | "google.golang.org/grpc/codes" 10 | "google.golang.org/grpc/status" 11 | "google.golang.org/protobuf/testing/protocmp" 12 | "gotest.tools/v3/assert" 13 | ) 14 | 15 | func (ts *serverTestSuite) testListSites(t *testing.T) { 16 | t.Parallel() 17 | ctx := withTestDeadline(context.Background(), t) 18 | 19 | t.Run("authorized", func(t *testing.T) { 20 | t.Run("ok", func(t *testing.T) { 21 | const ( 22 | member = "user:test@example.com" 23 | parent = "shippers/aaaa" 24 | count = 20 25 | ) 26 | fx := ts.newTestFixture(t) 27 | fx.createShipper(t, parent) 28 | fx.iam.AddPolicyBinding(t, "/", "roles/freight.admin", member) 29 | expected := make([]*iamexamplev1.Site, 0, count) 30 | for i := 0; i < count; i++ { 31 | created, err := fx.client.CreateSite( 32 | WithOutgoingMembers(ctx, member), 33 | &iamexamplev1.CreateSiteRequest{ 34 | Parent: parent, 35 | Site: &iamexamplev1.Site{ 36 | DisplayName: fmt.Sprintf("Test Site %d", i), 37 | }, 38 | SiteId: fmt.Sprintf("site%04d", i), 39 | }, 40 | ) 41 | assert.NilError(t, err) 42 | expected = append(expected, created) 43 | } 44 | actual := make([]*iamexamplev1.Site, 0, count) 45 | var pageToken string 46 | for { 47 | response, err := fx.client.ListSites( 48 | WithOutgoingMembers(ctx, member), 49 | &iamexamplev1.ListSitesRequest{ 50 | Parent: parent, 51 | PageSize: count / 6, 52 | PageToken: pageToken, 53 | }, 54 | ) 55 | assert.NilError(t, err) 56 | actual = append(actual, response.GetSites()...) 57 | pageToken = response.GetNextPageToken() 58 | if pageToken == "" { 59 | break 60 | } 61 | } 62 | assert.DeepEqual(t, expected, actual, protocmp.Transform()) 63 | }) 64 | }) 65 | 66 | t.Run("unauthorized", func(t *testing.T) { 67 | const member = "user:test@example.com" 68 | fx := ts.newTestFixture(t) 69 | response, err := fx.client.ListSites( 70 | WithOutgoingMembers(ctx, member), 71 | &iamexamplev1.ListSitesRequest{ 72 | PageSize: 10, 73 | }, 74 | ) 75 | assert.Equal(t, codes.PermissionDenied, status.Code(err), "unexpected status: %v", err) 76 | assert.Assert(t, response == nil) 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /iamexample/server_sites_search.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | 6 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 7 | "google.golang.org/grpc/codes" 8 | "google.golang.org/grpc/status" 9 | ) 10 | 11 | func (s *Server) SearchSites( 12 | context.Context, 13 | *iamexamplev1.SearchSitesRequest, 14 | ) (*iamexamplev1.SearchSitesResponse, error) { 15 | return nil, status.Error(codes.Unimplemented, "TODO: implement me") 16 | } 17 | -------------------------------------------------------------------------------- /iamexample/server_sites_update_test.go: -------------------------------------------------------------------------------- 1 | package iamexample 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | iamexamplev1 "go.einride.tech/iam/proto/gen/einride/iam/example/v1" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func (ts *serverTestSuite) testUpdateSite(t *testing.T) { 14 | t.Parallel() 15 | ctx := withTestDeadline(context.Background(), t) 16 | 17 | t.Run("authorized", func(t *testing.T) { 18 | t.Run("ok", func(t *testing.T) { 19 | const ( 20 | member = "user:test@example.com" 21 | parent = "shippers/aaaa" 22 | siteID = "bbbb" 23 | ) 24 | fx := ts.newTestFixture(t) 25 | fx.createShipper(t, parent) 26 | fx.iam.AddPolicyBinding(t, "/", "roles/freight.admin", member) 27 | input := &iamexamplev1.Site{ 28 | DisplayName: "Test Site", 29 | } 30 | got, err := fx.client.CreateSite( 31 | WithOutgoingMembers(ctx, member), 32 | &iamexamplev1.CreateSiteRequest{ 33 | Parent: parent, 34 | Site: input, 35 | SiteId: siteID, 36 | }, 37 | ) 38 | assert.NilError(t, err) 39 | assert.Equal(t, input.GetDisplayName(), got.GetDisplayName()) 40 | update := &iamexamplev1.Site{ 41 | Name: got.GetName(), 42 | DisplayName: "Updated Test Site", 43 | } 44 | updated, err := fx.client.UpdateSite( 45 | WithOutgoingMembers(ctx, member), 46 | &iamexamplev1.UpdateSiteRequest{ 47 | Site: update, 48 | }, 49 | ) 50 | assert.NilError(t, err) 51 | assert.Equal(t, update.GetDisplayName(), updated.GetDisplayName()) 52 | }) 53 | }) 54 | 55 | t.Run("unauthorized", func(t *testing.T) { 56 | const ( 57 | member = "user:test@example.com" 58 | parent = "shippers/aaaa" 59 | siteID = "bbbb" 60 | ) 61 | fx := ts.newTestFixture(t) 62 | got, err := fx.client.CreateSite( 63 | WithOutgoingMembers(ctx, member), 64 | &iamexamplev1.CreateSiteRequest{ 65 | Parent: parent, 66 | Site: &iamexamplev1.Site{ 67 | DisplayName: "Test Site", 68 | }, 69 | SiteId: siteID, 70 | }, 71 | ) 72 | assert.Equal(t, codes.PermissionDenied, status.Code(err), "unexpected status: %v", err) 73 | assert.Assert(t, got == nil) 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /iamfirebase/doc.go: -------------------------------------------------------------------------------- 1 | // Package iamfirebase provides Firebase-specific IAM primitives. 2 | // See: https://firebase.google.com/docs/rules/rules-and-auth 3 | package iamfirebase 4 | -------------------------------------------------------------------------------- /iamfirebase/idtoken.go: -------------------------------------------------------------------------------- 1 | package iamfirebase 2 | 3 | import ( 4 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 5 | ) 6 | 7 | // Issuer is the issuer of Firebase ID tokens. 8 | const Issuer = "https://securetoken.google.com" 9 | 10 | // IsFirebaseIdentityToken returns true if the JWT payload is from a Firebase ID token. 11 | // See: https://firebase.google.com/docs/rules/rules-and-auth 12 | func IsFirebaseIdentityToken(token *iamv1.IdentityToken) bool { 13 | return token.GetIss() == Issuer+"/"+token.GetAud() 14 | } 15 | -------------------------------------------------------------------------------- /iamfirebase/payload.go: -------------------------------------------------------------------------------- 1 | package iamfirebase 2 | 3 | import ( 4 | "strings" 5 | 6 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 7 | ) 8 | 9 | // ProjectID returns the token payload's Firebase project ID. 10 | func ProjectID(token *iamv1.IdentityToken) string { 11 | return strings.TrimPrefix(token.GetIss(), Issuer+"/") 12 | } 13 | -------------------------------------------------------------------------------- /iamgoogle/doc.go: -------------------------------------------------------------------------------- 1 | // Package iamgoogle provides Google-specific IAM primitives. 2 | // See: https://developers.google.com/identity/protocols/oauth2/openid-connect 3 | package iamgoogle 4 | -------------------------------------------------------------------------------- /iamgoogle/idtoken.go: -------------------------------------------------------------------------------- 1 | package iamgoogle 2 | 3 | import ( 4 | "strings" 5 | 6 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 7 | ) 8 | 9 | // IsGoogleIdentityToken returns true if the JWT payload is from a Google ID token. 10 | // See: https://developers.google.com/identity/protocols/oauth2/openid-connect 11 | func IsGoogleIdentityToken(token *iamv1.IdentityToken) bool { 12 | return strings.TrimPrefix(token.GetIss(), "https://") == "accounts.google.com" 13 | } 14 | -------------------------------------------------------------------------------- /iamgoogle/idtoken_test.go: -------------------------------------------------------------------------------- 1 | package iamgoogle 2 | 3 | import ( 4 | "testing" 5 | 6 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func TestIsGoogleIdentityToken(t *testing.T) { 11 | assert.Assert(t, IsGoogleIdentityToken(&iamv1.IdentityToken{Iss: "https://accounts.google.com"})) 12 | assert.Assert(t, IsGoogleIdentityToken(&iamv1.IdentityToken{Iss: "accounts.google.com"})) 13 | assert.Assert(t, !IsGoogleIdentityToken(&iamv1.IdentityToken{Iss: "foo.com"})) 14 | assert.Assert(t, !IsGoogleIdentityToken(&iamv1.IdentityToken{Iss: "http://accounts.google.com"})) 15 | } 16 | -------------------------------------------------------------------------------- /iamgoogle/payload.go: -------------------------------------------------------------------------------- 1 | package iamgoogle 2 | 3 | import ( 4 | "strings" 5 | 6 | "google.golang.org/api/idtoken" 7 | ) 8 | 9 | // IsEmailVerified returns true if the payload has the `email_verified` claim. 10 | func IsEmailVerified(payload *idtoken.Payload) bool { 11 | result, ok := payload.Claims["email_verified"].(bool) 12 | return result && ok 13 | } 14 | 15 | // Email returns the payload's `email` claim. 16 | func Email(payload *idtoken.Payload) (string, bool) { 17 | result, ok := payload.Claims["email"].(string) 18 | return result, ok 19 | } 20 | 21 | // HostedDomain returns the payload's `hd` claim. 22 | func HostedDomain(payload *idtoken.Payload) (string, bool) { 23 | result, ok := payload.Claims["hd"].(string) 24 | return result, ok 25 | } 26 | 27 | // IsGoogleCloudServiceAccountEmail returns true if the payload has a verified email belonging to a Google Cloud 28 | // service account. 29 | func IsGoogleCloudServiceAccountEmail(payload *idtoken.Payload) bool { 30 | if !IsEmailVerified(payload) { 31 | return false 32 | } 33 | email, ok := Email(payload) 34 | return ok && strings.HasSuffix(email, ".gserviceaccount.com") 35 | } 36 | -------------------------------------------------------------------------------- /iamgoogle/payload_test.go: -------------------------------------------------------------------------------- 1 | package iamgoogle 2 | 3 | import ( 4 | "testing" 5 | 6 | "google.golang.org/api/idtoken" 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func TestContextMemberResolver_IsGoogleServiceAccount(t *testing.T) { 11 | tests := []struct { 12 | email string 13 | valid bool 14 | }{ 15 | { 16 | email: "default-compute@developer.gserviceaccount.com", 17 | valid: true, 18 | }, 19 | { 20 | email: "user-managed@einride.iam.gserviceaccount.com", 21 | valid: true, 22 | }, 23 | { 24 | email: "google-managed@@cloudservices.gserviceaccount.com", 25 | valid: true, 26 | }, 27 | { 28 | email: "missing-dot@gserviceaccount.com", 29 | valid: false, 30 | }, 31 | { 32 | email: "any@example.com", 33 | valid: false, 34 | }, 35 | } 36 | 37 | for _, tt := range tests { 38 | claims := map[string]interface{}{ 39 | "email_verified": true, 40 | "email": tt.email, 41 | } 42 | 43 | assert.Equal(t, IsGoogleCloudServiceAccountEmail(&idtoken.Payload{ 44 | Claims: claims, 45 | }), tt.valid) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /iamgoogle/signature.go: -------------------------------------------------------------------------------- 1 | package iamgoogle 2 | 3 | import ( 4 | "strings" 5 | 6 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 7 | ) 8 | 9 | // IsSignatureRemoved checks if the ID token's signature has been removed by Google. 10 | // See: https://cloud.google.com/run/docs/troubleshooting#signature-removed 11 | func IsSignatureRemoved(identityToken *iamv1.IdentityToken) bool { 12 | return strings.HasSuffix(identityToken.GetRaw(), ".SIGNATURE_REMOVED_BY_GOOGLE") 13 | } 14 | -------------------------------------------------------------------------------- /iammember/doc.go: -------------------------------------------------------------------------------- 1 | // Package iammember provides primitives for IAM member identifiers. 2 | package iammember 3 | -------------------------------------------------------------------------------- /iammember/new.go: -------------------------------------------------------------------------------- 1 | package iammember 2 | 3 | // New returns a new IAM member with the provided kind and value. 4 | func New(kind, value string) string { 5 | if kind == "" || value == "" { 6 | return "" 7 | } 8 | return kind + ":" + value 9 | } 10 | -------------------------------------------------------------------------------- /iammember/new_test.go: -------------------------------------------------------------------------------- 1 | package iammember 2 | 3 | import ( 4 | "testing" 5 | 6 | "gotest.tools/v3/assert" 7 | ) 8 | 9 | func TestNew(t *testing.T) { 10 | assert.Equal(t, "", New("", "")) 11 | assert.Equal(t, "", New("a", "")) 12 | assert.Equal(t, "", New("", "b")) 13 | assert.Equal(t, "a:b", New("a", "b")) 14 | } 15 | -------------------------------------------------------------------------------- /iammember/parse.go: -------------------------------------------------------------------------------- 1 | package iammember 2 | 3 | import "strings" 4 | 5 | // Parse a member to extract the kind and value. 6 | func Parse(member string) (kind, value string, ok bool) { 7 | indexOfColon := strings.IndexByte(member, ':') 8 | if indexOfColon == -1 || indexOfColon == 0 || indexOfColon == len(member)-1 { 9 | return "", "", false 10 | } 11 | return member[:indexOfColon], member[indexOfColon+1:], true 12 | } 13 | -------------------------------------------------------------------------------- /iammember/parse_test.go: -------------------------------------------------------------------------------- 1 | package iammember 2 | 3 | import ( 4 | "testing" 5 | 6 | "gotest.tools/v3/assert" 7 | ) 8 | 9 | func TestParse(t *testing.T) { 10 | t.Run("ok", func(t *testing.T) { 11 | kind, value, ok := Parse("a:b") 12 | assert.Assert(t, ok) 13 | assert.Equal(t, "a", kind) 14 | assert.Equal(t, "b", value) 15 | }) 16 | 17 | t.Run("empty", func(t *testing.T) { 18 | kind, value, ok := Parse("") 19 | assert.Assert(t, !ok) 20 | assert.Equal(t, "", kind) 21 | assert.Equal(t, "", value) 22 | }) 23 | 24 | t.Run("missing kind", func(t *testing.T) { 25 | kind, value, ok := Parse(":b") 26 | assert.Assert(t, !ok) 27 | assert.Equal(t, "", kind) 28 | assert.Equal(t, "", value) 29 | }) 30 | 31 | t.Run("missing value", func(t *testing.T) { 32 | kind, value, ok := Parse("a:") 33 | assert.Assert(t, !ok) 34 | assert.Equal(t, "", kind) 35 | assert.Equal(t, "", value) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /iammember/validate.go: -------------------------------------------------------------------------------- 1 | package iammember 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Validate checks that an IAM member is valid. 9 | func Validate(member string) error { 10 | if member == "" { 11 | return fmt.Errorf("member is empty") 12 | } 13 | indexOfColon := strings.IndexByte(member, ':') 14 | if indexOfColon == -1 || indexOfColon == 0 || indexOfColon == len(member)-1 { 15 | return fmt.Errorf("member '%s' has invalid format", member) 16 | } 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /iammixin/doc.go: -------------------------------------------------------------------------------- 1 | // Package iammixin provides utilities for registering gRPC servers with IAM mixins. 2 | package iammixin 3 | -------------------------------------------------------------------------------- /iammixin/register.go: -------------------------------------------------------------------------------- 1 | package iammixin 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 7 | "cloud.google.com/go/iam/apiv1/iampb" 8 | "google.golang.org/grpc" 9 | ) 10 | 11 | // Server is an interface for servers that implement the essential IAM mixins. 12 | type Server interface { 13 | iampb.IAMPolicyServer 14 | ListRoles(context.Context, *adminpb.ListRolesRequest) (*adminpb.ListRolesResponse, error) 15 | GetRole(context.Context, *adminpb.GetRoleRequest) (*adminpb.Role, error) 16 | } 17 | 18 | // Register the IAM mixin server with the provided gRPC server. 19 | func Register(server *grpc.Server, serverImpl Server) { 20 | iampb.RegisterIAMPolicyServer(server, serverImpl) 21 | adminpb.RegisterIAMServer(server, &adminAdapter{server: serverImpl}) 22 | } 23 | 24 | // adminAdapter provides unimplemented methods for the non-essential IAM adminpb mixins. 25 | type adminAdapter struct { 26 | adminpb.UnimplementedIAMServer 27 | server Server 28 | } 29 | 30 | // ListRoles implements adminpb.IAMServer. 31 | func (a *adminAdapter) ListRoles( 32 | ctx context.Context, 33 | request *adminpb.ListRolesRequest, 34 | ) (*adminpb.ListRolesResponse, error) { 35 | return a.server.ListRoles(ctx, request) 36 | } 37 | 38 | // GetRole implements adminpb.IAMServer. 39 | func (a *adminAdapter) GetRole( 40 | ctx context.Context, 41 | request *adminpb.GetRoleRequest, 42 | ) (*adminpb.Role, error) { 43 | return a.server.GetRole(ctx, request) 44 | } 45 | 46 | // SetIamPolicy implements adminpb.IAMServer. 47 | func (a *adminAdapter) SetIamPolicy( 48 | ctx context.Context, 49 | request *iampb.SetIamPolicyRequest, 50 | ) (*iampb.Policy, error) { 51 | return a.server.SetIamPolicy(ctx, request) 52 | } 53 | 54 | // GetIamPolicy implements adminpb.IAMServer. 55 | func (a *adminAdapter) GetIamPolicy( 56 | ctx context.Context, 57 | request *iampb.GetIamPolicyRequest, 58 | ) (*iampb.Policy, error) { 59 | return a.server.GetIamPolicy(ctx, request) 60 | } 61 | 62 | // TestIamPermissions implements adminpb.IAMServer. 63 | func (a *adminAdapter) TestIamPermissions( 64 | ctx context.Context, 65 | request *iampb.TestIamPermissionsRequest, 66 | ) (*iampb.TestIamPermissionsResponse, error) { 67 | return a.server.TestIamPermissions(ctx, request) 68 | } 69 | -------------------------------------------------------------------------------- /iampermission/doc.go: -------------------------------------------------------------------------------- 1 | // Package iampermission provides primitives for working with IAM permissions. 2 | // See: https://cloud.google.com/iam/docs/overview#permissions 3 | package iampermission 4 | -------------------------------------------------------------------------------- /iampermission/haswildcard.go: -------------------------------------------------------------------------------- 1 | package iampermission 2 | 3 | // HasWildcard reports whether an IAM permission contains a wildcard '*' segment. 4 | func HasWildcard(permission string) bool { 5 | var sc Scanner 6 | sc.Init(permission) 7 | for sc.Scan() { 8 | if sc.Wildcard() { 9 | return true 10 | } 11 | } 12 | return false 13 | } 14 | -------------------------------------------------------------------------------- /iampermission/haswildcard_test.go: -------------------------------------------------------------------------------- 1 | package iampermission 2 | 3 | import ( 4 | "testing" 5 | 6 | "gotest.tools/v3/assert" 7 | ) 8 | 9 | func TestHasWildcard(t *testing.T) { 10 | for _, tt := range []struct { 11 | name string 12 | permission string 13 | expected bool 14 | }{ 15 | { 16 | name: "no wildcard", 17 | permission: "pubsub.subscriptions.consume", 18 | expected: false, 19 | }, 20 | 21 | { 22 | name: "empty", 23 | permission: "", 24 | expected: false, 25 | }, 26 | 27 | { 28 | name: "service wildcard", 29 | permission: "*.subscriptions.consume", 30 | expected: true, 31 | }, 32 | 33 | { 34 | name: "resource wildcard", 35 | permission: "pubsub.*.consume", 36 | expected: true, 37 | }, 38 | 39 | { 40 | name: "multiple wildcards", 41 | permission: "pubsub.*.*", 42 | expected: true, 43 | }, 44 | 45 | { 46 | name: "only wildcard", 47 | permission: "*", 48 | expected: true, 49 | }, 50 | } { 51 | t.Run(tt.name, func(t *testing.T) { 52 | assert.Equal(t, tt.expected, HasWildcard(tt.permission)) 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /iampermission/longrunningoperationpermission.go: -------------------------------------------------------------------------------- 1 | package iampermission 2 | 3 | import ( 4 | "strings" 5 | 6 | "cloud.google.com/go/longrunning/autogen/longrunningpb" 7 | "go.einride.tech/aip/resourcename" 8 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 9 | ) 10 | 11 | // LongRunningOperationRequest is an interface for long-running operation requests. 12 | type LongRunningOperationRequest interface { 13 | GetName() string 14 | } 15 | 16 | // ResolveLongRunningOperationPermission resolves a permission for a long-running operation. 17 | func ResolveLongRunningOperationPermission( 18 | operationsPermissions []*iamv1.LongRunningOperationPermissions, 19 | operationRequest LongRunningOperationRequest, 20 | ) (string, bool) { 21 | _, isListRequest := operationRequest.(*longrunningpb.ListOperationsRequest) 22 | var match *iamv1.LongRunningOperationPermissions 23 | OperationLoop: 24 | for _, operationPermissions := range operationsPermissions { 25 | for _, pattern := range operationPermissions.GetOperation().GetPattern() { 26 | if isListRequest { 27 | pattern = parentPattern(pattern) 28 | } 29 | if resourcename.Match(pattern, operationRequest.GetName()) { 30 | match = operationPermissions 31 | break OperationLoop 32 | } 33 | } 34 | } 35 | if match == nil { 36 | return "", false 37 | } 38 | switch operationRequest.(type) { 39 | case *longrunningpb.GetOperationRequest: 40 | return match.GetGet(), match.GetGet() != "" 41 | case *longrunningpb.ListOperationsRequest: 42 | return match.GetList(), match.GetList() != "" 43 | case *longrunningpb.CancelOperationRequest: 44 | return match.GetCancel(), match.GetCancel() != "" 45 | case *longrunningpb.DeleteOperationRequest: 46 | return match.GetDelete(), match.GetDelete() != "" 47 | case *longrunningpb.WaitOperationRequest: 48 | return match.GetWait(), match.GetWait() != "" 49 | default: 50 | return "", false 51 | } 52 | } 53 | 54 | func parentPattern(pattern string) string { 55 | return trimSegment(trimSegment(pattern)) 56 | } 57 | 58 | func trimSegment(pattern string) string { 59 | lastIndexSlash := strings.LastIndexByte(pattern, '/') 60 | if lastIndexSlash == -1 { 61 | return "" 62 | } 63 | return pattern[:lastIndexSlash] 64 | } 65 | -------------------------------------------------------------------------------- /iampermission/match.go: -------------------------------------------------------------------------------- 1 | package iampermission 2 | 3 | // Match reports whether the lhs permission name matches the rhs permission. 4 | // The lhs permission may contain a wildcard. 5 | // The result will always be false when any of lhs or rhs are invalid, or if rhs contains a wildcard. 6 | func Match(lhs, rhs string) bool { 7 | if !Valid(lhs) || !Valid(rhs) || HasWildcard(rhs) { 8 | return false 9 | } 10 | var scLHS Scanner 11 | scLHS.Init(lhs) 12 | var scRHS Scanner 13 | scRHS.Init(rhs) 14 | // Segment 1: Service. 15 | _, _ = scLHS.Scan(), scRHS.Scan() 16 | if scLHS.Segment() != scRHS.Segment() { 17 | return false 18 | } 19 | // Segment 2: Resource. 20 | _, _ = scLHS.Scan(), scRHS.Scan() 21 | if scLHS.Wildcard() { 22 | return true 23 | } 24 | if scLHS.Segment() != scRHS.Segment() { 25 | return false 26 | } 27 | // Segment 3: Verb. 28 | _, _ = scLHS.Scan(), scRHS.Scan() 29 | if scLHS.Wildcard() { 30 | return true 31 | } 32 | return scLHS.Segment() == scRHS.Segment() 33 | } 34 | -------------------------------------------------------------------------------- /iampermission/match_test.go: -------------------------------------------------------------------------------- 1 | package iampermission 2 | 3 | import ( 4 | "testing" 5 | 6 | "gotest.tools/v3/assert" 7 | ) 8 | 9 | func TestMatch(t *testing.T) { 10 | for _, tt := range []struct { 11 | name string 12 | lhs string 13 | rhs string 14 | expected bool 15 | }{ 16 | { 17 | name: "equal", 18 | lhs: "pubsub.subscriptions.consume", 19 | rhs: "pubsub.subscriptions.consume", 20 | expected: true, 21 | }, 22 | 23 | { 24 | name: "wildcard verb", 25 | lhs: "pubsub.subscriptions.*", 26 | rhs: "pubsub.subscriptions.consume", 27 | expected: true, 28 | }, 29 | 30 | { 31 | name: "wildcard resource", 32 | lhs: "pubsub.*", 33 | rhs: "pubsub.subscriptions.consume", 34 | expected: true, 35 | }, 36 | 37 | { 38 | name: "wildcard service not allowed", 39 | lhs: "*", 40 | rhs: "pubsub.subscriptions.consume", 41 | expected: false, 42 | }, 43 | 44 | { 45 | name: "wildcard both", 46 | lhs: "pubsub.*", 47 | rhs: "pubsub.*", 48 | expected: false, 49 | }, 50 | 51 | { 52 | name: "wildcard rhs", 53 | lhs: "pubsub.subscriptions.consume", 54 | rhs: "pubsub.*", 55 | expected: false, 56 | }, 57 | 58 | { 59 | name: "non-matching service", 60 | lhs: "pubsub.subscriptions.consume", 61 | rhs: "foo.subscriptions.consume", 62 | expected: false, 63 | }, 64 | 65 | { 66 | name: "non-matching resource", 67 | lhs: "pubsub.subscriptions.consume", 68 | rhs: "pubsub.foo.consume", 69 | expected: false, 70 | }, 71 | 72 | { 73 | name: "non-matching verb", 74 | lhs: "pubsub.subscriptions.consume", 75 | rhs: "pubsub.subscriptions.foo", 76 | expected: false, 77 | }, 78 | 79 | { 80 | name: "camelCase", 81 | lhs: "pubsub.fooSubscriptions.create", 82 | rhs: "pubsub.fooSubscriptions.create", 83 | expected: true, 84 | }, 85 | } { 86 | t.Run(tt.name, func(t *testing.T) { 87 | assert.Equal(t, tt.expected, Match(tt.lhs, tt.rhs)) 88 | }) 89 | } 90 | } 91 | 92 | var boolSink bool //nolint: gochecknoglobals 93 | 94 | func BenchmarkMatch(b *testing.B) { 95 | b.ReportAllocs() 96 | for i := 0; i < b.N; i++ { 97 | boolSink = Match("pubsub.subscriptions.*", "pubsub.subscriptions.consume") 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /iampermission/resourcepermission.go: -------------------------------------------------------------------------------- 1 | package iampermission 2 | 3 | import ( 4 | "go.einride.tech/aip/resourcename" 5 | "go.einride.tech/iam/iamresource" 6 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 7 | ) 8 | 9 | // ResolveMethodPermission resolves a permission for a method, given the requested resource. 10 | func ResolveMethodPermission( 11 | options *iamv1.MethodAuthorizationOptions, 12 | resourceName string, 13 | ) (string, bool) { 14 | switch permissions := options.GetPermissions().(type) { 15 | case *iamv1.MethodAuthorizationOptions_Permission: 16 | return permissions.Permission, true 17 | case *iamv1.MethodAuthorizationOptions_ResourcePermissions: 18 | return ResolveResourcePermission(permissions.ResourcePermissions.GetResourcePermission(), resourceName) 19 | default: 20 | return "", false 21 | } 22 | } 23 | 24 | // ResolveResourcePermission resolves a permission for a resource name, given a set of resource permissions. 25 | func ResolveResourcePermission( 26 | resourcePermissions []*iamv1.ResourcePermission, 27 | resourceName string, 28 | ) (string, bool) { 29 | for _, resourcePermission := range resourcePermissions { 30 | if resourcePermission.GetResource().GetType() == iamresource.Root && resourceName == iamresource.Root { 31 | return resourcePermission.GetPermission(), true 32 | } 33 | for _, pattern := range resourcePermission.GetResource().GetPattern() { 34 | if resourcename.Match(pattern, resourceName) { 35 | return resourcePermission.GetPermission(), true 36 | } 37 | } 38 | } 39 | return "", false 40 | } 41 | -------------------------------------------------------------------------------- /iampermission/resourcepermission_test.go: -------------------------------------------------------------------------------- 1 | package iampermission 2 | 3 | import ( 4 | "testing" 5 | 6 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 7 | "google.golang.org/genproto/googleapis/api/annotations" 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestResolveMethodPermission(t *testing.T) { 12 | t.Run("permission", func(t *testing.T) { 13 | options := &iamv1.MethodAuthorizationOptions{ 14 | Permissions: &iamv1.MethodAuthorizationOptions_Permission{ 15 | Permission: "foo.bar.baz", 16 | }, 17 | } 18 | result, ok := ResolveMethodPermission(options, "foos/foo") 19 | assert.Assert(t, ok) 20 | assert.Equal(t, "foo.bar.baz", result) 21 | }) 22 | 23 | t.Run("resource permissions", func(t *testing.T) { 24 | options := &iamv1.MethodAuthorizationOptions{ 25 | Permissions: &iamv1.MethodAuthorizationOptions_ResourcePermissions{ 26 | ResourcePermissions: &iamv1.ResourcePermissions{ 27 | ResourcePermission: []*iamv1.ResourcePermission{ 28 | { 29 | Resource: &annotations.ResourceDescriptor{ 30 | Type: "foo.test.com/Test", 31 | Pattern: []string{ 32 | "foos/{foo}", 33 | }, 34 | }, 35 | Permission: "foo.bar.baz", 36 | }, 37 | }, 38 | }, 39 | }, 40 | } 41 | result, ok := ResolveMethodPermission(options, "foos/foo") 42 | assert.Assert(t, ok) 43 | assert.Equal(t, "foo.bar.baz", result) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /iampermission/scanner.go: -------------------------------------------------------------------------------- 1 | package iampermission 2 | 3 | import "strings" 4 | 5 | // Scanner scans an IAM permission. 6 | type Scanner struct { 7 | permission string 8 | start, end int 9 | } 10 | 11 | // Init initializes the scanner. 12 | func (s *Scanner) Init(permission string) { 13 | s.permission = permission 14 | s.start, s.end = 0, 0 15 | } 16 | 17 | // Scan to the next segment. 18 | func (s *Scanner) Scan() bool { 19 | switch s.end { 20 | case len(s.permission): 21 | return false 22 | case 0: 23 | // start at the beginning 24 | default: 25 | s.start = s.end + 1 // start past latest dot '.' 26 | } 27 | if nextDot := strings.IndexByte(s.permission[s.start:], '.'); nextDot == -1 { 28 | s.end = len(s.permission) 29 | } else { 30 | s.end = s.start + nextDot 31 | } 32 | return true 33 | } 34 | 35 | // Segment returns the current segment. 36 | func (s *Scanner) Segment() string { 37 | return s.permission[s.start:s.end] 38 | } 39 | 40 | // Wildcard reports whether the current segment is a wildcard. 41 | func (s *Scanner) Wildcard() bool { 42 | return s.Segment() == "*" 43 | } 44 | -------------------------------------------------------------------------------- /iampermission/validate.go: -------------------------------------------------------------------------------- 1 | package iampermission 2 | 3 | import ( 4 | "fmt" 5 | "unicode" 6 | ) 7 | 8 | // Validate a permission. 9 | // 10 | // A permission is valid on the format `..`. 11 | // 12 | // - It contains at most 3 segments. 13 | // - When no segment is a wildcard, it contains exactly 3 segments. 14 | // - All segments are non-empty. 15 | // - All segments are lower-case. 16 | // - The first (service) segment is not a wildcard. 17 | // - A wildcard only occurs in the last segment. 18 | func Validate(permission string) error { 19 | var sc Scanner 20 | sc.Init(permission) 21 | // Segment 1: Service. 22 | if !sc.Scan() { 23 | return fmt.Errorf("permission is empty") 24 | } 25 | if sc.Wildcard() { 26 | return fmt.Errorf("permission `%s`: service segment must not be wildcard", permission) 27 | } 28 | if !isValidSegment(sc.Segment()) { 29 | return fmt.Errorf("permission `%s`: invalid service segment", permission) 30 | } 31 | // Segment 2: Resource. 32 | if !sc.Scan() { 33 | return fmt.Errorf("permission `%s`: missing resource segment", permission) 34 | } 35 | if sc.Wildcard() { 36 | if sc.Scan() { 37 | return fmt.Errorf("permission `%s`: only final segment can be wildcard", permission) 38 | } 39 | return nil 40 | } 41 | if !isValidSegment(sc.Segment()) { 42 | return fmt.Errorf("permission `%s`: invalid resource segment", permission) 43 | } 44 | // Segment 3: Verb. 45 | if !sc.Scan() { 46 | return fmt.Errorf("permission `%s`: missing verb segment", permission) 47 | } 48 | if sc.Wildcard() { 49 | if sc.Scan() { 50 | return fmt.Errorf("permission `%s`: only final segment can be wildcard", permission) 51 | } 52 | return nil 53 | } 54 | if !isValidSegment(sc.Segment()) { 55 | return fmt.Errorf("permission `%s`: invalid verb segment", permission) 56 | } 57 | // Segment 4? Invalid! 58 | if sc.Scan() { 59 | return fmt.Errorf("permission `%s`: too many segments", permission) 60 | } 61 | return nil 62 | } 63 | 64 | // Valid checks whether the provided permission is valid. 65 | // See Validate for what constitutes a valid permission. 66 | func Valid(permission string) bool { 67 | return Validate(permission) == nil 68 | } 69 | 70 | func isValidSegment(segment string) bool { 71 | if len(segment) == 0 { 72 | return false 73 | } 74 | for i, r := range segment { 75 | switch i { 76 | case 0: 77 | if !unicode.IsLower(r) { 78 | return false 79 | } 80 | default: 81 | if !unicode.In(r, unicode.Lower, unicode.Upper, unicode.Digit) { 82 | return false 83 | } 84 | } 85 | } 86 | return true 87 | } 88 | -------------------------------------------------------------------------------- /iampolicy/add.go: -------------------------------------------------------------------------------- 1 | package iampolicy 2 | 3 | import ( 4 | "cloud.google.com/go/iam/apiv1/iampb" 5 | ) 6 | 7 | // AddBinding adds the provided role and member binding to the policy. 8 | // If the role and member already exists, no updates are made. 9 | // No validation on the role or member is performed. 10 | func AddBinding(policy *iampb.Policy, role, member string) { 11 | // Add binding to policy. 12 | var added bool 13 | for _, binding := range policy.GetBindings() { 14 | if binding.GetRole() == role { 15 | for _, bindingMember := range binding.GetMembers() { 16 | if bindingMember == member { 17 | return // already have this policy binding 18 | } 19 | } 20 | binding.Members = append(binding.Members, member) 21 | added = true 22 | } 23 | } 24 | if !added { 25 | policy.Bindings = append(policy.Bindings, &iampb.Binding{ 26 | Role: role, 27 | Members: []string{member}, 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /iampolicy/doc.go: -------------------------------------------------------------------------------- 1 | // Package iampolicy provides primitives for working with IAM policies. 2 | package iampolicy 3 | -------------------------------------------------------------------------------- /iampolicy/remove.go: -------------------------------------------------------------------------------- 1 | package iampolicy 2 | 3 | import ( 4 | "cloud.google.com/go/iam/apiv1/iampb" 5 | ) 6 | 7 | // RemoveBinding removes the provided role and member binding from the policy. 8 | // If a binding of the role and member don't exist, no updates are made. 9 | // No validation on the role or member is performed. 10 | func RemoveBinding(policy *iampb.Policy, role, member string) { 11 | for _, binding := range policy.GetBindings() { 12 | if binding.GetRole() == role { 13 | binding.Members = removeMember(binding.GetMembers(), member) 14 | if len(binding.GetMembers()) == 0 { 15 | policy.Bindings = removeRole(policy.GetBindings(), role) 16 | } 17 | return 18 | } 19 | } 20 | } 21 | 22 | func removeMember(members []string, member string) []string { 23 | for i, candidate := range members { 24 | if candidate == member { 25 | return append(members[:i], members[i+1:]...) 26 | } 27 | } 28 | return members 29 | } 30 | 31 | func removeRole(bindings []*iampb.Binding, role string) []*iampb.Binding { 32 | for i, binding := range bindings { 33 | if binding.GetRole() == role { 34 | return append(bindings[:i], bindings[i+1:]...) 35 | } 36 | } 37 | return bindings 38 | } 39 | -------------------------------------------------------------------------------- /iampolicy/remove_test.go: -------------------------------------------------------------------------------- 1 | package iampolicy 2 | 3 | import ( 4 | "testing" 5 | 6 | "cloud.google.com/go/iam/apiv1/iampb" 7 | "google.golang.org/protobuf/testing/protocmp" 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestRemoveBinding(t *testing.T) { 12 | t.Run("ok", func(t *testing.T) { 13 | actual := &iampb.Policy{ 14 | Bindings: []*iampb.Binding{ 15 | { 16 | Role: "roles/test", 17 | Members: []string{"foo", "bar"}, 18 | }, 19 | { 20 | Role: "roles/test2", 21 | Members: []string{"foo", "bar"}, 22 | }, 23 | }, 24 | } 25 | RemoveBinding(actual, "roles/test2", "bar") 26 | expected := &iampb.Policy{ 27 | Bindings: []*iampb.Binding{ 28 | { 29 | Role: "roles/test", 30 | Members: []string{"foo", "bar"}, 31 | }, 32 | { 33 | Role: "roles/test2", 34 | Members: []string{"foo"}, 35 | }, 36 | }, 37 | } 38 | assert.DeepEqual(t, expected, actual, protocmp.Transform()) 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /iampolicy/validate.go: -------------------------------------------------------------------------------- 1 | package iampolicy 2 | 3 | import ( 4 | "fmt" 5 | 6 | "cloud.google.com/go/iam/apiv1/iampb" 7 | "go.einride.tech/aip/validation" 8 | ) 9 | 10 | // Validate an IAM policy. 11 | func Validate(policy *iampb.Policy) error { 12 | var result validation.MessageValidator 13 | for i, binding := range policy.GetBindings() { 14 | if len(binding.GetRole()) == 0 { 15 | result.AddFieldViolation(fmt.Sprintf("bindings[%d].role", i), "missing required field") 16 | } 17 | if len(binding.GetMembers()) == 0 { 18 | result.AddFieldViolation(fmt.Sprintf("bindings[%d].members", i), "missing required field") 19 | } 20 | for j, member := range binding.GetMembers() { 21 | if len(member) == 0 { 22 | result.AddFieldViolation(fmt.Sprintf("bindings[%d].members[%d]", i, j), "missing required field") 23 | } 24 | } 25 | } 26 | return result.Err() 27 | } 28 | -------------------------------------------------------------------------------- /iamregistry/doc.go: -------------------------------------------------------------------------------- 1 | // Package iamregistry provides data structures for resolving IAM roles and permissions. 2 | package iamregistry 3 | -------------------------------------------------------------------------------- /iamregistry/roles.go: -------------------------------------------------------------------------------- 1 | package iamregistry 2 | 3 | import ( 4 | "fmt" 5 | 6 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 7 | "go.einride.tech/iam/iamannotations" 8 | "go.einride.tech/iam/iamrole" 9 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 10 | ) 11 | 12 | // Roles are a set of roles. 13 | type Roles struct { 14 | roles map[string]*adminpb.Role 15 | } 16 | 17 | // NewRoles creates a set of Roles from a pre-defined roles annotation. 18 | func NewRoles(roles ...*adminpb.Role) (*Roles, error) { 19 | if err := iamannotations.ValidatePredefinedRoles(&iamv1.PredefinedRoles{Role: roles}); err != nil { 20 | return nil, fmt.Errorf("new roles registry: %w", err) 21 | } 22 | result := Roles{ 23 | roles: make(map[string]*adminpb.Role, len(roles)), 24 | } 25 | for _, role := range roles { 26 | result.roles[role.GetName()] = role 27 | } 28 | return &result, nil 29 | } 30 | 31 | // Count returns a count of the roles. 32 | func (r *Roles) Count() int { 33 | return len(r.roles) 34 | } 35 | 36 | // RoleHasPermission checks whether the role with the provided name has the provided permission. 37 | func (r *Roles) RoleHasPermission(name, permission string) bool { 38 | role, ok := r.FindRoleByName(name) 39 | if !ok { 40 | return false 41 | } 42 | return iamrole.HasPermission(role, permission) 43 | } 44 | 45 | // RangeRoles iterates over all registered roles while f returns true. 46 | // The iteration order is undefined. 47 | func (r *Roles) RangeRoles(fn func(*adminpb.Role) bool) { 48 | for _, role := range r.roles { 49 | if !fn(role) { 50 | break 51 | } 52 | } 53 | } 54 | 55 | // RangeRolesByPermission iterates over all registered roles with the provided permission while f returns true. 56 | // The iteration order is undefined, and permissions with wildcards are not allowed. 57 | func (r *Roles) RangeRolesByPermission(permission string, fn func(*adminpb.Role) bool) { 58 | for _, role := range r.roles { 59 | if iamrole.HasPermission(role, permission) { 60 | if !fn(role) { 61 | break 62 | } 63 | } 64 | } 65 | } 66 | 67 | // FindRoleByName looks up a role by resource name. 68 | func (r *Roles) FindRoleByName(name string) (*adminpb.Role, bool) { 69 | role, ok := r.roles[name] 70 | return role, ok 71 | } 72 | -------------------------------------------------------------------------------- /iamregistry/roles_test.go: -------------------------------------------------------------------------------- 1 | package iamregistry 2 | 3 | import ( 4 | "testing" 5 | 6 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func TestRoles_RangeRolesByPermission(t *testing.T) { 11 | t.Run("wildcard", func(t *testing.T) { 12 | roles, err := NewRoles( 13 | &adminpb.Role{ 14 | Name: "roles/test.admin", 15 | Title: "Test admin", 16 | Description: "Test description", 17 | IncludedPermissions: []string{"test.*"}, 18 | }, 19 | ) 20 | assert.NilError(t, err) 21 | var found bool 22 | roles.RangeRolesByPermission("test.foo.bar", func(role *adminpb.Role) bool { 23 | assert.Equal(t, "roles/test.admin", role.GetName()) 24 | found = true 25 | return true 26 | }) 27 | assert.Assert(t, found) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /iamresource/doc.go: -------------------------------------------------------------------------------- 1 | // Package iamresource provides primitives for working with IAM resources. 2 | package iamresource 3 | -------------------------------------------------------------------------------- /iamresource/root.go: -------------------------------------------------------------------------------- 1 | package iamresource 2 | 3 | // Root is the special IAM resource for binding root permissions. 4 | const Root = "/" 5 | -------------------------------------------------------------------------------- /iamrole/doc.go: -------------------------------------------------------------------------------- 1 | // Package iamrole provides primitives and operations on IAM roles. 2 | // See: https://cloud.google.com/iam/docs/understanding-roles 3 | // See also: https://cloud.google.com/iam/docs/understanding-custom-roles 4 | package iamrole 5 | -------------------------------------------------------------------------------- /iamrole/haspermission.go: -------------------------------------------------------------------------------- 1 | package iamrole 2 | 3 | import ( 4 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 5 | "go.einride.tech/iam/iampermission" 6 | ) 7 | 8 | // HasPermission reports whether the provided role has the provided permission. 9 | // Always returns false for wildcard permissions. 10 | func HasPermission(role *adminpb.Role, permission string) bool { 11 | for _, includedPermission := range role.GetIncludedPermissions() { 12 | if iampermission.Match(includedPermission, permission) { 13 | return true 14 | } 15 | } 16 | return false 17 | } 18 | -------------------------------------------------------------------------------- /iamrole/haspermission_test.go: -------------------------------------------------------------------------------- 1 | package iamrole 2 | 3 | import ( 4 | "testing" 5 | 6 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func TestHasPermission(t *testing.T) { 11 | for _, tt := range []struct { 12 | name string 13 | role *adminpb.Role 14 | permission string 15 | expected bool 16 | }{ 17 | { 18 | name: "via service wildcard", 19 | role: &adminpb.Role{ 20 | IncludedPermissions: []string{"pubsub.*", "storage.buckets.*", "kms.keys.create"}, 21 | }, 22 | permission: "pubsub.subscriptions.consume", 23 | expected: true, 24 | }, 25 | 26 | { 27 | name: "via resource wildcard", 28 | role: &adminpb.Role{ 29 | IncludedPermissions: []string{"pubsub.*", "storage.buckets.*", "kms.keys.create"}, 30 | }, 31 | permission: "storage.buckets.create", 32 | expected: true, 33 | }, 34 | 35 | { 36 | name: "via exact match", 37 | role: &adminpb.Role{ 38 | IncludedPermissions: []string{"pubsub.*", "storage.buckets.*", "kms.keys.create"}, 39 | }, 40 | permission: "kms.keys.create", 41 | expected: true, 42 | }, 43 | 44 | { 45 | name: "no match", 46 | role: &adminpb.Role{ 47 | IncludedPermissions: []string{"pubsub.*", "storage.buckets.*", "kms.keys.create"}, 48 | }, 49 | permission: "kms.keys.get", 50 | expected: false, 51 | }, 52 | 53 | { 54 | name: "no match with wildcard", 55 | role: &adminpb.Role{ 56 | IncludedPermissions: []string{"pubsub.*", "storage.buckets.*", "kms.keys.create"}, 57 | }, 58 | permission: "storage.objects.get", 59 | expected: false, 60 | }, 61 | } { 62 | t.Run(tt.name, func(t *testing.T) { 63 | assert.Equal(t, tt.expected, HasPermission(tt.role, tt.permission)) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /iamrole/validate.go: -------------------------------------------------------------------------------- 1 | package iamrole 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "unicode" 7 | 8 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 9 | "go.einride.tech/aip/validation" 10 | "go.einride.tech/iam/iampermission" 11 | ) 12 | 13 | // Validate checks that an IAM role is valid. 14 | func Validate(role *adminpb.Role) error { 15 | var result validation.MessageValidator 16 | if err := ValidateName(role.GetName()); err != nil { 17 | result.AddFieldError("name", err) 18 | } 19 | addTitleViolations(&result, role.GetTitle()) 20 | addDescriptionViolations(&result, role.GetTitle()) 21 | addIncludedPermissionsViolations(&result, role.GetIncludedPermissions()) 22 | return result.Err() 23 | } 24 | 25 | // ValidateName checks that an IAM role name is valid. 26 | func ValidateName(name string) error { 27 | if len(name) == 0 { 28 | return fmt.Errorf("role name must be non-empty") 29 | } 30 | if !strings.HasPrefix(name, "roles/") { 31 | return fmt.Errorf("role name '%s' is not on the format `roles/{service}.{role}`", name) 32 | } 33 | roleID := strings.TrimPrefix(name, "roles/") 34 | if len(roleID) > 64 { 35 | return fmt.Errorf("role name '%s' has a too long ID, it can be max 64 characters long", name) 36 | } 37 | indexOfPeriod := strings.IndexByte(roleID, '.') 38 | if indexOfPeriod == -1 { 39 | return fmt.Errorf("role name '%s' is not on the format `roles/{service}.{role}`", name) 40 | } 41 | service, role := roleID[:indexOfPeriod], roleID[indexOfPeriod+1:] 42 | if !isLowerCamelCase(service) || !isLowerCamelCase(role) { 43 | return fmt.Errorf("each part of role name '%s' must be valid lowerCamelCase", name) 44 | } 45 | return nil 46 | } 47 | 48 | func addTitleViolations(result *validation.MessageValidator, title string) { 49 | if len(title) == 0 || len(title) > 100 { 50 | result.AddFieldViolation("title", "must be non-empty and <= 100 characters") 51 | } 52 | } 53 | 54 | func addDescriptionViolations(result *validation.MessageValidator, description string) { 55 | if len(description) == 0 || len(description) > 256 { 56 | result.AddFieldViolation("description", "must be non-empty and <= 256 characters") 57 | } 58 | } 59 | 60 | func addIncludedPermissionsViolations(result *validation.MessageValidator, includedPermissions []string) { 61 | if len(includedPermissions) == 0 { 62 | result.AddFieldViolation("included_permissions", "missing required field") 63 | } 64 | for i, includedPermission := range includedPermissions { 65 | if err := iampermission.Validate(includedPermission); err != nil { 66 | result.AddFieldError(fmt.Sprintf("included_permissions[%d]", i), err) 67 | } 68 | } 69 | } 70 | 71 | func isLowerCamelCase(s string) bool { 72 | for i, r := range s { 73 | if r > unicode.MaxASCII { 74 | return false 75 | } 76 | if i == 0 { 77 | if !unicode.IsLower(r) { 78 | return false 79 | } 80 | } else { 81 | if !unicode.In(r, unicode.Letter, unicode.Digit) { 82 | return false 83 | } 84 | } 85 | } 86 | return true 87 | } 88 | -------------------------------------------------------------------------------- /iamrole/validate_test.go: -------------------------------------------------------------------------------- 1 | package iamrole 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 8 | "google.golang.org/genproto/googleapis/rpc/errdetails" 9 | "google.golang.org/grpc/status" 10 | "google.golang.org/protobuf/testing/protocmp" 11 | "gotest.tools/v3/assert" 12 | ) 13 | 14 | func TestValidate(t *testing.T) { 15 | for _, tt := range []struct { 16 | name string 17 | role *adminpb.Role 18 | expected *errdetails.BadRequest 19 | }{ 20 | { 21 | name: "valid", 22 | role: &adminpb.Role{ 23 | Name: "roles/foo.barBaz", 24 | Title: "Foo Bar Baz", 25 | Description: "Longer description", 26 | IncludedPermissions: []string{"pubsub.*", "storage.buckets.*", "kms.keys.create"}, 27 | }, 28 | }, 29 | 30 | { 31 | name: "invalid name format", 32 | role: &adminpb.Role{ 33 | Name: "foobarbaz", 34 | Title: "Foo Bar Baz", 35 | Description: "Longer description", 36 | IncludedPermissions: []string{"pubsub.*", "storage.buckets.*", "kms.keys.create"}, 37 | }, 38 | expected: &errdetails.BadRequest{ 39 | FieldViolations: []*errdetails.BadRequest_FieldViolation{ 40 | { 41 | Field: "name", 42 | Description: "role name 'foobarbaz' is not on the format `roles/{service}.{role}`", 43 | }, 44 | }, 45 | }, 46 | }, 47 | 48 | { 49 | name: "too long title", 50 | role: &adminpb.Role{ 51 | Name: "roles/foo.barBaz", 52 | Title: strings.Repeat("a", 101), 53 | Description: "Longer description", 54 | IncludedPermissions: []string{"pubsub.*", "storage.buckets.*", "kms.keys.create"}, 55 | }, 56 | expected: &errdetails.BadRequest{ 57 | FieldViolations: []*errdetails.BadRequest_FieldViolation{ 58 | { 59 | Field: "title", 60 | Description: "must be non-empty and <= 100 characters", 61 | }, 62 | }, 63 | }, 64 | }, 65 | } { 66 | t.Run(tt.name, func(t *testing.T) { 67 | err := Validate(tt.role) 68 | if tt.expected == nil { 69 | assert.NilError(t, err) 70 | } else { 71 | actual, ok := status.Convert(err).Details()[0].(*errdetails.BadRequest) 72 | assert.Assert(t, ok) 73 | assert.DeepEqual(t, tt.expected, actual, protocmp.Transform()) 74 | } 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /iamspanner/doc.go: -------------------------------------------------------------------------------- 1 | // Package iamspanner provides a Spanner-based storage implementation of the IAMPolicy service. 2 | package iamspanner 3 | -------------------------------------------------------------------------------- /iamspanner/schema.go: -------------------------------------------------------------------------------- 1 | package iamspanner 2 | 3 | import _ "embed" 4 | 5 | //go:embed schema.sql 6 | var spannerSQLSchema string 7 | 8 | // SQLSchema returns the example Spanner SQL schema. 9 | func SQLSchema() string { 10 | return spannerSQLSchema 11 | } 12 | -------------------------------------------------------------------------------- /iamspanner/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE iam_policy_bindings ( 2 | resource STRING(MAX) NOT NULL, 3 | binding_index INT64 NOT NULL, 4 | role STRING(MAX) NOT NULL, 5 | member_index INT64 NOT NULL, 6 | member STRING(MAX) NOT NULL, 7 | ) PRIMARY KEY ( 8 | resource ASC, 9 | binding_index ASC, 10 | role ASC, 11 | member_index ASC, 12 | member ASC, 13 | ); 14 | 15 | CREATE UNIQUE INDEX 16 | iam_policy_bindings_by_member_and_resource 17 | ON iam_policy_bindings( 18 | member ASC, 19 | resource ASC, 20 | role ASC, 21 | ); 22 | 23 | CREATE UNIQUE INDEX 24 | iam_policy_bindings_by_member_and_role 25 | ON iam_policy_bindings( 26 | member ASC, 27 | role ASC, 28 | resource ASC, 29 | ); 30 | -------------------------------------------------------------------------------- /iamspanner/server_policies_get.go: -------------------------------------------------------------------------------- 1 | package iamspanner 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/iam/apiv1/iampb" 7 | "go.einride.tech/aip/resourcename" 8 | "go.einride.tech/aip/validation" 9 | "go.einride.tech/iam/iamresource" 10 | ) 11 | 12 | // GetIamPolicy implements iampb.IAMPolicyServer. 13 | func (s *IAMServer) GetIamPolicy( 14 | ctx context.Context, 15 | request *iampb.GetIamPolicyRequest, 16 | ) (*iampb.Policy, error) { 17 | if err := validateGetIamPolicyRequest(request); err != nil { 18 | return nil, err 19 | } 20 | tx := s.client.Single() 21 | defer tx.Close() 22 | return s.ReadPolicyInTransaction(ctx, tx, request.GetResource()) 23 | } 24 | 25 | func validateGetIamPolicyRequest(request *iampb.GetIamPolicyRequest) error { 26 | var result validation.MessageValidator 27 | switch request.GetResource() { 28 | case iamresource.Root: // OK 29 | case "": 30 | result.AddFieldViolation("resource", "missing required field") 31 | default: 32 | if err := resourcename.Validate(request.GetResource()); err != nil { 33 | result.AddFieldError("resource", err) 34 | } 35 | if resourcename.ContainsWildcard(request.GetResource()) { 36 | result.AddFieldViolation("resource", "must not contain wildcard") 37 | } 38 | } 39 | return result.Err() 40 | } 41 | -------------------------------------------------------------------------------- /iamspanner/server_roles_get.go: -------------------------------------------------------------------------------- 1 | package iamspanner 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 7 | "go.einride.tech/aip/resourcename" 8 | "go.einride.tech/aip/validation" 9 | "google.golang.org/grpc/codes" 10 | "google.golang.org/grpc/status" 11 | ) 12 | 13 | // GetRole implements adminpb.IAMServer. 14 | func (s *IAMServer) GetRole( 15 | ctx context.Context, 16 | request *adminpb.GetRoleRequest, 17 | ) (*adminpb.Role, error) { 18 | var parsedRequest getRoleRequest 19 | if err := parsedRequest.parse(request); err != nil { 20 | return nil, err 21 | } 22 | return s.getRole(ctx, &parsedRequest) 23 | } 24 | 25 | // GetRole implements adminpb.IAMServer. 26 | func (s *IAMServer) getRole( 27 | _ context.Context, 28 | request *getRoleRequest, 29 | ) (*adminpb.Role, error) { 30 | role, ok := s.roles.FindRoleByName(request.name) 31 | if !ok { 32 | return nil, status.Error(codes.NotFound, "not found") 33 | } 34 | return role, nil 35 | } 36 | 37 | type getRoleRequest struct { 38 | name string 39 | } 40 | 41 | func (r *getRoleRequest) parse(request *adminpb.GetRoleRequest) error { 42 | var v validation.MessageValidator 43 | // name = 1 44 | switch { 45 | case request.GetName() == "": 46 | v.AddFieldViolation("name", "required field") 47 | case resourcename.ContainsWildcard(request.GetName()): 48 | v.AddFieldViolation("name", "must not contain wildcards") 49 | case !resourcename.Match("roles/{role}", request.GetName()): 50 | v.AddFieldViolation("name", "invalid format") 51 | default: 52 | r.name = request.GetName() 53 | } 54 | return v.Err() 55 | } 56 | -------------------------------------------------------------------------------- /iamspanner/server_roles_list.go: -------------------------------------------------------------------------------- 1 | package iamspanner 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | 7 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 8 | "go.einride.tech/aip/pagination" 9 | "go.einride.tech/aip/validation" 10 | "google.golang.org/protobuf/proto" 11 | ) 12 | 13 | // ListRoles implements adminpb.IAMServer. 14 | func (s *IAMServer) ListRoles( 15 | ctx context.Context, 16 | request *adminpb.ListRolesRequest, 17 | ) (*adminpb.ListRolesResponse, error) { 18 | var parsedRequest listRolesRequest 19 | if err := parsedRequest.parse(request); err != nil { 20 | return nil, err 21 | } 22 | return s.listRoles(ctx, &parsedRequest) 23 | } 24 | 25 | func (s *IAMServer) listRoles( 26 | _ context.Context, 27 | request *listRolesRequest, 28 | ) (*adminpb.ListRolesResponse, error) { 29 | roles := make([]*adminpb.Role, 0, s.roles.Count()) 30 | s.roles.RangeRoles(func(role *adminpb.Role) bool { 31 | roles = append(roles, role) 32 | return true 33 | }) 34 | sort.Slice(roles, func(i, j int) bool { 35 | return roles[i].GetName() < roles[j].GetName() 36 | }) 37 | response := adminpb.ListRolesResponse{ 38 | Roles: make([]*adminpb.Role, 0, request.pageSize), 39 | } 40 | from := int(request.pageToken.Offset) 41 | to := int(request.pageToken.Offset) + int(request.pageSize) 42 | if to >= len(roles) { 43 | to = len(roles) 44 | } else { 45 | response.NextPageToken = request.nextPageToken() 46 | } 47 | for _, role := range roles[from:to] { 48 | switch request.view { 49 | case adminpb.RoleView_FULL: 50 | response.Roles = append(response.Roles, role) 51 | default: 52 | clone := proto.Clone(role).(*adminpb.Role) 53 | clone.IncludedPermissions = nil 54 | response.Roles = append(response.Roles, clone) 55 | } 56 | } 57 | return &response, nil 58 | } 59 | 60 | type listRolesRequest struct { 61 | pageSize int32 62 | pageToken pagination.PageToken 63 | view adminpb.RoleView 64 | request *adminpb.ListRolesRequest 65 | } 66 | 67 | func (r *listRolesRequest) parse(request *adminpb.ListRolesRequest) error { 68 | const ( 69 | defaultPageSize = 300 70 | maxPageSize = 1_000 71 | ) 72 | var v validation.MessageValidator 73 | r.request = request 74 | // parent = 1 75 | if request.GetParent() != "" { 76 | v.AddFieldViolation("parent", "unsupported field") 77 | } 78 | // page_size = 2 79 | switch { 80 | case request.GetPageSize() < 0: 81 | v.AddFieldViolation("page_size", "must be >= 0") 82 | case request.GetPageSize() == 0: 83 | r.pageSize = defaultPageSize 84 | case request.GetPageSize() > maxPageSize: 85 | r.pageSize = maxPageSize 86 | default: 87 | r.pageSize = request.GetPageSize() 88 | } 89 | // page_token = 3 90 | pageToken, err := pagination.ParsePageToken(request) 91 | if err != nil { 92 | v.AddFieldViolation("page_token", "invalid format") 93 | } 94 | r.pageToken = pageToken 95 | switch request.GetView() { 96 | case adminpb.RoleView_BASIC, adminpb.RoleView_FULL: 97 | r.view = request.GetView() 98 | default: 99 | v.AddFieldViolation("view", "unsupported value: %d", request.GetView().Number()) 100 | } 101 | return v.Err() 102 | } 103 | 104 | func (r *listRolesRequest) nextPageToken() string { 105 | return r.pageToken.Next(r.request).String() 106 | } 107 | -------------------------------------------------------------------------------- /iamspanner/server_testiampermissions.go: -------------------------------------------------------------------------------- 1 | package iamspanner 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 8 | "cloud.google.com/go/iam/apiv1/iampb" 9 | "go.einride.tech/aip/resourcename" 10 | "go.einride.tech/aip/validation" 11 | "go.einride.tech/iam/iampermission" 12 | "go.einride.tech/iam/iamresource" 13 | ) 14 | 15 | // TestIamPermissions implements iampb.IAMPolicyServer. 16 | func (s *IAMServer) TestIamPermissions( 17 | ctx context.Context, 18 | request *iampb.TestIamPermissionsRequest, 19 | ) (*iampb.TestIamPermissionsResponse, error) { 20 | if err := validateTestIamPermissionsRequest(request); err != nil { 21 | return nil, err 22 | } 23 | caller, err := s.callerResolver.ResolveCaller(ctx) 24 | if err != nil { 25 | return nil, err 26 | } 27 | permissions := make(map[string]struct{}, len(request.GetPermissions())) 28 | tx := s.client.Single() 29 | defer tx.Close() 30 | if err := s.ReadBindingsByResourcesAndMembersInTransaction( 31 | ctx, 32 | tx, 33 | []string{request.GetResource()}, 34 | caller.GetMembers(), 35 | func(_ context.Context, _ string, role *adminpb.Role, _ string) error { 36 | for _, permission := range request.GetPermissions() { 37 | if s.roles.RoleHasPermission(role.GetName(), permission) { 38 | permissions[permission] = struct{}{} 39 | } 40 | } 41 | return nil 42 | }, 43 | ); err != nil { 44 | return nil, s.handleStorageError(ctx, err) 45 | } 46 | response := &iampb.TestIamPermissionsResponse{ 47 | Permissions: make([]string, 0, len(permissions)), 48 | } 49 | for _, permission := range request.GetPermissions() { 50 | if _, ok := permissions[permission]; ok { 51 | response.Permissions = append(response.Permissions, permission) 52 | } 53 | } 54 | return response, nil 55 | } 56 | 57 | func validateTestIamPermissionsRequest(request *iampb.TestIamPermissionsRequest) error { 58 | var result validation.MessageValidator 59 | switch request.GetResource() { 60 | case iamresource.Root: // OK 61 | case "": 62 | result.AddFieldViolation("resource", "missing required field") 63 | default: 64 | if err := resourcename.Validate(request.GetResource()); err != nil { 65 | result.AddFieldError("resource", err) 66 | } 67 | if resourcename.ContainsWildcard(request.GetResource()) { 68 | result.AddFieldViolation("resource", "must not contain wildcard") 69 | } 70 | } 71 | for i, permission := range request.GetPermissions() { 72 | if err := iampermission.Validate(permission); err != nil { 73 | result.AddFieldError(fmt.Sprintf("permissions[%d]", i), err) 74 | } 75 | } 76 | return result.Err() 77 | } 78 | -------------------------------------------------------------------------------- /iamspanner/server_testpermissions.go: -------------------------------------------------------------------------------- 1 | package iamspanner 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/iam/admin/apiv1/adminpb" 7 | "go.einride.tech/aip/resourcename" 8 | "go.einride.tech/iam/iamresource" 9 | "go.einride.tech/iam/iamrole" 10 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 11 | ) 12 | 13 | // TestPermissions implements iamcel.PermissionTester. 14 | func (s *IAMServer) TestPermissions( 15 | ctx context.Context, 16 | caller *iamv1.Caller, 17 | resourcePermissions map[string]string, 18 | ) (map[string]bool, error) { 19 | result := make(map[string]bool, len(resourcePermissions)) 20 | tx := s.client.Single() 21 | defer tx.Close() 22 | resources := make([]string, 0, len(resourcePermissions)) 23 | for resource := range resourcePermissions { 24 | resources = append(resources, resource) 25 | } 26 | if err := s.ReadBindingsByResourcesAndMembersInTransaction( 27 | ctx, 28 | tx, 29 | resources, 30 | caller.GetMembers(), 31 | func(_ context.Context, boundResource string, role *adminpb.Role, _ string) error { 32 | for resource, permission := range resourcePermissions { 33 | result[resource] = result[resource] || 34 | (boundResource == iamresource.Root || 35 | resource == boundResource || 36 | resourcename.HasParent(resource, boundResource) && 37 | iamrole.HasPermission(role, permission)) 38 | } 39 | return nil 40 | }, 41 | ); err != nil { 42 | return nil, s.handleStorageError(ctx, err) 43 | } 44 | for resource := range resourcePermissions { 45 | if _, ok := result[resource]; !ok { 46 | result[resource] = false 47 | } 48 | } 49 | return result, nil 50 | } 51 | -------------------------------------------------------------------------------- /iamtest/caller.go: -------------------------------------------------------------------------------- 1 | package iamtest 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/iam/iamcaller" 7 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 8 | ) 9 | 10 | // WithMembers returns a new context with resolved IAM members for test purposes. 11 | func WithMembers(ctx context.Context, members ...string) context.Context { 12 | return iamcaller.WithResolvedContext(ctx, NewCaller(members...)) 13 | } 14 | 15 | // NewCaller creates a new caller for test purposes. 16 | func NewCaller(members ...string) *iamv1.Caller { 17 | return &iamv1.Caller{ 18 | Members: members, 19 | Metadata: map[string]*iamv1.Caller_Metadata{ 20 | "iamtest": {Members: members}, 21 | }, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /iamtest/doc.go: -------------------------------------------------------------------------------- 1 | // Package iamtest provides primitives for unit testing IAM implementations. 2 | package iamtest 3 | -------------------------------------------------------------------------------- /iamtest/fixture.go: -------------------------------------------------------------------------------- 1 | package iamtest 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "cloud.google.com/go/iam/apiv1/iampb" 8 | "go.einride.tech/iam/iampolicy" 9 | "go.einride.tech/iam/iamspanner" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | // Fixture is a test fixture with helper methods for IAM testing. 14 | type Fixture struct { 15 | server *iamspanner.IAMServer 16 | } 17 | 18 | // NewFixture creates a new Fixture for the provided iamspanner.IAMServer. 19 | func NewFixture(server *iamspanner.IAMServer) *Fixture { 20 | return &Fixture{server: server} 21 | } 22 | 23 | // AddPolicyBinding adds the provided policy binding. 24 | func (fx *Fixture) AddPolicyBinding(t *testing.T, resource, role, member string) { 25 | ctx := withTestDeadline(context.Background(), t) 26 | // Get current policy. 27 | policy, err := fx.server.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{ 28 | Resource: resource, 29 | }) 30 | assert.NilError(t, err) 31 | iampolicy.AddBinding(policy, role, member) 32 | // Set updated policy. 33 | _, err = fx.server.SetIamPolicy(ctx, &iampb.SetIamPolicyRequest{ 34 | Resource: resource, 35 | Policy: policy, 36 | }) 37 | assert.NilError(t, err) 38 | } 39 | 40 | func withTestDeadline(ctx context.Context, t *testing.T) context.Context { 41 | deadline, ok := t.Deadline() 42 | if !ok { 43 | return ctx 44 | } 45 | ctx, cancel := context.WithDeadline(ctx, deadline) 46 | t.Cleanup(cancel) 47 | return ctx 48 | } 49 | -------------------------------------------------------------------------------- /iamtest/permissiontester_test.go: -------------------------------------------------------------------------------- 1 | package iamtest 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func TestPermissionTester(t *testing.T) { 11 | t.Run("allow none", func(t *testing.T) { 12 | const ( 13 | member = "email:foo@example.com" 14 | resource = "resources/1234" 15 | permission = "test.resources.foo" 16 | ) 17 | mock := NewPermissionTester() 18 | result, err := mock.TestPermissions(context.Background(), NewCaller(member), map[string]string{ 19 | resource: permission, 20 | }) 21 | assert.NilError(t, err) 22 | assert.DeepEqual(t, map[string]bool{ 23 | resource: false, 24 | }, result) 25 | }) 26 | 27 | t.Run("allow all", func(t *testing.T) { 28 | const ( 29 | member = "email:foo@example.com" 30 | resource = "resources/1234" 31 | permission = "test.resources.foo" 32 | ) 33 | mock := NewPermissionTester() 34 | mock.AllowAll() 35 | result, err := mock.TestPermissions(context.Background(), NewCaller(member), map[string]string{ 36 | resource: permission, 37 | }) 38 | assert.NilError(t, err) 39 | assert.DeepEqual(t, map[string]bool{ 40 | resource: true, 41 | }, result) 42 | }) 43 | 44 | t.Run("allow some", func(t *testing.T) { 45 | const ( 46 | member = "email:foo@example.com" 47 | resource1 = "resources/1234" 48 | resource2 = "resources/5678" 49 | permission = "test.resources.foo" 50 | ) 51 | mock := NewPermissionTester() 52 | mock.Allow(member, permission, resource2) 53 | result, err := mock.TestPermissions(context.Background(), NewCaller(member), map[string]string{ 54 | resource1: permission, 55 | resource2: permission, 56 | }) 57 | assert.NilError(t, err) 58 | assert.DeepEqual(t, map[string]bool{ 59 | resource1: false, 60 | resource2: true, 61 | }, result) 62 | }) 63 | 64 | t.Run("reset", func(t *testing.T) { 65 | const ( 66 | member = "email:foo@example.com" 67 | resource = "resources/1234" 68 | permission = "test.resources.foo" 69 | ) 70 | mock := NewPermissionTester() 71 | mock.AllowAll() 72 | mock.Reset() 73 | result, err := mock.TestPermissions(context.Background(), NewCaller(member), map[string]string{ 74 | resource: permission, 75 | }) 76 | assert.NilError(t, err) 77 | assert.DeepEqual(t, map[string]bool{ 78 | resource: false, 79 | }, result) 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /iamtoken/identitytoken.go: -------------------------------------------------------------------------------- 1 | package iamtoken 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "strings" 7 | 8 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 9 | "google.golang.org/protobuf/encoding/protojson" 10 | ) 11 | 12 | // ParseIdentityToken parses a JWT identity token. 13 | func ParseIdentityToken(token string) (*iamv1.IdentityToken, error) { 14 | s := strings.Split(token, ".") 15 | if len(s) < 2 { 16 | return nil, fmt.Errorf("parse identity token: not a valid token") 17 | } 18 | payloadData, err := base64.RawURLEncoding.DecodeString(s[1]) 19 | if err != nil { 20 | return nil, fmt.Errorf("parse identity token: %w", err) 21 | } 22 | var result iamv1.IdentityToken 23 | if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(payloadData, &result); err != nil { 24 | return nil, fmt.Errorf("parse identity token: %w", err) 25 | } 26 | result.Raw = token 27 | return &result, nil 28 | } 29 | -------------------------------------------------------------------------------- /iamtoken/identitytoken_test.go: -------------------------------------------------------------------------------- 1 | package iamtoken 2 | 3 | import ( 4 | "testing" 5 | 6 | "gotest.tools/v3/assert" 7 | ) 8 | 9 | func TestParseIdentityToken(t *testing.T) { 10 | //nolint: lll 11 | const input = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" 12 | actual, err := ParseIdentityToken(input) 13 | assert.NilError(t, err) 14 | assert.Equal(t, "1234567890", actual.GetSub()) 15 | assert.Equal(t, int64(1516239022), actual.GetIat()) 16 | } 17 | -------------------------------------------------------------------------------- /iamtoken/token.go: -------------------------------------------------------------------------------- 1 | package iamtoken 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "google.golang.org/grpc/metadata" 8 | ) 9 | 10 | // FromIncomingContext looks up a bearer token in the incoming gRPC request metadata by key. 11 | func FromIncomingContext(ctx context.Context, key string) (string, bool) { 12 | md, ok := metadata.FromIncomingContext(ctx) 13 | if !ok { 14 | return "", false 15 | } 16 | values := md.Get(key) 17 | if len(values) == 0 { 18 | return "", false 19 | } 20 | value := values[0] 21 | const prefix = "bearer " 22 | isBearerToken := len(value) > len(prefix) && strings.EqualFold(value[:len(prefix)], prefix) 23 | if !isBearerToken { 24 | return "", false 25 | } 26 | return value[len(prefix):], true 27 | } 28 | -------------------------------------------------------------------------------- /iamtoken/validate.go: -------------------------------------------------------------------------------- 1 | package iamtoken 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 8 | ) 9 | 10 | // timeNow is for mocking in tests. 11 | var timeNow = time.Now //nolint: gochecknoglobals 12 | 13 | // ValidateIdentityToken checks that an identity token is valid and acceptable for processing. 14 | func ValidateIdentityToken(token *iamv1.IdentityToken) error { 15 | if token.GetExp() > 0 { 16 | now := timeNow() 17 | expired := time.Unix(token.GetExp(), 0).UTC() 18 | if expired.Before(now) { 19 | return fmt.Errorf( 20 | "identity token expired at %s (%s ago)", 21 | expired.Format(time.RFC3339), 22 | now.Sub(expired), 23 | ) 24 | } 25 | } 26 | if token.GetNbf() > 0 { 27 | now := timeNow() 28 | notBefore := time.Unix(token.GetNbf(), 0).UTC() 29 | if now.Before(notBefore) { 30 | return fmt.Errorf( 31 | "identity token not valid before %s (in %s)", 32 | notBefore.Format(time.RFC3339), 33 | notBefore.Sub(now), 34 | ) 35 | } 36 | } 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /iamtoken/validate_test.go: -------------------------------------------------------------------------------- 1 | package iamtoken 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | iamv1 "go.einride.tech/iam/proto/gen/einride/iam/v1" 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestValidateIdentityToken(t *testing.T) { 12 | t.Run("ok", func(t *testing.T) { 13 | mockTimeNow(t, 1623998472) 14 | identityToken := &iamv1.IdentityToken{ 15 | Exp: 1623998480, 16 | Nbf: 1623998470, 17 | } 18 | assert.NilError(t, ValidateIdentityToken(identityToken)) 19 | }) 20 | 21 | t.Run("after exp", func(t *testing.T) { 22 | mockTimeNow(t, 1623998472) 23 | identityToken := &iamv1.IdentityToken{ 24 | Exp: 1623998471, 25 | Nbf: 1623998470, 26 | } 27 | assert.Error( 28 | t, 29 | ValidateIdentityToken(identityToken), 30 | "identity token expired at 2021-06-18T06:41:11Z (1s ago)", 31 | ) 32 | }) 33 | 34 | t.Run("before nbf", func(t *testing.T) { 35 | mockTimeNow(t, 1623998472) 36 | identityToken := &iamv1.IdentityToken{ 37 | Exp: 1623998480, 38 | Nbf: 1623998473, 39 | } 40 | assert.Error( 41 | t, 42 | ValidateIdentityToken(identityToken), 43 | "identity token not valid before 2021-06-18T06:41:13Z (in 1s)", 44 | ) 45 | }) 46 | } 47 | 48 | func mockTimeNow(t *testing.T, tt int64) { 49 | timeNow = func() time.Time { 50 | return time.Unix(tt, 0).UTC() 51 | } 52 | t.Cleanup(func() { 53 | timeNow = time.Now 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /proto/.gitignore: -------------------------------------------------------------------------------- 1 | tools/*/*/ 2 | build/ 3 | -------------------------------------------------------------------------------- /proto/Makefile: -------------------------------------------------------------------------------- 1 | # Code generated by go.einride.tech/sage. DO NOT EDIT. 2 | # To learn more, see ../.sage/main.go and https://github.com/einride/sage. 3 | 4 | .DEFAULT_GOAL := all 5 | 6 | cwd := $(dir $(realpath $(firstword $(MAKEFILE_LIST)))) 7 | sagefile := $(abspath $(cwd)/../.sage/bin/sagefile) 8 | 9 | # Setup Go. 10 | go := $(shell command -v go 2>/dev/null) 11 | export GOWORK ?= off 12 | ifndef go 13 | SAGE_GO_VERSION ?= 1.23.4 14 | export GOROOT := $(abspath $(cwd)/../.sage/tools/go/$(SAGE_GO_VERSION)/go) 15 | export PATH := $(PATH):$(GOROOT)/bin 16 | go := $(GOROOT)/bin/go 17 | os := $(shell uname | tr '[:upper:]' '[:lower:]') 18 | arch := $(shell uname -m) 19 | ifeq ($(arch),x86_64) 20 | arch := amd64 21 | endif 22 | $(go): 23 | $(info installing Go $(SAGE_GO_VERSION)...) 24 | @mkdir -p $(dir $(GOROOT)) 25 | @curl -sSL https://go.dev/dl/go$(SAGE_GO_VERSION).$(os)-$(arch).tar.gz | tar xz -C $(dir $(GOROOT)) 26 | @touch $(GOROOT)/go.mod 27 | @chmod +x $(go) 28 | endif 29 | 30 | .PHONY: $(sagefile) 31 | $(sagefile): $(go) 32 | @cd ../.sage && $(go) mod tidy && $(go) run . 33 | 34 | .PHONY: sage 35 | sage: 36 | @$(MAKE) $(sagefile) 37 | 38 | .PHONY: update-sage 39 | update-sage: $(go) 40 | @cd ../.sage && $(go) get -d go.einride.tech/sage@latest && $(go) mod tidy && $(go) run . 41 | 42 | .PHONY: clean-sage 43 | clean-sage: 44 | @git clean -fdx ../.sage/tools ../.sage/bin ../.sage/build 45 | 46 | .PHONY: api-linter-lint 47 | api-linter-lint: $(sagefile) 48 | @$(sagefile) Proto:APILinterLint 49 | 50 | .PHONY: all 51 | all: $(sagefile) 52 | @$(sagefile) Proto:All 53 | 54 | .PHONY: buf-breaking 55 | buf-breaking: $(sagefile) 56 | @$(sagefile) Proto:BufBreaking 57 | 58 | .PHONY: buf-format 59 | buf-format: $(sagefile) 60 | @$(sagefile) Proto:BufFormat 61 | 62 | .PHONY: buf-generate 63 | buf-generate: $(sagefile) 64 | @$(sagefile) Proto:BufGenerate 65 | 66 | .PHONY: buf-generate-example 67 | buf-generate-example: $(sagefile) 68 | @$(sagefile) Proto:BufGenerateExample 69 | 70 | .PHONY: buf-lint 71 | buf-lint: $(sagefile) 72 | @$(sagefile) Proto:BufLint 73 | 74 | .PHONY: protoc-gen-go 75 | protoc-gen-go: $(sagefile) 76 | @$(sagefile) Proto:ProtocGenGo 77 | 78 | .PHONY: protoc-gen-go-aip 79 | protoc-gen-go-aip: $(sagefile) 80 | @$(sagefile) Proto:ProtocGenGoAIP 81 | 82 | .PHONY: protoc-gen-go-grpc 83 | protoc-gen-go-grpc: $(sagefile) 84 | @$(sagefile) Proto:ProtocGenGoGRPC 85 | 86 | .PHONY: protoc-gen-go-iam 87 | protoc-gen-go-iam: $(sagefile) 88 | @$(sagefile) Proto:ProtocGenGoIAM 89 | -------------------------------------------------------------------------------- /proto/api-linter.yaml: -------------------------------------------------------------------------------- 1 | - included_paths: ["**"] 2 | disabled_rules: 3 | - core::0191 # file options managed by buf 4 | - included_paths: ["einride/iam/v1/annotations.proto"] 5 | disabled_rules: 6 | - core::0191 # file options managed by buf 7 | - core::0140::prepositions # allow prepositions 8 | - core::0140::reserved-words # allow reserved words 9 | - included_paths: ["einride/iam/v1/caller.proto"] 10 | disabled_rules: 11 | - core::0191 # file options managed by buf 12 | - core::0142::time-field-names # allow time fields without time suffix 13 | - included_paths: ["einride/iam/example/v1/freight_service.proto"] 14 | disabled_rules: 15 | # false positive from google.longrunning.Operation 16 | - core::0203::field-behavior-required 17 | -------------------------------------------------------------------------------- /proto/buf.gen.example.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | managed: 4 | enabled: true 5 | go_package_prefix: 6 | default: go.einride.tech/iam/proto/gen 7 | except: 8 | - buf.build/googleapis/googleapis 9 | - buf.build/einride/googleapis 10 | 11 | plugins: 12 | - name: go 13 | out: gen 14 | opt: 15 | - module=go.einride.tech/iam/proto/gen 16 | path: ../.sage/bin/protoc-gen-go 17 | 18 | - name: go-aip 19 | out: gen 20 | opt: 21 | - module=go.einride.tech/iam/proto/gen 22 | path: ../.sage/bin/protoc-gen-go-aip 23 | 24 | - name: go-iam 25 | out: gen 26 | opt: 27 | - module=go.einride.tech/iam/proto/gen 28 | path: ../.sage/bin/protoc-gen-go-iam 29 | 30 | - name: go-grpc 31 | out: gen 32 | opt: 33 | - module=go.einride.tech/iam/proto/gen 34 | - require_unimplemented_servers=false 35 | path: ../.sage/bin/protoc-gen-go-grpc 36 | -------------------------------------------------------------------------------- /proto/buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | managed: 4 | enabled: true 5 | go_package_prefix: 6 | default: go.einride.tech/iam/proto/gen 7 | except: 8 | - buf.build/googleapis/googleapis 9 | - buf.build/einride/googleapis 10 | 11 | plugins: 12 | - name: go 13 | out: gen 14 | opt: module=go.einride.tech/iam/proto/gen 15 | path: ../.sage/bin/protoc-gen-go 16 | -------------------------------------------------------------------------------- /proto/buf.lock: -------------------------------------------------------------------------------- 1 | # Generated by buf. DO NOT EDIT. 2 | version: v1 3 | deps: 4 | - remote: buf.build 5 | owner: einride 6 | repository: googleapis 7 | commit: 0f781e7c2b454728ac7d71b8bffdffb0 8 | - remote: buf.build 9 | owner: googleapis 10 | repository: googleapis 11 | commit: 28151c0d0a1641bf938a7672c500e01d 12 | -------------------------------------------------------------------------------- /proto/buf.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | name: buf.build/einride/iam 4 | 5 | deps: 6 | - buf.build/googleapis/googleapis 7 | - buf.build/einride/googleapis 8 | 9 | lint: 10 | use: 11 | - STANDARD 12 | except: 13 | - RPC_RESPONSE_STANDARD_NAME 14 | - RPC_REQUEST_RESPONSE_UNIQUE 15 | - ENUM_VALUE_PREFIX 16 | 17 | breaking: 18 | use: 19 | - PACKAGE 20 | -------------------------------------------------------------------------------- /proto/einride/iam/example/v1/shipment.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package einride.iam.example.v1; 4 | 5 | import "google/api/field_behavior.proto"; 6 | import "google/api/resource.proto"; 7 | import "google/protobuf/timestamp.proto"; 8 | 9 | // A shipment represents transportation of goods between an origin 10 | // [site][einride.example.freight.v1.Site] and a destination 11 | // [site][einride.example.freight.v1.Site]. 12 | message Shipment { 13 | option (google.api.resource) = { 14 | type: "iam-example.einride.tech/Shipment" 15 | pattern: "shippers/{shipper}/shipments/{shipment}" 16 | singular: "shipment" 17 | plural: "shipments" 18 | }; 19 | 20 | // The resource name of the shipment. 21 | string name = 1 [(google.api.field_behavior) = IDENTIFIER]; 22 | 23 | // The creation timestamp of the shipment. 24 | google.protobuf.Timestamp create_time = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; 25 | 26 | // The last update timestamp of the shipment. 27 | // 28 | // Updated when create/update/delete operation is shipment. 29 | google.protobuf.Timestamp update_time = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; 30 | 31 | // The deletion timestamp of the shipment. 32 | google.protobuf.Timestamp delete_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY]; 33 | 34 | // The resource name of the origin site of the shipment. 35 | // Format: shippers/{shipper}/sites/{site} 36 | string origin_site = 5 [ 37 | (google.api.field_behavior) = REQUIRED, 38 | (google.api.resource_reference).type = "iam-example.einride.tech/Site" 39 | ]; 40 | 41 | // The resource name of the destination site of the shipment. 42 | // Format: shippers/{shipper}/sites/{site} 43 | string destination_site = 6 [ 44 | (google.api.field_behavior) = REQUIRED, 45 | (google.api.resource_reference).type = "iam-example.einride.tech/Site" 46 | ]; 47 | 48 | // The earliest pickup time of the shipment at the origin site. 49 | google.protobuf.Timestamp pickup_earliest_time = 7 [(google.api.field_behavior) = REQUIRED]; 50 | 51 | // The latest pickup time of the shipment at the origin site. 52 | google.protobuf.Timestamp pickup_latest_time = 8 [(google.api.field_behavior) = REQUIRED]; 53 | 54 | // The earliest delivery time of the shipment at the destination site. 55 | google.protobuf.Timestamp delivery_earliest_time = 9 [(google.api.field_behavior) = REQUIRED]; 56 | 57 | // The latest delivery time of the shipment at the destination site. 58 | google.protobuf.Timestamp delivery_latest_time = 10 [(google.api.field_behavior) = REQUIRED]; 59 | 60 | // The line items of the shipment. 61 | repeated LineItem line_items = 11 [(google.api.field_behavior) = OPTIONAL]; 62 | 63 | // Annotations of the shipment. 64 | map annotations = 12 [(google.api.field_behavior) = OPTIONAL]; 65 | } 66 | 67 | // A shipment line item. 68 | message LineItem { 69 | // The title of the line item. 70 | string title = 1 [(google.api.field_behavior) = REQUIRED]; 71 | // The quantity of the line item. 72 | float quantity = 2 [(google.api.field_behavior) = REQUIRED]; 73 | // The weight of the line item in kilograms. 74 | float weight_kg = 3 [(google.api.field_behavior) = OPTIONAL]; 75 | // The volume of the line item in cubic meters. 76 | float volume_m3 = 4 [(google.api.field_behavior) = OPTIONAL]; 77 | } 78 | -------------------------------------------------------------------------------- /proto/einride/iam/example/v1/shipper.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package einride.iam.example.v1; 4 | 5 | import "google/api/field_behavior.proto"; 6 | import "google/api/resource.proto"; 7 | import "google/protobuf/timestamp.proto"; 8 | 9 | // A shipper is a supplier or owner of goods to be transported. 10 | message Shipper { 11 | option (google.api.resource) = { 12 | type: "iam-example.einride.tech/Shipper" 13 | pattern: "shippers/{shipper}" 14 | singular: "shipper" 15 | plural: "shippers" 16 | }; 17 | // The resource name of the shipper. 18 | string name = 1 [(google.api.field_behavior) = IDENTIFIER]; 19 | // The creation timestamp of the shipper. 20 | google.protobuf.Timestamp create_time = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; 21 | // The last update timestamp of the shipper. 22 | // 23 | // Updated when create/update/delete operation is performed. 24 | google.protobuf.Timestamp update_time = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; 25 | // The deletion timestamp of the shipper. 26 | google.protobuf.Timestamp delete_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY]; 27 | // The display name of the shipper. 28 | string display_name = 5 [(google.api.field_behavior) = REQUIRED]; 29 | } 30 | -------------------------------------------------------------------------------- /proto/einride/iam/example/v1/site.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package einride.iam.example.v1; 4 | 5 | import "google/api/field_behavior.proto"; 6 | import "google/api/resource.proto"; 7 | import "google/protobuf/timestamp.proto"; 8 | import "google/type/latlng.proto"; 9 | 10 | // A site is a node in a [shipper][einride.example.freight.v1.Shipper]'s 11 | // transport network. 12 | message Site { 13 | option (google.api.resource) = { 14 | type: "iam-example.einride.tech/Site" 15 | pattern: "shippers/{shipper}/sites/{site}" 16 | singular: "site" 17 | plural: "sites" 18 | }; 19 | // The resource name of the site. 20 | string name = 1 [(google.api.field_behavior) = IDENTIFIER]; 21 | // The creation timestamp of the site. 22 | google.protobuf.Timestamp create_time = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; 23 | // The last update timestamp of the site. 24 | // 25 | // Updated when create/update/delete operation is performed. 26 | google.protobuf.Timestamp update_time = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; 27 | // The deletion timestamp of the site. 28 | google.protobuf.Timestamp delete_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY]; 29 | // The display name of the site. 30 | string display_name = 5 [(google.api.field_behavior) = REQUIRED]; 31 | // The geographic location of the site. 32 | google.type.LatLng lat_lng = 6 [(google.api.field_behavior) = OPTIONAL]; 33 | } 34 | -------------------------------------------------------------------------------- /proto/einride/iam/v1/caller.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package einride.iam.v1; 4 | 5 | import "einride/iam/v1/identity_token.proto"; 6 | import "google/protobuf/timestamp.proto"; 7 | 8 | // Caller identity. 9 | message Caller { 10 | // The caller's resolved IAM members. 11 | repeated string members = 1; 12 | // Caller identity from gRPC metadata key/value pairs. 13 | map metadata = 2; 14 | // Caller context. 15 | // TODO: Remove this when cel-go supports async functions with context threading. 16 | Context context = 3; 17 | // Caller identity for a gRPC metadata key/value pair. 18 | message Metadata { 19 | // The IAM members from the metadata value. 20 | repeated string members = 1; 21 | // The identity token from the metadata value. 22 | IdentityToken identity_token = 2; 23 | } 24 | // Caller context for downstream network calls. 25 | // TODO: Remove this when cel-go supports async functions with context threading. 26 | message Context { 27 | // Deadline for the caller's request. 28 | google.protobuf.Timestamp deadline = 1; 29 | // Trace context for the caller's request. 30 | string trace = 2; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /proto/einride/iam/v1/identity_token.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package einride.iam.v1; 4 | 5 | // A JWT identity token. 6 | message IdentityToken { 7 | // The raw token value. 8 | string raw = 1 [debug_redact = true]; 9 | // The "iss" claim identifies the principal that issued the JWT. 10 | // The processing of this claim is generally application specific. 11 | // The "iss" value is a case-sensitive string containing a string or URI value. 12 | string iss = 2; 13 | // The "sub" claim identifies the principal that is the 14 | // subject of the JWT. The claims in a JWT are normally statements 15 | // about the subject. The subject value MUST either be scoped to be 16 | // locally unique in the context of the issuer or be globally unique. 17 | // The processing of this claim is generally application specific. 18 | // The "sub" value is a case-sensitive string containing a string or URI value. 19 | string sub = 3; 20 | // The "aud" claim identifies the recipients that the JWT is intended for. 21 | // Each principal intended to process the JWT MUST identify itself with a value in the audience claim. 22 | // If the principal processing the claim does not identify itself with a value in the 23 | // "aud" claim when this claim is present, then the JWT MUST be rejected. 24 | // In the general case, the "aud" value is an array of case-sensitive strings, each containing a 25 | // string or URI value. 26 | // In the special case when the JWT has one audience, the "aud" value MAY be a 27 | // single case-sensitive string containing a string or URI value. 28 | // The interpretation of audience values is generally application specific. 29 | string aud = 4; 30 | // The "exp" claim identifies the expiration time on or after 31 | // which the JWT MUST NOT be accepted for processing. 32 | // The processing of the "exp" claim requires that the current date/time 33 | // MUST be before the expiration date/time listed in the "exp" claim. 34 | int64 exp = 5; 35 | // The "nbf" (not before) claim identifies the time before which the JWT 36 | // MUST NOT be accepted for processing. The processing of the "nbf" 37 | // claim requires that the current date/time MUST be after or equal to 38 | // the not-before date/time listed in the "nbf" claim. Implementers MAY 39 | // provide for some small leeway, usually no more than a few minutes, to 40 | // account for clock skew. Its value MUST be a number containing a 41 | // numeric date value. 42 | int64 nbf = 6; 43 | // The "iat" claim identifies the time at which the JWT was 44 | // issued. This claim can be used to determine the age of the JWT. 45 | // Its value MUST be a number containing a numeric date value. 46 | int64 iat = 7; 47 | } 48 | -------------------------------------------------------------------------------- /proto/gen/einride/iam/example/v1/freight_service_aip.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-aip. DO NOT EDIT. 2 | // 3 | // versions: 4 | // protoc-gen-go-aip development 5 | // protoc (unknown) 6 | // source: einride/iam/example/v1/freight_service.proto 7 | 8 | package examplev1 9 | 10 | import ( 11 | fmt "fmt" 12 | resourcename "go.einride.tech/aip/resourcename" 13 | strings "strings" 14 | ) 15 | 16 | type ShipperOperationResourceName struct { 17 | Shipper string 18 | Operation string 19 | } 20 | 21 | func (n ShipperResourceName) ShipperOperationResourceName( 22 | operation string, 23 | ) ShipperOperationResourceName { 24 | return ShipperOperationResourceName{ 25 | Shipper: n.Shipper, 26 | Operation: operation, 27 | } 28 | } 29 | 30 | func (n ShipperOperationResourceName) Validate() error { 31 | if n.Shipper == "" { 32 | return fmt.Errorf("shipper: empty") 33 | } 34 | if strings.IndexByte(n.Shipper, '/') != -1 { 35 | return fmt.Errorf("shipper: contains illegal character '/'") 36 | } 37 | if n.Operation == "" { 38 | return fmt.Errorf("operation: empty") 39 | } 40 | if strings.IndexByte(n.Operation, '/') != -1 { 41 | return fmt.Errorf("operation: contains illegal character '/'") 42 | } 43 | return nil 44 | } 45 | 46 | func (n ShipperOperationResourceName) ContainsWildcard() bool { 47 | return false || n.Shipper == "-" || n.Operation == "-" 48 | } 49 | 50 | func (n ShipperOperationResourceName) String() string { 51 | return resourcename.Sprint( 52 | "shippers/{shipper}/operations/{operation}", 53 | n.Shipper, 54 | n.Operation, 55 | ) 56 | } 57 | 58 | func (n ShipperOperationResourceName) MarshalString() (string, error) { 59 | if err := n.Validate(); err != nil { 60 | return "", err 61 | } 62 | return n.String(), nil 63 | } 64 | 65 | func (n *ShipperOperationResourceName) UnmarshalString(name string) error { 66 | err := resourcename.Sscan( 67 | name, 68 | "shippers/{shipper}/operations/{operation}", 69 | &n.Shipper, 70 | &n.Operation, 71 | ) 72 | if err != nil { 73 | return err 74 | } 75 | return n.Validate() 76 | } 77 | 78 | func (n ShipperOperationResourceName) ShipperResourceName() ShipperResourceName { 79 | return ShipperResourceName{ 80 | Shipper: n.Shipper, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /proto/gen/einride/iam/example/v1/shipment_aip.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-aip. DO NOT EDIT. 2 | // 3 | // versions: 4 | // protoc-gen-go-aip development 5 | // protoc (unknown) 6 | // source: einride/iam/example/v1/shipment.proto 7 | 8 | package examplev1 9 | 10 | import ( 11 | fmt "fmt" 12 | resourcename "go.einride.tech/aip/resourcename" 13 | strings "strings" 14 | ) 15 | 16 | type ShipmentResourceName struct { 17 | Shipper string 18 | Shipment string 19 | } 20 | 21 | func (n ShipperResourceName) ShipmentResourceName( 22 | shipment string, 23 | ) ShipmentResourceName { 24 | return ShipmentResourceName{ 25 | Shipper: n.Shipper, 26 | Shipment: shipment, 27 | } 28 | } 29 | 30 | func (n ShipmentResourceName) Validate() error { 31 | if n.Shipper == "" { 32 | return fmt.Errorf("shipper: empty") 33 | } 34 | if strings.IndexByte(n.Shipper, '/') != -1 { 35 | return fmt.Errorf("shipper: contains illegal character '/'") 36 | } 37 | if n.Shipment == "" { 38 | return fmt.Errorf("shipment: empty") 39 | } 40 | if strings.IndexByte(n.Shipment, '/') != -1 { 41 | return fmt.Errorf("shipment: contains illegal character '/'") 42 | } 43 | return nil 44 | } 45 | 46 | func (n ShipmentResourceName) ContainsWildcard() bool { 47 | return false || n.Shipper == "-" || n.Shipment == "-" 48 | } 49 | 50 | func (n ShipmentResourceName) String() string { 51 | return resourcename.Sprint( 52 | "shippers/{shipper}/shipments/{shipment}", 53 | n.Shipper, 54 | n.Shipment, 55 | ) 56 | } 57 | 58 | func (n ShipmentResourceName) MarshalString() (string, error) { 59 | if err := n.Validate(); err != nil { 60 | return "", err 61 | } 62 | return n.String(), nil 63 | } 64 | 65 | func (n *ShipmentResourceName) UnmarshalString(name string) error { 66 | err := resourcename.Sscan( 67 | name, 68 | "shippers/{shipper}/shipments/{shipment}", 69 | &n.Shipper, 70 | &n.Shipment, 71 | ) 72 | if err != nil { 73 | return err 74 | } 75 | return n.Validate() 76 | } 77 | 78 | func (n ShipmentResourceName) ShipperResourceName() ShipperResourceName { 79 | return ShipperResourceName{ 80 | Shipper: n.Shipper, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /proto/gen/einride/iam/example/v1/shipper_aip.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-aip. DO NOT EDIT. 2 | // 3 | // versions: 4 | // protoc-gen-go-aip development 5 | // protoc (unknown) 6 | // source: einride/iam/example/v1/shipper.proto 7 | 8 | package examplev1 9 | 10 | import ( 11 | fmt "fmt" 12 | resourcename "go.einride.tech/aip/resourcename" 13 | strings "strings" 14 | ) 15 | 16 | type ShipperResourceName struct { 17 | Shipper string 18 | } 19 | 20 | func (n ShipperResourceName) Validate() error { 21 | if n.Shipper == "" { 22 | return fmt.Errorf("shipper: empty") 23 | } 24 | if strings.IndexByte(n.Shipper, '/') != -1 { 25 | return fmt.Errorf("shipper: contains illegal character '/'") 26 | } 27 | return nil 28 | } 29 | 30 | func (n ShipperResourceName) ContainsWildcard() bool { 31 | return false || n.Shipper == "-" 32 | } 33 | 34 | func (n ShipperResourceName) String() string { 35 | return resourcename.Sprint( 36 | "shippers/{shipper}", 37 | n.Shipper, 38 | ) 39 | } 40 | 41 | func (n ShipperResourceName) MarshalString() (string, error) { 42 | if err := n.Validate(); err != nil { 43 | return "", err 44 | } 45 | return n.String(), nil 46 | } 47 | 48 | func (n *ShipperResourceName) UnmarshalString(name string) error { 49 | err := resourcename.Sscan( 50 | name, 51 | "shippers/{shipper}", 52 | &n.Shipper, 53 | ) 54 | if err != nil { 55 | return err 56 | } 57 | return n.Validate() 58 | } 59 | -------------------------------------------------------------------------------- /proto/gen/einride/iam/example/v1/site_aip.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-aip. DO NOT EDIT. 2 | // 3 | // versions: 4 | // protoc-gen-go-aip development 5 | // protoc (unknown) 6 | // source: einride/iam/example/v1/site.proto 7 | 8 | package examplev1 9 | 10 | import ( 11 | fmt "fmt" 12 | resourcename "go.einride.tech/aip/resourcename" 13 | strings "strings" 14 | ) 15 | 16 | type SiteResourceName struct { 17 | Shipper string 18 | Site string 19 | } 20 | 21 | func (n ShipperResourceName) SiteResourceName( 22 | site string, 23 | ) SiteResourceName { 24 | return SiteResourceName{ 25 | Shipper: n.Shipper, 26 | Site: site, 27 | } 28 | } 29 | 30 | func (n SiteResourceName) Validate() error { 31 | if n.Shipper == "" { 32 | return fmt.Errorf("shipper: empty") 33 | } 34 | if strings.IndexByte(n.Shipper, '/') != -1 { 35 | return fmt.Errorf("shipper: contains illegal character '/'") 36 | } 37 | if n.Site == "" { 38 | return fmt.Errorf("site: empty") 39 | } 40 | if strings.IndexByte(n.Site, '/') != -1 { 41 | return fmt.Errorf("site: contains illegal character '/'") 42 | } 43 | return nil 44 | } 45 | 46 | func (n SiteResourceName) ContainsWildcard() bool { 47 | return false || n.Shipper == "-" || n.Site == "-" 48 | } 49 | 50 | func (n SiteResourceName) String() string { 51 | return resourcename.Sprint( 52 | "shippers/{shipper}/sites/{site}", 53 | n.Shipper, 54 | n.Site, 55 | ) 56 | } 57 | 58 | func (n SiteResourceName) MarshalString() (string, error) { 59 | if err := n.Validate(); err != nil { 60 | return "", err 61 | } 62 | return n.String(), nil 63 | } 64 | 65 | func (n *SiteResourceName) UnmarshalString(name string) error { 66 | err := resourcename.Sscan( 67 | name, 68 | "shippers/{shipper}/sites/{site}", 69 | &n.Shipper, 70 | &n.Site, 71 | ) 72 | if err != nil { 73 | return err 74 | } 75 | return n.Validate() 76 | } 77 | 78 | func (n SiteResourceName) ShipperResourceName() ShipperResourceName { 79 | return ShipperResourceName{ 80 | Shipper: n.Shipper, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /spanner.yaml: -------------------------------------------------------------------------------- 1 | databases: 2 | - name: iam 3 | schema: 4 | - ./iamspanner/schema.sql 5 | package: 6 | name: iamspannerdb 7 | path: ./iamspanner/iamspannerdb 8 | 9 | - name: example 10 | schema: 11 | - ./iamspanner/schema.sql 12 | - ./iamexample/schema.sql 13 | package: 14 | name: iamexampledb 15 | path: ./iamexample/iamexampledb 16 | --------------------------------------------------------------------------------