diff --git a/docs/schema/email_write_access.schema b/docs/schema/email_write_access.schema new file mode 100644 index 0000000..aa77c70 --- /dev/null +++ b/docs/schema/email_write_access.schema @@ -0,0 +1,5 @@ +CREATE TABLE email_write_access ( + email varchar(1024) NOT NULL, + orgName text NOT NULL, + PRIMARY KEY(email, orgname) +); diff --git a/graph/schema.resolvers.go b/graph/schema.resolvers.go index 8b8aa8b..d821b6b 100644 --- a/graph/schema.resolvers.go +++ b/graph/schema.resolvers.go @@ -23,7 +23,6 @@ import ( "github.com/openconfig/catalog-server/graph/generated" "github.com/openconfig/catalog-server/graph/model" - "github.com/openconfig/catalog-server/pkg/access" "github.com/openconfig/catalog-server/pkg/db" "github.com/openconfig/catalog-server/pkg/dbtograph" "github.com/openconfig/catalog-server/pkg/validate" @@ -34,7 +33,7 @@ func (r *mutationResolver) CreateModule(ctx context.Context, input model.NewModu successMsg := `Success` // Validate the token and check whether it contains access to certain organization - if err := access.CheckAccess(token, input.OrgName); err != nil { + if err := db.CheckAccess(token, input.OrgName); err != nil { return failMsg, fmt.Errorf("CreateModule: validate token failed: %v", err) } @@ -57,7 +56,7 @@ func (r *mutationResolver) DeleteModule(ctx context.Context, input model.ModuleK successMsg := `Success` // Validate the token and check whether it contains access to certain organization - if err := access.CheckAccess(token, input.OrgName); err != nil { + if err := db.CheckAccess(token, input.OrgName); err != nil { return failMsg, fmt.Errorf("DeleteModule: validate token failed: %v", err) } @@ -74,7 +73,7 @@ func (r *mutationResolver) CreateFeatureBundle(ctx context.Context, input model. successMsg := `Success` // Validate the token and check whether it contains access to certain organization - if err := access.CheckAccess(token, input.OrgName); err != nil { + if err := db.CheckAccess(token, input.OrgName); err != nil { return failMsg, fmt.Errorf("CreateFeatureBundle: validate token failed: %v", err) } @@ -97,7 +96,7 @@ func (r *mutationResolver) DeleteFeatureBundle(ctx context.Context, input model. successMsg := `Success` // Validate the token and check whether it contains access to certain organization - if err := access.CheckAccess(token, input.OrgName); err != nil { + if err := db.CheckAccess(token, input.OrgName); err != nil { return failMsg, fmt.Errorf("DeleteFeatureBundle: validate token failed: %v", err) } diff --git a/pkg/access/access.go b/pkg/access/access.go deleted file mode 100644 index d620b34..0000000 --- a/pkg/access/access.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright 2021 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/* -Package access contains function to validate token and parse access from a valid token - for write operations (i.e., create and update). -*/ -package access - -import ( - "context" - "fmt" - "os" - "strings" - - firebase "firebase.google.com/go/v4" -) - -// const variables related to token validation. -// *delimiter* is delimiter for claim string of a list of names of organizations that the token owner has access to. -// *accessField* is field name of claim that contains the list of names of organizations that one has access to. -// Note that organization's names should not contain delimiter. -const ( - delimiter = `,` - baseAccessField = `allow` -) - -func GetAccessField() (string, error) { - // name of target database - dbname, ok := os.LookupEnv("DB_NAME") - if !ok { - return "", fmt.Errorf("DB_NAME not set") - } - return (dbname + "-" + baseAccessField), nil -} - -// ParseAccess takes input of a token string. -// It first validates whether the token is valid using firebase, -// then parses from the token's claims a list organization names to which that the token owner has write access. -// If token is invalid, an error is returned. -func ParseAccess(token string) ([]string, error) { - // Set up firebase configuration to use correct token validation method. - ctx := context.Background() - projectID, ok := os.LookupEnv("PROJECT_ID") - if !ok { - return nil, fmt.Errorf("$PROJECT_ID not set") - } - config := &firebase.Config{ProjectID: projectID} - app, err := firebase.NewApp(ctx, config) - if err != nil { - return nil, fmt.Errorf("ParseAccess: error initializing app: %v\n", err) - } - client, err := app.Auth(ctx) - if err != nil { - return nil, fmt.Errorf("ParseAccess: generate firebase authentication admin failed") - } - - // Use firebase to validate token - verifiedToken, err := client.VerifyIDToken(ctx, token) - if err != nil { - return nil, fmt.Errorf("ParseAccess: error verifying ID token: %v\n", err) - } - - accessField, err := GetAccessField() - if err != nil { - return nil, fmt.Errorf("ParseAccess: get access field failed: %v", err) - } - - // Retrieve *accessField* from claims, if the field does not exist, return an error. - allowClaims, ok := verifiedToken.Claims[accessField] - if !ok { - return nil, fmt.Errorf("ParseAccess: verified token does not contain allow claims: %s", accessField) - } - - // Split string into a slice of names of organizations. - allowOrgs := strings.Split(allowClaims.(string), delimiter) - return allowOrgs, nil -} - -// This function takes input of a string of token and a string of organization's name. -// It checks whether the given token in valid and whether it contains access for write operation to *orgName*. -// If not, an error is returned. -func CheckAccess(token string, orgName string) error { - // Validate token - allowOrgs, err := ParseAccess(token) - if err != nil { - return fmt.Errorf("CheckAccess: user does not provide valid token: %v", err) - } - - // Check whether this account has access to such orgnization. - hasAccess := false - for _, allowOrg := range allowOrgs { - if allowOrg == orgName { - hasAccess = true - break - } - } - - // If the token does not contain access to input.OrgName, return an error. - if !hasAccess { - return fmt.Errorf("CheckAccess: user does not have access to organization %s", orgName) - } - - return nil -} diff --git a/pkg/db/access.go b/pkg/db/access.go new file mode 100644 index 0000000..28aa590 --- /dev/null +++ b/pkg/db/access.go @@ -0,0 +1,102 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package db + +import ( + "context" + "database/sql" + "fmt" + "os" + + firebase "firebase.google.com/go/v4" + + // Go postgres driver for Go's database/sql package + _ "github.com/lib/pq" +) + +const ( + // getEmailWriteAccess returns a list of orgnames to which the given email has write access. + getEmailWriteAccess = `SELECT orgname FROM email_write_access WHERE email = $1` +) + +// readEmailOrgAccesses scans from a single-attribute relation of orgName strings. +func readEmailOrgAccesses(rows *sql.Rows) (map[string]bool, error) { + orgNames := map[string]bool{} + for rows.Next() { + var orgName string + if err := rows.Scan(&orgName); err != nil { + return nil, fmt.Errorf("readEmailAccesses db scan: %v", err) + } + orgNames[orgName] = true + } + return orgNames, nil +} + +// getEmailOrgAccesses gets the set of orgNames to which a particular email has +// write access. +func getEmailOrgAccesses(email string) (map[string]bool, error) { + rows, err := db.Query(getEmailWriteAccess, email) + if err != nil { + return nil, err + } + defer rows.Close() + + return readEmailOrgAccesses(rows) +} + +// CheckAccess takes input of a string of token and a string of organization's name. +// It checks whether the given token in valid and whether the associated user +// has write access for the given organization name. +// If not, an error is returned. +func CheckAccess(token string, orgName string) error { + // Set up firebase configuration to use correct token validation method. + ctx := context.Background() + projectID, ok := os.LookupEnv("PROJECT_ID") + if !ok { + return fmt.Errorf("$PROJECT_ID not set") + } + config := &firebase.Config{ProjectID: projectID} + app, err := firebase.NewApp(ctx, config) + if err != nil { + return fmt.Errorf("access: error initializing app: %v\n", err) + } + client, err := app.Auth(ctx) + if err != nil { + return fmt.Errorf("access: generate firebase authentication admin failed") + } + + // Use firebase to validate token + verifiedToken, err := client.VerifyIDToken(ctx, token) + if err != nil { + return fmt.Errorf("error verifying ID token: %v\n", err) + } + + userRecord, err := client.GetUser(ctx, verifiedToken.UID) + if err != nil { + return fmt.Errorf("access: GetUser failed: %v", err) + } + + accesses, err := getEmailOrgAccesses(userRecord.Email) + if err != nil { + return fmt.Errorf("access: getEmailOrgAccesses failed: %v", err) + } + + if !accesses[orgName] { + // If the token does not contain access to input.OrgName, return an error. + return fmt.Errorf("user does not have access to organization %s", orgName) + } + + return nil +} diff --git a/scripts/admin/README.md b/scripts/admin/README.md index 11adc3d..d9efd03 100644 --- a/scripts/admin/README.md +++ b/scripts/admin/README.md @@ -1,13 +1,24 @@ ### Overview -This directory contains scripts for helping admin of catalog system. It provides functionality to: -+ Delete existing account. -+ Grant write access of organizations to an existing account. -It does not provide functionalities for admin to register a new user as it can register -via the login page by itself. +This directory contains scripts for administering the catalog system. It +currently provides the following functionalities: + ++ Delete existing accounts. can alternatively be done in the Firebase UI. This + may be deprecated in the future since it requires service account + impersonation. + +It does not provide functionalities for registering a new user as users can +register via the login page directly. + +It also does not provide the functionality to grant write access to certain +organizations for an existing account. This must be done by directly inserting +and deleting entries within an administrative SQL table. Although Firebase +custom claims can also achieve this purpose, they require service accounts and +generating a private key for management, which may present more of a security +risk, and is more cumbersome than just running some SQL statements. ### Usage -+ To use these scripts the user must be admin of identity platform where the catalog system is deployed. -+ To `delete`, run `go run deleteaccount.go -email EMAIL-OF-ACCOUNT`. -+ To `grant access`, run `go run grantaccess.go -db NAME-OF-DB -email EMAIL-OF-ACCOUNT -access STRING-OF_CLAIMS`. `STRING-OF_CLAIMS` is a string of list of organizations seperated by comma. That is, we expect the name of organization do not contain comma. Or we can choose a different delimiter with no conflicts with names of organizations. If user does not provide `-access STRING-OF_CLAIMS` is equivalent to setting access of this account to `empty access` (i.e., no write access to any organization). -+ To `read all existing users' access`, run `go run grantacces.go -db NAME-OF-DB -all`. + ++ To use these scripts the user must be admin of identity platform where the + catalog system is deployed. ++ To `delete`, run `go run deleteaccount.go -email EMAIL-OF-ACCOUNT`. diff --git a/scripts/admin/create-ca.sh b/scripts/admin/create-ca.sh deleted file mode 100644 index 23b87d3..0000000 --- a/scripts/admin/create-ca.sh +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright 2021 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -if [ -z "$CLOUDSDK_CORE_PROJECT" ]; then - echo 'Required environment variable $CLOUDSDK_CORE_PROJECT not set.' - exit 1 -fi - -# Note: this command is creating a service account, and should only be run once per project. -# It's possible that you might encounter errors when running this command. -# As long as you obain the key after running this script, you are all set. -gcloud iam service-accounts create sa-claims \ - --description="Service account for claims admin" \ - --display-name="Claims Service account" -gcloud projects add-iam-policy-binding $CLOUDSDK_CORE_PROJECT \ - --member serviceAccount:sa-claims@$CLOUDSDK_CORE_PROJECT.iam.gserviceaccount.com \ - --role roles/firebase.sdkAdminServiceAgent -gcloud iam service-accounts keys create sa-claims.key \ - --iam-account sa-claims@$CLOUDSDK_CORE_PROJECT.iam.gserviceaccount.com diff --git a/scripts/admin/grantaccess/grantaccess.go b/scripts/admin/grantaccess/grantaccess.go deleted file mode 100644 index 2ea7a40..0000000 --- a/scripts/admin/grantaccess/grantaccess.go +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright 2021 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package main - -import ( - "context" - "flag" - "fmt" - "log" - "os" - - firebase "firebase.google.com/go/v4" - "google.golang.org/api/impersonate" - "google.golang.org/api/iterator" - "google.golang.org/api/option" -) - -const ( - baseAccessField = `allow` -) - -func main() { - var emailPtr = flag.String("email", "", "email account that you want to change access for") - var accessPtr = flag.String("access", "", "string of a list of organizations that account would be granted access to, seperated by delimiter. If not set, it means set empty access for this account") - var listall = flag.Bool("all", false, "whether to list all current users' claims") - var dbnamePtr = flag.String("db", "", "name of db that you want to grant user access to") - - flag.Parse() - - if *dbnamePtr == "" { - flag.CommandLine.SetOutput(os.Stderr) - fmt.Fprintf(os.Stderr, "Please provide db name\n") - flag.Usage() - os.Exit(1) - } - - if !*listall && *emailPtr == "" { - flag.CommandLine.SetOutput(os.Stderr) - fmt.Fprintf(os.Stderr, "Please provide either provide email address or specify list `all`\n") - flag.Usage() - os.Exit(1) - } - - projectID, ok := os.LookupEnv("CLOUDSDK_CORE_PROJECT") - if !ok { - log.Fatalf("$CLOUDSDK_CORE_PROJECT not set.") - } - - ctx := context.Background() - // Impersonate service account. - ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ - TargetPrincipal: fmt.Sprintf("sa-claims@%s.iam.gserviceaccount.com", projectID), - Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, - }) - if err != nil { - log.Fatalf("impersonation failed: %v", err) - } - opt := option.WithTokenSource(ts) - - // Set up firebase configuration. - config := &firebase.Config{ - ProjectID: projectID, - } - app, err := firebase.NewApp(ctx, config, opt) - - if err != nil { - log.Fatalf("Error initializing app: %v\n", err) - } - client, err := app.Auth(ctx) - if err != nil { - log.Fatalf("Generate firebase authentication admin failed: %v\n", err) - } - - accessField := *dbnamePtr + "-" + baseAccessField - - // list all existing users and their claims - if *listall { - iter := client.Users(ctx, "") - for { - user, err := iter.Next() - if err == iterator.Done { - break - } - if err != nil { - log.Fatalf("error listing users: %s\n", err) - } - // Assume all users have email addresses. - if user.CustomClaims == nil { - log.Printf("%s does not have claims\n", user.Email) - } else if access, ok := user.CustomClaims[accessField]; !ok { - log.Printf("%s does not have access\n", user.Email) - } else { - log.Printf("%s current access is: %v\n", user.Email, access) - } - } - } else { - // Fetch user via email. - user, err := client.GetUserByEmail(ctx, *emailPtr) - if err != nil { - log.Fatalf("Retrieve account by email failed: %v\n", err) - } - // Check whether this user exists or not. - if user == nil { - log.Fatalf("Account %s does not exist\n", *emailPtr) - } - - // Obtain current claims from the user - currentClaims := user.CustomClaims - fmt.Println("Current claims", currentClaims) - // If the account does not contain any claims, then its claim is nil, - // we need to create a map of claims for it. - if currentClaims == nil { - currentClaims = make(map[string]interface{}) - } - - // Set claims to grant access. - currentClaims[accessField] = *accessPtr - if err := client.SetCustomUserClaims(ctx, user.UID, currentClaims); err != nil { - log.Fatalf("error setting custom claims %v\n", err) - } - fmt.Println("Successfully set up new claims", currentClaims) - } -}